docks-kit 0.3.0 → 0.5.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,21 @@ 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 { syncClaudeModel } from "./claudeModel"
20
- import { ensureExecutable, p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
19
+ import { bunBootstrap } from "./bun"
20
+ import {
21
+ syncClaudeAdvisor,
22
+ syncClaudeEffort,
23
+ syncClaudeModel
24
+ } from "./claudeSettingsModifiers"
25
+ import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
26
+ import { p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
21
27
  import type { Ctx } from "./index"
22
28
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
23
29
  import type { EngineServices } from "./services"
@@ -26,8 +32,12 @@ import { mergeSettings, reconcileSettings } from "./settings"
26
32
  import { ensure, field } from "./toolchain"
27
33
  import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
28
34
 
29
- export function claudeSync(ctx: Ctx): void {
30
- const { warn } = ctx.services.logger
35
+ export type ClaudeRuntimeState =
36
+ | { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
37
+ | { readonly kind: "deferred"; readonly reason: "bun-unavailable" }
38
+
39
+ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
40
+ const { err, warn } = ctx.services.logger
31
41
  const claudeDir = p(ctx.home, ".claude")
32
42
 
33
43
  if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
@@ -39,26 +49,60 @@ export function claudeSync(ctx: Ctx): void {
39
49
  }
40
50
 
41
51
  syncRtk(ctx, claudeDir)
42
- syncScripts(ctx, claudeDir)
43
- syncHooks(ctx, claudeDir)
52
+ const bun = bunBootstrap(ctx, ctx.services)
53
+ const runtime: ClaudeRuntimeState = bun.kind === "ready"
54
+ ? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
55
+ : { kind: "deferred", reason: "bun-unavailable" }
56
+ const template = parseJson(payloadText("SoT/.claude/settings.json"))
57
+ if (template === undefined) {
58
+ err("Embedded SoT/.claude/settings.json is not valid JSON")
59
+ throw new ExitError(1)
60
+ }
61
+ const materialized = materializeClaudeSettings(
62
+ template,
63
+ runtime.kind === "ready" ? runtime.paths : undefined,
64
+ ctx.services.platform
65
+ )
66
+ const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
67
+
68
+ syncClaudeRuntime(ctx, runtime)
44
69
  syncClaudeMd(ctx, claudeDir)
45
- syncSettings(ctx, claudeDir)
70
+ if (ctx.dryRun) {
71
+ describeSettingsSync(ctx, claudeDir)
72
+ } else {
73
+ if (prepared === undefined) throw new Error("Claude settings were not prepared")
74
+ commitClaudeSettings(ctx, prepared)
75
+ }
76
+ syncRemovals(ctx, claudeDir, runtime)
46
77
  syncCompactWindow(ctx, claudeDir)
47
78
  syncPermissive(ctx, claudeDir)
48
79
  syncClaudeModel(ctx, ctx.claudeModel)
80
+ syncClaudeEffort(ctx, ctx.claudeEffort)
81
+ syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
49
82
  syncClaudeJson(ctx)
50
83
  syncConnectorEnv(ctx)
51
- syncRemovals(ctx, claudeDir)
52
84
  syncPlugins(ctx, claudeDir)
53
85
  syncOptionalPlugins(ctx, claudeDir)
54
86
  syncLspServers(ctx)
87
+ return runtime
55
88
  }
56
89
 
57
90
  // ------------------------------------------------------------------ rtk ----
58
91
 
59
- /** RTK toolchain install callback. */
60
- export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
61
- return (mode, version, services) => {
92
+ type RtkInstaller = ((mode: "install" | "upgrade", version: string, services: EngineServices) => number) & {
93
+ readonly prerequisite: (services: EngineServices) => number | undefined
94
+ }
95
+
96
+ /** RTK toolchain install callback with the shared contextual curl boundary. */
97
+ export function rtkInstall(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): RtkInstaller {
98
+ const prerequisite = (services: EngineServices): number | undefined => {
99
+ if (services.deps.probe("curl").state === "present") return undefined
100
+ services.deps.warnMissing("curl", services.logger, missingCurlContext)
101
+ return missingCurlExit
102
+ }
103
+ const install = (mode: "install" | "upgrade", version: string, services: EngineServices): number => {
104
+ const blocked = prerequisite(services)
105
+ if (blocked !== undefined) return blocked
62
106
  const { change, err, verbose, warn } = services.logger
63
107
  const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
64
108
 
@@ -84,6 +128,16 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
84
128
  err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
85
129
  return 1
86
130
  }
131
+ return Object.assign(install, { prerequisite })
132
+ }
133
+
134
+ export function ensureRtk(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): number {
135
+ const installer = rtkInstall(ctx, missingCurlContext, missingCurlExit)
136
+ if (ctx.services.deps.probe("rtk").state === "missing") {
137
+ const blocked = installer.prerequisite(ctx.services)
138
+ if (blocked !== undefined) return blocked
139
+ }
140
+ return ensure(ctx, "rtk", installer)
87
141
  }
88
142
 
89
143
  function syncRtk(ctx: Ctx, claudeDir: string): void {
@@ -98,7 +152,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
98
152
  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
153
  return
100
154
  }
101
- } else if (ensure(ctx, "rtk", rtkInstall(ctx)) !== 0) {
155
+ } else if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
102
156
  warn("RTK bootstrap failed — continuing sync without it")
103
157
  }
104
158
 
@@ -118,60 +172,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
118
172
  }
119
173
  }
