docks-kit 0.17.1 → 0.17.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 (83) hide show
  1. package/AGENTS.md +36 -32
  2. package/cli/docs/omp-models.md +88 -53
  3. package/cli/src/argv.ts +38 -469
  4. package/cli/src/argvSurface.ts +371 -0
  5. package/cli/src/argvValidate.ts +122 -0
  6. package/cli/src/commands/docs.ts +57 -42
  7. package/cli/src/commands/harnesses.ts +38 -45
  8. package/cli/src/commands/model.ts +48 -53
  9. package/cli/src/commands/models.ts +24 -26
  10. package/cli/src/commands/omp.ts +132 -112
  11. package/cli/src/commands/plugins.ts +20 -20
  12. package/cli/src/commands/skills.ts +22 -20
  13. package/cli/src/commands/status.ts +87 -80
  14. package/cli/src/commands/sync.ts +113 -87
  15. package/cli/src/commands/toolchain.ts +21 -21
  16. package/cli/src/commands/update.ts +139 -117
  17. package/cli/src/efforts.ts +35 -34
  18. package/cli/src/engine-native/DESIGN.md +30 -30
  19. package/cli/src/engine-native/bun.ts +77 -55
  20. package/cli/src/engine-native/claudeLsp.ts +154 -0
  21. package/cli/src/engine-native/claudeOptionalPlugins.ts +113 -0
  22. package/cli/src/engine-native/claudePluginPasses.ts +348 -0
  23. package/cli/src/engine-native/claudeRemovals.ts +195 -0
  24. package/cli/src/engine-native/claudeRetired.ts +4 -4
  25. package/cli/src/engine-native/claudeRuntime.ts +94 -68
  26. package/cli/src/engine-native/claudeSettings.ts +174 -0
  27. package/cli/src/engine-native/claudeSettingsModifiers.ts +50 -54
  28. package/cli/src/engine-native/claudeSync.ts +176 -456
  29. package/cli/src/engine-native/codexConfig.ts +158 -0
  30. package/cli/src/engine-native/codexHooks.ts +91 -0
  31. package/cli/src/engine-native/codexPlugins.ts +290 -0
  32. package/cli/src/engine-native/codexStatus.ts +24 -0
  33. package/cli/src/engine-native/codexSync.ts +110 -568
  34. package/cli/src/engine-native/codexToml.ts +140 -138
  35. package/cli/src/engine-native/deps.ts +144 -107
  36. package/cli/src/engine-native/engineCtx.ts +126 -0
  37. package/cli/src/engine-native/exec.ts +98 -87
  38. package/cli/src/engine-native/failures.ts +3 -3
  39. package/cli/src/engine-native/harnesses.ts +62 -72
  40. package/cli/src/engine-native/index.ts +34 -315
  41. package/cli/src/engine-native/jq.ts +25 -24
  42. package/cli/src/engine-native/logger.ts +98 -100
  43. package/cli/src/engine-native/models.ts +50 -45
  44. package/cli/src/engine-native/modes.ts +83 -80
  45. package/cli/src/engine-native/ompFileDeploy.ts +111 -0
  46. package/cli/src/engine-native/ompMarketplace.ts +136 -0
  47. package/cli/src/engine-native/ompOverlay.ts +67 -64
  48. package/cli/src/engine-native/ompPaths.ts +49 -38
  49. package/cli/src/engine-native/ompPlugins.ts +170 -0
  50. package/cli/src/engine-native/ompSync.ts +64 -408
  51. package/cli/src/engine-native/ompYaml.ts +43 -41
  52. package/cli/src/engine-native/os/darwin.ts +5 -5
  53. package/cli/src/engine-native/os/index.ts +15 -15
  54. package/cli/src/engine-native/os/linux.ts +5 -5
  55. package/cli/src/engine-native/os/posix.ts +28 -24
  56. package/cli/src/engine-native/os/targets.ts +17 -17
  57. package/cli/src/engine-native/os/types.ts +31 -31
  58. package/cli/src/engine-native/os/windows.ts +61 -61
  59. package/cli/src/engine-native/parseArgs.ts +122 -372
  60. package/cli/src/engine-native/parseHelp.ts +66 -0
  61. package/cli/src/engine-native/parseModifiers.ts +222 -0
  62. package/cli/src/engine-native/services.ts +34 -34
  63. package/cli/src/engine-native/settings.ts +15 -15
  64. package/cli/src/engine-native/sharedTypes.d.ts +46 -0
  65. package/cli/src/engine-native/skillsInstall.ts +105 -0
  66. package/cli/src/engine-native/skillsLinks.ts +203 -0
  67. package/cli/src/engine-native/skillsManifest.ts +16 -0
  68. package/cli/src/engine-native/skillsPrune.ts +108 -0
  69. package/cli/src/engine-native/skillsSync.ts +28 -362
  70. package/cli/src/engine-native/syncConcurrency.ts +55 -0
  71. package/cli/src/engine-native/syncDispatch.ts +141 -0
  72. package/cli/src/engine-native/toolchain.ts +65 -62
  73. package/cli/src/engine.ts +65 -57
  74. package/cli/src/generated/sotPayload.ts +7 -7
  75. package/cli/src/kitHome.ts +44 -40
  76. package/cli/src/main.ts +63 -56
  77. package/cli/src/manifests.ts +53 -54
  78. package/cli/src/md.d.ts +2 -2
  79. package/cli/src/payload.ts +9 -9
  80. package/cli/src/services.ts +21 -13
  81. package/cli/tsconfig.json +4 -1
  82. package/package.json +9 -2
  83. package/cli/src/engine-native/claudePlugins.ts +0 -510
