aid-installer 0.7.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/aid CHANGED
@@ -2,9 +2,9 @@
2
2
  # aid - AID CLI dispatcher (Bash side).
3
3
  #
4
4
  # Purpose:
5
- # Persistent global command installed at $AID_HOME/bin/aid. Parses
5
+ # Persistent global command installed at $AID_CODE_HOME/bin/aid. Parses
6
6
  # subcommands and dispatches to the shared install-core engine located at
7
- # $AID_HOME/lib/aid-install-core.sh. Operates on the current working
7
+ # $AID_CODE_HOME/lib/aid-install-core.sh. Operates on the current working
8
8
  # directory (--target / AID_TARGET overrides).
9
9
  #
10
10
  # Usage:
@@ -15,6 +15,8 @@
15
15
  # aid add <tool>[,...] Add tool(s) to the current project
16
16
  # aid update [<tool>... | self] Update to latest; no arg = all tools; 'self' = the aid CLI
17
17
  # aid remove [<tool>... | self] Remove; no arg = ALL AID from project; 'self' = the aid CLI
18
+ # aid projects [list|add|remove|help] [path] [--local|--shared] [--verbose]
19
+ # List, register, or unregister AID projects
18
20
  # aid <command> -h | --help Per-command help
19
21
  #
20
22
  # Flags (shared across subcommands where applicable):
@@ -31,27 +33,77 @@ set -uo pipefail
31
33
  # Bootstrap URL - single place to update when the branch merges to master.
32
34
  # Override with AID_INSTALL_URL env var for tests.
33
35
  # ---------------------------------------------------------------------------
34
- AID_INSTALL_URL="${AID_INSTALL_URL:-https://raw.githubusercontent.com/AndreVianna/aid-methodology/worktree-work-002-auto-installer/install.sh}"
36
+ AID_INSTALL_URL="${AID_INSTALL_URL:-https://raw.githubusercontent.com/AndreVianna/aid-methodology/master/install.sh}"
35
37
 
36
38
  # ---------------------------------------------------------------------------
37
- # Locate $AID_HOME. The installed dispatcher lives at $AID_HOME/bin/aid.
38
- # When invoked directly (not via PATH), BASH_SOURCE[0] is the absolute path.
39
+ # AID_CODE_HOME: self-locate the read-only code payload (parent of bin/).
40
+ # NEVER overridden by an env var. Error-out if unresolvable (Q1 fail-safe).
39
41
  # ---------------------------------------------------------------------------
40
42
  _AID_SELF="${BASH_SOURCE[0]:-}"
41
43
  if [[ -n "$_AID_SELF" && -f "$_AID_SELF" ]]; then
42
- # Resolve symlinks so we get the real directory.
44
+ # Resolve symlinks so we get the real payload root directory.
43
45
  _AID_SELF_REAL="$(cd "$(dirname "$_AID_SELF")" && pwd -P)/$(basename "$_AID_SELF")"
44
- AID_HOME="${AID_HOME:-$(dirname "$(dirname "$_AID_SELF_REAL")")}"
46
+ AID_CODE_HOME="$(dirname "$(dirname "$_AID_SELF_REAL")")"
45
47
  else
46
- AID_HOME="${AID_HOME:-${HOME}/.aid}"
48
+ echo "ERROR: aid: cannot locate the AID code payload (AID_CODE_HOME unresolved). Re-run the AID bootstrap to repair." >&2
49
+ exit 1
50
+ fi
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Scope derivation: global iff AID_CODE_HOME is not writable by the current
54
+ # user. Reuses the _aid_priv_run writability approach (no second test).
55
+ # AID_STATE_HOME: mutable state home, env-overridable via AID_HOME.
56
+ # ---------------------------------------------------------------------------
57
+ if [[ -n "$AID_CODE_HOME" && -e "$AID_CODE_HOME" && ! -w "$AID_CODE_HOME" && "$(id -u)" -ne 0 ]]; then
58
+ _AID_SCOPE="global"
59
+ AID_STATE_HOME="${AID_HOME:-${AID_SHARED_STATE_HOME:-/var/lib/aid}}"
60
+ else
61
+ _AID_SCOPE="user"
62
+ AID_STATE_HOME="${AID_HOME:-${HOME}/.aid}"
47
63
  fi
48
64
 
49
65
  # ---------------------------------------------------------------------------
50
- # Source the shared install core from $AID_HOME/lib/.
66
+ # _aid_is_project_dir <dir>
67
+ # Return 0 (true) iff <dir> has a .aid/ subdirectory AND that subdirectory is
68
+ # NOT the CLI state home. Treats the CLI state home as a non-project dir so
69
+ # running 'aid' from $HOME (or any dir whose .aid/ == AID_STATE_HOME) does not
70
+ # falsely auto-register or trigger the format gate.
71
+ #
72
+ # Guard: resolves <dir>/.aid to a real path (tolerates non-existent) and
73
+ # compares against realpath($AID_STATE_HOME) and realpath($HOME/.aid).
74
+ # ASCII-safe: uses bash built-ins only.
75
+ # ---------------------------------------------------------------------------
76
+ _aid_is_project_dir() {
77
+ local _dir="$1"
78
+ # Fast out: no .aid/ subdirectory at all.
79
+ [[ -d "${_dir}/.aid" ]] || return 1
80
+ # Resolve to canonical real path, tolerating paths that may not fully exist.
81
+ local _aid_real
82
+ _aid_real="$(cd "${_dir}/.aid" 2>/dev/null && pwd -P)" || _aid_real="${_dir}/.aid"
83
+ # Resolve the two state-home paths.
84
+ local _sh_real _hd_real
85
+ _sh_real="$(cd "${AID_STATE_HOME}" 2>/dev/null && pwd -P)" || _sh_real="${AID_STATE_HOME}"
86
+ _hd_real="$(cd "${HOME}/.aid" 2>/dev/null && pwd -P)" || _hd_real="${HOME}/.aid"
87
+ # If .aid/ resolves to either state-home, this is NOT a project dir.
88
+ if [[ "${_aid_real}" == "${_sh_real}" || "${_aid_real}" == "${_hd_real}" ]]; then
89
+ return 1
90
+ fi
91
+ return 0
92
+ }
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # C1: Per-repo format stamp constant.
96
+ # The current .aid/ layout version. Bumped ONLY on a breaking layout change,
97
+ # never on every CLI release. Defined exactly once; all comparisons read this.
98
+ # ---------------------------------------------------------------------------
99
+ readonly AID_SUPPORTED_FORMAT=1
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Source the shared install core from AID_CODE_HOME/lib/.
51
103
  # ---------------------------------------------------------------------------
52
- _AID_CORE="${AID_HOME}/lib/aid-install-core.sh"
104
+ _AID_CORE="${AID_CODE_HOME}/lib/aid-install-core.sh"
53
105
  if [[ ! -f "$_AID_CORE" ]]; then
54
- echo "ERROR: aid: install core not found at ${_AID_CORE}. Re-run the AID bootstrap to repair." >&2
106
+ echo "ERROR: aid: cannot locate the AID code payload (AID_CODE_HOME unresolved). Re-run the AID bootstrap to repair." >&2
55
107
  exit 1
56
108
  fi
57
109
  # shellcheck source=../lib/aid-install-core.sh
@@ -82,21 +134,57 @@ _aid_usage() {
82
134
  printf ' Tools: claude-code, codex, cursor, copilot-cli, antigravity\n'
83
135
  ;;
84
136
  remove)
85
- printf 'aid remove [<tool>[,<tool>...] | self] [--force] [--verbose] [--target <dir>]\n'
137
+ printf 'aid remove [<tool>[,<tool>...]] [--force] [--verbose] [--target <dir>]\n'
138
+ printf 'aid remove self [--force] [--dry-run]\n'
86
139
  printf ' Remove tool(s) from the current project (manifest-driven).\n'
87
140
  printf ' No args: remove ALL AID from the project (asks for confirmation).\n'
88
- printf ' self: remove the aid CLI itself (asks for confirmation).\n'
141
+ printf ' self: COMPLETELY remove the aid CLI, channel-aware (asks for confirmation):\n'
142
+ printf ' npm -> npm uninstall -g | pypi -> pipx uninstall | curl -> rm $AID_HOME + unwire PATH.\n'
143
+ printf ' Auto-elevates with sudo only when the install location needs root.\n'
144
+ printf ' --dry-run: print the exact command(s) it would run, then exit (no changes).\n'
89
145
  ;;
90
146
  update)
91
- printf 'aid update [<tool>... | self] [--version <v>] [--from-bundle <path>]\n'
92
- printf ' [--force] [--verbose] [--target <dir>]\n'
147
+ printf 'aid update [<tool>...] [--version <v>] [--from-bundle <path>] [--force] [--target <dir>]\n'
148
+ printf 'aid update self [--from-bundle <path>] [--dry-run]\n'
93
149
  printf ' Update to latest. No args: update all installed tools.\n'
94
- printf ' self: update the aid CLI itself.\n'
150
+ printf ' self: COMPLETELY update the aid CLI, channel-aware:\n'
151
+ printf ' npm -> npm i -g | pypi -> pipx upgrade | curl -> re-bootstrap install.sh.\n'
152
+ printf ' Auto-elevates with sudo only when the install location needs root.\n'
153
+ printf ' --from-bundle <path>: install the CLI from a local artifact instead of @latest\n'
154
+ printf ' (npm .tgz | pypi .whl | curl release-staging dir with install.sh).\n'
155
+ printf ' --dry-run: print the exact command(s) it would run, then exit (no changes).\n'
95
156
  ;;
96
157
  version)
97
158
  printf 'aid version\n'
98
159
  printf ' Print the installed aid CLI version and exit 0.\n'
99
160
  ;;
161
+ dashboard)
162
+ printf 'aid dashboard start <node|python> [--remote] [--port <n>]\n'
163
+ printf 'aid dashboard stop\n'
164
+ printf ' Start or stop the machine-level pipeline dashboard (serves all registered projects).\n'
165
+ printf ' <node|python> select the server runtime to launch.\n'
166
+ printf ' --remote also expose it to authorized users over a private channel (never public);\n'
167
+ printf ' fails clearly if that mechanism is unavailable -- never binds publicly.\n'
168
+ printf ' --port <n> listen port on 127.0.0.1 (default 8787).\n'
169
+ printf ' The dashboard binds to 127.0.0.1 only. '"'"'stop'"'"' is idempotent and also tears down --remote.\n'
170
+ printf ' Works from any directory (not tied to the current project).\n'
171
+ ;;
172
+ projects)
173
+ printf 'aid projects [list] [--local|--shared] [--verbose]\n'
174
+ printf 'aid projects add [<path>] [--local|--shared]\n'
175
+ printf 'aid projects remove [<path>]\n'
176
+ printf ' List, register, or unregister AID projects in the registry.\n'
177
+ printf ' list (default): show all registered projects with state, tools, and tier.\n'
178
+ printf ' The current directory is marked with "*" in the leading marker column.\n'
179
+ printf ' Unregistered cwd with .aid/ present is shown as a footnote.\n'
180
+ printf ' add [path=cwd]: register a project (requires .aid/ to exist); tracking only,\n'
181
+ printf ' no tools are installed. Idempotent. Prints the tier written.\n'
182
+ printf ' remove [path=cwd]: unregister a project from the registry; no files removed.\n'
183
+ printf ' Works on stale/missing/no-aid entries. Idempotent.\n'
184
+ printf ' --local force user tier for add\n'
185
+ printf ' --shared force shared tier for add\n'
186
+ printf ' --verbose print extra detail\n'
187
+ ;;
100
188
  *)
101
189
  printf 'aid - AID CLI\n'
102
190
  printf '\n'
@@ -108,9 +196,11 @@ _aid_usage() {
108
196
  printf ' aid add <tool>[,...] Add tool(s) to the current project\n'
109
197
  printf ' aid update [<tool>... | self] Update to latest; no arg = all tools\n'
110
198
  printf ' aid remove [<tool>... | self] Remove; no arg = ALL AID from project\n'
199
+ printf ' aid dashboard start|stop ... Start/stop the local dashboard\n'
200
+ printf ' aid projects [list|add|remove] List/register/unregister AID projects\n'
111
201
  printf ' aid <command> -h | --help Per-command help\n'
112
202
  printf '\n'
113
- printf 'Flags: --from-bundle, --version, --force, --target, --verbose\n'
203
+ printf 'Flags: --from-bundle, --version, --force, --dry-run, --target, --verbose\n'
114
204
  printf "Run 'aid <command> -h' for details.\n"
115
205
  ;;
116
206
  esac
@@ -142,10 +232,10 @@ _find_install_sh() {
142
232
  # ---------------------------------------------------------------------------
143
233
 
144
234
  # _aid_check_update
145
- # Compares the installed CLI version ($AID_HOME/VERSION) against the latest
235
+ # Compares the installed CLI version ($AID_CODE_HOME/VERSION) against the latest
146
236
  # GitHub release. Prints ONE notice line when a newer version is available.
147
237
  # Fail-silent: any error (no curl, network down, bad JSON) is suppressed.
148
- # Throttle: re-fetches at most once per 24h; caches result in $AID_HOME/.update-check.
238
+ # Throttle: re-fetches at most once per 24h; caches result in ~/.aid/.update-check.
149
239
  # Opt-out: AID_NO_UPDATE_CHECK=1 -> skip entirely.
150
240
  # Test hook: AID_UPDATE_CHECK_URL overrides the fetch URL (and bypasses throttle).
151
241
  _aid_check_update() {
@@ -154,13 +244,13 @@ _aid_check_update() {
154
244
 
155
245
  # Read installed version.
156
246
  local installed_version=""
157
- local ver_file="${AID_HOME}/VERSION"
247
+ local ver_file="${AID_CODE_HOME}/VERSION"
158
248
  if [[ -f "$ver_file" ]]; then
159
249
  installed_version="$(tr -d '[:space:]' < "$ver_file")"
160
250
  fi
161
251
  [[ -z "$installed_version" ]] && return 0
162
252
 
163
- local cache_file="${AID_HOME}/.update-check"
253
+ local cache_file="${HOME}/.aid/.update-check"
164
254
  local now
165
255
  now="$(date +%s 2>/dev/null)" || return 0
166
256
  local throttle_secs=86400 # 24 hours
@@ -218,35 +308,104 @@ _aid_check_update() {
218
308
 
219
309
  # Compare: show notice only when latest > installed.
220
310
  if _semver_lt "$installed_version" "$latest_version"; then
221
- local _update_cmd
222
- case "${AID_INSTALL_CHANNEL:-}" in
223
- npm) _update_cmd="npm i -g aid-installer@latest" ;;
224
- pypi) _update_cmd="pipx upgrade aid-installer (or: pip install --user -U aid-installer)" ;;
225
- *) _update_cmd="aid update self" ;;
226
- esac
227
- printf 'A newer aid CLI is available: v%s (you have v%s). Run: %s\n' \
228
- "$latest_version" "$installed_version" "$_update_cmd"
311
+ # `aid update self` is now channel-aware and self-contained (it runs the
312
+ # right package manager + applies migrations), so point at it for every
313
+ # channel instead of a per-channel manual command.
314
+ printf 'A newer aid CLI is available: v%s (you have v%s). Run: aid update self\n' \
315
+ "$latest_version" "$installed_version"
229
316
  fi
230
317
  return 0
231
318
  }
232
319
 
320
+
233
321
  # ---------------------------------------------------------------------------
234
322
  # update self command (formerly self-update).
235
323
  # ---------------------------------------------------------------------------
324
+ # _aid_priv_run <writability-probe-dir> <cmd...>
325
+ # Run <cmd>, auto-elevating with sudo ONLY when <probe-dir> exists and is not
326
+ # writable by the current user (and we are not already root) -- e.g. a
327
+ # root-owned npm global prefix. A user-level prefix / pipx venv stays sudo-free.
328
+ # Honors _SELF_DRYRUN=1 (print the resolved command, prefixed with sudo when it
329
+ # would elevate, and do nothing). Returns the command's exit code, or 13 when
330
+ # elevation is needed but sudo is unavailable.
331
+ _aid_priv_run() {
332
+ local probe="$1"; shift
333
+ local need_root=0
334
+ if [[ -n "$probe" && -e "$probe" && ! -w "$probe" && "$(id -u)" -ne 0 ]]; then
335
+ need_root=1
336
+ fi
337
+ if [[ "${_SELF_DRYRUN:-0}" == "1" ]]; then
338
+ if [[ "$need_root" -eq 1 ]]; then printf '+ sudo %s\n' "$*"; else printf '+ %s\n' "$*"; fi
339
+ return 0
340
+ fi
341
+ if [[ "$need_root" -eq 1 ]]; then
342
+ if command -v sudo >/dev/null 2>&1; then
343
+ printf 'aid: %s is not writable -- elevating this step via sudo...\n' "$probe" >&2
344
+ sudo "$@"; return $?
345
+ fi
346
+ printf 'ERROR: aid: %s is not writable and sudo is unavailable. Run manually:\n %s\n' "$probe" "$*" >&2
347
+ return 13
348
+ fi
349
+ "$@"
350
+ }
351
+
352
+ # Channel-aware, self-contained CLI self-update. Reads the channel from
353
+ # AID_INSTALL_CHANNEL (injected by the npm/pypi shims); the curl/default channel
354
+ # re-bootstraps via install.sh. Honors _SELF_FROM_BUNDLE (a local CLI artifact:
355
+ # npm .tgz / pypi .whl / curl bundle dir) and _SELF_DRYRUN. The post-update
356
+ # migration scan runs in the caller's (user) context -- never under the sudo
357
+ # used here for the privileged install step.
236
358
  _cmd_update_self() {
237
- # AID_INSTALL_CHANNEL guard: npm/pypi channels print a package-manager hint
238
- # and exit 0 instead of re-bootstrapping (which would overwrite the channel).
239
- case "${AID_INSTALL_CHANNEL:-}" in
359
+ # AID_SKIP_SELF_INSTALL: the package manager already (re)installed the CLI
360
+ # (npm postinstall) and only wants the post-update migration to run. Skip the
361
+ # re-install step.
362
+ if [[ "${AID_SKIP_SELF_INSTALL:-0}" == "1" ]]; then
363
+ return 0
364
+ fi
365
+ local channel="${AID_INSTALL_CHANNEL:-}"
366
+ local bundle="${_SELF_FROM_BUNDLE:-}"
367
+ case "$channel" in
240
368
  npm)
241
- printf 'Updating the aid CLI: run npm i -g aid-installer@latest\n'
242
- return 0
369
+ command -v npm >/dev/null 2>&1 || { echo "ERROR: aid: npm not found; cannot update the npm-channel CLI" >&2; return 3; }
370
+ local gdir pkg
371
+ gdir="$(npm root -g 2>/dev/null)"
372
+ pkg="aid-installer@latest"; [[ -n "$bundle" ]] && pkg="$bundle"
373
+ printf 'Updating the aid CLI (npm channel)...\n'
374
+ _aid_priv_run "$gdir" npm install -g "$pkg"
375
+ return $?
243
376
  ;;
244
377
  pypi)
245
- printf 'Updating the aid CLI: run pipx upgrade aid-installer (or: pip install --user -U aid-installer)\n'
246
- return 0
378
+ command -v pipx >/dev/null 2>&1 || { echo "ERROR: aid: pipx not found; cannot update the pypi-channel CLI" >&2; return 3; }
379
+ printf 'Updating the aid CLI (pypi/pipx channel)...\n'
380
+ if [[ -n "$bundle" ]]; then
381
+ _aid_priv_run "" pipx install --force "$bundle"
382
+ else
383
+ _aid_priv_run "" pipx upgrade aid-installer
384
+ fi
385
+ return $?
247
386
  ;;
