Skip to content

Commit 06f448b

Browse files
W2: review-handoff fixes + simplify pass
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).
1 parent 217c35f commit 06f448b

5 files changed

Lines changed: 138 additions & 57 deletions

File tree

klasp-agents-claude/src/hook_template.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,6 @@ mod tests {
4747

4848
#[test]
4949
fn render_ends_with_newline() {
50-
// POSIX: text files end with newlines. The script gets `chmod +x`'d
51-
// and an interactive editor that does "ensure final newline" should
52-
// produce no diff.
5350
assert!(render(1).ends_with('\n'));
5451
}
5552
}

klasp-agents-claude/src/settings.rs

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
use serde_json::{Map, Value};
1111
use thiserror::Error;
1212

13+
// Claude Code's `.claude/settings.json` hook schema. These keys appear
14+
// across the merge / unmerge / lookup helpers; pinning them as constants
15+
// turns a typo into a compile error instead of a silent no-op.
16+
const HOOKS: &str = "hooks";
17+
const PRETOOL_USE: &str = "PreToolUse";
18+
const MATCHER: &str = "matcher";
19+
const TYPE: &str = "type";
20+
const COMMAND: &str = "command";
21+
const BASH: &str = "Bash";
22+
1323
#[derive(Debug, Error)]
1424
pub enum SettingsError {
1525
#[error("settings.json: invalid JSON: {0}")]
@@ -43,42 +53,52 @@ pub fn merge_hook_entry(settings_json: &str, hook_command: &str) -> Result<Strin
4353

4454
let root_obj = expect_object_mut(&mut root, "")?;
4555

46-
let hooks = get_or_insert_object(root_obj, "hooks")?;
47-
let pretool = get_or_insert_array(hooks, "PreToolUse", "hooks.PreToolUse")?;
56+
let hooks = get_or_insert_object(root_obj, HOOKS, HOOKS)?;
57+
let pretool = get_or_insert_array(hooks, PRETOOL_USE, "hooks.PreToolUse")?;
4858

4959
let bash_idx = find_bash_matcher(pretool)?;
5060
let bash_entry = match bash_idx {
5161
Some(i) => &mut pretool[i],
5262
None => {
5363
pretool.push(Value::Object({
5464
let mut m = Map::new();
55-
m.insert("matcher".into(), Value::String("Bash".into()));
56-
m.insert("hooks".into(), Value::Array(Vec::new()));
65+
m.insert(MATCHER.into(), Value::String(BASH.into()));
66+
m.insert(HOOKS.into(), Value::Array(Vec::new()));
5767
m
5868
}));
5969
pretool.last_mut().expect("just pushed")
6070
}
6171
};
6272

6373
let bash_obj = expect_object_mut(bash_entry, "hooks.PreToolUse[Bash]")?;
64-
let inner = get_or_insert_array(bash_obj, "hooks", "hooks.PreToolUse[Bash].hooks")?;
74+
let inner = get_or_insert_array(bash_obj, HOOKS, "hooks.PreToolUse[Bash].hooks")?;
6575

6676
if !inner.iter().any(|h| hook_command_matches(h, hook_command)) {
6777
let mut entry = Map::new();
68-
entry.insert("type".into(), Value::String("command".into()));
69-
entry.insert("command".into(), Value::String(hook_command.into()));
78+
entry.insert(TYPE.into(), Value::String(COMMAND.into()));
79+
entry.insert(COMMAND.into(), Value::String(hook_command.into()));
7080
inner.push(Value::Object(entry));
7181
}
7282

7383
Ok(serialise(&root))
7484
}
7585

