@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.sh CHANGED
@@ -1,511 +1,620 @@
1
- #!/bin/sh
2
- # Pi custom-setup installer — macOS / Linux / Git Bash
3
- # 1) Install/update pi (@earendil-works/pi-coding-agent)
4
- # 2) Install pi packages (pi-context-usage, @baretread/pi-forge)
5
- # 3) Install custom config (AGENTS.md, keybindings, extensions …)
6
- # 4) Install per-extension npm deps via each extension's install.sh
7
- #
8
- # TUI inspired by https://pi.dev/install.sh — spinner when TTY, silent otherwise.
9
- #
10
- # Usage:
11
- # curl -fsSL https://raw.githubusercontent.com/asterxsk/kiln/main/agent/install.sh | sh
12
- # ./install.sh --repo https://github.com/asterxsk/kiln --branch main
13
- # ./install.sh --target ~/.pi/agent --yes
14
- # ./install.sh --local # force local checkout, skip clone
15
- #
16
- # Safe to re-run. Managed files are force-overwritten; per-user files
17
- # (settings.json, taste.md, taste/, auth.json, trust.json, sessions/, etc.) are preserved.
18
-
19
- set -eu
20
-
21
- PI_PACKAGE="@earendil-works/pi-coding-agent"
22
- NODE_MIN="22.19.0"
23
- # Overridden at publish time; fallback is used only for local dev
24
- DEFAULT_REPO="https://github.com/asterxsk/kiln"
25
- # Disable credential prompts — public clone should never ask for GitHub login
26
- export GIT_TERMINAL_PROMPT=0
27
- export GCM_INTERACTIVE=never 2>/dev/null || true
28
- export GIT_ASKPASS=echo 2>/dev/null || true
29
- REPO_URL="${PI_CONFIG_REPO:-}"
30
- BRANCH="${PI_CONFIG_BRANCH:-main}"
31
- TARGET="${PI_AGENT_DIR:-}"
32
- SKIP_PI=0
33
- SKIP_PACKAGES=0
34
- ASSUME_YES=0
35
- FORCE_LOCAL=0
36
- CLONED_TMP=""
37
- START_TS="$(date +%s 2>/dev/null || echo 0)"
38
-
39
- # ── tmp + log under ~/.pi/tmp ────────────────────────────────────────────
40
- mkdir -p "$HOME/.pi/tmp" 2>/dev/null || true
41
- TMP_BASE="$HOME/.pi/tmp"
42
- if command -v mktemp >/dev/null 2>&1; then
43
- TMP_LOG="$(mktemp "$TMP_BASE/pi-setup-XXXXXX.log" 2>/dev/null || echo "$TMP_BASE/pi-setup-$$.log")"
44
- else
45
- TMP_LOG="$TMP_BASE/pi-setup-$$.log"
46
- fi
47
- : > "$TMP_LOG" 2>/dev/null || true
48
- # shellcheck disable=SC2064
49
- trap 'rm -f "$TMP_LOG.sp" 2>/dev/null; if [ -n "$CLONED_TMP" ] && [ -d "$CLONED_TMP" ]; then rm -rf "$CLONED_TMP"; fi' EXIT INT TERM
50
-
51
- # ── args ────────────────────────────────────────────────────────────────────
52
- while [ $# -gt 0 ]; do
53
- case "$1" in
54
- --repo) REPO_URL="$2"; shift 2;;
55
- --branch) BRANCH="$2"; shift 2;;
56
- --target) TARGET="$2"; shift 2;;
57
- --skip-pi) SKIP_PI=1; shift;;
58
- --skip-packages) SKIP_PACKAGES=1; shift;;
59
- --local) FORCE_LOCAL=1; shift;;
60
- --yes|-y) ASSUME_YES=1; shift;;
61
- --help|-h) echo "Usage: $0 [--repo URL] [--branch BRANCH] [--target DIR] [--local] [--skip-pi] [--skip-packages] [--yes]"; exit 0;;
62
- --) shift; break;;
63
- -*) echo "unknown arg: $1" >&2; exit 1;;
64
- *) break;;
65
- esac
66
- done
67
-
68
- # ── ANSI / VT ───────────────────────────────────────────────────────────────
69
- ESC="$(printf '\033')"
70
- CR="$(printf '\r')"
71
- if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then HAS_ANSI=1; else HAS_ANSI=0; fi
72
- if [ "$HAS_ANSI" -eq 1 ]; then
73
- C_RESET="${ESC}[0m"; C_BOLD="${ESC}[1m"; C_DIM="${ESC}[2m"
74
- C_GREEN="${ESC}[32m"; C_YELLOW="${ESC}[33m"; C_RED="${ESC}[31m"
75
- C_CYAN="${ESC}[36m"; C_GRAY="${ESC}[90m"
76
- else
77
- C_RESET=""; C_BOLD=""; C_DIM=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_CYAN=""; C_GRAY=""
78
- fi
79
-
80
- # Spinner frames (braille). Kept as a real array so the index advances in the
81
- # PARENT shell — calling get_spinner inside a $(...) subshell discarded the
82
- # increment and froze the spinner on frame 0.
83
- SPINNER_FRAMES=(⠋ ⠙ ⠹ ⠸ ⠼ ⠰ ⠠ ⠦ ⠧ ⠇ ⠏)
84
- SPINNER_N=${#SPINNER_FRAMES[@]}
85
- SPINNER_IDX=0
86
-
87
- write_title() {
88
- if [ "$HAS_ANSI" -eq 1 ]; then
89
- printf "${ESC}[1m \u25C6 Pi Setup${ESC}[0m\n${ESC}[2m custom agent config \u00B7 pi + forge + extensions${ESC}[0m\n\n"
90
- else
91
- printf "\n Pi Setup\n custom agent config · pi + forge + extensions\n\n"
92
- fi
93
- }
94
-
95
- # ── spinner runner ──────────────────────────────────────────────────────────
96
- # run_with_spinner STEP LABEL CMD...
97
- # Synchronous when no TTY (curl|sh, CI, Windows Git Bash without VT).
98
- # When TTY, shows spinner and overwrites line with \r.
99
- run_with_spinner() {
100
- STEP="$1"; LABEL="$2"; shift 2
101
- : > "$TMP_LOG.sp" 2>/dev/null || true
102
- if [ "$HAS_ANSI" -eq 0 ]; then
103
- # plain run — no spinner, keep output quiet (append to log)
104
- set +e
105
- "$@" >"$TMP_LOG.sp" 2>&1; CODE=$?
106
- set -e
107
- cat "$TMP_LOG.sp" >> "$TMP_LOG" 2>/dev/null || true
108
- return $CODE
109
- fi
110
- # TTY: spinner
111
- SPINNER_IDX=0
112
- set +e
113
- "$@" >"$TMP_LOG.sp" 2>&1 &
114
- PID=$!
115
- set -e
116
- while kill -0 "$PID" 2>/dev/null; do
117
- SPIN="${SPINNER_FRAMES[$SPINNER_IDX]}"
118
- SPINNER_IDX=$(( (SPINNER_IDX + 1) % SPINNER_N ))
119
- # \r + Erase-in-Line so each frame overwrites cleanly regardless of length
120
- printf "${CR}${ESC}[K ${C_YELLOW}%s${C_RESET} ${C_DIM}[%s/4]${C_RESET} %s" "$SPIN" "$STEP" "$LABEL"
121
- sleep 0.08
122
- done
123
- set +e
124
- wait "$PID" 2>/dev/null; CODE=$?
125
- set -e
126
- cat "$TMP_LOG.sp" >> "$TMP_LOG" 2>/dev/null || true
127
- printf "${CR}${ESC}[K"
128
- return $CODE
129
- }
130
-
131
- # ── helpers ─────────────────────────────────────────────────────────────────
132
- resolve_target() {
133
- if [ -n "$TARGET" ]; then printf "%s" "$TARGET"; return; fi
134
- # Git Bash on Windows: prefer Windows path if cygpath available
135
- if command -v cygpath >/dev/null 2>&1; then
136
- printf "%s" "$(cygpath -w "$HOME/.pi/agent" 2>/dev/null || echo "$HOME/.pi/agent")"
137
- return
138
- fi
139
- printf "%s/.pi/agent" "$HOME"
140
- }
141
-
142
- resolve_repo() {
143
- if [ -n "$REPO_URL" ]; then printf "%s" "$REPO_URL"; return; fi
144
- # Don't probe when piped (curl | sh) — $0 is sh/dash
145
- case "$0" in sh|dash|*\/sh|*\/dash|-) printf "%s" "$DEFAULT_REPO"; return;; esac
146
- HERE=""
147
- # Try to find a git checkout near the script
148
- _self=""
149
- case "$0" in /*|*/*) _self="$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null || echo "")";; esac
150
- for r in "$_self" "$(pwd)" "$(pwd)/agent"; do
151
- [ -z "$r" ] && continue
152
- if [ -d "$r/.git" ] || [ -d "$r/../.git" ]; then
153
- U="$(git -C "$r" remote get-url origin 2>/dev/null || true)"
154
- if [ -n "$U" ]; then printf "%s" "$U"; return; fi
155
- fi
156
- done
157
- printf "%s" "$DEFAULT_REPO"
158
- }
159
-
160
- resolve_source_root() {
161
- REPO="$1"
162
- # If forced local or script is inside a checkout, use it
163
- if [ "$FORCE_LOCAL" -eq 1 ]; then
164
- for r in "$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null || echo "")" "$(pwd)" "$(pwd)/agent"; do
165
- [ -z "$r" ] && continue
166
- if [ -d "$r/extensions" ] && [ -f "$r/AGENTS.md" ]; then printf "%s" "$r"; return 0; fi
167
- if [ -d "$r/agent/extensions" ]; then printf "%s" "$r/agent"; return 0; fi
168
- done
169
- else
170
- # When not piped, check for local checkout first to avoid clone
171
- case "$0" in sh|dash|*\/sh|*\/dash|-) :;; # piped — skip local check
172
- *)
173
- for r in "$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null || echo "")" "$(pwd)" "$(pwd)/agent"; do
174
- [ -z "$r" ] && continue
175
- if [ -d "$r/extensions" ] && [ -f "$r/AGENTS.md" ]; then printf "%s" "$r"; return 0; fi
176
- if [ -d "$r/agent/extensions" ]; then printf "%s" "$r/agent"; return 0; fi
177
- done
178
- ;;
179
- esac
180
- fi
181
- # Clone to ~/.pi/tmp — simple git clone, no account required, then copy agent folder.
182
- # Use a unique subdir per run so concurrent installs never clobber each other.
183
- mkdir -p "$TMP_BASE" 2>/dev/null || true
184
- if command -v mktemp >/dev/null 2>&1; then
185
- TMP="$(mktemp -d "$TMP_BASE/kiln-XXXXXX" 2>/dev/null || echo "$TMP_BASE/kiln-$$")"
186
- else
187
- TMP="$TMP_BASE/kiln-$$"
188
- fi
189
- mkdir -p "$TMP" 2>/dev/null || true
190
- CLONED_TMP="$TMP"
191
- printf "${C_DIM} → cloning %s (branch %s) → %s${C_RESET}\n" "$REPO" "$BRANCH" "$TMP" >&2
192
- set +e
193
- run_with_spinner "3" "fetching config" env GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=echo GCM_INTERACTIVE=never git clone --depth 1 --branch "$BRANCH" "$REPO" "$TMP"
194
- CODE=$?
195
- set -e
196
- if [ $CODE -ne 0 ]; then
197
- echo "git clone failed (exit $CODE) — see $TMP_LOG" >&2
198
- return 1
199
- fi
200
- if [ -d "$TMP/agent" ]; then printf "%s" "$TMP/agent"; else printf "%s" "$TMP"; fi
201
- }
202
-
203
- # Portable version compare: returns 0 if HAVE >= MIN
204
- version_ge() {
205
- HAVE="$1"; MIN="$2"
206
- # Use node itself if available (most reliable), else awk
207
- if command -v node >/dev/null 2>&1; then
208
- node -e "const [h,m]=process.argv.slice(1).map(v=>v.split('.').map(Number));let ge=true;for(let i=0;i<Math.max(h.length,m.length);i++){const a=h[i]||0,b=m[i]||0;if(a>b)break;if(a<b){ge=false;break}}process.exit(ge?0:1)" "$HAVE" "$MIN" 2>/dev/null && return 0 || return 1
209
- fi
210
- # awk fallback
211
- printf "%s\n%s\n" "$HAVE" "$MIN" | awk -F. '
212
- NR==1{split($0,h,"."); next}
213
- {split($0,m,"."); for(i=1;i<=3;i++){a=h[i]+0; b=m[i]+0; if(a>b)exit 0; if(a<b)exit 1} exit 0}
214
- ' 2>/dev/null && return 0 || return 1
215
- # last resort: string compare
216
- [ "$HAVE" = "$MIN" ] && return 0
217
- [ "$(printf "%s\n%s" "$HAVE" "$MIN" | sort 2>/dev/null | head -n1)" = "$MIN" ] && return 0 || return 1
218
- }
219
-
220
- # ── MAIN ─────────────────────────────────────────────────────────────────────
221
- write_title
222
-
223
- # Copy a tree excluding heavy/unwanted dirs (node_modules, .git).
224
- # Uses rsync when available, else cp + prune.
225
- # No-op when source and destination are the same path (self-install).
226
- copy_tree() {
227
- _src="$1"; _dst="$2"
228
- if [ "$_src" = "$_dst" ]; then return 0; fi
229
- # Resolve canonical paths to catch ./ vs absolute same target
230
- _src_can="$(cd "$_src" 2>/dev/null && pwd 2>/dev/null || echo "$_src")"
231
- _dst_can="$(cd "$_dst" 2>/dev/null && pwd 2>/dev/null || echo "$_dst")"
232
- if [ "$_src_can" = "$_dst_can" ]; then return 0; fi
233
- rm -rf "$_dst" 2>/dev/null || true
234
- if command -v rsync >/dev/null 2>&1; then
235
- rsync -a --exclude node_modules --exclude '.git' "$_src/" "$_dst/" 2>/dev/null || \
236
- { cp -R "$_src" "$_dst" 2>/dev/null || true; rm -rf "$_dst/node_modules" "$_dst/.git" 2>/dev/null || true; }
237
- else
238
- cp -R "$_src" "$_dst" 2>/dev/null || true
239
- rm -rf "$_dst/node_modules" "$_dst/.git" 2>/dev/null || true
240
- fi
241
- }
242
-
243
- TARGET_DIR="$(resolve_target)"
244
- REPO_URL="$(resolve_repo)"
245
- OK=1
246
-
247
- printf "${C_DIM} target ${C_RESET}%s\n" "$TARGET_DIR"
248
- printf "${C_DIM} repo ${C_RESET}%s ${C_DIM}(%s)${C_RESET}\n" "$REPO_URL" "$BRANCH"
249
- printf "${C_DIM} log ${C_RESET}%s\n\n" "$TMP_LOG"
250
-
251
- # ── preflight ────────────────────────────────────────────────────────────────
252
- if ! command -v node >/dev/null 2>&1; then
253
- printf "${C_RED} ✖ Node.js not found. Install Node %s+ from https://nodejs.org${C_RESET}\n" "$NODE_MIN"
254
- printf " then re-run this installer.\n\n"
255
- exit 1
256
- fi
257
- NODE_VER="$(node --version 2>/dev/null | sed 's/^v//;s/[^0-9.].*//' | cut -d- -f1)"
258
- if ! version_ge "$NODE_VER" "$NODE_MIN"; then
259
- printf "${C_YELLOW} ⚠ Node %s < %s — please upgrade to Node %s+${C_RESET}\n" "$NODE_VER" "$NODE_MIN" "$NODE_MIN"
260
- if [ "$ASSUME_YES" -eq 0 ]; then printf " continue anyway in 3s…\n"; sleep 3; fi
261
- fi
262
- if ! command -v npm >/dev/null 2>&1; then
263
- printf "${C_RED} ✖ npm not found (Node installed but npm missing)${C_RESET}\n"; exit 1
264
- fi
265
- if ! command -v git >/dev/null 2>&1; then
266
- printf "${C_RED} ✖ git not found — required to fetch config${C_RESET}\n"
267
- printf " install via xcode-select --install or sudo apt install git\n\n"; exit 1
268
- fi
269
- NPM_VER="$(npm --version 2>/dev/null | tr -d '\n' | tr -d '\r')"
270
- GIT_VER="$(git --version 2>/dev/null | tr -d '\n' | tr -d '\r')"
271
- printf "${C_DIM} node %s · npm %s · %s${C_RESET}\n\n" "$NODE_VER" "$NPM_VER" "$GIT_VER"
272
-
273
- # ── [1/4] pi ────────────────────────────────────────────────────────────────
274
- if [ "$SKIP_PI" -eq 1 ]; then
275
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} Install pi ${C_DIM}— skipped${C_RESET}\n"
276
- else
277
- if command -v pi >/dev/null 2>&1; then LABEL="updating pi ($PI_PACKAGE)"; else LABEL="installing pi ($PI_PACKAGE)"; fi
278
- set +e
279
- run_with_spinner "1" "$LABEL" npm install -g "$PI_PACKAGE@latest" --no-audit --no-fund --min-release-age=0
280
- CODE=$?
281
- set -e
282
- if [ $CODE -eq 0 ]; then
283
- PI_VER="$(pi --version 2>/dev/null | tr -d '\n' | tr -d '\r' || echo "?")"
284
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_DIM}— %s${C_RESET}\n" "$LABEL" "$PI_VER"
285
- else
286
- # Check for EACCES
287
- if grep -q "EACCES\|permission" "$TMP_LOG" 2>/dev/null; then
288
- printf " ${C_RED}✖${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_RED}failed (permission)${C_RESET}\n" "$LABEL"
289
- printf " ${C_DIM}try: sudo npm install -g %s or npm config set prefix ~/.npm-global${C_RESET}\n" "$PI_PACKAGE"
290
- else
291
- printf " ${C_RED}✖${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_RED}failed (exit %s)${C_RESET} ${C_DIM}see %s${C_RESET}\n" "$LABEL" "$CODE" "$TMP_LOG"
292
- fi
293
- printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
294
- OK=0
295
- fi
296
- fi
297
-
298
- # ── [2/4] pi packages ─────────────────────────────────────────────────────
299
- if [ "$SKIP_PACKAGES" -eq 1 ]; then
300
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages ${C_DIM}— skipped${C_RESET}\n"
301
- else
302
- LABEL="installing pi-context-usage + @baretread/pi-forge"
303
- set +e
304
- run_with_spinner "2" "$LABEL" npm install -g pi-context-usage@latest @baretread/pi-forge@latest --no-audit --no-fund
305
- CODE=$?
306
- set -e
307
- if [ $CODE -eq 0 ]; then
308
- # Verify packages are resolvable (handles npm global prefix quirks with mise/nvm)
309
- if ! npm ls -g pi-context-usage >/dev/null 2>&1 || ! npm ls -g @baretread/pi-forge >/dev/null 2>&1; then
310
- printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages installed but not in global ls — retrying with prefix check${C_RESET}\n"
311
- fi
312
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages ${C_DIM}— done${C_RESET}\n"
313
- else
314
- printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[2/4]${C_RESET} %s ${C_YELLOW}exit %s — continuing (pi will auto-install)${C_RESET}\n" "$LABEL" "$CODE"
315
- printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
316
- fi
317
- fi
318
-
319
- # ── [3/4] custom config ───────────────────────────────────────────────────
320
- set +e
321
- # NOTE: no command substitution here — resolve_source_root records the clone
322
- # dir in $CLONED_TMP, which would be lost in a $(...) subshell (that is why
323
- # ~/.pi/tmp was never deleted after install).
324
- resolve_source_root "$REPO_URL" >"$TMP_LOG.src"
325
- SRC_CODE=$?
326
- SOURCE_ROOT="$(cat "$TMP_LOG.src" 2>/dev/null || echo "")"
327
- rm -f "$TMP_LOG.src" 2>/dev/null || true
328
- set -e
329
- if [ $SRC_CODE -ne 0 ] || [ -z "$SOURCE_ROOT" ] || [ ! -d "$SOURCE_ROOT" ]; then
330
- printf " ${C_RED}✖${C_RESET} ${C_DIM}[3/4]${C_RESET} custom config ${C_RED}failed:${C_RESET} source not found\n"
331
- printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
332
- OK=0
333
- else
334
- printf "${C_DIM} source %s${C_RESET}\n" "$SOURCE_ROOT"
335
- mkdir -p "$TARGET_DIR" 2>/dev/null || true
336
-
337
- # backup (managed files only: extensions + top-level config files).
338
- # Per-user data (settings.json, auth.json, sessions/, taste/, etc.) is never
339
- # touched by the installer, so it is deliberately NOT backed up.
340
- if [ -f "$TARGET_DIR/AGENTS.md" ] || [ -d "$TARGET_DIR/extensions" ]; then
341
- BAK="$TARGET_DIR.bak.$(date +%Y%m%d-%H%M%S 2>/dev/null || echo $$)"
342
- printf "${C_DIM} backup %s/extensions → %s${C_RESET}\n" "$TARGET_DIR" "$BAK"
343
- set +e
344
- mkdir -p "$BAK" 2>/dev/null || true
345
- for f in AGENTS.md keybindings.json README.md; do
346
- [ -f "$TARGET_DIR/$f" ] && cp -f "$TARGET_DIR/$f" "$BAK/$f" 2>/dev/null || true
347
- done
348
- if [ -d "$TARGET_DIR/extensions" ]; then
349
- copy_tree "$TARGET_DIR/extensions" "$BAK/extensions" || printf "${C_YELLOW} ⚠ backup extensions failed${C_RESET}\n"
350
- fi
351
- set -e
352
- fi
353
-
354
- COPIED=0
355
- set +e
356
- for f in AGENTS.md keybindings.json README.md; do
357
- if [ -f "$SOURCE_ROOT/$f" ]; then
358
- # Self-install: source == target — no copy needed, just count
359
- if [ "$SOURCE_ROOT/$f" = "$TARGET_DIR/$f" ] || [ "$SOURCE_ROOT" = "$TARGET_DIR" ]; then
360
- COPIED=$((COPIED+1))
361
- else
362
- cp -f "$SOURCE_ROOT/$f" "$TARGET_DIR/$f" 2>/dev/null && COPIED=$((COPIED+1)) || printf "${C_YELLOW} ⚠ copy %s failed${C_RESET}\n" "$f"
363
- fi
364
- fi
365
- done
366
- # per-user files: never overwrite — seed from repo defaults if missing, otherwise keep.
367
- # An existing settings.json is never modified by the setup script.
368
- SETTINGS_JUST_CREATED=0
369
- if [ ! -f "$TARGET_DIR/settings.json" ] && [ -f "$SOURCE_ROOT/settings.json" ]; then
370
- if cp "$SOURCE_ROOT/settings.json" "$TARGET_DIR/settings.json" 2>/dev/null; then
371
- printf "${C_DIM} created settings.json from repo defaults${C_RESET}\n"
372
- SETTINGS_JUST_CREATED=1
373
- fi
374
- elif [ -f "$TARGET_DIR/settings.json" ]; then
375
- printf "${C_DIM} kept existing settings.json${C_RESET}\n"
376
- fi
377
- # ensure Pi extensions (forge + context) are registered — only on a freshly
378
- # seeded settings.json; an existing file is left completely untouched.
379
- if [ "$SETTINGS_JUST_CREATED" = "1" ] && [ -f "$TARGET_DIR/settings.json" ] && command -v node >/dev/null 2>&1; then
380
- node -e "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){}" "$TARGET_DIR/settings.json" 2>/dev/null || true
381
- fi
382
- # taste.md / taste/ — user-specific, preserve if exists
383
- for _preserve in taste.md taste taste.json; do
384
- if [ -f "$TARGET_DIR/$_preserve" ] || [ -d "$TARGET_DIR/$_preserve" ]; then
385
- printf "${C_DIM} kept existing %s${C_RESET}\n" "$_preserve"
386
- elif [ -e "$SOURCE_ROOT/$_preserve" ]; then
387
- cp -R "$SOURCE_ROOT/$_preserve" "$TARGET_DIR/$_preserve" 2>/dev/null || true
388
- fi
389
- done
390
- # auth.json / trust.json / sessions etc. are never shipped in kiln, so no copy needed; they remain untouched
391
-
392
- # extensions — full sync (set +e so one bad copy doesn't abort)
393
- if [ -d "$SOURCE_ROOT/extensions" ]; then
394
- mkdir -p "$TARGET_DIR/extensions" 2>/dev/null || true
395
- for d in "$SOURCE_ROOT/extensions"/*; do
396
- [ -e "$d" ] || continue
397
- [ -d "$d" ] || continue
398
- # skip stray files like AGENTS.md inside extensions
399
- case "$(basename "$d")" in *.md) continue;; esac
400
- base="$(basename "$d")"
401
- copy_tree "$d" "$TARGET_DIR/extensions/$base" && COPIED=$((COPIED+1)) || printf "${C_YELLOW} ⚠ copy extensions/%s failed${C_RESET}\n" "$base"
402
- done
403
- fi
404
-
405
- # themes if present — skip recursive .pi artifact and nul device file
406
- # Skip if source == target (self-install) — themes already in place
407
- if [ "$SOURCE_ROOT" != "$TARGET_DIR" ] && [ -d "$SOURCE_ROOT/themes" ]; then
408
- rm -rf "$TARGET_DIR/themes" 2>/dev/null || true
409
- mkdir -p "$TARGET_DIR/themes" 2>/dev/null || true
410
- # copy themes but exclude .pi and nul
411
- for t in "$SOURCE_ROOT/themes"/*; do
412
- [ -e "$t" ] || continue
413
- bn="$(basename "$t")"
414
- case "$bn" in .pi|nul) continue;; esac
415
- cp -R "$t" "$TARGET_DIR/themes/$bn" 2>/dev/null || printf "${C_YELLOW} ⚠ copy themes/%s failed${C_RESET}\n" "$bn"
416
- done
417
- # also copy files at themes root
418
- for tf in "$SOURCE_ROOT/themes"/.* "$SOURCE_ROOT/themes"/*; do
419
- [ -f "$tf" ] || continue
420
- bn="$(basename "$tf")"
421
- case "$bn" in .|..|.pi|nul) continue;; esac
422
- cp -f "$tf" "$TARGET_DIR/themes/$bn" 2>/dev/null || true
423
- done
424
- fi
425
- set -e
426
-
427
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[3/4]${C_RESET} custom config ${C_DIM}— %s items → %s${C_RESET}\n" "$COPIED" "$TARGET_DIR"
428
- # cleanup cloned temp — per spec: clone into ~/.pi/tmp, copy agent, delete temp
429
- case "$CLONED_TMP" in
430
- "$TMP_BASE"/kiln-*) rm -rf "$CLONED_TMP" 2>/dev/null || true; CLONED_TMP="";;
431
- esac
432
- # repo README → alongside the agent dir (e.g. ~/.pi/README.md)
433
- REPO_ROOT="$(cd "$SOURCE_ROOT/.." 2>/dev/null && pwd 2>/dev/null || echo "")"
434
- TARGET_PARENT="$(cd "$(dirname "$TARGET_DIR")" 2>/dev/null && pwd 2>/dev/null || echo "")"
435
- if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/README.md" ] && [ -n "$TARGET_PARENT" ] && [ "$REPO_ROOT" != "$TARGET_PARENT" ]; then
436
- cp -f "$REPO_ROOT/README.md" "$TARGET_PARENT/README.md" 2>/dev/null || true
437
- fi
438
- fi
439
-
440
- # ── [4/4] per-extension deps ──────────────────────────────────────────────
441
- EXT_ROOT="$TARGET_DIR/extensions"
442
- if [ ! -d "$EXT_ROOT" ]; then
443
- printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[4/4]${C_RESET} no extensions found at %s\n" "$EXT_ROOT"
444
- else
445
- FAILED=""
446
- INSTALLED=0
447
- set +e
448
- for ext in "$EXT_ROOT"/*; do
449
- [ -e "$ext" ] || continue
450
- [ -d "$ext" ] || continue
451
- case "$(basename "$ext")" in *.md) continue;; esac
452
- name="$(basename "$ext")"
453
- label="extensions/$name"
454
- has_pkg=0; [ -f "$ext/package.json" ] && has_pkg=1
455
- has_sh=0; [ -f "$ext/install.sh" ] && has_sh=1
456
-
457
- if [ "$has_pkg" -eq 0 ] && [ "$has_sh" -eq 0 ]; then
458
- printf " ${C_DIM} · %s — no package.json, skipping${C_RESET}\n" "$label"
459
- continue
460
- fi
461
-
462
- if [ "$has_sh" -eq 1 ]; then
463
- run_with_spinner "4" "$label" sh "$ext/install.sh"
464
- CODE=$?
465
- else
466
- if [ -f "$ext/package-lock.json" ]; then
467
- run_with_spinner "4" "$label" npm --prefix "$ext" ci --no-audit --no-fund
468
- CODE=$?
469
- else
470
- run_with_spinner "4" "$label" npm --prefix "$ext" install --no-audit --no-fund
471
- CODE=$?
472
- fi
473
- fi
474
- if [ $CODE -eq 0 ]; then
475
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[4/4]${C_RESET} %s\n" "$label"
476
- INSTALLED=$((INSTALLED+1))
477
- else
478
- printf " ${C_RED}✖${C_RESET} ${C_DIM}[4/4]${C_RESET} %s ${C_RED}failed (exit %s)${C_RESET}\n" "$label" "$CODE"
479
- FAILED="$FAILED $name"
480
- fi
481
- done
482
- set -e
483
- if [ -z "$FAILED" ]; then
484
- printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[4/4]${C_RESET} extension deps ${C_DIM}— %s installed${C_RESET}\n" "$INSTALLED"
485
- else
486
- # count without wc -w (no fork)
487
- CNT=0; for _ in $FAILED; do CNT=$((CNT+1)); done
488
- printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[4/4]${C_RESET} extension deps ${C_YELLOW}%s failed:${C_RESET}%s ${C_DIM}see %s${C_RESET}\n" "$CNT" "$FAILED" "$TMP_LOG"
489
- fi
490
- fi
491
-
492
- # ── summary ─────────────────────────────────────────────────────────────────
493
- END_TS="$(date +%s 2>/dev/null || echo 0)"
494
- ELAPSED=$((END_TS - START_TS))
495
- [ "$ELAPSED" -lt 0 ] 2>/dev/null && ELAPSED=0
496
- if [ "$ELAPSED" -ge 60 ]; then
497
- ELAPSED_FMT="$((ELAPSED / 60))m $((ELAPSED % 60))s"
498
- else
499
- ELAPSED_FMT="${ELAPSED}s"
500
- fi
501
- printf "\n"
502
- if [ "$OK" -eq 1 ]; then
503
- printf "${C_GREEN}${C_BOLD} ✓ Setup complete${C_RESET} ${C_DIM}→ %s · Kiln installed in %s${C_RESET}\n" "$TARGET_DIR" "$ELAPSED_FMT"
504
- if command -v pi >/dev/null 2>&1; then
505
- PI_VER2="$(pi --version 2>/dev/null | tr -d '\n' | tr -d '\r' || echo "")"
506
- if [ -n "$PI_VER2" ]; then printf "${C_DIM} pi %s · run: pi${C_RESET}\n" "$PI_VER2"; fi
507
- fi
508
- else
509
- printf "${C_YELLOW}${C_BOLD} ⚠ Setup finished with warnings${C_RESET} ${C_DIM}in %s · see %s${C_RESET}\n" "$ELAPSED_FMT" "$TMP_LOG"
510
- fi
511
- printf "${C_DIM} log: %s${C_RESET}\n\n" "$TMP_LOG"
1
+ #!/bin/sh
2
+ # Pi custom-setup installer — macOS / Linux / Git Bash
3
+ # 1) Install/update pi (@earendil-works/pi-coding-agent)
4
+ # 2) Install pi packages (pi-context-usage, @baretread/pi-forge)
5
+ # 3) Install custom config (AGENTS.md, keybindings, extensions …)
6
+ # 4) Install per-extension npm deps via each extension's install.sh
7
+ #
8
+ # TUI inspired by https://pi.dev/install.sh — spinner when TTY, silent otherwise.
9
+ #
10
+ # Usage:
11
+ # curl -fsSL https://raw.githubusercontent.com/asterxsk/kiln/main/agent/install.sh | sh
12
+ # ./install.sh --repo https://github.com/asterxsk/kiln --branch main
13
+ # ./install.sh --target ~/.pi/agent --yes
14
+ # ./install.sh --local # force local checkout, skip clone
15
+ #
16
+ # Safe to re-run. Managed files are force-overwritten; per-user files
17
+ # (settings.json, taste.md, taste/, auth.json, trust.json, sessions/, etc.) are preserved
18
+ # (pass --overwrite-settings to replace settings.json — otherwise you are asked).
19
+ # Already-installed items are skipped; outdated global packages are updated.
20
+ # Only one timestamped backup (<target>.bak.*) is kept; the clone temp dir
21
+ # under ~/.pi/tmp/kiln-* is always removed.
22
+
23
+ set -eu
24
+
25
+ PI_PACKAGE="@earendil-works/pi-coding-agent"
26
+ NODE_MIN="22.19.0"
27
+ # Overridden at publish time; fallback is used only for local dev
28
+ DEFAULT_REPO="https://github.com/asterxsk/kiln"
29
+ # Disable credential prompts — public clone should never ask for GitHub login
30
+ export GIT_TERMINAL_PROMPT=0
31
+ export GCM_INTERACTIVE=never 2>/dev/null || true
32
+ export GIT_ASKPASS=echo 2>/dev/null || true
33
+ REPO_URL="${PI_CONFIG_REPO:-}"
34
+ BRANCH="${PI_CONFIG_BRANCH:-main}"
35
+ TARGET="${PI_AGENT_DIR:-}"
36
+ SKIP_PI=0
37
+ SKIP_PACKAGES=0
38
+ ASSUME_YES=0
39
+ FORCE_LOCAL=0
40
+ SETTINGS_MODE="ask" # ask | overwrite | keep
41
+ CLONED_TMP=""
42
+ START_TS="$(date +%s 2>/dev/null || echo 0)"
43
+
44
+ # ── tmp + log under ~/.pi/tmp ────────────────────────────────────────────
45
+ mkdir -p "$HOME/.pi/tmp" 2>/dev/null || true
46
+ TMP_BASE="$HOME/.pi/tmp"
47
+ if command -v mktemp >/dev/null 2>&1; then
48
+ TMP_LOG="$(mktemp "$TMP_BASE/pi-setup-XXXXXX.log" 2>/dev/null || echo "$TMP_BASE/pi-setup-$$.log")"
49
+ else
50
+ TMP_LOG="$TMP_BASE/pi-setup-$$.log"
51
+ fi
52
+ : > "$TMP_LOG" 2>/dev/null || true
53
+ # shellcheck disable=SC2064
54
+ trap 'rm -f "$TMP_LOG.sp" 2>/dev/null; if [ -n "$CLONED_TMP" ] && [ -d "$CLONED_TMP" ]; then rm -rf "$CLONED_TMP"; fi' EXIT INT TERM
55
+
56
+ # ── args ────────────────────────────────────────────────────────────────────
57
+ while [ $# -gt 0 ]; do
58
+ case "$1" in
59
+ --repo) REPO_URL="$2"; shift 2;;
60
+ --branch) BRANCH="$2"; shift 2;;
61
+ --target) TARGET="$2"; shift 2;;
62
+ --skip-pi) SKIP_PI=1; shift;;
63
+ --skip-packages) SKIP_PACKAGES=1; shift;;
64
+ --local) FORCE_LOCAL=1; shift;;
65
+ --overwrite-settings) SETTINGS_MODE="overwrite"; shift;;
66
+ --keep-settings) SETTINGS_MODE="keep"; shift;;
67
+ --yes|-y) ASSUME_YES=1; shift;;
68
+ --help|-h) echo "Usage: $0 [--repo URL] [--branch BRANCH] [--target DIR] [--local] [--overwrite-settings|--keep-settings] [--yes]"; exit 0;;
69
+ --) shift; break;;
70
+ -*) echo "unknown arg: $1" >&2; exit 1;;
71
+ *) break;;
72
+ esac
73
+ done
74
+
75
+ # ── ANSI / VT ───────────────────────────────────────────────────────────────
76
+ ESC="$(printf '\033')"
77
+ CR="$(printf '\r')"
78
+ if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then HAS_ANSI=1; else HAS_ANSI=0; fi
79
+ if [ "$HAS_ANSI" -eq 1 ]; then
80
+ C_RESET="${ESC}[0m"; C_BOLD="${ESC}[1m"; C_DIM="${ESC}[2m"
81
+ C_GREEN="${ESC}[32m"; C_YELLOW="${ESC}[33m"; C_RED="${ESC}[31m"
82
+ C_CYAN="${ESC}[36m"; C_GRAY="${ESC}[90m"
83
+ else
84
+ C_RESET=""; C_BOLD=""; C_DIM=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_CYAN=""; C_GRAY=""
85
+ fi
86
+
87
+ # Spinner frames (braille). Kept as a real array so the index advances in the
88
+ # PARENT shell — calling get_spinner inside a $(...) subshell discarded the
89
+ # increment and froze the spinner on frame 0.
90
+ SPINNER_FRAMES=(⠋ ⠙ ⠹ ⠸ ⠼ ⠰ ⠠ ⠦ ⠧ ⠇ ⠏)
91
+ SPINNER_N=${#SPINNER_FRAMES[@]}
92
+ SPINNER_IDX=0
93
+
94
+ write_title() {
95
+ if [ "$HAS_ANSI" -eq 1 ]; then
96
+ printf "${ESC}[1m \u25C6 Pi Setup${ESC}[0m\n${ESC}[2m custom agent config \u00B7 pi + forge + extensions${ESC}[0m\n\n"
97
+ else
98
+ printf "\n Pi Setup\n custom agent config · pi + forge + extensions\n\n"
99
+ fi
100
+ }
101
+
102
+ # ── spinner runner ──────────────────────────────────────────────────────────
103
+ # run_with_spinner STEP LABEL CMD...
104
+ # Synchronous when no TTY (curl|sh, CI, Windows Git Bash without VT).
105
+ # When TTY, shows spinner and overwrites line with \r.
106
+ run_with_spinner() {
107
+ STEP="$1"; LABEL="$2"; shift 2
108
+ : > "$TMP_LOG.sp" 2>/dev/null || true
109
+ if [ "$HAS_ANSI" -eq 0 ]; then
110
+ # plain run — no spinner, keep output quiet (append to log)
111
+ set +e
112
+ "$@" >"$TMP_LOG.sp" 2>&1; CODE=$?
113
+ set -e
114
+ cat "$TMP_LOG.sp" >> "$TMP_LOG" 2>/dev/null || true
115
+ return $CODE
116
+ fi
117
+ # TTY: spinner
118
+ SPINNER_IDX=0
119
+ set +e
120
+ "$@" >"$TMP_LOG.sp" 2>&1 &
121
+ PID=$!
122
+ set -e
123
+ while kill -0 "$PID" 2>/dev/null; do
124
+ SPIN="${SPINNER_FRAMES[$SPINNER_IDX]}"
125
+ SPINNER_IDX=$(( (SPINNER_IDX + 1) % SPINNER_N ))
126
+ # \r + Erase-in-Line so each frame overwrites cleanly regardless of length
127
+ printf "${CR}${ESC}[K ${C_YELLOW}%s${C_RESET} ${C_DIM}[%s/4]${C_RESET} %s" "$SPIN" "$STEP" "$LABEL"
128
+ sleep 0.08
129
+ done
130
+ set +e
131
+ wait "$PID" 2>/dev/null; CODE=$?
132
+ set -e
133
+ cat "$TMP_LOG.sp" >> "$TMP_LOG" 2>/dev/null || true
134
+ printf "${CR}${ESC}[K"
135
+ return $CODE
136
+ }
137
+
138
+ # ── helpers ─────────────────────────────────────────────────────────────────
139
+ resolve_target() {
140
+ if [ -n "$TARGET" ]; then printf "%s" "$TARGET"; return; fi
141
+ # Git Bash on Windows: prefer Windows path if cygpath available
142
+ if command -v cygpath >/dev/null 2>&1; then
143
+ printf "%s" "$(cygpath -w "$HOME/.pi/agent" 2>/dev/null || echo "$HOME/.pi/agent")"
144
+ return
145
+ fi
146
+ printf "%s/.pi/agent" "$HOME"
147
+ }
148
+
149
+ resolve_repo() {
150
+ if [ -n "$REPO_URL" ]; then printf "%s" "$REPO_URL"; return; fi
151
+ # Don't probe when piped (curl | sh) — $0 is sh/dash
152
+ case "$0" in sh|dash|*\/sh|*\/dash|-) printf "%s" "$DEFAULT_REPO"; return;; esac
153
+ HERE=""
154
+ # Try to find a git checkout near the script
155
+ _self=""
156
+ case "$0" in /*|*/*) _self="$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null || echo "")";; esac
157
+ for r in "$_self" "$(pwd)" "$(pwd)/agent"; do
158
+ [ -z "$r" ] && continue
159
+ if [ -d "$r/.git" ] || [ -d "$r/../.git" ]; then
160
+ U="$(git -C "$r" remote get-url origin 2>/dev/null || true)"
161
+ if [ -n "$U" ]; then printf "%s" "$U"; return; fi
162
+ fi
163
+ done
164
+ printf "%s" "$DEFAULT_REPO"
165
+ }
166
+
167
+ resolve_source_root() {
168
+ REPO="$1"
169
+ # If forced local or script is inside a checkout, use it
170
+ if [ "$FORCE_LOCAL" -eq 1 ]; then
171
+ for r in "$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null || echo "")" "$(pwd)" "$(pwd)/agent"; do
172
+ [ -z "$r" ] && continue
173
+ if [ -d "$r/extensions" ] && [ -f "$r/AGENTS.md" ]; then printf "%s" "$r"; return 0; fi
174
+ if [ -d "$r/agent/extensions" ]; then printf "%s" "$r/agent"; return 0; fi
175
+ done
176
+ else
177
+ # When not piped, check for local checkout first to avoid clone
178
+ case "$0" in sh|dash|*\/sh|*\/dash|-) :;; # piped — skip local check
179
+ *)
180
+ for r in "$(cd "$(dirname "$0")" 2>/dev/null && pwd 2>/dev/null || echo "")" "$(pwd)" "$(pwd)/agent"; do
181
+ [ -z "$r" ] && continue
182
+ if [ -d "$r/extensions" ] && [ -f "$r/AGENTS.md" ]; then printf "%s" "$r"; return 0; fi
183
+ if [ -d "$r/agent/extensions" ]; then printf "%s" "$r/agent"; return 0; fi
184
+ done
185
+ ;;
186
+ esac
187
+ fi
188
+ # Clone to ~/.pi/tmp — simple git clone, no account required, then copy agent folder.
189
+ # Use a unique subdir per run so concurrent installs never clobber each other.
190
+ mkdir -p "$TMP_BASE" 2>/dev/null || true
191
+ if command -v mktemp >/dev/null 2>&1; then
192
+ TMP="$(mktemp -d "$TMP_BASE/kiln-XXXXXX" 2>/dev/null || echo "$TMP_BASE/kiln-$$")"
193
+ else
194
+ TMP="$TMP_BASE/kiln-$$"
195
+ fi
196
+ mkdir -p "$TMP" 2>/dev/null || true
197
+ CLONED_TMP="$TMP"
198
+ printf "${C_DIM} → cloning %s (branch %s) → %s${C_RESET}\n" "$REPO" "$BRANCH" "$TMP" >&2
199
+ set +e
200
+ run_with_spinner "3" "fetching config" env GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=echo GCM_INTERACTIVE=never git clone --depth 1 --branch "$BRANCH" "$REPO" "$TMP"
201
+ CODE=$?
202
+ set -e
203
+ if [ $CODE -ne 0 ]; then
204
+ echo "git clone failed (exit $CODE) — see $TMP_LOG" >&2
205
+ return 1
206
+ fi
207
+ if [ -d "$TMP/agent" ]; then printf "%s" "$TMP/agent"; else printf "%s" "$TMP"; fi
208
+ }
209
+
210
+ # Portable version compare: returns 0 if HAVE >= MIN
211
+ version_ge() {
212
+ HAVE="$1"; MIN="$2"
213
+ # Use node itself if available (most reliable), else awk
214
+ if command -v node >/dev/null 2>&1; then
215
+ node -e "const [h,m]=process.argv.slice(1).map(v=>v.split('.').map(Number));let ge=true;for(let i=0;i<Math.max(h.length,m.length);i++){const a=h[i]||0,b=m[i]||0;if(a>b)break;if(a<b){ge=false;break}}process.exit(ge?0:1)" "$HAVE" "$MIN" 2>/dev/null && return 0 || return 1
216
+ fi
217
+ # awk fallback
218
+ printf "%s\n%s\n" "$HAVE" "$MIN" | awk -F. '
219
+ NR==1{split($0,h,"."); next}
220
+ {split($0,m,"."); for(i=1;i<=3;i++){a=h[i]+0; b=m[i]+0; if(a>b)exit 0; if(a<b)exit 1} exit 0}
221
+ ' 2>/dev/null && return 0 || return 1
222
+ # last resort: string compare
223
+ [ "$HAVE" = "$MIN" ] && return 0
224
+ [ "$(printf "%s\n%s" "$HAVE" "$MIN" | sort 2>/dev/null | head -n1)" = "$MIN" ] && return 0 || return 1
225
+ }
226
+
227
+ # Installed global version of a package ("" when not installed).
228
+ npm_global_ver() {
229
+ npm ls -g "$1" --depth=0 2>/dev/null | grep -F "$1@" | sed -n 's|.*@\([0-9][0-9A-Za-z.+_-]*\).*|\1|p' | head -n1 | tr -d '\r\n '
230
+ }
231
+ # Latest registry version ("" when offline / unknown).
232
+ npm_latest_ver() {
233
+ _v="$(npm view "$1" version 2>/dev/null | head -n1 | tr -d '\r\n ')"
234
+ case "$_v" in [0-9]*) printf "%s" "$_v";; *) printf "";; esac
235
+ }
236
+ # Keep only the newest sibling backup (<target>.bak.*); delete the rest.
237
+ prune_backups() {
238
+ _pb_keep="$(basename "$2")"
239
+ for _pb_d in "$(dirname "$1")"/"$(basename "$1")".bak.*; do
240
+ [ -e "$_pb_d" ] || continue
241
+ [ "$(basename "$_pb_d")" = "$_pb_keep" ] && continue
242
+ rm -rf "$_pb_d" 2>/dev/null || true
243
+ done
244
+ }
245
+ # Remove stale clone dirs (~/.pi/tmp/kiln-*) except the active one.
246
+ sweep_stale_tmp() {
247
+ for _sw_d in "$TMP_BASE"/kiln-*; do
248
+ [ -e "$_sw_d" ] || continue
249
+ [ -d "$_sw_d" ] || continue
250
+ case "$CLONED_TMP" in "$_sw_d") continue;; esac
251
+ rm -rf "$_sw_d" 2>/dev/null || true
252
+ done
253
+ }
254
+ # Returns 0 when the user wants settings.json overwritten, 1 to keep.
255
+ want_overwrite_settings() {
256
+ if [ "$SETTINGS_MODE" = "overwrite" ]; then return 0; fi
257
+ if [ "$SETTINGS_MODE" = "keep" ]; then return 1; fi
258
+ if [ "$ASSUME_YES" -eq 1 ]; then return 1; fi
259
+ if [ ! -t 0 ] && [ ! -r /dev/tty ]; then return 1; fi
260
+ printf " settings.json exists. Overwrite with repo defaults? [y/N] (Recommended for first-time setup) "
261
+ _ans=""; read -r _ans < /dev/tty 2>/dev/null || read -r _ans 2>/dev/null || _ans=""
262
+ case "$_ans" in [yY]|[yY][eE][sS]) return 0;; *) return 1;; esac
263
+ }
264
+
265
+ # ── MAIN ─────────────────────────────────────────────────────────────────────
266
+ write_title
267
+
268
+ # Copy a tree excluding heavy/unwanted dirs (node_modules, .git).
269
+ # Uses rsync when available, else cp + prune.
270
+ # No-op when source and destination are the same path (self-install).
271
+ copy_tree() {
272
+ _src="$1"; _dst="$2"
273
+ if [ "$_src" = "$_dst" ]; then return 0; fi
274
+ # Resolve canonical paths to catch ./ vs absolute same target
275
+ _src_can="$(cd "$_src" 2>/dev/null && pwd 2>/dev/null || echo "$_src")"
276
+ _dst_can="$(cd "$_dst" 2>/dev/null && pwd 2>/dev/null || echo "$_dst")"
277
+ if [ "$_src_can" = "$_dst_can" ]; then return 0; fi
278
+ rm -rf "$_dst" 2>/dev/null || true
279
+ if command -v rsync >/dev/null 2>&1; then
280
+ rsync -a --exclude node_modules --exclude '.git' "$_src/" "$_dst/" 2>/dev/null || \
281
+ { cp -R "$_src" "$_dst" 2>/dev/null || true; rm -rf "$_dst/node_modules" "$_dst/.git" 2>/dev/null || true; }
282
+ else
283
+ cp -R "$_src" "$_dst" 2>/dev/null || true
284
+ rm -rf "$_dst/node_modules" "$_dst/.git" 2>/dev/null || true
285
+ fi
286
+ }
287
+
288
+ TARGET_DIR="$(resolve_target)"
289
+ REPO_URL="$(resolve_repo)"
290
+ OK=1
291
+
292
+ printf "${C_DIM} target ${C_RESET}%s\n" "$TARGET_DIR"
293
+ printf "${C_DIM} repo ${C_RESET}%s ${C_DIM}(%s)${C_RESET}\n" "$REPO_URL" "$BRANCH"
294
+ printf "${C_DIM} log ${C_RESET}%s\n\n" "$TMP_LOG"
295
+
296
+ # ── preflight ────────────────────────────────────────────────────────────────
297
+ if ! command -v node >/dev/null 2>&1; then
298
+ printf "${C_RED} ✖ Node.js not found. Install Node %s+ from https://nodejs.org${C_RESET}\n" "$NODE_MIN"
299
+ printf " then re-run this installer.\n\n"
300
+ exit 1
301
+ fi
302
+ NODE_VER="$(node --version 2>/dev/null | sed 's/^v//;s/[^0-9.].*//' | cut -d- -f1)"
303
+ if ! version_ge "$NODE_VER" "$NODE_MIN"; then
304
+ printf "${C_YELLOW} ⚠ Node %s < %s — please upgrade to Node %s+${C_RESET}\n" "$NODE_VER" "$NODE_MIN" "$NODE_MIN"
305
+ if [ "$ASSUME_YES" -eq 0 ]; then printf " continue anyway in 3s…\n"; sleep 3; fi
306
+ fi
307
+ if ! command -v npm >/dev/null 2>&1; then
308
+ printf "${C_RED} ✖ npm not found (Node installed but npm missing)${C_RESET}\n"; exit 1
309
+ fi
310
+ if ! command -v git >/dev/null 2>&1; then
311
+ printf "${C_RED} ✖ git not found — required to fetch config${C_RESET}\n"
312
+ printf " install via xcode-select --install or sudo apt install git\n\n"; exit 1
313
+ fi
314
+ NPM_VER="$(npm --version 2>/dev/null | tr -d '\n' | tr -d '\r')"
315
+ GIT_VER="$(git --version 2>/dev/null | tr -d '\n' | tr -d '\r')"
316
+ printf "${C_DIM} node %s · npm %s · %s${C_RESET}\n\n" "$NODE_VER" "$NPM_VER" "$GIT_VER"
317
+
318
+ # ── [1/4] pi ────────────────────────────────────────────────────────────────
319
+ if [ "$SKIP_PI" -eq 1 ]; then
320
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} Install pi ${C_DIM}— skipped${C_RESET}\n"
321
+ elif command -v pi >/dev/null 2>&1; then
322
+ PI_INSTALLED="$(npm_global_ver "$PI_PACKAGE")"
323
+ if [ -z "$PI_INSTALLED" ]; then PI_INSTALLED="$(pi --version 2>/dev/null | tr -d '\n' | tr -d '\r' | sed 's/^v//')"; fi
324
+ PI_LATEST="$(npm_latest_ver "$PI_PACKAGE")"
325
+ if [ -n "$PI_INSTALLED" ] && [ -n "$PI_LATEST" ] && [ "$PI_INSTALLED" = "$PI_LATEST" ]; then
326
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} pi ${C_DIM}— already installed (%s), skipping${C_RESET}\n" "$PI_INSTALLED"
327
+ elif [ -n "$PI_INSTALLED" ] && [ -z "$PI_LATEST" ]; then
328
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} pi ${C_DIM}— already installed (%s), skipping version check (offline)${C_RESET}\n" "$PI_INSTALLED"
329
+ else
330
+ if [ -n "$PI_INSTALLED" ] && [ -n "$PI_LATEST" ]; then LABEL="updating pi $PI_INSTALLED → $PI_LATEST ($PI_PACKAGE)"; else LABEL="installing pi ($PI_PACKAGE)"; fi
331
+ set +e
332
+ run_with_spinner "1" "$LABEL" npm install -g "$PI_PACKAGE@latest" --no-audit --no-fund --min-release-age=0
333
+ CODE=$?
334
+ set -e
335
+ if [ $CODE -eq 0 ]; then
336
+ PI_VER="$(pi --version 2>/dev/null | tr -d '\n' | tr -d '\r' || echo "?")"
337
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_DIM}— %s${C_RESET}\n" "$LABEL" "$PI_VER"
338
+ else
339
+ # Check for EACCES
340
+ if grep -q "EACCES\|permission" "$TMP_LOG" 2>/dev/null; then
341
+ printf " ${C_RED}✖${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_RED}failed (permission)${C_RESET}\n" "$LABEL"
342
+ printf " ${C_DIM}try: sudo npm install -g %s or npm config set prefix ~/.npm-global${C_RESET}\n" "$PI_PACKAGE"
343
+ else
344
+ printf " ${C_RED}✖${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_RED}failed (exit %s)${C_RESET} ${C_DIM}see %s${C_RESET}\n" "$LABEL" "$CODE" "$TMP_LOG"
345
+ fi
346
+ printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
347
+ OK=0
348
+ fi
349
+ fi
350
+ else
351
+ if command -v pi >/dev/null 2>&1; then LABEL="updating pi ($PI_PACKAGE)"; else LABEL="installing pi ($PI_PACKAGE)"; fi
352
+ set +e
353
+ run_with_spinner "1" "$LABEL" npm install -g "$PI_PACKAGE@latest" --no-audit --no-fund --min-release-age=0
354
+ CODE=$?
355
+ set -e
356
+ if [ $CODE -eq 0 ]; then
357
+ PI_VER="$(pi --version 2>/dev/null | tr -d '\n' | tr -d '\r' || echo "?")"
358
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_DIM}— %s${C_RESET}\n" "$LABEL" "$PI_VER"
359
+ else
360
+ # Check for EACCES
361
+ if grep -q "EACCES\|permission" "$TMP_LOG" 2>/dev/null; then
362
+ printf " ${C_RED}✖${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_RED}failed (permission)${C_RESET}\n" "$LABEL"
363
+ printf " ${C_DIM}try: sudo npm install -g %s or npm config set prefix ~/.npm-global${C_RESET}\n" "$PI_PACKAGE"
364
+ else
365
+ printf " ${C_RED}✖${C_RESET} ${C_DIM}[1/4]${C_RESET} %s ${C_RED}failed (exit %s)${C_RESET} ${C_DIM}see %s${C_RESET}\n" "$LABEL" "$CODE" "$TMP_LOG"
366
+ fi
367
+ printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
368
+ OK=0
369
+ fi
370
+ fi
371
+
372
+ # ── [2/4] pi packages ─────────────────────────────────────────────────────
373
+ if [ "$SKIP_PACKAGES" -eq 1 ]; then
374
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages ${C_DIM}— skipped${C_RESET}\n"
375
+ else
376
+ NEED=""
377
+ for _pkg in pi-context-usage @baretread/pi-forge; do
378
+ _inst="$(npm_global_ver "$_pkg")"
379
+ _latest="$(npm_latest_ver "$_pkg")"
380
+ if [ -n "$_inst" ] && [ -n "$_latest" ] && [ "$_inst" = "$_latest" ]; then
381
+ printf "${C_DIM} · %s@%s already installed, skipping${C_RESET}\n" "$_pkg" "$_inst"
382
+ elif [ -n "$_inst" ] && [ -z "$_latest" ]; then
383
+ printf "${C_DIM} · %s@%s already installed, skipping version check (offline)${C_RESET}\n" "$_pkg" "$_inst"
384
+ else
385
+ if [ -n "$_inst" ]; then printf "${C_DIM} · %s %s → %s — will update${C_RESET}\n" "$_pkg" "$_inst" "${_latest:-latest}"; fi
386
+ NEED="$NEED $_pkg@latest"
387
+ fi
388
+ done
389
+ if [ -z "$NEED" ]; then
390
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages ${C_DIM}— already installed, skipping${C_RESET}\n"
391
+ else
392
+ LABEL="installing$NEED"
393
+ set +e
394
+ # shellcheck disable=SC2086
395
+ run_with_spinner "2" "$LABEL" npm install -g $NEED --no-audit --no-fund
396
+ CODE=$?
397
+ set -e
398
+ if [ $CODE -eq 0 ]; then
399
+ # Verify packages are resolvable (handles npm global prefix quirks with mise/nvm)
400
+ if ! npm ls -g pi-context-usage >/dev/null 2>&1 || ! npm ls -g @baretread/pi-forge >/dev/null 2>&1; then
401
+ printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages installed but not in global ls — retrying with prefix check${C_RESET}\n"
402
+ fi
403
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[2/4]${C_RESET} pi packages ${C_DIM}— done${C_RESET}\n"
404
+ else
405
+ printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[2/4]${C_RESET} %s ${C_YELLOW}exit %s — continuing (pi will auto-install)${C_RESET}\n" "$LABEL" "$CODE"
406
+ printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
407
+ fi
408
+ fi
409
+ fi
410
+
411
+ # ── [3/4] custom config ───────────────────────────────────────────────────
412
+ set +e
413
+ # NOTE: no command substitution here — resolve_source_root records the clone
414
+ # dir in $CLONED_TMP, which would be lost in a $(...) subshell (that is why
415
+ # ~/.pi/tmp was never deleted after install).
416
+ resolve_source_root "$REPO_URL" >"$TMP_LOG.src"
417
+ SRC_CODE=$?
418
+ SOURCE_ROOT="$(cat "$TMP_LOG.src" 2>/dev/null || echo "")"
419
+ rm -f "$TMP_LOG.src" 2>/dev/null || true
420
+ set -e
421
+ if [ $SRC_CODE -ne 0 ] || [ -z "$SOURCE_ROOT" ] || [ ! -d "$SOURCE_ROOT" ]; then
422
+ printf " ${C_RED}✖${C_RESET} ${C_DIM}[3/4]${C_RESET} custom config ${C_RED}failed:${C_RESET} source not found\n"
423
+ printf " ${C_DIM}see %s${C_RESET}\n" "$TMP_LOG"
424
+ OK=0
425
+ else
426
+ printf "${C_DIM} source %s${C_RESET}\n" "$SOURCE_ROOT"
427
+ mkdir -p "$TARGET_DIR" 2>/dev/null || true
428
+
429
+ # backup (managed files only: extensions + top-level config files).
430
+ # Per-user data (settings.json, auth.json, sessions/, taste/, etc.) is never
431
+ # touched by the installer, so it is deliberately NOT backed up.
432
+ BAK=""
433
+ if [ -f "$TARGET_DIR/AGENTS.md" ] || [ -d "$TARGET_DIR/extensions" ]; then
434
+ BAK="$TARGET_DIR.bak.$(date +%Y%m%d-%H%M%S 2>/dev/null || echo $$)"
435
+ printf "${C_DIM} backup %s/extensions → %s${C_RESET}\n" "$TARGET_DIR" "$BAK"
436
+ set +e
437
+ mkdir -p "$BAK" 2>/dev/null || true
438
+ for f in AGENTS.md keybindings.json README.md; do
439
+ [ -f "$TARGET_DIR/$f" ] && cp -f "$TARGET_DIR/$f" "$BAK/$f" 2>/dev/null || true
440
+ done
441
+ if [ -d "$TARGET_DIR/extensions" ]; then
442
+ copy_tree "$TARGET_DIR/extensions" "$BAK/extensions" || printf "${C_YELLOW} ⚠ backup extensions failed${C_RESET}\n"
443
+ fi
444
+ set -e
445
+ prune_backups "$TARGET_DIR" "$BAK" # only one backup at a time
446
+ fi
447
+
448
+ COPIED=0
449
+ set +e
450
+ for f in AGENTS.md keybindings.json README.md; do
451
+ if [ -f "$SOURCE_ROOT/$f" ]; then
452
+ # Self-install: source == target — no copy needed, just count
453
+ if [ "$SOURCE_ROOT/$f" = "$TARGET_DIR/$f" ] || [ "$SOURCE_ROOT" = "$TARGET_DIR" ]; then
454
+ COPIED=$((COPIED+1))
455
+ else
456
+ cp -f "$SOURCE_ROOT/$f" "$TARGET_DIR/$f" 2>/dev/null && COPIED=$((COPIED+1)) || printf "${C_YELLOW} ⚠ copy %s failed${C_RESET}\n" "$f"
457
+ fi
458
+ fi
459
+ done
460
+ # per-user files: seed from repo defaults if missing; otherwise keep,
461
+ # unless --overwrite-settings is passed or the user answers Y.
462
+ SETTINGS_JUST_CREATED=0
463
+ if [ ! -f "$TARGET_DIR/settings.json" ] && [ -f "$SOURCE_ROOT/settings.json" ]; then
464
+ if cp "$SOURCE_ROOT/settings.json" "$TARGET_DIR/settings.json" 2>/dev/null; then
465
+ printf "${C_DIM} created settings.json from repo defaults${C_RESET}\n"
466
+ SETTINGS_JUST_CREATED=1
467
+ fi
468
+ elif [ -f "$TARGET_DIR/settings.json" ]; then
469
+ if want_overwrite_settings && [ -f "$SOURCE_ROOT/settings.json" ]; then
470
+ if [ -n "$BAK" ]; then cp -f "$TARGET_DIR/settings.json" "$BAK/settings.json" 2>/dev/null || true; fi
471
+ if cp -f "$SOURCE_ROOT/settings.json" "$TARGET_DIR/settings.json" 2>/dev/null; then
472
+ printf "${C_DIM} overwrote settings.json from repo defaults${C_RESET}\n"
473
+ SETTINGS_JUST_CREATED=1
474
+ fi
475
+ else
476
+ printf "${C_DIM} kept existing settings.json${C_RESET}\n"
477
+ fi
478
+ fi
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 [ "$SETTINGS_JUST_CREATED" = "1" ] && [ -f "$TARGET_DIR/settings.json" ] && command -v node >/dev/null 2>&1; then
482
+ node -e "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){}" "$TARGET_DIR/settings.json" 2>/dev/null || true
483
+ fi
484
+ # taste.md / taste/ — user-specific, preserve if exists
485
+ for _preserve in taste.md taste taste.json; do
486
+ if [ -f "$TARGET_DIR/$_preserve" ] || [ -d "$TARGET_DIR/$_preserve" ]; then
487
+ printf "${C_DIM} kept existing %s${C_RESET}\n" "$_preserve"
488
+ elif [ -e "$SOURCE_ROOT/$_preserve" ]; then
489
+ cp -R "$SOURCE_ROOT/$_preserve" "$TARGET_DIR/$_preserve" 2>/dev/null || true
490
+ fi
491
+ done
492
+ # auth.json / trust.json / sessions etc. are never shipped in kiln, so no copy needed; they remain untouched
493
+
494
+ # extensions — full sync (set +e so one bad copy doesn't abort)
495
+ if [ -d "$SOURCE_ROOT/extensions" ]; then
496
+ mkdir -p "$TARGET_DIR/extensions" 2>/dev/null || true
497
+ for d in "$SOURCE_ROOT/extensions"/*; do
498
+ [ -e "$d" ] || continue
499
+ [ -d "$d" ] || continue
500
+ # skip stray files like AGENTS.md inside extensions
501
+ case "$(basename "$d")" in *.md) continue;; esac
502
+ base="$(basename "$d")"
503
+ copy_tree "$d" "$TARGET_DIR/extensions/$base" && COPIED=$((COPIED+1)) || printf "${C_YELLOW} ⚠ copy extensions/%s failed${C_RESET}\n" "$base"
504
+ done
505
+ fi
506
+
507
+ # themes if present — skip recursive .pi artifact and nul device file
508
+ # Skip if source == target (self-install) — themes already in place
509
+ if [ "$SOURCE_ROOT" != "$TARGET_DIR" ] && [ -d "$SOURCE_ROOT/themes" ]; then
510
+ rm -rf "$TARGET_DIR/themes" 2>/dev/null || true
511
+ mkdir -p "$TARGET_DIR/themes" 2>/dev/null || true
512
+ # copy themes but exclude .pi and nul
513
+ for t in "$SOURCE_ROOT/themes"/*; do
514
+ [ -e "$t" ] || continue
515
+ bn="$(basename "$t")"
516
+ case "$bn" in .pi|nul) continue;; esac
517
+ cp -R "$t" "$TARGET_DIR/themes/$bn" 2>/dev/null || printf "${C_YELLOW} ⚠ copy themes/%s failed${C_RESET}\n" "$bn"
518
+ done
519
+ # also copy files at themes root
520
+ for tf in "$SOURCE_ROOT/themes"/.* "$SOURCE_ROOT/themes"/*; do
521
+ [ -f "$tf" ] || continue
522
+ bn="$(basename "$tf")"
523
+ case "$bn" in .|..|.pi|nul) continue;; esac
524
+ cp -f "$tf" "$TARGET_DIR/themes/$bn" 2>/dev/null || true
525
+ done
526
+ fi
527
+ set -e
528
+
529
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[3/4]${C_RESET} custom config ${C_DIM}— %s items → %s${C_RESET}\n" "$COPIED" "$TARGET_DIR"
530
+ # cleanup cloned temp — per spec: clone into ~/.pi/tmp, copy agent, delete temp
531
+ case "$CLONED_TMP" in
532
+ "$TMP_BASE"/kiln-*) rm -rf "$CLONED_TMP" 2>/dev/null || true; CLONED_TMP="";;
533
+ esac
534
+ sweep_stale_tmp # delete any leftover kiln-* temp dirs
535
+ # repo README → alongside the agent dir (e.g. ~/.pi/README.md)
536
+ REPO_ROOT="$(cd "$SOURCE_ROOT/.." 2>/dev/null && pwd 2>/dev/null || echo "")"
537
+ TARGET_PARENT="$(cd "$(dirname "$TARGET_DIR")" 2>/dev/null && pwd 2>/dev/null || echo "")"
538
+ if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/README.md" ] && [ -n "$TARGET_PARENT" ] && [ "$REPO_ROOT" != "$TARGET_PARENT" ]; then
539
+ cp -f "$REPO_ROOT/README.md" "$TARGET_PARENT/README.md" 2>/dev/null || true
540
+ fi
541
+ fi
542
+
543
+ # ── [4/4] per-extension deps ──────────────────────────────────────────────
544
+ EXT_ROOT="$TARGET_DIR/extensions"
545
+ if [ ! -d "$EXT_ROOT" ]; then
546
+ printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[4/4]${C_RESET} no extensions found at %s\n" "$EXT_ROOT"
547
+ else
548
+ FAILED=""
549
+ INSTALLED=0
550
+ SKIPPED=0
551
+ set +e
552
+ for ext in "$EXT_ROOT"/*; do
553
+ [ -e "$ext" ] || continue
554
+ [ -d "$ext" ] || continue
555
+ case "$(basename "$ext")" in *.md) continue;; esac
556
+ name="$(basename "$ext")"
557
+ label="extensions/$name"
558
+ if [ -d "$ext/node_modules" ]; then
559
+ printf " ${C_DIM} · %s — already installed, skipping${C_RESET}\n" "$label"
560
+ SKIPPED=$((SKIPPED+1))
561
+ continue
562
+ fi
563
+ has_pkg=0; [ -f "$ext/package.json" ] && has_pkg=1
564
+ has_sh=0; [ -f "$ext/install.sh" ] && has_sh=1
565
+
566
+ if [ "$has_pkg" -eq 0 ] && [ "$has_sh" -eq 0 ]; then
567
+ printf " ${C_DIM} · %s — no package.json, skipping${C_RESET}\n" "$label"
568
+ continue
569
+ fi
570
+
571
+ if [ "$has_sh" -eq 1 ]; then
572
+ run_with_spinner "4" "$label" sh "$ext/install.sh"
573
+ CODE=$?
574
+ else
575
+ if [ -f "$ext/package-lock.json" ]; then
576
+ run_with_spinner "4" "$label" npm --prefix "$ext" ci --no-audit --no-fund
577
+ CODE=$?
578
+ else
579
+ run_with_spinner "4" "$label" npm --prefix "$ext" install --no-audit --no-fund
580
+ CODE=$?
581
+ fi
582
+ fi
583
+ if [ $CODE -eq 0 ]; then
584
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[4/4]${C_RESET} %s\n" "$label"
585
+ INSTALLED=$((INSTALLED+1))
586
+ else
587
+ printf " ${C_RED}✖${C_RESET} ${C_DIM}[4/4]${C_RESET} %s ${C_RED}failed (exit %s)${C_RESET}\n" "$label" "$CODE"
588
+ FAILED="$FAILED $name"
589
+ fi
590
+ done
591
+ set -e
592
+ if [ -z "$FAILED" ]; then
593
+ printf " ${C_GREEN}✔${C_RESET} ${C_DIM}[4/4]${C_RESET} extension deps ${C_DIM}— %s installed, %s skipped${C_RESET}\n" "$INSTALLED" "$SKIPPED"
594
+ else
595
+ # count without wc -w (no fork)
596
+ CNT=0; for _ in $FAILED; do CNT=$((CNT+1)); done
597
+ printf " ${C_YELLOW}⚠${C_RESET} ${C_DIM}[4/4]${C_RESET} extension deps ${C_YELLOW}%s failed:${C_RESET}%s ${C_DIM}see %s${C_RESET}\n" "$CNT" "$FAILED" "$TMP_LOG"
598
+ fi
599
+ fi
600
+
601
+ # ── summary ─────────────────────────────────────────────────────────────────
602
+ END_TS="$(date +%s 2>/dev/null || echo 0)"
603
+ ELAPSED=$((END_TS - START_TS))
604
+ [ "$ELAPSED" -lt 0 ] 2>/dev/null && ELAPSED=0
605
+ if [ "$ELAPSED" -ge 60 ]; then
606
+ ELAPSED_FMT="$((ELAPSED / 60))m $((ELAPSED % 60))s"
607
+ else
608
+ ELAPSED_FMT="${ELAPSED}s"
609
+ fi
610
+ printf "\n"
611
+ if [ "$OK" -eq 1 ]; then
612
+ printf "${C_GREEN}${C_BOLD} ✓ Setup complete${C_RESET} ${C_DIM}→ %s · Kiln installed in %s${C_RESET}\n" "$TARGET_DIR" "$ELAPSED_FMT"
613
+ if command -v pi >/dev/null 2>&1; then
614
+ PI_VER2="$(pi --version 2>/dev/null | tr -d '\n' | tr -d '\r' || echo "")"
615
+ if [ -n "$PI_VER2" ]; then printf "${C_DIM} pi %s · run: pi${C_RESET}\n" "$PI_VER2"; fi
616
+ fi
617
+ else
618
+ printf "${C_YELLOW}${C_BOLD} ⚠ Setup finished with warnings${C_RESET} ${C_DIM}in %s · see %s${C_RESET}\n" "$ELAPSED_FMT" "$TMP_LOG"
619
+ fi
620
+ printf "${C_DIM} log: %s${C_RESET}\n\n" "$TMP_LOG"