120
174
 
121
- // ------------------------------------------------------ scripts + hooks ----
175
+ // ----------------------------------------------------------- runtime ----
122
176
 
123
- function syncScripts(ctx: Ctx, claudeDir: string): void {
124
- const { change, echo, verbose } = ctx.services.logger
177
+ function syncClaudeRuntime(ctx: Ctx, runtime: ClaudeRuntimeState): void {
178
+ const { change, echo, verbose, warn } = ctx.services.logger
179
+ if (runtime.kind === "deferred") {
180
+ warn("Bun unavailable — Claude statusline/hooks migration deferred; install Bun, then re-run sync claude")
181
+ return
182
+ }
125
183
  if (ctx.dryRun) {
126
- echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
184
+ echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
127
185
  return
128
186
  }
129
187
 
188
+ mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true })
130
189
  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"]
190
+ for (const [path, source] of [
191
+ [runtime.paths.statusline, "SoT/.claude/bin/statusline.mjs"],
192
+ [runtime.paths.sessionStart, "SoT/.claude/bin/session-start.mjs"],
193
+ [runtime.paths.notify, "SoT/.claude/bin/notify.mjs"]
134
194
  ] as const) {
135
- const path = p(claudeDir, script)
136
195
  if (writeTextIfChanged(path, payloadText(source))) changed = true
137
- if (ensureExecutable(path)) changed = true
138
196
  }