@@ -6,585 +6,305 @@
6
6
  */
7
7
  import {
8
8
  appendFileSync,
9
- copyFileSync,
10
9
  existsSync,
11
10
  mkdirSync,
12
11
  readFileSync,
13
12
  renameSync,
14
- rmdirSync,
15
- rmSync,
16
- writeFileSync
17
- } from "node:fs"
18
- import { bunBootstrap } from "./bun"
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { bunBootstrap } from "./bun";
16
+ import { syncClaudeAdvisor, syncClaudeEffort, syncClaudeModel } from "./claudeSettingsModifiers";
19
17
  import {
20
- syncClaudeAdvisor,
21
- syncClaudeEffort,
22
- syncClaudeModel
23
- } from "./claudeSettingsModifiers"
24
- import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
25
- import { syncLspServers, syncOptionalPlugins, syncPlugins } from "./claudePlugins"
26
- import { RETIRED_PERMISSION_RULES } from "./claudeRetired"
27
- import { p, spawnProcess, writeBytesIfChanged, writeTextIfChanged } from "./exec"
28
- import type { Ctx } from "./index"
29
- import { deepMerge, isObject, jqStringify, parseJson, readJsonFile, type Json } from "./jq"
30
- import { ExitError } from "./parseArgs"
31
- import { hostOs } from "./os"
32
- import { mergeSettings, reconcileSettings } from "./settings"
33
- import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
18
+ claudeRuntimePaths,
19
+ materializeClaudeSettings,
20
+ type ClaudeRuntimePaths,
21
+ } from "./claudeRuntime";
22
+ import {
23
+ commitClaudeSettings,
24
+ describeSettingsSync,
25
+ prepareClaudeSettings,
26
+ syncCompactWindow,
27
+ syncPermissive,
28
+ } from "./claudeSettings";
29
+ import { syncRemovals } from "./claudeRemovals";
30
+ import { syncPlugins } from "./claudePluginPasses";
31
+ import { syncOptionalPlugins } from "./claudeOptionalPlugins";
32
+ import { syncLspServers } from "./claudeLsp";
33
+ import { p, spawnProcess, writeBytesIfChanged, writeTextIfChanged } from "./exec";
34
+ import type { Ctx } from "./index";
35
+ import { deepMerge, isObject, jqStringify, parseJson, readJsonFile, type Json } from "./jq";
36
+ import { ExitError } from "./parseArgs";
37
+ import { hostOs } from "./os";
38
+ import { payloadBytes, payloadText } from "../payload";
39
+ import type { JsonObject } from "./sharedTypes";
40
+ export type { PreparedClaudeSettings } from "./claudeSettings";
41
+ export { commitClaudeSettings, prepareClaudeSettings } from "./claudeSettings";
34
42
 
35
43
  export type ClaudeRuntimeState =
36
44
  | { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
37
- | { readonly kind: "deferred"; readonly reason: "bun-unavailable" }
45
+ | { readonly kind: "deferred"; readonly reason: "bun-unavailable" };
38
46
 
39
47
  export async function claudeSync(ctx: Ctx): Promise<ClaudeRuntimeState> {
40
- const { err, warn } = ctx.services.logger
41
- const claudeDir = p(ctx.home, ".claude")
48
+ const { err, warn } = ctx.services.logger;
49
+ const claudeDir = p(ctx.home, ".claude");
42
50
 
43
- if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
51
+ if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true });
44
52
 
45
53
  if (ctx.services.deps.probe("claude").state === "missing") {
46
54
  warn(
47
- `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`
48
- )
55
+ `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`,
56
+ );
49
57
  }
50
58
 
51
- const bun = await bunBootstrap(ctx, ctx.services)
52
- const runtime: ClaudeRuntimeState = bun.kind === "ready"
53
- ? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
54
- : { kind: "deferred", reason: "bun-unavailable" }
55
- const template = parseJson(payloadText("SoT/.claude/settings.json"))
59
+ const bun = await bunBootstrap(ctx, ctx.services);
60
+ const runtime: ClaudeRuntimeState =
61
+ bun.kind === "ready"
62
+ ? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
63
+ : { kind: "deferred", reason: "bun-unavailable" };
64
+ const template = parseJson(payloadText("SoT/.claude/settings.json"));
56
65
  if (template === undefined) {
57
- err("Embedded SoT/.claude/settings.json is not valid JSON")
58
- throw new ExitError(1)
66
+ err("Embedded SoT/.claude/settings.json is not valid JSON");
67
+ throw new ExitError(1);
59
68
  }
