docks-kit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +15 -7
  3. package/cli/docs/install.md +12 -12
  4. package/cli/docs/overview.md +7 -1
  5. package/cli/docs/platforms.md +8 -7
  6. package/cli/docs/sync-layers.md +15 -7
  7. package/cli/docs/toolchain.md +8 -2
  8. package/cli/src/engine-native/DESIGN.md +25 -14
  9. package/cli/src/engine-native/bun.ts +87 -0
  10. package/cli/src/engine-native/claudeRuntime.ts +132 -0
  11. package/cli/src/engine-native/claudeSync.ts +179 -115
  12. package/cli/src/engine-native/codexSync.ts +42 -36
  13. package/cli/src/engine-native/deps.ts +27 -10
  14. package/cli/src/engine-native/exec.ts +8 -23
  15. package/cli/src/engine-native/index.ts +8 -8
  16. package/cli/src/engine-native/models.ts +14 -16
  17. package/cli/src/engine-native/modes.ts +18 -9
  18. package/cli/src/engine-native/parseArgs.ts +1 -17
  19. package/cli/src/engine-native/powershell.ts +11 -0
  20. package/cli/src/engine-native/skillsSync.ts +11 -46
  21. package/cli/src/engine-native/toolchain.ts +6 -6
  22. package/cli/src/generated/sotPayload.ts +41 -0
  23. package/cli/src/kitHome.ts +15 -11
  24. package/cli/src/manifests.ts +17 -15
  25. package/cli/src/payload.ts +28 -0
  26. package/docks-kit +6 -6
  27. package/package.json +2 -3
  28. package/SoT/.agents/skills.txt +0 -14
  29. package/SoT/.claude/CLAUDE.md +0 -146
  30. package/SoT/.claude/fetch-usage.sh +0 -66
  31. package/SoT/.claude/hooks/notify.sh +0 -14
  32. package/SoT/.claude/mcp-servers.json +0 -10
  33. package/SoT/.claude/settings.json +0 -235
  34. package/SoT/.claude/statusline.sh +0 -175
  35. package/SoT/.codex/AGENTS.md +0 -75
  36. package/SoT/.codex/agents/.gitkeep +0 -1
  37. package/SoT/.codex/config.toml +0 -45
  38. package/SoT/.codex/plugins/marketplace.json +0 -50
  39. package/SoT/.codex/rules/docks.rules +0 -116
  40. package/SoT/models.json +0 -31
  41. package/SoT/toolchain.json +0 -27
  42. package/notification.mp3 +0 -0
