diff --git a/app/src-tauri/src/marketplace/sync.sh b/app/src-tauri/src/marketplace/sync.sh index 06f37e4..6f2050e 100644 --- a/app/src-tauri/src/marketplace/sync.sh +++ b/app/src-tauri/src/marketplace/sync.sh @@ -35,9 +35,14 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -R=$(mktemp -d) || exit 2 +R=$(mktemp -d 2>/dev/null) || R="" +if [ -z "$R" ] || [ ! -d "$R" ]; then + printf '%s\n' '{"errors":["a temporary directory could not be created in the container, so marketplace items were not applied"]}' + exit 0 +fi trap 'rm -rf "$R"' EXIT -for f in installed updated removed skipped errors newstate new_slugs final_slugs; do +for f in installed updated removed skipped errors newstate new_slugs final_slugs \ + hook_pending hook_removals plugin_items; do : >"$R/$f" done @@ -89,19 +94,44 @@ claude_cmd() { owned() { jq -e --arg id "$1" '.items | has($id)' "$STATE" >/dev/null 2>&1; } prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$STATE"; } -in_manifest() { - jq -e --arg id "$1" 'any(.items[]; (.kind + ":" + .key) == $id)' "$MANIFEST" >/dev/null 2>&1 -} +# Every ":" the manifest names with string fields, well-formed or +# not: a selected item that failed this run must not be removed. +in_manifest() { grep -qxF "$1" "$R/manifest_ids"; } carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$STATE")"; } - -outcome() { +# $1 = installed|updated|none for this id at this commit. +outcome_of() { p=$(prev_commit "$1") if [ -z "$p" ]; then - report installed "$1" + echo installed elif [ "$p" != "$2" ]; then - report updated "$1" + echo updated + else + echo none fi } +outcome() { + o=$(outcome_of "$1" "$2") + [ "$o" = none ] || report "$o" "$1" +} +# Something is in the way at a user-owned location (dangling links included). +occupied() { [ -e "$1" ] || [ -L "$1" ]; } + +# The one place a destination is derived; removal never trusts a stored path. +item_path() { + case "$1" in + agent | command) printf '%s\n' "$CLAUDE_DIR/${1}s/$2.md" ;; + skill) printf '%s\n' "$CLAUDE_DIR/skills/$2" ;; + hook) printf '%s\n' "$BASE/hooks/$2" ;; + *) return 1 ;; + esac +} + +malformed() { + rm -rf "$WORK" + fail "$1" + emit_report + exit 0 +} # ── Unpack ─────────────────────────────────────────────────────────────────── if [ ! -f "$INCOMING/payload.tar" ]; then @@ -129,27 +159,58 @@ if [ -n "$(find "$WORK" -type l -print | head -n 1)" ]; then fi MANIFEST="$WORK/manifest.json" if ! jq -e '.version == 1' "$MANIFEST" >/dev/null 2>&1; then - fail "the payload manifest is missing or has an unsupported version" - emit_report - exit 0 + malformed "the payload manifest is missing or has an unsupported version" +fi +# Nothing is changed (and, above all, nothing removed) unless the manifest is +# structurally sound and every extraction below succeeds. +if ! jq -e '(.items | type) == "array" and (.plugin_marketplaces | type) == "array"' \ + "$MANIFEST" >/dev/null 2>&1; then + malformed "the payload manifest is malformed, so nothing was changed" +fi +# One line per item. Fields carry a "_" prefix so an empty one cannot make +# `read` shift the rest (tab is IFS whitespace); @tsv escapes tabs/newlines. +# A malformed item becomes a "bad" line instead of aborting the extraction. +if ! { + jq -r ' + .items[] + | if type == "object" and (.kind | type) == "string" and (.key | type) == "string" + and (.commit | type) == "string" + then ["ok", .kind, .key, .commit, (if (.slug | type) == "string" then .slug else "" end)] + else ["bad", + (if type == "object" then .kind | tostring else "?" end), + (if type == "object" then .key | tostring else "?" end), "", ""] + end + | map("_" + .) | @tsv' "$MANIFEST" >"$R/items.tsv" && + jq -r '.items[] | objects | select((.kind | type) == "string" and (.key | type) == "string") + | [.kind + ":" + .key] | @tsv' "$MANIFEST" >"$R/manifest_ids" && + jq -r '.plugin_marketplaces[] + | if type == "object" and (.slug | type) == "string" then .slug else "" end + | [.] | @tsv' "$MANIFEST" >"$R/new_slugs" +}; then + malformed "the payload manifest could not be read, so nothing was changed" fi if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE" fi # ── Agents, skills, commands, hooks ────────────────────────────────────────── -jq -r '.items[] | select(.kind != "plugin") | [.kind, .key, .commit] | @tsv' "$MANIFEST" >"$R/items.tsv" -while IFS="$TAB" read -r kind key commit; do +while IFS="$TAB" read -r status kind key commit slug; do + status=${status#_} kind=${kind#_} key=${key#_} commit=${commit#_} slug=${slug#_} id="$kind:$key" + if [ "$status" != ok ]; then skip "$id" "malformed manifest entry"; continue; fi if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi case "$kind" in + plugin) + # Applied per plugin marketplace below. + printf '%s\t%s\t%s\n' "_$key" "_$commit" "_$slug" >>"$R/plugin_items" + ;; agent | command) dir="$CLAUDE_DIR/${kind}s" src="$WORK/${kind}s/$key.md" - dest="$dir/$key.md" + dest=$(item_path "$kind" "$key") if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi - if [ -e "$dest" ] && ! owned "$id"; then + if occupied "$dest" && ! owned "$id"; then skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C" continue fi @@ -164,9 +225,9 @@ while IFS="$TAB" read -r kind key commit; do skill) dir="$CLAUDE_DIR/skills" src="$WORK/skills/$key" - dest="$dir/$key" + dest=$(item_path skill "$key") if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi - if [ -e "$dest" ] && ! owned "$id"; then + if occupied "$dest" && ! owned "$id"; then skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C" continue fi @@ -179,9 +240,9 @@ while IFS="$TAB" read -r kind key commit; do ;; hook) src="$WORK/hooks/$key" - dest="$BASE/hooks/$key" + dest=$(item_path hook "$key") entries=$(jq -c --arg k "$key" \ - 'first(.items[] | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST") + 'first(.items[] | objects | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST") if ! printf '%s' "$entries" | jq -e 'type == "object" and all(.[]; type == "array")' >/dev/null 2>&1; then skip "$id" "its hook settings are not an object of arrays" continue @@ -191,7 +252,8 @@ while IFS="$TAB" read -r kind key commit; do fail "$id: could not write $dest" continue fi - outcome "$id" "$commit" + # Reported only once its entries are in settings.json (see below). + printf '%s\t%s\n' "$(outcome_of "$id" "$commit")" "$id" >>"$R/hook_pending" record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \ '{commit: $c, path: $p, entries: $e}')" ;; @@ -209,14 +271,18 @@ while read -r id; do if grep -qxF "$id" "$R/new_ids"; then continue; fi # Still selected but failed this run: keep the old files and record. if in_manifest "$id"; then carry_forward "$id"; continue; fi - path=$(jq -r --arg id "$id" '.items[$id].path // ""' "$STATE") - case "$path" in - */../* | */..) fail "$id: refusing to remove unexpected path $path" ;; - "$CLAUDE_DIR"/*) - if rm -rf "$path"; then report removed "$id"; else fail "$id: could not remove $path"; fi - ;; - *) fail "$id: refusing to remove unexpected path $path" ;; - esac + kind=${id%%:*} + key=${id#*:} + if ! valid_key "$key" || ! path=$(item_path "$kind" "$key"); then + fail "$id: dropped an unrecognised record from the marketplace state" + continue + fi + if [ "$kind" = hook ]; then + # Removed once its entries are out of settings.json (see below). + printf '%s\n' "$id" >>"$R/hook_removals" + continue + fi + if rm -rf "$path"; then report removed "$id"; else fail "$id: could not remove $path"; fi done <"$R/old_ids" # ── Hook entries in settings.json ──────────────────────────────────────────── @@ -227,43 +293,64 @@ OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$STATE") NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES") HOOKS_FAILED=0 if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then - if [ -f "$SETTINGS" ]; then - current="$SETTINGS" - else - printf '{}\n' >"$R/empty.json" - current="$R/empty.json" + # A dotfiles symlink stays a symlink: write through to its target. + target="$SETTINGS" + if [ -L "$SETTINGS" ]; then + target=$(readlink -f "$SETTINGS" 2>/dev/null) || target="" fi + tmp="$target.tmp.$$" # settings.json may hold secrets and the entrypoint keeps it 0600: create # the replacement private and keep it that way (pre-flight N11). saved_umask=$(umask) umask 077 - if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" ' - def remove_first($x): - (to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i - | if $i == null then . else del(.[$i]) end; - reduce ($old | to_entries[]) as $ev (.; - if (.hooks[$ev.key] | type) == "array" - then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g)) - else . end) - | reduce ($new | to_entries[]) as $ev (.; - .hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value)) - | if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end - | if .hooks == {} then del(.hooks) else . end - ' "$current" >"$SETTINGS.tmp.$$"; then - mv -f "$SETTINGS.tmp.$$" "$SETTINGS" - chmod 600 "$SETTINGS" - else - rm -f "$SETTINGS.tmp.$$" + if [ -z "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then HOOKS_FAILED=1 - fail "~/.claude/settings.json is not valid JSON, so hook changes were not applied" + fail "~/.claude/settings.json is not a regular file, so hook changes were not applied" + elif [ -f "$target" ] && ! jq -s ' + if length == 0 then {} + elif length == 1 and (.[0] | type) == "object" then .[0] + else error("not a JSON object") end' "$target" >"$R/current.json" 2>/dev/null; then + HOOKS_FAILED=1 + fail "~/.claude/settings.json is not a JSON object, so hook changes were not applied" + else + # Missing, empty and whitespace-only files all read as {}. + [ -f "$target" ] || printf '{}\n' >"$R/current.json" + if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" ' + def remove_first($x): + (to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i + | if $i == null then . else del(.[$i]) end; + reduce ($old | to_entries[]) as $ev (.; + if (.hooks[$ev.key] | type) == "array" + then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g)) + else . end) + | reduce ($new | to_entries[]) as $ev (.; + .hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value)) + | if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end + | if .hooks == {} then del(.hooks) else . end + ' "$R/current.json" >"$tmp" 2>/dev/null && + jq -e 'type == "object"' "$tmp" >/dev/null 2>&1 && + mv -f "$tmp" "$target"; then + chmod 600 "$target" + else + rm -f "$tmp" + HOOKS_FAILED=1 + fail "~/.claude/settings.json could not be updated, so hook changes were not applied" + fi fi umask "$saved_umask" fi +if [ "$HOOKS_FAILED" = 0 ]; then + while IFS="$TAB" read -r o id; do + [ "$o" = none ] || report "$o" "$id" + done <"$R/hook_pending" + while read -r id; do + key=${id#hook:} + if rm -rf "$(item_path hook "$key")"; then report removed "$id"; else fail "$id: could not remove its files"; fi + done <"$R/hook_removals" +fi # ── Plugins ────────────────────────────────────────────────────────────────── jq -r '.plugin_marketplaces[]?' "$STATE" >"$R/old_slugs" -# @tsv escapes newlines and tabs, so one hostile slug stays one (invalid) line. -jq -r '.plugin_marketplaces[] | [.slug] | @tsv' "$MANIFEST" >"$R/new_slugs" while read -r slug; do if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi mname="triple-c-$slug" @@ -278,12 +365,10 @@ while read -r slug; do claude_cmd plugin marketplace update "$mname" || { fail "$mname: could not be registered"; continue; } fi printf '%s\n' "$slug" >>"$R/final_slugs" - jq -r --arg s "$slug" '.items[] | select(.kind == "plugin" and .slug == $s) | [.key, .commit] | @tsv' \ - "$MANIFEST" >"$R/plugins.tsv" - while IFS="$TAB" read -r key commit; do + while IFS="$TAB" read -r key commit pslug; do + key=${key#_} commit=${commit#_} pslug=${pslug#_} + [ "$pslug" = "$slug" ] || continue id="plugin:$key" - if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi - if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi p=$(prev_commit "$id") if [ -z "$p" ]; then claude_cmd plugin install "$key@$mname" || { fail "$id: install failed"; continue; } @@ -294,7 +379,7 @@ while read -r slug; do report updated "$id" fi record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')" - done <"$R/plugins.tsv" + done <"$R/plugin_items" done <"$R/new_slugs" # Plugins no longer selected. diff --git a/app/src-tauri/src/marketplace/sync_script_tests.rs b/app/src-tauri/src/marketplace/sync_script_tests.rs index 664ef6b..72968ea 100644 --- a/app/src-tauri/src/marketplace/sync_script_tests.rs +++ b/app/src-tauri/src/marketplace/sync_script_tests.rs @@ -93,9 +93,15 @@ fn run(env: &Env) -> SyncReport { /// Run the script under a specific shell (`sh` is dash on Ubuntu). fn run_with(env: &Env, shell: &str) -> SyncReport { + run_full(env, shell, &[]) +} + +/// Run the script with extra environment variables. +fn run_full(env: &Env, shell: &str, extra: &[(&str, &str)]) -> SyncReport { let out = Command::new(shell) .arg(&env.script) .env_clear() + .envs(extra.iter().copied()) .env("HOME", &env.home) .env( "PATH", @@ -588,3 +594,260 @@ fn sync_skips_user_owned_skill_and_command() { assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n"); assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n"); } + +// ── Review fix round 1 ────────────────────────────────────────────────────── + +fn install_all(env: &Env) { + let (files, manifest) = all_kinds(C1); + payload(env, &files, manifest); + let r = run(env); + assert_eq!(r.installed.len(), 4, "{r:?}"); +} + +fn assert_all_installed(env: &Env) { + let claude = env.home.join(".claude"); + assert!(claude.join("agents/code-reviewer.md").is_file()); + assert!(claude.join("skills/example-skill/SKILL.md").is_file()); + assert!(claude.join("commands/example-command.md").is_file()); + assert!(claude + .join("triple-c/hooks/notify-on-stop/notify.sh") + .is_file()); +} + +#[test] +fn sync_malformed_item_neither_aborts_nor_removes() { + let Some(env) = env() else { return }; + install_all(&env); + + // Malformed entries first, then the valid ones; the still-selected agent + // has a non-string commit. + let (files, mut manifest) = all_kinds(C1); + manifest["items"][0]["commit"] = json!(7); + let items = manifest["items"].as_array().unwrap().clone(); + let mut all = vec![ + json!({ "kind": "agent", "key": { "x": 1 }, "marketplace": "m1", "commit": C1 }), + json!(5), + json!({ "kind": ["agent"], "key": "k", "marketplace": "m1", "commit": C1 }), + json!({ "kind": "plugin", "key": { "y": 1 }, "marketplace": "m1", "commit": C1, "slug": SLUG }), + ]; + all.extend(items); + manifest["items"] = Value::Array(all); + payload(&env, &files, manifest); + + let r = run(&env); + + assert!(r.removed.is_empty(), "{r:?}"); + assert!(r.errors.is_empty(), "{r:?}"); + assert_all_installed(&env); + let skipped = r + .skipped + .iter() + .map(|s| s.item.as_str()) + .collect::>(); + assert!(skipped.contains(&"agent:code-reviewer"), "{r:?}"); + assert_eq!(r.skipped.len(), 5, "{r:?}"); + + // The carried-forward record is still owned: deselecting removes it. + payload(&env, &[], empty_manifest()); + let r = run(&env); + assert_eq!(r.removed.len(), 4, "{r:?}"); +} + +#[test] +fn sync_structurally_bad_manifest_removes_nothing() { + let Some(env) = env() else { return }; + install_all(&env); + for bad in [ + json!({ "version": 1 }), + json!({ "version": 1, "items": {}, "plugin_marketplaces": [] }), + json!({ "version": 1, "items": [], "plugin_marketplaces": "x" }), + ] { + payload(&env, &[], bad.clone()); + let r = run(&env); + assert!(r.removed.is_empty(), "{bad}: {r:?}"); + assert_eq!(r.errors.len(), 1, "{bad}: {r:?}"); + assert_all_installed(&env); + } + // State survived: a real deselection still removes everything. + payload(&env, &[], empty_manifest()); + assert_eq!(run(&env).removed.len(), 4); +} + +#[test] +fn sync_treats_blank_settings_as_empty() { + let Some(env) = env() else { return }; + let settings_path = env.home.join(".claude/settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write(&settings_path, " \n\t\n").unwrap(); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + assert!(r.errors.is_empty(), "{r:?}"); + assert!( + r.installed.contains(&"hook:notify-on-stop".to_string()), + "{r:?}" + ); + assert_eq!(read_json(&settings_path)["hooks"], hook_settings()); +} + +#[test] +fn sync_does_not_report_hooks_it_could_not_wire() { + let Some(env) = env() else { return }; + let settings_path = env.home.join(".claude/settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + for bad in ["[1]", "{\"a\":", "{} {}"] { + fs::write(&settings_path, bad).unwrap(); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + let r = run(&env); + assert!( + !r.installed.contains(&"hook:notify-on-stop".to_string()), + "{bad}: {r:?}" + ); + assert_eq!(r.errors.len(), 1, "{bad}: {r:?}"); + assert_eq!( + fs::read_to_string(&settings_path).unwrap(), + bad, + "left untouched" + ); + } + // Once settings.json is fixed the hook is installed for real. + fs::write(&settings_path, "{}").unwrap(); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + let r = run(&env); + assert_eq!(r.installed, vec!["hook:notify-on-stop"], "{r:?}"); + assert_eq!(read_json(&settings_path)["hooks"], hook_settings()); +} + +#[cfg(unix)] +#[test] +fn sync_skips_dangling_user_symlinks() { + use std::os::unix::fs::symlink; + let Some(env) = env() else { return }; + let claude = env.home.join(".claude"); + let agent = claude.join("agents/code-reviewer.md"); + let skill = claude.join("skills/example-skill"); + for p in [&agent, &skill] { + fs::create_dir_all(p.parent().unwrap()).unwrap(); + symlink("/nonexistent/dotfiles/target", p).unwrap(); + } + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + let skipped = sorted(r.skipped.iter().map(|s| s.item.clone()).collect()); + assert_eq!( + skipped, + vec!["agent:code-reviewer", "skill:example-skill"], + "{r:?}" + ); + for p in [&agent, &skill] { + assert_eq!( + fs::read_link(p).unwrap(), + Path::new("/nonexistent/dotfiles/target") + ); + } +} + +#[test] +fn sync_removal_never_uses_paths_from_state() { + let Some(env) = env() else { return }; + install_all(&env); + let claude = env.home.join(".claude"); + let keep = claude.join("keep.txt"); + fs::write(&keep, "keep").unwrap(); + let state_path = claude.join("triple-c/marketplace/state.json"); + let mut state = read_json(&state_path); + state["items"]["agent:code-reviewer"]["path"] = json!(format!("{}/", claude.display())); + state["items"]["skill:example-skill"]["path"] = + json!(format!("{}/.claude", env.home.display())); + state["items"]["command:example-command"]["path"] = json!(keep.display().to_string()); + fs::write(&state_path, state.to_string()).unwrap(); + + payload(&env, &[], empty_manifest()); + let r = run(&env); + + assert_eq!(r.removed.len(), 4, "{r:?}"); + assert_eq!(fs::read_to_string(&keep).unwrap(), "keep"); + assert!(!claude.join("agents/code-reviewer.md").exists()); + assert!(!claude.join("skills/example-skill").exists()); + assert!(!claude.join("commands/example-command.md").exists()); +} + +#[cfg(unix)] +#[test] +fn sync_writes_through_a_symlinked_settings_json() { + use std::os::unix::fs::symlink; + let Some(env) = env() else { return }; + let dotfiles = env.home.join("dotfiles/settings.json"); + fs::create_dir_all(dotfiles.parent().unwrap()).unwrap(); + fs::write(&dotfiles, r#"{"model":"opus"}"#).unwrap(); + let settings_path = env.home.join(".claude/settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + symlink(&dotfiles, &settings_path).unwrap(); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + assert!(r.errors.is_empty(), "{r:?}"); + assert!( + fs::symlink_metadata(&settings_path) + .unwrap() + .file_type() + .is_symlink(), + "link kept" + ); + let merged = read_json(&dotfiles); + assert_eq!(merged["model"], "opus"); + assert_eq!(merged["hooks"], hook_settings()); +} + +#[test] +fn sync_reports_when_mktemp_fails() { + let Some(env) = env() else { return }; + let r = run_full(&env, "sh", &[("TMPDIR", "/nonexistent/triple-c-tmp")]); + assert_eq!(r.errors.len(), 1, "{r:?}"); +} + +#[cfg(unix)] +#[test] +fn sync_settings_move_failure_is_reported_and_not_recorded() { + use std::os::unix::fs::PermissionsExt; + let Some(env) = env() else { return }; + // An `mv` that refuses to replace settings.json and delegates otherwise. + let mv = env.stub_dir.join("mv"); + fs::write( + &mv, + "#!/bin/sh\nfor a; do last=$a; done\ncase \"$last\" in */settings.json) exit 1 ;; esac\nexec /bin/mv \"$@\"\n", + ) + .unwrap(); + fs::set_permissions(&mv, fs::Permissions::from_mode(0o755)).unwrap(); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + assert_eq!(r.errors.len(), 1, "{r:?}"); + assert!( + !r.installed.contains(&"hook:notify-on-stop".to_string()), + "{r:?}" + ); + let claude = env.home.join(".claude"); + assert!(!claude.join("settings.json").exists()); + let leftovers: Vec<_> = fs::read_dir(&claude) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!(leftovers.is_empty(), "temp file cleaned up"); + let state = read_json(&claude.join("triple-c/marketplace/state.json")); + assert!( + state["items"].get("hook:notify-on-stop").is_none(), + "{state}" + ); +}