docks-kit 0.3.0 → 0.4.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.
@@ -9,15 +9,17 @@ import {
9
9
  copyFileSync,
10
10
  existsSync,
11
11
  mkdirSync,
12
- readdirSync,
13
12
  readFileSync,
14
13
  renameSync,
14
+ rmdirSync,
15
15
  rmSync,
16
16
  writeFileSync
17
17
  } from "node:fs"
18
18
  import { tmpdir } from "node:os"
19
+ import { bunBootstrap } from "./bun"
19
20
  import { syncClaudeModel } from "./claudeModel"
20
- import { ensureExecutable, p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
21
+ import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
22
+ import { p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
21
23
  import type { Ctx } from "./index"
22
24
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
23
25
  import type { EngineServices } from "./services"
@@ -26,8 +28,12 @@ import { mergeSettings, reconcileSettings } from "./settings"
26
28
  import { ensure, field } from "./toolchain"
27
29
  import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
28
30
 
29
- export function claudeSync(ctx: Ctx): void {
30
- const { warn } = ctx.services.logger
31
+ export type ClaudeRuntimeState =
32
+ | { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
33
+ | { readonly kind: "deferred"; readonly reason: "bun-unavailable" }
34
+
35
+ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
36
+ const { err, warn } = ctx.services.logger
31
37
  const claudeDir = p(ctx.home, ".claude")
32
38
 
33
39
  if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
@@ -39,26 +45,58 @@ export function claudeSync(ctx: Ctx): void {
39
45
  }
40
46
 
41
47
  syncRtk(ctx, claudeDir)
42
- syncScripts(ctx, claudeDir)
43
- syncHooks(ctx, claudeDir)
48
+ const bun = bunBootstrap(ctx, ctx.services)
49
+ const runtime: ClaudeRuntimeState = bun.kind === "ready"
50
+ ? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
51
+ : { kind: "deferred", reason: "bun-unavailable" }
52
+ const template = parseJson(payloadText("SoT/.claude/settings.json"))
53
+ if (template === undefined) {
54
+ err("Embedded SoT/.claude/settings.json is not valid JSON")
55
+ throw new ExitError(1)
56
+ }
57
+ const materialized = materializeClaudeSettings(
58
+ template,
59
+ runtime.kind === "ready" ? runtime.paths : undefined,
60
+ ctx.services.platform
61
+ )
62
+ const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
63
+
64
+ syncClaudeRuntime(ctx, runtime)
44
65
  syncClaudeMd(ctx, claudeDir)
45
- syncSettings(ctx, claudeDir)
66
+ if (ctx.dryRun) {
67
+ describeSettingsSync(ctx, claudeDir)
68
+ } else {
69
+ if (prepared === undefined) throw new Error("Claude settings were not prepared")
70
+ commitClaudeSettings(ctx, prepared)
71
+ }
72
+ syncRemovals(ctx, claudeDir, runtime)
46
73
  syncCompactWindow(ctx, claudeDir)
47
74
  syncPermissive(ctx, claudeDir)
48
75
  syncClaudeModel(ctx, ctx.claudeModel)
49
76
  syncClaudeJson(ctx)
50
77
  syncConnectorEnv(ctx)
51
- syncRemovals(ctx, claudeDir)
52
78
  syncPlugins(ctx, claudeDir)
53
79
  syncOptionalPlugins(ctx, claudeDir)
54
80
  syncLspServers(ctx)
81
+ return runtime
55
82
  }
56
83
 
57
84
  // ------------------------------------------------------------------ rtk ----
58
85
 
59
- /** RTK toolchain install callback. */
60
- export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
61
- return (mode, version, services) => {
86
+ type RtkInstaller = ((mode: "install" | "upgrade", version: string, services: EngineServices) => number) & {
87
+ readonly prerequisite: (services: EngineServices) => number | undefined
88
+ }
89
+
90
+ /** RTK toolchain install callback with the shared contextual curl boundary. */
91
+ export function rtkInstall(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): RtkInstaller {
92
+ const prerequisite = (services: EngineServices): number | undefined => {
93
+ if (services.deps.probe("curl").state === "present") return undefined
94
+ services.deps.warnMissing("curl", services.logger, missingCurlContext)
95
+ return missingCurlExit
96
+ }
97
+ const install = (mode: "install" | "upgrade", version: string, services: EngineServices): number => {
98
+ const blocked = prerequisite(services)
99
+ if (blocked !== undefined) return blocked
62
100
  const { change, err, verbose, warn } = services.logger
63
101
  const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
64
102
 
@@ -84,6 +122,16 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
84
122
  err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
85
123
  return 1
86
124
  }
125
+ return Object.assign(install, { prerequisite })
126
+ }
127
+
128
+ export function ensureRtk(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): number {
129
+ const installer = rtkInstall(ctx, missingCurlContext, missingCurlExit)
130
+ if (ctx.services.deps.probe("rtk").state === "missing") {
131
+ const blocked = installer.prerequisite(ctx.services)
132
+ if (blocked !== undefined) return blocked
133
+ }
134
+ return ensure(ctx, "rtk", installer)
87
135
  }
88
136
 
89
137
  function syncRtk(ctx: Ctx, claudeDir: string): void {
@@ -98,7 +146,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
98
146
  warn("rtk not installed — the kit's auto-install is Unix-only. Install natively (winget, or the rtk-*-windows-msvc.zip release), then re-run sync")
99
147
  return
100
148
  }
101
- } else if (ensure(ctx, "rtk", rtkInstall(ctx)) !== 0) {
149
+ } else if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
102
150
  warn("RTK bootstrap failed — continuing sync without it")
103
151
  }
104
152
 
@@ -118,60 +166,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
118
166
  }
119
167
  }
