docks-kit 0.1.0 → 0.1.2

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 (39) hide show
  1. package/AGENTS.md +14 -15
  2. package/README.md +12 -11
  3. package/SoT/.agents/skills.txt +3 -3
  4. package/SoT/.claude/settings.json +3 -26
  5. package/SoT/models.json +1 -1
  6. package/SoT/toolchain.json +3 -3
  7. package/cli/docs/install.md +39 -12
  8. package/cli/docs/overview.md +3 -4
  9. package/cli/docs/platforms.md +35 -22
  10. package/cli/src/commands/sync.ts +32 -4
  11. package/cli/src/commands/update.ts +116 -0
  12. package/cli/src/engine-native/DESIGN.md +89 -0
  13. package/cli/src/engine-native/claudeModel.ts +48 -0
  14. package/cli/src/engine-native/claudeSync.ts +771 -0
  15. package/cli/src/engine-native/codexSync.ts +439 -0
  16. package/cli/src/engine-native/codexToml.ts +67 -0
  17. package/cli/src/engine-native/exec.ts +54 -0
  18. package/cli/src/engine-native/index.ts +109 -0
  19. package/cli/src/engine-native/jq.ts +63 -0
  20. package/cli/src/engine-native/models.ts +73 -0
  21. package/cli/src/engine-native/modes.ts +133 -0
  22. package/cli/src/engine-native/output.ts +20 -0
  23. package/cli/src/engine-native/parseArgs.ts +223 -0
  24. package/cli/src/engine-native/settings.ts +41 -0
  25. package/cli/src/engine-native/skillsSync.ts +369 -0
  26. package/cli/src/engine-native/toolchain.ts +257 -0
  27. package/cli/src/engine.ts +35 -18
  28. package/cli/src/kitHome.ts +4 -4
  29. package/cli/src/main.ts +14 -1
  30. package/cli/src/manifests.ts +3 -2
  31. package/cli/tsconfig.json +1 -1
  32. package/docks-kit +6 -3
  33. package/package.json +8 -4
  34. package/lib/claude.sh +0 -846
  35. package/lib/codex.sh +0 -518
  36. package/lib/common.sh +0 -229
  37. package/lib/engine.sh +0 -153
  38. package/lib/skills.sh +0 -373
  39. package/lib/toolchain.sh +0 -222
