docks-kit 0.1.4 → 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,10 +576,18 @@ 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
  }
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
+ )
589
+ return
590
+ }
523
591
 
524
592
  const repoSettings = readJsonFile(repoSettingsFile)
525
593
  const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
@@ -599,29 +667,37 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
599
667
  }
600
668
 
601
669
  // Pass 6 — re-assert SoT enabled-state in the user settings.
602
- 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
+ }
603
674
 
604
675
  const failed = f1 + f2 + f4 + f5
605
676
  if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
606
- 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
607
679
  } else {
608
- log("Plugins already in sync")
680
+ verbose("Plugins already in sync")
609
681
  }
610
682
  if (failed > 0) {
611
683
  warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
612
684
  }
613
685
  }
614
686
 
615
- function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile: string): void {
616
- 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
617
690
  const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
618
691
 
692
+ let cliDisabled = false
619
693
  for (const [pluginId, value] of Object.entries(sotPlugins)) {
620
694
  if (value !== false) continue
621
695
  const user = readJsonFile(userSettingsFile)
622
696
  const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
623
697
  if (enabled !== true) continue
624
- if (!cli(["plugin", "disable", pluginId]).ok) {
698
+ if (cli(["plugin", "disable", pluginId]).ok) {
699
+ cliDisabled = true
700
+ } else {
625
701
  warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
626
702
  }
627
703
  }
@@ -629,44 +705,66 @@ function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile:
629
705
  const user = readJsonFile(userSettingsFile)
630
706
  if (user === undefined || !isObject(user)) {
631
707
  warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
632
- return
708
+ return false
633
709
  }
710
+ const beforeCanonical = jqStringify(user)
634
711
  user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
635
- writeFileSync(`${userSettingsFile}.tmp`, jqStringify(user))
712
+ const out = jqStringify(user)
713
+ if (out === beforeCanonical) return cliDisabled
714
+ writeFileSync(`${userSettingsFile}.tmp`, out)
636
715
  renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
716
+ return true
637
717
  }
638
718
 
639
719
  // ------------------------------------------------------ optional plugins ----
640
720
 
641
- 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
642
723
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
643
724
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
644
725
  const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
645
726
 
727
+ let marketplaceAdded = false
646
728
  if (marketplaceRepo !== "") {
647
729
  const known = readJsonFile(knownMarketplaces)
648
730
  const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
649
- if (!has && !cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
650
- warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
651
- 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
652
737
  }
653
738
  }
654
739
 
655
- if (!pluginUserScopeInstalled(installedPlugins, pluginId)) {
740
+ const wasInstalled = pluginUserScopeInstalled(installedPlugins, pluginId)
741
+ if (!wasInstalled) {
656
742
  if (!cli(["plugin", "install", pluginId]).ok) {
743
+ if (marketplaceAdded) change(`Optional plugin ${pluginId}: marketplace added (install failed — will retry next sync)`)
657
744
  warn(`Failed to install optional plugin ${pluginId}`)
658
- return
745
+ return marketplaceAdded
659
746
  }
660
747
  }
661
748
 
662
- if (cli(["plugin", "enable", pluginId]).ok) {
663
- log(`Optional plugin opted in: ${pluginId}`)
664
- } 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)`)
665
757
  warn(`Failed to enable optional plugin ${pluginId}`)
758
+ return marketplaceAdded || !wasInstalled
666
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
667
764
  }
668
765
 
669
766
  function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
767
+ const { echo, warn } = ctx.services.logger
670
768
  if (ctx.claudePlugins.length === 0) return
671
769
 
672
770
  if (ctx.dryRun) {
@@ -679,16 +777,16 @@ function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
679
777
  return
680
778
  }
681
779
 
682
- if (!commandExists("claude")) {
780
+ if (ctx.services.deps.probe("claude").state === "missing") {
683
781
  warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
684
782
  return
685
783
  }
686
784
 
687
785
  if (ctx.claudePlugins.includes("supabase")) {
688
- enableOptionalPlugin(claudeDir, "supabase@claude-plugins-official", "")
786
+ if (enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
689
787
  }
690
788
  if (ctx.claudePlugins.includes("n8n")) {
691
- 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
692
790
  }
693
791
  }
694
792
 
@@ -700,6 +798,7 @@ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
700
798
  }
701
799
 
702
800
  function syncLspServers(ctx: Ctx): void {
801
+ const { change, echo, verbose, warn } = ctx.services.logger
703
802
  const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
704
803
  const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
705
804
  if (enabled === undefined) return
@@ -708,17 +807,17 @@ function syncLspServers(ctx: Ctx): void {
708
807
  if (!hasPhp && !hasTs) return
709
808
 
710
809
  const missing: Array<string> = []
711
- 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"))
712
811
  if (hasTs) {
713
- if (!commandExists("typescript-language-server")) missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
714
- 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"))
715
814
  }
716
815
 
717
816
  if (missing.length === 0) {
718
817
  if (ctx.dryRun) {
719
818
  echo("[dry-run] LSP server binaries present")
720
819
  } else {
721
- log("LSP server binaries present")
820
+ verbose("LSP server binaries present")
722
821
  }
723
822
  return
724
823
  }
@@ -729,14 +828,19 @@ function syncLspServers(ctx: Ctx): void {
729
828
  return
730
829
  }
731
830
 
732
- if (!commandExists("npm")) {
733
- 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
+ )
734
837
  return
735
838
  }
736
839
 
737
- log(`Installing LSP servers via npm: ${specs}...`)
840
+ verbose(`Installing LSP servers via npm: ${specs}...`)
738
841
  if (spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" }).status === 0) {
739
- log(`LSP servers installed (${specs})`)
842
+ change(`LSP servers installed (${specs})`)
843
+ ctx.nextStepTriggers.claudeRestart = true
740
844
  } else {
741
845
  warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
742
846
  }
@@ -745,17 +849,18 @@ function syncLspServers(ctx: Ctx): void {
745
849
  // -------------------------------------------------------------- summary ----
746
850
 
747
851
  export function claudeSummary(ctx: Ctx): void {
852
+ const { echo } = ctx.services.logger
748
853
  const claudeDir = p(ctx.home, ".claude")
749
854
  echo(`Claude: ${claudeDir}`)
750
855
  if (!ctx.dryRun) {
751
856
  echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
752
- if (commandExists("rtk")) {
753
- const v = capture("rtk", ["--version"])
754
- 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"}`)
755
860
  } else {
756
861
  echo("RTK: not installed")
757
862
  }
758
- if (commandExists("claude")) {
863
+ if (ctx.services.deps.probe("claude").state === "present") {
759
864
  const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
760
865
  const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
761
866
  echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
@@ -765,7 +870,13 @@ export function claudeSummary(ctx: Ctx): void {
765
870
  }
766
871
  }
767
872
 
768
- export function claudeNextSteps(): void {
769
- echo("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
770
- 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
771
882
  }