docks-kit 0.1.0 → 0.1.1

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 (39) hide show
  1. package/AGENTS.md +14 -15
  2. package/README.md +12 -11
  3. package/SoT/.agents/skills.txt +3 -3
  4. package/SoT/.claude/settings.json +3 -26
  5. package/SoT/models.json +1 -1
  6. package/SoT/toolchain.json +3 -3
  7. package/cli/docs/install.md +39 -12
  8. package/cli/docs/overview.md +3 -4
  9. package/cli/docs/platforms.md +35 -22
  10. package/cli/src/commands/sync.ts +32 -4
  11. package/cli/src/commands/update.ts +116 -0
  12. package/cli/src/engine-native/DESIGN.md +89 -0
  13. package/cli/src/engine-native/claudeModel.ts +48 -0
  14. package/cli/src/engine-native/claudeSync.ts +746 -0
  15. package/cli/src/engine-native/codexSync.ts +439 -0
  16. package/cli/src/engine-native/codexToml.ts +67 -0
  17. package/cli/src/engine-native/exec.ts +54 -0
  18. package/cli/src/engine-native/index.ts +109 -0
  19. package/cli/src/engine-native/jq.ts +63 -0
  20. package/cli/src/engine-native/models.ts +73 -0
  21. package/cli/src/engine-native/modes.ts +133 -0
  22. package/cli/src/engine-native/output.ts +20 -0
  23. package/cli/src/engine-native/parseArgs.ts +218 -0
  24. package/cli/src/engine-native/settings.ts +41 -0
  25. package/cli/src/engine-native/skillsSync.ts +369 -0
  26. package/cli/src/engine-native/toolchain.ts +257 -0
  27. package/cli/src/engine.ts +35 -18
  28. package/cli/src/kitHome.ts +4 -4
  29. package/cli/src/main.ts +14 -1
  30. package/cli/src/manifests.ts +3 -2
  31. package/cli/tsconfig.json +1 -1
  32. package/docks-kit +6 -3
  33. package/package.json +8 -4
  34. package/lib/claude.sh +0 -846
  35. package/lib/codex.sh +0 -518
  36. package/lib/common.sh +0 -229
  37. package/lib/engine.sh +0 -153
  38. package/lib/skills.sh +0 -373
  39. package/lib/toolchain.sh +0 -222
