-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
167 lines (151 loc) · 7.94 KB
/
Copy pathinstall.sh
File metadata and controls
167 lines (151 loc) · 7.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#!/usr/bin/env bash
# Forge installer — idempotent, reversible, offline. It never downloads anything.
# bash install.sh install (symlink global/ into ~/.forge and ~/.claude, put `forge` on PATH)
# bash install.sh --dry-run print what it would do, change nothing
# bash install.sh --no-settings install, but skip the settings.json merge
# bash install.sh --uninstall remove Forge's symlinks AND the merged settings entries
# Installing MERGES Forge hooks/permissions/statusline into ~/.claude/settings.json
# (GLOBAL — affects all repos) via an idempotent, marker-guarded merge: your file is
# backed up first and existing entries are never clobbered. Skip it with --no-settings;
# reverse it any time with `install.sh --uninstall` or `forge init --remove-settings`.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Read-only assets (guards/statusline/tools) — a symlink into the bundle so hooks resolve.
FORGE_ASSETS="${FORGE_ASSETS:-$HOME/.forge}"
# Mutable personal state (recall) — a REAL dir in the XDG state home, NEVER inside the
# source tree. Keeping these separate is the whole point of P0-03.
FORGE_HOME="${FORGE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/forgekit}"
CLAUDE_DIR="$HOME/.claude"
BIN_DIR="$HOME/.local/bin"
STAMP="$(date +%Y%m%d-%H%M%S)"
DRY=0; MODE=install; NO_SETTINGS=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY=1 ;;
--no-settings) NO_SETTINGS=1 ;;
--uninstall) MODE=uninstall ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
say() { printf ' %s\n' "$*"; }
# Run argv directly (no eval — S-02). In dry-run, print the argv instead of executing.
act() { if [ "$DRY" = 1 ]; then say "[dry-run] $*"; else "$@"; fi; }
# link SRC DEST — back up an existing real file/dir OR a foreign symlink, then symlink.
# A symlink already pointing into this repo (Forge's own) is refreshed silently; any other
# existing symlink is a USER symlink and is backed up before being replaced, so `ln -sfn`
# never silently destroys it (HI-10).
link() {
local src="$1" dest="$2"
[ -e "$src" ] || { say "skip (missing in bundle): $src"; return; }
if [ -L "$dest" ]; then
case "$(readlink "$dest")" in
"$REPO"/*) : ;; # our own symlink — safe to refresh in place
*) act mv "$dest" "$dest.forge-bak-$STAMP"; say "backed up existing symlink $dest" ;;
esac
elif [ -e "$dest" ]; then
act mv "$dest" "$dest.forge-bak-$STAMP"; say "backed up existing $dest"
fi
act mkdir -p "$(dirname "$dest")"
act ln -sfn "$src" "$dest"
say "linked $dest -> $src"
}
# unlink DEST — remove only if it is a symlink pointing back into this repo.
unlink_ours() {
local dest="$1"
if [ -L "$dest" ] && case "$(readlink "$dest")" in "$REPO"/*) true;; *) false;; esac; then
act rm -f "$dest"; say "removed $dest"
fi
}
# One-shot migration: older installs symlinked ~/.forge -> <repo>/global, so personal
# recall facts were written into the source tree (global/recall/facts). Move them out.
migrate_recall() {
local old="$REPO/global/recall/facts"
[ -d "$old" ] || return 0
act mkdir -p "$FORGE_HOME/recall"
if [ ! -e "$FORGE_HOME/recall/facts" ]; then
act mv "$old" "$FORGE_HOME/recall/facts"
say "migrated personal recall -> $FORGE_HOME/recall/facts (out of the source tree)"
else
say "note: personal recall found in the source tree at $old; $FORGE_HOME/recall/facts already exists — merge by hand"
fi
}
install_forge() {
say "Installing Forge from $REPO"
link "$REPO/global" "$FORGE_ASSETS"
act mkdir -p "$FORGE_HOME" # mutable state home (recall) — real dir, outside the source tree
migrate_recall
for d in "$REPO"/global/tools/*/; do [ -d "$d" ] && link "$d" "$CLAUDE_DIR/skills/$(basename "$d")"; done
for f in "$REPO"/global/crew/*.md; do [ -e "$f" ] && link "$f" "$CLAUDE_DIR/agents/$(basename "$f")"; done
link "$REPO/src/cli.js" "$BIN_DIR/forge"
[ "$DRY" = 1 ] || chmod +x "$REPO/src/cli.js" 2>/dev/null || true
case ":$PATH:" in *":$BIN_DIR:"*) : ;; *) say "note: add $BIN_DIR to PATH (e.g. echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc)";; esac
# Wire guards + statusline into settings.json via the idempotent, marker-guarded merge
# (`forge init --settings-only`) instead of printing a block to paste by hand. mergeSettings
# never clobbers existing entries — it unions hooks/permissions and stamps a _forge marker,
# so re-running install.sh is a no-op. A FAILED merge (e.g. corrupt settings.json) must
# fail the install loudly (RA-04) — capture output first so the exit status is the node
# command's, not sed's, then report INCOMPLETE instead of Done.
SETTINGS_FAILED=0
if [ "$NO_SETTINGS" = 1 ]; then
say "settings merge skipped (--no-settings) — wire hooks later with: forge init --settings-only"
elif [ "$DRY" = 1 ]; then
say "[dry-run] would merge guards + statusline into $CLAUDE_DIR/settings.json (GLOBAL — affects all repos) via:"
say "[dry-run] node \"$REPO/src/cli.js\" init --settings-only"
say "[dry-run] (idempotent + _forge-marker-guarded — existing settings are preserved)"
else
say "Merging guards + statusline into $CLAUDE_DIR/settings.json (GLOBAL — affects all repos; idempotent, backed up, never clobbers)"
say " skip with --no-settings · reverse with \`forge init --remove-settings\` or \`install.sh --uninstall\`"
MERGE_OUT=""
if MERGE_OUT="$(node "$REPO/src/cli.js" init --settings-only 2>&1)"; then
printf '%s\n' "$MERGE_OUT" | sed 's/^/ /'
else
[ -n "$MERGE_OUT" ] && printf '%s\n' "$MERGE_OUT" | sed 's/^/ /'
SETTINGS_FAILED=1
echo " WARNING: settings merge FAILED — hooks/guards are NOT wired" >&2
fi
fi
if [ "$SETTINGS_FAILED" = 1 ]; then
cat >&2 <<EOF
Install INCOMPLETE — settings.json was not updated (fix ~/.claude/settings.json, then run: forge init --settings-only)
EOF
exit 1
fi
cat <<EOF
Done. Or install the plugin instead (guards auto-wire): /plugin marketplace add <this-repo> then /plugin install forgekit.
Run \`forge doctor\` to verify.
EOF
}
uninstall_forge() {
say "Uninstalling Forge (symlinks + merged settings entries; your own files are untouched)"
# RA-17: reverse the settings merge FIRST (while the repo is still intact) so
# ~/.claude/settings.json doesn't keep hooks pointing at removed assets. Fail-soft:
# a missing node, or a corrupt settings file, degrades to a manual instruction.
if [ "$DRY" = 1 ]; then
say "[dry-run] would remove merged settings entries via:"
say "[dry-run] node \"$REPO/src/cli.js\" init --remove-settings"
elif command -v node >/dev/null 2>&1; then
REMOVE_OUT=""
if REMOVE_OUT="$(node "$REPO/src/cli.js" init --remove-settings 2>&1)"; then
printf '%s\n' "$REMOVE_OUT" | sed 's/^/ /'
else
[ -n "$REMOVE_OUT" ] && printf '%s\n' "$REMOVE_OUT" | sed 's/^/ /'
# Removing the asset symlinks now would leave GLOBAL settings hooks firing scripts that
# no longer exist — the dangerous case (HI-10). Stop with the assets intact instead of
# printing a false "Done."
cat >&2 <<EOF
Uninstall INCOMPLETE — settings still reference Forge hooks; fix $CLAUDE_DIR/settings.json then re-run \`install.sh --uninstall\` (asset symlinks left in place)
EOF
exit 1
fi
else
say "note: node not found — remove the Forge hook/permission/statusline entries from $CLAUDE_DIR/settings.json by hand, or run \`forge init --remove-settings\` once node is available"
fi
for d in "$REPO"/global/tools/*/; do [ -d "$d" ] && unlink_ours "$CLAUDE_DIR/skills/$(basename "$d")"; done
for f in "$REPO"/global/crew/*.md; do [ -e "$f" ] && unlink_ours "$CLAUDE_DIR/agents/$(basename "$f")"; done
unlink_ours "$BIN_DIR/forge"
unlink_ours "$FORGE_ASSETS"
say "Personal state in $FORGE_HOME is left untouched (remove it by hand if you want)."
say "Done. Any backed-up files remain as *.forge-bak-* next to their originals."
}
[ "$MODE" = uninstall ] && uninstall_forge || install_forge