aid-installer 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1411 @@
1
+ #Requires -Version 5.1
2
+ # AidInstallCore.psm1 - Shared PowerShell install-core module for the AID installer.
3
+ #
4
+ # Purpose:
5
+ # Importable module of pure functions used by install.ps1 (PowerShell bootstrap)
6
+ # and any future in-language caller. No top-level side effects when imported -
7
+ # every function is defined here; nothing executes at import time.
8
+ #
9
+ # Provides:
10
+ # Get-Sha256File <path> - hex sha256 of file (returns string)
11
+ # Normalize-Tool <id> - canonical lower-case-hyphen tool id
12
+ # Detect-Tool <target> - detected tool id; throws on 0 or >1
13
+ # Resolve-AidVersion - latest GitHub release version; throws on fail
14
+ # Fetch-Tarball <tool> <ver> <destDir> - download + verify tarball; throws on error
15
+ # Extract-Tarball <tarball> <destDir> - extract (flat root); throws on fail
16
+ # Verify-BundleChecksum <tarball> - verify sibling SHA256SUMS if present
17
+ # Copy-AidFile <src> <dst> [force] [aidVerbose] - copy semantics; per-file output when verbose
18
+ # Copy-AidDir <srcDir> <dstDir> [force] [aidVerbose] - recursive copy via Copy-AidFile
19
+ # Install-AidTool <staging> <tool> <target> <version> [force] [aidVerbose]
20
+ # - full install for one tool (copy + manifest)
21
+ # Read-ManifestToolPaths <manifest> <tool> - array of paths from tools.<tool>.paths
22
+ # Read-ManifestToolVersion <manifest> <tool> - version string for named tool
23
+ # Read-ManifestRootAgent <manifest> <tool> <fname>
24
+ # - sha256 from root_agent_files entry (or empty)
25
+ # Read-ManifestRootAgentStatus <manifest> <tool> <fname>
26
+ # - status field from root_agent_files entry
27
+ # Write-AidManifest <manifest> <tool> <version> <paths> <rootEntries>
28
+ # - atomic write/merge of manifest JSON
29
+ # Remove-ManifestTool <manifest> <tool> - removes a tool section from manifest
30
+ # Test-ManifestExists <manifest> - returns $true when manifest exists/parseable
31
+ # Uninstall-AidTool <manifest> <tool> <target> [aidVerbose] - manifest-driven removal
32
+ # Write-VersionMarker <target> <version> - writes <target>/.aid/.aid-version
33
+ #
34
+ # Verbose mode:
35
+ # Pass -AidVerbose $true to Install-AidTool / Uninstall-AidTool / Copy-AidFile /
36
+ # Copy-AidDir to enable per-file Copied:/Up to date:/Updated:/Removed: lines.
37
+ # Default (false): only per-tool summary line. WARN lines always show.
38
+ #
39
+ # Exit codes (from install.ps1):
40
+ # 0 success
41
+ # 1 generic runtime failure
42
+ # 2 usage error
43
+ # 3 network / fetch failure
44
+ # 4 checksum mismatch
45
+ # 5 protect-on-diff blocked (without -Force)
46
+ # 6 uninstall with no manifest
47
+
48
+ Set-StrictMode -Version Latest
49
+
50
+ # Guard against being imported more than once.
51
+ # Use Get-Variable with -ErrorAction SilentlyContinue to avoid strict-mode failure on first load.
52
+ $_aidLoadedVar = Get-Variable -Name '_AID_INSTALL_CORE_LOADED' -Scope Global -ErrorAction SilentlyContinue
53
+ if ($_aidLoadedVar -and $_aidLoadedVar.Value -eq $true) { return }
54
+ Set-Variable -Name '_AID_INSTALL_CORE_LOADED' -Value $true -Scope Global
55
+
56
+ # Module-level per-tool copy counters. Reset by Install-AidTool before each tool.
57
+ $script:_CopyCountCopied = 0
58
+ $script:_CopyCountUpToDate = 0
59
+ $script:_CopyCountUpdated = 0
60
+ $script:_CopyCountSkipped = 0
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Constants
64
+ # ---------------------------------------------------------------------------
65
+
66
+ $script:AID_REPO_SLUG = "AndreVianna/aid-methodology"
67
+ $script:AID_API_BASE = "https://api.github.com/repos/$($script:AID_REPO_SLUG)"
68
+ $script:AID_DOWNLOAD_BASE = "https://github.com/$($script:AID_REPO_SLUG)/releases/download"
69
+
70
+ $script:AID_TOOLS = @('claude-code', 'codex', 'cursor', 'copilot-cli', 'antigravity')
71
+
72
+ # Root agent file per tool.
73
+ function script:Get-RootAgentFile {
74
+ param([string]$Tool)
75
+ switch ($Tool) {
76
+ 'claude-code' { return 'CLAUDE.md' }
77
+ default { return 'AGENTS.md' }
78
+ }
79
+ }
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Utility
83
+ # ---------------------------------------------------------------------------
84
+
85
+ # Get-Sha256File <path> - return lower-case hex sha256 of file content.
86
+ function Get-Sha256File {
87
+ param([string]$FilePath)
88
+ $hash = (Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash
89
+ return $hash.ToLower()
90
+ }
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Tool-id normalization + detection
94
+ # ---------------------------------------------------------------------------
95
+
96
+ # Normalize-Tool <input> - return canonical id or $null on unknown.
97
+ # Accepts canonical ids (case-insensitive) and PascalCase aliases.
98
+ # All forms normalize via ToLower() mapping:
99
+ # claude-code / claudecode / ClaudeCode -> 'claude-code'
100
+ # codex / Codex -> 'codex'
101
+ # cursor / Cursor -> 'cursor'
102
+ # copilot-cli / copilotcli / CopilotCli -> 'copilot-cli'
103
+ # antigravity / Antigravity -> 'antigravity'
104
+ function Normalize-Tool {
105
+ param([string]$Raw)
106
+ switch ($Raw.ToLower()) {
107
+ 'claude-code' { return 'claude-code' }
108
+ 'claudecode' { return 'claude-code' }
109
+ 'codex' { return 'codex' }
110
+ 'cursor' { return 'cursor' }
111
+ 'copilot-cli' { return 'copilot-cli' }
112
+ 'copilotcli' { return 'copilot-cli' }
113
+ 'antigravity' { return 'antigravity' }
114
+ default {
115
+ Write-Error "ERROR: AidInstallCore: unknown tool id: $Raw (valid: claude-code, codex, cursor, copilot-cli, antigravity)" -ErrorAction Continue
116
+ return $null
117
+ }
118
+ }
119
+ }
120
+
121
+ # Detect-Tool <target> - auto-detect installed host tool from tree markers.
122
+ # Returns canonical id. Writes error message to stderr and returns $null on ambiguous (>1) or none (0).
123
+ function Detect-Tool {
124
+ param([string]$TargetPath)
125
+ $found = [System.Collections.Generic.List[string]]::new()
126
+
127
+ if (Test-Path (Join-Path $TargetPath '.claude') -PathType Container) {
128
+ $found.Add('claude-code')
129
+ }
130
+ # codex: .codex or .agents dir
131
+ if ((Test-Path (Join-Path $TargetPath '.codex') -PathType Container) -or
132
+ (Test-Path (Join-Path $TargetPath '.agents') -PathType Container)) {
133
+ $found.Add('codex')
134
+ }
135
+ if (Test-Path (Join-Path $TargetPath '.cursor') -PathType Container) {
136
+ $found.Add('cursor')
137
+ }
138
+ # copilot-cli: .github with AID copilot subtree (.github/agents/ or .github/skills/)
139
+ $githubPath = Join-Path $TargetPath '.github'
140
+ if ((Test-Path $githubPath -PathType Container) -and
141
+ ((Test-Path (Join-Path $githubPath 'agents') -PathType Container) -or
142
+ (Test-Path (Join-Path $githubPath 'skills') -PathType Container))) {
143
+ $found.Add('copilot-cli')
144
+ }
145
+ if (Test-Path (Join-Path $TargetPath '.agent') -PathType Container) {
146
+ $found.Add('antigravity')
147
+ }
148
+
149
+ if ($found.Count -eq 1) {
150
+ return $found[0]
151
+ } elseif ($found.Count -eq 0) {
152
+ [Console]::Error.WriteLine("ERROR: cannot auto-detect host tool; pass --tool <name>")
153
+ return $null
154
+ } else {
155
+ $list = $found -join ', '
156
+ [Console]::Error.WriteLine("ERROR: ambiguous host tool (found: $list); pass --tool <name>")
157
+ return $null
158
+ }
159
+ }
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Version resolution (online)
163
+ # ---------------------------------------------------------------------------
164
+
165
+ # Resolve-AidVersion - fetch the latest release tag from GitHub API.
166
+ # Returns the version without leading 'v'. Returns $null on failure.
167
+ function Resolve-AidVersion {
168
+ $url = "$($script:AID_API_BASE)/releases/latest"
169
+ $headers = @{}
170
+ $token = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { '' }
171
+ if ($token) {
172
+ $headers['Authorization'] = "Bearer $token"
173
+ }
174
+
175
+ try {
176
+ $response = Invoke-RestMethod -Uri $url -Headers $headers -Method Get -ErrorAction Stop
177
+ $tag = $response.tag_name
178
+ if (-not $tag) {
179
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: could not parse tag_name from GitHub API response")
180
+ return $null
181
+ }
182
+ # Strip leading 'v'
183
+ return $tag -replace '^v', ''
184
+ } catch {
185
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: failed to fetch $url : $_")
186
+ return $null
187
+ }
188
+ }
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Fetch + extract (online mode)
192
+ # ---------------------------------------------------------------------------
193
+
194
+ # Fetch-Tarball <tool> <version> <destDir>
195
+ # Downloads the tarball + SHA256SUMS into destDir and verifies.
196
+ # Returns $true on success, $false on failure.
197
+ function Fetch-Tarball {
198
+ param(
199
+ [string]$Tool,
200
+ [string]$Version,
201
+ [string]$DestDir
202
+ )
203
+ $filename = "aid-$Tool-v$Version.tar.gz"
204
+ $url = "$($script:AID_DOWNLOAD_BASE)/v$Version/$filename"
205
+ $sumsUrl = "$($script:AID_DOWNLOAD_BASE)/v$Version/SHA256SUMS"
206
+ $tarball = Join-Path $DestDir $filename
207
+ $sumsFile = Join-Path $DestDir 'SHA256SUMS'
208
+
209
+ $headers = @{}
210
+ $token = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { '' }
211
+ if ($token) {
212
+ $headers['Authorization'] = "Bearer $token"
213
+ }
214
+
215
+ [Console]::Error.WriteLine("Fetching $filename ...")
216
+ try {
217
+ Invoke-WebRequest -Uri $url -OutFile $tarball -Headers $headers -UseBasicParsing -ErrorAction Stop
218
+ } catch {
219
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: failed to download $url : $_")
220
+ return $false
221
+ }
222
+
223
+ # Fetch SHA256SUMS (best-effort).
224
+ try {
225
+ Invoke-WebRequest -Uri $sumsUrl -OutFile $sumsFile -Headers $headers -UseBasicParsing -ErrorAction Stop
226
+ if (-not (Invoke-VerifyChecksum -Tarball $tarball -SumsFile $sumsFile)) {
227
+ return $false
228
+ }
229
+ } catch {
230
+ [Console]::Error.WriteLine("WARN: AidInstallCore: SHA256SUMS not available for v$Version; skipping checksum verification")
231
+ }
232
+
233
+ return $true
234
+ }
235
+
236
+ # Invoke-VerifyChecksum <tarball> <sumsFile>
237
+ # Returns $true if checksum matches; $false otherwise.
238
+ function script:Invoke-VerifyChecksum {
239
+ param([string]$Tarball, [string]$SumsFile)
240
+ $filename = [System.IO.Path]::GetFileName($Tarball)
241
+
242
+ $expected = ''
243
+ foreach ($line in [System.IO.File]::ReadAllLines($SumsFile)) {
244
+ # Format: "<hash> <filename>" or "<hash> *<filename>"
245
+ if ($line -match '^\s*([0-9a-fA-F]+)\s+[* ]?' + [regex]::Escape($filename) + '$') {
246
+ $expected = $matches[1].ToLower()
247
+ break
248
+ }
249
+ }
250
+
251
+ if (-not $expected) {
252
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: $filename not found in SHA256SUMS")
253
+ return $false
254
+ }
255
+
256
+ $actual = Get-Sha256File -FilePath $Tarball
257
+ if ($actual -ne $expected) {
258
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: checksum mismatch for $filename : expected $expected, got $actual")
259
+ return $false
260
+ }
261
+ [Console]::Error.WriteLine("Checksum OK: $filename")
262
+ return $true
263
+ }
264
+
265
+ # Verify-BundleChecksum <tarball>
266
+ # Checks for a sibling SHA256SUMS file. No-op if absent; returns $false on mismatch.
267
+ function Verify-BundleChecksum {
268
+ param([string]$Tarball)
269
+ # Resolve to absolute path if needed (handles relative paths).
270
+ $absPath = if ([System.IO.Path]::IsPathRooted($Tarball)) { $Tarball } else {
271
+ Join-Path (Get-Location).Path $Tarball
272
+ }
273
+ $dir = [System.IO.Path]::GetDirectoryName($absPath)
274
+ $sumsFile = Join-Path $dir 'SHA256SUMS'
275
+ if (-not (Test-Path $sumsFile -PathType Leaf)) {
276
+ return $true
277
+ }
278
+ return (Invoke-VerifyChecksum -Tarball $absPath -SumsFile $sumsFile)
279
+ }
280
+
281
+ # Extract-Tarball <tarball> <destDir>
282
+ # Extracts into destDir. feature-002 S2.3 guarantees a flat-root tarball.
283
+ # Asserts the flat-root contract and fails loudly when violated.
284
+ # Returns $true on success.
285
+ function Extract-Tarball {
286
+ param([string]$Tarball, [string]$DestDir)
287
+
288
+ if (-not (Test-Path $DestDir -PathType Container)) {
289
+ New-Item -ItemType Directory -Path $DestDir -Force | Out-Null
290
+ }
291
+
292
+ # Try tar.exe first (Windows 10 1803+, also available on Linux/macOS with pwsh).
293
+ $tarExe = (Get-Command 'tar' -ErrorAction SilentlyContinue)
294
+ if ($tarExe) {
295
+ try {
296
+ $listOutput = @(& tar -tzf $Tarball 2>&1)
297
+ if ($LASTEXITCODE -ne 0) {
298
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: failed to list tarball contents: $Tarball")
299
+ return $false
300
+ }
301
+
302
+ # Assert flat-root contract: first entry must not be a bare directory
303
+ # (pattern: "topdir/" with no sub-separators before the trailing slash).
304
+ $firstMember = $listOutput | Where-Object { $_ -match '\S' } | Select-Object -First 1
305
+ if ($firstMember -match '^[^/]+/$') {
306
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: tarball has a wrapping top-level directory ('$firstMember') - expected flat-root per feature-002 S2.3 contract: $Tarball")
307
+ return $false
308
+ }
309
+
310
+ & tar -xzf $Tarball -C $DestDir 2>&1 | Out-Null
311
+ if ($LASTEXITCODE -ne 0) {
312
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: failed to extract $Tarball")
313
+ return $false
314
+ }
315
+ return $true
316
+ } catch {
317
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: tar extraction failed: $_")
318
+ return $false
319
+ }
320
+ }
321
+
322
+ # Expand-Archive fallback (zip only - for .tar.gz this is a limitation;
323
+ # documented fallback per SPEC Artifact-consumption contract).
324
+ # For tar.gz on old Windows without tar.exe we use a workaround via .NET GZipStream.
325
+ [Console]::Error.WriteLine("WARN: AidInstallCore: tar not found; attempting Expand-Archive fallback (zip only)")
326
+ try {
327
+ Expand-Archive -Path $Tarball -DestinationPath $DestDir -Force
328
+ return $true
329
+ } catch {
330
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: Expand-Archive fallback failed: $_")
331
+ return $false
332
+ }
333
+ }
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Copy semantics
337
+ # ---------------------------------------------------------------------------
338
+
339
+ # Copy-AidFile <src> <dst> [force] [aidVerbose]
340
+ # Handles NON-root-agent files only.
341
+ # Per-file lines emitted only when AidVerbose=$true.
342
+ # Increments module-level counters: $script:_CopyCountCopied, _CopyCountUpToDate,
343
+ # _CopyCountUpdated, _CopyCountSkipped (caller resets before loop).
344
+ function Copy-AidFile {
345
+ param(
346
+ [string]$Src,
347
+ [string]$Dst,
348
+ [bool]$Force = $false,
349
+ [bool]$AidVerbose = $false
350
+ )
351
+ $dstDir = [System.IO.Path]::GetDirectoryName($Dst)
352
+ if ($dstDir -and -not (Test-Path $dstDir -PathType Container)) {
353
+ New-Item -ItemType Directory -Path $dstDir -Force | Out-Null
354
+ }
355
+
356
+ if (-not (Test-Path $Dst -PathType Leaf)) {
357
+ Copy-Item -LiteralPath $Src -Destination $Dst -Force
358
+ $script:_CopyCountCopied++
359
+ if ($AidVerbose) { Write-Host "Copied: $Dst" }
360
+ return
361
+ }
362
+
363
+ # Compare via SHA256.
364
+ $srcHash = Get-Sha256File -FilePath $Src
365
+ $dstHash = Get-Sha256File -FilePath $Dst
366
+
367
+ if ($srcHash -eq $dstHash) {
368
+ $script:_CopyCountUpToDate++
369
+ if ($AidVerbose) { Write-Host "Up to date: $Dst" }
370
+ return
371
+ }
372
+
373
+ # File exists and differs.
374
+ if ($Force) {
375
+ Copy-Item -LiteralPath $Src -Destination $Dst -Force
376
+ $script:_CopyCountUpdated++
377
+ if ($AidVerbose) { Write-Host "Updated: $Dst" }
378
+ } else {
379
+ $script:_CopyCountSkipped++
380
+ if ($AidVerbose) { Write-Host "Skipped (differs; use --force): $Dst" }
381
+ }
382
+ }
383
+
384
+ # Copy-AidDir <srcDir> <dstDir> [force] [aidVerbose]
385
+ # Recursively copies a directory tree, file by file (preserving empty dirs).
386
+ function Copy-AidDir {
387
+ param(
388
+ [string]$SrcDir,
389
+ [string]$DstDir,
390
+ [bool]$Force = $false,
391
+ [bool]$AidVerbose = $false
392
+ )
393
+
394
+ # Create directory structure first.
395
+ $dirs = Get-ChildItem -LiteralPath $SrcDir -Recurse -Directory -ErrorAction SilentlyContinue
396
+ foreach ($dir in $dirs) {
397
+ $rel = $dir.FullName.Substring($SrcDir.Length).TrimStart([char]'\', [char]'/')
398
+ $dstSub = Join-Path $DstDir $rel
399
+ if (-not (Test-Path $dstSub -PathType Container)) {
400
+ New-Item -ItemType Directory -Path $dstSub -Force | Out-Null
401
+ }
402
+ }
403
+
404
+ # Copy files in ordinal (byte-order) sorted order - matching Bash's `find | sort -z`.
405
+ # Use [System.Array]::Sort with StringComparer.Ordinal for byte-identical path ordering.
406
+ $fileItems = @(Get-ChildItem -LiteralPath $SrcDir -Recurse -File -ErrorAction SilentlyContinue)
407
+ if ($fileItems.Count -gt 0) {
408
+ $filePaths = [string[]]($fileItems | ForEach-Object { $_.FullName })
409
+ [System.Array]::Sort($filePaths, [System.StringComparer]::Ordinal)
410
+ foreach ($fp in $filePaths) {
411
+ $rel = $fp.Substring($SrcDir.Length).TrimStart([char]'\', [char]'/')
412
+ $dst = Join-Path $DstDir $rel
413
+ Copy-AidFile -Src $fp -Dst $dst -Force $Force -AidVerbose $AidVerbose
414
+ }
415
+ }
416
+ }
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Protect-on-diff (FR11) for root agent files
420
+ # ---------------------------------------------------------------------------
421
+
422
+ # script:Copy-RootAgentFile <src> <dst> <tool> <force> [manifest] [aidVerbose]
423
+ # Implements the FR11 algorithm.
424
+ # Returns:
425
+ # 0 - success (copied/up-to-date/updated/forced)
426
+ # 5 - protect-on-diff blocked (written .aid-new instead)
427
+ #
428
+ # Sets $script:_CORE_ROOT_AGENT_STATUS = 'owned' | 'pending-merge'.
429
+ # Increments module-level counters $script:_CopyCount* just like Copy-AidFile.
430
+ function script:Copy-RootAgentFile {
431
+ param(
432
+ [string]$Src,
433
+ [string]$Dst,
434
+ [string]$Tool,
435
+ [bool]$Force = $false,
436
+ [string]$Manifest = '',
437
+ [bool]$AidVerbose = $false
438
+ )
439
+
440
+ $script:_CORE_ROOT_AGENT_STATUS = 'owned'
441
+ $incSha = Get-Sha256File -FilePath $Src
442
+
443
+ if (-not (Test-Path $Dst -PathType Leaf)) {
444
+ # Step 2: Destination absent -> copy.
445
+ $dstDir = [System.IO.Path]::GetDirectoryName($Dst)
446
+ if ($dstDir -and -not (Test-Path $dstDir -PathType Container)) {
447
+ New-Item -ItemType Directory -Path $dstDir -Force | Out-Null
448
+ }
449
+ Copy-Item -LiteralPath $Src -Destination $Dst -Force
450
+ $script:_CopyCountCopied++
451
+ if ($AidVerbose) { Write-Host "Copied: $Dst" }
452
+ $script:_CORE_ROOT_AGENT_STATUS = 'owned'
453
+ return 0
454
+ }
455
+
456
+ $diskSha = Get-Sha256File -FilePath $Dst
457
+
458
+ if ($diskSha -eq $incSha) {
459
+ # Step 3: Identical -> up to date.
460
+ $script:_CopyCountUpToDate++
461
+ if ($AidVerbose) { Write-Host "Up to date: $Dst" }
462
+ $script:_CORE_ROOT_AGENT_STATUS = 'owned'
463
+ return 0
464
+ }
465
+
466
+ # Check manifest for AID-owned sha.
467
+ $recordedSha = ''
468
+ if ($Manifest -and (Test-Path $Manifest -PathType Leaf)) {
469
+ $fname = [System.IO.Path]::GetFileName($Dst)
470
+ $recordedSha = Read-ManifestRootAgent -ManifestPath $Manifest -Tool $Tool -FileName $fname
471
+ }
472
+
473
+ if ($recordedSha -and ($diskSha -eq $recordedSha)) {
474
+ # Step 4: AID owns it -> overwrite.
475
+ Copy-Item -LiteralPath $Src -Destination $Dst -Force
476
+ $script:_CopyCountUpdated++
477
+ if ($AidVerbose) { Write-Host "Updated: $Dst" }
478
+ $script:_CORE_ROOT_AGENT_STATUS = 'owned'
479
+ return 0
480
+ }
481
+
482
+ # Step 5: Someone else owns it.
483
+ if ($Force) {
484
+ Copy-Item -LiteralPath $Src -Destination $Dst -Force
485
+ $script:_CopyCountUpdated++
486
+ if ($AidVerbose) { Write-Host "Updated: $Dst (forced over existing)" }
487
+ $script:_CORE_ROOT_AGENT_STATUS = 'owned'
488
+ return 0
489
+ }
490
+
491
+ # Without -Force: write .aid-new.
492
+ Copy-Item -LiteralPath $Src -Destination "$Dst.aid-new" -Force
493
+ # WARN always shows regardless of AidVerbose.
494
+ [Console]::Error.WriteLine("WARN: $Dst exists and was not written by AID; wrote incoming version to $Dst.aid-new - review and merge, or re-run with --force to overwrite")
495
+ $script:_CORE_ROOT_AGENT_STATUS = 'pending-merge'
496
+ return 5
497
+ }
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Manifest - JSON reader/writer
501
+ # ---------------------------------------------------------------------------
502
+
503
+ # Read-ManifestToolPaths <manifest> <tool>
504
+ # Returns an array of paths from tools.<tool>.paths.
505
+ function Read-ManifestToolPaths {
506
+ param([string]$ManifestPath, [string]$Tool)
507
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return @() }
508
+ try {
509
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
510
+ $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
511
+ if ($toolData -and $toolData.paths) {
512
+ return @($toolData.paths)
513
+ }
514
+ } catch {}
515
+ return @()
516
+ }
517
+
518
+ # Read-ManifestToolVersion <manifest> <tool>
519
+ # Returns the version string for the named tool.
520
+ function Read-ManifestToolVersion {
521
+ param([string]$ManifestPath, [string]$Tool)
522
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return '' }
523
+ try {
524
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
525
+ $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
526
+ if ($toolData -and $toolData.version) { return $toolData.version }
527
+ } catch {}
528
+ return ''
529
+ }
530
+
531
+ # Read-ManifestRootAgent <manifest> <tool> <fname>
532
+ # Returns the sha256 for the root agent file entry (empty if not present).
533
+ function Read-ManifestRootAgent {
534
+ param([string]$ManifestPath, [string]$Tool, [string]$FileName)
535
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return '' }
536
+ try {
537
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
538
+ $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
539
+ if ($toolData -and $toolData.root_agent_files) {
540
+ foreach ($entry in $toolData.root_agent_files) {
541
+ if ($entry.path -eq $FileName) { return $entry.sha256 }
542
+ }
543
+ }
544
+ } catch {}
545
+ return ''
546
+ }
547
+
548
+ # Read-ManifestRootAgentStatus <manifest> <tool> <fname>
549
+ # Returns the status field ('owned' or 'pending-merge') for the root agent entry.
550
+ function Read-ManifestRootAgentStatus {
551
+ param([string]$ManifestPath, [string]$Tool, [string]$FileName)
552
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return '' }
553
+ try {
554
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
555
+ $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
556
+ if ($toolData -and $toolData.root_agent_files) {
557
+ foreach ($entry in $toolData.root_agent_files) {
558
+ if ($entry.path -eq $FileName) {
559
+ if ($entry.PSObject.Properties['status']) { return $entry.status }
560
+ return 'owned'
561
+ }
562
+ }
563
+ }
564
+ } catch {}
565
+ return ''
566
+ }
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Custom JSON serializer (2-space indent, LF newlines, byte-identical to
570
+ # Python's json.dump(data, f, indent=2) followed by f.write("\n")).
571
+ # ---------------------------------------------------------------------------
572
+
573
+ # script:Escape-JsonString <s> - escape a string for JSON embedding.
574
+ function script:Escape-JsonString {
575
+ param([string]$s)
576
+ $s = $s -replace '\\', '\\'
577
+ $s = $s -replace '"', '\"'
578
+ $s = $s -replace "`n", '\n'
579
+ $s = $s -replace "`r", '\r'
580
+ $s = $s -replace "`t", '\t'
581
+ return $s
582
+ }
583
+
584
+ # script:Build-ManifestJson - build the manifest JSON string with 2-space indent + LF + trailing LF.
585
+ # Parameters mirror Write-AidManifest internal state.
586
+ function script:Build-ManifestJson {
587
+ param(
588
+ [string] $TopInstalledAt,
589
+ [string] $TopVersion,
590
+ # Ordered list of [toolId, version, toolInstalledAt, paths[], rafEntries[]] tuples
591
+ # represented as an ordered hashtable keyed by toolId.
592
+ [System.Collections.Specialized.OrderedDictionary] $ToolsMap
593
+ )
594
+
595
+ $sb = [System.Text.StringBuilder]::new()
596
+ [void]$sb.Append("{`n")
597
+ [void]$sb.Append(" `"manifest_version`": 1,`n")
598
+ [void]$sb.Append(" `"aid_version`": `"$(script:Escape-JsonString $TopVersion)`",`n")
599
+ [void]$sb.Append(" `"installed_at`": `"$(script:Escape-JsonString $TopInstalledAt)`",`n")
600
+ [void]$sb.Append(" `"tools`": {`n")
601
+
602
+ $toolIds = @($ToolsMap.Keys)
603
+ $lastTool = if ($toolIds.Count -gt 0) { $toolIds[$toolIds.Count - 1] } else { $null }
604
+
605
+ foreach ($tid in $toolIds) {
606
+ $t = $ToolsMap[$tid]
607
+ [void]$sb.Append(" `"$tid`": {`n")
608
+ [void]$sb.Append(" `"version`": `"$(script:Escape-JsonString $t.Version)`",`n")
609
+ [void]$sb.Append(" `"installed_at`": `"$(script:Escape-JsonString $t.InstalledAt)`",`n")
610
+
611
+ # paths array
612
+ $paths = $t.Paths
613
+ if ($paths -and $paths.Count -gt 0) {
614
+ [void]$sb.Append(" `"paths`": [`n")
615
+ for ($i = 0; $i -lt $paths.Count; $i++) {
616
+ $comma = if ($i -lt $paths.Count - 1) { ',' } else { '' }
617
+ [void]$sb.Append(" `"$(script:Escape-JsonString $paths[$i])`"$comma`n")
618
+ }
619
+ [void]$sb.Append(" ],`n")
620
+ } else {
621
+ [void]$sb.Append(" `"paths`": [],`n")
622
+ }
623
+
624
+ # root_agent_files array
625
+ $raf = $t.RootAgentFiles
626
+ if ($raf -and $raf.Count -gt 0) {
627
+ [void]$sb.Append(" `"root_agent_files`": [`n")
628
+ for ($i = 0; $i -lt $raf.Count; $i++) {
629
+ $e = $raf[$i]
630
+ $comma = if ($i -lt $raf.Count - 1) { ',' } else { '' }
631
+ # Multi-line object per entry (matching Python json.dump indent=2 output)
632
+ [void]$sb.Append(" {`n")
633
+ [void]$sb.Append(" `"path`": `"$(script:Escape-JsonString $e.path)`",`n")
634
+ [void]$sb.Append(" `"sha256`": `"$(script:Escape-JsonString $e.sha256)`",`n")
635
+ [void]$sb.Append(" `"status`": `"$(script:Escape-JsonString $e.status)`"`n")
636
+ [void]$sb.Append(" }$comma`n")
637
+ }
638
+ [void]$sb.Append(" ]`n")
639
+ } else {
640
+ [void]$sb.Append(" `"root_agent_files`": []`n")
641
+ }
642
+
643
+ $toolComma = if ($tid -ne $lastTool) { ',' } else { '' }
644
+ [void]$sb.Append(" }$toolComma`n")
645
+ }
646
+
647
+ [void]$sb.Append(" }`n")
648
+ [void]$sb.Append("}`n")
649
+
650
+ return $sb.ToString()
651
+ }
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # Manifest writer
655
+ # ---------------------------------------------------------------------------
656
+
657
+ # Write-AidManifest <manifestPath> <tool> <version> <pathsArray> <rootEntriesArray>
658
+ #
659
+ # <pathsArray> - array of relative POSIX paths.
660
+ # <rootEntriesArray> - array of "path|sha256|status" strings.
661
+ #
662
+ # Reads the existing manifest (if any), merges the tool entry, writes back atomically
663
+ # (via a temp file). Creates <target>/.aid/ as needed.
664
+ # Key order contract: manifest_version, aid_version, installed_at, tools;
665
+ # per-tool: version, installed_at, paths, root_agent_files;
666
+ # root_agent_files entry: path, sha256, status.
667
+ # 2-space indent, LF newlines, trailing newline.
668
+ function Write-AidManifest {
669
+ param(
670
+ [string]$ManifestPath,
671
+ [string]$Tool,
672
+ [string]$Version,
673
+ [string[]]$Paths,
674
+ [string[]]$RootEntries
675
+ )
676
+
677
+ $manifestDir = [System.IO.Path]::GetDirectoryName($ManifestPath)
678
+ if ($manifestDir -and -not (Test-Path $manifestDir -PathType Container)) {
679
+ New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
680
+ }
681
+
682
+ $now = ([System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ'))
683
+
684
+ # Load existing manifest.
685
+ $existingData = $null
686
+ if (Test-Path $ManifestPath -PathType Leaf) {
687
+ try {
688
+ $existingData = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
689
+ } catch {
690
+ $existingData = $null
691
+ }
692
+ }
693
+
694
+ # Top-level installed_at: preserve existing.
695
+ $topInstalledAt = $now
696
+ if ($existingData -and $existingData.installed_at) {
697
+ $topInstalledAt = $existingData.installed_at
698
+ }
699
+
700
+ # Build the tools map preserving existing tools, then adding/merging the current tool.
701
+ # Use ordered dictionary so key order is deterministic.
702
+ $toolsMap = [System.Collections.Specialized.OrderedDictionary]::new()
703
+
704
+ # Preserve existing tools (other than the current one).
705
+ if ($existingData -and $existingData.tools) {
706
+ $existingData.tools.PSObject.Properties | ForEach-Object {
707
+ $tid = $_.Name
708
+ if ($tid -ne $Tool) {
709
+ $t = $_.Value
710
+ $tP = if ($t.paths) { [System.Collections.Generic.List[string]]($t.paths) } else { [System.Collections.Generic.List[string]]::new() }
711
+ $tR = [System.Collections.Generic.List[hashtable]]::new()
712
+ if ($t.root_agent_files) {
713
+ foreach ($e in $t.root_agent_files) {
714
+ $st = if ($e.PSObject.Properties['status']) { $e.status } else { 'owned' }
715
+ $tR.Add(@{ path = $e.path; sha256 = $e.sha256; status = $st })
716
+ }
717
+ }
718
+ $toolsMap[$tid] = @{
719
+ Version = if ($t.version) { $t.version } else { '' }
720
+ InstalledAt = if ($t.installed_at) { $t.installed_at } else { $now }
721
+ Paths = $tP
722
+ RootAgentFiles = $tR
723
+ }
724
+ }
725
+ }
726
+ }
727
+
728
+ # Existing data for the current tool.
729
+ $existingTool = $null
730
+ if ($existingData -and $existingData.tools -and ($existingData.tools.PSObject.Properties.Name -contains $Tool)) {
731
+ $existingTool = $existingData.tools.$Tool
732
+ }
733
+
734
+ # tool installed_at: preserve existing.
735
+ $toolInstalledAt = $now
736
+ if ($existingTool -and $existingTool.installed_at) {
737
+ $toolInstalledAt = $existingTool.installed_at
738
+ }
739
+
740
+ # De-duplicate paths (union, preserving order: existing first, then new).
741
+ $seenPaths = [System.Collections.Generic.HashSet[string]]::new()
742
+ $mergedPaths = [System.Collections.Generic.List[string]]::new()
743
+ if ($existingTool -and $existingTool.paths) {
744
+ foreach ($p in $existingTool.paths) {
745
+ if ($seenPaths.Add($p)) { $mergedPaths.Add($p) }
746
+ }
747
+ }
748
+ foreach ($p in $Paths) {
749
+ if ($p -and $seenPaths.Add($p)) { $mergedPaths.Add($p) }
750
+ }
751
+
752
+ # Merge root_agent_files: update or add per path.
753
+ $rafMap = [System.Collections.Specialized.OrderedDictionary]::new()
754
+ if ($existingTool -and $existingTool.root_agent_files) {
755
+ foreach ($e in $existingTool.root_agent_files) {
756
+ $st = if ($e.PSObject.Properties['status']) { $e.status } else { 'owned' }
757
+ $rafMap[$e.path] = @{ path = $e.path; sha256 = $e.sha256; status = $st }
758
+ }
759
+ }
760
+ foreach ($entry in $RootEntries) {
761
+ if (-not $entry) { continue }
762
+ $parts = $entry -split '\|', 3
763
+ $rpath = $parts[0]
764
+ $rsha = if ($parts.Count -gt 1) { $parts[1] } else { '' }
765
+ $rst = if ($parts.Count -gt 2) { $parts[2] } else { 'owned' }
766
+ $rafMap[$rpath] = @{ path = $rpath; sha256 = $rsha; status = $rst }
767
+ }
768
+ $mergedRaf = [System.Collections.Generic.List[hashtable]]::new()
769
+ foreach ($key in $rafMap.Keys) { $mergedRaf.Add($rafMap[$key]) }
770
+
771
+ # Add/overwrite current tool entry.
772
+ $toolsMap[$Tool] = @{
773
+ Version = $Version
774
+ InstalledAt = $toolInstalledAt
775
+ Paths = $mergedPaths
776
+ RootAgentFiles = $mergedRaf
777
+ }
778
+
779
+ # Build JSON string with exact 2-space indent, LF newlines, trailing LF.
780
+ $json = script:Build-ManifestJson -TopInstalledAt $topInstalledAt -TopVersion $Version -ToolsMap $toolsMap
781
+
782
+ # Write atomically via temp file.
783
+ $tmpPath = Join-Path $manifestDir (".manifest.tmp." + [System.IO.Path]::GetRandomFileName())
784
+ try {
785
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
786
+ [System.IO.File]::WriteAllBytes($tmpPath, $bytes)
787
+ if (Test-Path $ManifestPath -PathType Leaf) { Remove-Item -LiteralPath $ManifestPath -Force }
788
+ Move-Item -LiteralPath $tmpPath -Destination $ManifestPath -Force
789
+ } catch {
790
+ if (Test-Path $tmpPath -PathType Leaf) { Remove-Item -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue }
791
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: manifest write failed: $_")
792
+ throw
793
+ }
794
+ }
795
+
796
+ # Remove-ManifestTool <manifest> <tool>
797
+ # Removes a tool section from the manifest. If no tools remain, removes the manifest.
798
+ function Remove-ManifestTool {
799
+ param([string]$ManifestPath, [string]$Tool)
800
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return }
801
+
802
+ $data = $null
803
+ try {
804
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
805
+ } catch {
806
+ return
807
+ }
808
+
809
+ # Build tools map without the target tool.
810
+ $toolsMap = [System.Collections.Specialized.OrderedDictionary]::new()
811
+ if ($data.tools) {
812
+ $data.tools.PSObject.Properties | ForEach-Object {
813
+ $tid = $_.Name
814
+ if ($tid -ne $Tool) {
815
+ $t = $_.Value
816
+ $tP = if ($t.paths) { [System.Collections.Generic.List[string]]($t.paths) } else { [System.Collections.Generic.List[string]]::new() }
817
+ $tR = [System.Collections.Generic.List[hashtable]]::new()
818
+ if ($t.root_agent_files) {
819
+ foreach ($e in $t.root_agent_files) {
820
+ $st = if ($e.PSObject.Properties['status']) { $e.status } else { 'owned' }
821
+ $tR.Add(@{ path = $e.path; sha256 = $e.sha256; status = $st })
822
+ }
823
+ }
824
+ $toolsMap[$tid] = @{
825
+ Version = if ($t.version) { $t.version } else { '' }
826
+ InstalledAt = if ($t.installed_at) { $t.installed_at } else { '' }
827
+ Paths = $tP
828
+ RootAgentFiles = $tR
829
+ }
830
+ }
831
+ }
832
+ }
833
+
834
+ if ($toolsMap.Count -eq 0) {
835
+ Remove-Item -LiteralPath $ManifestPath -Force
836
+ return
837
+ }
838
+
839
+ $topIat = if ($data.installed_at) { $data.installed_at } else { ([System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')) }
840
+ $topVer = if ($data.aid_version) { $data.aid_version } else { '0.0.0' }
841
+
842
+ $json = script:Build-ManifestJson -TopInstalledAt $topIat -TopVersion $topVer -ToolsMap $toolsMap
843
+
844
+ $manifestDir = [System.IO.Path]::GetDirectoryName($ManifestPath)
845
+ $tmpPath = Join-Path $manifestDir (".manifest.tmp." + [System.IO.Path]::GetRandomFileName())
846
+ try {
847
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
848
+ [System.IO.File]::WriteAllBytes($tmpPath, $bytes)
849
+ if (Test-Path $ManifestPath -PathType Leaf) { Remove-Item -LiteralPath $ManifestPath -Force }
850
+ Move-Item -LiteralPath $tmpPath -Destination $ManifestPath -Force
851
+ } catch {
852
+ if (Test-Path $tmpPath) { Remove-Item -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue }
853
+ [Console]::Error.WriteLine("ERROR: AidInstallCore: manifest remove-tool write failed: $_")
854
+ throw
855
+ }
856
+ }
857
+
858
+ # Test-ManifestExists <manifest> - returns $true when manifest exists and is parseable.
859
+ function Test-ManifestExists {
860
+ param([string]$ManifestPath)
861
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return $false }
862
+ try {
863
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
864
+ return $null -ne $data.manifest_version
865
+ } catch {
866
+ return $false
867
+ }
868
+ }
869
+
870
+ # ---------------------------------------------------------------------------
871
+ # Version marker
872
+ # ---------------------------------------------------------------------------
873
+
874
+ # Write-VersionMarker <target> <version>
875
+ function Write-VersionMarker {
876
+ param([string]$Target, [string]$Version)
877
+ $aidDir = Join-Path $Target '.aid'
878
+ if (-not (Test-Path $aidDir -PathType Container)) {
879
+ New-Item -ItemType Directory -Path $aidDir -Force | Out-Null
880
+ }
881
+ $markerPath = Join-Path $aidDir '.aid-version'
882
+ # Write with LF newline.
883
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes("$Version`n")
884
+ [System.IO.File]::WriteAllBytes($markerPath, $bytes)
885
+ }
886
+
887
+ # ---------------------------------------------------------------------------
888
+ # High-level Install-AidTool
889
+ # ---------------------------------------------------------------------------
890
+
891
+ # Install-AidTool <stagingDir> <tool> <target> <version> [force] [aidVerbose]
892
+ # Returns:
893
+ # 0 - success (all files installed or up-to-date)
894
+ # 5 - at least one root agent file was protect-on-diff blocked
895
+ #
896
+ # Side effects: writes <target>/.aid/.aid-manifest.json and .aid/.aid-version.
897
+ function Install-AidTool {
898
+ param(
899
+ [string]$StagingDir,
900
+ [string]$Tool,
901
+ [string]$Target,
902
+ [string]$Version,
903
+ [bool]$Force = $false,
904
+ [bool]$AidVerbose = $false
905
+ )
906
+
907
+ $manifest = Join-Path $Target (Join-Path '.aid' '.aid-manifest.json')
908
+ $installPaths = [System.Collections.Generic.List[string]]::new()
909
+ $rootEntries = [System.Collections.Generic.List[string]]::new()
910
+ $blocked = $false
911
+
912
+ # Reset per-tool copy counters.
913
+ $script:_CopyCountCopied = 0
914
+ $script:_CopyCountUpToDate = 0
915
+ $script:_CopyCountUpdated = 0
916
+ $script:_CopyCountSkipped = 0
917
+
918
+ $rootAgentFile = script:Get-RootAgentFile -Tool $Tool
919
+
920
+ # Helper: collect file paths from a directory, ordinal-sorted (matching Bash `find | sort -z`).
921
+ $collectPaths = {
922
+ param([string]$Dir, [string]$StripPrefix, [System.Collections.Generic.List[string]]$OutList)
923
+ $items = @(Get-ChildItem -LiteralPath $Dir -Recurse -File -ErrorAction SilentlyContinue)
924
+ if ($items.Count -gt 0) {
925
+ $fps = [string[]]($items | ForEach-Object { $_.FullName })
926
+ [System.Array]::Sort($fps, [System.StringComparer]::Ordinal)
927
+ foreach ($fp in $fps) {
928
+ $rel = $fp.Substring($StripPrefix.Length).TrimStart([char]'\', [char]'/') -replace '\\', '/'
929
+ $OutList.Add($rel)
930
+ }
931
+ }
932
+ }
933
+
934
+ # Copy tool-specific directories.
935
+ switch ($Tool) {
936
+ 'claude-code' {
937
+ $claudeDir = Join-Path $StagingDir '.claude'
938
+ if (Test-Path $claudeDir -PathType Container) {
939
+ Copy-AidDir -SrcDir $claudeDir -DstDir (Join-Path $Target '.claude') -Force $Force -AidVerbose $AidVerbose
940
+ & $collectPaths $claudeDir $StagingDir $installPaths
941
+ }
942
+ }
943
+ 'codex' {
944
+ $codexDir = Join-Path $StagingDir '.codex'
945
+ $agentsDir = Join-Path $StagingDir '.agents'
946
+ if (Test-Path $codexDir -PathType Container) {
947
+ Copy-AidDir -SrcDir $codexDir -DstDir (Join-Path $Target '.codex') -Force $Force -AidVerbose $AidVerbose
948
+ & $collectPaths $codexDir $StagingDir $installPaths
949
+ }
950
+ if (Test-Path $agentsDir -PathType Container) {
951
+ Copy-AidDir -SrcDir $agentsDir -DstDir (Join-Path $Target '.agents') -Force $Force -AidVerbose $AidVerbose
952
+ & $collectPaths $agentsDir $StagingDir $installPaths
953
+ }
954
+ }
955
+ 'cursor' {
956
+ $cursorDir = Join-Path $StagingDir '.cursor'
957
+ if (Test-Path $cursorDir -PathType Container) {
958
+ Copy-AidDir -SrcDir $cursorDir -DstDir (Join-Path $Target '.cursor') -Force $Force -AidVerbose $AidVerbose
959
+ & $collectPaths $cursorDir $StagingDir $installPaths
960
+ }
961
+ }
962
+ 'copilot-cli' {
963
+ $githubDir = Join-Path $StagingDir '.github'
964
+ if (Test-Path $githubDir -PathType Container) {
965
+ Copy-AidDir -SrcDir $githubDir -DstDir (Join-Path $Target '.github') -Force $Force -AidVerbose $AidVerbose
966
+ & $collectPaths $githubDir $StagingDir $installPaths
967
+ }
968
+ }
969
+ 'antigravity' {
970
+ $agentDir = Join-Path $StagingDir '.agent'
971
+ if (Test-Path $agentDir -PathType Container) {
972
+ Copy-AidDir -SrcDir $agentDir -DstDir (Join-Path $Target '.agent') -Force $Force -AidVerbose $AidVerbose
973
+ & $collectPaths $agentDir $StagingDir $installPaths
974
+ }
975
+ }
976
+ }
977
+
978
+ # Handle root agent file via FR11.
979
+ $rootSrc = Join-Path $StagingDir $rootAgentFile
980
+ $rootDst = Join-Path $Target $rootAgentFile
981
+
982
+ if (Test-Path $rootSrc -PathType Leaf) {
983
+ $script:_CORE_ROOT_AGENT_STATUS = 'owned'
984
+ $rafRc = script:Copy-RootAgentFile -Src $rootSrc -Dst $rootDst -Tool $Tool -Force $Force `
985
+ -Manifest $manifest -AidVerbose $AidVerbose
986
+ if ($rafRc -eq 5) { $blocked = $true }
987
+
988
+ $incSha = Get-Sha256File -FilePath $rootSrc
989
+ $rootEntries.Add("$rootAgentFile|$incSha|$($script:_CORE_ROOT_AGENT_STATUS)")
990
+
991
+ # Include root agent path in paths list only when owned (not pending-merge).
992
+ if ($script:_CORE_ROOT_AGENT_STATUS -eq 'owned') {
993
+ $installPaths.Add($rootAgentFile)
994
+ }
995
+ }
996
+
997
+ # Write manifest (merge).
998
+ Write-AidManifest -ManifestPath $manifest -Tool $Tool -Version $Version `
999
+ -Paths @($installPaths) -RootEntries @($rootEntries)
1000
+
1001
+ # Write version marker.
1002
+ Write-VersionMarker -Target $Target -Version $Version
1003
+
1004
+ # Print concise install summary (always shown; per-file lines only when AidVerbose).
1005
+ $totalFiles = $script:_CopyCountCopied + $script:_CopyCountUpToDate + $script:_CopyCountUpdated + $script:_CopyCountSkipped
1006
+ if ($totalFiles -gt 0) {
1007
+ if ($script:_CopyCountCopied -gt 0 -and $script:_CopyCountUpToDate -eq 0 -and $script:_CopyCountUpdated -eq 0) {
1008
+ Write-Host " $($script:_CopyCountCopied) files installed"
1009
+ } elseif ($script:_CopyCountUpToDate -gt 0 -and $script:_CopyCountCopied -eq 0 -and $script:_CopyCountUpdated -eq 0) {
1010
+ Write-Host " up to date ($($script:_CopyCountUpToDate) files)"
1011
+ } else {
1012
+ $parts = [System.Collections.Generic.List[string]]::new()
1013
+ if ($script:_CopyCountUpdated -gt 0) { $parts.Add("$($script:_CopyCountUpdated) updated") }
1014
+ if ($script:_CopyCountCopied -gt 0) { $parts.Add("$($script:_CopyCountCopied) installed") }
1015
+ if ($script:_CopyCountUpToDate -gt 0) { $parts.Add("$($script:_CopyCountUpToDate) unchanged") }
1016
+ Write-Host " $($parts -join ', ')"
1017
+ }
1018
+ }
1019
+
1020
+ if ($blocked) { return 5 }
1021
+ return 0
1022
+ }
1023
+
1024
+ # ---------------------------------------------------------------------------
1025
+ # Uninstall
1026
+ # ---------------------------------------------------------------------------
1027
+
1028
+ # Uninstall-AidTool <manifest> <tool> <target> [aidVerbose]
1029
+ # Removes all files recorded under tools.<tool>.paths.
1030
+ # Root agent files removed only when sha256 still matches.
1031
+ # Returns 6 if manifest missing; 0 otherwise.
1032
+ function Uninstall-AidTool {
1033
+ param(
1034
+ [string]$ManifestPath,
1035
+ [string]$Tool,
1036
+ [string]$Target,
1037
+ [bool]$AidVerbose = $false
1038
+ )
1039
+
1040
+ if (-not (Test-ManifestExists -ManifestPath $ManifestPath)) { return 6 }
1041
+
1042
+ $paths = Read-ManifestToolPaths -ManifestPath $ManifestPath -Tool $Tool
1043
+
1044
+ if ($paths.Count -eq 0) {
1045
+ [Console]::Error.WriteLine("Nothing to uninstall for $Tool (no paths recorded)")
1046
+ Remove-ManifestTool -ManifestPath $ManifestPath -Tool $Tool
1047
+ return 0
1048
+ }
1049
+
1050
+ $rootAgentFile = script:Get-RootAgentFile -Tool $Tool
1051
+
1052
+ $uninstRemoved = 0
1053
+ $uninstLeftInPlace = 0
1054
+
1055
+ foreach ($p in $paths) {
1056
+ # Normalize path separators.
1057
+ $pNorm = $p -replace '/', [System.IO.Path]::DirectorySeparatorChar
1058
+ $full = Join-Path $Target $pNorm
1059
+
1060
+ if (-not (Test-Path $full)) {
1061
+ if ($AidVerbose) { Write-Host "Already absent: $full" }
1062
+ continue
1063
+ }
1064
+
1065
+ # Root agent file -> apply FR11 uninstall check.
1066
+ $base = [System.IO.Path]::GetFileName($p)
1067
+ # Match: base name is root agent file AND path has no directory separator.
1068
+ if ($base -eq $rootAgentFile -and ($p -eq $rootAgentFile -or $p -replace '\\','/' -eq $rootAgentFile)) {
1069
+ $recordedSha = Read-ManifestRootAgent -ManifestPath $ManifestPath -Tool $Tool -FileName $rootAgentFile
1070
+ if ($recordedSha) {
1071
+ $diskSha = Get-Sha256File -FilePath $full
1072
+ if ($diskSha -ne $recordedSha) {
1073
+ $uninstLeftInPlace++
1074
+ # "Left in place" always shown (important for user awareness).
1075
+ Write-Host "Left in place (modified or not AID-owned): $full"
1076
+ continue
1077
+ }
1078
+ }
1079
+ }
1080
+ Remove-Item -LiteralPath $full -Force
1081
+ $uninstRemoved++
1082
+ if ($AidVerbose) { Write-Host "Removed: $full" }
1083
+ }
1084
+
1085
+ # Print concise uninstall summary (always shown).
1086
+ if ($uninstRemoved -gt 0) {
1087
+ Write-Host " $uninstRemoved files removed"
1088
+ }
1089
+
1090
+ # Prune now-empty AID-owned dirs.
1091
+ $aidDirs = switch ($Tool) {
1092
+ 'claude-code' { @('.claude') }
1093
+ 'codex' { @('.codex', '.agents') }
1094
+ 'cursor' { @('.cursor') }
1095
+ 'copilot-cli' { @('.github') }
1096
+ 'antigravity' { @('.agent') }
1097
+ default { @() }
1098
+ }
1099
+
1100
+ foreach ($d in $aidDirs) {
1101
+ $fullDir = Join-Path $Target $d
1102
+ if (Test-Path $fullDir -PathType Container) {
1103
+ $remaining = Get-ChildItem -LiteralPath $fullDir -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
1104
+ if (-not $remaining) {
1105
+ Remove-Item -LiteralPath $fullDir -Recurse -Force
1106
+ if ($AidVerbose) { Write-Host "Removed dir: $fullDir" }
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ # Remove this tool from manifest.
1112
+ Remove-ManifestTool -ManifestPath $ManifestPath -Tool $Tool
1113
+
1114
+ # If no manifest remains, remove version marker and .aid dir if empty.
1115
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) {
1116
+ $aidMetaDir = [System.IO.Path]::GetDirectoryName($ManifestPath)
1117
+ $versionMarker = Join-Path $aidMetaDir '.aid-version'
1118
+ if (Test-Path $versionMarker -PathType Leaf) {
1119
+ Remove-Item -LiteralPath $versionMarker -Force
1120
+ }
1121
+ if (Test-Path $aidMetaDir -PathType Container) {
1122
+ $rem = Get-ChildItem -LiteralPath $aidMetaDir -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
1123
+ if (-not $rem) {
1124
+ Remove-Item -LiteralPath $aidMetaDir -Recurse -Force -ErrorAction SilentlyContinue
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ return 0
1130
+ }
1131
+
1132
+ # ---------------------------------------------------------------------------
1133
+ # CLI status helpers (additive - used by bin/aid.ps1 dispatcher)
1134
+ # ---------------------------------------------------------------------------
1135
+
1136
+ # Get-ManifestToolList <manifestPath>
1137
+ # Enumerates tools.<id> keys from the manifest.
1138
+ # Returns a list of [PSCustomObject]@{Id=; Version=; RootAgent=; RootStatus=} objects.
1139
+ # Returns an empty list when the manifest is absent.
1140
+ function Get-ManifestToolList {
1141
+ param([string]$ManifestPath)
1142
+ $result = [System.Collections.Generic.List[psobject]]::new()
1143
+ if (-not (Test-Path $ManifestPath -PathType Leaf)) { return $result }
1144
+ try {
1145
+ $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
1146
+ if (-not $data.tools) { return $result }
1147
+ $data.tools.PSObject.Properties | ForEach-Object {
1148
+ $tid = $_.Name
1149
+ $t = $_.Value
1150
+ $ver = if ($t.version) { $t.version } else { '' }
1151
+ # Determine root agent file for this tool.
1152
+ $rootAgent = switch ($tid) {
1153
+ 'claude-code' { 'CLAUDE.md' }
1154
+ default { 'AGENTS.md' }
1155
+ }
1156
+ # Read root agent status from manifest.
1157
+ $rootStatus = ''
1158
+ if ($t.root_agent_files) {
1159
+ foreach ($entry in $t.root_agent_files) {
1160
+ if ($entry.path -eq $rootAgent) {
1161
+ $rootStatus = if ($entry.PSObject.Properties['status']) { $entry.status } else { 'owned' }
1162
+ break
1163
+ }
1164
+ }
1165
+ }
1166
+ $result.Add([PSCustomObject]@{
1167
+ Id = $tid
1168
+ Version = $ver
1169
+ RootAgent = $rootAgent
1170
+ RootStatus = $rootStatus
1171
+ })
1172
+ }
1173
+ } catch {}
1174
+ return $result
1175
+ }
1176
+
1177
+ # ---------------------------------------------------------------------------
1178
+ # Semver comparison helper
1179
+ # ---------------------------------------------------------------------------
1180
+
1181
+ # script:Test-SemverLt <a> <b>
1182
+ # Returns $true when version string a is strictly less than b.
1183
+ # Splits on '.', compares major/minor/patch numerically.
1184
+ # Non-numeric characters at the end of a segment are stripped.
1185
+ function script:Test-SemverLt {
1186
+ param([string]$A, [string]$B)
1187
+ $partsA = $A -split '\.'
1188
+ $partsB = $B -split '\.'
1189
+ for ($i = 0; $i -lt 3; $i++) {
1190
+ $rawA = if ($i -lt $partsA.Count) { $partsA[$i] } else { '0' }
1191
+ $rawB = if ($i -lt $partsB.Count) { $partsB[$i] } else { '0' }
1192
+ # Strip non-numeric suffix.
1193
+ if ($rawA -match '^(\d+)') { $va = [int]$matches[1] } else { $va = 0 }
1194
+ if ($rawB -match '^(\d+)') { $vb = [int]$matches[1] } else { $vb = 0 }
1195
+ if ($va -lt $vb) { return $true }
1196
+ if ($va -gt $vb) { return $false }
1197
+ }
1198
+ return $false # equal -> not less than
1199
+ }
1200
+
1201
+ # ---------------------------------------------------------------------------
1202
+ # Shared tool-list renderer (used by Get-AidStatusBody and Get-AidStatus)
1203
+ # ---------------------------------------------------------------------------
1204
+
1205
+ # script:Invoke-RenderToolsBlock <manifestPath> <refVersion> <headerPrefix>
1206
+ #
1207
+ # Outputs the complete tools block to stdout via Write-Host:
1208
+ # - uniform: "<headerPrefix> - all at v<V>[hint]:\n <tool>\n..."
1209
+ # - divergent: "<headerPrefix>:\n <tool> v<ver>[hint]\n..."
1210
+ # Root-agent annotation only when status != "owned".
1211
+ function script:Invoke-RenderToolsBlock {
1212
+ param(
1213
+ [string]$ManifestPath,
1214
+ [string]$RefVersion,
1215
+ [string]$HeaderPrefix
1216
+ )
1217
+
1218
+ $tools = Get-ManifestToolList -ManifestPath $ManifestPath
1219
+
1220
+ if ($tools.Count -eq 0) {
1221
+ Write-Host "${HeaderPrefix}:"
1222
+ return
1223
+ }
1224
+
1225
+ # Determine uniform vs divergent.
1226
+ $firstVer = $tools[0].Version
1227
+ $uniform = $true
1228
+ foreach ($t in $tools) {
1229
+ if ($t.Version -ne $firstVer) { $uniform = $false; break }
1230
+ }
1231
+
1232
+ if ($uniform) {
1233
+ # Build update hint for header if tools are behind CLI.
1234
+ $hint = ''
1235
+ if ($RefVersion -and $firstVer -and (script:Test-SemverLt -A $firstVer -B $RefVersion)) {
1236
+ $hint = " (update -> v$RefVersion)"
1237
+ }
1238
+ Write-Host "$HeaderPrefix - all at v$firstVer${hint}:"
1239
+ foreach ($tool in $tools) {
1240
+ $rs = if ($tool.RootStatus) { $tool.RootStatus } else { 'owned' }
1241
+ $extra = ''
1242
+ if ($rs -ne 'owned' -and $rs) { $extra = ' (root pending merge)' }
1243
+ Write-Host " $($tool.Id)$extra"
1244
+ if ($env:AID_VERBOSE -eq '1') {
1245
+ $paths = Read-ManifestToolPaths -ManifestPath $ManifestPath -Tool $tool.Id
1246
+ Write-Host " ($($paths.Count) files installed)"
1247
+ }
1248
+ }
1249
+ } else {
1250
+ # Divergent case.
1251
+ Write-Host "${HeaderPrefix}:"
1252
+ foreach ($tool in $tools) {
1253
+ $ver = $tool.Version
1254
+ $rs = if ($tool.RootStatus) { $tool.RootStatus } else { 'owned' }
1255
+ $hint = ''
1256
+ if ($RefVersion -and $ver -and (script:Test-SemverLt -A $ver -B $RefVersion)) {
1257
+ $hint = " (update -> v$RefVersion)"
1258
+ }
1259
+ $rootExtra = ''
1260
+ if ($rs -ne 'owned' -and $rs) { $rootExtra = ' (root pending merge)' }
1261
+ # Pad tool id to 14 chars (matches Bash `printf ' %-14s v%s'`).
1262
+ $paddedId = $tool.Id.PadRight(14)
1263
+ Write-Host " $paddedId v$ver$hint$rootExtra"
1264
+ if ($env:AID_VERBOSE -eq '1') {
1265
+ $paths = Read-ManifestToolPaths -ManifestPath $ManifestPath -Tool $tool.Id
1266
+ Write-Host " ($($paths.Count) files installed)"
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ # Get-AidStatusBody <target>
1273
+ # Renders only the installed-tools block (no exit-7 logic, no project header).
1274
+ # Prints:
1275
+ # Installed tools (in <dir>) - all at v<V>[hint]: (uniform)
1276
+ # <per-tool lines (name-only when uniform)>
1277
+ # OR (divergent):
1278
+ # Installed tools (in <dir>):
1279
+ # <per-tool lines with version + hint>
1280
+ # OR (when no manifest):
1281
+ # No AID tools installed in <dir> yet - run 'aid add <tool>'.
1282
+ # Returns: 0 always (caller decides how to handle missing manifest).
1283
+ function Get-AidStatusBody {
1284
+ param([string]$Target = '.')
1285
+
1286
+ $resolvedTarget = (Resolve-Path $Target -ErrorAction SilentlyContinue)
1287
+ if (-not $resolvedTarget) {
1288
+ Write-Host "No AID tools installed in $Target yet - run 'aid add <tool>'."
1289
+ return 0
1290
+ }
1291
+ $targetPath = $resolvedTarget.Path
1292
+
1293
+ $manifest = Join-Path $targetPath (Join-Path '.aid' '.aid-manifest.json')
1294
+
1295
+ $manifestOk = $false
1296
+ if (Test-Path $manifest -PathType Leaf) {
1297
+ try {
1298
+ $raw = Get-Content -LiteralPath $manifest -Raw
1299
+ if ($raw -match '"manifest_version"') {
1300
+ $manifestOk = $true
1301
+ }
1302
+ } catch {}
1303
+ }
1304
+
1305
+ if (-not $manifestOk) {
1306
+ Write-Host "No AID tools installed in $targetPath yet - run 'aid add <tool>'."
1307
+ return 0
1308
+ }
1309
+
1310
+ # Read CLI ref version from $env:AID_HOME/VERSION.
1311
+ $refVersion = ''
1312
+ $aidHome = $env:AID_HOME
1313
+ if ($aidHome) {
1314
+ $verFile = Join-Path $aidHome 'VERSION'
1315
+ if (Test-Path $verFile -PathType Leaf) {
1316
+ $refVersion = (Get-Content -LiteralPath $verFile -Raw).Trim()
1317
+ }
1318
+ }
1319
+
1320
+ script:Invoke-RenderToolsBlock -ManifestPath $manifest -RefVersion $refVersion `
1321
+ -HeaderPrefix "Installed tools (in $targetPath)"
1322
+
1323
+ return 0
1324
+ }
1325
+
1326
+ # Get-AidStatus <target>
1327
+ # Renders the "aid status" output for the AID project rooted at <target>.
1328
+ # Output is byte-identical to the Bash aid_status function.
1329
+ # Returns:
1330
+ # 0 - manifest found; status printed to stdout.
1331
+ # 7 - no manifest in <target>; message printed + returns 7.
1332
+ function Get-AidStatus {
1333
+ param([string]$Target = '.')
1334
+
1335
+ $resolvedTarget = (Resolve-Path $Target -ErrorAction SilentlyContinue)
1336
+ if (-not $resolvedTarget) {
1337
+ Write-Host "No AID install found in $Target. Run 'aid add <tool>' to install."
1338
+ return 7
1339
+ }
1340
+ $targetPath = $resolvedTarget.Path
1341
+
1342
+ $manifest = Join-Path $targetPath (Join-Path '.aid' '.aid-manifest.json')
1343
+
1344
+ # Check if manifest exists and has manifest_version key.
1345
+ $manifestOk = $false
1346
+ if (Test-Path $manifest -PathType Leaf) {
1347
+ try {
1348
+ $raw = Get-Content -LiteralPath $manifest -Raw
1349
+ if ($raw -match '"manifest_version"') {
1350
+ $manifestOk = $true
1351
+ }
1352
+ } catch {}
1353
+ }
1354
+
1355
+ if (-not $manifestOk) {
1356
+ Write-Host "No AID install found in $targetPath. Run 'aid add <tool>' to install."
1357
+ return 7
1358
+ }
1359
+
1360
+ # Read aid_version from manifest.
1361
+ $aidVersion = ''
1362
+ try {
1363
+ $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json
1364
+ if ($data.aid_version) { $aidVersion = $data.aid_version }
1365
+ } catch {}
1366
+
1367
+ # Read CLI ref version from $env:AID_HOME/VERSION.
1368
+ $refVersion = ''
1369
+ $aidHome = $env:AID_HOME
1370
+ if ($aidHome) {
1371
+ $verFile = Join-Path $aidHome 'VERSION'
1372
+ if (Test-Path $verFile -PathType Leaf) {
1373
+ $refVersion = (Get-Content -LiteralPath $verFile -Raw).Trim()
1374
+ }
1375
+ }
1376
+
1377
+ # Emit header line - byte-identical format to Bash:
1378
+ # "AID <ver> (project: <dir>)"
1379
+ Write-Host "AID $aidVersion (project: $targetPath)"
1380
+
1381
+ script:Invoke-RenderToolsBlock -ManifestPath $manifest -RefVersion $refVersion `
1382
+ -HeaderPrefix "Installed tools"
1383
+
1384
+ return 0
1385
+ }
1386
+
1387
+ # Export only public functions (not script-scoped helpers).
1388
+ Export-ModuleMember -Function @(
1389
+ 'Get-Sha256File',
1390
+ 'Normalize-Tool',
1391
+ 'Detect-Tool',
1392
+ 'Resolve-AidVersion',
1393
+ 'Fetch-Tarball',
1394
+ 'Extract-Tarball',
1395
+ 'Verify-BundleChecksum',
1396
+ 'Copy-AidFile',
1397
+ 'Copy-AidDir',
1398
+ 'Install-AidTool',
1399
+ 'Read-ManifestToolPaths',
1400
+ 'Read-ManifestToolVersion',
1401
+ 'Read-ManifestRootAgent',
1402
+ 'Read-ManifestRootAgentStatus',
1403
+ 'Write-AidManifest',
1404
+ 'Remove-ManifestTool',
1405
+ 'Test-ManifestExists',
1406
+ 'Uninstall-AidTool',
1407
+ 'Write-VersionMarker',
1408
+ 'Get-ManifestToolList',
1409
+ 'Get-AidStatusBody',
1410
+ 'Get-AidStatus'
1411
+ )