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/LICENSE +21 -0
- package/README.md +17 -0
- package/VERSION +1 -0
- package/bin/aid +931 -0
- package/bin/aid.cmd +24 -0
- package/bin/aid.js +70 -0
- package/bin/aid.ps1 +875 -0
- package/lib/AidInstallCore.psm1 +1411 -0
- package/lib/aid-install-core.sh +1646 -0
- package/package.json +36 -0
|
@@ -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
|
+
)
|