aid-installer 1.0.0 → 1.1.1

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.ps1 CHANGED
@@ -2,9 +2,9 @@
2
2
  # aid.ps1 - AID CLI dispatcher (PowerShell side).
3
3
  #
4
4
  # Purpose:
5
- # Persistent global command installed at $AID_HOME\bin\aid.ps1. Parses
5
+ # Persistent global command installed at $AID_CODE_HOME\bin\aid.ps1. Parses
6
6
  # subcommands and dispatches to the shared install-core engine located at
7
- # $AID_HOME\lib\AidInstallCore.psm1. Operates on the current working
7
+ # $AID_CODE_HOME\lib\AidInstallCore.psm1. Operates on the current working
8
8
  # directory (-Target / AID_TARGET overrides).
9
9
  #
10
10
  # Usage:
@@ -52,25 +52,91 @@ function script:Exit-Aid {
52
52
  }
53
53
 
54
54
  # ---------------------------------------------------------------------------
55
- # Locate $AID_HOME. The installed dispatcher lives at $AID_HOME\bin\aid.ps1.
55
+ # AID_CODE_HOME: self-locate the read-only code payload (parent of bin/).
56
+ # NEVER overridden by an env var. Error-out if unresolvable (Q1 fail-safe).
56
57
  # ---------------------------------------------------------------------------
57
58
  $script:_AidSelfPath = $MyInvocation.MyCommand.Path
