docks-kit 0.1.5 → 0.3.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 (50) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +7 -5
  3. package/cli/docs/flags.md +1 -0
  4. package/cli/docs/install.md +9 -11
  5. package/cli/docs/overview.md +6 -0
  6. package/cli/docs/platforms.md +9 -10
  7. package/cli/src/commands/model.ts +7 -3
  8. package/cli/src/commands/sync.ts +14 -3
  9. package/cli/src/commands/toolchain.ts +7 -3
  10. package/cli/src/engine-native/DESIGN.md +79 -2
  11. package/cli/src/engine-native/claudeModel.ts +9 -3
  12. package/cli/src/engine-native/claudeSync.ts +220 -116
  13. package/cli/src/engine-native/codexSync.ts +131 -77
  14. package/cli/src/engine-native/codexToml.ts +12 -5
  15. package/cli/src/engine-native/deps.ts +325 -0
  16. package/cli/src/engine-native/exec.ts +35 -2
  17. package/cli/src/engine-native/index.ts +48 -13
  18. package/cli/src/engine-native/logger.ts +35 -0
  19. package/cli/src/engine-native/models.ts +22 -23
  20. package/cli/src/engine-native/modes.ts +30 -14
  21. package/cli/src/engine-native/os.ts +29 -0
  22. package/cli/src/engine-native/parseArgs.ts +19 -17
  23. package/cli/src/engine-native/services.ts +96 -0
  24. package/cli/src/engine-native/skillsSync.ts +77 -61
  25. package/cli/src/engine-native/toolchain.ts +50 -68
  26. package/cli/src/engine.ts +11 -2
  27. package/cli/src/generated/sotPayload.ts +41 -0
  28. package/cli/src/kitHome.ts +15 -11
  29. package/cli/src/main.ts +3 -2
  30. package/cli/src/manifests.ts +17 -15
  31. package/cli/src/payload.ts +28 -0
  32. package/cli/src/services.ts +34 -0
  33. package/docks-kit +6 -6
  34. package/package.json +2 -3
  35. package/SoT/.agents/skills.txt +0 -14
  36. package/SoT/.claude/CLAUDE.md +0 -146
  37. package/SoT/.claude/fetch-usage.sh +0 -66
  38. package/SoT/.claude/hooks/notify.sh +0 -14
  39. package/SoT/.claude/mcp-servers.json +0 -10
  40. package/SoT/.claude/settings.json +0 -235
  41. package/SoT/.claude/statusline.sh +0 -175
  42. package/SoT/.codex/AGENTS.md +0 -75
  43. package/SoT/.codex/agents/.gitkeep +0 -1
  44. package/SoT/.codex/config.toml +0 -45
  45. package/SoT/.codex/plugins/marketplace.json +0 -50
  46. package/SoT/.codex/rules/docks.rules +0 -116
  47. package/SoT/models.json +0 -28
  48. package/SoT/toolchain.json +0 -27
  49. package/cli/src/engine-native/output.ts +0 -20
  50. package/notification.mp3 +0 -0
@@ -7,11 +7,12 @@
7
7
  import { spawnSync } from "node:child_process"
8
8
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"
9
9
  import { tmpdir } from "node:os"
10
- import { capture, commandExists, isExecutable, p, which } from "./exec"
10
+ import { p, writeFileIfChanged } from "./exec"
11
11
  import type { Ctx } from "./index"
12
12
  import { compareCodepoints } from "./jq"
13
- import { echo, log, warn } from "./output"
13
+ import type { EngineServices, Platform } from "./services"
14
14
  import { ensure, field } from "./toolchain"
15
+ import { payloadText } from "../payload"
15
16
 
