@cairnkeep/cli 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ across sessions, projects, and harnesses (Claude Code, OpenCode, …).
10
10
  ## Status
11
11
 
12
12
  Shipped: the memory server, the `cairn` CLI (`bootstrap`, `memory-server`, `sync`,
13
- `doctor`, `memory`, `audit-timer`) installable via `npm i -g @cairnkeep/cli`, and the
13
+ `doctor`, `memory`, `audit-timer`, `uninstall`) installable via `npm i -g @cairnkeep/cli`, and the
14
14
  operating layer (commands,
15
15
  agents, hooks) installed on both Claude Code and OpenCode. The generic launchers
16
16
  expose wrapper seams (`.ai/pre-launch.sh`, `CAIRN_EXTRA_SETTINGS`,
@@ -28,7 +28,8 @@ cairnkeep-org sibling project.
28
28
  - **`bin/cairn`** — the CLI. `cairn bootstrap [path]` scaffolds a project's
29
29
  `.ai/` launchers + env; `cairn doctor` health-checks the configured pieces;
30
30
  `cairn memory export|import` relocates the durable store between machines;
31
- `cairn audit-timer` installs the scheduled memory+wiki audit.
31
+ `cairn audit-timer` installs the scheduled memory+wiki audit; `cairn uninstall`
32
+ reverses the install (backup-first, revertible).
32
33
  - **`templates/`** — project scaffolding (generic launchers, env) plus the
33
34
  derived-knowledge layer (wiki, alignment, graph, security, planning).
34
35
  - **`scripts/`** — asset-sync and maintenance utilities.
package/bin/cairn CHANGED
@@ -22,6 +22,9 @@ case "$cmd" in
22
22
  doctor)
23
23
  exec "$CAIRN_ROOT/scripts/doctor.sh" "$@"
24
24
  ;;
25
+ uninstall)
26
+ exec "$CAIRN_ROOT/scripts/uninstall.sh" "$@"
27
+ ;;
25
28
  memory)
26
29
  exec "$CAIRN_ROOT/scripts/memory-store.sh" "$@"
27
30
  ;;
@@ -66,6 +69,12 @@ Usage:
66
69
  cairn audit-timer [--on-calendar SPEC] [--para-root PATH] [--render-only DIR]
67
70
  Install (opt-in) a systemd user timer that runs the
68
71
  memory+wiki audit on a schedule; prints a cron alternative.
72
+ cairn uninstall [--dry-run] [--yes] [--purge-memory] [--live-root DIR] [PROJECT ...]
73
+ Reverse what cairnkeep installed/modified (operating
74
+ layer, hook registrations, MCP registration, audit
75
+ timer, and any given projects' bootstrap scaffold).
76
+ Backs everything up first with a revert.sh; keeps the
77
+ durable memory store unless --purge-memory is given.
69
78
  cairn version Print version
70
79
  cairn help Show this help
71
80
  USAGE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents (Claude Code, OpenCode).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "zod": "^3.25.76"
33
33
  },