@@ -4,25 +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, rmSync, 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
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 { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
13
14
 
14
15
  export function codexSync(ctx: Ctx): void {
15
16
  const codexDir = p(ctx.home, ".codex")
16
- const sotConfig = p(ctx.repoDir, "SoT", ".codex", "config.toml")
17
+ const sotConfig = payloadText("SoT/.codex/config.toml")
17
18
  const userConfig = p(codexDir, "config.toml")
18
19
 
19
20
  ensureBubblewrap(ctx)
20
21
  if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
21
22
  syncConfig(ctx, sotConfig, userConfig)
22
23
  syncCodexModel(ctx, ctx.codexModel)
23
- syncRules(ctx, p(ctx.repoDir, "SoT", ".codex", "rules"), p(codexDir, "rules"))
24
- syncAgentsMd(ctx, p(ctx.repoDir, "SoT", ".codex", "AGENTS.md"), p(codexDir, "AGENTS.md"))
25
- 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"))
26
27
  removeLegacyDocksMarketplace(ctx, userConfig)
27
28
  syncPlugins(ctx, sotConfig)
28
29
  }
@@ -95,9 +96,9 @@ function bwrapDetectPmInstallCmd(ctx: Ctx): string {
95
96
 
96
97
  // --------------------------------------------------------------- config ----
97
98
 
98
- function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
99
+ function syncConfig(ctx: Ctx, sotConfigText: string, userConfig: string): void {
99
100
  const { change, echo, verbose } = ctx.services.logger
100
- if (!existsSync(sotConfig)) return
101
+ const sotConfig = payloadDisplayPath("SoT/.codex/config.toml", ctx.repoDir)
101
102
 
102
103
  if (ctx.dryRun) {
103
104
  echo(`[dry-run] merge ${sotConfig} -> ${userConfig}`)
@@ -105,7 +106,7 @@ function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
105
106
  }
106
107
 
107
108
  if (!existsSync(userConfig)) {
108
- copyFileSync(sotConfig, userConfig)
109
+ writeFileSync(userConfig, sotConfigText)
109
110
  change("Codex config installed")
110
111
  ctx.nextStepTriggers.codexRestart = true
111
112
  return
@@ -119,8 +120,8 @@ function syncConfig(ctx: Ctx, sotConfig: string, userConfig: string): void {
119
120
  writeFileSync(staging, before)
120
121
 
121
122
  scrubDeprecatedFeatures(ctx, staging)
122
- mergeTopLevelSettings(sotConfig, staging)
123
- mergeTableSettings(sotConfig, staging)
123
+ mergeTopLevelSettings(sotConfigText, staging)
124
+ mergeTableSettings(sotConfigText, staging)
124
125
 
125
126
  if (readFileSync(staging, "utf8") === before) {
126
127
  rmSync(staging, { force: true })
@@ -179,8 +180,8 @@ function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
179
180
  change("Codex: scrubbed deprecated [features].use_legacy_landlock")
180
181
  }
181
182
 
182
- function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
183
- 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")) {
184
185
  if (line.startsWith("[")) break
185
186
  if (/^[ \t]*($|#)/.test(line)) continue
186
187
  if (!/^[A-Za-z0-9_.-]+[ \t]*=/.test(line)) continue
@@ -189,8 +190,8 @@ function mergeTopLevelSettings(sotConfig: string, userConfig: string): void {
189
190
  }
190
191
  }
191
192
 
192
- function mergeTableSettings(sotConfig: string, userConfig: string): void {
193
- const sotLines = readFileSync(sotConfig, "utf8").split("\n")
193
+ function mergeTableSettings(sotConfigText: string, userConfig: string): void {
194
+ const sotLines = sotConfigText.split("\n")
194
195
  for (const tableHeader of sotLines.filter((l) => /^\[[^\]]+\]/.test(l))) {
195
196
  // Extract the SoT block: from the exact header line to (excluding) the
196
197
  // next table header; `$(...)` strips trailing newlines.
@@ -225,9 +226,12 @@ function mergeTableSettings(sotConfig: string, userConfig: string): void {
225
226
 
226
227
  // ------------------------------------------------------- rules + agents ----
227
228
 
228
- function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
229
+ function syncRules(ctx: Ctx, sotRules: ReadonlyArray<PayloadPath>, userRulesDir: string): void {
229
230
  const { change, echo, verbose } = ctx.services.logger
230
- if (!existsSync(sotRulesDir)) return
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("/"))
231
235
 
232
236
  if (ctx.dryRun) {
233
237
  echo(`[dry-run] cp ${sotRulesDir}/*.rules -> ${userRulesDir}/`)
@@ -237,17 +241,15 @@ function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
237
241
  mkdirSync(userRulesDir, { recursive: true })
238
242
  let sawRules = false
239
243
  let rulesChanged = false
240
- const ruleFiles = readdirSync(sotRulesDir, { withFileTypes: true })
241
- .filter((e) => e.isFile() && e.name.endsWith(".rules"))
242
- .map((e) => p(sotRulesDir, e.name))
243
- .sort(compareCodepoints)
244
+ const ruleFiles = sotRules.filter((path) => path.endsWith(".rules")).sort(compareCodepoints)
244
245
  for (const ruleFile of ruleFiles) {
245
246
  sawRules = true
246
247
  const userRuleFile = p(userRulesDir, ruleFile.slice(ruleFile.lastIndexOf("/") + 1))
247
- const identical = existsSync(userRuleFile) && readFileSync(userRuleFile).equals(readFileSync(ruleFile))
248
+ const content = payloadBytes(ruleFile)
249
+ const identical = existsSync(userRuleFile) && readFileSync(userRuleFile).equals(content)
248
250
  if (identical) continue
249
251
  if (existsSync(userRuleFile)) copyFileSync(userRuleFile, `${userRuleFile}.bak`)
250
- copyFileSync(ruleFile, userRuleFile)
252
+ writeFileSync(userRuleFile, content)
251
253
  rulesChanged = true
252
254
  }
253
255
  if (rulesChanged) {
@@ -256,30 +258,30 @@ function syncRules(ctx: Ctx, sotRulesDir: string, userRulesDir: string): void {
256
258
  } else if (sawRules) verbose("Codex rules already in sync")
257
259
  }
258
260
 
259
- function syncAgentsMd(ctx: Ctx, sotAgentsMd: string, userAgentsMd: string): void {
261
+ function syncAgentsMd(ctx: Ctx, sotAgentsMdText: string, userAgentsMd: string): void {
260
262
  const { change, echo, verbose } = ctx.services.logger
261
- if (!existsSync(sotAgentsMd)) return
263
+ const sotAgentsMd = payloadDisplayPath("SoT/.codex/AGENTS.md", ctx.repoDir)
262
264
 
263
265
  if (ctx.dryRun) {
264
266
  echo(`[dry-run] cp ${sotAgentsMd} -> ${userAgentsMd}`)
265
267
  return
266
268
  }
267
269
 
268
- if (existsSync(userAgentsMd) && readFileSync(userAgentsMd).equals(readFileSync(sotAgentsMd))) {
270
+ if (existsSync(userAgentsMd) && readFileSync(userAgentsMd, "utf8") === sotAgentsMdText) {
269
271
  verbose("Codex AGENTS.md already in sync")
270
272
  return
271
273
  }
272
274
  if (existsSync(userAgentsMd)) copyFileSync(userAgentsMd, `${userAgentsMd}.bak`)
273
- copyFileSync(sotAgentsMd, userAgentsMd)
275
+ writeFileSync(userAgentsMd, sotAgentsMdText)
274
276
  change("Codex AGENTS.md synced")
275
277
  ctx.nextStepTriggers.codexRestart = true
276
278
  }
277
279
 
278
280
  // ---------------------------------------------------------- marketplace ----
279
281
 
280
- function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: string): void {
282
+ function syncMarketplace(ctx: Ctx, sotMarketplaceText: string, userMarketplace: string): void {
281
283
  const { change, echo, err, verbose } = ctx.services.logger
282
- if (!existsSync(sotMarketplace)) return
284
+ const sotMarketplace = payloadDisplayPath("SoT/.codex/plugins/marketplace.json", ctx.repoDir)
283
285
 
284
286
  if (ctx.dryRun) {
285
287
  echo(`[dry-run] cp ${sotMarketplace} -> ${userMarketplace}`)
@@ -287,7 +289,7 @@ function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: stri
287
289
  }
288
290
 
289
291
  mkdirSync(p(ctx.agentsDir, "plugins"), { recursive: true })
290
- const repo = parseJson(readFileSync(sotMarketplace, "utf8"))
292
+ const repo = parseJson(sotMarketplaceText)
291
293
  if (repo === undefined) throw new Error(`invalid SoT marketplace JSON: ${sotMarketplace}`)
292
294
 
293
295
  if (existsSync(userMarketplace)) {
@@ -307,7 +309,7 @@ function syncMarketplace(ctx: Ctx, sotMarketplace: string, userMarketplace: stri
307
309
  change("Codex marketplace merged (backup at marketplace.json.bak)")
308
310
  ctx.nextStepTriggers.codexRestart = true
309
311
  } else {
310
- copyFileSync(sotMarketplace, userMarketplace)
312
+ writeFileSync(userMarketplace, sotMarketplaceText)
311
313
  change("Codex marketplace installed")
312
314
  ctx.nextStepTriggers.codexRestart = true
313
315
  }
@@ -390,13 +392,17 @@ const standaloneInstallCommand = (ctx: Ctx): string => ctx.services.deps.spec("c
390
392
  /** codex::_enabled_plugin_ids — [plugins."<id>"] tables with enabled = true. */
391
393
  export function enabledPluginIds(configFile: string): Array<string> {
392
394
  if (!existsSync(configFile)) return []
395
+ return enabledPluginIdsFromText(readFileSync(configFile, "utf8"))
396
+ }
397
+
398
+ export function enabledPluginIdsFromText(configText: string): Array<string> {
393
399
  const ids: Array<string> = []
394
400
  let plugin = ""
395
401
  let enabled = false
396
402
  const flush = (): void => {
397
403
  if (plugin !== "" && enabled) ids.push(plugin)
398
404
  }
399
- for (const line of readFileSync(configFile, "utf8").split("\n")) {
405
+ for (const line of configText.split("\n")) {
400
406
  const m = /^\[plugins\."([^"]+)"\][ \t]*$/.exec(line)
401
407
  if (m !== null) {
402
408
  flush()
@@ -418,12 +424,12 @@ export function enabledPluginIds(configFile: string): Array<string> {
418
424
  return ids
419
425
  }
420
426
 
421
- function manualPluginRefreshCommand(sotConfig: string): string {
422
- const first = enabledPluginIds(sotConfig)[0]
427
+ function manualPluginRefreshCommand(sotConfigText: string): string {
428
+ const first = enabledPluginIdsFromText(sotConfigText)[0]
423
429
  return first !== undefined ? `codex plugin add ${first}` : "codex plugin add <plugin@marketplace>"
424
430
  }
425
431
 
426
- function syncPlugins(ctx: Ctx, sotConfig: string): void {
432
+ function syncPlugins(ctx: Ctx, sotConfigText: string): void {
427
433
  const { change, echo, warn } = ctx.services.logger
428
434
  if (ctx.dryRun) {
429
435
  echo("[dry-run] add enabled Codex plugins from SoT")
@@ -432,7 +438,7 @@ function syncPlugins(ctx: Ctx, sotConfig: string): void {
432
438
 
433
439
  if (ctx.services.deps.probe("codex").state === "missing") {
434
440
  warn(
435
- `codex CLI not in PATH - deployed config/marketplace only; install Codex with: ${standaloneInstallCommand(ctx)} | 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)}`
436
442
  )
437
443
  return
438
444
  }
@@ -447,7 +453,7 @@ function syncPlugins(ctx: Ctx, sotConfig: string): void {
447
453
 
448
454
  let refreshed = 0
449
455
  let failed = 0
450
- for (const pluginId of enabledPluginIds(sotConfig)) {
456
+ for (const pluginId of enabledPluginIdsFromText(sotConfigText)) {
451
457
  const res = spawnSync("codex", ["plugin", "add", pluginId], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
452
458
  const addOut = `${res.stdout ?? ""}${res.stderr ?? ""}`
453
459
  if (res.error === undefined && res.status === 0) {
@@ -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)"),
@@ -4,7 +4,7 @@
4
4
  * substitution: stdout with trailing newlines stripped, empty on failure.
5
5
  */
6
6
  import { spawnSync } from "node:child_process"
7
- import { accessSync, chmodSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
7
+ import { accessSync, chmodSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
8
  import { delimiter, isAbsolute, join } from "node:path"
9
9
 
10
10
  /**
@@ -69,34 +69,19 @@ export function ensureExecutable(path: string): boolean {
69
69
  return true
70
70
  }
71
71
 
72
- export function writeFileIfChanged(path: string, content: string): boolean {
72
+ export function writeTextIfChanged(path: string, content: string): boolean {
73
73
  if (existsSync(path) && readFileSync(path, "utf8") === content) return false
74
74
  writeFileSync(path, content)
75
75
  return true
76
76
  }
77
77
 
78
- /** Copy only when dest differs from src; returns whether a copy happened. */
79
- export function copyFileIfChanged(src: string, dest: string): boolean {
80
- if (existsSync(dest) && readFileSync(dest).equals(readFileSync(src))) return false
81
- copyFileSync(src, dest)
78
+ export function writeBytesIfChanged(path: string, content: Uint8Array): boolean {
79
+ const bytes = Buffer.from(content)
80
+ if (existsSync(path) && readFileSync(path).equals(bytes)) return false
81
+ writeFileSync(path, bytes)
82
82
  return true
83
83
  }
84
84
 
85
- /** Recursive copy via copyFileIfChanged; returns whether anything changed. */
86
- export function copyTreeIfChanged(srcDir: string, destDir: string): boolean {
87
- let changed = false
88
- for (const e of readdirSync(srcDir, { withFileTypes: true })) {
89
- const src = p(srcDir, e.name)
90
- const dest = p(destDir, e.name)
91
- if (e.isDirectory()) {
92
- if (!existsSync(dest)) {
93
- mkdirSync(dest, { recursive: true })
94
- changed = true
95
- }
96
- if (copyTreeIfChanged(src, dest)) changed = true
97
- } else if (copyFileIfChanged(src, dest)) {
98
- changed = true
99
- }
100
- }
101
- return changed
85
+ export function writeFileIfChanged(path: string, content: string): boolean {
86
+ return writeTextIfChanged(path, content)
102
87
  }
@@ -6,16 +6,16 @@
6
6
  * vocabulary directly.
7
7
  */
8
8
  import { p } from "./exec"
9
- import { existsSync } from "node:fs"
10
9
  import { homedir } from "node:os"
11
10
 
12
11
  import { kitHome } from "../kitHome"
13
12
  import { makeEngineServices, type EngineServices, type Logger } from "./services"
13
+ import type { BunRuntimeState } from "./bun"
14
14
  import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
15
15
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
16
16
  import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
17
17
  import { modeModel, modeToolchain } from "./modes"
18
- import { ExitError, parseArgs, preflight, validateModelFlags } from "./parseArgs"
18
+ import { ExitError, parseArgs, validateModelFlags } from "./parseArgs"
19
19
 
20
20
  export interface Ctx {
21
21
  readonly repoDir: string
@@ -34,6 +34,7 @@ export interface Ctx {
34
34
  codexModel: string
35
35
  /** Injected capability seam (logger/deps/platform) — see services.ts. */
36
36
  readonly services: EngineServices
37
+ bunRuntime?: BunRuntimeState
37
38
  targetFilterSet: boolean
38
39
  syncClaude: boolean
39
40
  syncCodex: boolean
@@ -78,21 +79,20 @@ function makeCtx(services: EngineServices): Ctx {
78
79
  function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
79
80
  const { echo } = ctx.services.logger
80
81
  parseArgs(ctx, args)
81
- preflight(ctx)
82
82
  validateModelFlags(ctx)
83
83
 
84
- const claudeRan = ctx.syncClaude && existsSync(p(ctx.repoDir, "SoT", ".claude"))
85
- if (claudeRan) claudeSync(ctx)
84
+ const claudeRan = ctx.syncClaude
85
+ const claudeRuntime = claudeRan ? claudeSync(ctx) : undefined
86
86
 
87
- const codexRan = ctx.syncCodex && existsSync(p(ctx.repoDir, "SoT", ".codex"))
87
+ const codexRan = ctx.syncCodex
88
88
  if (codexRan) codexSync(ctx)
89
89
 
90
- const skillsState = ctx.syncAgents && existsSync(p(ctx.repoDir, "SoT", ".agents")) ? skillsSync(ctx) : undefined
90
+ const skillsState = ctx.syncAgents ? skillsSync(ctx) : undefined
91
91
 
92
92
  echo("")
93
93
  echo("--- Sync complete ---")
94
94
  echo(`Repo: ${ctx.repoDir}`)
95
- if (claudeRan) claudeSummary(ctx)
95
+ if (claudeRuntime !== undefined) claudeSummary(ctx, claudeRuntime)
96
96
  if (codexRan) codexSummary(ctx)
97
97
  if (skillsState !== undefined) skillsSummary(ctx, skillsState)
98
98
 
@@ -2,49 +2,47 @@
2
2
  * Model-catalog helpers: manifest listing plus Claude/Codex model validation.
3
3
  * Message strings are covered by the golden suites.
4
4
  */
5
- import { p } from "./exec"
6
- import { readFileSync } from "node:fs"
7
-
8
5
  import type { Ctx } from "./index"
9
6
  import { isObject, parseJson, type Json } from "./jq"
7
+ import { payloadDisplayPath, payloadText } from "../payload"
10
8
 
11
- function catalog(repoDir: string): Json | undefined {
9
+ function catalog(): Json | undefined {
12
10
  try {
13
- return parseJson(readFileSync(p(repoDir, "SoT", "models.json"), "utf8"))
11
+ return parseJson(payloadText("SoT/models.json"))
14
12
  } catch {
15
13
  return undefined
16
14
  }
17
15
  }
18
16
 
19
- function toolEntry(repoDir: string, tool: string): { [k: string]: Json } | undefined {
20
- const doc = catalog(repoDir)
17
+ function toolEntry(tool: string): { [k: string]: Json } | undefined {
18
+ const doc = catalog()
21
19
  if (doc === undefined || !isObject(doc)) return undefined
22
20
  const entry = doc[tool]
23
21
  return entry !== undefined && isObject(entry) ? entry : undefined
24
22
  }
25
23
 
26
- function modelEntries(repoDir: string, tool: string): Array<{ [k: string]: Json }> {
27
- const entry = toolEntry(repoDir, tool)
24
+ function modelEntries(tool: string): Array<{ [k: string]: Json }> {
25
+ const entry = toolEntry(tool)
28
26
  const models = entry?.["models"]
29
27
  return Array.isArray(models) ? models.filter(isObject) : []
30
28
  }
31
29
 
32
- export function modelsFromManifest(repoDir: string, tool: string): Array<string> {
33
- return modelEntries(repoDir, tool)
30
+ export function modelsFromManifest(tool: string): Array<string> {
31
+ return modelEntries(tool)
34
32
  .map((m) => m["id"])
35
33
  .filter((id): id is string => typeof id === "string")
36
34
  }
37
35
 
38
36
  export function printModels(ctx: Ctx, tool: string): void {
39
37
  const { echo, warn } = ctx.services.logger
40
- const entry = toolEntry(ctx.repoDir, tool)
38
+ const entry = toolEntry(tool)
41
39
  if (entry === undefined) {
42
- warn(`Model catalog unavailable (${p(ctx.repoDir, "SoT", "models.json")})`)
40
+ warn(`Model catalog unavailable (${payloadDisplayPath("SoT/models.json", ctx.repoDir)})`)
43
41
  return
44
42
  }
45
43
  const verified = typeof entry["verified"] === "string" ? entry["verified"] : "?"
46
44
  const lines = [`Available ${tool} models (kit-verified ${verified} — SoT/models.json):`]
47
- for (const m of modelEntries(ctx.repoDir, tool)) {
45
+ for (const m of modelEntries(tool)) {
48
46
  const note = typeof m["note"] === "string" ? ` — ${m["note"]}` : ""
49
47
  lines.push(` ${String(m["id"] ?? "")}${note}`)
50
48
  }
@@ -55,7 +53,7 @@ export function printModels(ctx: Ctx, tool: string): void {
55
53
 
56
54
  export function validateClaudeModel(ctx: Ctx, m: string): boolean {
57
55
  if (m === "") return false
58
- if (modelsFromManifest(ctx.repoDir, "claude").includes(m)) return true
56
+ if (modelsFromManifest("claude").includes(m)) return true
59
57
  if (m.startsWith("claude-")) {
60
58
  ctx.services.logger.warn(`Claude model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway`)
61
59
  return true
@@ -65,7 +63,7 @@ export function validateClaudeModel(ctx: Ctx, m: string): boolean {
65
63
 
66
64
  export function validateCodexModel(ctx: Ctx, m: string): boolean {
67
65
  if (!/^[A-Za-z0-9._-]+$/.test(m)) return false
68
- if (!modelsFromManifest(ctx.repoDir, "codex").includes(m)) {
66
+ if (!modelsFromManifest("codex").includes(m)) {
69
67
  ctx.services.logger.warn(
70
68
  `Codex model '${m}' is not in the kit-verified catalog (SoT/models.json) — applying anyway (check ~/.codex/config.toml if Codex rejects it)`
71
69
  )
@@ -4,14 +4,16 @@
4
4
  */
5
5
  import { p } from "./exec"
6
6
  import { readFileSync } from "node:fs"
7
+ import { payloadText } from "../payload"
7
8
 
8
9
  import { syncClaudeModel } from "./claudeModel"
9
10
  import { syncCodexModel } from "./codexToml"
10
11
  import type { Ctx } from "./index"
11
12
  import { isObject, parseJson, type Json } from "./jq"
12
13
  import { printModels, validateClaudeModel, validateCodexModel } from "./models"
13
- import { rtkInstall } from "./claudeSync"
14
- import { agentBrowserInstall, bunBootstrap, effectSolutionsInstall } from "./skillsSync"
14
+ import { ensureRtk } from "./claudeSync"
15
+ import { bunBootstrap } from "./bun"
16
+ import { agentBrowserInstall, effectSolutionsInstall } from "./skillsSync"
15
17
  import { ensure, report } from "./toolchain"
16
18
 
17
19
  export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
@@ -41,7 +43,7 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
41
43
  return 0
42
44
  }
43
45
  echo(`deployed: ${jsonModelField(deployed)}`)
44
- echo(`SoT: ${jsonModelField(p(ctx.repoDir, "SoT", ".claude", "settings.json"))}`)
46
+ echo(`SoT: ${jsonModelText(payloadText("SoT/.claude/settings.json"))}`)
45
47
  } else {
46
48
  const deployed = p(ctx.home, ".codex", "config.toml")
47
49
  if (!fileReadable(deployed)) {
@@ -49,7 +51,7 @@ export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
49
51
  return 0
50
52
  }
51
53
  echo(`deployed: ${tomlModelField(deployed)}`)
52
- echo(`SoT: ${tomlModelField(p(ctx.repoDir, "SoT", ".codex", "config.toml"))}`)
54
+ echo(`SoT: ${tomlModelText(payloadText("SoT/.codex/config.toml"))}`)
53
55
  }
54
56
  printModels(ctx, tool)
55
57
  return 0
@@ -84,7 +86,11 @@ function fileReadable(p: string): boolean {
84
86
 
85
87
  /** `jq -r '.model // "default (unset)"'` — empty on unparseable input. */
86
88
  function jsonModelField(file: string): string {
87
- const doc = parseJson(readFileSync(file, "utf8"))
89
+ return jsonModelText(readFileSync(file, "utf8"))
90
+ }
91
+
92
+ function jsonModelText(text: string): string {
93
+ const doc = parseJson(text)
88
94
  if (doc === undefined) return ""
89
95
  const v: Json | undefined = isObject(doc) ? doc["model"] : undefined
90
96
  if (v === undefined || v === null || v === false) return "default (unset)"
@@ -93,7 +99,11 @@ function jsonModelField(file: string): string {
93
99
 
94
100
  /** `awk -F'"' '/^model[[:space:]]*=/{print $2; exit}'`. */
95
101
  function tomlModelField(file: string): string {
96
- for (const line of readFileSync(file, "utf8").split("\n")) {
102
+ return tomlModelText(readFileSync(file, "utf8"))
103
+ }
104
+
105
+ function tomlModelText(text: string): string {
106
+ for (const line of text.split("\n")) {
97
107
  if (/^model[ \t]*=/.test(line)) return line.split('"')[1] ?? ""
98
108
  }
99
109
  return ""
@@ -125,10 +135,9 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
125
135
  }
126
136
  switch (tool) {
127
137
  case "rtk":
128
- return ensure(ctx, "rtk", rtkInstall(ctx))
138
+ return ensureRtk(ctx, "cannot download RTK installer; toolchain ensure rtk aborted", 1)
129
139
  case "bun":
130
- // skills::_bun_bootstrap >/dev/null the found-bun stdout is discarded.
131
- return bunBootstrap(ctx, ctx.services) !== "" ? 0 : 1
140
+ return bunBootstrap(ctx, ctx.services).kind === "ready" ? 0 : 1
132
141
  case "effect-solutions":
133
142
  return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
134
143
  case "agent-browser":
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * EngineNative flag layer: usage / target selection / compact-window parsing /
3
- * optional-plugin parsing / model validation / preflight. ExitError mirrors an
3
+ * optional-plugin parsing / model validation. ExitError mirrors an
4
4
  * early parser exit and is caught once in runEngineNative.
5
5
  */
6
6
 
@@ -184,22 +184,6 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
184
184
  }
185
185
  }
186
186
 
187
- export function preflight(ctx: Ctx): void {
188
- const { err } = ctx.services.logger
189
- if (ctx.syncClaude || ctx.syncCodex) {
190
- if (ctx.services.deps.probe("jq").state === "missing") {
191
- err(`jq is required (deployed statusline/hooks call it). Install: ${ctx.services.deps.spec("jq").installHint()}`)
192
- throw new ExitError(1)
193
- }
194
- }
195
- if (ctx.syncClaude) {
196
- if (ctx.services.deps.probe("curl").state === "missing") {
197
- err(`curl is required. Install: ${ctx.services.deps.spec("curl").installHint()}`)
198
- throw new ExitError(1)
199
- }
200
- }
201
- }
202
-
203
187
  export function validateModelFlags(ctx: Ctx): void {
204
188
  const { err, warn } = ctx.services.logger
205
189
  if (ctx.claudeModel !== "") {
@@ -0,0 +1,11 @@
1
+ export function powerShellLiteral(value: string): string {
2
+ return `'${value.replaceAll("'", "''")}'`
3
+ }
4
+
5
+ export function encodePowerShellCommand(script: string): string {
6
+ return Buffer.from(script, "utf16le").toString("base64")
7
+ }
8
+
9
+ export function decodePowerShellCommand(encoded: string): string {
10
+ return Buffer.from(encoded, "base64").toString("utf16le")
11
+ }