@asterxsk/kiln 0.1.0 → 0.2.0

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 (203) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +170 -170
  3. package/agent/AGENTS.md +67 -67
  4. package/agent/README.md +5 -5
  5. package/agent/extensions/AGENTS.md +68 -68
  6. package/agent/extensions/ask-user/index.ts +418 -418
  7. package/agent/extensions/ask-user/package-lock.json +769 -769
  8. package/agent/extensions/ask-user/package.json +19 -19
  9. package/agent/extensions/ask-user/prompt.ts +45 -45
  10. package/agent/extensions/ask-user/tsconfig.json +7 -7
  11. package/agent/extensions/background-terminals/docs/implementation-guide.md +942 -942
  12. package/agent/extensions/background-terminals/index.ts +627 -627
  13. package/agent/extensions/background-terminals/manager.test.ts +735 -735
  14. package/agent/extensions/background-terminals/output.test.ts +109 -109
  15. package/agent/extensions/background-terminals/package-lock.json +769 -769
  16. package/agent/extensions/background-terminals/package.json +17 -17
  17. package/agent/extensions/background-terminals/prompt.test.ts +125 -125
  18. package/agent/extensions/background-terminals/ps.test.ts +82 -82
  19. package/agent/extensions/background-terminals/result-delivery.test.ts +44 -44
  20. package/agent/extensions/background-terminals/src/domain.ts +87 -87
  21. package/agent/extensions/background-terminals/src/manager.ts +907 -907
  22. package/agent/extensions/background-terminals/src/output.ts +84 -84
  23. package/agent/extensions/background-terminals/src/prompt.ts +142 -142
  24. package/agent/extensions/background-terminals/src/result-delivery.ts +27 -27
  25. package/agent/extensions/background-terminals/src/runtime.ts +36 -36
  26. package/agent/extensions/background-terminals/src/ui/output-view.ts +79 -79
  27. package/agent/extensions/background-terminals/src/ui/ps.ts +621 -621
  28. package/agent/extensions/background-terminals/tsconfig.json +7 -7
  29. package/agent/extensions/file-search/index.spec.ts +443 -443
  30. package/agent/extensions/file-search/index.ts +459 -459
  31. package/agent/extensions/file-search/package-lock.json +2253 -2253
  32. package/agent/extensions/file-search/package.json +23 -23
  33. package/agent/extensions/file-search/src/args.ts +122 -122
  34. package/agent/extensions/file-search/src/binaries.ts +422 -422
  35. package/agent/extensions/file-search/src/output.ts +126 -126
  36. package/agent/extensions/file-search/src/process.ts +146 -146
  37. package/agent/extensions/file-search/src/prompt.ts +52 -52
  38. package/agent/extensions/file-search/tsconfig.json +7 -7
  39. package/agent/extensions/modelconf/PLAN.md +915 -915
  40. package/agent/extensions/modelconf/index.ts +296 -296
  41. package/agent/extensions/modelconf/src/ui/ModelConfView.ts +1101 -1101
  42. package/agent/extensions/pi-web-access/CHANGELOG.md +690 -690
  43. package/agent/extensions/pi-web-access/LICENSE +21 -21
  44. package/agent/extensions/pi-web-access/README.md +470 -470
  45. package/agent/extensions/pi-web-access/SECURITY.md +5 -5
  46. package/agent/extensions/pi-web-access/activity.ts +101 -101
  47. package/agent/extensions/pi-web-access/auth-fetch.ts +148 -148
  48. package/agent/extensions/pi-web-access/brightdata-unlocker.ts +272 -272
  49. package/agent/extensions/pi-web-access/chrome-cookies.ts +669 -669
  50. package/agent/extensions/pi-web-access/content-find.ts +139 -139
  51. package/agent/extensions/pi-web-access/credential-source.ts +191 -191
  52. package/agent/extensions/pi-web-access/data-uri-sanitize.ts +406 -406
  53. package/agent/extensions/pi-web-access/datalab-pdf-extract.ts +568 -568
  54. package/agent/extensions/pi-web-access/declared-web-links.ts +173 -173
  55. package/agent/extensions/pi-web-access/evidence/CONTRACT-EVIDENCE.md +496 -496
  56. package/agent/extensions/pi-web-access/evidence/contract-probe.mjs +140 -140
  57. package/agent/extensions/pi-web-access/exa.ts +526 -526
  58. package/agent/extensions/pi-web-access/extract.ts +1196 -1196
  59. package/agent/extensions/pi-web-access/feature-config.ts +29 -29
  60. package/agent/extensions/pi-web-access/fetch-params.ts +111 -111
  61. package/agent/extensions/pi-web-access/gemini-adc.ts +298 -298
  62. package/agent/extensions/pi-web-access/gemini-api.ts +353 -353
  63. package/agent/extensions/pi-web-access/gemini-pdf-extract.ts +108 -108
  64. package/agent/extensions/pi-web-access/gemini-url-context.ts +128 -128
  65. package/agent/extensions/pi-web-access/gemini-web-config.ts +101 -101
  66. package/agent/extensions/pi-web-access/gemini-web.ts +487 -487
  67. package/agent/extensions/pi-web-access/github-api.ts +197 -197
  68. package/agent/extensions/pi-web-access/github-extract.ts +746 -746
  69. package/agent/extensions/pi-web-access/github-issue-pr.ts +700 -700
  70. package/agent/extensions/pi-web-access/index.ts +1737 -1737
  71. package/agent/extensions/pi-web-access/package-lock.json +5808 -5808
  72. package/agent/extensions/pi-web-access/package.json +64 -64
  73. package/agent/extensions/pi-web-access/page-query.ts +96 -96
  74. package/agent/extensions/pi-web-access/pdf-extract.ts +409 -409
  75. package/agent/extensions/pi-web-access/promise-try.d.ts +7 -7
  76. package/agent/extensions/pi-web-access/query-rewrite.ts +51 -51
  77. package/agent/extensions/pi-web-access/render-search-error.ts +170 -170
  78. package/agent/extensions/pi-web-access/rsc-extract.ts +338 -338
  79. package/agent/extensions/pi-web-access/source-check.ts +282 -282
  80. package/agent/extensions/pi-web-access/ssrf-protection.ts +526 -526
  81. package/agent/extensions/pi-web-access/storage.ts +521 -521
  82. package/agent/extensions/pi-web-access/summary-model-scope.ts +125 -125
  83. package/agent/extensions/pi-web-access/test/auth-fetch.test.mjs +208 -208
  84. package/agent/extensions/pi-web-access/test/brightdata-unlocker.test.mjs +840 -840
  85. package/agent/extensions/pi-web-access/test/chrome-cookie-extraction.test.mjs +441 -441
  86. package/agent/extensions/pi-web-access/test/config-path.test.mjs +283 -283
  87. package/agent/extensions/pi-web-access/test/content-find.test.mjs +25 -25
  88. package/agent/extensions/pi-web-access/test/credential-source.test.mjs +118 -118
  89. package/agent/extensions/pi-web-access/test/data-uri-sanitize.test.mjs +210 -210
  90. package/agent/extensions/pi-web-access/test/datalab-pdf-extract.test.mjs +552 -552
  91. package/agent/extensions/pi-web-access/test/declared-web-links.test.mjs +212 -212
  92. package/agent/extensions/pi-web-access/test/fetch-answer-storage.test.mjs +40 -40
  93. package/agent/extensions/pi-web-access/test/fetch-cache-storage.test.mjs +334 -334
  94. package/agent/extensions/pi-web-access/test/fetch-content-domain-policy.test.mjs +95 -95
  95. package/agent/extensions/pi-web-access/test/fetch-modes.test.mjs +53 -53
  96. package/agent/extensions/pi-web-access/test/fetch-not-found-guidance.test.mjs +92 -92
  97. package/agent/extensions/pi-web-access/test/fetch-params.test.mjs +86 -86
  98. package/agent/extensions/pi-web-access/test/fetch-render-call.test.mjs +34 -34
  99. package/agent/extensions/pi-web-access/test/fetch-routing.test.mjs +173 -173
  100. package/agent/extensions/pi-web-access/test/gemini-adc-auth.test.mjs +257 -257
  101. package/agent/extensions/pi-web-access/test/gemini-api-transport.test.mjs +170 -170
  102. package/agent/extensions/pi-web-access/test/gemini-pdf-extract.test.mjs +133 -133
  103. package/agent/extensions/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +178 -178
  104. package/agent/extensions/pi-web-access/test/gemini-web-header-overflow.test.mjs +148 -148
  105. package/agent/extensions/pi-web-access/test/get-search-content.test.mjs +223 -223
  106. package/agent/extensions/pi-web-access/test/github-extract.test.mjs +378 -378
  107. package/agent/extensions/pi-web-access/test/github-issue-pr.test.mjs +565 -565
  108. package/agent/extensions/pi-web-access/test/inline-content-config.test.mjs +99 -99
  109. package/agent/extensions/pi-web-access/test/lazy-extract-load.test.mjs +118 -118
  110. package/agent/extensions/pi-web-access/test/local-video-oversize.test.mjs +52 -52
  111. package/agent/extensions/pi-web-access/test/package-typebox-dependency.test.mjs +50 -50
  112. package/agent/extensions/pi-web-access/test/page-query.test.mjs +51 -51
  113. package/agent/extensions/pi-web-access/test/pdf-config.test.mjs +140 -140
  114. package/agent/extensions/pi-web-access/test/pdf-extract.test.mjs +500 -500
  115. package/agent/extensions/pi-web-access/test/proxy-transport.test.mjs +286 -286
  116. package/agent/extensions/pi-web-access/test/query-rewrite.test.mjs +52 -52
  117. package/agent/extensions/pi-web-access/test/rsc-fallback.test.mjs +102 -102
  118. package/agent/extensions/pi-web-access/test/search-error-render.test.mjs +152 -152
  119. package/agent/extensions/pi-web-access/test/search-providers.test.mjs +274 -274
  120. package/agent/extensions/pi-web-access/test/source-check.test.mjs +179 -179
  121. package/agent/extensions/pi-web-access/test/ssrf-allow-ranges-config.test.mjs +205 -205
  122. package/agent/extensions/pi-web-access/test/ssrf-protection.test.mjs +456 -456
  123. package/agent/extensions/pi-web-access/test/tool-registration-config.test.mjs +182 -182
  124. package/agent/extensions/pi-web-access/test/youtube-extract-errors.test.mjs +64 -64
  125. package/agent/extensions/pi-web-access/tsconfig.json +11 -11
  126. package/agent/extensions/pi-web-access/utils.ts +451 -451
  127. package/agent/extensions/pi-web-access/video-extract.ts +392 -392
  128. package/agent/extensions/pi-web-access/youtube-extract.ts +328 -328
  129. package/agent/extensions/shared/activity-status.ts +31 -31
  130. package/agent/extensions/shared/child-session.test.ts +270 -270
  131. package/agent/extensions/shared/child-session.ts +148 -148
  132. package/agent/extensions/shared/context-utilization.test.ts +48 -48
  133. package/agent/extensions/shared/context-utilization.ts +47 -47
  134. package/agent/extensions/shared/dashboard-state.ts +99 -99
  135. package/agent/extensions/shared/tool-call-timeout.test.ts +117 -117
  136. package/agent/extensions/shared/tool-call-timeout.ts +104 -104
  137. package/agent/extensions/subagents/by-the-way.test.ts +29 -29
  138. package/agent/extensions/subagents/claude.test.ts +119 -119
  139. package/agent/extensions/subagents/codex.test.ts +102 -102
  140. package/agent/extensions/subagents/context-usage.test.ts +107 -107
  141. package/agent/extensions/subagents/docs/design-plan.md +568 -568
  142. package/agent/extensions/subagents/docs/effect-v4-extension-guide.md +354 -354
  143. package/agent/extensions/subagents/docs/effect-v4-notes.md +571 -571
  144. package/agent/extensions/subagents/index.ts +779 -779
  145. package/agent/extensions/subagents/manager.test.ts +276 -276
  146. package/agent/extensions/subagents/package-lock.json +2244 -2244
  147. package/agent/extensions/subagents/package.json +19 -19
  148. package/agent/extensions/subagents/result-delivery.test.ts +27 -27
  149. package/agent/extensions/subagents/src/backend.ts +73 -73
  150. package/agent/extensions/subagents/src/backends/claude.ts +701 -701
  151. package/agent/extensions/subagents/src/backends/codex.ts +1060 -1060
  152. package/agent/extensions/subagents/src/backends/pi.ts +575 -575
  153. package/agent/extensions/subagents/src/backends/stub.ts +300 -300
  154. package/agent/extensions/subagents/src/by-the-way.ts +21 -21
  155. package/agent/extensions/subagents/src/domain.ts +253 -253
  156. package/agent/extensions/subagents/src/format.ts +74 -74
  157. package/agent/extensions/subagents/src/manager.ts +736 -736
  158. package/agent/extensions/subagents/src/prompt.ts +92 -92
  159. package/agent/extensions/subagents/src/result-delivery.ts +20 -20
  160. package/agent/extensions/subagents/src/runtime.ts +53 -53
  161. package/agent/extensions/subagents/src/ui/takeover.ts +583 -583
  162. package/agent/extensions/subagents/src/ui/transcript.ts +201 -201
  163. package/agent/extensions/subagents/takeover.test.ts +29 -29
  164. package/agent/extensions/subagents/tsconfig.json +7 -7
  165. package/agent/extensions/todo/AGENTS.md +38 -38
  166. package/agent/extensions/todo/LICENSE +21 -21
  167. package/agent/extensions/todo/config.ts +55 -55
  168. package/agent/extensions/todo/index.ts +151 -151
  169. package/agent/extensions/todo/locales/de.json +17 -17
  170. package/agent/extensions/todo/locales/en.json +15 -15
  171. package/agent/extensions/todo/locales/es.json +17 -17
  172. package/agent/extensions/todo/locales/fr.json +17 -17
  173. package/agent/extensions/todo/locales/pt-BR.json +17 -17
  174. package/agent/extensions/todo/locales/pt.json +17 -17
  175. package/agent/extensions/todo/locales/ru.json +17 -17
  176. package/agent/extensions/todo/locales/uk.json +17 -17
  177. package/agent/extensions/todo/locales/zh.json +17 -17
  178. package/agent/extensions/todo/package-lock.json +3358 -3358
  179. package/agent/extensions/todo/package.json +67 -67
  180. package/agent/extensions/todo/state/i18n-bridge.ts +64 -64
  181. package/agent/extensions/todo/state/invariants.ts +20 -20
  182. package/agent/extensions/todo/state/replay.ts +38 -38
  183. package/agent/extensions/todo/state/selectors.ts +107 -107
  184. package/agent/extensions/todo/state/state-reducer.ts +326 -326
  185. package/agent/extensions/todo/state/state.ts +18 -18
  186. package/agent/extensions/todo/state/store.ts +82 -82
  187. package/agent/extensions/todo/state/task-graph.ts +57 -57
  188. package/agent/extensions/todo/todo-overlay.ts +200 -200
  189. package/agent/extensions/todo/todo.ts +155 -155
  190. package/agent/extensions/todo/tool/response-envelope.ts +109 -109
  191. package/agent/extensions/todo/tool/types.ts +206 -206
  192. package/agent/extensions/todo/view/format.ts +177 -177
  193. package/agent/install.ps1 +637 -527
  194. package/agent/install.sh +620 -511
  195. package/agent/keybindings.json +7 -7
  196. package/bin/kiln.js +124 -11
  197. package/package.json +8 -2
  198. package/agent/extensions/taste/index.ts +0 -443
  199. package/agent/extensions/taste/install.ps1 +0 -23
  200. package/agent/extensions/taste/install.sh +0 -21
  201. /package/agent/extensions/{status line → statusline}/index.ts +0 -0
  202. /package/agent/extensions/{status line → statusline}/install.ps1 +0 -0
  203. /package/agent/extensions/{status line → statusline}/install.sh +0 -0