34
34
  "scripts": {
35
+ "prepack": "git ls-files -s | awk '$1==\"100755\"{print $4}' | xargs chmod 755",
35
36
  "test": "for test in scripts/test-*.sh; do \"$test\" || exit 1; done",
36
37
  "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm test",
37
38
  "prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server ci && npm --prefix mcp-memory-server test"
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-bash32.sh — run the sync scripts under macOS's default bash 3.2, in a
3
+ # container, to prove they *execute* there (the static test-portable-sh.sh only
4
+ # greps the source). This is the check that reproduces the real macOS failure:
5
+ # bash 3.2 misparsing `declare -A` / missing `mapfile`.
6
+ #
7
+ # Uses docker or podman; SKIPS (exit 0) when neither exists, so it is safe to
8
+ # run anywhere. Deliberately NOT named test-*.sh — `npm test` stays container-
9
+ # free; run this by hand or via the CI shell-portability job.
10
+ set -uo pipefail
11
+
12
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
13
+
14
+ RUNTIME=""
15
+ for r in docker podman; do
16
+ command -v "$r" >/dev/null 2>&1 && { RUNTIME="$r"; break; }
17
+ done
18
+ if [[ -z "$RUNTIME" ]]; then
19
+ echo "smoke-bash32: SKIP (no docker/podman available)"
20
+ exit 0
21
+ fi
22
+
23
+ IMG="docker.io/library/bash:3.2"
24
+ echo "smoke-bash32: $RUNTIME + $IMG"
25
+
26
+ # --check exercises the failing code paths: the hook block in the claude script
27
+ # (the declare -A site) and report_extra_live_assets in the opencode scripts
28
+ # (the mapfile site), the latter only when the live root exists — so create it.
29
+ SCRIPTS=(
30
+ scripts/sync-claude-assets.sh
31
+ scripts/sync-opencode-explore-assets.sh
32
+ scripts/sync-opencode-wiki-assets.sh
33
+ )
34
+
35
+ fails=0
36
+ for s in "${SCRIPTS[@]}"; do
37
+ out=$("$RUNTIME" run --rm -v "$ROOT_DIR":/w:ro -w /w "$IMG" \
38
+ bash -c 'mkdir -p /tmp/live && exec bash "$0" --check --live-root /tmp/live' "$s" 2>&1) || true
39
+ # Exit status is ignored on purpose: --check returns non-zero for DRIFT
40
+ # (expected against an empty live root). We fail only on a bash-3.2 execution
41
+ # failure — an unbound-variable abort (declare -A misparse), a missing builtin
42
+ # (mapfile/readarray), or a syntax error — the exact 4-ism classes.
43
+ if grep -qiE 'unbound variable|command not found|mapfile:|readarray:|syntax error' <<<"$out"; then
44
+ echo " [FAIL] $s"
45
+ tail -5 <<<"$out" | sed 's/^/ /'
46
+ fails=$((fails + 1))
47
+ else
48
+ echo " [PASS] $s runs on bash 3.2"
49
+ fi
50
+ done
51
+
52
+ if [[ $fails -eq 0 ]]; then
53
+ echo "smoke-bash32: OK"
54
+ exit 0
55
+ fi
56
+ echo "smoke-bash32: $fails script(s) fail on bash 3.2"
57
+ exit 1
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-mcp-boot.sh — boot the memory server and run a real MCP `initialize`
3
+ # handshake over stdio. Proves the built server actually starts and responds:
4
+ # dist is present, deps resolve (esp. under a global install), and the MCP
5
+ # transport works. This is the core product path, and nothing else tests it
6
+ # after packaging/installation.
7
+ #
8
+ # Prefers `cairn memory-server` (a global install), else falls back to node on
9
+ # the repo's dist build — so it works both post-install and from a clone.
10
+ set -uo pipefail
11
+
12
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
13
+
14
+ if command -v cairn >/dev/null 2>&1; then
15
+ server=(cairn memory-server)
16
+ elif [[ -f "$ROOT_DIR/mcp-memory-server/dist/index.js" ]]; then
17
+ server=(node "$ROOT_DIR/mcp-memory-server/dist/index.js")
18
+ else
19
+ echo "smoke-mcp-boot: FAIL (no cairn on PATH and no dist build to run)"
20
+ exit 1
21
+ fi
22
+
23
+ req='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}'
24
+
25
+ tmp=$(mktemp -d)
26
+ trap 'rm -rf "$tmp"' EXIT
27
+
28
+ # HOME points at a throwaway dir so the server's default store (~/.cairnkeep)
29
+ # lands there; `head -1` closes stdin after the first reply, so the server sees
30
+ # EOF and exits cleanly.
31
+ out=$(printf '%s\n' "$req" | HOME="$tmp" timeout 15 "${server[@]}" 2>/dev/null | head -1)
32
+
33
+ if grep -q '"result"' <<<"$out" && grep -q '"tools"' <<<"$out"; then
34
+ echo "smoke-mcp-boot: OK (server booted, initialize returned tools capability)"
35
+ exit 0
36
+ fi
37
+ echo "smoke-mcp-boot: FAIL (no valid initialize response)"
38
+ echo " got: ${out:0:200}"
39
+ exit 1
@@ -99,19 +99,27 @@ SETTINGS_FILE="$LIVE_ROOT/settings.json"
99
99
  # PreToolUse-style events; an optional trailing "@timeout" (seconds) threads
100
100
  # an explicit per-hook timeout into the settings.json registration entry so
101
101
  # Claude Code's kill budget is documented, not assumed (Pitfall 1).
102
- declare -A HOOK_EVENTS=(
103
- ["memory-wakeup.sh"]="SessionStart"
104
- ["memory-capture.sh"]="SessionEnd"
105
- ["memory-recall.sh"]="PreToolUse:Edit|Write|MultiEdit"
106
- ["context-explore-pretask.sh"]="UserPromptSubmit@25"
107
- )
102
+ #
103
+ # A case lookup, not `declare -A`: associative arrays are bash 4+, but macOS
104
+ # still ships bash 3.2 as /bin/bash, where `declare -A HOOK_EVENTS=([k]=v)`
105
+ # is parsed as a numeric-index assignment and dies under `set -u`
106
+ # ("k: unbound variable"). Portable to 3.2; empty string = "not a hook".
107
+ hook_event_for() {
108
+ case "$1" in
109
+ memory-wakeup.sh) echo "SessionStart" ;;
110
+ memory-capture.sh) echo "SessionEnd" ;;
111
+ memory-recall.sh) echo "PreToolUse:Edit|Write|MultiEdit" ;;
112
+ context-explore-pretask.sh) echo "UserPromptSubmit@25" ;;
113
+ *) echo "" ;;
114
+ esac
115
+ }
108
116
 
