diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md new file mode 100644 index 0000000..042d806 --- /dev/null +++ b/.claude/skills/setup/SKILL.md @@ -0,0 +1,184 @@ +--- +name: setup +description: "Install this repo's Claude Code setup into the current machine's config dir. Runs ONLY when the user types /setup inside a clone of claude-code-setup — never model-invoked. Orchestrates the check/install/merge scripts through a preflight → install → merge → handoff flow, with a diff shown and explicit consent taken before every write." +disable-model-invocation: true +argument-hint: (no arguments — run inside a fresh clone of this repo) +allowed-tools: + - Read + - Glob + - Grep + - Bash(bash scripts/check.sh:*) + - Bash(bash scripts/install.sh --dry-run:*) + - Bash(bash scripts/merge-settings.sh --dry-run:*) + - Bash(bash scripts/merge-claude-md.sh --dry-run:*) +--- + +# Setup Router — Adopter Install Flow + +You are the session executing `/setup` inside a **clone of this repo** (this skill is +repo-scoped: it lives under `.claude/skills/` and is discovered only when Claude Code runs +in the clone — it is never installed onto an adopter's machine). Your job is to install this +setup's managed artifacts (agents/hooks/rules/skills), then merge `settings.json` and +`CLAUDE.md`, into the current machine's Claude config dir — **safely, with a diff shown and +explicit consent taken before every write**. + +## Consent model (read before you start) + +This flow has **two consent gates by design**, and you must not collapse either: + +1. **Every write is preceded by a diff + an explicit ask.** You dry-run first, show the + plan/diff, and ask the user to proceed. +2. **The writing commands are deliberately NOT pre-approved in `allowed-tools`.** Only the + read-only and `--dry-run` forms are pre-approved. When you run the real install, a + real `--force=`, or a real `--apply`, Claude Code will raise a **permission + prompt** — that prompt is the *intended* second gate, not a bug. Tell the user to expect + it and let it happen; never try to route around it. + +Do **not** invent Edit/Write shortcuts to config paths, and do not seek broader permission +grants to suppress those prompts. + +## Script contract (shared by all four scripts) + +- Machine lines `CCS-STATUS::` print on **stdout**; human diffs/plan text also + on stdout; refusals/errors on **stderr**. +- Exit codes: **0** success · **1** refusal / partial · **2** usage / internal. +- `scripts/install.sh` (non-dry-run) prints `CCS-STATUS:backup-dir:` as its **first** + status line. **Capture that path** — that ONE run dir is threaded via `--backup-dir ` + into every later `--apply`/`--force` this session, so the whole install produces a single + backup run dir and a single receipt. + +## Global error rules (apply in every phase) + +- **Any script exits 2** → STOP the entire flow. Report the script's stderr **verbatim**; + do not attempt later phases. +- **Any script exits 1** → report its stderr **verbatim** and ask the user how to proceed + (it is a refusal/partial, not a hard stop — the user decides). +- **Abort-safe / idempotent:** every phase is idempotent. If the flow is aborted partway, + re-running `/setup` resumes safely — already-installed, identical files no-op + (`CCS-STATUS:unchanged:...`), and merges recompute from current on-disk state. There is no + state file; a declined step is simply remembered in this conversation. + +--- + +## P1 — Preflight + +Run: + +``` +bash scripts/check.sh +``` + +- **Exit 2 (FAIL)** → STOP. Present the `FAIL:` lines **verbatim** plus the fixes `check.sh` + suggests. Do not proceed to P2. +- **Exit 1 (WARN)** → present the `WARN:` lines and **ask the user whether to proceed** given + the warnings. Only continue if they say yes. +- **Exit 0 (PASS)** → proceed to P2. + +## P2 — Install managed artifacts + +1. **Dry-run first:** + + ``` + bash scripts/install.sh --dry-run --diff + ``` + + Present the plan: what would be installed (missing → install), what is identical + (`unchanged`), and per-category **diffs for any differing files** (differing files are + **skipped** by default, per category). + +2. **Real install** (this is NOT pre-approved — expect a permission prompt; explain that to + the user before running it): + + ``` + bash scripts/install.sh + ``` + + **Capture the first status line** `CCS-STATUS:backup-dir:`. Save `` — you will + pass `--backup-dir ` to every later apply/force so the session shares one backup run + dir and one receipt. + +3. **Resolve skipped (differing) files.** Any file that differed was left untouched. For each + affected **category**, show that category's diff and ask the user **keep vs overwrite**. + Overwrite only the categories the user approves, one category per run: + + ``` + bash scripts/install.sh --force= --backup-dir + ``` + + (`` is one of `agents`, `hooks`, `rules`, `skills`.) This run is also NOT + pre-approved — the permission prompt is again the intended consent gate. + +## P3 — Merge config files (one target at a time) + +Handle **`settings.json` first, then `CLAUDE.md`**. For each target, always: dry-run → show +the unified diff → take **explicit consent** → apply with the shared `--backup-dir `. + +### P3a — settings.json + +``` +bash scripts/merge-settings.sh --dry-run +``` + +Show the unified diff. Ask for explicit consent. On yes: + +``` +bash scripts/merge-settings.sh --apply --backup-dir +``` + +If the target already exists (an existing-config merge, not a fresh verbatim write), also +surface the **withheld-convenience-keys note** to the user: + +> Convenience keys (model, statusLine, defaultMode) were not merged to avoid overwriting +> yours — copy from `settings.example.json` if wanted. + +### P3b — CLAUDE.md + +``` +bash scripts/merge-claude-md.sh --dry-run +``` + +Show the unified diff. Ask for explicit consent. On yes: + +``` +bash scripts/merge-claude-md.sh --apply --backup-dir +``` + +**Declines are respected.** If the user declines either merge, do not write, do not re-ask in +a loop, and do not nag — just record the decline in the conversation and move on. + +## P4 — Handoff + +The install is done from this session's side. Print, for the user: + +- **Receipt location** and the **backup run dir** (`` captured in P2). +- A short **"what behaves differently now"** summary (3 lines): + 1. Pushes to `main`/`master` are blocked (branch-protection hook). + 2. The delegation guard is active during `/build` (orchestrator can't hand-edit source). + 3. Design + pre-PR human checkpoints are enforced. + +Then give the user the steps **they run AFTER restarting Claude Code**. The restart **severs +this session**, so the skill must NOT try to run these itself — hand them over as +instructions: + +1. **Restart Claude Code** (so the newly installed settings/hooks/skills load). +2. Verify the install landed: + + ``` + bash scripts/check.sh --post + ``` + +3. Confirm hooks loaded by running **`/hooks`** and checking the managed entries appear. + +--- + +## Anti-patterns to avoid + +- Do NOT skip the dry-run — a diff is shown before **every** consent, no exceptions. +- Do NOT collapse the two consent gates: never seek a broader grant to suppress the + install/force/apply permission prompts. +- Do NOT thread multiple backup dirs — capture the ONE `CCS-STATUS:backup-dir:` from + P2 and reuse it for every apply/force. +- Do NOT re-ask a declined merge in a loop. +- Do NOT run the post-restart steps yourself — the restart ends this session; they are the + user's to run. +- Do NOT continue past an exit-2 from any script — STOP and report verbatim. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..47a82aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +# push covers every branch (including feature branches, per project convention +# of feature-branch-only work); pull_request targeting main covers PRs. +on: + push: + pull_request: + branches: [main] + +jobs: + lint-and-test: + name: shellcheck + bats + manifest (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update -y && sudo apt-get install -y shellcheck + + # Same pinned git-checkout install as the macos job below. `npm install -g + # bats` broke on the ubuntu-24.04 runner image: npm's global prefix is + # /usr/local, whose man dirs the runner user cannot mkdir (EACCES). + - name: Install bats-core from a git checkout + run: | + git clone --depth 1 --branch v1.11.0 https://github.com/bats-core/bats-core.git "$RUNNER_TEMP/bats-core" + echo "$RUNNER_TEMP/bats-core/bin" >> "$GITHUB_PATH" + + - name: shellcheck scripts + hooks + # -x: scripts/*.sh source scripts/lib.sh; a bare single-file shellcheck + # trips info-level SC1091 (can't follow non-constant/relative source). + # House convention: always lint with -x here. + # + # --severity=error: hooks/*.sh (owned by an earlier task, out of scope + # here) carry pre-existing info/warning-level nits (SC2086, SC2015, + # SC2221/2222) that are stylistic, not bugs. Gating on errors keeps this + # a real correctness gate without failing CI on unrelated existing + # style notes. Judgment call — revisit if those files get cleaned up. + run: shellcheck -x --severity=error scripts/*.sh hooks/*.sh + + - name: bats (full suite, incl. e2e) + run: bats tests/*.bats + + - name: manifest.txt == git ls-files agents/ hooks/ rules/ skills/ (C3) + run: | + diff <(git ls-files agents/ hooks/ rules/ skills/) scripts/manifest.txt + + bats-macos: + name: bats (macos system /bin/bash 3.2 + BSD userland) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + # bats-core from a git checkout, invoked directly out of bin/ — NOT via + # brew. A brewed bats would pull in a modern GNU-flavored PATH/toolchain + # and mask exactly the GNUisms this job exists to catch. shellcheck is + # intentionally skipped here: the ubuntu job already lints every script. + - name: Install bats-core from a git checkout + run: | + git clone --depth 1 --branch v1.11.0 https://github.com/bats-core/bats-core.git "$RUNNER_TEMP/bats-core" + echo "$RUNNER_TEMP/bats-core/bin" >> "$GITHUB_PATH" + + - name: bats (full suite, incl. e2e) under system bash + run: | + echo "bash: $(command -v bash) ($(bash --version | head -n1))" + bats tests/*.bats diff --git a/.gitignore b/.gitignore index 571f992..d74a5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,21 @@ !skills/** !hooks/ !hooks/** +!scripts/ +!scripts/** +!tests/ +!tests/** +!.github/ +!.github/** + +# Allow the repo-scoped setup skill under .claude/, but nothing else in +# .claude/ (agent-memory, local settings, etc. must stay ignored). Gitignore +# requires un-ignoring each parent directory level before its children, so +# the chain below is intentionally explicit rather than a single !.claude/**. +!.claude/ +!.claude/skills/ +!.claude/skills/setup/ +!.claude/skills/setup/** # Never track local, personal, or generated Claude Code state, even if a # future copy step drops it in here by mistake. @@ -30,3 +45,7 @@ projects/ history.jsonl *.log .DS_Store + +# Security rail: this repo must never ship an active repo-local settings +# file, even though .claude/ is partially un-ignored above. +.claude/settings.json diff --git a/CLAUDE.md b/CLAUDE.md index 3dd6ded..38e0e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ your own" are personal-preference blocks you should adapt or delete; the ## Identity (example — replace with your own) PM-turned-engineer. Quality and learning over speed. Always explain WHY behind architectural decisions, not just WHAT. + ## Workflow Rules - Never push directly to main/master. Feature branches only. - Conventional Commits: feat:, fix:, refactor:, docs:, chore:, test: @@ -31,6 +32,7 @@ PM-turned-engineer. Quality and learning over speed. Always explain WHY behind a - Use subagents for tasks whose output would bloat the main context (exploration, review, large diffs). - Suggest /clear between unrelated tasks. - For large features: suggest one session per phase. + ## Stack Preferences (example — replace with your own; used only when a project's own CLAUDE.md is silent) - iOS: SwiftUI, async/await, no completion handlers, // MARK: sections diff --git a/README.md b/README.md index 1e98907..9b9f9f8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ A user-level `~/.claude` configuration for [Claude Code](https://claude.com/clau a `/design` → `/build` → `/ship` agentic workflow, six specialized subagents, model-routing rules, and a small set of hooks that turn advisory conventions into enforced ones. This is a sanitized export of a daily-driver setup, offered as a -clone-and-use template. +clone-and-use template — installed via a repo-scoped `/setup` skill (see +[Quick Start](#6-quick-start-clone-and-use)) that installs and merges everything +with a diff and your explicit consent, or, if you'd rather skip the +conversational flow, [four scripts](#7-standalone-install-no-llm-run-the-scripts-directly) +you can run by hand. ## 1. Why this exists @@ -123,51 +127,185 @@ Redacted categories: ## 6. Quick Start (clone-and-use) ```bash -# Agents — auto-register from their directory, available immediately -cp agents/*.md ~/.claude/agents/ +git clone https://github.com//claude-code-setup.git +cd claude-code-setup +claude +``` + +When Claude Code opens inside the clone for the first time, it shows a **trust +dialog** for the folder. **Accept it.** Trusting the repo is what gates access +to its `.claude/` directory — until you accept, the repo-scoped `/setup` skill +and its pre-approved read-only commands (`check.sh`, the `--dry-run` forms of +the install/merge scripts) aren't available to the session at all. This is a +one-time, per-directory Claude Code trust decision, not something this repo +configures. + +Once trusted, type: + +``` +/setup +``` + +`/setup` walks a **preflight → install → merge → handoff** flow +(`.claude/skills/setup/SKILL.md`). It runs a dry-run and shows you a diff +before every write, and asks for explicit consent before anything actually +changes your `~/.claude` config — nothing is written silently. Differing files +are skipped by default per category (agents/hooks/rules/skills); you choose +keep-vs-overwrite per category. `settings.json` and `CLAUDE.md` are merged one +at a time, each behind its own diff-then-consent step. + +> [!WARNING] +> **Never clone this repo directly into `~/.claude`, and never point +> `CLAUDE_CONFIG_DIR` at this clone.** The scripts refuse to install a repo +> onto itself (a source-equals-destination guard checks for exactly this), but +> don't rely on that guard — treat the clone and your Claude config dir as two +> separate locations, always. The scripts honor `CLAUDE_CONFIG_DIR` as an +> override for the config dir they install into/read from (default +> `~/.claude`); set it if your Claude Code config lives somewhere non-default. + +## 7. Standalone install (no LLM, run the scripts directly) + +If you'd rather not run `/setup` conversationally — or you're scripting an +install — the four scripts it orchestrates can be run by hand, in this order. +**Run them with `bash`, not `sh`** — they're bash scripts (`set -euo pipefail`, +bash-specific idioms) and will fail or behave incorrectly under a POSIX `sh`: + +```bash +bash scripts/check.sh # preflight +bash scripts/install.sh --dry-run --diff # see the plan first +bash scripts/install.sh # real install (prints a backup-dir path — save it) +bash scripts/merge-settings.sh --dry-run # see the settings.json diff +bash scripts/merge-settings.sh --apply --backup-dir +bash scripts/merge-claude-md.sh --dry-run # see the CLAUDE.md diff +bash scripts/merge-claude-md.sh --apply --backup-dir +``` + +Thread the same `--backup-dir ` (captured from `install.sh`'s first +`CCS-STATUS:backup-dir:` line) into every later `--apply`/`--force` call, +so the whole install shares one backup run dir and one receipt. -# Skills — auto-register from their directory, available immediately -cp -r skills/design skills/build skills/ship skills/compound skills/search ~/.claude/skills/ +## 8. Verify the install -# Hooks — require the settings.json wiring below AND a session restart -cp hooks/*.sh ~/.claude/hooks/ -chmod +x ~/.claude/hooks/*.sh +1. **Restart Claude Code** — hooks and skills load at session start, so a + fresh install/merge only takes effect after a restart. +2. Run: -# Rules -mkdir -p ~/.claude/rules -cp rules/orchestration.md ~/.claude/rules/ + ```bash + bash scripts/check.sh --post + ``` + + to confirm the managed artifacts landed under your config dir. +3. Run **`/hooks`** and confirm the managed hook entries appear. + +## 9. Updating + +```bash +cd claude-code-setup # your existing clone +git pull ``` -**CLAUDE.md**: merge, don't clobber. If you already have a `~/.claude/CLAUDE.md`, -adapt the blocks in this repo's `CLAUDE.md` marked "example — replace with your -own" (Identity, Stack Preferences) into your existing file rather than -overwriting it — the `Workflow Rules` section is the part worth keeping intact. +Then, **from inside the clone**, run `/setup` again. Any file you've +customized since the last install surfaces as a per-category diff/prompt — +it's **skipped by default** and only overwritten if you explicitly consent; +nothing you've changed is clobbered silently. + +## 10. Uninstalling + +There's no uninstall script yet (deliberately deferred — see below); removal +is manual but deterministic because every write is logged to a **receipt**. +The receipt for a given install lives at `/receipt.txt`, where +`` is a timestamped directory under +`~/.claude/backups/claude-code-setup//` (or under `$CLAUDE_CONFIG_DIR` +if you've overridden it). Each line is tab-separated: +`action path backup-path repo-sha timestamp`. + +Walk the receipt and act **per `action`**, in this order — the ordering +matters because it's what protects data that predates the install: -**settings.json**: hooks only take effect once wired into `~/.claude/settings.json`. -Copy the `hooks` block from `settings.example.json` into your own settings file -(merge, don't overwrite, if you have other settings), then **restart Claude Code** -— hooks load at session start. +- **`installed`** — the file didn't exist before `/setup`; `rm` it. +- **`overwritten`** — a file you already had was replaced; **restore it from + the backup path on that line** (do **not** `rm` it — `rm` would destroy the + original the backup exists specifically to preserve). +- **`merged`** — if the backup-path field is non-empty, same as + `overwritten`: **restore from the backup path** on that line (never `rm` + it). If the backup-path field is **empty**, the file didn't exist before + `/setup` and was created fresh by the merge — `rm` it, same as `installed`. +- **`unchanged`** — the file already matched what `/setup` would have + written, so nothing was touched; leave it alone. (Earlier runs' lines still + govern it if you ran `/setup` more than once.) +- **`skipped`** — nothing was written; leave it alone. -## 7. Prerequisites +If you ran `/setup` more than once (e.g. across an update), restore from the +**earliest** run's backups — that's the true pre-setup state; later runs' +backups are snapshots of an already-modified file, not the original. + +## 11. Prerequisites - **jq** — REQUIRED. `protect-branches.sh` and `protect-secrets.sh` fail closed without it: they block the corresponding git operations/edits entirely rather than silently skip the check if `jq` isn't installed. `brew install jq` / `apt install jq`. -- **gh** — the GitHub CLI, required for `/ship`'s PR creation. -- **git** — obviously. -- **Claude Code ≥ v2.1.132** — required for session-scoped sentinel isolation in - `orchestrator-delegate-guard.sh`; below this version the guard falls back to a - legacy global sentinel with only a TTL as backstop. -- **Claude Code ≥ v2.1.198** — required for the custom `Explore` agent's model - override to take effect (below this version, the built-in Explore agent - inherits the session's main-thread model instead of running on `haiku`). -- **context7 + exa MCP servers** — OPTIONAL. `skills/search/SKILL.md` falls back - to `WebSearch` for any angle where an MCP tool is unavailable; nothing breaks - without them, research just loses some precision (context7's version-pinned - docs, exa's semantic search). - -## 8. What to adapt vs keep +- **git** — obviously; also needed for `git identity` (`user.name` / + `user.email`) so the workflow's own commits (one per `/build` task, plus + `/ship`) have an author. +- **Claude Code ≥ v2.1.53** — REQUIRED. This is a security floor + (CVE-2026-33068); `check.sh` fails closed below it. +- **Claude Code ≥ v2.1.198** — RECOMMENDED, for the full feature set: the + custom `Explore` agent's model override (below this version it inherits the + session's main-thread model instead of running on `haiku`), and + session-scoped sentinel isolation in `orchestrator-delegate-guard.sh` (fully + available from ≥ v2.1.132; below that the guard falls back to a legacy + global sentinel with only a TTL as backstop). +- **gh** — the GitHub CLI, needed **only for `/ship`'s** PR creation, not for + install. Run `gh auth login` before your first `/ship`. +- **shellcheck + bats** — DEV-ONLY, for contributing to this repo's own + scripts/tests. Not required to adopt or run `/setup`. +- **context7 + exa MCP servers** — OPTIONAL. `skills/search/SKILL.md` falls + back to `WebSearch` for any angle where an MCP tool is unavailable; `/design` + and `/search` degrade gracefully without them, just with less precision + (context7's version-pinned docs, exa's semantic search). + +## 12. Troubleshooting + +- **`/setup` doesn't appear.** Confirm you accepted the folder's **trust** + dialog, and that you're running Claude Code from inside the clone (the skill + is repo-scoped, not installed globally). Then restart Claude Code once — + skill discovery has known upstream flakiness on a fresh trust grant + ([anthropics/claude-code#45956](https://github.com/anthropics/claude-code/issues/45956), + [anthropics/claude-code#43092](https://github.com/anthropics/claude-code/issues/43092)). + If it still doesn't appear, `bash scripts/check.sh` will surface targeted + fixes for common environment issues. +- **`check.sh --post` shows a `FAIL:` line.** A managed artifact is missing + from your config dir — re-run `/setup` to install it. +- **`check.sh --post` shows a `WARN: ... ignore if you declined this merge`.** + This is informational, not an error — it means you chose not to merge + `settings.json` or `CLAUDE.md`, and the check is just confirming that. +- **A hook is blocking legitimate work.** See the `protect-secrets` note + below. + +**`protect-secrets` self-lockout note**: once installed, the `protect-secrets` +hook blocks Claude Code itself from writing/editing files matching secret +patterns (`.env`, `*credentials*`, `*.pem`, SSH/AWS/GCP key paths, etc.) — and +it applies in **every** project you use Claude Code in afterward, not just this +one, because it's wired into your global `~/.claude/settings.json`. If it +blocks something you legitimately need to edit, that settings file is yours: +edit or remove the hook entry directly. Re-running `/setup` later will offer +to re-merge it, but it will never force the merge back in without your +consent. + +**Plugin packaging (future work)**: a Claude Code plugin is a plausible future +distribution mechanism for this setup. Today's clone-and-use + `/setup` +approach was chosen deliberately instead — current plugin docs impose +verbatim-file limits that don't fit this repo's merge-not-clobber model for +`settings.json`/`CLAUDE.md`. + +**Backups accumulate**: backup run directories under +`~/.claude/backups/claude-code-setup/` are never auto-deleted (there's no +pruning script yet). They're cheap to keep and are what makes uninstall +deterministic, but feel free to delete old ones by hand once you're confident +you won't need to restore from them. + +## 13. What to adapt vs keep **Yours to replace**: the `Identity` and `Stack Preferences` blocks in `CLAUDE.md` (marked "example — replace with your own"), the generic example diff --git a/WORKFLOW.md b/WORKFLOW.md index ec08850..b90433c 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -6,6 +6,13 @@ Documentation for the `/design` -> `/build` -> `/ship` workflow. Installed at `~ ## Quick Start +**Install**: clone the repo, open Claude Code in it, then run `/setup` — the +setup skill checks prerequisites, installs agents/skills/hooks/rules, and merges +`settings.json`/`CLAUDE.md` with your existing config (dry-run + consent at each +step; see the README's Quick Start section for the full flow). + +**Use**: + ``` /design -> iterates with research until you approve @@ -35,7 +42,7 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y | researcher | `sonnet` agent, read-only, web tools | `~/.claude/agents/researcher.md` | | implementer | `sonnet` default / `opus` per-call, writes code + atomic commit, no push | `~/.claude/agents/implementer.md` | | reviewer | `opus` agent, read-only, runs tests | `~/.claude/agents/reviewer.md` | -| design-reviewer | `opus` agent, read-only, doc/codebase fidelity (loop ≤3) | `~/.claude/agents/design-reviewer.md` | +| design-reviewer | `opus` agent, read-only, doc/codebase fidelity (loops until findings converge, escalates after 5) | `~/.claude/agents/design-reviewer.md` | | direction-reviewer | `opus` agent (effort xhigh), read-only, premise/direction (once) | `~/.claude/agents/direction-reviewer.md` | | Branch protection | Hook: blocks push *to* main/master + force push (by destination, not branch name) | `~/.claude/hooks/protect-branches.sh` | | Secrets protection | Hook: blocks edits to .env/keys/credentials/secrets (broadened) | `~/.claude/hooks/protect-secrets.sh` | @@ -57,7 +64,7 @@ Two human checkpoints: **after design** (you approve the plan), **before PR** (y - Only structured summaries return to main context (~500-1K tokens each) - Raw research (10-50K tokens) stays in subagent context 4. Synthesizes a design draft **and** `-tasks.md` (adaptive task count — 1 to many, never a fixed range; each task: disjoint files, assigned model, verification, one atomic Conventional commit) -5. **design-reviewer** loop (≤3, fresh context each pass) for consistency/codebase-fit/best-practices, then **direction-reviewer** once (`opus`, premise/problem-fit) +5. **design-reviewer** loop (fresh context each pass, continues until findings converge, escalates after 5 iterations) for consistency/codebase-fit/best-practices, then **direction-reviewer** once (`opus`, premise/problem-fit) 6. **STOPS for your approval** — presents both verdicts + tasks path; this is the exit-plan-mode boundary **Iteration**: feedback regenerates the design *and* `-tasks.md`, re-runs the review loop + direction review, and stops again at a single checkpoint. @@ -119,8 +126,10 @@ never lands process-rule edits inside the feature PR. | post-design checkpoint | — | **The exit-plan-mode boundary.** You approve, then leave plan mode | | `/build`, `/ship` | **execute** (default/acceptEdits) | `disable-model-invocation: true`; run explicitly after approval | -`defaultMode` stays `"plan"` globally — the plan-first guarantee is preserved; the -transition is driven by the checkpoint, not by weakening the default. +The shipped template sets `defaultMode: "auto"` — a convenience key, deliberately +withheld from merges into existing configs. The plan-first guarantee comes from +`/design` running in plan mode and the post-design checkpoint being the exit-plan-mode +boundary, not from a global `"plan"` default. ## Model Usage Map @@ -170,7 +179,7 @@ top balanced level). - **Output**: PASS/NEEDS WORK verdict, issues list, test results, design adherence ### design-reviewer (`opus`) -- **Purpose**: Pre-code fidelity review — consistency, codebase-fit, best-practices, research-ignored (the ≤3 loop) +- **Purpose**: Pre-code fidelity review — consistency, codebase-fit, best-practices, research-ignored (loops until findings converge, escalates after 5 iterations) - **Tools**: Read, Glob, Grep, web/exa/context7; **Cannot**: Write, Edit - **Output**: Strict JSON verdict (PASS/NEEDS_WORK) + issues + suggested_fixes @@ -193,7 +202,7 @@ All hooks are in `~/.claude/settings.json` and `~/.claude/hooks/`. They provide | protect-secrets.sh | PreToolUse (Write\|Edit) | Blocks edits to .env/.envrc, credentials, secrets, keys (.pem/.key/.p8/.pfx/.jks/.keystore), id_rsa, .aws/.ssh/.gnupg, service-account/gcp json, Config.swift | | orchestrator-delegate-guard.sh | PreToolUse (Write\|Edit) | While `/build` active, blocks orchestrator (main-thread) source edits; **session-scoped** (`/tmp/claude-orchestrator-active.$SESSION_ID`) with TTL self-heal | | syntax-check.sh | PostToolUse (Write\|Edit) | Multi-language check (py/js/sh/json/swift), surfaces real errors; never blocks | -| Notification | Notification event | macOS notification when Claude needs attention | +| Notification | Notification event | macOS notification when Claude needs attention (macOS-only, uses `osascript`) | **How hooks work**: - `PreToolUse` hooks run BEFORE a tool executes. Exit 2 = blocked. Exit 0 = allowed. @@ -257,7 +266,7 @@ These have ~70% compliance. For critical rules (branch protection, secrets), hoo ### Key frontmatter fields - **Skills**: `name`, `description`, `allowed-tools` (pre-approval), `disable-model-invocation`, `argument-hint` -- **Agents**: `name`, `description`, `model`, `tools`, `disallowedTools`, `maxTurns`, `skills`, `mcpServers`, `memory`, `permissionMode`, `color` (named colors only) +- **Agents**: `name`, `description`, `model`, `effort` (sets the subagent's reasoning effort tier), `tools`, `disallowedTools`, `maxTurns`, `skills`, `mcpServers`, `memory`, `permissionMode`, `color` (named colors only) --- diff --git a/agents/Explore.md b/agents/Explore.md index 8f35e4f..b3fee96 100644 --- a/agents/Explore.md +++ b/agents/Explore.md @@ -6,6 +6,9 @@ tools: - Read - Glob - Grep +disallowedTools: + - Write + - Edit color: gray --- diff --git a/agents/design-reviewer.md b/agents/design-reviewer.md index 7e99b3b..8c1a393 100644 --- a/agents/design-reviewer.md +++ b/agents/design-reviewer.md @@ -2,7 +2,7 @@ name: design-reviewer description: "Reviews a finalized design draft against the real codebase, current web best practices, and internal consistency. Returns a strict JSON verdict. Read-only — cannot modify files. Used by the /design auto-review loop." model: opus -effort: high +effort: xhigh maxTurns: 25 skills: - search diff --git a/agents/researcher.md b/agents/researcher.md index 1bb55b2..1f86363 100644 --- a/agents/researcher.md +++ b/agents/researcher.md @@ -2,6 +2,7 @@ name: researcher description: "Web research agent. Searches docs, best practices, and prior art using the 3-angle search methodology. Returns structured findings with citations. Read-only — cannot modify files." model: sonnet +effort: high maxTurns: 30 skills: - search @@ -16,6 +17,9 @@ tools: - mcp__exa__crawling_exa - mcp__context7__resolve-library-id - mcp__context7__query-docs +disallowedTools: + - Write + - Edit mcpServers: - exa - context7 diff --git a/agents/reviewer.md b/agents/reviewer.md index 11f0407..60e343e 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -2,6 +2,7 @@ name: reviewer description: "Code review agent. Reviews changes for quality, correctness, and convention adherence. Read-only — cannot modify files." model: opus +effort: xhigh maxTurns: 20 tools: - Read diff --git a/hooks/orchestrator-delegate-guard.sh b/hooks/orchestrator-delegate-guard.sh index f7f6a5c..225885f 100755 --- a/hooks/orchestrator-delegate-guard.sh +++ b/hooks/orchestrator-delegate-guard.sh @@ -53,9 +53,16 @@ fi FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty') # The orchestrator may still touch its own plan/design drafts and scratch space. +# When CLAUDE_CONFIG_DIR is set (adopters using a non-default config location), +# its plans/ dir is editable too, alongside the default $HOME/.claude/plans/. case "$FILE" in "$HOME"/.claude/plans/*|/tmp/*|"") exit 0 ;; esac +if [ -n "$CLAUDE_CONFIG_DIR" ]; then + case "$FILE" in + "$CLAUDE_CONFIG_DIR"/plans/*) exit 0 ;; + esac +fi -echo "Blocked: /build is active (session ${SID:-global}); the orchestrator must not edit source files directly. Spawn an implementer subagent for this change. Plan files under ~/.claude/plans/ remain editable." >&2 +echo "Blocked: /build is active (session ${SID:-global}); the orchestrator must not edit source files directly. Spawn an implementer subagent for this change. Plan files under ~/.claude/plans/ (or \$CLAUDE_CONFIG_DIR/plans/ if set) remain editable." >&2 exit 2 diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..0d4d617 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,298 @@ +#!/bin/bash +# scripts/check.sh — doctor-style prerequisite + post-install verifier. +# default mode : environment readiness with an explicit severity map. +# --post mode : verify managed artifacts landed under the resolved config dir. +# Aggregate exit: 0 PASS / 1 WARN / 2 FAIL (design v4 §2). Reads no stdin, never +# prompts. Human-readable lines on stdout. BSD-safe idioms only. + +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd -P )" +# shellcheck source=scripts/lib.sh +. "$SCRIPT_DIR/lib.sh" + +MANIFEST="$SCRIPT_DIR/manifest.txt" +CLAUDE_MD_MARKER='$' +END_RE='^$' + +usage() { + printf 'usage: merge-claude-md.sh (--dry-run|--apply) [--backup-dir ]\n' >&2 + exit "${1:-2}" +} + +# analyze_markers — set MK_BEGIN_COUNT / MK_END_COUNT and the first +# BEGIN/END line numbers (empty when none). BSD grep/cut/head only. +analyze_markers() { + local file="$1" bl el + bl="$( grep -nE "$BEGIN_RE" "$file" 2>/dev/null || true )" + el="$( grep -nE "$END_RE" "$file" 2>/dev/null || true )" + MK_BEGIN_COUNT="$( printf '%s' "$bl" | grep -c . || true )" + MK_END_COUNT="$( printf '%s' "$el" | grep -c . || true )" + MK_BEGIN_LINE="$( printf '%s\n' "$bl" | head -n 1 | cut -d: -f1 )" + MK_END_LINE="$( printf '%s\n' "$el" | head -n 1 | cut -d: -f1 )" +} + +# extract_source_block — write the repo's managed block (inclusive of markers) +# into $BLOCK_FILE. A malformed source is a packaging bug -> internal error. +extract_source_block() { + analyze_markers "$SOURCE_MD" + if [ "$MK_BEGIN_COUNT" != "1" ] || [ "$MK_END_COUNT" != "1" ] \ + || [ "$MK_BEGIN_LINE" -ge "$MK_END_LINE" ]; then + ccs_die 2 "packaging error: $SOURCE_MD has no single managed workflow-rules block" + fi + awk -v b="$MK_BEGIN_LINE" -v e="$MK_END_LINE" 'NR>=b && NR<=e' "$SOURCE_MD" > "$BLOCK_FILE" +} + +refuse_malformed() { + ccs_die 1 "malformed managed block in $TARGET_MD (found $MK_BEGIN_COUNT BEGIN / $MK_END_COUNT END marker(s)). Repair manually: keep exactly one matching '' ... '' pair with BEGIN before END, OR delete both marker lines to re-append a fresh block, then re-run." +} + +build_append() { + ACTION=merged + cp "$TARGET_MD" "$RESULT_FILE" + # Ensure a trailing newline, then exactly one blank-line separator. + if [ -s "$RESULT_FILE" ] && [ -n "$( tail -c 1 "$RESULT_FILE" )" ]; then + printf '\n' >> "$RESULT_FILE" + fi + printf '\n' >> "$RESULT_FILE" + cat "$BLOCK_FILE" >> "$RESULT_FILE" +} + +build_replace() { + ACTION=merged + awk -v b="$MK_BEGIN_LINE" 'NR "$RESULT_FILE" + cat "$BLOCK_FILE" >> "$RESULT_FILE" + awk -v e="$MK_END_LINE" 'NR>e' "$TARGET_MD" >> "$RESULT_FILE" +} + +# build_result — decide the action and materialize $RESULT_FILE from $TARGET_MD. +build_result() { + refuse_symlink "$TARGET_MD" + if [ ! -e "$TARGET_MD" ]; then + ACTION=merged + cp "$BLOCK_FILE" "$RESULT_FILE" + return 0 + fi + analyze_markers "$TARGET_MD" + if [ "$MK_BEGIN_COUNT" = "0" ] && [ "$MK_END_COUNT" = "0" ]; then + build_append + elif [ "$MK_BEGIN_COUNT" = "1" ] && [ "$MK_END_COUNT" = "1" ] \ + && [ "$MK_BEGIN_LINE" -lt "$MK_END_LINE" ]; then + build_replace + else + refuse_malformed + fi +} + +do_dry_run() { + if [ -e "$TARGET_MD" ] && cmp -s "$TARGET_MD" "$RESULT_FILE"; then + ccs_status unchanged "$TARGET_MD" + return 0 + fi + ccs_status "$ACTION" "$TARGET_MD" + local cur="/dev/null" + [ -e "$TARGET_MD" ] && cur="$TARGET_MD" + diff -u -L "a/CLAUDE.md" -L "b/CLAUDE.md" "$cur" "$RESULT_FILE" || true +} + +do_apply() { + if [ -e "$TARGET_MD" ] && cmp -s "$TARGET_MD" "$RESULT_FILE"; then + ccs_status unchanged "$TARGET_MD" + return 0 + fi + local run_dir backup="" + if [ -n "$BACKUP_DIR" ]; then + run_dir="$( ccs_backup_run_dir --backup-dir "$BACKUP_DIR" )" + else + run_dir="$( ccs_backup_run_dir )" + fi + if [ -e "$TARGET_MD" ]; then + backup="$run_dir/CLAUDE.md" + cp "$TARGET_MD" "$backup" + fi + ccs_atomic_install "$RESULT_FILE" "$TARGET_MD" + ccs_receipt_append "$run_dir" "$ACTION" "$TARGET_MD" "$backup" + ccs_status "$ACTION" "$TARGET_MD" +} + +main() { + local mode="" + BACKUP_DIR="" + while [ $# -gt 0 ]; do + case "$1" in + --dry-run) if [ -n "$mode" ]; then usage 2; fi; mode="dry-run" ;; + --apply) if [ -n "$mode" ]; then usage 2; fi; mode="apply" ;; + --backup-dir) + shift + if [ $# -eq 0 ]; then usage 2; fi + BACKUP_DIR="$1" ;; + -h|--help) + printf 'usage: merge-claude-md.sh (--dry-run|--apply) [--backup-dir ]\n' + exit 0 ;; + *) usage 2 ;; + esac + shift + done + [ -n "$mode" ] || usage 2 + + WORK="$( mktemp -d "${TMPDIR:-/tmp}/ccs-claudemd.XXXXXX" )" + trap 'rm -rf "$WORK"' EXIT + BLOCK_FILE="$WORK/block" + RESULT_FILE="$WORK/result" + + extract_source_block + build_result + + if [ "$mode" = "apply" ]; then + do_apply + else + do_dry_run + fi +} + +main "$@" diff --git a/scripts/merge-settings.sh b/scripts/merge-settings.sh new file mode 100644 index 0000000..63369b5 --- /dev/null +++ b/scripts/merge-settings.sh @@ -0,0 +1,268 @@ +#!/bin/bash +# scripts/merge-settings.sh — the SOLE writer of $CLAUDE_DIR/settings.json. +# +# Missing target -> write the repo template VERBATIM (design M3: the +# example IS the intended fresh-adopter config; the +# _comment_* keys are instructional and ship as-is). +# Existing target -> deterministic jq merge LIMITED to exactly two areas: +# `.hooks` and `.permissions.allow`. Every other key the +# adopter has (model, statusLine, defaultMode, env, any +# personal key) is UNTOUCHED and is never added from the +# template either (deliberately narrow). +# +# Hook reconciliation is BASENAME-KEYED. An existing hook command is classified by +# TOKEN-ANCHORED matching: the command string is split on whitespace, each token is +# stripped of surrounding quotes, and the entry is MANAGED only if some token's +# basename is EXACTLY a manifest-listed hook FILENAME (protect-branches.sh, +# protect-secrets.sh, orchestrator-delegate-guard.sh, syntax-check.sh — derived +# from manifest.txt). This matches both the config-dir-aware and old `~/.claude` +# invocation forms while never misreading a filename mentioned only in echo/log +# text. Managed entries are REPLACED by the template's current entry for that +# filename (landing command-string upgrades WITHOUT duplication). Hook entries +# referencing NO manifest filename are FOREIGN and preserved verbatim. Template +# hook entries not already present are added. `.permissions.allow` is a concat + +# ORDER-PRESERVING dedup (first occurrence wins; adopter entries are never dropped +# and the array is never reordered, which keeps the fresh-verbatim write a fixed +# point across repeat runs). +# +# Semantics notes: +# * Scalar-wins / null-safe: this script never uses recursive object merge, so a +# null template value can never delete an adopter key. +# * Malformed nested shapes — a non-object `.hooks` / `.permissions`, a +# non-array `.permissions.allow`, a non-array `.hooks.` value, a +# non-object element of one, a non-array inner `.hooks` list, a non-object +# inner hook entry, or a non-string `.command` on one — are REJECTED up front +# with an explicit exit-1 refusal on stderr in BOTH modes, so a bad shape +# never reaches jq as a raw exit-5 crash. +# * The unchanged/write-skip decision is SEMANTIC (sorted-compact canonical +# compare of target vs merged), not raw-byte: formatting-only differences (e.g. +# the template's hand-authored blank lines) never trigger a phantom rewrite. +# +# Global contract (design v4): Bash 3.2; set -euo pipefail; never reads stdin / +# never prompts; CCS-STATUS:: + diffs (dry-run only) on stdout; +# refusals on stderr; atomic writes; jq fail-closed. Exit 0 success (incl. the +# CCS-STATUS:unchanged no-op) / 1 refusal-partial / 2 usage-internal. + +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd -P )" +# shellcheck source=scripts/lib.sh +. "$SCRIPT_DIR/lib.sh" + +SOURCE="$REPO_ROOT/settings.example.json" +MANIFEST="$SCRIPT_DIR/manifest.txt" +TARGET="$CLAUDE_DIR/settings.json" +MERGED_TMP="" +trap 'rm -f "$MERGED_TMP"' EXIT + +usage() { printf 'usage: merge-settings.sh (--dry-run | --apply) [--backup-dir ]\n'; } +usage_err() { usage >&2; exit 2; } + +# ccs_hook_names_json — JSON array of the managed hook basenames from the manifest +# (hooks/*.sh lines). Basenames are plain .sh filenames, so raw quoting is safe. +ccs_hook_names_json() { + local rel base out="" + while IFS= read -r rel || [ -n "$rel" ]; do + case "$rel" in + hooks/*.sh) + base="${rel#hooks/}" + if [ -z "$out" ]; then out="\"$base\""; else out="$out,\"$base\""; fi + ;; + esac + done < "$MANIFEST" + printf '[%s]' "$out" +} + +# The merge program. Reconciliation is basename-keyed; see the file header. The +# $-prefixed tokens below are jq variables, not shell expansions (SC2016). +# shellcheck disable=SC2016 +JQ_MERGE=' +def basename_of: sub(".*/"; ""); +def strip_surround_quotes: + ([34,39] | implode) as $q + | sub("^[" + $q + "]+"; "") | sub("[" + $q + "]+$"; ""); +def is_managed($c): + any($c | splits("[[:space:]]+"); + (strip_surround_quotes | basename_of) as $b + | ($names | index($b)) != null); +def dedup: reduce .[] as $x ([]; if any(.[]; . == $x) then . else . + [$x] end); +def strip_managed: + if . == null then {} + else with_entries( + .value = ((.value // []) + | map(.hooks = ((.hooks // []) | map(select(is_managed(.command // "") | not)))) + | map(select((.hooks | length) > 0))) + ) + | with_entries(select((.value | length) > 0)) + end; +def add_group($g): + (map(.matcher) | index($g.matcher)) as $i + | if $i == null then . + [$g] + else (.[$i]) as $ex + | ($g.hooks | map(select(. as $e | ($ex.hooks | any(. == $e)) | not))) as $new + | .[$i].hooks = ($ex.hooks + $new) + end; +def merge_hooks($tpl): + ((. // {}) | strip_managed) as $base + | (reduce ($tpl | to_entries[]) as $te ({}; + .[$te.key] = (($base[$te.key] // []) | reduce ($te.value[]) as $g (.; add_group($g))) + )) as $withtpl + | reduce ($base | keys_unsorted[]) as $k ($withtpl; + if ($tpl | has($k)) then . else .[$k] = $base[$k] end); +($template[0]) as $t +| .hooks = ((.hooks // {}) | merge_hooks($t.hooks // {})) +| .permissions = (.permissions // {}) +| .permissions.allow = (((.permissions.allow // []) + ($t.permissions.allow // [])) | dedup) +' + +# ccs_build_merged — emit the merged JSON for an existing object target. +ccs_build_merged() { + jq --indent 2 --slurpfile template "$SOURCE" --argjson names "$( ccs_hook_names_json )" \ + "$JQ_MERGE" "$1" +} + +# ccs_validate_object — refuse (exit 1) unparseable / non-object targets +# and malformed nested shapes. A syntactically-valid object whose `.hooks` / +# `.permissions` is not an object, whose `.permissions.allow` is not an array, OR +# any NESTED shape the merge jq actually walks (every `.hooks.` value; each +# element of it; that element's own `.hooks` inner list, if present; and each +# inner entry's `.command`, where the merge does a string split/match on it) is +# rejected here (both modes) so a bad shape never reaches jq as a raw exit-5 +# crash — only the documented 0/1/2 exit contract (see header) is ever observed. +ccs_validate_object() { + local kind msg + if ! jq empty "$1" >/dev/null 2>&1; then + ccs_die 1 "target exists but is not valid JSON: $1" + fi + kind="$( jq -r 'type' "$1" )" + if [ "$kind" != "object" ]; then + ccs_die 1 "target is a JSON $kind, not an object: $1" + fi + msg="$( jq -r ' + (.hooks // null) as $h + | (.permissions // null) as $p + | def hookmsg: + ($h // {}) | to_entries[] + | .key as $ename + | if (.value | type) != "array" then + "field .hooks." + $ename + " must be a JSON array, not a " + (.value | type) + else + (.value[] | + if type != "object" then + "field .hooks." + $ename + "[] entry must be a JSON object, not a " + type + elif has("hooks") and ((.hooks | type) != "array") then + "field .hooks." + $ename + "[].hooks must be a JSON array, not a " + (.hooks | type) + else + (.hooks // [])[] | + if type != "object" then + "field .hooks." + $ename + "[].hooks[] entry must be a JSON object, not a " + type + elif has("command") and ((.command | type) != "string") then + "field .hooks." + $ename + "[].hooks[].command must be a JSON string, not a " + (.command | type) + else empty + end + end + ) + end; + if ($h != null and ($h | type) != "object") + then "field .hooks must be a JSON object, not a " + ($h | type) + elif ($p != null and ($p | type) != "object") + then "field .permissions must be a JSON object, not a " + ($p | type) + elif ($p != null and ($p.allow != null) and ($p.allow | type) != "array") + then "field .permissions.allow must be a JSON array, not a " + ($p.allow | type) + elif ($h != null) + then ([hookmsg] | first) // "" + else "" end' "$1" )" + if [ -n "$msg" ]; then + ccs_die 1 "$msg: $1" + fi +} + +# ccs_same_content — true when two JSON files are SEMANTICALLY identical +# (sorted-compact canonical form), so formatting-only differences never count as a +# change. Fails closed (non-equal) if either file will not canonicalize. +ccs_same_content() { + local ca cb + ca="$( jq -S -c . "$1" )" || return 1 + cb="$( jq -S -c . "$2" )" || return 1 + [ "$ca" = "$cb" ] +} + +# ccs_do_dry_run — preview only; unified diff of current -> merged, never mutates. +ccs_do_dry_run() { + if [ ! -e "$TARGET" ]; then + diff -u /dev/null "$SOURCE" || true + return 0 + fi + ccs_validate_object "$TARGET" + MERGED_TMP="$( mktemp "${TMPDIR:-/tmp}/ccs-settings.XXXXXX" )" + ccs_build_merged "$TARGET" > "$MERGED_TMP" + if ccs_same_content "$TARGET" "$MERGED_TMP"; then + ccs_status unchanged "$TARGET" + else + diff -u "$TARGET" "$MERGED_TMP" || true + fi +} + +# ccs_backup_dir_for — resolve the (optionally threaded) backup run dir. +ccs_backup_dir_for() { + if [ -n "$BACKUP_DIR" ]; then + ccs_backup_run_dir --backup-dir "$BACKUP_DIR" + else + ccs_backup_run_dir + fi +} + +# ccs_apply_fresh — no existing target: install the template verbatim (M3). +ccs_apply_fresh() { + local run_dir + run_dir="$( ccs_backup_dir_for )" + ccs_atomic_install "$SOURCE" "$TARGET" + ccs_receipt_append "$run_dir" merged "$TARGET" "" + ccs_status merged "$TARGET" +} + +# ccs_apply_existing — merge into an existing object target; no-op when unchanged. +ccs_apply_existing() { + local run_dir + ccs_validate_object "$TARGET" + MERGED_TMP="$( mktemp "${TMPDIR:-/tmp}/ccs-settings.XXXXXX" )" + ccs_build_merged "$TARGET" > "$MERGED_TMP" + if ccs_same_content "$TARGET" "$MERGED_TMP"; then + ccs_status unchanged "$TARGET" + return 0 + fi + run_dir="$( ccs_backup_dir_for )" + cp "$TARGET" "$run_dir/settings.json" + ccs_atomic_install "$MERGED_TMP" "$TARGET" + ccs_receipt_append "$run_dir" merged "$TARGET" "$run_dir/settings.json" + ccs_status merged "$TARGET" +} + +main() { + local mode="" + BACKUP_DIR="" + while [ $# -gt 0 ]; do + case "$1" in + --dry-run) if [ -n "$mode" ]; then usage_err; fi; mode="dry" ;; + --apply) if [ -n "$mode" ]; then usage_err; fi; mode="apply" ;; + --backup-dir) shift; if [ $# -eq 0 ]; then usage_err; fi; BACKUP_DIR="$1" ;; + -h|--help) usage; exit 0 ;; + *) usage_err ;; + esac + shift + done + if [ -z "$mode" ]; then usage_err; fi + + command -v jq >/dev/null 2>&1 || ccs_die 1 "jq is required but was not found on PATH" + refuse_symlink "$TARGET" + + if [ "$mode" = "dry" ]; then + ccs_do_dry_run + elif [ ! -e "$TARGET" ]; then + ccs_apply_fresh + else + ccs_apply_existing + fi +} + +main "$@" diff --git a/settings.example.json b/settings.example.json index 9b79a0d..d32b761 100644 --- a/settings.example.json +++ b/settings.example.json @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "bash ~/.claude/hooks/protect-branches.sh", + "command": "bash \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/protect-branches.sh\"", "if": "Bash(git *)", "timeout": 5 } @@ -39,12 +39,12 @@ "hooks": [ { "type": "command", - "command": "bash ~/.claude/hooks/protect-secrets.sh", + "command": "bash \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/protect-secrets.sh\"", "timeout": 5 }, { "type": "command", - "command": "bash ~/.claude/hooks/orchestrator-delegate-guard.sh", + "command": "bash \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/orchestrator-delegate-guard.sh\"", "timeout": 5 } ] @@ -56,7 +56,7 @@ "hooks": [ { "type": "command", - "command": "bash ~/.claude/hooks/syntax-check.sh", + "command": "bash \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/syntax-check.sh\"", "timeout": 15 } ] @@ -67,9 +67,9 @@ "matcher": "", "hooks": [ { - "_comment": "macOS-only (osascript). Cross-platform alternatives: notify-send (Linux) or terminal-notifier (macOS CLI wrapper) — swap the command for your OS.", + "_comment": "osascript is macOS-only. The command is guarded with a command -v check so it no-ops gracefully on Linux/other platforms. Cross-platform alternatives: notify-send (Linux) or terminal-notifier (macOS CLI wrapper) — swap the command for your OS.", "type": "command", - "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" + "command": "command -v osascript >/dev/null 2>&1 && osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"' || true" } ] } @@ -77,7 +77,7 @@ }, "statusLine": { - "_comment": "Reads the adopter's own local ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json) at runtime for account email, model, git branch, context window, and rate-limit usage. Requires jq.", + "_comment": "Reads the adopter's own local ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json) at runtime for account email, model, git branch, context window, and rate-limit usage. Requires jq. Note: the .oauthAccount field read from that JSON is an undocumented internal interface and may break in future Claude Code versions without notice.", "type": "command", "command": "input=$(cat); cfg=\"${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}\"; cfg=\"${cfg:-$HOME/.claude.json}\"; email=$(jq -r '.oauthAccount.emailAddress // empty' \"$cfg\" 2>/dev/null); model=$(echo \"$input\" | jq -r '.model.display_name // \"?\"'); branch=$(echo \"$input\" | jq -r '.git.branch // empty'); used=$(echo \"$input\" | jq -r '.context_window.used_percentage // empty'); in_tok=$(echo \"$input\" | jq -r '.context_window.total_input_tokens // empty'); out_tok=$(echo \"$input\" | jq -r '.context_window.total_output_tokens // empty'); five=$(echo \"$input\" | jq -r '.rate_limits.five_hour.used_percentage // empty'); week=$(echo \"$input\" | jq -r '.rate_limits.seven_day.used_percentage // empty'); bar=''; if [ -n \"$used\" ]; then u=$(printf '%.0f' \"$used\"); filled=$((u / 10)); empty=$((10 - filled)); bar='['; i=0; while [ $i -lt $filled ]; do bar=\"${bar}█\"; i=$((i+1)); done; i=0; while [ $i -lt $empty ]; do bar=\"${bar}░\"; i=$((i+1)); done; bar=\"${bar}] ${u}%\"; fi; tok=''; if [ -n \"$in_tok\" ]; then in_k=$((in_tok / 1000)); out_k=$((out_tok / 1000)); tok=\"${in_k}K in / ${out_k}K out\"; fi; out=\"${email:+$email | }$model\"; [ -n \"$branch\" ] && out=\"$out | $branch\"; [ -n \"$bar\" ] && out=\"$out | $bar\"; [ -n \"$tok\" ] && out=\"$out | $tok\"; [ -n \"$five\" ] && out=\"$out | 5h: $(printf '%.0f' \"$five\")%\"; [ -n \"$week\" ] && out=\"$out | 7d: $(printf '%.0f' \"$week\")%\"; echo \"$out\"" } diff --git a/skills/build/SKILL.md b/skills/build/SKILL.md index 0967219..fdcf49c 100644 --- a/skills/build/SKILL.md +++ b/skills/build/SKILL.md @@ -2,7 +2,7 @@ name: build description: "Implement an approved design by orchestrating subagents. Use when the user says /build, 'implement this', 'build this', or approves a /design output. Main agent acts as orchestrator: decomposes work, delegates to implementer subagent, reviews via reviewer subagent, iterates up to 3 times, then stops for human approval." disable-model-invocation: true -argument-hint: -tasks.md | 'continue'> +argument-hint: -tasks.md> allowed-tools: - Read - Glob @@ -16,6 +16,8 @@ allowed-tools: - Bash(swift:*) - Bash(xcodebuild:*) - Bash(gh:*) + - Bash(touch /tmp/claude-orchestrator-active*) + - Bash(rm -f /tmp/claude-orchestrator-active*) --- # Build Workflow — Orchestrator Pattern @@ -89,8 +91,9 @@ Set the Agent call's `model` to the task's `model:` field (`sonnet` default, `op the task says so). Spawn independent tasks in one turn, synchronously; dependent tasks sequentially (per Phase 1). -For a cost-bounded run, set a `task_budget` (advisory, beta header -`task-budgets-2026-03-13`, minimum 20k tokens) on the implementer spawn. Skip it for +For a cost-bounded run, set a `task_budget` (advisory, minimum 20k tokens; use whichever +task-budgets beta header is supported at run time — check current Claude Code release +notes rather than assuming a fixed value) on the implementer spawn. Skip it for quality-critical tasks where the work should not be scoped to a budget. ## Phase 2→3 Gate: Verify against ground truth (orchestrator, main thread) diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md index 7af4568..351de81 100644 --- a/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -16,6 +16,7 @@ allowed-tools: - Edit - Glob - Grep + - Agent --- # Ship Changes diff --git a/tests/check.bats b/tests/check.bats new file mode 100644 index 0000000..d918440 --- /dev/null +++ b/tests/check.bats @@ -0,0 +1,325 @@ +#!/usr/bin/env bats +# tests/check.bats — coverage for scripts/check.sh (default severity map, version +# floor tiers, --post verification) and the enforced manifest (C3). + +load test_helper + +setup() { + ccs_setup_sandbox + mkdir -p "$CLAUDE_CONFIG_DIR" +} + +# --- local fixtures ---------------------------------------------------------- + +# _stub_claude [mcp_server...] — fake `claude` that answers --version +# and `mcp list`. Any listed servers appear in the mcp output. +_stub_claude() { + local ver="$1" + shift + local file="$CCS_STUB_BIN/claude" m + { + printf '#!/usr/bin/env bash\n' + printf 'case "$1" in\n' + printf ' --version) printf "%%s\\n" %q ;;\n' "$ver (Claude Code)" + printf ' mcp) ' + for m in "$@"; do printf 'printf "%%s\\n" %q; ' "$m"; done + printf ':;;\n' + printf ' *) : ;;\n' + printf 'esac\n' + } > "$file" + chmod +x "$file" +} + +_set_git_identity() { + git config --global user.name "Test User" + git config --global user.email "test@example.com" +} + +# Everything present, healthy version, authed gh, identity set, MCP present. +_stub_clean_default() { + ccs_make_stub jq 0 + _stub_claude "2.1.200" context7 exa + ccs_make_stub gh 0 + _set_git_identity +} + +# Install every manifest artifact under $1 as an empty file (hooks executable). +_populate_at() { + local base="$1" rel + while IFS= read -r rel || [ -n "$rel" ]; do + [ -n "$rel" ] || continue + mkdir -p "$( dirname "$base/$rel" )" + : > "$base/$rel" + case "$rel" in hooks/*.sh) chmod +x "$base/$rel" ;; esac + done < "$REPO_ROOT/scripts/manifest.txt" +} + +_populate_claude_dir() { _populate_at "$CLAUDE_CONFIG_DIR"; } + +# _write_settings_with_hooks — settings.json whose four hook +# commands embed /.sh (filename match is what --post checks). +_write_settings_with_hooks() { + local prefix="$1" + cat > "$CLAUDE_CONFIG_DIR/settings.json" < "$CLAUDE_CONFIG_DIR/CLAUDE.md" <<'EOF' +# Managed + +rules + +EOF +} + +# --- default mode: severity map --------------------------------------------- + +@test "default: clean environment passes (exit 0)" { + ccs_isolate_path + _stub_clean_default + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT: PASS"* ]] +} + +@test "default: missing jq fails (exit 2)" { + ccs_isolate_path + _stub_claude "2.1.200" context7 exa + ccs_make_stub gh 0 + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"jq not found"* ]] +} + +@test "default: missing claude fails (exit 2)" { + ccs_isolate_path + ccs_make_stub jq 0 + ccs_make_stub gh 0 + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"claude CLI not found"* ]] +} + +@test "default: missing gh warns (exit 1)" { + ccs_isolate_path + ccs_make_stub jq 0 + _stub_claude "2.1.200" context7 exa + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"gh not found"* ]] +} + +@test "default: unset git identity warns (exit 1)" { + ccs_isolate_path + ccs_make_stub jq 0 + _stub_claude "2.1.200" context7 exa + ccs_make_stub gh 0 + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"user.name/user.email not set"* ]] +} + +@test "default: absent MCP servers warn (exit 1)" { + ccs_isolate_path + ccs_make_stub jq 0 + _stub_claude "2.1.200" + ccs_make_stub gh 0 + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"MCP server absent: context7"* ]] +} + +# --- default mode: version floor tiers -------------------------------------- + +@test "version below 2.1.53 fails and states the CVE" { + ccs_isolate_path + ccs_make_stub jq 0 + _stub_claude "2.1.40" context7 exa + ccs_make_stub gh 0 + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"CVE-2026-33068"* ]] +} + +@test "version below 2.1.132 warns (sentinel session isolation)" { + ccs_isolate_path + ccs_make_stub jq 0 + _stub_claude "2.1.100" context7 exa + ccs_make_stub gh 0 + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"sentinel session isolation"* ]] +} + +@test "version below 2.1.198 warns (Explore model override)" { + ccs_isolate_path + ccs_make_stub jq 0 + _stub_claude "2.1.150" context7 exa + ccs_make_stub gh 0 + _set_git_identity + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"Explore agent model override"* ]] +} + +@test "version >= 2.1.198 is clean (no warnings)" { + ccs_isolate_path + _stub_clean_default + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 0 ] + [[ "$output" != *"WARN"* ]] +} + +# --- default mode: config dir integrity ------------------------------------- + +@test "regular-file config dir fails (exit 2)" { + ccs_isolate_path + _stub_clean_default + rm -rf "$CLAUDE_CONFIG_DIR" + printf 'x' > "$CLAUDE_CONFIG_DIR" + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"not a directory"* ]] +} + +@test "source == destination is refused (exit 2)" { + ccs_isolate_path + _stub_clean_default + export CLAUDE_CONFIG_DIR="$REPO_ROOT" + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"repo root"* ]] +} + +@test "relative CLAUDE_CONFIG_DIR is normalized to absolute and honored" { + ccs_isolate_path + _stub_clean_default + mkdir -p "$SANDBOX/relcfg" + cd "$SANDBOX" + export CLAUDE_CONFIG_DIR="relcfg" + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"/relcfg"* ]] +} + +@test "symlinked config dir is accepted (checks pass through the link)" { + ccs_isolate_path + _stub_clean_default + mkdir -p "$SANDBOX/realcfg" + rm -rf "$CLAUDE_CONFIG_DIR" + ln -s "$SANDBOX/realcfg" "$CLAUDE_CONFIG_DIR" + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 0 ] +} + +# --- --post mode ------------------------------------------------------------- + +@test "post: fully installed artifacts pass (exit 0)" { + _populate_claude_dir + _write_settings_with_hooks "\${CLAUDE_CONFIG_DIR:-\$HOME/.claude}/hooks" + _write_managed_claude_md + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT: PASS"* ]] +} + +@test "post: hooks by a different literal path still pass (filename match)" { + _populate_claude_dir + _write_settings_with_hooks "/some/totally/different/path" + _write_managed_claude_md + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 0 ] + [[ "$output" == *"references all managed hooks"* ]] +} + +@test "post: settings missing a managed hook filename fails (exit 2)" { + _populate_claude_dir + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ "hooks": { "PreToolUse": [ { "hooks": [ + { "command": "bash x/protect-branches.sh" }, + { "command": "bash x/protect-secrets.sh" }, + { "command": "bash x/syntax-check.sh" } ] } ] } } +EOF + _write_managed_claude_md + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 2 ] + [[ "$output" == *"missing managed hook"* ]] +} + +@test "post: a missing managed artifact is detected (exit 2)" { + _populate_claude_dir + _write_settings_with_hooks "x" + _write_managed_claude_md + rm -f "$CLAUDE_CONFIG_DIR/hooks/protect-secrets.sh" + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 2 ] + [[ "$output" == *"missing managed artifact: hooks/protect-secrets.sh"* ]] +} + +@test "post: artifacts installed to the wrong dir are detected (exit 2)" { + _populate_at "$SANDBOX/other/.claude" + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 2 ] + [[ "$output" == *"missing managed artifact"* ]] +} + +@test "post: absent settings.json warns as a declined merge (exit 1)" { + _populate_claude_dir + _write_managed_claude_md + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 1 ] + [[ "$output" == *"ignore if you declined this merge"* ]] +} + +@test "post: valid settings.json with no managed hook entries warns (exit 1)" { + _populate_claude_dir + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ "hooks": { "PreToolUse": [ { "hooks": [ + { "command": "bash x/some-other-hook.sh" } ] } ] } } +EOF + _write_managed_claude_md + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 1 ] + [[ "$output" == *"WARN"* ]] + [[ "$output" == *"ignore if you declined this merge"* ]] +} + +@test "post: setup skill present under config dir fails the invariant (exit 2)" { + _populate_claude_dir + _write_settings_with_hooks "x" + _write_managed_claude_md + mkdir -p "$CLAUDE_CONFIG_DIR/skills/setup" + : > "$CLAUDE_CONFIG_DIR/skills/setup/SKILL.md" + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 2 ] + [[ "$output" == *"must never be installed"* ]] +} + +# --- manifest enforcement (C3) ---------------------------------------------- + +@test "manifest.txt equals git ls-files agents/ hooks/ rules/ skills/" { + local expected actual + expected="$( cd "$REPO_ROOT" && git ls-files agents/ hooks/ rules/ skills/ )" + actual="$( cat "$REPO_ROOT/scripts/manifest.txt" )" + [ "$actual" = "$expected" ] +} diff --git a/tests/e2e.bats b/tests/e2e.bats new file mode 100644 index 0000000..c8d8d38 --- /dev/null +++ b/tests/e2e.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# tests/e2e.bats — M4 end-to-end integration: the full adopter journey +# (check -> install -> merge-settings -> merge-claude-md -> check --post), +# run TWICE to prove the whole pipeline is byte-stable on repeat runs. +# +# CI reality: the `claude` binary is ABSENT on both CI runners (no real Claude +# Code install). Rather than depend on that being true of whatever machine runs +# this suite, `_hide_claude` (below) strips any real `claude` from PATH so +# `check.sh` (default mode) deterministically fails for that one reason (exit 2, +# "claude CLI not found") everywhere it runs — the point of this suite is to +# exercise the REAL install/merge pipeline end to end, not to fabricate a +# healthy doctor report by stubbing every tool. + +load test_helper + +# _hide_claude — strip any PATH entry that resolves a real `claude` binary, so +# "claude is absent" is asserted deterministically on every machine (CI runners +# and a contributor's laptop alike), rather than depending on the runner +# happening to lack a Claude Code install. Every other real tool (jq, git, +# bash...) stays reachable — this is NOT ccs_isolate_path's full sandboxing. +_hide_claude() { + local newpath="" dir oldifs + oldifs="$IFS" + IFS=':' + for dir in $PATH; do + [ -x "$dir/claude" ] && continue + newpath="${newpath:+$newpath:}$dir" + done + IFS="$oldifs" + PATH="$newpath" + export PATH +} + +setup() { + ccs_setup_sandbox + _hide_claude + # Sandbox git identity for hygiene: install/merge scripts shell out to git for + # provenance (ccs_repo_sha uses -C "$SCRIPT_DIR", which resolves inside the + # repo clone itself, so this is not strictly required for that call) but a + # scoped identity keeps any incidental git usage inside the sandbox honest and + # avoids ever touching the invoking user's real ~/.gitconfig. + git config --global user.name "CCS E2E" + git config --global user.email "ccs-e2e@example.com" +} + +# _run_pipeline — one full adopter journey. Sets BACKUP_DIR as a side effect +# (parsed from install's CCS-STATUS:backup-dir: line) so the caller can thread +# it into the merge scripts, exactly as the /setup skill does. +_run_pipeline() { + # 1. pre-install check: claude is absent in CI -> exit 2, specific reason. + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"claude CLI not found"* ]] + + # 2. real install. + run bash "$REPO_ROOT/scripts/install.sh" + [ "$status" -eq 0 ] + local backup_line + backup_line="$( printf '%s\n' "$output" | grep -m1 'CCS-STATUS:backup-dir:' )" + [ -n "$backup_line" ] + BACKUP_DIR="${backup_line#CCS-STATUS:backup-dir:}" + [ -d "$BACKUP_DIR" ] + + # 3. merge-settings, threaded into the same run dir. + run bash "$REPO_ROOT/scripts/merge-settings.sh" --apply --backup-dir "$BACKUP_DIR" + [ "$status" -eq 0 ] + + # 4. merge-claude-md, threaded into the same run dir. + run bash "$REPO_ROOT/scripts/merge-claude-md.sh" --apply --backup-dir "$BACKUP_DIR" + [ "$status" -eq 0 ] + + # 5. post-install check must pass now that every managed artifact landed. + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT: PASS"* ]] +} + +@test "full happy path: check -> install -> merge-settings -> merge-claude-md -> check --post (exit 0)" { + _run_pipeline + + # Receipt exists in the run dir with plausible action lines from every step. + [ -f "$BACKUP_DIR/receipt.txt" ] + grep -qE $'^installed\t' "$BACKUP_DIR/receipt.txt" + grep -qE $'^merged\t' "$BACKUP_DIR/receipt.txt" +} + +@test "repeat run: every step is a byte-stable no-op and --post still passes" { + _run_pipeline + local claude_md_sum settings_sum + claude_md_sum="$( cksum < "$CLAUDE_CONFIG_DIR/CLAUDE.md" )" + settings_sum="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + + # 1. repeat check (still no claude binary -> same specific failure). + run bash "$REPO_ROOT/scripts/check.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"claude CLI not found"* ]] + + # 2. repeat install: no installs/overwrites, every manifest entry unchanged. + run bash "$REPO_ROOT/scripts/install.sh" + [ "$status" -eq 0 ] + [[ "$output" != *"CCS-STATUS:installed:"* ]] + [[ "$output" != *"CCS-STATUS:overwritten:"* ]] + local rel + while IFS= read -r rel || [ -n "$rel" ]; do + [ -n "$rel" ] || continue + [[ "$output" == *"CCS-STATUS:unchanged:$rel"* ]] + done < "$REPO_ROOT/scripts/manifest.txt" + local repeat_backup_line repeat_backup_dir + repeat_backup_line="$( printf '%s\n' "$output" | grep -m1 'CCS-STATUS:backup-dir:' )" + repeat_backup_dir="${repeat_backup_line#CCS-STATUS:backup-dir:}" + + # 3. repeat merge-settings: byte-stable unchanged no-op. + run bash "$REPO_ROOT/scripts/merge-settings.sh" --apply --backup-dir "$repeat_backup_dir" + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:unchanged:"* ]] + [ "$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" = "$settings_sum" ] + + # 4. repeat merge-claude-md: byte-stable unchanged no-op. + run bash "$REPO_ROOT/scripts/merge-claude-md.sh" --apply --backup-dir "$repeat_backup_dir" + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:unchanged:"* ]] + [ "$( cksum < "$CLAUDE_CONFIG_DIR/CLAUDE.md" )" = "$claude_md_sum" ] + + # 5. --post is still green. + run bash "$REPO_ROOT/scripts/check.sh" --post + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT: PASS"* ]] +} diff --git a/tests/hooks.bats b/tests/hooks.bats new file mode 100644 index 0000000..b98dc6e --- /dev/null +++ b/tests/hooks.bats @@ -0,0 +1,77 @@ +#!/usr/bin/env bats +# Tests for hooks/orchestrator-delegate-guard.sh's plan-file allowlist across both +# the default ($HOME/.claude) and CLAUDE_CONFIG_DIR-based config layouts. +# +# Self-contained on purpose: does NOT source tests/test_helper.bash (owned by a +# different, concurrently-running task) and never touches any real +# /tmp/claude-orchestrator-active* sentinel -- only one fabricated per-test +# session id, created in setup() and removed in teardown(). + +setup() { + HOOK="${BATS_TEST_DIRNAME}/../hooks/orchestrator-delegate-guard.sh" + + # Unique, throwaway session id/sentinel per test -- never the real global sentinel. + SID="bats-guard-$$-${RANDOM}${RANDOM}" + SENTINEL="/tmp/claude-orchestrator-active.${SID}" + touch "$SENTINEL" + + # Fake HOME/CLAUDE_CONFIG_DIR sandboxes MUST NOT live under /tmp: the hook's + # own "/tmp/*" allowlist rule (line ~59) would then match any file path + # inside them and exit 0 before the CLAUDE_CONFIG_DIR branch under test is + # ever evaluated, making the assertion vacuously true (it stays green even + # if that branch is deleted). Root the sandbox under the real $HOME instead + # -- guaranteed writable in CI and outside /tmp on both macOS and Linux. + REAL_HOME="$HOME" + SANDBOX_ROOT="$(mktemp -d "${REAL_HOME}/.ccs-hooks-test.XXXXXX")" + case "$SANDBOX_ROOT" in + /tmp/*) + echo "FATAL: sandbox root resolved under /tmp ($SANDBOX_ROOT) -- would make the CLAUDE_CONFIG_DIR allowlist test vacuous. Aborting." >&2 + return 1 + ;; + esac + + TEST_HOME="${SANDBOX_ROOT}/home" + TEST_CONFIG_DIR="${SANDBOX_ROOT}/config" + mkdir -p "$TEST_HOME/.claude/plans" "$TEST_CONFIG_DIR/plans" + + export HOME="$TEST_HOME" + unset CLAUDE_CONFIG_DIR +} + +teardown() { + rm -f "$SENTINEL" + # Guard against an empty/unset SANDBOX_ROOT (e.g. setup aborted before it was + # set) turning `rm -rf` into a no-op-on-cwd footgun. + [ -n "${SANDBOX_ROOT:-}" ] && rm -rf "$SANDBOX_ROOT" +} + +# Builds the PreToolUse(Write|Edit) stdin JSON shape the hook reads: session_id +# (sentinel lookup) and tool_input.file_path (allowlist check). agent_type is +# intentionally omitted -- absent means "main thread" per the hook's own comment. +json_for() { + printf '{"session_id":"%s","tool_input":{"file_path":"%s"}}' "$SID" "$1" +} + +@test "plan-file edit under \$HOME/.claude/plans is allowed (default layout)" { + run bash "$HOOK" <<< "$(json_for "$TEST_HOME/.claude/plans/adopter-setup-tasks.md")" + [ "$status" -eq 0 ] +} + +@test "plan-file edit under \$CLAUDE_CONFIG_DIR/plans is allowed when set" { + export CLAUDE_CONFIG_DIR="$TEST_CONFIG_DIR" + run bash "$HOOK" <<< "$(json_for "$TEST_CONFIG_DIR/plans/adopter-setup-tasks.md")" + [ "$status" -eq 0 ] +} + +@test "source-file edit is blocked while sentinel is active (default layout)" { + run bash "$HOOK" <<< "$(json_for "/opt/some-project/src/main.py")" + [ "$status" -eq 2 ] + [[ "$output" == *"Blocked"* ]] +} + +@test "source-file edit is blocked while sentinel is active (CLAUDE_CONFIG_DIR layout)" { + export CLAUDE_CONFIG_DIR="$TEST_CONFIG_DIR" + run bash "$HOOK" <<< "$(json_for "/opt/some-project/src/main.py")" + [ "$status" -eq 2 ] + [[ "$output" == *"Blocked"* ]] +} diff --git a/tests/install.bats b/tests/install.bats new file mode 100644 index 0000000..94b0ded --- /dev/null +++ b/tests/install.bats @@ -0,0 +1,331 @@ +#!/usr/bin/env bats +# tests/install.bats — coverage for scripts/install.sh (manifest-driven +# install/unchanged/skip/overwrite policy, backups, receipts, dry-run safety). + +load test_helper + +setup() { + ccs_setup_sandbox +} + +# --- local fixtures ---------------------------------------------------------- + +_install() { run bash "$REPO_ROOT/scripts/install.sh" "$@"; } + +_expected_sha() { git -C "$REPO_ROOT" rev-parse HEAD; } + +# _agent_rel / _hook_rel — first manifest entries per category, resolved +# dynamically so these tests stay valid if the manifest contents change. +_agent_rel() { grep '^agents/' "$REPO_ROOT/scripts/manifest.txt" | head -n 1; } +_hook_rel() { grep '^hooks/' "$REPO_ROOT/scripts/manifest.txt" | head -n 1; } + +# _tree_digest — content-sensitive digest of an entire directory tree +# (paths + file contents), used to prove --dry-run performs zero mutations. +_tree_digest() { + ( cd "$1" 2>/dev/null && find . -type f -print0 | LC_ALL=C sort -z \ + | while IFS= read -r -d '' f; do printf '%s\n' "$f"; cat "$f"; done ) \ + | shasum -a 256 | awk '{print $1}' +} + +_receipt_files() { + find "$CLAUDE_CONFIG_DIR/backups/claude-code-setup" -name receipt.txt 2>/dev/null +} + +# --- fresh install ----------------------------------------------------------- + +@test "fresh install into a nonexistent CLAUDE_DIR creates it and installs everything" { + [ ! -e "$CLAUDE_CONFIG_DIR" ] + _install + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:backup-dir:"* ]] + local rel + while IFS= read -r rel || [ -n "$rel" ]; do + [ -n "$rel" ] || continue + [ -e "$CLAUDE_CONFIG_DIR/$rel" ] + [[ "$output" == *"CCS-STATUS:installed:$rel"* ]] + done < "$REPO_ROOT/scripts/manifest.txt" + [ -d "$CLAUDE_CONFIG_DIR/plans" ] +} + +@test "hooks are installed executable" { + _install + [ "$status" -eq 0 ] + local rel + while IFS= read -r rel || [ -n "$rel" ]; do + case "$rel" in + hooks/*) [ -x "$CLAUDE_CONFIG_DIR/$rel" ] ;; + esac + done < "$REPO_ROOT/scripts/manifest.txt" +} + +@test "idempotent second run reports unchanged everywhere and adds no backup content" { + _install + [ "$status" -eq 0 ] + local before_receipts after_receipts + before_receipts="$( _receipt_files | wc -l | tr -d ' ' )" + + _install + [ "$status" -eq 0 ] + [[ "$output" != *"CCS-STATUS:installed:"* ]] + [[ "$output" != *"CCS-STATUS:overwritten:"* ]] + local rel + while IFS= read -r rel || [ -n "$rel" ]; do + [ -n "$rel" ] || continue + [[ "$output" == *"CCS-STATUS:unchanged:$rel"* ]] + done < "$REPO_ROOT/scripts/manifest.txt" + + after_receipts="$( _receipt_files | wc -l | tr -d ' ' )" + # A run dir/receipt is (re)created each run (same-second timestamps may + # collide with the fresh install's dir, so only assert non-regression), but + # no run dir may carry copied adopter-file content beyond receipt.txt itself. + [ "$after_receipts" -ge "$before_receipts" ] + local d + while IFS= read -r d; do + [ -n "$d" ] || continue + [ "$( find "$d" -type f ! -name receipt.txt | wc -l | tr -d ' ' )" -eq 0 ] + done < <( _receipt_files | xargs -n1 dirname ) +} + +@test "CLAUDE_CONFIG_DIR override is honored end to end" { + local custom="$SANDBOX/custom-claude-dir" + CLAUDE_CONFIG_DIR="$custom" + export CLAUDE_CONFIG_DIR + _install + [ "$status" -eq 0 ] + [ -e "$custom/$( _agent_rel )" ] + [ ! -e "$HOME/.claude" ] +} + +@test "symlinked CLAUDE_DIR is accepted: install lands through the link" { + local real="$SANDBOX/real-claude-target" + mkdir -p "$real" + ln -s "$real" "$CLAUDE_CONFIG_DIR" + _install + [ "$status" -eq 0 ] + [ -L "$CLAUDE_CONFIG_DIR" ] + [ -e "$real/$( _agent_rel )" ] +} + +# --- differs / force policy --------------------------------------------------- + +@test "differing files are skipped per category, then --force= overwrites only that category" { + _install + [ "$status" -eq 0 ] + local agent_rel hook_rel + agent_rel="$( _agent_rel )" + hook_rel="$( _hook_rel )" + printf '\nadopter edit\n' >> "$CLAUDE_CONFIG_DIR/$agent_rel" + printf '\n# adopter edit\n' >> "$CLAUDE_CONFIG_DIR/$hook_rel" + + _install + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:skipped:$agent_rel"* ]] + [[ "$output" == *"CCS-STATUS:skipped:$hook_rel"* ]] + [[ "$( cat "$CLAUDE_CONFIG_DIR/$agent_rel" )" == *"adopter edit"* ]] + [[ "$( cat "$CLAUDE_CONFIG_DIR/$hook_rel" )" == *"adopter edit"* ]] + + run bash "$REPO_ROOT/scripts/install.sh" --force=agents + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:overwritten:$agent_rel"* ]] + [[ "$output" != *"CCS-STATUS:overwritten:$hook_rel"* ]] + cmp -s "$REPO_ROOT/$agent_rel" "$CLAUDE_CONFIG_DIR/$agent_rel" + [[ "$( cat "$CLAUDE_CONFIG_DIR/$hook_rel" )" == *"adopter edit"* ]] + + local latest backup_dir + latest="$( _receipt_files | LC_ALL=C sort | tail -n 1 )" + backup_dir="$( dirname "$latest" )" + [ -e "$backup_dir/$agent_rel" ] + [[ "$( cat "$backup_dir/$agent_rel" )" == *"adopter edit"* ]] + grep -qE $'^overwritten\t'"$CLAUDE_CONFIG_DIR/$agent_rel"$'\t'"$backup_dir/$agent_rel" "$latest" +} + +@test "plain --force overwrites all differing categories" { + _install + [ "$status" -eq 0 ] + local agent_rel hook_rel + agent_rel="$( _agent_rel )" + hook_rel="$( _hook_rel )" + printf '\nadopter edit\n' >> "$CLAUDE_CONFIG_DIR/$agent_rel" + printf '\n# adopter edit\n' >> "$CLAUDE_CONFIG_DIR/$hook_rel" + + run bash "$REPO_ROOT/scripts/install.sh" --force + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:overwritten:$agent_rel"* ]] + [[ "$output" == *"CCS-STATUS:overwritten:$hook_rel"* ]] + cmp -s "$REPO_ROOT/$agent_rel" "$CLAUDE_CONFIG_DIR/$agent_rel" + cmp -s "$REPO_ROOT/$hook_rel" "$CLAUDE_CONFIG_DIR/$hook_rel" + [ -x "$CLAUDE_CONFIG_DIR/$hook_rel" ] +} + +@test "receipt records installed/overwritten/skipped action types with the repo sha" { + _install + [ "$status" -eq 0 ] + local agent_rel + agent_rel="$( _agent_rel )" + printf '\nadopter edit\n' >> "$CLAUDE_CONFIG_DIR/$agent_rel" + + run bash "$REPO_ROOT/scripts/install.sh" --force=agents + [ "$status" -eq 0 ] + + local sha latest + sha="$( _expected_sha )" + latest="$( _receipt_files | LC_ALL=C sort | tail -n 1 )" + grep -qE $'^overwritten\t' "$latest" + grep -q "$sha" "$latest" + + local first + first="$( _receipt_files | LC_ALL=C sort | head -n 1 )" + grep -qE $'^installed\t' "$first" + grep -q "$sha" "$first" +} + +# --- dry-run safety ----------------------------------------------------------- + +@test "dry-run performs zero mutations" { + _install + [ "$status" -eq 0 ] + local before after + before="$( _tree_digest "$CLAUDE_CONFIG_DIR" )" + run bash "$REPO_ROOT/scripts/install.sh" --dry-run + [ "$status" -eq 0 ] + after="$( _tree_digest "$CLAUDE_CONFIG_DIR" )" + [ "$before" = "$after" ] +} + +@test "dry-run --diff previews a unified diff and still mutates nothing" { + _install + [ "$status" -eq 0 ] + local agent_rel + agent_rel="$( _agent_rel )" + printf '\nadopter edit\n' >> "$CLAUDE_CONFIG_DIR/$agent_rel" + + local before after + before="$( _tree_digest "$CLAUDE_CONFIG_DIR" )" + run bash "$REPO_ROOT/scripts/install.sh" --dry-run --diff + [ "$status" -eq 0 ] + after="$( _tree_digest "$CLAUDE_CONFIG_DIR" )" + [ "$before" = "$after" ] + [[ "$output" == *"CCS-STATUS:skipped:$agent_rel"* ]] + [[ "$output" == *"+++ $REPO_ROOT/$agent_rel"* ]] + [[ "$( cat "$CLAUDE_CONFIG_DIR/$agent_rel" )" == *"adopter edit"* ]] +} + +@test "--diff without --dry-run is a usage error (exit 2)" { + run bash "$REPO_ROOT/scripts/install.sh" --diff + [ "$status" -eq 2 ] +} + +@test "--dry-run must be the first flag (exit 2 otherwise)" { + run bash "$REPO_ROOT/scripts/install.sh" --force --dry-run + [ "$status" -eq 2 ] +} + +# --- symlinked destination file ------------------------------------------------ + +# --- manifest path-traversal guard -------------------------------------------- + +# _sandboxed_repo — copy the repo tree into the sandbox so a malicious manifest +# line can be injected without touching the real scripts/manifest.txt. Echoes +# the copy's root path. +_sandboxed_repo() { + local copy="$SANDBOX/repo-copy" + mkdir -p "$copy" + cp -R "$REPO_ROOT/." "$copy/" + printf '%s\n' "$copy" +} + +@test "a manifest line escaping the config dir via ../ is refused: non-zero exit, names the line, writes nothing outside CLAUDE_DIR" { + local copy + copy="$( _sandboxed_repo )" + # A faithful PoC: if the guard were absent, this line would resolve to + # "$CLAUDE_CONFIG_DIR/../pwned-outside.txt" == "$HOME/pwned-outside.txt", and + # the source file below would let that write actually succeed. + printf '../pwned-outside.txt\n' >> "$copy/scripts/manifest.txt" + printf 'pwned\n' > "$copy/pwned-outside.txt" + + run bash "$copy/scripts/install.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *"../pwned-outside.txt"* ]] + [ ! -e "$HOME/pwned-outside.txt" ] + [ ! -e "$CLAUDE_CONFIG_DIR" ] +} + +@test "an absolute-path manifest line is refused: non-zero exit and names the line" { + local copy + copy="$( _sandboxed_repo )" + printf '/etc/should-not-install.txt\n' >> "$copy/scripts/manifest.txt" + + run bash "$copy/scripts/install.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *"/etc/should-not-install.txt"* ]] + [ ! -e "$CLAUDE_CONFIG_DIR" ] +} + +# The two tests above are satisfied by install.sh's category allowlist alone +# (../pwned-outside.txt and /etc/... never carry a valid category prefix, so +# they never reach ccs_validate_manifest_rel's ".." check) — mutation testing +# proved both stay green even with ccs_validate_manifest_rel neutered to a +# no-op. This test uses a category-prefixed escape that ONLY the traversal +# guard can catch. +@test "a manifest line with a valid category prefix but an embedded ../ escape is refused by the traversal guard, not the category check" { + local copy + copy="$( _sandboxed_repo )" + # "agents/" is on install.sh's category allowlist, so only + # ccs_validate_manifest_rel's embedded-".." check -- not the category check -- + # can catch this line. The two ".." segments cancel "agents" and then step out + # of CLAUDE_CONFIG_DIR (.claude), landing at $HOME. + local rel="agents/../../pwned-in-home.txt" + printf '%s\n' "$rel" >> "$copy/scripts/manifest.txt" + # A faithful PoC source file: if the guard were absent, this content would + # actually land at $HOME/pwned-in-home.txt. + printf 'pwned\n' > "$SANDBOX/pwned-in-home.txt" + + run bash "$copy/scripts/install.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"CCS: manifest path escapes the config dir: $rel"* ]] + [[ "$output" != *"unmanaged category"* ]] + [ ! -e "$HOME/pwned-in-home.txt" ] + [ ! -e "$CLAUDE_CONFIG_DIR" ] +} + +# --- unchanged-hook executable-bit repair ------------------------------------- + +@test "a byte-identical hook with a stripped +x bit is repaired without --force and stays a no-op after" { + _install + [ "$status" -eq 0 ] + local hook_rel + hook_rel="$( _hook_rel )" + chmod -x "$CLAUDE_CONFIG_DIR/$hook_rel" + [ ! -x "$CLAUDE_CONFIG_DIR/$hook_rel" ] + + _install + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:unchanged:$hook_rel"* ]] + [[ "$output" == *"restored missing executable bit: $hook_rel"* ]] + [ -x "$CLAUDE_CONFIG_DIR/$hook_rel" ] + cmp -s "$REPO_ROOT/$hook_rel" "$CLAUDE_CONFIG_DIR/$hook_rel" + + _install + [ "$status" -eq 0 ] + [[ "$output" != *"restored missing executable bit"* ]] + [[ "$output" == *"CCS-STATUS:unchanged:$hook_rel"* ]] + [ -x "$CLAUDE_CONFIG_DIR/$hook_rel" ] +} + +@test "a symlinked destination file is refused, reported, and other files still install" { + local agent_rel hook_rel target + agent_rel="$( _agent_rel )" + hook_rel="$( _hook_rel )" + mkdir -p "$CLAUDE_CONFIG_DIR/$( dirname "$agent_rel" )" + target="$SANDBOX/elsewhere.md" + printf 'not managed\n' > "$target" + ln -s "$target" "$CLAUDE_CONFIG_DIR/$agent_rel" + + _install + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to write through symlink"* ]] + [[ "$output" == *"CCS-STATUS:installed:$hook_rel"* ]] + [ -L "$CLAUDE_CONFIG_DIR/$agent_rel" ] +} diff --git a/tests/manual-setup-checklist.md b/tests/manual-setup-checklist.md new file mode 100644 index 0000000..6583278 --- /dev/null +++ b/tests/manual-setup-checklist.md @@ -0,0 +1,67 @@ +# Manual pre-PR checklist — `/setup` walkthrough + +This is the **pre-PR human gate** for the repo-scoped `/setup` router skill +(`.claude/skills/setup/SKILL.md`). The skill orchestrates the four scripts and cannot be +fully verified by the automated `.bats` suites, because its behavior is a *conversation* +(diffs shown, consent taken, prompts raised). Walk a temp clone through `/setup` and confirm +each item below before opening the PR. + +## Setup + +1. `git clone` this repo (or copy the working tree) into a scratch dir, e.g. + `/tmp/ccs-setup-test`. +2. Point Claude Code's config dir at a **throwaway** location (do NOT test against your real + `~/.claude`) — e.g. export the config-dir override the scripts honor, or use a disposable + `$HOME`. Confirm the target is disposable before running any `--apply`/`--force`. +3. Open Claude Code inside the clone and run `/setup`. + +## Checklist + +### Preflight (P1) + +- [ ] `/setup` runs `bash scripts/check.sh` first, before any install/merge. +- [ ] **Exit-2 stops the whole flow.** Force a FAIL (e.g. a config the check rejects); confirm + the flow STOPS, shows the `FAIL:` lines verbatim, and does NOT proceed to install. +- [ ] On a WARN (exit 1), the session presents the warnings and **asks** before continuing. + +### Install (P2) + +- [ ] A **dry-run (`install.sh --dry-run --diff`) plan is shown before** the real install. +- [ ] The real `install.sh` run triggers a **permission prompt** (writing form is not + pre-approved) — the prompt is expected, not a bug. +- [ ] The `CCS-STATUS:backup-dir:` first status line is captured and reported. +- [ ] Differing files are **skipped**; per-category diff is shown and keep-vs-overwrite is + asked; overwrite uses `install.sh --force= --backup-dir `. + +### Merges (P3) + +- [ ] A **unified diff is shown before EVERY consent** — for `settings.json` and for + `CLAUDE.md`, no exceptions. +- [ ] Targets are handled **one at a time**: `settings.json` first, then `CLAUDE.md`. +- [ ] **A declined merge is respected:** decline one merge and confirm NO write happens, and + there is **no nag-loop** / re-ask (the decline is just recorded in conversation). +- [ ] **Existing-config path shows the withheld-convenience-keys note** (model / statusLine / + defaultMode not merged; copy from `settings.example.json` if wanted). Test against a + pre-existing `settings.json`, not a fresh write. + +### Backup threading (cross-phase) + +- [ ] The **backup dir from install is threaded into every apply/force** — one run dir, one + receipt for the whole session. Confirm no second/stray backup dir is created. + +### Handoff (P4) + idempotency + +- [ ] Handoff prints the **receipt location + backup dir** and the "what behaves differently + now" 3-line summary (main pushes blocked; delegation guard during `/build`; design + + pre-PR checkpoints enforced). +- [ ] Post-restart steps are handed to the **user to run after restarting** (not executed by + the skill): restart → `bash scripts/check.sh --post` → `/hooks`. +- [ ] After restart, `check.sh --post` **verifies success** and `/hooks` shows the managed + hook entries loaded. +- [ ] **Re-run `/setup` after an abort resumes idempotently:** abort partway, re-run, and + confirm unchanged files no-op (`CCS-STATUS:unchanged:...`) and no duplicate writes. + +## Cleanup + +- [ ] Delete the scratch clone and the throwaway config dir. Confirm your real `~/.claude` was + never touched. diff --git a/tests/merge-claude-md.bats b/tests/merge-claude-md.bats new file mode 100644 index 0000000..1cd0baa --- /dev/null +++ b/tests/merge-claude-md.bats @@ -0,0 +1,195 @@ +#!/usr/bin/env bats +# tests/merge-claude-md.bats — coverage for scripts/merge-claude-md.sh: fresh copy, +# version-agnostic in-place replace, append, malformed/symlink refusals, dry-run +# purity, and idempotency. Task-local fixtures only (test_helper is frozen). + +load test_helper + +MERGE="$REPO_ROOT/scripts/merge-claude-md.sh" + +setup() { + ccs_setup_sandbox + mkdir -p "$CLAUDE_CONFIG_DIR" + TARGET="$CLAUDE_CONFIG_DIR/CLAUDE.md" + EXPECTED="$BATS_TEST_TMPDIR/expected-block" + _expected_block > "$EXPECTED" +} + +# _expected_block — the repo's managed block, inclusive of its markers. +_expected_block() { + awk '/^ +STALE OLD RULES + + +keep me below +EOF + run bash "$MERGE" --apply + [ "$status" -eq 0 ] + grep -q "keep me above" "$TARGET" + grep -q "keep me below" "$TARGET" + ! grep -q "STALE OLD RULES" "$TARGET" + grep -q "Never push directly to main" "$TARGET" + [ "$( grep -c '^ +ancient rules + +EOF + run bash "$MERGE" --apply + [ "$status" -eq 0 ] + ! grep -q "workflow-rules v0" "$TARGET" + ! grep -q "ancient rules" "$TARGET" + [ "$( grep -c '^$' "$TARGET" )" -eq 1 ] + [ "$( grep -c '^$' "$TARGET" )" -eq 1 ] + diff "$EXPECTED" "$TARGET" +} + +# --- append (no markers) ----------------------------------------------------- + +@test "append: an unmanaged target keeps its content and gains the block" { + printf '# Existing config\nsome personal notes\n' > "$TARGET" + run bash "$MERGE" --apply + [ "$status" -eq 0 ] + grep -q "some personal notes" "$TARGET" + grep -q "Never push directly to main" "$TARGET" + [ "$( grep -c '^$' "$TARGET" )" -eq 1 ] + # A blank line must separate the original tail from the appended block. + grep -q '^$' "$TARGET" +} + +# --- refusals ---------------------------------------------------------------- + +@test "malformed: a lone BEGIN marker is refused and the target is untouched" { + cat > "$TARGET" <<'EOF' +# Header + +orphaned +EOF + local before + before="$( cat "$TARGET" )" + run bash "$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"malformed managed block"* ]] + [[ "$output" == *"Repair manually"* ]] + [ "$( cat "$TARGET" )" = "$before" ] +} + +@test "malformed: an END marker before BEGIN is refused, target untouched, no backup dir created" { + cat > "$TARGET" <<'EOF' +# Header + +orphaned tail + +EOF + local before + before="$( cat "$TARGET" )" + run bash "$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"malformed managed block"* ]] + [[ "$output" == *"found 1 BEGIN / 1 END marker(s)"* ]] + [[ "$output" == *"Repair manually"* ]] + [ "$( cat "$TARGET" )" = "$before" ] + [ ! -d "$CLAUDE_CONFIG_DIR/backups" ] +} + +@test "malformed: duplicate BEGIN/END pairs are refused, target untouched, no backup dir created" { + cat > "$TARGET" <<'EOF' + +first block + + + +second block + +EOF + local before + before="$( cat "$TARGET" )" + run bash "$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"malformed managed block"* ]] + [[ "$output" == *"found 2 BEGIN / 2 END marker(s)"* ]] + [[ "$output" == *"Repair manually"* ]] + [ "$( cat "$TARGET" )" = "$before" ] + [ ! -d "$CLAUDE_CONFIG_DIR/backups" ] +} + +@test "symlinked target is refused (exit 1)" { + printf 'real\n' > "$SANDBOX/real-claude.md" + ln -s "$SANDBOX/real-claude.md" "$TARGET" + run bash "$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"symlink"* ]] +} + +# --- dry-run purity ---------------------------------------------------------- + +@test "dry-run: emits a diff and mutates nothing" { + cat > "$TARGET" <<'EOF' + +STALE + +EOF + local before + before="$( cat "$TARGET" )" + run bash "$MERGE" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"@@"* ]] + [[ "$output" == *"CCS-STATUS:merged:$TARGET"* ]] + [ "$( cat "$TARGET" )" = "$before" ] +} + +# --- idempotency ------------------------------------------------------------- + +@test "idempotent: a second apply reports unchanged and is byte-identical" { + run bash "$MERGE" --apply + [ "$status" -eq 0 ] + local after_first + after_first="$( cat "$TARGET" )" + run bash "$MERGE" --apply + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:unchanged:$TARGET"* ]] + [ "$( cat "$TARGET" )" = "$after_first" ] +} + +# --- usage ------------------------------------------------------------------- + +@test "usage: no mode flag exits 2" { + run bash "$MERGE" + [ "$status" -eq 2 ] +} + +@test "usage: both mode flags exit 2" { + run bash "$MERGE" --apply --dry-run + [ "$status" -eq 2 ] +} diff --git a/tests/merge-settings.bats b/tests/merge-settings.bats new file mode 100644 index 0000000..3511e8f --- /dev/null +++ b/tests/merge-settings.bats @@ -0,0 +1,356 @@ +#!/usr/bin/env bats +# tests/merge-settings.bats — coverage for scripts/merge-settings.sh, the sole +# writer of settings.json. Verifies the fresh-verbatim path, the basename-keyed +# hook reconciliation (incl. cross-version old-form upgrade), the narrowed +# two-area merge, byte-stable idempotency, and every refusal path. + +load test_helper + +MERGE="scripts/merge-settings.sh" + +setup() { + ccs_setup_sandbox + mkdir -p "$CLAUDE_CONFIG_DIR" +} + +# --- local fixtures ---------------------------------------------------------- + +# _write_adopter — an adopter settings.json with a FOREIGN hook, personal keys, +# an old-form managed hook reference, and custom permissions.allow entries. +_write_adopter() { + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ + "model": "sonnet", + "customKey": "keep-me", + "permissions": { + "allow": [ "Bash(ls)", "Bash(git status)" ], + "defaultMode": "plan" + }, + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ + { "type": "command", "command": "bash ~/.claude/hooks/protect-branches.sh", "timeout": 5 } ] }, + { "matcher": "Custom", "hooks": [ + { "type": "command", "command": "bash /foreign/my-hook.sh" } ] } + ] + } +} +EOF +} + +# _managed_cmds — commands referencing a managed hook filename, one per line. +_managed_cmds() { + jq -r '[.. | .command? // empty | select(test("hooks/(protect-branches|protect-secrets|orchestrator-delegate-guard|syntax-check)\\.sh"))] | .[]' "$1" +} + +_receipt() { find "$CLAUDE_CONFIG_DIR/backups" -name receipt.txt | head -n 1; } + +# --- fresh install (M3 verbatim) -------------------------------------------- + +@test "fresh: --apply writes the template verbatim (byte-identical)" { + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:merged:$CLAUDE_CONFIG_DIR/settings.json"* ]] + cmp "$CLAUDE_CONFIG_DIR/settings.json" "$REPO_ROOT/settings.example.json" +} + +@test "fresh: --apply records a merged receipt line" { + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + run grep merged "$( _receipt )" + [ "$status" -eq 0 ] +} + +@test "fresh: --backup-dir threads the shared run dir" { + local run_dir="$SANDBOX/run" + mkdir -p "$run_dir" + run bash "$REPO_ROOT/$MERGE" --apply --backup-dir "$run_dir" + [ "$status" -eq 0 ] + [ -f "$run_dir/receipt.txt" ] +} + +# --- foreign preservation + narrowed merge ---------------------------------- + +@test "foreign-preserved: keeps foreign hook, personal keys, and dedups allow" { + _write_adopter + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + local s="$CLAUDE_CONFIG_DIR/settings.json" + # personal keys untouched + [ "$( jq -r '.model' "$s" )" = "sonnet" ] + [ "$( jq -r '.customKey' "$s" )" = "keep-me" ] + [ "$( jq -r '.permissions.defaultMode' "$s" )" = "plan" ] + # foreign hook preserved + [ "$( jq '[.. | .command? // empty | select(contains("/foreign/my-hook.sh"))] | length' "$s" )" -eq 1 ] + # all four managed hooks present + [ "$( _managed_cmds "$s" | sort -u | wc -l | tr -d ' ' )" -eq 4 ] + # allow: adopter entries survive, template entries added, deduped (no dup git status) + [ "$( jq '.permissions.allow | length' "$s" )" -eq 12 ] + [ "$( jq '[.permissions.allow[] | select(. == "Bash(git status)")] | length' "$s" )" -eq 1 ] + [ "$( jq '[.permissions.allow[] | select(. == "Bash(ls)")] | length' "$s" )" -eq 1 ] +} + +@test "foreign-preserved: a hook naming a managed file only in echo text is kept" { + # Token-anchored classification: the managed filename appears solely inside log + # text; the invoked script is custom-guard.sh. The entry must NOT be treated as + # managed (stripped), and the template hooks must be added alongside it. + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ + { "type": "command", "command": "echo 'migrated away from protect-branches.sh, now using custom-guard.sh' && bash /totally/different/custom-guard.sh" } ] } + ] + } +} +EOF + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + local s="$CLAUDE_CONFIG_DIR/settings.json" + # the adopter's real custom hook survives intact (command byte-preserved) + [ "$( jq '[.. | .command? // empty | select(contains("custom-guard.sh"))] | length' "$s" )" -eq 1 ] + [ "$( jq -r '[.. | .command? // empty | select(contains("custom-guard.sh"))][0]' "$s" )" = "echo 'migrated away from protect-branches.sh, now using custom-guard.sh' && bash /totally/different/custom-guard.sh" ] + # template managed hooks are added alongside (real protect-branches.sh invocation) + [ "$( jq '[.. | .command? // empty | select(contains("hooks/protect-branches.sh"))] | length' "$s" )" -eq 1 ] + [ "$( _managed_cmds "$s" | sort -u | wc -l | tr -d ' ' )" -eq 4 ] +} + +@test "narrowed merge: template-only key defaultMode is NOT added to permissions" { + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ "hooks": {} } +EOF + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + # permissions.allow created; defaultMode (present in template) not copied over + [ "$( jq 'has("permissions")' "$CLAUDE_CONFIG_DIR/settings.json" )" = "true" ] + [ "$( jq '.permissions | has("defaultMode")' "$CLAUDE_CONFIG_DIR/settings.json" )" = "false" ] + # model/statusLine from template are NOT introduced + [ "$( jq 'has("model")' "$CLAUDE_CONFIG_DIR/settings.json" )" = "false" ] + [ "$( jq 'has("statusLine")' "$CLAUDE_CONFIG_DIR/settings.json" )" = "false" ] +} + +# --- cross-version old-form upgrade (iter-4) -------------------------------- + +@test "old-form upgrade: a manual-cp-era hook ref becomes the new form, not doubled" { + _write_adopter + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + local s="$CLAUDE_CONFIG_DIR/settings.json" + # exactly ONE protect-branches entry, in the config-dir-aware form + [ "$( jq '[.. | .command? // empty | select(contains("protect-branches.sh"))] | length' "$s" )" -eq 1 ] + local cmd + cmd="$( jq -r '[.. | .command? // empty | select(contains("protect-branches.sh"))][0]' "$s" )" + [[ "$cmd" == *'${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/protect-branches.sh'* ]] + [[ "$cmd" != *"~/.claude"* ]] +} + +# --- permissions creation (iter-3) ------------------------------------------ + +@test "iter-3: config without a permissions key gains permissions.allow" { + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ "model": "opus" } +EOF + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + [ "$( jq '.permissions.allow | length' "$CLAUDE_CONFIG_DIR/settings.json" )" -eq 11 ] +} + +@test "iter-3: config with permissions but no allow gains allow, keeps siblings" { + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ "permissions": { "defaultMode": "plan" } } +EOF + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + local s="$CLAUDE_CONFIG_DIR/settings.json" + [ "$( jq '.permissions.allow | length' "$s" )" -eq 11 ] + [ "$( jq -r '.permissions.defaultMode' "$s" )" = "plan" ] +} + +# --- byte-stable idempotency (M7) ------------------------------------------- + +@test "fixed-point: fresh --apply then a SECOND --apply is a byte-identical no-op" { + # The e2e-critical sequence: a fresh verbatim write must be a fixed point, so a + # repeat run reports unchanged and never rewrites (no phantom diff to the user). + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:merged:"* ]] + local sum1 sum2 + sum1="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:unchanged:"* ]] + sum2="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + [ "$sum1" = "$sum2" ] +} + +@test "round-trip: second apply is a byte-identical no-op" { + _write_adopter + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + local sum1 sum2 + sum1="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + [[ "$output" == *"CCS-STATUS:unchanged:"* ]] + sum2="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + [ "$sum1" = "$sum2" ] +} + +@test "round-trip: unchanged no-op writes no new backup/receipt" { + _write_adopter + bash "$REPO_ROOT/$MERGE" --apply + local before after + before="$( find "$CLAUDE_CONFIG_DIR/backups" -type f | wc -l | tr -d ' ' )" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 0 ] + after="$( find "$CLAUDE_CONFIG_DIR/backups" -type f | wc -l | tr -d ' ' )" + [ "$before" = "$after" ] +} + +# --- dry-run safety (M6) ----------------------------------------------------- + +@test "dry-run: mutates nothing and emits a unified diff" { + _write_adopter + local sum1 sum2 + sum1="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"@@"* ]] + sum2="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + [ "$sum1" = "$sum2" ] +} + +@test "dry-run: fresh target previews the full template without creating it" { + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"protect-branches.sh"* ]] + [ ! -e "$CLAUDE_CONFIG_DIR/settings.json" ] +} + +# --- refusals (all exit 1, target untouched) -------------------------------- + +@test "refusal: symlinked target is refused (exit 1)" { + ln -s "$SANDBOX/elsewhere.json" "$CLAUDE_CONFIG_DIR/settings.json" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"symlink"* ]] +} + +@test "refusal: missing jq (exit 1)" { + ccs_isolate_path + _write_adopter + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"jq"* ]] +} + +@test "refusal: unparseable JSON target (exit 1)" { + printf 'not json {' > "$CLAUDE_CONFIG_DIR/settings.json" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"not valid JSON"* ]] +} + +@test "refusal: valid JSON that is not an object (exit 1)" { + printf '[1,2,3]' > "$CLAUDE_CONFIG_DIR/settings.json" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"not an object"* ]] +} + +@test "refusal: object with a non-object .hooks (exit 1, CCS message not jq crash)" { + printf '{"hooks": "nope"}' > "$CLAUDE_CONFIG_DIR/settings.json" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *".hooks must be a JSON object"* ]] + # dry-run refuses identically + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 1 ] + [[ "$output" == *".hooks must be a JSON object"* ]] +} + +@test "refusal: object with a non-array .permissions.allow (exit 1, CCS message)" { + printf '{"permissions":{"allow":"not-an-array"}}' > "$CLAUDE_CONFIG_DIR/settings.json" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *".permissions.allow must be a JSON array"* ]] + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 1 ] + [[ "$output" == *".permissions.allow must be a JSON array"* ]] +} + +# --- malformed NESTED hook shapes (regression: must refuse, never raw jq exit-5) -- + +@test "refusal: event value is a string, not an array (exit 1, CCS message, untouched)" { + printf '{"hooks": {"PreToolUse": "not-an-array"}}' > "$CLAUDE_CONFIG_DIR/settings.json" + local sum1 sum2 + sum1="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *".hooks.PreToolUse must be a JSON array"* ]] + sum2="$( cksum < "$CLAUDE_CONFIG_DIR/settings.json" )" + [ "$sum1" = "$sum2" ] + # dry-run refuses identically, never a raw jq crash + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 1 ] + [[ "$output" == *".hooks.PreToolUse must be a JSON array"* ]] +} + +@test "refusal: hook entry .command is a number, not a string (exit 1, CCS message)" { + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [ + { "type": "command", "command": 12345 } ] } + ] + } +} +EOF + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *".hooks.PreToolUse[].hooks[].command must be a JSON string"* ]] + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 1 ] + [[ "$output" == *".hooks.PreToolUse[].hooks[].command must be a JSON string"* ]] +} + +@test "refusal: inner .hooks value is an object, not an array (exit 1, CCS message)" { + cat > "$CLAUDE_CONFIG_DIR/settings.json" <<'EOF' +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": { "type": "command", "command": "bash foo.sh" } } + ] + } +} +EOF + run bash "$REPO_ROOT/$MERGE" --apply + [ "$status" -eq 1 ] + [[ "$output" == *"CCS:"* ]] + [[ "$output" == *".hooks.PreToolUse[].hooks must be a JSON array"* ]] + run bash "$REPO_ROOT/$MERGE" --dry-run + [ "$status" -eq 1 ] + [[ "$output" == *".hooks.PreToolUse[].hooks must be a JSON array"* ]] +} + +# --- usage errors (exit 2) --------------------------------------------------- + +@test "usage: no mode is a usage error (exit 2)" { + run bash "$REPO_ROOT/$MERGE" + [ "$status" -eq 2 ] +} + +@test "usage: both --dry-run and --apply is a usage error (exit 2)" { + run bash "$REPO_ROOT/$MERGE" --dry-run --apply + [ "$status" -eq 2 ] +} + +@test "usage: unknown flag is a usage error (exit 2)" { + run bash "$REPO_ROOT/$MERGE" --frobnicate + [ "$status" -eq 2 ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..da5fe05 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# tests/test_helper.bash — shared bats plumbing for the installer test suite. +# Sandbox setup ONLY; no task-specific fixtures. This file is frozen after Task 7 +# (design M9) — later tasks add their own fixtures to their own .bats files. + +# Absolute path to the repo under test. +REPO_ROOT="$( cd "$BATS_TEST_DIRNAME/.." && pwd -P )" +export REPO_ROOT + +# ccs_setup_sandbox — isolated HOME + config dir under the per-test tmpdir. +# Ambient CLAUDE_CONFIG_DIR must never leak into tests (M5): unset it, then set it +# explicitly to a sandbox path. A stub bin dir is prepended to PATH so fake +# executables can override real ones while real tools stay reachable. +ccs_setup_sandbox() { + SANDBOX="$BATS_TEST_TMPDIR/sandbox" + CCS_STUB_BIN="$SANDBOX/bin" + mkdir -p "$SANDBOX/home" "$CCS_STUB_BIN" + unset CLAUDE_CONFIG_DIR + HOME="$SANDBOX/home" + CLAUDE_CONFIG_DIR="$HOME/.claude" + PATH="$CCS_STUB_BIN:$PATH" + export HOME CLAUDE_CONFIG_DIR PATH +} + +# ccs_isolate_path — restrict PATH to the stub bin plus symlinks of the base +# utilities the scripts need, so a test can simulate a tool being ABSENT simply by +# not stubbing it. Call after ccs_setup_sandbox. +ccs_isolate_path() { + local orig="$PATH" util src + for util in bash sh env dirname basename grep head tr cat cp mv rm mkdir \ + chmod ln sort uniq mktemp date sed awk find git timeout; do + src="$( PATH="$orig" command -v "$util" 2>/dev/null || true )" + [ -n "$src" ] && ln -sf "$src" "$CCS_STUB_BIN/$util" + done + PATH="$CCS_STUB_BIN" + export PATH +} + +# ccs_make_stub [output_line...] — create a fake executable on +# the stub PATH that prints the given lines (verbatim, for any args) and exits +# with . +ccs_make_stub() { + local name="$1" code="$2" + shift 2 + local file="$CCS_STUB_BIN/$name" line + { + printf '#!/usr/bin/env bash\n' + for line in "$@"; do + printf 'printf "%%s\\n" %q\n' "$line" + done + printf 'exit %s\n' "$code" + } > "$file" + chmod +x "$file" +}