package/agent/install.ps1 CHANGED
@@ -1,527 +1,637 @@
1
- #!/usr/bin/env pwsh
2
- <#
3
- .SYNOPSIS
4
- Pi custom-setup installer — Windows / PowerShell
5
-
6
- 1) Install/update pi (@earendil-works/pi-coding-agent)
7
- 2) Install pi packages (pi-context-usage, @baretread/pi-forge)
8
- 3) Install custom config (AGENTS.md, keybindings, extensions …)
9
- 4) Install per-extension npm deps via each extension's install.ps1
10
-
11
- TUI inspired by https://pi.dev/install.ps1 — spinner when TTY, silent otherwise.
12
-
13
- .EXAMPLE
14
- powershell -NoProfile -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/asterxsk/kiln/main/agent/install.ps1 -UseBasicParsing | iex"
15
- .\install.ps1 -RepoUrl https://github.com/asterxsk/kiln -Branch main
16
- .\install.ps1 -Target "$env:USERPROFILE\.pi\agent" -Yes
17
-
18
- .NOTES
19
- Safe to re-run. Managed files are force-overwritten; per-user files (settings.json, taste.md, etc.) are preserved.
20
- #>
21
-
22
- param(
23
- [string]$RepoUrl = $env:PI_CONFIG_REPO,
24
- [string]$Branch = $(if ($env:PI_CONFIG_BRANCH) { $env:PI_CONFIG_BRANCH } else { "main" }),
25
- [string]$Target = "",
26
- [switch]$SkipPi,
27
- [switch]$SkipPackages,
28
- [switch]$Yes,
29
- [switch]$Local
30
- )
31
-
32
- $ErrorActionPreference = "Stop"
33
- Set-StrictMode -Version Latest
34
-
35
- $script:ClonedTmp = $null
36
- $script:StartTime = Get-Date
37
-
38
- $PiPackage = "@earendil-works/pi-coding-agent"
39
- $NodeMinimum = [version]"22.19.0"
40
- $Esc = [char]27
41
- $Cr = [char]13
42
- $PiHome = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
43
- $PiTmp = Join-Path $PiHome ".pi/tmp"
44
- try { New-Item -ItemType Directory -Path $PiTmp -Force | Out-Null } catch {}
45
- $TmpLog = Join-Path $PiTmp ("pi-setup-" + [Guid]::NewGuid().ToString("N").Substring(0,8) + ".log")
46
- $DefaultRepo = "https://github.com/asterxsk/kiln"
47
- $env:GIT_TERMINAL_PROMPT = "0"
48
- $env:GCM_INTERACTIVE = "never"
49
- $env:GIT_ASKPASS = "echo"
50
-
51
- # Ensure UTF-8 for glyphs (match pi.dev/install.ps1)
52
- try { [Console]::OutputEncoding = [Text.Encoding]::UTF8; $OutputEncoding = [Text.Encoding]::UTF8 } catch {}
53
- try { chcp 65001 2>$null | Out-Null } catch {}
54
-
55
- # ── VT / ANSI ───────────────────────────────────────────────────────────────
56
- function Test-IsInteractive {
57
- return (-not [Console]::IsOutputRedirected) -and ($env:TERM -ne "dumb")
58
- }
59
- function Enable-VT {
60
- if (-not (Test-IsInteractive)) { return $false }
61
- # kernel32 only exists on Windows
62
- if (-not $IsWindows -and $env:OS -notmatch "Windows") { return $false }
63
- if ($null -ne $script:VTEnabled) { return $script:VTEnabled }
64
- try {
65
- if (-not ("VT.Native" -as [Type])) {
66
- Add-Type -Namespace VT -Name Native -MemberDefinition @"
67
- [DllImport("kernel32.dll", SetLastError=true)] public static extern System.IntPtr GetStdHandle(int n);
68
- [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetConsoleMode(System.IntPtr h,out uint m);
69
- [DllImport("kernel32.dll", SetLastError=true)] public static extern bool SetConsoleMode(System.IntPtr h,uint m);
70
- "@
71
- }
72
- $h = [VT.Native]::GetStdHandle(-11)
73
- [uint32]$m = 0
74
- $ok = [VT.Native]::GetConsoleMode($h,[ref]$m)
75
- if ($ok) { $ok = [VT.Native]::SetConsoleMode($h, ($m -bor 0x0004)) }
76
- $script:VTEnabled = $ok
77
- return $ok
78
- } catch { $script:VTEnabled = $false; return $false }
79
- }
80
- function Test-Ansi { return Enable-VT }
81
- $HasAnsi = Test-Ansi
82
-
83
- # ── palette (scalar vars, dot-free names; braces delimit) ───────────
84
- if ($HasAnsi) {
85
- ${creset}="$Esc[0m"; ${cbold}="$Esc[1m"; ${cdim}="$Esc[2m"; ${cgreen}="$Esc[32m"
86
- ${cyellow}="$Esc[33m"; ${cred}="$Esc[31m"; ${ccyan}="$Esc[36m"; ${cgray}="$Esc[90m"
87
- } else {
88
- ${creset}=""; ${cbold}=""; ${cdim}=""; ${cgreen}=""; ${cyellow}=""; ${cred}=""; ${ccyan}=""; ${cgray}=""
89
- }
90
-
91
- # ── title ───────────────────────────────────────────────────────────────────
92
- function Write-Title {
93
- if ($HasAnsi) {
94
- [Console]::Write("${Esc}[1m \u25C6 Pi Setup${Esc}[0m`n${Esc}[2m custom agent config \u00B7 pi + forge + extensions${Esc}[0m`n`n")
95
- } else {
96
- Write-Host ""
97
- Write-Host " Pi Setup"
98
- Write-Host " custom agent config \xB7 pi + forge + extensions"
99
- Write-Host ""
100
- }
101
- }
102
-
103
- # ── spinner ─────────────────────────────────────────────────────────────────
104
- $SpinnerFrames = @('\u280B','\u2819','\u2839','\u2838','\u283C','\u2830','\u2820','\u2826','\u2827','\u2807','\u280F')
105
- # Fallback to ASCII if font missing — pi.dev uses same
106
- $SpinnerFrames = @('⠋','⠙','⠹','⠸','⠼','⠰','⠠','⠦','⠧','⠇','⠏')
107
- $SpinnerIdx = 0
108
- function Get-Spinner { $s = $SpinnerFrames[$script:SpinnerIdx % $SpinnerFrames.Count]; $script:SpinnerIdx++; return $s }
109
-
110
- function Write-Line($text) {
111
- if ($HasAnsi) { [Console]::Write("$text`n") } else { Write-Host $text }
112
- }
113
- function Write-StepLine($step, $label, $spin) {
114
- $prefix = "${cdim}[$step/4]${creset}"
115
- $frame = if ($spin) { "${cyellow}$spin${creset}" } else { " " }
116
- $line = " $frame $prefix $label"
117
- if ($HasAnsi -and (Test-IsInteractive)) {
118
- # \r + EL (\033[K) so each frame overwrites cleanly regardless of length
119
- [Console]::Write("$Cr${Esc}[K$line")
120
- } else {
121
- Write-Host $line
122
- }
123
- }
124
- function Clear-Line {
125
- if ($HasAnsi -and (Test-IsInteractive)) { [Console]::Write("$Cr${Esc}[K") }
126
- }
127
-
128
- # ── run with spinner ────────────────────────────────────────────────────────
129
- function Invoke-NativeWithSpinner {
130
- # NOTE: the argument parameter must NOT be named $Args — $Args is an
131
- # automatic variable, and splatting @Args silently binds the (empty)
132
- # automatic instead of the parameter, dropping every argument.
133
- param([string]$Step, [string]$Label, [string]$FilePath, [string[]]$ArgumentList)
134
- # non-interactive: run synchronously, capture to log
135
- if (-not (Test-IsInteractive) -or -not $HasAnsi) {
136
- $out = & $FilePath @ArgumentList 2>&1
137
- $code = $LASTEXITCODE
138
- if ($out) { $out | ForEach-Object { Add-Content $TmpLog "$_" } }
139
- return $code
140
- }
141
- $script:SpinnerIdx = 0
142
- $psi = New-Object Diagnostics.ProcessStartInfo
143
- $psi.FileName = $FilePath
144
- # Quote args with spaces
145
- $psi.Arguments = ($ArgumentList | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join " "
146
- $psi.RedirectStandardOutput = $true
147
- $psi.RedirectStandardError = $true
148
- $psi.UseShellExecute = $false
149
- $psi.CreateNoWindow = $true
150
- $p = New-Object Diagnostics.Process
151
- $p.StartInfo = $psi
152
- [void]$p.Start()
153
- while (-not $p.HasExited) {
154
- Write-StepLine $Step $Label (Get-Spinner)
155
- Start-Sleep -Milliseconds 80
156
- }
157
- $stdout = $p.StandardOutput.ReadToEnd()
158
- $stderr = $p.StandardError.ReadToEnd()
159
- if ($stdout) { Add-Content $TmpLog $stdout }
160
- if ($stderr) { Add-Content $TmpLog $stderr }
161
- Clear-Line
162
- return $p.ExitCode
163
- }
164
-
165
- # ── helpers ─────────────────────────────────────────────────────────────────
166
- function Resolve-Target {
167
- if ($Target) { return $Target }
168
- if ($env:PI_AGENT_DIR) { return $env:PI_AGENT_DIR }
169
- if ($IsWindows -or $env:OS -match "Windows") {
170
- return (Join-Path $env:USERPROFILE ".pi\agent")
171
- }
172
- $homeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
173
- return (Join-Path $homeDir ".pi/agent")
174
- }
175
-
176
- function Resolve-RepoUrl {
177
- if ($RepoUrl) { return $RepoUrl }
178
- # When piped via irm|iex, $PSScriptRoot is empty — don't probe, use default
179
- $isPiped = -not $PSScriptRoot
180
- if (-not $isPiped) {
181
- try {
182
- $here = $PSScriptRoot
183
- if (Test-Path (Join-Path $here ".git")) {
184
- $u = (git -C $here remote get-url origin 2>$null)
185
- if ($u) { return $u.Trim() }
186
- }
187
- $parent = Split-Path $here -Parent
188
- if (Test-Path (Join-Path $parent ".git")) {
189
- $u = (git -C $parent remote get-url origin 2>$null)
190
- if ($u) { return $u.Trim() }
191
- }
192
- if (Test-Path (Join-Path (Get-Location).Path ".git")) {
193
- $u = (git remote get-url origin 2>$null)
194
- if ($u) { return $u.Trim() }
195
- }
196
- } catch {}
197
- }
198
- return $DefaultRepo
199
- }
200
-
201
- function Resolve-SourceRoot {
202
- param([string]$Repo)
203
- # Local checkout detection (only when not piped or -Local forced)
204
- $checkLocal = $Local -or $PSScriptRoot
205
- if ($checkLocal) {
206
- $roots = @()
207
- if ($PSScriptRoot) { $roots += $PSScriptRoot; $roots += (Split-Path $PSScriptRoot -Parent); $roots += (Get-Location).Path }
208
- else { $roots += (Get-Location).Path }
209
- foreach ($r in $roots) {
210
- if ((Test-Path (Join-Path $r "extensions")) -and (Test-Path (Join-Path $r "AGENTS.md"))) { return $r }
211
- if (Test-Path (Join-Path $r "agent/extensions")) { return (Join-Path $r "agent") }
212
- }
213
- if ($Local) { throw "Local checkout not found (tried: $($roots -join ', '))" }
214
- }
215
- # Clone to ~/.pi/tmp — simple git clone, no account required, then copy agent folder.
216
- # Use a unique subdir per run so concurrent installs never clobber each other.
217
- if (-not (Test-Path $PiTmp)) { New-Item -ItemType Directory -Path $PiTmp -Force | Out-Null }
218
- $tmp = Join-Path $PiTmp ("kiln-" + [Guid]::NewGuid().ToString("N").Substring(0,8))
219
- New-Item -ItemType Directory -Path $tmp -Force | Out-Null
220
- Write-Line "${cdim} → cloning $Repo (branch $Branch) → $tmp${creset}"
221
- $env:GIT_TERMINAL_PROMPT = "0"; $env:GIT_ASKPASS = "echo"; $env:GCM_INTERACTIVE = "never"
222
- $code = Invoke-NativeWithSpinner -Step "3" -Label "fetching config" -FilePath "git" -ArgumentList @("clone","--depth","1","--branch",$Branch,$Repo,$tmp)
223
- if ($code -ne 0) { throw "git clone failed (exit $code). See $TmpLog" }
224
- $script:ClonedTmp = $tmp
225
- if (Test-Path (Join-Path $tmp "agent")) { return (Join-Path $tmp "agent") }
226
- return $tmp
227
- }
228
-
229
- function Resolve-Npm {
230
- # "npm" resolves to npm.ps1 (a PowerShell shim) under Windows PowerShell.
231
- # npm.ps1 re-parses its own invocation line, which mangles arguments
232
- # passed via variables/splatting — always prefer the native npm.cmd.
233
- $cmd = Get-Command "npm.cmd" -ErrorAction SilentlyContinue
234
- if ($cmd) { return $cmd.Source }
235
- return "npm"
236
- }
237
-
238
- function Test-Node {
239
- $node = Get-Command node -ErrorAction SilentlyContinue
240
- if (-not $node) { return $null }
241
- try { $v = (node --version 2>$null).Trim().TrimStart('v').Split('-')[0]; return [version]$v } catch { return $null }
242
- }
243
-
244
- # ── MAIN ─────────────────────────────────────────────────────────────────────
245
- "" | Set-Content $TmpLog
246
- # Cleanup trap
247
- $null = Register-EngineEvent PowerShell.Exiting -Action { if ($script:ClonedTmp -and (Test-Path $script:ClonedTmp)) { Remove-Item $script:ClonedTmp -Recurse -Force -ErrorAction SilentlyContinue } } -ErrorAction SilentlyContinue
248
-
249
- Write-Title
250
- $targetDir = Resolve-Target
251
- $repoUrl = Resolve-RepoUrl
252
- $ok = $true
253
-
254
- Write-Line "${cdim} target ${creset}$targetDir"
255
- Write-Line "${cdim} repo ${creset}$repoUrl ${cdim}($Branch)${creset}"
256
- Write-Line "${cdim} log ${creset}$TmpLog`n"
257
-
258
- # ── preflight ────────────────────────────────────────────────────────────────
259
- $nodeVer = Test-Node
260
- if (-not $nodeVer) {
261
- Write-Line "${cred} ✖ Node.js not found. Install Node 22.19+ from https://nodejs.org${creset}"
262
- Write-Line " then re-run this installer.`n"
263
- exit 1
264
- }
265
- if ($nodeVer -lt $NodeMinimum) {
266
- Write-Line "${cyellow} ⚠ Node $nodeVer < $NodeMinimum — please upgrade to Node 22.19+${creset}"
267
- if (-not $Yes) { Write-Line " continue anyway in 3s…"; Start-Sleep 3 }
268
- }
269
- if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
270
- Write-Line "${cred} ✖ npm not found (Node installed but npm missing)${creset}"; exit 1
271
- }
272
- if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
273
- Write-Line "${cred} ✖ git not found — required to fetch config${creset}"
274
- Write-Line " install from https://git-scm.com/downloads`n"; exit 1
275
- }
276
- $npmVer = (npm --version 2>$null).Trim()
277
- $gitVer = (git --version 2>$null).Trim()
278
- Write-Line "${cdim} node $nodeVer · npm $npmVer · $gitVer${creset}`n"
279
- $NpmBin = Resolve-Npm
280
-
281
- # ── [1/4] pi ────────────────────────────────────────────────────────────────
282
- if ($SkipPi) {
283
- Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} Install pi ${cdim}— skipped${creset}"
284
- } else {
285
- $hasPi = $null -ne (Get-Command pi -ErrorAction SilentlyContinue)
286
- $label = if ($hasPi) { "updating pi ($PiPackage)" } else { "installing pi ($PiPackage)" }
287
- $code = Invoke-NativeWithSpinner -Step "1" -Label $label -FilePath $NpmBin -ArgumentList @("install","-g","$PiPackage@latest","--no-audit","--no-fund","--min-release-age=0")
288
- if ($code -eq 0) {
289
- $piVer = try { (pi --version 2>$null).Trim() } catch { "?" }
290
- Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} $label ${cdim}— $piVer${creset}"
291
- } else {
292
- $isPerm = Select-String -Path $TmpLog -Pattern "EACCES|permission" -Quiet -ErrorAction SilentlyContinue
293
- if ($isPerm) {
294
- Write-Line " ${cred}✖${creset} ${cdim}[1/4]${creset} $label ${cred}permission denied${creset}"
295
- Write-Line " ${cdim}try: npm config set prefix ~/.npm-global or run as admin${creset}"
296
- } else {
297
- Write-Line " ${cred}✖${creset} ${cdim}[1/4]${creset} $label ${cred}failed (exit $code)${creset} ${cdim}see $TmpLog${creset}"
298
- }
299
- $ok = $false
300
- }
301
- }
302
-
303
- # ── [2/4] pi packages ─────────────────────────────────────────────────────
304
- if ($SkipPackages) {
305
- Write-Line " ${cgreen}✔${creset} ${cdim}[2/4]${creset} pi packages ${cdim}— skipped${creset}"
306
- } else {
307
- $label = "installing pi-context-usage + @baretread/pi-forge"
308
- $code = Invoke-NativeWithSpinner -Step "2" -Label $label -FilePath $NpmBin -ArgumentList @("install","-g","pi-context-usage@latest","@baretread/pi-forge@latest","--no-audit","--no-fund")
309
- if ($code -eq 0) {
310
- # Verify global ls (handles mise/nvm prefix quirks)
311
- $hasCtx = $null -ne (npm ls -g pi-context-usage 2>$null | Select-String "pi-context-usage")
312
- $hasForge = $null -ne (npm ls -g "@baretread/pi-forge" 2>$null | Select-String "pi-forge")
313
- if (-not $hasCtx -or -not $hasForge) {
314
- Write-Line " ${cyellow}⚠${creset} ${cdim}[2/4]${creset} pi packages installed but not in global ls — continuing${creset}"
315
- }
316
- Write-Line " ${cgreen}✔${creset} ${cdim}[2/4]${creset} pi packages ${cdim}— done${creset}"
317
- } else {
318
- Write-Line " ${cyellow}⚠${creset} ${cdim}[2/4]${creset} $label ${cyellow}exit $code — continuing (pi will auto-install)${creset}"
319
- Write-Line " ${cdim}see $TmpLog${creset}"
320
- }
321
- }
322
-
323
- # ── [3/4] custom config ───────────────────────────────────────────────────
324
- try {
325
- $sourceRoot = Resolve-SourceRoot -Repo $repoUrl
326
- Write-Line "${cdim} source $sourceRoot${creset}"
327
- if (-not (Test-Path $sourceRoot)) { throw "source not found: $sourceRoot" }
328
-
329
- if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
330
-
331
- # backup (managed files only: extensions + top-level config files).
332
- # Per-user data (settings.json, auth.json, sessions/, taste/, etc.) is never
333
- # touched by the installer, so it is deliberately NOT backed up.
334
- $hasExisting = (Test-Path (Join-Path $targetDir "AGENTS.md")) -or (Test-Path (Join-Path $targetDir "extensions"))
335
- if ($hasExisting) {
336
- $bak = "$targetDir.bak.$(Get-Date -Format yyyyMMdd-HHmmss)"
337
- Write-Line "${cdim} backup $targetDir/extensions → $bak${creset}"
338
- New-Item -ItemType Directory -Path $bak -Force | Out-Null
339
- foreach ($f in @("AGENTS.md","keybindings.json","README.md")) {
340
- $s = Join-Path $targetDir $f
341
- if (Test-Path $s) { Copy-Item $s (Join-Path $bak $f) -Force -ErrorAction SilentlyContinue }
342
- }
343
- $srcBakExt = Join-Path $targetDir "extensions"
344
- if (Test-Path $srcBakExt) {
345
- # NOTE: Copy-Item -Exclude only filters top-level items, not recursive
346
- # contents — copy per-extension while skipping node_modules/.git instead.
347
- $dstBakExt = Join-Path $bak "extensions"
348
- Get-ChildItem $srcBakExt -Directory | ForEach-Object {
349
- $d = Join-Path $dstBakExt $_.Name
350
- New-Item -ItemType Directory -Path $d -Force | Out-Null
351
- Get-ChildItem $_.FullName -Exclude @("node_modules",".git","bin") | ForEach-Object {
352
- Copy-Item $_.FullName $d -Recurse -Force -ErrorAction SilentlyContinue
353
- }
354
- }
355
- }
356
- }
357
-
358
- $copied = 0
359
- $isSelfInstall = ($sourceRoot -eq $targetDir)
360
- if ($isSelfInstall) { Write-Line "${cdim} source == target — skipping file copy (self-install)${creset}" }
361
- foreach ($f in @("AGENTS.md","keybindings.json","README.md")) {
362
- $s = Join-Path $sourceRoot $f
363
- if (-not (Test-Path $s)) { continue }
364
- $d = Join-Path $targetDir $f
365
- if ($isSelfInstall -or $s -eq $d) { $copied++; continue }
366
- Copy-Item $s $d -Force; $copied++
367
- }
368
- # settings.json: seed from repo defaults if missing; an existing file is never modified.
369
- $repoSettings = Join-Path $sourceRoot "settings.json"
370
- $settings = Join-Path $targetDir "settings.json"
371
- $settingsJustCreated = $false
372
- if ((-not (Test-Path $settings)) -and (Test-Path $repoSettings)) {
373
- Copy-Item $repoSettings $settings
374
- $settingsJustCreated = $true
375
- Write-Line "${cdim} created settings.json from repo defaults${creset}"
376
- } elseif (Test-Path $settings) {
377
- Write-Line "${cdim} kept existing settings.json${creset}"
378
- }
379
- # ensure Pi extensions (forge + context) are registered — only on a freshly
380
- # seeded settings.json; an existing file is left completely untouched.
381
- if ($settingsJustCreated -and (Test-Path $settings) -and (Get-Command node -ErrorAction SilentlyContinue)) {
382
- try {
383
- $patch = "const fs=require('fs');const p=process.argv[1];try{let j=JSON.parse(fs.readFileSync(p,'utf8'));let need=['npm:pi-context-usage','npm:@baretread/pi-forge'];j.packages=j.packages||[];let c=false;for(let pkg of need){if(!j.packages.includes(pkg)){j.packages.push(pkg);c=true}}if(c){fs.writeFileSync(p,JSON.stringify(j,null,2)+'\\n');console.log(' patched settings.json packages -> forge + context') } }catch(e){}"
384
- node -e $patch $settings 2>$null | ForEach-Object { Write-Line "${cdim} $_${creset}" }
385
- } catch {}
386
- }
387
- # taste.md / taste/ — user-specific, preserve if exists
388
- foreach ($preserve in @("taste.md","taste","taste.json")) {
389
- $p = Join-Path $targetDir $preserve
390
- $src = Join-Path $sourceRoot $preserve
391
- if (Test-Path $p) {
392
- Write-Line "${cdim} kept existing $preserve${creset}"
393
- } elseif (Test-Path $src) {
394
- Copy-Item $src $p -Recurse -Force -Exclude @("node_modules",".git") -ErrorAction SilentlyContinue
395
- }
396
- }
397
-
398
- # extensions — skip stray .md files, handle spaces
399
- $srcExt = Join-Path $sourceRoot "extensions"
400
- $dstExt = Join-Path $targetDir "extensions"
401
- if (Test-Path $srcExt) {
402
- if ($isSelfInstall -or $srcExt -eq $dstExt) {
403
- # Self-install: extensions already in place — just count, don't delete/re-copy
404
- Get-ChildItem $srcExt -Directory | Where-Object { $_.Name -notlike "*.md" } | ForEach-Object { $copied++ }
405
- } else {
406
- if (-not (Test-Path $dstExt)) { New-Item -ItemType Directory -Path $dstExt -Force | Out-Null }
407
- Get-ChildItem $srcExt -Directory | ForEach-Object {
408
- if ($_.Name -like "*.md") { return }
409
- $dst = Join-Path $dstExt $_.Name
410
- if (Test-Path $dst) { Remove-Item $dst -Recurse -Force -ErrorAction SilentlyContinue }
411
- # NOTE: Copy-Item -Exclude only filters top-level items, not recursive
412
- # contents — copy per-item while skipping node_modules/.git instead.
413
- # (node_modules are rebuilt by each extension's installer in step 4;
414
- # copying them follows junctions and stalls the install.)
415
- New-Item -ItemType Directory -Path $dst -Force | Out-Null
416
- Get-ChildItem $_.FullName -Exclude @("node_modules",".git") | ForEach-Object {
417
- Copy-Item $_.FullName $dst -Recurse -Force -ErrorAction SilentlyContinue
418
- }
419
- $copied++
420
- }
421
- }
422
- }
423
-
424
- # themes — exclude recursive .pi and nul device file
425
- $srcThemes = Join-Path $sourceRoot "themes"
426
- if ((Test-Path $srcThemes) -and -not $isSelfInstall -and $srcThemes -ne (Join-Path $targetDir "themes")) {
427
- $dstThemes = Join-Path $targetDir "themes"
428
- if (Test-Path $dstThemes) { Remove-Item $dstThemes -Recurse -Force -ErrorAction SilentlyContinue }
429
- New-Item -ItemType Directory -Path $dstThemes -Force | Out-Null
430
- Get-ChildItem $srcThemes -Force | Where-Object { $_.Name -notin @(".pi","nul") } | ForEach-Object {
431
- Copy-Item $_.FullName (Join-Path $dstThemes $_.Name) -Recurse -Force
432
- }
433
- }
434
-
435
- Write-Line " ${cgreen}✔${creset} ${cdim}[3/4]${creset} custom config ${cdim}— $copied items → $targetDir${creset}"
436
- # cleanup cloned temp — per spec: clone into ~/.pi/tmp, copy agent, delete temp
437
- if ($script:ClonedTmp -and (Test-Path $script:ClonedTmp)) {
438
- if ($script:ClonedTmp.StartsWith($PiTmp, [StringComparison]::OrdinalIgnoreCase)) {
439
- Remove-Item $script:ClonedTmp -Recurse -Force -ErrorAction SilentlyContinue
440
- $script:ClonedTmp = $null
441
- }
442
- }
443
- # repo README → alongside the agent dir (e.g. ~/.pi/README.md)
444
- $repoRoot = Split-Path $sourceRoot -Parent
445
- $targetParent = Split-Path $targetDir -Parent
446
- $repoReadme = Join-Path $repoRoot "README.md"
447
- if ((Test-Path $repoReadme) -and ($repoRoot -ne $targetParent)) {
448
- Copy-Item $repoReadme (Join-Path $targetParent "README.md") -Force -ErrorAction SilentlyContinue
449
- }
450
- } catch {
451
- Write-Line " ${cred}✖${creset} ${cdim}[3/4]${creset} custom config ${cred}failed:${creset} $($_.Exception.Message)"
452
- Write-Line " ${cdim}$($_.ScriptStackTrace)${creset}"
453
- Write-Line " ${cdim}see $TmpLog${creset}"
454
- $ok = $false
455
- }
456
-
457
- # ── [4/4] per-extension deps ──────────────────────────────────────────────
458
- try {
459
- $extRoot = Join-Path $targetDir "extensions"
460
- $exts = @()
461
- if (Test-Path $extRoot) { $exts = Get-ChildItem $extRoot -Directory | Where-Object { $_.Name -notlike "*.md" } | Sort-Object Name }
462
-
463
- if ($exts.Count -eq 0) {
464
- Write-Line " ${cyellow}⚠${creset} ${cdim}[4/4]${creset} no extensions found at $extRoot"
465
- } else {
466
- $failed = @()
467
- $installed = 0
468
- foreach ($ext in $exts) {
469
- $label = "extensions/$($ext.Name)"
470
- $ps1 = Join-Path $ext.FullName "install.ps1"
471
- $sh = Join-Path $ext.FullName "install.sh"
472
- $hasPkg = Test-Path (Join-Path $ext.FullName "package.json")
473
-
474
- if (-not $hasPkg -and -not (Test-Path $ps1)) {
475
- Write-Line " ${cdim} · $label — no package.json, skipping${creset}"
476
- continue
477
- }
478
-
479
- if (Test-Path $ps1) {
480
- # prefer pwsh, fallback to powershell
481
- $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue
482
- if ($pwsh) { $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath $pwsh.Source -ArgumentList @("-NoProfile","-File",$ps1) }
483
- else { $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath "powershell" -ArgumentList @("-NoProfile","-ExecutionPolicy","Bypass","-File",$ps1) }
484
- } elseif (Test-Path $sh) {
485
- $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath "sh" -ArgumentList @($sh)
486
- } else {
487
- $npmArgs = if (Test-Path (Join-Path $ext.FullName "package-lock.json")) { @("--prefix",$ext.FullName,"ci","--no-audit","--no-fund") } else { @("--prefix",$ext.FullName,"install","--no-audit","--no-fund") }
488
- $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath $NpmBin -ArgumentList $npmArgs
489
- }
490
-
491
- if ($code -eq 0) {
492
- Write-Line " ${cgreen}✔${creset} ${cdim}[4/4]${creset} $label"
493
- $installed++
494
- } else {
495
- Write-Line " ${cred}✖${creset} ${cdim}[4/4]${creset} $label ${cred}failed (exit $code)${creset}"
496
- $failed += $ext.Name
497
- }
498
- }
499
- if ($failed.Count -eq 0) {
500
- Write-Line " ${cgreen}✔${creset} ${cdim}[4/4]${creset} extension deps ${cdim}— $installed installed${creset}"
501
- } else {
502
- Write-Line " ${cyellow}⚠${creset} ${cdim}[4/4]${creset} extension deps ${cyellow}$($failed.Count) failed:${creset} $($failed -join ', ') ${cdim}see $TmpLog${creset}"
503
- }
504
- }
505
- } catch {
506
- Write-Line " ${cred}✖${creset} ${cdim}[4/4]${creset} extension deps ${cred}failed:${creset} $($_.Exception.Message)"
507
- $ok = $false
508
- }
509
-
510
- # ── cleanup + summary ─────────────────────────────────────────────────────
511
- if ($script:ClonedTmp -and (Test-Path $script:ClonedTmp)) {
512
- Remove-Item $script:ClonedTmp -Recurse -Force -ErrorAction SilentlyContinue
513
- }
514
-
515
- $elapsed = (Get-Date) - $script:StartTime
516
- if ($elapsed.TotalSeconds -ge 60) { $elapsedFmt = "{0}m {1}s" -f [int]$elapsed.TotalMinutes, $elapsed.Seconds }
517
- else { $elapsedFmt = "{0}s" -f [int]$elapsed.TotalSeconds }
518
-
519
- Write-Line ""
520
- if ($ok) {
521
- Write-Line "${cgreen}${cbold} ✓ Setup complete${creset} ${cdim}→ $targetDir · Kiln installed in $elapsedFmt${creset}"
522
- $piVer2 = try { (pi --version 2>$null).Trim() } catch { $null }
523
- if ($piVer2) { Write-Line "${cdim} pi $piVer2 · run: pi${creset}" }
524
- } else {
525
- Write-Line "${cyellow}${cbold} ⚠ Setup finished with warnings${creset} ${cdim}in $elapsedFmt · see $TmpLog${creset}"
526
- }
527
- Write-Line "${cdim} log: $TmpLog${creset}`n"
1
+ #!/usr/bin/env pwsh
2
+ <#
3
+ .SYNOPSIS
4
+ Pi custom-setup installer — Windows / PowerShell
5
+
6
+ 1) Install/update pi (@earendil-works/pi-coding-agent)
7
+ 2) Install pi packages (pi-context-usage, @baretread/pi-forge)
8
+ 3) Install custom config (AGENTS.md, keybindings, extensions …)
9
+ 4) Install per-extension npm deps via each extension's install.ps1
10
+
11
+ TUI inspired by https://pi.dev/install.ps1 — spinner when TTY, silent otherwise.
12
+
13
+ .EXAMPLE
14
+ powershell -NoProfile -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/asterxsk/kiln/main/agent/install.ps1 -UseBasicParsing | iex"
15
+ .\install.ps1 -RepoUrl https://github.com/asterxsk/kiln -Branch main
16
+ .\install.ps1 -Target "$env:USERPROFILE\.pi\agent" -Yes
17
+ .\install.ps1 -OverwriteSettings # replace settings.json (asks by default)
18
+
19
+ .NOTES
20
+ Safe to re-run. Managed files are force-overwritten; per-user files (settings.json, taste.md, etc.) are preserved
21
+ unless -OverwriteSettings is passed or you answer Y when asked.
22
+ Already-installed items are skipped; outdated global packages are updated.
23
+ Only one timestamped backup (<target>.bak.*) is kept; the clone temp dir
24
+ under ~/.pi/tmp/kiln-* is always removed.
25
+ #>
26
+
27
+ param(
28
+ [string]$RepoUrl = $env:PI_CONFIG_REPO,
29
+ [string]$Branch = $(if ($env:PI_CONFIG_BRANCH) { $env:PI_CONFIG_BRANCH } else { "main" }),
30
+ [string]$Target = "",
31
+ [switch]$SkipPi,
32
+ [switch]$SkipPackages,
33
+ [switch]$Yes,
34
+ [switch]$Local,
35
+ [switch]$OverwriteSettings,
36
+ [switch]$KeepSettings
37
+ )
38
+
39
+ $ErrorActionPreference = "Stop"
40
+ Set-StrictMode -Version Latest
41
+
42
+ $script:ClonedTmp = $null
43
+ $script:StartTime = Get-Date
44
+
45
+ $PiPackage = "@earendil-works/pi-coding-agent"
46
+ $NodeMinimum = [version]"22.19.0"
47
+ $Esc = [char]27
48
+ $Cr = [char]13
49
+ $PiHome = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
50
+ $PiTmp = Join-Path $PiHome ".pi/tmp"
51
+ try { New-Item -ItemType Directory -Path $PiTmp -Force | Out-Null } catch {}
52
+ $TmpLog = Join-Path $PiTmp ("pi-setup-" + [Guid]::NewGuid().ToString("N").Substring(0,8) + ".log")
53
+ $DefaultRepo = "https://github.com/asterxsk/kiln"
54
+ $env:GIT_TERMINAL_PROMPT = "0"
55
+ $env:GCM_INTERACTIVE = "never"
56
+ $env:GIT_ASKPASS = "echo"
57
+
58
+ # Ensure UTF-8 for glyphs (match pi.dev/install.ps1)
59
+ try { [Console]::OutputEncoding = [Text.Encoding]::UTF8; $OutputEncoding = [Text.Encoding]::UTF8 } catch {}
60
+ try { chcp 65001 2>$null | Out-Null } catch {}
61
+
62
+ # ── VT / ANSI ───────────────────────────────────────────────────────────────
63
+ function Test-IsInteractive {
64
+ return (-not [Console]::IsOutputRedirected) -and ($env:TERM -ne "dumb")
65
+ }
66
+ function Enable-VT {
67
+ if (-not (Test-IsInteractive)) { return $false }
68
+ # kernel32 only exists on Windows
69
+ if (-not $IsWindows -and $env:OS -notmatch "Windows") { return $false }
70
+ if ($null -ne $script:VTEnabled) { return $script:VTEnabled }
71
+ try {
72
+ if (-not ("VT.Native" -as [Type])) {
73
+ Add-Type -Namespace VT -Name Native -MemberDefinition @"
74
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern System.IntPtr GetStdHandle(int n);
75
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetConsoleMode(System.IntPtr h,out uint m);
76
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool SetConsoleMode(System.IntPtr h,uint m);
77
+ "@
78
+ }
79
+ $h = [VT.Native]::GetStdHandle(-11)
80
+ [uint32]$m = 0
81
+ $ok = [VT.Native]::GetConsoleMode($h,[ref]$m)
82
+ if ($ok) { $ok = [VT.Native]::SetConsoleMode($h, ($m -bor 0x0004)) }
83
+ $script:VTEnabled = $ok
84
+ return $ok
85
+ } catch { $script:VTEnabled = $false; return $false }
86
+ }
87
+ function Test-Ansi { return Enable-VT }
88
+ $HasAnsi = Test-Ansi
89
+
90
+ # ── palette (scalar vars, dot-free names; braces delimit) ───────────
91
+ if ($HasAnsi) {
92
+ ${creset}="$Esc[0m"; ${cbold}="$Esc[1m"; ${cdim}="$Esc[2m"; ${cgreen}="$Esc[32m"
93
+ ${cyellow}="$Esc[33m"; ${cred}="$Esc[31m"; ${ccyan}="$Esc[36m"; ${cgray}="$Esc[90m"
94
+ } else {
95
+ ${creset}=""; ${cbold}=""; ${cdim}=""; ${cgreen}=""; ${cyellow}=""; ${cred}=""; ${ccyan}=""; ${cgray}=""
96
+ }
97
+
98
+ # ── title ───────────────────────────────────────────────────────────────────
99
+ function Write-Title {
100
+ if ($HasAnsi) {
101
+ [Console]::Write("${Esc}[1m \u25C6 Pi Setup${Esc}[0m`n${Esc}[2m custom agent config \u00B7 pi + forge + extensions${Esc}[0m`n`n")
102
+ } else {
103
+ Write-Host ""
104
+ Write-Host " Pi Setup"
105
+ Write-Host " custom agent config \xB7 pi + forge + extensions"
106
+ Write-Host ""
107
+ }
108
+ }
109
+
110
+ # ── spinner ─────────────────────────────────────────────────────────────────
111
+ $SpinnerFrames = @('\u280B','\u2819','\u2839','\u2838','\u283C','\u2830','\u2820','\u2826','\u2827','\u2807','\u280F')
112
+ # Fallback to ASCII if font missing — pi.dev uses same
113
+ $SpinnerFrames = @('⠋','⠙','⠹','⠸','⠼','⠰','⠠','⠦','⠧','⠇','⠏')
114
+ $SpinnerIdx = 0
115
+ function Get-Spinner { $s = $SpinnerFrames[$script:SpinnerIdx % $SpinnerFrames.Count]; $script:SpinnerIdx++; return $s }
116
+
117
+ function Write-Line($text) {
118
+ if ($HasAnsi) { [Console]::Write("$text`n") } else { Write-Host $text }
119
+ }
120
+ function Write-StepLine($step, $label, $spin) {
121
+ $prefix = "${cdim}[$step/4]${creset}"
122
+ $frame = if ($spin) { "${cyellow}$spin${creset}" } else { " " }
123
+ $line = " $frame $prefix $label"
124
+ if ($HasAnsi -and (Test-IsInteractive)) {
125
+ # \r + EL (\033[K) so each frame overwrites cleanly regardless of length
126
+ [Console]::Write("$Cr${Esc}[K$line")
127
+ } else {
128
+ Write-Host $line
129
+ }
130
+ }
131
+ function Clear-Line {
132
+ if ($HasAnsi -and (Test-IsInteractive)) { [Console]::Write("$Cr${Esc}[K") }
133
+ }
134
+
135
+ # ── run with spinner ────────────────────────────────────────────────────────
136
+ function Invoke-NativeWithSpinner {
137
+ # NOTE: the argument parameter must NOT be named $Args — $Args is an
138
+ # automatic variable, and splatting @Args silently binds the (empty)
139
+ # automatic instead of the parameter, dropping every argument.
140
+ param([string]$Step, [string]$Label, [string]$FilePath, [string[]]$ArgumentList)
141
+ # non-interactive: run synchronously, capture to log
142
+ if (-not (Test-IsInteractive) -or -not $HasAnsi) {
143
+ $out = & $FilePath @ArgumentList 2>&1
144
+ $code = $LASTEXITCODE
145
+ if ($out) { $out | ForEach-Object { Add-Content $TmpLog "$_" } }
146
+ return $code
147
+ }
148
+ $script:SpinnerIdx = 0
149
+ $psi = New-Object Diagnostics.ProcessStartInfo
150
+ $psi.FileName = $FilePath
151
+ # Quote args with spaces
152
+ $psi.Arguments = ($ArgumentList | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join " "
153
+ $psi.RedirectStandardOutput = $true
154
+ $psi.RedirectStandardError = $true
155
+ $psi.UseShellExecute = $false
156
+ $psi.CreateNoWindow = $true
157
+ $p = New-Object Diagnostics.Process
158
+ $p.StartInfo = $psi
159
+ [void]$p.Start()
160
+ while (-not $p.HasExited) {
161
+ Write-StepLine $Step $Label (Get-Spinner)
162
+ Start-Sleep -Milliseconds 80
163
+ }
164
+ $stdout = $p.StandardOutput.ReadToEnd()
165
+ $stderr = $p.StandardError.ReadToEnd()
166
+ if ($stdout) { Add-Content $TmpLog $stdout }
167
+ if ($stderr) { Add-Content $TmpLog $stderr }
168
+ Clear-Line
169
+ return $p.ExitCode
170
+ }
171
+
172
+ # ── helpers ─────────────────────────────────────────────────────────────────
173
+ function Resolve-Target {
174
+ if ($Target) { return $Target }
175
+ if ($env:PI_AGENT_DIR) { return $env:PI_AGENT_DIR }
176
+ if ($IsWindows -or $env:OS -match "Windows") {
177
+ return (Join-Path $env:USERPROFILE ".pi\agent")
178
+ }
179
+ $homeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
180
+ return (Join-Path $homeDir ".pi/agent")
181
+ }
182
+
183
+ function Resolve-RepoUrl {
184
+ if ($RepoUrl) { return $RepoUrl }
185
+ # When piped via irm|iex, $PSScriptRoot is empty — don't probe, use default
186
+ $isPiped = -not $PSScriptRoot
187
+ if (-not $isPiped) {
188
+ try {
189
+ $here = $PSScriptRoot
190
+ if (Test-Path (Join-Path $here ".git")) {
191
+ $u = (git -C $here remote get-url origin 2>$null)
192
+ if ($u) { return $u.Trim() }
193
+ }
194
+ $parent = Split-Path $here -Parent
195
+ if (Test-Path (Join-Path $parent ".git")) {
196
+ $u = (git -C $parent remote get-url origin 2>$null)
197
+ if ($u) { return $u.Trim() }
198
+ }
199
+ if (Test-Path (Join-Path (Get-Location).Path ".git")) {
200
+ $u = (git remote get-url origin 2>$null)
201
+ if ($u) { return $u.Trim() }
202
+ }
203
+ } catch {}
204
+ }
205
+ return $DefaultRepo
206
+ }
207
+
208
+ function Resolve-SourceRoot {
209
+ param([string]$Repo)
210
+ # Local checkout detection (only when not piped or -Local forced)
211
+ $checkLocal = $Local -or $PSScriptRoot
212
+ if ($checkLocal) {
213
+ $roots = @()
214
+ if ($PSScriptRoot) { $roots += $PSScriptRoot; $roots += (Split-Path $PSScriptRoot -Parent); $roots += (Get-Location).Path }
215
+ else { $roots += (Get-Location).Path }
216
+ foreach ($r in $roots) {
217
+ if ((Test-Path (Join-Path $r "extensions")) -and (Test-Path (Join-Path $r "AGENTS.md"))) { return $r }
218
+ if (Test-Path (Join-Path $r "agent/extensions")) { return (Join-Path $r "agent") }
219
+ }
220
+ if ($Local) { throw "Local checkout not found (tried: $($roots -join ', '))" }
221
+ }
222
+ # Clone to ~/.pi/tmp — simple git clone, no account required, then copy agent folder.
223
+ # Use a unique subdir per run so concurrent installs never clobber each other.
224
+ if (-not (Test-Path $PiTmp)) { New-Item -ItemType Directory -Path $PiTmp -Force | Out-Null }
225
+ $tmp = Join-Path $PiTmp ("kiln-" + [Guid]::NewGuid().ToString("N").Substring(0,8))
226
+ New-Item -ItemType Directory -Path $tmp -Force | Out-Null
227
+ Write-Line "${cdim} → cloning $Repo (branch $Branch) → $tmp${creset}"
228
+ $env:GIT_TERMINAL_PROMPT = "0"; $env:GIT_ASKPASS = "echo"; $env:GCM_INTERACTIVE = "never"
229
+ $code = Invoke-NativeWithSpinner -Step "3" -Label "fetching config" -FilePath "git" -ArgumentList @("clone","--depth","1","--branch",$Branch,$Repo,$tmp)
230
+ if ($code -ne 0) { throw "git clone failed (exit $code). See $TmpLog" }
231
+ $script:ClonedTmp = $tmp
232
+ if (Test-Path (Join-Path $tmp "agent")) { return (Join-Path $tmp "agent") }
233
+ return $tmp
234
+ }
235
+
236
+ function Resolve-Npm {
237
+ # "npm" resolves to npm.ps1 (a PowerShell shim) under Windows PowerShell.
238
+ # npm.ps1 re-parses its own invocation line, which mangles arguments
239
+ # passed via variables/splatting — always prefer the native npm.cmd.
240
+ $cmd = Get-Command "npm.cmd" -ErrorAction SilentlyContinue
241
+ if ($cmd) { return $cmd.Source }
242
+ return "npm"
243
+ }
244
+
245
+ function Test-Node {
246
+ $node = Get-Command node -ErrorAction SilentlyContinue
247
+ if (-not $node) { return $null }
248
+ try { $v = (node --version 2>$null).Trim().TrimStart('v').Split('-')[0]; return [version]$v } catch { return $null }
249
+ }
250
+
251
+ # Installed global version of a package ("" when not installed).
252
+ function Get-GlobalPkgVersion {
253
+ param([string]$Name)
254
+ try {
255
+ $bin = Resolve-Npm
256
+ $out = & $bin ls -g $Name --depth=0 2>$null | Out-String
257
+ $m = [regex]::Match($out, [regex]::Escape($Name) + '@(\S+)')
258
+ if ($m.Success) { return ($m.Groups[1].Value -replace '[^0-9A-Za-z.+-].*$','') }
259
+ } catch {}
260
+ return ""
261
+ }
262
+ # Latest registry version ("" when offline / unknown).
263
+ function Get-LatestPkgVersion {
264
+ param([string]$Name)
265
+ try {
266
+ $bin = Resolve-Npm
267
+ $v = ((& $bin view $Name version 2>$null | Out-String).Trim() -split '\s+')[0]
268
+ if ($v -match '^[0-9][0-9A-Za-z.+-]*$') { return $v }
269
+ } catch {}
270
+ return ""
271
+ }
272
+ # Returns $true when the user wants settings.json overwritten.
273
+ function Test-WantOverwriteSettings {
274
+ if ($OverwriteSettings) { return $true }
275
+ if ($KeepSettings) { return $false }
276
+ if ($Yes) { return $false }
277
+ try {
278
+ $ans = Read-Host " settings.json exists. Overwrite with repo defaults? [y/N] (Recommended for first-time setup)"
279
+ return ($ans -eq 'y' -or $ans -eq 'Y' -or $ans -eq 'yes' -or $ans -eq 'YES')
280
+ } catch { return $false }
281
+ }
282
+
283
+ # ── MAIN ─────────────────────────────────────────────────────────────────────
284
+ "" | Set-Content $TmpLog
285
+ # Cleanup trap
286
+ $null = Register-EngineEvent PowerShell.Exiting -Action { if ($script:ClonedTmp -and (Test-Path $script:ClonedTmp)) { Remove-Item $script:ClonedTmp -Recurse -Force -ErrorAction SilentlyContinue } } -ErrorAction SilentlyContinue
287
+
288
+ Write-Title
289
+ $targetDir = Resolve-Target
290
+ $repoUrl = Resolve-RepoUrl
291
+ $ok = $true
292
+
293
+ Write-Line "${cdim} target ${creset}$targetDir"
294
+ Write-Line "${cdim} repo ${creset}$repoUrl ${cdim}($Branch)${creset}"
295
+ Write-Line "${cdim} log ${creset}$TmpLog`n"
296
+
297
+ # ── preflight ────────────────────────────────────────────────────────────────
298
+ $nodeVer = Test-Node
299
+ if (-not $nodeVer) {
300
+ Write-Line "${cred} ✖ Node.js not found. Install Node 22.19+ from https://nodejs.org${creset}"
301
+ Write-Line " then re-run this installer.`n"
302
+ exit 1
303
+ }
304
+ if ($nodeVer -lt $NodeMinimum) {
305
+ Write-Line "${cyellow} ⚠ Node $nodeVer < $NodeMinimum — please upgrade to Node 22.19+${creset}"
306
+ if (-not $Yes) { Write-Line " continue anyway in 3s…"; Start-Sleep 3 }
307
+ }
308
+ if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
309
+ Write-Line "${cred} ✖ npm not found (Node installed but npm missing)${creset}"; exit 1
310
+ }
311
+ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
312
+ Write-Line "${cred} ✖ git not found — required to fetch config${creset}"
313
+ Write-Line " install from https://git-scm.com/downloads`n"; exit 1
314
+ }
315
+ $npmVer = (npm --version 2>$null).Trim()
316
+ $gitVer = (git --version 2>$null).Trim()
317
+ Write-Line "${cdim} node $nodeVer · npm $npmVer · $gitVer${creset}`n"
318
+ $NpmBin = Resolve-Npm
319
+
320
+ # ── [1/4] pi ────────────────────────────────────────────────────────────────
321
+ if ($SkipPi) {
322
+ Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} Install pi ${cdim}— skipped${creset}"
323
+ } elseif (Get-Command pi -ErrorAction SilentlyContinue) {
324
+ $piInstalled = Get-GlobalPkgVersion $PiPackage
325
+ if (-not $piInstalled) { $piInstalled = try { (pi --version 2>$null).Trim().TrimStart('v') } catch { "" } }
326
+ $piLatest = Get-LatestPkgVersion $PiPackage
327
+ if ($piInstalled -and $piLatest -and ($piInstalled -eq $piLatest)) {
328
+ Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} pi ${cdim}— already installed ($piInstalled), skipping${creset}"
329
+ } elseif ($piInstalled -and -not $piLatest) {
330
+ Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} pi ${cdim}— already installed ($piInstalled), skipping version check (offline)${creset}"
331
+ } else {
332
+ if ($piInstalled -and $piLatest) { $label = "updating pi $piInstalled → $piLatest ($PiPackage)" } else { $label = "installing pi ($PiPackage)" }
333
+ $code = Invoke-NativeWithSpinner -Step "1" -Label $label -FilePath $NpmBin -ArgumentList @("install","-g","$PiPackage@latest","--no-audit","--no-fund","--min-release-age=0")
334
+ if ($code -eq 0) {
335
+ $piVer = try { (pi --version 2>$null).Trim() } catch { "?" }
336
+ Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} $label ${cdim}— $piVer${creset}"
337
+ } else {
338
+ $isPerm = Select-String -Path $TmpLog -Pattern "EACCES|permission" -Quiet -ErrorAction SilentlyContinue
339
+ if ($isPerm) {
340
+ Write-Line " ${cred}✖${creset} ${cdim}[1/4]${creset} $label ${cred}permission denied${creset}"
341
+ Write-Line " ${cdim}try: npm config set prefix ~/.npm-global or run as admin${creset}"
342
+ } else {
343
+ Write-Line " ${cred}✖${creset} ${cdim}[1/4]${creset} $label ${cred}failed (exit $code)${creset} ${cdim}see $TmpLog${creset}"
344
+ }
345
+ $ok = $false
346
+ }
347
+ }
348
+ } else {
349
+ $hasPi = $null -ne (Get-Command pi -ErrorAction SilentlyContinue)
350
+ $label = if ($hasPi) { "updating pi ($PiPackage)" } else { "installing pi ($PiPackage)" }
351
+ $code = Invoke-NativeWithSpinner -Step "1" -Label $label -FilePath $NpmBin -ArgumentList @("install","-g","$PiPackage@latest","--no-audit","--no-fund","--min-release-age=0")
352
+ if ($code -eq 0) {
353
+ $piVer = try { (pi --version 2>$null).Trim() } catch { "?" }
354
+ Write-Line " ${cgreen}✔${creset} ${cdim}[1/4]${creset} $label ${cdim}— $piVer${creset}"
355
+ } else {
356
+ $isPerm = Select-String -Path $TmpLog -Pattern "EACCES|permission" -Quiet -ErrorAction SilentlyContinue
357
+ if ($isPerm) {
358
+ Write-Line " ${cred}✖${creset} ${cdim}[1/4]${creset} $label ${cred}permission denied${creset}"
359
+ Write-Line " ${cdim}try: npm config set prefix ~/.npm-global or run as admin${creset}"
360
+ } else {
361
+ Write-Line " ${cred}✖${creset} ${cdim}[1/4]${creset} $label ${cred}failed (exit $code)${creset} ${cdim}see $TmpLog${creset}"
362
+ }
363
+ $ok = $false
364
+ }
365
+ }
366
+
367
+ # ── [2/4] pi packages ─────────────────────────────────────────────────────
368
+ if ($SkipPackages) {
369
+ Write-Line " ${cgreen}✔${creset} ${cdim}[2/4]${creset} pi packages ${cdim}— skipped${creset}"
370
+ } else {
371
+ $need = @()
372
+ foreach ($pkgName in @("pi-context-usage","@baretread/pi-forge")) {
373
+ $inst = Get-GlobalPkgVersion $pkgName
374
+ $latest = Get-LatestPkgVersion $pkgName
375
+ if ($inst -and $latest -and ($inst -eq $latest)) {
376
+ Write-Line "${cdim} · $pkgName@$inst already installed, skipping${creset}"
377
+ } elseif ($inst -and -not $latest) {
378
+ Write-Line "${cdim} · $pkgName@$inst already installed, skipping version check (offline)${creset}"
379
+ } else {
380
+ if ($inst) { Write-Line "${cdim} · $pkgName $inst → $(if ($latest) { $latest } else { 'latest' }) — will update${creset}" }
381
+ $need += "$pkgName@latest"
382
+ }
383
+ }
384
+ if ($need.Count -eq 0) {
385
+ Write-Line " ${cgreen}✔${creset} ${cdim}[2/4]${creset} pi packages ${cdim}— already installed, skipping${creset}"
386
+ } else {
387
+ $label = "installing $($need -join ' ')"
388
+ $code = Invoke-NativeWithSpinner -Step "2" -Label $label -FilePath $NpmBin -ArgumentList (@("install","-g") + $need + @("--no-audit","--no-fund"))
389
+ if ($code -eq 0) {
390
+ # Verify global ls (handles mise/nvm prefix quirks)
391
+ $hasCtx = $null -ne (npm ls -g pi-context-usage 2>$null | Select-String "pi-context-usage")
392
+ $hasForge = $null -ne (npm ls -g "@baretread/pi-forge" 2>$null | Select-String "pi-forge")
393
+ if (-not $hasCtx -or -not $hasForge) {
394
+ Write-Line " ${cyellow}⚠${creset} ${cdim}[2/4]${creset} pi packages installed but not in global ls — continuing${creset}"
395
+ }
396
+ Write-Line " ${cgreen}✔${creset} ${cdim}[2/4]${creset} pi packages ${cdim}— done${creset}"
397
+ } else {
398
+ Write-Line " ${cyellow}⚠${creset} ${cdim}[2/4]${creset} $label ${cyellow}exit $code — continuing (pi will auto-install)${creset}"
399
+ Write-Line " ${cdim}see $TmpLog${creset}"
400
+ }
401
+ }
402
+ }
403
+
404
+ # ── [3/4] custom config ───────────────────────────────────────────────────
405
+ try {
406
+ $sourceRoot = Resolve-SourceRoot -Repo $repoUrl
407
+ Write-Line "${cdim} source $sourceRoot${creset}"
408
+ if (-not (Test-Path $sourceRoot)) { throw "source not found: $sourceRoot" }
409
+
410
+ if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
411
+
412
+ # backup (managed files only: extensions + top-level config files).
413
+ # Per-user data (settings.json, auth.json, sessions/, taste/, etc.) is never
414
+ # touched by the installer, so it is deliberately NOT backed up.
415
+ $bak = $null
416
+ $hasExisting = (Test-Path (Join-Path $targetDir "AGENTS.md")) -or (Test-Path (Join-Path $targetDir "extensions"))
417
+ if ($hasExisting) {
418
+ $bak = "$targetDir.bak.$(Get-Date -Format yyyyMMdd-HHmmss)"
419
+ Write-Line "${cdim} backup $targetDir/extensions → $bak${creset}"
420
+ New-Item -ItemType Directory -Path $bak -Force | Out-Null
421
+ foreach ($f in @("AGENTS.md","keybindings.json","README.md")) {
422
+ $s = Join-Path $targetDir $f
423
+ if (Test-Path $s) { Copy-Item $s (Join-Path $bak $f) -Force -ErrorAction SilentlyContinue }
424
+ }
425
+ $srcBakExt = Join-Path $targetDir "extensions"
426
+ if (Test-Path $srcBakExt) {
427
+ # NOTE: Copy-Item -Exclude only filters top-level items, not recursive
428
+ # contents — copy per-extension while skipping node_modules/.git instead.
429
+ $dstBakExt = Join-Path $bak "extensions"
430
+ Get-ChildItem $srcBakExt -Directory | ForEach-Object {
431
+ $d = Join-Path $dstBakExt $_.Name
432
+ New-Item -ItemType Directory -Path $d -Force | Out-Null
433
+ Get-ChildItem $_.FullName -Exclude @("node_modules",".git","bin") | ForEach-Object {
434
+ Copy-Item $_.FullName $d -Recurse -Force -ErrorAction SilentlyContinue
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ # only one backup at a time — delete older siblings, keep the newest
441
+ if ($bak) {
442
+ $bakKeep = Split-Path $bak -Leaf
443
+ $bakBase = Split-Path $targetDir -Leaf
444
+ $bakParent = Split-Path $targetDir -Parent
445
+ Get-ChildItem $bakParent -Directory -Filter "$bakBase.bak.*" -ErrorAction SilentlyContinue |
446
+ Where-Object { $_.Name -ne $bakKeep } |
447
+ ForEach-Object { Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
448
+ }
449
+
450
+ $copied = 0
451
+ $isSelfInstall = ($sourceRoot -eq $targetDir)
452
+ if ($isSelfInstall) { Write-Line "${cdim} source == target — skipping file copy (self-install)${creset}" }
453
+ foreach ($f in @("AGENTS.md","keybindings.json","README.md")) {
454
+ $s = Join-Path $sourceRoot $f
455
+ if (-not (Test-Path $s)) { continue }
456
+ $d = Join-Path $targetDir $f
457
+ if ($isSelfInstall -or $s -eq $d) { $copied++; continue }
458
+ Copy-Item $s $d -Force; $copied++
459
+ }
460
+ # settings.json: seed from repo defaults if missing; otherwise keep,
461
+ # unless -OverwriteSettings is passed or the user answers Y.
462
+ $repoSettings = Join-Path $sourceRoot "settings.json"
463
+ $settings = Join-Path $targetDir "settings.json"
464
+ $settingsJustCreated = $false
465
+ if ((-not (Test-Path $settings)) -and (Test-Path $repoSettings)) {
466
+ Copy-Item $repoSettings $settings
467
+ $settingsJustCreated = $true
468
+ Write-Line "${cdim} created settings.json from repo defaults${creset}"
469
+ } elseif (Test-Path $settings) {
470
+ if ((Test-WantOverwriteSettings) -and (Test-Path $repoSettings)) {
471
+ if ($bak) { Copy-Item $settings (Join-Path $bak "settings.json") -Force -ErrorAction SilentlyContinue }
472
+ Copy-Item $repoSettings $settings -Force
473
+ $settingsJustCreated = $true
474
+ Write-Line "${cdim} overwrote settings.json from repo defaults${creset}"
475
+ } else {
476
+ Write-Line "${cdim} kept existing settings.json${creset}"
477
+ }
478
+ }
479
+ # ensure Pi extensions (forge + context) are registered — only on a freshly
480
+ # seeded settings.json; an existing file is left completely untouched.
481
+ if ($settingsJustCreated -and (Test-Path $settings) -and (Get-Command node -ErrorAction SilentlyContinue)) {
482
+ try {
483
+ $patch = "const fs=require('fs');const p=process.argv[1];try{let j=JSON.parse(fs.readFileSync(p,'utf8'));let need=['npm:pi-context-usage','npm:@baretread/pi-forge'];j.packages=j.packages||[];let c=false;for(let pkg of need){if(!j.packages.includes(pkg)){j.packages.push(pkg);c=true}}if(c){fs.writeFileSync(p,JSON.stringify(j,null,2)+'\\n');console.log(' patched settings.json packages -> forge + context') } }catch(e){}"
484
+ node -e $patch $settings 2>$null | ForEach-Object { Write-Line "${cdim} $_${creset}" }
485
+ } catch {}
486
+ }
487
+ # taste.md / taste/ — user-specific, preserve if exists
488
+ foreach ($preserve in @("taste.md","taste","taste.json")) {
489
+ $p = Join-Path $targetDir $preserve
490
+ $src = Join-Path $sourceRoot $preserve
491
+ if (Test-Path $p) {
492
+ Write-Line "${cdim} kept existing $preserve${creset}"
493
+ } elseif (Test-Path $src) {
494
+ Copy-Item $src $p -Recurse -Force -Exclude @("node_modules",".git") -ErrorAction SilentlyContinue
495
+ }
496
+ }
497
+
498
+ # extensions — skip stray .md files, handle spaces
499
+ $srcExt = Join-Path $sourceRoot "extensions"
500
+ $dstExt = Join-Path $targetDir "extensions"
501
+ if (Test-Path $srcExt) {
502
+ if ($isSelfInstall -or $srcExt -eq $dstExt) {
503
+ # Self-install: extensions already in place — just count, don't delete/re-copy
504
+ Get-ChildItem $srcExt -Directory | Where-Object { $_.Name -notlike "*.md" } | ForEach-Object { $copied++ }
505
+ } else {
506
+ if (-not (Test-Path $dstExt)) { New-Item -ItemType Directory -Path $dstExt -Force | Out-Null }
507
+ Get-ChildItem $srcExt -Directory | ForEach-Object {
508
+ if ($_.Name -like "*.md") { return }
509
+ $dst = Join-Path $dstExt $_.Name
510
+ if (Test-Path $dst) { Remove-Item $dst -Recurse -Force -ErrorAction SilentlyContinue }
511
+ # NOTE: Copy-Item -Exclude only filters top-level items, not recursive
512
+ # contents — copy per-item while skipping node_modules/.git instead.
513
+ # (node_modules are rebuilt by each extension's installer in step 4;
514
+ # copying them follows junctions and stalls the install.)
515
+ New-Item -ItemType Directory -Path $dst -Force | Out-Null
516
+ Get-ChildItem $_.FullName -Exclude @("node_modules",".git") | ForEach-Object {
517
+ Copy-Item $_.FullName $dst -Recurse -Force -ErrorAction SilentlyContinue
518
+ }
519
+ $copied++
520
+ }
521
+ }
522
+ }
523
+
524
+ # themes — exclude recursive .pi and nul device file
525
+ $srcThemes = Join-Path $sourceRoot "themes"
526
+ if ((Test-Path $srcThemes) -and -not $isSelfInstall -and $srcThemes -ne (Join-Path $targetDir "themes")) {
527
+ $dstThemes = Join-Path $targetDir "themes"
528
+ if (Test-Path $dstThemes) { Remove-Item $dstThemes -Recurse -Force -ErrorAction SilentlyContinue }
529
+ New-Item -ItemType Directory -Path $dstThemes -Force | Out-Null
530
+ Get-ChildItem $srcThemes -Force | Where-Object { $_.Name -notin @(".pi","nul") } | ForEach-Object {
531
+ Copy-Item $_.FullName (Join-Path $dstThemes $_.Name) -Recurse -Force
532
+ }
533
+ }
534
+
535
+ Write-Line " ${cgreen}✔${creset} ${cdim}[3/4]${creset} custom config ${cdim}— $copied items → $targetDir${creset}"
536
+ # cleanup cloned temp — per spec: clone into ~/.pi/tmp, copy agent, delete temp
537
+ if ($script:ClonedTmp -and (Test-Path $script:ClonedTmp)) {
538
+ if ($script:ClonedTmp.StartsWith($PiTmp, [StringComparison]::OrdinalIgnoreCase)) {
539
+ Remove-Item $script:ClonedTmp -Recurse -Force -ErrorAction SilentlyContinue
540
+ $script:ClonedTmp = $null
541
+ }
542
+ }
543
+ # delete any leftover kiln-* temp dirs from interrupted runs
544
+ Get-ChildItem $PiTmp -Directory -Filter "kiln-*" -ErrorAction SilentlyContinue |
545
+ Where-Object { $_.FullName -ne $script:ClonedTmp } |
546
+ ForEach-Object { Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
547
+ # repo README → alongside the agent dir (e.g. ~/.pi/README.md)
548
+ $repoRoot = Split-Path $sourceRoot -Parent
549
+ $targetParent = Split-Path $targetDir -Parent
550
+ $repoReadme = Join-Path $repoRoot "README.md"
551
+ if ((Test-Path $repoReadme) -and ($repoRoot -ne $targetParent)) {
552
+ Copy-Item $repoReadme (Join-Path $targetParent "README.md") -Force -ErrorAction SilentlyContinue
553
+ }
554
+ } catch {
555
+ Write-Line " ${cred}✖${creset} ${cdim}[3/4]${creset} custom config ${cred}failed:${creset} $($_.Exception.Message)"
556
+ Write-Line " ${cdim}$($_.ScriptStackTrace)${creset}"
557
+ Write-Line " ${cdim}see $TmpLog${creset}"
558
+ $ok = $false
559
+ }
560
+
561
+ # ── [4/4] per-extension deps ──────────────────────────────────────────────
562
+ try {
563
+ $extRoot = Join-Path $targetDir "extensions"
564
+ $exts = @()
565
+ if (Test-Path $extRoot) { $exts = Get-ChildItem $extRoot -Directory | Where-Object { $_.Name -notlike "*.md" } | Sort-Object Name }
566
+
567
+ if ($exts.Count -eq 0) {
568
+ Write-Line " ${cyellow}⚠${creset} ${cdim}[4/4]${creset} no extensions found at $extRoot"
569
+ } else {
570
+ $failed = @()
571
+ $installed = 0
572
+ $skipped = 0
573
+ foreach ($ext in $exts) {
574
+ $label = "extensions/$($ext.Name)"
575
+ if (Test-Path (Join-Path $ext.FullName "node_modules")) {
576
+ Write-Line " ${cdim} · $label — already installed, skipping${creset}"
577
+ $skipped++
578
+ continue
579
+ }
580
+ $ps1 = Join-Path $ext.FullName "install.ps1"
581
+ $sh = Join-Path $ext.FullName "install.sh"
582
+ $hasPkg = Test-Path (Join-Path $ext.FullName "package.json")
583
+
584
+ if (-not $hasPkg -and -not (Test-Path $ps1)) {
585
+ Write-Line " ${cdim} · $label — no package.json, skipping${creset}"
586
+ continue
587
+ }
588
+
589
+ if (Test-Path $ps1) {
590
+ # prefer pwsh, fallback to powershell
591
+ $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue
592
+ if ($pwsh) { $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath $pwsh.Source -ArgumentList @("-NoProfile","-File",$ps1) }
593
+ else { $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath "powershell" -ArgumentList @("-NoProfile","-ExecutionPolicy","Bypass","-File",$ps1) }
594
+ } elseif (Test-Path $sh) {
595
+ $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath "sh" -ArgumentList @($sh)
596
+ } else {
597
+ $npmArgs = if (Test-Path (Join-Path $ext.FullName "package-lock.json")) { @("--prefix",$ext.FullName,"ci","--no-audit","--no-fund") } else { @("--prefix",$ext.FullName,"install","--no-audit","--no-fund") }
598
+ $code = Invoke-NativeWithSpinner -Step "4" -Label $label -FilePath $NpmBin -ArgumentList $npmArgs
599
+ }
600
+
601
+ if ($code -eq 0) {
602
+ Write-Line " ${cgreen}✔${creset} ${cdim}[4/4]${creset} $label"
603
+ $installed++
604
+ } else {
605
+ Write-Line " ${cred}✖${creset} ${cdim}[4/4]${creset} $label ${cred}failed (exit $code)${creset}"
606
+ $failed += $ext.Name
607
+ }
608
+ }
609
+ if ($failed.Count -eq 0) {
610
+ Write-Line " ${cgreen}✔${creset} ${cdim}[4/4]${creset} extension deps ${cdim}— $installed installed, $skipped skipped${creset}"
611
+ } else {
612
+ Write-Line " ${cyellow}⚠${creset} ${cdim}[4/4]${creset} extension deps ${cyellow}$($failed.Count) failed:${creset} $($failed -join ', ') ${cdim}see $TmpLog${creset}"
613
+ }
614
+ }
615
+ } catch {
616
+ Write-Line " ${cred}✖${creset} ${cdim}[4/4]${creset} extension deps ${cred}failed:${creset} $($_.Exception.Message)"
617
+ $ok = $false
618
+ }
619
+
620
+ # ── cleanup + summary ─────────────────────────────────────────────────────
621
+ if ($script:ClonedTmp -and (Test-Path $script:ClonedTmp)) {
622
+ Remove-Item $script:ClonedTmp -Recurse -Force -ErrorAction SilentlyContinue
623
+ }
624
+
625
+ $elapsed = (Get-Date) - $script:StartTime
626
+ if ($elapsed.TotalSeconds -ge 60) { $elapsedFmt = "{0}m {1}s" -f [int]$elapsed.TotalMinutes, $elapsed.Seconds }
627
+ else { $elapsedFmt = "{0}s" -f [int]$elapsed.TotalSeconds }
628
+
629
+ Write-Line ""
630
+ if ($ok) {
631
+ Write-Line "${cgreen}${cbold} ✓ Setup complete${creset} ${cdim}→ $targetDir · Kiln installed in $elapsedFmt${creset}"
632
+ $piVer2 = try { (pi --version 2>$null).Trim() } catch { $null }
633
+ if ($piVer2) { Write-Line "${cdim} pi $piVer2 · run: pi${creset}" }
634
+ } else {
635
+ Write-Line "${cyellow}${cbold} ⚠ Setup finished with warnings${creset} ${cdim}in $elapsedFmt · see $TmpLog${creset}"
636
+ }
637
+ Write-Line "${cdim} log: $TmpLog${creset}`n"