60
69
  const materialized = materializeClaudeSettings(
61
70
  template,
62
- runtime.kind === "ready" ? runtime.paths : undefined
63
- )
64
- const prepared = prepareClaudeSettings(ctx, claudeDir, materialized)
71
+ runtime.kind === "ready" ? runtime.paths : undefined,
72
+ );
73
+ const prepared = prepareClaudeSettings(ctx, claudeDir, materialized);
65
74
 
66
- syncClaudeRuntime(ctx, runtime)
67
- syncClaudeMd(ctx, claudeDir)
75
+ syncClaudeRuntime(ctx, runtime);
76
+ syncClaudeMd(ctx, claudeDir);
68
77
  if (ctx.dryRun) {
69
- describeSettingsSync(ctx, claudeDir)
78
+ describeSettingsSync(ctx, claudeDir);
70
79
  } else {
71
- commitClaudeSettings(ctx, prepared)
72
- }
73
- syncRemovals(ctx, claudeDir, runtime)
74
- syncCompactWindow(ctx, claudeDir)
75
- syncPermissive(ctx, claudeDir)
76
- syncClaudeModel(ctx, ctx.claudeModel)
77
- syncClaudeEffort(ctx, ctx.claudeEffort)
78
- syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
79
- syncClaudeJson(ctx)
80
- await syncConnectorEnv(ctx)
81
- await syncPlugins(ctx, claudeDir)
82
- await syncOptionalPlugins(ctx, claudeDir)
83
- await syncLspServers(ctx)
84
- return runtime
80
+ commitClaudeSettings(ctx, prepared);
81
+ }
82
+ syncRemovals(ctx, claudeDir, runtime);
83
+ syncCompactWindow(ctx, claudeDir);
84
+ syncPermissive(ctx, claudeDir);
85
+ syncClaudeModel(ctx, ctx.claudeModel);
86
+ syncClaudeEffort(ctx, ctx.claudeEffort);
87
+ syncClaudeAdvisor(ctx, ctx.claudeAdvisor);
88
+ syncClaudeJson(ctx);
89
+ await syncConnectorEnv(ctx);
90
+ await syncPlugins(ctx, claudeDir);
91
+ await syncOptionalPlugins(ctx, claudeDir);
92
+ await syncLspServers(ctx);
93
+ return runtime;
85
94
  }
86
95
 
87
96
  // ----------------------------------------------------------- runtime ----
88
97
 
89
98
  function syncClaudeRuntime(ctx: Ctx, runtime: ClaudeRuntimeState): void {
90
- const { change, echo, verbose, warn } = ctx.services.logger
99
+ const { change, echo, verbose, warn } = ctx.services.logger;
91
100
  if (runtime.kind === "deferred") {
92
- warn("Bun unavailable — Claude statusline/hooks migration deferred; install Bun, then re-run sync claude")
93
- return
101
+ warn(
102
+ "Bun unavailable — Claude statusline/hooks migration deferred; install Bun, then re-run sync claude",
103
+ );
104
+ return;
94
105
  }
95
106
  if (ctx.dryRun) {
96
- echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
97
- return
107
+ echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3");
108
+ return;
98
109
  }
99
110
 
100
- mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true })
101
- let changed = false
111
+ mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true });
112
+ let changed = false;
102
113
  for (const [path, source] of [
103
114
  [runtime.paths.statusline, "SoT/.claude/bin/statusline.mjs"],
104
115
  [runtime.paths.sessionStart, "SoT/.claude/bin/session-start.mjs"],
105
- [runtime.paths.notify, "SoT/.claude/bin/notify.mjs"]
116
+ [runtime.paths.notify, "SoT/.claude/bin/notify.mjs"],
106
117
  ] as const) {
107
- if (writeTextIfChanged(path, payloadText(source))) changed = true
118
+ if (writeTextIfChanged(path, payloadText(source))) changed = true;
108
119
  }