248
387
  esac
388
+ # curl / default channel -- re-bootstrap install.sh.
249
389
  printf 'Updating the aid CLI...\n'
390
+ if [[ -n "$bundle" ]]; then
391
+ # --from-bundle <dir> on the curl channel: a release-staging dir that
392
+ # carries install.sh + the CLI bundle + SHA256SUMS. Run it offline.
393
+ if [[ -f "${bundle%/}/install.sh" ]]; then
394
+ if [[ "${_SELF_DRYRUN:-0}" == "1" ]]; then
395
+ printf '+ AID_CLI_BUNDLE_BASE=file://%s AID_LIB_BASE=file://%s bash %s/install.sh\n' "${bundle%/}" "${bundle%/}" "${bundle%/}"
396
+ return 0
397
+ fi
398
+ AID_CLI_BUNDLE_BASE="file://${bundle%/}" AID_LIB_BASE="file://${bundle%/}" \
399
+ bash "${bundle%/}/install.sh"
400
+ return $?
401
+ fi
402
+ echo "ERROR: aid: --from-bundle <dir> for the curl channel must contain install.sh (got: ${bundle})" >&2
403
+ return 2
404
+ fi
405
+ if [[ "${_SELF_DRYRUN:-0}" == "1" ]]; then
406
+ printf '+ curl -fsSL %s | bash\n' "${AID_INSTALL_URL}"
407
+ return 0
408
+ fi
250
409
  if command -v curl >/dev/null 2>&1; then
251
410
  curl -fsSL "${AID_INSTALL_URL}" | bash
252
411
  return $?
@@ -256,6 +415,73 @@ _cmd_update_self() {
256
415
  fi
257
416
  }
258
417
 
418
+ # ---------------------------------------------------------------------------
419
+ # _aid_update_self_if_stale (FF-3 preamble / CLI-2 / task-079)
420
+ # Self-update-if-needed preamble for the 'aid update [<tool>]' reach.
421
+ # Reuses _cmd_update_self's channel logic gated by a skip-if-current check
422
+ # (OQ-6 resolved simplest-correct: compare installed $AID_CODE_HOME/VERSION against
423
+ # the cached .update-check latest; if stale -> call _cmd_update_self; if
424
+ # current or unknown -> silent no-op).
425
+ #
426
+ # Safety notes (to prevent re-bootstrap/loop hazards):
427
+ # - This is called BEFORE the tool-install loop on the 'update' reach only
428
+ # (not 'update self', not 'add') -- no recursion possible.
429
+ # - _cmd_update_self is channel-aware and self-contained: npm runs
430
+ # `npm install -g`, pypi runs `pipx install/upgrade` (may sudo-prompt only
431
+ # when the install location needs root), curl re-runs the bootstrap (which
432
+ # replaces bin/aid on disk). In every case the current process keeps running
433
+ # the already-loaded script, so the subsequent migration runs under the
434
+ # current code as the invoking user -- acceptable (same pattern as
435
+ # 'aid update self' + post-update scan).
436
+ # - WARN-not-fail: a self-update failure is logged and the tool-install
437
+ # continues (NFR12).
438
+ # ---------------------------------------------------------------------------
439
+ _aid_update_self_if_stale() {
440
+ # Read installed version (same pattern as _aid_check_update).
441
+ local _installed=""
442
+ local _ver_file="${AID_CODE_HOME}/VERSION"
443
+ if [[ -f "${_ver_file}" ]]; then
444
+ _installed="$(tr -d '[:space:]' < "${_ver_file}")"
445
+ fi
446
+ [[ -z "${_installed}" ]] && return 0 # no installed version known -> skip
447
+
448
+ # Read cached latest version from .update-check (line 2 of the cache file).
449
+ local _cache_file="${HOME}/.aid/.update-check"
450
+ local _cached_latest=""
451
+ if [[ -f "${_cache_file}" ]]; then
452
+ _cached_latest="$(awk 'NR==2{print $1}' "${_cache_file}" 2>/dev/null)" || _cached_latest=""
453
+ fi
454
+ [[ -z "${_cached_latest}" ]] && return 0 # no cached latest known -> skip (no network call here)
455
+
456
+ # Offline / explicit install: when the caller supplied a local bundle, do NOT
457
+ # phone the package channel to self-update. The bundle is the source of truth
458
+ # for this install; reaching out to the registry would defeat an air-gapped or
459
+ # pre-release install (and could replace the running CLI behind the user's back).
460
+ [[ -n "${_AID_FROM_BUNDLE:-}" ]] && return 0
461
+
462
+ # Skip if already current.
463
+ if [[ "${_installed}" == "${_cached_latest}" ]]; then
464
+ return 0
465
+ fi
466
+
467
+ # Only self-update when the installed CLI is strictly OLDER than the latest
468
+ # (semver-aware). A newer installed version (e.g. an unreleased dev build) must
469
+ # never be downgraded to "latest". sort -V puts the lower version first.
470
+ local _lower
471
+ _lower="$(printf '%s\n%s\n' "${_installed}" "${_cached_latest}" | sort -V | head -1)"
472
+ if [[ "${_lower}" != "${_installed}" ]]; then
473
+ return 0 # installed >= latest -> nothing to do (never downgrade)
474
+ fi
475
+
476
+ # Stale: call the channel-appropriate self-update logic.
477
+ # WARN-not-fail: failure here must not abort the tool-update.
478
+ printf 'aid update: CLI is not current (installed: %s, available: %s); self-updating before tool install...\n' \
479
+ "${_installed}" "${_cached_latest}"
480
+ _cmd_update_self || \
481
+ echo "WARN: aid: self-update failed (continuing with tool install)" >&2
482
+ return 0
483
+ }
484
+
259
485
  # ---------------------------------------------------------------------------
260
486
  # Path-wiring helpers (Unix).
261
487
  # ---------------------------------------------------------------------------
@@ -416,15 +642,14 @@ _unwire_path_unix() {
416
642
  # ---------------------------------------------------------------------------
417
643
 
418
644
  # _install_global_cli <version> <src_bin_aid> <src_lib_core>
419
- # Stage then atomic-move into $AID_HOME.
645
+ # Stage then atomic-move into AID_CODE_HOME (the read-only code payload root).
420
646
  _install_global_cli() {
421
647
  local version="$1"
422
648
  local src_bin_aid="$2"
423
649
  local src_lib_core="$3"
424
650
 
425
- local aid_home="${AID_HOME:-${HOME}/.aid}"
426
- local bin_dir="${aid_home}/bin"
427
- local lib_dir="${aid_home}/lib"
651
+ local bin_dir="${AID_CODE_HOME}/bin"
652
+ local lib_dir="${AID_CODE_HOME}/lib"
428
653
 
429
654
  mkdir -p "$bin_dir" "$lib_dir"
430
655
 
@@ -436,43 +661,63 @@ _install_global_cli() {
436
661
  cp "$src_lib_core" "${lib_dir}/aid-install-core.sh"
437
662
 
438
663
  # Write the VERSION file.
439
- printf '%s\n' "$version" > "${aid_home}/VERSION"
664
+ printf '%s\n' "$version" > "${AID_CODE_HOME}/VERSION"
440
665
 
441
- echo "aid CLI v${version} installed to ${aid_home}."
666
+ echo "aid CLI v${version} installed to ${AID_CODE_HOME}."
442
667
  }
443
668
 
444
669
  # ---------------------------------------------------------------------------
445
670
  # remove self (formerly self-uninstall).
446
671
  # ---------------------------------------------------------------------------
447
672
 
673
+ # Channel-aware, self-contained CLI removal. npm/pypi installs are owned by the
674
+ # package manager, so removing only $AID_HOME left the wrapper + bin shim behind
675
+ # (a dangling entry point). Now each channel does the COMPLETE removal:
676
+ # npm -> npm uninstall -g aid-installer (package + vendored tree + shim)
677
+ # pypi -> pipx uninstall aid-installer (venv + entry point)
678
+ # curl -> rm -rf $AID_HOME + unwire PATH (unchanged)
679
+ # Privileged step (root-owned npm global) auto-elevates via _aid_priv_run.
680
+ # Honors --dry-run.
448
681
  _cmd_remove_self() {
449
682
  local force=0
450
683
  local no_path=0
451
684
  local profile_file=""
685
+ local dryrun=0
452
686
 
453
687
  while [[ $# -gt 0 ]]; do
454
688
  case "$1" in
455
689
  --force|-y) force=1; shift ;;
456
690
  --no-path) no_path=1; shift ;;
457
691
  --profile-file) profile_file="$2"; shift 2 ;;
692
+ --dry-run) dryrun=1; shift ;;
458
693
  -h|--help) _aid_usage remove; exit 0 ;;
459
694
  *) _aid_die "unknown flag for 'remove self': $1" 2 ;;
460
695
  esac
461
696
  done
697
+ _SELF_DRYRUN="$dryrun"; export _SELF_DRYRUN
462
698
 
463
699
  # Apply AID_FORCE env-var fallback.
464
700
  if [[ "$force" -eq 0 && ( "${AID_FORCE:-0}" == "1" || "${AID_FORCE:-0}" == "true" ) ]]; then
465
701
  force=1
466
702
  fi
467
703
 
704
+ local channel="${AID_INSTALL_CHANNEL:-}"
468
705
  local aid_home="${AID_HOME:-${HOME}/.aid}"
469
706
 
470
- if [[ "$force" -eq 0 ]]; then
707
+ # Channel-aware description of what will be removed (NFR transparency).
708
+ local what
709
+ case "$channel" in
710
+ npm) what="the npm global package 'aid-installer' (npm uninstall -g)" ;;
711
+ pypi) what="the pipx app 'aid-installer' (pipx uninstall)" ;;
712
+ *) what="${aid_home} and its PATH wiring" ;;
713
+ esac
714
+
715
+ if [[ "$force" -eq 0 && "$dryrun" -ne 1 ]]; then
471
716
  # Skip prompt when non-interactive (piped or no tty).
472
717
  if [[ ! -t 0 ]]; then
473
718
  force=1
474
719
  else
475
- printf 'Remove the aid CLI from %s? [y/N] ' "$aid_home"
720
+ printf 'Remove the aid CLI -- %s? [y/N] ' "$what"
476
721
  local answer
477
722
  if [[ -e /dev/tty ]]; then
478
723
  read -r answer < /dev/tty
@@ -487,24 +732,46 @@ _cmd_remove_self() {
487
732
  fi
488
733
 
489
734
  local partial=0
735
+ case "$channel" in
736
+ npm)
737
+ command -v npm >/dev/null 2>&1 || { echo "ERROR: aid: npm not found; cannot remove the npm-channel CLI" >&2; exit 3; }
738
+ local gdir; gdir="$(npm root -g 2>/dev/null)"
739
+ _aid_priv_run "$gdir" npm uninstall -g aid-installer || partial=1
740
+ ;;
741
+ pypi)
742
+ command -v pipx >/dev/null 2>&1 || { echo "ERROR: aid: pipx not found; cannot remove the pypi-channel CLI" >&2; exit 3; }
743
+ if [[ "$dryrun" -eq 1 ]]; then
744
+ printf '+ pipx uninstall aid-installer\n'
745
+ else
746
+ pipx uninstall aid-installer || partial=1
747
+ fi
748
+ ;;
749
+ *)
750
+ # curl / default channel -- the AID_HOME tree + shell-profile PATH wiring.
751
+ if [[ "$dryrun" -eq 1 ]]; then
752
+ [[ "$no_path" -eq 0 ]] && printf '+ (unwire %s/bin from your shell profile)\n' "$aid_home"
753
+ printf '+ rm -rf %s\n' "$aid_home"
754
+ else
755
+ if [[ "$no_path" -eq 0 ]]; then
756
+ if [[ -n "$profile_file" ]]; then
757
+ _unwire_path_unix --profile-file "$profile_file" || partial=1
758
+ else
759
+ _unwire_path_unix || partial=1
760
+ fi
761
+ fi
762
+ if [[ -d "$aid_home" ]]; then
763
+ rm -rf "$aid_home" || {
764
+ echo "ERROR: aid: failed to remove ${aid_home}" >&2
765
+ partial=1
766
+ }
767
+ fi
768
+ fi
769
+ ;;
770
+ esac
490
771
 
491
- # Remove PATH wiring first (while we still have the profile-detect logic).
492
- if [[ "$no_path" -eq 0 ]]; then
493
- if [[ -n "$profile_file" ]]; then
494
- _unwire_path_unix --profile-file "$profile_file" || partial=1
495
- else
496
- _unwire_path_unix || partial=1
497
- fi
498
- fi
499
-
500
- # Remove $AID_HOME.
501
- if [[ -d "$aid_home" ]]; then
502
- rm -rf "$aid_home" || {
503
- echo "ERROR: aid: failed to remove ${aid_home}" >&2
504
- partial=1
505
- }
772
+ if [[ "$dryrun" -eq 1 ]]; then
773
+ exit 0
506
774
  fi
507
-
508
775
  if [[ "$partial" -eq 1 ]]; then
509
776
  echo "aid CLI partially removed. Check the messages above for what remained."
510
777
  exit 1
@@ -515,151 +782,2071 @@ _cmd_remove_self() {
515
782
  }
516
783
 
517
784
  # ---------------------------------------------------------------------------
518
- # Parse subcommand and dispatch.
785
+ # Remote exposure helpers (feature-005 / LC-EXP-B).
786
+ # SEC-1: These helpers invoke ONLY 'tailscale serve' (tailnet-only). The public
787
+ # exposure verb is never used -- a bare grep for it returns nothing
788
+ # anywhere in this file (structural never-public, C1).
789
+ # SEC-6: --remote exposes the CLI home (all registered repos, OQ5/DR-4): a
790
+ # granted tailnet identity sees the full registered-repo list + each
791
+ # repo's home.html/kb.html/api/model. This is the accepted OQ5 trade-off
792
+ # -- a grantee is already a trusted operator of this host. The helpers
793
+ # below, the bind, and the teardown are UNCHANGED; only what the port
794
+ # serves changed (DR-2/task-047). Never-public (C1) and host/user-ACL
795
+ # scoping (C3) hold exactly as before.
519
796
  # ---------------------------------------------------------------------------
520
797
 
521
- # Shared flag buckets (populated during subcommand-specific arg parsing).
522
- _AID_TOOL_ARG=""
523
- _AID_VERSION_ARG=""
524
- _AID_FROM_BUNDLE=""
525
- _AID_FORCE=0
526
- _AID_TARGET=""
527
- _AID_VERBOSE="${AID_VERBOSE:-0}"
528
- _AID_NO_PATH=0
798
+ # _aid_remote_expose <port>
799
+ # Bring up tailscale serve (tailnet-only) for a loopback port.
800
+ # stdout (exit 0): two lines: handle (tailscale-serve:<port>) + https URL.
801
+ # stderr: human messages, errors, FR18 ACL-grant guidance.
802
+ # exit: 0=ok 10=mechanism absent 11=non-loopback target 12=serve failed
803
+ _aid_remote_expose() {
804
+ local port="$1"
805
+
806
+ # Step 1: Re-assert the loopback target (belt-and-suspenders, SEC-1).
807
+ # This function only accepts a bare port number (caller always passes 127.0.0.1:<port>
808
+ # as the server's bind, but exposes only via a port token). If someone passes a
809
+ # non-numeric or IP-prefixed token the contract is violated.
810
+ if [[ -z "$port" ]] || ! [[ "$port" =~ ^[0-9]+$ ]]; then
811
+ echo "ERROR: aid: dashboard: expose target must be 127.0.0.1 (got: ${port})" >&2
812
+ return 11
813
+ fi
529
814
 
530
- # ---------------------------------------------------------------------------
531
- # Dashboard (bare 'aid' - no arguments).
532
- # ---------------------------------------------------------------------------
533
- _cmd_dashboard() {
534
- # Block 1 + 2: Header + description.
535
- local cli_version="unknown"
536
- local ver_file="${AID_HOME}/VERSION"
537
- if [[ -f "$ver_file" ]]; then
538
- cli_version="$(tr -d '[:space:]' < "$ver_file")"
815
+ # Step 2a: availability -- tailscale on PATH?
816
+ if ! command -v tailscale >/dev/null 2>&1; then
817
+ echo "ERROR: aid: dashboard: --remote requested but tailscale is not on PATH; --remote is unavailable" >&2
818
+ return 10
539
819
  fi
540
- printf 'AID v%s - Agentic Iterative Development\n' "$cli_version"
541
- printf 'Install, update, and manage AID across your repositories.\n'
542
820
 
543
- # Block 3: Installed tools for cwd.
544
- printf '\n'
545
- aid_status_body "."
821
+ # Step 2b: availability -- node logged in and Running?
822
+ local ts_status_out
823
+ ts_status_out="$(tailscale status 2>&1)" || true
824
+ # 'tailscale status' exits nonzero and prints "not running" or similar when the
825
+ # daemon is stopped, or when not logged in.
826
+ if echo "$ts_status_out" | grep -qiE '(not running|logged out|Stopped|NeedsLogin|NoState|not logged in)'; then
827
+ echo "ERROR: aid: dashboard: --remote requested but tailscale is not running or not logged in (tailscale status: ${ts_status_out}); --remote is unavailable" >&2
828
+ return 10
829
+ fi
830
+ # Also check for error/failure exit where output may be empty.
831
+ if [[ -z "$ts_status_out" ]]; then
832
+ echo "ERROR: aid: dashboard: --remote requested but tailscale status returned no output; --remote is unavailable" >&2
833
+ return 10
834
+ fi
546
835
 
547
- # Block 4: Usage/help.
548
- printf '\n'
549
- _aid_usage
836
+ # Step 3: Bring up Serve (tailnet-only; the public exposure verb is never invoked -- SEC-1).
837
+ local serve_err
838
+ serve_err="$(tailscale serve --bg "$port" 2>&1)"
839
+ local serve_rc=$?
840
+ if [[ "$serve_rc" -ne 0 ]]; then
841
+ echo "ERROR: aid: dashboard: tailscale serve failed (rc=${serve_rc}): ${serve_err}" >&2
842
+ # Revert: take down the 443 frontend mapping (if it was partially set).
843
+ tailscale serve --bg --https=443 off >/dev/null 2>&1 || true
844
+ return 12
845
+ fi
550
846
 
551
- # Block 5: Update check notice (final line, non-blocking).
552
- _aid_check_update
847
+ # Step 4: Resolve the private URL from tailscale's Self.DNSName (the MagicDNS name).
848
+ # NOTE: 'tailscale status --json' is PRETTY-PRINTED ("DNSName": "host.tailnet.ts.net."),
849
+ # so the match MUST tolerate whitespace after the colon. '--peers=false' isolates Self so
850
+ # a peer's DNSName is never picked up by mistake. We must NEVER fall back to the machine's
851
+ # own hostname/FQDN for this URL: that resolves to the local/corporate DNS domain (e.g.
852
+ # host.example.com), not the tailnet -- producing a URL that does not work and leaking the
853
+ # wrong domain into the ACL guidance below.
854
+ local ts_json node_fqdn private_url
855
+ ts_json="$(tailscale status --json --peers=false 2>/dev/null)" || ts_json=""
856
+ if [[ -z "$ts_json" ]]; then
857
+ ts_json="$(tailscale status --json 2>/dev/null)" || ts_json=""
858
+ fi
859
+ node_fqdn=""
860
+ if [[ -n "$ts_json" ]]; then
861
+ # Scope the parse to the Self object so a peer DNSName can never be selected
862
+ # regardless of JSON ordering. sed -n prints from "Self": to the first "Peer":
863
+ # line (exclusive); when --peers=false was used there is no "Peer" line so the
864
+ # range runs to EOF -- which is Self-only, the correct and safe result.
865
+ local self_block
866
+ self_block="$(printf '%s' "$ts_json" \
867
+ | sed -n '/"Self"[[:space:]]*:/,/"Peer"[[:space:]]*:/p')"
868
+ [[ -z "$self_block" ]] && self_block="$ts_json"
869
+ node_fqdn="$(printf '%s' "$self_block" \
870
+ | grep -oE '"DNSName"[[:space:]]*:[[:space:]]*"[^"]*"' \
871
+ | head -1 \
872
+ | sed -E 's/.*"DNSName"[[:space:]]*:[[:space:]]*"//; s/"$//; s/\.$//')"
873
+ fi
874
+ if [[ -z "$node_fqdn" ]]; then
875
+ # Defensive fallback: a *.ts.net host reported by 'tailscale serve status --json'.
876
+ local serve_json
877
+ serve_json="$(tailscale serve status --json 2>/dev/null)" || serve_json=""
878
+ if [[ -n "$serve_json" ]]; then
879
+ node_fqdn="$(printf '%s' "$serve_json" \
880
+ | grep -oE '[a-z0-9-]+(\.[a-z0-9-]+)*\.ts\.net' | head -1)"
881
+ fi
882
+ fi
883
+ if [[ -n "$node_fqdn" ]]; then
884
+ private_url="https://${node_fqdn}/"
885
+ else
886
+ # Could not resolve the tailnet MagicDNS name. Do NOT fabricate a public-domain URL.
887
+ private_url="(unresolved: run 'tailscale status' to find this host's .ts.net name)"
888
+ fi
889
+
890
+ # Resolve display values for the ACL-grant guidance. The grant *src* (who may reach the
891
+ # host) is an identity only you can choose -- your login, a group:, or a tag: -- and a DNS
892
+ # domain is NOT a valid grant selector, so AID shows a placeholder rather than guessing it.
893
+ # The *dst* is correctly THIS host's tailnet short-name.
894
+ local node_short
895
+ node_short="$(printf '%s' "$node_fqdn" | cut -d. -f1)"
896
+ if [[ -z "$node_short" && -n "$ts_json" ]]; then
897
+ # Same Self-scoping applied to HostName extraction to prevent a peer hostname
898
+ # from being selected when the Self-first ordering assumption does not hold.
899
+ local self_block_hn
900
+ self_block_hn="$(printf '%s' "$ts_json" \
901
+ | sed -n '/"Self"[[:space:]]*:/,/"Peer"[[:space:]]*:/p')"
902
+ [[ -z "$self_block_hn" ]] && self_block_hn="$ts_json"
903
+ node_short="$(printf '%s' "$self_block_hn" \
904
+ | grep -oE '"HostName"[[:space:]]*:[[:space:]]*"[^"]*"' \
905
+ | head -1 \
906
+ | sed -E 's/.*"HostName"[[:space:]]*:[[:space:]]*"//; s/"$//' \
907
+ | tr 'A-Z' 'a-z')"
908
+ fi
909
+
910
+ # Step 5: Print FR18 ACL-grant guidance to STDERR (informational only).
911
+ local src_placeholder dst_placeholder
912
+ src_placeholder="<you@example.com>"
913
+ dst_placeholder="${node_short:-<this-host>}"
914
+
915
+ cat >&2 <<GUIDANCE_EOF
916
+
917
+ Remote exposure is UP (tailnet-private). Every device on your tailnet can now reach this host.
918
+ To restrict access to only you, add a deny-by-default ACL grant in the tailnet policy file:
919
+ https://login.tailscale.com/admin/acls/file
920
+ {"grants":[{"src":["${src_placeholder}"],"dst":["${dst_placeholder}"],"ip":["tcp:443"]}]}
921
+ Note: granted identities see all registered project paths/names. See 'aid dashboard --help'.
922
+
923
+ GUIDANCE_EOF
924
+
925
+ # Step 6: Emit handle + URL on stdout, exit 0.
926
+ printf 'tailscale-serve:%s\n' "$port"
927
+ printf '%s\n' "$private_url"
928
+ return 0
553
929
  }
