aid-installer 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/aid.ps1 ADDED
@@ -0,0 +1,875 @@
1
+ #Requires -Version 5.1
2
+ # aid.ps1 - AID CLI dispatcher (PowerShell side).
3
+ #
4
+ # Purpose:
5
+ # Persistent global command installed at $AID_HOME\bin\aid.ps1. Parses
6
+ # subcommands and dispatches to the shared install-core engine located at
7
+ # $AID_HOME\lib\AidInstallCore.psm1. Operates on the current working
8
+ # directory (-Target / AID_TARGET overrides).
9
+ #
10
+ # Usage:
11
+ # aid Show the dashboard
12
+ # aid -h | --help Show help
13
+ # aid version Print the CLI version
14
+ # aid status Show AID state of the current project
15
+ # aid add <tool>[,...] Add tool(s) to the current project
16
+ # aid update [<tool>... | self] Update to latest; no arg = all tools; 'self' = the aid CLI
17
+ # aid remove [<tool>... | self] Remove; no arg = ALL AID from project; 'self' = the aid CLI
18
+ # aid <command> -h | --help Per-command help
19
+ #
20
+ # Flags (shared across subcommands where applicable):
21
+ # -FromBundle <path> Offline install from a pre-downloaded tarball / dir.
22
+ # -Version <v> Pin to a specific release version (e.g. 0.7.0).
23
+ # -Force Overwrite differing files / skip confirmation prompts.
24
+ # -Target <dir> Project root (default: current directory).
25
+ # -Verbose Print per-file detail (default: concise summary).
26
+ # -NoPath (bootstrap / update self only) Skip PATH wiring.
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Bootstrap URL - single place to update when the branch merges to master.
30
+ # Override with $env:AID_INSTALL_URL for tests.
31
+ # ---------------------------------------------------------------------------
32
+ $script:_AidInstallUrl = if ($env:AID_INSTALL_URL) { $env:AID_INSTALL_URL } else {
33
+ 'https://raw.githubusercontent.com/AndreVianna/aid-methodology/worktree-work-002-auto-installer/install.ps1'
34
+ }
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Piped-mode / terminal-survival guard.
38
+ # When invoked via scriptblock or iex, calling exit <N> kills the host.
39
+ # Use the same sentinel-throw pattern as install.ps1.
40
+ # ---------------------------------------------------------------------------
41
+ $script:_PipedMode = [string]::IsNullOrEmpty($PSCommandPath)
42
+ $script:_SentinelTag = '__AidDispatcherExit__'
43
+
44
+ function script:Exit-Aid {
45
+ param([int]$Code)
46
+ if ($script:_PipedMode) {
47
+ $global:LASTEXITCODE = $Code
48
+ throw "$($script:_SentinelTag)$Code"
49
+ } else {
50
+ exit $Code
51
+ }
52
+ }
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Locate $AID_HOME. The installed dispatcher lives at $AID_HOME\bin\aid.ps1.
56
+ # ---------------------------------------------------------------------------
57
+ $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
+ }
64
+ } 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' }
67
+ }
68
+ }
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Import the shared install core from $AID_HOME\lib\.
72
+ # ---------------------------------------------------------------------------
73
+ $script:_CoreModule = Join-Path $script:_AidHome 'lib' | Join-Path -ChildPath 'AidInstallCore.psm1'
74
+ if (-not (Test-Path $script:_CoreModule -PathType Leaf)) {
75
+ [Console]::Error.WriteLine("ERROR: aid: install core not found at $($script:_CoreModule). Re-run the AID bootstrap to repair.")
76
+ script:Exit-Aid 1
77
+ }
78
+ # Load the core lib by dot-sourcing its content (NOT Import-Module - avoids PowerShell's
79
+ # module-analysis cache, which can serve a stale exported-command list across upgrades).
80
+ # Export-ModuleMember is a module-only cmdlet; shadow it with a local no-op so the lib's
81
+ # trailing Export-ModuleMember call is harmless when dot-sourced.
82
+ $_aidLibRaw = $null
83
+ try {
84
+ $_aidLibRaw = Get-Content -LiteralPath $script:_CoreModule -Raw -Encoding utf8 -ErrorAction Stop
85
+ } catch {
86
+ [Console]::Error.WriteLine("ERROR: aid: failed to read the CLI core from $($script:_CoreModule): $_")
87
+ script:Exit-Aid 1
88
+ }
89
+ function Export-ModuleMember { param([Parameter(ValueFromRemainingArguments=$true)]$args) }
90
+ . ([scriptblock]::Create($_aidLibRaw))
91
+
92
+ # Defensive guard: verify the required core function was loaded via dot-source.
93
+ # If Get-AidStatusBody is still absent after dot-sourcing, the lib is genuinely
94
+ # broken or incomplete (not a cache issue - the file itself is the problem).
95
+ if (-not (Get-Command 'Get-AidStatusBody' -ErrorAction SilentlyContinue)) {
96
+ [Console]::Error.WriteLine("ERROR: aid: failed to load the CLI core from $($script:_CoreModule). The file may be incomplete - reinstall with: irm $($script:_AidInstallUrl) | iex")
97
+ script:Exit-Aid 1
98
+ }
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Usage helper.
102
+ # ---------------------------------------------------------------------------
103
+ function script:Show-AidUsage {
104
+ param([string]$Sub = '')
105
+ switch ($Sub) {
106
+ 'status' {
107
+ Write-Host 'aid status [-Verbose] [-Target <dir>]'
108
+ Write-Host ' Show AID state of the current project (default: cwd).'
109
+ Write-Host ' Exit 7 when no AID install is found.'
110
+ }
111
+ 'add' {
112
+ Write-Host 'aid add <tool>[,<tool>...] [-Version <v>] [-FromBundle <path>]'
113
+ Write-Host ' [-Force] [-Verbose] [-Target <dir>]'
114
+ Write-Host ' Add tool(s) to the current project.'
115
+ Write-Host ' Tools: claude-code, codex, cursor, copilot-cli, antigravity'
116
+ }
117
+ 'remove' {
118
+ Write-Host 'aid remove [<tool>[,<tool>...] | self] [-Force] [-Verbose] [-Target <dir>]'
119
+ Write-Host ' Remove tool(s) from the current project (manifest-driven).'
120
+ 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).'
122
+ }
123
+ 'update' {
124
+ Write-Host 'aid update [<tool>... | self] [-Version <v>] [-FromBundle <path>]'
125
+ Write-Host ' [-Force] [-Verbose] [-Target <dir>]'
126
+ Write-Host ' Update to latest. No args: update all installed tools.'
127
+ Write-Host ' self: update the aid CLI itself.'
128
+ }
129
+ 'version' {
130
+ Write-Host 'aid version'
131
+ Write-Host ' Print the installed aid CLI version and exit 0.'
132
+ }
133
+ default {
134
+ Write-Host 'aid - AID CLI'
135
+ Write-Host ''
136
+ Write-Host 'Usage:'
137
+ Write-Host ' aid Show the dashboard'
138
+ Write-Host ' aid -h | --help Show this help'
139
+ Write-Host ' aid version Print the CLI version'
140
+ Write-Host ' aid status Show AID state of the current project'
141
+ Write-Host ' aid add <tool>[,...] Add tool(s) to the current project'
142
+ Write-Host ' aid update [<tool>... | self] Update to latest; no arg = all tools'
143
+ Write-Host ' aid remove [<tool>... | self] Remove; no arg = ALL AID from project'
144
+ Write-Host " aid <command> -h | --help Per-command help"
145
+ Write-Host ''
146
+ Write-Host 'Flags: -FromBundle, -Version, -Force, -Target, -Verbose'
147
+ Write-Host "Run 'aid <command> -h' for details."
148
+ }
149
+ }
150
+ }
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Error helper.
154
+ # ---------------------------------------------------------------------------
155
+ function script:Fail-Aid {
156
+ param([string]$Message, [int]$Code = 1)
157
+ [Console]::Error.WriteLine("ERROR: aid: $Message")
158
+ script:Exit-Aid $Code
159
+ }
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Update check (throttled, cached, non-blocking, opt-out).
163
+ # ---------------------------------------------------------------------------
164
+
165
+ # Invoke-AidUpdateCheck
166
+ # Compares installed CLI version against latest GitHub release.
167
+ # 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.
169
+ # Opt-out: $env:AID_NO_UPDATE_CHECK = '1'
170
+ # Test hook: $env:AID_UPDATE_CHECK_URL overrides the fetch URL (and bypasses throttle).
171
+ function script:Invoke-AidUpdateCheck {
172
+ # Opt-out.
173
+ if ($env:AID_NO_UPDATE_CHECK -eq '1') { return }
174
+
175
+ # Read installed version.
176
+ $verFile = Join-Path $script:_AidHome 'VERSION'
177
+ if (-not (Test-Path $verFile -PathType Leaf)) { return }
178
+ $installedVersion = (Get-Content -LiteralPath $verFile -Raw -ErrorAction SilentlyContinue).Trim()
179
+ if (-not $installedVersion) { return }
180
+
181
+ $cacheFile = Join-Path $script:_AidHome '.update-check'
182
+ $throttleSecs = 86400 # 24 hours
183
+ try { $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } catch { return }
184
+
185
+ # Determine URL and throttle behaviour.
186
+ $checkUrl = $env:AID_UPDATE_CHECK_URL
187
+ $useThrottle = [string]::IsNullOrEmpty($checkUrl)
188
+ if ($useThrottle) {
189
+ $checkUrl = "https://api.github.com/repos/AndreVianna/aid-methodology/releases/latest"
190
+ }
191
+
192
+ # Try to read cache.
193
+ $cachedTs = 0
194
+ $cachedLatest = ''
195
+ if (Test-Path $cacheFile -PathType Leaf) {
196
+ try {
197
+ $lines = @(Get-Content -LiteralPath $cacheFile -ErrorAction SilentlyContinue)
198
+ if ($lines.Count -ge 1) { $cachedTs = [long]$lines[0] }
199
+ if ($lines.Count -ge 2) { $cachedLatest = $lines[1].Trim() }
200
+ } catch {}
201
+ }
202
+
203
+ # Decide whether to fetch.
204
+ $latestVersion = ''
205
+ $needFetch = $true
206
+ if ($useThrottle -and $cachedLatest) {
207
+ $age = $now - $cachedTs
208
+ if ($age -lt $throttleSecs) {
209
+ $needFetch = $false
210
+ $latestVersion = $cachedLatest
211
+ }
212
+ }
213
+
214
+ if ($needFetch) {
215
+ $body = ''
216
+ try {
217
+ # Support file:// URLs for hermetic tests (PowerShell web cmdlets don't
218
+ # handle file://, so we strip the scheme and read the file directly).
219
+ if ($checkUrl -match '^file:///?(.+)$') {
220
+ $filePath = $matches[1]
221
+ # On Windows file:///C:/path -> C:/path; on Linux file:///tmp/path -> /tmp/path
222
+ if ($filePath -notmatch '^[A-Za-z]:') {
223
+ $filePath = '/' + $filePath.TrimStart('/')
224
+ }
225
+ $body = Get-Content -LiteralPath $filePath -Raw -ErrorAction Stop
226
+ } else {
227
+ $resp = Invoke-WebRequest -Uri $checkUrl -UseBasicParsing -TimeoutSec 2 `
228
+ -ErrorAction Stop
229
+ $body = $resp.Content
230
+ }
231
+ } catch {
232
+ return # fail-silent
233
+ }
234
+ if ($body -match '"tag_name"\s*:\s*"([^"]+)"') {
235
+ $tag = $matches[1] -replace '^v', ''
236
+ $latestVersion = $tag
237
+ # Update cache.
238
+ try {
239
+ [System.IO.File]::WriteAllText($cacheFile, "$now`n$latestVersion`n")
240
+ } catch {}
241
+ }
242
+ }
243
+
244
+ if (-not $latestVersion) { return }
245
+
246
+ # Compare: notice only when latest > installed.
247
+ # Inline semver comparison (mirrors script:Test-SemverLt from AidInstallCore.psm1
248
+ # but kept local here since script:-scoped module functions are not callable across
249
+ # the module boundary from the dispatcher script).
250
+ $partsA = $installedVersion -split '\.'
251
+ $partsB = $latestVersion -split '\.'
252
+ $isLt = $false
253
+ for ($i = 0; $i -lt 3; $i++) {
254
+ $rawA = if ($i -lt $partsA.Count) { $partsA[$i] } else { '0' }
255
+ $rawB = if ($i -lt $partsB.Count) { $partsB[$i] } else { '0' }
256
+ if ($rawA -match '^(\d+)') { $va = [int]$matches[1] } else { $va = 0 }
257
+ if ($rawB -match '^(\d+)') { $vb = [int]$matches[1] } else { $vb = 0 }
258
+ if ($va -lt $vb) { $isLt = $true; break }
259
+ if ($va -gt $vb) { break }
260
+ }
261
+ 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"
268
+ }
269
+ }
270
+
271
+ # 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.
275
+ function script:Invoke-AidUpdateSelf {
276
+ switch ($env:AID_INSTALL_CHANNEL) {
277
+ 'npm' {
278
+ Write-Host 'Updating the aid CLI: run npm i -g aid-installer@latest'
279
+ script:Exit-Aid 0
280
+ return
281
+ }
282
+ '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
286
+ }
287
+ }
288
+ Write-Host 'Updating the aid CLI...'
289
+ $url = $script:_AidInstallUrl
290
+ try {
291
+ $scriptContent = (Invoke-RestMethod -Uri $url -ErrorAction Stop)
292
+ & ([scriptblock]::Create($scriptContent))
293
+ script:Exit-Aid $LASTEXITCODE
294
+ } catch {
295
+ [Console]::Error.WriteLine("ERROR: aid: update self failed: $_")
296
+ script:Exit-Aid 3
297
+ }
298
+ }
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # PATH wiring helpers (Windows - User-scope registry).
302
+ # ---------------------------------------------------------------------------
303
+
304
+ # Add-AidToPath <binDir> [-NoPath]
305
+ # Idempotently wire binDir into the User PATH via [Environment]::SetEnvironmentVariable.
306
+ # Deduplicates on ';'-split. Warns if path would exceed safe length.
307
+ # Updates $env:Path in-process so the convenience-chain first action works immediately.
308
+ function script:Add-AidToPath {
309
+ param([string]$BinDir, [bool]$NoPath = $false)
310
+
311
+ if ($NoPath) {
312
+ Write-Host "Add `"$BinDir`" to your PATH manually."
313
+ return
314
+ }
315
+
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)"
325
+ }
326
+ return
327
+ }
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."
338
+ return
339
+ }
340
+
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)"
346
+ }
347
+
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
+ }
351
+
352
+ # Remove-AidFromPath <binDir>
353
+ # Remove binDir from User PATH idempotently.
354
+ function script:Remove-AidFromPath {
355
+ param([string]$BinDir)
356
+
357
+ $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
358
+ if (-not $currentPath) { return }
359
+
360
+ $parts = $currentPath -split ';' | Where-Object { $_ -and $_.Trim() -ne $BinDir }
361
+ $newPath = $parts -join ';'
362
+
363
+ if ($newPath -ne $currentPath) {
364
+ [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
365
+ Write-Host "PATH wiring removed (User scope): $BinDir"
366
+ }
367
+ }
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # Wrap everything in try/catch for terminal-survival in piped/iex mode.
371
+ # ---------------------------------------------------------------------------
372
+ try {
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Parse raw args.
376
+ # aid.ps1 is invoked by aid.cmd which forwards all args as positional strings.
377
+ # We parse manually to support both flag-style and positional style uniformly.
378
+ # ---------------------------------------------------------------------------
379
+ $script:_RawArgs = $args
380
+
381
+ # Resolve verbose from env var first (flag overrides below).
382
+ $script:_AidVerbose = ($env:AID_VERBOSE -eq '1')
383
+
384
+ # ---- Bare aid -> dashboard landing screen ----
385
+ if ($script:_RawArgs.Count -eq 0) {
386
+ # Block 1 + 2: Header + description.
387
+ $cliVersion = 'unknown'
388
+ $verFile = Join-Path $script:_AidHome 'VERSION'
389
+ if (Test-Path $verFile -PathType Leaf) {
390
+ $cliVersion = (Get-Content -LiteralPath $verFile -Raw).Trim()
391
+ }
392
+ Write-Host "AID v$cliVersion - Agentic Iterative Development"
393
+ Write-Host "Install, update, and manage AID across your repositories."
394
+
395
+ # Block 3: Installed tools for cwd.
396
+ Write-Host ""
397
+ $null = Get-AidStatusBody -Target '.'
398
+
399
+ # Block 4: Usage/help.
400
+ Write-Host ""
401
+ script:Show-AidUsage
402
+
403
+ # Block 5: Update check notice (final line, non-blocking).
404
+ script:Invoke-AidUpdateCheck
405
+ script:Exit-Aid 0
406
+ }
407
+
408
+ # ---- Early -h / --help ----
409
+ if ($script:_RawArgs[0] -in @('-h', '--help', '-Help', '/help', '/?')) {
410
+ script:Show-AidUsage
411
+ script:Exit-Aid 0
412
+ }
413
+
414
+ $SUBCMD = $script:_RawArgs[0]
415
+ $script:_RemArgs = @($script:_RawArgs | Select-Object -Skip 1)
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # version
419
+ # ---------------------------------------------------------------------------
420
+ if ($SUBCMD -eq 'version') {
421
+ $versionFile = Join-Path $script:_AidHome 'VERSION'
422
+ if (Test-Path $versionFile -PathType Leaf) {
423
+ Write-Host (Get-Content -LiteralPath $versionFile -Raw).Trim()
424
+ } else {
425
+ Write-Host "unknown (VERSION file not found at $versionFile)"
426
+ }
427
+ script:Exit-Aid 0
428
+ }
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # help (bare 'aid help' still works as general help)
432
+ # ---------------------------------------------------------------------------
433
+ if ($SUBCMD -in @('help', '-h', '--help')) {
434
+ script:Show-AidUsage
435
+ script:Exit-Aid 0
436
+ }
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # status
440
+ # ---------------------------------------------------------------------------
441
+ if ($SUBCMD -eq 'status') {
442
+ $statusTarget = ''
443
+ $remIdx = 0
444
+ while ($remIdx -lt $script:_RemArgs.Count) {
445
+ $a = $script:_RemArgs[$remIdx]
446
+ switch ($a) {
447
+ { $_ -in @('-Target', '--target') } {
448
+ $remIdx++
449
+ if ($remIdx -ge $script:_RemArgs.Count) { script:Fail-Aid "-Target requires a value" 2 }
450
+ $statusTarget = $script:_RemArgs[$remIdx]
451
+ }
452
+ { $_ -in @('-Verbose', '--verbose') } { $script:_AidVerbose = $true; $env:AID_VERBOSE = '1' }
453
+ { $_ -in @('-h', '--help', '-Help') } { script:Show-AidUsage 'status'; script:Exit-Aid 0 }
454
+ default {
455
+ if ($a.StartsWith('-')) {
456
+ script:Fail-Aid "unknown flag for status: $a" 2
457
+ } else {
458
+ script:Fail-Aid "unexpected argument for status: $a" 2
459
+ }
460
+ }
461
+ }
462
+ $remIdx++
463
+ }
464
+
465
+ # Apply env-var fallback.
466
+ if (-not $statusTarget -and $env:AID_TARGET) { $statusTarget = $env:AID_TARGET }
467
+ if (-not $statusTarget) { $statusTarget = '.' }
468
+ if ($script:_AidVerbose) { $env:AID_VERBOSE = '1' }
469
+
470
+ $rc = Get-AidStatus -Target $statusTarget
471
+ # Update check notice appended after status output (non-blocking).
472
+ script:Invoke-AidUpdateCheck
473
+ script:Exit-Aid $rc
474
+ }
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # update (with 'self' subarg -> update self)
478
+ # ---------------------------------------------------------------------------
479
+ if ($SUBCMD -eq 'update') {
480
+ if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -eq 'self') {
481
+ # Consume any flags after 'self'.
482
+ $remIdx = 1
483
+ while ($remIdx -lt $script:_RemArgs.Count) {
484
+ $a = $script:_RemArgs[$remIdx]
485
+ 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 }
488
+ default { script:Fail-Aid "unknown flag for 'update self': $a" 2 }
489
+ }
490
+ $remIdx++
491
+ }
492
+ script:Invoke-AidUpdateSelf
493
+ # Invoke-AidUpdateSelf always calls Exit-Aid.
494
+ }
495
+ # Fall through to shared add/update handler below.
496
+ }
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # remove (with 'self' subarg -> remove self)
500
+ # ---------------------------------------------------------------------------
501
+ if ($SUBCMD -eq 'remove') {
502
+ if ($script:_RemArgs.Count -gt 0 -and $script:_RemArgs[0] -eq 'self') {
503
+ # Parse flags after 'self'.
504
+ $rsForce = $false
505
+ $rsNoPath = $false
506
+ $remIdx = 1
507
+ while ($remIdx -lt $script:_RemArgs.Count) {
508
+ $a = $script:_RemArgs[$remIdx]
509
+ 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 }
513
+ default { script:Fail-Aid "unknown flag for 'remove self': $a" 2 }
514
+ }
515
+ $remIdx++
516
+ }
517
+
518
+ # Env-var fallback for force.
519
+ if (-not $rsForce -and ($env:AID_FORCE -eq '1' -or $env:AID_FORCE -eq 'true')) {
520
+ $rsForce = $true
521
+ }
522
+
523
+ $aidHome = $script:_AidHome
524
+
525
+ if (-not $rsForce) {
526
+ # Skip prompt when non-interactive.
527
+ $isInteractive = [Environment]::UserInteractive -and [Console]::In -ne [System.IO.TextReader]::Null
528
+ if (-not $isInteractive) {
529
+ $rsForce = $true
530
+ } else {
531
+ Write-Host -NoNewline "Remove the aid CLI from ${aidHome}? [y/N] "
532
+ $answer = Read-Host
533
+ if ($answer -notin @('y', 'Y', 'yes', 'YES')) {
534
+ Write-Host "Aborted."
535
+ script:Exit-Aid 0
536
+ }
537
+ }
538
+ }
539
+
540
+ $partial = $false
541
+
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
555
+ }
556
+ }
557
+
558
+ if ($partial) {
559
+ Write-Host "aid CLI partially removed. Check the messages above for what remained."
560
+ script:Exit-Aid 1
561
+ }
562
+
563
+ Write-Host "aid CLI removed. Per-project AID installs are unaffected; run 'aid remove' in a project before removing the CLI if you also want to remove those."
564
+ script:Exit-Aid 0
565
+ }
566
+ # Fall through to shared remove handler below (may be 'remove' with no arg or with tool).
567
+ }
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # add / remove / update - validate subcommand
571
+ # ---------------------------------------------------------------------------
572
+ if ($SUBCMD -notin @('add', 'remove', 'update')) {
573
+ [Console]::Error.WriteLine("ERROR: aid: unknown command: $SUBCMD (see 'aid -h')")
574
+ script:Exit-Aid 2
575
+ }
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # Parse shared flags for add/remove/update.
579
+ # ---------------------------------------------------------------------------
580
+ $_AidToolArg = ''
581
+ $_AidVersionArg = ''
582
+ $_AidFromBundle = ''
583
+ $_AidForce = $false
584
+ $_AidRemoveForce = $false
585
+ $_AidTarget = ''
586
+ $_AidPosTools = [System.Collections.Generic.List[string]]::new()
587
+
588
+ $remIdx = 0
589
+ while ($remIdx -lt $script:_RemArgs.Count) {
590
+ $a = $script:_RemArgs[$remIdx]
591
+ switch -Regex ($a) {
592
+ '^(-FromBundle|--from-bundle)$' {
593
+ $remIdx++
594
+ if ($remIdx -ge $script:_RemArgs.Count) { script:Fail-Aid "-FromBundle requires a value" 2 }
595
+ $_AidFromBundle = $script:_RemArgs[$remIdx]
596
+ break
597
+ }
598
+ '^(-Version|--version)$' {
599
+ $remIdx++
600
+ if ($remIdx -ge $script:_RemArgs.Count) { script:Fail-Aid "-Version requires a value" 2 }
601
+ $_AidVersionArg = $script:_RemArgs[$remIdx]
602
+ break
603
+ }
604
+ '^(-Force|--force|-y)$' { $_AidForce = $true; $_AidRemoveForce = $true; break }
605
+ '^(-Verbose|--verbose)$' { $script:_AidVerbose = $true; $env:AID_VERBOSE = '1'; break }
606
+ '^(-Target|--target)$' {
607
+ $remIdx++
608
+ if ($remIdx -ge $script:_RemArgs.Count) { script:Fail-Aid "-Target requires a value" 2 }
609
+ $_AidTarget = $script:_RemArgs[$remIdx]
610
+ break
611
+ }
612
+ '^(-NoPath|--no-path)$' { break <# bootstrap-only; silently ignore here #> }
613
+ '^(-h|--help|-Help)$' { script:Show-AidUsage $SUBCMD; script:Exit-Aid 0 }
614
+ '^-' {
615
+ script:Fail-Aid "unknown flag: $a" 2
616
+ }
617
+ default {
618
+ # Positional: tool name(s) - comma-separated or space-separated.
619
+ $_AidPosTools.Add($a)
620
+ break
621
+ }
622
+ }
623
+ $remIdx++
624
+ }
625
+
626
+ # Apply env-var fallbacks.
627
+ if (-not $_AidToolArg -and $_AidPosTools.Count -gt 0) { $_AidToolArg = $_AidPosTools -join ',' }
628
+ if (-not $_AidToolArg -and $env:AID_TOOL) { $_AidToolArg = $env:AID_TOOL }
629
+ if (-not $_AidVersionArg -and $env:AID_VERSION) { $_AidVersionArg = $env:AID_VERSION }
630
+ if (-not $_AidTarget -and $env:AID_TARGET) { $_AidTarget = $env:AID_TARGET }
631
+ if (-not $_AidForce -and ($env:AID_FORCE -eq '1' -or $env:AID_FORCE -eq 'true')) {
632
+ $_AidForce = $true
633
+ $_AidRemoveForce = $true
634
+ }
635
+ if ($script:_AidVerbose) { $env:AID_VERBOSE = '1' }
636
+
637
+ if (-not $_AidTarget) { $_AidTarget = '.' }
638
+
639
+ # Validate target directory.
640
+ if (-not (Test-Path $_AidTarget -PathType Container)) {
641
+ script:Fail-Aid "target directory does not exist: $_AidTarget" 2
642
+ }
643
+ $_AidTarget = (Resolve-Path $_AidTarget).Path
644
+
645
+ # Strip leading 'v' from version.
646
+ if ($_AidVersionArg) { $_AidVersionArg = $_AidVersionArg -replace '^v', '' }
647
+
648
+ # --from-bundle and --version are mutually exclusive.
649
+ if ($_AidFromBundle -and $_AidVersionArg) {
650
+ script:Fail-Aid "-FromBundle and -Version are mutually exclusive" 2
651
+ }
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # Manifest path.
655
+ # ---------------------------------------------------------------------------
656
+ $_AidManifest = Join-Path $_AidTarget (Join-Path '.aid' '.aid-manifest.json')
657
+
658
+ # ---------------------------------------------------------------------------
659
+ # For 'remove' with no tool arg: confirm then remove all.
660
+ # ---------------------------------------------------------------------------
661
+ if ($SUBCMD -eq 'remove' -and -not $_AidToolArg) {
662
+ if (-not $_AidRemoveForce) {
663
+ # Skip prompt when non-interactive.
664
+ $isInteractive = [Environment]::UserInteractive -and [Console]::In -ne [System.IO.TextReader]::Null
665
+ if (-not $isInteractive) {
666
+ $_AidRemoveForce = $true
667
+ } else {
668
+ Write-Host -NoNewline "Remove ALL AID from ${_AidTarget}? [y/N] "
669
+ $answer = Read-Host
670
+ if ($answer -notin @('y', 'Y', 'yes', 'YES')) {
671
+ Write-Host "Aborted."
672
+ script:Exit-Aid 0
673
+ }
674
+ }
675
+ }
676
+ # Proceed: fall through to resolve all tools from manifest.
677
+ }
678
+
679
+ # ---------------------------------------------------------------------------
680
+ # Resolve tool list.
681
+ # ---------------------------------------------------------------------------
682
+ function script:Resolve-AidToolList {
683
+ # Returns a [ref] result: sets $ResultRef.Value to $true (success) or $false (error).
684
+ # On success, populates $OutList with tool ids (may be empty for update/remove
685
+ # when no manifest exists).
686
+ # On error (auto-detect failed, normalize failed), sets $ResultRef.Value to $false.
687
+ param([string]$Raw, [string]$Subcmd, [string]$ManifestPath, [string]$TargetDir,
688
+ [ref]$ResultRef, [System.Collections.Generic.List[string]]$OutList)
689
+
690
+ if (-not $Raw) {
691
+ if ($Subcmd -in @('update', 'remove')) {
692
+ # No tool specified -> all tools in manifest.
693
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) {
694
+ $ResultRef.Value = $true # success, empty list = no manifest
695
+ return
696
+ }
697
+ $tools = Get-ManifestToolList -ManifestPath $ManifestPath
698
+ foreach ($t in $tools) { $OutList.Add($t.Id) }
699
+ $ResultRef.Value = $true
700
+ return
701
+ }
702
+ # Auto-detect for 'add'.
703
+ $detected = Detect-Tool -TargetPath $TargetDir
704
+ if ($null -eq $detected) { $ResultRef.Value = $false; return }
705
+ $OutList.Add($detected)
706
+ $ResultRef.Value = $true
707
+ return
708
+ }
709
+
710
+ # Split on comma.
711
+ $rawList = $Raw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
712
+ foreach ($t in $rawList) {
713
+ $canonical = Normalize-Tool -Raw $t
714
+ if ($null -eq $canonical) { $ResultRef.Value = $false; return }
715
+ $OutList.Add($canonical)
716
+ }
717
+ $ResultRef.Value = $true
718
+ }
719
+
720
+ $_AidToolsList = [System.Collections.Generic.List[string]]::new()
721
+ $_AidToolsOk = [ref]$false
722
+ script:Resolve-AidToolList -Raw $_AidToolArg -Subcmd $SUBCMD `
723
+ -ManifestPath $_AidManifest -TargetDir $_AidTarget `
724
+ -ResultRef $_AidToolsOk -OutList $_AidToolsList
725
+
726
+ if (-not $_AidToolsOk.Value) {
727
+ script:Exit-Aid 2
728
+ }
729
+
730
+ $_AidTools = $_AidToolsList
731
+
732
+ if ($_AidTools.Count -eq 0) {
733
+ switch ($SUBCMD) {
734
+ 'remove' {
735
+ [Console]::Error.WriteLine("ERROR: aid: no manifest at $_AidTarget\.aid\.aid-manifest.json (exit 6)")
736
+ script:Exit-Aid 6
737
+ }
738
+ 'update' {
739
+ [Console]::Error.WriteLine("ERROR: aid: no manifest at $_AidTarget\.aid\.aid-manifest.json; nothing to update (exit 6)")
740
+ script:Exit-Aid 6
741
+ }
742
+ 'add' {
743
+ [Console]::Error.WriteLine("ERROR: aid: cannot auto-detect host tool; pass tool name as argument (e.g. aid add codex)")
744
+ script:Exit-Aid 2
745
+ }
746
+ }
747
+ }
748
+
749
+ # ---------------------------------------------------------------------------
750
+ # Staging area management.
751
+ # ---------------------------------------------------------------------------
752
+ $_AidStagingBase = Join-Path ([System.IO.Path]::GetTempPath()) ("aid-" + [System.IO.Path]::GetRandomFileName())
753
+ New-Item -ItemType Directory -Path $_AidStagingBase -Force | Out-Null
754
+
755
+ $script:_DispResolvedVersion = ''
756
+ $script:_DispStagingDir = ''
757
+
758
+ function script:Prepare-AidToolStaging {
759
+ param([string]$Tool, [string]$Version, [string]$Bundle)
760
+
761
+ $toolStaging = Join-Path $_AidStagingBase ("staging-$Tool-" + [System.IO.Path]::GetRandomFileName())
762
+ New-Item -ItemType Directory -Path $toolStaging -Force | Out-Null
763
+
764
+ if ($Bundle) {
765
+ $tarball = $Bundle
766
+ if (Test-Path $Bundle -PathType Container) {
767
+ $pattern = Join-Path $Bundle "aid-$Tool-v*.tar.gz"
768
+ $found = Get-ChildItem -Path $pattern -ErrorAction SilentlyContinue | Select-Object -First 1
769
+ if (-not $found) {
770
+ [Console]::Error.WriteLine("ERROR: aid: no tarball found for tool '$Tool' in bundle directory: $Bundle")
771
+ script:Exit-Aid 1
772
+ }
773
+ $tarball = $found.FullName
774
+ }
775
+ if (-not (Test-Path $tarball -PathType Leaf)) {
776
+ [Console]::Error.WriteLine("ERROR: aid: bundle file not found: $tarball")
777
+ script:Exit-Aid 1
778
+ }
779
+ if (-not (Verify-BundleChecksum -Tarball $tarball)) { script:Exit-Aid 4 }
780
+ $tbase = [System.IO.Path]::GetFileName($tarball)
781
+ $script:_DispResolvedVersion = $tbase -replace "^aid-$Tool-v", '' -replace '\.tar\.gz$', ''
782
+ if (-not $script:_DispResolvedVersion) {
783
+ $script:_DispResolvedVersion = if ($Version) { $Version } else { 'unknown' }
784
+ }
785
+ if (-not (Extract-Tarball -Tarball $tarball -DestDir $toolStaging)) { script:Exit-Aid 1 }
786
+ } else {
787
+ if (-not $Version) {
788
+ $script:_DispResolvedVersion = Resolve-AidVersion
789
+ if (-not $script:_DispResolvedVersion) { script:Exit-Aid 3 }
790
+ } else {
791
+ $script:_DispResolvedVersion = $Version
792
+ }
793
+ $dlDir = Join-Path $_AidStagingBase ("download-$Tool-" + [System.IO.Path]::GetRandomFileName())
794
+ New-Item -ItemType Directory -Path $dlDir -Force | Out-Null
795
+ if (-not (Fetch-Tarball -Tool $Tool -Version $script:_DispResolvedVersion -DestDir $dlDir)) {
796
+ script:Exit-Aid 3
797
+ }
798
+ $tarball = Join-Path $dlDir "aid-$Tool-v$($script:_DispResolvedVersion).tar.gz"
799
+ if (-not (Extract-Tarball -Tarball $tarball -DestDir $toolStaging)) { script:Exit-Aid 1 }
800
+ }
801
+
802
+ $script:_DispStagingDir = $toolStaging
803
+ }
804
+
805
+ # ---------------------------------------------------------------------------
806
+ # Dispatch to engine.
807
+ # ---------------------------------------------------------------------------
808
+ try {
809
+ $overallBlocked = $false
810
+
811
+ switch ($SUBCMD) {
812
+ { $_ -in @('add', 'update') } {
813
+ foreach ($t in $_AidTools) {
814
+ Write-Host ""
815
+ script:Prepare-AidToolStaging -Tool $t -Version $_AidVersionArg -Bundle $_AidFromBundle
816
+ Write-Host "Installing $t v$($script:_DispResolvedVersion) -> $_AidTarget"
817
+ $rc = Install-AidTool -StagingDir $script:_DispStagingDir -Tool $t -Target $_AidTarget `
818
+ -Version $script:_DispResolvedVersion -Force ([bool]$_AidForce) `
819
+ -AidVerbose $script:_AidVerbose
820
+ if ($rc -eq 5) {
821
+ $overallBlocked = $true
822
+ } elseif ($rc -ne 0) {
823
+ script:Exit-Aid $rc
824
+ }
825
+ }
826
+
827
+ Write-Host ""
828
+ if ($overallBlocked) {
829
+ Write-Host "Install complete with warnings: one or more root agent files were not overwritten."
830
+ Write-Host "Review the *.aid-new file(s) and merge, or re-run with -Force to overwrite."
831
+ script:Exit-Aid 5
832
+ }
833
+ Write-Host "Done. AID $($script:_DispResolvedVersion) installed into: $_AidTarget"
834
+ script:Exit-Aid 0
835
+ }
836
+
837
+ 'remove' {
838
+ if (-not (Test-ManifestExists -ManifestPath $_AidManifest)) {
839
+ [Console]::Error.WriteLine("ERROR: aid: no manifest at $_AidTarget\.aid\.aid-manifest.json; nothing to uninstall")
840
+ script:Exit-Aid 6
841
+ }
842
+
843
+ foreach ($t in $_AidTools) {
844
+ Write-Host ""
845
+ Write-Host "Uninstalling $t from $_AidTarget"
846
+ $rc = Uninstall-AidTool -ManifestPath $_AidManifest -Tool $t -Target $_AidTarget `
847
+ -AidVerbose $script:_AidVerbose
848
+ if ($rc -eq 6) { script:Exit-Aid 6 }
849
+ if ($rc -ne 0) { script:Exit-Aid $rc }
850
+ }
851
+
852
+ Write-Host ""
853
+ Write-Host "Uninstall complete."
854
+ script:Exit-Aid 0
855
+ }
856
+ }
857
+ } finally {
858
+ if (Test-Path $_AidStagingBase -PathType Container) {
859
+ Remove-Item -LiteralPath $_AidStagingBase -Recurse -Force -ErrorAction SilentlyContinue
860
+ }
861
+ }
862
+
863
+ } catch {
864
+ $msg = "$_"
865
+ if ($msg.StartsWith($script:_SentinelTag)) {
866
+ # Clean unwind in piped mode - $global:LASTEXITCODE already set.
867
+ return
868
+ }
869
+ if ($script:_PipedMode) {
870
+ $global:LASTEXITCODE = 1
871
+ [Console]::Error.WriteLine("ERROR: aid: unhandled exception: $_")
872
+ return
873
+ }
874
+ throw
875
+ }