109
- if (writeBytesIfChanged(p(ctx.home, ".claude", "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
120
+ if (
121
+ writeBytesIfChanged(
122
+ p(ctx.home, ".claude", "notification.mp3"),
123
+ payloadBytes("notification.mp3"),
124
+ )
125
+ )
126
+ changed = true;
110
127
  if (changed) {
111
- change("Claude runtime synced (statusline, session-start, notify, notification)")
112
- ctx.nextStepTriggers.claudeRestart = true
128
+ change("Claude runtime synced (statusline, session-start, notify, notification)");
129
+ ctx.nextStepTriggers.claudeRestart = true;
113
130
  } else {
114
- verbose("Claude runtime already in sync (statusline, session-start, notify, notification)")
131
+ verbose("Claude runtime already in sync (statusline, session-start, notify, notification)");
115
132
  }
116
133
  }
117
134
 
118
135
  function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
119
- const { change, echo, verbose } = ctx.services.logger
136
+ const { change, echo, verbose } = ctx.services.logger;
120
137
  if (ctx.dryRun) {
121
- echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md")
122
- return
138
+ echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md");
139
+ return;
123
140
  }
124
141
 
125
- const source = payloadText("SoT/.claude/CLAUDE.md")
142
+ const source = payloadText("SoT/.claude/CLAUDE.md");
126
143
  if (writeTextIfChanged(p(claudeDir, "CLAUDE.md"), source)) {
127
- change("CLAUDE.md synced")
128
- } else {
129
- verbose("CLAUDE.md already in sync")
130
- }
131
- }
132
-
133
- // ------------------------------------------------------------- settings ----
134
-
135
- export interface PreparedClaudeSettings {
136
- readonly path: string
137
- readonly bytes: string
138
- readonly previousBytes: string | undefined
139
- readonly changed: boolean
140
- }
141
-
142
- function assertMaterializedSettings(bytes: string): void {
143
- if (bytes.includes("__DOCKS_KIT_")) throw new Error("Claude settings contain unresolved runtime sentinels")
144
- }
145
-
146
- /** Build the candidate settings bytes before the readiness-gated runtime cutover mutates disk. */
147
- export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json): PreparedClaudeSettings {
148
- const path = p(claudeDir, "settings.json")
149
- if (!existsSync(path)) {
150
- const bytes = jqStringify(repo)
151
- assertMaterializedSettings(bytes)
152
- return { path, bytes, previousBytes: undefined, changed: true }
153
- }
154
-
155
- const previousBytes = readFileSync(path, "utf8")
156
- const user = parseJson(previousBytes)
157
- if (user === undefined || !isObject(user)) {
158
- const reason = user === undefined ? "is not valid JSON" : "must contain a JSON object"
159
- ctx.services.logger.err(`Aborting sync: ${path} ${reason}. Fix it manually or delete it to reinstall.`)
160
- throw new ExitError(1)
161
- }
162
- const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
163
- const bytes = jqStringify(merged)
164
- assertMaterializedSettings(bytes)
165
- return { path, bytes, previousBytes, changed: bytes !== previousBytes }
166
- }
167
-
168
- /** Commit a fully prepared document; callers must finish runtime preparation first. */
169
- export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings): void {
170
- const { change, verbose } = ctx.services.logger
171
- if (!prepared.changed) {
172
- verbose("Settings already in sync")
173
- return
174
- }
175
-
176
- if (prepared.previousBytes !== undefined) copyFileSync(prepared.path, `${prepared.path}.bak`)
177
- writeFileSync(`${prepared.path}.tmp`, prepared.bytes)
178
- renameSync(`${prepared.path}.tmp`, prepared.path)
179
- ctx.nextStepTriggers.claudeRestart = true
180
- if (prepared.previousBytes === undefined) {
181
- change("Settings installed")
182
- } else if (ctx.reconcile) {
183
- change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
144
+ change("CLAUDE.md synced");
184
145
  } else {
185
- change("Settings merged (backup at settings.json.bak)")
186
- }
187
- }
188
-
189
- function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
190
- const { echo } = ctx.services.logger
191
- const repoSettings = payloadDisplayPath("SoT/.claude/settings.json")
192
- const userSettings = p(claudeDir, "settings.json")
193
-
194
- if (!existsSync(userSettings)) {
195
- echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
196
- } else if (ctx.reconcile) {
197
- echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
198
- } else {
199
- echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
200
- }
201
- }
202
-
203
- /** Shared shape of the three jq-edit modifiers (compact window, permissive). */
204
- function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
205
- const { err, warn } = ctx.services.logger
206
- const userSettings = p(claudeDir, "settings.json")
207
- if (!existsSync(userSettings)) {
208
- warn(`(${tag}) ${userSettings} missing — skipped`)
209
- return false
210
- }
211
- const before = readFileSync(userSettings, "utf8")
212
- const doc = parseJson(before)
213
- if (doc === undefined) {
214
- err(`(${tag}) ${userSettings} is not valid JSON — skipped`)
215
- return false
216
- }
217
- edit(doc)
218
- const out = jqStringify(doc)
219
- if (out === before) return false
220
- writeFileSync(`${userSettings}.tmp`, out)
221
- renameSync(`${userSettings}.tmp`, userSettings)
222
- return true
223
- }
224
-
225
- function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
226
- const { change, echo, verbose } = ctx.services.logger
227
- if (ctx.claudeCompactWindow === "") return
228
-
229
- if (ctx.dryRun) {
230
- echo(`[dry-run] (--claude-compact-window) set env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=${ctx.claudeCompactWindow} in ${p(claudeDir, "settings.json")}`)
231
- return
232
- }
233
-
234
- const changed = jqEditSettings(ctx, claudeDir, "--claude-compact-window", (doc) => {
235
- if (!isObject(doc)) return
236
- const env = isObject(doc["env"]) ? doc["env"] : {}
237
- env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = ctx.claudeCompactWindow
238
- doc["env"] = env
239
- })
240
- if (changed) {
241
- change(`Compact window: set to ${ctx.claudeCompactWindow} tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)`)
242
- ctx.nextStepTriggers.claudeRestart = true
243
- }
244
- else verbose(`Compact window: already set to ${ctx.claudeCompactWindow} tokens in deployed settings`)
245
- }
246
-
247
- function syncPermissive(ctx: Ctx, claudeDir: string): void {
248
- const { change, echo, verbose } = ctx.services.logger
249
- if (!ctx.claudePermissive) return
250
-
251
- if (ctx.dryRun) {
252
- echo(`[dry-run] (--claude-permissive) empty permissions.ask and permissions.deny in ${p(claudeDir, "settings.json")}`)
253
- return
254
- }
255
-
256
- const changed = jqEditSettings(ctx, claudeDir, "--claude-permissive", (doc) => {
257
- if (!isObject(doc)) return
258
- const permissions = isObject(doc["permissions"]) ? doc["permissions"] : {}
259
- permissions["ask"] = []
260
- permissions["deny"] = []
261
- doc["permissions"] = permissions
262
- })
263
- if (changed) {
264
- change("Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)")
265
- ctx.nextStepTriggers.claudeRestart = true
146
+ verbose("CLAUDE.md already in sync");
266
147
  }
267
- else verbose("Permissive mode: permissions.ask/deny already empty in deployed settings")
268
148
  }
