docks-kit 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs"
4
4
 
5
5
  import { resolveEffort } from "../efforts"
6
6
  import type { Ctx } from "./index"
7
+ import { ExitError } from "./parseArgs"
7
8
  import { isObject, jqStringify, parseJson } from "./jq"
8
9
 
9
10
  interface ClaudeSettingEdit {
@@ -19,27 +20,44 @@ function syncClaudeSetting(ctx: Ctx, edit: ClaudeSettingEdit): void {
19
20
  const { change, echo, err, verbose, warn } = ctx.services.logger
20
21
  const userSettings = p(ctx.home, ".claude", "settings.json")
21
22
 
22
- if (ctx.dryRun) {
23
- echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
24
- return
25
- }
26
-
27
23
  let text: string
28
24
  try {
29
25
  text = readFileSync(userSettings, "utf8")
30
- } catch {
31
- warn(`(${edit.tag}) ${userSettings} missing skipped`)
32
- return
26
+ } catch (error) {
27
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
28
+ // A dry-run sync writes nothing, so an absent file here does not mean the
29
+ // edit is skipped: the same run already previewed installing the file,
30
+ // and the real run applies the edit to it. Outside a sync, such as
31
+ // `docks-kit model claude <m>`, nothing creates the file and the skip
32
+ // stands.
33
+ if (ctx.dryRun && ctx.syncClaude) {
34
+ echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
35
+ return
36
+ }
37
+ warn(`(${edit.tag}) ${userSettings} missing — skipped`)
38
+ return
39
+ }
40
+ const cause = error instanceof Error ? error.message : String(error)
41
+ err(`(${edit.tag}) could not read ${userSettings}: ${cause}`)
42
+ throw new ExitError(1)
33
43
  }
34
44
  const doc = parseJson(text)
35
45
  if (doc === undefined) {
36
46
  err(`(${edit.tag}) ${userSettings} is not valid JSON — skipped`)
37
47
  return
38
48
  }
39
- if (isObject(doc)) {
40
- if (edit.value === undefined) delete doc[edit.key]
41
- else doc[edit.key] = edit.value
49
+ if (!isObject(doc)) {
50
+ err(`(${edit.tag}) ${userSettings} must contain a JSON object — aborting`)
51
+ throw new ExitError(1)
42
52
  }
53
+
54
+ if (ctx.dryRun) {
55
+ echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
56
+ return
57
+ }
58
+
59
+ if (edit.value === undefined) delete doc[edit.key]
60
+ else doc[edit.key] = edit.value
43
61
  const out = jqStringify(doc)
44
62
  if (out === text) {
45
63
  verbose(edit.unchanged)
@@ -59,14 +59,13 @@ export async function claudeSync(ctx: Ctx): Promise<ClaudeRuntimeState> {
59
59
  template,
60
60
  runtime.kind === "ready" ? runtime.paths : undefined
61
61
  )
62
- const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
62
+ const prepared = prepareClaudeSettings(ctx, claudeDir, materialized)
63
63
 
64
64
  syncClaudeRuntime(ctx, runtime)
65
65
  syncClaudeMd(ctx, claudeDir)
66
66
  if (ctx.dryRun) {
67
67
  describeSettingsSync(ctx, claudeDir)
68
68
  } else {
69
- if (prepared === undefined) throw new Error("Claude settings were not prepared")
70
69
  commitClaudeSettings(ctx, prepared)
71
70
  }
72
71
  syncRemovals(ctx, claudeDir, runtime)
@@ -153,8 +152,9 @@ export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json):
153
152
 
154
153
  const previousBytes = readFileSync(path, "utf8")
155
154
  const user = parseJson(previousBytes)
156
- if (user === undefined) {
157
- ctx.services.logger.err(`Skipping settings sync: ${path} is not valid JSON. Fix it manually or delete it to reinstall.`)
155
+ if (user === undefined || !isObject(user)) {
156
+ const reason = user === undefined ? "is not valid JSON" : "must contain a JSON object"
157
+ ctx.services.logger.err(`Aborting sync: ${path} ${reason}. Fix it manually or delete it to reinstall.`)
158
158
  throw new ExitError(1)
159
159
  }
160
160
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
@@ -186,7 +186,7 @@ export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings)
186
186
 
187
187
  function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
188
188
  const { echo } = ctx.services.logger
189
- const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
189
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json")
190
190
  const userSettings = p(claudeDir, "settings.json")
191
191
 
192
192
  if (!existsSync(userSettings)) {
@@ -292,11 +292,12 @@ function syncClaudeJson(ctx: Ctx): void {
292
292
  if (existsSync(claudeJson)) {
293
293
  const before = readFileSync(claudeJson, "utf8")
294
294
  const doc = parseJson(before)
295
- if (doc === undefined) {
296
- err("Skipping ~/.claude.json edit: not valid JSON. Fix or delete it.")
295
+ if (doc === undefined || !isObject(doc)) {
296
+ const reason = doc === undefined ? "not valid JSON" : "root must be a JSON object"
297
+ err(`Skipping ~/.claude.json edit: ${reason}. Fix or delete it.`)
297
298
  return
298
299
  }
299
- const obj = isObject(doc) ? doc : {}
300
+ const obj = doc
300
301
  applyFilter(obj)
301
302
  const out = jqStringify(obj)
302
303
  if (out === before) {
@@ -536,7 +537,7 @@ function sortedKeys(obj: Json | undefined): Array<string> {
536
537
  }
537
538
 
538
539
  /** claude::_plugin_user_scope_installed. */
539
- function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
540
+ export function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
540
541
  const doc = readJsonFile(installedPlugins)
541
542
  if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
542
543
  const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
@@ -658,14 +659,15 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
658
659
  if (separator > 0) kitMarketplaces.add(pluginId.slice(separator + 1))
659
660
  }
660
661
 
661
- // Pass 3 — refresh the kit-owned marketplaces and plugins unless the update
662
- // command selected its install-missing-only fast path.
662
+ // Pass 3 — refresh the kit-owned marketplaces unless the update command
663
+ // selected its install-missing-only fast path.
663
664
  if (!ctx.skipPluginRefresh) {
664
665
  for (const mpName of [...kitMarketplaces].sort(compareCodepoints)) {
665
666
  progress(`Refreshing marketplace ${mpName}...`)
666
667
  await cli(["plugin", "marketplace", "update", mpName])
667
668
  clearProgress()
668
669
  }
670
+ // Pass 4 — update the kit-owned installed plugins.
669
671
  for (const pluginId of [...kitPluginIds].sort(compareCodepoints)) {
670
672
  if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
671
673
  progress(`Updating plugin ${pluginId}...`)
@@ -675,14 +677,14 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
675
677
  }
676
678
  }
677
679
 
678
- // Passes 4 + 5 — prune-gated uninstall + marketplace removal.
680
+ // Pass 5 — prune-gated user-scope plugin uninstall.
679
681
  let removedPl = 0
680
682
  let removedMp = 0
681
- let f4 = 0
682
683
  let f5 = 0
684
+ let f6 = 0
683
685
  if (ctx.prune) {
684
686
  for (const pluginId of installedKeys) {
685
- if (isObject(sotPlugins) && Object.prototype.hasOwnProperty.call(sotPlugins, pluginId)) continue
687
+ if (kitPluginIds.has(pluginId)) continue
686
688
  if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
687
689
  progress(`Uninstalling plugin ${pluginId}...`)
688
690
  const uninstallResult = await cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId])
@@ -691,15 +693,15 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
691
693
  removedPl++
692
694
  } else {
693
695
  warn(`Failed to uninstall plugin: ${pluginId}`)
694
- f4++
696
+ f5++
695
697
  }
696
698
  }
699
+ // Pass 6 — prune-gated marketplace removal.
697
700
  const known = readJsonFile(knownMarketplaces)
698
701
  for (const mpName of sortedKeys(known)) {
699
702
  if (mpName === "claude-plugins-official") continue
700
703
  if (nonUserMarketplaces.has(mpName)) continue
701
- const declared = isObject(sotMarketplaces) ? sotMarketplaces[mpName] : undefined
702
- if (declared !== undefined && declared !== null && declared !== false) continue
704
+ if (kitMarketplaces.has(mpName)) continue
703
705
  progress(`Removing marketplace ${mpName}...`)
704
706
  const removeResult = await cli(["plugin", "marketplace", "remove", mpName])
705
707
  clearProgress()
@@ -707,18 +709,18 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
707
709
  removedMp++
708
710
  } else {
709
711
  warn(`Failed to remove marketplace: ${mpName}`)
710
- f5++
712
+ f6++
711
713
  }
712
714
  }
713
715
  }
714
716
 
715
- // Pass 6 — re-assert SoT enabled-state in the user settings.
717
+ // Pass 7 — re-assert SoT enabled-state in the user settings.
716
718
  if (await reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
717
719
  change("Plugin enable-state re-asserted from SoT in settings.json")
718
720
  ctx.nextStepTriggers.claudePlugins = true
719
721
  }
720
722
 
721
- const failed = f1 + f2 + f4 + f5
723
+ const failed = f1 + f2 + f5 + f6
722
724
  if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
723
725
  change(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
724
726
  ctx.nextStepTriggers.claudePlugins = true
@@ -841,9 +843,11 @@ async function syncOptionalPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
841
843
 
842
844
  // ---------------------------------------------------------- LSP servers ----
843
845
 
844
- function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
845
- const v = field(ctx, tool, "verified")
846
- return v !== "" ? `${pkg}@${v}` : pkg
846
+ function lspPkg(ctx: Ctx, tool: string, pkg: string): string | undefined {
847
+ const version = field(ctx, tool, "verified")
848
+ if (version !== "") return `${pkg}@${version}`
849
+ ctx.services.logger.warn(`Skipping ${pkg} install: ${tool} has no verified version in SoT/toolchain.json`)
850
+ return undefined
847
851
  }
848
852
 
849
853
  async function syncLspServers(ctx: Ctx): Promise<void> {
@@ -855,14 +859,17 @@ async function syncLspServers(ctx: Ctx): Promise<void> {
855
859
  const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
856
860
  if (!hasPhp && !hasTs) return
857
861
 
858
- const missing: Array<string> = []
859
- if (hasPhp && ctx.services.deps.probe("intelephense").state === "missing") missing.push(lspPkg(ctx, "intelephense", "intelephense"))
860
- if (hasTs) {
861
- if (ctx.services.deps.probe("typescript-language-server").state === "missing") missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
862
- if (ctx.services.deps.probe("tsc").state === "missing") missing.push(lspPkg(ctx, "tsc", "typescript"))
863
- }
864
-
865
- if (missing.length === 0) {
862
+ const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
863
+ const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
864
+ const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
865
+ const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing)
866
+ const missing = [
867
+ phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
868
+ tsServerMissing ? lspPkg(ctx, "typescript-language-server", "typescript-language-server") : undefined,
869
+ tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
870
+ ].filter((spec): spec is string => spec !== undefined)
871
+
872
+ if (missingToolCount === 0) {
866
873
  if (ctx.dryRun) {
867
874
  echo("[dry-run] LSP server binaries present")
868
875
  } else {
@@ -870,6 +877,7 @@ async function syncLspServers(ctx: Ctx): Promise<void> {
870
877
  }
871
878
  return
872
879
  }
880
+ if (missing.length === 0) return
873
881
 
874
882
  const specs = missing.join(" ")
875
883
  if (ctx.dryRun) {
@@ -70,7 +70,10 @@ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
70
70
  return
71
71
  }
72
72
 
73
- if ((await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })).exitCode === 0) {
73
+ const namespaceProbe = await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })
74
+ if (namespaceProbe.error !== undefined) {
75
+ warn(`Could not run unshare to check user namespaces: ${namespaceProbe.error.message}`)
76
+ } else if (namespaceProbe.exitCode === 0) {
74
77
  change(`bubblewrap installed and functional (${await ctx.services.deps.version("bwrap")})`)
75
78
  } else {
76
79
  warn(
@@ -100,10 +103,14 @@ function bwrapDetectPmInstallCmd(ctx: Ctx): string {
100
103
 
101
104
  function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
102
105
  const { change, echo, verbose } = ctx.services.logger
103
- const sotConfig = payloadDisplayPath("SoT/.codex/config.toml", ctx.repoDir)
106
+ const sotConfig = payloadDisplayPath("SoT/.codex/config.toml")
104
107
 
105
108
  if (ctx.dryRun) {
106
- echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
109
+ if (existsSync(userConfig)) {
110
+ echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
111
+ } else {
112
+ echo(`[dry-run] install ${sotConfig} -> ${userConfig}`)
113
+ }
107
114
  return
108
115
  }
109
116
 
@@ -119,7 +126,8 @@ function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
119
126
  // overwrite the recovery copy with already-merged content.
120
127
  const before = readFileSync(userConfig, "utf8")
121
128
  const staging = `${userConfig}.merge.tmp`
122
- writeFileSync(staging, before)
129
+ // Normalize once before record transforms because CR bytes change table-header identity.
130
+ writeFileSync(staging, before.replace(/\r\n/g, "\n"))
123
131
 
124
132
  scrubDeprecatedFeatures(ctx, staging)
125
133
  removeRetiredPluginTables(ctx, staging)
@@ -146,15 +154,20 @@ export function scrubDeprecatedFeaturesText(content: string): string {
146
154
  let header = ""
147
155
  let body = ""
148
156
  let keep = false
157
+ let changed = false
149
158
  for (const line of lines) {
150
159
  if (inFeatures) {
151
160
  if (line.startsWith("[")) {
152
161
  inFeatures = false
153
162
  if (keep) out += `${header}\n${body}`
163
+ else changed = true
154
164
  out += `${line}\n`
155
165
  continue
156
166
  }
157
- if (/^use_legacy_landlock[ \t]*=/.test(line)) continue
167
+ if (/^use_legacy_landlock[ \t]*=/.test(line)) {
168
+ changed = true
169
+ continue
170
+ }
158
171
  body += `${line}\n`
159
172
  if (/[^ \t\f\v\r]/.test(line)) keep = true
160
173
  continue
@@ -168,17 +181,21 @@ export function scrubDeprecatedFeaturesText(content: string): string {
168
181
  }
169
182
  out += `${line}\n`
170
183
  }
171
- if (inFeatures && keep) out += `${header}\n${body}`
172
- return out
184
+ if (inFeatures) {
185
+ if (keep) out += `${header}\n${body}`
186
+ else changed = true
187
+ }
188
+ return changed ? out : content
173
189
  }
174
190
 
175
191
  function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
176
192
  const { change } = ctx.services.logger
177
193
  if (!existsSync(userConfig)) return
178
194
  const content = readFileSync(userConfig, "utf8")
179
- if (!content.split("\n").some((l) => /^use_legacy_landlock[ \t]*=/.test(l))) return
195
+ const next = scrubDeprecatedFeaturesText(content)
196
+ if (next === content) return
180
197
 
181
- writeFileSync(`${userConfig}.tmp`, scrubDeprecatedFeaturesText(content))
198
+ writeFileSync(`${userConfig}.tmp`, next)
182
199
  renameSync(`${userConfig}.tmp`, userConfig)
183
200
  change("Codex: scrubbed deprecated [features].use_legacy_landlock")
184
201
  }
@@ -236,38 +253,134 @@ function mergeTopLevelSettings(sotConfigText: string, userConfig: string): void
236
253
  }
237
254
  }
238
255
 
239
- function mergeTableSettings(sotConfigText: string, userConfig: string): void {
256
+ interface TomlTableHeader {
257
+ readonly path: string
258
+ }
259
+
260
+ const TOML_BASIC_ESCAPES: Readonly<Record<string, string>> = {
261
+ b: "\b",
262
+ t: "\t",
263
+ n: "\n",
264
+ f: "\f",
265
+ r: "\r",
266
+ '"': '"',
267
+ "\\": "\\"
268
+ }
269
+
270
+ function tomlBasicEscape(line: string, offset: number): { readonly next: number; readonly value: string } | undefined {
271
+ const escaped = line[offset]
272
+ const simple = escaped === undefined ? undefined : TOML_BASIC_ESCAPES[escaped]
273
+ if (simple !== undefined) return { next: offset + 1, value: simple }
274
+ const digits = escaped === "u" ? 4 : escaped === "U" ? 8 : 0
275
+ if (digits === 0) return undefined
276
+ const hex = line.slice(offset + 1, offset + 1 + digits)
277
+ if (hex.length !== digits || !/^[0-9A-Fa-f]+$/.test(hex)) return undefined
278
+ const codePoint = Number.parseInt(hex, 16)
279
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined
280
+ return { next: offset + 1 + digits, value: String.fromCodePoint(codePoint) }
281
+ }
282
+
283
+ /** Decode a table header to the TOML path that determines managed ownership. */
284
+ function tomlTableHeader(line: string): TomlTableHeader | undefined {
285
+ let offset = 0
286
+ const skipWhitespace = (): void => {
287
+ while (line[offset] === " " || line[offset] === "\t") offset++
288
+ }
289
+
290
+ skipWhitespace()
291
+ if (line[offset] !== "[") return undefined
292
+ offset++
293
+ const array = line[offset] === "["
294
+ if (array) offset++
295
+
296
+ const keys: Array<string> = []
297
+ while (true) {
298
+ skipWhitespace()
299
+ const quote = line[offset]
300
+ let key = ""
301
+ if (quote === '"' || quote === "'") {
302
+ offset++
303
+ let closed = false
304
+ while (offset < line.length) {
305
+ const char = line[offset]!
306
+ if (char === quote) {
307
+ offset++
308
+ closed = true
309
+ break
310
+ }
311
+ if (quote === '"' && char === "\\") {
312
+ const escape = tomlBasicEscape(line, offset + 1)
313
+ if (escape === undefined) return undefined
314
+ key += escape.value
315
+ offset = escape.next
316
+ continue
317
+ }
318
+ if (char === "\n" || char === "\r") return undefined
319
+ key += char
320
+ offset++
321
+ }
322
+ if (!closed) return undefined
323
+ } else {
324
+ const start = offset
325
+ while (offset < line.length && /[A-Za-z0-9_-]/.test(line[offset]!)) offset++
326
+ if (offset === start) return undefined
327
+ key = line.slice(start, offset)
328
+ }
329
+ keys.push(key)
330
+
331
+ skipWhitespace()
332
+ if (line[offset] === ".") {
333
+ offset++
334
+ continue
335
+ }
336
+ if (line[offset] !== "]") return undefined
337
+ offset++
338
+ if (array) {
339
+ if (line[offset] !== "]") return undefined
340
+ offset++
341
+ }
342
+ skipWhitespace()
343
+ if (offset < line.length && line[offset] !== "#") return undefined
344
+ return { path: JSON.stringify(keys) }
345
+ }
346
+ }
347
+
348
+ function mergeTableSettingsText(sotConfigText: string, userConfigText: string): string {
240
349
  const sotLines = sotConfigText.split("\n")
241
- for (const tableHeader of sotLines.filter((l) => /^\[[^\]]+\]/.test(l))) {
242
- // Extract the SoT block: from the exact header line to (excluding) the
243
- // next table header; `$(...)` strips trailing newlines.
244
- let printing = false
350
+ let merged = userConfigText
351
+ for (let tableOffset = 0; tableOffset < sotLines.length; tableOffset++) {
352
+ const managedHeader = tomlTableHeader(sotLines[tableOffset]!)
353
+ if (managedHeader === undefined) continue
354
+
245
355
  const block: Array<string> = []
246
- for (const line of sotLines) {
247
- if (line === tableHeader) printing = true
248
- else if (printing && line.startsWith("[")) break
249
- if (printing) block.push(line)
356
+ for (let blockOffset = tableOffset; blockOffset < sotLines.length; blockOffset++) {
357
+ const line = sotLines[blockOffset]!
358
+ if (blockOffset !== tableOffset && tomlTableHeader(line) !== undefined) break
359
+ block.push(line)
250
360
  }
251
361
  const tableBlock = block.join("\n").replace(/\n+$/, "")
252
362
 
253
- // Remove the existing block from the user config…
254
- const userLines = readFileSync(userConfig, "utf8").split("\n")
363
+ const userLines = merged.split("\n")
255
364
  if (userLines[userLines.length - 1] === "") userLines.pop()
256
365
  let skip = false
257
366
  const kept: Array<string> = []
258
367
  for (const line of userLines) {
259
- if (line === tableHeader) {
260
- skip = true
261
- continue
368
+ const header = tomlTableHeader(line)
369
+ if (header !== undefined) {
370
+ skip = header.path === managedHeader.path
371
+ if (skip) continue
262
372
  }
263
- if (skip && line.startsWith("[")) skip = false
264
373
  if (!skip) kept.push(line)
265
374
  }
266
- // …and append the SoT block (printf '\n'; printf '%s\n' "$block").
267
- const next = `${kept.join("\n")}\n\n${tableBlock}\n`
268
- writeFileSync(`${userConfig}.tmp`, next)
269
- renameSync(`${userConfig}.tmp`, userConfig)
375
+ merged = `${kept.join("\n")}\n\n${tableBlock}\n`
270
376
  }
377
+ return merged
378
+ }
379
+
380
+ function mergeTableSettings(sotConfigText: string, userConfig: string): void {
381
+ const next = mergeTableSettingsText(sotConfigText, readFileSync(userConfig, "utf8"))
382
+ writeFileSync(`${userConfig}.tmp`, next)
383
+ renameSync(`${userConfig}.tmp`, userConfig)
271
384
  }
272
385
 
273
386
  // ------------------------------------------------------- rules + agents ----
@@ -276,7 +389,7 @@ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir:
276
389
  const { change, echo, verbose } = ctx.services.logger
277
390
  const firstRule = sotRules[0]
278
391
  if (firstRule === undefined) return
279
- const firstDisplay = payloadDisplayPath(firstRule, ctx.repoDir)
392
+ const firstDisplay = payloadDisplayPath(firstRule)
280
393
  const sotRulesDir = firstDisplay.slice(0, firstDisplay.lastIndexOf("/"))
281
394
 
282
395
  if (ctx.dryRun) {
@@ -306,7 +419,7 @@ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir:
306
419
 
307
420
  function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string): void {
308
421
  const { change, echo, verbose } = ctx.services.logger
309
- const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md", ctx.repoDir)
422
+ const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md")
310
423
 
311
424
  if (ctx.dryRun) {
312
425
  echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
@@ -326,26 +439,32 @@ function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string):
326
439
  // ---------------------------------------------------------- marketplace ----
327
440
 
328
441
  function syncMarketplace(ctx: Ctx, sotMarketplaceText: string, userMarketplace: string): void {
329
- const { change, echo, err, verbose } = ctx.services.logger
330
- const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json", ctx.repoDir)
442
+ const { change, echo, verbose } = ctx.services.logger
443
+ const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json")
444
+ const repo = parseJson(sotMarketplaceText)
445
+ if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
446
+
447
+ const userText = existsSync(userMarketplace) ? readFileSync(userMarketplace, "utf8") : undefined
448
+ const user = userText === undefined ? undefined : parseJson(userText)
449
+ if (userText !== undefined && user === undefined) {
450
+ throw new Error(`invalid deployed Codex marketplace JSON: ${userMarketplace}. Fix or delete it.`)
451
+ }
452
+ const out = user === undefined ? undefined : jqStringify(mergeMarketplace(repo, user))
331
453
 
332
454
  if (ctx.dryRun) {
333
- echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
455
+ if (userText === undefined) {
456
+ echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
457
+ } else if (out === userText) {
458
+ verbose("Codex marketplace already in sync")
459
+ } else {
460
+ echo(`[dry-run] merge ${sotMarketplace} -> ${userMarketplace} (backup at ${userMarketplace}.bak)`)
461
+ }
334
462
  return
335
463
  }
336
464
 
337
465
  mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
338
- const repo = parseJson(sotMarketplaceText)
339
- if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
340
-
341
- if (existsSync(userMarketplace)) {
342
- const user = parseJson(readFileSync(userMarketplace, "utf8"))
343
- if (user === undefined) {
344
- err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
345
- return
346
- }
347
- const out = jqStringify(mergeMarketplace(repo, user))
348
- if (out === readFileSync(userMarketplace, "utf8")) {
466
+ if (userText !== undefined && out !== undefined) {
467
+ if (out === userText) {
349
468
  verbose("Codex marketplace already in sync")
350
469
  return
351
470
  }
@@ -5,7 +5,7 @@
5
5
  * historical record-oriented behavior.
6
6
  */
7
7
  import { p } from "./exec"
8
- import { readFileSync, renameSync, writeFileSync } from "node:fs"
8
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"
9
9
 
10
10
  import { resolveEffort } from "../efforts"
11
11
  import type { Ctx } from "./index"
@@ -61,17 +61,22 @@ function syncCodexSetting(ctx: Ctx, edit: CodexSettingEdit): void {
61
61
  const { change, echo, verbose, warn } = ctx.services.logger
62
62
  const userCodexSettings = p(ctx.home, ".codex", "config.toml")
63
63
 
64
- if (ctx.dryRun) {
65
- echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
64
+ if (!existsSync(userCodexSettings)) {
65
+ // A dry-run sync already previewed installing the file, so the edit is not skipped.
66
+ // The `docks-kit model codex <m>` command creates no file, so the skip stands.
67
+ if (ctx.dryRun && ctx.syncCodex) {
68
+ echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
69
+ return
70
+ }
71
+ warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
66
72
  return
67
73
  }
68
74
 
69
- try {
70
- readFileSync(userCodexSettings)
71
- } catch {
72
- warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
75
+ if (ctx.dryRun) {
76
+ echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
73
77
  return
74
78
  }
79
+
75
80
  if (replaceTopLevelSettingInFile(userCodexSettings, edit.key, `${edit.key} = "${edit.value}"`)) {
76
81
  change(edit.changed)
77
82
  ctx.nextStepTriggers.codexRestart = true
@@ -107,11 +107,11 @@ const home = (): string => {
107
107
  return envHome !== undefined && envHome !== "" ? envHome : homedir()
108
108
  }
109
109
 
110
-
111
- // The resolved path gets persisted into global direct-exec hooks, so a
112
- // relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
113
- // break outside the sync working directory.
114
-
110
+ /**
111
+ * The resolved path gets persisted into global direct-exec hooks, so a
112
+ * relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
113
+ * break outside the sync working directory.
114
+ */
115
115
  const findBun = (exec: ProbeExecutor): { command: string; path: string } | undefined => {
116
116
  const onPath = exec.which("bun")
117
117
  if (onPath !== "" && isAbsolute(onPath)) {
@@ -141,22 +141,20 @@ const resolveEffectSolutions = (exec: ProbeExecutor): ProbeResult => {
141
141
  : { state: "missing" }
142
142
  }
143
143
 
144
- const versionBunCommand = (exec: ProbeExecutor): string =>
145
- exec.commandExists("bun") ? "bun" : p(home(), ".bun", "bin", "bun")
146
-
147
144
  const versionEffectSolutions = async (exec: ProbeExecutor): Promise<string> => {
148
- const bun = versionBunCommand(exec)
149
- if (bun !== "bun" && exec.which(bun) === "") return ""
150
- const match = /effect-solutions@([0-9][0-9.]*)/.exec(await exec.capture(bun, ["pm", "-g", "ls"]))
145
+ const bun = findBun(exec)
146
+ if (bun === undefined) return ""
147
+ const match = /effect-solutions@([0-9][0-9.]*)/.exec(await exec.capture(bun.command, ["pm", "-g", "ls"]))
151
148
  return match?.[1] ?? ""
152
149
  }
153
150
 
154
151
  const locateEffectSolutions = async (exec: ProbeExecutor): Promise<DependencyLocation> => {
155
152
  const strictBun = findBun(exec)
156
153
  const pathBun = exec.which("bun")
157
- const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
158
- if (bun === undefined) return { path: "", binDir: "" }
159
- const globalBin = await exec.capture(bun.command, ["pm", "-g", "bin"])
154
+ // This site may accept a relative hit because it does not persist the Bun path.
155
+ const bunForGlobalBin = strictBun?.command ?? (pathBun !== "" ? "bun" : undefined)
156
+ if (bunForGlobalBin === undefined) return { path: "", binDir: "" }
157
+ const globalBin = await exec.capture(bunForGlobalBin, ["pm", "-g", "bin"])
160
158
  const path = globalBin !== "" ? p(globalBin, "effect-solutions") : ""
161
159
  const resolved = path !== "" && exec.which(path) !== "" ? path : ""
162
160
  return { path: resolved, binDir: globalBin }
@@ -233,7 +231,11 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
233
231
  () => "curl -fsSL https://bun.sh/install | bash",
234
232
  {
235
233
  resolve: resolveBun,
236
- version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
234
+ version: async (exec) => {
235
+ const bun = findBun(exec)
236
+ if (bun === undefined) return ""
237
+ return await exec.capture(bun.command, ["--version"])
238
+ },
237
239
  locate: async (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
238
240
  }
239
241
  ),