agent-director 0.4.0 → 0.4.2

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.
@@ -0,0 +1,620 @@
1
+ #!/usr/bin/env bash
2
+ # install.sh — install or upgrade agent-director on this machine.
3
+ #
4
+ # Per SRD §16.2. The script is deliberately bash + jq + standard
5
+ # coreutils — no Go, no exotic deps. The Apiary skill harness invokes
6
+ # it; an operator can also run it directly from a checked-out tree.
7
+ #
8
+ # Flags:
9
+ # --binary <path> Source binary to install. Defaults to looking
10
+ # next to the script first, then to whatever
11
+ # `command -v agent-director` resolves to.
12
+ # --from-release [tag] Download a pre-built binary for this host's
13
+ # OS/arch from GitHub Releases and install it.
14
+ # With no tag, resolves the latest release via
15
+ # `gh release view` (if available) or
16
+ # `curl + jq` against api.github.com. Mutually
17
+ # exclusive with --binary.
18
+ # --sha256 <hex> Verify the downloaded asset against this
19
+ # sha256 (lowercase hex, 64 chars). Only
20
+ # meaningful with --from-release. Optional —
21
+ # omit to skip verification.
22
+ # --symlink-dir <dir> Drop a PATH symlink at <dir>/agent-director.
23
+ # Default: ~/.local/bin if on PATH; otherwise
24
+ # no symlink.
25
+ # --no-symlink Suppress symlink creation regardless of dir.
26
+ # --register-mcp Run `claude mcp add` for the stdio server.
27
+ # --no-hooks Skip the ~/.claude/settings.json hook
28
+ # injection step entirely. settings.json is
29
+ # left byte-identical (no .bak backup, no
30
+ # edit). Default OFF — defaulting to skip
31
+ # would defeat install.sh's main value over a
32
+ # bare binary copy.
33
+ # --keep-prior Before overwriting an existing binary,
34
+ # snapshot it to <target>.prior (overwriting
35
+ # any previous .prior). Roll back with
36
+ # `mv <target>.prior <target>`. Default OFF.
37
+ #
38
+ # Exit codes:
39
+ # 0 success
40
+ # 2 pre-flight failure (claude/tmux missing, whitespace in path)
41
+ # 3 binary source not found / not executable
42
+ # 4 hook merge failure (~/.claude/settings.json malformed)
43
+ # 5 store warmup failure
44
+ #
45
+ # Idempotent: re-running the script with no flags after a clean
46
+ # install is a no-op (returns 0, prints "already installed at vX").
47
+
48
+ set -euo pipefail
49
+
50
+ # --------------------------------------------------------------------
51
+ # Defaults + flag parsing
52
+ # --------------------------------------------------------------------
53
+
54
+ readonly DEFAULT_INSTALL_ROOT="${HOME}/.agent-director"
55
+ readonly DEFAULT_BIN_DIR="${DEFAULT_INSTALL_ROOT}/bin"
56
+ readonly DEFAULT_SETTINGS_PATH="${HOME}/.claude/settings.json"
57
+
58
+ BINARY_SRC=""
59
+ FROM_RELEASE=0
60
+ FROM_RELEASE_TAG=""
61
+ SHA256_EXPECTED=""
62
+ SYMLINK_DIR=""
63
+ SYMLINK_DEFAULT=""
64
+ NO_SYMLINK=0
65
+ REGISTER_MCP=0
66
+ NO_HOOKS=0
67
+ KEEP_PRIOR=0
68
+
69
+ # GitHub repo slug used by --from-release. Matches go.mod's module path
70
+ # and the release.sh asset naming.
71
+ readonly RELEASE_REPO_SLUG="gabemahoney/agent-director"
72
+
73
+ # Pick a sensible default symlink dir: ~/.local/bin if on PATH.
74
+ if printf '%s' ":${PATH}:" | grep -q ":${HOME}/.local/bin:"; then
75
+ SYMLINK_DEFAULT="${HOME}/.local/bin"
76
+ fi
77
+
78
+ while [[ $# -gt 0 ]]; do
79
+ case "$1" in
80
+ --binary)
81
+ BINARY_SRC="$2"; shift 2 ;;
82
+ --from-release)
83
+ FROM_RELEASE=1
84
+ # Optional tag argument: accept only if the next arg
85
+ # doesn't look like another flag.
86
+ if [[ $# -ge 2 && -n "${2:-}" && "${2:-}" != -* ]]; then
87
+ FROM_RELEASE_TAG="$2"; shift 2
88
+ else
89
+ shift
90
+ fi
91
+ ;;
92
+ --sha256)
93
+ SHA256_EXPECTED="$2"; shift 2 ;;
94
+ --symlink-dir)
95
+ SYMLINK_DIR="$2"; shift 2 ;;
96
+ --no-symlink)
97
+ NO_SYMLINK=1; shift ;;
98
+ --register-mcp)
99
+ REGISTER_MCP=1; shift ;;
100
+ --no-hooks)
101
+ NO_HOOKS=1; shift ;;
102
+ --keep-prior)
103
+ KEEP_PRIOR=1; shift ;;
104
+ -h|--help)
105
+ sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
106
+ exit 0 ;;
107
+ *)
108
+ echo "install.sh: unknown flag: $1" >&2
109
+ exit 2 ;;
110
+ esac
111
+ done
112
+
113
+ [[ -z "$SYMLINK_DIR" && "$NO_SYMLINK" -eq 0 ]] && SYMLINK_DIR="$SYMLINK_DEFAULT"
114
+
115
+ if [[ "$FROM_RELEASE" -eq 1 && -n "$BINARY_SRC" ]]; then
116
+ echo "install.sh: --from-release and --binary are mutually exclusive" >&2
117
+ exit 2
118
+ fi
119
+ if [[ -n "$SHA256_EXPECTED" && "$FROM_RELEASE" -eq 0 ]]; then
120
+ echo "install.sh: --sha256 only applies with --from-release" >&2
121
+ exit 2
122
+ fi
123
+ if [[ -n "$SHA256_EXPECTED" && ! "$SHA256_EXPECTED" =~ ^[0-9a-f]{64}$ ]]; then
124
+ echo "install.sh: --sha256 must be 64 lowercase hex characters" >&2
125
+ exit 2
126
+ fi
127
+
128
+ # --------------------------------------------------------------------
129
+ # Pre-flight
130
+ # --------------------------------------------------------------------
131
+
132
+ # SRD §4.3: tmux's direct-argv invocation requires shell-safe paths.
133
+ # Reject any whitespace in the install root up front so an operator
134
+ # whose $HOME has a space sees the error immediately, not at the
135
+ # first spawn.
136
+ if [[ "$DEFAULT_INSTALL_ROOT" =~ [[:space:]] ]]; then
137
+ echo "install.sh: install path contains whitespace: $DEFAULT_INSTALL_ROOT" >&2
138
+ echo " SRD §4.3 requires a whitespace-free install path." >&2
139
+ exit 2
140
+ fi
141
+
142
+ # SRD §SR-2.1 / Idea Bee b.fg3: hard-refuse any host outside the v0.4.1
143
+ # supported set {Linux/x86_64, Darwin/arm64} at preflight time, mirroring
144
+ # the umbrella's npm/bun-side os/cpu gate + postinstall host-pair refusal
145
+ # (Pattern A). install.sh is the direct-invocation surface (Pattern B
146
+ # fallback + Pattern A second step); without this gate an operator on an
147
+ # unsupported host could still copy a wrong-arch CLI into place.
148
+ uname_s="$(uname -s)"
149
+ uname_m="$(uname -m)"
150
+ case "${uname_s}/${uname_m}" in
151
+ Linux/x86_64|Darwin/arm64)
152
+ ;;
153
+ *)
154
+ echo "install.sh: unsupported host: ${uname_s}/${uname_m}. Supported: Linux/x86_64, Darwin/arm64. See b.fg3 for cross-platform expansion status." >&2
155
+ exit 2
156
+ ;;
157
+ esac
158
+
159
+ # claude + tmux must be on PATH. `file` is required for the --binary
160
+ # architecture probe (SR-2.2) — hard requirement; never silent-skip.
161
+ required_tools=(claude tmux jq file)
162
+ [[ "$FROM_RELEASE" -eq 1 ]] && required_tools+=(curl)
163
+ for tool in "${required_tools[@]}"; do
164
+ if ! command -v "$tool" >/dev/null 2>&1; then
165
+ echo "install.sh: required tool not found on PATH: $tool" >&2
166
+ case "$tool" in
167
+ claude) echo " Install Claude Code first: https://claude.com/claude-code" >&2 ;;
168
+ tmux) echo " Install tmux via your package manager (apt/brew/dnf/etc.)." >&2 ;;
169
+ jq) echo " Install jq via your package manager (we use it to safely edit settings.json)." >&2 ;;
170
+ file) echo " Install file via your package manager (apt install file / brew install file-formula / dnf install file). Required for the --binary architecture probe." >&2 ;;
171
+ curl) echo " --from-release downloads via curl; install it via your package manager." >&2 ;;
172
+ esac
173
+ exit 2
174
+ fi
175
+ done
176
+
177
+ echo "install.sh: pre-flight OK"
178
+ echo " claude : $(claude --version 2>/dev/null || echo '<unknown>')"
179
+ echo " tmux : $(tmux -V 2>/dev/null || echo '<unknown>')"
180
+
181
+ # --------------------------------------------------------------------
182
+ # --from-release: resolve tag, download asset for this OS/arch, hand
183
+ # the temp path to the rest of the install flow as if --binary had
184
+ # been passed.
185
+ # --------------------------------------------------------------------
186
+
187
+ if [[ "$FROM_RELEASE" -eq 1 ]]; then
188
+ case "$(uname -s)" in
189
+ Linux) rel_os="linux" ;;
190
+ Darwin) rel_os="darwin" ;;
191
+ *)
192
+ echo "install.sh: --from-release: unsupported OS $(uname -s)" >&2
193
+ echo " release.sh only publishes linux and darwin builds." >&2
194
+ exit 3 ;;
195
+ esac
196
+ case "$(uname -m)" in
197
+ x86_64|amd64) rel_arch="amd64" ;;
198
+ arm64|aarch64) rel_arch="arm64" ;;
199
+ *)
200
+ echo "install.sh: --from-release: unsupported arch $(uname -m)" >&2
201
+ exit 3 ;;
202
+ esac
203
+ asset="agent-director-${rel_os}-${rel_arch}"
204
+
205
+ # Resolve the tag if the operator didn't supply one. Prefer `gh`
206
+ # (carries the operator's auth, avoids the unauthenticated API
207
+ # rate limit); fall back to curl + jq against the public API.
208
+ if [[ -z "$FROM_RELEASE_TAG" ]]; then
209
+ if command -v gh >/dev/null 2>&1; then
210
+ FROM_RELEASE_TAG=$(gh release view --repo "$RELEASE_REPO_SLUG" \
211
+ --json tagName -q .tagName 2>/dev/null || true)
212
+ fi
213
+ if [[ -z "$FROM_RELEASE_TAG" ]]; then
214
+ api_url="https://api.github.com/repos/${RELEASE_REPO_SLUG}/releases/latest"
215
+ FROM_RELEASE_TAG=$(curl -fsSL "$api_url" 2>/dev/null \
216
+ | jq -r '.tag_name // empty' 2>/dev/null || true)
217
+ fi
218
+ if [[ -z "$FROM_RELEASE_TAG" || "$FROM_RELEASE_TAG" == "null" ]]; then
219
+ echo "install.sh: --from-release: no releases published for $RELEASE_REPO_SLUG yet" >&2
220
+ echo " options:" >&2
221
+ echo " - build from source: make build && bash $0" >&2
222
+ echo " - point at a local binary: bash $0 --binary <path>" >&2
223
+ exit 3
224
+ fi
225
+ fi
226
+ echo " release : $RELEASE_REPO_SLUG @ $FROM_RELEASE_TAG ($asset)"
227
+
228
+ asset_url="https://github.com/${RELEASE_REPO_SLUG}/releases/download/${FROM_RELEASE_TAG}/${asset}"
229
+ tmp_bin="$(mktemp -t agent-director.XXXXXX)"
230
+ # Defer-cleanup the tempfile on any exit path that doesn't move
231
+ # past the BINARY_SRC assignment. install -m 0755 later in the
232
+ # script copies the contents into place, so the tempfile being
233
+ # cleaned up at script exit is fine.
234
+ trap 'rm -f "$tmp_bin"' EXIT
235
+ if ! curl -fsSL --retry 2 -o "$tmp_bin" "$asset_url"; then
236
+ echo "install.sh: --from-release: failed to download $asset_url" >&2
237
+ echo " check that the asset exists for $FROM_RELEASE_TAG on $RELEASE_REPO_SLUG." >&2
238
+ exit 3
239
+ fi
240
+
241
+ if [[ -n "$SHA256_EXPECTED" ]]; then
242
+ if command -v sha256sum >/dev/null 2>&1; then
243
+ actual=$(sha256sum "$tmp_bin" | awk '{print $1}')
244
+ elif command -v shasum >/dev/null 2>&1; then
245
+ actual=$(shasum -a 256 "$tmp_bin" | awk '{print $1}')
246
+ else
247
+ echo "install.sh: --sha256: neither sha256sum nor shasum available" >&2
248
+ exit 3
249
+ fi
250
+ if [[ "$actual" != "$SHA256_EXPECTED" ]]; then
251
+ echo "install.sh: --from-release: sha256 mismatch" >&2
252
+ echo " expected: $SHA256_EXPECTED" >&2
253
+ echo " actual : $actual" >&2
254
+ exit 3
255
+ fi
256
+ echo " sha256 : verified"
257
+ fi
258
+
259
+ chmod +x "$tmp_bin"
260
+ BINARY_SRC="$tmp_bin"
261
+ fi
262
+
263
+ # --------------------------------------------------------------------
264
+ # Locate source binary
265
+ # --------------------------------------------------------------------
266
+
267
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
268
+
269
+ # Defaults to 1; cleared to 0 only when BINARY_SRC came from PATH
270
+ # (option (c)), since "whatever's on PATH" makes no claim about the
271
+ # operator's source tree.
272
+ VERSION_CHECK_REQUIRED=1
273
+
274
+ if [[ -z "$BINARY_SRC" ]]; then
275
+ # Prefer the in-repo build (skills/install-agent-director sits two
276
+ # levels under the repo root; bin/ is at the root).
277
+ candidate="${SCRIPT_DIR}/../../bin/agent-director"
278
+ if [[ -x "$candidate" ]]; then
279
+ BINARY_SRC="$candidate"
280
+ elif command -v agent-director >/dev/null 2>&1; then
281
+ BINARY_SRC="$(command -v agent-director)"
282
+ VERSION_CHECK_REQUIRED=0
283
+ else
284
+ echo "install.sh: no source binary found." >&2
285
+ echo " Tried: $candidate" >&2
286
+ echo " Tried: command -v agent-director" >&2
287
+ echo " Pass --binary <path> to override." >&2
288
+ exit 3
289
+ fi
290
+ fi
291
+ if [[ ! -x "$BINARY_SRC" ]]; then
292
+ echo "install.sh: source binary not executable: $BINARY_SRC" >&2
293
+ exit 3
294
+ fi
295
+ echo " source : $BINARY_SRC"
296
+
297
+ # --------------------------------------------------------------------
298
+ # --binary architecture probe (SR-2.2, preflight step 6)
299
+ #
300
+ # Catches the case where a supported host receives a wrong-arch binary
301
+ # (e.g. operator passes a darwin-arm64 artifact on a Linux/x86_64 host).
302
+ # Runs file(1) against $BINARY_SRC and pattern-matches against the
303
+ # host pair captured by the OS/CPU gate (T1). On mismatch: exit 2 with
304
+ # the SR-2.2 message. file(1) is a hard preflight requirement (T2 +
305
+ # required_tools); never silent-skip.
306
+ #
307
+ # Multiple substring matches joined by && rather than a single regex —
308
+ # file's output format varies subtly across distros (`x86-64` vs
309
+ # `x86_64`), and a brittle regex would silently misclassify a valid
310
+ # binary on a future toolchain.
311
+ # --------------------------------------------------------------------
312
+
313
+ file_out="$(file -L -b "$BINARY_SRC")"
314
+ arch_ok=0
315
+ case "${uname_s}/${uname_m}" in
316
+ Linux/x86_64)
317
+ if grep -q "ELF 64-bit LSB" <<<"$file_out" \
318
+ && { grep -q "x86-64" <<<"$file_out" || grep -q "x86_64" <<<"$file_out"; }; then
319
+ arch_ok=1
320
+ fi
321
+ ;;
322
+ Darwin/arm64)
323
+ if grep -q "Mach-O" <<<"$file_out" \
324
+ && { grep -q "arm64e" <<<"$file_out" || grep -q "arm64" <<<"$file_out"; }; then
325
+ arch_ok=1
326
+ fi
327
+ ;;
328
+ esac
329
+
330
+ if [[ "$arch_ok" -ne 1 ]]; then
331
+ # Distil the diagnostic excerpt from file's output — first ~60 chars
332
+ # is plenty to surface "Mach-O arm64" or "ELF 64-bit LSB x86-64".
333
+ detected="$(printf '%s' "$file_out" | head -c 80 | tr '\n' ' ')"
334
+ echo "install.sh: --binary $BINARY_SRC: architecture mismatch (binary appears to be ${detected}; host is ${uname_s}/${uname_m}). Did you pass the wrong --binary?" >&2
335
+ exit 2
336
+ fi
337
+
338
+ # --------------------------------------------------------------------
339
+ # Source-tree version check
340
+ #
341
+ # When the operator points install.sh at a local binary (either via
342
+ # --binary or via the in-repo ./bin/agent-director fallback) AND
343
+ # install.sh itself lives inside a git checkout, refuse to install a
344
+ # binary whose embedded commit doesn't match the checkout's HEAD.
345
+ # Catches the "operator forgot to `make build` after pulling new
346
+ # code" footgun — installing a stale artifact silently is exactly
347
+ # what b.qag flagged.
348
+ #
349
+ # Skipped when:
350
+ # - --from-release was used (the asset is by construction not the
351
+ # operator's source tree)
352
+ # - install.sh is not inside a git checkout (curled tarball case)
353
+ # - BINARY_SRC came from `command -v` (option (c)): there's no
354
+ # promise it was built from this tree, and the user explicitly
355
+ # asked for "whatever's on PATH"
356
+ # --------------------------------------------------------------------
357
+
358
+ if [[ "$FROM_RELEASE" -eq 0 && "${VERSION_CHECK_REQUIRED:-1}" -eq 1 ]]; then
359
+ # Find the script's repo root (walking up from SCRIPT_DIR). The
360
+ # script-path is what tells us "this checkout"; CWD might be
361
+ # somewhere unrelated.
362
+ repo_root=""
363
+ probe="$SCRIPT_DIR"
364
+ while [[ "$probe" != "/" && -n "$probe" ]]; do
365
+ if [[ -d "$probe/.git" ]]; then
366
+ repo_root="$probe"; break
367
+ fi
368
+ probe="$(dirname "$probe")"
369
+ done
370
+
371
+ if [[ -n "$repo_root" ]] && head_sha=$(git -C "$repo_root" rev-parse HEAD 2>/dev/null); then
372
+ # Run the binary's `version` verb. An older binary without the
373
+ # verb will exit non-zero / emit an err_name envelope; jq -e
374
+ # returns non-zero if .commit is absent or null. Either way we
375
+ # land in the mismatch path with bin_commit empty.
376
+ bin_commit=$("$BINARY_SRC" version 2>/dev/null \
377
+ | jq -er '.commit // empty' 2>/dev/null \
378
+ || true)
379
+
380
+ if [[ -z "$bin_commit" || "$bin_commit" == "unknown" || "$bin_commit" != "$head_sha" ]]; then
381
+ echo "install.sh: source-tree version check failed." >&2
382
+ echo " binary : $BINARY_SRC" >&2
383
+ if [[ -z "$bin_commit" ]]; then
384
+ echo " built from: <no version stamp — binary is older than this verb, or built without ldflags>" >&2
385
+ elif [[ "$bin_commit" == "unknown" ]]; then
386
+ echo " built from: <unstamped — likely a plain 'go build' without -ldflags>" >&2
387
+ else
388
+ echo " built from: $bin_commit" >&2
389
+ fi
390
+ echo " HEAD : $head_sha ($repo_root)" >&2
391
+ echo "" >&2
392
+ echo " The binary at $BINARY_SRC was not built from this checkout's" >&2
393
+ echo " current HEAD. Installing it would silently substitute stale code" >&2
394
+ echo " for the source you're sitting on. Either:" >&2
395
+ echo " - rebuild it first: make build" >&2
396
+ echo " - or download release: rerun with --from-release (omit --binary)" >&2
397
+ exit 3
398
+ fi
399
+ echo " version-check: binary commit matches HEAD ($head_sha)"
400
+ fi
401
+ fi
402
+
403
+ # --------------------------------------------------------------------
404
+ # Create install root + bin dir
405
+ # --------------------------------------------------------------------
406
+
407
+ mkdir -p "$DEFAULT_INSTALL_ROOT"
408
+ chmod 0700 "$DEFAULT_INSTALL_ROOT"
409
+ mkdir -p "$DEFAULT_BIN_DIR"
410
+ chmod 0755 "$DEFAULT_BIN_DIR"
411
+
412
+ # --------------------------------------------------------------------
413
+ # Atomic install: write to a sibling temp path, then mv over the target.
414
+ #
415
+ # `mv` within the same filesystem is atomic at the inode level —
416
+ # concurrent readers see either the old binary or the new, never half.
417
+ # A running process holds the old inode reference, so an in-flight
418
+ # exec is unaffected by the swap.
419
+ #
420
+ # This is the standard pattern for single-binary CLI installers
421
+ # (gh, kubectl, terraform). The version-manager pattern (canonical
422
+ # symlink → versioned files) is only worth the complexity when you
423
+ # actually manage multiple concurrent versions; we don't.
424
+ # --------------------------------------------------------------------
425
+
426
+ CANONICAL="${DEFAULT_BIN_DIR}/agent-director"
427
+ PRIOR="${CANONICAL}.prior"
428
+ TMP="${CANONICAL}.tmp.$$"
429
+
430
+ if [[ "$KEEP_PRIOR" -eq 1 && -f "$CANONICAL" ]]; then
431
+ cp -f "$CANONICAL" "$PRIOR"
432
+ chmod 0755 "$PRIOR"
433
+ echo " prior : snapshotted to $PRIOR"
434
+ fi
435
+
436
+ cp "$BINARY_SRC" "$TMP"
437
+ chmod 0755 "$TMP"
438
+ mv "$TMP" "$CANONICAL"
439
+
440
+ echo " binary : $CANONICAL"
441
+
442
+ # --------------------------------------------------------------------
443
+ # Optional PATH symlink
444
+ # --------------------------------------------------------------------
445
+
446
+ if [[ "$NO_SYMLINK" -eq 0 && -n "$SYMLINK_DIR" ]]; then
447
+ if [[ ! -d "$SYMLINK_DIR" ]]; then
448
+ echo " symlink : skipped — $SYMLINK_DIR does not exist"
449
+ elif [[ "$SYMLINK_DIR" =~ [[:space:]] ]]; then
450
+ echo " symlink : skipped — $SYMLINK_DIR contains whitespace"
451
+ else
452
+ target="${SYMLINK_DIR}/agent-director"
453
+ ln -sfn "$CANONICAL" "${target}.new"
454
+ mv -f "${target}.new" "$target"
455
+ echo " symlink : $target → $CANONICAL"
456
+ fi
457
+ fi
458
+
459
+ # --------------------------------------------------------------------
460
+ # Warm up state.db via `agent-director help`
461
+ # --------------------------------------------------------------------
462
+
463
+ if "$CANONICAL" help >/dev/null 2>&1; then
464
+ state_db="${DEFAULT_INSTALL_ROOT}/state.db"
465
+ if [[ -f "$state_db" ]]; then
466
+ chmod 0600 "$state_db" 2>/dev/null || true
467
+ echo " state.db: $(stat -c '%a' "$state_db" 2>/dev/null || stat -f '%Lp' "$state_db") at $state_db"
468
+ fi
469
+ else
470
+ echo "install.sh: store warmup (agent-director help) failed" >&2
471
+ exit 5
472
+ fi
473
+
474
+ # --------------------------------------------------------------------
475
+ # Hook injection — additive merge into ~/.claude/settings.json
476
+ #
477
+ # Skipped entirely under --no-hooks: settings.json is not read, not
478
+ # backed up, not written. There's no edit, so there's nothing to back
479
+ # up — leaving settings.json byte-identical to its pre-install state.
480
+ # --------------------------------------------------------------------
481
+
482
+ if [[ "$NO_HOOKS" -eq 1 ]]; then
483
+ echo " hooks : skipped (--no-hooks)"
484
+ else
485
+ mkdir -p "$(dirname "$DEFAULT_SETTINGS_PATH")"
486
+
487
+ # Read existing settings or start from {}.
488
+ if [[ -f "$DEFAULT_SETTINGS_PATH" ]]; then
489
+ existing=$(<"$DEFAULT_SETTINGS_PATH")
490
+ if ! printf '%s' "$existing" | jq empty >/dev/null 2>&1; then
491
+ echo "install.sh: ~/.claude/settings.json is not valid JSON" >&2
492
+ exit 4
493
+ fi
494
+ else
495
+ existing='{}'
496
+ fi
497
+
498
+ # Our hook entries are uniquely identified by the command string
499
+ # (the canonical binary path + " help"). Idempotency check: only add
500
+ # if the command isn't already present in that event's hook list.
501
+ help_cmd="${CANONICAL} help"
502
+
503
+ # Merge logic (jq):
504
+ # - Ensure hooks.SessionStart is an array; append our entry if not
505
+ # already there (matched by command).
506
+ # - Ensure hooks.SessionEnd is an array; append our compact-matcher
507
+ # entry if not already there.
508
+ new_settings=$(printf '%s' "$existing" | jq \
509
+ --arg cmd "$help_cmd" '
510
+ .hooks //= {}
511
+ | .hooks.SessionStart //= []
512
+ | .hooks.SessionEnd //= []
513
+ | (
514
+ if any(.hooks.SessionStart[]?; .hooks[]?.command == $cmd)
515
+ then .
516
+ else .hooks.SessionStart += [{"hooks":[{"type":"command","command":$cmd}]}]
517
+ end
518
+ )
519
+ | (
520
+ if any(.hooks.SessionEnd[]?; .matcher == "compact" and (.hooks[]?.command == $cmd))
521
+ then .
522
+ else .hooks.SessionEnd += [{"matcher":"compact","hooks":[{"type":"command","command":$cmd}]}]
523
+ end
524
+ )
525
+ ')
526
+
527
+ # Backup-before-edit: snapshot the prior settings.json (if any) into a
528
+ # timestamped .bak alongside the original so a regressed jq filter is
529
+ # recoverable. Only the *prior* contents are backed up; in-place
530
+ # re-runs of the install will keep the most recent pre-edit copy.
531
+ if [[ -f "$DEFAULT_SETTINGS_PATH" ]]; then
532
+ backup_settings="${DEFAULT_SETTINGS_PATH}.bak.$(date +%Y%m%d-%H%M%S)"
533
+ cp -f "$DEFAULT_SETTINGS_PATH" "$backup_settings"
534
+ echo " backup : $backup_settings"
535
+ fi
536
+
537
+ # Atomic write: tempfile + mv.
538
+ tmp_settings="${DEFAULT_SETTINGS_PATH}.new"
539
+ printf '%s\n' "$new_settings" > "$tmp_settings"
540
+ mv -f "$tmp_settings" "$DEFAULT_SETTINGS_PATH"
541
+
542
+ echo " hooks : injected into $DEFAULT_SETTINGS_PATH"
543
+ fi
544
+
545
+ # --------------------------------------------------------------------
546
+ # inject_help_hook config flag — opt-in dynamic per-Spawn help hook.
547
+ #
548
+ # Driven by the same Q4 (inject persistent help hooks?) signal: when
549
+ # the operator picked "yes" (i.e. did NOT pass --no-hooks),
550
+ # agent-director should also tag its own Spawns with a help hook
551
+ # regardless of the Spawn's CLAUDE_CONFIG_DIR. install.sh sets the
552
+ # flag here; the binary reads it at spawn-synth time.
553
+ #
554
+ # Q4=no (--no-hooks) leaves config.toml untouched — the flag stays at
555
+ # its zero-value default of false.
556
+ # --------------------------------------------------------------------
557
+
558
+ if [[ "$NO_HOOKS" -eq 0 ]]; then
559
+ CONFIG_TOML="${DEFAULT_INSTALL_ROOT}/config.toml"
560
+ if [[ -f "$CONFIG_TOML" ]]; then
561
+ backup_cfg="${CONFIG_TOML}.bak.$(date +%Y%m%d-%H%M%S)"
562
+ cp -f "$CONFIG_TOML" "$backup_cfg"
563
+ # awk merge: rewrite an existing inject_help_hook line under
564
+ # [defaults] to =true; if [defaults] exists but lacks the key,
565
+ # append it inside the section; if no [defaults] section exists
566
+ # at all, add one at end of file. Preserves every other key
567
+ # and section verbatim.
568
+ merged=$(awk '
569
+ BEGIN { written = 0; in_defaults = 0 }
570
+ /^\[/ {
571
+ if (in_defaults && !written) {
572
+ print "inject_help_hook = true"
573
+ written = 1
574
+ }
575
+ in_defaults = ($0 ~ /^\[defaults\][[:space:]]*$/) ? 1 : 0
576
+ print
577
+ next
578
+ }
579
+ in_defaults && /^[[:space:]]*inject_help_hook[[:space:]]*=/ {
580
+ print "inject_help_hook = true"
581
+ written = 1
582
+ next
583
+ }
584
+ { print }
585
+ END {
586
+ if (in_defaults && !written) {
587
+ print "inject_help_hook = true"
588
+ written = 1
589
+ }
590
+ if (!written) {
591
+ print ""
592
+ print "[defaults]"
593
+ print "inject_help_hook = true"
594
+ }
595
+ }
596
+ ' "$CONFIG_TOML")
597
+ tmp_cfg="${CONFIG_TOML}.new"
598
+ printf '%s\n' "$merged" > "$tmp_cfg"
599
+ mv -f "$tmp_cfg" "$CONFIG_TOML"
600
+ echo " config : merged inject_help_hook=true into $CONFIG_TOML (backup $backup_cfg)"
601
+ else
602
+ printf '[defaults]\ninject_help_hook = true\n' > "$CONFIG_TOML"
603
+ chmod 0600 "$CONFIG_TOML"
604
+ echo " config : created $CONFIG_TOML with inject_help_hook=true"
605
+ fi
606
+ fi
607
+
608
+ # --------------------------------------------------------------------
609
+ # Optional MCP registration
610
+ # --------------------------------------------------------------------
611
+
612
+ if [[ "$REGISTER_MCP" -eq 1 ]]; then
613
+ if claude mcp add agent-director "$CANONICAL" serve --stdio 2>/dev/null; then
614
+ echo " mcp : registered with claude mcp"
615
+ else
616
+ echo " mcp : registration failed (continuing anyway)" >&2
617
+ fi
618
+ fi
619
+
620
+ echo "install.sh: done. Try: $CANONICAL help"