docks-kit 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +7 -5
  3. package/cli/docs/flags.md +1 -0
  4. package/cli/docs/install.md +9 -11
  5. package/cli/docs/overview.md +6 -0
  6. package/cli/docs/platforms.md +9 -10
  7. package/cli/src/commands/model.ts +7 -3
  8. package/cli/src/commands/sync.ts +14 -3
  9. package/cli/src/commands/toolchain.ts +7 -3
  10. package/cli/src/engine-native/DESIGN.md +79 -2
  11. package/cli/src/engine-native/claudeModel.ts +9 -3
  12. package/cli/src/engine-native/claudeSync.ts +220 -116
  13. package/cli/src/engine-native/codexSync.ts +131 -77
  14. package/cli/src/engine-native/codexToml.ts +12 -5
  15. package/cli/src/engine-native/deps.ts +325 -0
  16. package/cli/src/engine-native/exec.ts +35 -2
  17. package/cli/src/engine-native/index.ts +48 -13
  18. package/cli/src/engine-native/logger.ts +35 -0
  19. package/cli/src/engine-native/models.ts +22 -23
  20. package/cli/src/engine-native/modes.ts +30 -14
  21. package/cli/src/engine-native/os.ts +29 -0
  22. package/cli/src/engine-native/parseArgs.ts +19 -17
  23. package/cli/src/engine-native/services.ts +96 -0
  24. package/cli/src/engine-native/skillsSync.ts +77 -61
  25. package/cli/src/engine-native/toolchain.ts +50 -68
  26. package/cli/src/engine.ts +11 -2
  27. package/cli/src/generated/sotPayload.ts +41 -0
  28. package/cli/src/kitHome.ts +15 -11
  29. package/cli/src/main.ts +3 -2
  30. package/cli/src/manifests.ts +17 -15
  31. package/cli/src/payload.ts +28 -0
  32. package/cli/src/services.ts +34 -0
  33. package/docks-kit +6 -6
  34. package/package.json +2 -3
  35. package/SoT/.agents/skills.txt +0 -14
  36. package/SoT/.claude/CLAUDE.md +0 -146
  37. package/SoT/.claude/fetch-usage.sh +0 -66
  38. package/SoT/.claude/hooks/notify.sh +0 -14
  39. package/SoT/.claude/mcp-servers.json +0 -10
  40. package/SoT/.claude/settings.json +0 -235
  41. package/SoT/.claude/statusline.sh +0 -175
  42. package/SoT/.codex/AGENTS.md +0 -75
  43. package/SoT/.codex/agents/.gitkeep +0 -1
  44. package/SoT/.codex/config.toml +0 -45
  45. package/SoT/.codex/plugins/marketplace.json +0 -50
  46. package/SoT/.codex/rules/docks.rules +0 -116
  47. package/SoT/models.json +0 -28
  48. package/SoT/toolchain.json +0 -27
  49. package/cli/src/engine-native/output.ts +0 -20
  50. package/notification.mp3 +0 -0
@@ -6,40 +6,35 @@
6
6
  import { spawnSync } from "node:child_process"
7
7
  import {
8
8
  appendFileSync,
9
- chmodSync,
10
9
  copyFileSync,
11
- cpSync,
12
10
  existsSync,
13
11
  mkdirSync,
14
12
  readdirSync,
15
13
  readFileSync,
16
14
  renameSync,
17
15
  rmSync,
18
- statSync,
19
16
  writeFileSync
20
17
  } from "node:fs"
21
18
  import { tmpdir } from "node:os"
22
19
  import { syncClaudeModel } from "./claudeModel"