269
149
 
270
150
  // ---------------------------------------------------------- claude.json ----
271
151
 
272
152
  function syncClaudeJson(ctx: Ctx): void {
273
- const { change, echo, err, verbose } = ctx.services.logger
274
- const claudeJson = p(ctx.home, ".claude.json")
153
+ const { change, echo, err, verbose } = ctx.services.logger;
154
+ const claudeJson = p(ctx.home, ".claude.json");
275
155
 
276
- const mcp: Json | undefined = parseJson(payloadText("SoT/.claude/mcp-servers.json"))
277
- const haveMcp = mcp !== undefined
156
+ const mcp: Json | undefined = parseJson(payloadText("SoT/.claude/mcp-servers.json"));
157
+ const haveMcp = mcp !== undefined;
278
158
 
279
159
  if (ctx.dryRun) {
280
- echo("[dry-run] set showTurnDuration=true in ~/.claude.json")
281
- if (haveMcp) echo("[dry-run] merge mcpServers from SoT/.claude/mcp-servers.json into ~/.claude.json")
282
- return
160
+ echo("[dry-run] set showTurnDuration=true in ~/.claude.json");
161
+ if (haveMcp)
162
+ echo("[dry-run] merge mcpServers from SoT/.claude/mcp-servers.json into ~/.claude.json");
163
+ return;
283
164
  }
284
165
 
285
- const applyFilter = (doc: { [k: string]: Json }): void => {
286
- doc["showTurnDuration"] = true
166
+ const applyFilter = (doc: JsonObject): void => {
167
+ doc["showTurnDuration"] = true;
287
168
  if (haveMcp) {
288
- const sotServers = isObject(mcp!) ? mcp!["mcpServers"] ?? {} : {}
289
- doc["mcpServers"] = deepMerge(isObject(doc["mcpServers"]) ? doc["mcpServers"] : {}, sotServers)
169
+ const sotServers = isObject(mcp!) ? (mcp!["mcpServers"] ?? {}) : {};
170
+ doc["mcpServers"] = deepMerge(
171
+ isObject(doc["mcpServers"]) ? doc["mcpServers"] : {},
172
+ sotServers,
173
+ );
290
174
  }
291
- }
175
+ };
292
176
 
293
- let changed = true
177
+ let changed = true;
294
178
  if (existsSync(claudeJson)) {
295
- const before = readFileSync(claudeJson, "utf8")
296
- const doc = parseJson(before)
179
+ const before = readFileSync(claudeJson, "utf8");
180
+ const doc = parseJson(before);
297
181
  if (doc === undefined || !isObject(doc)) {
298
- const reason = doc === undefined ? "not valid JSON" : "root must be a JSON object"
299
- err(`Skipping ~/.claude.json edit: ${reason}. Fix or delete it.`)
300
- return
182
+ const reason = doc === undefined ? "not valid JSON" : "root must be a JSON object";
183
+ err(`Skipping ~/.claude.json edit: ${reason}. Fix or delete it.`);
184
+ return;
301
185
  }
302
- const obj = doc
303
- applyFilter(obj)
304
- const out = jqStringify(obj)
186
+ const obj = doc;
187
+ applyFilter(obj);
188
+ const out = jqStringify(obj);
305
189
  if (out === before) {
306
- changed = false
190
+ changed = false;
307
191
  } else {
308
- writeFileSync(`${claudeJson}.tmp`, out)
309
- renameSync(`${claudeJson}.tmp`, claudeJson)
192
+ writeFileSync(`${claudeJson}.tmp`, out);
193
+ renameSync(`${claudeJson}.tmp`, claudeJson);
310
194
  }
311
195
  } else {
312
- const obj: { [k: string]: Json } = {}
313
- applyFilter(obj)
314
- writeFileSync(claudeJson, jqStringify(obj))
196
+ const obj: JsonObject = {};
197
+ applyFilter(obj);
198
+ writeFileSync(claudeJson, jqStringify(obj));
315
199
  }
316
200
  if (changed) {
317
- change(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
318
- ctx.nextStepTriggers.claudeRestart = true
319
- }
320
- else verbose(`~/.claude.json already in sync (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
201
+ change(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`);
202
+ ctx.nextStepTriggers.claudeRestart = true;
203
+ } else
204
+ verbose(`~/.claude.json already in sync (showTurnDuration${haveMcp ? ", mcpServers" : ""})`);
321
205
  }