@@ -0,0 +1,439 @@
1
+ /**
2
+ * EngineNative `sync codex` pipeline. Line-based TOML passes intentionally
3
+ * avoid a TOML library because reformatting user configs would be a behavior
4
+ * change. Guard order, message strings, and backup behavior are golden-tested.
5
+ */
6
+ import { spawnSync } from "node:child_process"
7
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
8
+
9
+ import { syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
10
+ import { capture, commandExists, p } from "./exec"
11
+ import type { Ctx } from "./index"
12
+ import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
13
+ import { echo, err, log, warn } from "./output"
14
+
15
+ export function codexSync(ctx: Ctx): void {
16
+ const codexDir = p(ctx.home, ".codex")
17
+ const sotConfig = p(ctx.repoDir, "SoT", ".codex", "config.toml")
18
+ const userConfig = p(codexDir, "config.toml")
19
+
20
+ ensureBubblewrap(ctx)
21
+ if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
22
+ syncConfig(ctx, sotConfig, userConfig)
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"))
27
+ removeLegacyDocksMarketplace(ctx, userConfig)
28
+ syncPlugins(ctx, sotConfig)
29
+ }
30
+
31
+ // ---------------------------------------------------------- bubblewrap ----
32
+
33
+ function ensureBubblewrap(ctx: Ctx): void {
34
+ if (!bwrapSupportedOs()) return
35
+
36
+ if (ctx.dryRun) {
37
+ echo("[dry-run] verify bubblewrap installed (recommended Codex Linux sandbox runtime)")
38
+ return
39
+ }
40
+
41
+ if (commandExists("bwrap")) return
42
+
43
+ if (ctx.skipRtk) {
44
+ warn(
45
+ "bubblewrap not installed (--skip-rtk skips auto-install). Codex may use its bundled helper if user namespaces work; recommended install: sudo apt install -y bubblewrap"
46
+ )
47
+ return
48
+ }
49
+
50
+ const pmInstall = bwrapDetectPmInstallCmd()
51
+ if (pmInstall === "") {
52
+ warn(
53
+ "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."
54
+ )
55
+ return
56
+ }
57
+
58
+ warn(`bubblewrap not installed - recommended for Codex Linux sandbox. Running: ${pmInstall} (sudo prompt may appear)`)
59
+ const res = spawnSync("bash", ["-c", pmInstall], { stdio: ["inherit", "inherit", "inherit"] })
60
+ if (res.status !== 0) {
61
+ warn(`Failed to auto-install bubblewrap. Install manually: ${pmInstall}`)
62
+ return
63
+ }
64
+
65
+ if (!commandExists("bwrap")) {
66
+ warn("Package install reported success but bwrap not on PATH — check installation manually")
67
+ return
68
+ }
69
+
70
+ 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})`)
73
+ } else {
74
+ warn(
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"
76
+ )
77
+ }
78
+ }
79
+
80
+ function bwrapSupportedOs(): boolean {
81
+ if (process.platform === "linux") return true
82
+ if (process.platform === "darwin" || process.platform === "win32") return false
83
+ warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
84
+ return false
85
+ }
86
+
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"
92
+ return ""
93
+ }
94
+
95
+ // --------------------------------------------------------------- config ----
96
+
97
+ function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
98
+ if (!existsSync(sotConfig)) return
99
+
100
+ if (ctx.dryRun) {
101
+ echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
102
+ return
103
+ }
104
+
105
+ if (!existsSync(userConfig)) {
106
+ copyFileSync(sotConfig, userConfig)
107
+ log("Codex config installed")
108
+ return
109
+ }
110
+
111
+ copyFileSync(userConfig, `${userConfig}.bak`)
112
+
113
+ scrubDeprecatedFeatures(userConfig)
114
+ mergeTopLevelSettings(sotConfig, userConfig)
115
+ mergeTableSettings(sotConfig, userConfig)
116
+
117
+ log("Codex config merged (backup at config.toml.bak; user-only keys/tables preserved)")
118
+ }
119
+
120
+ /** codex::scrub_deprecated_features — the [features].use_legacy_landlock awk pass. */
121
+ export function scrubDeprecatedFeaturesText(content: string): string {
122
+ const lines = content.split("\n")
123
+ if (lines[lines.length - 1] === "") lines.pop()
124
+ let out = ""
125
+ let inFeatures = false
126
+ let header = ""
127
+ let body = ""
128
+ let keep = false
129
+ for (const line of lines) {
130
+ if (inFeatures) {
131
+ if (line.startsWith("[")) {
132
+ inFeatures = false
133
+ if (keep) out += `${header}\n${body}`
134
+ out += `${line}\n`
135
+ continue
136
+ }
137
+ if (/^use_legacy_landlock[ \t]*=/.test(line)) continue
138
+ body += `${line}\n`
139
+ if (/[^ \t\f\v\r]/.test(line)) keep = true
140
+ continue
141
+ }
142
+ if (/^\[features\][ \t]*$/.test(line)) {
143
+ inFeatures = true
144
+ header = line
145
+ body = ""
146
+ keep = false
147
+ continue
148
+ }
149
+ out += `${line}\n`
150
+ }
151
+ if (inFeatures && keep) out += `${header}\n${body}`
152
+ return out
153
+ }
154
+
155
+ function scrubDeprecatedFeatures(userConfig: string): void {
156
+ if (!existsSync(userConfig)) return
157
+ const content = readFileSync(userConfig, "utf8")
158
+ if (!content.split("\n").some((l) => /^use_legacy_landlock[ \t]*=/.test(l))) return
159
+
160
+ writeFileSync(`${userConfig}.tmp`, scrubDeprecatedFeaturesText(content))
161
+ renameSync(`${userConfig}.tmp`, userConfig)
162
+ log("Codex: scrubbed deprecated [features].use_legacy_landlock")
163
+ }
164
+
165
+ function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
166
+ for (const line of readFileSync(sotConfig, "utf8").split("\n")) {
167
+ if (line.startsWith("[")) break
168
+ if (/^[ \t]*($|#)/.test(line)) continue
169
+ if (!/^[A-Za-z0-9_.-]+[ \t]*=/.test(line)) continue
170
+ const key = line.slice(0, line.indexOf("=")).replace(/[ \t]+$/, "")
171
+ replaceTopLevelSettingInFile(userConfig, key, line)
172
+ }
173
+ }
174
+
175
+ function mergeTableSettings(sotConfig: string, userConfig: string): void {
176
+ const sotLines = readFileSync(sotConfig, "utf8").split("\n")
177
+ for (const tableHeader of sotLines.filter((l) => /^\[[^\]]+\]/.test(l))) {
178
+ // Extract the SoT block: from the exact header line to (excluding) the
179
+ // next table header; `$(...)` strips trailing newlines.
180
+ let printing = false
181
+ const block: Array<string> = []
182
+ for (const line of sotLines) {
183
+ if (line === tableHeader) printing = true
184
+ else if (printing && line.startsWith("[")) break
185
+ if (printing) block.push(line)
186
+ }
187
+ const tableBlock = block.join("\n").replace(/\n+$/, "")
188
+
189
+ // Remove the existing block from the user config…
190
+ const userLines = readFileSync(userConfig, "utf8").split("\n")
191
+ if (userLines[userLines.length - 1] === "") userLines.pop()
192
+ let skip = false
193
+ const kept: Array<string> = []
194
+ for (const line of userLines) {
195
+ if (line === tableHeader) {
196
+ skip = true
197
+ continue
198
+ }
199
+ if (skip && line.startsWith("[")) skip = false
200
+ if (!skip) kept.push(line)
201
+ }
202
+ // …and append the SoT block (printf '\n'; printf '%s\n' "$block").
203
+ const next = `${kept.join("\n")}\n\n${tableBlock}\n`
204
+ writeFileSync(`${userConfig}.tmp`, next)
205
+ renameSync(`${userConfig}.tmp`, userConfig)
206
+ }
207
+ }
208
+
209
+ // ------------------------------------------------------- rules + agents ----
210
+
211
+ function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
212
+ if (!existsSync(sotRulesDir)) return
213
+
214
+ if (ctx.dryRun) {
215
+ echo(`[dry-run] cp ${sotRulesDir}/*.rules -> ${userRulesDir}/`)
216
+ return
217
+ }
218
+
219
+ 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)
225
+ for (const ruleFile of ruleFiles) {
226
+ const userRuleFile = p(userRulesDir, ruleFile.slice(ruleFile.lastIndexOf("/") + 1))
227
+ if (existsSync(userRuleFile)) copyFileSync(userRuleFile, `${userRuleFile}.bak`)
228
+ copyFileSync(ruleFile, userRuleFile)
229
+ rulesSynced = true
230
+ }
231
+ if (rulesSynced) log("Codex rules synced")
232
+ }
233
+
234
+ function syncAgentsMd(ctx: Ctx, sotAgentsMd: string, userAgentsMd: string): void {
235
+ if (!existsSync(sotAgentsMd)) return
236
+
237
+ if (ctx.dryRun) {
238
+ echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
239
+ return
240
+ }
241
+
242
+ if (existsSync(userAgentsMd)) copyFileSync(userAgentsMd, `${userAgentsMd}.bak`)
243
+ copyFileSync(sotAgentsMd, userAgentsMd)
244
+ log("Codex AGENTS.md synced")
245
+ }
246
+
247
+ // ---------------------------------------------------------- marketplace ----
248
+
249
+ function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: string): void {
250
+ if (!existsSync(sotMarketplace)) return
251
+
252
+ if (ctx.dryRun) {
253
+ echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
254
+ return
255
+ }
256
+
257
+ mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
258
+ const repo = parseJson(readFileSync(sotMarketplace, "utf8"))
259
+ if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
260
+
261
+ if (existsSync(userMarketplace)) {
262
+ const user = parseJson(readFileSync(userMarketplace, "utf8"))
263
+ if (user === undefined) {
264
+ err(`Skipping marketplace sync: ${userMarketplace} is not valid JSON. Fix or delete it.`)
265
+ return
266
+ }
267
+ copyFileSync(userMarketplace, `${userMarketplace}.bak`)
268
+ writeFileSync(`${userMarketplace}.tmp`, jqStringify(mergeMarketplace(repo, user)))
269
+ renameSync(`${userMarketplace}.tmp`, userMarketplace)
270
+ log("Codex marketplace merged (backup at marketplace.json.bak)")
271
+ } else {
272
+ copyFileSync(sotMarketplace, userMarketplace)
273
+ log("Codex marketplace installed")
274
+ }
275
+ }
276
+
277
+ /**
278
+ * The jq -s marketplace merge: `$user *` a {name, interface} coalesce, then
279
+ * plugins = user+repo | reverse | unique_by(.name) | reverse — SoT (repo)
280
+ * wins per plugin name; distinct names end up descending by name, exactly
281
+ * like jq's unique_by (ascending) followed by reverse.
282
+ */
283
+ export function mergeMarketplace(repo: Json, user: Json): Json {
284
+ const u = isObject(user) ? user : {}
285
+ const r = isObject(repo) ? repo : {}
286
+ const coalesce = (a: Json | undefined, b: Json | undefined): Json =>
287
+ a !== undefined && a !== null && a !== false ? a : (b ?? null)
288
+ const merged: { [k: string]: Json } = {
289
+ ...u,
290
+ name: coalesce(u["name"], r["name"]),
291
+ interface: coalesce(u["interface"], r["interface"])
292
+ }
293
+ const plugins = [
294
+ ...(Array.isArray(u["plugins"]) ? u["plugins"] : []),
295
+ ...(Array.isArray(r["plugins"]) ? r["plugins"] : [])
296
+ ]
297
+ const firstByName = new Map<string, Json>()
298
+ for (const p of [...plugins].reverse()) {
299
+ const name = isObject(p) && typeof p["name"] === "string" ? p["name"] : ""
300
+ if (!firstByName.has(name)) firstByName.set(name, p)
301
+ }
302
+ merged["plugins"] = [...firstByName.keys()].sort(compareCodepoints).map((n) => firstByName.get(n)!).reverse()
303
+ return merged
304
+ }
305
+
306
+ // -------------------------------------------------------------- plugins ----
307
+
308
+ /** codex::_marketplace_source — first `source =` inside [marketplaces.<name>]. */
309
+ export function marketplaceSource(marketplace: string, configFile: string): string {
310
+ if (!existsSync(configFile)) return ""
311
+ let inMarketplace = false
312
+ for (const line of readFileSync(configFile, "utf8").split("\n")) {
313
+ if (line === `[marketplaces.${marketplace}]`) {
314
+ inMarketplace = true
315
+ continue
316
+ }
317
+ if (line.startsWith("[")) inMarketplace = false
318
+ if (inMarketplace && /^[ \t]*source[ \t]*=/.test(line)) {
319
+ return line
320
+ .replace(/^[^=]+=[ \t]*/, "")
321
+ .replace(/[ \t]*#.*/, "")
322
+ .replace(/^"|"$/g, "")
323
+ }
324
+ }
325
+ return ""
326
+ }
327
+
328
+ function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
329
+ if (ctx.dryRun) {
330
+ echo("[dry-run] remove legacy configured Codex Docks marketplace when personal marketplace is deployed")
331
+ return
332
+ }
333
+
334
+ if (!commandExists("codex")) return
335
+
336
+ const source = marketplaceSource("docks", userConfig)
337
+ if (source !== "https://github.com/DocksDocks/docks.git" && source !== "DocksDocks/docks") return
338
+ const res = spawnSync("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
339
+ if (res.error === undefined && res.status === 0) {
340
+ log("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
341
+ } else {
342
+ warn("Failed to remove legacy configured Codex Docks marketplace")
343
+ }
344
+ }
345
+
346
+ /** 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"'
351
+
352
+ /** codex::_enabled_plugin_ids — [plugins."<id>"] tables with enabled = true. */
353
+ export function enabledPluginIds(configFile: string): Array<string> {
354
+ if (!existsSync(configFile)) return []
355
+ const ids: Array<string> = []
356
+ let plugin = ""
357
+ let enabled = false
358
+ const flush = (): void => {
359
+ if (plugin !== "" && enabled) ids.push(plugin)
360
+ }
361
+ for (const line of readFileSync(configFile, "utf8").split("\n")) {
362
+ const m = /^\[plugins\."([^"]+)"\][ \t]*$/.exec(line)
363
+ if (m !== null) {
364
+ flush()
365
+ plugin = m[1]!
366
+ enabled = false
367
+ continue
368
+ }
369
+ if (line.startsWith("[")) {
370
+ flush()
371
+ plugin = ""
372
+ enabled = false
373
+ continue
374
+ }
375
+ if (plugin !== "" && /^[ \t]*enabled[ \t]*=[ \t]*true([ \t]*(#.*)?)?$/.test(line)) {
376
+ enabled = true
377
+ }
378
+ }
379
+ flush()
380
+ return ids
381
+ }
382
+
383
+ function manualPluginRefreshCommand(sotConfig: string): string {
384
+ const first = enabledPluginIds(sotConfig)[0]
385
+ return first !== undefined ? `codex plugin add ${first}` : "codex plugin add <plugin@marketplace>"
386
+ }
387
+
388
+ function syncPlugins(ctx: Ctx, sotConfig: string): void {
389
+ if (ctx.dryRun) {
390
+ echo("[dry-run] add enabled Codex plugins from SoT")
391
+ return
392
+ }
393
+
394
+ if (!commandExists("codex")) {
395
+ 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)}`
397
+ )
398
+ return
399
+ }
400
+
401
+ let refreshed = 0
402
+ let failed = 0
403
+ for (const pluginId of enabledPluginIds(sotConfig)) {
404
+ const res = spawnSync("codex", ["plugin", "add", pluginId], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
405
+ const addOut = `${res.stdout ?? ""}${res.stderr ?? ""}`
406
+ if (res.error === undefined && res.status === 0) {
407
+ refreshed++
408
+ } else if (addOut.includes("could not find a Codex CLI binary")) {
409
+ warn(
410
+ `Codex plugin refresh hit a stale launcher/wrapper on PATH - install current standalone Codex with: ${standaloneInstallCommand()}`
411
+ )
412
+ failed++
413
+ } else {
414
+ const failureLine = addOut.split("\n")[0] ?? ""
415
+ warn(
416
+ `Codex plugin refresh failed for ${pluginId}: ${failureLine !== "" ? failureLine : "unknown error"}; run manually: codex plugin add ${pluginId}`
417
+ )
418
+ failed++
419
+ }
420
+ }
421
+
422
+ if (refreshed > 0) log(`Codex plugins synced (plugins: ~${refreshed})`)
423
+ if (failed > 0) warn(`${failed} Codex plugin operation(s) failed — re-run sync or install manually`)
424
+ }
425
+
426
+ // -------------------------------------------------------------- summary ----
427
+
428
+ export function codexSummary(ctx: Ctx): void {
429
+ const codexDir = p(ctx.home, ".codex")
430
+ echo(`Codex: ${codexDir}`)
431
+ if (!ctx.dryRun) {
432
+ const count = enabledPluginIds(p(codexDir, "config.toml")).length
433
+ echo(`Codex plugins: ${count} enabled in config.toml`)
434
+ }
435
+ }
436
+
437
+ export function codexNextSteps(): void {
438
+ echo("Restart Codex to load any refreshed plugins, skills, or tools.")
439
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Top-level Codex TOML setting replacement plus `--codex-model`. The pass is
3
+ * line-based on purpose: a TOML library would reformat user configs and break
4
+ * the golden contract. The key is used as a raw regex fragment, preserving the
5
+ * historical record-oriented behavior.
6
+ */
7
+ import { p } from "./exec"
8
+ import { readFileSync, renameSync, writeFileSync } from "node:fs"
9
+
10
+ import type { Ctx } from "./index"
11
+ import { echo, log, warn } from "./output"
12
+
13
+ export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
14
+ const lines = content.split("\n")
15
+ if (lines[lines.length - 1] === "") lines.pop() // awk records exclude a trailing empty split artifact
16
+ const keyRe = new RegExp(`^${key}[ \\t]*=`)
17
+ const out: Array<string> = []
18
+ let inTable = false
19
+ let replaced = false
20
+ for (const line of lines) {
21
+ if (line.startsWith("[")) {
22
+ if (!replaced) {
23
+ out.push(replacement)
24
+ replaced = true
25
+ }
26
+ inTable = true
27
+ out.push(line)
28
+ continue
29
+ }
30
+ if (!inTable && keyRe.test(line)) {
31
+ if (!replaced) {
32
+ out.push(replacement)
33
+ replaced = true
34
+ }
35
+ continue
36
+ }
37
+ out.push(line)
38
+ }
39
+ if (!replaced) out.push(replacement)
40
+ return `${out.join("\n")}\n`
41
+ }
42
+
43
+ export function replaceTopLevelSettingInFile(file: string, key: string, replacement: string): void {
44
+ const next = replaceTopLevelSetting(readFileSync(file, "utf8"), key, replacement)
45
+ writeFileSync(`${file}.tmp`, next)
46
+ renameSync(`${file}.tmp`, file)
47
+ }
48
+
49
+ export function syncCodexModel(ctx: Ctx, model: string): void {
50
+ const userCodexSettings = p(ctx.home, ".codex", "config.toml")
51
+
52
+ if (model === "") return
53
+
54
+ if (ctx.dryRun) {
55
+ echo(`[dry-run] (--codex-model) set model = "${model}" in ${userCodexSettings}`)
56
+ return
57
+ }
58
+
59
+ try {
60
+ readFileSync(userCodexSettings)
61
+ } catch {
62
+ warn(`(--codex-model) ${userCodexSettings} missing — skipped`)
63
+ return
64
+ }
65
+ replaceTopLevelSettingInFile(userCodexSettings, "model", `model = "${model}"`)
66
+ log(`Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`)
67
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Child-process + PATH primitives. Golden rule: every external probe spawns
3
+ * the intended binary with deterministic argv, and capture() mirrors command
4
+ * substitution: stdout with trailing newlines stripped, empty on failure.
5
+ */
6
+ import { spawnSync } from "node:child_process"
7
+ import { accessSync, constants, existsSync, statSync } from "node:fs"
8
+ import { delimiter, join } from "node:path"
9
+
10
+ /**
11
+ * Engine paths are built with "/" because they appear verbatim in output
12
+ * (dry-run lines, warns), where node:path.join would print "\" on Windows
13
+ * and break the golden contract. fs accepts "/" on every platform.
14
+ */
15
+ export function p(...parts: Array<string>): string {
16
+ return parts.join("/")
17
+ }
18
+
19
+ export function capture(cmd: string, args: ReadonlyArray<string>): string {
20
+ const res = spawnSync(cmd, args as Array<string>, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
21
+ if (res.error !== undefined || res.status !== 0) return ""
22
+ return (res.stdout ?? "").replace(/[\r\n]+$/, "")
23
+ }
24
+
25
+ /** `command -v` — resolve a name on PATH (PATHEXT-aware on Windows). */
26
+ export function which(name: string): string {
27
+ const exts = process.platform === "win32" ? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT;.COM").split(";").concat("") : [""]
28
+ for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
29
+ if (dir === "") continue
30
+ for (const ext of exts) {
31
+ const cand = join(dir, name + ext.toLowerCase())
32
+ if (isExecutable(cand)) return cand
33
+ }
34
+ }
35
+ return ""
36
+ }
37
+
38
+ export function commandExists(name: string): boolean {
39
+ return which(name) !== ""
40
+ }
41
+
42
+ export function isExecutable(p: string): boolean {
43
+ try {
44
+ if (!statSync(p).isFile()) return false
45
+ if (process.platform !== "win32") accessSync(p, constants.X_OK)
46
+ return true
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ export function fileExists(p: string): boolean {
53
+ return existsSync(p)
54
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * EngineNative — the supported sync/model/toolchain engine.
3
+ *
4
+ * Golden suites drive it through main.ts's harness-private `native-raw`
5
+ * channel, which bypasses @effect/cli so tests see the internal argv
6
+ * vocabulary directly.
7
+ */
8
+ import { p } from "./exec"
9
+ import { existsSync } from "node:fs"
10
+ import { homedir } from "node:os"
11
+
12
+ import { kitHome } from "../kitHome"
13
+ import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
14
+ import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
15
+ import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
16
+ import { modeModel, modeToolchain } from "./modes"
17
+ import { echo } from "./output"
18
+ import { ExitError, parseArgs, preflight, validateModelFlags } from "./parseArgs"
19
+
20
+ export interface Ctx {
21
+ readonly repoDir: string
22
+ readonly home: string
23
+ readonly agentsDir: string
24
+ dryRun: boolean
25
+ skipRtk: boolean
26
+ reconcile: boolean
27
+ prune: boolean
28
+ assumeYes: boolean
29
+ claudeCompactWindow: string
30
+ claudePermissive: boolean
31
+ claudePlugins: Array<string>
32
+ claudeModel: string
33
+ codexModel: string
34
+ targetFilterSet: boolean
35
+ syncClaude: boolean
36
+ syncCodex: boolean
37
+ syncAgents: boolean
38
+ }
39
+
40
+ /** Globals default from env using the historical ${VAR:-default} contract. */
41
+ function makeCtx(): Ctx {
42
+ const env = process.env
43
+ const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
44
+ return {
45
+ repoDir: kitHome(),
46
+ home,
47
+ agentsDir: env["AGENTS_DIR"] !== undefined && env["AGENTS_DIR"] !== "" ? env["AGENTS_DIR"] : p(home, ".agents"),
48
+ dryRun: env["DRY_RUN"] === "1",
49
+ skipRtk: env["SKIP_RTK"] === "1",
50
+ reconcile: env["RECONCILE"] === "1",
51
+ prune: env["PRUNE"] === "1",
52
+ assumeYes: env["ASSUME_YES"] === "1",
53
+ claudeCompactWindow: env["CLAUDE_COMPACT_WINDOW"] ?? "",
54
+ claudePermissive: env["CLAUDE_PERMISSIVE"] === "1",
55
+ claudePlugins: (env["CLAUDE_PLUGINS"] ?? "").split(" ").filter((s) => s !== ""),
56
+ claudeModel: env["CLAUDE_MODEL"] ?? "",
57
+ codexModel: env["CODEX_MODEL"] ?? "",
58
+ targetFilterSet: false,
59
+ syncClaude: false,
60
+ syncCodex: false,
61
+ syncAgents: false
62
+ }
63
+ }
64
+
65
+ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
66
+ parseArgs(ctx, args)
67
+ preflight(ctx)
68
+ validateModelFlags(ctx)
69
+
70
+ const claudeRan = ctx.syncClaude && existsSync(p(ctx.repoDir, "SoT", ".claude"))
71
+ if (claudeRan) claudeSync(ctx)
72
+
73
+ const codexRan = ctx.syncCodex && existsSync(p(ctx.repoDir, "SoT", ".codex"))
74
+ if (codexRan) codexSync(ctx)
75
+
76
+ const skillsState = ctx.syncAgents && existsSync(p(ctx.repoDir, "SoT", ".agents")) ? skillsSync(ctx) : undefined
77
+
78
+ echo("")
79
+ echo("--- Sync complete ---")
80
+ echo(`Repo: ${ctx.repoDir}`)
81
+ if (claudeRan) claudeSummary(ctx)
82
+ if (codexRan) codexSummary(ctx)
83
+ if (skillsState !== undefined) skillsSummary(ctx, skillsState)
84
+
85
+ echo("")
86
+ if (claudeRan) claudeNextSteps()
87
+ if (codexRan) codexNextSteps()
88
+ if (skillsState !== undefined) skillsNextSteps()
89
+ return 0
90
+ }
91
+
92
+ export function runEngineNative(argv: ReadonlyArray<string>): number {
93
+ const ctx = makeCtx()
94
+ try {
95
+ switch (argv[0]) {
96
+ case "model":
97
+ return modeModel(ctx, argv.slice(1))
98
+ case "toolchain":
99
+ return modeToolchain(ctx, argv.slice(1))
100
+ case "sync":
101
+ return engineSync(ctx, argv.slice(1))
102
+ default:
103
+ return engineSync(ctx, argv)
104
+ }
105
+ } catch (e) {
106
+ if (e instanceof ExitError) return e.code
107
+ throw e
108
+ }
109
+ }