139
- if (writeBytesIfChanged(p(claudeDir, "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
197
+ if (writeBytesIfChanged(p(ctx.home, ".claude", "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
140
198
  if (changed) {
141
- change("Scripts synced (statusline, fetch-usage, notification)")
199
+ change("Claude runtime synced (statusline, session-start, notify, notification)")
142
200
  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
- }
201
+ } else {
202
+ verbose("Claude runtime already in sync (statusline, session-start, notify, notification)")
170
203
  }
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
204
  }
176
205
 
177
206
  function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
@@ -214,55 +243,73 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
214
243
 
215
244
  // ------------------------------------------------------------- settings ----
216
245
 
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")
246
+ export interface PreparedClaudeSettings {
247
+ readonly path: string
248
+ readonly bytes: string
249
+ readonly previousBytes: string | undefined
250
+ readonly changed: boolean
251
+ }
222
252
 
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
- }
253
+ function assertMaterializedSettings(bytes: string): void {
254
+ if (bytes.includes("__DOCKS_KIT_")) throw new Error("Claude settings contain unresolved runtime sentinels")
255
+ }
233
256
 
234
- if (!existsSync(userSettings)) {
235
- writeFileSync(userSettings, repoSettingsText)
236
- change("Settings installed")
237
- ctx.nextStepTriggers.claudeRestart = true
238
- return
257
+ /** Build the candidate settings bytes before the readiness-gated runtime cutover mutates disk. */
258
+ export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json): PreparedClaudeSettings {
259
+ const path = p(claudeDir, "settings.json")
260
+ if (!existsSync(path)) {
261
+ const bytes = jqStringify(repo)
262
+ assertMaterializedSettings(bytes)
263
+ return { path, bytes, previousBytes: undefined, changed: true }
239
264
  }
240
265
 
241
- const user = parseJson(readFileSync(userSettings, "utf8"))
266
+ const previousBytes = readFileSync(path, "utf8")
267
+ const user = parseJson(previousBytes)
242
268
  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.`)
269
+ ctx.services.logger.err(`Skipping settings sync: ${path} is not valid JSON. Fix it manually or delete it to reinstall.`)
245
270
  throw new ExitError(1)
246
271
  }
247
- const repo = parseJson(repoSettingsText)!
248
-
249
272
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
250
- const out = jqStringify(merged)
251
- if (out === readFileSync(userSettings, "utf8")) {
273
+ const bytes = jqStringify(merged)
274
+ assertMaterializedSettings(bytes)
275
+ return { path, bytes, previousBytes, changed: bytes !== previousBytes }
276
+ }
277
+
278
+ /** Commit a fully prepared document; callers must finish runtime preparation first. */
279
+ export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings): void {
280
+ const { change, verbose } = ctx.services.logger
281
+ if (!prepared.changed) {
252
282
  verbose("Settings already in sync")
253
283
  return
254
284
  }
255
- copyFileSync(userSettings, `${userSettings}.bak`)
256
- writeFileSync(`${userSettings}.tmp`, out)
257
- renameSync(`${userSettings}.tmp`, userSettings)
285
+
286
+ if (prepared.previousBytes !== undefined) copyFileSync(prepared.path, `${prepared.path}.bak`)
287
+ writeFileSync(`${prepared.path}.tmp`, prepared.bytes)
288
+ renameSync(`${prepared.path}.tmp`, prepared.path)
258
289
  ctx.nextStepTriggers.claudeRestart = true
259
- if (ctx.reconcile) {
290
+ if (prepared.previousBytes === undefined) {
291
+ change("Settings installed")
292
+ } else if (ctx.reconcile) {
260
293
  change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
261
294
  } else {
262
295
  change("Settings merged (backup at settings.json.bak)")
263
296
  }
264
297
  }
265
298
 
299
+ function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
300
+ const { echo } = ctx.services.logger
301
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
302
+ const userSettings = p(claudeDir, "settings.json")
303
+
304
+ if (!existsSync(userSettings)) {
305
+ echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
306
+ } else if (ctx.reconcile) {
307
+ echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
308
+ } else {
309
+ echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
310
+ }
311
+ }
312
+
266
313
  /** Shared shape of the three jq-edit modifiers (compact window, permissive). */
267
314
  function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
268
315
  const { err, warn } = ctx.services.logger
@@ -448,6 +495,7 @@ const REMOVED_MANIFEST = {
448
495
  files: ["alert_bubble.mp3"],
449
496
  settingsKeys: [
450
497
  "showTurnDuration",
498
+ "advisorModel",
451
499
  "env.CLAUDE_CODE_SUBAGENT_MODEL",
452
500
  "env.ANTHROPIC_DEFAULT_OPUS_MODEL",
453
501
  "env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
@@ -455,7 +503,12 @@ const REMOVED_MANIFEST = {
455
503
  "env.CLAUDE_CODE_FORK_SUBAGENT",
456
504
  "env.CLAUDE_CODE_EFFORT_LEVEL"
457
505
  ],
458
- claudeJsonKeys: [] as Array<string>
506
+ claudeJsonKeys: [] as Array<string>,
507
+ runtimeReady: {
508
+ hooks: ["notify.sh"],
509
+ files: ["statusline.sh", "fetch-usage.sh"],
510
+ settingsKeys: ["hooks.Stop"]
511
+ }
459
512
  }
460
513
 
461
514
  /** claude::_prune_json_keys — present-count; deletes when !dryRun. */
@@ -464,18 +517,15 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
464
517
  const doc = parseJson(readFileSync(file, "utf8"))
465
518
  if (doc === undefined) return 0
466
519
 
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]
520
+ const hasPath = (root: Json, path: Array<string>): boolean => {
521
+ let cur: Json = root
522
+ for (const seg of path.slice(0, -1)) {
523
+ if (!isObject(cur) || cur[seg] === undefined) return false
524
+ cur = cur[seg]!
472
525
  }
473
- return cur
526
+ return isObject(cur) && Object.prototype.hasOwnProperty.call(cur, path[path.length - 1]!)
474
527
  }
475
- const presentKeys = keys.filter((k) => {
476
- const v = getPath(doc, k.split("."))
477
- return v !== undefined && v !== null
478
- })
528
+ const presentKeys = keys.filter((k) => hasPath(doc, k.split(".")))
479
529
  if (presentKeys.length === 0) return 0
480
530
 
481
531
  if (!ctx.dryRun) {
@@ -494,12 +544,24 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
494
544
  return presentKeys.length
495
545
  }
496
546
 
497
- function syncRemovals(ctx: Ctx, claudeDir: string): void {
547
+ function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState): void {
498
548
  const { change, echo } = ctx.services.logger
499
549
  let hooksRemoved = 0
500
550
  let filesRemoved = 0
501
-
502
- for (const name of REMOVED_MANIFEST.hooks) {
551
+ const hooks = [
552
+ ...REMOVED_MANIFEST.hooks,
553
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.hooks : [])
554
+ ]
555
+ const files = [
556
+ ...REMOVED_MANIFEST.files,
557
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.files : [])
558
+ ]
559
+ const settingsKeys = [
560
+ ...REMOVED_MANIFEST.settingsKeys.filter((key) => key !== "advisorModel" || ctx.claudeAdvisor === ""),
561
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.settingsKeys : [])
562
+ ]
563
+
564
+ for (const name of hooks) {
503
565
  const path = p(claudeDir, "hooks", name)
504
566
  if (!existsSync(path)) continue
505
567
  if (ctx.dryRun) {
@@ -509,8 +571,15 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
509
571
  hooksRemoved++
510
572
  }
511
573
  }
574
+ if (!ctx.dryRun && hooksRemoved > 0) {
575
+ try {
576
+ rmdirSync(p(claudeDir, "hooks"))
577
+ } catch {
578
+ // Preserve a non-empty user hooks directory.
579
+ }
580
+ }
512
581
 
513
- for (const rel of REMOVED_MANIFEST.files) {
582
+ for (const rel of files) {
514
583
  const path = p(claudeDir, rel)
515
584
  if (!existsSync(path)) continue
516
585
  if (ctx.dryRun) {
@@ -521,7 +590,7 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
521
590
  }
522
591
  }
523
592
 
524
- const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), REMOVED_MANIFEST.settingsKeys)
593
+ const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), settingsKeys)
525
594
  const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
526
595
 
527
596
  if (ctx.dryRun) {
@@ -846,12 +915,16 @@ function syncLspServers(ctx: Ctx): void {
846
915
 
847
916
  // -------------------------------------------------------------- summary ----
848
917
 
849
- export function claudeSummary(ctx: Ctx): void {
918
+ export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
850
919
  const { echo } = ctx.services.logger
851
920
  const claudeDir = p(ctx.home, ".claude")
852
921
  echo(`Claude: ${claudeDir}`)
853
922
  if (!ctx.dryRun) {
854
- echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
923
+ if (runtime.kind === "ready") {
924
+ echo("Hooks: Bun (statusline, session-start, notify)")
925
+ } else {
926
+ echo("Hooks: migration deferred (Bun unavailable; existing hook/statusline settings preserved)")
927
+ }
855
928
  if (ctx.services.deps.probe("rtk").state === "present") {
856
929
  const version = ctx.services.deps.version("rtk")
857
930
  echo(`RTK: ${version !== "" ? version : "installed"}`)
@@ -6,7 +6,7 @@
6
6
  import { spawnSync } from "node:child_process"
7
7
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
8
8
 
9
- import { syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
9
+ import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
10
10
  import { p } from "./exec"
11
11
  import type { Ctx } from "./index"
12
12
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
@@ -21,6 +21,7 @@ export function codexSync(ctx: Ctx): void {
21
21
  if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
22
22
  syncConfig(ctx, sotConfig, userConfig)
23
23
  syncCodexModel(ctx, ctx.codexModel)
24
+ syncCodexEffort(ctx, ctx.codexEffort)
24
25
  syncRules(ctx, payloadPaths("SoT/.codex/rules/"), p(codexDir, "rules"))
25
26
  syncAgentsMd(ctx, payloadText("SoT/.codex/AGENTS.md"), p(codexDir, "AGENTS.md"))
26
27
  syncMarketplace(ctx, payloadText("SoT/.codex/plugins/marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
@@ -7,6 +7,7 @@
7
7
  import { p } from "./exec"
8
8
  import { readFileSync, renameSync, writeFileSync } from "node:fs"
9
9
 
10
+ import { resolveEffort } from "../efforts"
10
11
  import type { Ctx } from "./index"
11
12
 
12
13
  export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
@@ -48,27 +49,59 @@ export function replaceTopLevelSettingInFile(file: string, key: string, replacem
48
49
  return true
49
50
  }
50
51
 
51
- export function syncCodexModel(ctx: Ctx, model: string): void {
52
+ interface CodexSettingEdit {
53
+ readonly tag: string
54
+ readonly key: "model" | "model_reasoning_effort"
55
+ readonly value: string
56
+ readonly changed: string
57
+ readonly unchanged: string
58
+ }
59
+
60
+ function syncCodexSetting(ctx: Ctx, edit: CodexSettingEdit): void {
52
61
  const { change, echo, verbose, warn } = ctx.services.logger
53
62
  const userCodexSettings = p(ctx.home, ".codex", "config.toml")
54
63
 
55
- if (model === "") return
56
-
57
64
  if (ctx.dryRun) {
58
- echo(`[dry-run] (--codex-model) set model = "${model}" in ${userCodexSettings}`)
65
+ echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
59
66
  return
60
67
  }
61
68
 
62
69
  try {
63
70
  readFileSync(userCodexSettings)
64
71
  } catch {
65
- warn(`(--codex-model) ${userCodexSettings} missing — skipped`)
72
+ warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
66
73
  return
67
74
  }
68
- if (replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)) {
69
- change(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
75
+ if (replaceTopLevelSettingInFile(userCodexSettings, edit.key, `${edit.key} = "${edit.value}"`)) {
76
+ change(edit.changed)
70
77
  ctx.nextStepTriggers.codexRestart = true
71
78
  } else {
72
- verbose(`Model: deployed Codex model already ${model}`)
79
+ verbose(edit.unchanged)
73
80
  }
74
81
  }
82
+
83
+ export function syncCodexModel(ctx: Ctx, model: string): void {
84
+ if (model === "") return
85
+ syncCodexSetting(ctx, {
86
+ tag: "--codex-model",
87
+ key: "model",
88
+ value: model,
89
+ changed: `Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`,
90
+ unchanged: `Model: deployed Codex model already ${model}`
91
+ })
92
+ }
93
+
94
+ export function syncCodexEffort(ctx: Ctx, effort: string): void {
95
+ if (effort === "") return
96
+ const resolved = resolveEffort("codex", effort)
97
+ const useDefault = effort === "default"
98
+ syncCodexSetting(ctx, {
99
+ tag: "--codex-effort",
100
+ key: "model_reasoning_effort",
101
+ value: resolved,
102
+ changed: useDefault
103
+ ? `Effort: deployed Codex model_reasoning_effort set to ${resolved} (SoT default)`
104
+ : `Effort: deployed Codex model_reasoning_effort set to ${resolved} (SoT unchanged; flag-less sync reverts)`,
105
+ unchanged: `Effort: deployed Codex model_reasoning_effort already ${resolved}`
106
+ })
107
+ }
@@ -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)"),