109
117
  for hook_src in "$CLAUDE_SOURCE"/hooks/*.sh; do
110
118
  [[ -f "$hook_src" ]] || continue
111
119
  hook_name="$(basename "$hook_src")"
112
120
  hook_dst="$HOOK_LIVE_DIR/$hook_name"
113
121
  hook_cmd="bash \"$hook_dst\""
114
- event_spec="${HOOK_EVENTS[$hook_name]:-}"
122
+ event_spec="$(hook_event_for "$hook_name")"
115
123
  if [[ -z "$event_spec" ]]; then
116
124
  # Unknown hook: still render it, but do not auto-register (avoid stomping settings).
117
125
  if [[ "$MODE" == "apply" ]]; then
@@ -98,7 +98,11 @@ report_extra_live_assets() {
98
98
  return
99
99
  fi
100
100
 
101
- mapfile -t extras < <(collect_extra_live_assets "$LIVE_ROOT")
101
+ # read loop, not `mapfile`: mapfile is bash 4+, absent from macOS bash 3.2.
102
+ local line
103
+ while IFS= read -r line; do
104
+ extras+=("$line")
105
+ done < <(collect_extra_live_assets "$LIVE_ROOT")
102
106
  if ((${#extras[@]} == 0)); then
103
107
  return
104
108
  fi
@@ -133,7 +133,11 @@ report_extra_live_assets() {
133
133
  return
134
134
  fi
135
135
 
136
- mapfile -t extras < <(collect_extra_live_assets "$LIVE_ROOT")
136
+ # read loop, not `mapfile`: mapfile is bash 4+, absent from macOS bash 3.2.
137
+ local line
138
+ while IFS= read -r line; do
139
+ extras+=("$line")
140
+ done < <(collect_extra_live_assets "$LIVE_ROOT")
137
141
  if ((${#extras[@]} == 0)); then
138
142
  return
139
143
  fi
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # Guard: shipped shell scripts must run on macOS's default bash 3.2.
3
+ #
4
+ # macOS still ships bash 3.2 (2007) as /bin/bash, and `#!/usr/bin/env bash`
5
+ # picks it up unless the user installed a newer bash on PATH. bash-4-only
6
+ # features (associative arrays via `declare -A`, `mapfile`/`readarray`, the
7
+ # `${var^^}`/`${var,,}` case operators) blow up there — e.g. `declare -A`
8
+ # is misparsed as a numeric-index assignment and dies under `set -u` with a
9
+ # bogus "unbound variable". CI runs on bash 5, so nothing else catches this.
10
+ #
11
+ # Grep-based, so it needs no bash 3.2 to run. Ceiling: it flags the common
12
+ # 4-isms by pattern, not every one; the fresh-system smoke test should still
13
+ # exercise the install under an actual bash 3.2 (docker run bash:3.2 ...).
14
+ set -euo pipefail
15
+
16
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
17
+
18
+ # One ERE, alternated: assoc arrays | mapfile/readarray | ${v^^}/${v,,}.
19
+ BASH4_PATTERN='declare[[:space:]]+-A|(mapfile|readarray)[[:space:]]|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)'
20
+
21
+ failed=0
22
+ while IFS= read -r f; do
23
+ [[ -n "$f" ]] || continue
24
+ # grep -n, then drop full-line comments (`^NN: # ...`) so the pattern
25
+ # only trips on real usage, not prose that names the feature.
26
+ matches=$(grep -nE "$BASH4_PATTERN" "$f" | grep -vE '^[0-9]+:[[:space:]]*#' || true)
27
+ if [[ -n "$matches" ]]; then
28
+ if [[ $failed -eq 0 ]]; then
29
+ echo "FAIL: bash-4-only feature(s) in scripts that must run on macOS bash 3.2:" >&2
30
+ failed=1
31
+ fi
32
+ echo " ${f#"$ROOT_DIR"/}" >&2
33
+ sed 's/^/ /' <<<"$matches" >&2
34
+ fi
35
+ done < <(find "$ROOT_DIR/scripts" "$ROOT_DIR/bin" -type f \( -name '*.sh' -o -name '*.template' \) | sort)
36
+
37
+ if [[ $failed -ne 0 ]]; then
38
+ echo "Rewrite portably (case lookup for maps, while-read for mapfile)." >&2
39
+ exit 1
40
+ fi
41
+
42
+ echo "test-portable-sh: OK (no bash-4-only features in shipped scripts)"
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Regression guard for the 744-perms packaging bug. A root-owned `sudo npm i -g`
3
+ # makes root the file owner, so a non-root user can only run cairn if "other"
4
+ # has the execute bit. A publisher umask can strip it at pack time; the
5
+ # `prepack` script re-applies git's tracked executable bit. This verifies
6
+ # prepack heals a umask-mangled entrypoint back to 755 (world-executable).
7
+ #
8
+ # Deliberately does NOT run `npm pack`: this test runs inside `npm test`, which
9
+ # runs inside `npm publish`'s prepublishOnly, and nesting npm that deep is not
10
+ # portable. Running the prepack command directly tests the same guarantee.
11
+ set -euo pipefail
12
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
13
+ cd "$ROOT"
14
+ fail() { echo "FAIL: $1" >&2; exit 1; }
15
+
16
+ prepack=$(node -e 'process.stdout.write(require("./package.json").scripts.prepack||"")')
17
+ [[ -n "$prepack" ]] || fail "package.json has no prepack script (perms fix missing)"
18
+
19
+ entry=bin/cairn
20
+ trap 'chmod 755 "$entry" 2>/dev/null || true' EXIT
21
+ chmod 744 "$entry" # simulate a stripping umask
22
+ sh -c "$prepack" # run the real prepack command
23
+ mode=$(stat -c '%a' "$entry" 2>/dev/null || stat -f '%Lp' "$entry") # GNU || BSD/macOS
24
+ [[ "$mode" == "755" ]] || fail "prepack did not restore $entry to 755 (got $mode) -- 744 packaging bug"
25
+ echo "OK: prepack restores shipped executables to 755"
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # test-uninstall.sh — install/uninstall/revert cycle for uninstall.sh.
3
+ #
4
+ # uninstall.sh removes and edits real user state (assets, settings.json hooks,
5
+ # MCP registration, the memory store), so it is tested in a fully isolated HOME
6
+ # with stubbed `claude`/`systemctl` — it never touches the real machine. Asserts
7
+ # the three properties that matter: dry-run changes nothing, uninstall removes
8
+ # what cairnkeep owns, and revert.sh restores it exactly.
9
+ set -uo pipefail
10
+
11
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
12
+ SB=$(mktemp -d)
13
+ trap 'rm -rf "$SB"' EXIT
14
+
15
+ fails=0
16
+ ok() { printf ' [PASS] %s\n' "$1"; }
17
+ bad() { printf ' [FAIL] %s\n' "$1"; fails=$((fails + 1)); }
18
+ check() { if [[ "$2" == "$3" ]]; then ok "$1 ($2)"; else bad "$1 (got '$2', want '$3')"; fi; }
19
+
20
+ # Isolated environment: fake HOME + stubbed system commands.
21
+ mkdir -p "$SB/home" "$SB/bin"
22
+ printf '#!/usr/bin/env bash\ntrue\n' >"$SB/bin/claude"
23
+ printf '#!/usr/bin/env bash\ntrue\n' >"$SB/bin/systemctl"
24
+ chmod +x "$SB/bin/"*
25
+ export HOME="$SB/home" XDG_CONFIG_HOME="$SB/home/.config" PATH="$SB/bin:$PATH"
26
+ LIVE="$SB/live"
27
+
28
+ echo "test-uninstall"
29
+
30
+ # --- install the operating layer -------------------------------------------
31
+ "$ROOT_DIR/scripts/sync-claude-assets.sh" --apply --live-root "$LIVE" >/dev/null 2>&1
32
+ md_installed=$(find "$LIVE" -type f -name '*.md' | wc -l | tr -d ' ')
33
+ check "assets installed" "$([[ $md_installed -gt 0 ]] && echo yes || echo no)" "yes"
34
+ check "hooks registered" "$(grep -c 'hooks/' "$LIVE/settings.json")" "4"
35
+ cp "$LIVE/settings.json" "$SB/settings.before.json"
36
+
37
+ # --- dry-run must change nothing -------------------------------------------
38
+ "$ROOT_DIR/scripts/uninstall.sh" --dry-run --live-root "$LIVE" >/dev/null 2>&1
39
+ check "dry-run leaves assets" "$(find "$LIVE" -type f -name '*.md' | wc -l | tr -d ' ')" "$md_installed"
40
+ check "dry-run makes no bundle" "$(ls -d "$SB/home/.cairnkeep-uninstall-"* 2>/dev/null | wc -l | tr -d ' ')" "0"
41
+
42
+ # --- real uninstall ---------------------------------------------------------
43
+ "$ROOT_DIR/scripts/uninstall.sh" --yes --live-root "$LIVE" >/dev/null 2>&1
44
+ check "assets removed" "$(find "$LIVE" -type f -name '*.md' | wc -l | tr -d ' ')" "0"
45
+ check "hooks de-registered" "$(grep -c 'hooks/' "$LIVE/settings.json" 2>/dev/null || true)" "0"
46
+ BK=$(ls -d "$SB/home/.cairnkeep-uninstall-"* 2>/dev/null | head -1)
47
+ check "revert.sh generated" "$([[ -n "$BK" && -f "$BK/revert.sh" ]] && echo yes || echo no)" "yes"
48
+
49
+ # --- revert restores everything --------------------------------------------
50
+ bash "$BK/revert.sh" >/dev/null 2>&1
51
+ check "assets restored" "$(find "$LIVE" -type f -name '*.md' | wc -l | tr -d ' ')" "$md_installed"
52
+ check "settings.json identical" "$(cmp -s "$SB/settings.before.json" "$LIVE/settings.json" && echo yes || echo no)" "yes"
53
+
54
+ # --- project scaffold + memory purge round-trip ----------------------------
55
+ PROJ="$SB/proj"; mkdir -p "$PROJ"; git -C "$PROJ" init -q
56
+ "$ROOT_DIR/scripts/bootstrap.sh" --untracked "$PROJ" >/dev/null 2>&1
57
+ EXCL="$PROJ/.git/info/exclude"
58
+ mkdir -p "$SB/home/.cairnkeep"; echo data >"$SB/home/.cairnkeep/db"
59
+ "$ROOT_DIR/scripts/uninstall.sh" --yes --purge-memory --live-root "$SB/home/.claude" "$PROJ" >/dev/null 2>&1
60
+ check "project .ai removed" "$([[ -e "$PROJ/.ai" ]] && echo no || echo yes)" "yes"
61
+ check "exclude lines stripped" "$(grep -cE '\.ai/|\.planning/' "$EXCL" 2>/dev/null || true)" "0"
62
+ check "memory store purged" "$([[ -e "$SB/home/.cairnkeep" ]] && echo no || echo yes)" "yes"
63
+ BK2=$(ls -dt "$SB/home/.cairnkeep-uninstall-"* 2>/dev/null | head -1)
64
+ bash "$BK2/revert.sh" >/dev/null 2>&1
65
+ check "project scaffold restored" "$([[ -d "$PROJ/.ai" ]] && echo yes || echo no)" "yes"
66
+ check "memory store restored" "$(cat "$SB/home/.cairnkeep/db" 2>/dev/null)" "data"
67
+
68
+ echo
69
+ if [[ "$fails" -gt 0 ]]; then echo "test-uninstall: $fails check(s) failed."; exit 1; fi
70
+ echo "test-uninstall: OK"
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bash
2
+ # cairn uninstall — reverse what cairnkeep installed/modified on this machine.
3
+ #
4
+ # Backup-first and revertible: every file it removes or edits is copied into a
5
+ # timestamped bundle ($HOME/.cairnkeep-uninstall-<ts>/) with a generated
6
+ # revert.sh, so `bash <bundle>/revert.sh` puts the machine back. Removes only
7
+ # things cairnkeep owns; the durable memory store ($HOME/.cairnkeep) is DATA and
8
+ # is left alone unless you pass --purge-memory (then it too is backed up first).
9
+ #
10
+ # Undoes: the operating layer (`cairn sync --apply` — commands, agents,
11
+ # templates, hooks + their settings.json registrations), the MCP registration
12
+ # (`claude mcp add cairn-memory`), the opt-in audit timer, and — for any project
13
+ # paths you pass — the `cairn bootstrap` scaffold. The npm package itself is
14
+ # npm's to remove; the final step is printed, not run (it would delete this
15
+ # script mid-execution).
16
+ set -uo pipefail
17
+
18
+ CAIRN_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
19
+ CLAUDE_SOURCE="$CAIRN_ROOT/claude"
20
+ TPL="$CAIRN_ROOT/templates"
21
+
22
+ LIVE_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
23
+ STORE_DIR="${CAIRN_AGENTFS_BASE_DIR:-$HOME/.cairnkeep}"
24
+ STORE_DIR="${STORE_DIR/#\~/$HOME}"
25
+ DRY_RUN=0
26
+ ASSUME_YES=0
27
+ PURGE_MEMORY=0
28
+ PROJECTS=()
29
+
30
+ # The hooks cairnkeep registers (basename is the settings.json match token).
31
+ HOOK_NAMES=(memory-wakeup.sh memory-capture.sh memory-recall.sh context-explore-pretask.sh)
32
+
33
+ usage() {
34
+ cat <<'EOF'
35
+ Usage: uninstall.sh [--dry-run] [--yes] [--purge-memory] [--live-root PATH] [PROJECT ...]
36
+
37
+ Reverse cairnkeep's install. Everything removed/edited is backed up into
38
+ $HOME/.cairnkeep-uninstall-<ts>/ with a revert.sh before any change.
39
+
40
+ --dry-run Print what would happen; touch nothing.
41
+ --yes Do not prompt for confirmation.
42
+ --purge-memory Also remove the durable memory store ($HOME/.cairnkeep or
43
+ $CAIRN_AGENTFS_BASE_DIR). Backed up first, but this is your
44
+ accumulated memory — off by default.
45
+ --live-root PATH Claude root to clean (default: $CLAUDE_CONFIG_DIR or ~/.claude).
46
+ PROJECT ... Also revert `cairn bootstrap` in these project dirs
47
+ (.ai/, .planning/, and any .git/info/exclude entries).
48
+ -h, --help Show this help.
49
+ EOF
50
+ }
51
+
52
+ while [[ $# -gt 0 ]]; do
53
+ case "$1" in
54
+ --dry-run) DRY_RUN=1; shift ;;
55
+ --yes) ASSUME_YES=1; shift ;;
56
+ --purge-memory) PURGE_MEMORY=1; shift ;;
57
+ --live-root) LIVE_ROOT="$2"; shift 2 ;;
58
+ -h|--help) usage; exit 0 ;;
59
+ -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
60
+ *) PROJECTS+=("$1"); shift ;;
61
+ esac
62
+ done
63
+
64
+ # ---- backup bundle -----------------------------------------------------------
65
+ # Timestamp comes from the runtime clock on the user's machine.
66
+ BACKUP_DIR="$HOME/.cairnkeep-uninstall-$(date +%Y%m%d-%H%M%S)"
67
+ MANIFEST="$BACKUP_DIR/manifest.tsv"
68
+ NOTES="$BACKUP_DIR/NOTES.txt"
69
+
70
+ ensure_bundle() {
71
+ [[ $DRY_RUN -eq 1 ]] && return 0
72
+ [[ -d "$BACKUP_DIR" ]] && return 0
73
+ mkdir -p "$BACKUP_DIR/files"
74
+ : >"$MANIFEST"
75
+ : >"$NOTES"
76
+ }
77
+
78
+ # back_up ABS — copy an existing path into the bundle, mirrored by absolute path,
79
+ # and record it in the manifest so revert.sh can restore it. Safe on dirs (cp -a).
80
+ back_up() {
81
+ local abs="$1" rel
82
+ [[ -e "$abs" ]] || return 0
83
+ ensure_bundle
84
+ [[ $DRY_RUN -eq 1 ]] && return 0
85
+ rel="files/${abs#/}"
86
+ mkdir -p "$BACKUP_DIR/$(dirname "$rel")"
87
+ cp -a "$abs" "$BACKUP_DIR/$rel"
88
+ printf '%s\t%s\n' "$abs" "$rel" >>"$MANIFEST"
89
+ }
90
+
91
+ # remove_path ABS — back up then delete (file or dir).
92
+ remove_path() {
93
+ local abs="$1"
94
+ [[ -e "$abs" ]] || return 0
95
+ if [[ $DRY_RUN -eq 1 ]]; then echo " would remove: $abs"; return 0; fi
96
+ back_up "$abs"
97
+ rm -rf "$abs"
98
+ echo " removed: $abs"
99
+ }
100
+
101
+ note() { [[ $DRY_RUN -eq 1 ]] || { ensure_bundle; printf '%s\n' "$1" >>"$NOTES"; }; }
102
+
103
+ # ---- what will be removed ----------------------------------------------------
104
+ # Asset dests: derived exactly as sync-claude-assets.sh installs them, so the two
105
+ # never drift. claude/**/*.md -> <live>/..., security|wiki templates, hooks.
106
+ asset_dests() {
107
+ local rel tpl h
108
+ while IFS= read -r rel; do echo "$LIVE_ROOT/$rel"; done \
109
+ < <(cd "$CLAUDE_SOURCE" 2>/dev/null && find . -type f -name '*.md' | sed 's|^\./||' | sort)
110
+ for tpl in "$TPL"/security-*.template "$TPL"/wiki-*.template; do
111
+ [[ -f "$tpl" ]] && echo "$LIVE_ROOT/templates/$(basename "$tpl")"
112
+ done
113
+ for h in "$CLAUDE_SOURCE"/hooks/*.sh; do
114
+ [[ -f "$h" ]] && echo "$LIVE_ROOT/hooks/$(basename "$h")"
115
+ done
116
+ }
117
+
118
+ echo "cairn uninstall"
119
+ [[ $DRY_RUN -eq 1 ]] && echo "(dry run — nothing will change)"
120
+ echo " live root: $LIVE_ROOT"
121
+ echo " memory store: $STORE_DIR ($([[ $PURGE_MEMORY -eq 1 ]] && echo 'WILL be purged' || echo 'kept'))"
122
+ [[ ${#PROJECTS[@]} -gt 0 ]] && echo " projects: ${PROJECTS[*]}"
123
+
124
+ if [[ $DRY_RUN -eq 0 && $ASSUME_YES -eq 0 ]]; then
125
+ printf 'Proceed? [y/N] '
126
+ read -r ans
127
+ [[ "$ans" == [yY] || "$ans" == [yY][eE][sS] ]] || { echo "aborted."; exit 0; }
128
+ fi
129
+
130
+ # 1. Operating-layer asset files.
131
+ echo "Operating layer (assets):"
132
+ while IFS= read -r dst; do remove_path "$dst"; done < <(asset_dests)
133
+
134
+ # 2. settings.json hook registrations (back up whole file, then de-register).
135
+ SETTINGS="$LIVE_ROOT/settings.json"
136
+ if [[ -f "$SETTINGS" ]]; then
137
+ echo "Hook registrations (settings.json):"
138
+ if [[ $DRY_RUN -eq 1 ]]; then
139
+ echo " would de-register: ${HOOK_NAMES[*]}"
140
+ elif command -v node >/dev/null 2>&1; then
141
+ back_up "$SETTINGS"
142
+ node -e '
143
+ const fs = require("fs");
144
+ const [p, ...names] = process.argv.slice(1);
145
+ let s; try { s = JSON.parse(fs.readFileSync(p, "utf8")); }
146
+ catch (e) { console.error(" settings.json parse failed, leaving untouched"); process.exit(1); }
147
+ if (!s.hooks) process.exit(0);
148
+ let removed = 0;
149
+ for (const ev of Object.keys(s.hooks)) {
150
+ if (!Array.isArray(s.hooks[ev])) continue;
151
+ s.hooks[ev] = s.hooks[ev].filter((entry) => {
152
+ if (!Array.isArray(entry.hooks)) return true;
153
+ const before = entry.hooks.length;
154
+ entry.hooks = entry.hooks.filter((h) =>
155
+ !(typeof h.command === "string" && names.some((n) => h.command.includes(n))));
156
+ removed += before - entry.hooks.length;
157
+ return entry.hooks.length > 0;
158
+ });
159
+ if (s.hooks[ev].length === 0) delete s.hooks[ev];
160
+ }
161
+ if (Object.keys(s.hooks).length === 0) delete s.hooks;
162
+ fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
163
+ console.log(" de-registered " + removed + " hook(s)");
164
+ ' "$SETTINGS" "${HOOK_NAMES[@]}" || echo " (settings.json left untouched)"
165
+ else
166
+ echo " node not found — cannot edit settings.json; remove hook entries manually."
167
+ fi
168
+ fi
169
+
170
+ # 3. MCP registration (best-effort; a command, not a file — noted for revert).
171
+ echo "MCP registration (cairn-memory):"
172
+ if command -v claude >/dev/null 2>&1; then
173
+ if [[ $DRY_RUN -eq 1 ]]; then
174
+ echo " would run: claude mcp remove cairn-memory -s user"
175
+ else
176
+ claude mcp remove cairn-memory -s user 2>/dev/null && echo " removed cairn-memory" \
177
+ || echo " cairn-memory not registered (or already gone)"
178
+ note "Re-add MCP server: claude mcp add cairn-memory -s user -- cairn memory-server"
179
+ fi
180
+ else
181
+ echo " claude CLI not found — if registered, run: claude mcp remove cairn-memory -s user"
182
+ fi
183
+
184
+ # 4. Audit timer (opt-in; disable + remove unit files).
185
+ UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
186
+ if [[ -f "$UNIT_DIR/memory-wiki-audit.timer" || -f "$UNIT_DIR/memory-wiki-audit.service" ]]; then
187
+ echo "Audit timer:"
188
+ if [[ $DRY_RUN -eq 1 ]]; then
189
+ echo " would disable + remove memory-wiki-audit.{timer,service}"
190
+ else
191
+ command -v systemctl >/dev/null 2>&1 && systemctl --user disable --now memory-wiki-audit.timer 2>/dev/null || true
192
+ remove_path "$UNIT_DIR/memory-wiki-audit.timer"
193
+ remove_path "$UNIT_DIR/memory-wiki-audit.service"
194
+ command -v systemctl >/dev/null 2>&1 && systemctl --user daemon-reload 2>/dev/null || true
195
+ note "Re-enable timer: cairn audit-timer"
196
+ fi
197
+ fi
198
+
199
+ # 5. Memory store (DATA — only with --purge-memory).
200
+ if [[ $PURGE_MEMORY -eq 1 ]]; then
201
+ echo "Memory store:"
202
+ remove_path "$STORE_DIR"
203
+ fi
204
+
205
+ # 6. Per-project bootstrap scaffold.
206
+ for proj in "${PROJECTS[@]:-}"; do
207
+ [[ -n "$proj" && -d "$proj" ]] || continue
208
+ proj=$(cd "$proj" && pwd)
209
+ echo "Project scaffold: $proj"
210
+ remove_path "$proj/.ai"
211
+ remove_path "$proj/.planning"
212
+ # Strip the .git/info/exclude lines contributor-mode bootstrap added.
213
+ if excl=$(cd "$proj" && git rev-parse --git-path info/exclude 2>/dev/null); then
214
+ [[ "$excl" == /* ]] || excl="$proj/$excl"
215
+ prefix=$(cd "$proj" && git rev-parse --show-prefix 2>/dev/null)
216
+ if [[ -f "$excl" ]] && grep -qxF "/${prefix}.ai/" "$excl" 2>/dev/null; then
217
+ if [[ $DRY_RUN -eq 1 ]]; then
218
+ echo " would strip .ai/ + .planning/ from ${excl}"
219
+ else
220
+ back_up "$excl"
221
+ grep -vxF -e "/${prefix}.ai/" -e "/${prefix}.planning/" "$excl" >"$excl.tmp" && mv "$excl.tmp" "$excl"
222
+ echo " stripped exclude entries from ${excl}"
223
+ fi
224
+ fi
225
+ fi
226
+ done
227
+
228
+ # ---- generate revert.sh ------------------------------------------------------
229
+ if [[ $DRY_RUN -eq 0 && -f "$MANIFEST" ]]; then
230
+ cat >"$BACKUP_DIR/revert.sh" <<'REVERT'
231
+ #!/usr/bin/env bash
232
+ # Restore everything cairn uninstall removed/edited from this bundle.
233
+ set -euo pipefail
234
+ here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
235
+ while IFS=$'\t' read -r abs rel; do
236
+ [[ -n "$abs" ]] || continue
237
+ mkdir -p "$(dirname "$abs")"
238
+ rm -rf "$abs"
239
+ cp -a "$here/$rel" "$abs"
240
+ echo "restored: $abs"
241
+ done <"$here/manifest.tsv"
242
+ echo
243
+ echo "Files restored. Command-based steps (if they applied) — see NOTES.txt:"
244
+ [[ -f "$here/NOTES.txt" ]] && cat "$here/NOTES.txt"
245
+ REVERT
246
+ chmod +x "$BACKUP_DIR/revert.sh"
247
+ fi
248
+
249
+ echo
250
+ if [[ $DRY_RUN -eq 1 ]]; then
251
+ echo "Dry run complete — no changes made."
252
+ else
253
+ echo "Done. Backup + revert.sh: $BACKUP_DIR"
254
+ echo " revert with: bash \"$BACKUP_DIR/revert.sh\""
255
+ fi
256
+ echo "Final step (npm's to do, not this script's): npm uninstall -g @cairnkeep/cli"