docks-kit 0.1.0 → 0.1.1

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 +746 -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 +218 -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,746 @@
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
+ const line = "export ENABLE_CLAUDEAI_MCP_SERVERS=false"
336
+ const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
337
+ const candidates = [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"].map((f) => p(ctx.home, f))
338
+
339
+ for (const f of candidates) {
340
+ if (existsSync(f) && readFileSync(f, "utf8").includes("ENABLE_CLAUDEAI_MCP_SERVERS")) {
341
+ if (ctx.dryRun) {
342
+ echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
343
+ } else {
344
+ log(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
345
+ }
346
+ return
347
+ }
348
+ }
349
+
350
+ const shell = process.env["SHELL"] ?? "bash"
351
+ const shellName = shell.slice(shell.lastIndexOf("/") + 1)
352
+ const target = shellName === "zsh" ? p(ctx.home, ".zshrc") : shellName === "bash" ? p(ctx.home, ".bashrc") : p(ctx.home, ".profile")
353
+
354
+ if (ctx.dryRun) {
355
+ echo(`[dry-run] append 'export ENABLE_CLAUDEAI_MCP_SERVERS=false' to ${target}`)
356
+ return
357
+ }
358
+
359
+ appendFileSync(target, `\n${marker}\n${line}\n`)
360
+ log(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
361
+ }
362
+
363
+ // ------------------------------------------------------------- removals ----
364
+
365
+ const REMOVED_MANIFEST = {
366
+ hooks: ["disable-claudeai-connectors.sh"],
367
+ files: ["alert_bubble.mp3"],
368
+ settingsKeys: [
369
+ "showTurnDuration",
370
+ "env.CLAUDE_CODE_SUBAGENT_MODEL",
371
+ "env.ANTHROPIC_DEFAULT_OPUS_MODEL",
372
+ "env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
373
+ "env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
374
+ "env.CLAUDE_CODE_FORK_SUBAGENT",
375
+ "env.CLAUDE_CODE_EFFORT_LEVEL"
376
+ ],
377
+ claudeJsonKeys: [] as Array<string>
378
+ }
379
+
380
+ /** claude::_prune_json_keys — present-count; deletes when !dryRun. */
381
+ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
382
+ if (keys.length === 0 || !existsSync(file)) return 0
383
+ const doc = parseJson(readFileSync(file, "utf8"))
384
+ if (doc === undefined) return 0
385
+
386
+ const getPath = (root: Json, path: Array<string>): Json | undefined => {
387
+ let cur: Json | undefined = root
388
+ for (const seg of path) {
389
+ if (cur === undefined || !isObject(cur)) return undefined
390
+ cur = cur[seg]
391
+ }
392
+ return cur
393
+ }
394
+ const presentKeys = keys.filter((k) => {
395
+ const v = getPath(doc, k.split("."))
396
+ return v !== undefined && v !== null
397
+ })
398
+ if (presentKeys.length === 0) return 0
399
+
400
+ if (!ctx.dryRun) {
401
+ for (const k of presentKeys) {
402
+ const path = k.split(".")
403
+ let cur: Json = doc
404
+ for (const seg of path.slice(0, -1)) {
405
+ if (!isObject(cur)) break
406
+ cur = cur[seg]!
407
+ }
408
+ if (isObject(cur)) delete cur[path[path.length - 1]!]
409
+ }
410
+ writeFileSync(`${file}.tmp`, jqStringify(doc))
411
+ renameSync(`${file}.tmp`, file)
412
+ }
413
+ return presentKeys.length
414
+ }
415
+
416
+ function syncRemovals(ctx: Ctx, claudeDir: string): void {
417
+ let hooksRemoved = 0
418
+ let filesRemoved = 0
419
+
420
+ for (const name of REMOVED_MANIFEST.hooks) {
421
+ const path = p(claudeDir, "hooks", name)
422
+ if (!existsSync(path)) continue
423
+ if (ctx.dryRun) {
424
+ echo(`[dry-run] rm ${path}`)
425
+ } else {
426
+ rmSync(path, { force: true })
427
+ hooksRemoved++
428
+ }
429
+ }
430
+
431
+ for (const rel of REMOVED_MANIFEST.files) {
432
+ const path = p(claudeDir, rel)
433
+ if (!existsSync(path)) continue
434
+ if (ctx.dryRun) {
435
+ echo(`[dry-run] rm ${path}`)
436
+ } else {
437
+ rmSync(path, { force: true })
438
+ filesRemoved++
439
+ }
440
+ }
441
+
442
+ const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), REMOVED_MANIFEST.settingsKeys)
443
+ const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
444
+
445
+ if (ctx.dryRun) {
446
+ if (skeys > 0) echo(`[dry-run] del ${skeys} stale key(s) from ${p(claudeDir, "settings.json")}`)
447
+ if (cjkeys > 0) echo(`[dry-run] del ${cjkeys} stale key(s) from ${p(ctx.home, ".claude.json")}`)
448
+ return
449
+ }
450
+
451
+ if (hooksRemoved + filesRemoved + skeys + cjkeys > 0) {
452
+ log(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys})`)
453
+ }
454
+ }
455
+
456
+ // -------------------------------------------------------------- plugins ----
457
+
458
+ function cli(args: Array<string>): { ok: boolean; out: string } {
459
+ const res = spawnSync("claude", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
460
+ return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}` }
461
+ }
462
+
463
+ function readJsonFile(file: string): Json | undefined {
464
+ return existsSync(file) ? parseJson(readFileSync(file, "utf8")) : undefined
465
+ }
466
+
467
+ function sortedKeys(obj: Json | undefined): Array<string> {
468
+ return obj !== undefined && isObject(obj) ? Object.keys(obj).sort(compareCodepoints) : []
469
+ }
470
+
471
+ /** claude::_plugin_user_scope_installed. */
472
+ function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
473
+ const doc = readJsonFile(installedPlugins)
474
+ if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
475
+ const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
476
+ if (rec === undefined || rec === null) return false
477
+ const records = Array.isArray(rec) ? rec : [rec]
478
+ return records.some((r) => isObject(r) && r["scope"] === "user")
479
+ }
480
+
481
+ function syncPlugins(ctx: Ctx, claudeDir: string): void {
482
+ const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
483
+ const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
484
+ const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
485
+
486
+ if (ctx.dryRun) {
487
+ echo("[dry-run] bootstrap + update plugin marketplaces + plugins from SoT")
488
+ if (ctx.prune) {
489
+ echo("[dry-run] (--prune) would also uninstall plugins not in SoT and remove extra marketplaces")
490
+ }
491
+ return
492
+ }
493
+
494
+ if (!commandExists("claude")) {
495
+ warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
496
+ return
497
+ }
498
+
499
+ const repoSettings = readJsonFile(repoSettingsFile)
500
+ const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
501
+ const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
502
+ const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
503
+
504
+ // Pass 1 — add missing marketplaces (SoT insertion order, like to_entries).
505
+ let addedMp = 0
506
+ let f1 = 0
507
+ for (const [mpName, mpValue] of Object.entries(sotMarketplaces)) {
508
+ const known = readJsonFile(knownMarketplaces)
509
+ if (known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false) continue
510
+ const repo = isObject(mpValue) && isObject(mpValue["source"]) ? String((mpValue["source"] as { [k: string]: Json })["repo"] ?? "") : ""
511
+ if (cli(["plugin", "marketplace", "add", repo]).ok) {
512
+ addedMp++
513
+ } else {
514
+ warn(`Failed to add marketplace: ${mpName} (${repo})`)
515
+ f1++
516
+ }
517
+ }
518
+
519
+ // Pass 2 — install SoT-enabled plugins missing at user scope (jq keys[] sorts).
520
+ let addedPl = 0
521
+ let f2 = 0
522
+ let refreshed = false
523
+ for (const pluginId of sortedKeys(sotPlugins)) {
524
+ if (pluginUserScopeInstalled(installedPlugins, pluginId)) continue
525
+ if (!refreshed) {
526
+ cli(["plugin", "marketplace", "update"])
527
+ refreshed = true
528
+ }
529
+ if (cli(["plugin", "install", pluginId]).ok) {
530
+ addedPl++
531
+ } else {
532
+ warn(`Failed to install plugin: ${pluginId}`)
533
+ f2++
534
+ }
535
+ }
536
+
537
+ // Pass 3 — refresh every installed plugin.
538
+ cli(["plugin", "marketplace", "update"])
539
+ let updatedPl = 0
540
+ const installedDoc = readJsonFile(installedPlugins)
541
+ const installedKeys = installedDoc !== undefined && isObject(installedDoc) ? sortedKeys(installedDoc["plugins"]) : []
542
+ for (const pluginId of installedKeys) {
543
+ if (cli(["plugin", "update", pluginId]).out.includes("Successfully updated")) updatedPl++
544
+ }
545
+
546
+ // Passes 4 + 5 — prune-gated uninstall + marketplace removal.
547
+ let removedPl = 0
548
+ let removedMp = 0
549
+ let f4 = 0
550
+ let f5 = 0
551
+ if (ctx.prune) {
552
+ for (const pluginId of installedKeys) {
553
+ if (isObject(sotPlugins) && Object.prototype.hasOwnProperty.call(sotPlugins, pluginId)) continue
554
+ if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
555
+ if (cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId]).ok) {
556
+ removedPl++
557
+ } else {
558
+ warn(`Failed to uninstall plugin: ${pluginId}`)
559
+ f4++
560
+ }
561
+ }
562
+ const known = readJsonFile(knownMarketplaces)
563
+ for (const mpName of sortedKeys(known)) {
564
+ if (mpName === "claude-plugins-official") continue
565
+ const declared = isObject(sotMarketplaces) ? sotMarketplaces[mpName] : undefined
566
+ if (declared !== undefined && declared !== null && declared !== false) continue
567
+ if (cli(["plugin", "marketplace", "remove", mpName]).ok) {
568
+ removedMp++
569
+ } else {
570
+ warn(`Failed to remove marketplace: ${mpName}`)
571
+ f5++
572
+ }
573
+ }
574
+ }
575
+
576
+ // Pass 6 — re-assert SoT enabled-state in the user settings.
577
+ reassertEnabledState(repoObj, p(claudeDir, "settings.json"))
578
+
579
+ const failed = f1 + f2 + f4 + f5
580
+ if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
581
+ log(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
582
+ } else {
583
+ log("Plugins already in sync")
584
+ }
585
+ if (failed > 0) {
586
+ warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
587
+ }
588
+ }
589
+
590
+ function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile: string): void {
591
+ if (!existsSync(userSettingsFile)) return
592
+ const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
593
+
594
+ for (const [pluginId, value] of Object.entries(sotPlugins)) {
595
+ if (value !== false) continue
596
+ const user = readJsonFile(userSettingsFile)
597
+ const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
598
+ if (enabled !== true) continue
599
+ if (!cli(["plugin", "disable", pluginId]).ok) {
600
+ warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
601
+ }
602
+ }
603
+
604
+ const user = readJsonFile(userSettingsFile)
605
+ if (user === undefined || !isObject(user)) {
606
+ warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
607
+ return
608
+ }
609
+ user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
610
+ writeFileSync(`${userSettingsFile}.tmp`, jqStringify(user))
611
+ renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
612
+ }
613
+
614
+ // ------------------------------------------------------ optional plugins ----
615
+
616
+ function enableOptionalPlugin(claudeDir: string, pluginId: string, marketplaceRepo: string): void {
617
+ const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
618
+ const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
619
+ const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
620
+
621
+ if (marketplaceRepo !== "") {
622
+ const known = readJsonFile(knownMarketplaces)
623
+ const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
624
+ if (!has && !cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
625
+ warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
626
+ return
627
+ }
628
+ }
629
+
630
+ if (!pluginUserScopeInstalled(installedPlugins, pluginId)) {
631
+ if (!cli(["plugin", "install", pluginId]).ok) {
632
+ warn(`Failed to install optional plugin ${pluginId}`)
633
+ return
634
+ }
635
+ }
636
+
637
+ if (cli(["plugin", "enable", pluginId]).ok) {
638
+ log(`Optional plugin opted in: ${pluginId}`)
639
+ } else {
640
+ warn(`Failed to enable optional plugin ${pluginId}`)
641
+ }
642
+ }
643
+
644
+ function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
645
+ if (ctx.claudePlugins.length === 0) return
646
+
647
+ if (ctx.dryRun) {
648
+ if (ctx.claudePlugins.includes("supabase")) {
649
+ echo("[dry-run] (--claude-plugin=supabase) install + enable supabase@claude-plugins-official in deployed settings")
650
+ }
651
+ if (ctx.claudePlugins.includes("n8n")) {
652
+ echo("[dry-run] (--claude-plugin=n8n) add czlonkowski/n8n-skills marketplace + install + enable n8n-mcp-skills@n8n-mcp-skills")
653
+ }
654
+ return
655
+ }
656
+
657
+ if (!commandExists("claude")) {
658
+ warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
659
+ return
660
+ }
661
+
662
+ if (ctx.claudePlugins.includes("supabase")) {
663
+ enableOptionalPlugin(claudeDir, "supabase@claude-plugins-official", "")
664
+ }
665
+ if (ctx.claudePlugins.includes("n8n")) {
666
+ enableOptionalPlugin(claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")
667
+ }
668
+ }
669
+
670
+ // ---------------------------------------------------------- LSP servers ----
671
+
672
+ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
673
+ const v = field(ctx, tool, "verified")
674
+ return v !== "" ? `${pkg}@${v}` : pkg
675
+ }
676
+
677
+ function syncLspServers(ctx: Ctx): void {
678
+ const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
679
+ const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
680
+ if (enabled === undefined) return
681
+ const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
682
+ const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
683
+ if (!hasPhp && !hasTs) return
684
+
685
+ const missing: Array<string> = []
686
+ if (hasPhp && !commandExists("intelephense")) missing.push(lspPkg(ctx, "intelephense", "intelephense"))
687
+ if (hasTs) {
688
+ if (!commandExists("typescript-language-server")) missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
689
+ if (!commandExists("tsc")) missing.push(lspPkg(ctx, "tsc", "typescript"))
690
+ }
691
+
692
+ if (missing.length === 0) {
693
+ if (ctx.dryRun) {
694
+ echo("[dry-run] LSP server binaries present")
695
+ } else {
696
+ log("LSP server binaries present")
697
+ }
698
+ return
699
+ }
700
+
701
+ const specs = missing.join(" ")
702
+ if (ctx.dryRun) {
703
+ echo(`[dry-run] would install: npm install -g ${specs}`)
704
+ return
705
+ }
706
+
707
+ if (!commandExists("npm")) {
708
+ 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.`)
709
+ return
710
+ }
711
+
712
+ log(`Installing LSP servers via npm: ${specs}...`)
713
+ if (spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" }).status === 0) {
714
+ log(`LSP servers installed (${specs})`)
715
+ } else {
716
+ warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
717
+ }
718
+ }
719
+
720
+ // -------------------------------------------------------------- summary ----
721
+
722
+ export function claudeSummary(ctx: Ctx): void {
723
+ const claudeDir = p(ctx.home, ".claude")
724
+ echo(`Claude: ${claudeDir}`)
725
+ if (!ctx.dryRun) {
726
+ echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
727
+ if (commandExists("rtk")) {
728
+ const v = capture("rtk", ["--version"])
729
+ echo(`RTK: ${v !== "" ? v : "installed"}`)
730
+ } else {
731
+ echo("RTK: not installed")
732
+ }
733
+ if (commandExists("claude")) {
734
+ const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
735
+ const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
736
+ echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
737
+ } else {
738
+ echo("Plugins: skipped - claude CLI not installed")
739
+ }
740
+ }
741
+ }
742
+
743
+ export function claudeNextSteps(): void {
744
+ echo("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
745
+ echo("Restart Claude Code for hook/env-var changes to take effect.")
746
+ }