@caddis/cli 0.4.18 → 0.4.20

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.
Files changed (43) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +530 -0
  3. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +8 -0
  4. package/bundles/antigravity-plugin/skills/_registry.md +5 -0
  5. package/bundles/antigravity-plugin/skills/data-lineage/SKILL.md +32 -0
  6. package/bundles/antigravity-plugin/skills/hld/SKILL.md +39 -0
  7. package/bundles/antigravity-plugin/skills/lld/SKILL.md +45 -0
  8. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +87 -0
  9. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +231 -0
  10. package/bundles/antigravity-plugin/skills/port-to-github/references/ci-and-credentials.md +66 -0
  11. package/bundles/antigravity-plugin/skills/port-to-github/references/config.example.json +18 -0
  12. package/bundles/antigravity-plugin/skills/port-to-github/references/old-host.md +61 -0
  13. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  14. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  15. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  16. package/bundles/antigravity-plugin/skills/port-to-github/scripts/PortCommon.ps1 +130 -0
  17. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  18. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  19. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  20. package/bundles/antigravity-plugin/skills/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  21. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  22. package/bundles/antigravity-plugin-extras/skills/_registry.md +3 -0
  23. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +158 -0
  24. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +1430 -0
  25. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +220 -0
  26. package/bundles/antigravity-plugin-extras/skills/hld/references/gold-standard-hld.md +428 -0
  27. package/bundles/antigravity-plugin-extras/skills/hld/scripts/convert_doc_to_confluence.py +1430 -0
  28. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +198 -0
  29. package/bundles/antigravity-plugin-extras/skills/lld/scripts/convert_doc_to_confluence.py +1430 -0
  30. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +231 -0
  31. package/bundles/codex/.codex/skills/devops/port-to-github/references/ci-and-credentials.md +66 -0
  32. package/bundles/codex/.codex/skills/devops/port-to-github/references/config.example.json +18 -0
  33. package/bundles/codex/.codex/skills/devops/port-to-github/references/old-host.md +61 -0
  34. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Compare-PortRefs.ps1 +68 -0
  35. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Convert-PortWorkflow.ps1 +92 -0
  36. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Get-PortSource.ps1 +459 -0
  37. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/PortCommon.ps1 +130 -0
  38. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Publish-PortToGitHub.ps1 +156 -0
  39. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Switch-CheckoutOrigin.ps1 +182 -0
  40. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortReadiness.ps1 +218 -0
  41. package/bundles/codex/.codex/skills/devops/port-to-github/scripts/Test-PortWorkflowParses.ps1 +62 -0
  42. package/bundles/manifest.json +3 -3
  43. package/package.json +1 -1
