Skip to content

v0.1 W2: ClaudeCodeSurface install/uninstall + settings.json merge#10

Merged
liam-ai-reality merged 2 commits into
mainfrom
claude/modest-sanderson-2241ea
May 4, 2026
Merged

v0.1 W2: ClaudeCodeSurface install/uninstall + settings.json merge#10
liam-ai-reality merged 2 commits into
mainfrom
claude/modest-sanderson-2241ea

Conversation

@liam-ai-reality
Copy link
Copy Markdown
Contributor

Summary

  • klasp-agents-claude now ships the real ClaudeCodeSurface impl per docs/design.md §3.1, §5, §7: hook-script template, surgical settings.json merge, idempotent install/uninstall.
  • klasp install and klasp uninstall are wired through a new SurfaceRegistry in the binary crate.
  • 32 new tests (19 unit + 3 fallow-fixture integration + 8 install integration + 2 insta snapshots).

Closes

Closes #2.

What this delivers (issue #2 checklist)

  • klasp-agents-claude crate skeleton; depends only on klasp-core (plus serde_json/tempfile/thiserror as documented in design §12)
  • klasp-agents-claude/src/hook_template.rs# klasp:managed marker + KLASP_GATE_SCHEMA=N export + exec klasp gate "$@"
  • klasp-agents-claude/src/settings.rs — surgical JSON merge preserving every sibling key, idempotent on exact command match
  • klasp-agents-claude/src/surface.rsClaudeCodeSurface impl of AgentSurface with MarkerConflict unless --force
  • klasp/src/cmd/install.rs + klasp/src/cmd/uninstall.rs wired through SurfaceRegistry
  • Settings.json merge unit tests including a real fallow-shaped fixture proving sibling hooks survive byte-for-byte
  • insta snapshot of the rendered hook script (v1 + v7 to exercise schema-version interpolation)

Decisions worth flagging for review

  1. Hook entry command${CLAUDE_PROJECT_DIR}/.claude/hooks/klasp-gate.sh (envar-resolved). Design didn't pin this; documented in klasp-agents-claude/src/surface.rs.
  2. SurfaceRegistry location — lives in the klasp binary crate, not in klasp-core, to keep core free of per-agent crate deps. Design §5's SurfaceRegistry::default() example is binary-side.
  3. Empty-matcher cleanup on uninstall — when our removal leaves a Bash matcher with an empty hooks array, we drop the matcher entry too (so install→uninstall round-trips a fresh repo to {}). Untouched non-Bash matchers and Bash matchers that started with sibling hooks are preserved.
  4. Atomic writestempfile::NamedTempFile in the same dir + persist. Avoids torn writes on settings.json (the highest-risk file).
  5. Windows path/permissionchmod 0o755 is unix-only; no-op on Windows per docs/design.md §14 3rd bullet (audited at W4).

Smoke test (manual)

$ cd /tmp/smoke && git init -q && mkdir .claude
$ klasp install
claude_code: installed
  wrote /private/tmp/smoke/.claude/hooks/klasp-gate.sh
  wrote /private/tmp/smoke/.claude/settings.json
$ klasp install
claude_code: already installed (no changes)
$ klasp uninstall
claude_code: removed
  /private/tmp/smoke/.claude/hooks/klasp-gate.sh
  /private/tmp/smoke/.claude/settings.json
$ cat .claude/settings.json
{}

Test plan

  • cargo build --workspace (verified locally)
  • cargo fmt --all -- --check (verified locally)
  • cargo clippy --all-targets --workspace -- -D warnings (verified locally)
  • cargo test --workspace — 67 passing (was 35 before this PR), 1 ignored (W1's documented git-dash-c case)
  • CI green on cargo check + fmt + clippy, build / macos-14, build / ubuntu-latest, build / windows-latest, npm publish --dry-run, pypi build + twine check
  • Run /code-review:code-review per the agentic-flow plan — settings.json merge is the highest-risk module of v0.1 (design §5 + §14)

Out of scope

  • W3 klasp gate runtime (the hook script execs into klasp gate, which still prints "not yet implemented" — that's the next issue, [v0.1 W3] klasp gate runtime + trigger + Shell CheckSource #3).
  • Settings.json key-order roundtrip preservation (design §14 4th bullet) — serde_json::Value normalises key order; the merge tests verify content but not byte-for-byte ordering. File a follow-up if review surfaces real-world drift.
  • Windows chmod audit (design §14 3rd bullet, scheduled for W4).

🤖 Generated with Claude Code

Implements W2 of the v0.1 milestone (#2). Per design.md §3.1, §5, §7:

- klasp-agents-claude::hook_template — three-line bash shim with the
  `# klasp:managed` marker and `KLASP_GATE_SCHEMA=N` export, parameterised
  by schema version so the contract test (W3) can verify the binary and
  fixture stay in lockstep.
- klasp-agents-claude::settings — surgical serde_json-based merge that
  preserves every sibling key in `.claude/settings.json`, idempotent on
  exact `command` match. Inverse `unmerge_hook_entry` cleans up empty
  matchers so install→uninstall round-trips a fresh repo to `{}`.
- klasp-agents-claude::surface — `ClaudeCodeSurface` impl of
  `klasp_core::AgentSurface`. Marker-comment idempotency check, atomic
  writes via tempfile, chmod 0o755 (Unix). `MarkerConflict` unless
  `--force` when an existing hook lacks our marker.
- klasp/src/registry — `SurfaceRegistry` with `ClaudeCodeSurface`
  pre-registered. Lives in the binary crate so klasp-core stays free of
  per-agent dependencies (relevant for the v0.3 plugin model).
- klasp/src/cmd/install + uninstall — CLI wiring per design §5: filter
  surfaces by `--agent` and `detect()`, build `InstallContext`, dispatch.

Tests:
- 19 unit tests in settings.rs (empty-input, sibling preservation,
  idempotency, malformed JSON, shape errors, uninstall round-trip)
- 3 integration tests against a realistic fallow-shaped fixture
  (proves fallow's hook entry survives byte-for-byte)
- 8 integration tests in klasp/tests/install_claude_code.rs (install,
  re-install no-op, dry-run, sibling preservation, marker-conflict
  refusal, --force overwrite, uninstall, uninstall dry-run)
- 2 insta snapshot tests of the rendered hook script (v1 + v7 to
  exercise schema-version interpolation)

The hook entry's `command` resolves `${CLAUDE_PROJECT_DIR}` at hook
execution time, so the same `.claude/settings.json` works regardless of
the cwd Claude is invoked from.

Closes #2.
Addresses both findings from the code-review of #10 and the subsequent
/simplify sweep, per agentic-flow steps 07/09/10.

Review fixes:
- atomic_write now preserves the destination's prior Unix mode rather
  than silently downgrading .claude/settings.json to NamedTempFile's
  0o600 default. Fresh files default to 0o644; the hook script
  continues to land at 0o755. Three new tests in install_claude_code.rs
  pin the mode contract.
- Tighten the doc-comment on unmerge_hook_entry to admit the round-trip
  caveat: a pre-existing {matcher: "Bash", hooks: []} placeholder is
  also dropped because we can't recover provenance. The corresponding
  inline comment now points at the doc.

/simplify pass:
- atomic_write takes mode: u32 instead of Option<u32>; the None branch
  was dead.
- get_or_insert_object / get_or_insert_array harmonised to (map, key,
  path) — eliminates the asymmetric "path derived from key" rule.
- JSON-schema keys ("hooks", "PreToolUse", "matcher", "Bash", "type",
  "command") are now module-level constants in settings.rs. Stops a
  typo from becoming a silent no-op.
- Drop a few comments that restated obvious behaviour (HookState
  variant docs, narrating-the-change comments in tests, POSIX rationale
  on a single-line newline assertion, task-reference in the
  fallow-fixture test header).

cargo fmt + clippy --workspace -- -D warnings + cargo test --workspace
all green: 70 passing, 1 ignored (W1's documented git-dash-c case).
@liam-ai-reality
Copy link
Copy Markdown
Contributor Author

Code review

Ran /code-review:code-review on commit 217c35f (the W2 implementation, before the simplify pass).

Summary

Implements W2 of klasp v0.1 per docs/design.md §3.1, §5, §7. CLAUDE.md compliance was checked against the parent ~/Projects/CLAUDE.md (no in-repo CLAUDE.md exists). Diff-only and depth-pass bug agents ran in parallel.

Critical issues

# File Line Issue
None

Suggestions

# File Issue Validation
1 klasp-agents-claude/src/surface.rs (atomic_write) Uses tempfile::NamedTempFile::new_in (mode 0o600) and persist() does a rename — destination inherits 0o600. Hook script is recovered to 0o755 via set_executable, but settings.json has no equivalent: each install/uninstall silently downgrades .claude/settings.json to 0o600. VALIDATED — high confidence on mechanism, medium on user-visible severity
2 klasp-agents-claude/src/settings.rs (unmerge_hook_entry retain block + doc-comments) Drops any matcher: "Bash" entry whose hooks: [] is empty after klasp's removal. The inline comment claimed user-owned empty placeholders are preserved, but the code can't distinguish "klasp emptied this" from "user pre-installed an empty placeholder" — it deletes unconditionally. The function doc-comment promising round-trip preservation is also imprecise. VALIDATED — niche failure (a literal {matcher: "Bash", hooks: []} placeholder is unusual to hand-author), but the comment-vs-code mismatch is real

What looks good

  • All new files comfortably under the 500-line workspace rule (largest is settings.rs at 467).
  • Settings merge edge cases well covered: empty input, sibling matchers preserved, malformed JSON, shape errors, fallow fixture round-trip, idempotency.
  • set_executable correctly gated on #[cfg(unix)] per docs/design.md §14.
  • atomic_write uses NamedTempFile::new_in in the destination directory — same-FS rename, no cross-FS-rename failure mode.
  • is_none_or MSRV regression caught and replaced with map_or(true, ...) — correct for rust-version = "1.75".

Verdict

Approve with minor notes. Both suggestions are scoped follow-ups, not blockers. Both fixed in 06f448b — see remediation comment below.

🤖 Generated with Claude Code

@liam-ai-reality
Copy link
Copy Markdown
Contributor Author

Review remediation + simplify pass

Both review findings fixed in 06f448b, plus a /simplify sweep on the same commit. CI green (6/6 checks).

Review-handoff fixes

Finding Fix
atomic_write silently downgrades settings.json to 0o600 atomic_write now takes an explicit mode: u32 parameter; install/uninstall capture the file's prior Unix mode via current_mode() and restore it after persist(). Fresh files default to 0o644; the hook script continues to land at 0o755. Three new tests in klasp/tests/install_claude_code.rs pin the mode contract.
unmerge_hook_entry drops user-owned empty Bash matchers; comments imprecise Tightened the function doc-comment to admit the round-trip caveat (provenance of an empty placeholder is unrecoverable). The corresponding inline comment now points at the doc instead of restating the rule.

/simplify pass (Step 09)

Three review agents (reuse / quality / efficiency) ran in parallel against the diff. Genuine wins applied:

  • atomic_write takes mode: u32 instead of Option<u32> — the None branch was dead code.
  • get_or_insert_object / get_or_insert_array signatures harmonised to (map, key, path) — eliminates the asymmetric "path derived from key" rule.
  • JSON-schema keys (hooks, PreToolUse, matcher, Bash, type, command) are now module-level consts in settings.rs. A typo now becomes a compile error instead of a silent no-op.
  • Dropped a handful of comments that restated obvious behaviour (HookState variant docs, narrating-the-change comments in tests, POSIX rationale on a single-line newline assertion, task-reference in the fallow-fixture test header).

Skipped: the TOCTOU note on exists() + read_to_string (efficiency agent's own assessment was "not a real problem at CLI granularity") and the meta-commentary about why the expect_/get_or_insert pairs aren't unifiable (over-explanation of absent abstraction).

Verification

cargo fmt --check + cargo clippy --all-targets --workspace -- -D warnings + cargo test --workspace — all green. 70 passing tests, 1 ignored (W1's documented git -c x=y commit regex case).

🤖 Generated with Claude Code

@liam-ai-reality liam-ai-reality merged commit f312a36 into main May 4, 2026
6 checks passed
@liam-ai-reality liam-ai-reality deleted the claude/modest-sanderson-2241ea branch May 4, 2026 12:03
liam-ai-reality added a commit that referenced this pull request May 4, 2026
The original `[#1, W1]` ... `[#5, W5]` brackets were issue numbers, not
PR numbers, and GitHub auto-link rules resolve `#N` to PRs first —
readers landing on those refs got the build-plan issue rather than the
merge commits.

Replace with the actual PR numbers, sourced from the merge log:

  W1 → no PR (direct push to main; reference the SHA `5740eb3` instead)
  W2 → #10
  W3 → #11
  W4 → #13
  W5 → #15
  W6-7 → #17 (this PR)

The W3 follow-ups merge `[#14]` was already correct.

Co-Authored-By: claude-flow <ruv@ruv.net>
liam-ai-reality added a commit that referenced this pull request May 4, 2026
* feat(dogfood): install klasp gate on klasp's own repo

Run `klasp init` + `klasp install --agent claude_code` against this
worktree and commit the resulting gate files so worktrees and remote
agents inherit the install. The klasp.toml is the canonical v0.1
reference example: cargo check + cargo clippy -D warnings on
commit+push, cargo test --workspace on push only.

.gitignore gains scoped un-ignore rules (`!/.claude/settings.json`,
`!/.claude/hooks/klasp-gate.sh`) so only the repo-root .claude/ gate
artifacts are tracked; subdirectory .claude/ folders (e.g. agent
worktree state) stay ignored.

Verified end-to-end:

  - clean tree: gate exits 0, no stderr
  - clippy-failing change: gate exits 2, structured findings on stderr
  - klasp install run twice = no diff (idempotent)
  - klasp doctor: all checks passed

Closes the W6 dogfood deliverable from #6.

* docs(recipes): add v0.1 recipes guide

Worked klasp.toml [[checks]] blocks for the six tools the v0.1 launch
demo and most users will actually run: pre-commit, fallow, pytest,
cargo, ESLint/Biome, ruff. Each recipe is paired with two-three
sentences on why the chosen flags / triggers fit a klasp gate.

The Patterns section up top covers the cross-cutting decisions every
config faces — commit vs push triggers, ${KLASP_BASE_REF} usage,
monorepo limitations (full discovery is a v0.2.5 deliverable per
design.md §14 and roadmap.md §v0.2.5), and fail-open semantics.

The "What's next" section points at v0.2's named recipes (`type =
"pre_commit"`, `type = "fallow"`, etc.) so users adopting verbose v0.1
shell shapes know the upgrade path.

Part of #6.

* docs(release): v0.1.0 changelog + flip README placeholder

Add CHANGELOG.md with the v0.1.0 entry enumerating every closed
v0.1-labelled issue (W1 #1, W2 #2, W3 #3 + follow-ups #14, W4 #4 +
follow-up #13, W5 #5 + follow-up #15, and W6-7 #6) under a single
release heading. The Out-of-scope section restates v0.2+ deferrals
(Codex, named recipes, parallel execution, monorepo discovery) so the
launch story is honest about what users get and what they don't.

README.md gets three small updates: status flips from "name-reservation
placeholder" to "v0.1 ships when v0.1.0 tag is pushed" with a caveat
that a `klasp --version` check is the right way to confirm a real
install (vs the lingering 0.0.0 placeholder); the docs list grows
pointers to recipes.md and CHANGELOG.md; and the repository-layout
table drops the *(planned)* qualifiers now that all three crates ship.

The user pushes the v0.1.0 tag from main themselves; this commit only
prepares the changelog + README so the release is documented when they
do.

Closes #6.

* feat(gate): set KLASP_BASE_REF env var for shell checks

Threads a merge-base ref through `RepoState` and exports it as
`KLASP_BASE_REF` on every `ShellSource` child. Computes the value via
`git merge-base @{upstream} HEAD`, falling back to `origin/main`,
`origin/master`, then `HEAD~1` — the canonical "branch divergence
point" lookup for diff-aware tools.

This matches the contract design.md §3.5 already commits to and the
`klasp.toml` / docs/recipes.md examples already reference. Without it,
copying the recipes (`pre-commit run --from-ref ${KLASP_BASE_REF}`,
`fallow audit --base ${KLASP_BASE_REF}`) silently substitutes empty
strings and the diff-aware tools lint the entire tree.

Wiring:
- klasp-core: `RepoState` gains a `base_ref: String` field. Plugins
  read it directly; the gate runtime constructs it.
- klasp/git.rs: new `compute_base_ref()` helper with three-level
  fallback chain. Two unit tests against a real `tempdir` git repo
  cover the no-remote (`HEAD~1`) and clone-with-upstream paths.
- klasp/sources/shell.rs: spawn-time `.env("KLASP_BASE_REF", ...)`.
  New unit test asserts a child running `printf "$KLASP_BASE_REF"`
  echoes the configured value.
- klasp/tests/gate_flow.rs: end-to-end test spawns the binary against
  a real two-commit git repo, runs a check that writes
  `$KLASP_BASE_REF` to a sentinel file, asserts the captured value is
  the expected `HEAD~1` fallback.

Touches `klasp/src/sources/shell.rs` (locked file in the W6-7 brief)
because the env-var injection is precisely what the gate was designed
to hand off to the source — implementing it elsewhere would route
around the only `Command::new(...)` call in v0.1. The same file was
already touched post-W3 lock by #14 for child-process cleanup.

Refs: docs/design.md §3.5, docs/recipes.md §`${KLASP_BASE_REF}`.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): correct PR references for v0.1.0 entries

The original `[#1, W1]` ... `[#5, W5]` brackets were issue numbers, not
PR numbers, and GitHub auto-link rules resolve `#N` to PRs first —
readers landing on those refs got the build-plan issue rather than the
merge commits.

Replace with the actual PR numbers, sourced from the merge log:

  W1 → no PR (direct push to main; reference the SHA `5740eb3` instead)
  W2 → #10
  W3 → #11
  W4 → #13
  W5 → #15
  W6-7 → #17 (this PR)

The W3 follow-ups merge `[#14]` was already correct.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(recipes): defer verdict_path mention to v0.2 (not in v0.1 schema)

The previous wording claimed "the ConfigV1 schema reserves the
`verdict_path` field for this transition; ignore it for now." Reality:
`klasp_core::CheckSourceConfig::Shell` has no such field — a user who
copy-pasted `verdict_path = "..."` into their klasp.toml hit a serde
parse error rather than a "this is reserved, not yet implemented"
no-op.

Reword to honestly defer JSON-output parsing to v0.2's named recipes
(`type = "fallow"`, `type = "pytest"`) and tell the user to fall back
on the check tool's exit code in v0.1.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: claude-flow <ruv@ruv.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v0.1 W2] ClaudeCodeSurface install/uninstall + settings.json merge

1 participant