23
- import { capture, commandExists, p } from "./exec"
20
+ import { ensureExecutable, p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
24
21
  import type { Ctx } from "./index"
25
22
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
26
- import { echo, err, log, warn } from "./output"
23
+ import type { EngineServices } from "./services"
27
24
  import { ExitError } from "./parseArgs"
28
25
  import { mergeSettings, reconcileSettings } from "./settings"
29
26
  import { ensure, field } from "./toolchain"
27
+ import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
30
28
 
31
29
  export function claudeSync(ctx: Ctx): void {
30
+ const { warn } = ctx.services.logger
32
31
  const claudeDir = p(ctx.home, ".claude")
33
32
 
34
33
  if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
35
34
 
36
- if (!commandExists("claude")) {
37
- const hint =
38
- process.platform === "win32"
39
- ? "winget install Anthropic.ClaudeCode"
40
- : "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh"
35
+ if (ctx.services.deps.probe("claude").state === "missing") {
41
36
  warn(
42
- `claude CLI not found - config deploys, but plugin passes are skipped. Install Claude Code: ${hint} | docs: https://code.claude.com/docs/en/setup`
37
+ `claude CLI not found - config deploys, but plugin passes are skipped. Install Claude Code: ${ctx.services.deps.spec("claude").installHint()} | docs: https://code.claude.com/docs/en/setup`
43
38
  )
44
39
  }
45
40
 
@@ -62,11 +57,12 @@ export function claudeSync(ctx: Ctx): void {
62
57
  // ------------------------------------------------------------------ rtk ----
63
58
 
64
59
  /** RTK toolchain install callback. */
65
- export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
66
- return (mode, version) => {
60
+ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
61
+ return (mode, version, services) => {
62
+ const { change, err, verbose, warn } = services.logger
67
63
  const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
68
64
 
69
- if (mode === "upgrade") log(`Upgrading RTK${version !== "" ? ` to ${version}` : ""}...`)
65
+ if (mode === "upgrade") verbose(`Upgrading RTK${version !== "" ? ` to ${version}` : ""}...`)
70
66
  else warn(`RTK not found. Installing${version !== "" ? ` ${version}` : ""}...`)
71
67
  const installer = p(tmpdir(), `rtk-install-${process.pid}.sh`)
72
68
  const dl = spawnSync("curl", ["-fsSL", `https://raw.githubusercontent.com/rtk-ai/rtk/${installerRef}/install.sh`, "-o", installer], {
@@ -80,9 +76,9 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
80
76
  }
81
77
  rmSync(installer, { force: true })
82
78
  process.env["PATH"] = `${ctx.home}/.local/bin:${ctx.home}/.cargo/bin:${process.env["PATH"] ?? ""}`
83
- if (commandExists("rtk")) {
84
- const v = capture("rtk", ["--version"])
85
- log(`RTK ready (${v !== "" ? v : "version unknown"})`)
79
+ const installed = services.deps.version("rtk")
80
+ if (services.deps.probe("rtk").state === "present") {
81
+ change(`RTK ready (${installed !== "" ? installed : "version unknown"})`)
86
82
  return 0
87
83
  }
88
84
  err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
@@ -91,13 +87,14 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
91
87
  }
92
88
 
93
89
  function syncRtk(ctx: Ctx, claudeDir: string): void {
90
+ const { change, echo, verbose, warn } = ctx.services.logger
94
91
  if (ctx.skipRtk) {
95
92
  warn("Skipping RTK (--skip-rtk)")
96
93
  return
97
94
  }
98
95
 
99
- if (process.platform === "win32") {
100
- if (!commandExists("rtk")) {
96
+ if (ctx.services.platform.isWindows()) {
97
+ if (ctx.services.deps.probe("rtk").state === "missing") {
101
98
  warn("rtk not installed — the kit's auto-install is Unix-only. Install natively (winget, or the rtk-*-windows-msvc.zip release), then re-run sync")
102
99
  return
103
100
  }
@@ -105,7 +102,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
105
102
  warn("RTK bootstrap failed — continuing sync without it")
106
103
  }
107
104
 
108
- if (!commandExists("rtk")) return
105
+ if (ctx.services.deps.probe("rtk").state === "missing") return
109
106
  if (!existsSync(p(claudeDir, "RTK.md"))) {
110
107
  if (ctx.dryRun) {
111
108
  echo("[dry-run] rtk init --global (RTK.md missing; runs before the settings merge, which normalizes rtk's settings rewrite)")
@@ -115,30 +112,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
115
112
  // sync before the success log and before any settings/plugin mutation.
116
113
  const res = spawnSync("rtk", ["init", "--global"], { stdio: "inherit" })
117
114
  if (res.error !== undefined || res.status !== 0) throw new ExitError(res.status ?? 1)
118
- log("RTK initialized (RTK.md generated; the following settings merge re-asserts the SoT hooks)")
115
+ change("RTK initialized (RTK.md generated; the following settings merge re-asserts the SoT hooks)")
119
116
  } else if (!ctx.dryRun) {
120
- log("RTK already initialized")
117
+ verbose("RTK already initialized")
121
118
  }
122
119
  }
123
120
 
124
121
  // ------------------------------------------------------ scripts + hooks ----
125
122
 
126
123
  function syncScripts(ctx: Ctx, claudeDir: string): void {
124
+ const { change, echo, verbose } = ctx.services.logger
127
125
  if (ctx.dryRun) {
128
126
  echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
129
127
  return
130
128
  }
131
129
 
132
- for (const script of ["statusline.sh", "fetch-usage.sh"]) {
133
- const src = p(ctx.repoDir, "SoT", ".claude", script)
134
- if (existsSync(src)) {
135
- copyFileSync(src, p(claudeDir, script))
136
- chmodSync(p(claudeDir, script), statSync(p(claudeDir, script)).mode | 0o111)
137
- }
138
- }
139
- const mp3 = p(ctx.repoDir, "notification.mp3")
140
- if (existsSync(mp3)) copyFileSync(mp3, p(claudeDir, "notification.mp3"))
141
- log("Scripts synced (statusline, fetch-usage, notification)")
130
+ let changed = false
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
140
+ if (changed) {
141
+ change("Scripts synced (statusline, fetch-usage, notification)")
142
+ ctx.nextStepTriggers.claudeRestart = true
143
+ } else verbose("Scripts already in sync (statusline, fetch-usage, notification)")
142
144
  }
143
145
 
144
146
  function shellScriptCount(hooksDir: string): number {
@@ -150,8 +152,8 @@ function shellScriptCount(hooksDir: string): number {
150
152
  }
151
153
 
152
154
  function syncHooks(ctx: Ctx, claudeDir: string): void {
153
- const sotHooks = p(ctx.repoDir, "SoT", ".claude", "hooks")
154
- if (!existsSync(sotHooks)) return
155
+ const { change, echo, verbose } = ctx.services.logger
156
+ const sotHooks = payloadDisplayPath("SoT/.claude/hooks/notify.sh", ctx.repoDir).replace(/\/notify\.sh$/, "")
155
157
 
156
158
  if (ctx.dryRun) {
157
159
  echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
@@ -160,43 +162,62 @@ function syncHooks(ctx: Ctx, claudeDir: string): void {
160
162
 
161
163
  const hooksDir = p(claudeDir, "hooks")
162
164
  mkdirSync(hooksDir, { recursive: true })
163
- cpSync(sotHooks, hooksDir, { recursive: true })
165
+ let changed = writeTextIfChanged(p(hooksDir, "notify.sh"), payloadText("SoT/.claude/hooks/notify.sh"))
164
166
  for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
165
167
  if (e.isFile() && e.name.endsWith(".sh")) {
166
- chmodSync(p(hooksDir, e.name), statSync(p(hooksDir, e.name)).mode | 0o111)
168
+ if (ensureExecutable(p(hooksDir, e.name))) changed = true
167
169
  }
168
170
  }
169
- log(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
171
+ if (changed) {
172
+ change(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
173
+ ctx.nextStepTriggers.claudeRestart = true
174
+ } else verbose(`Hooks already in sync (${shellScriptCount(hooksDir)} scripts)`)
170
175
  }
171
176
 
172
177
  function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
178
+ const { change, echo, verbose } = ctx.services.logger
179
+ // The @RTK.md import only resolves once `rtk init` has generated
180
+ // ~/.claude/RTK.md (the rtk phase runs before this). Deploying the import
181
+ // without the file leaves a dangling reference in every Claude session
182
+ // (seen on Windows, where rtk never auto-installs) — strip it while the
183
+ // file is absent; a later sync after rtk init restores it.
184
+ const rtkMdAbsent = !existsSync(p(claudeDir, "RTK.md"))
173
185
  if (ctx.dryRun) {
174
186
  if (ctx.skipRtk) {
175
187
  echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (stripping @RTK.md import: --skip-rtk)")
188
+ } else if (rtkMdAbsent) {
189
+ echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (would strip @RTK.md import while ~/.claude/RTK.md is absent)")
176
190
  } else {
177
191
  echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md")
178
192
  }
179
193
  return
180
194
  }
181
195
 
182
- const src = p(ctx.repoDir, "SoT", ".claude", "CLAUDE.md")
183
- if (ctx.skipRtk) {
184
- const stripped = readFileSync(src, "utf8")
196
+ const source = payloadText("SoT/.claude/CLAUDE.md")
197
+ const stripReason = ctx.skipRtk ? "--skip-rtk" : rtkMdAbsent ? "~/.claude/RTK.md absent (rtk not initialized)" : ""
198
+ if (stripReason !== "") {
199
+ const stripped = source
185
200
  .split("\n")
186
201
  .filter((l) => l !== "@RTK.md")
187
202
  .join("\n")
188
- writeFileSync(p(claudeDir, "CLAUDE.md"), stripped)
189
- log("CLAUDE.md synced (@RTK.md import stripped: --skip-rtk)")
203
+ if (writeFileIfChanged(p(claudeDir, "CLAUDE.md"), stripped)) {
204
+ change(`CLAUDE.md synced (@RTK.md import stripped: ${stripReason})`)
205
+ } else {
206
+ verbose("CLAUDE.md already in sync")
207
+ }
208
+ } else if (writeTextIfChanged(p(claudeDir, "CLAUDE.md"), source)) {
209
+ change("CLAUDE.md synced")
190
210
  } else {
191
- copyFileSync(src, p(claudeDir, "CLAUDE.md"))
192
- log("CLAUDE.md synced")
211
+ verbose("CLAUDE.md already in sync")
193
212
  }
194
213
  }
195
214
 
196
215
  // ------------------------------------------------------------- settings ----
197
216
 
198
217
  function syncSettings(ctx: Ctx, claudeDir: string): void {
199
- const repoSettings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
218
+ const { change, echo, err, verbose } = ctx.services.logger
219
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
220
+ const repoSettingsText = payloadText("SoT/.claude/settings.json")
200
221
  const userSettings = p(claudeDir, "settings.json")
201
222
 
202
223
  if (ctx.dryRun) {
@@ -211,8 +232,9 @@ function syncSettings(ctx: Ctx, claudeDir: string): void {
211
232
  }
212
233
 
213
234
  if (!existsSync(userSettings)) {
214
- copyFileSync(repoSettings, userSettings)
215
- log("Settings installed")
235
+ writeFileSync(userSettings, repoSettingsText)
236
+ change("Settings installed")
237
+ ctx.nextStepTriggers.claudeRestart = true
216
238
  return
217
239
  }
218
240
 
@@ -222,37 +244,49 @@ function syncSettings(ctx: Ctx, claudeDir: string): void {
222
244
  err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
223
245
  throw new ExitError(1)
224
246
  }
225
- const repo = parseJson(readFileSync(repoSettings, "utf8"))!
247
+ const repo = parseJson(repoSettingsText)!
226
248
 
227
- copyFileSync(userSettings, `${userSettings}.bak`)
228
249
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
229
- writeFileSync(`${userSettings}.tmp`, jqStringify(merged))
250
+ const out = jqStringify(merged)
251
+ if (out === readFileSync(userSettings, "utf8")) {
252
+ verbose("Settings already in sync")
253
+ return
254
+ }
255
+ copyFileSync(userSettings, `${userSettings}.bak`)
256
+ writeFileSync(`${userSettings}.tmp`, out)
230
257
  renameSync(`${userSettings}.tmp`, userSettings)
258
+ ctx.nextStepTriggers.claudeRestart = true
231
259
  if (ctx.reconcile) {
232
- log("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
260
+ change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
233
261
  } else {
234
- log("Settings merged (backup at settings.json.bak)")
262
+ change("Settings merged (backup at settings.json.bak)")
235
263
  }
236
264
  }
237
265
 
238
266
  /** Shared shape of the three jq-edit modifiers (compact window, permissive). */
239
- function jqEditSettings(claudeDir: string, tag: string, edit: (doc: Json) => void): void {
267
+ function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
268
+ const { err, warn } = ctx.services.logger
240
269
  const userSettings = p(claudeDir, "settings.json")
241
270
  if (!existsSync(userSettings)) {
242
271
  warn(`(${tag}) ${userSettings} missing — skipped`)
243
- return
272
+ return false
244
273
  }
245
- const doc = parseJson(readFileSync(userSettings, "utf8"))
274
+ const before = readFileSync(userSettings, "utf8")
275
+ const doc = parseJson(before)
246
276
  if (doc === undefined) {
247
277
  err(`(${tag}) ${userSettings} is not valid JSON — skipped`)
248
- return
278
+ return false
249
279
  }
250
280
  edit(doc)
251
- writeFileSync(`${userSettings}.tmp`, jqStringify(doc))
281
+ const out = jqStringify(doc)
282
+ if (out === before) return false
283
+ writeFileSync(`${userSettings}.tmp`, out)
252
284
  renameSync(`${userSettings}.tmp`, userSettings)
285
+ return true
253
286
  }
254
287
 
255
288
  function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
289
+ const { change, echo, verbose } = ctx.services.logger
256
290
  if (ctx.claudeCompactWindow === "") return
257
291
 
258
292
  if (ctx.dryRun) {
@@ -260,16 +294,21 @@ function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
260
294
  return
261
295
  }
262
296
 
263
- jqEditSettings(claudeDir, "--claude-compact-window", (doc) => {
297
+ const changed = jqEditSettings(ctx, claudeDir, "--claude-compact-window", (doc) => {
264
298
  if (!isObject(doc)) return
265
299
  const env = isObject(doc["env"]) ? doc["env"] : {}
266
300
  env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = ctx.claudeCompactWindow
267
301
  doc["env"] = env
268
302
  })
269
- log(`Compact window: set to ${ctx.claudeCompactWindow} tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)`)
303
+ if (changed) {
304
+ change(`Compact window: set to ${ctx.claudeCompactWindow} tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)`)
305
+ ctx.nextStepTriggers.claudeRestart = true
306
+ }
307
+ else verbose(`Compact window: already set to ${ctx.claudeCompactWindow} tokens in deployed settings`)
270
308
  }
271
309
 
272
310
  function syncPermissive(ctx: Ctx, claudeDir: string): void {
311
+ const { change, echo, verbose } = ctx.services.logger
273
312
  if (!ctx.claudePermissive) return
274
313
 
275
314
  if (ctx.dryRun) {
@@ -277,24 +316,27 @@ function syncPermissive(ctx: Ctx, claudeDir: string): void {
277
316
  return
278
317
  }
279
318
 
280
- jqEditSettings(claudeDir, "--claude-permissive", (doc) => {
319
+ const changed = jqEditSettings(ctx, claudeDir, "--claude-permissive", (doc) => {
281
320
  if (!isObject(doc)) return
282
321
  const permissions = isObject(doc["permissions"]) ? doc["permissions"] : {}
283
322
  permissions["ask"] = []
284
323
  permissions["deny"] = []
285
324
  doc["permissions"] = permissions
286
325
  })
287
- log("Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)")
326
+ if (changed) {
327
+ change("Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)")
328
+ ctx.nextStepTriggers.claudeRestart = true
329
+ }
330
+ else verbose("Permissive mode: permissions.ask/deny already empty in deployed settings")
288
331
  }
289
332
 
290
333
  // ---------------------------------------------------------- claude.json ----
291
334
 
292
335
  function syncClaudeJson(ctx: Ctx): void {
336
+ const { change, echo, err, verbose } = ctx.services.logger
293
337
  const claudeJson = p(ctx.home, ".claude.json")
294
- const mcpSot = p(ctx.repoDir, "SoT", ".claude", "mcp-servers.json")
295
338
 
296
- let mcp: Json | undefined
297
- if (existsSync(mcpSot)) mcp = parseJson(readFileSync(mcpSot, "utf8"))
339
+ const mcp: Json | undefined = parseJson(payloadText("SoT/.claude/mcp-servers.json"))
298
340
  const haveMcp = mcp !== undefined
299
341
 
300
342
  if (ctx.dryRun) {
@@ -311,37 +353,49 @@ function syncClaudeJson(ctx: Ctx): void {
311
353
  }
312
354
  }
313
355
 
356
+ let changed = true
314
357
  if (existsSync(claudeJson)) {
315
- const doc = parseJson(readFileSync(claudeJson, "utf8"))
358
+ const before = readFileSync(claudeJson, "utf8")
359
+ const doc = parseJson(before)
316
360
  if (doc === undefined) {
317
361
  err("Skipping ~/.claude.json edit: not valid JSON. Fix or delete it.")
318
362
  return
319
363
  }
320
364
  const obj = isObject(doc) ? doc : {}
321
365
  applyFilter(obj)
322
- writeFileSync(`${claudeJson}.tmp`, jqStringify(obj))
323
- renameSync(`${claudeJson}.tmp`, claudeJson)
366
+ const out = jqStringify(obj)
367
+ if (out === before) {
368
+ changed = false
369
+ } else {
370
+ writeFileSync(`${claudeJson}.tmp`, out)
371
+ renameSync(`${claudeJson}.tmp`, claudeJson)
372
+ }
324
373
  } else {
325
374
  const obj: { [k: string]: Json } = {}
326
375
  applyFilter(obj)
327
376
  writeFileSync(claudeJson, jqStringify(obj))
328
377
  }
329
- log(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
378
+ if (changed) {
379
+ change(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
380
+ ctx.nextStepTriggers.claudeRestart = true
381
+ }
382
+ else verbose(`~/.claude.json already in sync (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
330
383
  }
331
384
 
332
385
  // -------------------------------------------------------- connector env ----
333
386
 
334
387
  function syncConnectorEnv(ctx: Ctx): void {
388
+ const { change, echo, verbose, warn } = ctx.services.logger
335
389
  // win32: Claude Code launches from PowerShell/GUI, so the flag must be a
336
390
  // real user env var (setx), not a Git-Bash-only shell-rc export. Never
337
391
  // clobbers an existing value (set =true yourself to keep connectors).
338
- if (process.platform === "win32") {
392
+ if (ctx.services.platform.isWindows()) {
339
393
  const existing = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "ENABLE_CLAUDEAI_MCP_SERVERS"], {
340
394
  stdio: "ignore"
341
395
  })
342
396
  if (existing.error === undefined && existing.status === 0) {
343
397
  if (ctx.dryRun) echo("[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in user environment — would skip")
344
- else log("claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in user environment (left as-is)")
398
+ else verbose("claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in user environment (left as-is)")
345
399
  return
346
400
  }
347
401
  if (ctx.dryRun) {
@@ -350,7 +404,8 @@ function syncConnectorEnv(ctx: Ctx): void {
350
404
  }
351
405
  const res = spawnSync("setx", ["ENABLE_CLAUDEAI_MCP_SERVERS", "false"], { stdio: "ignore" })
352
406
  if (res.error === undefined && res.status === 0) {
353
- log("claude.ai connectors disabled via setx (open a new terminal to apply)")
407
+ change("claude.ai connectors disabled via setx (open a new terminal to apply)")
408
+ ctx.nextStepTriggers.claudeRestart = true
354
409
  } else {
355
410
  warn("setx ENABLE_CLAUDEAI_MCP_SERVERS false failed — set it manually in System Properties > Environment Variables")
356
411
  }
@@ -366,7 +421,7 @@ function syncConnectorEnv(ctx: Ctx): void {
366
421
  if (ctx.dryRun) {
367
422
  echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
368
423
  } else {
369
- log(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
424
+ verbose(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
370
425
  }
371
426
  return
372
427
  }
@@ -382,7 +437,8 @@ function syncConnectorEnv(ctx: Ctx): void {
382
437
  }
383
438
 
384
439
  appendFileSync(target, `\n${marker}\n${line}\n`)
385
- log(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
440
+ change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
441
+ ctx.nextStepTriggers.claudeRestart = true
386
442
  }
387
443
 
388
444
  // ------------------------------------------------------------- removals ----
@@ -439,6 +495,7 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
439
495
  }
440
496
 
441
497
  function syncRemovals(ctx: Ctx, claudeDir: string): void {
498
+ const { change, echo } = ctx.services.logger
442
499
  let hooksRemoved = 0
443
500
  let filesRemoved = 0
444
501
 
@@ -474,7 +531,8 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
474
531
  }
475
532
 
476
533
  if (hooksRemoved + filesRemoved + skeys + cjkeys > 0) {
477
- log(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys})`)
534
+ change(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys})`)
535
+ ctx.nextStepTriggers.claudeRestart = true
478
536
  }
479
537
  }
480
538
 
@@ -504,7 +562,7 @@ function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): b
504
562
  }
505
563
 
506
564
  function syncPlugins(ctx: Ctx, claudeDir: string): void {
507
- const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
565
+ const { change, echo, verbose, warn } = ctx.services.logger
508
566
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
509
567
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
510
568
 
@@ -516,17 +574,20 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
516
574
  return
517
575
  }
518
576
 
519
- if (!commandExists("claude")) {
577
+ if (ctx.services.deps.probe("claude").state === "missing") {
520
578
  warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
521
579
  return
522
580
  }
523
- if (!commandExists("git")) {
524
- const hint = process.platform === "win32" ? "winget install Git.Git (then open a new terminal)" : "install git via your package manager"
525
- warn(`git not found — plugin marketplaces are git repos, so every plugin operation would fail. Skipping plugin passes. Install: ${hint}, then re-run sync`)
581
+ if (ctx.services.deps.probe("git").state === "missing") {
582
+ ctx.services.deps.warnMissing(
583
+ "git",
584
+ ctx.services.logger,
585
+ "plugin marketplaces are git repos — Claude plugin passes skipped; re-run sync after installing"
586
+ )
526
587
  return
527
588
  }
528
589
 
529
- const repoSettings = readJsonFile(repoSettingsFile)
590
+ const repoSettings = parseJson(payloadText("SoT/.claude/settings.json"))
530
591
  const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
531
592
  const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
532
593
  const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
@@ -604,29 +665,37 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
604
665
  }
605
666
 
606
667
  // Pass 6 — re-assert SoT enabled-state in the user settings.
607
- reassertEnabledState(repoObj, p(claudeDir, "settings.json"))
668
+ if (reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
669
+ change("Plugin enable-state re-asserted from SoT in settings.json")
670
+ ctx.nextStepTriggers.claudePlugins = true
671
+ }
608
672
 
609
673
  const failed = f1 + f2 + f4 + f5
610
674
  if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
611
- log(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
675
+ change(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
676
+ ctx.nextStepTriggers.claudePlugins = true
612
677
  } else {
613
- log("Plugins already in sync")
678
+ verbose("Plugins already in sync")
614
679
  }
615
680
  if (failed > 0) {
616
681
  warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
617
682
  }
618
683
  }
619
684
 
620
- function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile: string): void {
621
- if (!existsSync(userSettingsFile)) return
685
+ function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSettingsFile: string): boolean {
686
+ const { warn } = ctx.services.logger
687
+ if (!existsSync(userSettingsFile)) return false
622
688
  const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
623
689
 
690
+ let cliDisabled = false
624
691
  for (const [pluginId, value] of Object.entries(sotPlugins)) {
625
692
  if (value !== false) continue
626
693
  const user = readJsonFile(userSettingsFile)
627
694
  const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
628
695
  if (enabled !== true) continue
629
- if (!cli(["plugin", "disable", pluginId]).ok) {
696
+ if (cli(["plugin", "disable", pluginId]).ok) {
697
+ cliDisabled = true
698
+ } else {
630
699
  warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
631
700
  }
632
701
  }
@@ -634,44 +703,66 @@ function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile:
634
703
  const user = readJsonFile(userSettingsFile)
635
704
  if (user === undefined || !isObject(user)) {
636
705
  warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
637
- return
706
+ return false
638
707
  }
708
+ const beforeCanonical = jqStringify(user)
639
709
  user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
640
- writeFileSync(`${userSettingsFile}.tmp`, jqStringify(user))
710
+ const out = jqStringify(user)
711
+ if (out === beforeCanonical) return cliDisabled
712
+ writeFileSync(`${userSettingsFile}.tmp`, out)
641
713
  renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
714
+ return true
642
715
  }
643
716
 
644
717
  // ------------------------------------------------------ optional plugins ----
645
718
 
646
- function enableOptionalPlugin(claudeDir: string, pluginId: string, marketplaceRepo: string): void {
719
+ function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, marketplaceRepo: string): boolean {
720
+ const { change, verbose, warn } = ctx.services.logger
647
721
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
648
722
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
649
723
  const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
650
724
 
725
+ let marketplaceAdded = false
651
726
  if (marketplaceRepo !== "") {
652
727
  const known = readJsonFile(knownMarketplaces)
653
728
  const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
654
- if (!has && !cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
655
- warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
656
- return
729
+ if (!has) {
730
+ if (!cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
731
+ warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
732
+ return false
733
+ }
734
+ marketplaceAdded = true
657
735
  }
658
736
  }
659
737
 
660
- if (!pluginUserScopeInstalled(installedPlugins, pluginId)) {
738
+ const wasInstalled = pluginUserScopeInstalled(installedPlugins, pluginId)
739
+ if (!wasInstalled) {
661
740
  if (!cli(["plugin", "install", pluginId]).ok) {
741
+ if (marketplaceAdded) change(`Optional plugin ${pluginId}: marketplace added (install failed — will retry next sync)`)
662
742
  warn(`Failed to install optional plugin ${pluginId}`)
663
- return
743
+ return marketplaceAdded
664
744
  }
665
745
  }
666
746
 
667
- if (cli(["plugin", "enable", pluginId]).ok) {
668
- log(`Optional plugin opted in: ${pluginId}`)
669
- } else {
747
+ const settingsDoc = readJsonFile(p(claudeDir, "settings.json"))
748
+ const wasEnabled =
749
+ settingsDoc !== undefined && isObject(settingsDoc) && isObject(settingsDoc["enabledPlugins"])
750
+ ? (settingsDoc["enabledPlugins"] as { [k: string]: Json })[pluginId] === true
751
+ : false
752
+
753
+ if (!cli(["plugin", "enable", pluginId]).ok) {
754
+ if (marketplaceAdded || !wasInstalled) change(`Optional plugin ${pluginId}: installed (enable failed — will retry next sync)`)
670
755
  warn(`Failed to enable optional plugin ${pluginId}`)
756
+ return marketplaceAdded || !wasInstalled
671
757
  }
758
+ const changed = marketplaceAdded || !wasInstalled || !wasEnabled
759
+ if (changed) change(`Optional plugin opted in: ${pluginId}`)
760
+ else verbose(`Optional plugin already opted in: ${pluginId} (enable re-asserted)`)
761
+ return changed
672
762
  }
673
763
 
674
764
  function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
765
+ const { echo, warn } = ctx.services.logger
675
766
  if (ctx.claudePlugins.length === 0) return
676
767
 
677
768
  if (ctx.dryRun) {
@@ -684,16 +775,16 @@ function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
684
775
  return
685
776
  }
686
777
 
687
- if (!commandExists("claude")) {
778
+ if (ctx.services.deps.probe("claude").state === "missing") {
688
779
  warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
689
780
  return
690
781
  }
691
782
 
692
783
  if (ctx.claudePlugins.includes("supabase")) {
693
- enableOptionalPlugin(claudeDir, "supabase@claude-plugins-official", "")
784
+ if (enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
694
785
  }
695
786
  if (ctx.claudePlugins.includes("n8n")) {
696
- enableOptionalPlugin(claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")
787
+ if (enableOptionalPlugin(ctx, claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")) ctx.nextStepTriggers.claudePlugins = true
697
788
  }
698
789
  }
699
790
 
@@ -705,7 +796,8 @@ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
705
796
  }
706
797
 
707
798
  function syncLspServers(ctx: Ctx): void {
708
- const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
799
+ const { change, echo, verbose, warn } = ctx.services.logger
800
+ const sot = parseJson(payloadText("SoT/.claude/settings.json"))
709
801
  const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
710
802
  if (enabled === undefined) return
711
803
  const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
@@ -713,17 +805,17 @@ function syncLspServers(ctx: Ctx): void {
713
805
  if (!hasPhp && !hasTs) return
714
806
 
715
807
  const missing: Array<string> = []
716
- if (hasPhp && !commandExists("intelephense")) missing.push(lspPkg(ctx, "intelephense", "intelephense"))
808
+ if (hasPhp && ctx.services.deps.probe("intelephense").state === "missing") missing.push(lspPkg(ctx, "intelephense", "intelephense"))
717
809
  if (hasTs) {
718
- if (!commandExists("typescript-language-server")) missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
719
- if (!commandExists("tsc")) missing.push(lspPkg(ctx, "tsc", "typescript"))
810
+ if (ctx.services.deps.probe("typescript-language-server").state === "missing") missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
811
+ if (ctx.services.deps.probe("tsc").state === "missing") missing.push(lspPkg(ctx, "tsc", "typescript"))
720
812
  }
721
813
 
722
814
  if (missing.length === 0) {
723
815
  if (ctx.dryRun) {
724
816
  echo("[dry-run] LSP server binaries present")
725
817
  } else {
726
- log("LSP server binaries present")
818
+ verbose("LSP server binaries present")
727
819
  }
728
820
  return
729
821
  }
@@ -734,14 +826,19 @@ function syncLspServers(ctx: Ctx): void {
734
826
  return
735
827
  }
736
828
 
737
- if (!commandExists("npm")) {
738
- warn(`npm not found — cannot install LSP servers (${specs}); the php-lsp/typescript-lsp plugins stay no-ops. Install Node.js, then re-run sync.`)
829
+ if (ctx.services.deps.probe("npm").state === "missing") {
830
+ ctx.services.deps.warnMissing(
831
+ "npm",
832
+ ctx.services.logger,
833
+ `cannot install LSP servers (${specs}); the php-lsp/typescript-lsp plugins stay no-ops`
834
+ )
739
835
  return
740
836
  }
741
837
 
742
- log(`Installing LSP servers via npm: ${specs}...`)
838
+ verbose(`Installing LSP servers via npm: ${specs}...`)
743
839
  if (spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" }).status === 0) {
744
- log(`LSP servers installed (${specs})`)
840
+ change(`LSP servers installed (${specs})`)
841
+ ctx.nextStepTriggers.claudeRestart = true
745
842
  } else {
746
843
  warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
747
844
  }
@@ -750,17 +847,18 @@ function syncLspServers(ctx: Ctx): void {
750
847
  // -------------------------------------------------------------- summary ----
751
848
 
752
849
  export function claudeSummary(ctx: Ctx): void {
850
+ const { echo } = ctx.services.logger
753
851
  const claudeDir = p(ctx.home, ".claude")
754
852
  echo(`Claude: ${claudeDir}`)
755
853
  if (!ctx.dryRun) {
756
854
  echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
757
- if (commandExists("rtk")) {
758
- const v = capture("rtk", ["--version"])
759
- echo(`RTK: ${v !== "" ? v : "installed"}`)
855
+ if (ctx.services.deps.probe("rtk").state === "present") {
856
+ const version = ctx.services.deps.version("rtk")
857
+ echo(`RTK: ${version !== "" ? version : "installed"}`)
760
858
  } else {
761
859
  echo("RTK: not installed")
762
860
  }
763
- if (commandExists("claude")) {
861
+ if (ctx.services.deps.probe("claude").state === "present") {
764
862
  const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
765
863
  const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
766
864
  echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
@@ -770,7 +868,13 @@ export function claudeSummary(ctx: Ctx): void {
770
868
  }
771
869
  }
772
870
 
773
- export function claudeNextSteps(): void {
774
- echo("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
775
- echo("Restart Claude Code for hook/env-var changes to take effect.")
871
+ export function claudeNextSteps(ctx: Ctx): Array<string> {
872
+ const lines: Array<string> = []
873
+ if (ctx.verbose || ctx.nextStepTriggers.claudePlugins) {
874
+ lines.push("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
875
+ }
876
+ if (ctx.verbose || ctx.nextStepTriggers.claudeRestart) {
877
+ lines.push("Restart Claude Code for hook/env-var changes to take effect.")
878
+ }
879
+ return lines
776
880
  }