16
17
  export interface SkillsState {
17
18
  present: number
@@ -20,11 +21,9 @@ export interface SkillsState {
20
21
  export function skillsSync(ctx: Ctx): SkillsState {
21
22
  const state: SkillsState = { present: 0 }
22
23
  const skillsDir = p(ctx.agentsDir, "skills")
23
- const manifest = p(ctx.repoDir, "SoT", ".agents", "skills.txt")
24
+ const manifest = payloadText("SoT/.agents/skills.txt")
24
25
  const snapshot = p(ctx.agentsDir, ".kit-managed-skills")
25
26
 
26
- if (!existsSync(manifest)) return state
27
-
28
27
  if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
29
28
 
30
29
  syncUniversal(ctx, state, skillsDir, manifest)
@@ -58,8 +57,9 @@ function readSlugs(file: string): Array<string> {
58
57
  }
59
58
 
60
59
  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)")
60
+ const { change, echo, verbose, warn } = ctx.services.logger
61
+ if (ctx.services.deps.probe("npx").state === "missing") {
62
+ ctx.services.deps.warnMissing("npx", ctx.services.logger, "skipping universal skills bootstrap")
63
63
  return
64
64
  }
65
65
 
@@ -68,7 +68,7 @@ function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest
68
68
  let failed = 0
69
69
  let healed = 0
70
70
 
71
- for (const slug of readSlugs(manifest)) {
71
+ for (const slug of normalizeManifest(manifest)) {
72
72
  const base = slug.slice(slug.lastIndexOf("/") + 1)
73
73
 
74
74
  if (ctx.dryRun) {
@@ -103,12 +103,14 @@ function syncUniversal(ctx: Ctx, state: SkillsState, skillsDir: string, manifest
103
103
  state.present = added + already
104
104
 
105
105
  if (added > 0) {
106
- log(`Universal skills synced (+${added} new, ${already} already present)`)
106
+ change(`Universal skills synced (+${added} new, ${already} already present)`)
107
+ ctx.nextStepTriggers.skillsRestart = true
107
108
  } else {
108
- log(`Universal skills already in sync (${already} present)`)
109
+ verbose(`Universal skills already in sync (${already} present)`)
109
110
  }
110
111
  if (healed > 0) {
111
- log(`Claude per-tool symlinks healed (+${healed}) — canonical present, ~/.claude/skills/<name> was missing or broken`)
112
+ change(`Claude per-tool symlinks healed (+${healed}) — canonical present, ~/.claude/skills/<name> was missing or broken`)
113
+ ctx.nextStepTriggers.skillsRestart = true
112
114
  }
113
115
  if (failed > 0) {
114
116
  warn(`${failed} skill install(s) failed — re-run sync or install manually with: npx skills add <slug> -g -y -a claude-code codex`)
@@ -125,6 +127,7 @@ function isDir(path: string): boolean {
125
127
 
126
128
  /** skills::heal_claude_symlink — true when a heal occurred. */
127
129
  function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
130
+ const { echo, warn } = ctx.services.logger
128
131
  const canonical = p(skillsDir, base)
129
132
  const claudeSkillsDir = p(ctx.home, ".claude", "skills")
130
133
  const claudeLink = p(claudeSkillsDir, base)
@@ -138,7 +141,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
138
141
  if (current === relTarget) return false
139
142
  // win32: `npx skills add` creates absolute symlinks/junctions — any link
140
143
  // that RESOLVES to the canonical dir is healthy, not stale.
141
- if (process.platform === "win32" && realpathEquals(claudeLink, canonical)) return false
144
+ if (ctx.services.platform.isWindows() && realpathEquals(claudeLink, canonical)) return false
142
145
  if (ctx.dryRun) {
143
146
  echo(`[dry-run] would replace stale Claude symlink: ~/.claude/skills/${base} -> ${current} (correct: ${relTarget})`)
144
147
  return true
@@ -156,7 +159,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
156
159
  }
157
160
 
158
161
  mkdirSync(claudeSkillsDir, { recursive: true })
159
- return linkOrCopy(relTarget, claudeLink)
162
+ return linkOrCopyWithWarnings(relTarget, claudeLink, ctx.services)
160
163
  }
161
164
 
162
165
  function lstat(path: string): ReturnType<typeof lstatSync> | undefined {
@@ -201,10 +204,10 @@ function removeLink(path: string): boolean {
201
204
  }
202
205
 
203
206
  /** skills::_link_or_copy — real symlink preferred, copy fallback (Windows). */
204
- export function linkOrCopy(target: string, link: string): boolean {
207
+ export function linkOrCopy(target: string, link: string, platform: Platform): boolean {
205
208
  removeLink(link)
206
209
  try {
207
- symlinkSync(target, link, process.platform === "win32" ? "dir" : undefined)
210
+ symlinkSync(target, link, platform.isWindows() ? "dir" : undefined)
208
211
  } catch {
209
212
  // fall through to the copy fallback below
210
213
  }
@@ -216,47 +219,56 @@ export function linkOrCopy(target: string, link: string): boolean {
216
219
  } catch {
217
220
  // fall through to the existence check below
218
221
  }
219
- if (existsSync(link)) {
220
- warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
221
- return true
222
+ return existsSync(link)
223
+ }
224
+
225
+ function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
226
+ const linked = linkOrCopy(target, link, services.platform)
227
+ if (!linked) {
228
+ services.logger.warn(`could not create ${link} (symlink and copy both failed)`)
229
+ } else if (lstat(link)?.isSymbolicLink() !== true) {
230
+ services.logger.warn(`symlinks unsupported here — ${link} is a copy (refreshed on sync; enable Windows Developer Mode for real links)`)
222
231
  }
223
- warn(`could not create ${link} (symlink and copy both failed)`)
224
- return false
232
+ return linked
225
233
  }
226
234
 
227
235
  // ------------------------------------------------- toolchain callbacks ----
228
236
 
229
237
  /** skills::_agent_browser_install. */
230
- export function agentBrowserInstall(mode: "install" | "upgrade", version: string): number {
238
+ export function agentBrowserInstall(mode: "install" | "upgrade", version: string, services: EngineServices): number {
239
+ const { change, verbose, warn } = services.logger
231
240
  const verb = mode === "upgrade" ? "Upgrading" : "Installing"
232
241
  const pkg = version !== "" ? `agent-browser@${version}` : "agent-browser"
233
- const installFlags = process.platform === "linux" ? ["--with-deps"] : []
242
+ const installFlags = services.platform.isLinux() ? ["--with-deps"] : []
234
243
 
235
- log(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
244
+ verbose(`${verb} agent-browser CLI via npm${version !== "" ? ` (pinned ${version})` : ""}...`)
236
245
  if (spawnSync("npm", ["install", "-g", pkg], { stdio: "ignore" }).status !== 0) {
237
246
  warn(`npm install -g ${pkg} failed. Try manually: npm install -g ${pkg}`)
238
247
  return 1
239
248
  }
240
249
 
241
250
  if (mode === "install") {
242
- log("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
251
+ warn("Downloading Chrome for Testing (~175 MB; sudo may be requested for system libs on Linux)...")
243
252
  if (spawnSync("agent-browser", ["install", ...installFlags], { stdio: "inherit" }).status !== 0) {
244
253
  warn(`agent-browser install failed. Re-run manually: agent-browser install ${installFlags.join(" ")}`)
245
254
  return 1
246
255
  }
247
256
  }
248
- const out = capture("agent-browser", ["--version"])
257
+ const out = services.deps.version("agent-browser")
249
258
  const fields = (out.split("\n")[0] ?? "").trim().split(/[ \t]+/)
250
259
  const version2 = out !== "" ? fields[fields.length - 1] ?? "version unknown" : "version unknown"
251
- log(`agent-browser CLI ready (${version2})`)
260
+ change(`agent-browser CLI ready (${version2})`)
252
261
  return 0
253
262
  }
254
263
 
255
264
  function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
256
- if (!existsSync(manifest) || !readFileSync(manifest, "utf8").split("\n").includes("vercel-labs/agent-browser")) return
265
+ const { warn } = ctx.services.logger
266
+ if (!manifest.split("\n").includes("vercel-labs/agent-browser")) return
257
267
 
258
- if (!commandExists("npm")) {
259
- if (!ctx.dryRun) warn("npm not found — cannot auto-install agent-browser CLI. Install Node.js, then re-run sync.")
268
+ if (ctx.services.deps.probe("npm").state === "missing") {
269
+ if (!ctx.dryRun) {
270
+ ctx.services.deps.warnMissing("npm", ctx.services.logger, "cannot auto-install agent-browser CLI; re-run sync after installing")
271
+ }
260
272
  return
261
273
  }
262
274
 
@@ -267,21 +279,16 @@ function syncAgentBrowserCli(ctx: Ctx, manifest: string): void {
267
279
 
268
280
  /** skills::_find_bun — resolved bun path or "". */
269
281
  function findBun(ctx: Ctx): string {
270
- const onPath = which("bun")
271
- if (onPath !== "") return onPath
272
- const bunInstall = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== "" ? process.env["BUN_INSTALL"] : p(ctx.home, ".bun")
273
- for (const cand of [p(bunInstall, "bin", "bun"), p(ctx.home, ".bun", "bin", "bun")]) {
274
- if (isExecutable(cand)) return cand
275
- }
276
- return ""
282
+ return ctx.services.deps.path("bun")
277
283
  }
278
284
 
279
285
  /** skills::_bun_bootstrap — bun path or "" after a failed bootstrap. */
280
- export function bunBootstrap(ctx: Ctx): string {
286
+ export function bunBootstrap(ctx: Ctx, services: EngineServices): string {
287
+ const { change, warn } = services.logger
281
288
  let bun = findBun(ctx)
282
289
  if (bun !== "") return bun
283
290
 
284
- if (!commandExists("curl")) {
291
+ if (services.deps.probe("curl").state === "missing") {
285
292
  warn("Bun and curl both missing — cannot bootstrap Bun. Install Bun manually, then re-run sync.")
286
293
  return ""
287
294
  }
@@ -298,41 +305,44 @@ export function bunBootstrap(ctx: Ctx): string {
298
305
  warn("Bun install failed. Install manually: curl -fsSL https://bun.sh/install -o /tmp/bun.sh && bash /tmp/bun.sh")
299
306
  return ""
300
307
  }
301
- const v = capture(bun, ["--version"])
302
- log(`Bun installed (${v !== "" ? v : "version unknown"})`)
308
+ const version = services.deps.version("bun")
309
+ change(`Bun installed (${version !== "" ? version : "version unknown"})`)
303
310
  return bun
304
311
  }
305
312
 
306
313
  /** skills::_effect_solutions_install. */
307
- export function effectSolutionsInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
308
- return (mode, version) => {
314
+ export function effectSolutionsInstall(
315
+ ctx: Ctx
316
+ ): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
317
+ return (mode, version, services) => {
318
+ const { change, verbose, warn } = services.logger
309
319
  const verb = mode === "upgrade" ? "Upgrading" : "Installing"
310
320
  const pkg = `effect-solutions@${version !== "" ? version : "latest"}`
311
321
 
312
- const bun = bunBootstrap(ctx)
322
+ const bun = bunBootstrap(ctx, services)
313
323
  if (bun === "") return 1
314
324
 
315
- log(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
325
+ verbose(`${verb} effect-solutions CLI via bun${version !== "" ? ` (pinned ${version})` : ""}...`)
316
326
  if (spawnSync(bun, ["add", "-g", pkg], { stdio: "ignore" }).status !== 0) {
317
327
  warn(`bun add -g ${pkg} failed. Try manually: bun add -g ${pkg}`)
318
328
  return 1
319
329
  }
320
330
 
321
- const gbin = capture(bun, ["pm", "-g", "bin"])
331
+ const location = services.deps.location("effect-solutions")
332
+ const gbin = location.binDir
322
333
  // win32: bun writes an .exe shim (not the bare Unix name), and the
323
334
  // ~/.local/bin link step below is Unix-only plumbing (non-interactive
324
335
  // agent PATH) — bun's global bin is already the Windows PATH entry.
325
- if (process.platform === "win32") {
326
- const found = gbin !== "" && ["exe", "cmd", "bunx"].some((ext) => existsSync(p(gbin, `effect-solutions.${ext}`)))
327
- if (found) log(`effect-solutions CLI ready (${gbin})`)
336
+ if (services.platform.isWindows()) {
337
+ if (location.path !== "") change(`effect-solutions CLI ready (${gbin})`)
328
338
  else warn(`effect-solutions installed but no shim found under '${gbin !== "" ? gbin : "<unknown>"}' — check bun pm -g bin`)
329
339
  return 0
330
340
  }
331
- if (gbin !== "" && isExecutable(p(gbin, "effect-solutions"))) {
341
+ if (location.path !== "") {
332
342
  mkdirSync(p(ctx.home, ".local", "bin"), { recursive: true })
333
- linkOrCopy(bun, p(ctx.home, ".local", "bin", "bun"))
334
- linkOrCopy(p(gbin, "effect-solutions"), p(ctx.home, ".local", "bin", "effect-solutions"))
335
- log("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
343
+ linkOrCopyWithWarnings(bun, p(ctx.home, ".local", "bin", "bun"), services)
344
+ linkOrCopyWithWarnings(location.path, p(ctx.home, ".local", "bin", "effect-solutions"), services)
345
+ change("effect-solutions CLI ready (linked bun + effect-solutions into ~/.local/bin)")
336
346
  } else {
337
347
  warn(`effect-solutions installed but binary not found under '${gbin !== "" ? gbin : "<unknown>"}' — link it onto PATH manually`)
338
348
  }
@@ -341,9 +351,8 @@ export function effectSolutionsInstall(ctx: Ctx): (mode: "install" | "upgrade",
341
351
  }
342
352
 
343
353
  function syncEffectSolutionsCli(ctx: Ctx): void {
344
- const settings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
345
- if (!existsSync(settings)) return
346
- if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(readFileSync(settings, "utf8"))) return
354
+ const { warn } = ctx.services.logger
355
+ if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(payloadText("SoT/.claude/settings.json"))) return
347
356
 
348
357
  if (ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx)) !== 0) {
349
358
  warn("effect-solutions bootstrap failed — continuing sync")
@@ -353,6 +362,7 @@ function syncEffectSolutionsCli(ctx: Ctx): void {
353
362
  // ----------------------------------------------------- prune + snapshot ----
354
363
 
355
364
  function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
365
+ const { change, echo, warn } = ctx.services.logger
356
366
  if (!existsSync(snapshot)) {
357
367
  if (ctx.dryRun) {
358
368
  echo(
@@ -362,7 +372,7 @@ function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
362
372
  return
363
373
  }
364
374
 
365
- const current = readSlugs(manifest)
375
+ const current = normalizeManifest(manifest)
366
376
  let removed = 0
367
377
  let failed = 0
368
378
  for (const slug of readSlugs(snapshot)) {
@@ -383,7 +393,10 @@ function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): void {
383
393
  }
384
394
  }
385
395
 
386
- if (removed > 0) log(`Kit-managed skills removed (-${removed})`)
396
+ if (removed > 0) {
397
+ change(`Kit-managed skills removed (-${removed})`)
398
+ ctx.nextStepTriggers.skillsRestart = true
399
+ }
387
400
  if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove -g -y -a '*' -s <name>`)
388
401
  }
389
402
 
@@ -391,19 +404,22 @@ function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
391
404
  if (ctx.dryRun) return
392
405
 
393
406
  mkdirSync(ctx.agentsDir, { recursive: true })
394
- const sorted = [...new Set(readSlugs(manifest))].sort(compareCodepoints)
395
- writeFileSync(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
407
+ const sorted = [...new Set(normalizeManifest(manifest))].sort(compareCodepoints)
408
+ writeFileIfChanged(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
396
409
  }
397
410
 
398
411
  // -------------------------------------------------------------- summary ----
399
412
 
400
413
  export function skillsSummary(ctx: Ctx, state: SkillsState): void {
414
+ const { echo } = ctx.services.logger
401
415
  echo(`Skills: ${p(ctx.agentsDir, "skills")}`)
402
416
  if (!ctx.dryRun) {
403
417
  echo(` ${state.present} universal skill(s) installed`)
404
418
  }
405
419
  }
406
420
 
407
- export function skillsNextSteps(): void {
408
- echo("Restart Claude Code (and Codex) to discover newly installed universal skills.")
421
+ export function skillsNextSteps(ctx: Ctx): Array<string> {
422
+ return ctx.verbose || ctx.nextStepTriggers.skillsRestart
423
+ ? ["Restart Claude Code (and Codex) to discover newly installed universal skills."]
424
+ : []
409
425
  }
@@ -2,23 +2,24 @@
2
2
  * Verified-version-floor layer over SoT/toolchain.json. Probe/install commands
3
3
  * spawn deterministic argv arrays and are covered by golden regression cases.
4
4
  */
5
- import { readFileSync, readSync } from "node:fs"
5
+ import { readSync } from "node:fs"
6
6
 
7
- import { capture, commandExists, isExecutable, p } from "./exec"
7
+ import type { ToolId } from "./deps"
8
8
  import type { Ctx } from "./index"
9
9
  import { compareCodepoints, isObject, parseJson, type Json } from "./jq"
10
- import { echo, log, warn } from "./output"
10
+ import type { EngineServices } from "./services"
11
+ import { payloadText } from "../payload"
11
12
 
12
- type InstallFn = (mode: "install" | "upgrade", version: string) => number
13
+ type InstallFn = (mode: "install" | "upgrade", version: string, services: EngineServices) => number
13
14
 
14
- function manifest(ctx: Ctx): { [k: string]: Json } {
15
- const doc = parseJson(readFileSync(p(ctx.repoDir, "SoT", "toolchain.json"), "utf8"))
15
+ function manifest(): { [k: string]: Json } {
16
+ const doc = parseJson(payloadText("SoT/toolchain.json"))
16
17
  const tools = doc !== undefined && isObject(doc) ? doc["tools"] : undefined
17
18
  return tools !== undefined && isObject(tools) ? tools : {}
18
19
  }
19
20
 
20
21
  export function field(ctx: Ctx, tool: string, name: string): string {
21
- const entry = manifest(ctx)[tool]
22
+ const entry = manifest()[tool]
22
23
  if (entry === undefined || !isObject(entry)) return ""
23
24
  const v = entry[name]
24
25
  return v === undefined || v === null ? "" : String(v)
@@ -37,15 +38,8 @@ export function isNewer(a: string, b: string): boolean {
37
38
  return compareCodepoints(a, b) > 0
38
39
  }
39
40
 
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)
41
+ export function present(ctx: Ctx, tool: ToolId): boolean {
42
+ return ctx.services.deps.probe(tool).state === "present"
49
43
  }
50
44
 
51
45
  function firstLineField(out: string, index: number): string {
@@ -53,67 +47,51 @@ function firstLineField(out: string, index: number): string {
53
47
  return fields[index === -1 ? fields.length - 1 : index] ?? ""
54
48
  }
55
49
 
56
- export function installedVersion(ctx: Ctx, tool: string): string {
57
- if (!present(ctx, tool)) return ""
50
+ export function installedVersion(ctx: Ctx, tool: ToolId): string {
51
+ const version = (): string => ctx.services.deps.version(tool)
58
52
  switch (tool) {
59
53
  case "rtk":
60
- return firstLineField(capture("rtk", ["--version"]), 1)
54
+ return firstLineField(version(), 1)
61
55
  case "claude":
62
- return firstLineField(capture("claude", ["--version"]), 0)
56
+ return firstLineField(version(), 0)
63
57
  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
58
  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
- }
59
+ return firstLineField(version(), -1)
75
60
  case "git":
76
- return firstLineField(capture("git", ["--version"]), 2)
61
+ return firstLineField(version(), 2)
77
62
  case "node":
78
- return capture("node", ["--version"]).replace(/^v/, "")
79
- case "npm":
80
- return capture("npm", ["--version"])
63
+ return version().replace(/^v/, "")
81
64
  case "jq":
82
- return capture("jq", ["--version"]).replace(/^jq-/, "")
65
+ return version().replace(/^jq-/, "")
83
66
  case "curl":
84
- return firstLineField(capture("curl", ["--version"]), 1)
85
67
  case "tsc":
86
- return firstLineField(capture("tsc", ["--version"]), 1)
68
+ return firstLineField(version(), 1)
69
+ case "bun":
70
+ case "effect-solutions":
71
+ case "npm":
72
+ return version()
87
73
  default:
88
74
  return ""
89
75
  }
90
76
  }
91
77
 
92
- export function latestVersion(tool: string): string {
93
- switch (tool) {
94
- case "rtk": {
95
- const body = capture("curl", ["-fsSL", "--max-time", "5", "https://api.github.com/repos/rtk-ai/rtk/releases/latest"])
96
- const doc = parseJson(body)
97
- const tag = doc !== undefined && isObject(doc) && typeof doc["tag_name"] === "string" ? doc["tag_name"] : ""
98
- return tag.replace(/^v/, "")
99
- }
100
- case "agent-browser":
101
- case "effect-solutions":
102
- return commandExists("npm") ? capture("npm", ["view", tool, "version"]) : ""
103
- default:
104
- return ""
105
- }
78
+ export function latestVersion(ctx: Ctx, tool: ToolId): string {
79
+ return ctx.services.deps.latest(tool)
106
80
  }
107
81
 
108
82
  /** Blocking TTY prompt matching bash `read -r -p` (prompt on stderr). */
109
- function promptLine(prompt: string): string {
110
- process.stderr.write(prompt)
83
+ export function promptLine(
84
+ prompt: string,
85
+ write: (chunk: string) => void = (chunk) => void process.stderr.write(chunk),
86
+ readByte: (buffer: Buffer) => number = (buffer) => readSync(0, buffer, 0, 1, null)
87
+ ): string {
88
+ write(prompt)
111
89
  const buf = Buffer.alloc(1)
112
90
  let line = ""
113
91
  for (;;) {
114
92
  let n: number
115
93
  try {
116
- n = readSync(0, buf, 0, 1, null)
94
+ n = readByte(buf)
117
95
  } catch {
118
96
  break
119
97
  }
@@ -127,6 +105,7 @@ function promptLine(prompt: string): string {
127
105
 
128
106
  /** toolchain::_gate — { proceed, target } ("" target = latest). */
129
107
  function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: string): { proceed: boolean; target: string } {
108
+ const { warn } = ctx.services.logger
130
109
  const verified = field(ctx, tool, "verified")
131
110
  const pinnable = field(ctx, tool, "pinnable")
132
111
 
@@ -138,7 +117,7 @@ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: strin
138
117
  }
139
118
 
140
119
  if (process.stdin.isTTY === true) {
141
- process.stderr.write(`\x1b[1;33m[warn]\x1b[0m ${tool} ${latest} is not kit-verified (verified: ${verified}).\n`)
120
+ ctx.services.logger.warn(`${tool} ${latest} is not kit-verified (verified: ${verified}).`)
142
121
  const answer = promptLine(`Install ${tool} ${latest} anyway? [y/N] `)
143
122
  if (/^[yY]/.test(answer)) return { proceed: true, target: "" }
144
123
  }
@@ -153,11 +132,12 @@ function gate(ctx: Ctx, tool: string, mode: "install" | "upgrade", latest: strin
153
132
  return { proceed: false, target: "" }
154
133
  }
155
134
 
156
- export function ensure(ctx: Ctx, tool: string, installFn: InstallFn): number {
135
+ export function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): number {
136
+ const { echo, verbose, warn } = ctx.services.logger
157
137
  const policy = field(ctx, tool, "policy")
158
138
 
159
139
  if (!present(ctx, tool)) {
160
- const latest = latestVersion(tool)
140
+ const latest = latestVersion(ctx, tool)
161
141
  if (ctx.dryRun) {
162
142
  echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
163
143
  return 0
@@ -176,7 +156,7 @@ export function ensure(ctx: Ctx, tool: string, installFn: InstallFn): number {
176
156
  if (!g.proceed) return 0
177
157
  target = g.target
178
158
  }
179
- return installFn("install", target !== "" ? target : latest)
159
+ return installFn("install", target !== "" ? target : latest, ctx.services)
180
160
  }
181
161
 
182
162
  const installed = installedVersion(ctx, tool)
@@ -187,17 +167,17 @@ export function ensure(ctx: Ctx, tool: string, installFn: InstallFn): number {
187
167
  echo(`[dry-run] ${tool} present (${installedLabel})`)
188
168
  return 0
189
169
  }
190
- log(`${tool} present (${installedLabel})`)
170
+ verbose(`${tool} present (${installedLabel})`)
191
171
  return 0
192
172
  }
193
173
 
194
- const latest = latestVersion(tool)
174
+ const latest = latestVersion(ctx, tool)
195
175
  if (latest === "") {
196
176
  if (ctx.dryRun) {
197
177
  echo(`[dry-run] ${tool} present (${installedLabel}); latest unknown (offline?) — no action`)
198
178
  return 0
199
179
  }
200
- log(`${tool} present (${installedLabel}; latest unknown — no action)`)
180
+ verbose(`${tool} present (${installedLabel}; latest unknown — no action)`)
201
181
  return 0
202
182
  }
203
183
 
@@ -208,14 +188,14 @@ export function ensure(ctx: Ctx, tool: string, installFn: InstallFn): number {
208
188
  }
209
189
  const g = gate(ctx, tool, "upgrade", latest)
210
190
  if (!g.proceed) return 0
211
- return installFn("upgrade", g.target !== "" ? g.target : latest)
191
+ return installFn("upgrade", g.target !== "" ? g.target : latest, ctx.services)
212
192
  }
213
193
 
214
194
  if (ctx.dryRun) {
215
195
  echo(`[dry-run] ${tool} up to date (${installed})`)
216
196
  return 0
217
197
  }
218
- log(`${tool} up to date (${installed})`)
198
+ verbose(`${tool} up to date (${installed})`)
219
199
  return 0
220
200
  }
221
201
 
@@ -225,10 +205,11 @@ function row(cells: [string, string, string, string, string, string]): string {
225
205
  }
226
206
 
227
207
  export function report(ctx: Ctx): void {
208
+ const { echo } = ctx.services.logger
228
209
  echo(row(["TOOL", "KIND", "INSTALLED", "FLOOR", "VERIFIED", "STATUS"]))
229
- const platformOs =
230
- process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : ""
231
- for (const tool of Object.keys(manifest(ctx)).sort(compareCodepoints)) {
210
+ const pn = ctx.services.platform.name()
211
+ const platformOs = pn === "unknown" ? "" : pn
212
+ for (const tool of Object.keys(manifest()).sort(compareCodepoints)) {
232
213
  const os = field(ctx, tool, "os")
233
214
  if (os !== "" && platformOs !== "" && os !== platformOs) continue
234
215
  const kind = field(ctx, tool, "kind")
@@ -241,8 +222,9 @@ export function report(ctx: Ctx): void {
241
222
  }
242
223
  let installed: string
243
224
  let status: string
244
- if (present(ctx, tool)) {
245
- installed = installedVersion(ctx, tool)
225
+ const toolId = tool as ToolId
226
+ if (present(ctx, toolId)) {
227
+ installed = installedVersion(ctx, toolId)
246
228
  status = "ok"
247
229
  if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
248
230
  status = "below-floor"
package/cli/src/engine.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { Console, Effect } from "effect"
2
2
  import { spawnSync } from "node:child_process"
3
3
  import { runEngineNative } from "./engine-native"
4
+ import { makeEngineServices } from "./engine-native/services"
4
5
  import { kitHome } from "./kitHome"
6
+ import { DependencyManagerService, LoggerService, PlatformService } from "./services"
7
+
8
+ // Same factory as the Effect rim's live layers — this path runs outside the
9
+ // runtime (child-spawn capture), so it takes the services directly.
10
+ const services = makeEngineServices()
5
11
 
6
12
  /**
7
13
  * The single seam between the typed CLI and EngineNative. Engine execution
@@ -24,7 +30,10 @@ export const engine = (args: ReadonlyArray<string>) =>
24
30
  if (bashEngineRequested()) {
25
31
  yield* bail(bashRemovedMessage, 2)
26
32
  }
27
- const code = yield* Effect.sync(() => runEngineNative(args))
33
+ const logger = yield* LoggerService
34
+ const deps = yield* DependencyManagerService
35
+ const platform = yield* PlatformService
36
+ const code = yield* Effect.sync(() => runEngineNative(args, { logger, deps, platform }))
28
37
  if (code !== 0) {
29
38
  yield* Effect.sync(() => process.exit(code))
30
39
  }
@@ -43,7 +52,7 @@ export const engineCapture = (args: ReadonlyArray<string>) =>
43
52
  stdio: ["ignore", "pipe", "inherit"]
44
53
  })
45
54
  if (res.error !== undefined || res.status !== 0) {
46
- process.stderr.write(`\x1b[1;33m[warn]\x1b[0m engine capture failed (${args.join(" ")} exited ${res.status ?? "spawn-error"})\n`)
55
+ services.logger.warn(`engine capture failed (${args.join(" ")} exited ${res.status ?? "spawn-error"})`)
47
56
  }
48
57
  return res.stdout ?? ""
49
58
  })