docks-kit 0.1.0 → 0.1.2

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 (39) hide show
  1. package/AGENTS.md +14 -15
  2. package/README.md +12 -11
  3. package/SoT/.agents/skills.txt +3 -3
  4. package/SoT/.claude/settings.json +3 -26
  5. package/SoT/models.json +1 -1
  6. package/SoT/toolchain.json +3 -3
  7. package/cli/docs/install.md +39 -12
  8. package/cli/docs/overview.md +3 -4
  9. package/cli/docs/platforms.md +35 -22
  10. package/cli/src/commands/sync.ts +32 -4
  11. package/cli/src/commands/update.ts +116 -0
  12. package/cli/src/engine-native/DESIGN.md +89 -0
  13. package/cli/src/engine-native/claudeModel.ts +48 -0
  14. package/cli/src/engine-native/claudeSync.ts +771 -0
  15. package/cli/src/engine-native/codexSync.ts +439 -0
  16. package/cli/src/engine-native/codexToml.ts +67 -0
  17. package/cli/src/engine-native/exec.ts +54 -0
  18. package/cli/src/engine-native/index.ts +109 -0
  19. package/cli/src/engine-native/jq.ts +63 -0
  20. package/cli/src/engine-native/models.ts +73 -0
  21. package/cli/src/engine-native/modes.ts +133 -0
  22. package/cli/src/engine-native/output.ts +20 -0
  23. package/cli/src/engine-native/parseArgs.ts +223 -0
  24. package/cli/src/engine-native/settings.ts +41 -0
  25. package/cli/src/engine-native/skillsSync.ts +369 -0
  26. package/cli/src/engine-native/toolchain.ts +257 -0
  27. package/cli/src/engine.ts +35 -18
  28. package/cli/src/kitHome.ts +4 -4
  29. package/cli/src/main.ts +14 -1
  30. package/cli/src/manifests.ts +3 -2
  31. package/cli/tsconfig.json +1 -1
  32. package/docks-kit +6 -3
  33. package/package.json +8 -4
  34. package/lib/claude.sh +0 -846
  35. package/lib/codex.sh +0 -518
  36. package/lib/common.sh +0 -229
  37. package/lib/engine.sh +0 -153
  38. package/lib/skills.sh +0 -373
  39. package/lib/toolchain.sh +0 -222