322
206
 
323
207
  // -------------------------------------------------------- connector env ----
324
208
 
325
209
  async function syncConnectorEnv(ctx: Ctx): Promise<void> {
326
- const { change, echo, verbose, warn } = ctx.services.logger
327
- const name = "ENABLE_CLAUDEAI_MCP_SERVERS"
328
- const setting = hostOs().environmentSetting(name, "false")
210
+ const { change, echo, verbose, warn } = ctx.services.logger;
211
+ const name = "ENABLE_CLAUDEAI_MCP_SERVERS";
212
+ const setting = hostOs().environmentSetting(name, "false");
329
213
 
330
214
  switch (setting.kind) {
331
215
  case "profile": {
332
- const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
333
- const candidates = setting.candidates.map((candidate) => p(ctx.home, candidate))
216
+ const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)";
217
+ const candidates = setting.candidates.map((candidate) => p(ctx.home, candidate));
334
218
 
335
219
  for (const f of candidates) {
336
220
  if (existsSync(f) && readFileSync(f, "utf8").includes(name)) {
337
221
  if (ctx.dryRun) {
338
- echo(`[dry-run] ${name} already in ${f} — would skip`)
222
+ echo(`[dry-run] ${name} already in ${f} — would skip`);
339
223
  } else {
340
- verbose(`claude.ai connectors: ${name} already set in ${f} (left as-is)`)
224
+ verbose(`claude.ai connectors: ${name} already set in ${f} (left as-is)`);
341
225
  }
342
- return
226
+ return;
343
227
  }
344
228
  }
345
229
 
346
- const target = p(ctx.home, setting.target(process.env["SHELL"]))
230
+ const target = p(ctx.home, setting.target(process.env["SHELL"]));
347
231
  if (ctx.dryRun) {
348
- echo(`[dry-run] append '${setting.line}' to ${target}`)
349
- return
232
+ echo(`[dry-run] append '${setting.line}' to ${target}`);
233
+ return;
350
234
  }
351
235
 
352
- appendFileSync(target, `\n${marker}\n${setting.line}\n`)
353
- change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
354
- ctx.nextStepTriggers.claudeRestart = true
355
- return
236
+ appendFileSync(target, `\n${marker}\n${setting.line}\n`);
237
+ change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`);
238
+ ctx.nextStepTriggers.claudeRestart = true;
239
+ return;
356
240
  }
357
241
  case "command": {
358
- const existing = await spawnProcess(setting.probe.command, setting.probe.args, { stdio: "ignore" })
242
+ const existing = await spawnProcess(setting.probe.command, setting.probe.args, {
243
+ stdio: "ignore",
244
+ });
359
245
  if (existing.error === undefined && existing.exitCode === 0) {
360
- if (ctx.dryRun) echo(`[dry-run] ${name} already in ${setting.location} — would skip`)
361
- else verbose(`claude.ai connectors: ${name} already set in ${setting.location} (left as-is)`)
362
- return
246
+ if (ctx.dryRun) echo(`[dry-run] ${name} already in ${setting.location} — would skip`);
247
+ else
248
+ verbose(`claude.ai connectors: ${name} already set in ${setting.location} (left as-is)`);
249
+ return;
363
250
  }
364
251
 
365
- const applyCommand = [setting.apply.command, ...setting.apply.args].join(" ")
252
+ const applyCommand = [setting.apply.command, ...setting.apply.args].join(" ");
366
253
  if (ctx.dryRun) {
367
- echo(`[dry-run] ${applyCommand} (${setting.location})`)
368
- return
254
+ echo(`[dry-run] ${applyCommand} (${setting.location})`);
255
+ return;
369
256
  }
370
257
 
371
- const applied = await spawnProcess(setting.apply.command, setting.apply.args, { stdio: "ignore" })
258
+ const applied = await spawnProcess(setting.apply.command, setting.apply.args, {
259
+ stdio: "ignore",
260
+ });
372
261
  if (applied.error === undefined && applied.exitCode === 0) {
373
- change(`claude.ai connectors disabled via ${setting.apply.command} (open a new terminal to apply)`)
374
- ctx.nextStepTriggers.claudeRestart = true
262
+ change(
263
+ `claude.ai connectors disabled via ${setting.apply.command} (open a new terminal to apply)`,
264
+ );
265
+ ctx.nextStepTriggers.claudeRestart = true;
375
266
  } else {
376
- warn(`${applyCommand} failed — ${setting.manualHint}`)
267
+ warn(`${applyCommand} failed — ${setting.manualHint}`);
377
268
  }
378
- return
379
- }
380
- }
381
- }
382
-
383
- // ------------------------------------------------------------- removals ----
384
-
385
- const REMOVED_MANIFEST = {
386
- hooks: ["disable-claudeai-connectors.sh"],
387
- files: ["alert_bubble.mp3"],
388
- settingsKeys: [
389
- "showTurnDuration",
390
- "advisorModel",
391
- "env.CLAUDE_CODE_SUBAGENT_MODEL",
392
- "env.ANTHROPIC_DEFAULT_OPUS_MODEL",
393
- "env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
394
- "env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
395
- "env.CLAUDE_CODE_FORK_SUBAGENT",
396
- "env.CLAUDE_CODE_EFFORT_LEVEL",
397
- "enabledPlugins.effect-kit@docks",
398
- "enabledPlugins.session-relay@docks",
399
- "hooks.PreToolUse"
400
- ],
401
- permissionRules: {
402
- allow: ["Write(./)", "Bash(rtk *)", ...RETIRED_PERMISSION_RULES.allow],
403
- deny: [
404
- "Write(**/.env)",
405
- "Write(**/.env.local)",
406
- "Write(**/secrets/**)",
407
- ...RETIRED_PERMISSION_RULES.deny
408
- ],
409
- ask: [...RETIRED_PERMISSION_RULES.ask]
410
- },
411
- claudeJsonKeys: [] as Array<string>,
412
- /** Home-relative artifacts the kit installed outside ~/.claude. */
413
- homeFiles: [".local/bin/effect-solutions", ".local/bin/session-relay"],
414
- runtimeReady: {
415
- hooks: ["notify.sh"],
416
- files: ["statusline.sh", "fetch-usage.sh"],
417
- settingsKeys: ["hooks.Stop"]
418
- }
419
- }
420
-
421
- /** claude::_prune_json_keys — present-count; deletes when !dryRun. */
422
- function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
423
- if (keys.length === 0 || !existsSync(file)) return 0
424
- const doc = parseJson(readFileSync(file, "utf8"))
425
- if (doc === undefined) return 0
426
-
427
- const hasPath = (root: Json, path: Array<string>): boolean => {
428
- let cur: Json = root
429
- for (const seg of path.slice(0, -1)) {
430
- if (!isObject(cur) || cur[seg] === undefined) return false
431
- cur = cur[seg]!
432
- }
433
- return isObject(cur) && Object.prototype.hasOwnProperty.call(cur, path[path.length - 1]!)
434
- }
435
- const presentKeys = keys.filter((k) => hasPath(doc, k.split(".")))
436
- if (presentKeys.length === 0) return 0
437
-
438
- if (!ctx.dryRun) {
439
- for (const k of presentKeys) {
440
- const path = k.split(".")
441
- let cur: Json = doc
442
- for (const seg of path.slice(0, -1)) {
443
- if (!isObject(cur)) break
444
- cur = cur[seg]!
445
- }
446
- if (isObject(cur)) delete cur[path[path.length - 1]!]
447
- }
448
- writeFileSync(`${file}.tmp`, jqStringify(doc))
449
- renameSync(`${file}.tmp`, file)
450
- }
451
- return presentKeys.length
452
- }
453
-
454
- function prunePermissionRules(
455
- ctx: Ctx,
456
- file: string,
457
- rules: Readonly<Record<"allow" | "deny" | "ask", ReadonlyArray<string>>>
458
- ): number {
459
- if (!existsSync(file)) return 0
460
- const doc = parseJson(readFileSync(file, "utf8"))
461
- if (doc === undefined || !isObject(doc) || !isObject(doc["permissions"])) return 0
462
- const permissions = doc["permissions"]
463
- let present = 0
464
- for (const key of ["allow", "deny", "ask"] as const) {
465
- const values = permissions[key]
466
- if (!Array.isArray(values)) continue
467
- const removed = new Set(rules[key])
468
- present += rules[key].filter((rule) => values.includes(rule)).length
469
- if (!ctx.dryRun) permissions[key] = values.filter((value) => typeof value !== "string" || !removed.has(value))
470
- }
471
- if (present > 0 && !ctx.dryRun) {
472
- writeFileSync(`${file}.tmp`, jqStringify(doc))
473
- renameSync(`${file}.tmp`, file)
474
- }
475
- return present
476
- }
477
-
478
- function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState): void {
479
- const { change, echo } = ctx.services.logger
480
- let hooksRemoved = 0
481
- let filesRemoved = 0
482
- const hooks = [
483
- ...REMOVED_MANIFEST.hooks,
484
- ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.hooks : [])
485
- ]
486
- const files = [
487
- ...REMOVED_MANIFEST.files,
488
- ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.files : [])
489
- ]
490
- const settingsKeys = [
491
- ...REMOVED_MANIFEST.settingsKeys.filter((key) => key !== "advisorModel" || ctx.claudeAdvisor === ""),
492
- ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.settingsKeys : [])
493
- ]
494
-
495
- for (const name of hooks) {
496
- const path = p(claudeDir, "hooks", name)
497
- if (!existsSync(path)) continue
498
- if (ctx.dryRun) {
499
- echo(`[dry-run] rm ${path}`)
500
- } else {
501
- rmSync(path, { force: true })
502
- hooksRemoved++
503
- }
504
- }
505
- if (!ctx.dryRun && hooksRemoved > 0) {
506
- try {
507
- rmdirSync(p(claudeDir, "hooks"))
508
- } catch {
509
- // Preserve a non-empty user hooks directory.
510
- }
511
- }
512
-
513
- for (const rel of files) {
514
- const path = p(claudeDir, rel)
515
- if (!existsSync(path)) continue
516
- if (ctx.dryRun) {
517
- echo(`[dry-run] rm ${path}`)
518
- } else {
519
- rmSync(path, { force: true })
520
- filesRemoved++
269
+ return;
521
270
  }
522
271
  }
523
-
524
- for (const rel of REMOVED_MANIFEST.homeFiles) {
525
- const path = p(ctx.home, rel)
526
- if (!existsSync(path)) continue
527
- if (ctx.dryRun) {
528
- echo(`[dry-run] rm ${path}`)
529
- } else {
530
- rmSync(path, { force: true })
531
- filesRemoved++
532
- }
533
- }
534
-
535
- const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), settingsKeys)
536
- const permissionRules = prunePermissionRules(
537
- ctx,
538
- p(claudeDir, "settings.json"),
539
- REMOVED_MANIFEST.permissionRules
540
- )
541
- const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
542
-
543
- if (ctx.dryRun) {
544
- if (skeys > 0) echo(`[dry-run] del ${skeys} stale key(s) from ${p(claudeDir, "settings.json")}`)
545
- if (permissionRules > 0) {
546
- echo(`[dry-run] del ${permissionRules} stale permission rule(s) from ${p(claudeDir, "settings.json")}`)
547
- }
548
- if (cjkeys > 0) echo(`[dry-run] del ${cjkeys} stale key(s) from ${p(ctx.home, ".claude.json")}`)
549
- return
550
- }
551
-
552
- if (hooksRemoved + filesRemoved + skeys + permissionRules + cjkeys > 0) {
553
- const permissionSummary = permissionRules > 0 ? `, permission rules: ${permissionRules}` : ""
554
- change(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys}${permissionSummary})`)
555
- ctx.nextStepTriggers.claudeRestart = true
556
- }
557
272
  }
