diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml
new file mode 100644
index 00000000..28a14453
--- /dev/null
+++ b/.github/workflows/installer.yml
@@ -0,0 +1,56 @@
+name: Installer
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Installer (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
+
+ - name: Install ShellCheck (Linux)
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+
+ - name: Install ShellCheck (macOS)
+ if: runner.os == 'macOS'
+ run: brew install shellcheck
+
+ - name: ShellCheck installer scripts
+ run: |
+ shellcheck -x \
+ scripts/installer-common.sh \
+ scripts/project-install.sh \
+ scripts/doctor.sh \
+ scripts/uninstall.sh \
+ tests/installer.sh
+
+ - name: Show test interpreter
+ run: |
+ if [ "${{ runner.os }}" = "macOS" ]; then
+ /bin/bash --version | head -n 1
+ else
+ bash --version | head -n 1
+ fi
+
+ - name: Run installer tests (Linux)
+ if: runner.os == 'Linux'
+ run: bash tests/installer.sh
+
+ - name: Run installer tests (macOS system Bash 3.2)
+ if: runner.os == 'macOS'
+ run: /bin/bash tests/installer.sh
diff --git a/docs/installer.md b/docs/installer.md
new file mode 100644
index 00000000..867c5205
--- /dev/null
+++ b/docs/installer.md
@@ -0,0 +1,213 @@
+# Agent OS installer
+
+The hardened installer family is made of three small, dependency-free Bash
+scripts plus one shared helper:
+
+| Script | Purpose |
+| --- | --- |
+| `scripts/project-install.sh` | Install/update standards and commands into a project |
+| `scripts/doctor.sh` | Verify a project against its install manifest |
+| `scripts/uninstall.sh` | Remove only the files the installer owns |
+| `scripts/installer-common.sh` | Shared manifest, hashing, path and profile helpers |
+
+Every script uses `set -Eeuo pipefail` and begins with `cd/pwd -P`
+canonicalisation (never `realpath`), so they run on macOS Bash 3.2 and Linux
+Bash 4/5. SHA-256 comes from `sha256sum`, `shasum -a 256` or `openssl`,
+whichever is present.
+
+## Install
+
+```sh
+scripts/project-install.sh [options]
+```
+
+| Option | Meaning |
+| --- | --- |
+| `--project-dir
` | Project to install into (default: current directory) |
+| `--profile ` | Profile to install (default: `default_profile` in `config.yml`) |
+| `--target ` | `claude` installs `.claude/commands/agent-os`, `none` installs standards only (default: `claude`) |
+| `--commands-only` | Update commands only; leave existing standards untouched |
+| `--dry-run` | Print the plan without changing the project |
+| `--yes` | Assume yes for any confirmation prompt |
+| `--force` | Overwrite unmanaged/modified files, backing them up first |
+| `--verbose` | Show detailed progress (accepted for upstream compatibility) |
+| `-h`, `--help` | Usage |
+
+Unknown options, missing option values, a `--target` other than
+`claude`/`none`, profile names containing path separators, and the no-op
+combination `--commands-only --target none` are rejected. Installing into the
+base installation directory itself is refused.
+
+### What gets installed
+
+* Standards: every `*.md` file under `profiles//` is copied to
+ `agent-os/standards/` at the same relative path. `profiles/default/global/tech-stack.md`
+ therefore lands at `agent-os/standards/global/tech-stack.md`.
+* Index: if the profile supplies `profiles//index.yml` it is **opaque** and
+ copied **byte-for-byte** (structured metadata included, never parsed or
+ rewritten). Otherwise a nested-path-aware index is generated in the format the
+ existing `/index-standards` and `/inject-standards` commands read. On update,
+ the project's own `agent-os/standards/index.yml` is read back so existing
+ descriptions carry across the regenerated index: for each `folder/name` key
+ (folder keys are flat, e.g. `api/auth`) the `description` value is decoded and
+ reused. The reader understands the simple scalar styles the installer itself
+ emits — plain scalars, single-quoted strings (`''` for an apostrophe) and
+ double-quoted strings (`\"` and `\\` escapes) — with two-space name and
+ four-space description indentation, so a description containing `:`, `#`,
+ quotes or backslashes is preserved and `#`/`:` are treated as a comment or
+ separator only **outside** quotes (a literal `hash#name` stays data). It is
+ deliberately not a general YAML parser and never sources, imports or evaluates
+ the file: any other structure, an unsupported escape or malformed quoting, a
+ duplicate folder or name key, a name left without a description before the
+ next key or the end of the file, a plain key or value that begins with a YAML
+ indicator (a flow collection `[` `]` `{` `}` `,`, a node tag, anchor or alias
+ `!` `&` `*`, a block scalar `|` `>`, or another reserved character) or a
+ literal tab or carriage return inside a quoted scalar is a hard error **before
+ any project mutation**, so a project index the installer cannot fully
+ understand is never silently reset back to default descriptions. A CRLF file
+ is accepted, because only a single trailing carriage return is stripped from
+ each line.
+ Descriptions are not carried across profiles, and a supplied profile index is
+ never consulted for them. Regenerated keys and decoded descriptions are
+ re-quoted (via the same safe scalar emitter) when they contain
+ YAML-significant characters such as `:` or `#`.
+* Commands: for `--target claude`, `commands/agent-os/*.md` is copied to
+ `.claude/commands/agent-os/`.
+
+The installer deliberately does not invent any other structure: no
+`agent-os-*` skills, no optimizer, router or adapters.
+
+### Profiles and inheritance
+
+Inheritance is read from the `profiles:` section of `config.yml`
+(`inherits_from`), never by sourcing profile content. The chain is applied
+base-first so a child profile overrides its parents. Invalid parent names,
+missing profiles, symlinked profile directories and inheritance cycles are
+rejected before anything is written.
+
+## The manifest
+
+All writes are recorded in `agent-os/install-manifest.tsv`, a versioned,
+tab-separated `hashpath` file:
+
+```
+# agent-os install manifest v1
+b2d7847...f6960f6 agent-os/standards/global/tech-stack.md
+334ff2a...f5cb6e5 .claude/commands/agent-os/inject-standards.md
+```
+
+Only paths under two owned prefixes may appear: `agent-os/standards/` and
+`.claude/commands/agent-os/`. Rows with the wrong field count, a non-hex hash,
+a tab, newline or carriage return in the path, a `..`/`.`/empty path segment, a
+path outside the prefixes, a missing header, an empty manifest, or duplicate
+rows are rejected. A manifest must also be **newline-terminated**: because the
+row readers stop at an unterminated final row, such a manifest is rejected
+outright rather than being partially trusted (so a truncated manifest can never
+make `doctor` report success while ignoring the last file). The manifest lists
+files, never directories, and never itself.
+
+On update, the manifest retains prior entries for scopes a run does not cover,
+so a `--commands-only` run keeps the tracked standards (and vice versa). It also
+retains rows for files that are still on disk but have since disappeared from
+the source profile, so ownership of stale files is never silently dropped and
+`doctor`/`uninstall` can still account for them.
+
+## Safety model
+
+* **Preflight before writing.** Sources and every destination path component
+ are checked; symlinked source roots (`profiles`, `commands/agent-os`),
+ destination symlinks (including a symlinked manifest), non-directory parents
+ and traversal are refused before any write.
+* **No clobbering without consent.** Files that are unmanaged, or tracked but
+ modified since install, abort the run. `--yes` does not override this — only
+ `--force` does, and `--force` copies each conflicting file to a freshly
+ created, symlink-checked `agent-os/.backups/.XXXXXX/` directory
+ first (unique per run, so repeated or concurrent runs cannot overwrite an
+ earlier backup).
+* **Staged, then committed atomically with rollback.** Content is staged in a
+ temp directory, preflight runs, then each file is written to a sibling temp
+ file on the same filesystem and renamed into place, and the manifest is
+ written last. Each snapshot is validated before the corresponding write is
+ armed. A failed write, `die`, or `INT`/`TERM`/`HUP` signal restores every
+ already-committed file, removes files and directories that did not exist
+ before, and never interpolates untrusted paths into trap code.
+* **`--dry-run` never touches the project.**
+
+## Doctor
+
+```sh
+scripts/doctor.sh [--project-dir ]
+```
+
+Recomputes the SHA-256 of every manifest-listed file. Missing, modified or
+symlinked managed files are reported as drift and the exit status is non-zero
+(zero when everything matches). A missing or malformed manifest is an error, as
+is a symlinked parent directory anywhere along the manifest or a managed path
+(so drift can never hide behind a redirected path component).
+
+## Uninstall
+
+```sh
+scripts/uninstall.sh [--project-dir ] [--force]
+```
+
+Before deleting anything, it preflights the manifest and the parents of every
+managed path (refusing a symlinked parent), so a redirected path component can
+never make it delete files outside the project. It then removes only
+manifest-listed files that are still unchanged. Files that were modified since
+install are retained, stay tracked in a rewritten manifest, and make the exit
+status non-zero. Symlinks are never followed or deleted. Unrelated files are
+never touched and directories are never removed recursively. With `--force`,
+files that drifted (modified regular files) are copied to a freshly created,
+symlink-checked `agent-os/.backups/-uninstall.XXXXXX/` directory and
+then removed; symlinks are still never removed.
+
+## Examples
+
+```sh
+# Default install into the current project
+scripts/project-install.sh --yes
+
+# Preview without changing anything
+scripts/project-install.sh --dry-run
+
+# Standards only, no Claude commands
+scripts/project-install.sh --target none --yes
+
+# Refresh commands after editing a standard by hand
+scripts/project-install.sh --commands-only --yes
+
+# Take over files that were edited or created by hand (keeps a backup)
+scripts/project-install.sh --force --yes
+
+# Check for drift, then clean up
+scripts/doctor.sh
+scripts/uninstall.sh --force
+```
+
+## Testing
+
+`tests/installer.sh` builds throwaway base installs and projects and exercises
+flat installs, index preservation/inheritance/quoting, decoded index scalar
+round-trip across special keys and quote/backslash/escape styles, fail-closed
+rejection of an unsupported project index (leaving the project fingerprint
+unchanged), option errors, dry-run, commands-only ownership, unchanged updates,
+stale-row retention, unmanaged/drift protection, unique force backups,
+source-root/leaf symlink and traversal rejection, manifest integrity (including
+rejection of an unterminated or otherwise malformed manifest with no project
+mutation), backslash and colon-space filename handling, dotted-profile
+inheritance, a failing hash tool aborting before any write, doctor, uninstall
+preservation, `--force` drift removal, symlinked-parent deletion and mid-commit
+rollback (including created directories whose names contain spaces or a literal
+`|`).
+Each test runs in an isolated subshell with errexit active, and a harness
+self-test fails the suite if a failure followed by a success were ever reported
+as a pass:
+
+```sh
+bash tests/installer.sh
+```
+
+CI (`.github/workflows/installer.yml`) runs the suite and ShellCheck on all
+installer scripts on Linux and macOS. On macOS it invokes the suite through the
+system `/bin/bash` so the Bash 3.2 code path is exercised.
diff --git a/scripts/doctor.sh b/scripts/doctor.sh
new file mode 100755
index 00000000..26a4df17
--- /dev/null
+++ b/scripts/doctor.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+#
+# Agent OS install doctor.
+#
+# Verifies that every file recorded in agent-os/install-manifest.tsv still
+# exists and matches its recorded SHA-256. Any missing, modified or symlinked
+# managed file is reported as drift, and the exit status is non-zero. A
+# symlinked parent directory along any managed path is refused outright so drift
+# can never be hidden behind a redirected path component.
+
+set -Eeuo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
+
+# shellcheck source=scripts/installer-common.sh
+. "$SCRIPT_DIR/installer-common.sh"
+
+PROGRAM=$(basename "$0")
+PROJECT_DIR=""
+
+show_help() {
+ cat < Project directory to check (default: current directory)
+ -h, --help Show this help message
+EOF
+ exit 0
+}
+
+parse_arguments() {
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --project-dir)
+ if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then
+ ico_die "option --project-dir requires a value"
+ fi
+ PROJECT_DIR="$2"
+ shift 2
+ ;;
+ -h|--help)
+ show_help
+ ;;
+ -*)
+ ico_die "unknown option: $1"
+ ;;
+ *)
+ ico_die "unexpected argument: $1"
+ ;;
+ esac
+ done
+}
+
+main() {
+ parse_arguments "$@"
+ PROJECT_DIR=$(ico_resolve_project_dir "${PROJECT_DIR:-$PWD}")
+
+ local manifest="$PROJECT_DIR/$ICO_MANIFEST_REL"
+ if [ ! -e "$manifest" ] && [ ! -L "$manifest" ]; then
+ ico_err "no install manifest found at $ICO_MANIFEST_REL (is Agent OS installed?)"
+ exit 1
+ fi
+
+ # Refuse symlinked parents (and a symlinked manifest) before reading paths.
+ ico_preflight_managed_paths "$PROJECT_DIR" "$manifest"
+
+ local drift=0 total=0 hash path dest cur
+ while IFS=$'\t' read -r hash path; do
+ [ -n "$hash" ] || continue
+ total=$((total + 1))
+ dest="$PROJECT_DIR/$path"
+ if [ -L "$dest" ]; then
+ ico_err "drift: $path is a symlink"
+ drift=$((drift + 1))
+ elif [ ! -e "$dest" ]; then
+ ico_err "drift: $path is missing"
+ drift=$((drift + 1))
+ elif [ ! -f "$dest" ]; then
+ ico_err "drift: $path is not a regular file"
+ drift=$((drift + 1))
+ else
+ cur=$(ico_hash_file "$dest")
+ if [ "$cur" != "$hash" ]; then
+ ico_err "drift: $path has been modified"
+ drift=$((drift + 1))
+ fi
+ fi
+ done < <(ico_manifest_each "$manifest")
+
+ if [ "$drift" -gt 0 ]; then
+ ico_err "$drift of $total managed file(s) have drifted"
+ exit 1
+ fi
+ ico_ok "all $total managed file(s) match the manifest"
+}
+
+main "$@"
diff --git a/scripts/installer-common.sh b/scripts/installer-common.sh
new file mode 100755
index 00000000..f5767703
--- /dev/null
+++ b/scripts/installer-common.sh
@@ -0,0 +1,421 @@
+#!/usr/bin/env bash
+#
+# Shared helpers for the hardened Agent OS installer family:
+# scripts/project-install.sh, scripts/doctor.sh, scripts/uninstall.sh
+#
+# Written for portability across macOS Bash 3.2 and Linux Bash 4/5:
+# - no associative arrays, no mapfile, no GNU-only tools
+# - SHA-256 via sha256sum, shasum -a 256, or openssl
+# - project roots are canonicalised with cd/pwd -P (never realpath)
+
+set -Eeuo pipefail
+LC_ALL=C
+export LC_ALL
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+
+ICO_MANIFEST_HEADER="# agent-os install manifest v1"
+ICO_STANDARDS_PREFIX="agent-os/standards/"
+ICO_COMMANDS_PREFIX=".claude/commands/agent-os/"
+
+# Shared constants consumed by the scripts that source this file.
+# shellcheck disable=SC2034
+ICO_MANIFEST_REL="agent-os/install-manifest.tsv"
+# shellcheck disable=SC2034
+ICO_DEFAULT_DESCRIPTION="Needs description - run /index-standards"
+
+# ---------------------------------------------------------------------------
+# Output
+# ---------------------------------------------------------------------------
+
+if [ -z "${NO_COLOR:-}" ] && { [ -t 1 ] || [ -t 2 ]; }; then
+ ICO_RED=$'\033[31m'
+ ICO_GREEN=$'\033[32m'
+ ICO_YELLOW=$'\033[33m'
+ ICO_BLUE=$'\033[34m'
+ ICO_NC=$'\033[0m'
+else
+ ICO_RED=""; ICO_GREEN=""; ICO_YELLOW=""; ICO_BLUE=""; ICO_NC=""
+fi
+
+ico_info() { printf '%s==>%s %s\n' "$ICO_BLUE" "$ICO_NC" "$*"; }
+ico_ok() { printf '%s ok %s %s\n' "$ICO_GREEN" "$ICO_NC" "$*"; }
+ico_warn() { printf '%swarn%s %s\n' "$ICO_YELLOW" "$ICO_NC" "$*" >&2; }
+ico_err() { printf '%sfail%s %s\n' "$ICO_RED" "$ICO_NC" "$*" >&2; }
+ico_die() { ico_err "$*"; exit 1; }
+
+# ---------------------------------------------------------------------------
+# Paths and hashing
+# ---------------------------------------------------------------------------
+
+# Canonicalise an existing directory to an absolute, physically-resolved path.
+ico_resolve_project_dir() {
+ local dir=$1
+ if [ ! -d "$dir" ]; then
+ ico_die "project directory does not exist: $dir"
+ fi
+ ( cd "$dir" && pwd -P )
+}
+
+# Print the SHA-256 of a file as 64 lowercase hex characters. The file is fed
+# on stdin (never as a filename argument) so paths containing backslashes,
+# newlines or a leading "-" can never be misparsed by the hashing tool. The
+# result is validated and any tool failure propagates (never masked into an
+# empty or malformed value).
+ico_hash_file() {
+ local file=$1 out
+ if command -v sha256sum >/dev/null 2>&1; then
+ out=$(sha256sum <"$file") || return 1
+ out=${out%% *}
+ elif command -v shasum >/dev/null 2>&1; then
+ out=$(shasum -a 256 <"$file") || return 1
+ out=${out%% *}
+ elif command -v openssl >/dev/null 2>&1; then
+ out=$(openssl dgst -sha256 <"$file") || return 1
+ out=${out##* }
+ else
+ ico_die "no SHA-256 tool found (need sha256sum, shasum or openssl)"
+ fi
+ ico_is_sha256 "$out" || return 1
+ printf '%s\n' "$out"
+}
+
+ico_is_sha256() {
+ case "$1" in
+ *[!0-9a-f]*) return 1 ;;
+ esac
+ [ "${#1}" -eq 64 ]
+}
+
+# A safe manifest path is relative, free of tabs/newlines/carriage returns and
+# free of any empty, "." or ".." segment (so it cannot escape the project).
+ico_path_ok() {
+ local p=$1
+ [ -n "$p" ] || return 1
+ case "$p" in
+ /*) return 1 ;;
+ *$'\t'*|*$'\n'*|*$'\r'*) return 1 ;;
+ esac
+ case "/$p/" in
+ *"/../"*|*"/./"*|*"//"*) return 1 ;;
+ esac
+ return 0
+}
+
+# Only files under these two prefixes are ever owned by the manifest.
+ico_path_owned() {
+ case "$1" in
+ "$ICO_STANDARDS_PREFIX"*) return 0 ;;
+ "$ICO_COMMANDS_PREFIX"*) return 0 ;;
+ esac
+ return 1
+}
+
+# Scope of an owned path: "standards" or "commands" (empty when unowned).
+ico_scope_of() {
+ case "$1" in
+ "$ICO_STANDARDS_PREFIX"*) printf 'standards\n' ;;
+ "$ICO_COMMANDS_PREFIX"*) printf 'commands\n' ;;
+ *) return 1 ;;
+ esac
+}
+
+# Refuse to read or write through a symlink anywhere along a project-relative
+# path, and refuse non-directory parents / non-regular-file destinations.
+ico_assert_dest_safe() {
+ local root=$1
+ local cur=$root part rest=$2
+ while :; do
+ case "$rest" in
+ */*) part=${rest%%/*}; rest=${rest#*/} ;;
+ *) part=$rest; rest="" ;;
+ esac
+ cur="$cur/$part"
+ if [ -L "$cur" ]; then
+ ico_die "refusing to use symlink path component: $cur"
+ fi
+ if [ -n "$rest" ]; then
+ if [ -e "$cur" ] && [ ! -d "$cur" ]; then
+ ico_die "path component is not a directory: $cur"
+ fi
+ else
+ if [ -e "$cur" ] && [ ! -f "$cur" ]; then
+ ico_die "destination is not a regular file: $cur"
+ fi
+ fi
+ [ -n "$rest" ] || break
+ done
+}
+
+# Assert that every *parent* directory component of a project-relative path is
+# a real directory (never a symlink). The final leaf is intentionally ignored so
+# callers can still report leaf-level drift. Dies before any mutation.
+ico_assert_parent_safe() {
+ local root=$1
+ local cur=$root part rest=$2
+ while :; do
+ case "$rest" in
+ */*) part=${rest%%/*}; rest=${rest#*/} ;;
+ *) break ;;
+ esac
+ cur="$cur/$part"
+ if [ -L "$cur" ]; then
+ ico_die "refusing to use symlink path component: $cur"
+ fi
+ if [ -e "$cur" ] && [ ! -d "$cur" ]; then
+ ico_die "path component is not a directory: $cur"
+ fi
+ done
+}
+
+# ---------------------------------------------------------------------------
+# Backups
+# ---------------------------------------------------------------------------
+
+# Create and print a fresh, uniquely named backup directory under
+# agent-os/.backups. Refuses symlinked backup components and never reuses a
+# name, so concurrent or repeated runs cannot clobber earlier backups.
+ico_make_backup_dir() {
+ local root=$1 label=$2 rel="agent-os/.backups"
+ ico_assert_parent_safe "$root" "$rel/placeholder"
+ mkdir -p "$root/$rel"
+ mktemp -d "$root/$rel/$label.XXXXXX"
+}
+
+# ---------------------------------------------------------------------------
+# YAML scalar emission
+# ---------------------------------------------------------------------------
+
+# True when a value can be emitted as an unquoted YAML plain scalar. Anything
+# containing a ": ", "#", quote, backslash, comma, bracket, indicator character,
+# leading/trailing whitespace or tab is rejected and will be quoted instead.
+# So are the reserved YAML words (true/false/yes/no/on/off/null, matched
+# case-insensitively) and any value starting with a digit, so filenames such as
+# "true.md" or "2024-01-01.md" are never read back as booleans, numbers or
+# dates. Ordinary names (global, root, tech-stack) stay plain.
+ico_yaml_plain_ok() {
+ local v=$1 lower
+ [ -n "$v" ] || return 1
+ case "$v" in
+ [A-Za-z]*) : ;;
+ *) return 1 ;;
+ esac
+ case "$v" in
+ *[!A-Za-z0-9\ ./_-]*) return 1 ;;
+ esac
+ case "$v" in
+ *' '|*$'\t'*) return 1 ;;
+ esac
+ lower=$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')
+ case "$lower" in
+ true|false|yes|no|on|off|null) return 1 ;;
+ esac
+ return 0
+}
+
+# Emit a YAML-safe scalar: a bare word when unambiguously safe, otherwise a
+# double-quoted, escaped string (so filenames with ":" or "#" stay valid).
+ico_yaml_scalar() {
+ local v=$1
+ if ico_yaml_plain_ok "$v"; then
+ printf '%s' "$v"
+ return 0
+ fi
+ v=${v//\\/\\\\}
+ v=${v//\"/\\\"}
+ printf '"%s"' "$v"
+}
+
+# ---------------------------------------------------------------------------
+# Profiles and inheritance (read from config.yml, never sourced)
+# ---------------------------------------------------------------------------
+
+ico_profile_name_ok() {
+ local n=$1
+ [ -n "$n" ] || return 1
+ case "$n" in
+ .|..|-*|*/*|*\\*|*$'\t'*|*$'\n'*) return 1 ;;
+ esac
+ case "$n" in
+ *[!A-Za-z0-9._-]*) return 1 ;;
+ esac
+ return 0
+}
+
+ico_config_default_profile() {
+ local file=$1 value
+ value=$(sed -n 's/^default_profile:[[:space:]]*//p' "$file" | head -n 1)
+ value=$(printf '%s' "$value" | sed 's/[[:space:]]*$//')
+ if [ -n "$value" ]; then
+ printf '%s\n' "$value"
+ else
+ printf 'default\n'
+ fi
+}
+
+# Print the inherits_from value for a profile, or nothing when unset. The target
+# stanza is selected by an exact string comparison of the parsed key against the
+# requested profile name (never by regex interpolation), so a profile such as
+# "foo.bar" can never match an unrelated "fooXbar" stanza.
+ico_config_inherits_from() {
+ local file=$1 profile=$2
+ awk -v profile="$profile" '
+ /^profiles:[[:space:]]*$/ { in_profiles = 1; next }
+ in_profiles && /^[^[:space:]]/ { in_profiles = 0 }
+ !in_profiles { next }
+ !in_target {
+ line = $0
+ if (line ~ /^ [^[:space:]][^:]*:[[:space:]]*$/) {
+ key = line
+ sub(/^ /, "", key)
+ sub(/:[[:space:]]*$/, "", key)
+ if (key == profile) { in_target = 1 }
+ }
+ next
+ }
+ in_target {
+ if ($0 ~ /^ [^[:space:]]/) { in_target = 0; next }
+ if ($0 ~ /^[[:space:]]+inherits_from:[[:space:]]*/) {
+ line = $0
+ sub(/^[[:space:]]*inherits_from:[[:space:]]*/, "", line)
+ sub(/[[:space:]]+$/, "", line)
+ print line
+ exit
+ }
+ }
+ ' "$file"
+}
+
+# Build the inheritance chain base-first, rejecting invalid names, symlinked
+# profile directories, missing profiles and inheritance cycles.
+ico_profile_chain() {
+ local config=$1 profiles_dir=$2 start=$3
+ local chain="" visited="" current="$start" parent
+ while [ -n "$current" ]; do
+ if ! ico_profile_name_ok "$current"; then
+ ico_die "invalid profile name in inheritance chain: $current"
+ fi
+ case "$visited" in
+ *"|$current|"*) ico_die "circular profile inheritance detected at: $current" ;;
+ esac
+ if [ -L "$profiles_dir/$current" ]; then
+ ico_die "profile directory is a symlink: $current"
+ fi
+ if [ ! -d "$profiles_dir/$current" ]; then
+ ico_die "profile not found: $current"
+ fi
+ visited="$visited|$current|"
+ if [ -n "$chain" ]; then
+ chain="$current
+$chain"
+ else
+ chain="$current"
+ fi
+ parent=$(ico_config_inherits_from "$config" "$current")
+ current="$parent"
+ done
+ printf '%s\n' "$chain"
+}
+
+# ---------------------------------------------------------------------------
+# Manifest I/O
+# ---------------------------------------------------------------------------
+
+# True when a non-empty file's final byte is a newline, using only `tail -c`
+# (available on both BSD/macOS and GNU coreutils; no GNU-only option). Every
+# manifest consumer reads rows with a plain `while read` loop that silently
+# drops an unterminated final row, so such a manifest must be rejected rather
+# than partially trusted.
+ico_file_ends_with_newline() {
+ [ "$(tail -c 1 "$1")" = "" ]
+}
+
+# Strictly validate a manifest: non-empty header, TSV shape, lowercase SHA-256
+# format, safe/owned path, no duplicate rows and no stray carriage returns. The
+# manifest must be newline-terminated so no consumer can silently drop a final
+# unterminated row.
+ico_manifest_validate() {
+ local file=$1 line first=1 lineno=0 hash path seen=$'\n'
+ if [ -L "$file" ]; then
+ ico_die "refusing to read symlink manifest: $file"
+ fi
+ if [ ! -e "$file" ]; then
+ ico_die "manifest not found: $file"
+ fi
+ if [ ! -f "$file" ]; then
+ ico_die "manifest is not a regular file: $file"
+ fi
+ if [ -s "$file" ] && ! ico_file_ends_with_newline "$file"; then
+ ico_die "manifest is not newline-terminated: $file"
+ fi
+ while IFS= read -r line || [ -n "$line" ]; do
+ lineno=$((lineno + 1))
+ if [ "$first" -eq 1 ]; then
+ first=0
+ if [ "$line" != "$ICO_MANIFEST_HEADER" ]; then
+ ico_die "invalid manifest header on line $lineno"
+ fi
+ continue
+ fi
+ case "$line" in
+ *$'\t'*) ;;
+ *) ico_die "invalid manifest row without tab on line $lineno" ;;
+ esac
+ hash=${line%%$'\t'*}
+ path=${line#*$'\t'}
+ case "$path" in
+ *$'\t'*) ico_die "invalid manifest row with extra tab on line $lineno" ;;
+ esac
+ if ! ico_is_sha256 "$hash"; then
+ ico_die "invalid manifest hash on line $lineno"
+ fi
+ if ! ico_path_ok "$path"; then
+ ico_die "invalid manifest path on line $lineno: $path"
+ fi
+ if ! ico_path_owned "$path"; then
+ ico_die "manifest path outside owned prefixes on line $lineno: $path"
+ fi
+ if printf '%s\n' "$seen" | grep -Fqx -- "$path"; then
+ ico_die "duplicate manifest path on line $lineno: $path"
+ fi
+ seen="$seen$path"$'\n'
+ done < "$file"
+ if [ "$first" -eq 1 ]; then
+ ico_die "empty manifest (missing header): $file"
+ fi
+}
+
+# Validate a manifest and prove that the manifest itself, and every managed
+# path, is reachable without traversing a symlinked parent directory. The
+# manifest's own parent is checked *before* the manifest is read, so a hostile
+# parent symlink can never redirect the read; each managed path is then checked
+# before the caller performs any mutation.
+ico_preflight_managed_paths() {
+ local root=$1 manifest=$2 hash path
+ ico_assert_parent_safe "$root" "$ICO_MANIFEST_REL"
+ ico_manifest_validate "$manifest"
+ while IFS=$'\t' read -r hash path; do
+ [ -n "$hash" ] || continue
+ ico_assert_parent_safe "$root" "$path"
+ done < <(ico_manifest_each "$manifest")
+}
+
+# Print the data rows (everything after the header).
+ico_manifest_each() {
+ tail -n +2 "$1"
+}
+
+# Print the recorded hash for a path, or nothing when it is not tracked.
+ico_manifest_hash() {
+ local file=$1 want=$2 hash path
+ while IFS=$'\t' read -r hash path; do
+ [ -n "$hash" ] || continue
+ if [ "$path" = "$want" ]; then
+ printf '%s\n' "$hash"
+ return 0
+ fi
+ done < <(ico_manifest_each "$file")
+ return 1
+}
diff --git a/scripts/project-install.sh b/scripts/project-install.sh
index 3f7b34a4..ca84c9f7 100755
--- a/scripts/project-install.sh
+++ b/scripts/project-install.sh
@@ -1,477 +1,939 @@
-#!/bin/bash
+#!/usr/bin/env bash
+#
+# Agent OS project installer (hardened).
+#
+# Installs Agent OS standards from a profile (optionally inherited) into a
+# project's agent-os/standards directory and, for the claude target, the
+# agent-os slash commands into .claude/commands/agent-os.
+#
+# Every write is tracked in agent-os/install-manifest.tsv so that later runs,
+# doctor.sh and uninstall.sh can detect drift and never clobber user edits.
-# =============================================================================
-# Agent OS Project Installation Script
-# Installs Agent OS into a project's codebase
-# =============================================================================
+set -Eeuo pipefail
-set -e
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
+BASE_DIR=$(cd "$SCRIPT_DIR/.." && pwd -P)
-# Get the directory where this script is located
-SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-BASE_DIR="$(dirname "$SCRIPT_DIR")"
-PROJECT_DIR="$(pwd)"
+# shellcheck source=scripts/installer-common.sh
+. "$SCRIPT_DIR/installer-common.sh"
-# Source common functions
-source "$SCRIPT_DIR/common-functions.sh"
+PROGRAM=$(basename "$0")
-# -----------------------------------------------------------------------------
-# Default Values
-# -----------------------------------------------------------------------------
-
-VERBOSE="false"
+# Option state -----------------------------------------------------------------
+TARGET="claude"
PROFILE=""
-COMMANDS_ONLY="false"
+PROJECT_DIR=""
+COMMANDS_ONLY=false
+DRY_RUN=false
+ASSUME_YES=false
+FORCE=false
+VERBOSE=false
+
+# Resolved state ---------------------------------------------------------------
+CHAIN=""
+MANIFEST=""
+MANIFEST_REL="$ICO_MANIFEST_REL"
+TARGET_PATHS=""
+CONFLICTS=""
+EXISTING=0
+SUPPLIED_INDEX=""
+COVERS_STANDARDS=true
+COVERS_COMMANDS=true
+
+WORK_DIR=""
+STAGE=""
+ROLLBACK_ARMED=false
+COMMIT_TMP=""
+commit_count=0
+SNAP_COUNT=0
+CREATED_DIR_COUNT=0
+CREATED_DIRS=()
+
+SNAP_PATHS=()
+SNAP_BACKUPS=()
+SNAP_HAD=()
# -----------------------------------------------------------------------------
-# Help Function
+# Help
# -----------------------------------------------------------------------------
show_help() {
- cat << EOF
-Usage: $0 [OPTIONS]
+ cat < Use specified profile (default: from config.yml)
- --commands-only Only update commands, preserve existing standards
- --verbose Show detailed output
- -h, --help Show this help message
+ --project-dir Target project directory (default: current directory)
+ --profile Profile to install (default: default_profile in config.yml)
+ --target "claude" installs .claude/commands/agent-os, "none" installs
+ standards only (default: claude)
+ --commands-only Update only commands, leave existing standards untouched
+ --dry-run Print the plan without changing the project
+ --yes Assume yes for any confirmation prompt
+ --force Overwrite unmanaged/modified files, backing them up first
+ --verbose Show detailed progress
+ -h, --help Show this help message
Examples:
- $0
- $0 --profile rails
- $0 --commands-only
-
+ $PROGRAM
+ $PROGRAM --profile rails --target none
+ $PROGRAM --project-dir ../app --commands-only --yes
+ $PROGRAM --dry-run
EOF
exit 0
}
# -----------------------------------------------------------------------------
-# Parse Command Line Arguments
+# Argument parsing
# -----------------------------------------------------------------------------
parse_arguments() {
- while [[ $# -gt 0 ]]; do
- case $1 in
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --project-dir)
+ if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then
+ ico_die "option --project-dir requires a value"
+ fi
+ PROJECT_DIR="$2"
+ shift 2
+ ;;
--profile)
+ if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then
+ ico_die "option --profile requires a value"
+ fi
PROFILE="$2"
shift 2
;;
+ --target)
+ if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then
+ ico_die "option --target requires a value"
+ fi
+ TARGET="$2"
+ shift 2
+ ;;
--commands-only)
- COMMANDS_ONLY="true"
+ COMMANDS_ONLY=true
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ --yes|-y)
+ ASSUME_YES=true
+ shift
+ ;;
+ --force)
+ FORCE=true
shift
;;
--verbose)
- VERBOSE="true"
+ VERBOSE=true
shift
;;
-h|--help)
show_help
;;
+ -*)
+ ico_die "unknown option: $1"
+ ;;
*)
- print_error "Unknown option: $1"
- show_help
+ ico_die "unexpected argument: $1"
;;
esac
done
}
# -----------------------------------------------------------------------------
-# Validation Functions
+# Validation
# -----------------------------------------------------------------------------
-validate_base_installation() {
- if [[ ! -d "$BASE_DIR" ]]; then
- print_error "Agent OS base installation not found"
- exit 1
+validate_options() {
+ case "$TARGET" in
+ claude|none) ;;
+ *) ico_die "invalid --target: $TARGET (expected 'claude' or 'none')" ;;
+ esac
+ if [ "$COMMANDS_ONLY" = true ] && [ "$TARGET" = "none" ]; then
+ ico_die "--commands-only cannot be combined with --target none (nothing would be installed)"
fi
-
- if [[ ! -f "$BASE_DIR/config.yml" ]]; then
- print_error "Base installation config.yml not found"
- exit 1
+ if [ -n "$PROFILE" ]; then
+ ico_profile_name_ok "$PROFILE" || ico_die "invalid profile name: $PROFILE"
fi
}
-validate_not_in_base() {
- if [[ "$PROJECT_DIR" == "$BASE_DIR" ]]; then
- print_error "Cannot install Agent OS in the base installation directory"
- echo ""
- echo "Navigate to your project directory first:"
- echo " cd /path/to/your/project"
- echo ""
- exit 1
- fi
+validate_base_installation() {
+ [ -d "$BASE_DIR" ] || ico_die "Agent OS base installation not found: $BASE_DIR"
+ [ -f "$BASE_DIR/config.yml" ] || ico_die "missing config.yml in $BASE_DIR"
+ [ -d "$BASE_DIR/profiles" ] || ico_die "missing profiles directory in $BASE_DIR"
}
-# -----------------------------------------------------------------------------
-# Configuration Functions
-# -----------------------------------------------------------------------------
-
-load_configuration() {
- local config_file="$BASE_DIR/config.yml"
-
- # Get default profile from config
- local default_profile=$(get_yaml_value "$config_file" "default_profile" "default")
-
- # Use command line profile or default
- EFFECTIVE_PROFILE="${PROFILE:-$default_profile}"
-
- # Validate profile exists
- if [[ ! -d "$BASE_DIR/profiles/$EFFECTIVE_PROFILE" ]]; then
- print_error "Profile not found: $EFFECTIVE_PROFILE"
- exit 1
- fi
+# Reject symlinked live source roots so a profile/command tree cannot be
+# redirected outside the base installation.
+validate_source_roots() {
+ local p
+ for p in profiles commands commands/agent-os; do
+ if [ -L "$BASE_DIR/$p" ]; then
+ ico_die "source root is a symlink: $p"
+ fi
+ done
+}
- # Build inheritance chain
- local chain_result=$(get_profile_inheritance_chain "$config_file" "$EFFECTIVE_PROFILE" "$BASE_DIR/profiles")
-
- # Check for errors
- if [[ "$chain_result" == CIRCULAR:* ]]; then
- local cycle_path="${chain_result#CIRCULAR:}"
- echo ""
- print_error "Circular dependency detected in profile inheritance chain:"
- echo " $cycle_path"
- echo ""
- echo "Please fix the inheritance configuration in:"
- echo " $config_file"
- echo ""
- echo "The 'profiles' section contains a circular reference that must be resolved."
- exit 1
+resolve_project() {
+ local dir=${PROJECT_DIR:-$PWD}
+ PROJECT_DIR=$(ico_resolve_project_dir "$dir")
+ if [ "$PROJECT_DIR" = "$BASE_DIR" ]; then
+ ico_die "cannot install into the Agent OS base installation directory: $BASE_DIR"
fi
-
- if [[ "$chain_result" == NOTFOUND:* ]]; then
- local missing_profile="${chain_result#NOTFOUND:}"
- print_error "Profile not found: $missing_profile"
- echo ""
- echo "This profile is referenced in the inheritance chain but doesn't exist."
- echo "Check the 'profiles' section in: $config_file"
- exit 1
+ MANIFEST="$PROJECT_DIR/$MANIFEST_REL"
+ if [ -e "$MANIFEST" ] || [ -L "$MANIFEST" ]; then
+ ico_manifest_validate "$MANIFEST"
fi
-
- # Store the inheritance chain (newline-separated, base first)
- INHERITANCE_CHAIN="$chain_result"
-
- print_verbose "Using profile: $EFFECTIVE_PROFILE"
- print_verbose "Inheritance chain: $(echo "$INHERITANCE_CHAIN" | tr '\n' ' ')"
}
-# -----------------------------------------------------------------------------
-# Confirmation Functions
-# -----------------------------------------------------------------------------
-
-confirm_standards_overwrite() {
- if [[ "$COMMANDS_ONLY" == "true" ]]; then
- return 0
+load_profile_chain() {
+ local default_profile
+ default_profile=$(ico_config_default_profile "$BASE_DIR/config.yml")
+ if [ -z "$PROFILE" ]; then
+ PROFILE="$default_profile"
fi
+ ico_profile_name_ok "$PROFILE" || ico_die "invalid profile name: $PROFILE"
+ if [ -L "$BASE_DIR/profiles/$PROFILE" ]; then
+ ico_die "profile directory is a symlink: $PROFILE"
+ fi
+ if [ ! -d "$BASE_DIR/profiles/$PROFILE" ]; then
+ ico_die "profile not found: $PROFILE"
+ fi
+ CHAIN=$(ico_profile_chain "$BASE_DIR/config.yml" "$BASE_DIR/profiles" "$PROFILE")
- local existing_standards="$PROJECT_DIR/agent-os/standards"
-
- if [[ -d "$existing_standards" ]]; then
- echo ""
- print_warning "Existing standards folder detected at: $existing_standards"
- echo ""
- echo "This will overwrite your existing standards with standards from the '$EFFECTIVE_PROFILE' profile."
- echo ""
- read -p "Do you want to continue? (y/N) " -n 1 -r
- echo ""
- if [[ ! $REPLY =~ ^[Yy]$ ]]; then
- echo ""
- echo "Installation cancelled."
- echo ""
- echo "To update only commands without touching standards, use:"
- echo " $0 --commands-only"
- echo ""
- exit 0
- fi
+ COVERS_STANDARDS=true
+ COVERS_COMMANDS=true
+ if [ "$COMMANDS_ONLY" = true ]; then
+ COVERS_STANDARDS=false
+ fi
+ if [ "$TARGET" = "none" ]; then
+ COVERS_COMMANDS=false
fi
}
# -----------------------------------------------------------------------------
-# Installation Functions
+# Staging
# -----------------------------------------------------------------------------
-create_project_structure() {
- print_status "Creating project structure..."
+setup_workdir() {
+ WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/agent-os-install.XXXXXX")
+ STAGE="$WORK_DIR/stage"
+ mkdir -p "$STAGE"
+}
- ensure_dir "$PROJECT_DIR/agent-os"
- ensure_dir "$PROJECT_DIR/agent-os/standards"
+stage_standards() {
+ local name proot rel abs src dest idx list
+ SUPPLIED_INDEX=""
+ while IFS= read -r name; do
+ [ -n "$name" ] || continue
+ proot="$BASE_DIR/profiles/$name"
+ idx="$proot/index.yml"
+ if [ -L "$idx" ]; then
+ ico_die "profile index is a symlink: $name/index.yml"
+ fi
+ if [ -f "$idx" ]; then
+ SUPPLIED_INDEX="$idx"
+ fi
+ list="$WORK_DIR/find.standards.$name"
+ if ! ( cd "$proot" && find . \( -type f -name '*.md' -o -type l \) -print0 ) >"$list"; then
+ ico_die "failed to scan profile '$name' for standards"
+ fi
+ while IFS= read -r -d '' abs; do
+ rel=${abs#./}
+ if ! ico_path_ok "$rel"; then
+ ico_die "unsafe path in profile '$name': $rel"
+ fi
+ case "/$rel/" in
+ */.backups/*) continue ;;
+ esac
+ case "$rel" in
+ index.yml) continue ;;
+ esac
+ src="$proot/$rel"
+ if [ -L "$src" ]; then
+ ico_die "profile file is a symlink: $name/$rel"
+ fi
+ dest="$STAGE/agent-os/standards/$rel"
+ mkdir -p "$(dirname "$dest")"
+ cp -p "$src" "$dest"
+ done <"$list"
+ done <<<"$CHAIN"
+}
- print_success "Created agent-os/ directory structure"
+stage_index() {
+ local standards_dir="$STAGE/agent-os/standards"
+ local out="$standards_dir/index.yml"
+ mkdir -p "$standards_dir"
+ if [ -n "$SUPPLIED_INDEX" ]; then
+ cp -p "$SUPPLIED_INDEX" "$out"
+ ico_info "using profile-supplied index.yml"
+ else
+ generate_index "$standards_dir" "$PROJECT_DIR/agent-os/standards/index.yml" "$out"
+ fi
}
-install_standards() {
- if [[ "$COMMANDS_ONLY" == "true" ]]; then
- print_status "Skipping standards (--commands-only)"
- return
+# Build a nested-path-aware index.yml that the existing commands understand.
+generate_index() {
+ local dir=$1 old=$2 out=$3
+ local lookup="$WORK_DIR/index.lookup"
+ local entries="$WORK_DIR/index.entries"
+ local list="$WORK_DIR/index.files"
+ : >"$lookup"
+ : >"$entries"
+
+ if [ -f "$old" ] && [ ! -L "$old" ]; then
+ if ! index_parse_descriptions "$old" >>"$lookup"; then
+ ico_die "unsupported or malformed standards index at $old; refusing to reset descriptions to the default"
+ fi
fi
- echo ""
- print_status "Installing standards..."
+ if ! find "$dir" -type f -name '*.md' -print0 >"$list"; then
+ ico_die "failed to scan staged standards for index generation"
+ fi
- local project_standards="$PROJECT_DIR/agent-os/standards"
- local profiles_used=0
+ local abs rel name dpart label key sk desc
+ while IFS= read -r -d '' abs; do
+ rel=${abs#"$dir/"}
+ name=${rel##*/}
+ name=${name%.md}
+ dpart=${rel%/*}
+ if [ "$dpart" = "$rel" ]; then
+ dpart=""
+ fi
+ if [ -z "$dpart" ]; then
+ label="root"; key="root/$name"; sk="0"
+ else
+ label="$dpart"; key="$dpart/$name"; sk="1"
+ fi
+ desc=$(index_lookup "$lookup" "$key")
+ if [ -z "$desc" ]; then
+ desc="$ICO_DEFAULT_DESCRIPTION"
+ fi
+ case "$desc" in
+ *$'\t'*) desc="$ICO_DEFAULT_DESCRIPTION" ;;
+ esac
+ printf '%s\t%s\t%s\t%s\n' "$sk" "$label" "$name" "$desc" >>"$entries"
+ done <"$list"
+
+ {
+ printf '# Agent OS Standards Index\n'
+ sort -t "$(printf '\t')" -k1,1 -k2,2 -k3,3 "$entries" | {
+ local prev=""
+ while IFS=$'\t' read -r sk label name desc; do
+ if [ "$label" != "$prev" ]; then
+ printf '\n%s:\n' "$(ico_yaml_scalar "$label")"
+ prev="$label"
+ fi
+ printf ' %s:\n description: %s\n' "$(ico_yaml_scalar "$name")" "$(ico_yaml_scalar "$desc")"
+ done
+ }
+ } >"$out"
+}
- # Temp file to track file sources (format: relative_path|profile_name)
- local sources_file=$(mktemp)
- trap "rm -f $sources_file" EXIT
+# Read an existing generated index back into "folder/namedescription" pairs
+# so custom descriptions survive regeneration. Only the simple shape the
+# installer itself emits is understood -- folder -> name -> description, with
+# two-space name indentation, four-space description indentation and flat
+# nested-path folder keys such as api/auth -- where every key and value is a
+# plain, single-quoted or double-quoted YAML scalar. Separators and #-comments
+# are recognised only outside quotes, so a literal hash#name stays data. The
+# file is never sourced or evaluated. Anything the reader cannot fully
+# understand is a hard error before any project mutation instead of a silent
+# reset to default descriptions: unsupported structure, an unsupported escape
+# or malformed quoting, a duplicate folder or name key (which would otherwise
+# be silently first-win), a name left without a description before the next
+# key or the end of the file, a plain key or value that begins with a YAML
+# indicator ([ { ! & * | > and friends), or a literal tab or carriage return
+# inside a quoted scalar (which the TSV round-trip and the trailing-CR strip
+# would otherwise silently lose). CRLF input is supported by stripping only a
+# single trailing carriage return from each line.
+index_parse_descriptions() {
+ local file="$1" prog="$WORK_DIR/index-parse.awk"
+ cat >"$prog" <<'AGENT_OS_AWK'
+function fail(msg) {
+ if (!failed) print "index parse error: " msg > "/dev/stderr"
+ failed = 1
+ exit 2
+}
- # Process each profile in the inheritance chain (base first, so later ones override)
- while IFS= read -r profile_name; do
- [[ -z "$profile_name" ]] && continue
+function rtrim(s, n, c) {
+ n = length(s)
+ while (n > 0) {
+ c = substr(s, n, 1)
+ if (c == " " || c == TAB) { n--; continue }
+ break
+ }
+ return substr(s, 1, n)
+}
- local profile_standards="$BASE_DIR/profiles/$profile_name/standards"
+# A plain (unquoted) scalar may not begin with a YAML indicator: a flow
+# collection ([ ] { } ,), a node property (! &), an alias (*), a block scalar
+# (| >), a directive or reserved character ( % @ `) -- or "-", "?" or ":"
+# when they stand alone or are followed by whitespace, where the node kind
+# would change and the plain reading would lose that meaning.
+function bad_plain(s, c, d) {
+ c = substr(s, 1, 1)
+ if (c == "[" || c == "]" || c == "{" || c == "}" || c == ",") return 1
+ if (c == "&" || c == "*" || c == "!" || c == "|" || c == ">") return 1
+ if (c == "%" || c == "@" || c == "`") return 1
+ if (c == "-" || c == "?" || c == ":") {
+ d = substr(s, 2, 1)
+ if (d == "" || d == " " || d == TAB) return 1
+ }
+ return 0
+}
- if [[ ! -d "$profile_standards" ]]; then
+function quoted(s, p, n, i, c, out) {
+ n = length(s)
+ out = ""
+ if (substr(s, p, 1) == "'") {
+ i = p + 1
+ while (i <= n) {
+ c = substr(s, i, 1)
+ if (c == TAB || c == CR) fail("tab or carriage return inside a quoted scalar")
+ if (c == "'") {
+ if (substr(s, i + 1, 1) == "'") { out = out "'"; i += 2; continue }
+ sc_end = i + 1
+ return out
+ }
+ out = out c
+ i++
+ }
+ fail("unterminated single-quoted scalar")
+ }
+ i = p + 1
+ while (i <= n) {
+ c = substr(s, i, 1)
+ if (c == TAB || c == CR) fail("tab or carriage return inside a quoted scalar")
+ if (c == BS) {
+ c = substr(s, i + 1, 1)
+ if (c == DQ) out = out DQ
+ else if (c == BS) out = out BS
+ else fail("unsupported escape in double-quoted scalar")
+ i += 2
continue
- fi
+ }
+ if (c == DQ) { sc_end = i + 1; return out }
+ out = out c
+ i++
+ }
+ fail("unterminated double-quoted scalar")
+}
+
+function tail_ok(s, p, n, k, c) {
+ n = length(s)
+ k = p
+ while (k <= n) {
+ c = substr(s, k, 1)
+ if (c == " " || c == TAB) { k++; continue }
+ if (c == "#") return
+ fail("unexpected trailing content in generated index")
+ }
+}
- local profile_file_count=0
+function parse_key(s, n, i) {
+ n = length(s)
+ if (substr(s, 1, 1) == "'" || substr(s, 1, 1) == DQ) {
+ key = quoted(s, 1)
+ i = sc_end
+ while (i <= n && substr(s, i, 1) == " ") i++
+ if (substr(s, i, 1) != ":") fail("expected ':' after key scalar")
+ tail_ok(s, i + 1)
+ return key
+ }
+ i = index(s, ":")
+ if (i == 0) fail("missing ':' after key")
+ key = rtrim(substr(s, 1, i - 1))
+ if (key == "" || substr(key, 1, 1) == " " || substr(key, 1, 1) == TAB) fail("empty or malformed key")
+ if (bad_plain(key)) fail("unsupported YAML indicator in plain key: " key)
+ if (index(key, TAB) > 0) fail("tab in plain key")
+ tail_ok(s, i + 1)
+ return key
+}
- # Find all .md files in this profile, excluding .backups
- while IFS= read -r -d '' file; do
- local relative_path="${file#$profile_standards/}"
- local dest_file="$project_standards/$relative_path"
+function parse_description(s, n, i, c, val, k) {
+ n = length(s)
+ if (substr(s, 1, 11) != "description") fail("only a description is supported here")
+ i = 12
+ while (i <= n && (substr(s, i, 1) == " " || substr(s, i, 1) == TAB)) i++
+ if (substr(s, i, 1) != ":") fail("expected ':' after description")
+ i++
+ while (i <= n && (substr(s, i, 1) == " " || substr(s, i, 1) == TAB)) i++
+ if (i > n || substr(s, i, 1) == "#") return ""
+ c = substr(s, i, 1)
+ if (c == "'" || c == DQ) {
+ val = quoted(s, i)
+ tail_ok(s, sc_end)
+ return val
+ }
+ if (bad_plain(substr(s, i))) fail("unsupported YAML indicator in plain value")
+ val = substr(s, i)
+ n = length(val)
+ for (k = 2; k <= n; k++) {
+ if (substr(val, k, 1) == "#" && (substr(val, k - 1, 1) == " " || substr(val, k - 1, 1) == TAB)) {
+ val = substr(val, 1, k - 1)
+ break
+ }
+ }
+ val = rtrim(val)
+ if (index(val, TAB) > 0) fail("tab in plain value")
+ if (index(val, ": ") > 0 || index(val, ":" TAB) > 0 || substr(val, length(val), 1) == ":") {
+ fail("unsupported nested value in generated index")
+ }
+ return val
+}
- ensure_dir "$(dirname "$dest_file")"
- cp "$file" "$dest_file"
+BEGIN {
+ TAB = sprintf("%c", 9)
+ CR = sprintf("%c", 13)
+ DQ = sprintf("%c", 34)
+ BS = sprintf("%c", 92)
+ folder = ""
+ name = ""
+}
+{
+ line = $0
+ n = length(line)
+ if (n > 0 && substr(line, n, 1) == CR) { line = substr(line, 1, n - 1); n = n - 1 }
+ if (index(line, CR) > 0) fail("carriage return in index content")
+ i = 1
+ while (i <= n) {
+ c = substr(line, i, 1)
+ if (c == " " || c == TAB) { i++; continue }
+ break
+ }
+ if (i > n) next
+ if (substr(line, i, 1) == "#") next
+ ind = 0
+ while (ind < n && substr(line, ind + 1, 1) == " ") ind++
+ if (substr(line, ind + 1, 1) == TAB) fail("tab indentation is not supported")
+ rest = substr(line, ind + 1)
+ if (ind == 0) {
+ if (name != "") fail("name key left without a description before the next folder")
+ folder = parse_key(rest)
+ if (folder in seen) fail("duplicate folder key: " folder)
+ seen[folder] = 1
+ name = ""
+ } else if (ind == 2) {
+ if (folder == "") fail("name key without a preceding folder")
+ if (name != "") fail("name key left without a description before the next name")
+ name = parse_key(rest)
+ nkey = folder SUBSEP name
+ if (nkey in seenname) fail("duplicate name key: " folder "/" name)
+ seenname[nkey] = 1
+ } else if (ind == 4) {
+ if (folder == "" || name == "") fail("description without a folder and name")
+ desc = parse_description(rest)
+ print folder "/" name TAB desc
+ name = ""
+ } else {
+ fail("unsupported indentation level " ind)
+ }
+}
+END {
+ if (name != "") fail("name key left without a description at end of index")
+}
+AGENT_OS_AWK
+ awk -f "$prog" <"$file"
+}
- # Track the source - remove old entry if exists, add new one
- grep -v "^${relative_path}|" "$sources_file" > "${sources_file}.tmp" 2>/dev/null || true
- mv "${sources_file}.tmp" "$sources_file"
- echo "${relative_path}|${profile_name}" >> "$sources_file"
- (( profile_file_count++ )) || true
- done < <(find "$profile_standards" -name "*.md" -type f ! -path "*/.backups/*" -print0 2>/dev/null)
+# Print the description recorded for a "folder/name" key, or nothing. Compared
+# with a byte-exact bash string test: passing the key to awk -v would
+# escape-process a backslash in a filename and silently mismatch the lookup.
+index_lookup() {
+ local file="$1" want="$2" key val
+ while IFS=$'\t' read -r key val; do
+ if [ "$key" = "$want" ]; then
+ printf '%s\n' "$val"
+ return 0
+ fi
+ done <"$file"
+ return 0
+}
- if [[ "$profile_file_count" -gt 0 ]]; then
- (( profiles_used++ )) || true
+stage_commands() {
+ local src_dir="$BASE_DIR/commands/agent-os"
+ if [ ! -d "$src_dir" ]; then
+ ico_warn "no commands directory in base installation; skipping commands"
+ return 0
+ fi
+ local f base dest n=0 list="$WORK_DIR/find.commands"
+ if ! find "$src_dir" \( -type f -name '*.md' -o -type l \) -print0 >"$list"; then
+ ico_die "failed to scan commands directory"
+ fi
+ while IFS= read -r -d '' f; do
+ if [ -L "$f" ]; then
+ ico_die "command source is a symlink: $f"
fi
- done <<< "$INHERITANCE_CHAIN"
+ base=${f##*/}
+ dest="$STAGE/.claude/commands/agent-os/$base"
+ mkdir -p "$(dirname "$dest")"
+ cp -p "$f" "$dest"
+ n=$((n + 1))
+ done <"$list"
+ ico_info "staged $n command(s)"
+}
- # Count profiles in chain to determine if we show sources
- local chain_count=$(echo "$INHERITANCE_CHAIN" | grep -c .)
+collect_paths() {
+ local raw="$WORK_DIR/paths.raw" abs list
+ : >"$raw"
+ if [ -d "$STAGE/agent-os/standards" ]; then
+ list="$WORK_DIR/find.coll.stds"
+ if ! find "$STAGE/agent-os/standards" -type f -print0 >"$list"; then
+ ico_die "failed to scan staged standards"
+ fi
+ while IFS= read -r -d '' abs; do
+ printf '%s\n' "${abs#"$STAGE/"}"
+ done <"$list" >>"$raw"
+ fi
+ if [ -d "$STAGE/.claude/commands/agent-os" ]; then
+ list="$WORK_DIR/find.coll.cmds"
+ if ! find "$STAGE/.claude/commands/agent-os" -type f -print0 >"$list"; then
+ ico_die "failed to scan staged commands"
+ fi
+ while IFS= read -r -d '' abs; do
+ printf '%s\n' "${abs#"$STAGE/"}"
+ done <"$list" >>"$raw"
+ fi
+ TARGET_PATHS=$(sort "$raw")
+}
- # Count and display
- local total_count=$(wc -l < "$sources_file" | tr -d ' ')
+# -----------------------------------------------------------------------------
+# Preflight and plan
+# -----------------------------------------------------------------------------
- if [[ "$total_count" -gt 0 ]]; then
- # Sort and display files - only show source if inheritance is present
- sort "$sources_file" | while IFS='|' read -r filepath profile; do
- if [[ "$chain_count" -gt 1 ]]; then
- echo " $filepath (from $profile)"
+preflight() {
+ CONFLICTS=""
+ EXISTING=0
+ local rel dest mh ch
+ ico_assert_dest_safe "$PROJECT_DIR" "$MANIFEST_REL"
+ while IFS= read -r rel; do
+ [ -n "$rel" ] || continue
+ if ! ico_path_owned "$rel"; then
+ ico_die "refusing to write outside owned prefixes: $rel"
+ fi
+ ico_assert_dest_safe "$PROJECT_DIR" "$rel"
+ dest="$PROJECT_DIR/$rel"
+ if [ -e "$dest" ]; then
+ EXISTING=$((EXISTING + 1))
+ mh=""
+ if [ -f "$MANIFEST" ]; then
+ mh=$(ico_manifest_hash "$MANIFEST" "$rel") || mh=""
+ fi
+ if [ -z "$mh" ]; then
+ CONFLICTS="${CONFLICTS}unmanaged: $rel"$'\n'
else
- echo " $filepath"
+ ch=$(ico_hash_file "$dest")
+ if [ "$ch" != "$mh" ]; then
+ CONFLICTS="${CONFLICTS}modified: $rel"$'\n'
+ fi
fi
- done
-
- if [[ "$profiles_used" -gt 1 ]]; then
- print_success "Installed $total_count standards files (from $profiles_used profiles)"
- else
- print_success "Installed $total_count standards files"
fi
- else
- print_success "No standards to install (profile is empty)"
- fi
+ done <<<"$TARGET_PATHS"
}
-create_index() {
- echo ""
- print_status "Updating standards index..."
-
- local standards_dir="$PROJECT_DIR/agent-os/standards"
- local index_file="$standards_dir/index.yml"
- local temp_file="$standards_dir/.index_temp.yml"
- local old_index=""
+target_has_path() {
+ printf '%s\n' "$TARGET_PATHS" | grep -Fqx -- "$1"
+}
- # Save existing index content for description lookup
- if [[ -f "$index_file" ]]; then
- old_index=$(cat "$index_file")
+# Build the new manifest. Every previously tracked row that this run does not
+# rewrite is retained (ownership of stale files is never silently dropped), and
+# fresh hashes are recorded for everything staged this run.
+build_manifest() {
+ local out="$STAGE/$MANIFEST_REL"
+ local rows="$WORK_DIR/manifest.rows"
+ mkdir -p "$(dirname "$out")"
+ : >"$rows"
+
+ local hash path rel h
+ if [ -f "$MANIFEST" ]; then
+ while IFS=$'\t' read -r hash path; do
+ [ -n "$hash" ] || continue
+ if target_has_path "$path"; then
+ continue
+ fi
+ printf '%s\t%s\n' "$hash" "$path" >>"$rows"
+ done < <(ico_manifest_each "$MANIFEST")
fi
- local entry_count=0
- local new_count=0
-
- # Start fresh
- echo "# Agent OS Standards Index" > "$temp_file"
- echo "" >> "$temp_file"
+ while IFS= read -r rel; do
+ [ -n "$rel" ] || continue
+ h=$(ico_hash_file "$STAGE/$rel") || ico_die "failed to hash staged file: $rel"
+ if [ -z "$h" ]; then
+ ico_die "empty hash for staged file: $rel"
+ fi
+ printf '%s\t%s\n' "$h" "$rel" >>"$rows"
+ done <<<"$TARGET_PATHS"
- # Helper to get existing description from old index
- # Looks for pattern: folder:\n filename:\n description: ...
- get_existing_description() {
- local folder="$1"
- local filename="$2"
+ {
+ printf '%s\n' "$ICO_MANIFEST_HEADER"
+ sort "$rows"
+ } >"$out"
+}
- if [[ -z "$old_index" ]]; then
- return 1
+print_plan() {
+ ico_info "profile '$PROFILE' -> target '$TARGET' (commands-only: $COMMANDS_ONLY, dry-run: $DRY_RUN)"
+ local rel dest state
+ while IFS= read -r rel; do
+ [ -n "$rel" ] || continue
+ dest="$PROJECT_DIR/$rel"
+ if [ -e "$dest" ]; then
+ state="update"
+ else
+ state="create"
fi
+ printf ' %-6s %s\n' "$state" "$rel"
+ done <<<"$TARGET_PATHS"
+ printf ' %-6s %s\n' "update" "$MANIFEST_REL"
+ if [ -n "$CONFLICTS" ]; then
+ ico_warn "conflicts detected:"
+ printf '%s' "$CONFLICTS" | sed 's/^/ /' >&2
+ fi
+}
- # Use awk to find the description for this folder/file combo
- local desc=$(echo "$old_index" | awk -v folder="$folder" -v file="$filename" '
- $0 ~ "^"folder":$" { in_folder=1; next }
- /^[a-zA-Z0-9_-]+:$/ { in_folder=0 }
- in_folder && $0 ~ "^ "file":$" { in_file=1; next }
- in_folder && /^ [a-zA-Z0-9_-]+:$/ { in_file=0 }
- in_folder && in_file && /description:/ {
- sub(/^[[:space:]]*description:[[:space:]]*/, "")
- print
- exit
- }
- ')
+confirm_or_abort() {
+ if [ "$ASSUME_YES" = true ]; then
+ return 0
+ fi
+ if [ ! -t 0 ]; then
+ return 0
+ fi
+ if [ "$EXISTING" -eq 0 ]; then
+ return 0
+ fi
+ printf 'Existing Agent OS files will be updated. Continue? (y/N) ' >&2
+ local reply=""
+ if ! read -r reply; then
+ reply=""
+ fi
+ case "$reply" in
+ [Yy]*) return 0 ;;
+ *) ico_die "installation cancelled" ;;
+ esac
+}
- if [[ -n "$desc" && "$desc" != "Needs description - run /index-standards" ]]; then
- echo "$desc"
- return 0
- fi
- return 1
- }
+backup_conflicts() {
+ [ -n "$CONFLICTS" ] || return 0
+ local dir line rel n=0
+ dir=$(ico_make_backup_dir "$PROJECT_DIR" "$(date -u +%Y%m%dT%H%M%SZ)")
+ while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ rel=${line#*: }
+ mkdir -p "$dir/$(dirname "$rel")"
+ cp -p "$PROJECT_DIR/$rel" "$dir/$rel"
+ n=$((n + 1))
+ done <<<"$CONFLICTS"
+ ico_warn "backed up $n conflicting file(s) to ${dir#"$PROJECT_DIR"/}/"
+}
- # First, handle root-level .md files (not in subfolders)
- local root_files=$(find "$standards_dir" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort)
- if [[ -n "$root_files" ]]; then
- echo "root:" >> "$temp_file"
- while IFS= read -r file; do
- local filename=$(basename "$file" .md)
- local desc=$(get_existing_description "root" "$filename")
- if [[ -z "$desc" ]]; then
- desc="Needs description - run /index-standards"
- (( new_count++ )) || true
- fi
- echo " $filename:" >> "$temp_file"
- echo " description: $desc" >> "$temp_file"
- (( entry_count++ )) || true
- done <<< "$root_files"
- echo "" >> "$temp_file"
- fi
-
- # Then handle files in subfolders
- local folders=$(find "$standards_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
- for folder in $folders; do
- local folder_name=$(basename "$folder")
- local md_files=$(find "$folder" -name "*.md" -type f 2>/dev/null | sort)
-
- if [[ -n "$md_files" ]]; then
- echo "$folder_name:" >> "$temp_file"
- while IFS= read -r file; do
- local filename=$(basename "$file" .md)
- local desc=$(get_existing_description "$folder_name" "$filename")
- if [[ -z "$desc" ]]; then
- desc="Needs description - run /index-standards"
- (( new_count++ )) || true
- fi
- echo " $filename:" >> "$temp_file"
- echo " description: $desc" >> "$temp_file"
- (( entry_count++ )) || true
- done <<< "$md_files"
- echo "" >> "$temp_file"
+# -----------------------------------------------------------------------------
+# Commit with rollback
+# -----------------------------------------------------------------------------
+
+# Record the directories that do not yet exist along a destination path, so a
+# failed commit can remove directories it created.
+record_created_dirs() {
+ local target=$1 root=$PROJECT_DIR rel cur part rest
+ case "$target" in
+ "$root"/*) rel=${target#"$root"/} ;;
+ *) return 0 ;;
+ esac
+ cur=$root
+ rest=$rel
+ while [ -n "$rest" ]; do
+ case "$rest" in
+ */*) part=${rest%%/*}; rest=${rest#*/} ;;
+ *) part=$rest; rest="" ;;
+ esac
+ cur="$cur/$part"
+ if [ ! -e "$cur" ]; then
+ CREATED_DIRS[CREATED_DIR_COUNT]="$cur"
+ CREATED_DIR_COUNT=$((CREATED_DIR_COUNT + 1))
fi
done
+}
- # Move temp file to final location
- mv "$temp_file" "$index_file"
-
- if [[ "$entry_count" -gt 0 ]]; then
- if [[ "$new_count" -gt 0 ]]; then
- print_success "Updated index.yml ($entry_count entries, $new_count new)"
- else
- print_success "Updated index.yml ($entry_count entries)"
+snapshot_path() {
+ local rel=$1
+ local dest="$PROJECT_DIR/$rel" idx=$SNAP_COUNT backup
+ if [ -e "$dest" ] || [ -L "$dest" ]; then
+ mkdir -p "$WORK_DIR/rollback"
+ backup="$WORK_DIR/rollback/$idx"
+ cp -p "$dest" "$backup" || return 1
+ if [ ! -f "$backup" ]; then
+ return 1
fi
+ SNAP_HAD[idx]="1"
+ SNAP_BACKUPS[idx]="$backup"
else
- print_success "Created index.yml (no standards to index)"
+ SNAP_HAD[idx]="0"
+ SNAP_BACKUPS[idx]=""
fi
+ SNAP_PATHS[idx]="$rel"
+ SNAP_COUNT=$((SNAP_COUNT + 1))
}
-install_commands() {
- echo ""
- print_status "Installing commands..."
-
- local commands_source="$BASE_DIR/commands/agent-os"
- local commands_dest="$PROJECT_DIR/.claude/commands/agent-os"
-
- if [[ ! -d "$commands_source" ]]; then
- print_warning "No commands found in base installation"
- return
+# Write one file atomically: stage a sibling temp file on the same filesystem,
+# snapshot first, then rename it into place.
+commit_one() {
+ local rel=$1 staged=$2
+ local dest="$PROJECT_DIR/$rel" d tmp
+ d=$(dirname "$dest")
+ record_created_dirs "$d"
+ mkdir -p "$d"
+ if ! snapshot_path "$rel"; then
+ ico_die "could not snapshot $rel before writing"
fi
+ ROLLBACK_ARMED=true
+ ico_assert_dest_safe "$PROJECT_DIR" "$rel"
+ tmp=$(mktemp "$d/.agent-os-staging.XXXXXX") || ico_die "could not create staging file for $rel"
+ COMMIT_TMP="$tmp"
+ cp -p "$staged" "$tmp"
+ mv -f "$tmp" "$dest"
+ COMMIT_TMP=""
+}
- ensure_dir "$commands_dest"
+rollback_commit() {
+ local i=0 p b had
+ while [ "$i" -lt "$SNAP_COUNT" ]; do
+ p=${SNAP_PATHS[$i]}
+ b=${SNAP_BACKUPS[$i]}
+ had=${SNAP_HAD[$i]}
+ if [ "$had" = "1" ]; then
+ cp -p "$b" "$PROJECT_DIR/$p" || ico_warn "rollback: could not restore $p"
+ else
+ rm -f "$PROJECT_DIR/$p" || ico_warn "rollback: could not remove $p"
+ fi
+ i=$((i + 1))
+ done
+ SNAP_PATHS=(); SNAP_BACKUPS=(); SNAP_HAD=(); SNAP_COUNT=0
+}
- local count=0
- for file in "$commands_source"/*.md; do
- if [[ -f "$file" ]]; then
- cp "$file" "$commands_dest/"
- (( count++ )) || true
+# Remove, deepest first (reverse recording order), any directories created
+# during a failed commit. An indexed array plus an explicit counter (Bash 3.2
+# nounset-safe) carries directory names containing spaces or a literal "|"
+# through untouched, unlike a pipe- or whitespace-delimited list.
+rollback_created_dirs() {
+ [ "$CREATED_DIR_COUNT" -gt 0 ] || return 0
+ local i=$CREATED_DIR_COUNT d
+ while [ "$i" -gt 0 ]; do
+ i=$((i - 1))
+ d=${CREATED_DIRS[$i]}
+ [ -n "$d" ] || continue
+ if rmdir "$d" 2>/dev/null; then
+ ico_info "rollback: removed created directory ${d#"$PROJECT_DIR"/}"
fi
done
+ CREATED_DIRS=()
+ CREATED_DIR_COUNT=0
+}
- if [[ "$count" -gt 0 ]]; then
- print_success "Installed $count commands to .claude/commands/agent-os/"
- else
- print_warning "No command files found"
+commit_all() {
+ local rel
+ while IFS= read -r rel; do
+ [ -n "$rel" ] || continue
+ commit_one "$rel" "$STAGE/$rel"
+ commit_count=$((commit_count + 1))
+ if [ -n "${AGENT_OS_INSTALL_FAIL_AFTER:-}" ] && [ "$commit_count" -ge "$AGENT_OS_INSTALL_FAIL_AFTER" ]; then
+ ico_die "test hook: injected failure after $commit_count committed file(s)"
+ fi
+ done <<<"$TARGET_PATHS"
+ commit_one "$MANIFEST_REL" "$STAGE/$MANIFEST_REL"
+ ROLLBACK_ARMED=false
+}
+
+cleanup_workdir() {
+ if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then
+ rm -rf "$WORK_DIR"
fi
+ return 0
+}
+
+on_exit() {
+ local rc=$?
+ trap - EXIT
+ if [ "$ROLLBACK_ARMED" = true ]; then
+ ico_warn "installation failed; rolling back partial changes"
+ rollback_commit
+ rollback_created_dirs
+ fi
+ if [ -n "$COMMIT_TMP" ]; then
+ rm -f "$COMMIT_TMP"
+ fi
+ cleanup_workdir
+ exit "$rc"
+}
+
+on_signal() {
+ ROLLBACK_ARMED=true
+ exit 130
}
# -----------------------------------------------------------------------------
-# Main Execution
+# Main
# -----------------------------------------------------------------------------
main() {
- print_section "Agent OS Project Installation"
-
- # Parse arguments
parse_arguments "$@"
-
- # Validations
- validate_not_in_base
+ validate_options
validate_base_installation
+ validate_source_roots
+ resolve_project
+ load_profile_chain
+ setup_workdir
+
+ if [ "$VERBOSE" = true ]; then
+ ico_info "base installation: $BASE_DIR"
+ ico_info "project directory: $PROJECT_DIR"
+ ico_info "inheritance chain: $(printf '%s' "$CHAIN" | tr '\n' ' ')"
+ fi
- # Load configuration
- load_configuration
-
- # Show configuration
- echo ""
- print_status "Configuration:"
-
- # Display inheritance chain
- local chain_depth=0
- local chain_display=""
- # Read chain in reverse order (from requested profile back to base) for display
- local reversed_chain=$(echo "$INHERITANCE_CHAIN" | awk '{a[NR]=$0} END{for(i=NR;i>=1;i--)print a[i]}')
- while IFS= read -r profile_name; do
- [[ -z "$profile_name" ]] && continue
- if [[ "$chain_depth" -eq 0 ]]; then
- chain_display=" Profile: $profile_name"
- else
- local indent=""
- for ((i=0; i&2
+ ico_err "re-run with --force to back them up and overwrite"
+ exit 1
+ fi
- echo ""
- print_success "Agent OS installed successfully!"
- echo ""
- echo "Next steps:"
- echo " 1. Run /discover-standards to extract patterns from your codebase"
- echo " 2. Run /inject-standards to inject standards into your context"
- echo ""
+ confirm_or_abort
+ if [ "$FORCE" = true ]; then
+ backup_conflicts
+ fi
+ commit_all
+ ico_ok "installed $(printf '%s\n' "$TARGET_PATHS" | wc -l | tr -d ' ') file(s); manifest: $MANIFEST_REL"
}
-# Run main function
+trap on_exit EXIT
+trap on_signal INT TERM HUP
+
main "$@"
diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh
new file mode 100755
index 00000000..026a221e
--- /dev/null
+++ b/scripts/uninstall.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+#
+# Agent OS uninstaller.
+#
+# Removes only the files that are listed in agent-os/install-manifest.tsv and
+# are still unchanged. Modified files are retained by default and reported as a
+# non-zero exit status; with --force they are backed up and removed. Symlinks are
+# never followed or deleted. Every managed path is preflighted (including its
+# parent directories and the manifest itself) before any file is removed, so a
+# redirected parent component cannot make the uninstaller delete outside files.
+
+set -Eeuo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
+
+# shellcheck source=scripts/installer-common.sh
+. "$SCRIPT_DIR/installer-common.sh"
+
+PROGRAM=$(basename "$0")
+PROJECT_DIR=""
+FORCE=false
+
+# Global so the EXIT trap can still see it after main() returns.
+UNINSTALL_TMP=""
+
+show_help() {
+ cat < Project directory to clean (default: current directory)
+ --force Back up and remove files that drifted from the manifest
+ -h, --help Show this help message
+EOF
+ exit 0
+}
+
+parse_arguments() {
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --project-dir)
+ if [ $# -lt 2 ] || [ -z "${2:-}" ] || [ "${2#-}" != "$2" ]; then
+ ico_die "option --project-dir requires a value"
+ fi
+ PROJECT_DIR="$2"
+ shift 2
+ ;;
+ --force)
+ FORCE=true
+ shift
+ ;;
+ -h|--help)
+ show_help
+ ;;
+ -*)
+ ico_die "unknown option: $1"
+ ;;
+ *)
+ ico_die "unexpected argument: $1"
+ ;;
+ esac
+ done
+}
+
+cleanup_tmp() {
+ if [ -n "$UNINSTALL_TMP" ] && [ -d "$UNINSTALL_TMP" ]; then
+ rm -rf "$UNINSTALL_TMP"
+ fi
+ return 0
+}
+
+main() {
+ parse_arguments "$@"
+ PROJECT_DIR=$(ico_resolve_project_dir "${PROJECT_DIR:-$PWD}")
+
+ local manifest="$PROJECT_DIR/$ICO_MANIFEST_REL"
+ if [ ! -e "$manifest" ] && [ ! -L "$manifest" ]; then
+ ico_err "no install manifest found at $ICO_MANIFEST_REL (nothing to uninstall)"
+ exit 1
+ fi
+
+ # Full preflight (manifest + every path and its parents) before any mutation.
+ ico_preflight_managed_paths "$PROJECT_DIR" "$manifest"
+
+ local keep backup_dir="" drift=0
+ UNINSTALL_TMP=$(mktemp -d "${TMPDIR:-/tmp}/agent-os-uninstall.XXXXXX")
+ trap cleanup_tmp EXIT
+ keep="$UNINSTALL_TMP/keep.tsv"
+ : >"$keep"
+
+ if [ "$FORCE" = true ]; then
+ backup_dir=$(ico_make_backup_dir "$PROJECT_DIR" "$(date -u +%Y%m%dT%H%M%SZ)-uninstall")
+ fi
+
+ local removed=0 retained=0 missing=0 hash path dest cur
+ while IFS=$'\t' read -r hash path; do
+ [ -n "$hash" ] || continue
+ dest="$PROJECT_DIR/$path"
+ if [ -L "$dest" ]; then
+ ico_warn "retaining drifted (symlink; never followed): $path"
+ printf '%s\t%s\n' "$hash" "$path" >>"$keep"
+ retained=$((retained + 1)); drift=$((drift + 1))
+ elif [ ! -e "$dest" ]; then
+ missing=$((missing + 1))
+ elif [ ! -f "$dest" ]; then
+ ico_warn "retaining drifted (not a regular file): $path"
+ printf '%s\t%s\n' "$hash" "$path" >>"$keep"
+ retained=$((retained + 1)); drift=$((drift + 1))
+ else
+ cur=$(ico_hash_file "$dest")
+ if [ "$cur" = "$hash" ]; then
+ if [ -n "$backup_dir" ]; then
+ mkdir -p "$backup_dir/$(dirname "$path")"
+ cp -p "$dest" "$backup_dir/$path"
+ fi
+ rm -f "$dest"
+ removed=$((removed + 1))
+ elif [ "$FORCE" = true ]; then
+ ico_warn "backing up and removing drifted file: $path"
+ mkdir -p "$backup_dir/$(dirname "$path")"
+ cp -p "$dest" "$backup_dir/$path"
+ rm -f "$dest"
+ removed=$((removed + 1))
+ else
+ ico_warn "retaining drifted (modified): $path"
+ printf '%s\t%s\n' "$hash" "$path" >>"$keep"
+ retained=$((retained + 1)); drift=$((drift + 1))
+ fi
+ fi
+ done < <(ico_manifest_each "$manifest")
+
+ if [ -s "$keep" ]; then
+ {
+ printf '%s\n' "$ICO_MANIFEST_HEADER"
+ sort "$keep"
+ } >"$manifest"
+ ico_warn "retained $retained tracked file(s); manifest rewritten at $ICO_MANIFEST_REL"
+ else
+ rm -f "$manifest"
+ fi
+
+ ico_ok "removed $removed file(s), skipped $missing missing, retained $retained drifted"
+ if [ -n "$backup_dir" ]; then
+ ico_ok "backups written to ${backup_dir#"$PROJECT_DIR"/}/"
+ fi
+ if [ "$drift" -gt 0 ]; then
+ exit 1
+ fi
+}
+
+main "$@"
diff --git a/tests/installer.sh b/tests/installer.sh
new file mode 100755
index 00000000..8ef1e6f2
--- /dev/null
+++ b/tests/installer.sh
@@ -0,0 +1,1074 @@
+#!/usr/bin/env bash
+#
+# Functional tests for the hardened Agent OS installer family:
+# scripts/project-install.sh, scripts/doctor.sh and scripts/uninstall.sh.
+#
+# Portable across macOS Bash 3.2 and Linux Bash 4/5. Each test builds its own
+# throwaway base install and project under a temp directory.
+
+set -Eeuo pipefail
+LC_ALL=C
+export LC_ALL
+
+REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
+SCRIPTS="$REPO/scripts"
+
+ROOT=$(mktemp -d "${TMPDIR:-/tmp}/agent-os-tests.XXXXXX")
+cleanup_root() {
+ if [ -n "$ROOT" ] && [ -d "$ROOT" ]; then
+ rm -rf "$ROOT"
+ fi
+ return 0
+}
+trap cleanup_root EXIT
+
+PASS=0
+FAIL=0
+
+pass() { PASS=$((PASS + 1)); printf 'ok %s\n' "$1"; }
+fail() { FAIL=$((FAIL + 1)); printf 'FAIL %s\n' "$1"; }
+
+# ---------------------------------------------------------------------------
+# Assertions (return non-zero on failure so the test subshell aborts)
+# ---------------------------------------------------------------------------
+
+eq() { [ "$1" = "$2" ] || { printf ' eq failed: [%s] != [%s]\n' "$1" "$2"; return 1; }; }
+exists() { [ -e "$1" ] || { printf ' expected to exist: %s\n' "$1"; return 1; }; }
+absent() { [ ! -e "$1" ] || { printf ' expected to be absent: %s\n' "$1"; return 1; }; }
+file_eq() { cmp -s "$1" "$2" || { printf ' files differ: %s vs %s\n' "$1" "$2"; return 1; }; }
+contains() { printf '%s' "$1" | grep -qF -- "$2" || { printf ' expected to contain: %s\n' "$2"; return 1; }; }
+
+expect_fail() {
+ if "$@" >/dev/null 2>&1; then
+ printf ' expected failure but command succeeded: %s\n' "$*"
+ return 1
+ fi
+ return 0
+}
+
+# ---------------------------------------------------------------------------
+# Harness
+#
+# Each test runs in its own subshell with errexit active, and its status is
+# captured without an enclosing conditional, so a failing intermediate
+# assertion aborts and fails the test instead of being silently ignored.
+# ---------------------------------------------------------------------------
+
+t() {
+ local name=$1 out rc
+ shift
+ set +e
+ out=$( ( set -e; "$@" ) 2>&1 )
+ rc=$?
+ set -e
+ if [ "$rc" -eq 0 ]; then
+ pass "$name"
+ else
+ fail "$name"
+ printf '%s\n' "$out" | sed 's/^/ /'
+ fi
+}
+
+new_env() {
+ NAME=$1
+ BASE="$ROOT/$NAME/base"
+ PROJ="$ROOT/$NAME/proj"
+ mkdir -p "$BASE" "$PROJ"
+ cp -R "$SCRIPTS" "$BASE/scripts"
+ cp -R "$REPO/profiles" "$BASE/profiles"
+ cp -R "$REPO/commands" "$BASE/commands"
+ cp "$REPO/config.yml" "$BASE/config.yml"
+}
+
+# Every script under test is launched with "$BASH" (the interpreter running the
+# harness), not a bare shebang lookup, so `/bin/bash tests/installer.sh` really
+# exercises every script under that same Bash (3.2 on macOS).
+install() { "$BASH" "$BASE/scripts/project-install.sh" --project-dir "$PROJ" "$@"; }
+doctor() { "$BASH" "$BASE/scripts/doctor.sh" --project-dir "$PROJ" "$@"; }
+uninstall() { "$BASH" "$BASE/scripts/uninstall.sh" --project-dir "$PROJ" "$@"; }
+
+# Deterministic content fingerprint of a project tree.
+snapdir() {
+ (
+ cd "$1" || return 1
+ find . -type f | LC_ALL=C sort | while IFS= read -r f; do
+ printf '%s %s\n' "$f" "$(cksum <"$f")"
+ done
+ )
+}
+
+backup_has() {
+ [ -d "$1" ] || return 1
+ [ -n "$(find "$1" -name "$2" 2>/dev/null)" ]
+}
+
+# SHA-256 of a file's *content*, fed on stdin and portable across
+# sha256sum/shasum/openssl, so it can be compared against the installer's
+# recorded hash regardless of which tool is present.
+content_sha256() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum <"$1" | cut -d' ' -f1
+ elif command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 <"$1" | cut -d' ' -f1
+ elif command -v openssl >/dev/null 2>&1; then
+ openssl dgst -sha256 <"$1" | sed 's/.* //'
+ else
+ return 1
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Harness self-test: without this the suite could report a false pass if the
+# runner ever stopped propagating intermediate failures.
+# ---------------------------------------------------------------------------
+
+_selftest_bad() { false; true; }
+_selftest_good() { true; }
+
+test_harness_selfcheck() {
+ local f0=$FAIL p0=$PASS
+ t harness_probe_should_fail _selftest_bad
+ if [ "$FAIL" -ne $((f0 + 1)) ]; then
+ printf ' harness masked a failure followed by success\n'
+ return 1
+ fi
+ t harness_probe_should_pass _selftest_good
+ if [ "$PASS" -ne $((p0 + 1)) ]; then
+ printf ' harness failed to record a passing test\n'
+ return 1
+ fi
+ FAIL=$f0
+ PASS=$p0
+ return 0
+}
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+test_flat_standard_install() {
+ new_env flat
+ install --yes
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+ file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/default/global/tech-stack.md"
+ exists "$PROJ/agent-os/standards/index.yml"
+ exists "$PROJ/.claude/commands/agent-os/discover-standards.md"
+ exists "$PROJ/agent-os/install-manifest.tsv"
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" "global:"
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" "tech-stack:"
+ contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md"
+ # manifest itself is bookkeeping, not a managed file
+ if grep -q "install-manifest" "$PROJ/agent-os/install-manifest.tsv"; then
+ printf ' manifest must not list itself\n'
+ return 1
+ fi
+ # no invented skills / optimizer / router artefacts
+ absent "$PROJ/agent-os/skills"
+ [ -z "$(find "$PROJ" -name 'agent-os-*' 2>/dev/null)" ] || { printf ' unexpected agent-os-* artefacts\n'; return 1; }
+}
+
+test_index_preservation_and_inheritance() {
+ new_env idx
+ mkdir -p "$BASE/profiles/child"
+ printf '# Extra standard\n' >"$BASE/profiles/child/extra.md"
+ cat >"$BASE/profiles/child/index.yml" <<'YAML'
+root:
+ extra:
+ description: Curated description
+ tags:
+ - one
+ - two
+YAML
+ cat >>"$BASE/config.yml" <<'YAML'
+
+profiles:
+ child:
+ inherits_from: default
+YAML
+ install --profile child --yes
+ # supplied index is copied byte-for-byte, structured metadata included
+ file_eq "$PROJ/agent-os/standards/index.yml" "$BASE/profiles/child/index.yml"
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" "tags:"
+ exists "$PROJ/agent-os/standards/extra.md"
+ # inherited standard from the parent profile is present too
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+}
+
+test_index_description_preservation() {
+ new_env idxdesc
+ install --yes
+ cat >"$PROJ/agent-os/standards/index.yml" <<'YAML'
+# Agent OS Standards Index
+
+global:
+ tech-stack:
+ description: Custom curated description
+YAML
+ install --force --yes
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" "Custom curated description"
+}
+
+test_index_quoting_special_names() {
+ new_env idxquote
+ mkdir -p "$BASE/profiles/default/weird"
+ printf '# Colon standard\n' >"$BASE/profiles/default/weird/colon:name.md"
+ printf '# Hash standard\n' >"$BASE/profiles/default/weird/hash#name.md"
+ install --yes
+ exists "$PROJ/agent-os/standards/weird/colon:name.md"
+ exists "$PROJ/agent-os/standards/weird/hash#name.md"
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"colon:name":'
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"hash#name":'
+}
+
+test_option_errors() {
+ new_env opts
+ expect_fail install --target bogus
+ expect_fail install --profile
+ expect_fail install --project-dir
+ expect_fail install --unknown
+ expect_fail install --profile ../evil
+ expect_fail install --project-dir "$ROOT/does-not-exist"
+ expect_fail install extra-arg
+ absent "$PROJ/agent-os"
+}
+
+test_target_none() {
+ new_env none
+ install --target none --yes
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+ absent "$PROJ/.claude"
+ # --commands-only with no commands target is a rejected no-op, not a silent success
+ new_env noneco
+ expect_fail install --target none --commands-only --yes
+ absent "$PROJ/agent-os"
+}
+
+test_verbose_and_self_install_guard() {
+ new_env verbose
+ install --verbose --yes
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+ # installing into the base installation itself is refused
+ expect_fail "$BASH" "$BASE/scripts/project-install.sh" --project-dir "$BASE" --yes
+ absent "$BASE/agent-os"
+}
+
+test_dry_run_side_effect_free() {
+ new_env dry
+ install --dry-run --yes
+ absent "$PROJ/agent-os"
+ absent "$PROJ/.claude"
+
+ install --yes
+ before=$(snapdir "$PROJ")
+ install --dry-run --yes
+ after=$(snapdir "$PROJ")
+ eq "$after" "$before"
+}
+
+test_commands_only_ownership() {
+ new_env co
+ install --yes
+ printf 'user edit\n' >>"$PROJ/agent-os/standards/global/tech-stack.md"
+ # commands-only must not touch (or abort on) the drifted standards
+ install --commands-only --yes
+ contains "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "user edit"
+ contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md"
+ exists "$PROJ/.claude/commands/agent-os/discover-standards.md"
+}
+
+test_unchanged_updates() {
+ new_env upd
+ install --yes
+ before=$(snapdir "$PROJ")
+ install --yes
+ eq "$(snapdir "$PROJ")" "$before"
+ printf '\nnew body\n' >>"$BASE/profiles/default/global/tech-stack.md"
+ install --yes
+ file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/default/global/tech-stack.md"
+}
+
+test_stale_rows_retained() {
+ new_env stale
+ install --yes
+ # a managed standard disappears from the source tree
+ rm -f "$BASE/profiles/default/global/tech-stack.md"
+ install --yes
+ # the on-disk file (and its manifest row) is retained, not silently dropped
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+ contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md"
+ doctor
+}
+
+test_unmanaged_and_drift_protection() {
+ new_env prot
+ # unmanaged file at a managed path, no manifest yet
+ mkdir -p "$PROJ/agent-os/standards/global"
+ printf 'user file\n' >"$PROJ/agent-os/standards/global/tech-stack.md"
+ expect_fail install --yes
+ eq "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "user file"
+ # --yes alone must not override the protection
+ expect_fail install --yes
+ # --force backs up and overwrites
+ install --force --yes
+ file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/default/global/tech-stack.md"
+ backup_has "$PROJ/agent-os/.backups" "tech-stack.md"
+
+ # managed file modified after install
+ new_env prot2
+ install --yes
+ printf 'tampered\n' >>"$PROJ/agent-os/standards/global/tech-stack.md"
+ expect_fail install --yes
+ install --force --yes
+ bk=$(find "$PROJ/agent-os/.backups" -name tech-stack.md | head -n 1)
+ contains "$(cat "$bk")" "tampered"
+}
+
+test_backup_unique_dirs() {
+ new_env bkp
+ mkdir -p "$PROJ/agent-os/standards/global"
+ printf 'user file\n' >"$PROJ/agent-os/standards/global/tech-stack.md"
+ install --force --yes
+ printf 'user file 2\n' >"$PROJ/agent-os/standards/global/tech-stack.md"
+ install --force --yes
+ # two backups in the same second must not overwrite each other
+ count=$(find "$PROJ/agent-os/.backups" -name tech-stack.md | wc -l | tr -d ' ')
+ eq "$count" "2"
+}
+
+test_symlink_rejection() {
+ # source leaf symlink in the profile
+ new_env symsrc
+ ln -s "$BASE/profiles/default/global/tech-stack.md" "$BASE/profiles/default/evil.md"
+ expect_fail install --yes
+ absent "$PROJ/agent-os/standards/evil.md"
+
+ # destination symlink for agent-os/standards
+ new_env symdst
+ mkdir -p "$PROJ/agent-os" "$ROOT/elsewhere"
+ ln -s "$ROOT/elsewhere" "$PROJ/agent-os/standards"
+ expect_fail install --yes
+ absent "$ROOT/elsewhere/global/tech-stack.md"
+
+ # symlinked manifest
+ new_env symmanifest
+ mkdir -p "$PROJ/agent-os"
+ ln -s "$ROOT/missing-manifest.tsv" "$PROJ/agent-os/install-manifest.tsv"
+ expect_fail install --yes
+ expect_fail doctor
+}
+
+test_source_root_symlinks() {
+ new_env rootsymprofiles
+ mv "$BASE/profiles" "$BASE/profiles.real"
+ ln -s "$BASE/profiles.real" "$BASE/profiles"
+ expect_fail install --yes
+
+ new_env rootsymcommands
+ mv "$BASE/commands" "$BASE/commands.real"
+ ln -s "$BASE/commands.real" "$BASE/commands"
+ expect_fail install --yes
+}
+
+test_bad_manifest() {
+ ZERO_HASH="0000000000000000000000000000000000000000000000000000000000000000"
+ HEADER="# agent-os install manifest v1"
+
+ # traversal path
+ new_env trav
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "../escape"; } >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+
+ # path outside the owned prefixes
+ new_env trav2
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/product/evil.md"; } >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+
+ # extra tab in a row
+ new_env trav3
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md" "extra"; } >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+
+ # non-hex hash
+ new_env trav4
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "nothex" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+
+ # wrong header
+ new_env trav5
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "# wrong header"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+
+ # empty manifest (no header)
+ new_env trav6
+ mkdir -p "$PROJ/agent-os"
+ : >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+
+ # duplicate rows
+ new_env trav7
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv"
+ expect_fail doctor
+}
+
+test_filenames_with_spaces() {
+ new_env space
+ mkdir -p "$BASE/profiles/default/global"
+ printf '# Spaced standard\n' >"$BASE/profiles/default/global/my standard.md"
+ install --yes
+ exists "$PROJ/agent-os/standards/global/my standard.md"
+ contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/my standard.md"
+ doctor
+ uninstall
+ absent "$PROJ/agent-os/standards/global/my standard.md"
+}
+
+test_inheritance_guards() {
+ # circular inheritance
+ new_env inhcycle
+ mkdir -p "$BASE/profiles/a" "$BASE/profiles/b"
+ cat >>"$BASE/config.yml" <<'YAML'
+
+profiles:
+ a:
+ inherits_from: b
+ b:
+ inherits_from: a
+YAML
+ expect_fail install --profile a --yes
+
+ # invalid parent name
+ new_env inhbad
+ mkdir -p "$BASE/profiles/a"
+ cat >>"$BASE/config.yml" <<'YAML'
+
+profiles:
+ a:
+ inherits_from: ../evil
+YAML
+ expect_fail install --profile a --yes
+
+ # missing profile
+ new_env inhmissing
+ expect_fail install --profile ghost --yes
+
+ # symlinked profile directory
+ new_env inhlink
+ ln -s "$BASE/profiles/default" "$BASE/profiles/link"
+ expect_fail install --profile link --yes
+}
+
+test_inheritance_override() {
+ new_env ovr
+ mkdir -p "$BASE/profiles/child/global"
+ printf '# Child stack\n' >"$BASE/profiles/child/global/tech-stack.md"
+ cat >>"$BASE/config.yml" <<'YAML'
+
+profiles:
+ child:
+ inherits_from: default
+YAML
+ install --profile child --yes
+ contains "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "Child stack"
+}
+
+test_project_dir_canonicalization() {
+ new_env canon
+ ln -s "$PROJ" "$ROOT/canon/link"
+ "$BASH" "$BASE/scripts/project-install.sh" --project-dir "$ROOT/canon/link" --yes
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+}
+
+test_doctor() {
+ new_env doc
+ install --yes
+ doctor
+ printf 'tampered\n' >>"$PROJ/agent-os/standards/global/tech-stack.md"
+ expect_fail doctor
+ rm -f "$PROJ/.claude/commands/agent-os/discover-standards.md"
+ expect_fail doctor
+
+ new_env doc2
+ expect_fail doctor
+}
+
+test_doctor_parent_symlink() {
+ new_env docparent
+ install --yes
+ outside="$ROOT/docparent/outside"
+ mkdir -p "$outside"
+ cp -R "$PROJ/agent-os/standards/." "$outside/"
+ rm -rf "$PROJ/agent-os/standards"
+ ln -s "$outside" "$PROJ/agent-os/standards"
+ # a symlinked parent must never be reported as healthy
+ expect_fail doctor
+}
+
+test_uninstall_preserves_unrelated() {
+ new_env un
+ install --yes
+ printf 'notes\n' >"$PROJ/agent-os/notes.txt"
+ printf 'keep\n' >"$PROJ/agent-os/standards/mine.md"
+ printf 'root\n' >"$PROJ/README.md"
+ uninstall
+ absent "$PROJ/agent-os/standards/global/tech-stack.md"
+ absent "$PROJ/.claude/commands/agent-os/discover-standards.md"
+ absent "$PROJ/agent-os/install-manifest.tsv"
+ exists "$PROJ/agent-os/notes.txt"
+ exists "$PROJ/agent-os/standards/mine.md"
+ exists "$PROJ/README.md"
+
+ # drifted files are retained, stay tracked and make the run non-zero
+ new_env un2
+ install --yes
+ printf 'edit\n' >>"$PROJ/agent-os/standards/global/tech-stack.md"
+ expect_fail uninstall
+ exists "$PROJ/agent-os/standards/global/tech-stack.md"
+ exists "$PROJ/agent-os/install-manifest.tsv"
+ contains "$(cat "$PROJ/agent-os/install-manifest.tsv")" "agent-os/standards/global/tech-stack.md"
+ absent "$PROJ/.claude/commands/agent-os/discover-standards.md"
+
+ # --force backs up removed files
+ new_env un3
+ install --yes
+ uninstall --force
+ absent "$PROJ/agent-os/standards/global/tech-stack.md"
+ backup_has "$PROJ/agent-os/.backups" "tech-stack.md"
+}
+
+test_uninstall_force_drift() {
+ new_env undrift
+ install --yes
+ printf 'edit\n' >>"$PROJ/agent-os/standards/global/tech-stack.md"
+ # normal uninstall retains the modification and reports failure
+ expect_fail uninstall
+ contains "$(cat "$PROJ/agent-os/standards/global/tech-stack.md")" "edit"
+ # --force backs up and removes the drifted regular file
+ uninstall --force
+ absent "$PROJ/agent-os/standards/global/tech-stack.md"
+ bk=$(find "$PROJ/agent-os/.backups" -name tech-stack.md | head -n 1)
+ contains "$(cat "$bk")" "edit"
+}
+
+test_uninstall_never_follows_symlinks() {
+ new_env unsym
+ install --yes
+ printf 'secret\n' >"$ROOT/unsym/secret.txt"
+ rm -f "$PROJ/agent-os/standards/global/tech-stack.md"
+ ln -s "$ROOT/unsym/secret.txt" "$PROJ/agent-os/standards/global/tech-stack.md"
+ # even --force must refuse to remove the symlink and never touch its target
+ expect_fail uninstall --force
+ [ -L "$PROJ/agent-os/standards/global/tech-stack.md" ] || { printf ' symlink was removed\n'; return 1; }
+ exists "$ROOT/unsym/secret.txt"
+}
+
+test_parent_symlink_deletion_exploit() {
+ new_env exploit
+ install --yes
+ # replace the standards directory with a symlink to a tree copied outside
+ outside="$ROOT/exploit/outside"
+ mkdir -p "$outside"
+ cp -R "$PROJ/agent-os/standards/." "$outside/"
+ rm -rf "$PROJ/agent-os/standards"
+ ln -s "$outside" "$PROJ/agent-os/standards"
+ exists "$outside/global/tech-stack.md"
+ expect_fail doctor
+ expect_fail uninstall --force
+ # nothing outside the project may have been deleted
+ exists "$outside/global/tech-stack.md"
+ exists "$outside/index.yml"
+}
+
+test_rollback_mid_commit() {
+ new_env rb
+ install --target none --yes
+ before=$(snapdir "$PROJ")
+ # a modified standard and a new standard so the re-run both updates and creates
+ printf '\nchanged body\n' >>"$BASE/profiles/default/global/tech-stack.md"
+ printf '# Extra\n' >"$BASE/profiles/default/global/extra.md"
+ export AGENT_OS_INSTALL_FAIL_AFTER=2
+ expect_fail install --target none --yes
+ unset AGENT_OS_INSTALL_FAIL_AFTER
+ eq "$(snapdir "$PROJ")" "$before"
+ absent "$PROJ/agent-os/standards/global/extra.md"
+
+ # failure after the very first write also rolls back cleanly
+ export AGENT_OS_INSTALL_FAIL_AFTER=1
+ expect_fail install --target none --yes
+ unset AGENT_OS_INSTALL_FAIL_AFTER
+ eq "$(snapdir "$PROJ")" "$before"
+}
+
+test_rollback_removes_created_dirs() {
+ new_env rbdir
+ install --target none --yes
+ before=$(snapdir "$PROJ")
+ # a brand new nested directory tree that only this run creates
+ mkdir -p "$BASE/profiles/default/newdir/deep"
+ printf '# Deep\n' >"$BASE/profiles/default/newdir/deep/file.md"
+ export AGENT_OS_INSTALL_FAIL_AFTER=1
+ expect_fail install --target none --yes
+ unset AGENT_OS_INSTALL_FAIL_AFTER
+ absent "$PROJ/agent-os/standards/newdir"
+ eq "$(snapdir "$PROJ")" "$before"
+}
+
+test_help() {
+ new_env help
+ out=$("$BASH" "$BASE/scripts/project-install.sh" --help)
+ contains "$out" "Usage:"
+ contains "$out" "--verbose"
+ out=$(doctor --help)
+ contains "$out" "Usage:"
+ out=$(uninstall --help)
+ contains "$out" "Usage:"
+}
+
+test_backslash_filename_hash() {
+ new_env bslash
+ mkdir -p "$BASE/profiles/default/global"
+ bs="$BASE/profiles/default/global/back\\slash.md"
+ printf '# Backslash standard\n' >"$bs"
+ install --yes
+ dest="$PROJ/agent-os/standards/global/back\\slash.md"
+ exists "$dest"
+ # The recorded hash must equal the content hash (the hashing tool is fed on
+ # stdin, never the raw backslash-bearing filename).
+ want=$(content_sha256 "$bs")
+ row=$(grep -F "$want" "$PROJ/agent-os/install-manifest.tsv")
+ contains "$row" "back\\slash.md"
+ doctor
+ uninstall
+ absent "$dest"
+}
+
+test_index_quoting_reserved_and_numeric() {
+ new_env idxres
+ mkdir -p "$BASE/profiles/default/weird" "$BASE/profiles/default/1st"
+ for n in true false yes no on off null 2024; do
+ printf '# %s\n' "$n" >"$BASE/profiles/default/weird/$n.md"
+ done
+ printf '# Plain\n' >"$BASE/profiles/default/weird/plainname.md"
+ printf '# One\n' >"$BASE/profiles/default/1st/plain.md"
+ install --yes
+ idx=$(cat "$PROJ/agent-os/standards/index.yml")
+ # reserved words and digit-leading names stay quoted strings
+ for n in true false yes no on off null 2024; do
+ contains "$idx" "\"$n\":"
+ done
+ # a digit-leading folder label is quoted too
+ contains "$idx" '"1st":'
+ # a plainly-safe name stays an unquoted plain scalar
+ contains "$idx" ' plainname:'
+}
+
+test_dotted_profile_inheritance() {
+ new_env dotted
+ mkdir -p "$BASE/profiles/parent/global" "$BASE/profiles/foo.bar/global" "$BASE/profiles/fooXbar/global"
+ printf '# Parent stack\n' >"$BASE/profiles/parent/global/tech-stack.md"
+ printf '# Parent only\n' >"$BASE/profiles/parent/global/parent-only.md"
+ printf '# Dotted stack\n' >"$BASE/profiles/foo.bar/global/tech-stack.md"
+ printf '# X stack\n' >"$BASE/profiles/fooXbar/global/tech-stack.md"
+ cat >>"$BASE/config.yml" <<'YAML'
+
+profiles:
+ foo.bar:
+ inherits_from: parent
+ fooXbar:
+ inherits_from: ghost
+YAML
+ # If "foo.bar" were matched as a regex it would resolve to the fooXbar stanza
+ # and die on the nonexistent "ghost" parent.
+ install --profile foo.bar --yes
+ # exact dotted parent override: foo.bar replaces its parent's standard
+ file_eq "$PROJ/agent-os/standards/global/tech-stack.md" "$BASE/profiles/foo.bar/global/tech-stack.md"
+ # the parent's other standard still arrives through the exact chain
+ exists "$PROJ/agent-os/standards/global/parent-only.md"
+}
+
+test_rollback_pipe_space_dirs() {
+ new_env rbpipe
+ # Both the project directory name and the created standards directory name
+ # carry a literal "|" and a space, so rollback cannot rely on a pipe- or
+ # whitespace-delimited list of created directories.
+ PROJ="$ROOT/rbpipe/proj with|pipe"
+ mkdir -p "$PROJ"
+ install --target none --yes
+ before=$(snapdir "$PROJ")
+ # The new directory sorts first, so the first commit creates it (and its
+ # parent) before the injected failure.
+ mkdir -p "$BASE/profiles/default/aaa b|c/deep"
+ printf '# Pipe dir\n' >"$BASE/profiles/default/aaa b|c/deep/file.md"
+ export AGENT_OS_INSTALL_FAIL_AFTER=1
+ expect_fail install --target none --yes
+ unset AGENT_OS_INSTALL_FAIL_AFTER
+ absent "$PROJ/agent-os/standards/aaa b|c"
+ eq "$(snapdir "$PROJ")" "$before"
+}
+
+test_colon_space_force_backup() {
+ new_env colonspace
+ mkdir -p "$BASE/profiles/default/weird"
+ printf '# Colon space\n' >"$BASE/profiles/default/weird/colon: name.md"
+ # Pre-existing unmanaged file: the conflict line becomes
+ # "unmanaged: agent-os/standards/weird/colon: name.md". The embedded ": "
+ # must not be mistaken for the conflict "reason: " separator.
+ mkdir -p "$PROJ/agent-os/standards/weird"
+ printf 'user colon file\n' >"$PROJ/agent-os/standards/weird/colon: name.md"
+ expect_fail install --yes
+ install --force --yes
+ file_eq "$PROJ/agent-os/standards/weird/colon: name.md" "$BASE/profiles/default/weird/colon: name.md"
+ bk=$(find "$PROJ/agent-os/.backups" -name "colon: name.md" | head -n 1)
+ [ -n "$bk" ] || { printf ' no backup for the colon-space file\n'; return 1; }
+ contains "$(cat "$bk")" "user colon file"
+ doctor
+}
+
+test_hash_tool_failure_no_writes() {
+ new_env hashfail
+ fake="$ROOT/hashfail/fakebin"
+ mkdir -p "$fake"
+ for tool in sha256sum shasum openssl; do
+ printf '#!/bin/sh\nexit 1\n' >"$fake/$tool"
+ chmod +x "$fake/$tool"
+ done
+ # With every SHA-256 tool failing, hashing must abort the run before any
+ # project mutation rather than recording empty/garbage hashes.
+ set +e
+ PATH="$fake:$PATH" install --yes >/dev/null 2>&1
+ rc=$?
+ set -e
+ [ "$rc" -ne 0 ] || { printf ' install succeeded despite a failing hash tool\n'; return 1; }
+ absent "$PROJ/agent-os"
+}
+
+test_manifest_unterminated_rejected() {
+ new_env term
+ install --yes
+ m="$PROJ/agent-os/install-manifest.tsv"
+ # Drop the manifest's final terminating newline — exactly the corruption that
+ # previously let doctor exit 0 while silently ignoring the last row.
+ printf '%s' "$(cat "$m")" >"$m.tmp" && mv "$m.tmp" "$m"
+ [ -n "$(tail -c 1 "$m")" ] || { printf ' setup: manifest still newline-terminated\n'; return 1; }
+ before=$(snapdir "$PROJ")
+
+ set +e
+ out=$(doctor 2>&1); rc=$?
+ set -e
+ [ "$rc" -ne 0 ] || { printf ' doctor exited 0 on an unterminated manifest\n'; return 1; }
+ case "$out" in
+ *"match the manifest"*) printf ' doctor falsely reported success\n'; return 1 ;;
+ esac
+
+ expect_fail uninstall
+ expect_fail install --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # Restoring the terminator makes the manifest valid again.
+ printf '\n' >>"$m"
+ doctor
+}
+
+test_malformed_manifest_no_mutation() {
+ ZERO_HASH="0000000000000000000000000000000000000000000000000000000000000000"
+ HEADER="# agent-os install manifest v1"
+
+ # traversal row
+ new_env badmut1
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "../escape"; } >"$PROJ/agent-os/install-manifest.tsv"
+ before=$(snapdir "$PROJ")
+ expect_fail install --yes
+ expect_fail uninstall
+ expect_fail doctor
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # duplicate rows
+ new_env badmut2
+ mkdir -p "$PROJ/agent-os"
+ { printf '%s\n' "$HEADER"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; printf '%s\t%s\n' "$ZERO_HASH" "agent-os/standards/a.md"; } >"$PROJ/agent-os/install-manifest.tsv"
+ before=$(snapdir "$PROJ")
+ expect_fail install --yes
+ expect_fail uninstall
+ eq "$(snapdir "$PROJ")" "$before"
+}
+
+test_index_scalar_roundtrip() {
+ new_env idxscalar
+ # Real profile keys/folders that force YAML quoting: a space, a colon, a
+ # literal hash, a double quote, a backslash, a reserved word and a
+ # digit-leading name.
+ mkdir -p "$BASE/profiles/default/space dir" "$BASE/profiles/default/weird"
+ mkdir -p "$BASE/profiles/default/quote\"dir"
+ printf '# Spaced\n' >"$BASE/profiles/default/space dir/plain.md"
+ printf '# Colon\n' >"$BASE/profiles/default/weird/colon:name.md"
+ printf '# Hash\n' >"$BASE/profiles/default/weird/hash#name.md"
+ printf '# Quote\n' >"$BASE/profiles/default/quote\"dir/plain.md"
+ printf '# Slash\n' >"$BASE/profiles/default/weird/back\\slash.md"
+ printf '# True\n' >"$BASE/profiles/default/weird/true.md"
+ printf '# Digit\n' >"$BASE/profiles/default/weird/123.md"
+ install --yes
+
+ # Hand-curate the project index with every supported scalar style and
+ # YAML-significant content (colon, hash, escaped quotes, a backslash and a
+ # doubled single quote). The reader must decode these exactly.
+ cat >"$PROJ/agent-os/standards/index.yml" <<'YAML'
+# Agent OS Standards Index
+
+"space dir":
+ plain:
+ description: "colon: and hash # inside quotes"
+
+weird:
+ "colon:name":
+ description: 'it''s got a doubled apostrophe'
+ "hash#name":
+ description: "escaped \"quote\" and a # too"
+ "back\\slash":
+ description: "one\\backslash stays"
+ "true":
+ description: plain spaced value
+ "123":
+ description: "plain:colon"
+
+"quote\"dir":
+ plain:
+ description: "a \"quoted\" folder"
+YAML
+
+ # The 2nd install (forced) must decode and keep every curated description.
+ install --force --yes
+ idx=$(cat "$PROJ/agent-os/standards/index.yml")
+ # emitter-safe plain scalars stay bare; YAML-significant content is quoted
+ contains "$idx" 'space dir:'
+ contains "$idx" '"quote\"dir":'
+ contains "$idx" '"colon:name":'
+ contains "$idx" '"hash#name":'
+ contains "$idx" '"colon: and hash # inside quotes"'
+ contains "$idx" "\"it's got a doubled apostrophe\""
+ contains "$idx" '"escaped \"quote\" and a # too"'
+ contains "$idx" '"one\\backslash stays"'
+ contains "$idx" 'description: plain spaced value'
+ contains "$idx" '"plain:colon"'
+ contains "$idx" '"a \"quoted\" folder"'
+
+ # The 3rd install (forced) must be byte-stable against the 2nd and must
+ # still verify clean afterwards.
+ after2=$(snapdir "$PROJ")
+ install --force --yes
+ eq "$(snapdir "$PROJ")" "$after2"
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"one\\backslash stays"'
+
+ # A CRLF index (every line closed with a carriage return) is accepted: only
+ # a single trailing carriage return is stripped per line, so the curated
+ # descriptions still decode and survive regeneration to an LF index that is
+ # byte-stable on the next forced run.
+ cr=$(printf '\r')
+ while IFS= read -r line || [ -n "$line" ]; do
+ printf '%s%s\n' "$line" "$cr"
+ done <"$PROJ/agent-os/standards/index.yml" >"$PROJ/agent-os/standards/index.crlf"
+ mv "$PROJ/agent-os/standards/index.crlf" "$PROJ/agent-os/standards/index.yml"
+ grep -q "$cr" "$PROJ/agent-os/standards/index.yml" || { printf ' setup: no CR introduced\n'; return 1; }
+ install --force --yes
+ contains "$(cat "$PROJ/agent-os/standards/index.yml")" '"one\\backslash stays"'
+ crlf_stable=$(snapdir "$PROJ")
+ install --force --yes
+ eq "$(snapdir "$PROJ")" "$crlf_stable"
+
+ doctor
+}
+
+test_index_unsupported_fails_closed() {
+ # A project index the reader cannot fully understand must abort before any
+ # project mutation -- using --force so the drift guard is definitively not
+ # what refuses -- rather than silently resetting descriptions to defaults.
+
+ # Nested/structured metadata under a name.
+ new_env idxnested
+ install --yes
+ cat >"$PROJ/agent-os/standards/index.yml" <<'YAML'
+# Agent OS Standards Index
+
+global:
+ tech-stack:
+ description: "kept"
+ tags:
+ - one
+YAML
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # An unsupported escape sequence in a double-quoted scalar.
+ new_env idxescape
+ install --yes
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' ' description: "bad \t escape"' >"$PROJ/agent-os/standards/index.yml"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # Malformed (unterminated) quoting.
+ new_env idxunterm
+ install --yes
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' " description: 'unterminated" >"$PROJ/agent-os/standards/index.yml"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # Unsupported indentation level.
+ new_env idxindent
+ install --yes
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' ' description: x' >"$PROJ/agent-os/standards/index.yml"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # A duplicate folder key (silently first-win otherwise).
+ new_env idxdupdir
+ install --yes
+ cat >"$PROJ/agent-os/standards/index.yml" <<'YAML'
+# Agent OS Standards Index
+
+global:
+ tech-stack:
+ description: first
+global:
+ other:
+ description: second
+YAML
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # A duplicate name key under one folder.
+ new_env idxdupname
+ install --yes
+ cat >"$PROJ/agent-os/standards/index.yml" <<'YAML'
+# Agent OS Standards Index
+
+global:
+ tech-stack:
+ description: first
+ tech-stack:
+ description: second
+YAML
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # A name left without a description before the next name, the next folder
+ # or the end of the file.
+ new_env idxnodesc
+ install --yes
+ idx="$PROJ/agent-os/standards/index.yml"
+
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' ' tech-stack-2:' ' description: x' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' 'other:' ' plain:' ' description: x' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # A plain description value that begins with a YAML indicator: a flow
+ # collection, a node tag/anchor/alias, a block scalar or a reserved
+ # character.
+ new_env idxindicator
+ install --yes
+ idx="$PROJ/agent-os/standards/index.yml"
+ for value in '[flow]' '{flow}' '!tag v' '&anchor v' '*alias' '|block' '>folded' '%dir' '@res' '`res'; do
+ printf '%s\n' '# Agent OS Standards Index' '' 'global:' ' tech-stack:' " description: $value" >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+ done
+
+ # ... and a plain key that begins with an indicator.
+ printf '%s\n' '# Agent OS Standards Index' '' '!tag:' ' tech-stack:' ' description: x' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ # A literal tab inside a double-quoted and a single-quoted scalar, and a
+ # literal carriage return inside a quoted scalar, which the TSV round-trip
+ # or the trailing-CR strip would otherwise silently lose.
+ new_env idxcontrol
+ install --yes
+ idx="$PROJ/agent-os/standards/index.yml"
+
+ printf '# Agent OS Standards Index\n\nglobal:\n tech-stack:\n description: "tab\tinside"\n' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ printf '# Agent OS Standards Index\n\nglobal:\n tech-stack:\n description: '\''tab\tinside'\''\n' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+
+ printf '# Agent OS Standards Index\n\nglobal:\n tech-stack:\n description: "car\rriage"\n' >"$idx"
+ before=$(snapdir "$PROJ")
+ expect_fail install --force --yes
+ eq "$(snapdir "$PROJ")" "$before"
+}
+
+# ---------------------------------------------------------------------------
+# Run
+# ---------------------------------------------------------------------------
+
+printf 'Agent OS installer tests (bash %s)\n\n' "$BASH_VERSION"
+
+t harness_selfcheck test_harness_selfcheck
+t flat_standard_install test_flat_standard_install
+t index_preservation_and_inheritance test_index_preservation_and_inheritance
+t index_description_preservation test_index_description_preservation
+t index_quoting_special_names test_index_quoting_special_names
+t option_errors test_option_errors
+t target_none test_target_none
+t verbose_and_self_install_guard test_verbose_and_self_install_guard
+t dry_run_side_effect_free test_dry_run_side_effect_free
+t commands_only_ownership test_commands_only_ownership
+t unchanged_updates test_unchanged_updates
+t stale_rows_retained test_stale_rows_retained
+t unmanaged_and_drift_protection test_unmanaged_and_drift_protection
+t backup_unique_dirs test_backup_unique_dirs
+t symlink_rejection test_symlink_rejection
+t source_root_symlinks test_source_root_symlinks
+t bad_manifest test_bad_manifest
+t inheritance_guards test_inheritance_guards
+t inheritance_override test_inheritance_override
+t project_dir_canonicalization test_project_dir_canonicalization
+t doctor test_doctor
+t doctor_parent_symlink test_doctor_parent_symlink
+t uninstall_preserves_unrelated test_uninstall_preserves_unrelated
+t uninstall_force_drift test_uninstall_force_drift
+t uninstall_never_follows_symlinks test_uninstall_never_follows_symlinks
+t parent_symlink_deletion_exploit test_parent_symlink_deletion_exploit
+t rollback_mid_commit test_rollback_mid_commit
+t rollback_removes_created_dirs test_rollback_removes_created_dirs
+t rollback_pipe_space_dirs test_rollback_pipe_space_dirs
+t backslash_filename_hash test_backslash_filename_hash
+t index_quoting_reserved_numeric test_index_quoting_reserved_and_numeric
+t index_scalar_roundtrip test_index_scalar_roundtrip
+t index_unsupported_fails_closed test_index_unsupported_fails_closed
+t dotted_profile_inheritance test_dotted_profile_inheritance
+t colon_space_force_backup test_colon_space_force_backup
+t hash_tool_failure_no_writes test_hash_tool_failure_no_writes
+t manifest_unterminated_rejected test_manifest_unterminated_rejected
+t malformed_manifest_no_mutation test_malformed_manifest_no_mutation
+t help test_help
+
+printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ]