docks-kit 0.2.0 → 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.
package/AGENTS.md CHANGED
@@ -22,7 +22,7 @@ Tool-specific instructions live alongside this file:
22
22
  | `install.sh` | Global installer |
23
23
  | `.github/workflows/release-cli.yml` | `cli-v*` release binaries + npm publish |
24
24
  | `README.md` | Front door |
25
- | `package.json` / `bun.lock` | npm package: `bin` = `cli/src/main.ts`; bundles `cli/` + `SoT/` |
25
+ | `package.json` / `bun.lock` | npm package: `bin` = `cli/src/main.ts`; bundles `cli/` with the generated in-memory SoT payload |
26
26
  | `SoT/.agents/skills.txt` | Universal-skill manifest. One [agentskills.io](https://agentskills.io) slug per line; EngineNative runs `npx skills add` for each missing entry into `~/.agents/skills/`, where Codex et al. discover it natively and Claude Code follows a symlink at `~/.claude/skills/` |
27
27
  | `notification.mp3` | Audio asset for Notification hooks (consumed by Claude Code today; tool-agnostic file) |
28
28
  | `docs/plans/` | Multi-commit work-item plans (`active/` with status in frontmatter, plus `finished/` archive). Convention: `docs/plans/AGENTS.md` |
package/README.md CHANGED
@@ -25,8 +25,9 @@ see `./docks-kit docs install`.
25
25
 
26
26
  **No-Bun recovery path**:
27
27
 
28
- Download the platform release binary from GitHub Releases and run it from a
29
- kit checkout, or set `DOCKS_KIT_HOME` to the checkout containing `SoT/`.
28
+ Download the platform release binary from GitHub Releases and run it directly.
29
+ The executable carries the generated sync payload; no checkout or adjacent
30
+ `SoT/` directory is required.
30
31
 
31
32
  Prerequisites for source/global installs: Bun plus `jq` and `curl` for sync
32
33
  preflight; Node/npm for npm-global tools.
@@ -93,6 +94,7 @@ and a later flag-less sync reverts them. Full reference: `docks-kit docs flags`
93
94
  | `SoT/models.json` | Kit-verified model catalog |
94
95
  | `SoT/toolchain.json` | Verified-version floors |
95
96
  | `cli/src/engine-native/` | EngineNative sync/model/toolchain implementation |
97
+ | `cli/src/generated/sotPayload.ts` | Generated in-memory payload used by standalone and npm installs |
96
98
  | `cli/` | docks-kit CLI (Effect-TS on Bun) + bundled docs topics |
97
99
  | `docks-kit` | Launcher (binary → bun-from-source) |
98
100
  | `install.sh` | Global installer (Bun bootstrap + `bun add -g`) |
@@ -113,8 +115,8 @@ Details: `docks-kit docs platforms`.
113
115
 
114
116
  Tagging `cli-v*` builds five standalone binaries (+ SHA256SUMS) and attaches
115
117
  them to the GitHub release; npm publish runs when the `NPM_TOKEN` secret is
116
- configured. Package bundles the CLI + SoT, so npm releases are versioned config
117
- snapshots.
118
+ configured. Package bundles the CLI + generated payload, so npm releases are
119
+ versioned config snapshots without shipping the authoring `SoT/` tree.
118
120
 
119
121
  ## Deeper docs
120
122
 
@@ -21,10 +21,10 @@ docks-kit sync
21
21
  bunx docks-kit status
22
22
  ```
23
23
 
24
- The npm package bundles the CLI and the SoT — releases are versioned config
25
- snapshots. Kit-home resolution: `DOCKS_KIT_HOME` env nearest ancestor
26
- directory containing `SoT/` + `package.json` (a checkout wins over the bundled
27
- copy) the package's own bundled SoT.
24
+ The npm package bundles the CLI and its generated in-memory payload — releases
25
+ are versioned config snapshots without publishing the authoring `SoT/` tree.
26
+ Kit-home resolution remains available for checkout/package update behavior and
27
+ display paths, but sync reads do not depend on it.
28
28
 
29
29
  ## 3. curl installer (Unix-only)
30
30
 
@@ -44,12 +44,10 @@ kit before. The installer bootstraps Bun when absent, runs
44
44
  Two supported paths (CI-verified on windows-2025, native PowerShell):
45
45
 
46
46
  - **Compiled binary** — `docks-kit-windows-x64.exe` release asset. No Bun,
47
- no Git Bash: the exe embeds the runtime and EngineNative runs in-process.
48
- It still needs the SoT it deploys — run it from inside a kit checkout, or
49
- point `DOCKS_KIT_HOME` at one.
47
+ no Git Bash: the exe embeds the runtime and generated payload, and
48
+ EngineNative runs in-process from any working directory.
50
49
  - **`bun add -g docks-kit`** — bun creates a working shim for the
51
- `#!/usr/bin/env bun` bin; outside a checkout the package's own bundled
52
- SoT is used.
50
+ `#!/usr/bin/env bun` bin; the package carries the same generated payload.
53
51
 
54
52
  `install.sh` is not a Windows path.
55
53
 
@@ -72,8 +70,8 @@ nudges when the checkout is stale (silent offline / detached / no git).
72
70
  ## No-Bun recovery
73
71
 
74
72
  No Bun or constrained sandbox: download the platform release binary from GitHub
75
- Releases and run it from inside a kit checkout, or set `DOCKS_KIT_HOME` to the
76
- checkout containing `SoT/`.
73
+ Releases and run it directly. No checkout or `DOCKS_KIT_HOME` is required for
74
+ sync/config reads.
77
75
 
78
76
  ## Prerequisites
79
77
 
@@ -14,6 +14,7 @@ AI-assisted dev environment on every machine.
14
14
  | `SoT/.agents/` | Universal agent skills manifest (agentskills.io standard) |
15
15
  | `SoT/models.json` | Kit-verified model catalog (see `docks-kit docs models`) |
16
16
  | `SoT/toolchain.json` | Verified-version floors for external tools (see `docks-kit docs toolchain`) |
17
+ | `cli/src/generated/sotPayload.ts` | Deterministic generated payload embedded in standalone/npm execution |
17
18
  | `cli/src/engine-native/` | EngineNative mutation logic for sync/model/toolchain |
18
19
  | `cli/` | This CLI (Effect-TS on Bun) plus bundled docs |
19
20
  | `docks-kit` | Launcher: compiled binary → bun-from-source, with Bun auto-install |
@@ -31,6 +32,8 @@ AI-assisted dev environment on every machine.
31
32
  changes, and a later flag-less sync reverts them (`docks-kit docs modifiers`).
32
33
  - **Engine/CLI split**: the CLI adds UX (typed flags, pickers, docs, JSON);
33
34
  EngineNative owns mutation. No-Bun recovery is a platform release binary.
35
+ - **Authoring/runtime split**: changes begin in `SoT/`; build/prepack freshness
36
+ checks keep the generated in-memory payload byte-identical for every runtime.
34
37
 
35
38
  ## Where to go next
36
39
 
@@ -16,12 +16,10 @@ Supported entrypoints (both verified in CI on windows-2025 under native
16
16
  PowerShell — see `.github/workflows/windows-entrypoints.yml`):
17
17
 
18
18
  - **`docks-kit-windows-x64.exe`** (release asset) — the no-toolchain path.
19
- No Bun, no Git Bash; the exe embeds the runtime and EngineNative runs
20
- in-process. It still needs the SoT it deploys: run from a kit checkout or
21
- set `DOCKS_KIT_HOME`.
19
+ No Bun, no Git Bash; the exe embeds the runtime and generated payload, and
20
+ EngineNative runs in-process from any working directory.
22
21
  - **`bun add -g docks-kit`** — bun creates a working Windows shim for the
23
- `#!/usr/bin/env bun` bin; outside a checkout kit-home resolves to the
24
- package's own bundled SoT.
22
+ `#!/usr/bin/env bun` bin; the package carries the same generated payload.
25
23
  - `install.sh` is **Unix-only** and not a Windows path.
26
24
 
27
25
  The managed tools all run natively on Windows: Claude Code (requires Git
@@ -36,6 +36,9 @@ explicit removed-engine diagnostic and exits 2 with the recovery tag message.
36
36
  shell script.
37
37
  - **Backups precede mutation.** Deployed settings/config files write `.bak`
38
38
  backups before replacement.
39
+ - **Runtime payload is in memory.** `SoT/` remains the reviewed authoring tree;
40
+ generated payload freshness gates build/package production, and EngineNative
41
+ never needs an adjacent runtime `SoT/` directory.
39
42
 
40
43
  ## Output Policy (log UX contract)
41
44
 
@@ -113,6 +116,7 @@ active logger binding.
113
116
  |---|---|
114
117
  | `parseArgs.ts` | engine usage, target selection, flag parsing, legacy rename hints, preflight, model flag validation |
115
118
  | `index.ts` | sync orchestration, target dispatch, run summary and next-step blocks |
119
+ | `../payload.ts` | generated text/byte payload reads and presentation-only source labels |
116
120
  | `claudeSync.ts` | Claude pipeline: RTK, assets, settings merge, deploy-time modifiers, `~/.claude.json`, connector env, removed artifacts, plugins, optional plugins, LSP binaries |
117
121
  | `settings.ts` | pure Claude settings merge/reconcile semantics and permission-array union |
118
122
  | `claudeModel.ts` | deployed Claude model modifier and direct `model claude` write path |
@@ -17,13 +17,14 @@ import {
17
17
  } from "node:fs"
18
18
  import { tmpdir } from "node:os"
19
19
  import { syncClaudeModel } from "./claudeModel"
20
- import { copyFileIfChanged, copyTreeIfChanged, ensureExecutable, p, writeFileIfChanged } from "./exec"
20
+ import { ensureExecutable, p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
21
21
  import type { Ctx } from "./index"
22
22
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
23
23
  import type { EngineServices } from "./services"
24
24
  import { ExitError } from "./parseArgs"
25
25
  import { mergeSettings, reconcileSettings } from "./settings"
26
26
  import { ensure, field } from "./toolchain"
27
+ import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
27
28
 
28
29
  export function claudeSync(ctx: Ctx): void {
29
30
  const { warn } = ctx.services.logger
@@ -127,15 +128,15 @@ function syncScripts(ctx: Ctx, claudeDir: string): void {
127
128
  }
128
129
 
129
130
  let changed = false
130
- for (const script of ["statusline.sh", "fetch-usage.sh"]) {
131
- const src = p(ctx.repoDir, "SoT", ".claude", script)
132
- if (existsSync(src)) {
133
- if (copyFileIfChanged(src, p(claudeDir, script))) changed = true
134
- if (ensureExecutable(p(claudeDir, script))) changed = true
135
- }
136
- }
137
- const mp3 = p(ctx.repoDir, "notification.mp3")
138
- if (existsSync(mp3) && copyFileIfChanged(mp3, p(claudeDir, "notification.mp3"))) changed = true
131
+ for (const [script, source] of [
132
+ ["statusline.sh", "SoT/.claude/statusline.sh"],
133
+ ["fetch-usage.sh", "SoT/.claude/fetch-usage.sh"]
134
+ ] as const) {
135
+ const path = p(claudeDir, script)
136
+ if (writeTextIfChanged(path, payloadText(source))) changed = true
137
+ if (ensureExecutable(path)) changed = true
138
+ }
139
+ if (writeBytesIfChanged(p(claudeDir, "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
139
140
  if (changed) {
140
141
  change("Scripts synced (statusline, fetch-usage, notification)")
141
142
  ctx.nextStepTriggers.claudeRestart = true
@@ -152,8 +153,7 @@ function shellScriptCount(hooksDir: string): number {
152
153
 
153
154
  function syncHooks(ctx: Ctx, claudeDir: string): void {
154
155
  const { change, echo, verbose } = ctx.services.logger
155
- const sotHooks = p(ctx.repoDir, "SoT", ".claude", "hooks")
156
- if (!existsSync(sotHooks)) return
156
+ const sotHooks = payloadDisplayPath("SoT/.claude/hooks/notify.sh", ctx.repoDir).replace(/\/notify\.sh$/, "")
157
157
 
158
158
  if (ctx.dryRun) {
159
159
  echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
@@ -162,7 +162,7 @@ function syncHooks(ctx: Ctx, claudeDir: string): void {
162
162
 
163
163
  const hooksDir = p(claudeDir, "hooks")
164
164
  mkdirSync(hooksDir, { recursive: true })
165
- let changed = copyTreeIfChanged(sotHooks, hooksDir)
165
+ let changed = writeTextIfChanged(p(hooksDir, "notify.sh"), payloadText("SoT/.claude/hooks/notify.sh"))
166
166
  for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
167
167
  if (e.isFile() && e.name.endsWith(".sh")) {
168
168
  if (ensureExecutable(p(hooksDir, e.name))) changed = true
@@ -193,10 +193,10 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
193
193
  return
194
194
  }
195
195
 
196
- const src = p(ctx.repoDir, "SoT", ".claude", "CLAUDE.md")
196
+ const source = payloadText("SoT/.claude/CLAUDE.md")
197
197
  const stripReason = ctx.skipRtk ? "--skip-rtk" : rtkMdAbsent ? "~/.claude/RTK.md absent (rtk not initialized)" : ""
198
198
  if (stripReason !== "") {
199
- const stripped = readFileSync(src, "utf8")
199
+ const stripped = source
200
200
  .split("\n")
201
201
  .filter((l) => l !== "@RTK.md")
202
202
  .join("\n")
@@ -205,7 +205,7 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
205
205
  } else {
206
206
  verbose("CLAUDE.md already in sync")
207
207
  }
208
- } else if (copyFileIfChanged(src, p(claudeDir, "CLAUDE.md"))) {
208
+ } else if (writeTextIfChanged(p(claudeDir, "CLAUDE.md"), source)) {
209
209
  change("CLAUDE.md synced")
210
210
  } else {
211
211
  verbose("CLAUDE.md already in sync")
@@ -216,7 +216,8 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
216
216
 
217
217
  function syncSettings(ctx: Ctx, claudeDir: string): void {
218
218
  const { change, echo, err, verbose } = ctx.services.logger
219
- const repoSettings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
219
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
220
+ const repoSettingsText = payloadText("SoT/.claude/settings.json")
220
221
  const userSettings = p(claudeDir, "settings.json")
221
222
 
222
223
  if (ctx.dryRun) {
@@ -231,7 +232,7 @@ function syncSettings(ctx: Ctx, claudeDir: string): void {
231
232
  }
232
233
 
233
234
  if (!existsSync(userSettings)) {
234
- copyFileSync(repoSettings, userSettings)
235
+ writeFileSync(userSettings, repoSettingsText)
235
236
  change("Settings installed")
236
237
  ctx.nextStepTriggers.claudeRestart = true
237
238
  return
@@ -243,7 +244,7 @@ function syncSettings(ctx: Ctx, claudeDir: string): void {
243
244
  err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
244
245
  throw new ExitError(1)
245
246
  }
246
- const repo = parseJson(readFileSync(repoSettings, "utf8"))!
247
+ const repo = parseJson(repoSettingsText)!
247
248
 
248
249
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
249
250
  const out = jqStringify(merged)
@@ -334,10 +335,8 @@ function syncPermissive(ctx: Ctx, claudeDir: string): void {
334
335
  function syncClaudeJson(ctx: Ctx): void {
335
336
  const { change, echo, err, verbose } = ctx.services.logger
336
337
  const claudeJson = p(ctx.home, ".claude.json")
337
- const mcpSot = p(ctx.repoDir, "SoT", ".claude", "mcp-servers.json")
338
338
 
339
- let mcp: Json | undefined
340
- if (existsSync(mcpSot)) mcp = parseJson(readFileSync(mcpSot, "utf8"))
339
+ const mcp: Json | undefined = parseJson(payloadText("SoT/.claude/mcp-servers.json"))
341
340
  const haveMcp = mcp !== undefined
342
341
 
343
342
  if (ctx.dryRun) {
@@ -564,7 +563,6 @@ function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): b
564
563
 
565
564
  function syncPlugins(ctx: Ctx, claudeDir: string): void {
566
565
  const { change, echo, verbose, warn } = ctx.services.logger
567
- const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
568
566
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
569
567
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
570
568
 
@@ -589,7 +587,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
589
587
  return
590
588
  }
591
589
 
592
- const repoSettings = readJsonFile(repoSettingsFile)
590
+ const repoSettings = parseJson(payloadText("SoT/.claude/settings.json"))
593
591
  const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
594
592
  const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
595
593
  const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
@@ -799,7 +797,7 @@ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
799
797
 
800
798
  function syncLspServers(ctx: Ctx): void {
801
799
  const { change, echo, verbose, warn } = ctx.services.logger
802
- const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
800
+ const sot = parseJson(payloadText("SoT/.claude/settings.json"))
803
801
  const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
804
802
  if (enabled === undefined) return
805
803
  const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
@@ -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) {
@@ -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,7 +6,6 @@
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"
@@ -81,13 +80,13 @@ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
81
80
  preflight(ctx)
82
81
  validateModelFlags(ctx)
83
82
 
84
- const claudeRan = ctx.syncClaude && existsSync(p(ctx.repoDir, "SoT", ".claude"))
83
+ const claudeRan = ctx.syncClaude
85
84
  if (claudeRan) claudeSync(ctx)
86
85
 
87
- const codexRan = ctx.syncCodex && existsSync(p(ctx.repoDir, "SoT", ".codex"))
86
+ const codexRan = ctx.syncCodex
88
87
  if (codexRan) codexSync(ctx)
89
88
 
90
- const skillsState = ctx.syncAgents && existsSync(p(ctx.repoDir, "SoT", ".agents")) ? skillsSync(ctx) : undefined
89
+ const skillsState = ctx.syncAgents ? skillsSync(ctx) : undefined
91
90
 
92
91
  echo("")
93
92
  echo("--- Sync complete ---")