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