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
@@ -4,26 +4,26 @@
4
4
  * change. Guard order, message strings, and backup behavior are golden-tested.
5
5
  */
6
6
  import { spawnSync } from "node:child_process"
7
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
7
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
8
8
 
9
9
  import { syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
10
- import { capture, commandExists, p } from "./exec"
10
+ import { p } from "./exec"
11
11
  import type { Ctx } from "./index"
12
12
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
13
- import { echo, err, log, warn } from "./output"
13
+ import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
14
14
 
15
15
  export function codexSync(ctx: Ctx): void {
16
16
  const codexDir = p(ctx.home, ".codex")
17
- const sotConfig = p(ctx.repoDir, "SoT", ".codex", "config.toml")
17
+ const sotConfig = payloadText("SoT/.codex/config.toml")
18
18
  const userConfig = p(codexDir, "config.toml")
19
19
 
20
20
  ensureBubblewrap(ctx)
21
21
  if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
22
22
  syncConfig(ctx, sotConfig, userConfig)
23
23
  syncCodexModel(ctx, ctx.codexModel)
24
- syncRules(ctx, p(ctx.repoDir, "SoT", ".codex", "rules"), p(codexDir, "rules"))
25
- syncAgentsMd(ctx, p(ctx.repoDir, "SoT", ".codex", "AGENTS.md"), p(codexDir, "AGENTS.md"))
26
- syncMarketplace(ctx, p(ctx.repoDir, "SoT", ".codex", "plugins", "marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
24
+ syncRules(ctx, payloadPaths("SoT/.codex/rules/"), p(codexDir, "rules"))
25
+ syncAgentsMd(ctx, payloadText("SoT/.codex/AGENTS.md"), p(codexDir, "AGENTS.md"))
26
+ syncMarketplace(ctx, payloadText("SoT/.codex/plugins/marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
27
27
  removeLegacyDocksMarketplace(ctx, userConfig)
28
28
  syncPlugins(ctx, sotConfig)
29
29
  }
@@ -31,14 +31,15 @@ export function codexSync(ctx: Ctx): void {
31
31
  // ---------------------------------------------------------- bubblewrap ----
32
32
 
33
33
  function ensureBubblewrap(ctx: Ctx): void {
34
- if (!bwrapSupportedOs()) return
34
+ const { change, echo, warn } = ctx.services.logger
35
+ if (!bwrapSupportedOs(ctx)) return
35
36
 
36
37
  if (ctx.dryRun) {
37
38
  echo("[dry-run] verify bubblewrap installed (recommended Codex Linux sandbox runtime)")
38
39
  return
39
40
  }
40
41
 
41
- if (commandExists("bwrap")) return
42
+ if (ctx.services.deps.probe("bwrap").state === "present") return
42
43
 
43
44
  if (ctx.skipRtk) {
44
45
  warn(
@@ -47,7 +48,7 @@ function ensureBubblewrap(ctx: Ctx): void {
47
48
  return
48
49
  }
49
50
 
50
- const pmInstall = bwrapDetectPmInstallCmd()
51
+ const pmInstall = bwrapDetectPmInstallCmd(ctx)
51
52
  if (pmInstall === "") {
52
53
  warn(
53
54
  "bubblewrap not installed and no supported package manager found (apt-get/dnf/pacman/zypper). Codex may use its bundled helper if user namespaces work; install system bubblewrap manually when possible."
@@ -62,14 +63,13 @@ function ensureBubblewrap(ctx: Ctx): void {
62
63
  return
63
64
  }
64
65
 
65
- if (!commandExists("bwrap")) {
66
+ if (ctx.services.deps.probe("bwrap").state === "missing") {
66
67
  warn("Package install reported success but bwrap not on PATH — check installation manually")
67
68
  return
68
69
  }
69
70
 
70
71
  if (spawnSync("unshare", ["-Ur", "true"], { stdio: "ignore" }).status === 0) {
71
- const version = capture("bwrap", ["--version"]).split("\n")[0] ?? ""
72
- log(`bubblewrap installed and functional (${version})`)
72
+ change(`bubblewrap installed and functional (${ctx.services.deps.version("bwrap")})`)
73
73
  } else {
74
74
  warn(
75
75
  "bubblewrap installed but unprivileged user namespaces appear blocked. On Ubuntu 24.04+, prefer loading the AppArmor bwrap-userns-restrict profile; fallback: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0"
@@ -77,25 +77,28 @@ function ensureBubblewrap(ctx: Ctx): void {
77
77
  }
78
78
  }
79
79
 
80
- function bwrapSupportedOs(): boolean {
81
- if (process.platform === "linux") return true
82
- if (process.platform === "darwin" || process.platform === "win32") return false
80
+ function bwrapSupportedOs(ctx: Ctx): boolean {
81
+ const { warn } = ctx.services.logger
82
+ const pn = ctx.services.platform.name()
83
+ if (pn === "linux") return true
84
+ if (pn === "darwin" || pn === "windows") return false
83
85
  warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
84
86
  return false
85
87
  }
86
88
 
87
- function bwrapDetectPmInstallCmd(): string {
88
- if (commandExists("apt-get")) return "sudo apt-get install -y bubblewrap"
89
- if (commandExists("dnf")) return "sudo dnf install -y bubblewrap"
90
- if (commandExists("pacman")) return "sudo pacman -S --noconfirm bubblewrap"
91
- if (commandExists("zypper")) return "sudo zypper install -y bubblewrap"
89
+ function bwrapDetectPmInstallCmd(ctx: Ctx): string {
90
+ if (ctx.services.deps.probe("apt-get").state === "present") return "sudo apt-get install -y bubblewrap"
91
+ if (ctx.services.deps.probe("dnf").state === "present") return "sudo dnf install -y bubblewrap"
92
+ if (ctx.services.deps.probe("pacman").state === "present") return "sudo pacman -S --noconfirm bubblewrap"
93
+ if (ctx.services.deps.probe("zypper").state === "present") return "sudo zypper install -y bubblewrap"
92
94
  return ""
93
95
  }
94
96
 
95
97
  // --------------------------------------------------------------- config ----
96
98
 
97
- function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
98
- if (!existsSync(sotConfig)) return
99
+ function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
100
+ const { change, echo, verbose } = ctx.services.logger
101
+ const sotConfig = payloadDisplayPath("SoT/.codex/config.toml", ctx.repoDir)
99
102
 
100
103
  if (ctx.dryRun) {
101
104
  echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
@@ -103,18 +106,32 @@ function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
103
106
  }
104
107
 
105
108
  if (!existsSync(userConfig)) {
106
- copyFileSync(sotConfig, userConfig)
107
- log("Codex config installed")
109
+ writeFileSync(userConfig, sotConfigText)
110
+ change("Codex config installed")
111
+ ctx.nextStepTriggers.codexRestart = true
108
112
  return
109
113
  }
110
114
 
111
- copyFileSync(userConfig, `${userConfig}.bak`)
115
+ // Merge into a staging copy so `.bak` is written only when the config
116
+ // actually changes — an unconditional early backup lets a later no-op run
117
+ // overwrite the recovery copy with already-merged content.
118
+ const before = readFileSync(userConfig, "utf8")
119
+ const staging = `${userConfig}.merge.tmp`
120
+ writeFileSync(staging, before)
112
121
 
113
- scrubDeprecatedFeatures(userConfig)
114
- mergeTopLevelSettings(sotConfig, userConfig)
115
- mergeTableSettings(sotConfig, userConfig)
122
+ scrubDeprecatedFeatures(ctx, staging)
123
+ mergeTopLevelSettings(sotConfigText, staging)
124
+ mergeTableSettings(sotConfigText, staging)
116
125
 
117
- log("Codex config merged (backup at config.toml.bak; user-only keys/tables preserved)")
126
+ if (readFileSync(staging, "utf8") === before) {
127
+ rmSync(staging, { force: true })
128
+ verbose("Codex config already in sync")
129
+ } else {
130
+ copyFileSync(userConfig, `${userConfig}.bak`)
131
+ renameSync(staging, userConfig)
132
+ change("Codex config merged (backup at config.toml.bak; user-only keys/tables preserved)")
133
+ ctx.nextStepTriggers.codexRestart = true
134
+ }
118
135
  }
119
136
 
120
137
  /** codex::scrub_deprecated_features — the [features].use_legacy_landlock awk pass. */
@@ -152,18 +169,19 @@ export function scrubDeprecatedFeaturesText(content: string): string {
152
169
  return out
153
170
  }
154
171
 
155
- function scrubDeprecatedFeatures(userConfig: string): void {
172
+ function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
173
+ const { change } = ctx.services.logger
156
174
  if (!existsSync(userConfig)) return
157
175
  const content = readFileSync(userConfig, "utf8")
158
176
  if (!content.split("\n").some((l) => /^use_legacy_landlock[ \t]*=/.test(l))) return
159
177
 
160
178
  writeFileSync(`${userConfig}.tmp`, scrubDeprecatedFeaturesText(content))
161
179
  renameSync(`${userConfig}.tmp`, userConfig)
162
- log("Codex: scrubbed deprecated [features].use_legacy_landlock")
180
+ change("Codex: scrubbed deprecated [features].use_legacy_landlock")
163
181
  }
164
182
 
165
- function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
166
- for (const line of readFileSync(sotConfig, "utf8").split("\n")) {
183
+ function mergeTopLevelSettings(sotConfigText: string, userConfig: string): void {
184
+ for (const line of sotConfigText.split("\n")) {
167
185
  if (line.startsWith("[")) break
168
186
  if (/^[ \t]*($|#)/.test(line)) continue
169
187
  if (!/^[A-Za-z0-9_.-]+[ \t]*=/.test(line)) continue
@@ -172,8 +190,8 @@ function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
172
190
  }
173
191
  }
174
192
 
175
- function mergeTableSettings(sotConfig: string, userConfig: string): void {
176
- const sotLines = readFileSync(sotConfig, "utf8").split("\n")
193
+ function mergeTableSettings(sotConfigText: string, userConfig: string): void {
194
+ const sotLines = sotConfigText.split("\n")
177
195
  for (const tableHeader of sotLines.filter((l) => /^\[[^\]]+\]/.test(l))) {
178
196
  // Extract the SoT block: from the exact header line to (excluding) the
179
197
  // next table header; `$(...)` strips trailing newlines.
@@ -208,8 +226,12 @@ function mergeTableSettings(sotConfig: string, userConfig: string): void {
208
226
 
209
227
  // ------------------------------------------------------- rules + agents ----
210
228
 
211
- function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
212
- if (!existsSync(sotRulesDir)) return
229
+ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir: string): void {
230
+ const { change, echo, verbose } = ctx.services.logger
231
+ const firstRule = sotRules[0]
232
+ if (firstRule === undefined) return
233
+ const firstDisplay = payloadDisplayPath(firstRule, ctx.repoDir)
234
+ const sotRulesDir = firstDisplay.slice(0, firstDisplay.lastIndexOf("/"))
213
235
 
214
236
  if (ctx.dryRun) {
215
237
  echo(`[dry-run] cp ${sotRulesDir}/*.rules -> ${userRulesDir}/`)
@@ -217,37 +239,49 @@ function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
217
239
  }
218
240
 
219
241
  mkdirSync(userRulesDir, { recursive: true })
220
- let rulesSynced = false
221
- const ruleFiles = readdirSync(sotRulesDir, { withFileTypes: true })
222
- .filter((e) => e.isFile() && e.name.endsWith(".rules"))
223
- .map((e) => p(sotRulesDir, e.name))
224
- .sort(compareCodepoints)
242
+ let sawRules = false
243
+ let rulesChanged = false
244
+ const ruleFiles = sotRules.filter((path) => path.endsWith(".rules")).sort(compareCodepoints)
225
245
  for (const ruleFile of ruleFiles) {
246
+ sawRules = true
226
247
  const userRuleFile = p(userRulesDir, ruleFile.slice(ruleFile.lastIndexOf("/") + 1))
248
+ const content = payloadBytes(ruleFile)
249
+ const identical = existsSync(userRuleFile) && readFileSync(userRuleFile).equals(content)
250
+ if (identical) continue
227
251
  if (existsSync(userRuleFile)) copyFileSync(userRuleFile, `${userRuleFile}.bak`)
228
- copyFileSync(ruleFile, userRuleFile)
229
- rulesSynced = true
252
+ writeFileSync(userRuleFile, content)
253
+ rulesChanged = true
230
254
  }
231
- if (rulesSynced) log("Codex rules synced")
255
+ if (rulesChanged) {
256
+ change("Codex rules synced")
257
+ ctx.nextStepTriggers.codexRestart = true
258
+ } else if (sawRules) verbose("Codex rules already in sync")
232
259
  }
233
260
 
234
- function syncAgentsMd(ctx: Ctx, sotAgentsMd: string, userAgentsMd: string): void {
235
- if (!existsSync(sotAgentsMd)) return
261
+ function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string): void {
262
+ const { change, echo, verbose } = ctx.services.logger
263
+ const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md", ctx.repoDir)
236
264
 
237
265
  if (ctx.dryRun) {
238
266
  echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
239
267
  return
240
268
  }
241
269
 
270
+ if (existsSync(userAgentsMd) && readFileSync(userAgentsMd, "utf8") === sotAgentsMdText) {
271
+ verbose("Codex AGENTS.md already in sync")
272
+ return
273
+ }
242
274
  if (existsSync(userAgentsMd)) copyFileSync(userAgentsMd, `${userAgentsMd}.bak`)
243
- copyFileSync(sotAgentsMd, userAgentsMd)
244
- log("Codex AGENTS.md synced")
275
+ writeFileSync(userAgentsMd, sotAgentsMdText)
276
+ change("Codex AGENTS.md synced")
277
+ ctx.nextStepTriggers.codexRestart = true
245
278
  }
246
279
 
247
280
  // ---------------------------------------------------------- marketplace ----
248
281
 
249
- function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: string): void {
250
- if (!existsSync(sotMarketplace)) return
282
+ function syncMarketplace(ctx: Ctx, sotMarketplaceText: string, userMarketplace: string): void {
283
+ const { change, echo, err, verbose } = ctx.services.logger
284
+ const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json", ctx.repoDir)
251
285
 
252
286
  if (ctx.dryRun) {
253
287
  echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
@@ -255,7 +289,7 @@ function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: stri
255
289
  }
256
290
 
257
291
  mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
258
- const repo = parseJson(readFileSync(sotMarketplace, "utf8"))
292
+ const repo = parseJson(sotMarketplaceText)
259
293
  if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
260
294
 
261
295
  if (existsSync(userMarketplace)) {
@@ -264,13 +298,20 @@ function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: stri
264
298
  err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
265
299
  return
266
300
  }
301
+ const out = jqStringify(mergeMarketplace(repo, user))
302
+ if (out === readFileSync(userMarketplace, "utf8")) {
303
+ verbose("Codex marketplace already in sync")
304
+ return
305
+ }
267
306
  copyFileSync(userMarketplace, `${userMarketplace}.bak`)
268
- writeFileSync(`${userMarketplace}.tmp`, jqStringify(mergeMarketplace(repo, user)))
307
+ writeFileSync(`${userMarketplace}.tmp`, out)
269
308
  renameSync(`${userMarketplace}.tmp`, userMarketplace)
270
- log("Codex marketplace merged (backup at marketplace.json.bak)")
309
+ change("Codex marketplace merged (backup at marketplace.json.bak)")
310
+ ctx.nextStepTriggers.codexRestart = true
271
311
  } else {
272
- copyFileSync(sotMarketplace, userMarketplace)
273
- log("Codex marketplace installed")
312
+ writeFileSync(userMarketplace, sotMarketplaceText)
313
+ change("Codex marketplace installed")
314
+ ctx.nextStepTriggers.codexRestart = true
274
315
  }
275
316
  }
276
317
 
@@ -326,39 +367,42 @@ export function marketplaceSource(marketplace: string, configFile: string): stri
326
367
  }
327
368
 
328
369
  function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
370
+ const { change, echo, warn } = ctx.services.logger
329
371
  if (ctx.dryRun) {
330
372
  echo("[dry-run] remove legacy configured Codex Docks marketplace when personal marketplace is deployed")
331
373
  return
332
374
  }
333
375
 
334
- if (!commandExists("codex")) return
376
+ if (ctx.services.deps.probe("codex").state === "missing") return
335
377
 
336
378
  const source = marketplaceSource("docks", userConfig)
337
379
  if (source !== "https://github.com/DocksDocks/docks.git" && source !== "DocksDocks/docks") return
338
380
  const res = spawnSync("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
339
381
  if (res.error === undefined && res.status === 0) {
340
- log("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
382
+ change("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
383
+ ctx.nextStepTriggers.codexRestart = true
341
384
  } else {
342
385
  warn("Failed to remove legacy configured Codex Docks marketplace")
343
386
  }
344
387
  }
345
388
 
346
389
  /** codex::_standalone_install_command — per-OS official standalone installer. */
347
- const standaloneInstallCommand = (): string =>
348
- process.platform === "win32"
349
- ? `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`
350
- : 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o "$tmp" && CODEX_NON_INTERACTIVE=1 sh "$tmp"'
390
+ const standaloneInstallCommand = (ctx: Ctx): string => ctx.services.deps.spec("codex").installHint()
351
391
 
352
392
  /** codex::_enabled_plugin_ids — [plugins."<id>"] tables with enabled = true. */
353
393
  export function enabledPluginIds(configFile: string): Array<string> {
354
394
  if (!existsSync(configFile)) return []
395
+ return enabledPluginIdsFromText(readFileSync(configFile, "utf8"))
396
+ }
397
+
398
+ export function enabledPluginIdsFromText(configText: string): Array<string> {
355
399
  const ids: Array<string> = []
356
400
  let plugin = ""
357
401
  let enabled = false
358
402
  const flush = (): void => {
359
403
  if (plugin !== "" && enabled) ids.push(plugin)
360
404
  }
361
- for (const line of readFileSync(configFile, "utf8").split("\n")) {
405
+ for (const line of configText.split("\n")) {
362
406
  const m = /^\[plugins\."([^"]+)"\][ \t]*$/.exec(line)
363
407
  if (m !== null) {
364
408
  flush()
@@ -380,39 +424,43 @@ export function enabledPluginIds(configFile: string): Array<string> {
380
424
  return ids
381
425
  }
382
426
 
383
- function manualPluginRefreshCommand(sotConfig: string): string {
384
- const first = enabledPluginIds(sotConfig)[0]
427
+ function manualPluginRefreshCommand(sotConfigText: string): string {
428
+ const first = enabledPluginIdsFromText(sotConfigText)[0]
385
429
  return first !== undefined ? `codex plugin add ${first}` : "codex plugin add <plugin@marketplace>"
386
430
  }
387
431
 
388
- function syncPlugins(ctx: Ctx, sotConfig: string): void {
432
+ function syncPlugins(ctx: Ctx, sotConfigText: string): void {
433
+ const { change, echo, warn } = ctx.services.logger
389
434
  if (ctx.dryRun) {
390
435
  echo("[dry-run] add enabled Codex plugins from SoT")
391
436
  return
392
437
  }
393
438
 
394
- if (!commandExists("codex")) {
439
+ if (ctx.services.deps.probe("codex").state === "missing") {
395
440
  warn(
396
- `codex CLI not in PATH - deployed config/marketplace only; install Codex with: ${standaloneInstallCommand()} | docs: https://developers.openai.com/codex/cli; then run: ${manualPluginRefreshCommand(sotConfig)}`
441
+ `codex CLI not in PATH - deployed config/marketplace only; install Codex with: ${standaloneInstallCommand(ctx)} | docs: https://developers.openai.com/codex/cli; then run: ${manualPluginRefreshCommand(sotConfigText)}`
397
442
  )
398
443
  return
399
444
  }
400
- if (!commandExists("git")) {
401
- const hint = process.platform === "win32" ? "winget install Git.Git (then open a new terminal)" : "install git via your package manager"
402
- warn(`git not found — Codex plugin marketplaces are git repos, so plugin refresh would fail. Skipping. Install: ${hint}, then re-run sync`)
445
+ if (ctx.services.deps.probe("git").state === "missing") {
446
+ ctx.services.deps.warnMissing(
447
+ "git",
448
+ ctx.services.logger,
449
+ "plugin marketplaces are git repos — Codex plugin refresh skipped; re-run sync after installing"
450
+ )
403
451
  return
404
452
  }
405
453
 
406
454
  let refreshed = 0
407
455
  let failed = 0
408
- for (const pluginId of enabledPluginIds(sotConfig)) {
456
+ for (const pluginId of enabledPluginIdsFromText(sotConfigText)) {
409
457
  const res = spawnSync("codex", ["plugin", "add", pluginId], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
410
458
  const addOut = `${res.stdout ?? ""}${res.stderr ?? ""}`
411
459
  if (res.error === undefined && res.status === 0) {
412
460
  refreshed++
413
461
  } else if (addOut.includes("could not find a Codex CLI binary")) {
414
462
  warn(
415
- `Codex plugin refresh hit a stale launcher/wrapper on PATH - install current standalone Codex with: ${standaloneInstallCommand()}`
463
+ `Codex plugin refresh hit a stale launcher/wrapper on PATH - install current standalone Codex with: ${standaloneInstallCommand(ctx)}`
416
464
  )
417
465
  failed++
418
466
  } else {
@@ -424,13 +472,17 @@ function syncPlugins(ctx: Ctx, sotConfig: string): void {
424
472
  }
425
473
  }
426
474
 
427
- if (refreshed > 0) log(`Codex plugins synced (plugins: ~${refreshed})`)
475
+ if (refreshed > 0) {
476
+ change(`Codex plugins synced (plugins: ~${refreshed})`)
477
+ ctx.nextStepTriggers.codexRestart = true
478
+ }
428
479
  if (failed > 0) warn(`${failed} Codex plugin operation(s) failed — re-run sync or install manually`)
429
480
  }
430
481
 
431
482
  // -------------------------------------------------------------- summary ----
432
483
 
433
484
  export function codexSummary(ctx: Ctx): void {
485
+ const { echo } = ctx.services.logger
434
486
  const codexDir = p(ctx.home, ".codex")
435
487
  echo(`Codex: ${codexDir}`)
436
488
  if (!ctx.dryRun) {
@@ -439,6 +491,8 @@ export function codexSummary(ctx: Ctx): void {
439
491
  }
440
492
  }
441
493
 
442
- export function codexNextSteps(): void {
443
- echo("Restart Codex to load any refreshed plugins, skills, or tools.")
494
+ export function codexNextSteps(ctx: Ctx): Array<string> {
495
+ return ctx.verbose || ctx.nextStepTriggers.codexRestart
496
+ ? ["Restart Codex to load any refreshed plugins, skills, or tools."]
497
+ : []
444
498
  }
@@ -8,7 +8,6 @@ import { p } from "./exec"
8
8
  import { readFileSync, renameSync, writeFileSync } from "node:fs"
9
9
 
10
10
  import type { Ctx } from "./index"
11
- import { echo, log, warn } from "./output"
12
11
 
13
12
  export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
14
13
  const lines = content.split("\n")
@@ -40,13 +39,17 @@ export function replaceTopLevelSetting(content: string, key: string, replacement
40
39
  return `${out.join("\n")}\n`
41
40
  }
42
41
 
43
- export function replaceTopLevelSettingInFile(file: string, key: string, replacement: string): void {
44
- const next = replaceTopLevelSetting(readFileSync(file, "utf8"), key, replacement)
42
+ export function replaceTopLevelSettingInFile(file: string, key: string, replacement: string): boolean {
43
+ const before = readFileSync(file, "utf8")
44
+ const next = replaceTopLevelSetting(before, key, replacement)
45
+ if (next === before) return false
45
46
  writeFileSync(`${file}.tmp`, next)
46
47
  renameSync(`${file}.tmp`, file)
48
+ return true
47
49
  }
48
50
 
49
51
  export function syncCodexModel(ctx: Ctx, model: string): void {
52
+ const { change, echo, verbose, warn } = ctx.services.logger
50
53
  const userCodexSettings = p(ctx.home, ".codex", "config.toml")
51
54
 
52
55
  if (model === "") return
@@ -62,6 +65,10 @@ export function syncCodexModel(ctx: Ctx, model: string): void {
62
65
  warn(`(--codex-model) ${userCodexSettings} missing — skipped`)
63
66
  return
64
67
  }
65
- replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)
66
- log(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
68
+ if (replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)) {
69
+ change(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
70
+ ctx.nextStepTriggers.codexRestart = true
71
+ } else {
72
+ verbose(`Model: deployed Codex model already ${model}`)
73
+ }
67
74
  }