120
168
 
121
- // ------------------------------------------------------ scripts + hooks ----
169
+ // ----------------------------------------------------------- runtime ----
122
170
 
123
- function syncScripts(ctx: Ctx, claudeDir: string): void {
124
- const { change, echo, verbose } = ctx.services.logger
171
+ function syncClaudeRuntime(ctx: Ctx, runtime: ClaudeRuntimeState): void {
172
+ const { change, echo, verbose, warn } = ctx.services.logger
173
+ if (runtime.kind === "deferred") {
174
+ warn("Bun unavailable — Claude statusline/hooks migration deferred; install Bun, then re-run sync claude")
175
+ return
176
+ }
125
177
  if (ctx.dryRun) {
126
- echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
178
+ echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
127
179
  return
128
180
  }
129
181
 
182
+ mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true })
130
183
  let changed = false
131
- for (const [script, source] of [
132
- ["statusline.sh", "SoT/.claude/statusline.sh"],
133
- ["fetch-usage.sh", "SoT/.claude/fetch-usage.sh"]
184
+ for (const [path, source] of [
185
+ [runtime.paths.statusline, "SoT/.claude/bin/statusline.mjs"],
186
+ [runtime.paths.sessionStart, "SoT/.claude/bin/session-start.mjs"],
187
+ [runtime.paths.notify, "SoT/.claude/bin/notify.mjs"]
134
188
  ] as const) {
135
- const path = p(claudeDir, script)
136
189
  if (writeTextIfChanged(path, payloadText(source))) changed = true
137
- if (ensureExecutable(path)) changed = true
138
190
  }
139
- if (writeBytesIfChanged(p(claudeDir, "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
191
+ if (writeBytesIfChanged(p(ctx.home, ".claude", "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
140
192
  if (changed) {
141
- change("Scripts synced (statusline, fetch-usage, notification)")
193
+ change("Claude runtime synced (statusline, session-start, notify, notification)")
142
194
  ctx.nextStepTriggers.claudeRestart = true
143
- } else verbose("Scripts already in sync (statusline, fetch-usage, notification)")
144
- }
145
-
146
- function shellScriptCount(hooksDir: string): number {
147
- try {
148
- return readdirSync(hooksDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".sh")).length
149
- } catch {
150
- return 0
151
- }
152
- }
153
-
154
- function syncHooks(ctx: Ctx, claudeDir: string): void {
155
- const { change, echo, verbose } = ctx.services.logger
156
- const sotHooks = payloadDisplayPath("SoT/.claude/hooks/notify.sh", ctx.repoDir).replace(/\/notify\.sh$/, "")
157
-
158
- if (ctx.dryRun) {
159
- echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
160
- return
161
- }
162
-
163
- const hooksDir = p(claudeDir, "hooks")
164
- mkdirSync(hooksDir, { recursive: true })
165
- let changed = writeTextIfChanged(p(hooksDir, "notify.sh"), payloadText("SoT/.claude/hooks/notify.sh"))
166
- for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
167
- if (e.isFile() && e.name.endsWith(".sh")) {
168
- if (ensureExecutable(p(hooksDir, e.name))) changed = true
169
- }
195
+ } else {
196
+ verbose("Claude runtime already in sync (statusline, session-start, notify, notification)")
170
197
  }
171
- if (changed) {
172
- change(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
173
- ctx.nextStepTriggers.claudeRestart = true
174
- } else verbose(`Hooks already in sync (${shellScriptCount(hooksDir)} scripts)`)
175
198
  }
176
199
 
177
200
  function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
@@ -214,55 +237,73 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
214
237
 
215
238
  // ------------------------------------------------------------- settings ----
216
239
 
217
- function syncSettings(ctx: Ctx, claudeDir: string): void {
218
- const { change, echo, err, verbose } = ctx.services.logger
219
- const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
220
- const repoSettingsText = payloadText("SoT/.claude/settings.json")
221
- const userSettings = p(claudeDir, "settings.json")
240
+ export interface PreparedClaudeSettings {
241
+ readonly path: string
242
+ readonly bytes: string
243
+ readonly previousBytes: string | undefined
244
+ readonly changed: boolean
245
+ }
222
246
 
223
- if (ctx.dryRun) {
224
- if (!existsSync(userSettings)) {
225
- echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
226
- } else if (ctx.reconcile) {
227
- echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
228
- } else {
229
- echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
230
- }
231
- return
232
- }
247
+ function assertMaterializedSettings(bytes: string): void {
248
+ if (bytes.includes("__DOCKS_KIT_")) throw new Error("Claude settings contain unresolved runtime sentinels")
249
+ }
233
250
 
234
- if (!existsSync(userSettings)) {
235
- writeFileSync(userSettings, repoSettingsText)
236
- change("Settings installed")
237
- ctx.nextStepTriggers.claudeRestart = true
238
- return
251
+ /** Build the candidate settings bytes before the readiness-gated runtime cutover mutates disk. */
252
+ export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json): PreparedClaudeSettings {
253
+ const path = p(claudeDir, "settings.json")
254
+ if (!existsSync(path)) {
255
+ const bytes = jqStringify(repo)
256
+ assertMaterializedSettings(bytes)
257
+ return { path, bytes, previousBytes: undefined, changed: true }
239
258
  }
240
259
 
241
- const user = parseJson(readFileSync(userSettings, "utf8"))
260
+ const previousBytes = readFileSync(path, "utf8")
261
+ const user = parseJson(previousBytes)
242
262
  if (user === undefined) {
243
- // claude::_settings_validate returns 1 claude::sync aborts under set -e.
244
- err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
263
+ ctx.services.logger.err(`Skipping settings sync: ${path} is not valid JSON. Fix it manually or delete it to reinstall.`)
245
264
  throw new ExitError(1)
246
265
  }
247
- const repo = parseJson(repoSettingsText)!
248
-
249
266
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
250
- const out = jqStringify(merged)
251
- if (out === readFileSync(userSettings, "utf8")) {
267
+ const bytes = jqStringify(merged)
268
+ assertMaterializedSettings(bytes)
269
+ return { path, bytes, previousBytes, changed: bytes !== previousBytes }
270
+ }
271
+
272
+ /** Commit a fully prepared document; callers must finish runtime preparation first. */
273
+ export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings): void {
274
+ const { change, verbose } = ctx.services.logger
275
+ if (!prepared.changed) {
252
276
  verbose("Settings already in sync")
253
277
  return
254
278
  }
255
- copyFileSync(userSettings, `${userSettings}.bak`)
256
- writeFileSync(`${userSettings}.tmp`, out)
257
- renameSync(`${userSettings}.tmp`, userSettings)
279
+
280
+ if (prepared.previousBytes !== undefined) copyFileSync(prepared.path, `${prepared.path}.bak`)
281
+ writeFileSync(`${prepared.path}.tmp`, prepared.bytes)
282
+ renameSync(`${prepared.path}.tmp`, prepared.path)
258
283
  ctx.nextStepTriggers.claudeRestart = true
259
- if (ctx.reconcile) {
284
+ if (prepared.previousBytes === undefined) {
285
+ change("Settings installed")
286
+ } else if (ctx.reconcile) {
260
287
  change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
261
288
  } else {
262
289
  change("Settings merged (backup at settings.json.bak)")
263
290
  }
264
291
  }
265
292
 
293
+ function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
294
+ const { echo } = ctx.services.logger
295
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
296
+ const userSettings = p(claudeDir, "settings.json")
297
+
298
+ if (!existsSync(userSettings)) {
299
+ echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
300
+ } else if (ctx.reconcile) {
301
+ echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
302
+ } else {
303
+ echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
304
+ }
305
+ }
306
+
266
307
  /** Shared shape of the three jq-edit modifiers (compact window, permissive). */
267
308
  function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
268
309
  const { err, warn } = ctx.services.logger
@@ -455,7 +496,12 @@ const REMOVED_MANIFEST = {
455
496
  "env.CLAUDE_CODE_FORK_SUBAGENT",
456
497
  "env.CLAUDE_CODE_EFFORT_LEVEL"
457
498
  ],
458
- claudeJsonKeys: [] as Array<string>
499
+ claudeJsonKeys: [] as Array<string>,
500
+ runtimeReady: {
501
+ hooks: ["notify.sh"],
502
+ files: ["statusline.sh", "fetch-usage.sh"],
503
+ settingsKeys: ["hooks.Stop"]
504
+ }
459
505
  }
460
506
 
461
507
  /** claude::_prune_json_keys — present-count; deletes when !dryRun. */
@@ -464,18 +510,15 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
464
510
  const doc = parseJson(readFileSync(file, "utf8"))
465
511
  if (doc === undefined) return 0
466
512
 
467
- const getPath = (root: Json, path: Array<string>): Json | undefined => {
468
- let cur: Json | undefined = root
469
- for (const seg of path) {
470
- if (cur === undefined || !isObject(cur)) return undefined
471
- cur = cur[seg]
513
+ const hasPath = (root: Json, path: Array<string>): boolean => {
514
+ let cur: Json = root
515
+ for (const seg of path.slice(0, -1)) {
516
+ if (!isObject(cur) || cur[seg] === undefined) return false
517
+ cur = cur[seg]!
472
518
  }
473
- return cur
519
+ return isObject(cur) && Object.prototype.hasOwnProperty.call(cur, path[path.length - 1]!)
474
520
  }
475
- const presentKeys = keys.filter((k) => {
476
- const v = getPath(doc, k.split("."))
477
- return v !== undefined && v !== null
478
- })
521
+ const presentKeys = keys.filter((k) => hasPath(doc, k.split(".")))
479
522
  if (presentKeys.length === 0) return 0
480
523
 
481
524
  if (!ctx.dryRun) {
@@ -494,12 +537,24 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
494
537
  return presentKeys.length
495
538
  }
496
539
 
497
- function syncRemovals(ctx: Ctx, claudeDir: string): void {
540
+ function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState): void {
498
541
  const { change, echo } = ctx.services.logger
499
542
  let hooksRemoved = 0
500
543
  let filesRemoved = 0
501
-
502
- for (const name of REMOVED_MANIFEST.hooks) {
544
+ const hooks = [
545
+ ...REMOVED_MANIFEST.hooks,
546
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.hooks : [])
547
+ ]
548
+ const files = [
549
+ ...REMOVED_MANIFEST.files,
550
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.files : [])
551
+ ]
552
+ const settingsKeys = [
553
+ ...REMOVED_MANIFEST.settingsKeys,
554
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.settingsKeys : [])
555
+ ]
556
+
557
+ for (const name of hooks) {
503
558
  const path = p(claudeDir, "hooks", name)
504
559
  if (!existsSync(path)) continue
505
560
  if (ctx.dryRun) {
@@ -509,8 +564,15 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
509
564
  hooksRemoved++
510
565
  }
511
566
  }
567
+ if (!ctx.dryRun && hooksRemoved > 0) {
568
+ try {
569
+ rmdirSync(p(claudeDir, "hooks"))
570
+ } catch {
571
+ // Preserve a non-empty user hooks directory.
572
+ }
573
+ }
512
574
 
513
- for (const rel of REMOVED_MANIFEST.files) {
575
+ for (const rel of files) {
514
576
  const path = p(claudeDir, rel)
515
577
  if (!existsSync(path)) continue
516
578
  if (ctx.dryRun) {
@@ -521,7 +583,7 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
521
583
  }
522
584
  }
523
585
 
524
- const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), REMOVED_MANIFEST.settingsKeys)
586
+ const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), settingsKeys)
525
587
  const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
526
588
 
527
589
  if (ctx.dryRun) {
@@ -846,12 +908,16 @@ function syncLspServers(ctx: Ctx): void {
846
908
 
847
909
  // -------------------------------------------------------------- summary ----
848
910
 
849
- export function claudeSummary(ctx: Ctx): void {
911
+ export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
850
912
  const { echo } = ctx.services.logger
851
913
  const claudeDir = p(ctx.home, ".claude")
852
914
  echo(`Claude: ${claudeDir}`)
853
915
  if (!ctx.dryRun) {
854
- echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
916
+ if (runtime.kind === "ready") {
917
+ echo("Hooks: Bun (statusline, session-start, notify)")
918
+ } else {
919
+ echo("Hooks: migration deferred (Bun unavailable; existing hook/statusline settings preserved)")
920
+ }
855
921
  if (ctx.services.deps.probe("rtk").state === "present") {
856
922
  const version = ctx.services.deps.version("rtk")
857
923
  echo(`RTK: ${version !== "" ? version : "installed"}`)
@@ -8,6 +8,7 @@
8
8
  * command that installs a missing one.
9
9
  */
10
10
  import { homedir } from "node:os"
11
+ import { isAbsolute } from "node:path"
11
12
  import { existsSync, readdirSync } from "node:fs"
12
13
 
13
14
  import { capture, commandExists, p, which } from "./exec"
@@ -110,21 +111,34 @@ const home = (): string => {
110
111
  return envHome !== undefined && envHome !== "" ? envHome : homedir()
111
112
  }
112
113
 
113
- const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
114
+ const absoluteWindowsExe = (path: string): boolean =>
115
+ /\.exe$/i.test(path) && (/^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path))
116
+
117
+ // The resolved path gets persisted into global direct-exec hooks, so a
118
+ // relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
119
+ // break outside the sync working directory.
120
+ const absoluteExecutable = (path: string, platform: NodeJS.Platform): boolean =>
121
+ platform === "win32" ? absoluteWindowsExe(path) : isAbsolute(path)
122
+
123
+ const findBun = (exec: ProbeExecutor, platform: NodeJS.Platform = rawPlatform()): { command: string; path: string } | undefined => {
114
124
  const onPath = exec.which("bun")
115
- if (onPath !== "") return { command: "bun", path: onPath }
125
+ if (onPath !== "" && absoluteExecutable(onPath, platform)) {
126
+ return { command: platform === "win32" ? onPath : "bun", path: onPath }
127
+ }
116
128
  const root =
117
129
  process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
118
130
  ? process.env["BUN_INSTALL"]!
119
131
  : p(home(), ".bun")
120
- for (const candidate of [p(root, "bin", "bun"), p(home(), ".bun", "bin", "bun")]) {
121
- if (exec.which(candidate) !== "") return { command: candidate, path: candidate }
132
+ const name = platform === "win32" ? "bun.exe" : "bun"
133
+ for (const candidate of [p(root, "bin", name), p(home(), ".bun", "bin", name)]) {
134
+ const found = exec.which(candidate)
135
+ if (found !== "" && absoluteExecutable(found, platform)) return { command: found, path: found }
122
136
  }
123
137
  return undefined
124
138
  }
125
139
 
126
- const resolveBun = (exec: ProbeExecutor): ProbeResult => {
127
- const bun = findBun(exec)
140
+ const resolveBun = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
141
+ const bun = findBun(exec, platform)
128
142
  return bun === undefined
129
143
  ? { state: "missing" }
130
144
  : { state: "present", path: bun.path }
@@ -147,7 +161,9 @@ const versionEffectSolutions = (exec: ProbeExecutor): string => {
147
161
  }
148
162
 
149
163
  const locateEffectSolutions = (exec: ProbeExecutor, platform: NodeJS.Platform): DependencyLocation => {
150
- const bun = findBun(exec)
164
+ const strictBun = findBun(exec, platform)
165
+ const pathBun = exec.which("bun")
166
+ const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
151
167
  if (bun === undefined) return { path: "", binDir: "" }
152
168
  const globalBin = exec.capture(bun.command, ["pm", "-g", "bin"])
153
169
  const names =
@@ -180,6 +196,7 @@ const resolveChrome = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeRes
180
196
  }
181
197
 
182
198
  const latestRtk = (exec: ProbeExecutor): string => {
199
+ if (!exec.commandExists("curl")) return ""
183
200
  const doc = parseJson(
184
201
  exec.capture("curl", ["-fsSL", "--max-time", "5", "https://api.github.com/repos/rtk-ai/rtk/releases/latest"])
185
202
  )
@@ -204,7 +221,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
204
221
  : "sudo apt install -y git (or your distro's package manager)",
205
222
  { version: versionProbe("git") }
206
223
  ),
207
- jq: spec("jq", "required", (pf = rawPlatform()) =>
224
+ jq: spec("jq", "optional", (pf = rawPlatform()) =>
208
225
  pf === "win32"
209
226
  ? "winget install jqlang.jq (then open a new terminal)"
210
227
  : pf === "darwin"
@@ -212,7 +229,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
212
229
  : "sudo apt install -y jq",
213
230
  { version: versionProbe("jq") }
214
231
  ),
215
- curl: spec("curl", "required", (pf = rawPlatform()) =>
232
+ curl: spec("curl", "optional", (pf = rawPlatform()) =>
216
233
  pf === "win32" ? "winget install cURL.cURL" : pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
217
234
  { version: versionProbe("curl") }
218
235
  ),
@@ -251,7 +268,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
251
268
  {
252
269
  resolve: resolveBun,
253
270
  version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
254
- locate: (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
271
+ locate: (exec, platform) => ({ path: findBun(exec, platform)?.path ?? "", binDir: "" })
255
272
  }
256
273
  ),
257
274
  bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)"),
@@ -10,11 +10,12 @@ import { homedir } from "node:os"
10
10
 
11
11
  import { kitHome } from "../kitHome"
12
12
  import { makeEngineServices, type EngineServices, type Logger } from "./services"
13
+ import type { BunRuntimeState } from "./bun"
13
14
  import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
14
15
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
15
16
  import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
16
17
  import { modeModel, modeToolchain } from "./modes"
17
- import { ExitError, parseArgs, preflight, validateModelFlags } from "./parseArgs"
18
+ import { ExitError, parseArgs, validateModelFlags } from "./parseArgs"
18
19
 
19
20
  export interface Ctx {
20
21
  readonly repoDir: string
@@ -33,6 +34,7 @@ export interface Ctx {
33
34
  codexModel: string
34
35
  /** Injected capability seam (logger/deps/platform) — see services.ts. */
35
36
  readonly services: EngineServices
37
+ bunRuntime?: BunRuntimeState
36
38
  targetFilterSet: boolean
37
39
  syncClaude: boolean
38
40
  syncCodex: boolean
@@ -77,11 +79,10 @@ function makeCtx(services: EngineServices): Ctx {
77
79
  function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
78
80
  const { echo } = ctx.services.logger
79
81
  parseArgs(ctx, args)
80
- preflight(ctx)
81
82
  validateModelFlags(ctx)
82
83
 
83
84
  const claudeRan = ctx.syncClaude
84
- if (claudeRan) claudeSync(ctx)
85
+ const claudeRuntime = claudeRan ? claudeSync(ctx) : undefined
85
86
 
86
87
  const codexRan = ctx.syncCodex
87
88
  if (codexRan) codexSync(ctx)
@@ -91,7 +92,7 @@ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
91
92
  echo("")
92
93
  echo("--- Sync complete ---")
93
94
  echo(`Repo: ${ctx.repoDir}`)
94
- if (claudeRan) claudeSummary(ctx)
95
+ if (claudeRuntime !== undefined) claudeSummary(ctx, claudeRuntime)
95
96
  if (codexRan) codexSummary(ctx)
96
97
  if (skillsState !== undefined) skillsSummary(ctx, skillsState)
97
98
 
@@ -11,8 +11,9 @@ import { syncCodexModel } from "./codexToml"
11
11
  import type { Ctx } from "./index"
12
12
  import { isObject, parseJson, type Json } from "./jq"
13
13
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
14
- import { rtkInstall } from "./claudeSync"
15
- import { agentBrowserInstall, bunBootstrap, effectSolutionsInstall } from "./skillsSync"
14
+ import { ensureRtk } from "./claudeSync"
15
+ import { bunBootstrap } from "./bun"
16
+ import { agentBrowserInstall, effectSolutionsInstall } from "./skillsSync"
16
17
  import { ensure, report } from "./toolchain"
17
18
 
18
19
  export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
@@ -134,10 +135,9 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
134
135
  }
135
136
  switch (tool) {
136
137
  case "rtk":
137
- return ensure(ctx, "rtk", rtkInstall(ctx))
138
+ return ensureRtk(ctx, "cannot download RTK installer; toolchain ensure rtk aborted", 1)
138
139
  case "bun":
139
- // skills::_bun_bootstrap >/dev/null the found-bun stdout is discarded.
140
- return bunBootstrap(ctx, ctx.services) !== "" ? 0 : 1
140
+ return bunBootstrap(ctx, ctx.services).kind === "ready" ? 0 : 1
141
141
  case "effect-solutions":
142
142
  return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
143
143
  case "agent-browser":
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * EngineNative flag layer: usage / target selection / compact-window parsing /
3
- * optional-plugin parsing / model validation / preflight. ExitError mirrors an
3
+ * optional-plugin parsing / model validation. ExitError mirrors an
4
4
  * early parser exit and is caught once in runEngineNative.
5
5
  */
6
6
 
@@ -184,22 +184,6 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
184
184
  }
185
185
  }
186
186
 
187
- export function preflight(ctx: Ctx): void {
188
- const { err } = ctx.services.logger
189
- if (ctx.syncClaude || ctx.syncCodex) {
190
- if (ctx.services.deps.probe("jq").state === "missing") {
191
- err(`jq is required (deployed statusline/hooks call it). Install: ${ctx.services.deps.spec("jq").installHint()}`)
192
- throw new ExitError(1)
193
- }
194
- }
195
- if (ctx.syncClaude) {
196
- if (ctx.services.deps.probe("curl").state === "missing") {
197
- err(`curl is required. Install: ${ctx.services.deps.spec("curl").installHint()}`)
198
- throw new ExitError(1)
199
- }
200
- }
201
- }
202
-
203
187
  export function validateModelFlags(ctx: Ctx): void {
204
188
  const { err, warn } = ctx.services.logger
205
189
  if (ctx.claudeModel !== "") {
@@ -0,0 +1,11 @@
1
+ export function powerShellLiteral(value: string): string {
2
+ return `'${value.replaceAll("'", "''")}'`
3
+ }
4
+
5
+ export function encodePowerShellCommand(script: string): string {
6
+ return Buffer.from(script, "utf16le").toString("base64")
7
+ }
8
+
9
+ export function decodePowerShellCommand(encoded: string): string {
10
+ return Buffer.from(encoded, "base64").toString("utf16le")
11
+ }