58
- if (-not [string]::IsNullOrEmpty($script:_AidSelfPath)) {
59
- $script:_AidHome = $env:AID_HOME
60
- if (-not $script:_AidHome) {
61
- # bin/aid.ps1 -> parent of bin/ = AID_HOME
62
- $script:_AidHome = Split-Path -Parent (Split-Path -Parent $script:_AidSelfPath)
63
- }
59
+ if (-not [string]::IsNullOrEmpty($script:_AidSelfPath) -and (Test-Path $script:_AidSelfPath -PathType Leaf)) {
60
+ # bin/aid.ps1 -> parent of bin/ = AID_CODE_HOME
61
+ $script:_AidCodeHome = Split-Path -Parent (Split-Path -Parent $script:_AidSelfPath)
64
62
  } else {
65
- $script:_AidHome = if ($env:AID_HOME) { $env:AID_HOME } else {
66
- if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'aid' } else { Join-Path $HOME '.aid' }
63
+ [Console]::Error.WriteLine("ERROR: aid: cannot locate the AID code payload (AID_CODE_HOME unresolved). Re-run the AID bootstrap to repair.")
64
+ script:Exit-Aid 1
65
+ }
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Scope derivation: global iff AID_CODE_HOME is not writable by the current
69
+ # user. Mirrors bash: [[ ! -w "$AID_CODE_HOME" && "$(id -u)" -ne 0 ]].
70
+ # No install-time marker -- writability of the code payload is the sole test.
71
+ # AID_STATE_HOME: mutable state home, env-overridable via AID_HOME.
72
+ # ---------------------------------------------------------------------------
73
+ $script:_AidCodeHomeWritable = $false
74
+ try {
75
+ $_aidWriteTestFile = Join-Path $script:_AidCodeHome ('.aid-write-test.' + [System.IO.Path]::GetRandomFileName())
76
+ [System.IO.File]::WriteAllText($_aidWriteTestFile, '')
77
+ Remove-Item -LiteralPath $_aidWriteTestFile -Force -ErrorAction SilentlyContinue
78
+ $script:_AidCodeHomeWritable = $true
79
+ } catch {
80
+ $script:_AidCodeHomeWritable = $false
81
+ }
82
+
83
+ if ($script:_AidCodeHomeWritable) {
84
+ $script:_AidScope = 'user'
85
+ $script:_AidStateHome = if ($env:AID_HOME) { $env:AID_HOME } else { Join-Path $HOME '.aid' }
86
+ } else {
87
+ $script:_AidScope = 'global'
88
+ $script:_AidStateHome = if ($env:AID_HOME) { $env:AID_HOME } else {
89
+ if ($env:AID_SHARED_STATE_HOME) {
90
+ $env:AID_SHARED_STATE_HOME
91
+ } elseif ($env:ProgramData) {
92
+ Join-Path $env:ProgramData 'aid'
93
+ } else {
94
+ Join-Path $HOME '.aid'
95
+ }
67
96
  }
68
97
  }
69
98
 
70
99
  # ---------------------------------------------------------------------------
71
- # Import the shared install core from $AID_HOME\lib\.
100
+ # Test-AidIsProjectDir <Dir>
101
+ # Return $true iff <Dir> has a .aid/ subdirectory AND that subdirectory is NOT
102
+ # the CLI state home. Excludes the state home from "is project" classification
103
+ # so running 'aid' from $HOME (or any dir whose .aid/ == _AidStateHome) does not
104
+ # falsely auto-register or trigger the format gate.
105
+ #
106
+ # Guard: resolves <Dir>\.aid to a canonical path and compares against both
107
+ # Resolve-Path($script:_AidStateHome) and Resolve-Path($HOME\.aid).
108
+ # Mirror of bash _aid_is_project_dir.
109
+ # ---------------------------------------------------------------------------
110
+ function script:Test-AidIsProjectDir {
111
+ param([string]$Dir)
112
+ $aidSub = Join-Path $Dir '.aid'
113
+ if (-not (Test-Path $aidSub -PathType Container)) { return $false }
114
+ # Resolve .aid/ to canonical real path (tolerates non-existent intermediate).
115
+ $aidReal = try { (Resolve-Path -LiteralPath $aidSub -ErrorAction Stop).Path } catch { $aidSub }
116
+ # Resolve both state-home candidates.
117
+ $shReal = try { (Resolve-Path -LiteralPath $script:_AidStateHome -ErrorAction Stop).Path } catch { $script:_AidStateHome }
118
+ $hdAid = Join-Path $HOME '.aid'
119
+ $hdReal = try { (Resolve-Path -LiteralPath $hdAid -ErrorAction Stop).Path } catch { $hdAid }
120
+ # Compare using OrdinalIgnoreCase to handle Windows path normalization.
121
+ if ([string]::Equals($aidReal, $shReal, [System.StringComparison]::OrdinalIgnoreCase) -or
122
+ [string]::Equals($aidReal, $hdReal, [System.StringComparison]::OrdinalIgnoreCase)) {
123
+ return $false
124
+ }
125
+ return $true
126
+ }
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # C1': Per-repo format stamp constant.
130
+ # The current .aid/ layout version. Bumped ONLY on a breaking layout change,
131
+ # never on every CLI release. Defined exactly once; all comparisons read this.
132
+ # Integer must equal the bash AID_SUPPORTED_FORMAT in bin/aid.
72
133
  # ---------------------------------------------------------------------------
73
- $script:_CoreModule = Join-Path $script:_AidHome 'lib' | Join-Path -ChildPath 'AidInstallCore.psm1'
134
+ Set-Variable -Name AidSupportedFormat -Value 1 -Option Constant -Scope Script
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Import the shared install core from AID_CODE_HOME\lib\.
138
+ # ---------------------------------------------------------------------------
139
+ $script:_CoreModule = Join-Path $script:_AidCodeHome 'lib' | Join-Path -ChildPath 'AidInstallCore.psm1'
74
140
  if (-not (Test-Path $script:_CoreModule -PathType Leaf)) {
75
141
  [Console]::Error.WriteLine("ERROR: aid: install core not found at $($script:_CoreModule). Re-run the AID bootstrap to repair.")
76
142
  script:Exit-Aid 1
@@ -115,21 +181,57 @@ function script:Show-AidUsage {
115
181
  Write-Host ' Tools: claude-code, codex, cursor, copilot-cli, antigravity'
116
182
  }
117
183
  'remove' {
118
- Write-Host 'aid remove [<tool>[,<tool>...] | self] [-Force] [-Verbose] [-Target <dir>]'
184
+ Write-Host 'aid remove [<tool>[,<tool>...]] [-Force] [-Verbose] [-Target <dir>]'
185
+ Write-Host 'aid remove self [-Force] [-DryRun]'
119
186
  Write-Host ' Remove tool(s) from the current project (manifest-driven).'
120
187
  Write-Host ' No args: remove ALL AID from the project (asks for confirmation).'
121
- Write-Host ' self: remove the aid CLI itself (asks for confirmation).'
188
+ Write-Host ' self: COMPLETELY remove the aid CLI, channel-aware (asks for confirmation):'
189
+ Write-Host ' npm -> npm uninstall -g | pypi -> pipx uninstall | curl -> rm $AID_CODE_HOME + unwire PATH.'
190
+ Write-Host ' On Windows, elevation is the caller''s responsibility (no sudo).'
191
+ Write-Host ' -DryRun: print the exact command(s) it would run, then exit (no changes).'
122
192
  }
123
193
  'update' {
124
- Write-Host 'aid update [<tool>... | self] [-Version <v>] [-FromBundle <path>]'
125
- Write-Host ' [-Force] [-Verbose] [-Target <dir>]'
194
+ Write-Host 'aid update [<tool>...] [-Version <v>] [-FromBundle <path>] [-Force] [-Target <dir>]'
195
+ Write-Host 'aid update self [-FromBundle <path>] [-DryRun]'
126
196
  Write-Host ' Update to latest. No args: update all installed tools.'
127
- Write-Host ' self: update the aid CLI itself.'
197
+ Write-Host ' self: COMPLETELY update the aid CLI, channel-aware:'
198
+ Write-Host ' npm -> npm i -g | pypi -> pipx upgrade | curl -> re-bootstrap install.ps1.'
199
+ Write-Host ' On Windows, elevation is the caller''s responsibility (no sudo).'
200
+ Write-Host ' -FromBundle <path>: install the CLI from a local artifact instead of @latest'
201
+ Write-Host ' (npm .tgz | pypi .whl | curl release-staging dir with install.ps1).'
202
+ Write-Host ' -DryRun: print the exact command(s) it would run, then exit (no changes).'
128
203
  }
129
204
  'version' {
130
205
  Write-Host 'aid version'
131
206
  Write-Host ' Print the installed aid CLI version and exit 0.'
132
207
  }
208
+ 'dashboard' {
209
+ Write-Host 'aid dashboard start <node|python> [--remote] [--port <n>]'
210
+ Write-Host 'aid dashboard stop'
211
+ Write-Host ' Start or stop the machine-level pipeline dashboard (serves all registered projects).'
212
+ Write-Host ' <node|python> select the server runtime to launch.'
213
+ Write-Host ' --remote also expose it to authorized users over a private channel (never public);'
214
+ Write-Host ' fails clearly if that mechanism is unavailable -- never binds publicly.'
215
+ Write-Host ' --port <n> listen port on 127.0.0.1 (default 8787).'
216
+ Write-Host " The dashboard binds to 127.0.0.1 only. 'stop' is idempotent and also tears down --remote."
217
+ Write-Host ' Works from any directory (not tied to the current project).'
218
+ }
219
+ 'projects' {
220
+ Write-Host 'aid projects [list] [--local|--shared] [--verbose]'
221
+ Write-Host 'aid projects add [<path>] [--local|--shared]'
222
+ Write-Host 'aid projects remove [<path>]'
223
+ Write-Host ' List, register, or unregister AID projects in the registry.'
224
+ Write-Host ' list (default): show all registered projects with state, tools, and tier.'
225
+ Write-Host ' The current directory is marked with "*" in the leading marker column.'
226
+ Write-Host ' Unregistered cwd with .aid/ present is shown as a footnote.'
227
+ Write-Host ' add [path=cwd]: register a project (requires .aid/ to exist); tracking only,'
228
+ Write-Host ' no tools are installed. Idempotent. Prints the tier written.'
229
+ Write-Host ' remove [path=cwd]: unregister a project from the registry; no files removed.'
230
+ Write-Host ' Works on stale/missing/no-aid entries. Idempotent.'
231
+ Write-Host ' --local force user tier for add'
232
+ Write-Host ' --shared force shared tier for add'
233
+ Write-Host ' --verbose print extra detail'
234
+ }
133
235
  default {
134
236
  Write-Host 'aid - AID CLI'
135
237
  Write-Host ''
@@ -141,9 +243,11 @@ function script:Show-AidUsage {
141
243
  Write-Host ' aid add <tool>[,...] Add tool(s) to the current project'
142
244
  Write-Host ' aid update [<tool>... | self] Update to latest; no arg = all tools'
143
245
  Write-Host ' aid remove [<tool>... | self] Remove; no arg = ALL AID from project'
246
+ Write-Host ' aid dashboard start|stop ... Start/stop the local dashboard'
247
+ Write-Host ' aid projects [list|add|remove] List/register/unregister AID projects'
144
248
  Write-Host " aid <command> -h | --help Per-command help"
145
249
  Write-Host ''
146
- Write-Host 'Flags: -FromBundle, -Version, -Force, -Target, -Verbose'
250
+ Write-Host 'Flags: -FromBundle, -Version, -Force, -DryRun, -Target, -Verbose'
147
251
  Write-Host "Run 'aid <command> -h' for details."
148
252
  }
149
253
  }
@@ -165,7 +269,7 @@ function script:Fail-Aid {
165
269
  # Invoke-AidUpdateCheck
166
270
  # Compares installed CLI version against latest GitHub release.
167
271
  # Prints ONE notice line when newer is available. Fail-silent.
168
- # Throttle: re-fetches at most once per 24h; cache in $AID_HOME\.update-check.
272
+ # Throttle: re-fetches at most once per 24h; cache in $HOME\.aid\.update-check (FR10: always per-user).
169
273
  # Opt-out: $env:AID_NO_UPDATE_CHECK = '1'
170
274
  # Test hook: $env:AID_UPDATE_CHECK_URL overrides the fetch URL (and bypasses throttle).
171
275
  function script:Invoke-AidUpdateCheck {
@@ -173,12 +277,15 @@ function script:Invoke-AidUpdateCheck {
173
277
  if ($env:AID_NO_UPDATE_CHECK -eq '1') { return }
174
278
 
175
279
  # Read installed version.
176
- $verFile = Join-Path $script:_AidHome 'VERSION'
280
+ $verFile = Join-Path $script:_AidCodeHome 'VERSION'
177
281
  if (-not (Test-Path $verFile -PathType Leaf)) { return }
178
282
  $installedVersion = (Get-Content -LiteralPath $verFile -Raw -ErrorAction SilentlyContinue).Trim()
179
283
  if (-not $installedVersion) { return }
180
284
 
181
- $cacheFile = Join-Path $script:_AidHome '.update-check'
285
+ # FR10: .update-check is always per-user ($HOME/.aid), never AID_STATE_HOME.
286
+ # This ensures a routine version check never writes into /var/lib/aid on a
287
+ # root-owned global install and never triggers elevation.
288
+ $cacheFile = Join-Path $HOME (Join-Path '.aid' '.update-check')
182
289
  $throttleSecs = 86400 # 24 hours
183
290
  try { $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } catch { return }
184
291
 
@@ -259,41 +366,166 @@ function script:Invoke-AidUpdateCheck {
259
366
  if ($va -gt $vb) { break }
260
367
  }
261
368
  if ($isLt) {
262
- $updateCmd = switch ($env:AID_INSTALL_CHANNEL) {
263
- 'npm' { 'npm i -g aid-installer@latest' }
264
- 'pypi' { 'pipx upgrade aid-installer (or: pip install --user -U aid-installer)' }
265
- default { 'aid update self' }
266
- }
267
- Write-Host "A newer aid CLI is available: v$latestVersion (you have v$installedVersion). Run: $updateCmd"
369
+ # `aid update self` is now channel-aware and self-contained (it runs the
370
+ # right package manager + applies migrations), so point at it for every
371
+ # channel instead of a per-channel manual command.
372
+ Write-Host "A newer aid CLI is available: v$latestVersion (you have v$installedVersion). Run: aid update self"
268
373
  }
269
374
  }
270
375
 
271
376
  # Invoke-AidUpdateSelf
272
- # Re-runs the bootstrap in place. Relays bootstrap exit code.
273
- # AID_INSTALL_CHANNEL guard: npm/pypi channels print a package-manager hint
274
- # and exit 0 instead of re-bootstrapping.
377
+ # Channel-aware, self-contained CLI self-update. Returns the exit code (does NOT call Exit-Aid).
378
+ # Reads the channel from AID_INSTALL_CHANNEL (injected by the npm/pypi shims).
379
+ # Honors $script:_SelfFromBundle (a local CLI artifact: npm .tgz / pypi .whl / curl bundle dir)
380
+ # and $script:_SelfDryRun. On Windows there is no sudo -- if a privileged location is not
381
+ # writable, the underlying tool (npm/pipx) will surface its own error; callers elevate their
382
+ # own shell. Dry-run prints "+ <command>" lines and returns 0 without making changes.
383
+ # Callers are responsible for calling Exit-Aid after the update completes.
275
384
  function script:Invoke-AidUpdateSelf {
276
- switch ($env:AID_INSTALL_CHANNEL) {
385
+ # AID_SKIP_SELF_INSTALL: the package manager already (re)installed the CLI
386
+ # (postinstall) and only wants the post-update step to run. Skip the
387
+ # re-install step.
388
+ if ($env:AID_SKIP_SELF_INSTALL -eq '1') { return 0 }
389
+ $channel = $env:AID_INSTALL_CHANNEL
390
+ $bundle = $script:_SelfFromBundle
391
+ $dryRun = $script:_SelfDryRun
392
+
393
+ switch ($channel) {
277
394
  'npm' {
278
- Write-Host 'Updating the aid CLI: run npm i -g aid-installer@latest'
279
- script:Exit-Aid 0
280
- return
395
+ $npmCmd = Get-Command 'npm' -ErrorAction SilentlyContinue
396
+ if (-not $npmCmd) {
397
+ [Console]::Error.WriteLine("ERROR: aid: npm not found; cannot update the npm-channel CLI")
398
+ return 3
399
+ }
400
+ $pkg = if ($bundle) { $bundle } else { 'aid-installer@latest' }
401
+ if ($dryRun) {
402
+ Write-Host "+ npm install -g $pkg"
403
+ return 0
404
+ }
405
+ Write-Host 'Updating the aid CLI (npm channel)...'
406
+ & npm install -g $pkg
407
+ return $LASTEXITCODE
281
408
  }
282
409
  'pypi' {
283
- Write-Host 'Updating the aid CLI: run pipx upgrade aid-installer (or: pip install --user -U aid-installer)'
284
- script:Exit-Aid 0
285
- return
410
+ $pipxCmd = Get-Command 'pipx' -ErrorAction SilentlyContinue
411
+ if (-not $pipxCmd) {
412
+ [Console]::Error.WriteLine("ERROR: aid: pipx not found; cannot update the pypi-channel CLI")
413
+ return 3
414
+ }
415
+ if ($dryRun) {
416
+ if ($bundle) {
417
+ Write-Host "+ pipx install --force $bundle"
418
+ } else {
419
+ Write-Host '+ pipx upgrade aid-installer'
420
+ }
421
+ return 0
422
+ }
423
+ Write-Host 'Updating the aid CLI (pypi/pipx channel)...'
424
+ if ($bundle) {
425
+ & pipx install --force $bundle
426
+ } else {
427
+ & pipx upgrade aid-installer
428
+ }
429
+ return $LASTEXITCODE
286
430
  }
287
431
  }
432
+
433
+ # curl / default channel -- re-bootstrap install.ps1.
288
434
  Write-Host 'Updating the aid CLI...'
435
+ if ($bundle) {
436
+ # --from-bundle <dir> on the curl channel: a release-staging dir that
437
+ # carries install.ps1 + the CLI bundle + SHA256SUMS. Run it offline.
438
+ $installScript = Join-Path ($bundle.TrimEnd('/\')) 'install.ps1'
439
+ if (Test-Path $installScript -PathType Leaf) {
440
+ if ($dryRun) {
441
+ $bundleNorm = $bundle.TrimEnd('/\')
442
+ Write-Host "+ `$env:AID_CLI_BUNDLE_BASE='file://$bundleNorm'; `$env:AID_LIB_BASE='file://$bundleNorm'; & '$installScript'"
443
+ return 0
444
+ }
445
+ $bundleNorm = $bundle.TrimEnd('/\')
446
+ $env:AID_CLI_BUNDLE_BASE = "file://$bundleNorm"
447
+ $env:AID_LIB_BASE = "file://$bundleNorm"
448
+ & $installScript
449
+ return $LASTEXITCODE
450
+ }
451
+ [Console]::Error.WriteLine("ERROR: aid: -FromBundle <dir> for the curl channel must contain install.ps1 (got: $bundle)")
452
+ return 2
453
+ }
289
454
  $url = $script:_AidInstallUrl
455
+ if ($dryRun) {
456
+ Write-Host "+ irm $url | iex"
457
+ return 0
458
+ }
290
459
  try {
291
460
  $scriptContent = (Invoke-RestMethod -Uri $url -ErrorAction Stop)
292
461
  & ([scriptblock]::Create($scriptContent))
293
- script:Exit-Aid $LASTEXITCODE
462
+ return $LASTEXITCODE
294
463
  } catch {
295
464
  [Console]::Error.WriteLine("ERROR: aid: update self failed: $_")
296
- script:Exit-Aid 3
465
+ return 3
466
+ }
467
+ }
468
+
469
+ # Invoke-AidUpdateSelfIfStale (FF-3 preamble / CLI-2 / task-079)
470
+ # Self-update-if-needed preamble for the 'aid update [<tool>]' reach.
471
+ # Reuses Invoke-AidUpdateSelf channel logic gated by a skip-if-current check
472
+ # (OQ-6 resolved simplest-correct: compare installed $AID_CODE_HOME/VERSION against
473
+ # the cached .update-check latest; if stale -> call Invoke-AidUpdateSelf; if
474
+ # current or unknown -> silent no-op).
475
+ #
476
+ # Safety notes (no re-bootstrap/loop hazard):
477
+ # - Called only on 'update [<tool>]', not 'update self' or 'add'.
478
+ # - WARN-not-fail: self-update failure is logged; tool-install continues (NFR12).
479
+ function script:Invoke-AidUpdateSelfIfStale {
480
+ param([string]$FromBundle = '')
481
+
482
+ # Offline / explicit bundle install: when the caller supplied a local bundle, do NOT
483
+ # phone the package channel to self-update. The bundle is the source of truth for this
484
+ # install; reaching out to the registry would defeat an air-gapped or pre-release
485
+ # install (and could replace the running CLI behind the user's back). Mirrors bin/aid.
486
+ if ($FromBundle) { return }
487
+
488
+ # Read installed version from the code payload (read-only).
489
+ $verFile = Join-Path $script:_AidCodeHome 'VERSION'
490
+ $installed = ''
491
+ if (Test-Path $verFile -PathType Leaf) {
492
+ $installed = (Get-Content $verFile -Raw).Trim()
493
+ }
494
+ if (-not $installed) { return } # no installed version known -> skip
495
+
496
+ # Read cached latest version from per-user .update-check (FR10: always $HOME/.aid).
497
+ $cacheFile = Join-Path $HOME (Join-Path '.aid' '.update-check')
498
+ $cachedLatest = ''
499
+ if (Test-Path $cacheFile -PathType Leaf) {
500
+ $lines = Get-Content $cacheFile -ErrorAction SilentlyContinue
501
+ if ($lines -and $lines.Count -ge 2) {
502
+ $cachedLatest = ($lines[1]).Trim()
503
+ }
504
+ }
505
+ if (-not $cachedLatest) { return } # no cached latest known -> skip (no network call here)
506
+
507
+ # Skip if already current (string equality fast-path).
508
+ if ($installed -eq $cachedLatest) { return }
509
+
510
+ # Only self-update when the installed CLI is strictly OLDER than the cached latest.
511
+ # A newer installed version (e.g. an unreleased dev build) must never be downgraded
512
+ # to "latest". Parse both as [version]; if either is unparseable, skip conservatively.
513
+ # Mirrors the `sort -V` guard in bin/aid (never downgrade).
514
+ $installedVer = $null
515
+ $latestVer = $null
516
+ if (-not ([version]::TryParse($installed, [ref]$installedVer)) -or
517
+ -not ([version]::TryParse($cachedLatest, [ref]$latestVer))) {
518
+ return # unparseable version -> conservatively skip (never risk a downgrade)
519
+ }
520
+ if ($installedVer -ge $latestVer) { return } # installed >= latest -> nothing to do
521
+
522
+ # Stale: call the channel-appropriate self-update logic.
523
+ # WARN-not-fail: failure must not abort the tool-update.
524
+ Write-Host "aid update: CLI is not current (installed: $installed, available: $cachedLatest); self-updating before tool install..."
525
+ try {
526
+ $null = script:Invoke-AidUpdateSelf
527
+ } catch {
528
+ [Console]::Error.WriteLine("WARN: aid: self-update failed (continuing with tool install)")
297
529
  }
298
530
  }
299
531
 
@@ -313,57 +545,1433 @@ function script:Add-AidToPath {
313
545
  return
314
546
  }
315
547
 
316
- $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
317
- if (-not $currentPath) { $currentPath = '' }
318
-
319
- # Split on ';', filter empty, deduplicate while preserving order.
320
- $parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() }
321
- if ($parts -contains $BinDir) {
322
- # Already present - update in-process path and return silently.
323
- if ($env:Path -notmatch [regex]::Escape($BinDir)) {
324
- $env:Path = "$BinDir;$($env:Path)"
548
+ $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
549
+ if (-not $currentPath) { $currentPath = '' }
550
+
551
+ # Split on ';', filter empty, deduplicate while preserving order.
552
+ $parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() }
553
+ if ($parts -contains $BinDir) {
554
+ # Already present - update in-process path and return silently.
555
+ if ($env:Path -notmatch [regex]::Escape($BinDir)) {
556
+ $env:Path = "$BinDir;$($env:Path)"
557
+ }
558
+ return
559
+ }
560
+
561
+ $newParts = @($BinDir) + @($parts)
562
+ $newPath = $newParts -join ';'
563
+
564
+ # Safety guard: warn if exceeding ~2000 chars (Windows limit is 32767 but
565
+ # practical registry/shell limit is much lower for User PATH).
566
+ $safeLimit = 2000
567
+ if ($newPath.Length -gt $safeLimit) {
568
+ Write-Host "WARN: aid: User PATH would exceed $safeLimit chars. Skipping automatic PATH wiring."
569
+ Write-Host "Add `"$BinDir`" to your PATH manually via System Properties > Environment Variables."
570
+ return
571
+ }
572
+
573
+ [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
574
+
575
+ # Update in-process immediately so the convenience-chain first action works.
576
+ if ($env:Path -notmatch [regex]::Escape($BinDir)) {
577
+ $env:Path = "$BinDir;$($env:Path)"
578
+ }
579
+
580
+ Write-Host "PATH wiring added (User scope): $BinDir"
581
+ Write-Host "Open a new shell, or the PATH is already active in this session."
582
+ }
583
+
584
+ # Remove-AidFromPath <binDir>
585
+ # Remove binDir from User PATH idempotently.
586
+ function script:Remove-AidFromPath {
587
+ param([string]$BinDir)
588
+
589
+ $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
590
+ if (-not $currentPath) { return }
591
+
592
+ $parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() -ne $BinDir }
593
+ $newPath = $parts -join ';'
594
+
595
+ if ($newPath -ne $currentPath) {
596
+ [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
597
+ Write-Host "PATH wiring removed (User scope): $BinDir"
598
+ }
599
+ }
600
+
601
+ # ---------------------------------------------------------------------------
602
+ # Remote exposure helpers (feature-005 / LC-EXP-P).
603
+ # SEC-1: These helpers invoke ONLY 'tailscale serve' (tailnet-only). The public
604
+ # exposure verb is never used -- a bare grep for it returns nothing
605
+ # anywhere in this file (structural never-public, C1).
606
+ # SEC-6: --remote exposes the CLI home (all registered repos, OQ5/DR-4): a
607
+ # granted tailnet identity sees the full registered-repo list + each
608
+ # repo's home.html/kb.html/api/model. This is the accepted OQ5 trade-off
609
+ # -- a grantee is already a trusted operator of this host. The helpers
610
+ # below, the bind, and the teardown are UNCHANGED; only what the port
611
+ # serves changed (DR-2/task-047). Never-public (C1) and host/user-ACL
612
+ # scoping (C3) hold exactly as before.
613
+ # ---------------------------------------------------------------------------
614
+
615
+ # Invoke-AidRemoteExpose -Port <n>
616
+ # Bring up tailscale serve (tailnet-only) for a loopback port.
617
+ # stdout (exit 0): two lines: handle (tailscale-serve:<port>) + https URL.
618
+ # stderr: human messages, errors, FR18 ACL-grant guidance.
619
+ # exit: 0=ok 10=mechanism absent 11=non-loopback target 12=serve failed
620
+ function script:Invoke-AidRemoteExpose {
621
+ param([int]$Port)
622
+
623
+ # Step 1: Re-assert the loopback target (belt-and-suspenders, SEC-1).
624
+ if ($Port -le 0) {
625
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: expose target must be 127.0.0.1 (got: $Port)")
626
+ return 11
627
+ }
628
+
629
+ # Step 2a: availability -- tailscale on PATH?
630
+ if (-not (Get-Command 'tailscale' -ErrorAction SilentlyContinue)) {
631
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but tailscale is not on PATH; --remote is unavailable")
632
+ return 10
633
+ }
634
+
635
+ # Step 2b: availability -- node logged in and Running?
636
+ $tsStatusOut = ''
637
+ try { $tsStatusOut = (& tailscale status 2>&1) -join "`n" } catch { $tsStatusOut = '' }
638
+ if ($tsStatusOut -match '(?i)(not running|logged out|Stopped|NeedsLogin|NoState|not logged in)') {
639
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but tailscale is not running or not logged in (tailscale status: $tsStatusOut); --remote is unavailable")
640
+ return 10
641
+ }
642
+ if ([string]::IsNullOrEmpty($tsStatusOut)) {
643
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: --remote requested but tailscale status returned no output; --remote is unavailable")
644
+ return 10
645
+ }
646
+
647
+ # Step 3: Bring up Serve (tailnet-only; the public exposure verb is never invoked -- SEC-1).
648
+ $serveErr = ''
649
+ $serveRc = 0
650
+ try {
651
+ $serveErr = (& tailscale serve --bg $Port 2>&1) -join "`n"
652
+ $serveRc = $LASTEXITCODE
653
+ } catch {
654
+ $serveErr = "$_"
655
+ $serveRc = 1
656
+ }
657
+ if ($serveRc -ne 0) {
658
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: tailscale serve failed (rc=${serveRc}): $serveErr")
659
+ # Revert: take down the 443 frontend mapping (if it was partially set).
660
+ try { & tailscale serve --bg --https=443 off 2>&1 | Out-Null } catch {}
661
+ return 12
662
+ }
663
+
664
+ # Step 4: Resolve the private URL from tailscale's Self.DNSName (the MagicDNS name).
665
+ # NOTE: 'tailscale status --json' is PRETTY-PRINTED, so the regex tolerates whitespace
666
+ # after the colon; '--peers=false' isolates Self so a peer DNSName is never picked up.
667
+ # NEVER fall back to the machine hostname/FQDN here -- that is the local/corporate DNS
668
+ # domain, not the tailnet, and would produce a non-working URL + wrong ACL src.
669
+ $tsJson = ''
670
+ $nodeFqdn = ''
671
+ try { $tsJson = (& tailscale status --json --peers=false 2>&1) -join "`n" } catch { $tsJson = '' }
672
+ if ([string]::IsNullOrEmpty($tsJson)) {
673
+ try { $tsJson = (& tailscale status --json 2>&1) -join "`n" } catch { $tsJson = '' }
674
+ }
675
+ if (-not [string]::IsNullOrEmpty($tsJson)) {
676
+ # Scope the parse to the Self object so a peer DNSName can never be selected
677
+ # regardless of JSON ordering. Find "Self" index, then "Peer" index after it;
678
+ # use the substring between them (or from "Self" to end if no "Peer" present).
679
+ $selfIdx = $tsJson.IndexOf('"Self"')
680
+ $selfBlock = $tsJson
681
+ if ($selfIdx -ge 0) {
682
+ $peerIdx = $tsJson.IndexOf('"Peer"', $selfIdx + 1)
683
+ if ($peerIdx -gt $selfIdx) {
684
+ $selfBlock = $tsJson.Substring($selfIdx, $peerIdx - $selfIdx)
685
+ } else {
686
+ $selfBlock = $tsJson.Substring($selfIdx)
687
+ }
688
+ }
689
+ if ($selfBlock -match '"DNSName"\s*:\s*"([^"]*)"') {
690
+ $nodeFqdn = $matches[1] -replace '\.$', ''
691
+ }
692
+ }
693
+ if ([string]::IsNullOrEmpty($nodeFqdn)) {
694
+ # Defensive fallback: a *.ts.net host reported by 'tailscale serve status --json'.
695
+ $serveJson = ''
696
+ try { $serveJson = (& tailscale serve status --json 2>&1) -join "`n" } catch { $serveJson = '' }
697
+ if (-not [string]::IsNullOrEmpty($serveJson)) {
698
+ if ($serveJson -match '([a-z0-9-]+(\.[a-z0-9-]+)*\.ts\.net)') {
699
+ $nodeFqdn = $matches[1]
700
+ }
701
+ }
702
+ }
703
+ if (-not [string]::IsNullOrEmpty($nodeFqdn)) {
704
+ $privateUrl = "https://${nodeFqdn}/"
705
+ } else {
706
+ # Could not resolve the tailnet MagicDNS name. Do NOT fabricate a public-domain URL.
707
+ $privateUrl = "(unresolved: run 'tailscale status' to find this host's .ts.net name)"
708
+ }
709
+
710
+ # Resolve display values for the ACL-grant guidance. The grant *src* is an identity only
711
+ # you can choose (login/group/tag); a DNS domain is not a valid selector, so AID shows a
712
+ # placeholder. The *dst* is THIS host's tailnet short-name.
713
+ $nodeShort = ($nodeFqdn -split '\.')[0]
714
+ if ([string]::IsNullOrEmpty($nodeShort) -and -not [string]::IsNullOrEmpty($tsJson)) {
715
+ # Same Self-scoping applied to HostName extraction.
716
+ $selfIdx2 = $tsJson.IndexOf('"Self"')
717
+ $selfBlockHn = $tsJson
718
+ if ($selfIdx2 -ge 0) {
719
+ $peerIdx2 = $tsJson.IndexOf('"Peer"', $selfIdx2 + 1)
720
+ if ($peerIdx2 -gt $selfIdx2) {
721
+ $selfBlockHn = $tsJson.Substring($selfIdx2, $peerIdx2 - $selfIdx2)
722
+ } else {
723
+ $selfBlockHn = $tsJson.Substring($selfIdx2)
724
+ }
725
+ }
726
+ if ($selfBlockHn -match '"HostName"\s*:\s*"([^"]*)"') {
727
+ $nodeShort = $matches[1].ToLower()
728
+ }
729
+ }
730
+
731
+ # Step 5: Print FR18 ACL-grant guidance to STDERR (informational only).
732
+ $srcPlaceholder = '<you@example.com>'
733
+ $dstPlaceholder = if ($nodeShort) { $nodeShort } else { '<this-host>' }
734
+
735
+ [Console]::Error.WriteLine('')
736
+ [Console]::Error.WriteLine('Remote exposure is UP (tailnet-private). Every device on your tailnet can now reach this host.')
737
+ [Console]::Error.WriteLine('To restrict access to only you, add a deny-by-default ACL grant in the tailnet policy file:')
738
+ [Console]::Error.WriteLine(' https://login.tailscale.com/admin/acls/file')
739
+ [Console]::Error.WriteLine(" {`"grants`":[{`"src`":[`"$srcPlaceholder`"],`"dst`":[`"$dstPlaceholder`"],`"ip`":[`"tcp:443`"]}]}")
740
+ [Console]::Error.WriteLine("Note: granted identities see all registered project paths/names. See 'aid dashboard --help'.")
741
+ [Console]::Error.WriteLine('')
742
+
743
+ # Step 6: Emit handle + URL on stdout, exit 0.
744
+ Write-Output "tailscale-serve:$Port"
745
+ Write-Output $privateUrl
746
+ return 0
747
+ }
748
+
749
+ # Invoke-AidRemoteTeardown -Handle <s>
750
+ # Revert the tailscale serve mapping created by Invoke-AidRemoteExpose.
751
+ # exit: 0=ok/idempotent 13=revert warned
752
+ function script:Invoke-AidRemoteTeardown {
753
+ param([string]$Handle = '')
754
+
755
+ # Step 1: Parse the handle; malformed/empty -> idempotent exit 0.
756
+ if ([string]::IsNullOrEmpty($Handle)) {
757
+ return 0
758
+ }
759
+ if ($Handle -notmatch '^tailscale-serve:([0-9]+)$') {
760
+ # Malformed handle -- nothing to tear down.
761
+ return 0
762
+ }
763
+ # We don't use the port for teardown (we target the HTTPS:443 frontend, not the backend port).
764
+
765
+ # Step 2: If tailscale is gone now -> WARN, exit 0.
766
+ if (-not (Get-Command 'tailscale' -ErrorAction SilentlyContinue)) {
767
+ [Console]::Error.WriteLine("WARN: aid: dashboard: tailscale not found; cannot revert serve mapping (handle: $Handle)")
768
+ return 0
769
+ }
770
+
771
+ # Step 3: Revert the HTTPS:443 frontend mapping (not a backend port off).
772
+ $offErr = ''
773
+ $offRc = 0
774
+ try {
775
+ $offErr = (& tailscale serve --bg --https=443 off 2>&1) -join "`n"
776
+ $offRc = $LASTEXITCODE
777
+ } catch {
778
+ $offErr = "$_"
779
+ $offRc = 1
780
+ }
781
+ if ($offRc -ne 0) {
782
+ # Fallback: check if serve status shows no other mappings; if so, reset.
783
+ $srvStatus = ''
784
+ try { $srvStatus = (& tailscale serve status 2>&1) -join "`n" } catch { $srvStatus = '' }
785
+ $mappingCount = ([regex]::Matches($srvStatus, '(?:https?://|tcp://)') | Measure-Object).Count
786
+ if ($mappingCount -le 1) {
787
+ try { & tailscale serve reset 2>&1 | Out-Null } catch {}
788
+ # After reset, exit 0 -- best effort.
789
+ return 0
790
+ }
791
+ [Console]::Error.WriteLine("WARN: aid: dashboard: tailscale serve --https=443 off failed (rc=${offRc}): $offErr")
792
+ return 13
793
+ }
794
+
795
+ # Step 4: exit 0 on clean revert.
796
+ return 0
797
+ }
798
+
799
+ # ---------------------------------------------------------------------------
800
+ # Dashboard control (aid dashboard start|stop).
801
+ # ---------------------------------------------------------------------------
802
+ function script:Invoke-AidDashboardCtl {
803
+ param([string[]]$DcArgs)
804
+
805
+ $verb = if ($DcArgs -and $DcArgs.Count -gt 0) { $DcArgs[0] } else { '' }
806
+ $rest = [string[]]@(if ($DcArgs -and $DcArgs.Count -gt 1) { $DcArgs[1..($DcArgs.Count - 1)] } else { @() })
807
+
808
+ # Top-level help.
809
+ if ($verb -in @('-h', '--help', '-Help')) {
810
+ script:Show-AidUsage 'dashboard'
811
+ script:Exit-Aid 0
812
+ }
813
+
814
+ if ($verb -ne 'start' -and $verb -ne 'stop') {
815
+ if ([string]::IsNullOrEmpty($verb)) {
816
+ [Console]::Error.WriteLine('ERROR: aid: dashboard requires a verb: start or stop (e.g. aid dashboard start python)')
817
+ script:Exit-Aid 2
818
+ }
819
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: unknown verb '$verb' (expected: start or stop)")
820
+ script:Exit-Aid 2
821
+ }
822
+
823
+ # --- shared arg parsing ---
824
+ $dcVerbose = $false
825
+ $dcPort = 8787
826
+ $dcRemote = $false
827
+ $dcRuntime = ''
828
+
829
+ $idx = 0
830
+ if ($verb -eq 'start') {
831
+ # First positional after verb is runtime (if not a flag).
832
+ if ($rest.Count -gt 0 -and -not $rest[0].StartsWith('-')) {
833
+ $dcRuntime = $rest[0]
834
+ $idx = 1
835
+ }
836
+ }
837
+
838
+ while ($idx -lt $rest.Count) {
839
+ $a = $rest[$idx]
840
+ switch ($a) {
841
+ { $_ -in @('-h', '--help', '-Help') } {
842
+ script:Show-AidUsage 'dashboard'
843
+ script:Exit-Aid 0
844
+ }
845
+ { $_ -in @('-Verbose', '--verbose') } { $dcVerbose = $true }
846
+ { $_ -in @('-Remote', '--remote') } {
847
+ if ($verb -eq 'stop') {
848
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
849
+ script:Exit-Aid 2
850
+ }
851
+ $dcRemote = $true
852
+ }
853
+ { $_ -in @('-Port', '--port') } {
854
+ if ($verb -eq 'stop') {
855
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
856
+ script:Exit-Aid 2
857
+ }
858
+ $idx++
859
+ if ($idx -ge $rest.Count) {
860
+ [Console]::Error.WriteLine('ERROR: aid: dashboard: --port requires a value')
861
+ script:Exit-Aid 2
862
+ }
863
+ $portVal = $rest[$idx]
864
+ if ($portVal -notmatch '^\d+$' -or [int]$portVal -lt 1024 -or [int]$portVal -gt 65535) {
865
+ [Console]::Error.WriteLine('ERROR: aid: dashboard: --port must be an integer in 1024..65535')
866
+ script:Exit-Aid 2
867
+ }
868
+ $dcPort = [int]$portVal
869
+ }
870
+ default {
871
+ if ($a.StartsWith('-')) {
872
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
873
+ script:Exit-Aid 2
874
+ }
875
+ # Stray positional.
876
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: unknown flag: $a")
877
+ script:Exit-Aid 2
878
+ }
879
+ }
880
+ $idx++
881
+ }
882
+
883
+ if ($verb -eq 'start') {
884
+ script:Invoke-DcStart -Runtime $dcRuntime -Port $dcPort -Remote $dcRemote -Verbose $dcVerbose
885
+ } else {
886
+ script:Invoke-DcStop -Verbose $dcVerbose
887
+ }
888
+ }
889
+
890
+ function script:Invoke-DcStart {
891
+ param([string]$Runtime, [int]$Port, [bool]$Remote, [bool]$Verbose)
892
+
893
+ # Step 1: validate runtime.
894
+ if ([string]::IsNullOrEmpty($Runtime)) {
895
+ [Console]::Error.WriteLine('ERROR: aid: dashboard start requires a runtime: node or python (e.g. aid dashboard start python)')
896
+ script:Exit-Aid 2
897
+ }
898
+ if ($Runtime -ne 'node' -and $Runtime -ne 'python') {
899
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: unknown runtime '$Runtime' (expected: node or python)")
900
+ script:Exit-Aid 2
901
+ }
902
+
903
+ # pid/log live in the per-user state home (.temp), always writable.
904
+ # FR10 precedent: always per-user $HOME/.aid, never AID_STATE_HOME on global installs.
905
+ $pidFile = Join-Path $HOME (Join-Path '.aid' (Join-Path '.temp' 'dashboard.pid'))
906
+ $logFile = Join-Path $HOME (Join-Path '.aid' (Join-Path '.temp' 'dashboard.log'))
907
+
908
+ # Step 4: already-running guard (stale-record reclaim included).
909
+ if (Test-Path $pidFile -PathType Leaf) {
910
+ $pidContent = Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue
911
+ $existingPid = 0
912
+ $existingPort = 0
913
+ $existingRuntime = ''
914
+ if ($pidContent -match '"pid"\s*:\s*(\d+)') { $existingPid = [int]$matches[1] }
915
+ if ($pidContent -match '"port"\s*:\s*(\d+)') { $existingPort = [int]$matches[1] }
916
+ if ($pidContent -match '"runtime"\s*:\s*"([^"]+)"') { $existingRuntime = $matches[1] }
917
+ $procAlive = $false
918
+ if ($existingPid -gt 0) {
919
+ try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $procAlive = $true } catch {}
920
+ }
921
+ if ($procAlive) {
922
+ Write-Host "aid: dashboard already running (runtime $existingRuntime, http://127.0.0.1:${existingPort}); run 'aid dashboard stop' first."
923
+ script:Exit-Aid 8
924
+ } else {
925
+ # Stale record: reclaim silently (or verbosely).
926
+ if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: reclaiming stale record (pid $existingPid is dead)") }
927
+ Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
928
+ if (Test-Path $logFile -PathType Leaf) { Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue }
929
+ }
930
+ }
931
+
932
+ # Step 5: check runtime on PATH.
933
+ if ($Runtime -eq 'python') {
934
+ $interp = 'python3'
935
+ if (-not (Get-Command 'python3' -ErrorAction SilentlyContinue)) {
936
+ [Console]::Error.WriteLine('ERROR: aid: dashboard: python3 not found on PATH (install it, or try: aid dashboard start node)')
937
+ script:Exit-Aid 9
938
+ }
939
+ } else {
940
+ $interp = 'node'
941
+ if (-not (Get-Command 'node' -ErrorAction SilentlyContinue)) {
942
+ [Console]::Error.WriteLine('ERROR: aid: dashboard: node not found on PATH (install it, or try: aid dashboard start python)')
943
+ script:Exit-Aid 9
944
+ }
945
+ }
946
+
947
+ # Step 6: locate the server entry point.
948
+ # <assets> = $AID_CODE_HOME/dashboard (the co-vendored server+reader unit in the install tree).
949
+ $assetsDir = Join-Path $script:_AidCodeHome 'dashboard'
950
+ if ($Runtime -eq 'python') {
951
+ $entryPoint = Join-Path $assetsDir (Join-Path 'server' 'server.py')
952
+ } else {
953
+ $entryPoint = Join-Path $assetsDir (Join-Path 'server' 'server.mjs')
954
+ }
955
+ if (-not (Test-Path $entryPoint -PathType Leaf)) {
956
+ [Console]::Error.WriteLine("ERROR: aid: dashboard: the dashboard server is missing from the install tree ($Runtime entry-point not found at $entryPoint); run 'aid update' or reinstall aid")
957
+ script:Exit-Aid 7
958
+ }
959
+
960
+ # Ensure log dir exists (per-user state home, always writable).
961
+ $tempDir = Join-Path $HOME (Join-Path '.aid' '.temp')
962
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
963
+
964
+ # Step 7: spawn the server child (detached daemon).
965
+ # SEC-1: literal 127.0.0.1 -- never read from input/config/env.
966
+ # WINDOWS/POWERSHELL: do NOT pass -RedirectStandardOutput/-RedirectStandardError here.
967
+ # Start-Process WITH redirection uses full handle inheritance, so the long-lived server
968
+ # inherits and holds open the caller's stdout/stderr pipe; a caller that captures our output
969
+ # (e.g. `$out = aid dashboard start 2>&1`, as the CI smoke does) then HANGS forever waiting
970
+ # for EOF. Omitting redirection makes Start-Process use ShellExecute, which does NOT inherit
971
+ # the caller's handles (no hang) and fully detaches the daemon -- the Windows analog of the
972
+ # Bash launcher's `setsid`. Trade-off: the server's own stdout/stderr are not file-captured on
973
+ # Windows; readiness is verified by TCP poll (not the log), so start/stop/status are
974
+ # behaviour-identical. (KI: Windows dashboard server log not captured; Bash captures it via
975
+ # `setsid ... >"$log_file" 2>&1`.)
976
+ # The multi-repo server (feature-010) serves every registered repo from the registry
977
+ # under AID_STATE_HOME; export AID_HOME=AID_STATE_HOME so the server resolves the
978
+ # registry via its legacy AID_HOME env var (delivery-008 seam).
979
+ $env:AID_HOME = $script:_AidStateHome
980
+ $spawnArgs = @($entryPoint, '--host', '127.0.0.1', '--port', "$Port")
981
+ $proc = Start-Process -FilePath $interp `
982
+ -ArgumentList $spawnArgs `
983
+ -PassThru `
984
+ -WindowStyle Hidden
985
+
986
+ $childPid = $proc.Id
987
+
988
+ if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: spawned $Runtime server (pid $childPid, port $Port)") }
989
+
990
+ # Step 8: bounded readiness wait (~5s, poll TCP socket).
991
+ $ready = $false
992
+ $attempts = 0
993
+ $maxAttempts = 50 # 50 x 0.1s = 5s
994
+ while ($attempts -lt $maxAttempts) {
995
+ # Check child is still alive.
996
+ $childAlive = $false
997
+ try { $null = Get-Process -Id $childPid -ErrorAction Stop; $childAlive = $true } catch {}
998
+ if (-not $childAlive) {
999
+ # Child exited early.
1000
+ [Console]::Error.WriteLine('ERROR: aid: dashboard: server failed to start (last log lines, if any):')
1001
+ if (Test-Path $logFile -PathType Leaf) {
1002
+ Get-Content -LiteralPath $logFile -Tail 10 -ErrorAction SilentlyContinue | ForEach-Object { [Console]::Error.WriteLine($_) }
1003
+ Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue
1004
+ }
1005
+ script:Exit-Aid 3
1006
+ }
1007
+ # Try TCP connect to 127.0.0.1:<Port>.
1008
+ try {
1009
+ $tcpClient = [System.Net.Sockets.TcpClient]::new()
1010
+ $tcpClient.Connect('127.0.0.1', $Port)
1011
+ $tcpClient.Close()
1012
+ $ready = $true
1013
+ break
1014
+ } catch {}
1015
+ Start-Sleep -Milliseconds 100
1016
+ $attempts++
1017
+ }
1018
+
1019
+ # Check if child is still alive even if not ready (timeout case).
1020
+ if (-not $ready) {
1021
+ $childAlive = $false
1022
+ try { $null = Get-Process -Id $childPid -ErrorAction Stop; $childAlive = $true } catch {}
1023
+ if (-not $childAlive) {
1024
+ [Console]::Error.WriteLine('ERROR: aid: dashboard: server failed to start (last log lines, if any):')
1025
+ if (Test-Path $logFile -PathType Leaf) {
1026
+ Get-Content -LiteralPath $logFile -Tail 10 -ErrorAction SilentlyContinue | ForEach-Object { [Console]::Error.WriteLine($_) }
1027
+ Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue
1028
+ }
1029
+ script:Exit-Aid 3
1030
+ }
1031
+ # Timeout but pid alive: warn and continue.
1032
+ [Console]::Error.WriteLine("WARN: aid: dashboard: server started but not yet responding on :${Port}; check $logFile")
1033
+ }
1034
+
1035
+ # Step 9: write dashboard.pid JSON record (DM-1).
1036
+ $startedAt = [System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
1037
+ if (-not $startedAt) { $startedAt = 'unknown' }
1038
+ # String.Replace (literal) doubles each backslash for valid JSON on Windows paths.
1039
+ $pidJson = @"
1040
+ {
1041
+ "schema": 1,
1042
+ "pid": $childPid,
1043
+ "runtime": "$Runtime",
1044
+ "port": $Port,
1045
+ "bind": "127.0.0.1",
1046
+ "remote": false,
1047
+ "remote_handle": null,
1048
+ "started_at": "$startedAt",
1049
+ "logfile": "$($logFile.Replace('\', '\\'))"
1050
+ }
1051
+ "@
1052
+ [System.IO.File]::WriteAllText($pidFile, $pidJson)
1053
+
1054
+ # Step 10: --remote: invoke Invoke-AidRemoteExpose; update record on success.
1055
+ if ($Remote) {
1056
+ # Capture all pipeline output; let stderr (guidance + errors) flow to the user.
1057
+ # Invoke-AidRemoteExpose emits handle+URL via Write-Output (strings) and the return
1058
+ # integer via 'return <n>' -- both land on the pipeline. We split by type.
1059
+ $rawOut = @(script:Invoke-AidRemoteExpose -Port $Port)
1060
+ $exposeRc = 0
1061
+ $exposeHandle = ''
1062
+ $exposeUrl = ''
1063
+ $strLines = [System.Collections.Generic.List[string]]::new()
1064
+ foreach ($item in $rawOut) {
1065
+ if ($item -is [int]) { $exposeRc = $item }
1066
+ elseif ($item -ne $null) { $strLines.Add([string]$item) }
1067
+ }
1068
+ if ($exposeRc -ne 0) {
1069
+ # All expose failures (10/11/12) map to user-facing exit 10.
1070
+ # dashboard stays local-only (server remains running).
1071
+ [Console]::Error.WriteLine("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}.")
1072
+ script:Exit-Aid 10
1073
+ }
1074
+ if ($strLines.Count -ge 1) { $exposeHandle = $strLines[0] }
1075
+ if ($strLines.Count -ge 2) { $exposeUrl = $strLines[1] }
1076
+ # Update the record with remote=true and the handle.
1077
+ $pidJson2 = @"
1078
+ {
1079
+ "schema": 1,
1080
+ "pid": $childPid,
1081
+ "runtime": "$Runtime",
1082
+ "port": $Port,
1083
+ "bind": "127.0.0.1",
1084
+ "remote": true,
1085
+ "remote_handle": "$exposeHandle",
1086
+ "started_at": "$startedAt",
1087
+ "logfile": "$($logFile.Replace('\', '\\'))"
1088
+ }
1089
+ "@
1090
+ [System.IO.File]::WriteAllText($pidFile, $pidJson2)
1091
+ # Step 11 (remote success): print local URL + remote URL.
1092
+ Write-Host "Dashboard ($Runtime) running at http://127.0.0.1:${Port} -- stop with: aid dashboard stop"
1093
+ if ($exposeUrl -like 'https://*') {
1094
+ Write-Host "Remote (private): $exposeUrl"
1095
+ } else {
1096
+ Write-Host "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."
1097
+ }
1098
+ script:Exit-Aid 0
1099
+ }
1100
+
1101
+ # Step 11: print success (local-only).
1102
+ Write-Host "Dashboard ($Runtime) running at http://127.0.0.1:${Port} -- stop with: aid dashboard stop"
1103
+ script:Exit-Aid 0
1104
+ }
1105
+
1106
+ function script:Invoke-DcStop {
1107
+ param([bool]$Verbose)
1108
+
1109
+ $pidFile = Join-Path $HOME (Join-Path '.aid' (Join-Path '.temp' 'dashboard.pid'))
1110
+
1111
+ # Step 3: read record; absent or stale -> idempotent exit 0.
1112
+ if (-not (Test-Path $pidFile -PathType Leaf)) {
1113
+ Write-Host 'aid: dashboard: not running (nothing to stop).'
1114
+ script:Exit-Aid 0
1115
+ }
1116
+
1117
+ $pidContent = Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue
1118
+ $existingPid = 0
1119
+ $logFile = ''
1120
+ if ($pidContent -match '"pid"\s*:\s*(\d+)') { $existingPid = [int]$matches[1] }
1121
+ if ($pidContent -match '"logfile"\s*:\s*"([^"]+)"') { $logFile = $matches[1] }
1122
+
1123
+ $procAlive = $false
1124
+ if ($existingPid -gt 0) {
1125
+ try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $procAlive = $true } catch {}
1126
+ }
1127
+
1128
+ if (-not $procAlive) {
1129
+ if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: record exists but pid $existingPid is dead; cleaning up.") }
1130
+ Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
1131
+ if ($logFile -and (Test-Path $logFile -PathType Leaf)) { Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue }
1132
+ Write-Host 'aid: dashboard: not running (nothing to stop).'
1133
+ script:Exit-Aid 0
1134
+ }
1135
+
1136
+ # Step 4: --remote teardown (if the record says remote=true, call Invoke-AidRemoteTeardown).
1137
+ $existingRemote = ''
1138
+ $existingHandle = ''
1139
+ if ($pidContent -match '"remote"\s*:\s*(true|false)') { $existingRemote = $matches[1] }
1140
+ if ($pidContent -match '"remote_handle"\s*:\s*"([^"]*)"') { $existingHandle = $matches[1] }
1141
+ if ($existingRemote -eq 'true' -and -not [string]::IsNullOrEmpty($existingHandle)) {
1142
+ if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: tearing down remote exposure (handle: $existingHandle)") }
1143
+ $teardownRc = script:Invoke-AidRemoteTeardown -Handle $existingHandle
1144
+ if ($teardownRc -eq 13) {
1145
+ [Console]::Error.WriteLine('WARN: aid: dashboard: remote teardown reported a warning; continuing server shutdown')
1146
+ }
1147
+ }
1148
+
1149
+ # Step 5: terminate the process cleanly.
1150
+ if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: sending Stop-Process to pid $existingPid") }
1151
+ try { Stop-Process -Id $existingPid -ErrorAction SilentlyContinue } catch {}
1152
+
1153
+ # Wait up to ~5s for exit.
1154
+ $waited = 0
1155
+ while ($waited -lt 50) {
1156
+ $stillAlive = $false
1157
+ try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $stillAlive = $true } catch {}
1158
+ if (-not $stillAlive) { break }
1159
+ Start-Sleep -Milliseconds 100
1160
+ $waited++
1161
+ }
1162
+
1163
+ # Escalate to -Force if still alive.
1164
+ $stillAlive = $false
1165
+ try { $null = Get-Process -Id $existingPid -ErrorAction Stop; $stillAlive = $true } catch {}
1166
+ if ($stillAlive) {
1167
+ if ($Verbose) { [Console]::Error.WriteLine("aid: dashboard: escalating to Stop-Process -Force on pid $existingPid") }
1168
+ try { Stop-Process -Id $existingPid -Force -ErrorAction SilentlyContinue } catch {}
1169
+ }
1170
+
1171
+ # Step 6: remove record and logfile, print success.
1172
+ Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
1173
+ if ($logFile -and (Test-Path $logFile -PathType Leaf)) { Remove-Item -LiteralPath $logFile -Force -ErrorAction SilentlyContinue }
1174
+ Write-Host 'aid: dashboard stopped.'
1175
+ script:Exit-Aid 0
1176
+ }
1177
+
1178
+ # ---------------------------------------------------------------------------
1179
+ # C4': Get-AidRepoFormat <Repo>
1180
+ # Read the format_version stamp from <Repo>/.aid/settings.yml.
1181
+ # Greps the FIRST ^format_version: line, replicates the era-a closure strip
1182
+ # logic inline (prefix strip, trim, inline # comment strip, quote-unwrap),
1183
+ # validates as ^\d+$; returns the integer.
1184
+ # Collapses absent/empty/non-integer/malformed/negative to 0 (legacy default).
1185
+ # Never returns a value > sup from a garbled stamp (fail-safe).
1186
+ # Defined here (before dispatch) so it is available to status/bare-aid/update.
1187
+ # ---------------------------------------------------------------------------
1188
+ function script:Get-AidRepoFormat {
1189
+ param([string]$Repo)
1190
+ $settingsFile = Join-Path $Repo (Join-Path '.aid' 'settings.yml')
1191
+ if (-not (Test-Path $settingsFile -PathType Leaf)) { return 0 }
1192
+ # First-match read (parity with duplicate-line policy).
1193
+ $rawLine = $null
1194
+ foreach ($ln in (Get-Content -LiteralPath $settingsFile -Encoding utf8 -ErrorAction SilentlyContinue)) {
1195
+ if ($ln -match '^format_version:') { $rawLine = $ln; break }
1196
+ }
1197
+ if (-not $rawLine) { return 0 }
1198
+ # Replicate the era-a closure strip logic inline (column-0 key variant).
1199
+ # Step 1: strip the "format_version:" prefix.
1200
+ $val = $rawLine -replace '^format_version:', ''
1201
+ # Step 2: strip one optional leading space (the colon-space separator).
1202
+ if ($val.StartsWith(' ')) { $val = $val.Substring(1) }
1203
+ # Step 3: strip inline # comment (first " #" to end of line).
1204
+ $commentIdx = $val.IndexOf(' #')
1205
+ if ($commentIdx -ge 0) { $val = $val.Substring(0, $commentIdx) }
1206
+ # Step 4: quote-unwrap (double then single).
1207
+ $val = $val.Trim('"').Trim("'")
1208
+ # Step 5: full trim (remaining whitespace).
1209
+ $val = $val.Trim()
1210
+ # Step 6: validate non-negative integer; collapse anything else to 0.
1211
+ if ($val -match '^\d+$') { return [int]$val }
1212
+ return 0
1213
+ }
1214
+
1215
+ # ---------------------------------------------------------------------------
1216
+ # C5': Invoke-AidFormatGate <Repo>
1217
+ # 3-way classify <Repo>'s format stamp vs $AidSupportedFormat:
1218
+ # repo > sup -> refuse (stderr, return 1, no .aid/ write)
1219
+ # repo < sup -> warn + offer aid update (stdout, return 0, non-blocking)
1220
+ # repo == sup -> silent (return 0)
1221
+ # AID_NO_MIGRATE=1 suppresses the warn+offer notice only; never the refuse.
1222
+ # Defined here (before dispatch) so it is available to status/bare-aid/update.
1223
+ # ---------------------------------------------------------------------------
1224
+ function script:Invoke-AidFormatGate {
1225
+ param([string]$Repo)
1226
+ $repoFmt = script:Get-AidRepoFormat -Repo $Repo
1227
+ $sup = $script:AidSupportedFormat
1228
+ if ($repoFmt -gt $sup) {
1229
+ [Console]::Error.WriteLine("ERROR: aid: project format $repoFmt is newer than this CLI supports ($sup). Upgrade the aid CLI to operate on this project.")
1230
+ return 1
1231
+ }
1232
+ if ($repoFmt -lt $sup) {
1233
+ $manifestPath = Join-Path $Repo (Join-Path '.aid' '.aid-manifest.json')
1234
+ if ($env:AID_NO_MIGRATE -ne '1' -and (Test-Path $manifestPath -PathType Leaf)) {
1235
+ Write-Host "WARN: aid: this project uses an older format (v${repoFmt}; current: v${sup}). Run: aid update"
1236
+ }
1237
+ return 0
1238
+ }
1239
+ # repo == sup: silent.
1240
+ return 0
1241
+ }
1242
+
1243
+ # ---------------------------------------------------------------------------
1244
+ # Registry helpers (DR-1 / FF-1 / FR29 -- PS twin of Bash registry_register /
1245
+ # registry_unregister). Implements DM-1 schema, DD-3 Move-Item -Force atomic
1246
+ # write, DD-REG-FMT line-scan (no YAML library).
1247
+ # Defined here (before the sentinel try block) so they are available to the
1248
+ # dispatch handlers (status, bare-aid, update-tool) that call them.
1249
+ # ---------------------------------------------------------------------------
1250
+
1251
+ # Read the repos list from registry.yml (line-scan, no YAML parser).
1252
+ # Returns [string[]] of canonical paths; empty array when file is absent.
1253
+ function script:Get-RegistryRepos {
1254
+ param([string]$RegPath)
1255
+ if (-not (Test-Path $RegPath -PathType Leaf)) { return @() }
1256
+ $results = [System.Collections.Generic.List[string]]::new()
1257
+ foreach ($line in (Get-Content -LiteralPath $RegPath -Encoding utf8 -ErrorAction SilentlyContinue)) {
1258
+ if ($line -match '^\s*-\s+(.+\S)\s*$') {
1259
+ $results.Add($Matches[1])
1260
+ }
1261
+ }
1262
+ return $results.ToArray()
1263
+ }
1264
+
1265
+ # Get-RegistryUnion
1266
+ # Return the deduped sort-unique union of the primary tier ($script:_AidStateHome/
1267
+ # registry.yml, which honors the AID_HOME override via the startup scope derivation)
1268
+ # and, when $script:_AidStateHome differs from $HOME/.aid, also the $HOME/.aid/
1269
+ # registry.yml fallback tier. Prunes stale entries quietly: a path is emitted only
1270
+ # if its .aid/ sub-directory still exists. Never writes or mutates any registry file.
1271
+ #
1272
+ # Per-user collapse: when $script:_AidStateHome == $HOME/.aid the two paths are the
1273
+ # same file -- the union degenerates to a single-tier read (no double-read, no elevation).
1274
+ # Mirror of bash _registry_read_union.
1275
+ function script:Get-RegistryUnion {
1276
+ $primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
1277
+ $userDotAid = Join-Path $HOME '.aid'
1278
+ $fallbackReg = Join-Path $userDotAid 'registry.yml'
1279
+
1280
+ $raw = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
1281
+
1282
+ # Primary tier (always read).
1283
+ foreach ($p in (script:Get-RegistryRepos -RegPath $primaryReg)) {
1284
+ if ($p) { [void]$raw.Add($p) }
1285
+ }
1286
+
1287
+ # Fallback tier only when paths differ (global install).
1288
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1289
+ $fallbackNorm = [System.IO.Path]::GetFullPath($userDotAid)
1290
+ if ($primaryNorm -ne $fallbackNorm) {
1291
+ foreach ($p in (script:Get-RegistryRepos -RegPath $fallbackReg)) {
1292
+ if ($p) { [void]$raw.Add($p) }
1293
+ }
1294
+ }
1295
+
1296
+ # Quiet-prune: emit only paths whose .aid/ still exists.
1297
+ $result = [System.Collections.Generic.List[string]]::new()
1298
+ foreach ($p in ($raw | Sort-Object)) {
1299
+ $aidDir = Join-Path $p '.aid'
1300
+ if (Test-Path $aidDir -PathType Container) {
1301
+ $result.Add($p)
1302
+ }
1303
+ }
1304
+ return $result.ToArray()
1305
+ }
1306
+
1307
+ # Get-RegistryRawUnion
1308
+ # Like Get-RegistryUnion but WITHOUT the .aid/ quiet-prune.
1309
+ # Returns EVERY registered path including paths whose .aid/ is absent or the
1310
+ # directory does not exist. Used by 'aid projects list' to render no-aid/missing
1311
+ # states. Never writes or mutates any registry file on read.
1312
+ #
1313
+ # Per-user collapse: when AID_STATE_HOME == $HOME/.aid, the single-tier read
1314
+ # preserves registry-file order (no sort), mirroring bash. When paths differ
1315
+ # (global install), the deduped union of primary + fallback is sort-unique.
1316
+ # Mirror of bash _registry_read_raw_union.
1317
+ function script:Get-RegistryRawUnion {
1318
+ $primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
1319
+ $userDotAid = Join-Path $HOME '.aid'
1320
+ $fallbackReg = Join-Path $userDotAid 'registry.yml'
1321
+
1322
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1323
+ $fallbackNorm = [System.IO.Path]::GetFullPath($userDotAid)
1324
+ $perUser = ($primaryNorm -eq $fallbackNorm)
1325
+
1326
+ $result = [System.Collections.Generic.List[string]]::new()
1327
+
1328
+ if ($perUser) {
1329
+ # Per-user collapse: single-tier; preserve registry-file order (no sort).
1330
+ foreach ($p in (script:Get-RegistryRepos -RegPath $primaryReg)) {
1331
+ if ($p) { $result.Add($p) }
1332
+ }
1333
+ } else {
1334
+ # Distinct paths: sort-unique union of primary and fallback.
1335
+ # Use SortedSet with Ordinal comparer to match bash `sort -u` (bytewise, uppercase-first).
1336
+ $raw = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal)
1337
+ foreach ($p in (script:Get-RegistryRepos -RegPath $primaryReg)) {
1338
+ if ($p) { [void]$raw.Add($p) }
1339
+ }
1340
+ foreach ($p in (script:Get-RegistryRepos -RegPath $fallbackReg)) {
1341
+ if ($p) { [void]$raw.Add($p) }
1342
+ }
1343
+ foreach ($p in $raw) {
1344
+ $result.Add($p)
1345
+ }
1346
+ }
1347
+
1348
+ return $result.ToArray()
1349
+ }
1350
+
1351
+ # Resolve-AidTier <CanonPath> [-TierOverride <string>]
1352
+ # Deterministic, non-interactive tier selection for 'aid projects add' (FR6/AC6).
1353
+ # Returns "user" or "shared".
1354
+ #
1355
+ # Auto rule:
1356
+ # - Returns "user" if _AidScope != "global" (per-user install), OR if the
1357
+ # path is under $HOME (any install type).
1358
+ # - Otherwise (global install AND path outside $HOME): returns "shared".
1359
+ #
1360
+ # Override via -TierOverride:
1361
+ # "" no override, use auto rule (default)
1362
+ # "--local" force "user" regardless of install type/path
1363
+ # "--shared" force "shared"; but on a per-user install (AID_STATE_HOME == ~/.aid)
1364
+ # there is no separate shared tier -- returns "user" and prints a
1365
+ # one-line notice to stderr.
1366
+ #
1367
+ # Never prompts; never blocks; always returns normally.
1368
+ # Mirror of bash _aid_resolve_tier.
1369
+ function script:Resolve-AidTier {
1370
+ param(
1371
+ [string]$CanonPath,
1372
+ [string]$TierOverride = ''
1373
+ )
1374
+
1375
+ # Detect per-user install (no separate shared tier).
1376
+ $userDotAid = Join-Path $HOME '.aid'
1377
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1378
+ $userNorm = [System.IO.Path]::GetFullPath($userDotAid)
1379
+ $perUser = ($primaryNorm -eq $userNorm)
1380
+
1381
+ # Handle explicit override flags.
1382
+ switch ($TierOverride) {
1383
+ '--local' { return 'user' }
1384
+ '--shared' {
1385
+ if ($perUser) {
1386
+ [Console]::Error.WriteLine('no shared tier under a per-user install; using user tier')
1387
+ return 'user'
1388
+ }
1389
+ return 'shared'
1390
+ }
1391
+ }
1392
+
1393
+ # Auto rule: user if per-user install OR path is under $HOME.
1394
+ $inHome = $CanonPath.StartsWith($HOME + [System.IO.Path]::DirectorySeparatorChar) -or
1395
+ $CanonPath -eq $HOME
1396
+
1397
+ if ($script:_AidScope -ne 'global' -or $inHome) {
1398
+ return 'user'
1399
+ }
1400
+ return 'shared'
1401
+ }
1402
+
1403
+ # Get-AidProjectState <Path>
1404
+ # Return the state of an AID project directory:
1405
+ # "missing" -- the directory does not exist
1406
+ # "no-aid" -- directory exists but has no .aid/ subdirectory
1407
+ # "untracked" -- .aid/ exists but no .aid/.aid-manifest.json is present
1408
+ # "vX.Y.Z" -- tracked; semver version string from .aid/.aid-manifest.json
1409
+ # (key "aid_version"), falling back to .aid/.aid-version
1410
+ # Never errors; always returns normally.
1411
+ # Mirror of bash _aid_project_state.
1412
+ function script:Get-AidProjectState {
1413
+ param([string]$Path)
1414
+ if (-not (Test-Path $Path -PathType Container)) { return 'missing' }
1415
+ $aidDir = Join-Path $Path '.aid'
1416
+ if (-not (Test-Path $aidDir -PathType Container)) { return 'no-aid' }
1417
+ $manifest = Join-Path $aidDir '.aid-manifest.json'
1418
+ $verFile = Join-Path $aidDir '.aid-version'
1419
+ if (Test-Path $manifest -PathType Leaf) {
1420
+ $content = Get-Content -LiteralPath $manifest -Raw -Encoding utf8 -ErrorAction SilentlyContinue
1421
+ if ($content -and $content -match '"aid_version"\s*:\s*"([^"]*)"') {
1422
+ $raw = $Matches[1]
1423
+ if ($raw -match '([0-9]+\.[0-9]+\.[0-9]+[^\s]*)') {
1424
+ return $Matches[1]
1425
+ }
1426
+ }
1427
+ }
1428
+ if (Test-Path $verFile -PathType Leaf) {
1429
+ $vfContent = Get-Content -LiteralPath $verFile -Raw -Encoding utf8 -ErrorAction SilentlyContinue
1430
+ if ($vfContent -and $vfContent -match '([0-9]+\.[0-9]+\.[0-9]+[^\s]*)') {
1431
+ return $Matches[1]
1432
+ }
1433
+ }
1434
+ return 'untracked'
1435
+ }
1436
+
1437
+ # Get-AidProjectTools <Path>
1438
+ # Return a comma-separated list of tool names installed in an AID project, as
1439
+ # recorded in <path>/.aid/.aid-manifest.json under the "tools" object.
1440
+ # Returns empty string when the manifest is absent or has no tools.
1441
+ #
1442
+ # Mirrors the canonical bash awk extractor (lib/aid-install-core.sh:~1019):
1443
+ # /"tools"/{found=1} found && /^ "[a-z]/{gsub(/[^a-zA-Z0-9_.-]/,"",$1); if ($1!="") print $1}
1444
+ # Scans the whole file once found=1 -- no break condition -- so ALL tool names
1445
+ # at exactly 4-space indent with a lowercase-initial key are collected.
1446
+ # Does NOT use a closing-brace break (the bash awk doesn't either), avoiding
1447
+ # the premature-exit bug that fires on each tool's own closing " }".
1448
+ # Mirror of bash _aid_project_tools.
1449
+ function script:Get-AidProjectTools {
1450
+ param([string]$Path)
1451
+ $manifest = Join-Path $Path '.aid' '.aid-manifest.json'
1452
+ if (-not (Test-Path $manifest -PathType Leaf)) { return '' }
1453
+ $lines = Get-Content -LiteralPath $manifest -Encoding utf8 -ErrorAction SilentlyContinue
1454
+ if (-not $lines) { return '' }
1455
+ # Mirrors: /"tools"/{found=1} found && /^ "[a-z]/{...}
1456
+ $found = $false
1457
+ $tools = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
1458
+ foreach ($line in $lines) {
1459
+ if ($line -match '"tools"') { $found = $true; continue }
1460
+ if ($found -and $line -match '^ "[a-z]') {
1461
+ # Strip everything except [a-zA-Z0-9_.-] from the key token (mirrors gsub).
1462
+ # The key is the first "word" on the line: characters up to the first non-key char.
1463
+ if ($line -match '^ "([^"]+)"') {
1464
+ $raw = $Matches[1]
1465
+ # gsub(/[^a-zA-Z0-9_.-]/,"",$1) -- keep only identifier chars
1466
+ $toolName = [regex]::Replace($raw, '[^a-zA-Z0-9_.\-]', '')
1467
+ if ($toolName -and -not $tools.Contains($toolName)) {
1468
+ [void]$tools.Add($toolName)
1469
+ }
1470
+ }
1471
+ }
1472
+ }
1473
+ # sort -u (bytewise ordinal, matching bash): use SortedSet with Ordinal comparer.
1474
+ $sortedTools = [System.Collections.Generic.SortedSet[string]]::new($tools, [System.StringComparer]::Ordinal)
1475
+ return ($sortedTools -join ',')
1476
+ }
1477
+
1478
+ # Get-WhichTierHolds <Path>
1479
+ # Returns "user" or "shared" based on which registry file contains the path.
1480
+ # Falls back to Resolve-AidTier if the path is not found in either.
1481
+ # Mirror of bash _which_tier_holds.
1482
+ function script:Get-WhichTierHolds {
1483
+ param([string]$Path)
1484
+ $primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
1485
+ $userReg = Join-Path $HOME '.aid' 'registry.yml'
1486
+
1487
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1488
+ $userNorm = [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid'))
1489
+
1490
+ if ($primaryNorm -ne $userNorm) {
1491
+ # Global install: check shared/primary first.
1492
+ if ((script:Get-RegistryRepos -RegPath $primaryReg) -contains $Path) {
1493
+ return 'shared'
1494
+ }
1495
+ if ((script:Get-RegistryRepos -RegPath $userReg) -contains $Path) {
1496
+ return 'user'
1497
+ }
1498
+ } else {
1499
+ # Per-user: single file.
1500
+ if ((script:Get-RegistryRepos -RegPath $primaryReg) -contains $Path) {
1501
+ return 'user'
1502
+ }
1503
+ }
1504
+ # Fallback: derive from tier resolution.
1505
+ return (script:Resolve-AidTier -CanonPath $Path)
1506
+ }
1507
+
1508
+ # Invoke-AidProjectsList [Verbose]
1509
+ # Render the raw union as an aligned table: marker, path, state, tools, tier.
1510
+ # Marks cwd with "*"; footnotes unregistered AID cwd.
1511
+ # Mirror of bash _cmd_projects_list.
1512
+ function script:Invoke-AidProjectsList {
1513
+ param([bool]$Verbose = $false)
1514
+
1515
+ # Canonical cwd.
1516
+ $cwd = (Resolve-Path -LiteralPath '.' -ErrorAction SilentlyContinue).Path
1517
+ if (-not $cwd) { $cwd = (Get-Location).Path }
1518
+
1519
+ $paths = @(script:Get-RegistryRawUnion)
1520
+
1521
+ # Column header.
1522
+ Write-Host ('{0,-2} {1,-45} {2,-10} {3,-20} {4}' -f ' ', 'PATH', 'STATE', 'TOOLS', 'TIER')
1523
+ Write-Host ('{0,-2} {1,-45} {2,-10} {3,-20} {4}' -f '--', '----', '-----', '-----', '----')
1524
+
1525
+ $cwdRegistered = $false
1526
+ foreach ($entry in $paths) {
1527
+ $state = script:Get-AidProjectState -Path $entry
1528
+ $tools = script:Get-AidProjectTools -Path $entry
1529
+ $tier = script:Get-WhichTierHolds -Path $entry
1530
+ $marker = ' '
1531
+ if ($entry -eq $cwd) {
1532
+ $marker = '* '
1533
+ $cwdRegistered = $true
1534
+ }
1535
+ $toolsDisplay = if ($tools) { $tools } else { '-' }
1536
+ Write-Host ('{0,-2} {1,-45} {2,-10} {3,-20} {4}' -f $marker, $entry, $state, $toolsDisplay, $tier)
1537
+ if ($Verbose) {
1538
+ $regSrc = if ($tier -eq 'shared' -and
1539
+ ([System.IO.Path]::GetFullPath($script:_AidStateHome) -ne [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid')))) {
1540
+ Join-Path $script:_AidStateHome 'registry.yml'
1541
+ } else {
1542
+ Join-Path $HOME '.aid' 'registry.yml'
1543
+ }
1544
+ Write-Host (' registry: {0}' -f $regSrc)
1545
+ }
1546
+ }
1547
+
1548
+ if ($paths.Count -eq 0) {
1549
+ Write-Host '(no projects registered)'
1550
+ }
1551
+
1552
+ # Footnote: unregistered AID cwd (only when cwd is a real project, not the state home).
1553
+ if (-not $cwdRegistered -and (script:Test-AidIsProjectDir -Dir $cwd)) {
1554
+ Write-Host ''
1555
+ Write-Host "(here) -- not registered; run 'aid projects add'"
1556
+ }
1557
+
1558
+ # Legend.
1559
+ if ($paths.Count -gt 0) {
1560
+ Write-Host ''
1561
+ Write-Host '* = current directory'
1562
+ }
1563
+ }
1564
+
1565
+ # Invoke-AidProjectsAdd [RawPath] [TierOverride] [Verbose]
1566
+ # Register a project path (default: cwd) in the deterministic tier.
1567
+ # Mirror of bash _cmd_projects_add.
1568
+ function script:Invoke-AidProjectsAdd {
1569
+ param(
1570
+ [string]$RawPath = '.',
1571
+ [string]$TierOverride = '',
1572
+ [bool] $Verbose = $false
1573
+ )
1574
+
1575
+ # Canonicalize.
1576
+ $resolvedAdd = Resolve-Path -LiteralPath $RawPath -ErrorAction SilentlyContinue
1577
+ $canon = if ($resolvedAdd) { $resolvedAdd.Path } else { $null }
1578
+ if (-not $canon) {
1579
+ [Console]::Error.WriteLine("ERROR: aid projects add: path does not exist: $RawPath")
1580
+ script:Exit-Aid 2
1581
+ }
1582
+
1583
+ # Require a real AID project (.aid/ present AND not the CLI state home).
1584
+ if (-not (script:Test-AidIsProjectDir -Dir $canon)) {
1585
+ [Console]::Error.WriteLine("ERROR: aid projects add: '$canon' is not an AID project; run 'aid add <tool>' first.")
1586
+ script:Exit-Aid 2
1587
+ }
1588
+
1589
+ # Resolve tier.
1590
+ $tier = script:Resolve-AidTier -CanonPath $canon -TierOverride $TierOverride
1591
+
1592
+ # Register (idempotent). Suppress Registry-Register's own Write-Host output so we
1593
+ # emit a single consolidated message instead; stderr (WARN lines) flows through.
1594
+ script:Registry-Register -Repo $canon -Tier $tier 6>$null
1595
+ Write-Host ("aid projects: '$canon' registered in $tier tier.")
1596
+ if ($Verbose) {
1597
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1598
+ $userNorm = [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid'))
1599
+ $regFile = if ($tier -eq 'shared' -and $primaryNorm -ne $userNorm) {
1600
+ Join-Path $script:_AidStateHome 'registry.yml'
1601
+ } else {
1602
+ Join-Path $HOME '.aid' 'registry.yml'
1603
+ }
1604
+ Write-Host ("aid projects: registry file: $regFile")
1605
+ }
1606
+ }
1607
+
1608
+ # Invoke-AidProjectsRemove [RawPath] [Verbose]
1609
+ # Unregister a project path (default: cwd); no .aid/ required (repair stale).
1610
+ # Mirror of bash _cmd_projects_remove.
1611
+ function script:Invoke-AidProjectsRemove {
1612
+ param(
1613
+ [string]$RawPath = '.',
1614
+ [bool] $Verbose = $false
1615
+ )
1616
+
1617
+ # Canonicalize without requiring the directory to exist.
1618
+ $resolvedRem = Resolve-Path -LiteralPath $RawPath -ErrorAction SilentlyContinue
1619
+ $canon = if ($resolvedRem) { $resolvedRem.Path } else { $null }
1620
+ if (-not $canon) {
1621
+ # Directory absent (stale entry); use raw path as-is.
1622
+ $canon = $RawPath
1623
+ }
1624
+
1625
+ # Check if registered before unregistering (for idempotency message).
1626
+ $primaryReg = Join-Path $script:_AidStateHome 'registry.yml'
1627
+ $userReg = Join-Path $HOME '.aid' 'registry.yml'
1628
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1629
+ $userNorm = [System.IO.Path]::GetFullPath((Join-Path $HOME '.aid'))
1630
+ $found = $false
1631
+ if ((script:Get-RegistryRepos -RegPath $primaryReg) -contains $canon) {
1632
+ $found = $true
1633
+ } elseif ($primaryNorm -ne $userNorm) {
1634
+ if ((script:Get-RegistryRepos -RegPath $userReg) -contains $canon) {
1635
+ $found = $true
1636
+ }
1637
+ }
1638
+
1639
+ script:Registry-Unregister -Repo $canon
1640
+ if (-not $found) {
1641
+ Write-Host ("aid projects: '$canon' was not registered (nothing to remove).")
1642
+ } elseif ($Verbose) {
1643
+ Write-Host ("aid projects: removed '$canon' from registry.")
1644
+ }
1645
+ }
1646
+
1647
+ # Invoke-AidProjects [Action] [RemArgs]
1648
+ # Orchestrates list/add/remove/help for the project registry.
1649
+ # Mirror of bash _cmd_projects.
1650
+ function script:Invoke-AidProjects {
1651
+ param(
1652
+ [string] $Action = 'list',
1653
+ [string[]]$RemArgs = @()
1654
+ )
1655
+
1656
+ $pathArg = ''
1657
+ $tierOverride = ''
1658
+ $verbose = $false
1659
+
1660
+ # Parse remaining args.
1661
+ $i = 0
1662
+ while ($i -lt $RemArgs.Count) {
1663
+ $a = $RemArgs[$i]
1664
+ switch ($a) {
1665
+ { $_ -in @('-h', '--help', '-Help') } { $Action = 'help'; break }
1666
+ '--local' { $tierOverride = '--local'; break }
1667
+ '--shared' { $tierOverride = '--shared'; break }
1668
+ '--verbose' { $verbose = $true; $script:_AidVerbose = $true; break }
1669
+ { $_ -match '^-' } {
1670
+ [Console]::Error.WriteLine("ERROR: aid projects: unknown flag: $a (see 'aid projects -h')")
1671
+ script:Exit-Aid 2
1672
+ break
1673
+ }
1674
+ default {
1675
+ if (-not $pathArg) { $pathArg = $a }
1676
+ }
1677
+ }
1678
+ $i++
1679
+ }
1680
+
1681
+ $resolvedPath = if ($pathArg) { $pathArg } else { '.' }
1682
+
1683
+ switch ($Action) {
1684
+ 'list' { script:Invoke-AidProjectsList -Verbose $verbose }
1685
+ 'add' { script:Invoke-AidProjectsAdd -RawPath $resolvedPath -TierOverride $tierOverride -Verbose $verbose }
1686
+ 'remove' { script:Invoke-AidProjectsRemove -RawPath $resolvedPath -Verbose $verbose }
1687
+ 'help' { script:Show-AidUsage 'projects'; script:Exit-Aid 0 }
1688
+ default {
1689
+ [Console]::Error.WriteLine("ERROR: aid projects: unknown action: $Action (expected: list, add, remove, help)")
1690
+ script:Exit-Aid 2
1691
+ }
1692
+ }
1693
+ }
1694
+
1695
+ # Invoke-AidCwdClassify <Target>
1696
+ # C-table: classify the cwd repo and perform register-on-encounter.
1697
+ # Called before repo commands (status, update [tool]) when .aid/ exists.
1698
+ # Checks if already registered (union read); if not, picks tier and registers.
1699
+ # Returns always (registration is best-effort; never blocks the host command).
1700
+ # Mirror of bash _aid_cwd_classify.
1701
+ function script:Invoke-AidCwdClassify {
1702
+ param([string]$Target)
1703
+
1704
+ $canonTarget = (Resolve-Path -LiteralPath $Target -ErrorAction SilentlyContinue).Path
1705
+ if (-not $canonTarget) { $canonTarget = $Target }
1706
+
1707
+ # Check if already registered in the union.
1708
+ $isRegistered = $false
1709
+ foreach ($regP in (script:Get-RegistryUnion)) {
1710
+ if ($regP -eq $canonTarget) { $isRegistered = $true; break }
1711
+ }
1712
+
1713
+ if (-not $isRegistered) {
1714
+ # Not registered -- pick tier deterministically via Resolve-AidTier (FR7: no prompt).
1715
+ $regTier = script:Resolve-AidTier -CanonPath $canonTarget
1716
+ try { script:Registry-Register -Repo $canonTarget -Tier $regTier } catch {}
1717
+ }
1718
+ }
1719
+
1720
+ # Invoke-AidCwdNoAidOffer <Target>
1721
+ # C-table last row: .aid/ absent -- print offer + optional non-git note, exit 0.
1722
+ # No hard refuse (decision #5): missing .aid/ is an offer, not an error.
1723
+ # Mirror of bash _aid_cwd_no_aid_offer.
1724
+ function script:Invoke-AidCwdNoAidOffer {
1725
+ param([string]$Target)
1726
+
1727
+ $canon = (Resolve-Path -LiteralPath $Target -ErrorAction SilentlyContinue).Path
1728
+ if (-not $canon) { $canon = $Target }
1729
+
1730
+ Write-Host 'no AID project here -- set it up? (aid add)'
1731
+ # Non-git note (decision #5): a non-git dir can use AID; .aid/ just won't be
1732
+ # version-controlled if git is absent.
1733
+ $gitOut = $null
1734
+ try { $gitOut = & git -C $canon rev-parse --git-dir 2>&1 } catch {}
1735
+ if ($LASTEXITCODE -ne 0 -or -not $gitOut) {
1736
+ Write-Host "Note: $canon is not a git repository -- .aid/ will not be version-controlled."
1737
+ }
1738
+ script:Exit-Aid 0
1739
+ }
1740
+
1741
+ # Register a canonical repo path in the registry.yml (set-insert, idempotent).
1742
+ # Prints one line on a real change; silent on no-op. Prints WARN on failure; never
1743
+ # throws (host-tool op is never blocked -- NFR10 / DD-3 / CLI-1).
1744
+ #
1745
+ # Tier param: 'user' (default) or 'shared'.
1746
+ #
1747
+ # USER tier (default): primary target is $script:_AidStateHome/registry.yml (honors
1748
+ # AID_HOME override via startup scope derivation). If AID_STATE_HOME is not user-
1749
+ # writable AND is a different path from $HOME/.aid, degrades to $HOME/.aid/registry.yml
1750
+ # with a WARN (fire-and-continue; never blocks the host command). Per-user collapse:
1751
+ # when AID_STATE_HOME == $HOME/.aid the two are the same file -- single-tier, no fallback.
1752
+ #
1753
+ # SHARED tier: writes to $script:_AidStateHome/registry.yml directly (no elevation
1754
+ # wrapper on Windows -- the underlying tool surfaces its own access error; callers
1755
+ # elevate their own shell). If the write fails or the shared dir is not writable,
1756
+ # DEGRADES to skip + WARN + return (matching bash _aid_priv_run-declined contract;
1757
+ # SPEC AC6 / decision #2). Per-user install (AID_STATE_HOME == ~/.aid): shared-tier
1758
+ # argument is treated as user-tier (same file, no elevation needed).
1759
+ # Defined before the sentinel try block so it is available to bare-aid, status, and
1760
+ # Invoke-AidCwdClassify (all of which call it before any later def would be reached).
1761
+ function script:Registry-Register {
1762
+ param([string]$Repo, [string]$Tier = 'user')
1763
+
1764
+ # Per-user collapse: AID_STATE_HOME == $HOME/.aid -> shared-tier is user-tier (same file).
1765
+ $userDotAid = Join-Path $HOME '.aid'
1766
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1767
+ $userNorm = [System.IO.Path]::GetFullPath($userDotAid)
1768
+ $perUser = ($primaryNorm -eq $userNorm)
1769
+
1770
+ # Helper: test writability of a directory.
1771
+ $testWritable = {
1772
+ param([string]$dir)
1773
+ if (-not (Test-Path $dir -PathType Container)) { return $false }
1774
+ $probe = Join-Path $dir ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
1775
+ try { [System.IO.File]::WriteAllText($probe, ''); Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue; return $true } catch { return $false }
1776
+ }
1777
+
1778
+ # Helper: write registry content to a file (temp+mv atomic).
1779
+ $writeRegistry = {
1780
+ param([string]$regPath, [string[]]$repos)
1781
+ $dir = Split-Path $regPath -Parent
1782
+ if (-not (Test-Path $dir -PathType Container)) {
1783
+ try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
1784
+ }
1785
+ $tmp = Join-Path $dir ("registry.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
1786
+ try {
1787
+ $lns = [System.Collections.Generic.List[string]]::new()
1788
+ $lns.Add("# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit).")
1789
+ $lns.Add("# Holds ONLY the base folders of projects this CLI install manages. Per-project name and")
1790
+ $lns.Add("# description come from .aid/settings.yml; version/tools from the manifest, at render time.")
1791
+ $lns.Add("schema: 1")
1792
+ $lns.Add("projects:")
1793
+ foreach ($p in ($repos | Where-Object { $_ } | Sort-Object -Unique)) { $lns.Add(" - $p") }
1794
+ Set-Content -LiteralPath $tmp -Value $lns.ToArray() -Encoding utf8NoBOM -ErrorAction Stop
1795
+ Move-Item -LiteralPath $tmp -Destination $regPath -Force -ErrorAction Stop
1796
+ return $true
1797
+ } catch {
1798
+ Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
1799
+ return $false
1800
+ }
1801
+ }
1802
+
1803
+ # ------ SHARED tier -------------------------------------------------------
1804
+ if ($Tier -eq 'shared' -and -not $perUser) {
1805
+ # Shared write: attempt directly (no elevation wrapper on Windows).
1806
+ # If the shared dir is not writable, degrade: skip + WARN + return (0-equivalent).
1807
+ $sharedRegDir = $script:_AidStateHome
1808
+ if (-not (Test-Path $sharedRegDir -PathType Container)) {
1809
+ try { New-Item -ItemType Directory -Path $sharedRegDir -Force | Out-Null } catch {}
1810
+ }
1811
+ if (-not (& $testWritable $sharedRegDir)) {
1812
+ [Console]::Error.WriteLine("WARN: aid: shared registry write declined or unavailable; project not registered in shared tier ($sharedRegDir\registry.yml)")
1813
+ return
1814
+ }
1815
+ $sharedReg = Join-Path $sharedRegDir 'registry.yml'
1816
+ $existing = @(script:Get-RegistryRepos -RegPath $sharedReg)
1817
+ if ($existing -contains $Repo) {
1818
+ if ($script:_AidVerbose) { Write-Host "Registry: $Repo already registered in shared tier (no-op)." }
1819
+ return
1820
+ }
1821
+ $ok = & $writeRegistry $sharedReg ($existing + @($Repo))
1822
+ if (-not $ok) {
1823
+ [Console]::Error.WriteLine("WARN: aid: could not update the shared project registry ($sharedReg): write failed")
1824
+ return
1825
+ }
1826
+ Write-Host "Registered $Repo with the AID CLI (shared registry)."
1827
+ return
1828
+ }
1829
+
1830
+ # ------ USER tier (default) or per-user collapse -------------------------
1831
+ # Primary: $script:_AidStateHome (honors AID_HOME override via startup scope derivation).
1832
+ # Fallback: $HOME/.aid (when AID_STATE_HOME is not writable and is a different path).
1833
+ # Never-elevate: empty probe.
1834
+ if (-not (Test-Path $script:_AidStateHome -PathType Container)) {
1835
+ try { New-Item -ItemType Directory -Path $script:_AidStateHome -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
1836
+ }
1837
+ $writeDir = $null
1838
+ if (& $testWritable $script:_AidStateHome) {
1839
+ $writeDir = $script:_AidStateHome
1840
+ } else {
1841
+ # AID_STATE_HOME not writable; degrade to $HOME/.aid (user fallback).
1842
+ if (-not (Test-Path $userDotAid -PathType Container)) {
1843
+ try { New-Item -ItemType Directory -Path $userDotAid -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
1844
+ }
1845
+ # Degrade is the designed global-install behavior -- silent by default; shown under verbose.
1846
+ if ($script:_AidVerbose) {
1847
+ [Console]::Error.WriteLine("WARN: aid: could not write to state home $($script:_AidStateHome); using $userDotAid\registry.yml")
325
1848
  }
1849
+ $writeDir = $userDotAid
1850
+ }
1851
+ $writeReg = Join-Path $writeDir 'registry.yml'
1852
+ $existing = @(script:Get-RegistryRepos -RegPath $writeReg)
1853
+ # Idempotent: already registered -> silent no-op.
1854
+ if ($existing -contains $Repo) {
1855
+ if ($script:_AidVerbose) { Write-Host "Registry: $Repo already registered (no-op)." }
326
1856
  return
327
1857
  }
328
-
329
- $newParts = @($BinDir) + @($parts)
330
- $newPath = $newParts -join ';'
331
-
332
- # Safety guard: warn if exceeding ~2000 chars (Windows limit is 32767 but
333
- # practical registry/shell limit is much lower for User PATH).
334
- $safeLimit = 2000
335
- if ($newPath.Length -gt $safeLimit) {
336
- Write-Host "WARN: aid: User PATH would exceed $safeLimit chars. Skipping automatic PATH wiring."
337
- Write-Host "Add `"$BinDir`" to your PATH manually via System Properties > Environment Variables."
1858
+ $ok = & $writeRegistry $writeReg ($existing + @($Repo))
1859
+ if (-not $ok) {
1860
+ [Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($writeReg): write failed")
338
1861
  return
339
1862
  }
1863
+ Write-Host "Registered $Repo with the AID CLI."
1864
+ }
340
1865
 
341
- [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
342
-
343
- # Update in-process immediately so the convenience-chain first action works.
344
- if ($env:Path -notmatch [regex]::Escape($BinDir)) {
345
- $env:Path = "$BinDir;$($env:Path)"
1866
+ # Unregister a canonical repo path from the registry.yml (set-remove, idempotent).
1867
+ # Called only when the repo manifest is now gone (last tool removed).
1868
+ # Prints one line on a real change; silent on no-op. Prints WARN on failure; never throws.
1869
+ #
1870
+ # Tier-aware: removes from tier(s) where the entry is found. Scope-aware resolution
1871
+ # mirrors Registry-Register: primary=$script:_AidStateHome, fallback=$HOME/.aid.
1872
+ # Per-user install (AID_STATE_HOME == $HOME/.aid): both paths are the same -- single write.
1873
+ # Defined before the sentinel try block (symmetric with Registry-Register above).
1874
+ function script:Registry-Unregister {
1875
+ param([string]$Repo)
1876
+
1877
+ $userDotAid = Join-Path $HOME '.aid'
1878
+ $primaryNorm = [System.IO.Path]::GetFullPath($script:_AidStateHome)
1879
+ $userNorm = [System.IO.Path]::GetFullPath($userDotAid)
1880
+ $perUser = ($primaryNorm -eq $userNorm)
1881
+
1882
+ $sharedReg = Join-Path $script:_AidStateHome 'registry.yml'
1883
+ $userReg = Join-Path $userDotAid 'registry.yml'
1884
+
1885
+ # Helper: test writability of a directory.
1886
+ $testW = {
1887
+ param([string]$dir)
1888
+ if (-not (Test-Path $dir -PathType Container)) { return $false }
1889
+ $probe = Join-Path $dir ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
1890
+ try { [System.IO.File]::WriteAllText($probe, ''); Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue; return $true } catch { return $false }
346
1891
  }
347
1892
 
348
- Write-Host "PATH wiring added (User scope): $BinDir"
349
- Write-Host "Open a new shell, or the PATH is already active in this session."
350
- }
1893
+ # Helper: rewrite registry removing $Repo (atomic temp+mv).
1894
+ $rewriteReg = {
1895
+ param([string]$regPath, [string[]]$current)
1896
+ $dir = Split-Path $regPath -Parent
1897
+ $tmp = Join-Path $dir ("registry.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
1898
+ try {
1899
+ $remaining = $current | Where-Object { $_ -ne $Repo } | Sort-Object -Unique
1900
+ $lns = [System.Collections.Generic.List[string]]::new()
1901
+ $lns.Add("# AID machine project registry (managed by 'aid add' / 'aid remove' -- do not hand-edit).")
1902
+ $lns.Add("# Holds ONLY the base folders of projects this CLI install manages. Per-project name and")
1903
+ $lns.Add("# description come from .aid/settings.yml; version/tools from the manifest, at render time.")
1904
+ $lns.Add("schema: 1")
1905
+ $lns.Add("projects:")
1906
+ if ($remaining) { foreach ($p in $remaining) { $lns.Add(" - $p") } }
1907
+ Set-Content -LiteralPath $tmp -Value $lns.ToArray() -Encoding utf8NoBOM -ErrorAction Stop
1908
+ Move-Item -LiteralPath $tmp -Destination $regPath -Force -ErrorAction Stop
1909
+ return $true
1910
+ } catch {
1911
+ Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
1912
+ return $false
1913
+ }
1914
+ }
351
1915
 
352
- # Remove-AidFromPath <binDir>
353
- # Remove binDir from User PATH idempotently.
354
- function script:Remove-AidFromPath {
355
- param([string]$BinDir)
1916
+ $foundAny = $false
356
1917
 
357
- $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
358
- if (-not $currentPath) { return }
1918
+ # --- PRIMARY TIER ($AID_STATE_HOME) ---
1919
+ if (& $testW $script:_AidStateHome) {
1920
+ $ex = @(script:Get-RegistryRepos -RegPath $sharedReg)
1921
+ if ($ex -contains $Repo) {
1922
+ $foundAny = $true
1923
+ $ok = & $rewriteReg $sharedReg $ex
1924
+ if (-not $ok) {
1925
+ [Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($sharedReg): write failed")
1926
+ return
1927
+ }
1928
+ }
1929
+ } else {
1930
+ # AID_STATE_HOME not writable: check/operate on fallback $HOME/.aid tier.
1931
+ if (-not (Test-Path $userDotAid -PathType Container)) {
1932
+ try { New-Item -ItemType Directory -Path $userDotAid -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
1933
+ }
1934
+ $ex = @(script:Get-RegistryRepos -RegPath $userReg)
1935
+ if ($ex -contains $Repo) {
1936
+ $foundAny = $true
1937
+ # Degrade is the designed global-install behavior -- silent by default; shown under verbose.
1938
+ if ($script:_AidVerbose) {
1939
+ [Console]::Error.WriteLine("WARN: aid: could not write to state home $($script:_AidStateHome); using $userReg")
1940
+ }
1941
+ $ok = & $rewriteReg $userReg $ex
1942
+ if (-not $ok) {
1943
+ [Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($userReg): write failed")
1944
+ return
1945
+ }
1946
+ }
1947
+ }
359
1948
 
360
- $parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() -ne $BinDir }
361
- $newPath = $parts -join ';'
1949
+ # --- FALLBACK / SECONDARY TIER ($HOME/.aid, global install only) ---
1950
+ # When AID_STATE_HOME is writable and != $HOME/.aid, also check if the entry
1951
+ # exists in $HOME/.aid (e.g. was registered when AID_STATE_HOME was non-writable).
1952
+ if (-not $perUser -and (& $testW $script:_AidStateHome)) {
1953
+ $fbEx = @(script:Get-RegistryRepos -RegPath $userReg)
1954
+ if ($fbEx -contains $Repo) {
1955
+ $foundAny = $true
1956
+ if (-not (Test-Path $userDotAid -PathType Container)) {
1957
+ try { New-Item -ItemType Directory -Path $userDotAid -Force -ErrorAction SilentlyContinue | Out-Null } catch {}
1958
+ }
1959
+ if (& $testW $userDotAid) {
1960
+ $ok = & $rewriteReg $userReg $fbEx
1961
+ if (-not $ok) {
1962
+ [Console]::Error.WriteLine("WARN: aid: could not update the machine project registry ($userReg): write failed")
1963
+ }
1964
+ } else {
1965
+ [Console]::Error.WriteLine("WARN: aid: could not write to registry at $userReg (not writable); unregister skipped")
1966
+ }
1967
+ }
1968
+ }
362
1969
 
363
- if ($newPath -ne $currentPath) {
364
- [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
365
- Write-Host "PATH wiring removed (User scope): $BinDir"
1970
+ if (-not $foundAny) {
1971
+ if ($script:_AidVerbose) { Write-Host "Registry: $Repo not in registry (no-op)." }
1972
+ return
366
1973
  }
1974
+ Write-Host "Unregistered $Repo from the AID CLI."
367
1975
  }
368
1976
 
369
1977
  # ---------------------------------------------------------------------------
@@ -383,14 +1991,32 @@ $script:_AidVerbose = ($env:AID_VERBOSE -eq '1')
383
1991
 
384
1992
  # ---- Bare aid -> dashboard landing screen ----
385
1993
  if ($script:_RawArgs.Count -eq 0) {
1994
+ # C-table: if cwd is not an AID project -> offer (no hard refuse, decision #5); exit 0.
1995
+ # Test-AidIsProjectDir excludes the CLI state home from "is project" classification.
1996
+ if (-not (script:Test-AidIsProjectDir -Dir '.')) {
1997
+ script:Invoke-AidCwdNoAidOffer -Target '.'
1998
+ # Invoke-AidCwdNoAidOffer always calls Exit-Aid 0.
1999
+ }
2000
+ # C-table register-on-encounter (best-effort, never blocks bare aid).
2001
+ script:Invoke-AidCwdClassify -Target '.'
2002
+
386
2003
  # Block 1 + 2: Header + description.
387
2004
  $cliVersion = 'unknown'
388
- $verFile = Join-Path $script:_AidHome 'VERSION'
2005
+ $verFile = Join-Path $script:_AidCodeHome 'VERSION'
389
2006
  if (Test-Path $verFile -PathType Leaf) {
390
2007
  $cliVersion = (Get-Content -LiteralPath $verFile -Raw).Trim()
391
2008
  }
392
2009
  Write-Host "AID v$cliVersion - Agentic Iterative Development"
393
- Write-Host "Install, update, and manage AID across your repositories."
2010
+ Write-Host "Install, update, and manage AID across your projects."
2011
+
2012
+ # C6': format gate for cwd repo (.aid/ is guaranteed present here -- the
2013
+ # non-project case is intercepted above via Invoke-AidCwdNoAidOffer;
2014
+ # register-on-encounter already ran via Invoke-AidCwdClassify).
2015
+ # Test-AidIsProjectDir guards the state-home exclusion (double-check).
2016
+ if (script:Test-AidIsProjectDir -Dir '.') {
2017
+ $gateRc = script:Invoke-AidFormatGate -Repo '.'
2018
+ if ($gateRc -ne 0) { script:Exit-Aid $gateRc }
2019
+ }
394
2020
 
395
2021
  # Block 3: Installed tools for cwd.
396
2022
  Write-Host ""
@@ -418,7 +2044,7 @@ $script:_RemArgs = @($script:_RawArgs | Select-Object -Skip 1)
418
2044
  # version
419
2045
  # ---------------------------------------------------------------------------
420
2046
  if ($SUBCMD -eq 'version') {
421
- $versionFile = Join-Path $script:_AidHome 'VERSION'
2047
+ $versionFile = Join-Path $script:_AidCodeHome 'VERSION'
422
2048
  if (Test-Path $versionFile -PathType Leaf) {
423
2049
  Write-Host (Get-Content -LiteralPath $versionFile -Raw).Trim()
424
2050
  } else {
@@ -467,49 +2093,486 @@ if ($SUBCMD -eq 'status') {
467
2093
  if (-not $statusTarget) { $statusTarget = '.' }
468
2094
  if ($script:_AidVerbose) { $env:AID_VERBOSE = '1' }
469
2095
 
2096
+ # C-table: if target is not an AID project -> offer (no hard refuse, decision #5); exit 0.
2097
+ # Test-AidIsProjectDir excludes the CLI state home from "is project" classification.
2098
+ if (-not (script:Test-AidIsProjectDir -Dir $statusTarget)) {
2099
+ script:Invoke-AidCwdNoAidOffer -Target $statusTarget
2100
+ # Invoke-AidCwdNoAidOffer always calls Exit-Aid 0.
2101
+ }
2102
+ # C-table register-on-encounter (best-effort, never blocks status).
2103
+ script:Invoke-AidCwdClassify -Target $statusTarget
2104
+ # C6': format gate for status target (only when target is a real project).
2105
+ $gateRc = script:Invoke-AidFormatGate -Repo $statusTarget
2106
+ if ($gateRc -ne 0) { script:Exit-Aid $gateRc }
2107
+
470
2108
  $rc = Get-AidStatus -Target $statusTarget
471
2109
  # Update check notice appended after status output (non-blocking).
472
2110
  script:Invoke-AidUpdateCheck
473
2111
  script:Exit-Aid $rc
474
2112
  }
475
2113
 
2114
+ # ---------------------------------------------------------------------------
2115
+ # Invoke-AidMigrateRepo <repo> (FF-1 / LC-MIG / task-077)
2116
+ # Per-repo migration core -- PS twin of bash _aid_migrate_repo.
2117
+ # Runs DETECT->SETTINGS->ADD->RELOCATE->REGISTER in order.
2118
+ # Each step is WARN-not-fail: a step failure emits WARN and the next step runs.
2119
+ # Always returns 0 (SEC-4 / NFR12). <repo> is a canonical repo base folder.
2120
+ # ---------------------------------------------------------------------------
2121
+ function script:Invoke-AidMigrateRepo {
2122
+ param([string]$Repo)
2123
+
2124
+ # ------------------------------------------------------------------
2125
+ # STEP 0 -- DETECT / QUALIFY (DD-6 / SEC-1) -- read-only.
2126
+ # ------------------------------------------------------------------
2127
+ $aidDir = Join-Path $Repo '.aid'
2128
+ if (-not (Test-Path $aidDir -PathType Container)) { return 0 }
2129
+
2130
+ $settingsPath = Join-Path $aidDir 'settings.yml'
2131
+ $kbDir = Join-Path $aidDir 'knowledge'
2132
+ $dsA = Join-Path $kbDir 'DISCOVERY_STATE.md'
2133
+ $dsB = Join-Path $kbDir 'DISCOVERY-STATE.md'
2134
+ $dsC = Join-Path $kbDir 'STATE.md'
2135
+
2136
+ $era = ''
2137
+ if (Test-Path $settingsPath -PathType Leaf) {
2138
+ $era = 'a'
2139
+ } elseif ((Test-Path $dsA -PathType Leaf) -or (Test-Path $dsB -PathType Leaf) -or (Test-Path $dsC -PathType Leaf) -or (Test-Path (Join-Path $aidDir '.aid-manifest.json') -PathType Leaf)) {
2140
+ # Era-b: KB-state present, OR a tracked repo (manifest present) with no
2141
+ # settings.yml yet (the `aid add`-only state). Synthesize a fresh stamped
2142
+ # settings.yml so the format gate stops warning and the repo is brought
2143
+ # current. Mirrors bin/aid. Without the manifest clause such repos warn
2144
+ # forever and are never stamped.
2145
+ $era = 'b'
2146
+ } else {
2147
+ return 0 # bare .aid/ (no settings.yml, no KB state, no manifest) -- not a candidate
2148
+ }
2149
+
2150
+ $repoName = Split-Path $Repo -Leaf
2151
+ $manifest = Join-Path $aidDir '.aid-manifest.json'
2152
+
2153
+ # ------------------------------------------------------------------
2154
+ # STEP 1 -- SETTINGS (DM-1 / task-074 contract)
2155
+ # ------------------------------------------------------------------
2156
+ if ($era -eq 'a') {
2157
+ try {
2158
+ script:Invoke-AidRepairSettingsEraA -SettingsFile $settingsPath -RepoName $repoName
2159
+ } catch {
2160
+ [Console]::Error.WriteLine("WARN: aid migrate: settings repair failed for ${settingsPath}: $_")
2161
+ }
2162
+ } else {
2163
+ try {
2164
+ script:Invoke-AidSynthesizeSettingsEraB -SettingsFile $settingsPath -RepoName $repoName -ManifestPath $manifest
2165
+ } catch {
2166
+ [Console]::Error.WriteLine("WARN: aid migrate: settings synthesis failed for ${settingsPath}: $_")
2167
+ }
2168
+ }
2169
+
2170
+ # ------------------------------------------------------------------
2171
+ # STEP 2 -- ADD home.html (FR40 / RC-2) -- copy-when-absent only.
2172
+ # ------------------------------------------------------------------
2173
+ $dashDir = Join-Path $aidDir 'dashboard'
2174
+ $htmlDest = Join-Path $dashDir 'home.html'
2175
+ if (-not (Test-Path $htmlDest -PathType Leaf)) {
2176
+ $htmlSrc = Join-Path $script:_AidCodeHome 'dashboard' | Join-Path -ChildPath 'home.html'
2177
+ if (Test-Path $htmlSrc -PathType Leaf) {
2178
+ try {
2179
+ if (-not (Test-Path $dashDir -PathType Container)) {
2180
+ New-Item -ItemType Directory -Path $dashDir -Force | Out-Null
2181
+ }
2182
+ Copy-Item -LiteralPath $htmlSrc -Destination $htmlDest -ErrorAction Stop
2183
+ } catch {
2184
+ [Console]::Error.WriteLine("WARN: aid migrate: copy home.html failed for ${Repo}: $_")
2185
+ }
2186
+ } else {
2187
+ [Console]::Error.WriteLine("WARN: aid migrate: home.html source not found at ${htmlSrc} (continuing)")
2188
+ }
2189
+ }
2190
+
2191
+ # ------------------------------------------------------------------
2192
+ # STEP 3 -- RELOCATE legacy summary (DM-4 / FR31) -- no-clobber mv.
2193
+ # ------------------------------------------------------------------
2194
+ $oldSummary = Join-Path $aidDir 'knowledge' | Join-Path -ChildPath 'knowledge-summary.html'
2195
+ $newSummary = Join-Path $dashDir 'kb.html'
2196
+ if ((Test-Path $oldSummary -PathType Leaf) -and (-not (Test-Path $newSummary -PathType Leaf))) {
2197
+ try {
2198
+ if (-not (Test-Path $dashDir -PathType Container)) {
2199
+ New-Item -ItemType Directory -Path $dashDir -Force | Out-Null
2200
+ }
2201
+ Move-Item -LiteralPath $oldSummary -Destination $newSummary -ErrorAction Stop
2202
+ } catch {
2203
+ [Console]::Error.WriteLine("WARN: aid migrate: relocate legacy summary failed for ${Repo}: $_")
2204
+ }
2205
+ }
2206
+
2207
+ # ------------------------------------------------------------------
2208
+ # STEP 4 -- REGISTER (DM-2 / FR28) -- existing idempotent writer.
2209
+ # FR7 never-elevate: resolve tier deterministically; if shared but the shared
2210
+ # dir is not writable, degrade silently to user (mirrors bash _aid_migrate_repo).
2211
+ # ------------------------------------------------------------------
2212
+ try {
2213
+ $_migTier = script:Resolve-AidTier -CanonPath $Repo
2214
+ # Degrade: shared + non-writable shared dir -> user (never-elevate in migrate).
2215
+ if ($_migTier -eq 'shared') {
2216
+ $testW = { param([string]$d)
2217
+ if (-not (Test-Path $d -PathType Container)) { return $false }
2218
+ $probe = Join-Path $d ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
2219
+ try { [System.IO.File]::WriteAllText($probe, ''); Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue; return $true } catch { return $false }
2220
+ }
2221
+ if (-not (& $testW $script:_AidStateHome)) { $_migTier = 'user' }
2222
+ }
2223
+ script:Registry-Register -Repo $Repo -Tier $_migTier
2224
+ } catch {
2225
+ [Console]::Error.WriteLine("WARN: aid migrate: registry_register failed for ${Repo}: $_")
2226
+ }
2227
+
2228
+ return 0
2229
+ }
2230
+
2231
+ # script:Invoke-AidRepairSettingsEraA <SettingsFile> <RepoName>
2232
+ # Era-a: validate/repair REQUIRED keys via targeted edits only.
2233
+ # A valid file -> no write (idempotent).
2234
+ function script:Invoke-AidRepairSettingsEraA {
2235
+ param([string]$SettingsFile, [string]$RepoName)
2236
+ if (-not (Test-Path $SettingsFile -PathType Leaf)) { throw "settings file not found" }
2237
+
2238
+ $lines = [System.Collections.Generic.List[string]](Get-Content -LiteralPath $SettingsFile -Encoding utf8 -ErrorAction Stop)
2239
+ $changed = $false
2240
+
2241
+ # ---- locate section header index ("^<sect>:\s*$") ----
2242
+ $findSection = {
2243
+ param([string]$sect)
2244
+ for ($i = 0; $i -lt $lines.Count; $i++) {
2245
+ if ($lines[$i] -match "^${sect}:\s*$") { return $i }
2246
+ }
2247
+ return -1
2248
+ }
2249
+
2250
+ # ---- locate indented key index inside a section ----
2251
+ $findKeyInSection = {
2252
+ param([int]$sectIdx, [string]$key)
2253
+ for ($i = $sectIdx + 1; $i -lt $lines.Count; $i++) {
2254
+ $ln = $lines[$i]
2255
+ if ($ln -match '^[a-zA-Z_]') { return -1 }
2256
+ if ($ln -match "^\s+${key}:") { return $i }
2257
+ }
2258
+ return -1
2259
+ }
2260
+
2261
+ # ---- get scalar value from " key: value" line ----
2262
+ $getScalarValue = {
2263
+ param([string]$ln, [string]$key)
2264
+ $v = ($ln -replace "^\s+${key}:\s*", '') -replace '\s*#.*$', ''
2265
+ $v = $v.Trim().Trim('"').Trim("'")
2266
+ return $v
2267
+ }
2268
+
2269
+ # ---- insert a line after index ----
2270
+ $insertAfter = {
2271
+ param([int]$idx, [string]$newLine)
2272
+ $lines.Insert($idx + 1, $newLine)
2273
+ $changed = $true
2274
+ }
2275
+
2276
+ # ---- append a block at EOF ----
2277
+ # Prepends a blank line so the new section is visually separated from the
2278
+ # preceding content (matching the template's blank-line-between-sections style).
2279
+ # Idempotency is preserved: on a 2nd run the section exists, so this path is skipped.
2280
+ $appendBlock = {
2281
+ param([string]$block)
2282
+ $lines.Add("")
2283
+ foreach ($bl in ($block -split "`n")) {
2284
+ $lines.Add($bl)
2285
+ }
2286
+ $changed = $true
2287
+ }
2288
+
2289
+ # ---- replace single line (IDIOM-A) ----
2290
+ $replaceLine = {
2291
+ param([int]$idx, [string]$newLine)
2292
+ $lines[$idx] = $newLine
2293
+ $changed = $true
2294
+ }
2295
+
2296
+ # --- C3': format_version ensure-key step (top-of-file column-0 prepend) ---
2297
+ # If a ^format_version: line is present, replace it in-place (IDIOM-A).
2298
+ # If absent, prepend format_version: <sup> at index 0 above project:.
2299
+ $fvIdx = -1
2300
+ for ($fi = 0; $fi -lt $lines.Count; $fi++) {
2301
+ if ($lines[$fi] -match '^format_version:') { $fvIdx = $fi; break }
2302
+ }
2303
+ if ($fvIdx -ge 0) {
2304
+ # Key present: replace with canonical value (IDIOM-A).
2305
+ & $replaceLine $fvIdx "format_version: $($script:AidSupportedFormat)"
2306
+ } else {
2307
+ # Key absent: prepend at index 0 (new top-of-file col-0 insert above project:).
2308
+ $lines.Insert(0, "format_version: $($script:AidSupportedFormat)")
2309
+ $changed = $true
2310
+ }
2311
+
2312
+ # --- project section ---
2313
+ $projIdx = & $findSection 'project'
2314
+ if ($projIdx -eq -1) {
2315
+ & $appendBlock "project:`n name: ${RepoName}`n description: <project-description>`n type: brownfield"
2316
+ $changed = $true
2317
+ } else {
2318
+ $nameIdx = & $findKeyInSection $projIdx 'name'
2319
+ if ($nameIdx -eq -1) {
2320
+ & $insertAfter $projIdx " name: ${RepoName}"; $changed = $true
2321
+ } else {
2322
+ $nv = & $getScalarValue $lines[$nameIdx] 'name'
2323
+ if ([string]::IsNullOrEmpty($nv)) { & $replaceLine $nameIdx " name: ${RepoName}"; $changed = $true }
2324
+ }
2325
+
2326
+ $descIdx = & $findKeyInSection $projIdx 'description'
2327
+ if ($descIdx -eq -1) {
2328
+ $nameIdx2 = & $findKeyInSection $projIdx 'name'
2329
+ $insAfterDesc = if ($nameIdx2 -ne -1) { $nameIdx2 } else { $projIdx }
2330
+ & $insertAfter $insAfterDesc ' description: <project-description>'; $changed = $true
2331
+ }
2332
+
2333
+ $typeIdx = & $findKeyInSection $projIdx 'type'
2334
+ if ($typeIdx -eq -1) {
2335
+ $descIdx2 = & $findKeyInSection $projIdx 'description'
2336
+ $nameIdx3 = & $findKeyInSection $projIdx 'name'
2337
+ $insAfterType = if ($descIdx2 -ne -1) { $descIdx2 } elseif ($nameIdx3 -ne -1) { $nameIdx3 } else { $projIdx }
2338
+ & $insertAfter $insAfterType ' type: brownfield'; $changed = $true
2339
+ } else {
2340
+ $tv = & $getScalarValue $lines[$typeIdx] 'type'
2341
+ if ($tv -ne 'brownfield' -and $tv -ne 'greenfield') {
2342
+ & $replaceLine $typeIdx ' type: brownfield'; $changed = $true
2343
+ }
2344
+ }
2345
+ }
2346
+
2347
+ # --- tools section ---
2348
+ $toolsIdx = & $findSection 'tools'
2349
+ if ($toolsIdx -eq -1) {
2350
+ & $appendBlock "tools:`n installed: []"; $changed = $true
2351
+ } else {
2352
+ $instIdx = & $findKeyInSection $toolsIdx 'installed'
2353
+ if ($instIdx -eq -1) { & $insertAfter $toolsIdx ' installed: []'; $changed = $true }
2354
+ }
2355
+
2356
+ # --- review section ---
2357
+ $revIdx = & $findSection 'review'
2358
+ if ($revIdx -eq -1) {
2359
+ & $appendBlock "review:`n minimum_grade: A"; $changed = $true
2360
+ } else {
2361
+ $mgIdx = & $findKeyInSection $revIdx 'minimum_grade'
2362
+ if ($mgIdx -eq -1) {
2363
+ & $insertAfter $revIdx ' minimum_grade: A'; $changed = $true
2364
+ } else {
2365
+ $mv = & $getScalarValue $lines[$mgIdx] 'minimum_grade'
2366
+ if ($mv -notmatch '^[A-F][+-]?$') { & $replaceLine $mgIdx ' minimum_grade: A'; $changed = $true }
2367
+ }
2368
+ }
2369
+
2370
+ # --- execution section ---
2371
+ $execIdx = & $findSection 'execution'
2372
+ if ($execIdx -eq -1) {
2373
+ & $appendBlock "execution:`n max_parallel_tasks: 5"; $changed = $true
2374
+ } else {
2375
+ $mptIdx = & $findKeyInSection $execIdx 'max_parallel_tasks'
2376
+ if ($mptIdx -eq -1) {
2377
+ & $insertAfter $execIdx ' max_parallel_tasks: 5'; $changed = $true
2378
+ } else {
2379
+ $mv2 = & $getScalarValue $lines[$mptIdx] 'max_parallel_tasks'
2380
+ if ($mv2 -notmatch '^\d+$' -or [int]$mv2 -le 0) {
2381
+ & $replaceLine $mptIdx ' max_parallel_tasks: 5'; $changed = $true
2382
+ }
2383
+ }
2384
+ }
2385
+
2386
+ # --- traceability section ---
2387
+ $traceIdx = & $findSection 'traceability'
2388
+ if ($traceIdx -eq -1) {
2389
+ & $appendBlock "traceability:`n heartbeat_interval: 1"; $changed = $true
2390
+ } else {
2391
+ $hbIdx = & $findKeyInSection $traceIdx 'heartbeat_interval'
2392
+ if ($hbIdx -eq -1) {
2393
+ & $insertAfter $traceIdx ' heartbeat_interval: 1'; $changed = $true
2394
+ } else {
2395
+ $hv = & $getScalarValue $lines[$hbIdx] 'heartbeat_interval'
2396
+ if ($hv -notmatch '^\d+$') { & $replaceLine $hbIdx ' heartbeat_interval: 1'; $changed = $true }
2397
+ }
2398
+ }
2399
+
2400
+ # Write only if changed (idempotent: no edit -> no write).
2401
+ if (-not $changed) { return }
2402
+
2403
+ $sfDir = Split-Path $SettingsFile -Parent
2404
+ $tmp = Join-Path $sfDir ("settings.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
2405
+ try {
2406
+ Set-Content -LiteralPath $tmp -Value $lines.ToArray() -Encoding utf8NoBOM -ErrorAction Stop
2407
+ Move-Item -LiteralPath $tmp -Destination $SettingsFile -Force -ErrorAction Stop
2408
+ } catch {
2409
+ Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
2410
+ throw
2411
+ }
2412
+ }
2413
+
2414
+ # script:Invoke-AidSynthesizeSettingsEraB <SettingsFile> <RepoName> <ManifestPath>
2415
+ # Era-b: write fresh template-derived settings.yml (crash-safe temp+mv).
2416
+ function script:Invoke-AidSynthesizeSettingsEraB {
2417
+ param([string]$SettingsFile, [string]$RepoName, [string]$ManifestPath)
2418
+
2419
+ $toolIds = @(Read-ManifestTools -ManifestPath $ManifestPath)
2420
+
2421
+ $sb = [System.Text.StringBuilder]::new()
2422
+ # C2': format_version stamp is the FIRST line (before project:).
2423
+ [void]$sb.Append("format_version: $($script:AidSupportedFormat)`n")
2424
+ [void]$sb.Append("project:`n")
2425
+ [void]$sb.Append(" name: ${RepoName}`n")
2426
+ [void]$sb.Append(" description: <project-description>`n")
2427
+ [void]$sb.Append(" type: brownfield`n")
2428
+ [void]$sb.Append("`n")
2429
+ [void]$sb.Append("tools:`n")
2430
+ if ($toolIds.Count -eq 0) {
2431
+ [void]$sb.Append(" installed: []`n")
2432
+ } else {
2433
+ [void]$sb.Append(" installed:`n")
2434
+ foreach ($t in $toolIds) { [void]$sb.Append(" - ${t}`n") }
2435
+ }
2436
+ [void]$sb.Append("`n")
2437
+ [void]$sb.Append("review:`n")
2438
+ [void]$sb.Append(" minimum_grade: A`n")
2439
+ [void]$sb.Append("`n")
2440
+ [void]$sb.Append("execution:`n")
2441
+ [void]$sb.Append(" max_parallel_tasks: 5`n")
2442
+ [void]$sb.Append("`n")
2443
+ [void]$sb.Append("traceability:`n")
2444
+ [void]$sb.Append(" heartbeat_interval: 1`n")
2445
+
2446
+ $sfDir = Split-Path $SettingsFile -Parent
2447
+ if (-not (Test-Path $sfDir -PathType Container)) {
2448
+ New-Item -ItemType Directory -Path $sfDir -Force | Out-Null
2449
+ }
2450
+ $tmp = Join-Path $sfDir ("settings.yml.aid-tmp." + [System.IO.Path]::GetRandomFileName())
2451
+ try {
2452
+ # Write as UTF-8 NoBOM; use raw string to control LF line endings.
2453
+ $raw = $sb.ToString()
2454
+ [System.IO.File]::WriteAllText($tmp, $raw, [System.Text.UTF8Encoding]::new($false))
2455
+ Move-Item -LiteralPath $tmp -Destination $SettingsFile -Force -ErrorAction Stop
2456
+ } catch {
2457
+ Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
2458
+ throw
2459
+ }
2460
+ }
2461
+
2462
+ # Script-scope vars consumed by Invoke-AidUpdateSelf (reset here before each parse).
2463
+ $script:_SelfFromBundle = ''
2464
+ $script:_SelfDryRun = $false
2465
+
476
2466
  # ---------------------------------------------------------------------------
477
2467
  # update (with 'self' subarg -> update self)
478
2468
  # ---------------------------------------------------------------------------
479
2469
  if ($SUBCMD -eq 'update') {
480
2470
  if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -eq 'self') {
481
2471
  # Consume any flags after 'self'.
2472
+ $script:_SelfFromBundle = ''
2473
+ $script:_SelfDryRun = $false
482
2474
  $remIdx = 1
483
2475
  while ($remIdx -lt $script:_RemArgs.Count) {
484
2476
  $a = $script:_RemArgs[$remIdx]
485
2477
  switch ($a) {
486
- { $_ -in @('-Force', '--force', '-y') } { } # no-op for update self
487
- { $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'update'; script:Exit-Aid 0 }
2478
+ { $_ -in @('-Force', '--force', '-y') } { } # no-op for update self
2479
+ { $_ -in @('-DryRun', '--dry-run') } { $script:_SelfDryRun = $true }
2480
+ { $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'update'; script:Exit-Aid 0 }
2481
+ { $_ -in @('-FromBundle', '--from-bundle') } {
2482
+ $remIdx++
2483
+ if ($remIdx -ge $script:_RemArgs.Count) {
2484
+ script:Fail-Aid '-FromBundle requires a value' 2
2485
+ }
2486
+ $script:_SelfFromBundle = $script:_RemArgs[$remIdx]
2487
+ }
488
2488
  default { script:Fail-Aid "unknown flag for 'update self': $a" 2 }
489
2489
  }
490
2490
  $remIdx++
491
2491
  }
492
- script:Invoke-AidUpdateSelf
493
- # Invoke-AidUpdateSelf always calls Exit-Aid.
2492
+ $usRc = script:Invoke-AidUpdateSelf
2493
+ if ($usRc -ne 0) { script:Exit-Aid $usRc }
2494
+ # Post-update: registry-driven migration (feature-004).
2495
+ # Iterate Get-RegistryUnion -- NO scan -- with All/Yes/No/Cancel per-repo
2496
+ # consent walk. Unregistered repos are caught lazily by the per-repo stamp.
2497
+ # No .migrated marker is written (removed; stamp in settings.yml is the record).
2498
+ # dry-run: the install step already printed its command; skip migration silently.
2499
+ if (-not $script:_SelfDryRun) {
2500
+ $usAutoYes = ($env:AID_MIGRATE_YES -eq '1')
2501
+ $usRepos = @(script:Get-RegistryUnion)
2502
+ if ($usRepos.Count -eq 0) {
2503
+ Write-Host 'No registered projects to migrate.'
2504
+ } else {
2505
+ # Determine interactive mode: AID_MIGRATE_YES=1 is the explicit opt-in for
2506
+ # auto-yes. Non-interactive without opt-in -> no migration (per SPEC).
2507
+ $usAutoYesFinal = $usAutoYes -or ($env:AID_MIGRATE_YES -eq '1')
2508
+ $usIsInteractive = [Environment]::UserInteractive
2509
+ if (-not $usAutoYesFinal -and -not $usIsInteractive) {
2510
+ Write-Host 'Skipping project migration (non-interactive; set AID_MIGRATE_YES=1 to opt in).'
2511
+ } else {
2512
+ $usMigrateAll = $false
2513
+ $usMigrateCancel = $false
2514
+ foreach ($usRepo in $usRepos) {
2515
+ if ($usMigrateCancel) { break }
2516
+ if ($usMigrateAll -or $usAutoYesFinal) {
2517
+ $usAnswer = 'y'
2518
+ } else {
2519
+ Write-Host -NoNewline "Migrate project $usRepo? [All/Yes/No/Cancel] "
2520
+ try { $usAnswer = Read-Host } catch { $usAnswer = '' }
2521
+ }
2522
+ switch -Regex ($usAnswer) {
2523
+ '^[Aa](ll|LL)?$' {
2524
+ $usMigrateAll = $true
2525
+ try { script:Invoke-AidMigrateRepo -Repo $usRepo } catch {
2526
+ [Console]::Error.WriteLine("WARN: aid: migration failed for ${usRepo}: $_")
2527
+ }
2528
+ }
2529
+ '^[Yy](es|ES)?$' {
2530
+ try { script:Invoke-AidMigrateRepo -Repo $usRepo } catch {
2531
+ [Console]::Error.WriteLine("WARN: aid: migration failed for ${usRepo}: $_")
2532
+ }
2533
+ }
2534
+ '^[Cc](ancel|ANCEL)?$' {
2535
+ $usMigrateCancel = $true
2536
+ Write-Host 'Migration cancelled.'
2537
+ }
2538
+ default {
2539
+ Write-Host "Skipped: $usRepo"
2540
+ }
2541
+ }
2542
+ }
2543
+ }
2544
+ }
2545
+ }
2546
+ script:Exit-Aid 0
494
2547
  }
495
2548
  # Fall through to shared add/update handler below.
496
2549
  }
497
2550
 
498
2551
  # ---------------------------------------------------------------------------
499
2552
  # remove (with 'self' subarg -> remove self)
2553
+ # Channel-aware, self-contained CLI removal. npm/pypi installs are owned by the
2554
+ # package manager, so removing only $AID_HOME left the wrapper + bin shim behind.
2555
+ # Now each channel does the COMPLETE removal:
2556
+ # npm -> npm uninstall -g aid-installer (package + vendored tree + shim)
2557
+ # pypi -> pipx uninstall aid-installer (venv + entry point)
2558
+ # curl -> Remove-Item $AID_CODE_HOME + unwire PATH
2559
+ # On Windows there is no sudo -- callers elevate their own shell if needed.
2560
+ # Honors -DryRun.
500
2561
  # ---------------------------------------------------------------------------
501
2562
  if ($SUBCMD -eq 'remove') {
502
2563
  if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -eq 'self') {
503
2564
  # Parse flags after 'self'.
504
2565
  $rsForce = $false
505
2566
  $rsNoPath = $false
2567
+ $rsDryRun = $false
506
2568
  $remIdx = 1
507
2569
  while ($remIdx -lt $script:_RemArgs.Count) {
508
2570
  $a = $script:_RemArgs[$remIdx]
509
2571
  switch ($a) {
510
- { $_ -in @('-Force', '--force', '-y') } { $rsForce = $true }
511
- { $_ -in @('-NoPath', '--no-path', '/nopath') } { $rsNoPath = $true }
512
- { $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'remove'; script:Exit-Aid 0 }
2572
+ { $_ -in @('-Force', '--force', '-y') } { $rsForce = $true }
2573
+ { $_ -in @('-NoPath', '--no-path', '/nopath') } { $rsNoPath = $true }
2574
+ { $_ -in @('-DryRun', '--dry-run') } { $rsDryRun = $true }
2575
+ { $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'remove'; script:Exit-Aid 0 }
513
2576
  default { script:Fail-Aid "unknown flag for 'remove self': $a" 2 }
514
2577
  }
515
2578
  $remIdx++
@@ -520,15 +2583,23 @@ if ($SUBCMD -eq 'remove') {
520
2583
  $rsForce = $true
521
2584
  }
522
2585
 
523
- $aidHome = $script:_AidHome
2586
+ $channel = $env:AID_INSTALL_CHANNEL
2587
+ $aidHome = $script:_AidCodeHome
2588
+
2589
+ # Channel-aware description of what will be removed (NFR transparency).
2590
+ $what = switch ($channel) {
2591
+ 'npm' { "the npm global package 'aid-installer' (npm uninstall -g)" }
2592
+ 'pypi' { "the pipx app 'aid-installer' (pipx uninstall)" }
2593
+ default { "$aidHome and its PATH wiring" }
2594
+ }
524
2595
 
525
- if (-not $rsForce) {
2596
+ if (-not $rsForce -and -not $rsDryRun) {
526
2597
  # Skip prompt when non-interactive.
527
2598
  $isInteractive = [Environment]::UserInteractive -and [Console]::In -ne [System.IO.TextReader]::Null
528
2599
  if (-not $isInteractive) {
529
2600
  $rsForce = $true
530
2601
  } else {
531
- Write-Host -NoNewline "Remove the aid CLI from ${aidHome}? [y/N] "
2602
+ Write-Host -NoNewline "Remove the aid CLI -- ${what}? [y/N] "
532
2603
  $answer = Read-Host
533
2604
  if ($answer -notin @('y', 'Y', 'yes', 'YES')) {
534
2605
  Write-Host "Aborted."
@@ -539,22 +2610,62 @@ if ($SUBCMD -eq 'remove') {
539
2610
 
540
2611
  $partial = $false
541
2612
 
542
- # Remove PATH wiring.
543
- if (-not $rsNoPath) {
544
- $binDir = Join-Path $aidHome 'bin'
545
- try { script:Remove-AidFromPath -BinDir $binDir } catch { $partial = $true }
546
- }
547
-
548
- # Remove $AID_HOME directory.
549
- if (Test-Path $aidHome -PathType Container) {
550
- try {
551
- Remove-Item -LiteralPath $aidHome -Recurse -Force -ErrorAction Stop
552
- } catch {
553
- [Console]::Error.WriteLine("ERROR: aid: failed to remove $aidHome : $_")
554
- $partial = $true
2613
+ switch ($channel) {
2614
+ 'npm' {
2615
+ $npmCmd = Get-Command 'npm' -ErrorAction SilentlyContinue
2616
+ if (-not $npmCmd) {
2617
+ [Console]::Error.WriteLine("ERROR: aid: npm not found; cannot remove the npm-channel CLI")
2618
+ script:Exit-Aid 3
2619
+ }
2620
+ if ($rsDryRun) {
2621
+ Write-Host '+ npm uninstall -g aid-installer'
2622
+ } else {
2623
+ & npm uninstall -g aid-installer
2624
+ if ($LASTEXITCODE -ne 0) { $partial = $true }
2625
+ }
2626
+ }
2627
+ 'pypi' {
2628
+ $pipxCmd = Get-Command 'pipx' -ErrorAction SilentlyContinue
2629
+ if (-not $pipxCmd) {
2630
+ [Console]::Error.WriteLine("ERROR: aid: pipx not found; cannot remove the pypi-channel CLI")
2631
+ script:Exit-Aid 3
2632
+ }
2633
+ if ($rsDryRun) {
2634
+ Write-Host '+ pipx uninstall aid-installer'
2635
+ } else {
2636
+ & pipx uninstall aid-installer
2637
+ if ($LASTEXITCODE -ne 0) { $partial = $true }
2638
+ }
2639
+ }
2640
+ default {
2641
+ # curl / default channel -- the _AidCodeHome tree + User PATH wiring.
2642
+ if ($rsDryRun) {
2643
+ if (-not $rsNoPath) {
2644
+ Write-Host "+ (unwire $aidHome\bin from your User PATH)"
2645
+ }
2646
+ Write-Host "+ Remove-Item -Recurse -Force $aidHome"
2647
+ } else {
2648
+ # Remove PATH wiring.
2649
+ if (-not $rsNoPath) {
2650
+ $binDir = Join-Path $aidHome 'bin'
2651
+ try { script:Remove-AidFromPath -BinDir $binDir } catch { $partial = $true }
2652
+ }
2653
+
2654
+ # Remove _AidCodeHome directory.
2655
+ if (Test-Path $aidHome -PathType Container) {
2656
+ try {
2657
+ Remove-Item -LiteralPath $aidHome -Recurse -Force -ErrorAction Stop
2658
+ } catch {
2659
+ [Console]::Error.WriteLine("ERROR: aid: failed to remove $aidHome : $_")
2660
+ $partial = $true
2661
+ }
2662
+ }
2663
+ }
555
2664
  }
556
2665
  }
557
2666
 
2667
+ if ($rsDryRun) { script:Exit-Aid 0 }
2668
+
558
2669
  if ($partial) {
559
2670
  Write-Host "aid CLI partially removed. Check the messages above for what remained."
560
2671
  script:Exit-Aid 1
@@ -566,6 +2677,86 @@ if ($SUBCMD -eq 'remove') {
566
2677
  # Fall through to shared remove handler below (may be 'remove' with no arg or with tool).
567
2678
  }
568
2679
 
2680
+
2681
+ # ---------------------------------------------------------------------------
2682
+ # dashboard
2683
+ # ---------------------------------------------------------------------------
2684
+ if ($SUBCMD -eq 'dashboard') {
2685
+ script:Invoke-AidDashboardCtl -DcArgs $script:_RemArgs
2686
+ script:Exit-Aid $LASTEXITCODE
2687
+ }
2688
+
2689
+ # ---------------------------------------------------------------------------
2690
+ # __migrate-repo (hidden, callable-core only -- task-077/081)
2691
+ # ---------------------------------------------------------------------------
2692
+ if ($SUBCMD -eq '__migrate-repo') {
2693
+ if ($script:_RemArgs.Count -lt 1) {
2694
+ [Console]::Error.WriteLine("ERROR: aid __migrate-repo requires a <repo> path argument")
2695
+ script:Exit-Aid 2
2696
+ }
2697
+ $_MigTarget = $script:_RemArgs[0]
2698
+ if (-not (Test-Path $_MigTarget -PathType Container)) {
2699
+ [Console]::Error.WriteLine("ERROR: aid __migrate-repo: not a directory: $_MigTarget")
2700
+ script:Exit-Aid 2
2701
+ }
2702
+ $_MigTarget = (Resolve-Path -LiteralPath $_MigTarget).Path
2703
+ script:Invoke-AidMigrateRepo -Repo $_MigTarget
2704
+ script:Exit-Aid 0
2705
+ }
2706
+
2707
+ # ---------------------------------------------------------------------------
2708
+ # projects
2709
+ # ---------------------------------------------------------------------------
2710
+ if ($SUBCMD -eq 'projects') {
2711
+ # Check for -h/--help as first arg before dispatching.
2712
+ if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -in @('-h', '--help', '-Help')) {
2713
+ script:Show-AidUsage 'projects'
2714
+ script:Exit-Aid 0
2715
+ }
2716
+
2717
+ # Determine sub-action (first positional or default "list").
2718
+ # Scan through leading flags to find the action word; unknown positionals are
2719
+ # rejected here so errors surface before entering Invoke-AidProjects.
2720
+ $_ProjAction = 'list'
2721
+ $_ProjArgs = [System.Collections.Generic.List[string]]::new()
2722
+ $remIdx = 0
2723
+ while ($remIdx -lt $script:_RemArgs.Count) {
2724
+ $a = $script:_RemArgs[$remIdx]
2725
+ switch ($a) {
2726
+ { $_ -in @('list', 'add', 'remove', 'help') } {
2727
+ $_ProjAction = $a
2728
+ $remIdx++
2729
+ while ($remIdx -lt $script:_RemArgs.Count) {
2730
+ $_ProjArgs.Add($script:_RemArgs[$remIdx])
2731
+ $remIdx++
2732
+ }
2733
+ break
2734
+ }
2735
+ { $_ -in @('-h', '--help', '-Help') } {
2736
+ script:Show-AidUsage 'projects'
2737
+ script:Exit-Aid 0
2738
+ break
2739
+ }
2740
+ { $_ -in @('--local', '--shared', '--verbose') } {
2741
+ $_ProjArgs.Add($a)
2742
+ break
2743
+ }
2744
+ { $_ -match '^-' } {
2745
+ # Unknown flag: pass through to Invoke-AidProjects for rejection.
2746
+ $_ProjArgs.Add($a)
2747
+ break
2748
+ }
2749
+ default {
2750
+ [Console]::Error.WriteLine("ERROR: aid projects: unknown action: $a (expected: list, add, remove, help)")
2751
+ script:Exit-Aid 2
2752
+ }
2753
+ }
2754
+ $remIdx++
2755
+ }
2756
+ script:Invoke-AidProjects -Action $_ProjAction -RemArgs $_ProjArgs.ToArray()
2757
+ script:Exit-Aid 0
2758
+ }
2759
+
569
2760
  # ---------------------------------------------------------------------------
570
2761
  # add / remove / update - validate subcommand
571
2762
  # ---------------------------------------------------------------------------
@@ -640,7 +2831,32 @@ if (-not $_AidTarget) { $_AidTarget = '.' }
640
2831
  if (-not (Test-Path $_AidTarget -PathType Container)) {
641
2832
  script:Fail-Aid "target directory does not exist: $_AidTarget" 2
642
2833
  }
643
- $_AidTarget = (Resolve-Path $_AidTarget).Path
2834
+ $_AidTarget = (Resolve-Path -LiteralPath $_AidTarget).Path
2835
+
2836
+ # ---- C-table pre-check for 'update [tool]': non-project -> offer + exit 0 ----
2837
+ # Must run BEFORE resolve-tools so we never reach exit-6 when no .aid/ exists.
2838
+ # 'add' uses the B-table (checked inside the dispatch case); 'remove' is not in C-table.
2839
+ # Test-AidIsProjectDir excludes the CLI state home from "is project" classification.
2840
+ if ($SUBCMD -eq 'update') {
2841
+ if (-not (script:Test-AidIsProjectDir -Dir $_AidTarget)) {
2842
+ script:Invoke-AidCwdNoAidOffer -Target $_AidTarget
2843
+ # Invoke-AidCwdNoAidOffer always calls Exit-Aid 0.
2844
+ }
2845
+ }
2846
+
2847
+ # C6': format gate for the update repo path (only when target is a real project;
2848
+ # an add to a fresh repo with no .aid/ falls through normally).
2849
+ if ($SUBCMD -eq 'update' -and (script:Test-AidIsProjectDir -Dir $_AidTarget)) {
2850
+ $gateRc = script:Invoke-AidFormatGate -Repo $_AidTarget
2851
+ if ($gateRc -ne 0) { script:Exit-Aid $gateRc }
2852
+ }
2853
+
2854
+ # ---- Self-update-if-needed preamble (FF-3 / CLI-2 / task-079) --------------
2855
+ # For 'update [<tool>]' only (not 'add', not 'update self'). Ensures the CLI
2856
+ # is current before the per-repo migration runs (FR38 / OQ-6). WARN-not-fail.
2857
+ if ($SUBCMD -eq 'update') {
2858
+ script:Invoke-AidUpdateSelfIfStale -FromBundle $_AidFromBundle
2859
+ }
644
2860
 
645
2861
  # Strip leading 'v' from version.
646
2862
  if ($_AidVersionArg) { $_AidVersionArg = $_AidVersionArg -replace '^v', '' }
@@ -810,6 +3026,25 @@ try {
810
3026
 
811
3027
  switch ($SUBCMD) {
812
3028
  { $_ -in @('add', 'update') } {
3029
+ # B-table (for 'add'): writability pre-check BEFORE any .aid/ is created.
3030
+ # Decision #3: never elevate .aid/ creation -- error if folder is not writable.
3031
+ if ($SUBCMD -eq 'add') {
3032
+ $_aidTargetWritable = $false
3033
+ $_wProbe = Join-Path $_AidTarget ('.aid-write-probe.' + [System.IO.Path]::GetRandomFileName())
3034
+ try { [System.IO.File]::WriteAllText($_wProbe, ''); Remove-Item -LiteralPath $_wProbe -Force -ErrorAction SilentlyContinue; $_aidTargetWritable = $true } catch {}
3035
+ if (-not $_aidTargetWritable) {
3036
+ [Console]::Error.WriteLine("ERROR: aid: add: target directory is not writable: $_AidTarget")
3037
+ [Console]::Error.WriteLine("ERROR: aid: add: AID will not create a root-owned .aid/ -- fix folder permissions and retry.")
3038
+ script:Exit-Aid 1
3039
+ }
3040
+ }
3041
+
3042
+ # C-table (for 'update [tool]'): register-on-encounter.
3043
+ # The missing-.aid/ case was already intercepted above (pre-resolve-tools).
3044
+ if ($SUBCMD -eq 'update') {
3045
+ script:Invoke-AidCwdClassify -Target $_AidTarget
3046
+ }
3047
+
813
3048
  foreach ($t in $_AidTools) {
814
3049
  Write-Host ""
815
3050
  script:Prepare-AidToolStaging -Tool $t -Version $_AidVersionArg -Bundle $_AidFromBundle
@@ -831,6 +3066,27 @@ try {
831
3066
  script:Exit-Aid 5
832
3067
  }
833
3068
  Write-Host "Done. AID $($script:_DispResolvedVersion) installed into: $_AidTarget"
3069
+
3070
+ # B-table (for 'add'): tier-aware registration after successful install.
3071
+ # Decision #3 (unwritable) already handled above with error+abort.
3072
+ if ($SUBCMD -eq 'add') {
3073
+ # FR7: deterministic, non-interactive tier selection via Resolve-AidTier.
3074
+ $_btabTier = script:Resolve-AidTier -CanonPath $_AidTarget
3075
+ script:Registry-Register -Repo $_AidTarget -Tier $_btabTier
3076
+ } else {
3077
+ # 'update [tool]': C-table register-on-encounter already ran above.
3078
+ # The post-install register is idempotent; route via user tier.
3079
+ script:Registry-Register -Repo $_AidTarget -Tier 'user'
3080
+ }
3081
+
3082
+ # FF-3 / CLI-2 / task-079: per-repo migration on the 'update' reach only.
3083
+ # Runs on the already-canonicalized $_AidTarget (Resolve-Path above).
3084
+ # The Registry-Register above already ran, so migration step 4 is an
3085
+ # idempotent no-op; steps 1-3 run per FF-1. WARN-not-fail (NFR12):
3086
+ # migration never changes the tool-update exit code.
3087
+ if ($SUBCMD -eq 'update') {
3088
+ script:Invoke-AidMigrateRepo -Repo $_AidTarget
3089
+ }
834
3090
  script:Exit-Aid 0
835
3091
  }
836
3092
 
@@ -851,6 +3107,10 @@ try {
851
3107
 
852
3108
  Write-Host ""
853
3109
  Write-Host "Uninstall complete."
3110
+ # DR-1 registry side-effect: unregister repo only when manifest is now gone (last tool removed).
3111
+ if (-not (Test-Path $_AidManifest -PathType Leaf)) {
3112
+ script:Registry-Unregister -Repo $_AidTarget
3113
+ }
854
3114
  script:Exit-Aid 0
855
3115
  }
856
3116
  }