554
930
 
555
- # Early help check.
556
- if [[ $# -eq 0 ]]; then
557
- # Bare 'aid' -> dashboard landing screen.
558
- _cmd_dashboard
559
- exit 0
560
- fi
931
+ # _aid_remote_teardown <handle>
932
+ # Revert the tailscale serve mapping created by _aid_remote_expose.
933
+ # exit: 0=ok/idempotent 13=revert warned
934
+ _aid_remote_teardown() {
935
+ local handle="${1:-}"
561
936
 
562
- case "$1" in
563
- -h|--help)
564
- _aid_usage
565
- exit 0
566
- ;;
567
- esac
937
+ # Step 1: Parse the handle; malformed/empty -> idempotent exit 0.
938
+ if [[ -z "$handle" ]]; then
939
+ return 0
940
+ fi
941
+ if ! [[ "$handle" =~ ^tailscale-serve:([0-9]+)$ ]]; then
942
+ # Malformed handle -- nothing to tear down.
943
+ return 0
944
+ fi
945
+ # We don't use the port for teardown (we target the HTTPS:443 frontend, not the backend port).
568
946
 
569
- SUBCMD="$1"
570
- shift
947
+ # Step 2: If tailscale is gone now -> WARN, exit 0.
948
+ if ! command -v tailscale >/dev/null 2>&1; then
949
+ echo "WARN: aid: dashboard: tailscale not found; cannot revert serve mapping (handle: ${handle})" >&2
950
+ return 0
951
+ fi
571
952
 
572
- # ---- version ----------------------------------------------------------------
573
- if [[ "$SUBCMD" == "version" ]]; then
574
- local_version_file="${AID_HOME}/VERSION"
575
- if [[ -f "$local_version_file" ]]; then
576
- cat "$local_version_file"
577
- else
578
- echo "unknown (VERSION file not found at ${local_version_file})"
953
+ # Step 3: Revert the HTTPS:443 frontend mapping (not a backend port off).
954
+ local off_err
955
+ off_err="$(tailscale serve --bg --https=443 off 2>&1)"
956
+ local off_rc=$?
957
+ if [[ "$off_rc" -ne 0 ]]; then
958
+ # Fallback: check if serve status shows no other mappings; if so, reset.
959
+ local srv_status
960
+ srv_status="$(tailscale serve status 2>/dev/null)" || srv_status=""
961
+ # Count serve entries. If there's nothing else to protect, do a reset.
962
+ local mapping_count
963
+ mapping_count="$(echo "$srv_status" | grep -cE '(https?://|tcp://)' 2>/dev/null || echo "0")"
964
+ if [[ "$mapping_count" -le 1 ]]; then
965
+ tailscale serve reset >/dev/null 2>&1 || true
966
+ # After reset, exit 0 -- best effort.
967
+ return 0
968
+ fi
969
+ echo "WARN: aid: dashboard: tailscale serve --https=443 off failed (rc=${off_rc}): ${off_err}" >&2
970
+ return 13
579
971
  fi
580
- exit 0
581
- fi
582
972
 
583
- # ---- help -------------------------------------------------------------------
584
- if [[ "$SUBCMD" == "help" || "$SUBCMD" == "-h" || "$SUBCMD" == "--help" ]]; then
585
- _aid_usage
586
- exit 0
587
- fi
973
+ # Step 4: exit 0 on clean revert.
974
+ return 0
975
+ }
976
+
977
+ # ---------------------------------------------------------------------------
978
+ # Dashboard control (aid dashboard start|stop).
979
+ # ---------------------------------------------------------------------------
980
+ _cmd_dashboard_ctl() {
981
+ local verb="${1:-}"
982
+ [[ $# -gt 0 ]] && shift
983
+
984
+ # Top-level help.
985
+ if [[ "$verb" == "-h" || "$verb" == "--help" ]]; then
986
+ _aid_usage dashboard
987
+ exit 0
988
+ fi
989
+
990
+ if [[ "$verb" != "start" && "$verb" != "stop" ]]; then
991
+ if [[ -z "$verb" ]]; then
992
+ echo "ERROR: aid: dashboard requires a verb: start or stop (e.g. aid dashboard start python)" >&2
993
+ exit 2
994
+ fi
995
+ echo "ERROR: aid: dashboard: unknown verb '${verb}' (expected: start or stop)" >&2
996
+ exit 2
997
+ fi
998
+
999
+ # --- shared arg parsing ---
1000
+ local _dc_verbose=0
1001
+ local _dc_port=8787
1002
+ local _dc_remote=0
1003
+ local _dc_runtime=""
1004
+
1005
+ if [[ "$verb" == "start" ]]; then
1006
+ # First positional after verb is runtime.
1007
+ if [[ $# -gt 0 && "$1" != -* ]]; then
1008
+ _dc_runtime="$1"
1009
+ shift
1010
+ fi
1011
+ fi
588
1012
 
589
- # ---- status -----------------------------------------------------------------
590
- if [[ "$SUBCMD" == "status" ]]; then
591
- # Parse flags for status.
592
1013
  while [[ $# -gt 0 ]]; do
593
1014
  case "$1" in
594
- --target)
595
- [[ $# -lt 2 ]] && _aid_die "--target requires a value" 2
596
- _AID_TARGET="$2"; shift 2 ;;
597
- --verbose) _AID_VERBOSE=1; shift ;;
598
- -h|--help) _aid_usage status; exit 0 ;;
599
- -*) _aid_die "unknown flag for status: $1" 2 ;;
600
- *) _aid_die "unexpected argument for status: $1" 2 ;;
1015
+ -h|--help)
1016
+ _aid_usage dashboard
1017
+ exit 0
1018
+ ;;
1019
+ --verbose) _dc_verbose=1; shift ;;
1020
+ --remote)
1021
+ if [[ "$verb" == "stop" ]]; then
1022
+ echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
1023
+ fi
1024
+ _dc_remote=1; shift ;;
1025
+ --port)
1026
+ if [[ "$verb" == "stop" ]]; then
1027
+ echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
1028
+ fi
1029
+ [[ $# -lt 2 ]] && _aid_die "dashboard: --port requires a value" 2
1030
+ _dc_port="$2"; shift 2
1031
+ # Validate port: integer in 1024..65535.
1032
+ if ! [[ "$_dc_port" =~ ^[0-9]+$ ]] || [[ "$_dc_port" -lt 1024 || "$_dc_port" -gt 65535 ]]; then
1033
+ echo "ERROR: aid: dashboard: --port must be an integer in 1024..65535" >&2
1034
+ exit 2
1035
+ fi
1036
+ ;;
1037
+ -*)
1038
+ echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2 ;;
1039
+ *)
1040
+ if [[ "$verb" == "stop" ]]; then
1041
+ echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2
1042
+ fi
1043
+ # Stray positional on start after runtime was consumed.
1044
+ echo "ERROR: aid: dashboard: unknown flag: $1" >&2; exit 2 ;;
601
1045
  esac
602
1046
  done
603
- # Apply env-var fallbacks.
604
- [[ -z "$_AID_TARGET" && -n "${AID_TARGET:-}" ]] && _AID_TARGET="$AID_TARGET"
605
- _AID_TARGET="${_AID_TARGET:-.}"
606
- export AID_VERBOSE="$_AID_VERBOSE"
607
- aid_status "$_AID_TARGET"
608
- _status_rc=$?
609
- # Update check notice appended after status output (non-blocking).
610
- _aid_check_update
611
- exit $_status_rc
612
- fi
613
1047
 
614
- # ---- update -----------------------------------------------------------------
615
- if [[ "$SUBCMD" == "update" ]]; then
616
- # Check for 'update self' as first positional arg.
617
- if [[ $# -gt 0 && "$1" == "self" ]]; then
618
- shift
619
- # Consume any flags after 'self'.
620
- while [[ $# -gt 0 ]]; do
621
- case "$1" in
622
- --force|-y) shift ;; # no-op for update self
623
- -h|--help) _aid_usage update; exit 0 ;;
624
- *) _aid_die "unknown flag for 'update self': $1" 2 ;;
625
- esac
626
- done
627
- _cmd_update_self
628
- exit $?
1048
+ if [[ "$verb" == "start" ]]; then
1049
+ _dc_start "$_dc_runtime" "$_dc_port" "$_dc_remote" "$_dc_verbose"
1050
+ else
1051
+ _dc_stop "$_dc_verbose"
629
1052
  fi
630
- # Fall through to the shared add/update handler below.
631
- fi
1053
+ }
632
1054
 
633
- # ---- remove -----------------------------------------------------------------
634
- if [[ "$SUBCMD" == "remove" ]]; then
635
- # Check for 'remove self' as first positional arg.
636
- if [[ $# -gt 0 && "$1" == "self" ]]; then
637
- shift
638
- _cmd_remove_self "$@"
639
- # _cmd_remove_self always exits.
1055
+ _dc_start() {
1056
+ local runtime="$1"
1057
+ local port="$2"
1058
+ local remote="$3"
1059
+ local verbose="$4"
1060
+
1061
+ # Step 1: validate runtime.
1062
+ if [[ -z "$runtime" ]]; then
1063
+ echo "ERROR: aid: dashboard start requires a runtime: node or python (e.g. aid dashboard start python)" >&2
1064
+ exit 2
1065
+ fi
1066
+ if [[ "$runtime" != "node" && "$runtime" != "python" ]]; then
1067
+ echo "ERROR: aid: dashboard: unknown runtime '${runtime}' (expected: node or python)" >&2
1068
+ exit 2
640
1069
  fi
641
1070
 
642
- # Check for 'remove' with no tool args (remove ALL from project).
643
- # We do this check after parsing flags below, so continue to parse first.
644
- fi
1071
+ # pid/log live in the per-user state home (.temp), always writable.
1072
+ # FR10 precedent: always per-user $HOME/.aid, never AID_STATE_HOME on global installs.
1073
+ local pid_file="${HOME}/.aid/.temp/dashboard.pid"
1074
+ local log_file="${HOME}/.aid/.temp/dashboard.log"
1075
+
1076
+ # Step 4: already-running guard (stale-record reclaim included).
1077
+ if [[ -f "$pid_file" ]]; then
1078
+ local existing_pid existing_port existing_runtime
1079
+ existing_pid="$(grep '"pid"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\1/')"
1080
+ existing_port="$(grep '"port"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\1/')"
1081
+ existing_runtime="$(grep '"runtime"' "$pid_file" | sed 's/.*"runtime": *"\([^"]*\)".*/\1/')"
1082
+ if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
1083
+ echo "aid: dashboard already running (runtime ${existing_runtime}, http://127.0.0.1:${existing_port}); run 'aid dashboard stop' first."
1084
+ exit 8
1085
+ else
1086
+ # Stale record: reclaim silently (or verbosely).
1087
+ [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: reclaiming stale record (pid ${existing_pid} is dead)" >&2
1088
+ rm -f "$pid_file" "$log_file"
1089
+ fi
1090
+ fi
645
1091
 
646
- # ---- add / remove / update --------------------------------------------------
647
- # These subcommands all share flag parsing; we then call the engine functions
648
- # (install_tool / uninstall_tool) directly through a per-tool loop, exactly as
649
- # install.sh does. We build a temporary staging area for install/update, and
650
- # reuse the same prepare_tool_staging + install_tool / uninstall_tool pattern.
1092
+ # Step 5: check runtime on PATH.
1093
+ local interp
1094
+ if [[ "$runtime" == "python" ]]; then
1095
+ interp="python3"
1096
+ if ! command -v python3 >/dev/null 2>&1; then
1097
+ echo "ERROR: aid: dashboard: python3 not found on PATH (install it, or try: aid dashboard start node)" >&2
1098
+ exit 9
1099
+ fi
1100
+ else
1101
+ interp="node"
1102
+ if ! command -v node >/dev/null 2>&1; then
1103
+ echo "ERROR: aid: dashboard: node not found on PATH (install it, or try: aid dashboard start python)" >&2
1104
+ exit 9
1105
+ fi
1106
+ fi
651
1107
 
652
- # First, validate the subcommand.
653
- case "$SUBCMD" in
654
- add|remove|update) ;;
655
- *)
656
- echo "ERROR: aid: unknown command: ${SUBCMD} (see 'aid -h')" >&2
657
- exit 2
658
- ;;
659
- esac
1108
+ # Step 6: locate the server entry point.
1109
+ # <assets> = $AID_CODE_HOME/dashboard (the co-vendored server+reader unit in the install tree).
1110
+ local assets_dir="${AID_CODE_HOME}/dashboard"
1111
+ local entry_point
1112
+ if [[ "$runtime" == "python" ]]; then
1113
+ entry_point="${assets_dir}/server/server.py"
1114
+ else
1115
+ entry_point="${assets_dir}/server/server.mjs"
1116
+ fi
1117
+ if [[ ! -f "$entry_point" ]]; then
1118
+ echo "ERROR: aid: dashboard: the dashboard server is missing from the install tree (${runtime} entry-point not found at ${entry_point}); run 'aid update' or reinstall aid" >&2
1119
+ exit 7
1120
+ fi
660
1121
 
1122
+ # Ensure log dir exists (per-user state home, always writable).
1123
+ mkdir -p "${HOME}/.aid/.temp"
1124
+
1125
+ # Step 7: spawn the server child in a new session (clean process-group kill on stop).
1126
+ # SEC-1: literal 127.0.0.1 -- never read from input/config/env.
1127
+ # The multi-repo server (feature-010) serves every registered repo from the
1128
+ # registry under AID_STATE_HOME; export AID_HOME=AID_STATE_HOME so the server
1129
+ # resolves the registry via its legacy AID_HOME env var (delivery-008 seam).
1130
+ AID_HOME="$AID_STATE_HOME" setsid "$interp" "$entry_point" --host 127.0.0.1 --port "$port" \
1131
+ >"$log_file" 2>&1 &
1132
+ local child_pid=$!
1133
+
1134
+ [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: spawned ${runtime} server (pid ${child_pid}, port ${port})" >&2
1135
+
1136
+ # Step 8: bounded readiness wait (~5s, poll TCP socket).
1137
+ local ready=0
1138
+ local attempts=0
1139
+ local max_attempts=50 # 50 x 0.1s = 5s
1140
+ while [[ "$attempts" -lt "$max_attempts" ]]; do
1141
+ # Check child is still alive.
1142
+ if ! kill -0 "$child_pid" 2>/dev/null; then
1143
+ # Child exited early.
1144
+ echo "ERROR: aid: dashboard: server failed to start; last log lines:" >&2
1145
+ tail -n 10 "$log_file" >&2 2>/dev/null || true
1146
+ rm -f "$log_file"
1147
+ exit 3
1148
+ fi
1149
+ # Try TCP connect to 127.0.0.1:<port>.
1150
+ if (: < /dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
1151
+ ready=1
1152
+ break
1153
+ fi
1154
+ sleep 0.1
1155
+ attempts=$((attempts + 1))
1156
+ done
661
1157
 
662
- # Collect positional tool args (comma-separated or space-separated before flags).
1158
+ # Check if child is still alive even if not ready (timeout case).
1159
+ if [[ "$ready" -eq 0 ]]; then
1160
+ if ! kill -0 "$child_pid" 2>/dev/null; then
1161
+ echo "ERROR: aid: dashboard: server failed to start; last log lines:" >&2
1162
+ tail -n 10 "$log_file" >&2 2>/dev/null || true
1163
+ rm -f "$log_file"
1164
+ exit 3
1165
+ fi
1166
+ # Timeout but pid alive: warn and continue (child may be slow on a large repo).
1167
+ echo "WARN: aid: dashboard: server started but not yet responding on :${port}; check ${log_file}" >&2
1168
+ fi
1169
+
1170
+ # Step 9: write dashboard.pid JSON record (DM-1) with remote=false initially.
1171
+ local started_at
1172
+ started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "unknown")"
1173
+ cat > "$pid_file" <<EOF
1174
+ {
1175
+ "schema": 1,
1176
+ "pid": ${child_pid},
1177
+ "runtime": "${runtime}",
1178
+ "port": ${port},
1179
+ "bind": "127.0.0.1",
1180
+ "remote": false,
1181
+ "remote_handle": null,
1182
+ "started_at": "${started_at}",
1183
+ "logfile": "${log_file}"
1184
+ }
1185
+ EOF
1186
+
1187
+ # Step 10: --remote: invoke _aid_remote_expose; update record on success.
1188
+ if [[ "$remote" -eq 1 ]]; then
1189
+ # Capture stdout into a temp file; let stderr (guidance + errors) flow to the user.
1190
+ local _expose_tmp expose_rc expose_handle expose_url
1191
+ _expose_tmp="$(mktemp)"
1192
+ _aid_remote_expose "$port" >"$_expose_tmp"
1193
+ expose_rc=$?
1194
+ if [[ "$expose_rc" -ne 0 ]]; then
1195
+ rm -f "$_expose_tmp"
1196
+ # All expose failures (10/11/12) map to user-facing exit 10.
1197
+ # dashboard stays local-only (server remains running).
1198
+ echo "ERROR: aid: dashboard: --remote requested but the secure remote-exposure mechanism is not available on this host; the dashboard is NOT exposed. Local server still running at http://127.0.0.1:${port}." >&2
1199
+ exit 10
1200
+ fi
1201
+ expose_handle="$(head -1 "$_expose_tmp")"
1202
+ expose_url="$(sed -n '2p' "$_expose_tmp")"
1203
+ rm -f "$_expose_tmp"
1204
+ # Update the record with remote=true and the handle.
1205
+ cat > "$pid_file" <<EOF
1206
+ {
1207
+ "schema": 1,
1208
+ "pid": ${child_pid},
1209
+ "runtime": "${runtime}",
1210
+ "port": ${port},
1211
+ "bind": "127.0.0.1",
1212
+ "remote": true,
1213
+ "remote_handle": "${expose_handle}",
1214
+ "started_at": "${started_at}",
1215
+ "logfile": "${log_file}"
1216
+ }
1217
+ EOF
1218
+ # Step 11 (remote success): print local URL + remote URL.
1219
+ echo "Dashboard (${runtime}) running at http://127.0.0.1:${port} -- stop with: aid dashboard stop"
1220
+ if [[ "${expose_url}" == https://* ]]; then
1221
+ echo "Remote (private): ${expose_url}"
1222
+ else
1223
+ echo "Remote exposure is UP (tailnet-private), but the .ts.net URL could not be auto-detected -- run 'tailscale status' on this host to find it."
1224
+ fi
1225
+ exit 0
1226
+ fi
1227
+
1228
+ # Step 11: print success (local-only).
1229
+ echo "Dashboard (${runtime}) running at http://127.0.0.1:${port} -- stop with: aid dashboard stop"
1230
+ exit 0
1231
+ }
1232
+
1233
+ _dc_stop() {
1234
+ local verbose="$1"
1235
+
1236
+ local pid_file="${HOME}/.aid/.temp/dashboard.pid"
1237
+
1238
+ # Step 3: read record; absent or stale -> idempotent exit 0.
1239
+ if [[ ! -f "$pid_file" ]]; then
1240
+ echo "aid: dashboard: not running (nothing to stop)."
1241
+ exit 0
1242
+ fi
1243
+
1244
+ local existing_pid
1245
+ existing_pid="$(grep '"pid"' "$pid_file" | sed 's/[^0-9]*\([0-9]*\).*/\1/')"
1246
+ local log_file
1247
+ log_file="$(grep '"logfile"' "$pid_file" | sed 's/.*"logfile": *"\([^"]*\)".*/\1/')"
1248
+
1249
+ if [[ -z "$existing_pid" ]] || ! kill -0 "$existing_pid" 2>/dev/null; then
1250
+ [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: record exists but pid ${existing_pid} is dead; cleaning up." >&2
1251
+ rm -f "$pid_file" "$log_file"
1252
+ echo "aid: dashboard: not running (nothing to stop)."
1253
+ exit 0
1254
+ fi
1255
+
1256
+ # Step 4: --remote teardown (if the record says remote=true, call _aid_remote_teardown).
1257
+ local existing_remote existing_handle
1258
+ existing_remote="$(grep '"remote":' "$pid_file" | grep -v '"remote_handle"' | sed 's/.*"remote":[[:space:]]*//' | tr -d '", ')"
1259
+ # Extract handle: matches quoted string value; if unquoted (null), returns empty.
1260
+ existing_handle="$(grep '"remote_handle"' "$pid_file" | sed -n 's/.*"remote_handle":[[:space:]]*"\([^"]*\)".*/\1/p')"
1261
+ if [[ "$existing_remote" == "true" && -n "$existing_handle" ]]; then
1262
+ [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: tearing down remote exposure (handle: ${existing_handle})" >&2
1263
+ _aid_remote_teardown "$existing_handle"
1264
+ local teardown_rc=$?
1265
+ if [[ "$teardown_rc" -eq 13 ]]; then
1266
+ echo "WARN: aid: dashboard: remote teardown reported a warning; continuing server shutdown" >&2
1267
+ fi
1268
+ fi
1269
+
1270
+ # Step 5: terminate the process group cleanly.
1271
+ [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: sending SIGTERM to process group ${existing_pid}" >&2
1272
+ kill -TERM -"$existing_pid" 2>/dev/null || true
1273
+
1274
+ # Wait up to ~5s for exit.
1275
+ local waited=0
1276
+ while kill -0 "$existing_pid" 2>/dev/null && [[ "$waited" -lt 50 ]]; do
1277
+ sleep 0.1
1278
+ waited=$((waited + 1))
1279
+ done
1280
+
1281
+ # Escalate to SIGKILL if still alive.
1282
+ if kill -0 "$existing_pid" 2>/dev/null; then
1283
+ [[ "$verbose" -eq 1 ]] && echo "aid: dashboard: escalating to SIGKILL on process group ${existing_pid}" >&2
1284
+ kill -KILL -"$existing_pid" 2>/dev/null || true
1285
+ fi
1286
+
1287
+ # Step 6: remove record and logfile, print success.
1288
+ rm -f "$pid_file" "$log_file"
1289
+ echo "aid: dashboard stopped."
1290
+ exit 0
1291
+ }
1292
+
1293
+ # ---------------------------------------------------------------------------
1294
+ # Registry helpers (DR-1 / FF-1 / FR29).
1295
+ # Implements DM-1 schema, DD-3 atomic write, DD-REG-FMT line-scan.
1296
+ # ---------------------------------------------------------------------------
1297
+
1298
+ # _registry_read_repos <reg-path>
1299
+ # Print newline-delimited canonical repo paths recorded in registry.yml.
1300
+ # Returns nothing (empty) when the file is absent or has no items.
1301
+ _registry_read_repos() {
1302
+ local reg="$1"
1303
+ [[ -f "$reg" ]] || return 0
1304
+ grep -E '^[[:space:]]*-[[:space:]]+' "$reg" 2>/dev/null \
1305
+ | sed -E 's/^[[:space:]]*-[[:space:]]+//' \
1306
+ | sed -E 's/[[:space:]]+$//'
1307
+ }
1308
+
1309
+ # _registry_read_union
1310
+ # Return the deduped sort -u union of the primary tier ($AID_STATE_HOME/registry.yml,
1311
+ # which honors the AID_HOME override via the startup scope derivation) and, when
1312
+ # $AID_STATE_HOME differs from $HOME/.aid, also the $HOME/.aid/registry.yml
1313
+ # fallback tier (entries that may have been written there when AID_STATE_HOME was
1314
+ # non-writable). Prunes stale entries quietly: a path is emitted only if
1315
+ # [[ -d "$p/.aid" ]]. Never writes or mutates any registry file on read.
1316
+ #
1317
+ # Per-user collapse: when $AID_STATE_HOME == $HOME/.aid the two paths are the
1318
+ # same file -- the union degenerates to a single-tier read (no double-read, no
1319
+ # elevation).
1320
+ _registry_read_union() {
1321
+ local _primary_reg="${AID_STATE_HOME}/registry.yml"
1322
+ local _raw
1323
+ if [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]]; then
1324
+ # Per-user collapse: single-tier; primary == fallback, no double-read.
1325
+ _raw="$(_registry_read_repos "$_primary_reg")"
1326
+ else
1327
+ # Distinct paths: union of primary ($AID_STATE_HOME) and fallback ($HOME/.aid).
1328
+ local _fallback_reg="${HOME}/.aid/registry.yml"
1329
+ _raw="$({ _registry_read_repos "$_primary_reg"; _registry_read_repos "$_fallback_reg"; } \
1330
+ | sed '/^$/d' | sort -u)"
1331
+ fi
1332
+ # Quiet-prune: emit only paths whose .aid/ still exists.
1333
+ while IFS= read -r p; do
1334
+ [[ -n "$p" ]] || continue
1335
+ [[ -d "${p}/.aid" ]] && printf '%s\n' "$p"
1336
+ done <<< "$_raw"
1337
+ }
1338
+
1339
+ # _registry_read_raw_union
1340
+ # Like _registry_read_union but WITHOUT the [[ -d "$p/.aid" ]] quiet-prune.
1341
+ # Returns EVERY registered path (deduped union of $AID_STATE_HOME/registry.yml
1342
+ # and the $HOME/.aid/registry.yml fallback, with per-user collapse), including
1343
+ # paths whose .aid/ is absent or the directory does not exist.
1344
+ # Used by 'aid projects list' to render no-aid/missing/untracked states.
1345
+ # Never writes or mutates any registry file on read.
1346
+ _registry_read_raw_union() {
1347
+ local _primary_reg="${AID_STATE_HOME}/registry.yml"
1348
+ local _raw
1349
+ if [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]]; then
1350
+ # Per-user collapse: single-tier; primary == fallback, no double-read.
1351
+ _raw="$(_registry_read_repos "$_primary_reg")"
1352
+ else
1353
+ # Distinct paths: union of primary ($AID_STATE_HOME) and fallback ($HOME/.aid).
1354
+ local _fallback_reg="${HOME}/.aid/registry.yml"
1355
+ _raw="$({ _registry_read_repos "$_primary_reg"; _registry_read_repos "$_fallback_reg"; } \
1356
+ | sed '/^$/d' | sort -u)"
1357
+ fi
1358
+ # Emit every non-empty path (no prune -- no-aid/missing paths are included).
1359
+ while IFS= read -r p; do
1360
+ [[ -n "$p" ]] || continue
1361
+ printf '%s\n' "$p"
1362
+ done <<< "$_raw"
1363
+ }
1364
+
1365
+ # _aid_resolve_tier <canon-path>
1366
+ # Deterministic, non-interactive tier selection for 'aid projects add' (FR6/AC6).
1367
+ # Returns "user" or "shared" on stdout.
1368
+ #
1369
+ # Auto rule:
1370
+ # - Returns "user" if $_AID_SCOPE != "global" (per-user install), OR if the
1371
+ # path is under $HOME (any install type).
1372
+ # - Otherwise (global install AND path outside $HOME): returns "shared".
1373
+ #
1374
+ # Override convention (set before calling; cleared by caller):
1375
+ # _AID_TIER_OVERRIDE="" no override, use auto rule (default)
1376
+ # _AID_TIER_OVERRIDE="--local" force "user" regardless of install type/path
1377
+ # _AID_TIER_OVERRIDE="--shared" force "shared"; but on a per-user install
1378
+ # ($AID_STATE_HOME == $HOME/.aid) there is no
1379
+ # separate shared tier -- returns "user" and
1380
+ # prints a one-line notice to stderr.
1381
+ #
1382
+ # Never prompts; never blocks; always returns 0.
1383
+ _aid_resolve_tier() {
1384
+ local _canon_path="$1"
1385
+
1386
+ # Detect per-user install (no separate shared tier).
1387
+ local _per_user=0
1388
+ [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]] && _per_user=1
1389
+
1390
+ # Handle explicit override flags.
1391
+ case "${_AID_TIER_OVERRIDE:-}" in
1392
+ --local)
1393
+ printf 'user\n'
1394
+ return 0
1395
+ ;;
1396
+ --shared)
1397
+ if [[ "$_per_user" -eq 1 ]]; then
1398
+ printf 'no shared tier under a per-user install; using user tier\n' >&2
1399
+ printf 'user\n'
1400
+ else
1401
+ printf 'shared\n'
1402
+ fi
1403
+ return 0
1404
+ ;;
1405
+ esac
1406
+
1407
+ # Auto rule: user if per-user install OR path is under $HOME.
1408
+ local _in_home=0
1409
+ case "$_canon_path" in
1410
+ "${HOME}/"*|"${HOME}") _in_home=1 ;;
1411
+ esac
1412
+
1413
+ if [[ "$_AID_SCOPE" != "global" || "$_in_home" -eq 1 ]]; then
1414
+ printf 'user\n'
1415
+ else
1416
+ printf 'shared\n'
1417
+ fi
1418
+ return 0
1419
+ }
1420
+
1421
+ # _aid_project_state <path>
1422
+ # Print the state of an AID project directory:
1423
+ # "missing" -- the directory does not exist
1424
+ # "no-aid" -- directory exists but has no .aid/ subdirectory
1425
+ # "untracked" -- .aid/ exists but no .aid/.aid-manifest.json is present
1426
+ # "vX.Y.Z" -- tracked; semver version string from .aid/.aid-manifest.json
1427
+ # (key "aid_version"), falling back to .aid/.aid-version
1428
+ # The version is extracted by the same semver regex on both paths -- a malformed
1429
+ # aid_version value in the manifest falls through to untracked (not returned raw).
1430
+ # Never errors; always returns 0.
1431
+ _aid_project_state() {
1432
+ local _path="$1"
1433
+ if [[ ! -d "$_path" ]]; then
1434
+ printf 'missing\n'
1435
+ return 0
1436
+ fi
1437
+ if [[ ! -d "${_path}/.aid" ]]; then
1438
+ printf 'no-aid\n'
1439
+ return 0
1440
+ fi
1441
+ local _manifest="${_path}/.aid/.aid-manifest.json"
1442
+ local _ver_file="${_path}/.aid/.aid-version"
1443
+ if [[ -f "$_manifest" ]]; then
1444
+ local _ver
1445
+ # Extract aid_version value; then validate as semver (same guard as .aid-version branch).
1446
+ _ver="$(grep -o '"aid_version"[[:space:]]*:[[:space:]]*"[^"]*"' "$_manifest" 2>/dev/null \
1447
+ | sed -E 's/.*"([^"]+)"[[:space:]]*$/\1/' \
1448
+ | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' \
1449
+ | head -1)"
1450
+ if [[ -n "$_ver" ]]; then
1451
+ printf '%s\n' "$_ver"
1452
+ return 0
1453
+ fi
1454
+ fi
1455
+ # Fallback: .aid/.aid-version plain-text file.
1456
+ if [[ -f "$_ver_file" ]]; then
1457
+ local _vf_ver
1458
+ _vf_ver="$(grep -Eo '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' "$_ver_file" 2>/dev/null | head -1)"
1459
+ if [[ -n "$_vf_ver" ]]; then
1460
+ printf '%s\n' "$_vf_ver"
1461
+ return 0
1462
+ fi
1463
+ fi
1464
+ printf 'untracked\n'
1465
+ return 0
1466
+ }
1467
+
1468
+ # _aid_project_tools <path>
1469
+ # Print a comma-separated list of tool names installed in an AID project, as
1470
+ # recorded in <path>/.aid/.aid-manifest.json under the "tools" object.
1471
+ # The manifest schema is: "tools": { "<tool-name>": { ... }, ... } (object keyed
1472
+ # by tool name, NO "name" field inside). This is the schema written by every
1473
+ # canonical writer in lib/aid-install-core.sh (see the awk extractor at ~:1019).
1474
+ # Prints an empty string when the manifest is absent or has no tools.
1475
+ # Used by 'aid projects list' to populate the "tools" column (task-004).
1476
+ _aid_project_tools() {
1477
+ local _path="$1"
1478
+ local _manifest="${_path}/.aid/.aid-manifest.json"
1479
+ [[ -f "$_manifest" ]] || { printf ''; return 0; }
1480
+ # Extract tool names as object keys inside the "tools": { ... } block.
1481
+ # Mirrors the canonical awk extractor in lib/aid-install-core.sh:~1019:
1482
+ # /"tools"/{found=1} found && /^ "[a-z]/{gsub(/[^a-zA-Z-]/,"",$1); print $1}
1483
+ local _tools
1484
+ _tools="$(awk '/"tools"/{found=1} found && /^ "[a-z]/{gsub(/[^a-zA-Z0-9_.-]/,"",$1); if ($1!="") print $1}' \
1485
+ "$_manifest" 2>/dev/null \
1486
+ | sort -u \
1487
+ | tr '\n' ',' \
1488
+ | sed -E 's/,+$//')"
1489
+ printf '%s' "$_tools"
1490
+ return 0
1491
+ }
1492
+
1493
+ # registry_register <canon-path> [<tier>]
1494
+ # Set-insert <canon-path> into the target tier registry (idempotent; atomic write).
1495
+ # <tier> is "user" (default) or "shared".
1496
+ # On a real change prints one concise line. On failure prints WARN and returns 0
1497
+ # so the host-tool op is never blocked (NFR10 / DD-3 / CLI-1).
1498
+ #
1499
+ # USER tier (default): primary target is $AID_STATE_HOME/registry.yml (which
1500
+ # honors the AID_HOME override via the startup scope derivation). If AID_STATE_HOME
1501
+ # is not user-writable AND is a different path from $HOME/.aid, degrades to
1502
+ # $HOME/.aid/registry.yml with a WARN (fire-and-continue; never blocks the host
1503
+ # command). Per-user collapse: when $AID_STATE_HOME == $HOME/.aid the two paths
1504
+ # are the same file -- single-tier, no fallback needed.
1505
+ #
1506
+ # SHARED tier: writes to $AID_STATE_HOME/registry.yml using a REAL probe of the
1507
+ # shared dir via _aid_priv_run. If elevation is declined or there is no TTY,
1508
+ # the function degrades: skip + WARN + return 0 (the host command is NOT blocked;
1509
+ # design SS3.3 decision #2 / SPEC AC6).
1510
+ # Per-user install ($AID_STATE_HOME == ~/.aid): shared-tier argument is treated
1511
+ # as user-tier (same file, no elevation needed).
1512
+ registry_register() {
1513
+ local repo="$1" tier="${2:-user}" reg tmp existing
1514
+ local _shared_reg_dir="${AID_STATE_HOME}"
1515
+ local _shared_reg="${AID_STATE_HOME}/registry.yml"
1516
+ # Per-user collapse: AID_STATE_HOME is the same path as $HOME/.aid.
1517
+ # In this case shared-tier is treated as user-tier (same file, no elevation).
1518
+ local _per_user=0
1519
+ [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]] && _per_user=1
1520
+ if [[ "$tier" == "shared" && "$_per_user" -eq 0 ]]; then
1521
+ # SHARED-tier write: real probe of the shared dir; elevation allowed but
1522
+ # degrades on decline / no-TTY rather than blocking.
1523
+ # Ensure the shared dir exists (non-prompting best-effort).
1524
+ _aid_priv_run "" mkdir -p "$_shared_reg_dir" 2>/dev/null || true
1525
+ if [[ ! -w "$_shared_reg_dir" ]]; then
1526
+ # Shared dir not writable; real probe will elevate via sudo if available.
1527
+ # We attempt the write via _aid_priv_run with the REAL probe (not empty).
1528
+ # If elevation is declined or sudo is unavailable, _aid_priv_run returns
1529
+ # non-zero -- we catch that and degrade: skip + warn + return 0.
1530
+ existing="$(_registry_read_repos "$_shared_reg")"
1531
+ if printf '%s\n' "$existing" | grep -qxF "$repo"; then
1532
+ [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} already registered in shared tier (no-op)."
1533
+ return 0
1534
+ fi
1535
+ tmp="$(mktemp "/tmp/.aid-reg-tmp.XXXXXX" 2>/dev/null)" || {
1536
+ echo "WARN: aid: could not update the shared project registry (${_shared_reg}): mktemp failed" >&2
1537
+ return 0
1538
+ }
1539
+ {
1540
+ printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
1541
+ printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
1542
+ printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
1543
+ printf '%s\n' "schema: 1"
1544
+ printf '%s\n' "projects:"
1545
+ { printf '%s\n' "$existing"; printf '%s\n' "$repo"; } \
1546
+ | sed '/^$/d' | sort -u \
1547
+ | while IFS= read -r p; do printf ' - %s\n' "$p"; done
1548
+ } > "$tmp" || {
1549
+ rm -f "$tmp"
1550
+ echo "WARN: aid: could not update the shared project registry (${_shared_reg}): write failed" >&2
1551
+ return 0
1552
+ }
1553
+ # Real probe: elevates only when the shared dir is not user-writable.
1554
+ # If elevation is declined / no-TTY / sudo unavailable: skip + warn.
1555
+ _aid_priv_run "$_shared_reg_dir" mv -f "$tmp" "$_shared_reg" || {
1556
+ rm -f "$tmp" 2>/dev/null
1557
+ echo "WARN: aid: shared registry write declined or unavailable; project not registered in shared tier (${_shared_reg})" >&2
1558
+ return 0
1559
+ }
1560
+ else
1561
+ # Shared dir is user-writable (e.g. group-writable install or test sandbox).
1562
+ existing="$(_registry_read_repos "$_shared_reg")"
1563
+ if printf '%s\n' "$existing" | grep -qxF "$repo"; then
1564
+ [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} already registered in shared tier (no-op)."
1565
+ return 0
1566
+ fi
1567
+ tmp="$(mktemp "${_shared_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
1568
+ echo "WARN: aid: could not update the shared project registry (${_shared_reg}): mktemp failed" >&2
1569
+ return 0
1570
+ }
1571
+ {
1572
+ printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
1573
+ printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
1574
+ printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
1575
+ printf '%s\n' "schema: 1"
1576
+ printf '%s\n' "projects:"
1577
+ { printf '%s\n' "$existing"; printf '%s\n' "$repo"; } \
1578
+ | sed '/^$/d' | sort -u \
1579
+ | while IFS= read -r p; do printf ' - %s\n' "$p"; done
1580
+ } > "$tmp" || {
1581
+ rm -f "$tmp"
1582
+ echo "WARN: aid: could not update the shared project registry (${_shared_reg}): write failed" >&2
1583
+ return 0
1584
+ }
1585
+ _aid_priv_run "" mv -f "$tmp" "$_shared_reg" || {
1586
+ rm -f "$tmp" 2>/dev/null
1587
+ echo "WARN: aid: could not update the shared project registry (${_shared_reg}): mv failed" >&2
1588
+ return 0
1589
+ }
1590
+ fi
1591
+ echo "Registered ${repo} with the AID CLI (shared registry)."
1592
+ return 0
1593
+ fi
1594
+ # USER tier (default) or per-user collapse.
1595
+ # Primary: $AID_STATE_HOME (honors AID_HOME override via startup scope derivation).
1596
+ # Fallback: $HOME/.aid (when AID_STATE_HOME is not writable and is a different path).
1597
+ # Never-elevate: empty probe + ensure-exists.
1598
+ _aid_priv_run "" mkdir -p "$AID_STATE_HOME" 2>/dev/null || true
1599
+ if [[ -w "$AID_STATE_HOME" ]]; then
1600
+ reg="${AID_STATE_HOME}/registry.yml"
1601
+ else
1602
+ # AID_STATE_HOME not writable; degrade to $HOME/.aid (user fallback).
1603
+ # This is the designed fallback for global installs -- silent by default,
1604
+ # visible under --verbose. Hard failures (mktemp/write/mv) stay unconditional.
1605
+ local _fb_dir="${HOME}/.aid"
1606
+ mkdir -p "$_fb_dir" 2>/dev/null || true
1607
+ [[ "${_AID_VERBOSE:-0}" == "1" ]] && \
1608
+ echo "WARN: aid: could not write to state home ${AID_STATE_HOME}; using ${_fb_dir}/registry.yml" >&2
1609
+ reg="${_fb_dir}/registry.yml"
1610
+ fi
1611
+ existing="$(_registry_read_repos "$reg")"
1612
+ # Idempotent: already registered -> silent no-op.
1613
+ if printf '%s\n' "$existing" | grep -qxF "$repo"; then
1614
+ [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} already registered (no-op)."
1615
+ return 0
1616
+ fi
1617
+ # mktemp in the chosen (writable) target dir so mv is atomic same-filesystem.
1618
+ tmp="$(mktemp "${reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
1619
+ echo "WARN: aid: could not update the machine project registry (${reg}): mktemp failed" >&2
1620
+ return 0
1621
+ }
1622
+ {
1623
+ printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
1624
+ printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
1625
+ printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
1626
+ printf '%s\n' "schema: 1"
1627
+ printf '%s\n' "projects:"
1628
+ { printf '%s\n' "$existing"; printf '%s\n' "$repo"; } \
1629
+ | sed '/^$/d' | sort -u \
1630
+ | while IFS= read -r p; do printf ' - %s\n' "$p"; done
1631
+ } > "$tmp" || {
1632
+ rm -f "$tmp"
1633
+ echo "WARN: aid: could not update the machine project registry (${reg}): write failed" >&2
1634
+ return 0
1635
+ }
1636
+ # Never-elevate atomic commit: empty probe forces direct (no-sudo) mv.
1637
+ _aid_priv_run "" mv -f "$tmp" "$reg" || {
1638
+ rm -f "$tmp" 2>/dev/null
1639
+ echo "WARN: aid: could not update the machine project registry (${reg}): mv failed" >&2
1640
+ return 0
1641
+ }
1642
+ echo "Registered ${repo} with the AID CLI."
1643
+ }
1644
+
1645
+ # registry_unregister <canon-path>
1646
+ # Set-remove <canon-path> from whichever tier(s) it appears in (idempotent; atomic write).
1647
+ # Called only when the repo manifest is now gone (last tool removed).
1648
+ # On a real change prints one concise line. On failure prints WARN and returns 0.
1649
+ #
1650
+ # Tier-aware: searches user tier and (when global scope) shared tier. Removes from
1651
+ # each tier where the entry is found, best-effort. User-tier write is never-elevate
1652
+ # (empty-probe _aid_priv_run "" mv -f). Shared-tier write uses real probe; if not
1653
+ # user-writable and elevation is unavailable, WARN + skip + return 0.
1654
+ # Per-user install ($AID_STATE_HOME == ~/.aid): both tiers are the same file; single
1655
+ # write, no elevation ever.
1656
+ registry_unregister() {
1657
+ local repo="$1" tmp existing
1658
+ # Determine the effective registry path, mirroring registry_register's
1659
+ # primary/fallback logic: $AID_STATE_HOME is primary (honors AID_HOME override);
1660
+ # $HOME/.aid is the fallback when AID_STATE_HOME is not writable.
1661
+ # Also search the other tier in case the entry was recorded there.
1662
+ local _shared_reg_dir="${AID_STATE_HOME}"
1663
+ local _shared_reg="${AID_STATE_HOME}/registry.yml"
1664
+ local _user_reg_dir="${HOME}/.aid"
1665
+ local _user_reg="${_user_reg_dir}/registry.yml"
1666
+ # Per-user collapse: AID_STATE_HOME is the same path as $HOME/.aid.
1667
+ local _per_user=0
1668
+ [[ "$AID_STATE_HOME" == "${HOME}/.aid" ]] && _per_user=1
1669
+ local _found_any=0
1670
+ # --- PRIMARY TIER ($AID_STATE_HOME) ---
1671
+ _aid_priv_run "" mkdir -p "$AID_STATE_HOME" 2>/dev/null || true
1672
+ if [[ -w "$AID_STATE_HOME" ]]; then
1673
+ existing="$(_registry_read_repos "$_shared_reg")"
1674
+ if printf '%s\n' "$existing" | grep -qxF "$repo"; then
1675
+ _found_any=1
1676
+ tmp="$(mktemp "${_shared_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
1677
+ echo "WARN: aid: could not update the machine project registry (${_shared_reg}): mktemp failed" >&2
1678
+ return 0
1679
+ }
1680
+ {
1681
+ printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
1682
+ printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
1683
+ printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
1684
+ printf '%s\n' "schema: 1"
1685
+ printf '%s\n' "projects:"
1686
+ { printf '%s\n' "$existing" | grep -vxF "$repo" || true; } | sed '/^$/d' | sort -u \
1687
+ | while IFS= read -r p; do printf ' - %s\n' "$p"; done
1688
+ } > "$tmp" || {
1689
+ rm -f "$tmp"
1690
+ echo "WARN: aid: could not update the machine project registry (${_shared_reg}): write failed" >&2
1691
+ return 0
1692
+ }
1693
+ _aid_priv_run "" mv -f "$tmp" "$_shared_reg" || {
1694
+ rm -f "$tmp" 2>/dev/null
1695
+ echo "WARN: aid: could not update the machine project registry (${_shared_reg}): mv failed" >&2
1696
+ return 0
1697
+ }
1698
+ fi
1699
+ else
1700
+ # AID_STATE_HOME not writable; check/operate in fallback $HOME/.aid tier.
1701
+ # Degrade WARN is silent by default (designed global-install behavior); visible under --verbose.
1702
+ mkdir -p "$_user_reg_dir" 2>/dev/null || true
1703
+ existing="$(_registry_read_repos "$_user_reg")"
1704
+ if printf '%s\n' "$existing" | grep -qxF "$repo"; then
1705
+ _found_any=1
1706
+ [[ "${_AID_VERBOSE:-0}" == "1" ]] && \
1707
+ echo "WARN: aid: could not write to state home ${AID_STATE_HOME}; using ${_user_reg}" >&2
1708
+ tmp="$(mktemp "${_user_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
1709
+ echo "WARN: aid: could not update the machine project registry (${_user_reg}): mktemp failed" >&2
1710
+ return 0
1711
+ }
1712
+ {
1713
+ printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
1714
+ printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
1715
+ printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
1716
+ printf '%s\n' "schema: 1"
1717
+ printf '%s\n' "projects:"
1718
+ { printf '%s\n' "$existing" | grep -vxF "$repo" || true; } | sed '/^$/d' | sort -u \
1719
+ | while IFS= read -r p; do printf ' - %s\n' "$p"; done
1720
+ } > "$tmp" || {
1721
+ rm -f "$tmp"
1722
+ echo "WARN: aid: could not update the machine project registry (${_user_reg}): write failed" >&2
1723
+ return 0
1724
+ }
1725
+ _aid_priv_run "" mv -f "$tmp" "$_user_reg" || {
1726
+ rm -f "$tmp" 2>/dev/null
1727
+ echo "WARN: aid: could not update the machine project registry (${_user_reg}): mv failed" >&2
1728
+ return 0
1729
+ }
1730
+ fi
1731
+ fi
1732
+ # --- FALLBACK / SECONDARY TIER ($HOME/.aid, global install only) ---
1733
+ # When AID_STATE_HOME is writable and != $HOME/.aid, also check if the entry
1734
+ # exists in $HOME/.aid (e.g. was registered when AID_STATE_HOME was non-writable).
1735
+ if [[ "$_per_user" -eq 0 && -w "$AID_STATE_HOME" ]]; then
1736
+ local _fb_existing
1737
+ _fb_existing="$(_registry_read_repos "$_user_reg")"
1738
+ if printf '%s\n' "$_fb_existing" | grep -qxF "$repo"; then
1739
+ _found_any=1
1740
+ mkdir -p "$_user_reg_dir" 2>/dev/null || true
1741
+ tmp="$(mktemp "${_user_reg}.aid-tmp.XXXXXX" 2>/dev/null)" || {
1742
+ echo "WARN: aid: could not update the machine project registry (${_user_reg}): mktemp failed" >&2
1743
+ }
1744
+ if [[ -n "$tmp" ]]; then
1745
+ {
1746
+ printf '%s\n' "# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit)."
1747
+ printf '%s\n' "# Holds ONLY the base folders of projects this CLI install manages. Per-project name and"
1748
+ printf '%s\n' "# description come from .aid/settings.yml; version/tools from the manifest, at render time."
1749
+ printf '%s\n' "schema: 1"
1750
+ printf '%s\n' "projects:"
1751
+ { printf '%s\n' "$_fb_existing" | grep -vxF "$repo" || true; } | sed '/^$/d' | sort -u \
1752
+ | while IFS= read -r p; do printf ' - %s\n' "$p"; done
1753
+ } > "$tmp" || {
1754
+ rm -f "$tmp"
1755
+ echo "WARN: aid: could not update the machine project registry (${_user_reg}): write failed" >&2
1756
+ tmp=""
1757
+ }
1758
+ fi
1759
+ if [[ -n "$tmp" ]]; then
1760
+ _aid_priv_run "" mv -f "$tmp" "$_user_reg" || {
1761
+ rm -f "$tmp" 2>/dev/null
1762
+ echo "WARN: aid: could not update the machine project registry (${_user_reg}): mv failed" >&2
1763
+ }
1764
+ fi
1765
+ fi
1766
+ fi
1767
+ if [[ "$_found_any" -eq 0 ]]; then
1768
+ [[ "$_AID_VERBOSE" == "1" ]] && echo "Registry: ${repo} not in registry (no-op)."
1769
+ return 0
1770
+ fi
1771
+ echo "Unregistered ${repo} from the AID CLI."
1772
+ }
1773
+
1774
+ # ---------------------------------------------------------------------------
1775
+ # C4: _aid_repo_format <repo>
1776
+ # Read the format_version stamp from <repo>/.aid/settings.yml.
1777
+ # Greps the FIRST ^format_version: line, replicates the era-a closure strip
1778
+ # logic inline (prefix strip, trim, inline # comment strip, quote-unwrap),
1779
+ # validates as ^[0-9]+$; echoes the integer.
1780
+ # Collapses absent/empty/non-integer/malformed/negative to 0 (legacy default).
1781
+ # Never returns a value > sup from a garbled stamp (fail-safe).
1782
+ # ---------------------------------------------------------------------------
1783
+ _aid_repo_format() {
1784
+ local _repo="$1"
1785
+ local _settings="${_repo}/.aid/settings.yml"
1786
+ if [[ ! -f "${_settings}" ]]; then
1787
+ echo "0"
1788
+ return 0
1789
+ fi
1790
+ # First-match read (parity with duplicate-line policy).
1791
+ local _raw_line
1792
+ _raw_line="$(grep -m1 '^format_version:' "${_settings}" 2>/dev/null)" || true
1793
+ if [[ -z "${_raw_line}" ]]; then
1794
+ echo "0"
1795
+ return 0
1796
+ fi
1797
+ # Replicate the era-a closure strip logic inline (column-0 key variant).
1798
+ # Step 1: strip the "format_version:" prefix.
1799
+ local _val="${_raw_line#format_version:}"
1800
+ # Step 2: strip one optional leading space (the colon-space separator).
1801
+ _val="${_val# }"
1802
+ # Step 3: strip inline # comment (first " #" to end of line).
1803
+ _val="${_val%% #*}"
1804
+ # Step 4: quote-unwrap (double then single).
1805
+ _val="${_val%\"}"
1806
+ _val="${_val#\"}"
1807
+ _val="${_val%%\'}"
1808
+ _val="${_val##\'}"
1809
+ # Step 5: full trim (ltrim + rtrim remaining whitespace).
1810
+ local _lstrip="${_val%%[![:space:]]*}"
1811
+ _val="${_val#"${_lstrip}"}"
1812
+ local _rstrip="${_val##*[![:space:]]}"
1813
+ _val="${_val%"${_rstrip}"}"
1814
+ # Step 6: validate non-negative integer; collapse anything else to 0.
1815
+ if [[ "${_val}" =~ ^[0-9]+$ ]]; then
1816
+ echo "${_val}"
1817
+ else
1818
+ echo "0"
1819
+ fi
1820
+ return 0
1821
+ }
1822
+
1823
+ # ---------------------------------------------------------------------------
1824
+ # C5: _aid_format_gate <repo>
1825
+ # 3-way classify <repo>'s format stamp vs AID_SUPPORTED_FORMAT:
1826
+ # repo > sup -> refuse (stderr, return 1, no .aid/ write)
1827
+ # repo < sup -> warn + offer aid update (stdout, return 0, non-blocking)
1828
+ # repo == sup -> silent (return 0)
1829
+ # AID_NO_MIGRATE=1 suppresses the warn+offer notice only; never the refuse.
1830
+ # ---------------------------------------------------------------------------
1831
+ _aid_format_gate() {
1832
+ local _repo="$1"
1833
+ local _repo_fmt
1834
+ _repo_fmt="$(_aid_repo_format "${_repo}")"
1835
+ local _sup="${AID_SUPPORTED_FORMAT}"
1836
+ if [[ "${_repo_fmt}" -gt "${_sup}" ]]; then
1837
+ printf 'ERROR: aid: project format %s is newer than this CLI supports (%s). Upgrade the aid CLI to operate on this project.\n' \
1838
+ "${_repo_fmt}" "${_sup}" >&2
1839
+ return 1
1840
+ fi
1841
+ if [[ "${_repo_fmt}" -lt "${_sup}" ]]; then
1842
+ if [[ "${AID_NO_MIGRATE:-0}" != "1" ]] \
1843
+ && [[ -f "${_repo}/.aid/.aid-manifest.json" ]]; then
1844
+ printf 'WARN: aid: this project uses an older format (v%s; current: v%s). Run: aid update\n' \
1845
+ "${_repo_fmt}" "${_sup}"
1846
+ fi
1847
+ return 0
1848
+ fi
1849
+ # repo == sup: silent.
1850
+ return 0
1851
+ }
1852
+
1853
+ # ---------------------------------------------------------------------------
1854
+ # _aid_migrate_repo <repo> (FF-1 / LC-MIG / task-077)
1855
+ # Per-repo migration core. Runs DETECT->SETTINGS->ADD->RELOCATE->REGISTER in
1856
+ # order. Each step is WARN-not-fail: a step failure logs WARN and the next
1857
+ # step runs; the function always returns 0 (SEC-4 / NFR12).
1858
+ # <repo> is a CAN-1 canonical repo base folder (resolved by the caller via
1859
+ # cd "$repo" && pwd -- identical to bin/aid:1366 / feature-010 SEC-5).
1860
+ # ---------------------------------------------------------------------------
1861
+ _aid_migrate_repo() {
1862
+ local repo="$1"
1863
+
1864
+ # ------------------------------------------------------------------
1865
+ # STEP 0 -- DETECT / QUALIFY (DD-6 / SEC-1) -- read-only, no write.
1866
+ # Qualify iff <repo>/.aid/ exists AND at least one era marker is
1867
+ # present. A bare .aid/ with no marker is NOT a candidate.
1868
+ # ------------------------------------------------------------------
1869
+ if [[ ! -d "${repo}/.aid" ]]; then
1870
+ return 0
1871
+ fi
1872
+
1873
+ local _era=""
1874
+ if [[ -f "${repo}/.aid/settings.yml" ]]; then
1875
+ _era="a"
1876
+ elif [[ -f "${repo}/.aid/knowledge/DISCOVERY_STATE.md" ]] \
1877
+ || [[ -f "${repo}/.aid/knowledge/DISCOVERY-STATE.md" ]] \
1878
+ || [[ -f "${repo}/.aid/knowledge/STATE.md" ]]; then
1879
+ _era="b"
1880
+ else
1881
+ # Bare .aid/ -- not a candidate, no mutation.
1882
+ return 0
1883
+ fi
1884
+
1885
+ # ------------------------------------------------------------------
1886
+ # STEP 1 -- SETTINGS (DM-1 / task-074 contract)
1887
+ # ------------------------------------------------------------------
1888
+ local _settings="${repo}/.aid/settings.yml"
1889
+ local _manifest="${repo}/.aid/.aid-manifest.json"
1890
+ local _repo_name; _repo_name="$(basename "${repo}")"
1891
+
1892
+ if [[ "$_era" == "a" ]]; then
1893
+ # Era-a: validate and targeted-repair REQUIRED keys only.
1894
+ # Preserves every present kb_baseline.* line and <skill>.minimum_grade
1895
+ # line byte-intact (IDIOM-A single-line replace / IDIOM-B append-block).
1896
+ _aid_migrate_repair_settings_era_a "${_settings}" "${_repo_name}" || \
1897
+ echo "WARN: aid migrate: settings repair failed for ${repo}/.aid/settings.yml (continuing)" >&2
1898
+ else
1899
+ # Era-b: synthesize a fresh settings.yml from the template defaults.
1900
+ _aid_migrate_synthesize_settings_era_b "${_settings}" "${_repo_name}" "${_manifest}" || \
1901
+ echo "WARN: aid migrate: settings synthesis failed for ${repo}/.aid/settings.yml (continuing)" >&2
1902
+ fi
1903
+
1904
+ # ------------------------------------------------------------------
1905
+ # STEP 2 -- ADD home.html (FR40 / RC-2) -- copy-when-absent only.
1906
+ # ------------------------------------------------------------------
1907
+ local _home_html_dest="${repo}/.aid/dashboard/home.html"
1908
+ if [[ ! -f "${_home_html_dest}" ]]; then
1909
+ local _home_html_src="${AID_CODE_HOME}/dashboard/home.html"
1910
+ if [[ -f "${_home_html_src}" ]]; then
1911
+ mkdir -p "${repo}/.aid/dashboard" 2>/dev/null || \
1912
+ echo "WARN: aid migrate: mkdir .aid/dashboard failed for ${repo} (continuing)" >&2
1913
+ if [[ -d "${repo}/.aid/dashboard" ]]; then
1914
+ cp "${_home_html_src}" "${_home_html_dest}" 2>/dev/null || \
1915
+ echo "WARN: aid migrate: copy home.html failed for ${repo} (continuing)" >&2
1916
+ fi
1917
+ else
1918
+ echo "WARN: aid migrate: home.html source not found at ${_home_html_src} (continuing)" >&2
1919
+ fi
1920
+ fi
1921
+
1922
+ # ------------------------------------------------------------------
1923
+ # STEP 3 -- RELOCATE legacy summary (DM-4 / FR31) -- no-clobber mv.
1924
+ # Exact idiom from canonical/scripts/summarize/summarize-preflight.sh:102-113
1925
+ # ------------------------------------------------------------------
1926
+ local _old_summary="${repo}/.aid/knowledge/knowledge-summary.html"
1927
+ local _new_summary="${repo}/.aid/dashboard/kb.html"
1928
+ if [[ -f "${_old_summary}" ]] && [[ ! -f "${_new_summary}" ]]; then
1929
+ mkdir -p "${repo}/.aid/dashboard" 2>/dev/null || \
1930
+ echo "WARN: aid migrate: mkdir .aid/dashboard failed for ${repo} (step 3, continuing)" >&2
1931
+ if [[ -d "${repo}/.aid/dashboard" ]]; then
1932
+ mv -n "${_old_summary}" "${_new_summary}" 2>/dev/null || \
1933
+ echo "WARN: aid migrate: relocate legacy summary failed for ${repo} (continuing)" >&2
1934
+ fi
1935
+ fi
1936
+
1937
+ # ------------------------------------------------------------------
1938
+ # STEP 4 -- REGISTER (DM-2 / FR28) -- existing idempotent writer.
1939
+ # Canonicalize path (same rule as bin/aid:1366).
1940
+ # FR7: deterministic tier via _aid_resolve_tier; never-elevate: if shared would
1941
+ # need elevation (shared dir not user-writable), degrade silently to user.
1942
+ # ------------------------------------------------------------------
1943
+ local _canon_repo
1944
+ _canon_repo="$(cd "${repo}" && pwd)" 2>/dev/null || _canon_repo="${repo}"
1945
+ local _migrate_tier
1946
+ _migrate_tier="$(_aid_resolve_tier "${_canon_repo}")"
1947
+ if [[ "$_migrate_tier" == "shared" && ! -w "${AID_STATE_HOME}" ]]; then
1948
+ _migrate_tier="user"
1949
+ fi
1950
+ registry_register "${_canon_repo}" "$_migrate_tier" || true
1951
+
1952
+ return 0
1953
+ }
1954
+
1955
+ # _aid_migrate_repair_settings_era_a <settings_file> <repo_name>
1956
+ # Era-a: validate/repair REQUIRED keys via targeted edits only.
1957
+ # A valid file -> no write (idempotent).
1958
+ # Batch all edits in memory, then write a single temp+mv -f (crash-safe).
1959
+ _aid_migrate_repair_settings_era_a() {
1960
+ local _sf="$1" _rname="$2"
1961
+ [[ -f "${_sf}" ]] || return 1
1962
+
1963
+ # Read file into an array (preserves byte content per line).
1964
+ local -a _lines=()
1965
+ while IFS= read -r _l || [[ -n "${_l}" ]]; do
1966
+ _lines+=("${_l}")
1967
+ done < "${_sf}"
1968
+
1969
+ local _changed=0
1970
+
1971
+ # ---- Helper: locate a section header line index (col-0 "^<sect>:$") ----
1972
+ _find_section() {
1973
+ local _sect="$1" _i
1974
+ for _i in "${!_lines[@]}"; do
1975
+ if [[ "${_lines[$_i]}" =~ ^${_sect}:[[:space:]]*$ ]]; then
1976
+ echo "$_i"; return 0
1977
+ fi
1978
+ done
1979
+ echo "-1"
1980
+ }
1981
+
1982
+ # ---- Helper: locate an indented key line index inside a section ----
1983
+ _find_key_in_section() {
1984
+ local _sect_idx="$1" _key="$2" _i
1985
+ local _n="${#_lines[@]}"
1986
+ for (( _i=_sect_idx+1; _i<_n; _i++ )); do
1987
+ local _ln="${_lines[$_i]}"
1988
+ # Stop at next col-0 non-comment non-blank line (next section).
1989
+ if [[ "${_ln}" =~ ^[a-zA-Z_] ]]; then
1990
+ echo "-1"; return 0
1991
+ fi
1992
+ if [[ "${_ln}" =~ ^[[:space:]]+${_key}:[[:space:]] ]] || \
1993
+ [[ "${_ln}" =~ ^[[:space:]]+${_key}:[[:space:]]*$ ]]; then
1994
+ echo "$_i"; return 0
1995
+ fi
1996
+ done
1997
+ echo "-1"
1998
+ }
1999
+
2000
+ # ---- Helper: get the scalar value of an indented " key: value" line ----
2001
+ _get_scalar_value() {
2002
+ local _ln="$1" _key="$2" _val
2003
+ # strip leading whitespace + key: (colon only; trailing space is optional so a bare
2004
+ # "name:" with no value is also reduced to empty; parity with the PS twin's \s*)
2005
+ _val="${_ln#*${_key}:}"
2006
+ # strip one optional leading space (was the colon-space separator)
2007
+ _val="${_val# }"
2008
+ # strip inline comment: first " #" to end of line (YAML inline-comment form).
2009
+ # This intentionally matches the first space-hash occurrence (parity with PS twin's
2010
+ # \s*#.*$ strip and the reader's _strip_yaml_inline_comment rule).
2011
+ _val="${_val%% #*}"
2012
+ _val="${_val%\"}"
2013
+ _val="${_val#\"}"
2014
+ _val="${_val%%\'}"
2015
+ _val="${_val##\'}"
2016
+ # Full rtrim: remove all trailing whitespace left after the comment strip.
2017
+ # The previous "%% " (single-space suffix) only removed ONE trailing space,
2018
+ # leaving the alignment padding that precedes " # comment" in lines like
2019
+ # " type: brownfield # brownfield | greenfield".
2020
+ local _rstrip="${_val##*[![:space:]]}"
2021
+ _val="${_val%"${_rstrip}"}"
2022
+ # Full ltrim: remove any leading whitespace (e.g. from multi-space colon-separator).
2023
+ local _lstrip="${_val%%[![:space:]]*}"
2024
+ _val="${_val#"${_lstrip}"}"
2025
+ echo "${_val}"
2026
+ }
2027
+
2028
+ # ---- Insert an indented line after a given index in _lines ----
2029
+ _insert_after() {
2030
+ local _idx="$1" _new_line="$2"
2031
+ local -a _new_lines=()
2032
+ local _i
2033
+ for (( _i=0; _i<${#_lines[@]}; _i++ )); do
2034
+ _new_lines+=("${_lines[$_i]}")
2035
+ if [[ "$_i" -eq "$_idx" ]]; then
2036
+ _new_lines+=("${_new_line}")
2037
+ fi
2038
+ done
2039
+ _lines=("${_new_lines[@]}")
2040
+ _changed=1
2041
+ }
2042
+
2043
+ # ---- Append a block at EOF (IDIOM-B for whole-section missing) ----
2044
+ # Always prepends a blank line so the new section is visually separated from
2045
+ # the preceding content (matching the template's blank-line-between-sections style).
2046
+ # Idempotency is preserved: on a 2nd run the section now exists, so _find_section
2047
+ # finds it and this path never executes again.
2048
+ _append_block() {
2049
+ local _block="$1"
2050
+ local -a _block_lines=()
2051
+ _block_lines+=("")
2052
+ while IFS= read -r _bl; do
2053
+ _block_lines+=("${_bl}")
2054
+ done <<< "${_block}"
2055
+ _lines+=("${_block_lines[@]}")
2056
+ _changed=1
2057
+ }
2058
+
2059
+ # ---- Replace a single line (IDIOM-A) ----
2060
+ _replace_line() {
2061
+ local _idx="$1" _new="$2"
2062
+ _lines[$_idx]="${_new}"
2063
+ _changed=1
2064
+ }
2065
+
2066
+ # ------------------------------------------------------------------
2067
+ # Validate / repair each REQUIRED key.
2068
+ # ------------------------------------------------------------------
2069
+
2070
+ # C3: format_version ensure-key step (top-of-file column-0 prepend).
2071
+ # If a ^format_version: line is present anywhere, replace it in-place
2072
+ # with the canonical stamp value (IDIOM-A single-line replace).
2073
+ # If absent, prepend format_version: <sup> at index 0 of _lines so it
2074
+ # sits above project: at column 0 (existing _append_block is EOF-only;
2075
+ # _insert_after places indented lines after a header -- neither works
2076
+ # here; we prepend directly).
2077
+ local _fv_idx=-1 _fi
2078
+ for _fi in "${!_lines[@]}"; do
2079
+ if [[ "${_lines[$_fi]}" =~ ^format_version: ]]; then
2080
+ _fv_idx="$_fi"
2081
+ break
2082
+ fi
2083
+ done
2084
+ if [[ "${_fv_idx}" -ge 0 ]]; then
2085
+ # Key present: replace with canonical value (IDIOM-A).
2086
+ _replace_line "${_fv_idx}" "format_version: ${AID_SUPPORTED_FORMAT}"
2087
+ else
2088
+ # Key absent: prepend at index 0 (new top-of-file col-0 insert).
2089
+ local -a _fv_new_lines=()
2090
+ _fv_new_lines+=("format_version: ${AID_SUPPORTED_FORMAT}")
2091
+ local _fv_i
2092
+ for _fv_i in "${!_lines[@]}"; do
2093
+ _fv_new_lines+=("${_lines[$_fv_i]}")
2094
+ done
2095
+ _lines=("${_fv_new_lines[@]}")
2096
+ _changed=1
2097
+ fi
2098
+
2099
+ # project section
2100
+ local _proj_idx; _proj_idx="$(_find_section "project")"
2101
+ if [[ "$_proj_idx" -eq -1 ]]; then
2102
+ # Whole project: section missing -> IDIOM-B append.
2103
+ _append_block "project:
2104
+ name: ${_rname}
2105
+ description: <project-description>
2106
+ type: brownfield"
2107
+ else
2108
+ # project.name
2109
+ local _name_idx; _name_idx="$(_find_key_in_section "$_proj_idx" "name")"
2110
+ if [[ "$_name_idx" -eq -1 ]]; then
2111
+ _insert_after "$_proj_idx" " name: ${_rname}"
2112
+ else
2113
+ local _name_val; _name_val="$(_get_scalar_value "${_lines[$_name_idx]}" "name")"
2114
+ if [[ -z "${_name_val}" ]]; then
2115
+ _replace_line "$_name_idx" " name: ${_rname}"
2116
+ fi
2117
+ fi
2118
+
2119
+ # project.description (key must exist; value may be placeholder)
2120
+ local _desc_idx; _desc_idx="$(_find_key_in_section "$_proj_idx" "description")"
2121
+ if [[ "$_desc_idx" -eq -1 ]]; then
2122
+ # Find name line to insert after it, else insert after section header.
2123
+ local _name_idx2; _name_idx2="$(_find_key_in_section "$_proj_idx" "name")"
2124
+ if [[ "$_name_idx2" -ne -1 ]]; then
2125
+ _insert_after "$_name_idx2" " description: <project-description>"
2126
+ else
2127
+ _insert_after "$_proj_idx" " description: <project-description>"
2128
+ fi
2129
+ fi
2130
+
2131
+ # project.type
2132
+ local _type_idx; _type_idx="$(_find_key_in_section "$_proj_idx" "type")"
2133
+ if [[ "$_type_idx" -eq -1 ]]; then
2134
+ # Insert after description (or name, or section header).
2135
+ local _desc_idx2; _desc_idx2="$(_find_key_in_section "$_proj_idx" "description")"
2136
+ local _ins_after_type
2137
+ if [[ "$_desc_idx2" -ne -1 ]]; then
2138
+ _ins_after_type="$_desc_idx2"
2139
+ else
2140
+ local _name_idx3; _name_idx3="$(_find_key_in_section "$_proj_idx" "name")"
2141
+ if [[ "$_name_idx3" -ne -1 ]]; then
2142
+ _ins_after_type="$_name_idx3"
2143
+ else
2144
+ _ins_after_type="$_proj_idx"
2145
+ fi
2146
+ fi
2147
+ _insert_after "$_ins_after_type" " type: brownfield"
2148
+ else
2149
+ local _type_val; _type_val="$(_get_scalar_value "${_lines[$_type_idx]}" "type")"
2150
+ if [[ "$_type_val" != "brownfield" && "$_type_val" != "greenfield" ]]; then
2151
+ _replace_line "$_type_idx" " type: brownfield"
2152
+ fi
2153
+ fi
2154
+ fi
2155
+
2156
+ # tools section
2157
+ local _tools_idx; _tools_idx="$(_find_section "tools")"
2158
+ if [[ "$_tools_idx" -eq -1 ]]; then
2159
+ _append_block "tools:
2160
+ installed: []"
2161
+ else
2162
+ local _inst_idx; _inst_idx="$(_find_key_in_section "$_tools_idx" "installed")"
2163
+ if [[ "$_inst_idx" -eq -1 ]]; then
2164
+ _insert_after "$_tools_idx" " installed: []"
2165
+ fi
2166
+ # If installed key exists (any value, even empty list) -> valid; no touch.
2167
+ fi
2168
+
2169
+ # review section
2170
+ local _rev_idx; _rev_idx="$(_find_section "review")"
2171
+ if [[ "$_rev_idx" -eq -1 ]]; then
2172
+ _append_block "review:
2173
+ minimum_grade: A"
2174
+ else
2175
+ local _mg_idx; _mg_idx="$(_find_key_in_section "$_rev_idx" "minimum_grade")"
2176
+ if [[ "$_mg_idx" -eq -1 ]]; then
2177
+ _insert_after "$_rev_idx" " minimum_grade: A"
2178
+ else
2179
+ local _mg_val; _mg_val="$(_get_scalar_value "${_lines[$_mg_idx]}" "minimum_grade")"
2180
+ if ! [[ "$_mg_val" =~ ^[A-F][+-]?$ ]]; then
2181
+ _replace_line "$_mg_idx" " minimum_grade: A"
2182
+ fi
2183
+ fi
2184
+ fi
2185
+
2186
+ # execution section
2187
+ local _exec_idx; _exec_idx="$(_find_section "execution")"
2188
+ if [[ "$_exec_idx" -eq -1 ]]; then
2189
+ _append_block "execution:
2190
+ max_parallel_tasks: 5"
2191
+ else
2192
+ local _mpt_idx; _mpt_idx="$(_find_key_in_section "$_exec_idx" "max_parallel_tasks")"
2193
+ if [[ "$_mpt_idx" -eq -1 ]]; then
2194
+ _insert_after "$_exec_idx" " max_parallel_tasks: 5"
2195
+ else
2196
+ local _mpt_val; _mpt_val="$(_get_scalar_value "${_lines[$_mpt_idx]}" "max_parallel_tasks")"
2197
+ if ! [[ "$_mpt_val" =~ ^[0-9]+$ ]] || [[ "$_mpt_val" -le 0 ]]; then
2198
+ _replace_line "$_mpt_idx" " max_parallel_tasks: 5"
2199
+ fi
2200
+ fi
2201
+ fi
2202
+
2203
+ # traceability section
2204
+ local _trace_idx; _trace_idx="$(_find_section "traceability")"
2205
+ if [[ "$_trace_idx" -eq -1 ]]; then
2206
+ _append_block "traceability:
2207
+ heartbeat_interval: 1"
2208
+ else
2209
+ local _hb_idx; _hb_idx="$(_find_key_in_section "$_trace_idx" "heartbeat_interval")"
2210
+ if [[ "$_hb_idx" -eq -1 ]]; then
2211
+ _insert_after "$_trace_idx" " heartbeat_interval: 1"
2212
+ else
2213
+ local _hb_val; _hb_val="$(_get_scalar_value "${_lines[$_hb_idx]}" "heartbeat_interval")"
2214
+ if ! [[ "$_hb_val" =~ ^[0-9]+$ ]]; then
2215
+ _replace_line "$_hb_idx" " heartbeat_interval: 1"
2216
+ fi
2217
+ fi
2218
+ fi
2219
+
2220
+ # ------------------------------------------------------------------
2221
+ # Write only if any edit was made (idempotent: no edit -> no write).
2222
+ # ------------------------------------------------------------------
2223
+ if [[ "$_changed" -eq 0 ]]; then
2224
+ return 0
2225
+ fi
2226
+
2227
+ local _tmp
2228
+ _tmp="$(mktemp "${_sf}.aid-tmp.XXXXXX")" || return 1
2229
+ {
2230
+ local _wi
2231
+ for _wi in "${!_lines[@]}"; do
2232
+ printf '%s\n' "${_lines[$_wi]}"
2233
+ done
2234
+ } > "${_tmp}" || { rm -f "${_tmp}"; return 1; }
2235
+ mv -f "${_tmp}" "${_sf}" || { rm -f "${_tmp}" 2>/dev/null; return 1; }
2236
+ return 0
2237
+ }
2238
+
2239
+ # _aid_migrate_synthesize_settings_era_b <settings_file> <repo_name> <manifest>
2240
+ # Era-b: write a fresh template-derived settings.yml when none exists.
2241
+ # tools.installed from manifest_list_tools (bash) or empty list if no manifest.
2242
+ # Crash-safe: same-directory temp + mv -f.
2243
+ _aid_migrate_synthesize_settings_era_b() {
2244
+ local _sf="$1" _rname="$2" _manifest="$3"
2245
+
2246
+ # Collect installed tools from manifest (exits 0, prints nothing when absent).
2247
+ local -a _tools=()
2248
+ while IFS= read -r _tid; do
2249
+ [[ -n "${_tid}" ]] && _tools+=("${_tid}")
2250
+ done < <(manifest_list_tools "${_manifest}" 2>/dev/null)
2251
+
2252
+ local _tmp
2253
+ _tmp="$(mktemp "${_sf}.aid-tmp.XXXXXX")" || return 1
2254
+
2255
+ {
2256
+ # C2: format_version stamp is the FIRST line (before project:).
2257
+ printf 'format_version: %s\n' "${AID_SUPPORTED_FORMAT}"
2258
+ printf 'project:\n'
2259
+ printf ' name: %s\n' "${_rname}"
2260
+ printf ' description: <project-description>\n'
2261
+ printf ' type: brownfield\n'
2262
+ printf '\n'
2263
+ printf 'tools:\n'
2264
+ if [[ "${#_tools[@]}" -eq 0 ]]; then
2265
+ printf ' installed: []\n'
2266
+ else
2267
+ printf ' installed:\n'
2268
+ local _t
2269
+ for _t in "${_tools[@]}"; do
2270
+ printf ' - %s\n' "${_t}"
2271
+ done
2272
+ fi
2273
+ printf '\n'
2274
+ printf 'review:\n'
2275
+ printf ' minimum_grade: A\n'
2276
+ printf '\n'
2277
+ printf 'execution:\n'
2278
+ printf ' max_parallel_tasks: 5\n'
2279
+ printf '\n'
2280
+ printf 'traceability:\n'
2281
+ printf ' heartbeat_interval: 1\n'
2282
+ } > "${_tmp}" || { rm -f "${_tmp}"; return 1; }
2283
+
2284
+ mv -f "${_tmp}" "${_sf}" || { rm -f "${_tmp}" 2>/dev/null; return 1; }
2285
+ return 0
2286
+ }
2287
+
2288
+
2289
+ # ---------------------------------------------------------------------------
2290
+ # _aid_cwd_classify <target-dir>
2291
+ # C-table: classify the cwd repo and perform register-on-encounter.
2292
+ # Called before repo commands (status, update [tool]) when .aid/ exists.
2293
+ # When called with a dir that has .aid/, this function:
2294
+ # 1. Checks if already registered (union read); if not, picks tier and registers.
2295
+ # 2. Does NOT check stale here -- callers use _aid_format_gate for that.
2296
+ # Returns 0 always (registration is best-effort; never blocks the host command).
2297
+ _aid_cwd_classify() {
2298
+ local _target="$1"
2299
+ local _canon_target
2300
+ _canon_target="$(cd "$_target" && pwd)" 2>/dev/null || _canon_target="$_target"
2301
+
2302
+ # Check if already registered in the union.
2303
+ local _is_registered=0
2304
+ while IFS= read -r _reg_p; do
2305
+ if [[ "$_reg_p" == "$_canon_target" ]]; then
2306
+ _is_registered=1
2307
+ break
2308
+ fi
2309
+ done < <(_registry_read_union)
2310
+
2311
+ if [[ "$_is_registered" -eq 0 ]]; then
2312
+ # Not registered -- pick tier and register (best-effort, never blocks).
2313
+ # FR7: deterministic, non-interactive tier selection via _aid_resolve_tier.
2314
+ # _AID_TIER_OVERRIDE is empty here (auto path; no CLI override in cwd-classify).
2315
+ local _reg_tier
2316
+ _reg_tier="$(_aid_resolve_tier "$_canon_target")"
2317
+ # register is best-effort: failure warns, returns 0, host command proceeds.
2318
+ registry_register "$_canon_target" "$_reg_tier" || true
2319
+ fi
2320
+ return 0
2321
+ }
2322
+
2323
+ # _aid_cwd_no_aid_offer <target-dir>
2324
+ # C-table last row: .aid/ absent -- print offer + optional non-git note, then exit 0.
2325
+ # This is the "no hard refuse" rule (decision #5): never errors on missing .aid/.
2326
+ _aid_cwd_no_aid_offer() {
2327
+ local _target="$1"
2328
+ local _canon
2329
+ _canon="$(cd "$_target" 2>/dev/null && pwd)" || _canon="$_target"
2330
+ printf 'no AID project here -- set it up? (aid add)\n'
2331
+ # Non-git note (decision #5): a non-git dir can use AID; .aid/ just won't be
2332
+ # version-controlled if git is absent.
2333
+ if ! git -C "$_canon" rev-parse --git-dir >/dev/null 2>&1; then
2334
+ printf 'Note: %s is not a git repository -- .aid/ will not be version-controlled.\n' "$_canon"
2335
+ fi
2336
+ exit 0
2337
+ }
2338
+
2339
+ # ---------------------------------------------------------------------------
2340
+ # _cmd_projects -- list/add/remove/help for the project registry.
2341
+ # ---------------------------------------------------------------------------
2342
+ _cmd_projects() {
2343
+ local _action="${1:-list}"
2344
+ local _path_arg=""
2345
+ local _verbose=0
2346
+ shift || true
2347
+
2348
+ # Parse remaining args: sub-action already consumed above.
2349
+ while [[ $# -gt 0 ]]; do
2350
+ case "$1" in
2351
+ -h|--help) _action="help"; shift ;;
2352
+ --local) _AID_TIER_OVERRIDE="--local"; shift ;;
2353
+ --shared) _AID_TIER_OVERRIDE="--shared"; shift ;;
2354
+ --verbose) _verbose=1; _AID_VERBOSE=1; shift ;;
2355
+ -*)
2356
+ echo "ERROR: aid projects: unknown flag: $1 (see 'aid projects -h')" >&2
2357
+ exit 2
2358
+ ;;
2359
+ *)
2360
+ if [[ -z "$_path_arg" ]]; then
2361
+ _path_arg="$1"
2362
+ fi
2363
+ shift ;;
2364
+ esac
2365
+ done
2366
+
2367
+ case "$_action" in
2368
+ list) _cmd_projects_list "$_verbose" ;;
2369
+ add) _cmd_projects_add "$_path_arg" "$_verbose" ;;
2370
+ remove) _cmd_projects_remove "$_path_arg" "$_verbose" ;;
2371
+ help) _aid_usage projects; exit 0 ;;
2372
+ *)
2373
+ echo "ERROR: aid projects: unknown action: ${_action} (expected: list, add, remove, help)" >&2
2374
+ exit 2
2375
+ ;;
2376
+ esac
2377
+ }
2378
+
2379
+ # _cmd_projects_list [verbose]
2380
+ # Render the raw union as an aligned table: marker, path, state, tools, tier.
2381
+ # Marks cwd with "*"; footnotes unregistered AID cwd.
2382
+ # --verbose: also print the registry file each entry was read from.
2383
+ _cmd_projects_list() {
2384
+ local _verbose="${1:-0}"
2385
+
2386
+ # Canonical cwd.
2387
+ local _cwd
2388
+ _cwd="$(cd . && pwd)"
2389
+
2390
+ # Collect raw union (includes no-aid / missing paths).
2391
+ local -a _paths=()
2392
+ while IFS= read -r _p; do
2393
+ [[ -n "$_p" ]] && _paths+=("$_p")
2394
+ done < <(_registry_read_raw_union)
2395
+
2396
+ # Column header.
2397
+ printf '%-2s %-45s %-10s %-20s %s\n' " " "PATH" "STATE" "TOOLS" "TIER"
2398
+ printf '%-2s %-45s %-10s %-20s %s\n' "--" "----" "-----" "-----" "----"
2399
+
2400
+ local _cwd_registered=0
2401
+ local _entry
2402
+ for _entry in "${_paths[@]+"${_paths[@]}"}"; do
2403
+ local _state _tools _tier _marker
2404
+ _state="$(_aid_project_state "$_entry")"
2405
+ _tools="$(_aid_project_tools "$_entry")"
2406
+ _tier="$(_which_tier_holds "$_entry")"
2407
+ _marker=" "
2408
+ if [[ "$_entry" == "$_cwd" ]]; then
2409
+ _marker="* "
2410
+ _cwd_registered=1
2411
+ fi
2412
+ # Truncate long tools string for display.
2413
+ local _tools_display="${_tools:--}"
2414
+ printf '%-2s %-45s %-10s %-20s %s\n' \
2415
+ "$_marker" "$_entry" "$_state" "$_tools_display" "$_tier"
2416
+ if [[ "$_verbose" -eq 1 ]]; then
2417
+ local _reg_src
2418
+ if [[ "$_tier" == "shared" && "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
2419
+ _reg_src="${AID_STATE_HOME}/registry.yml"
2420
+ else
2421
+ _reg_src="${HOME}/.aid/registry.yml"
2422
+ fi
2423
+ printf ' registry: %s\n' "$_reg_src"
2424
+ fi
2425
+ done
2426
+
2427
+ if [[ "${#_paths[@]}" -eq 0 ]]; then
2428
+ printf '(no projects registered)\n'
2429
+ fi
2430
+
2431
+ # Footnote: unregistered AID cwd (only when cwd is a real project, not the state home).
2432
+ if [[ "$_cwd_registered" -eq 0 ]] && _aid_is_project_dir "${_cwd}"; then
2433
+ printf '\n'
2434
+ printf "(here) -- not registered; run 'aid projects add'\n"
2435
+ fi
2436
+
2437
+ # Legend.
2438
+ if [[ "${#_paths[@]}" -gt 0 ]]; then
2439
+ printf '\n'
2440
+ printf '* = current directory\n'
2441
+ fi
2442
+ }
2443
+
2444
+ # _which_tier_holds <canon-path>
2445
+ # Returns "user" or "shared" based on which registry file contains the path.
2446
+ # Falls back to _aid_resolve_tier if the path is not found in either.
2447
+ _which_tier_holds() {
2448
+ local _p="$1"
2449
+ local _primary_reg="${AID_STATE_HOME}/registry.yml"
2450
+ local _user_reg="${HOME}/.aid/registry.yml"
2451
+ # Check shared/primary first.
2452
+ if [[ "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
2453
+ if _registry_read_repos "$_primary_reg" 2>/dev/null | grep -qxF "$_p"; then
2454
+ printf 'shared\n'
2455
+ return 0
2456
+ fi
2457
+ if _registry_read_repos "$_user_reg" 2>/dev/null | grep -qxF "$_p"; then
2458
+ printf 'user\n'
2459
+ return 0
2460
+ fi
2461
+ else
2462
+ # Per-user: single file.
2463
+ if _registry_read_repos "$_primary_reg" 2>/dev/null | grep -qxF "$_p"; then
2464
+ printf 'user\n'
2465
+ return 0
2466
+ fi
2467
+ fi
2468
+ # Fallback: derive from tier resolution.
2469
+ _aid_resolve_tier "$_p"
2470
+ }
2471
+
2472
+ # _cmd_projects_add [path] [verbose]
2473
+ # Register a project path (default: cwd) in the deterministic tier.
2474
+ _cmd_projects_add() {
2475
+ local _raw_path="${1:-.}"
2476
+ local _verbose="${2:-0}"
2477
+
2478
+ # Canonicalize.
2479
+ local _canon
2480
+ if ! _canon="$(cd "$_raw_path" 2>/dev/null && pwd)"; then
2481
+ echo "ERROR: aid projects add: path does not exist: ${_raw_path}" >&2
2482
+ exit 2
2483
+ fi
2484
+
2485
+ # Require a real AID project (.aid/ present AND not the CLI state home).
2486
+ if ! _aid_is_project_dir "${_canon}"; then
2487
+ echo "ERROR: aid projects add: '${_canon}' is not an AID project; run 'aid add <tool>' first." >&2
2488
+ exit 2
2489
+ fi
2490
+
2491
+ # Resolve tier.
2492
+ local _tier
2493
+ _tier="$(_aid_resolve_tier "$_canon")"
2494
+
2495
+ # Register (idempotent).
2496
+ # Suppress registry_register's own "Registered..." stdout line so we emit a
2497
+ # single consolidated message instead of two lines. stderr (WARN lines) is
2498
+ # left to flow through unchanged.
2499
+ registry_register "$_canon" "$_tier" >/dev/null
2500
+ local _rc=$?
2501
+ if [[ $_rc -eq 0 ]]; then
2502
+ printf "aid projects: '%s' registered in %s tier.\n" "$_canon" "$_tier"
2503
+ if [[ "$_verbose" -eq 1 ]]; then
2504
+ local _reg_file
2505
+ if [[ "$_tier" == "shared" && "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
2506
+ _reg_file="${AID_STATE_HOME}/registry.yml"
2507
+ else
2508
+ _reg_file="${HOME}/.aid/registry.yml"
2509
+ fi
2510
+ printf "aid projects: registry file: %s\n" "$_reg_file"
2511
+ fi
2512
+ fi
2513
+ return 0
2514
+ }
2515
+
2516
+ # _cmd_projects_remove [path] [verbose]
2517
+ # Unregister a project path (default: cwd) from the registry; no .aid/ required.
2518
+ _cmd_projects_remove() {
2519
+ local _raw_path="${1:-.}"
2520
+ local _verbose="${2:-0}"
2521
+
2522
+ # Canonicalize without requiring the directory to exist.
2523
+ local _canon
2524
+ if _canon="$(cd "$_raw_path" 2>/dev/null && pwd)"; then
2525
+ : # directory exists; use canonical path
2526
+ else
2527
+ # Directory absent (stale entry); use the raw path as-is after normalizing.
2528
+ _canon="$_raw_path"
2529
+ fi
2530
+
2531
+ # Unregister (idempotent; registry_unregister prints on real change).
2532
+ # Check first so we can emit the idempotent no-op message ourselves.
2533
+ local _primary_reg="${AID_STATE_HOME}/registry.yml"
2534
+ local _user_reg="${HOME}/.aid/registry.yml"
2535
+ local _found=0
2536
+ if _registry_read_repos "$_primary_reg" 2>/dev/null | grep -qxF "$_canon"; then
2537
+ _found=1
2538
+ elif [[ "$AID_STATE_HOME" != "${HOME}/.aid" ]]; then
2539
+ if _registry_read_repos "$_user_reg" 2>/dev/null | grep -qxF "$_canon"; then
2540
+ _found=1
2541
+ fi
2542
+ fi
2543
+
2544
+ registry_unregister "$_canon"
2545
+ if [[ "$_found" -eq 0 ]]; then
2546
+ printf "aid projects: '%s' was not registered (nothing to remove).\n" "$_canon"
2547
+ elif [[ "$_verbose" -eq 1 ]]; then
2548
+ printf "aid projects: removed '%s' from registry.\n" "$_canon"
2549
+ fi
2550
+ return 0
2551
+ }
2552
+
2553
+ # ---------------------------------------------------------------------------
2554
+ # Parse subcommand and dispatch.
2555
+ # ---------------------------------------------------------------------------
2556
+
2557
+ # Shared flag buckets (populated during subcommand-specific arg parsing).
2558
+ _AID_TOOL_ARG=""
2559
+ _AID_VERSION_ARG=""
2560
+ _AID_FROM_BUNDLE=""
2561
+ _AID_FORCE=0
2562
+ _AID_TARGET=""
2563
+ _AID_VERBOSE="${AID_VERBOSE:-0}"
2564
+ _AID_NO_PATH=0
2565
+
2566
+ # ---------------------------------------------------------------------------
2567
+ # Dashboard (bare 'aid' - no arguments).
2568
+ # ---------------------------------------------------------------------------
2569
+ _cmd_dashboard() {
2570
+ # Block 1 + 2: Header + description.
2571
+ local cli_version="unknown"
2572
+ local ver_file="${AID_CODE_HOME}/VERSION"
2573
+ if [[ -f "$ver_file" ]]; then
2574
+ cli_version="$(tr -d '[:space:]' < "$ver_file")"
2575
+ fi
2576
+ printf 'AID v%s - Agentic Iterative Development\n' "$cli_version"
2577
+ printf 'Install, update, and manage AID across your projects.\n'
2578
+
2579
+ # C6: format gate for cwd repo (.aid/ is guaranteed present here -- the
2580
+ # non-project case is intercepted at the dispatch level above via
2581
+ # _aid_cwd_no_aid_offer; register-on-encounter already ran via _aid_cwd_classify).
2582
+ # _aid_is_project_dir guards the state-home exclusion (double-check).
2583
+ if _aid_is_project_dir "."; then
2584
+ _aid_format_gate "." || return $?
2585
+ fi
2586
+
2587
+ # Block 3: Installed tools for cwd.
2588
+ printf '\n'
2589
+ aid_status_body "."
2590
+
2591
+ # Block 4: Usage/help.
2592
+ printf '\n'
2593
+ _aid_usage
2594
+
2595
+ # Block 5: Update check notice (final line, non-blocking).
2596
+ _aid_check_update
2597
+ }
2598
+
2599
+ # Early help check.
2600
+ if [[ $# -eq 0 ]]; then
2601
+ # C-table: if cwd is not an AID project -> offer (no hard refuse, decision #5); exit 0.
2602
+ # _aid_is_project_dir excludes the CLI state home from the "is project" classification.
2603
+ if ! _aid_is_project_dir "."; then
2604
+ _aid_cwd_no_aid_offer "."
2605
+ # _aid_cwd_no_aid_offer always exits 0.
2606
+ fi
2607
+ # C-table register-on-encounter (best-effort, never blocks bare aid).
2608
+ _aid_cwd_classify "."
2609
+ # Bare 'aid' -> dashboard landing screen.
2610
+ _cmd_dashboard
2611
+ exit $?
2612
+ fi
2613
+
2614
+ case "$1" in
2615
+ -h|--help)
2616
+ _aid_usage
2617
+ exit 0
2618
+ ;;
2619
+ esac
2620
+
2621
+ SUBCMD="$1"
2622
+ shift
2623
+
2624
+ # ---- version ----------------------------------------------------------------
2625
+ if [[ "$SUBCMD" == "version" ]]; then
2626
+ local_version_file="${AID_CODE_HOME}/VERSION"
2627
+ if [[ -f "$local_version_file" ]]; then
2628
+ cat "$local_version_file"
2629
+ else
2630
+ echo "unknown (VERSION file not found at ${local_version_file})"
2631
+ fi
2632
+ exit 0
2633
+ fi
2634
+
2635
+ # ---- help -------------------------------------------------------------------
2636
+ if [[ "$SUBCMD" == "help" || "$SUBCMD" == "-h" || "$SUBCMD" == "--help" ]]; then
2637
+ _aid_usage
2638
+ exit 0
2639
+ fi
2640
+
2641
+ # ---- status -----------------------------------------------------------------
2642
+ if [[ "$SUBCMD" == "status" ]]; then
2643
+ # Parse flags for status.
2644
+ while [[ $# -gt 0 ]]; do
2645
+ case "$1" in
2646
+ --target)
2647
+ [[ $# -lt 2 ]] && _aid_die "--target requires a value" 2
2648
+ _AID_TARGET="$2"; shift 2 ;;
2649
+ --verbose) _AID_VERBOSE=1; shift ;;
2650
+ -h|--help) _aid_usage status; exit 0 ;;
2651
+ -*) _aid_die "unknown flag for status: $1" 2 ;;
2652
+ *) _aid_die "unexpected argument for status: $1" 2 ;;
2653
+ esac
2654
+ done
2655
+ # Apply env-var fallbacks.
2656
+ [[ -z "$_AID_TARGET" && -n "${AID_TARGET:-}" ]] && _AID_TARGET="$AID_TARGET"
2657
+ _AID_TARGET="${_AID_TARGET:-.}"
2658
+ export AID_VERBOSE="$_AID_VERBOSE"
2659
+ # C-table: if target is not an AID project -> offer (no hard refuse, decision #5); exit 0.
2660
+ # _aid_is_project_dir excludes the CLI state home from the "is project" classification.
2661
+ if ! _aid_is_project_dir "${_AID_TARGET}"; then
2662
+ _aid_cwd_no_aid_offer "${_AID_TARGET}"
2663
+ # _aid_cwd_no_aid_offer always exits 0.
2664
+ fi
2665
+ # C-table register-on-encounter (best-effort, never blocks status).
2666
+ _aid_cwd_classify "${_AID_TARGET}"
2667
+ # C6: format gate for status target (only when target is a real project).
2668
+ _aid_format_gate "${_AID_TARGET}" || exit $?
2669
+ aid_status "$_AID_TARGET"
2670
+ _status_rc=$?
2671
+ # Update check notice appended after status output (non-blocking).
2672
+ _aid_check_update
2673
+ exit $_status_rc
2674
+ fi
2675
+
2676
+ # ---- update -----------------------------------------------------------------
2677
+ if [[ "$SUBCMD" == "update" ]]; then
2678
+ # Check for 'update self' as first positional arg.
2679
+ if [[ $# -gt 0 && "$1" == "self" ]]; then
2680
+ shift
2681
+ # Consume any flags after 'self'.
2682
+ _SELF_FROM_BUNDLE=""
2683
+ _SELF_DRYRUN=0
2684
+ while [[ $# -gt 0 ]]; do
2685
+ case "$1" in
2686
+ --force|-y) shift ;; # no-op for update self
2687
+ --from-bundle)
2688
+ [[ $# -lt 2 ]] && _aid_die "--from-bundle requires a value" 2
2689
+ _SELF_FROM_BUNDLE="$2"; shift 2 ;;
2690
+ --dry-run) _SELF_DRYRUN=1; shift ;;
2691
+ -h|--help) _aid_usage update; exit 0 ;;
2692
+ *) _aid_die "unknown flag for 'update self': $1" 2 ;;
2693
+ esac
2694
+ done
2695
+ export _SELF_FROM_BUNDLE _SELF_DRYRUN
2696
+ _cmd_update_self; _us_rc=$?
2697
+ if [[ "${_us_rc}" -ne 0 ]]; then exit "${_us_rc}"; fi
2698
+ # Post-update: registry-driven migration (feature-004).
2699
+ # Iterate _registry_read_union -- NO scan -- with All/Yes/No/Cancel per-repo
2700
+ # consent walk. Unregistered repos are caught lazily by the per-repo stamp.
2701
+ # No .migrated marker is written (removed; stamp in settings.yml is the record).
2702
+ # dry-run: the install step already printed its command; skip migration silently.
2703
+ if [[ "${_SELF_DRYRUN:-0}" != "1" ]]; then
2704
+ _us_migrate_all=0
2705
+ _us_migrate_cancel=0
2706
+ # Read the union of registered repos (quiet-prunes stale entries).
2707
+ _us_repos=()
2708
+ while IFS= read -r _us_r; do
2709
+ [[ -n "$_us_r" ]] && _us_repos+=("$_us_r")
2710
+ done < <(_registry_read_union)
2711
+ if [[ "${#_us_repos[@]}" -eq 0 ]]; then
2712
+ echo "No registered projects to migrate."
2713
+ else
2714
+ # Determine interactive mode: AID_MIGRATE_YES=1 is the explicit opt-in for
2715
+ # auto-yes. Non-interactive without opt-in -> no migration (per SPEC: without
2716
+ # opt-in, no migration is forced). Test /dev/tty by attempting to open it.
2717
+ _us_auto_yes=0
2718
+ _us_have_tty=0
2719
+ [[ "${AID_MIGRATE_YES:-0}" == "1" ]] && _us_auto_yes=1
2720
+ { exec 3</dev/tty; } 2>/dev/null && { _us_have_tty=1; exec 3>&-; } || true
2721
+ if [[ "$_us_auto_yes" -eq 0 && "$_us_have_tty" -eq 0 ]]; then
2722
+ # Non-interactive, no opt-in: skip all (non-interactive default, SPEC edge-cases).
2723
+ echo "Skipping project migration (non-interactive; set AID_MIGRATE_YES=1 to opt in)."
2724
+ else
2725
+ for _us_repo in "${_us_repos[@]}"; do
2726
+ if [[ "$_us_migrate_cancel" -eq 1 ]]; then
2727
+ break
2728
+ fi
2729
+ if [[ "$_us_migrate_all" -eq 1 || "$_us_auto_yes" -eq 1 ]]; then
2730
+ _us_answer="y"
2731
+ else
2732
+ printf 'Migrate project %s? [All/Yes/No/Cancel] ' "$_us_repo"
2733
+ _us_answer=""
2734
+ read -r _us_answer < /dev/tty
2735
+ fi
2736
+ case "$_us_answer" in
2737
+ [Aa]|all|All|ALL)
2738
+ _us_migrate_all=1
2739
+ _aid_migrate_repo "$_us_repo"
2740
+ ;;
2741
+ [Yy]|yes|Yes|YES)
2742
+ _aid_migrate_repo "$_us_repo"
2743
+ ;;
2744
+ [Cc]|cancel|Cancel|CANCEL)
2745
+ _us_migrate_cancel=1
2746
+ echo "Migration cancelled."
2747
+ ;;
2748
+ *)
2749
+ echo "Skipped: ${_us_repo}"
2750
+ ;;
2751
+ esac
2752
+ done
2753
+ fi
2754
+ fi
2755
+ fi
2756
+ exit 0
2757
+ fi
2758
+ # Fall through to the shared add/update handler below.
2759
+ fi
2760
+
2761
+ # ---- remove -----------------------------------------------------------------
2762
+ if [[ "$SUBCMD" == "remove" ]]; then
2763
+ # Check for 'remove self' as first positional arg.
2764
+ if [[ $# -gt 0 && "$1" == "self" ]]; then
2765
+ shift
2766
+ _cmd_remove_self "$@"
2767
+ # _cmd_remove_self always exits.
2768
+ fi
2769
+
2770
+ # Check for 'remove' with no tool args (remove ALL from project).
2771
+ # We do this check after parsing flags below, so continue to parse first.
2772
+ fi
2773
+
2774
+ # ---- dashboard --------------------------------------------------------------
2775
+ if [[ "$SUBCMD" == "dashboard" ]]; then
2776
+ _cmd_dashboard_ctl "$@"
2777
+ exit $?
2778
+ fi
2779
+
2780
+ # ---- projects ---------------------------------------------------------------
2781
+ if [[ "$SUBCMD" == "projects" ]]; then
2782
+ # Check for -h/--help as first arg before dispatching.
2783
+ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
2784
+ _aid_usage projects
2785
+ exit 0
2786
+ fi
2787
+ # Determine sub-action (first positional or default "list").
2788
+ # Scan through leading flags to find the action word; unknown positionals are
2789
+ # rejected here so errors surface before entering _cmd_projects.
2790
+ _PROJ_ACTION="list"
2791
+ _PROJ_ARGS=()
2792
+ while [[ $# -gt 0 ]]; do
2793
+ case "$1" in
2794
+ list|add|remove|help)
2795
+ _PROJ_ACTION="$1"; shift
2796
+ _PROJ_ARGS+=("$@")
2797
+ set --
2798
+ break
2799
+ ;;
2800
+ -h|--help) _aid_usage projects; exit 0 ;;
2801
+ --local|--shared|--verbose)
2802
+ _PROJ_ARGS+=("$1"); shift ;;
2803
+ -*)
2804
+ # Unknown flag: pass through to _cmd_projects for rejection.
2805
+ _PROJ_ARGS+=("$1"); shift ;;
2806
+ *)
2807
+ echo "ERROR: aid projects: unknown action: ${1} (expected: list, add, remove, help)" >&2
2808
+ exit 2
2809
+ ;;
2810
+ esac
2811
+ done
2812
+ _AID_TIER_OVERRIDE="${_AID_TIER_OVERRIDE:-}"
2813
+ _cmd_projects "$_PROJ_ACTION" "${_PROJ_ARGS[@]+"${_PROJ_ARGS[@]}"}"
2814
+ exit $?
2815
+ fi
2816
+
2817
+ # ---- __migrate-repo (hidden, callable-core only -- task-077/081) ------------
2818
+ if [[ "$SUBCMD" == "__migrate-repo" ]]; then
2819
+ if [[ $# -lt 1 ]]; then
2820
+ echo "ERROR: aid __migrate-repo requires a <repo> path argument" >&2
2821
+ exit 2
2822
+ fi
2823
+ _MIG_TARGET="$1"
2824
+ if [[ ! -d "${_MIG_TARGET}" ]]; then
2825
+ echo "ERROR: aid __migrate-repo: not a directory: ${_MIG_TARGET}" >&2
2826
+ exit 2
2827
+ fi
2828
+ _MIG_TARGET="$(cd "${_MIG_TARGET}" && pwd)"
2829
+ _aid_migrate_repo "${_MIG_TARGET}"
2830
+ exit 0
2831
+ fi
2832
+
2833
+ # ---- add / remove / update --------------------------------------------------
2834
+ # These subcommands all share flag parsing; we then call the engine functions
2835
+ # (install_tool / uninstall_tool) directly through a per-tool loop, exactly as
2836
+ # install.sh does. We build a temporary staging area for install/update, and
2837
+ # reuse the same prepare_tool_staging + install_tool / uninstall_tool pattern.
2838
+
2839
+ # First, validate the subcommand.
2840
+ case "$SUBCMD" in
2841
+ add|remove|update) ;;
2842
+ *)
2843
+ echo "ERROR: aid: unknown command: ${SUBCMD} (see 'aid -h')" >&2
2844
+ exit 2
2845
+ ;;
2846
+ esac
2847
+
2848
+
2849
+ # Collect positional tool args (comma-separated or space-separated before flags).
663
2850
  _AID_POSITIONAL_TOOLS=""
664
2851
  _AID_REMOVE_FORCE=0
665
2852
 
@@ -709,6 +2896,24 @@ if [[ ! -d "$_AID_TARGET" ]]; then
709
2896
  fi
710
2897
  _AID_TARGET="$(cd "$_AID_TARGET" && pwd)"
711
2898
 
2899
+ # ---- C-table pre-check for 'update [tool]': missing .aid/ -> offer + exit 0 ----
2900
+ # Must run BEFORE _resolve_tools_for_aid so we never reach exit-6 when no .aid/ exists.
2901
+ # 'add' uses the B-table (checked inside the dispatch case); 'remove' is not in C-table.
2902
+ # _aid_is_project_dir excludes the CLI state home from the "is project" classification.
2903
+ if [[ "$SUBCMD" == "update" ]]; then
2904
+ if ! _aid_is_project_dir "${_AID_TARGET}"; then
2905
+ _aid_cwd_no_aid_offer "${_AID_TARGET}"
2906
+ # _aid_cwd_no_aid_offer always exits 0.
2907
+ fi
2908
+ fi
2909
+
2910
+ # ---- Self-update-if-needed preamble (FF-3 / CLI-2 / task-079) --------------
2911
+ # For 'update [<tool>]' only (not 'add', not 'update self'). Ensures the CLI
2912
+ # is current before the per-repo migration runs (FR38 / OQ-6). WARN-not-fail.
2913
+ if [[ "$SUBCMD" == "update" ]]; then
2914
+ _aid_update_self_if_stale
2915
+ fi
2916
+
712
2917
  # Strip leading 'v' from version.
713
2918
  _AID_VERSION_ARG="${_AID_VERSION_ARG#v}"
714
2919
 
@@ -858,8 +3063,22 @@ _prepare_tool_staging_aid() {
858
3063
  verify_bundle_checksum "$tarball" || exit $?
859
3064
  local tbase
860
3065
  tbase="$(basename "$tarball")"
861
- _AID_RESOLVED_VERSION="$(echo "$tbase" | sed "s/aid-${tool}-v//" | sed 's/\.tar\.gz$//')"
862
- [[ -z "$_AID_RESOLVED_VERSION" ]] && _AID_RESOLVED_VERSION="${version:-unknown}"
3066
+ # Derive the version STRICTLY from the canonical tool-bundle name
3067
+ # (aid-<tool>-v<semver>.tar.gz). Never stamp a raw filename: a name that
3068
+ # does not match this shape is the wrong artifact (e.g. the CLI installer
3069
+ # package aid-installer-<ver>.tgz), not a tool bundle. Fall back to an
3070
+ # explicit --version, else fail loudly rather than recording garbage.
3071
+ if [[ "$tbase" =~ ^aid-${tool}-v([0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.+-]+)?)\.tar\.gz$ ]]; then
3072
+ _AID_RESOLVED_VERSION="${BASH_REMATCH[1]}"
3073
+ elif [[ -n "${version:-}" ]]; then
3074
+ _AID_RESOLVED_VERSION="$version"
3075
+ else
3076
+ echo "ERROR: aid: '${tbase}' is not a valid AID tool bundle for '${tool}'." >&2
3077
+ echo " Expected a tarball named 'aid-${tool}-v<version>.tar.gz'." >&2
3078
+ echo " (Did you pass the CLI installer package instead of a tool bundle?" >&2
3079
+ echo " Point --from-bundle at a release staging dir or an aid-${tool}-v*.tar.gz file.)" >&2
3080
+ exit 2
3081
+ fi
863
3082
  extract_tarball "$tarball" "$tool_staging" || exit $?
864
3083
  else
865
3084
  if [[ -z "$version" ]]; then
@@ -880,31 +3099,59 @@ _prepare_tool_staging_aid() {
880
3099
  # ---------------------------------------------------------------------------
881
3100
  # Dispatch to engine.
882
3101
  # ---------------------------------------------------------------------------
883
- _AID_OVERALL_BLOCKED=0
884
-
885
3102
  case "$SUBCMD" in
886
3103
  add|update)
3104
+ # B-table (for 'add'): writability pre-check BEFORE any .aid/ is created.
3105
+ # Decision #3: never elevate .aid/ creation -- error if folder is not writable.
3106
+ if [[ "$SUBCMD" == "add" ]]; then
3107
+ if [[ ! -w "$_AID_TARGET" ]]; then
3108
+ echo "ERROR: aid: add: target directory is not writable: ${_AID_TARGET}" >&2
3109
+ echo "ERROR: aid: add: AID will not create a root-owned .aid/ -- fix folder permissions and retry." >&2
3110
+ rm -rf "$_AID_STAGING_BASE"
3111
+ exit 1
3112
+ fi
3113
+ fi
3114
+
3115
+ # C-table (for 'update [tool]'): register-on-encounter + format gate.
3116
+ # The missing-.aid/ case was already intercepted above (pre-resolve-tools).
3117
+ if [[ "$SUBCMD" == "update" ]]; then
3118
+ # C-table register-on-encounter (best-effort).
3119
+ _aid_cwd_classify "${_AID_TARGET}"
3120
+ # C6: format gate for the update repo path.
3121
+ _aid_format_gate "${_AID_TARGET}" || exit $?
3122
+ fi
3123
+
887
3124
  for _tool in "${_AID_TOOLS[@]}"; do
888
3125
  echo ""
889
3126
  _prepare_tool_staging_aid "$_tool" "$_AID_VERSION_ARG" "$_AID_FROM_BUNDLE"
890
3127
  echo "Installing ${_tool} v${_AID_RESOLVED_VERSION} -> ${_AID_TARGET}"
891
- install_tool "$_AID_STAGING_DIR" "$_tool" "$_AID_TARGET" "$_AID_RESOLVED_VERSION" "$_AID_FORCE" || {
892
- _RC=$?
893
- if [[ "$_RC" -eq 5 ]]; then
894
- _AID_OVERALL_BLOCKED=1
895
- else
896
- exit "$_RC"
897
- fi
898
- }
3128
+ install_tool "$_AID_STAGING_DIR" "$_tool" "$_AID_TARGET" "$_AID_RESOLVED_VERSION" "$_AID_FORCE" || exit $?
899
3129
  done
900
3130
 
901
3131
  echo ""
902
- if [[ "$_AID_OVERALL_BLOCKED" -eq 1 ]]; then
903
- echo "Install complete with warnings: one or more root agent files were not overwritten."
904
- echo "Review the *.aid-new file(s) and merge, or re-run with --force to overwrite."
905
- exit 5
906
- fi
907
3132
  echo "Done. AID ${_AID_RESOLVED_VERSION:-} installed into: ${_AID_TARGET}"
3133
+
3134
+ # B-table (for 'add'): tier-aware registration after successful install.
3135
+ # Decision #3 (unwritable) already handled above with error+abort.
3136
+ if [[ "$SUBCMD" == "add" ]]; then
3137
+ # FR7: deterministic, non-interactive tier selection via _aid_resolve_tier.
3138
+ # Honors _AID_TIER_OVERRIDE (--local/--shared) if already set by caller.
3139
+ _btab_tier="$(_aid_resolve_tier "$_AID_TARGET")"
3140
+ registry_register "$_AID_TARGET" "$_btab_tier"
3141
+ else
3142
+ # 'update [tool]': C-table register-on-encounter already ran above.
3143
+ # The post-install register is idempotent; route via user tier.
3144
+ registry_register "$_AID_TARGET" "user"
3145
+ fi
3146
+
3147
+ # FF-3 / CLI-2 / task-079: per-repo migration on the 'update' reach only.
3148
+ # Runs on the already-CAN-1-canonicalized $_AID_TARGET (cd && pwd above).
3149
+ # The registry_register above already ran, so migration step 4 is an
3150
+ # idempotent no-op; steps 1-3 run per FF-1. WARN-not-fail (NFR12):
3151
+ # migration never changes the tool-update exit code.
3152
+ if [[ "$SUBCMD" == "update" ]]; then
3153
+ _aid_migrate_repo "$_AID_TARGET"
3154
+ fi
908
3155
  exit 0
909
3156
  ;;
910
3157
 
@@ -926,6 +3173,10 @@ case "$SUBCMD" in
926
3173
 
927
3174
  echo ""
928
3175
  echo "Uninstall complete."
3176
+ # DR-1 registry side-effect: unregister repo only when the manifest is now gone (last tool removed).
3177
+ if [[ ! -f "$_AID_MANIFEST" ]]; then
3178
+ registry_unregister "$_AID_TARGET"
3179
+ fi
929
3180
  exit 0
930
3181
  ;;
931
3182
  esac