7686
/// Inverse of [`merge_hook_entry`]: remove every hook with `command` exactly
77-
/// equal to `hook_command`. Cleans up empty arrays/objects so a fresh-install
78-
/// settings.json round-trips through install→uninstall to its pre-install shape.
87+
/// equal to `hook_command`, then clean up the empty `Bash` matcher and
88+
/// surrounding `hooks.PreToolUse` / `hooks` containers when they're left
89+
/// empty. After install→uninstall, an `.claude/settings.json` that had no
90+
/// pre-existing hook content returns to `{}`.
91+
///
92+
/// Round-trip is best-effort, not byte-identical: serialised output uses
93+
/// 2-space pretty-print + trailing newline regardless of pre-install
94+
/// formatting. A pre-existing `{matcher: "Bash", hooks: []}` placeholder
95+
/// is also dropped — we can't distinguish "klasp emptied this" from
96+
/// "user pre-installed an empty placeholder" — but that shape is rare in
97+
/// practice. Non-Bash matchers and Bash matchers with surviving sibling
98+
/// hooks are preserved.
7999
///
80-
/// Idempotent: running on a settings.json that has no klasp entry returns the
81-
/// input unchanged.
100+
/// Idempotent: running on a settings.json that has no klasp entry parses
101+
/// and re-serialises (whitespace may differ but JSON content is unchanged).
82102
pub fn unmerge_hook_entry(
83103
settings_json: &str,
84104
hook_command: &str,
@@ -91,12 +111,12 @@ pub fn unmerge_hook_entry(
91111
let mut root: Value = serde_json::from_str(trimmed)?;
92112
let root_obj = expect_object_mut(&mut root, "")?;
93113

94-
let Some(hooks_val) = root_obj.get_mut("hooks") else {
114+
let Some(hooks_val) = root_obj.get_mut(HOOKS) else {
95115
return Ok(serialise(&root));
96116
};
97-
let hooks = expect_object_mut(hooks_val, "hooks")?;
117+
let hooks = expect_object_mut(hooks_val, HOOKS)?;
98118

99-
let Some(pretool_val) = hooks.get_mut("PreToolUse") else {
119+
let Some(pretool_val) = hooks.get_mut(PRETOOL_USE) else {
100120
return Ok(serialise(&root));
101121
};
102122
let pretool = expect_array_mut(pretool_val, "hooks.PreToolUse")?;
@@ -105,7 +125,7 @@ pub fn unmerge_hook_entry(
105125
let Some(matcher_obj) = matcher.as_object_mut() else {
106126
continue;
107127
};
108-
let Some(inner_val) = matcher_obj.get_mut("hooks") else {
128+
let Some(inner_val) = matcher_obj.get_mut(HOOKS) else {
109129
continue;
110130
};
111131
let Some(inner) = inner_val.as_array_mut() else {
@@ -114,28 +134,26 @@ pub fn unmerge_hook_entry(
114134
inner.retain(|h| !hook_command_matches(h, hook_command));
115135
}
116136

117-
// Sweep up Bash matchers whose `hooks` array is now empty (klasp put
118-
// them there in the first place). Untouched matchers — those that
119-
// started with sibling hooks — keep their `hooks: []` if a teammate
120-
// emptied them, since that's the user's data.
137+
// See the function doc-comment for why pre-existing empty Bash
138+
// placeholders are also swept (provenance is unrecoverable).
121139
pretool.retain(|m| {
122140
let Some(obj) = m.as_object() else {
123141
return true;
124142
};
125-
if obj.get("matcher").and_then(Value::as_str) != Some("Bash") {
143+
if obj.get(MATCHER).and_then(Value::as_str) != Some(BASH) {
126144
return true;
127145
}
128146
!matches!(
129-
obj.get("hooks").and_then(Value::as_array),
147+
obj.get(HOOKS).and_then(Value::as_array),
130148
Some(arr) if arr.is_empty()
131149
)
132150
});
133151

134152
if pretool.is_empty() {
135-
hooks.remove("PreToolUse");
153+
hooks.remove(PRETOOL_USE);
136154
}
137155
if hooks.is_empty() {
138-
root_obj.remove("hooks");
156+
root_obj.remove(HOOKS);
139157
}
140158

141159
Ok(serialise(&root))
@@ -174,11 +192,12 @@ fn expect_array_mut<'a>(
174192
fn get_or_insert_object<'a>(
175193
map: &'a mut Map<String, Value>,
176194
key: &str,
195+
path: &str,
177196
) -> Result<&'a mut Map<String, Value>, SettingsError> {
178197
let entry = map.entry(key).or_insert_with(|| Value::Object(Map::new()));
179198
let got = describe(entry);
180199
entry.as_object_mut().ok_or(SettingsError::Shape {
181-
path: key.to_string(),
200+
path: path.to_string(),
182201
expected: "object",
183202
got,
184203
})
@@ -187,20 +206,17 @@ fn get_or_insert_object<'a>(
187206
fn get_or_insert_array<'a>(
188207
map: &'a mut Map<String, Value>,
189208
key: &str,
190-
full_path: &str,
209+
path: &str,
191210
) -> Result<&'a mut Vec<Value>, SettingsError> {
192211
let entry = map.entry(key).or_insert_with(|| Value::Array(Vec::new()));
193212
let got = describe(entry);
194213
entry.as_array_mut().ok_or(SettingsError::Shape {
195-
path: full_path.to_string(),
214+
path: path.to_string(),
196215
expected: "array",
197216
got,
198217
})
199218
}
200219

201-
/// Find the index of a matcher object in `PreToolUse` whose `matcher` field
202-
/// is exactly the string `"Bash"`. Errors only if a matcher entry isn't an
203-
/// object (Claude's schema requires it).
204220
fn find_bash_matcher(pretool: &[Value]) -> Result<Option<usize>, SettingsError> {
205221
for (i, m) in pretool.iter().enumerate() {
206222
let Some(obj) = m.as_object() else {
@@ -210,15 +226,15 @@ fn find_bash_matcher(pretool: &[Value]) -> Result<Option<usize>, SettingsError>
210226
got: describe(m),
211227
});
212228
};
213-
if obj.get("matcher").and_then(Value::as_str) == Some("Bash") {
229+
if obj.get(MATCHER).and_then(Value::as_str) == Some(BASH) {
214230
return Ok(Some(i));
215231
}
216232
}
217233
Ok(None)
218234
}
219235

220236
fn hook_command_matches(hook: &Value, expected_command: &str) -> bool {
221-
hook.get("command").and_then(Value::as_str) == Some(expected_command)
237+
hook.get(COMMAND).and_then(Value::as_str) == Some(expected_command)
222238
}
223239

224240
fn describe(v: &Value) -> &'static str {
@@ -436,8 +452,6 @@ mod tests {
436452
);
437453
let out = unmerge_hook_entry(&input, KLASP_CMD).unwrap();
438454
let v = parse(&out);
439-
// hooks key should be gone (the only matcher was klasp's, which we
440-
// then dropped because its hooks array became empty).
441455
assert!(v.get("hooks").is_none(), "got: {v:#?}");
442456
}
443457

klasp-agents-claude/src/surface.rs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,16 @@ impl AgentSurface for ClaudeCodeSurface {
8686

8787
if !matches!(hook_state, HookState::Identical) {
8888
ensure_parent(&hook_path)?;
89-
atomic_write(&hook_path, rendered.as_bytes())?;
90-
set_executable(&hook_path)?;
89+
atomic_write(&hook_path, rendered.as_bytes(), 0o755)?;
9190
paths_written.push(hook_path.clone());
9291
}
9392

9493
if !settings_unchanged {
9594
ensure_parent(&settings_path)?;
96-
atomic_write(&settings_path, merged.as_bytes())?;
95+
// Preserve the user's prior mode rather than overwriting it with
96+
// NamedTempFile's 0o600 default; fall back to 0o644 for new files.
97+
let mode = current_mode(&settings_path).unwrap_or(0o644);
98+
atomic_write(&settings_path, merged.as_bytes(), mode)?;
9799
paths_written.push(settings_path.clone());
98100
}
99101

@@ -137,7 +139,8 @@ impl AgentSurface for ClaudeCodeSurface {
137139
.map_err(|e| settings_error(&settings_path, e))?;
138140
if new != existing {
139141
if !dry_run {
140-
atomic_write(&settings_path, new.as_bytes())?;
142+
let mode = current_mode(&settings_path).unwrap_or(0o644);
143+
atomic_write(&settings_path, new.as_bytes(), mode)?;
141144
}
142145
paths.push(settings_path);
143146
}
@@ -148,10 +151,7 @@ impl AgentSurface for ClaudeCodeSurface {
148151
}
149152

150153
enum HookState {
151-
/// File already exists and matches the rendered output byte-for-byte.
152154
Identical,
153-
/// File doesn't exist, or exists with the marker but different content
154-
/// (e.g. older schema version) — safe to overwrite.
155155
Writable,
156156
}
157157

@@ -202,7 +202,10 @@ fn ensure_parent(path: &Path) -> Result<(), InstallError> {
202202
})
203203
}
204204

205-
fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), InstallError> {
205+
/// Atomic write via tempfile + rename. `mode` is applied after the rename
206+
/// (Unix only) — without it the destination silently inherits
207+
/// `NamedTempFile`'s `0o600` default.
208+
fn atomic_write(path: &Path, contents: &[u8], mode: u32) -> Result<(), InstallError> {
206209
let dir = path.parent().unwrap_or_else(|| Path::new("."));
207210
let mut tf = tempfile::NamedTempFile::new_in(dir).map_err(|e| InstallError::Io {
208211
path: dir.to_path_buf(),
@@ -220,20 +223,29 @@ fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), InstallError> {
220223
path: path.to_path_buf(),
221224
source: e.error,
222225
})?;
226+
apply_mode(path, mode)?;
223227
Ok(())
224228
}
225229

226-
fn set_executable(path: &Path) -> Result<(), InstallError> {
230+
/// The file's current Unix mode (low 12 bits), or `None` if the file
231+
/// doesn't exist or we're not on Unix. Called *before* `atomic_write`
232+
/// so we can restore the user's prior mode after the rename.
233+
#[cfg(unix)]
234+
fn current_mode(path: &Path) -> Option<u32> {
235+
use std::os::unix::fs::PermissionsExt;
236+
fs::metadata(path).ok().map(|m| m.permissions().mode())
237+
}
238+
239+
#[cfg(not(unix))]
240+
fn current_mode(_path: &Path) -> Option<u32> {
241+
None
242+
}
243+
244+
fn apply_mode(path: &Path, mode: u32) -> Result<(), InstallError> {
227245
#[cfg(unix)]
228246
{
229247
use std::os::unix::fs::PermissionsExt;
230-
let mut perms = fs::metadata(path)
231-
.map_err(|e| InstallError::Io {
232-
path: path.to_path_buf(),
233-
source: e,
234-
})?
235-
.permissions();
236-
perms.set_mode(0o755);
248+
let perms = std::fs::Permissions::from_mode(mode);
237249
fs::set_permissions(path, perms).map_err(|e| InstallError::Io {
238250
path: path.to_path_buf(),
239251
source: e,
@@ -243,7 +255,7 @@ fn set_executable(path: &Path) -> Result<(), InstallError> {
243255
{
244256
// Windows: bash interprets the shebang regardless of the NTFS bit.
245257
// Per design.md §14, the Windows path/permission audit lands at W4.
246-
let _ = path;
258+
let _ = (path, mode);
247259
}
248260
Ok(())
249261
}

klasp-agents-claude/tests/settings_merge.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
//! Integration test: merge into a realistic fallow-shaped `.claude/settings.json`.
2-
//!
3-
//! Per the W2 issue (#2): "Unit tests for settings.json merge with a real
4-
//! fallow settings.json fixture (proves sibling hooks survive)".
1+
//! Integration test: merge into a realistic fallow-shaped
2+
//! `.claude/settings.json`, proving every sibling hook entry and
3+
//! top-level key survives byte-for-byte.
54
65
use klasp_agents_claude::ClaudeCodeSurface;
76
use klasp_agents_claude::{merge_hook_entry, unmerge_hook_entry};

klasp/tests/install_claude_code.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,65 @@ fn uninstall_removes_klasp_and_preserves_siblings() {
187187
assert_eq!(inner[0]["command"], "fallow gate");
188188
}
189189

190+
#[cfg(unix)]
191+
#[test]
192+
fn install_preserves_existing_settings_mode() {
193+
use std::os::unix::fs::PermissionsExt;
194+
195+
let repo = fresh_repo();
196+
let settings_path = repo.path().join(".claude/settings.json");
197+
fs::write(&settings_path, "{}").unwrap();
198+
fs::set_permissions(&settings_path, fs::Permissions::from_mode(0o644)).unwrap();
199+
200+
ClaudeCodeSurface
201+
.install(&ctx_for(repo.path(), false))
202+
.unwrap();
203+
204+
let mode = fs::metadata(&settings_path).unwrap().permissions().mode() & 0o777;
205+
assert_eq!(
206+
mode, 0o644,
207+
"atomic_write must preserve existing settings.json mode, not downgrade to NamedTempFile's 0o600 default"
208+
);
209+
}
210+
211+
#[cfg(unix)]
212+
#[test]
213+
fn install_creates_fresh_settings_with_0o644() {
214+
use std::os::unix::fs::PermissionsExt;
215+
216+
let repo = fresh_repo();
217+
let settings_path = repo.path().join(".claude/settings.json");
218+
assert!(!settings_path.exists(), "fresh repo has no settings.json");
219+
220+
ClaudeCodeSurface
221+
.install(&ctx_for(repo.path(), false))
222+
.unwrap();
223+
224+
let mode = fs::metadata(&settings_path).unwrap().permissions().mode() & 0o777;
225+
assert_eq!(
226+
mode, 0o644,
227+
"freshly-created settings.json should land at 0o644, not the 0o600 default of NamedTempFile"
228+
);
229+
}
230+
231+
#[cfg(unix)]
232+
#[test]
233+
fn install_writes_hook_with_0o755() {
234+
use std::os::unix::fs::PermissionsExt;
235+
236+
let repo = fresh_repo();
237+
ClaudeCodeSurface
238+
.install(&ctx_for(repo.path(), false))
239+
.unwrap();
240+
241+
let mode = fs::metadata(repo.path().join(".claude/hooks/klasp-gate.sh"))
242+
.unwrap()
243+
.permissions()
244+
.mode()
245+
& 0o777;
246+
assert_eq!(mode, 0o755);
247+
}
248+
190249
#[test]
191250
fn uninstall_dry_run_writes_nothing() {
192251
let repo = fresh_repo();

0 commit comments

Comments
 (0)