558
273
 
559
274
  // -------------------------------------------------------------- summary ----
560
275
 
561
276
  export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
562
- const { echo } = ctx.services.logger
563
- const claudeDir = p(ctx.home, ".claude")
564
- echo(`Claude: ${claudeDir}`)
277
+ const { echo } = ctx.services.logger;
278
+ const claudeDir = p(ctx.home, ".claude");
279
+ echo(`Claude: ${claudeDir}`);
565
280
  if (!ctx.dryRun) {
566
281
  if (runtime.kind === "ready") {
567
- echo("Hooks: Bun (statusline, session-start, notify)")
282
+ echo("Hooks: Bun (statusline, session-start, notify)");
568
283
  } else {
569
- echo("Hooks: migration deferred (Bun unavailable; existing hook/statusline settings preserved)")
284
+ echo(
285
+ "Hooks: migration deferred (Bun unavailable; existing hook/statusline settings preserved)",
286
+ );
570
287
  }
571
288
  if (ctx.services.deps.probe("claude").state === "present") {
572
- const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
573
- const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
574
- echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
289
+ const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"));
290
+ const count =
291
+ installed !== undefined && isObject(installed) && isObject(installed["plugins"])
292
+ ? Object.keys(installed["plugins"]).length
293
+ : 0;
294
+ echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`);
575
295
  } else {
576
- echo("Plugins: skipped - claude CLI not installed")
296
+ echo("Plugins: skipped - claude CLI not installed");
577
297
  }
578
298
  }
579
299
  }
580
300
 
581
301
  export function claudeNextSteps(ctx: Ctx): Array<string> {
582
- const lines: Array<string> = []
302
+ const lines: Array<string> = [];
583
303
  if (ctx.verbose || ctx.nextStepTriggers.claudePlugins) {
584
- lines.push("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
304
+ lines.push("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.");
585
305
  }
586
306
  if (ctx.verbose || ctx.nextStepTriggers.claudeRestart) {
587
- lines.push("Restart Claude Code for hook/env-var changes to take effect.")
307
+ lines.push("Restart Claude Code for hook/env-var changes to take effect.");
588
308
  }
589
- return lines
309
+ return lines;
590
310
  }