@@ -0,0 +1,771 @@
1
+ /**
2
+ * EngineNative `sync claude` pipeline. Step order is load-bearing: rtk BEFORE
3
+ * the settings merge, modifiers after it, removals before plugins. Message
4
+ * strings, guard order, JSON semantics, and spawned argv are golden-tested.
5
+ */
6
+ import { spawnSync } from "node:child_process"
7
+ import {
8
+ appendFileSync,
9
+ chmodSync,
10
+ copyFileSync,
11
+ cpSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ renameSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync
20
+ } from "node:fs"
21
+ import { tmpdir } from "node:os"
22
+ import { syncClaudeModel } from "./claudeModel"
23
+ import { capture, commandExists, p } from "./exec"
24
+ import type { Ctx } from "./index"
25
+ import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
26
+ import { echo, err, log, warn } from "./output"
27
+ import { ExitError } from "./parseArgs"
28
+ import { mergeSettings, reconcileSettings } from "./settings"
29
+ import { ensure, field } from "./toolchain"
30
+
31
+ export function claudeSync(ctx: Ctx): void {
32
+ const claudeDir = p(ctx.home, ".claude")
33
+
34
+ if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
35
+
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"
41
+ 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`
43
+ )
44
+ }
45
+
46
+ syncRtk(ctx, claudeDir)
47
+ syncScripts(ctx, claudeDir)
48
+ syncHooks(ctx, claudeDir)
49
+ syncClaudeMd(ctx, claudeDir)
50
+ syncSettings(ctx, claudeDir)
51
+ syncCompactWindow(ctx, claudeDir)
52
+ syncPermissive(ctx, claudeDir)
53
+ syncClaudeModel(ctx, ctx.claudeModel)
54
+ syncClaudeJson(ctx)
55
+ syncConnectorEnv(ctx)
56
+ syncRemovals(ctx, claudeDir)
57
+ syncPlugins(ctx, claudeDir)
58
+ syncOptionalPlugins(ctx, claudeDir)
59
+ syncLspServers(ctx)
60
+ }
61
+
62
+ // ------------------------------------------------------------------ rtk ----
63
+
64
+ /** RTK toolchain install callback. */
65
+ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
66
+ return (mode, version) => {
67
+ const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
68
+
69
+ if (mode === "upgrade") log(`Upgrading RTK${version !== "" ? ` to ${version}` : ""}...`)
70
+ else warn(`RTK not found. Installing${version !== "" ? ` ${version}` : ""}...`)
71
+ const installer = p(tmpdir(), `rtk-install-${process.pid}.sh`)
72
+ const dl = spawnSync("curl", ["-fsSL", `https://raw.githubusercontent.com/rtk-ai/rtk/${installerRef}/install.sh`, "-o", installer], {
73
+ stdio: "inherit"
74
+ })
75
+ if (dl.error === undefined && dl.status === 0) {
76
+ spawnSync("bash", [installer], {
77
+ stdio: "inherit",
78
+ env: { ...process.env, RTK_VERSION: version !== "" ? `v${version}` : "" }
79
+ })
80
+ }
81
+ rmSync(installer, { force: true })
82
+ 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"})`)
86
+ return 0
87
+ }
88
+ err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
89
+ return 1
90
+ }
91
+ }
92
+
93
+ function syncRtk(ctx: Ctx, claudeDir: string): void {
94
+ if (ctx.skipRtk) {
95
+ warn("Skipping RTK (--skip-rtk)")
96
+ return
97
+ }
98
+
99
+ if (process.platform === "win32") {
100
+ if (!commandExists("rtk")) {
101
+ 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
+ return
103
+ }
104
+ } else if (ensure(ctx, "rtk", rtkInstall(ctx)) !== 0) {
105
+ warn("RTK bootstrap failed — continuing sync without it")
106
+ }
107
+
108
+ if (!commandExists("rtk")) return
109
+ if (!existsSync(p(claudeDir, "RTK.md"))) {
110
+ if (ctx.dryRun) {
111
+ echo("[dry-run] rtk init --global (RTK.md missing; runs before the settings merge, which normalizes rtk's settings rewrite)")
112
+ return
113
+ }
114
+ // Plain command under bash set -e: a nonzero `rtk init` aborts the whole
115
+ // sync before the success log and before any settings/plugin mutation.
116
+ const res = spawnSync("rtk", ["init", "--global"], { stdio: "inherit" })
117
+ 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)")
119
+ } else if (!ctx.dryRun) {
120
+ log("RTK already initialized")
121
+ }
122
+ }
123
+
124
+ // ------------------------------------------------------ scripts + hooks ----
125
+
126
+ function syncScripts(ctx: Ctx, claudeDir: string): void {
127
+ if (ctx.dryRun) {
128
+ echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
129
+ return
130
+ }
131
+
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)")
142
+ }
143
+
144
+ function shellScriptCount(hooksDir: string): number {
145
+ try {
146
+ return readdirSync(hooksDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".sh")).length
147
+ } catch {
148
+ return 0
149
+ }
150
+ }
151
+
152
+ function syncHooks(ctx: Ctx, claudeDir: string): void {
153
+ const sotHooks = p(ctx.repoDir, "SoT", ".claude", "hooks")
154
+ if (!existsSync(sotHooks)) return
155
+
156
+ if (ctx.dryRun) {
157
+ echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
158
+ return
159
+ }
160
+
161
+ const hooksDir = p(claudeDir, "hooks")
162
+ mkdirSync(hooksDir, { recursive: true })
163
+ cpSync(sotHooks, hooksDir, { recursive: true })
164
+ for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
165
+ if (e.isFile() && e.name.endsWith(".sh")) {
166
+ chmodSync(p(hooksDir, e.name), statSync(p(hooksDir, e.name)).mode | 0o111)
167
+ }
168
+ }
169
+ log(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
170
+ }
171
+
172
+ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
173
+ if (ctx.dryRun) {
174
+ if (ctx.skipRtk) {
175
+ echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (stripping @RTK.md import: --skip-rtk)")
176
+ } else {
177
+ echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md")
178
+ }
179
+ return
180
+ }
181
+
182
+ const src = p(ctx.repoDir, "SoT", ".claude", "CLAUDE.md")
183
+ if (ctx.skipRtk) {
184
+ const stripped = readFileSync(src, "utf8")
185
+ .split("\n")
186
+ .filter((l) => l !== "@RTK.md")
187
+ .join("\n")
188
+ writeFileSync(p(claudeDir, "CLAUDE.md"), stripped)
189
+ log("CLAUDE.md synced (@RTK.md import stripped: --skip-rtk)")
190
+ } else {
191
+ copyFileSync(src, p(claudeDir, "CLAUDE.md"))
192
+ log("CLAUDE.md synced")
193
+ }
194
+ }
195
+
196
+ // ------------------------------------------------------------- settings ----
197
+
198
+ function syncSettings(ctx: Ctx, claudeDir: string): void {
199
+ const repoSettings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
200
+ const userSettings = p(claudeDir, "settings.json")
201
+
202
+ if (ctx.dryRun) {
203
+ if (!existsSync(userSettings)) {
204
+ echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
205
+ } else if (ctx.reconcile) {
206
+ echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
207
+ } else {
208
+ echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
209
+ }
210
+ return
211
+ }
212
+
213
+ if (!existsSync(userSettings)) {
214
+ copyFileSync(repoSettings, userSettings)
215
+ log("Settings installed")
216
+ return
217
+ }
218
+
219
+ const user = parseJson(readFileSync(userSettings, "utf8"))
220
+ if (user === undefined) {
221
+ // claude::_settings_validate returns 1 → claude::sync aborts under set -e.
222
+ err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
223
+ throw new ExitError(1)
224
+ }
225
+ const repo = parseJson(readFileSync(repoSettings, "utf8"))!
226
+
227
+ copyFileSync(userSettings, `${userSettings}.bak`)
228
+ const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
229
+ writeFileSync(`${userSettings}.tmp`, jqStringify(merged))
230
+ renameSync(`${userSettings}.tmp`, userSettings)
231
+ if (ctx.reconcile) {
232
+ log("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
233
+ } else {
234
+ log("Settings merged (backup at settings.json.bak)")
235
+ }
236
+ }
237
+
238
+ /** Shared shape of the three jq-edit modifiers (compact window, permissive). */
239
+ function jqEditSettings(claudeDir: string, tag: string, edit: (doc: Json) => void): void {
240
+ const userSettings = p(claudeDir, "settings.json")
241
+ if (!existsSync(userSettings)) {
242
+ warn(`(${tag}) ${userSettings} missing — skipped`)
243
+ return
244
+ }
245
+ const doc = parseJson(readFileSync(userSettings, "utf8"))
246
+ if (doc === undefined) {
247
+ err(`(${tag}) ${userSettings} is not valid JSON — skipped`)
248
+ return
249
+ }
250
+ edit(doc)
251
+ writeFileSync(`${userSettings}.tmp`, jqStringify(doc))
252
+ renameSync(`${userSettings}.tmp`, userSettings)
253
+ }
254
+
255
+ function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
256
+ if (ctx.claudeCompactWindow === "") return
257
+
258
+ if (ctx.dryRun) {
259
+ echo(`[dry-run] (--claude-compact-window) set env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=${ctx.claudeCompactWindow} in ${p(claudeDir, "settings.json")}`)
260
+ return
261
+ }
262
+
263
+ jqEditSettings(claudeDir, "--claude-compact-window", (doc) => {
264
+ if (!isObject(doc)) return
265
+ const env = isObject(doc["env"]) ? doc["env"] : {}
266
+ env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = ctx.claudeCompactWindow
267
+ doc["env"] = env
268
+ })
269
+ log(`Compact window: set to ${ctx.claudeCompactWindow} tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)`)
270
+ }
271
+
272
+ function syncPermissive(ctx: Ctx, claudeDir: string): void {
273
+ if (!ctx.claudePermissive) return
274
+
275
+ if (ctx.dryRun) {
276
+ echo(`[dry-run] (--claude-permissive) empty permissions.ask and permissions.deny in ${p(claudeDir, "settings.json")}`)
277
+ return
278
+ }
279
+
280
+ jqEditSettings(claudeDir, "--claude-permissive", (doc) => {
281
+ if (!isObject(doc)) return
282
+ const permissions = isObject(doc["permissions"]) ? doc["permissions"] : {}
283
+ permissions["ask"] = []
284
+ permissions["deny"] = []
285
+ doc["permissions"] = permissions
286
+ })
287
+ log("Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)")
288
+ }
289
+
290
+ // ---------------------------------------------------------- claude.json ----
291
+
292
+ function syncClaudeJson(ctx: Ctx): void {
293
+ const claudeJson = p(ctx.home, ".claude.json")
294
+ const mcpSot = p(ctx.repoDir, "SoT", ".claude", "mcp-servers.json")
295
+
296
+ let mcp: Json | undefined
297
+ if (existsSync(mcpSot)) mcp = parseJson(readFileSync(mcpSot, "utf8"))
298
+ const haveMcp = mcp !== undefined
299
+
300
+ if (ctx.dryRun) {
301
+ echo("[dry-run] set showTurnDuration=true in ~/.claude.json")
302
+ if (haveMcp) echo("[dry-run] merge mcpServers from SoT/.claude/mcp-servers.json into ~/.claude.json")
303
+ return
304
+ }
305
+
306
+ const applyFilter = (doc: { [k: string]: Json }): void => {
307
+ doc["showTurnDuration"] = true
308
+ if (haveMcp) {
309
+ const sotServers = isObject(mcp!) ? mcp!["mcpServers"] ?? {} : {}
310
+ doc["mcpServers"] = deepMerge(isObject(doc["mcpServers"]) ? doc["mcpServers"] : {}, sotServers)
311
+ }
312
+ }
313
+
314
+ if (existsSync(claudeJson)) {
315
+ const doc = parseJson(readFileSync(claudeJson, "utf8"))
316
+ if (doc === undefined) {
317
+ err("Skipping ~/.claude.json edit: not valid JSON. Fix or delete it.")
318
+ return
319
+ }
320
+ const obj = isObject(doc) ? doc : {}
321
+ applyFilter(obj)
322
+ writeFileSync(`${claudeJson}.tmp`, jqStringify(obj))
323
+ renameSync(`${claudeJson}.tmp`, claudeJson)
324
+ } else {
325
+ const obj: { [k: string]: Json } = {}
326
+ applyFilter(obj)
327
+ writeFileSync(claudeJson, jqStringify(obj))
328
+ }
329
+ log(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
330
+ }
331
+
332
+ // -------------------------------------------------------- connector env ----
333
+
334
+ function syncConnectorEnv(ctx: Ctx): void {
335
+ // win32: Claude Code launches from PowerShell/GUI, so the flag must be a
336
+ // real user env var (setx), not a Git-Bash-only shell-rc export. Never
337
+ // clobbers an existing value (set =true yourself to keep connectors).
338
+ if (process.platform === "win32") {
339
+ const existing = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "ENABLE_CLAUDEAI_MCP_SERVERS"], {
340
+ stdio: "ignore"
341
+ })
342
+ if (existing.error === undefined && existing.status === 0) {
343
+ 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)")
345
+ return
346
+ }
347
+ if (ctx.dryRun) {
348
+ echo("[dry-run] setx ENABLE_CLAUDEAI_MCP_SERVERS false (user environment)")
349
+ return
350
+ }
351
+ const res = spawnSync("setx", ["ENABLE_CLAUDEAI_MCP_SERVERS", "false"], { stdio: "ignore" })
352
+ if (res.error === undefined && res.status === 0) {
353
+ log("claude.ai connectors disabled via setx (open a new terminal to apply)")
354
+ } else {
355
+ warn("setx ENABLE_CLAUDEAI_MCP_SERVERS false failed — set it manually in System Properties > Environment Variables")
356
+ }
357
+ return
358
+ }
359
+
360
+ const line = "export ENABLE_CLAUDEAI_MCP_SERVERS=false"
361
+ const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
362
+ const candidates = [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"].map((f) => p(ctx.home, f))
363
+
364
+ for (const f of candidates) {
365
+ if (existsSync(f) && readFileSync(f, "utf8").includes("ENABLE_CLAUDEAI_MCP_SERVERS")) {
366
+ if (ctx.dryRun) {
367
+ echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
368
+ } else {
369
+ log(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
370
+ }
371
+ return
372
+ }
373
+ }
374
+
375
+ const shell = process.env["SHELL"] ?? "bash"
376
+ const shellName = shell.slice(shell.lastIndexOf("/") + 1)
377
+ const target = shellName === "zsh" ? p(ctx.home, ".zshrc") : shellName === "bash" ? p(ctx.home, ".bashrc") : p(ctx.home, ".profile")
378
+
379
+ if (ctx.dryRun) {
380
+ echo(`[dry-run] append 'export ENABLE_CLAUDEAI_MCP_SERVERS=false' to ${target}`)
381
+ return
382
+ }
383
+
384
+ appendFileSync(target, `\n${marker}\n${line}\n`)
385
+ log(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
386
+ }
387
+
388
+ // ------------------------------------------------------------- removals ----
389
+
390
+ const REMOVED_MANIFEST = {
391
+ hooks: ["disable-claudeai-connectors.sh"],
392
+ files: ["alert_bubble.mp3"],
393
+ settingsKeys: [
394
+ "showTurnDuration",
395
+ "env.CLAUDE_CODE_SUBAGENT_MODEL",
396
+ "env.ANTHROPIC_DEFAULT_OPUS_MODEL",
397
+ "env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
398
+ "env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
399
+ "env.CLAUDE_CODE_FORK_SUBAGENT",
400
+ "env.CLAUDE_CODE_EFFORT_LEVEL"
401
+ ],
402
+ claudeJsonKeys: [] as Array<string>
403
+ }
404
+
405
+ /** claude::_prune_json_keys — present-count; deletes when !dryRun. */
406
+ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
407
+ if (keys.length === 0 || !existsSync(file)) return 0
408
+ const doc = parseJson(readFileSync(file, "utf8"))
409
+ if (doc === undefined) return 0
410
+
411
+ const getPath = (root: Json, path: Array<string>): Json | undefined => {
412
+ let cur: Json | undefined = root
413
+ for (const seg of path) {
414
+ if (cur === undefined || !isObject(cur)) return undefined
415
+ cur = cur[seg]
416
+ }
417
+ return cur
418
+ }
419
+ const presentKeys = keys.filter((k) => {
420
+ const v = getPath(doc, k.split("."))
421
+ return v !== undefined && v !== null
422
+ })
423
+ if (presentKeys.length === 0) return 0
424
+
425
+ if (!ctx.dryRun) {
426
+ for (const k of presentKeys) {
427
+ const path = k.split(".")
428
+ let cur: Json = doc
429
+ for (const seg of path.slice(0, -1)) {
430
+ if (!isObject(cur)) break
431
+ cur = cur[seg]!
432
+ }
433
+ if (isObject(cur)) delete cur[path[path.length - 1]!]
434
+ }
435
+ writeFileSync(`${file}.tmp`, jqStringify(doc))
436
+ renameSync(`${file}.tmp`, file)
437
+ }
438
+ return presentKeys.length
439
+ }
440
+
441
+ function syncRemovals(ctx: Ctx, claudeDir: string): void {
442
+ let hooksRemoved = 0
443
+ let filesRemoved = 0
444
+
445
+ for (const name of REMOVED_MANIFEST.hooks) {
446
+ const path = p(claudeDir, "hooks", name)
447
+ if (!existsSync(path)) continue
448
+ if (ctx.dryRun) {
449
+ echo(`[dry-run] rm ${path}`)
450
+ } else {
451
+ rmSync(path, { force: true })
452
+ hooksRemoved++
453
+ }
454
+ }
455
+
456
+ for (const rel of REMOVED_MANIFEST.files) {
457
+ const path = p(claudeDir, rel)
458
+ if (!existsSync(path)) continue
459
+ if (ctx.dryRun) {
460
+ echo(`[dry-run] rm ${path}`)
461
+ } else {
462
+ rmSync(path, { force: true })
463
+ filesRemoved++
464
+ }
465
+ }
466
+
467
+ const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), REMOVED_MANIFEST.settingsKeys)
468
+ const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
469
+
470
+ if (ctx.dryRun) {
471
+ if (skeys > 0) echo(`[dry-run] del ${skeys} stale key(s) from ${p(claudeDir, "settings.json")}`)
472
+ if (cjkeys > 0) echo(`[dry-run] del ${cjkeys} stale key(s) from ${p(ctx.home, ".claude.json")}`)
473
+ return
474
+ }
475
+
476
+ if (hooksRemoved + filesRemoved + skeys + cjkeys > 0) {
477
+ log(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys})`)
478
+ }
479
+ }
480
+
481
+ // -------------------------------------------------------------- plugins ----
482
+
483
+ function cli(args: Array<string>): { ok: boolean; out: string } {
484
+ const res = spawnSync("claude", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
485
+ return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}` }
486
+ }
487
+
488
+ function readJsonFile(file: string): Json | undefined {
489
+ return existsSync(file) ? parseJson(readFileSync(file, "utf8")) : undefined
490
+ }
491
+
492
+ function sortedKeys(obj: Json | undefined): Array<string> {
493
+ return obj !== undefined && isObject(obj) ? Object.keys(obj).sort(compareCodepoints) : []
494
+ }
495
+
496
+ /** claude::_plugin_user_scope_installed. */
497
+ function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
498
+ const doc = readJsonFile(installedPlugins)
499
+ if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
500
+ const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
501
+ if (rec === undefined || rec === null) return false
502
+ const records = Array.isArray(rec) ? rec : [rec]
503
+ return records.some((r) => isObject(r) && r["scope"] === "user")
504
+ }
505
+
506
+ function syncPlugins(ctx: Ctx, claudeDir: string): void {
507
+ const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
508
+ const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
509
+ const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
510
+
511
+ if (ctx.dryRun) {
512
+ echo("[dry-run] bootstrap + update plugin marketplaces + plugins from SoT")
513
+ if (ctx.prune) {
514
+ echo("[dry-run] (--prune) would also uninstall plugins not in SoT and remove extra marketplaces")
515
+ }
516
+ return
517
+ }
518
+
519
+ if (!commandExists("claude")) {
520
+ warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
521
+ return
522
+ }
523
+
524
+ const repoSettings = readJsonFile(repoSettingsFile)
525
+ const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
526
+ const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
527
+ const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
528
+
529
+ // Pass 1 — add missing marketplaces (SoT insertion order, like to_entries).
530
+ let addedMp = 0
531
+ let f1 = 0
532
+ for (const [mpName, mpValue] of Object.entries(sotMarketplaces)) {
533
+ const known = readJsonFile(knownMarketplaces)
534
+ if (known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false) continue
535
+ const repo = isObject(mpValue) && isObject(mpValue["source"]) ? String((mpValue["source"] as { [k: string]: Json })["repo"] ?? "") : ""
536
+ if (cli(["plugin", "marketplace", "add", repo]).ok) {
537
+ addedMp++
538
+ } else {
539
+ warn(`Failed to add marketplace: ${mpName} (${repo})`)
540
+ f1++
541
+ }
542
+ }
543
+
544
+ // Pass 2 — install SoT-enabled plugins missing at user scope (jq keys[] sorts).
545
+ let addedPl = 0
546
+ let f2 = 0
547
+ let refreshed = false
548
+ for (const pluginId of sortedKeys(sotPlugins)) {
549
+ if (pluginUserScopeInstalled(installedPlugins, pluginId)) continue
550
+ if (!refreshed) {
551
+ cli(["plugin", "marketplace", "update"])
552
+ refreshed = true
553
+ }
554
+ if (cli(["plugin", "install", pluginId]).ok) {
555
+ addedPl++
556
+ } else {
557
+ warn(`Failed to install plugin: ${pluginId}`)
558
+ f2++
559
+ }
560
+ }
561
+
562
+ // Pass 3 — refresh every installed plugin.
563
+ cli(["plugin", "marketplace", "update"])
564
+ let updatedPl = 0
565
+ const installedDoc = readJsonFile(installedPlugins)
566
+ const installedKeys = installedDoc !== undefined && isObject(installedDoc) ? sortedKeys(installedDoc["plugins"]) : []
567
+ for (const pluginId of installedKeys) {
568
+ if (cli(["plugin", "update", pluginId]).out.includes("Successfully updated")) updatedPl++
569
+ }
570
+
571
+ // Passes 4 + 5 — prune-gated uninstall + marketplace removal.
572
+ let removedPl = 0
573
+ let removedMp = 0
574
+ let f4 = 0
575
+ let f5 = 0
576
+ if (ctx.prune) {
577
+ for (const pluginId of installedKeys) {
578
+ if (isObject(sotPlugins) && Object.prototype.hasOwnProperty.call(sotPlugins, pluginId)) continue
579
+ if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
580
+ if (cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId]).ok) {
581
+ removedPl++
582
+ } else {
583
+ warn(`Failed to uninstall plugin: ${pluginId}`)
584
+ f4++
585
+ }
586
+ }
587
+ const known = readJsonFile(knownMarketplaces)
588
+ for (const mpName of sortedKeys(known)) {
589
+ if (mpName === "claude-plugins-official") continue
590
+ const declared = isObject(sotMarketplaces) ? sotMarketplaces[mpName] : undefined
591
+ if (declared !== undefined && declared !== null && declared !== false) continue
592
+ if (cli(["plugin", "marketplace", "remove", mpName]).ok) {
593
+ removedMp++
594
+ } else {
595
+ warn(`Failed to remove marketplace: ${mpName}`)
596
+ f5++
597
+ }
598
+ }
599
+ }
600
+
601
+ // Pass 6 — re-assert SoT enabled-state in the user settings.
602
+ reassertEnabledState(repoObj, p(claudeDir, "settings.json"))
603
+
604
+ const failed = f1 + f2 + f4 + f5
605
+ if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
606
+ log(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
607
+ } else {
608
+ log("Plugins already in sync")
609
+ }
610
+ if (failed > 0) {
611
+ warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
612
+ }
613
+ }
614
+
615
+ function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile: string): void {
616
+ if (!existsSync(userSettingsFile)) return
617
+ const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
618
+
619
+ for (const [pluginId, value] of Object.entries(sotPlugins)) {
620
+ if (value !== false) continue
621
+ const user = readJsonFile(userSettingsFile)
622
+ const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
623
+ if (enabled !== true) continue
624
+ if (!cli(["plugin", "disable", pluginId]).ok) {
625
+ warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
626
+ }
627
+ }
628
+
629
+ const user = readJsonFile(userSettingsFile)
630
+ if (user === undefined || !isObject(user)) {
631
+ warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
632
+ return
633
+ }
634
+ user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
635
+ writeFileSync(`${userSettingsFile}.tmp`, jqStringify(user))
636
+ renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
637
+ }
638
+
639
+ // ------------------------------------------------------ optional plugins ----
640
+
641
+ function enableOptionalPlugin(claudeDir: string, pluginId: string, marketplaceRepo: string): void {
642
+ const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
643
+ const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
644
+ const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
645
+
646
+ if (marketplaceRepo !== "") {
647
+ const known = readJsonFile(knownMarketplaces)
648
+ 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
652
+ }
653
+ }
654
+
655
+ if (!pluginUserScopeInstalled(installedPlugins, pluginId)) {
656
+ if (!cli(["plugin", "install", pluginId]).ok) {
657
+ warn(`Failed to install optional plugin ${pluginId}`)
658
+ return
659
+ }
660
+ }
661
+
662
+ if (cli(["plugin", "enable", pluginId]).ok) {
663
+ log(`Optional plugin opted in: ${pluginId}`)
664
+ } else {
665
+ warn(`Failed to enable optional plugin ${pluginId}`)
666
+ }
667
+ }
668
+
669
+ function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
670
+ if (ctx.claudePlugins.length === 0) return
671
+
672
+ if (ctx.dryRun) {
673
+ if (ctx.claudePlugins.includes("supabase")) {
674
+ echo("[dry-run] (--claude-plugin=supabase) install + enable supabase@claude-plugins-official in deployed settings")
675
+ }
676
+ if (ctx.claudePlugins.includes("n8n")) {
677
+ echo("[dry-run] (--claude-plugin=n8n) add czlonkowski/n8n-skills marketplace + install + enable n8n-mcp-skills@n8n-mcp-skills")
678
+ }
679
+ return
680
+ }
681
+
682
+ if (!commandExists("claude")) {
683
+ warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
684
+ return
685
+ }
686
+
687
+ if (ctx.claudePlugins.includes("supabase")) {
688
+ enableOptionalPlugin(claudeDir, "supabase@claude-plugins-official", "")
689
+ }
690
+ if (ctx.claudePlugins.includes("n8n")) {
691
+ enableOptionalPlugin(claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")
692
+ }
693
+ }
694
+
695
+ // ---------------------------------------------------------- LSP servers ----
696
+
697
+ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
698
+ const v = field(ctx, tool, "verified")
699
+ return v !== "" ? `${pkg}@${v}` : pkg
700
+ }
701
+
702
+ function syncLspServers(ctx: Ctx): void {
703
+ const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
704
+ const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
705
+ if (enabled === undefined) return
706
+ const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
707
+ const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
708
+ if (!hasPhp && !hasTs) return
709
+
710
+ const missing: Array<string> = []
711
+ if (hasPhp && !commandExists("intelephense")) missing.push(lspPkg(ctx, "intelephense", "intelephense"))
712
+ 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"))
715
+ }
716
+
717
+ if (missing.length === 0) {
718
+ if (ctx.dryRun) {
719
+ echo("[dry-run] LSP server binaries present")
720
+ } else {
721
+ log("LSP server binaries present")
722
+ }
723
+ return
724
+ }
725
+
726
+ const specs = missing.join(" ")
727
+ if (ctx.dryRun) {
728
+ echo(`[dry-run] would install: npm install -g ${specs}`)
729
+ return
730
+ }
731
+
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.`)
734
+ return
735
+ }
736
+
737
+ log(`Installing LSP servers via npm: ${specs}...`)
738
+ if (spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" }).status === 0) {
739
+ log(`LSP servers installed (${specs})`)
740
+ } else {
741
+ warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
742
+ }
743
+ }
744
+
745
+ // -------------------------------------------------------------- summary ----
746
+
747
+ export function claudeSummary(ctx: Ctx): void {
748
+ const claudeDir = p(ctx.home, ".claude")
749
+ echo(`Claude: ${claudeDir}`)
750
+ if (!ctx.dryRun) {
751
+ echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
752
+ if (commandExists("rtk")) {
753
+ const v = capture("rtk", ["--version"])
754
+ echo(`RTK: ${v !== "" ? v : "installed"}`)
755
+ } else {
756
+ echo("RTK: not installed")
757
+ }
758
+ if (commandExists("claude")) {
759
+ const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
760
+ const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
761
+ echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
762
+ } else {
763
+ echo("Plugins: skipped - claude CLI not installed")
764
+ }
765
+ }
766
+ }
767
+
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.")
771
+ }