@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 +3 -2
- package/bin/cairn +9 -0
- package/package.json +2 -1
- package/scripts/smoke-bash32.sh +57 -0
- package/scripts/smoke-mcp-boot.sh +39 -0
- package/scripts/sync-claude-assets.sh +15 -7
- package/scripts/sync-opencode-explore-assets.sh +5 -1
- package/scripts/sync-opencode-wiki-assets.sh +5 -1
- package/scripts/test-portable-sh.sh +42 -0
- package/scripts/test-shipped-perms.sh +25 -0
- package/scripts/test-uninstall.sh +70 -0
- package/scripts/uninstall.sh +256 -0
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.
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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="$
|
|
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
|
|
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
|
|
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"
|