@@ -0,0 +1,369 @@
1
+ /**
2
+ * EngineNative `sync agents` pipeline: universal-skill bootstrap
3
+ * (`npx skills@<pin> add`), Claude symlink healing, --prune reconcile against
4
+ * the kit-managed snapshot, agent-browser/effect-solutions toolchain callbacks,
5
+ * and the snapshot write.
6
+ */
7
+ import { spawnSync } from "node:child_process"
8
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
9
+ import { tmpdir } from "node:os"
10
+ import { capture, commandExists, isExecutable, p, which } from "./exec"
11
+ import type { Ctx } from "./index"
12
+ import { compareCodepoints } from "./jq"
13
+ import { echo, log, warn } from "./output"
14
+ import { ensure, field } from "./toolchain"
15
+
16
+ export interface SkillsState {
17
+ present: number
18
+ }
19
+
20
+ export function skillsSync(ctx: Ctx): SkillsState {
21
+ const state: SkillsState = { present: 0 }
22
+ const skillsDir = p(ctx.agentsDir, "skills")
23
+ const manifest = p(ctx.repoDir, "SoT", ".agents", "skills.txt")
24
+ const snapshot = p(ctx.agentsDir, ".kit-managed-skills")
25
+
26
+ if (!existsSync(manifest)) return state
27
+
28
+ if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
29
+
30
+ syncUniversal(ctx, state, skillsDir, manifest)
31
+ if (ctx.prune) reconcileRemovals(ctx, manifest, snapshot)
32
+ syncAgentBrowserCli(ctx, manifest)
33
+ syncEffectSolutionsCli(ctx)
34
+ updateSnapshot(ctx, manifest, snapshot)
35
+ return state
36
+ }
37
+
38
+ /** skills::_skills_cli — the pinned npx package spec. */
39
+ function skillsCli(ctx: Ctx): string {
40
+ const v = field(ctx, "skills-cli", "verified")
41
+ return v !== "" ? `skills@${v}` : "skills"
42
+ }
43
+
44
+ /** skills::_normalize_manifest — cleaned slugs, one per line. */
45
+ export function normalizeManifest(content: string): Array<string> {
46
+ const out: Array<string> = []
47
+ for (const line of content.split("\n")) {
48
+ if (/^[ \t]*#/.test(line)) continue
49
+ if (/^[ \t]*$/.test(line)) continue
50
+ const cleaned = line.replace(/[ \t]*#.*$/, "").replace(/[ \t\r]+/g, "")
51
+ if (cleaned.length > 0) out.push(cleaned)
52
+ }
53
+ return out
54
+ }
55
+
56
+ function readSlugs(file: string): Array<string> {
57
+ return existsSync(file) ? normalizeManifest(readFileSync(file, "utf8")) : []
58
+ }
59
+
60
+ function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest: string): void {
61
+ if (!commandExists("node")) {
62
+ warn("node/npx not in PATH — skipping universal skills bootstrap (install Node.js to enable)")
63
+ return
64
+ }
65
+
66
+ let added = 0
67
+ let already = 0
68
+ let failed = 0
69
+ let healed = 0
70
+
71
+ for (const slug of readSlugs(manifest)) {
72
+ const base = slug.slice(slug.lastIndexOf("/") + 1)
73
+
74
+ if (ctx.dryRun) {
75
+ if (isDir(p(skillsDir, base))) {
76
+ echo(`[dry-run] universal skill present: ${base}`)
77
+ healClaudeSymlink(ctx, skillsDir, base)
78
+ } else {
79
+ echo(`[dry-run] npx ${skillsCli(ctx)} add ${slug} -g -y -a claude-code codex`)
80
+ }
81
+ continue
82
+ }
83
+
84
+ if (isDir(p(skillsDir, base))) {
85
+ already++
86
+ if (healClaudeSymlink(ctx, skillsDir, base)) healed++
87
+ continue
88
+ }
89
+
90
+ const res = spawnSync("npx", ["--yes", skillsCli(ctx), "add", slug, "-g", "-y", "-a", "claude-code", "codex"], {
91
+ stdio: "ignore"
92
+ })
93
+ if (res.error === undefined && res.status === 0) {
94
+ added++
95
+ } else {
96
+ warn(`Failed to install universal skill: ${slug}`)
97
+ failed++
98
+ }
99
+ }
100
+
101
+ if (ctx.dryRun) return
102
+
103
+ state.present = added + already
104
+
105
+ if (added > 0) {
106
+ log(`Universal skills synced (+${added} new, ${already} already present)`)
107
+ } else {
108
+ log(`Universal skills already in sync (${already} present)`)
109
+ }
110
+ if (healed > 0) {
111
+ log(`Claude per-tool symlinks healed (+${healed}) — canonical present, ~/.claude/skills/<name> was missing or broken`)
112
+ }
113
+ if (failed > 0) {
114
+ warn(`${failed} skill install(s) failed — re-run sync or install manually with: npx skills add <slug> -g -y -a claude-code codex`)
115
+ }
116
+ }
117
+
118
+ function isDir(path: string): boolean {
119
+ try {
120
+ return statSync(path).isDirectory()
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+
126
+ /** skills::heal_claude_symlink — true when a heal occurred. */
127
+ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
128
+ const canonical = p(skillsDir, base)
129
+ const claudeSkillsDir = p(ctx.home, ".claude", "skills")
130
+ const claudeLink = p(claudeSkillsDir, base)
131
+ const relTarget = `../../.agents/skills/${base}`
132
+
133
+ if (!isDir(canonical)) return false
134
+
135
+ const linkStat = lstat(claudeLink)
136
+ if (linkStat?.isSymbolicLink() === true) {
137
+ const current = safeReadlink(claudeLink)
138
+ if (current === relTarget) return false
139
+ if (ctx.dryRun) {
140
+ echo(`[dry-run] would replace stale Claude symlink: ~/.claude/skills/${base} -> ${current} (correct: ${relTarget})`)
141
+ return true
142
+ }
143
+ rmSync(claudeLink, { force: true })
144
+ } else if (linkStat !== undefined) {
145
+ warn(`~/.claude/skills/${base} exists as a real path (not a symlink) — leaving alone; remove manually if it's stale`)
146
+ return false
147
+ } else if (ctx.dryRun) {
148
+ echo(`[dry-run] would create missing Claude symlink: ~/.claude/skills/${base} -> ${relTarget}`)
149
+ return true
150
+ }
151
+
152
+ mkdirSync(claudeSkillsDir, { recursive: true })
153
+ return linkOrCopy(relTarget, claudeLink)
154
+ }
155
+
156
+ function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
157
+ try {
158
+ return lstatSync(path)
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ function safeReadlink(path: string): string {
165
+ try {
166
+ return readlinkSync(path)
167
+ } catch {
168
+ return ""
169
+ }
170
+ }
171
+
172
+ /** skills::_link_or_copy — real symlink preferred, copy fallback (Windows). */
173
+ export function linkOrCopy(target: string, link: string): boolean {
174
+ rmSync(link, { force: true })
175
+ try {
176
+ symlinkSync(target, link, process.platform === "win32" ? "dir" : undefined)
177
+ } catch {
178
+ // fall through to the copy fallback below
179
+ }
180
+ if (lstat(link)?.isSymbolicLink() === true) return true
181
+ try {
182
+ // Resolve a relative target against the link's parent, like ln does.
183
+ const resolved = target.startsWith("/") ? target : p(link.slice(0, link.lastIndexOf("/")), target)
184
+ cpSync(resolved, link, { recursive: true })
185
+ } catch {
186
+ // fall through to the existence check below
187
+ }
188
+ if (existsSync(link)) {
189
+ warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
190
+ return true
191
+ }
192
+ warn(`could not create ${link} (symlink and copy both failed)`)
193
+ return false
194
+ }
195
+
196
+ // ------------------------------------------------- toolchain callbacks ----
197
+
198
+ /** skills::_agent_browser_install. */
199
+ export function agentBrowserInstall(mode: "install" | "upgrade", version: string): number {
200
+ const verb = mode === "upgrade" ? "Upgrading" : "Installing"
201
+ const pkg = version !== "" ? `agent-browser@${version}` : "agent-browser"
202
+ const installFlags = process.platform === "linux" ? ["--with-deps"] : []
203
+
204
+ log(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
205
+ if (spawnSync("npm", ["install", "-g", pkg], { stdio: "ignore" }).status !== 0) {
206
+ warn(`npm install -g ${pkg} failed. Try manually: npm install -g ${pkg}`)
207
+ return 1
208
+ }
209
+
210
+ if (mode === "install") {
211
+ log("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
212
+ if (spawnSync("agent-browser", ["install", ...installFlags], { stdio: "inherit" }).status !== 0) {
213
+ warn(`agent-browser install failed. Re-run manually: agent-browser install ${installFlags.join(" ")}`)
214
+ return 1
215
+ }
216
+ }
217
+ const out = capture("agent-browser", ["--version"])
218
+ const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
219
+ const version2 = out !== "" ? fields[fields.length - 1] ?? "version unknown" : "version unknown"
220
+ log(`agent-browser CLI ready (${version2})`)
221
+ return 0
222
+ }
223
+
224
+ function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
225
+ if (!existsSync(manifest) || !readFileSync(manifest, "utf8").split("\n").includes("vercel-labs/agent-browser")) return
226
+
227
+ if (!commandExists("npm")) {
228
+ if (!ctx.dryRun) warn("npm not found — cannot auto-install agent-browser CLI. Install Node.js, then re-run sync.")
229
+ return
230
+ }
231
+
232
+ if (ensure(ctx, "agent-browser", agentBrowserInstall) !== 0) {
233
+ warn("agent-browser bootstrap failed — continuing sync")
234
+ }
235
+ }
236
+
237
+ /** skills::_find_bun — resolved bun path or "". */
238
+ function findBun(ctx: Ctx): string {
239
+ const onPath = which("bun")
240
+ if (onPath !== "") return onPath
241
+ const bunInstall = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== "" ? process.env["BUN_INSTALL"] : p(ctx.home, ".bun")
242
+ for (const cand of [p(bunInstall, "bin", "bun"), p(ctx.home, ".bun", "bin", "bun")]) {
243
+ if (isExecutable(cand)) return cand
244
+ }
245
+ return ""
246
+ }
247
+
248
+ /** skills::_bun_bootstrap — bun path or "" after a failed bootstrap. */
249
+ export function bunBootstrap(ctx: Ctx): string {
250
+ let bun = findBun(ctx)
251
+ if (bun !== "") return bun
252
+
253
+ if (!commandExists("curl")) {
254
+ warn("Bun and curl both missing — cannot bootstrap Bun. Install Bun manually, then re-run sync.")
255
+ return ""
256
+ }
257
+ const pin = field(ctx, "bun", "verified")
258
+ warn(`Bun not found — installing Bun${pin !== "" ? ` ${pin} (kit-verified)` : ""}...`)
259
+ const installer = p(tmpdir(), `bun-install-${process.pid}.sh`)
260
+ const dl = spawnSync("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], { stdio: "ignore" })
261
+ if (dl.error === undefined && dl.status === 0) {
262
+ spawnSync("bash", [installer, ...(pin !== "" ? [`bun-v${pin}`] : [])], { stdio: "ignore" })
263
+ }
264
+ rmSync(installer, { force: true })
265
+ bun = findBun(ctx)
266
+ if (bun === "") {
267
+ warn("Bun install failed. Install manually: curl -fsSL https://bun.sh/install -o /tmp/bun.sh && bash /tmp/bun.sh")
268
+ return ""
269
+ }
270
+ const v = capture(bun, ["--version"])
271
+ log(`Bun installed (${v !== "" ? v : "version unknown"})`)
272
+ return bun
273
+ }
274
+
275
+ /** skills::_effect_solutions_install. */
276
+ export function effectSolutionsInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
277
+ return (mode, version) => {
278
+ const verb = mode === "upgrade" ? "Upgrading" : "Installing"
279
+ const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
280
+
281
+ const bun = bunBootstrap(ctx)
282
+ if (bun === "") return 1
283
+
284
+ log(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
285
+ if (spawnSync(bun, ["add", "-g", pkg], { stdio: "ignore" }).status !== 0) {
286
+ warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
287
+ return 1
288
+ }
289
+
290
+ const gbin = capture(bun, ["pm", "-g", "bin"])
291
+ if (gbin !== "" && isExecutable(p(gbin, "effect-solutions"))) {
292
+ mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
293
+ linkOrCopy(bun, p(ctx.home, ".local", "bin", "bun"))
294
+ linkOrCopy(p(gbin, "effect-solutions"), p(ctx.home, ".local", "bin", "effect-solutions"))
295
+ log("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
296
+ } else {
297
+ warn(`effect-solutions installed but binary not found under '${gbin !== "" ? gbin : "<unknown>"}' — link it onto PATH manually`)
298
+ }
299
+ return 0
300
+ }
301
+ }
302
+
303
+ function syncEffectSolutionsCli(ctx: Ctx): void {
304
+ const settings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
305
+ if (!existsSync(settings)) return
306
+ if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(readFileSync(settings, "utf8"))) return
307
+
308
+ if (ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx)) !== 0) {
309
+ warn("effect-solutions bootstrap failed — continuing sync")
310
+ }
311
+ }
312
+
313
+ // ----------------------------------------------------- prune + snapshot ----
314
+
315
+ function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
316
+ if (!existsSync(snapshot)) {
317
+ if (ctx.dryRun) {
318
+ echo(
319
+ `[dry-run] (--prune) no kit-managed-skills snapshot yet; first real sync writes ${snapshot}, then future --prune runs reconcile against it`
320
+ )
321
+ }
322
+ return
323
+ }
324
+
325
+ const current = readSlugs(manifest)
326
+ let removed = 0
327
+ let failed = 0
328
+ for (const slug of readSlugs(snapshot)) {
329
+ if (current.includes(slug)) continue
330
+ const base = slug.slice(slug.lastIndexOf("/") + 1)
331
+ if (ctx.dryRun) {
332
+ echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
333
+ continue
334
+ }
335
+ const res = spawnSync("npx", ["--yes", skillsCli(ctx), "remove", "--global", "-y", "-a", "*", "-s", base], {
336
+ stdio: "ignore"
337
+ })
338
+ if (res.error === undefined && res.status === 0) {
339
+ removed++
340
+ } else {
341
+ warn(`Failed to remove kit-managed skill: ${base}`)
342
+ failed++
343
+ }
344
+ }
345
+
346
+ if (removed > 0) log(`Kit-managed skills removed (-${removed})`)
347
+ if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove -g -y -a '*' -s <name>`)
348
+ }
349
+
350
+ function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
351
+ if (ctx.dryRun) return
352
+
353
+ mkdirSync(ctx.agentsDir, { recursive: true })
354
+ const sorted = [...new Set(readSlugs(manifest))].sort(compareCodepoints)
355
+ writeFileSync(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
356
+ }
357
+
358
+ // -------------------------------------------------------------- summary ----
359
+
360
+ export function skillsSummary(ctx: Ctx, state: SkillsState): void {
361
+ echo(`Skills: ${p(ctx.agentsDir, "skills")}`)
362
+ if (!ctx.dryRun) {
363
+ echo(` ${state.present} universal skill(s) installed`)
364
+ }
365
+ }
366
+
367
+ export function skillsNextSteps(): void {
368
+ echo("Restart Claude Code (and Codex) to discover newly installed universal skills.")
369
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Verified-version-floor layer over SoT/toolchain.json. Probe/install commands
3
+ * spawn deterministic argv arrays and are covered by golden regression cases.
4
+ */
5
+ import { readFileSync, readSync } from "node:fs"
6
+
7
+ import { capture, commandExists, isExecutable, p } from "./exec"
8
+ import type { Ctx } from "./index"
9
+ import { compareCodepoints, isObject, parseJson, type Json } from "./jq"
10
+ import { echo, log, warn } from "./output"
11
+
12
+ type InstallFn = (mode: "install" | "upgrade", version: string) => number
13
+
14
+ function manifest(ctx: Ctx): { [k: string]: Json } {
15
+ const doc = parseJson(readFileSync(p(ctx.repoDir, "SoT", "toolchain.json"), "utf8"))
16
+ const tools = doc !== undefined && isObject(doc) ? doc["tools"] : undefined
17
+ return tools !== undefined && isObject(tools) ? tools : {}
18
+ }
19
+
20
+ export function field(ctx: Ctx, tool: string, name: string): string {
21
+ const entry = manifest(ctx)[tool]
22
+ if (entry === undefined || !isObject(entry)) return ""
23
+ const v = entry[name]
24
+ return v === undefined || v === null ? "" : String(v)
25
+ }
26
+
27
+ /** toolchain::_is_newer — numeric per dotted field, GNU-sort last-resort tie-break. */
28
+ export function isNewer(a: string, b: string): boolean {
29
+ if (a === "" || b === "" || a === b) return false
30
+ const fa = a.split(".")
31
+ const fb = b.split(".")
32
+ for (let i = 0; i < 3; i++) {
33
+ const na = parseInt(fa[i] ?? "", 10) || 0
34
+ const nb = parseInt(fb[i] ?? "", 10) || 0
35
+ if (na !== nb) return na > nb
36
+ }
37
+ return compareCodepoints(a, b) > 0
38
+ }
39
+
40
+ export function present(ctx: Ctx, tool: string): boolean {
41
+ if (tool === "bun") {
42
+ return (
43
+ commandExists("bun") ||
44
+ isExecutable(p(process.env["BUN_INSTALL"] ?? p(ctx.home, ".bun"), "bin", "bun")) ||
45
+ isExecutable(p(ctx.home, ".bun", "bin", "bun"))
46
+ )
47
+ }
48
+ return commandExists(tool)
49
+ }
50
+
51
+ function firstLineField(out: string, index: number): string {
52
+ const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
53
+ return fields[index === -1 ? fields.length - 1 : index] ?? ""
54
+ }
55
+
56
+ export function installedVersion(ctx: Ctx, tool: string): string {
57
+ if (!present(ctx, tool)) return ""
58
+ switch (tool) {
59
+ case "rtk":
60
+ return firstLineField(capture("rtk", ["--version"]), 1)
61
+ case "claude":
62
+ return firstLineField(capture("claude", ["--version"]), 0)
63
+ case "codex":
64
+ return firstLineField(capture("codex", ["--version"]), -1)
65
+ case "bun":
66
+ return commandExists("bun") ? capture("bun", ["--version"]) : capture(p(ctx.home, ".bun", "bin", "bun"), ["--version"])
67
+ case "agent-browser":
68
+ return firstLineField(capture("agent-browser", ["--version"]), -1)
69
+ case "effect-solutions": {
70
+ const bunbin = commandExists("bun") ? "bun" : p(ctx.home, ".bun", "bin", "bun")
71
+ if (bunbin !== "bun" && !isExecutable(bunbin)) return ""
72
+ const m = /effect-solutions@([0-9][0-9.]*)/.exec(capture(bunbin, ["pm", "-g", "ls"]))
73
+ return m?.[1] ?? ""
74
+ }
75
+ case "node":
76
+ return capture("node", ["--version"]).replace(/^v/, "")
77
+ case "npm":
78
+ return capture("npm", ["--version"])
79
+ case "jq":
80
+ return capture("jq", ["--version"]).replace(/^jq-/, "")
81
+ case "curl":
82
+ return firstLineField(capture("curl", ["--version"]), 1)
83
+ case "tsc":
84
+ return firstLineField(capture("tsc", ["--version"]), 1)
85
+ default:
86
+ return ""
87
+ }
88
+ }
89
+
90
+ export function latestVersion(tool: string): string {
91
+ switch (tool) {
92
+ case "rtk": {
93
+ const body = capture("curl", ["-fsSL", "--max-time", "5", "https://api.github.com/repos/rtk-ai/rtk/releases/latest"])
94
+ const doc = parseJson(body)
95
+ const tag = doc !== undefined && isObject(doc) && typeof doc["tag_name"] === "string" ? doc["tag_name"] : ""
96
+ return tag.replace(/^v/, "")
97
+ }
98
+ case "agent-browser":
99
+ case "effect-solutions":
100
+ return commandExists("npm") ? capture("npm", ["view", tool, "version"]) : ""
101
+ default:
102
+ return ""
103
+ }
104
+ }
105
+
106
+ /** Blocking TTY prompt matching bash `read -r -p` (prompt on stderr). */
107
+ function promptLine(prompt: string): string {
108
+ process.stderr.write(prompt)
109
+ const buf = Buffer.alloc(1)
110
+ let line = ""
111
+ for (;;) {
112
+ let n: number
113
+ try {
114
+ n = readSync(0, buf, 0, 1, null)
115
+ } catch {
116
+ break
117
+ }
118
+ if (n === 0) break
119
+ const ch = buf.toString("utf8")
120
+ if (ch === "\n") break
121
+ line += ch
122
+ }
123
+ return line.replace(/\r$/, "")
124
+ }
125
+
126
+ /** toolchain::_gate — { proceed, target } ("" target = latest). */
127
+ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: string): { proceed: boolean; target: string } {
128
+ const verified = field(ctx, tool, "verified")
129
+ const pinnable = field(ctx, tool, "pinnable")
130
+
131
+ if (verified === "" || !isNewer(latest, verified)) return { proceed: true, target: "" }
132
+
133
+ if (ctx.assumeYes) {
134
+ warn(`${tool} ${latest} is newer than kit-verified ${verified} — proceeding (--yes)`)
135
+ return { proceed: true, target: "" }
136
+ }
137
+
138
+ if (process.stdin.isTTY === true) {
139
+ process.stderr.write(`\x1b[1;33m[warn]\x1b[0m ${tool} ${latest} is not kit-verified (verified: ${verified}).\n`)
140
+ const answer = promptLine(`Install ${tool} ${latest} anyway? [y/N] `)
141
+ if (/^[yY]/.test(answer)) return { proceed: true, target: "" }
142
+ }
143
+
144
+ if (mode === "install" && pinnable === "true") {
145
+ warn(`installing kit-verified ${tool} ${verified} instead of ${latest}`)
146
+ return { proceed: true, target: verified }
147
+ }
148
+ warn(
149
+ `skipping ${tool} ${mode} (latest ${latest} is above kit-verified ${verified}; pass --yes to accept, or update SoT/toolchain.json after testing)`
150
+ )
151
+ return { proceed: false, target: "" }
152
+ }
153
+
154
+ export function ensure(ctx: Ctx, tool: string, installFn: InstallFn): number {
155
+ const policy = field(ctx, tool, "policy")
156
+
157
+ if (!present(ctx, tool)) {
158
+ const latest = latestVersion(tool)
159
+ if (ctx.dryRun) {
160
+ echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
161
+ return 0
162
+ }
163
+ let target: string
164
+ if (latest === "") {
165
+ target = field(ctx, tool, "verified")
166
+ if (target !== "" && field(ctx, tool, "pinnable") === "true") {
167
+ warn(`${tool} latest version unknown (offline?) — installing kit-verified ${target} instead`)
168
+ } else {
169
+ target = ""
170
+ warn(`${tool} latest version unknown (offline?) and not pinnable — installing latest unverified`)
171
+ }
172
+ } else {
173
+ const g = gate(ctx, tool, "install", latest)
174
+ if (!g.proceed) return 0
175
+ target = g.target
176
+ }
177
+ return installFn("install", target !== "" ? target : latest)
178
+ }
179
+
180
+ const installed = installedVersion(ctx, tool)
181
+ const installedLabel = installed !== "" ? installed : "version unknown"
182
+
183
+ if (policy !== "track") {
184
+ if (ctx.dryRun) {
185
+ echo(`[dry-run] ${tool} present (${installedLabel})`)
186
+ return 0
187
+ }
188
+ log(`${tool} present (${installedLabel})`)
189
+ return 0
190
+ }
191
+
192
+ const latest = latestVersion(tool)
193
+ if (latest === "") {
194
+ if (ctx.dryRun) {
195
+ echo(`[dry-run] ${tool} present (${installedLabel}); latest unknown (offline?) — no action`)
196
+ return 0
197
+ }
198
+ log(`${tool} present (${installedLabel}; latest unknown — no action)`)
199
+ return 0
200
+ }
201
+
202
+ if (installed === "" || isNewer(latest, installed)) {
203
+ if (ctx.dryRun) {
204
+ echo(`[dry-run] would upgrade ${tool} (${installed !== "" ? installed : "unknown"} -> ${latest}, gated by toolchain.json verified pin)`)
205
+ return 0
206
+ }
207
+ const g = gate(ctx, tool, "upgrade", latest)
208
+ if (!g.proceed) return 0
209
+ return installFn("upgrade", g.target !== "" ? g.target : latest)
210
+ }
211
+
212
+ if (ctx.dryRun) {
213
+ echo(`[dry-run] ${tool} up to date (${installed})`)
214
+ return 0
215
+ }
216
+ log(`${tool} up to date (${installed})`)
217
+ return 0
218
+ }
219
+
220
+ function row(cells: [string, string, string, string, string, string]): string {
221
+ const widths = [28, 9, 14, 9, 9]
222
+ return cells.map((c, i) => (i < widths.length ? c.padEnd(widths[i]!) : c)).join(" ")
223
+ }
224
+
225
+ export function report(ctx: Ctx): void {
226
+ echo(row(["TOOL", "KIND", "INSTALLED", "FLOOR", "VERIFIED", "STATUS"]))
227
+ const platformOs =
228
+ process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : ""
229
+ for (const tool of Object.keys(manifest(ctx)).sort(compareCodepoints)) {
230
+ const os = field(ctx, tool, "os")
231
+ if (os !== "" && platformOs !== "" && os !== platformOs) continue
232
+ const kind = field(ctx, tool, "kind")
233
+ const floor = field(ctx, tool, "floor")
234
+ const verified = field(ctx, tool, "verified")
235
+ const dash = (v: string): string => (v !== "" ? v : "-")
236
+ if (kind === "pin") {
237
+ echo(row([tool, kind, "(npx)", dash(floor), dash(verified), "pinned"]))
238
+ continue
239
+ }
240
+ let installed: string
241
+ let status: string
242
+ if (present(ctx, tool)) {
243
+ installed = installedVersion(ctx, tool)
244
+ status = "ok"
245
+ if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
246
+ status = "below-floor"
247
+ } else if (verified !== "" && installed !== "" && isNewer(installed, verified)) {
248
+ status = "above-verified"
249
+ }
250
+ installed = installed !== "" ? installed : "?"
251
+ } else {
252
+ installed = "-"
253
+ status = "missing"
254
+ }
255
+ echo(row([tool, dash(kind), installed, dash(floor), dash(verified), status]))
256
+ }
257
+ }