aid-installer 0.7.5

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,1646 @@
1
+ #!/usr/bin/env bash
2
+ # aid-install-core.sh - Shared Bash install-core library for the AID installer.
3
+ #
4
+ # Purpose:
5
+ # Sourceable library of pure functions used by install.sh (Bash bootstrap) and
6
+ # any future in-language caller. No top-level side effects when sourced - every
7
+ # function is defined here; nothing executes at source time.
8
+ #
9
+ # Provides:
10
+ # sha256_file <path> -> hex sha256 of file content (stdout)
11
+ # normalize_tool <id> -> canonical lower-case-hyphen tool id (stdout)
12
+ # detect_tool <target> -> detected tool id (stdout); exit 2 on 0 or >1 matches
13
+ # resolve_version -> latest GitHub release version (stdout); exit 3 on fail
14
+ # fetch_tarball <tool> <ver> <dest_dir>
15
+ # -> downloads aid-<tool>-v<ver>.tar.gz + SHA256SUMS into
16
+ # dest_dir; verifies sha256; exit 3 on fetch, 4 on mismatch
17
+ # extract_tarball <tarball> <dest_dir>
18
+ # -> extracts tarball (flat root) into dest_dir; exit 1 on fail
19
+ # verify_bundle_checksum <tarball>
20
+ # -> verifies sibling SHA256SUMS when present; exit 4 on mismatch
21
+ # copy_file <src> <dst> [force]
22
+ # -> copy semantics (skip-identical/skip-on-diff/force)
23
+ # copy_dir <src_dir> <dst_dir> [force]
24
+ # -> recursive copy via copy_file
25
+ # install_tool <staging> <tool> <target> <version> [force]
26
+ # -> run the full install for one tool (copy + manifest + protect-on-diff)
27
+ # manifest_read_tool_paths <manifest> <tool>
28
+ # -> newline-delimited paths from tools.<tool>.paths (stdout)
29
+ # manifest_read_tool_version <manifest> <tool>
30
+ # -> version string from tools.<tool>.version (stdout)
31
+ # manifest_read_root_agent <manifest> <tool> <path>
32
+ # -> sha256 from root_agent_files entry (stdout); empty if absent
33
+ # manifest_read_root_agent_status <manifest> <tool> <path>
34
+ # -> status field from root_agent_files entry (stdout)
35
+ # manifest_write <manifest> <tool> <version> <paths_arr_name> <root_entries_arr_name>
36
+ # -> atomically writes/merges the manifest JSON
37
+ # manifest_remove_tool <manifest> <tool>
38
+ # -> removes a tool section from the manifest
39
+ # manifest_exists <manifest> -> exit 0 when manifest exists and is parseable, else exit 6
40
+ # uninstall_tool <manifest> <tool> <target>
41
+ # -> manifest-driven removal of one tool's files
42
+ # write_version_marker <target> <version>
43
+ # -> writes <target>/.aid/.aid-version
44
+ #
45
+ # Verbose mode:
46
+ # Set AID_VERBOSE=1 (or pass --verbose to install.sh) to print per-file
47
+ # Copied:/Up to date:/Updated:/Skipped:/Removed: lines. Default (0) prints
48
+ # only the per-tool summary line. Protect-on-diff WARNs and errors always show.
49
+ #
50
+ # Exit codes (from install.sh):
51
+ # 0 success
52
+ # 1 generic runtime failure
53
+ # 2 usage error
54
+ # 3 network / fetch failure
55
+ # 4 checksum mismatch
56
+ # 5 protect-on-diff blocked (without --force)
57
+ # 6 uninstall with no manifest
58
+
59
+ # Guard against being sourced more than once.
60
+ [[ -n "${_AID_INSTALL_CORE_LOADED:-}" ]] && return 0
61
+ _AID_INSTALL_CORE_LOADED=1
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Constants
65
+ # ---------------------------------------------------------------------------
66
+
67
+ AID_REPO_SLUG="AndreVianna/aid-methodology"
68
+ AID_API_BASE="https://api.github.com/repos/${AID_REPO_SLUG}"
69
+ AID_DOWNLOAD_BASE="https://github.com/${AID_REPO_SLUG}/releases/download"
70
+
71
+ # Canonical tool ids.
72
+ AID_TOOLS=(claude-code codex cursor copilot-cli antigravity)
73
+
74
+ # Root agent file per tool.
75
+ _root_agent_file() {
76
+ case "$1" in
77
+ claude-code) echo "CLAUDE.md" ;;
78
+ codex|cursor|copilot-cli|antigravity) echo "AGENTS.md" ;;
79
+ esac
80
+ }
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Utility
84
+ # ---------------------------------------------------------------------------
85
+
86
+ # sha256_file <path> - print lower-case hex sha256 of file.
87
+ sha256_file() {
88
+ local f="$1"
89
+ if command -v sha256sum >/dev/null 2>&1; then
90
+ sha256sum "$f" | awk '{print $1}'
91
+ elif command -v shasum >/dev/null 2>&1; then
92
+ shasum -a 256 "$f" | awk '{print $1}'
93
+ else
94
+ echo "ERROR: aid-install-core: no sha256 utility (need sha256sum or shasum)" >&2
95
+ return 1
96
+ fi
97
+ }
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Tool-id normalization + detection
101
+ # ---------------------------------------------------------------------------
102
+
103
+ # normalize_tool <input> - print canonical id or exit 2 on unknown.
104
+ normalize_tool() {
105
+ local raw
106
+ raw="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
107
+ case "$raw" in
108
+ claude-code|claudecode) echo "claude-code" ;;
109
+ codex) echo "codex" ;;
110
+ cursor) echo "cursor" ;;
111
+ copilot-cli|copilotcli) echo "copilot-cli" ;;
112
+ antigravity) echo "antigravity" ;;
113
+ *)
114
+ echo "ERROR: aid-install-core: unknown tool id: $1 (valid: claude-code, codex, cursor, copilot-cli, antigravity)" >&2
115
+ return 2
116
+ ;;
117
+ esac
118
+ }
119
+
120
+ # detect_tool <target> - auto-detect the installed host tool from tree markers.
121
+ # Prints the canonical id. Exits 2 on ambiguous (>1) or undetectable (0).
122
+ detect_tool() {
123
+ local target="$1"
124
+ local found=()
125
+
126
+ [[ -d "${target}/.claude" ]] && found+=("claude-code")
127
+ # codex: .codex or .agents dir
128
+ if [[ -d "${target}/.codex" || -d "${target}/.agents" ]]; then
129
+ found+=("codex")
130
+ fi
131
+ [[ -d "${target}/.cursor" ]] && found+=("cursor")
132
+ # copilot-cli: .github with AID copilot subtree (.github/agents/ or .github/skills/)
133
+ if [[ -d "${target}/.github" ]] && \
134
+ ( [[ -d "${target}/.github/agents" ]] || [[ -d "${target}/.github/skills" ]] ); then
135
+ found+=("copilot-cli")
136
+ fi
137
+ [[ -d "${target}/.agent" ]] && found+=("antigravity")
138
+
139
+ if [[ "${#found[@]}" -eq 1 ]]; then
140
+ echo "${found[0]}"
141
+ return 0
142
+ elif [[ "${#found[@]}" -eq 0 ]]; then
143
+ echo "ERROR: cannot auto-detect host tool; pass --tool <name>" >&2
144
+ return 2
145
+ else
146
+ local list
147
+ list="$(printf '%s, ' "${found[@]}")"
148
+ list="${list%, }"
149
+ echo "ERROR: ambiguous host tool (found: ${list}); pass --tool <name>" >&2
150
+ return 2
151
+ fi
152
+ }
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Version resolution (online)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ # resolve_version - fetch the latest release tag from GitHub API.
159
+ # Prints the version without leading 'v'. Exits 3 on failure.
160
+ resolve_version() {
161
+ local url="${AID_API_BASE}/releases/latest"
162
+ local curl_args=(-fsSL)
163
+ # Optional bearer token for rate-limit relief.
164
+ local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
165
+ if [[ -n "$token" ]]; then
166
+ curl_args+=(-H "Authorization: Bearer ${token}")
167
+ fi
168
+
169
+ local response
170
+ response="$(curl "${curl_args[@]}" "$url" 2>/dev/null)" || {
171
+ echo "ERROR: aid-install-core: failed to fetch ${url}" >&2
172
+ return 3
173
+ }
174
+
175
+ # Extract tag_name via grep+sed (no jq required).
176
+ local tag
177
+ tag="$(echo "$response" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
178
+ if [[ -z "$tag" ]]; then
179
+ echo "ERROR: aid-install-core: could not parse tag_name from GitHub API response" >&2
180
+ return 3
181
+ fi
182
+ # Strip leading 'v'
183
+ echo "${tag#v}"
184
+ }
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Fetch + extract (online mode)
188
+ # ---------------------------------------------------------------------------
189
+
190
+ # fetch_tarball <tool> <version> <dest_dir>
191
+ # Downloads the tarball + SHA256SUMS into dest_dir and verifies.
192
+ # Exits 3 on fetch failure, 4 on checksum mismatch.
193
+ fetch_tarball() {
194
+ local tool="$1" version="$2" dest_dir="$3"
195
+ local filename="aid-${tool}-v${version}.tar.gz"
196
+ local url="${AID_DOWNLOAD_BASE}/v${version}/${filename}"
197
+ local sums_url="${AID_DOWNLOAD_BASE}/v${version}/SHA256SUMS"
198
+ local tarball="${dest_dir}/${filename}"
199
+ local sums_file="${dest_dir}/SHA256SUMS"
200
+
201
+ local curl_args=(-fsSL)
202
+ local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
203
+ if [[ -n "$token" ]]; then
204
+ curl_args+=(-H "Authorization: Bearer ${token}")
205
+ fi
206
+
207
+ echo "Fetching ${filename} ..." >&2
208
+ curl "${curl_args[@]}" -o "$tarball" "$url" || {
209
+ echo "ERROR: aid-install-core: failed to download ${url}" >&2
210
+ return 3
211
+ }
212
+
213
+ # Fetch SHA256SUMS (best-effort: warn if absent on older releases).
214
+ if curl "${curl_args[@]}" -o "$sums_file" "$sums_url" 2>/dev/null; then
215
+ _verify_checksum "$tarball" "$sums_file" || return 4
216
+ else
217
+ echo "WARN: aid-install-core: SHA256SUMS not available for v${version}; skipping checksum verification" >&2
218
+ fi
219
+ }
220
+
221
+ # _verify_checksum <tarball> <sums_file>
222
+ # Fails with exit 4 if the tarball's sha256 is not in the sums file.
223
+ _verify_checksum() {
224
+ local tarball="$1" sums_file="$2"
225
+ local filename
226
+ filename="$(basename "$tarball")"
227
+
228
+ local expected
229
+ expected="$(grep "[[:space:]]${filename}$" "$sums_file" | awk '{print $1}')"
230
+ if [[ -z "$expected" ]]; then
231
+ echo "ERROR: aid-install-core: ${filename} not found in SHA256SUMS" >&2
232
+ return 4
233
+ fi
234
+
235
+ local actual
236
+ actual="$(sha256_file "$tarball")"
237
+ if [[ "$actual" != "$expected" ]]; then
238
+ echo "ERROR: aid-install-core: checksum mismatch for ${filename}: expected ${expected}, got ${actual}" >&2
239
+ return 4
240
+ fi
241
+ echo "Checksum OK: ${filename}" >&2
242
+ }
243
+
244
+ # verify_bundle_checksum <tarball>
245
+ # Checks for a sibling SHA256SUMS file next to the tarball.
246
+ # No-op if absent; exits 4 on mismatch.
247
+ verify_bundle_checksum() {
248
+ local tarball="$1"
249
+ local dir
250
+ dir="$(dirname "$tarball")"
251
+ local sums_file="${dir}/SHA256SUMS"
252
+ [[ -f "$sums_file" ]] || return 0
253
+ _verify_checksum "$tarball" "$sums_file" || return 4
254
+ }
255
+
256
+ # extract_tarball <tarball> <dest_dir>
257
+ # Extracts into dest_dir. feature-002 S2.3 guarantees a flat-root tarball (no
258
+ # wrapping top-level directory). Asserts this contract and fails loudly when
259
+ # violated rather than silently stripping components.
260
+ extract_tarball() {
261
+ local tarball="$1" dest_dir="$2"
262
+ mkdir -p "$dest_dir"
263
+
264
+ # Verify flat-root contract: the first entry must NOT be a bare directory
265
+ # (i.e. must not be "somedir/" with no slash before the trailing slash at the
266
+ # very start of the path component, e.g. "topdir/").
267
+ # Use a temp file to avoid pipefail from SIGPIPE when piping tar -t to head.
268
+ local _first_member_file
269
+ _first_member_file="$(mktemp)"
270
+ tar -tzf "$tarball" > "$_first_member_file" 2>/dev/null
271
+ local _tar_list_rc=$?
272
+ local first_member
273
+ first_member="$(head -1 "$_first_member_file")"
274
+ rm -f "$_first_member_file"
275
+
276
+ if [[ "$_tar_list_rc" -ne 0 && -z "$first_member" ]]; then
277
+ echo "ERROR: aid-install-core: failed to list tarball contents: ${tarball}" >&2
278
+ return 1
279
+ fi
280
+
281
+ # A wrapping dir would look like "topdir/" (a single path component ending with /).
282
+ # Pattern: starts with optional "./" then one path component (no inner slashes) then "/"
283
+ # at the end of the string (meaning the entire entry IS just "topdir/").
284
+ # "./topdir/" counts as a wrapping dir; "./.claude/file.md" does not.
285
+ local _stripped="${first_member#./}"
286
+ if [[ "$_stripped" =~ ^[^/]+/$ ]]; then
287
+ echo "ERROR: aid-install-core: tarball has a wrapping top-level directory ('${first_member}') - expected flat-root per feature-002 S2.3 contract: ${tarball}" >&2
288
+ return 1
289
+ fi
290
+
291
+ # Flat tarball (expected feature-002 layout).
292
+ tar -xzf "$tarball" -C "$dest_dir" || {
293
+ echo "ERROR: aid-install-core: failed to extract ${tarball}" >&2
294
+ return 1
295
+ }
296
+ }
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Copy semantics
300
+ # ---------------------------------------------------------------------------
301
+
302
+ # copy_file <src> <dst> [force]
303
+ # Returns 0 always (errors are logged; protect-on-diff is handled in install_tool).
304
+ # Prints per-file lines only when AID_VERBOSE=1.
305
+ # Increments counters: _COPY_COUNT_COPIED, _COPY_COUNT_UPTODATE, _COPY_COUNT_UPDATED,
306
+ # _COPY_COUNT_SKIPPED (caller initialises before iterating; install_tool reads them).
307
+ # This function handles NON-root-agent files only.
308
+ # Root agent files go through _copy_root_agent_file in install_tool.
309
+ copy_file() {
310
+ local src="$1" dst="$2" force="${3:-0}"
311
+ local dst_dir
312
+ dst_dir="$(dirname "$dst")"
313
+ mkdir -p "$dst_dir"
314
+
315
+ if [[ ! -e "$dst" ]]; then
316
+ cp "$src" "$dst"
317
+ _COPY_COUNT_COPIED=$((_COPY_COUNT_COPIED + 1))
318
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Copied: ${dst}"
319
+ return 0
320
+ fi
321
+
322
+ if cmp -s "$src" "$dst"; then
323
+ _COPY_COUNT_UPTODATE=$((_COPY_COUNT_UPTODATE + 1))
324
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Up to date: ${dst}"
325
+ return 0
326
+ fi
327
+
328
+ # File exists and differs.
329
+ if [[ "$force" -eq 1 ]]; then
330
+ cp "$src" "$dst"
331
+ _COPY_COUNT_UPDATED=$((_COPY_COUNT_UPDATED + 1))
332
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Updated: ${dst}"
333
+ else
334
+ _COPY_COUNT_SKIPPED=$((_COPY_COUNT_SKIPPED + 1))
335
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Skipped (differs; use --force): ${dst}"
336
+ fi
337
+ }
338
+
339
+ # copy_dir <src_dir> <dst_dir> [force]
340
+ # Recursively copies a directory tree, file by file (preserving empty dirs).
341
+ # Root agent files in the source dir are skipped - caller handles them.
342
+ copy_dir() {
343
+ local src="$1" dst="$2" force="${3:-0}"
344
+
345
+ # Create directory structure first.
346
+ while IFS= read -r -d '' dir; do
347
+ local rel="${dir#${src}/}"
348
+ mkdir -p "${dst}/${rel}"
349
+ done < <(find "$src" -mindepth 1 -type d -print0 2>/dev/null)
350
+
351
+ # Copy files.
352
+ while IFS= read -r -d '' file; do
353
+ local rel="${file#${src}/}"
354
+ copy_file "$file" "${dst}/${rel}" "$force"
355
+ done < <(find "$src" -type f -print0 2>/dev/null | sort -z)
356
+ }
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Protect-on-diff (FR11) for root agent files
360
+ # ---------------------------------------------------------------------------
361
+
362
+ # _copy_root_agent_file <src> <dst> <tool> <force> <manifest>
363
+ # Implements the FR11 algorithm.
364
+ # Returns:
365
+ # 0 - success (copied/up-to-date/updated/forced)
366
+ # 5 - protect-on-diff blocked (written .aid-new instead)
367
+ #
368
+ # Callers accumulate path/sha256 data into arrays and pass them to manifest_write.
369
+ # This function prints FR11 messages to stdout (progress) and stderr (warnings).
370
+ # It writes the status into _CORE_ROOT_AGENT_STATUS (caller reads this var).
371
+ _copy_root_agent_file() {
372
+ local src="$1" dst="$2" tool="$3" force="${4:-0}" manifest="${5:-}"
373
+
374
+ _CORE_ROOT_AGENT_STATUS="owned"
375
+ local inc_sha
376
+ inc_sha="$(sha256_file "$src")"
377
+
378
+ if [[ ! -e "$dst" ]]; then
379
+ # Step 2: Destination absent -> copy.
380
+ mkdir -p "$(dirname "$dst")"
381
+ cp "$src" "$dst"
382
+ _COPY_COUNT_COPIED=$((_COPY_COUNT_COPIED + 1))
383
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Copied: ${dst}"
384
+ _CORE_ROOT_AGENT_STATUS="owned"
385
+ return 0
386
+ fi
387
+
388
+ local disk_sha
389
+ disk_sha="$(sha256_file "$dst")"
390
+
391
+ if [[ "$disk_sha" == "$inc_sha" ]]; then
392
+ # Step 3: Identical -> up to date.
393
+ _COPY_COUNT_UPTODATE=$((_COPY_COUNT_UPTODATE + 1))
394
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Up to date: ${dst}"
395
+ _CORE_ROOT_AGENT_STATUS="owned"
396
+ return 0
397
+ fi
398
+
399
+ # Check manifest for AID-owned sha.
400
+ local recorded_sha=""
401
+ if [[ -n "$manifest" && -f "$manifest" ]]; then
402
+ recorded_sha="$(manifest_read_root_agent "$manifest" "$tool" "$(basename "$dst")")"
403
+ fi
404
+
405
+ if [[ -n "$recorded_sha" && "$disk_sha" == "$recorded_sha" ]]; then
406
+ # Step 4: AID owns it -> overwrite.
407
+ cp "$src" "$dst"
408
+ _COPY_COUNT_UPDATED=$((_COPY_COUNT_UPDATED + 1))
409
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Updated: ${dst}"
410
+ _CORE_ROOT_AGENT_STATUS="owned"
411
+ return 0
412
+ fi
413
+
414
+ # Step 5: Someone else owns it.
415
+ if [[ "$force" -eq 1 ]]; then
416
+ cp "$src" "$dst"
417
+ _COPY_COUNT_UPDATED=$((_COPY_COUNT_UPDATED + 1))
418
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Updated: ${dst} (forced over existing)"
419
+ _CORE_ROOT_AGENT_STATUS="owned"
420
+ return 0
421
+ fi
422
+
423
+ # Without --force: write .aid-new.
424
+ cp "$src" "${dst}.aid-new"
425
+ # WARN always shows regardless of AID_VERBOSE.
426
+ echo "WARN: ${dst} exists and was not written by AID; wrote incoming version to ${dst}.aid-new - review and merge, or re-run with --force to overwrite" >&2
427
+ _CORE_ROOT_AGENT_STATUS="pending-merge"
428
+ return 5
429
+ }
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Manifest - pure-Bash reader (no jq/python required)
433
+ # ---------------------------------------------------------------------------
434
+ #
435
+ # The manifest has this shape (2-space indent, \n newlines):
436
+ # {
437
+ # "manifest_version": 1,
438
+ # "aid_version": "0.7.0",
439
+ # "installed_at": "...",
440
+ # "tools": {
441
+ # "claude-code": {
442
+ # "version": "0.7.0",
443
+ # "installed_at": "...",
444
+ # "paths": ["..."],
445
+ # "root_agent_files": [{"path": "...", "sha256": "...", "status": "owned"}]
446
+ # }
447
+ # }
448
+ # }
449
+ #
450
+ # Readers use grep/sed/awk to extract what they need - sufficient because the
451
+ # schema is flat enough.
452
+
453
+ # manifest_read_tool_paths <manifest> <tool>
454
+ # Prints one path per line (from the "paths" array of the named tool section).
455
+ manifest_read_tool_paths() {
456
+ local manifest="$1" tool="$2"
457
+ [[ -f "$manifest" ]] || return 0
458
+ # Fast path: python3
459
+ if command -v python3 >/dev/null 2>&1; then
460
+ python3 - "$manifest" "$tool" <<'PY'
461
+ import json, sys
462
+ try:
463
+ data = json.load(open(sys.argv[1]))
464
+ for p in data.get("tools", {}).get(sys.argv[2], {}).get("paths", []):
465
+ print(p)
466
+ except Exception:
467
+ pass
468
+ PY
469
+ return
470
+ fi
471
+ # Pure-Bash fallback: extract section between "tool": { ... } and next top-level "tool":
472
+ # Use awk to capture paths array lines for the target tool.
473
+ awk -v tool="$tool" '
474
+ BEGIN { in_tool=0; in_paths=0 }
475
+ /"'"${tool}"'"[[:space:]]*:/ { in_tool=1; next }
476
+ in_tool && /"paths"[[:space:]]*:/ { in_paths=1; next }
477
+ in_tool && in_paths && /\]/ { exit }
478
+ in_tool && in_paths && /"[^"]*"/ {
479
+ # Extract the quoted string.
480
+ s=$0; gsub(/^[^"]*"/, "", s); gsub(/".*/, "", s)
481
+ if (s != "") print s
482
+ }
483
+ ' "$manifest"
484
+ }
485
+
486
+ # manifest_read_tool_version <manifest> <tool>
487
+ # Prints the version string for the named tool.
488
+ manifest_read_tool_version() {
489
+ local manifest="$1" tool="$2"
490
+ [[ -f "$manifest" ]] || return 0
491
+ if command -v python3 >/dev/null 2>&1; then
492
+ python3 - "$manifest" "$tool" <<'PY'
493
+ import json, sys
494
+ try:
495
+ data = json.load(open(sys.argv[1]))
496
+ v = data.get("tools", {}).get(sys.argv[2], {}).get("version", "")
497
+ if v: print(v)
498
+ except Exception:
499
+ pass
500
+ PY
501
+ return
502
+ fi
503
+ # Pure-Bash: find version line inside the tool block.
504
+ awk -v tool="$tool" '
505
+ BEGIN { in_tool=0; depth=0 }
506
+ /"'"${tool}"'"[[:space:]]*:/ { in_tool=1; depth=0; next }
507
+ in_tool && /\{/ { depth++ }
508
+ in_tool && /\}/ { depth--; if (depth<0) exit }
509
+ in_tool && depth==1 && /"version"/ {
510
+ s=$0; gsub(/.*"version"[^:]*:[^"]*"/, "", s); gsub(/".*/, "", s)
511
+ print s; exit
512
+ }
513
+ ' "$manifest"
514
+ }
515
+
516
+ # manifest_read_root_agent <manifest> <tool> <filename>
517
+ # Prints the sha256 for the root agent file entry (empty if not present).
518
+ manifest_read_root_agent() {
519
+ local manifest="$1" tool="$2" fname="$3"
520
+ [[ -f "$manifest" ]] || return 0
521
+ if command -v python3 >/dev/null 2>&1; then
522
+ python3 - "$manifest" "$tool" "$fname" <<'PY'
523
+ import json, sys
524
+ try:
525
+ data = json.load(open(sys.argv[1]))
526
+ for e in data.get("tools", {}).get(sys.argv[2], {}).get("root_agent_files", []):
527
+ if e.get("path") == sys.argv[3]:
528
+ print(e.get("sha256", ""))
529
+ break
530
+ except Exception:
531
+ pass
532
+ PY
533
+ return
534
+ fi
535
+ # Pure-Bash: find root_agent_files section for tool, look for fname.
536
+ awk -v tool="$tool" -v fname="$fname" '
537
+ BEGIN { in_tool=0; in_raf=0; in_entry=0; found_path=0 }
538
+ /"'"${tool}"'"[[:space:]]*:/ { in_tool=1 }
539
+ in_tool && /"root_agent_files"/ { in_raf=1 }
540
+ in_raf && /\{/ { in_entry=1; found_path=0 }
541
+ in_raf && in_entry && /"path"/ {
542
+ s=$0; gsub(/.*"path"[^:]*:[^"]*"/, "", s); gsub(/".*/, "", s)
543
+ if (s == fname) found_path=1
544
+ }
545
+ in_raf && in_entry && found_path && /"sha256"/ {
546
+ s=$0; gsub(/.*"sha256"[^:]*:[^"]*"/, "", s); gsub(/".*/, "", s)
547
+ print s; exit
548
+ }
549
+ in_raf && /\]/ { exit }
550
+ ' "$manifest"
551
+ }
552
+
553
+ # manifest_read_root_agent_status <manifest> <tool> <filename>
554
+ # Prints the status field ("owned" or "pending-merge") for the root agent entry.
555
+ manifest_read_root_agent_status() {
556
+ local manifest="$1" tool="$2" fname="$3"
557
+ [[ -f "$manifest" ]] || return 0
558
+ if command -v python3 >/dev/null 2>&1; then
559
+ python3 - "$manifest" "$tool" "$fname" <<'PY'
560
+ import json, sys
561
+ try:
562
+ data = json.load(open(sys.argv[1]))
563
+ for e in data.get("tools", {}).get(sys.argv[2], {}).get("root_agent_files", []):
564
+ if e.get("path") == sys.argv[3]:
565
+ print(e.get("status", "owned"))
566
+ break
567
+ except Exception:
568
+ pass
569
+ PY
570
+ return
571
+ fi
572
+ awk -v tool="$tool" -v fname="$fname" '
573
+ BEGIN { in_tool=0; in_raf=0; in_entry=0; found_path=0 }
574
+ /"'"${tool}"'"[[:space:]]*:/ { in_tool=1 }
575
+ in_tool && /"root_agent_files"/ { in_raf=1 }
576
+ in_raf && /\{/ { in_entry=1; found_path=0 }
577
+ in_raf && in_entry && /"path"/ {
578
+ s=$0; gsub(/.*"path"[^:]*:[^"]*"/, "", s); gsub(/".*/, "", s)
579
+ if (s == fname) found_path=1
580
+ }
581
+ in_raf && in_entry && found_path && /"status"/ {
582
+ s=$0; gsub(/.*"status"[^:]*:[^"]*"/, "", s); gsub(/".*/, "", s)
583
+ print s; exit
584
+ }
585
+ in_raf && /\]/ { exit }
586
+ ' "$manifest"
587
+ }
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # Manifest writer (pure Bash + python3 fast-path)
591
+ # ---------------------------------------------------------------------------
592
+
593
+ # manifest_write <manifest_path> <tool> <version> <paths_varname> <root_entries_varname>
594
+ #
595
+ # <paths_varname> - name of a Bash array variable holding relative POSIX paths.
596
+ # <root_entries_varname> - name of a Bash array variable holding entries, each formatted as
597
+ # "path|sha256|status" (pipe-delimited).
598
+ #
599
+ # Reads the existing manifest (if any), merges the tool's entry, writes back atomically
600
+ # via a temp file. Creates <target>/.aid/ as needed.
601
+ manifest_write() {
602
+ local manifest="$1" tool="$2" version="$3"
603
+ local paths_var="$4" # indirect reference to array
604
+ local root_var="$5" # indirect reference to array
605
+
606
+ # Dereference array variables (Bash 4.3+ nameref or indirect via eval).
607
+ local -a paths_arr=()
608
+ local -a root_arr=()
609
+ eval "paths_arr=(\"\${${paths_var}[@]}\")"
610
+ eval "root_arr=(\"\${${root_var}[@]}\")"
611
+
612
+ local manifest_dir
613
+ manifest_dir="$(dirname "$manifest")"
614
+ mkdir -p "$manifest_dir"
615
+
616
+ local now
617
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
618
+
619
+ if command -v python3 >/dev/null 2>&1; then
620
+ _manifest_write_python "$manifest" "$tool" "$version" "$now" "${paths_arr[@]+"${paths_arr[@]}"}" -- "${root_arr[@]+"${root_arr[@]}"}"
621
+ else
622
+ _manifest_write_bash "$manifest" "$tool" "$version" "$now" "${paths_arr[@]+"${paths_arr[@]}"}" -- "${root_arr[@]+"${root_arr[@]}"}"
623
+ fi
624
+ }
625
+
626
+ # _manifest_write_python - write manifest via python3 (fast path).
627
+ # Signature: <manifest> <tool> <version> <now> [paths...] -- [root_entries...]
628
+ _manifest_write_python() {
629
+ local manifest="$1" tool="$2" version="$3" now="$4"
630
+ shift 4
631
+
632
+ local -a paths_arr=()
633
+ local -a root_arr=()
634
+ local past_sep=0
635
+ for arg in "$@"; do
636
+ if [[ "$arg" == "--" ]]; then
637
+ past_sep=1
638
+ continue
639
+ fi
640
+ if [[ "$past_sep" -eq 0 ]]; then
641
+ paths_arr+=("$arg")
642
+ else
643
+ root_arr+=("$arg")
644
+ fi
645
+ done
646
+
647
+ python3 - "$manifest" "$tool" "$version" "$now" \
648
+ "$(printf '%s\n' "${paths_arr[@]+"${paths_arr[@]}"}")" \
649
+ "$(printf '%s\n' "${root_arr[@]+"${root_arr[@]}"}")" <<'PY'
650
+ import json, sys, os, tempfile
651
+
652
+ manifest_path = sys.argv[1]
653
+ tool = sys.argv[2]
654
+ version = sys.argv[3]
655
+ now = sys.argv[4]
656
+ paths_raw = sys.argv[5]
657
+ roots_raw = sys.argv[6]
658
+
659
+ paths = [p for p in paths_raw.splitlines() if p]
660
+ roots = []
661
+ for line in roots_raw.splitlines():
662
+ if not line:
663
+ continue
664
+ parts = line.split("|", 2)
665
+ entry = {"path": parts[0], "sha256": parts[1] if len(parts) > 1 else "",
666
+ "status": parts[2] if len(parts) > 2 else "owned"}
667
+ roots.append(entry)
668
+
669
+ # Load existing manifest.
670
+ data = {}
671
+ if os.path.isfile(manifest_path):
672
+ try:
673
+ with open(manifest_path) as f:
674
+ data = json.load(f)
675
+ except Exception:
676
+ data = {}
677
+
678
+ if not isinstance(data.get("tools"), dict):
679
+ data["tools"] = {}
680
+
681
+ # Merge: preserve existing installed_at for the tool if present.
682
+ existing_tool = data["tools"].get(tool, {})
683
+ tool_installed_at = existing_tool.get("installed_at", now)
684
+
685
+ # De-duplicate paths (union).
686
+ existing_paths = existing_tool.get("paths", [])
687
+ merged_paths = list(dict.fromkeys(existing_paths + paths))
688
+
689
+ # Merge root_agent_files: update or add per path.
690
+ existing_raf = {e["path"]: e for e in existing_tool.get("root_agent_files", [])}
691
+ for e in roots:
692
+ existing_raf[e["path"]] = e
693
+ merged_raf = list(existing_raf.values())
694
+
695
+ data["tools"][tool] = {
696
+ "version": version,
697
+ "installed_at": tool_installed_at,
698
+ "paths": merged_paths,
699
+ "root_agent_files": merged_raf,
700
+ }
701
+
702
+ # Build output with canonical key order.
703
+ top_installed_at = data.get("installed_at", now)
704
+ output = {
705
+ "manifest_version": 1,
706
+ "aid_version": version,
707
+ "installed_at": top_installed_at,
708
+ "tools": data["tools"],
709
+ }
710
+ data = output
711
+
712
+ # Write atomically.
713
+ manifest_dir = os.path.dirname(manifest_path)
714
+ fd, tmp = tempfile.mkstemp(dir=manifest_dir, suffix=".tmp")
715
+ try:
716
+ with os.fdopen(fd, "w", newline="\n") as f:
717
+ json.dump(data, f, indent=2)
718
+ f.write("\n")
719
+ os.replace(tmp, manifest_path)
720
+ except Exception as e:
721
+ os.unlink(tmp)
722
+ print(f"ERROR: aid-install-core: manifest write failed: {e}", file=sys.stderr)
723
+ sys.exit(1)
724
+ PY
725
+ }
726
+
727
+ # _manifest_write_bash - pure-Bash fallback manifest writer.
728
+ # Signature same as _manifest_write_python.
729
+ _manifest_write_bash() {
730
+ local manifest="$1" tool="$2" version="$3" now="$4"
731
+ shift 4
732
+
733
+ local -a paths_arr=()
734
+ local -a root_arr=()
735
+ local past_sep=0
736
+ for arg in "$@"; do
737
+ if [[ "$arg" == "--" ]]; then
738
+ past_sep=1
739
+ continue
740
+ fi
741
+ if [[ "$past_sep" -eq 0 ]]; then
742
+ paths_arr+=("$arg")
743
+ else
744
+ root_arr+=("$arg")
745
+ fi
746
+ done
747
+
748
+ local manifest_dir
749
+ manifest_dir="$(dirname "$manifest")"
750
+
751
+ # Load existing manifest fields we need to preserve.
752
+ local top_installed_at="$now"
753
+ local tool_installed_at="$now"
754
+ local -a existing_paths=()
755
+ local existing_aid_version=""
756
+
757
+ if [[ -f "$manifest" ]]; then
758
+ top_installed_at="$(grep '"installed_at"' "$manifest" | head -1 | sed 's/.*"installed_at"[^:]*:[^"]*"\([^"]*\)".*/\1/')"
759
+ [[ -z "$top_installed_at" ]] && top_installed_at="$now"
760
+
761
+ # Collect existing paths for this tool.
762
+ while IFS= read -r p; do
763
+ [[ -n "$p" ]] && existing_paths+=("$p")
764
+ done < <(manifest_read_tool_paths "$manifest" "$tool")
765
+
766
+ local t_inst
767
+ t_inst="$(manifest_read_tool_version "$manifest" "$tool")"
768
+ # Read tool-specific installed_at (reuse existing or now).
769
+ tool_installed_at="$(awk -v tool="$tool" '
770
+ BEGIN{in_tool=0}
771
+ /"'"${tool}"'"/{in_tool=1}
772
+ in_tool && /"installed_at"/{
773
+ s=$0; gsub(/.*"installed_at"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); print s; exit
774
+ }' "$manifest")"
775
+ [[ -z "$tool_installed_at" ]] && tool_installed_at="$now"
776
+ fi
777
+
778
+ # Merge paths (de-duplicate).
779
+ local -a merged_paths=()
780
+ declare -A _seen_paths=()
781
+ for p in "${existing_paths[@]+"${existing_paths[@]}"}" "${paths_arr[@]+"${paths_arr[@]}"}"; do
782
+ if [[ -z "${_seen_paths[$p]+x}" ]]; then
783
+ _seen_paths[$p]=1
784
+ merged_paths+=("$p")
785
+ fi
786
+ done
787
+
788
+ # Assemble paths JSON array.
789
+ local paths_json="["
790
+ local first=1
791
+ for p in "${merged_paths[@]+"${merged_paths[@]}"}"; do
792
+ [[ "$first" -eq 0 ]] && paths_json+=","
793
+ paths_json+=$'\n "'
794
+ paths_json+="$p"
795
+ paths_json+='"'
796
+ first=0
797
+ done
798
+ if [[ "${#merged_paths[@]}" -gt 0 ]]; then
799
+ paths_json+=$'\n '
800
+ fi
801
+ paths_json+="]"
802
+
803
+ # Assemble root_agent_files JSON array.
804
+ # Merge with existing entries.
805
+ local -a all_root=()
806
+ if [[ -f "$manifest" ]]; then
807
+ # Read existing root_agent_files for this tool from manifest.
808
+ local _existing_raf_paths=()
809
+ while IFS= read -r _raf_line; do
810
+ [[ -n "$_raf_line" ]] && _existing_raf_paths+=("$_raf_line")
811
+ done < <(awk -v tool="$tool" '
812
+ BEGIN{in_tool=0;in_raf=0;in_entry=0}
813
+ /"'"${tool}"'"[[:space:]]*:/{in_tool=1}
814
+ in_tool && /"root_agent_files"/{in_raf=1}
815
+ in_raf && /\{/{in_entry=1; cur_path=""; cur_sha=""; cur_status="owned"}
816
+ in_raf && in_entry && /"path"/{
817
+ s=$0; gsub(/.*"path"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_path=s
818
+ }
819
+ in_raf && in_entry && /"sha256"/{
820
+ s=$0; gsub(/.*"sha256"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_sha=s
821
+ }
822
+ in_raf && in_entry && /"status"/{
823
+ s=$0; gsub(/.*"status"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_status=s
824
+ }
825
+ in_raf && in_entry && /\}/ {
826
+ if (cur_path!="") print cur_path "|" cur_sha "|" cur_status
827
+ in_entry=0
828
+ }
829
+ in_raf && /\]/{exit}
830
+ ' "$manifest")
831
+
832
+ # Build map of existing entries.
833
+ declare -A _raf_map=()
834
+ for _entry in "${_existing_raf_paths[@]+"${_existing_raf_paths[@]}"}"; do
835
+ local _k="${_entry%%|*}"
836
+ _raf_map["$_k"]="$_entry"
837
+ done
838
+ # Override with incoming entries.
839
+ for _entry in "${root_arr[@]+"${root_arr[@]}"}"; do
840
+ local _k="${_entry%%|*}"
841
+ _raf_map["$_k"]="$_entry"
842
+ done
843
+ for _k in "${!_raf_map[@]}"; do
844
+ all_root+=("${_raf_map[$_k]}")
845
+ done
846
+ else
847
+ all_root=("${root_arr[@]+"${root_arr[@]}"}")
848
+ fi
849
+
850
+ local raf_json="["
851
+ local first=1
852
+ for entry in "${all_root[@]+"${all_root[@]}"}"; do
853
+ local rpath rsha rstatus
854
+ IFS='|' read -r rpath rsha rstatus <<< "$entry"
855
+ [[ "$first" -eq 0 ]] && raf_json+=","
856
+ raf_json+=$'\n '
857
+ raf_json+='{ "path": "'"${rpath}"'", "sha256": "'"${rsha}"'", "status": "'"${rstatus}"'" }'
858
+ first=0
859
+ done
860
+ if [[ "${#all_root[@]}" -gt 0 ]]; then
861
+ raf_json+=$'\n '
862
+ fi
863
+ raf_json+="]"
864
+
865
+ # Read all existing tools to preserve them.
866
+ local -a all_tool_ids=()
867
+ if [[ -f "$manifest" ]]; then
868
+ while IFS= read -r tid; do
869
+ [[ -n "$tid" && "$tid" != "$tool" ]] && all_tool_ids+=("$tid")
870
+ done < <(grep -o '"[a-z][a-zA-Z-]*"[[:space:]]*:' "$manifest" | \
871
+ grep -v 'manifest_version\|aid_version\|installed_at\|version\|paths\|root_agent_files\|sha256\|status\|path\|tools' | \
872
+ sed 's/"//g' | sed 's/[[:space:]]*://g')
873
+ fi
874
+
875
+ # Assemble complete manifest JSON.
876
+ local tmp_file
877
+ tmp_file="$(mktemp "${manifest_dir}/.manifest.tmp.XXXXXX")"
878
+
879
+ {
880
+ printf '{\n'
881
+ printf ' "manifest_version": 1,\n'
882
+ printf ' "aid_version": "%s",\n' "$version"
883
+ printf ' "installed_at": "%s",\n' "$top_installed_at"
884
+ printf ' "tools": {\n'
885
+
886
+ # Write other tools first (preserve existing).
887
+ local need_comma=0
888
+ for tid in "${all_tool_ids[@]+"${all_tool_ids[@]}"}"; do
889
+ # Re-serialize existing tool block.
890
+ if [[ "$need_comma" -eq 1 ]]; then printf ',\n'; fi
891
+ local t_ver t_iat
892
+ t_ver="$(manifest_read_tool_version "$manifest" "$tid")"
893
+ t_iat="$(awk -v t="$tid" 'BEGIN{in_t=0} /"'"${tid}"'"/{in_t=1} in_t && /"installed_at"/{s=$0; gsub(/.*"installed_at"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); print s; exit}' "$manifest")"
894
+ [[ -z "$t_iat" ]] && t_iat="$now"
895
+ printf ' "%s": {\n' "$tid"
896
+ printf ' "version": "%s",\n' "$t_ver"
897
+ printf ' "installed_at": "%s",\n' "$t_iat"
898
+ # Re-read and emit paths for existing tool.
899
+ local t_paths_json="["
900
+ local tp_first=1
901
+ while IFS= read -r tp; do
902
+ [[ -z "$tp" ]] && continue
903
+ [[ "$tp_first" -eq 0 ]] && t_paths_json+=","
904
+ t_paths_json+=$'\n "'
905
+ t_paths_json+="$tp"
906
+ t_paths_json+='"'
907
+ tp_first=0
908
+ done < <(manifest_read_tool_paths "$manifest" "$tid")
909
+ [[ "$tp_first" -eq 0 ]] && t_paths_json+=$'\n '
910
+ t_paths_json+="]"
911
+ printf ' "paths": %s,\n' "$t_paths_json"
912
+ # Re-read root_agent_files for existing tool using the awk RAF parser.
913
+ local t_raf_json="["
914
+ local tr_first=1
915
+ local -a _t_raf_lines=()
916
+ while IFS= read -r _t_raf_line; do
917
+ [[ -n "$_t_raf_line" ]] && _t_raf_lines+=("$_t_raf_line")
918
+ done < <(awk -v tool="$tid" '
919
+ BEGIN{in_tool=0;in_raf=0;in_entry=0}
920
+ /"'"${tid}"'"[[:space:]]*:/{in_tool=1}
921
+ in_tool && /"root_agent_files"/{in_raf=1}
922
+ in_raf && /\{/{in_entry=1; cur_path=""; cur_sha=""; cur_status="owned"}
923
+ in_raf && in_entry && /"path"/{
924
+ s=$0; gsub(/.*"path"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_path=s
925
+ }
926
+ in_raf && in_entry && /"sha256"/{
927
+ s=$0; gsub(/.*"sha256"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_sha=s
928
+ }
929
+ in_raf && in_entry && /"status"/{
930
+ s=$0; gsub(/.*"status"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_status=s
931
+ }
932
+ in_raf && in_entry && /\}/ {
933
+ if (cur_path!="") print cur_path "|" cur_sha "|" cur_status
934
+ in_entry=0
935
+ }
936
+ in_raf && /\]/{exit}
937
+ ' "$manifest")
938
+ for _t_entry in "${_t_raf_lines[@]+"${_t_raf_lines[@]}"}"; do
939
+ local _t_rpath _t_rsha _t_rstatus
940
+ IFS='|' read -r _t_rpath _t_rsha _t_rstatus <<< "$_t_entry"
941
+ [[ "$tr_first" -eq 0 ]] && t_raf_json+=","
942
+ t_raf_json+=$'\n '
943
+ t_raf_json+='{ "path": "'"${_t_rpath}"'", "sha256": "'"${_t_rsha}"'", "status": "'"${_t_rstatus}"'" }'
944
+ tr_first=0
945
+ done
946
+ if [[ "${#_t_raf_lines[@]}" -gt 0 ]]; then
947
+ t_raf_json+=$'\n '
948
+ fi
949
+ t_raf_json+="]"
950
+ printf ' "root_agent_files": %s\n' "$t_raf_json"
951
+ printf ' }'
952
+ need_comma=1
953
+ done
954
+
955
+ # Write the current tool.
956
+ if [[ "$need_comma" -eq 1 ]]; then printf ',\n'; fi
957
+ printf ' "%s": {\n' "$tool"
958
+ printf ' "version": "%s",\n' "$version"
959
+ printf ' "installed_at": "%s",\n' "$tool_installed_at"
960
+ printf ' "paths": %s,\n' "$paths_json"
961
+ printf ' "root_agent_files": %s\n' "$raf_json"
962
+ printf ' }\n'
963
+
964
+ printf ' }\n'
965
+ printf '}\n'
966
+ } > "$tmp_file"
967
+
968
+ mv "$tmp_file" "$manifest"
969
+ }
970
+
971
+ # manifest_remove_tool <manifest> <tool>
972
+ # Removes a tool's section from the manifest. If no tools remain, removes the manifest.
973
+ manifest_remove_tool() {
974
+ local manifest="$1" tool="$2"
975
+ [[ -f "$manifest" ]] || return 0
976
+
977
+ if command -v python3 >/dev/null 2>&1; then
978
+ python3 - "$manifest" "$tool" <<'PY'
979
+ import json, sys, os, tempfile
980
+
981
+ manifest_path = sys.argv[1]
982
+ tool = sys.argv[2]
983
+
984
+ try:
985
+ with open(manifest_path) as f:
986
+ data = json.load(f)
987
+ except Exception:
988
+ sys.exit(0)
989
+
990
+ tools = data.get("tools", {})
991
+ tools.pop(tool, None)
992
+ data["tools"] = tools
993
+
994
+ if not tools:
995
+ os.remove(manifest_path)
996
+ sys.exit(0)
997
+
998
+ manifest_dir = os.path.dirname(manifest_path)
999
+ fd, tmp = tempfile.mkstemp(dir=manifest_dir, suffix=".tmp")
1000
+ try:
1001
+ with os.fdopen(fd, "w", newline="\n") as f:
1002
+ json.dump(data, f, indent=2)
1003
+ f.write("\n")
1004
+ os.replace(tmp, manifest_path)
1005
+ except Exception as e:
1006
+ os.unlink(tmp)
1007
+ print(f"ERROR: manifest remove failed: {e}", file=sys.stderr)
1008
+ sys.exit(1)
1009
+ PY
1010
+ else
1011
+ # Pure-Bash: re-read all other tools and write a new manifest.
1012
+ # Collect remaining tool ids.
1013
+ local -a remaining=()
1014
+ while IFS= read -r tid; do
1015
+ [[ -n "$tid" && "$tid" != "$tool" ]] && remaining+=("$tid")
1016
+ done < <(awk '/"tools"/{found=1} found && /^ "[a-z]/{gsub(/[^a-zA-Z-]/,"",$1); print $1}' "$manifest" | sort -u)
1017
+
1018
+ if [[ "${#remaining[@]}" -eq 0 ]]; then
1019
+ rm -f "$manifest"
1020
+ return
1021
+ fi
1022
+
1023
+ # Rebuild the manifest with only remaining tools.
1024
+ local now
1025
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1026
+ local top_iat
1027
+ top_iat="$(grep '"installed_at"' "$manifest" | head -1 | sed 's/.*"installed_at"[^:]*:[^"]*"\([^"]*\)".*/\1/')"
1028
+ [[ -z "$top_iat" ]] && top_iat="$now"
1029
+ local last_ver
1030
+ last_ver="$(grep '"aid_version"' "$manifest" | head -1 | sed 's/.*"aid_version"[^:]*:[^"]*"\([^"]*\)".*/\1/')"
1031
+ [[ -z "$last_ver" ]] && last_ver="0.0.0"
1032
+
1033
+ local tmp_file
1034
+ tmp_file="$(mktemp "$(dirname "$manifest")/.manifest.tmp.XXXXXX")"
1035
+ {
1036
+ printf '{\n'
1037
+ printf ' "manifest_version": 1,\n'
1038
+ printf ' "aid_version": "%s",\n' "$last_ver"
1039
+ printf ' "installed_at": "%s",\n' "$top_iat"
1040
+ printf ' "tools": {\n'
1041
+ local need_comma=0
1042
+ for tid in "${remaining[@]}"; do
1043
+ [[ "$need_comma" -eq 1 ]] && printf ',\n'
1044
+ local t_ver t_iat
1045
+ t_ver="$(manifest_read_tool_version "$manifest" "$tid")"
1046
+ t_iat="$(awk -v t="$tid" 'BEGIN{in_t=0} /"'"${tid}"'"/{in_t=1} in_t && /"installed_at"/{s=$0; gsub(/.*"installed_at"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); print s; exit}' "$manifest")"
1047
+ [[ -z "$t_iat" ]] && t_iat="$now"
1048
+ printf ' "%s": {\n' "$tid"
1049
+ printf ' "version": "%s",\n' "$t_ver"
1050
+ printf ' "installed_at": "%s",\n' "$t_iat"
1051
+ # Re-read paths for this tool.
1052
+ local rm_paths_json="["
1053
+ local rm_tp_first=1
1054
+ while IFS= read -r rm_tp; do
1055
+ [[ -z "$rm_tp" ]] && continue
1056
+ [[ "$rm_tp_first" -eq 0 ]] && rm_paths_json+=","
1057
+ rm_paths_json+=$'\n "'
1058
+ rm_paths_json+="$rm_tp"
1059
+ rm_paths_json+='"'
1060
+ rm_tp_first=0
1061
+ done < <(manifest_read_tool_paths "$manifest" "$tid")
1062
+ [[ "$rm_tp_first" -eq 0 ]] && rm_paths_json+=$'\n '
1063
+ rm_paths_json+="]"
1064
+ printf ' "paths": %s,\n' "$rm_paths_json"
1065
+ # Re-read root_agent_files for this tool using the awk RAF parser.
1066
+ local rm_raf_json="["
1067
+ local rm_tr_first=1
1068
+ local -a _rm_raf_lines=()
1069
+ while IFS= read -r _rm_raf_line; do
1070
+ [[ -n "$_rm_raf_line" ]] && _rm_raf_lines+=("$_rm_raf_line")
1071
+ done < <(awk -v tool="$tid" '
1072
+ BEGIN{in_tool=0;in_raf=0;in_entry=0}
1073
+ /"'"${tid}"'"[[:space:]]*:/{in_tool=1}
1074
+ in_tool && /"root_agent_files"/{in_raf=1}
1075
+ in_raf && /\{/{in_entry=1; cur_path=""; cur_sha=""; cur_status="owned"}
1076
+ in_raf && in_entry && /"path"/{
1077
+ s=$0; gsub(/.*"path"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_path=s
1078
+ }
1079
+ in_raf && in_entry && /"sha256"/{
1080
+ s=$0; gsub(/.*"sha256"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_sha=s
1081
+ }
1082
+ in_raf && in_entry && /"status"/{
1083
+ s=$0; gsub(/.*"status"[^:]*:[^"]*"/,"",s); gsub(/".*$/,"",s); cur_status=s
1084
+ }
1085
+ in_raf && in_entry && /\}/ {
1086
+ if (cur_path!="") print cur_path "|" cur_sha "|" cur_status
1087
+ in_entry=0
1088
+ }
1089
+ in_raf && /\]/{exit}
1090
+ ' "$manifest")
1091
+ for _rm_entry in "${_rm_raf_lines[@]+"${_rm_raf_lines[@]}"}"; do
1092
+ local _rm_rpath _rm_rsha _rm_rstatus
1093
+ IFS='|' read -r _rm_rpath _rm_rsha _rm_rstatus <<< "$_rm_entry"
1094
+ [[ "$rm_tr_first" -eq 0 ]] && rm_raf_json+=","
1095
+ rm_raf_json+=$'\n '
1096
+ rm_raf_json+='{ "path": "'"${_rm_rpath}"'", "sha256": "'"${_rm_rsha}"'", "status": "'"${_rm_rstatus}"'" }'
1097
+ rm_tr_first=0
1098
+ done
1099
+ if [[ "${#_rm_raf_lines[@]}" -gt 0 ]]; then
1100
+ rm_raf_json+=$'\n '
1101
+ fi
1102
+ rm_raf_json+="]"
1103
+ printf ' "root_agent_files": %s\n' "$rm_raf_json"
1104
+ printf ' }'
1105
+ need_comma=1
1106
+ done
1107
+ printf '\n }\n'
1108
+ printf '}\n'
1109
+ } > "$tmp_file"
1110
+ mv "$tmp_file" "$manifest"
1111
+ fi
1112
+ }
1113
+
1114
+ # manifest_list_tools <manifest>
1115
+ # Prints each installed tool id (one per line) from tools.<id> keys.
1116
+ # Exits 0 even when the manifest is absent (prints nothing).
1117
+ manifest_list_tools() {
1118
+ local manifest="$1"
1119
+ [[ -f "$manifest" ]] || return 0
1120
+ if command -v python3 >/dev/null 2>&1; then
1121
+ python3 - "$manifest" <<'PY'
1122
+ import json, sys
1123
+ try:
1124
+ data = json.load(open(sys.argv[1]))
1125
+ for t in data.get("tools", {}).keys():
1126
+ print(t)
1127
+ except Exception:
1128
+ pass
1129
+ PY
1130
+ return
1131
+ fi
1132
+ # Pure-Bash fallback: extract tool keys from the "tools" section.
1133
+ # Each tool entry looks like: "tool-name": { at 4-space indent.
1134
+ awk '
1135
+ BEGIN { in_tools=0; depth=0 }
1136
+ /"tools"[[:space:]]*:/ { in_tools=1; depth=0; next }
1137
+ in_tools && /\{/ { depth++ }
1138
+ in_tools && /\}/ { depth--; if (depth<=0) exit }
1139
+ in_tools && depth==1 && /^ "[a-z][a-zA-Z-]*"[[:space:]]*:/ {
1140
+ s=$0; gsub(/^[[:space:]]*"/, "", s); gsub(/".*/, "", s)
1141
+ if (s != "") print s
1142
+ }
1143
+ ' "$manifest"
1144
+ }
1145
+
1146
+ # ---------------------------------------------------------------------------
1147
+ # Semver comparison helpers
1148
+ # ---------------------------------------------------------------------------
1149
+
1150
+ # _semver_lt <a> <b>
1151
+ # Returns 0 (true) when version a < version b using simple numeric major.minor.patch
1152
+ # comparison. Non-numeric segments are treated as 0. Returns 1 when a >= b.
1153
+ _semver_lt() {
1154
+ local a="$1" b="$2"
1155
+ local -a pa=() pb=()
1156
+ IFS='.' read -ra pa <<< "$a"
1157
+ IFS='.' read -ra pb <<< "$b"
1158
+ local i
1159
+ for i in 0 1 2; do
1160
+ local va="${pa[$i]:-0}" vb="${pb[$i]:-0}"
1161
+ # Strip non-numeric suffixes (e.g. "1-rc1" -> "1").
1162
+ va="${va%%[^0-9]*}"
1163
+ vb="${vb%%[^0-9]*}"
1164
+ [[ -z "$va" ]] && va=0
1165
+ [[ -z "$vb" ]] && vb=0
1166
+ if (( va < vb )); then return 0; fi
1167
+ if (( va > vb )); then return 1; fi
1168
+ done
1169
+ return 1 # equal -> not less than
1170
+ }
1171
+
1172
+ # ---------------------------------------------------------------------------
1173
+ # Shared tool-list renderer (used by both aid_status_body and aid_status)
1174
+ # ---------------------------------------------------------------------------
1175
+
1176
+ # _render_tools_block <manifest> <ref_version> <header_prefix>
1177
+ #
1178
+ # <manifest> - path to the .aid-manifest.json
1179
+ # <ref_version> - the CLI's own version (from $AID_HOME/VERSION)
1180
+ # <header_prefix> - text before the "- all at vX:" collapse suffix (e.g.
1181
+ # "Installed tools (in /path)" or "Installed tools")
1182
+ #
1183
+ # Outputs the complete tools block:
1184
+ # - uniform case: "<header_prefix> - all at v<V>[update hint]:\n <tool>\n..."
1185
+ # - divergent case: "<header_prefix>:\n <tool> v<ver>[update hint]\n..."
1186
+ # Root-agent annotation only when status != "owned".
1187
+ _render_tools_block() {
1188
+ local manifest="$1"
1189
+ local ref_version="$2"
1190
+ local header_prefix="$3"
1191
+
1192
+ # Enumerate tools (sorted by insertion order from manifest).
1193
+ local -a tools=()
1194
+ while IFS= read -r t; do
1195
+ [[ -n "$t" ]] && tools+=("$t")
1196
+ done < <(manifest_list_tools "$manifest")
1197
+
1198
+ if [[ "${#tools[@]}" -eq 0 ]]; then
1199
+ # Nothing to show - shouldn't happen if manifest exists, but safe guard.
1200
+ printf '%s:\n' "$header_prefix"
1201
+ return 0
1202
+ fi
1203
+
1204
+ # Collect per-tool version + root-agent status.
1205
+ local -a tool_vers=()
1206
+ local -a tool_rstatus=()
1207
+ for tool_id in "${tools[@]}"; do
1208
+ local ver
1209
+ ver="$(manifest_read_tool_version "$manifest" "$tool_id")"
1210
+ tool_vers+=("${ver:-}")
1211
+ local root_agent
1212
+ root_agent="$(_root_agent_file "$tool_id")"
1213
+ local root_status=""
1214
+ if [[ -n "$root_agent" ]]; then
1215
+ root_status="$(manifest_read_root_agent_status "$manifest" "$tool_id" "$root_agent")"
1216
+ fi
1217
+ tool_rstatus+=("${root_status:-owned}")
1218
+ done
1219
+
1220
+ # Determine uniform vs divergent.
1221
+ local first_ver="${tool_vers[0]:-}"
1222
+ local uniform=1
1223
+ local ver
1224
+ for ver in "${tool_vers[@]}"; do
1225
+ if [[ "$ver" != "$first_ver" ]]; then
1226
+ uniform=0
1227
+ break
1228
+ fi
1229
+ done
1230
+
1231
+ if [[ "$uniform" -eq 1 ]]; then
1232
+ # Uniform case.
1233
+ local hint=""
1234
+ if [[ -n "$ref_version" && -n "$first_ver" ]] && _semver_lt "$first_ver" "$ref_version"; then
1235
+ hint=" (update -> v${ref_version})"
1236
+ fi
1237
+ printf '%s - all at v%s%s:\n' "$header_prefix" "$first_ver" "$hint"
1238
+ local idx=0
1239
+ for tool_id in "${tools[@]}"; do
1240
+ local rs="${tool_rstatus[$idx]}"
1241
+ local extra=""
1242
+ if [[ "$rs" != "owned" && -n "$rs" ]]; then
1243
+ extra=" (root pending merge)"
1244
+ fi
1245
+ printf ' %s%s\n' "$tool_id" "$extra"
1246
+ # --verbose: also show file count
1247
+ if [[ "${AID_VERBOSE:-0}" == "1" ]]; then
1248
+ local count=0
1249
+ while IFS= read -r _p; do
1250
+ [[ -n "$_p" ]] && count=$((count + 1))
1251
+ done < <(manifest_read_tool_paths "$manifest" "$tool_id")
1252
+ printf ' (%d files installed)\n' "$count"
1253
+ fi
1254
+ idx=$((idx + 1))
1255
+ done
1256
+ else
1257
+ # Divergent case.
1258
+ printf '%s:\n' "$header_prefix"
1259
+ local idx=0
1260
+ for tool_id in "${tools[@]}"; do
1261
+ local ver="${tool_vers[$idx]}"
1262
+ local rs="${tool_rstatus[$idx]}"
1263
+ local hint=""
1264
+ if [[ -n "$ref_version" && -n "$ver" ]] && _semver_lt "$ver" "$ref_version"; then
1265
+ hint=" (update -> v${ref_version})"
1266
+ fi
1267
+ local root_extra=""
1268
+ if [[ "$rs" != "owned" && -n "$rs" ]]; then
1269
+ root_extra=" (root pending merge)"
1270
+ fi
1271
+ # Pad tool id to 14 chars for alignment.
1272
+ local line
1273
+ printf -v line ' %-14s v%s%s%s' "$tool_id" "$ver" "$hint" "$root_extra"
1274
+ printf '%s\n' "$line"
1275
+ # --verbose: also show file count
1276
+ if [[ "${AID_VERBOSE:-0}" == "1" ]]; then
1277
+ local count=0
1278
+ while IFS= read -r _p; do
1279
+ [[ -n "$_p" ]] && count=$((count + 1))
1280
+ done < <(manifest_read_tool_paths "$manifest" "$tool_id")
1281
+ printf ' (%d files installed)\n' "$count"
1282
+ fi
1283
+ idx=$((idx + 1))
1284
+ done
1285
+ fi
1286
+ return 0
1287
+ }
1288
+
1289
+ # aid_status_body <target>
1290
+ # Renders only the installed-tools block for an AID project rooted at <target>.
1291
+ # Caller is responsible for checking whether a manifest exists first.
1292
+ # Prints:
1293
+ # Installed tools (in <cwd>) - all at v<V>[hint]:
1294
+ # <per-tool lines (name-only when uniform)>
1295
+ # OR (divergent):
1296
+ # Installed tools (in <cwd>):
1297
+ # <per-tool lines with version + hint>
1298
+ # OR (when no manifest):
1299
+ # No AID tools installed in <cwd> yet - run 'aid add <tool>'.
1300
+ # Returns:
1301
+ # 0 - always (no exit-7; caller decides what to do on missing manifest)
1302
+ aid_status_body() {
1303
+ local target="${1:-.}"
1304
+ local manifest="${target}/.aid/.aid-manifest.json"
1305
+ local cwd_display
1306
+ cwd_display="$(cd "$target" && pwd)"
1307
+
1308
+ if [[ ! -f "$manifest" ]] || ! grep -q '"manifest_version"' "$manifest" 2>/dev/null; then
1309
+ printf "No AID tools installed in %s yet - run 'aid add <tool>'.\n" "$cwd_display"
1310
+ return 0
1311
+ fi
1312
+
1313
+ # Read CLI ref version from $AID_HOME/VERSION.
1314
+ local ref_version=""
1315
+ if [[ -n "${AID_HOME:-}" && -f "${AID_HOME}/VERSION" ]]; then
1316
+ ref_version="$(tr -d '[:space:]' < "${AID_HOME}/VERSION")"
1317
+ fi
1318
+
1319
+ _render_tools_block "$manifest" "$ref_version" "Installed tools (in ${cwd_display})"
1320
+ return 0
1321
+ }
1322
+
1323
+ # aid_status <target>
1324
+ # Renders the "aid status" output for the AID project rooted at <target>.
1325
+ # Reads <target>/.aid/.aid-manifest.json (and .aid/.aid-version).
1326
+ # Returns:
1327
+ # 0 - manifest found; status printed to stdout.
1328
+ # 7 - no manifest in <target>; "not an AID project here" message printed to stdout.
1329
+ aid_status() {
1330
+ local target="${1:-.}"
1331
+ local manifest="${target}/.aid/.aid-manifest.json"
1332
+ local cwd_display
1333
+ cwd_display="$(cd "$target" && pwd)"
1334
+
1335
+ if [[ ! -f "$manifest" ]] || ! grep -q '"manifest_version"' "$manifest" 2>/dev/null; then
1336
+ printf "No AID install found in %s. Run 'aid add <tool>' to install.\n" "$cwd_display"
1337
+ return 7
1338
+ fi
1339
+
1340
+ # Read aid_version from manifest.
1341
+ local aid_version=""
1342
+ if command -v python3 >/dev/null 2>&1; then
1343
+ aid_version="$(python3 - "$manifest" <<'PY'
1344
+ import json, sys
1345
+ try:
1346
+ data = json.load(open(sys.argv[1]))
1347
+ print(data.get("aid_version", ""))
1348
+ except Exception:
1349
+ pass
1350
+ PY
1351
+ )"
1352
+ else
1353
+ aid_version="$(grep '"aid_version"' "$manifest" | head -1 | \
1354
+ sed 's/.*"aid_version"[^:]*:[^"]*"\([^"]*\)".*/\1/')"
1355
+ fi
1356
+
1357
+ # Read CLI ref version from $AID_HOME/VERSION.
1358
+ local ref_version=""
1359
+ if [[ -n "${AID_HOME:-}" && -f "${AID_HOME}/VERSION" ]]; then
1360
+ ref_version="$(tr -d '[:space:]' < "${AID_HOME}/VERSION")"
1361
+ fi
1362
+
1363
+ printf 'AID %s (project: %s)\n' "$aid_version" "$cwd_display"
1364
+
1365
+ _render_tools_block "$manifest" "$ref_version" "Installed tools"
1366
+
1367
+ return 0
1368
+ }
1369
+
1370
+ # manifest_exists <manifest> - exits 0 when manifest exists and is parseable; 6 otherwise.
1371
+ manifest_exists() {
1372
+ local manifest="$1"
1373
+ if [[ ! -f "$manifest" ]]; then
1374
+ return 6
1375
+ fi
1376
+ # Must have at least one key.
1377
+ if grep -q '"manifest_version"' "$manifest" 2>/dev/null; then
1378
+ return 0
1379
+ fi
1380
+ return 6
1381
+ }
1382
+
1383
+ # ---------------------------------------------------------------------------
1384
+ # Version marker
1385
+ # ---------------------------------------------------------------------------
1386
+
1387
+ # write_version_marker <target> <version>
1388
+ write_version_marker() {
1389
+ local target="$1" version="$2"
1390
+ mkdir -p "${target}/.aid"
1391
+ printf '%s\n' "$version" > "${target}/.aid/.aid-version"
1392
+ }
1393
+
1394
+ # ---------------------------------------------------------------------------
1395
+ # High-level install_tool
1396
+ # ---------------------------------------------------------------------------
1397
+
1398
+ # install_tool <staging_dir> <tool> <target> <version> <force>
1399
+ # <staging_dir> - directory produced by extract_tarball (content of profiles/<tool>/)
1400
+ # Returns:
1401
+ # 0 - success (all files installed or up-to-date)
1402
+ # 5 - at least one root agent file was protect-on-diff blocked
1403
+ #
1404
+ # Side effects: writes <target>/.aid/.aid-manifest.json and .aid/.aid-version.
1405
+ install_tool() {
1406
+ local staging="$1" tool="$2" target="$3" version="$4" force="${5:-0}"
1407
+ local manifest="${target}/.aid/.aid-manifest.json"
1408
+
1409
+ local -a install_paths=()
1410
+ local -a root_entries=()
1411
+ local blocked=0
1412
+
1413
+ # Per-tool file counters (incremented by copy_file and _copy_root_agent_file).
1414
+ _COPY_COUNT_COPIED=0
1415
+ _COPY_COUNT_UPTODATE=0
1416
+ _COPY_COUNT_UPDATED=0
1417
+ _COPY_COUNT_SKIPPED=0
1418
+
1419
+ local root_agent
1420
+ root_agent="$(_root_agent_file "$tool")"
1421
+
1422
+ # Determine which dirs/files this tool installs (mirrors install.sh per-tool dispatch).
1423
+ case "$tool" in
1424
+ claude-code)
1425
+ # .claude/ tree + CLAUDE.md
1426
+ if [[ -d "${staging}/.claude" ]]; then
1427
+ copy_dir "${staging}/.claude" "${target}/.claude" "$force"
1428
+ # Collect paths from .claude/
1429
+ while IFS= read -r -d '' f; do
1430
+ local rel="${f#${staging}/}"
1431
+ install_paths+=("$rel")
1432
+ done < <(find "${staging}/.claude" -type f -print0 2>/dev/null | sort -z)
1433
+ fi
1434
+ ;;
1435
+ codex)
1436
+ # .codex/ + .agents/ + AGENTS.md
1437
+ if [[ -d "${staging}/.codex" ]]; then
1438
+ copy_dir "${staging}/.codex" "${target}/.codex" "$force"
1439
+ while IFS= read -r -d '' f; do
1440
+ local rel="${f#${staging}/}"
1441
+ install_paths+=("$rel")
1442
+ done < <(find "${staging}/.codex" -type f -print0 2>/dev/null | sort -z)
1443
+ fi
1444
+ if [[ -d "${staging}/.agents" ]]; then
1445
+ copy_dir "${staging}/.agents" "${target}/.agents" "$force"
1446
+ while IFS= read -r -d '' f; do
1447
+ local rel="${f#${staging}/}"
1448
+ install_paths+=("$rel")
1449
+ done < <(find "${staging}/.agents" -type f -print0 2>/dev/null | sort -z)
1450
+ fi
1451
+ ;;
1452
+ cursor)
1453
+ # .cursor/ + AGENTS.md
1454
+ if [[ -d "${staging}/.cursor" ]]; then
1455
+ copy_dir "${staging}/.cursor" "${target}/.cursor" "$force"
1456
+ while IFS= read -r -d '' f; do
1457
+ local rel="${f#${staging}/}"
1458
+ install_paths+=("$rel")
1459
+ done < <(find "${staging}/.cursor" -type f -print0 2>/dev/null | sort -z)
1460
+ fi
1461
+ ;;
1462
+ copilot-cli)
1463
+ # .github/ + AGENTS.md
1464
+ if [[ -d "${staging}/.github" ]]; then
1465
+ copy_dir "${staging}/.github" "${target}/.github" "$force"
1466
+ while IFS= read -r -d '' f; do
1467
+ local rel="${f#${staging}/}"
1468
+ install_paths+=("$rel")
1469
+ done < <(find "${staging}/.github" -type f -print0 2>/dev/null | sort -z)
1470
+ fi
1471
+ ;;
1472
+ antigravity)
1473
+ # .agent/ + AGENTS.md
1474
+ if [[ -d "${staging}/.agent" ]]; then
1475
+ copy_dir "${staging}/.agent" "${target}/.agent" "$force"
1476
+ while IFS= read -r -d '' f; do
1477
+ local rel="${f#${staging}/}"
1478
+ install_paths+=("$rel")
1479
+ done < <(find "${staging}/.agent" -type f -print0 2>/dev/null | sort -z)
1480
+ fi
1481
+ ;;
1482
+ esac
1483
+
1484
+ # Handle root agent file via FR11.
1485
+ local root_src="${staging}/${root_agent}"
1486
+ local root_dst="${target}/${root_agent}"
1487
+
1488
+ if [[ -f "$root_src" ]]; then
1489
+ _CORE_ROOT_AGENT_STATUS="owned"
1490
+ _copy_root_agent_file "$root_src" "$root_dst" "$tool" "$force" "$manifest" || {
1491
+ local rc=$?
1492
+ [[ "$rc" -eq 5 ]] && blocked=1
1493
+ }
1494
+
1495
+ local inc_sha
1496
+ inc_sha="$(sha256_file "$root_src")"
1497
+ root_entries+=("${root_agent}|${inc_sha}|${_CORE_ROOT_AGENT_STATUS}")
1498
+
1499
+ # Include root agent path in paths list only when owned (not pending-merge).
1500
+ if [[ "${_CORE_ROOT_AGENT_STATUS}" == "owned" ]]; then
1501
+ install_paths+=("$root_agent")
1502
+ fi
1503
+ fi
1504
+
1505
+ # Write manifest (merge).
1506
+ manifest_write "$manifest" "$tool" "$version" "install_paths" "root_entries"
1507
+
1508
+ # Write version marker.
1509
+ write_version_marker "$target" "$version"
1510
+
1511
+ # Print concise summary (always shown; per-file lines only when AID_VERBOSE=1).
1512
+ local _total_files=$((_COPY_COUNT_COPIED + _COPY_COUNT_UPTODATE + _COPY_COUNT_UPDATED + _COPY_COUNT_SKIPPED))
1513
+ if [[ "$_total_files" -gt 0 ]]; then
1514
+ if [[ "$_COPY_COUNT_COPIED" -gt 0 && "$_COPY_COUNT_UPTODATE" -eq 0 && "$_COPY_COUNT_UPDATED" -eq 0 ]]; then
1515
+ echo " ${_COPY_COUNT_COPIED} files installed"
1516
+ elif [[ "$_COPY_COUNT_UPTODATE" -gt 0 && "$_COPY_COUNT_COPIED" -eq 0 && "$_COPY_COUNT_UPDATED" -eq 0 ]]; then
1517
+ echo " up to date (${_COPY_COUNT_UPTODATE} files)"
1518
+ else
1519
+ local _parts=""
1520
+ [[ "$_COPY_COUNT_UPDATED" -gt 0 ]] && _parts="${_COPY_COUNT_UPDATED} updated"
1521
+ [[ "$_COPY_COUNT_COPIED" -gt 0 ]] && {
1522
+ [[ -n "$_parts" ]] && _parts="${_parts}, "
1523
+ _parts="${_parts}${_COPY_COUNT_COPIED} installed"
1524
+ }
1525
+ [[ "$_COPY_COUNT_UPTODATE" -gt 0 ]] && {
1526
+ [[ -n "$_parts" ]] && _parts="${_parts}, "
1527
+ _parts="${_parts}${_COPY_COUNT_UPTODATE} unchanged"
1528
+ }
1529
+ echo " ${_parts}"
1530
+ fi
1531
+ fi
1532
+
1533
+ if [[ "$blocked" -eq 1 ]]; then
1534
+ return 5
1535
+ fi
1536
+ return 0
1537
+ }
1538
+
1539
+ # ---------------------------------------------------------------------------
1540
+ # Uninstall
1541
+ # ---------------------------------------------------------------------------
1542
+
1543
+ # uninstall_tool <manifest> <tool> <target>
1544
+ # Removes all files recorded under tools.<tool>.paths that still exist.
1545
+ # For root agent files: removes only when sha256 still matches recorded value.
1546
+ # Prunes now-empty AID dirs. Removes the manifest if no tools remain.
1547
+ uninstall_tool() {
1548
+ local manifest="$1" tool="$2" target="$3"
1549
+
1550
+ manifest_exists "$manifest" || return 6
1551
+
1552
+ # Read all paths for this tool.
1553
+ local -a paths=()
1554
+ while IFS= read -r p; do
1555
+ [[ -n "$p" ]] && paths+=("$p")
1556
+ done < <(manifest_read_tool_paths "$manifest" "$tool")
1557
+
1558
+ if [[ "${#paths[@]}" -eq 0 ]]; then
1559
+ echo "Nothing to uninstall for ${tool} (no paths recorded)" >&2
1560
+ manifest_remove_tool "$manifest" "$tool"
1561
+ return 0
1562
+ fi
1563
+
1564
+ # Determine root agent file name.
1565
+ local root_agent
1566
+ root_agent="$(_root_agent_file "$tool")"
1567
+
1568
+ # Per-uninstall counters.
1569
+ local _uninst_removed=0
1570
+ local _uninst_leftinplace=0
1571
+
1572
+ # Remove each path.
1573
+ for p in "${paths[@]}"; do
1574
+ local full="${target}/${p}"
1575
+ if [[ ! -e "$full" ]]; then
1576
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Already absent: ${full}"
1577
+ continue
1578
+ fi
1579
+ # Check if this is the root agent file -> apply FR11 uninstall check.
1580
+ local base
1581
+ base="$(basename "$p")"
1582
+ if [[ "$base" == "$root_agent" && "$p" == "$root_agent" ]]; then
1583
+ local recorded_sha
1584
+ recorded_sha="$(manifest_read_root_agent "$manifest" "$tool" "$root_agent")"
1585
+ if [[ -n "$recorded_sha" ]]; then
1586
+ local disk_sha
1587
+ disk_sha="$(sha256_file "$full")"
1588
+ if [[ "$disk_sha" != "$recorded_sha" ]]; then
1589
+ _uninst_leftinplace=$((_uninst_leftinplace + 1))
1590
+ # "Left in place" always shown (important for user awareness).
1591
+ echo "Left in place (modified or not AID-owned): ${full}"
1592
+ continue
1593
+ fi
1594
+ fi
1595
+ fi
1596
+ rm -f "$full"
1597
+ _uninst_removed=$((_uninst_removed + 1))
1598
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Removed: ${full}"
1599
+ done
1600
+
1601
+ # Print concise uninstall summary (always shown).
1602
+ if [[ "$_uninst_removed" -gt 0 ]]; then
1603
+ echo " ${_uninst_removed} files removed"
1604
+ fi
1605
+
1606
+ # Prune now-empty AID-owned dirs (in reverse depth order).
1607
+ local -a aid_dirs=()
1608
+ case "$tool" in
1609
+ claude-code) aid_dirs+=(".claude") ;;
1610
+ codex) aid_dirs+=(".codex" ".agents") ;;
1611
+ cursor) aid_dirs+=(".cursor") ;;
1612
+ copilot-cli) aid_dirs+=(".github") ;;
1613
+ antigravity) aid_dirs+=(".agent") ;;
1614
+ esac
1615
+
1616
+ for d in "${aid_dirs[@]}"; do
1617
+ local full_dir="${target}/${d}"
1618
+ if [[ -d "$full_dir" ]]; then
1619
+ # Remove if empty (find will list any remaining files).
1620
+ local remaining_files
1621
+ remaining_files="$(find "$full_dir" -type f 2>/dev/null | head -1)"
1622
+ if [[ -z "$remaining_files" ]]; then
1623
+ rm -rf "$full_dir"
1624
+ [[ "${AID_VERBOSE:-0}" -eq 1 ]] && echo "Removed dir: ${full_dir}"
1625
+ fi
1626
+ fi
1627
+ done
1628
+
1629
+ # Remove this tool from manifest.
1630
+ manifest_remove_tool "$manifest" "$tool"
1631
+
1632
+ # If no manifest remains, remove the .aid version marker too.
1633
+ if [[ ! -f "$manifest" ]]; then
1634
+ local version_marker
1635
+ version_marker="$(dirname "$manifest")/.aid-version"
1636
+ rm -f "$version_marker"
1637
+ # Remove .aid dir if empty.
1638
+ local aid_meta_dir
1639
+ aid_meta_dir="$(dirname "$manifest")"
1640
+ if [[ -d "$aid_meta_dir" ]]; then
1641
+ local rem
1642
+ rem="$(find "$aid_meta_dir" -type f 2>/dev/null | head -1)"
1643
+ [[ -z "$rem" ]] && rmdir "$aid_meta_dir" 2>/dev/null || true
1644
+ fi
1645
+ fi
1646
+ }