@@ -0,0 +1,156 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Create the GitHub repository (empty) and push a staged mirror to it, then prove every ref landed.
4
+
5
+ .DESCRIPTION
6
+ - Pushes with an EXPLICIT refspec (heads and tags). Never --mirror: a server's hidden refs
7
+ (refs/pull/*) are refused by GitHub, and --mirror would also DELETE anything on GitHub the
8
+ stage lacks.
9
+ - Never force-pushes. If the GitHub repo already exists it must be empty, or every one of its
10
+ branches must be an ancestor of (or equal to) the staged branch of the same name. Otherwise it
11
+ refuses: pushing on top of unrelated history welds a fake ancestry in permanently.
12
+ - Verifies by SHA with Compare-PortRefs.ps1 and exits 1 if any ref differs.
13
+
14
+ Uses the gh CLI for the API and your normal git credential helper for the push
15
+ (run `gh auth setup-git` once if git cannot authenticate to github.com).
16
+ #>
17
+ [CmdletBinding(SupportsShouldProcess)]
18
+ param(
19
+ [Parameter(Mandatory)][string]$Name,
20
+ [string]$Owner,
21
+ [string]$Repo,
22
+ [ValidateSet('private', 'internal', 'public')][string]$Visibility,
23
+ [string]$Description,
24
+ [string]$StagingRoot,
25
+ [string]$ConfigPath,
26
+ # Allow pushing into an existing NON-empty repo whose history is compatible (fast-forward only).
27
+ [switch]$AllowExisting,
28
+ # The staged branches already contain .github/workflows; pushing them WILL run those
29
+ # workflows (a deploy job included). Pass only after reading them.
30
+ [switch]$AllowWorkflowRuns
31
+ )
32
+
33
+ $ErrorActionPreference = 'Stop'
34
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
35
+
36
+ $config = Get-PortConfig $ConfigPath
37
+ $stage = Join-Path (Get-PortStagingRoot $config $StagingRoot) $Name
38
+ $mirror = Join-Path $stage 'mirror.git'
39
+ $manifestPath = Join-Path $stage 'manifest.json'
40
+ if (-not (Test-Path -LiteralPath $mirror)) { throw "No staged mirror at $mirror. Run Get-PortSource.ps1 first." }
41
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
42
+
43
+ $readinessPath = Join-Path $stage 'readiness.json'
44
+ if (-not (Test-Path -LiteralPath $readinessPath)) { throw "No readiness report. Run Test-PortReadiness.ps1 -Name $Name first." }
45
+ $readiness = Get-Content -LiteralPath $readinessPath -Raw | ConvertFrom-Json
46
+ if (-not $readiness.ready) { throw "Readiness has $(@($readiness.blockers).Count) blocker(s). Fix them and re-run Test-PortReadiness.ps1 before publishing." }
47
+ $live = @()
48
+ if ($readiness.PSObject.Properties.Name -contains 'branchesWithGithubWorkflows') { $live = @($readiness.branchesWithGithubWorkflows) }
49
+ if ($live.Count -gt 0 -and -not $AllowWorkflowRuns) {
50
+ throw "Branch(es) $($live -join ', ') contain .github/workflows. The push will RUN them on GitHub (deploy jobs included). Read them, then re-run with -AllowWorkflowRuns - or remove/disable them in the source first."
51
+ }
52
+
53
+ # The stage must still hold exactly what Get-PortSource recorded. Anything else (a stray fetch,
54
+ # a hand edit, a second tool) would be pushed and then "verified" against itself.
55
+ $nowRefs = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads', 'refs/tags') | Sort-Object)
56
+ $wasRefs = @(@($manifest.heads) + @($manifest.tags) | Where-Object { $_ } | Sort-Object)
57
+ $drift = @(Compare-Object $wasRefs $nowRefs)
58
+ $extra = @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)') | Where-Object { $_ -and $_ -notlike 'refs/heads/*' -and $_ -notlike 'refs/tags/*' })
59
+ if ($drift.Count -gt 0 -or $extra.Count -gt 0) {
60
+ $d = @($drift | ForEach-Object { "$($_.SideIndicator) $($_.InputObject)" }) + @($extra | ForEach-Object { "extra $_" })
61
+ throw "The staged mirror no longer matches manifest.json (=> added, <= missing):`n $($d -join "`n ")`nRe-run Get-PortSource.ps1 -Force to rebuild the stage."
62
+ }
63
+
64
+ if (-not $Owner) { $Owner = Get-PortConfigValue $config 'owner' $null }
65
+ if (-not $Owner) { throw 'No -Owner and no "owner" in the config file.' }
66
+ if (-not $Repo) { $Repo = $Name }
67
+ if (-not $Visibility) { $Visibility = Get-PortConfigValue $config 'visibility' 'private' }
68
+ $full = "$Owner/$Repo"
69
+ $url = "https://github.com/$full.git"
70
+
71
+ # ---- 1. Who am I, and can I see the owner -------------------------------------------------------
72
+ $login = (& gh api user --jq .login 2>&1)
73
+ if ($LASTEXITCODE -ne 0) { throw "gh is not signed in: $login. Run gh auth login." }
74
+ Write-PortStep "Signed in to GitHub as $login; target $full ($Visibility)"
75
+
76
+ # ---- 2. Does the repo exist? -----------------------------------------------------------------------
77
+ $exists = $false
78
+ $null = & gh api "repos/$full" --jq .full_name 2>$null
79
+ if ($LASTEXITCODE -eq 0) { $exists = $true }
80
+ $global:LASTEXITCODE = 0
81
+
82
+ if ($exists) {
83
+ $remoteRefs = @(Invoke-PortGit @('ls-remote', '--heads', $url))
84
+ if ($remoteRefs.Count -eq 0 -or -not ($remoteRefs -join '')) {
85
+ Write-PortStep "$full exists and is empty. Using it."
86
+ }
87
+ else {
88
+ if (-not $AllowExisting) {
89
+ throw "$full already exists and has $($remoteRefs.Count) branch(es). Check it shares history with the source (see SKILL.md, 'An existing repo'), then re-run with -AllowExisting, or delete it and re-run."
90
+ }
91
+ Write-PortStep "$full exists with history. Checking every GitHub branch is an ancestor of the staged branch."
92
+ # --no-tags: without it git auto-follows the OTHER repo's tags into the stage, and the
93
+ # next push would publish them. Found by the dogfood run, 2026-09-19.
94
+ Invoke-PortGit @('-C', $mirror, 'fetch', '-q', '--no-tags', $url, '+refs/heads/*:refs/port-check/*') | Out-Null
95
+ try {
96
+ foreach ($line in $remoteRefs) {
97
+ $sha, $ref = $line -split "`t", 2
98
+ $b = $ref.Substring(11)
99
+ $local = Invoke-PortGit -AllowFailure @('-C', $mirror, 'rev-parse', '--verify', '-q', "refs/heads/$b")
100
+ if ($local.ExitCode -ne 0) { continue } # branch only on GitHub: left alone
101
+ $anc = Invoke-PortGit -AllowFailure @('-C', $mirror, 'merge-base', '--is-ancestor', $sha, ($local.Output -join ''))
102
+ if ($anc.ExitCode -ne 0) {
103
+ throw "GitHub branch '$b' ($($sha.Substring(0,12))) is NOT an ancestor of the staged '$b'. The histories are unrelated or diverged. Refusing: resolve this by hand."
104
+ }
105
+ }
106
+ }
107
+ finally {
108
+ foreach ($r in @(Invoke-PortGit @('-C', $mirror, 'for-each-ref', '--format=%(refname)', 'refs/port-check'))) {
109
+ if ($r) { Invoke-PortGit @('-C', $mirror, 'update-ref', '-d', $r) | Out-Null }
110
+ }
111
+ }
112
+ Write-PortOk 'Every GitHub branch is an ancestor. The push is a fast-forward.'
113
+ }
114
+ }
115
+ else {
116
+ if ($PSCmdlet.ShouldProcess($full, "Create $Visibility GitHub repository")) {
117
+ $createArgs = @('repo', 'create', $full, "--$Visibility", '--disable-wiki')
118
+ if ($Description) { $createArgs += @('--description', $Description) }
119
+ $out = & gh @createArgs 2>&1
120
+ if ($LASTEXITCODE -ne 0) { throw "gh repo create failed: $out" }
121
+ Write-PortOk "Created $full"
122
+ }
123
+ else { Write-Host "WhatIf: would create $full"; return }
124
+ }
125
+
126
+ # ---- 3. Push -------------------------------------------------------------------------------------------
127
+ if ($PSCmdlet.ShouldProcess($full, 'Push refs/heads/* and refs/tags/*')) {
128
+ Write-PortStep "Pushing branches and tags to $full"
129
+ # --no-verify is NOT used: no hooks exist in a fresh bare mirror anyway.
130
+ Invoke-PortGit @('-C', $mirror, 'push', '--porcelain', $url, 'refs/heads/*:refs/heads/*', 'refs/tags/*:refs/tags/*') | Out-Null
131
+ }
132
+ else { Write-Host "WhatIf: would push to $url"; return }
133
+
134
+ # ---- 4. Default branch ---------------------------------------------------------------------------------
135
+ $def = $manifest.defaultBranch
136
+ $cur = (& gh api "repos/$full" --jq .default_branch 2>$null)
137
+ if ($cur -ne $def) {
138
+ & gh api -X PATCH "repos/$full" -f "default_branch=$def" --silent 2>&1 | Out-Null
139
+ if ($LASTEXITCODE -ne 0) { Write-PortWarn "Could not set the default branch to $def. Set it by hand." }
140
+ }
141
+
142
+ # ---- 5. Prove it by SHA ---------------------------------------------------------------------------------
143
+ Write-PortStep 'Verifying every branch and tag by SHA'
144
+ $mode = if ($exists) { 'LeftInRight' } else { 'Equal' }
145
+ & (Join-Path $PSScriptRoot 'Compare-PortRefs.ps1') -Left $mirror -Right $url -Mode $mode -LeftLabel 'staged' -RightLabel 'github'
146
+ $verified = ($LASTEXITCODE -eq 0)
147
+
148
+ $manifest | Add-Member -NotePropertyName github -NotePropertyValue ([pscustomobject]@{
149
+ repo = $full; url = $url; pushedAt = (Get-Date).ToString('o'); pushedBy = $login; verified = $verified; defaultBranch = $def
150
+ }) -Force
151
+ $manifest.status = if ($verified) { 'published' } else { 'publish-unverified' }
152
+ ConvertTo-PortJsonFile $manifest $manifestPath
153
+
154
+ if (-not $verified) { Write-PortFail "Push finished but refs do not match. Do NOT continue to CI or cutover."; exit 1 }
155
+ Write-PortOk "Published $full - https://github.com/$full"
156
+ exit 0
@@ -0,0 +1,182 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Repoint an existing checkout (a deploy target, a dev box) from its old origin to GitHub,
4
+ prove it still agrees with GitHub, and roll back by itself if it does not.
5
+
6
+ .DESCRIPTION
7
+ Works on a local path, or on a remote box over WinRM (-ComputerName).
8
+
9
+ -Mode Inspect print origin, branch, HEAD, dirty count. Changes nothing.
10
+ -Mode Switch record the old URL in a local state file, keep it as a second remote
11
+ (-KeepOldAs), set origin to -NewUrl, fetch, and require HEAD to be contained
12
+ in some origin/* branch. On ANY failure the old URL is restored at once.
13
+ -Mode Rollback put the old URL back from the state file and remove the remote Switch added.
14
+
15
+ A deploy host usually holds NO GitHub credential of its own: CI supplies one per run.
16
+ -VerifyWithGhToken passes your current gh token to that ONE fetch as an HTTP header in the
17
+ process environment. It is never written to disk or to git config, and it is gone when the
18
+ command ends. Without it, a private repo fetch on such a host fails and the switch rolls back.
19
+
20
+ If the old URL embedded a credential (user:secret@host), the state file stores it WITHOUT
21
+ the credential and says so. Rotate that credential.
22
+ #>
23
+ [CmdletBinding(SupportsShouldProcess)]
24
+ param(
25
+ [Parameter(Mandatory)][string]$Path,
26
+ [string]$ComputerName,
27
+ [pscredential]$Credential,
28
+ [ValidateSet('Inspect', 'Switch', 'Rollback')][string]$Mode = 'Inspect',
29
+ [string]$NewUrl,
30
+ [string]$KeepOldAs,
31
+ [string]$StateFile,
32
+ [string]$StagingRoot,
33
+ [string]$ConfigPath,
34
+ [switch]$VerifyWithGhToken
35
+ )
36
+
37
+ $ErrorActionPreference = 'Stop'
38
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
39
+ $config = Get-PortConfig $ConfigPath
40
+ if (-not $KeepOldAs) { $KeepOldAs = Get-PortConfigValue $config 'oldRemoteName' 'old-origin' }
41
+
42
+ $where = if ($ComputerName) { $ComputerName } else { $env:COMPUTERNAME }
43
+ if (-not $StateFile) {
44
+ $dir = Join-Path (Get-PortStagingRoot $config $StagingRoot) 'cutover'
45
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
46
+ $safe = ($Path -replace '[:\\/ ]+', '_').Trim('_')
47
+ $StateFile = Join-Path $dir "$where-$safe.json"
48
+ }
49
+
50
+ # One script block, run locally or remotely. Windows PowerShell 5.1 compatible. Native stderr
51
+ # must not become a terminating error, so git runs under 'Continue' and is judged by exit code.
52
+ $block = {
53
+ param($P, $M, $New, $KeepAs, $OldUrl, $RestoreUrl, $AddedRemote, $Token)
54
+ $ErrorActionPreference = 'Continue'
55
+ function G { $o = & git -c safe.directory=* -C $P @args 2>&1; $c = $LASTEXITCODE; return @{ code = $c; out = @($o | ForEach-Object { "$_" }) } }
56
+ $r = @{ ok = $false; origin = $null; branch = $null; head = $null; dirty = 0; remotes = @(); message = $null; addedRemote = $false; rolledBack = $false }
57
+ if (-not (Test-Path -LiteralPath $P)) { $r.message = "Path not found: $P"; return $r }
58
+ $o = G remote get-url origin
59
+ if ($o.code -ne 0) { $r.message = "No origin remote in $P"; return $r }
60
+ $r.origin = ($o.out -join '')
61
+ $b = G symbolic-ref --short HEAD; if ($b.code -eq 0) { $r.branch = ($b.out -join '') }
62
+ $hd = G rev-parse --verify -q HEAD
63
+ if ($hd.code -ne 0 -or -not ($hd.out -join '')) { $r.message = "$P has no commit at HEAD (empty or unborn repository)"; return $r }
64
+ $r.head = ($hd.out -join '')
65
+ $r.dirty = @((G status --porcelain).out | Where-Object { $_ }).Count
66
+ $r.remotes = @((G remote).out)
67
+
68
+ if ($M -eq 'Inspect') { $r.ok = $true; return $r }
69
+
70
+ if ($M -eq 'Rollback') {
71
+ $s = G remote set-url origin $OldUrl
72
+ if ($s.code -ne 0) { $r.message = "set-url failed: $($s.out -join ' ')"; return $r }
73
+ if ($AddedRemote -and ($r.remotes -contains $KeepAs)) { G remote remove $KeepAs | Out-Null }
74
+ $r.origin = $OldUrl; $r.ok = $true; $r.message = 'rolled back'; return $r
75
+ }
76
+
77
+ # Switch
78
+ if ($r.origin -eq $New) { $r.ok = $true; $r.message = 'origin already points at the new URL'; return $r }
79
+ if (-not ($r.remotes -contains $KeepAs)) {
80
+ $a = G remote add $KeepAs $OldUrl
81
+ if ($a.code -ne 0) { $r.message = "could not add remote ${KeepAs}: $($a.out -join ' ')"; return $r }
82
+ $r.addedRemote = $true
83
+ }
84
+ $s = G remote set-url origin $New
85
+ if ($s.code -ne 0) { $r.message = "set-url failed: $($s.out -join ' ')"; return $r }
86
+
87
+ # From here origin points at the new URL. ANY failure, including an exception, must restore it.
88
+ $fail = $null
89
+ $env:GIT_TERMINAL_PROMPT = '0'
90
+ try {
91
+ if ($Token) {
92
+ $env:GIT_CONFIG_COUNT = '1'
93
+ $env:GIT_CONFIG_KEY_0 = 'http.https://github.com/.extraheader'
94
+ $env:GIT_CONFIG_VALUE_0 = 'AUTHORIZATION: basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$Token"))
95
+ }
96
+ $f = G fetch -q origin
97
+ $contained = @()
98
+ if ($f.code -eq 0) { $contained = @((G for-each-ref --contains HEAD --format='%(refname)' refs/remotes/origin).out | Where-Object { $_ }) }
99
+ if ($f.code -ne 0) { $fail = "fetch from the new origin failed (exit $($f.code)): $(($f.out | Select-Object -First 3) -join ' ')" }
100
+ elseif ($contained.Count -eq 0) { $fail = "HEAD $($r.head.Substring(0,12)) is not contained in any origin/* branch: this checkout has commits GitHub does not" }
101
+ }
102
+ catch { $fail = "verification threw: $($_.Exception.Message)" }
103
+ finally {
104
+ Remove-Item Env:GIT_CONFIG_COUNT, Env:GIT_CONFIG_KEY_0, Env:GIT_CONFIG_VALUE_0 -ErrorAction SilentlyContinue
105
+ }
106
+ if ($fail) {
107
+ G remote set-url origin $RestoreUrl | Out-Null
108
+ if ($r.addedRemote) { G remote remove $KeepAs | Out-Null; $r.addedRemote = $false }
109
+ $r.rolledBack = $true; $r.message = $fail; return $r
110
+ }
111
+ $r.origin = $New; $r.ok = $true; $r.message = "HEAD is contained in: $($contained -join ', ')"; return $r
112
+ }
113
+
114
+ function Invoke-Block($ArgList) {
115
+ if ($ComputerName) {
116
+ $sa = @{ ComputerName = $ComputerName; ScriptBlock = $block; ArgumentList = $ArgList }
117
+ if ($Credential) { $sa.Credential = $Credential }
118
+ return Invoke-Command @sa
119
+ }
120
+ return & $block @ArgList
121
+ }
122
+
123
+ # Always inspect first.
124
+ $now = Invoke-Block @($Path, 'Inspect', $null, $KeepOldAs, $null, $null, $false, $null)
125
+ if (-not $now.ok) { throw "[$where] $($now.message)" }
126
+ $clean = Remove-UrlCredential $now.origin
127
+ Write-Host ("[{0}] {1}`n origin : {2}`n branch : {3} HEAD {4} uncommitted: {5} remotes: {6}" -f $where, $Path, $clean.Url, $now.branch, $now.head.Substring(0, 12), $now.dirty, ($now.remotes -join ', '))
128
+ if ($clean.HadCredential) { Write-PortWarn 'The current origin URL EMBEDS A CREDENTIAL. It is shown without it. Rotate that credential after the cutover.' }
129
+ if ($Mode -eq 'Inspect') { exit 0 }
130
+
131
+ if ($Mode -eq 'Switch') {
132
+ if (-not $NewUrl) { throw '-NewUrl is required for -Mode Switch.' }
133
+ if ($now.dirty -gt 0) { Write-PortWarn "The checkout has $($now.dirty) uncommitted change(s). A deploy that resets the tree will discard them. Look before you continue." }
134
+ if (-not $PSCmdlet.ShouldProcess("$where $Path", "Set origin to $NewUrl")) { exit 0 }
135
+ $token = $null
136
+ if ($VerifyWithGhToken) {
137
+ $token = (& gh auth token 2>$null)
138
+ if ($LASTEXITCODE -ne 0 -or -not $token) { throw 'gh auth token returned nothing. Sign in with gh auth login, or drop -VerifyWithGhToken.' }
139
+ }
140
+ # Record the rollback BEFORE changing anything.
141
+ $state = [ordered]@{
142
+ computer = $where; path = $Path; oldUrl = $clean.Url; oldUrlHadCredential = $clean.HadCredential
143
+ newUrl = $NewUrl; keepOldAs = $KeepOldAs; addedRemote = $false; switchedAt = (Get-Date).ToString('o'); headAtSwitch = $now.head
144
+ }
145
+ ConvertTo-PortJsonFile $state $StateFile
146
+ # The second remote gets the credential-free URL. On failure the ORIGINAL value is restored.
147
+ try {
148
+ $res = Invoke-Block @($Path, 'Switch', $NewUrl, $KeepOldAs, $clean.Url, $now.origin, $false, $token)
149
+ }
150
+ catch {
151
+ # The call itself failed (e.g. the WinRM transport dropped). The remote side may or may
152
+ # not have changed origin. Put the ORIGINAL value back; leave the kept remote for a human.
153
+ $token = $null
154
+ Write-PortFail "[$where] Switch call failed: $($_.Exception.Message). Restoring the original origin."
155
+ try { $null = Invoke-Block @($Path, 'Rollback', $null, $KeepOldAs, $now.origin, $null, $false, $null) } catch { Write-PortFail "[$where] Restore ALSO failed. Set origin back by hand to the URL in $StateFile." }
156
+ exit 1
157
+ }
158
+ $token = $null
159
+ if (-not $res.ok) {
160
+ if ($res.rolledBack) { Write-PortFail "[$where] Switch failed and was ROLLED BACK: $($res.message)" }
161
+ else { Write-PortFail "[$where] Switch failed: $($res.message)" }
162
+ Remove-Item -LiteralPath $StateFile -ErrorAction SilentlyContinue
163
+ exit 1
164
+ }
165
+ $state.addedRemote = [bool]$res.addedRemote
166
+ ConvertTo-PortJsonFile $state $StateFile
167
+ Write-PortOk "[$where] origin -> $NewUrl. $($res.message)"
168
+ Write-Host " old origin kept as remote '$KeepOldAs'. Rollback: $($MyInvocation.MyCommand.Name) -Path '$Path'$(if ($ComputerName) { " -ComputerName $ComputerName" }) -Mode Rollback"
169
+ Write-Host " state: $StateFile"
170
+ exit 0
171
+ }
172
+
173
+ if ($Mode -eq 'Rollback') {
174
+ if (-not (Test-Path -LiteralPath $StateFile)) { throw "No state file at $StateFile. Pass -StateFile." }
175
+ $state = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json
176
+ if (-not $PSCmdlet.ShouldProcess("$where $Path", "Set origin back to $($state.oldUrl)")) { exit 0 }
177
+ $res = Invoke-Block @($Path, 'Rollback', $null, $state.keepOldAs, $state.oldUrl, $null, [bool]$state.addedRemote, $null)
178
+ if (-not $res.ok) { Write-PortFail "[$where] Rollback failed: $($res.message)"; exit 1 }
179
+ if ($state.oldUrlHadCredential) { Write-PortWarn 'The old URL had an embedded credential. It was restored WITHOUT it; the credential helper must supply one.' }
180
+ Write-PortOk "[$where] origin restored to $($state.oldUrl)"
181
+ exit 0
182
+ }
@@ -0,0 +1,218 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Inspect a staged repository and list what will break or leak when it moves to GitHub.
4
+
5
+ .DESCRIPTION
6
+ Reads <staging>\<Name>\mirror.git (written by Get-PortSource.ps1). Changes nothing.
7
+ Writes <staging>\<Name>\readiness.json and prints a report.
8
+
9
+ BLOCKERS (exit 1): a file over 100 MB anywhere in history, Git LFS content, a workflow
10
+ containing an empty expression, a likely live credential in the current tree.
11
+ WARNINGS: everything a human must decide: CI credentials, dependency hosts, runner labels,
12
+ hardcoded references to the old host, secret-looking files in past history.
13
+ #>
14
+ [CmdletBinding()]
15
+ param(
16
+ [Parameter(Mandatory)][string]$Name,
17
+ [string]$StagingRoot,
18
+ [string]$ConfigPath
19
+ )
20
+
21
+ $ErrorActionPreference = 'Stop'
22
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
23
+
24
+ $config = Get-PortConfig $ConfigPath
25
+ $stage = Join-Path (Get-PortStagingRoot $config $StagingRoot) $Name
26
+ $mirror = Join-Path $stage 'mirror.git'
27
+ if (-not (Test-Path -LiteralPath $mirror)) { throw "No staged mirror at $mirror. Run Get-PortSource.ps1 first." }
28
+ $manifest = Get-Content -LiteralPath (Join-Path $stage 'manifest.json') -Raw | ConvertFrom-Json
29
+ $branch = $manifest.defaultBranch
30
+
31
+ $blockers = New-Object System.Collections.Generic.List[string]
32
+ $warnings = New-Object System.Collections.Generic.List[string]
33
+ $report = [ordered]@{ name = $Name; defaultBranch = $branch; checkedAt = (Get-Date).ToString('o') }
34
+
35
+ function MGit([string[]]$a) { Invoke-PortGit (@('-C', $mirror) + $a) }
36
+ function MGitTry([string[]]$a) { Invoke-PortGit -AllowFailure (@('-C', $mirror) + $a) }
37
+
38
+ # ---- 1. Size: GitHub refuses any blob over 100 MB, in ANY commit, not just the tip ----------
39
+ Write-PortStep 'Checking object sizes across all history'
40
+ $big = @()
41
+ foreach ($line in (MGit @('cat-file', '--batch-all-objects', '--batch-check=%(objecttype) %(objectsize) %(objectname)'))) {
42
+ $t, $s, $o = $line -split ' '
43
+ if ($t -eq 'blob' -and [int64]$s -gt 50MB) { $big += [pscustomobject]@{ sha = $o; bytes = [int64]$s } }
44
+ }
45
+ if ($big.Count -gt 0) {
46
+ $paths = @{}
47
+ foreach ($l in (MGit @('rev-list', '--objects', '--all'))) {
48
+ $sha, $p = $l -split ' ', 2
49
+ if ($p -and ($big.sha -contains $sha)) { $paths[$sha] = $p }
50
+ }
51
+ foreach ($b in $big) {
52
+ $p = $paths[$b.sha]
53
+ $mb = [math]::Round($b.bytes / 1MB, 1)
54
+ if ($b.bytes -gt 100MB) { $blockers.Add("File over 100 MB in history: $p ($mb MB). GitHub rejects the push. Rewrite history (git filter-repo) or move it to LFS/releases first.") }
55
+ else { $warnings.Add("Large file in history: $p ($mb MB). GitHub warns above 50 MB.") }
56
+ }
57
+ }
58
+ $report.largeBlobs = $big.Count
59
+
60
+ # ---- 2. LFS ---------------------------------------------------------------------------------
61
+ $attrs = MGitTry @('show', "${branch}:.gitattributes")
62
+ if ($attrs.ExitCode -eq 0 -and (($attrs.Output -join "`n") -match 'filter=lfs')) {
63
+ $blockers.Add('The repository uses Git LFS. LFS objects are not in a git mirror; push them separately (git lfs fetch --all from the source, git lfs push --all to GitHub) before declaring the port done.')
64
+ }
65
+
66
+ # ---- 3. Secrets ---------------------------------------------------------------------------------
67
+ Write-PortStep 'Scanning for credentials'
68
+ $liveSecretPatterns = @('gh[pousr]_[A-Za-z0-9]{36,}', 'github_pat_[A-Za-z0-9_]{40,}', 'AKIA[0-9A-Z]{16}', '-----BEGIN [A-Z ]*PRIVATE KEY-----', 'xox[baprs]-[A-Za-z0-9-]{10,}')
69
+ $grepArgs = @('grep', '-I', '-l', '-E')
70
+ $liveHits = @()
71
+ foreach ($p in $liveSecretPatterns) {
72
+ $g = MGitTry ($grepArgs + @('-e', $p, $branch))
73
+ if ($g.ExitCode -eq 0) { $liveHits += $g.Output }
74
+ }
75
+ foreach ($h in ($liveHits | Sort-Object -Unique)) { $blockers.Add("Likely live credential in the current tree: $h. Remove it and ROTATE it; history still holds it.") }
76
+
77
+ # The same patterns across ALL history. A token committed once and deleted later is gone from
78
+ # the tip but still readable by anyone who can clone the new repo. -G matches commits whose
79
+ # diff adds or removes a matching line (extended regex).
80
+ $histPattern = $liveSecretPatterns -join '|'
81
+ $histOut = @(MGit @('log', '--all', "-G$histPattern", '--format=@@%h', '--name-only'))
82
+ $cur = $null
83
+ $histHits = @{}
84
+ foreach ($l in $histOut) {
85
+ if (-not $l) { continue }
86
+ if ($l.StartsWith('@@')) { $cur = $l.Substring(2); continue }
87
+ if (-not $histHits.ContainsKey($l)) { $histHits[$l] = @() }
88
+ $histHits[$l] += $cur
89
+ }
90
+ foreach ($file in ($histHits.Keys | Sort-Object)) {
91
+ $tipName = "${branch}:$file"
92
+ if ($liveHits -contains $tipName) { continue } # already reported as a current-tree blocker
93
+ $blockers.Add("Likely credential in PAST history: $file (commit(s) $(($histHits[$file] | Select-Object -Unique) -join ', ')). It is gone from the tip but ships with the history. ROTATE it, then either accept that or rewrite history (git filter-repo) before publishing.")
94
+ }
95
+ $report.historySecretFiles = @($histHits.Keys)
96
+
97
+ $pwHits = MGitTry @('grep', '-I', '-l', '-i', '-E', '-e', '(password|passwd|pwd|api[_-]?key|secret)[[:space:]]*[:=][[:space:]]*[''"][^''" ]{8,}[''"]', $branch)
98
+ if ($pwHits.ExitCode -eq 0) {
99
+ foreach ($h in $pwHits.Output) { $warnings.Add("Possible hardcoded password or key: $h. Check it is a placeholder or test value.") }
100
+ }
101
+
102
+ $treeFiles = @(MGit @('ls-tree', '-r', '--name-only', $branch))
103
+ foreach ($f in $treeFiles) {
104
+ if (Test-PortSecretName (Split-Path $f -Leaf)) { $warnings.Add("Secret-type file tracked in the current tree: $f. Check it holds no real value.") }
105
+ }
106
+ $histNames = @(MGit @('log', '--all', '--format=', '--name-only') | Where-Object { $_ } | Sort-Object -Unique)
107
+ $histSecret = @($histNames | Where-Object { (Test-PortSecretName (Split-Path $_ -Leaf)) -and ($treeFiles -notcontains $_) })
108
+ foreach ($f in $histSecret) { $warnings.Add("Secret-type file existed in PAST history: $f. It will be public to anyone who can read the new repo. Rotate what it held, or rewrite history first.") }
109
+
110
+ # ---- 4. CI ----------------------------------------------------------------------------------
111
+ Write-PortStep 'Inventorying CI'
112
+ $ciFiles = @($treeFiles | Where-Object {
113
+ $_ -match '^\.(gitea|github|forgejo)/workflows/[^/]+\.ya?ml$' -or
114
+ $_ -in @('.gitlab-ci.yml', 'Jenkinsfile', 'azure-pipelines.yml', 'bitbucket-pipelines.yml', '.drone.yml', '.woodpecker.yml')
115
+ })
116
+ $report.ciFiles = $ciFiles
117
+ $workflows = @()
118
+ foreach ($f in $ciFiles) {
119
+ $text = (MGit @('show', "${branch}:$f")) -join "`n"
120
+ $lines = $text -split "`n"
121
+ $wf = [ordered]@{ file = $f; emptyExpressions = @(); credentialLines = @(); runsOn = @(); hosts = @(); dispatchOnly = $false }
122
+ for ($i = 0; $i -lt $lines.Count; $i++) {
123
+ $ln = $lines[$i]
124
+ if ($ln -match '\$\{\{\s*\}\}') { $wf.emptyExpressions += ($i + 1) }
125
+ if ($ln -match 'secrets\.|github\.token|(?i)token\s*:|_TOKEN\b|_USER\b|PASSWORD') { $wf.credentialLines += ("{0}: {1}" -f ($i + 1), $ln.Trim()) }
126
+ if ($ln -match '^\s*runs-on:\s*(.+)$') { $wf.runsOn += $Matches[1].Trim() }
127
+ foreach ($m in [regex]::Matches($ln, 'https?://([A-Za-z0-9.-]+(:\d+)?)')) { $wf.hosts += $m.Groups[1].Value }
128
+ }
129
+ $wf.runsOn = @($wf.runsOn | Sort-Object -Unique)
130
+ $wf.hosts = @($wf.hosts | Where-Object { $_ -notmatch '^(github\.com|api\.github\.com|localhost|127\.0\.0\.1)' } | Sort-Object -Unique)
131
+ if ($text -match '(?m)^on:\s*(\[\s*)?workflow_dispatch' -and $text -notmatch '(?m)^\s+(push|pull_request)\s*:') { $wf.dispatchOnly = $true }
132
+ if ($wf.emptyExpressions.Count -gt 0) {
133
+ $msg = "$f has an empty `${{ }} on line(s) $($wf.emptyExpressions -join ', '). GitHub rejects the WHOLE file (startup_failure, zero jobs), even inside a comment in a run: block."
134
+ # GitHub parses only .github/workflows. A .gitea/.forgejo file is inert on GitHub until
135
+ # phase 4 copies it, so it is a warning here and a blocker there.
136
+ if ($f -like '.github/*') { $blockers.Add($msg) } else { $warnings.Add("$msg Fix it when translating (phase 4).") }
137
+ }
138
+ if ($wf.dispatchOnly) { $warnings.Add("$f triggers only on workflow_dispatch. If this is a FROZEN copy, generate the GitHub workflow from the last live version in history instead.") }
139
+ if ($wf.hosts.Count -gt 0) { $warnings.Add("$f references non-GitHub host(s): $($wf.hosts -join ', '). Each needs a credential that works FROM GitHub's runners, or the step breaks.") }
140
+ $workflows += [pscustomobject]$wf
141
+ }
142
+ $report.workflows = $workflows
143
+ # Workflows already under .github/workflows RUN on GitHub the moment their branch is pushed.
144
+ # Check every branch, not only the default one: a push of any branch fires its own workflows.
145
+ $liveOnPush = @()
146
+ foreach ($h in @(MGit @('for-each-ref', '--format=%(refname:short)', 'refs/heads'))) {
147
+ $ls = MGitTry @('ls-tree', '--name-only', "${h}:.github/workflows")
148
+ if ($ls.ExitCode -eq 0 -and ($ls.Output -join '')) { $liveOnPush += $h }
149
+ }
150
+ $report.branchesWithGithubWorkflows = $liveOnPush
151
+ if ($liveOnPush.Count -gt 0) {
152
+ $warnings.Add("$($liveOnPush.Count) branch(es) already contain .github/workflows ($($liveOnPush -join ', ')). Publishing PUSHES them, which fires every push-triggered workflow - including any deploy job. Publish refuses unless you pass -AllowWorkflowRuns after reading those workflows.")
153
+ }
154
+
155
+ $otherCi = @($ciFiles | Where-Object { $_ -notmatch '^\.(gitea|github|forgejo)/workflows/' })
156
+ if ($otherCi.Count -gt 0) {
157
+ $warnings.Add("Non-Actions CI found ($($otherCi -join ', ')). It will not run on GitHub; it must be rewritten as a GitHub Actions workflow.")
158
+ }
159
+ if ($ciFiles.Count -eq 0) { $warnings.Add('No CI found. After the port nothing builds, tests or deploys this app automatically.') }
160
+
161
+ # ---- 5. Dependencies that live on another host --------------------------------------------------
162
+ Write-PortStep 'Checking dependency pins'
163
+ $depHosts = @()
164
+ foreach ($f in ($treeFiles | Where-Object { $_ -match '(^|/)(pyproject\.toml|requirements[^/]*\.txt|setup\.cfg|package\.json|\.npmrc|\.pypirc|pip\.conf|go\.mod|Cargo\.toml)$' })) {
165
+ $text = (MGit @('show', "${branch}:$f")) -join "`n"
166
+ foreach ($m in [regex]::Matches($text, '(git\+)?(https?|ssh)://(?:[^@/\s]+@)?([A-Za-z0-9.-]+(:\d+)?)/')) {
167
+ $h = $m.Groups[3].Value
168
+ if ($h -match '^(pypi\.org|files\.pythonhosted\.org|registry\.npmjs\.org|github\.com)$') {
169
+ if ($h -eq 'github.com' -and $m.Groups[1].Value) { $depHosts += [pscustomobject]@{ file = $f; host = $h } }
170
+ continue
171
+ }
172
+ $depHosts += [pscustomobject]@{ file = $f; host = $h }
173
+ }
174
+ if ($text -match '(?m)^\s*registry\s*=') { $warnings.Add("$f sets a custom package registry. CI on GitHub needs a credential for it, or the install fails.") }
175
+ }
176
+ $report.dependencyHosts = $depHosts
177
+ foreach ($h in ($depHosts | Group-Object host)) {
178
+ $warnings.Add("Dependencies pinned from $($h.Name) in: $((($h.Group.file) | Sort-Object -Unique) -join ', '). CI and every deploy host need a read credential for THAT host, chosen per host.")
179
+ }
180
+
181
+ # ---- 6. References to the old host --------------------------------------------------------------
182
+ $oldHost = $null
183
+ if ($manifest.sourceKind -eq 'url') {
184
+ if ($manifest.source -match '^[a-z]+://(?:[^@/]+@)?(?<h>[^/:]+)') { $oldHost = $Matches.h }
185
+ elseif ($manifest.source -match '^[^@\s]+@(?<h>[^:\s]+):') { $oldHost = $Matches.h } # scp form: git@host:org/repo.git
186
+ }
187
+ if ($oldHost) {
188
+ $ref = MGitTry @('grep', '-I', '-l', '-F', '-e', $oldHost, $branch)
189
+ if ($ref.ExitCode -eq 0) {
190
+ $files = @($ref.Output | ForEach-Object { $_ -replace "^${branch}:", '' })
191
+ $report.oldHostReferences = $files
192
+ $warnings.Add("$($files.Count) file(s) still name the old host $oldHost ($($files[0..([math]::Min(4, $files.Count - 1))] -join ', ')$(if ($files.Count -gt 5) { ', ...' })). Decide per file: a dependency pin (keep, needs a credential) or an origin reference (change).")
193
+ }
194
+ }
195
+
196
+ # ---- 7. Carry forward what the source step found -----------------------------------------------
197
+ foreach ($w in $manifest.warnings) { $warnings.Add($w) }
198
+
199
+ $report.blockers = @($blockers)
200
+ $report.warnings = @($warnings)
201
+ $report.ready = ($blockers.Count -eq 0)
202
+ ConvertTo-PortJsonFile $report (Join-Path $stage 'readiness.json')
203
+
204
+ Write-Host ''
205
+ Write-Host "Readiness for $Name (branch $branch): $($blockers.Count) blocker(s), $($warnings.Count) warning(s)"
206
+ foreach ($b in $blockers) { Write-PortFail $b }
207
+ foreach ($w in $warnings) { Write-PortWarn $w }
208
+ foreach ($wf in $workflows) {
209
+ if ($wf.credentialLines.Count -gt 0) {
210
+ Write-Host ''
211
+ Write-Host "Credential lines in $($wf.file) - classify EACH as ORIGIN (becomes github.token) or DEPENDENCY (keeps a token for that host):"
212
+ $wf.credentialLines | ForEach-Object { Write-Host " $_" }
213
+ }
214
+ if ($wf.runsOn.Count -gt 0) { Write-Host "Runner labels in $($wf.file): $($wf.runsOn -join ' | ') - confirm GitHub has runners with these labels." }
215
+ }
216
+ Write-Host " report: $(Join-Path $stage 'readiness.json')"
217
+ if ($blockers.Count -gt 0) { exit 1 }
218
+ exit 0
@@ -0,0 +1,62 @@
1
+ <#
2
+ .SYNOPSIS
3
+ After pushing a feature branch, prove GitHub could PARSE its workflows.
4
+
5
+ .DESCRIPTION
6
+ GitHub cannot evaluate the triggers of a workflow file it cannot parse, so an invalid file
7
+ produces a failed run with ZERO jobs ("startup_failure") on every push, even on a branch its
8
+ triggers would never match. A valid file whose triggers do not match produces no run at all.
9
+
10
+ So, for the pushed commit:
11
+ a run with conclusion startup_failure, or failure with zero jobs -> INVALID (exit 1)
12
+ no run, or runs with real jobs -> parsed (exit 0)
13
+
14
+ Waits -WaitSeconds for GitHub to react, because an invalid file's run appears within seconds
15
+ and "no run" is only meaningful after that window.
16
+ #>
17
+ [CmdletBinding()]
18
+ param(
19
+ [Parameter(Mandatory)][string]$Owner,
20
+ [Parameter(Mandatory)][string]$Repo,
21
+ [Parameter(Mandatory)][string]$Branch,
22
+ [string]$Sha,
23
+ [int]$WaitSeconds = 60
24
+ )
25
+
26
+ $ErrorActionPreference = 'Stop'
27
+ . (Join-Path $PSScriptRoot 'PortCommon.ps1')
28
+ $full = "$Owner/$Repo"
29
+
30
+ if (-not $Sha) {
31
+ $Sha = (& gh api "repos/$full/commits/$Branch" --jq .sha 2>&1)
32
+ if ($LASTEXITCODE -ne 0) { throw "Branch $Branch not found on ${full}: $Sha" }
33
+ }
34
+ Write-PortStep "Watching $full @ $($Sha.Substring(0,12)) on $Branch for $WaitSeconds s"
35
+
36
+ $deadline = (Get-Date).AddSeconds($WaitSeconds)
37
+ $invalid = @()
38
+ $runs = @()
39
+ do {
40
+ Start-Sleep -Seconds 10
41
+ $json = & gh api "repos/$full/actions/runs?head_sha=$Sha&per_page=50" 2>&1
42
+ if ($LASTEXITCODE -ne 0) { throw "Cannot list runs: $json" }
43
+ $runs = @(($json | ConvertFrom-Json).workflow_runs)
44
+ $invalid = @()
45
+ foreach ($r in $runs) {
46
+ if ($r.conclusion -eq 'startup_failure') { $invalid += $r; continue }
47
+ if ($r.status -eq 'completed' -and $r.conclusion -eq 'failure') {
48
+ $jobs = (& gh api "repos/$full/actions/runs/$($r.id)/jobs" --jq .total_count 2>$null)
49
+ if ("$jobs" -eq '0') { $invalid += $r }
50
+ }
51
+ }
52
+ if ($invalid.Count -gt 0) { break }
53
+ } while ((Get-Date) -lt $deadline)
54
+
55
+ if ($invalid.Count -gt 0) {
56
+ foreach ($r in $invalid) { Write-PortFail "INVALID workflow: '$($r.name)' ($($r.path)) - $($r.html_url)" }
57
+ Write-Host ' GitHub gives no line number. Look first for an empty ${{ }}; then bisect by removing jobs from the END (removing a middle job breaks needs: and fails the same way).'
58
+ exit 1
59
+ }
60
+ if ($runs.Count -eq 0) { Write-PortOk "No run for this commit: every workflow parsed and none matched a branch push. This is the expected result for main-only triggers." }
61
+ else { foreach ($r in $runs) { Write-PortOk "Run '$($r.name)' has real jobs ($($r.status)/$($r.conclusion)): the file parsed. $($r.html_url)" } }
62
+ exit 0
@@ -1,8 +1,8 @@
1
1
  {
2
- "poolVersion": "1.3.110",
2
+ "poolVersion": "1.3.112",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.110",
5
- "antigravity-plugin-extras": "1.3.29",
4
+ "antigravity-plugin": "1.3.112",
5
+ "antigravity-plugin-extras": "1.3.31",
6
6
  "codex": "unknown"
7
7
  }
8
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",