docks-kit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +15 -7
  3. package/cli/docs/install.md +12 -12
  4. package/cli/docs/overview.md +7 -1
  5. package/cli/docs/platforms.md +8 -7
  6. package/cli/docs/sync-layers.md +15 -7
  7. package/cli/docs/toolchain.md +8 -2
  8. package/cli/src/engine-native/DESIGN.md +25 -14
  9. package/cli/src/engine-native/bun.ts +87 -0
  10. package/cli/src/engine-native/claudeRuntime.ts +132 -0
  11. package/cli/src/engine-native/claudeSync.ts +179 -115
  12. package/cli/src/engine-native/codexSync.ts +42 -36
  13. package/cli/src/engine-native/deps.ts +27 -10
  14. package/cli/src/engine-native/exec.ts +8 -23
  15. package/cli/src/engine-native/index.ts +8 -8
  16. package/cli/src/engine-native/models.ts +14 -16
  17. package/cli/src/engine-native/modes.ts +18 -9
  18. package/cli/src/engine-native/parseArgs.ts +1 -17
  19. package/cli/src/engine-native/powershell.ts +11 -0
  20. package/cli/src/engine-native/skillsSync.ts +11 -46
  21. package/cli/src/engine-native/toolchain.ts +6 -6
  22. package/cli/src/generated/sotPayload.ts +41 -0
  23. package/cli/src/kitHome.ts +15 -11
  24. package/cli/src/manifests.ts +17 -15
  25. package/cli/src/payload.ts +28 -0
  26. package/docks-kit +6 -6
  27. package/package.json +2 -3
  28. package/SoT/.agents/skills.txt +0 -14
  29. package/SoT/.claude/CLAUDE.md +0 -146
  30. package/SoT/.claude/fetch-usage.sh +0 -66
  31. package/SoT/.claude/hooks/notify.sh +0 -14
  32. package/SoT/.claude/mcp-servers.json +0 -10
  33. package/SoT/.claude/settings.json +0 -235
  34. package/SoT/.claude/statusline.sh +0 -175
  35. package/SoT/.codex/AGENTS.md +0 -75
  36. package/SoT/.codex/agents/.gitkeep +0 -1
  37. package/SoT/.codex/config.toml +0 -45
  38. package/SoT/.codex/plugins/marketplace.json +0 -50
  39. package/SoT/.codex/rules/docks.rules +0 -116
  40. package/SoT/models.json +0 -31
  41. package/SoT/toolchain.json +0 -27
  42. package/notification.mp3 +0 -0
@@ -9,24 +9,31 @@ import {
9
9
  copyFileSync,
10
10
  existsSync,
11
11
  mkdirSync,
12
- readdirSync,
13
12
  readFileSync,
14
13
  renameSync,
14
+ rmdirSync,
15
15
  rmSync,
16
16
  writeFileSync
17
17
  } from "node:fs"
18
18
  import { tmpdir } from "node:os"
19
+ import { bunBootstrap } from "./bun"
19
20
  import { syncClaudeModel } from "./claudeModel"
20
- import { copyFileIfChanged, copyTreeIfChanged, ensureExecutable, p, writeFileIfChanged } from "./exec"
21
+ import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
22
+ import { p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
21
23
  import type { Ctx } from "./index"
22
24
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
23
25
  import type { EngineServices } from "./services"
24
26
  import { ExitError } from "./parseArgs"
25
27
  import { mergeSettings, reconcileSettings } from "./settings"
26
28
  import { ensure, field } from "./toolchain"
29
+ import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
27
30
 
28
- export function claudeSync(ctx: Ctx): void {
29
- const { warn } = ctx.services.logger
31
+ export type ClaudeRuntimeState =
32
+ | { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
33
+ | { readonly kind: "deferred"; readonly reason: "bun-unavailable" }
34
+
35
+ export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
36
+ const { err, warn } = ctx.services.logger
30
37
  const claudeDir = p(ctx.home, ".claude")
31
38
 
32
39
  if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
@@ -38,26 +45,58 @@ export function claudeSync(ctx: Ctx): void {
38
45
  }
39
46
 
40
47
  syncRtk(ctx, claudeDir)
41
- syncScripts(ctx, claudeDir)
42
- syncHooks(ctx, claudeDir)
48
+ const bun = bunBootstrap(ctx, ctx.services)
49
+ const runtime: ClaudeRuntimeState = bun.kind === "ready"
50
+ ? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
51
+ : { kind: "deferred", reason: "bun-unavailable" }
52
+ const template = parseJson(payloadText("SoT/.claude/settings.json"))
53
+ if (template === undefined) {
54
+ err("Embedded SoT/.claude/settings.json is not valid JSON")
55
+ throw new ExitError(1)
56
+ }
57
+ const materialized = materializeClaudeSettings(
58
+ template,
59
+ runtime.kind === "ready" ? runtime.paths : undefined,
60
+ ctx.services.platform
61
+ )
62
+ const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
63
+
64
+ syncClaudeRuntime(ctx, runtime)
43
65
  syncClaudeMd(ctx, claudeDir)
44
- syncSettings(ctx, claudeDir)
66
+ if (ctx.dryRun) {
67
+ describeSettingsSync(ctx, claudeDir)
68
+ } else {
69
+ if (prepared === undefined) throw new Error("Claude settings were not prepared")
70
+ commitClaudeSettings(ctx, prepared)
71
+ }
72
+ syncRemovals(ctx, claudeDir, runtime)
45
73
  syncCompactWindow(ctx, claudeDir)
46
74
  syncPermissive(ctx, claudeDir)
47
75
  syncClaudeModel(ctx, ctx.claudeModel)
48
76
  syncClaudeJson(ctx)
49
77
  syncConnectorEnv(ctx)
50
- syncRemovals(ctx, claudeDir)
51
78
  syncPlugins(ctx, claudeDir)
52
79
  syncOptionalPlugins(ctx, claudeDir)
53
80
  syncLspServers(ctx)
81
+ return runtime
54
82
  }
55
83
 
56
84
  // ------------------------------------------------------------------ rtk ----
57
85
 
58
- /** RTK toolchain install callback. */
59
- export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
60
- return (mode, version, services) => {
86
+ type RtkInstaller = ((mode: "install" | "upgrade", version: string, services: EngineServices) => number) & {
87
+ readonly prerequisite: (services: EngineServices) => number | undefined
88
+ }
89
+
90
+ /** RTK toolchain install callback with the shared contextual curl boundary. */
91
+ export function rtkInstall(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): RtkInstaller {
92
+ const prerequisite = (services: EngineServices): number | undefined => {
93
+ if (services.deps.probe("curl").state === "present") return undefined
94
+ services.deps.warnMissing("curl", services.logger, missingCurlContext)
95
+ return missingCurlExit
96
+ }
97
+ const install = (mode: "install" | "upgrade", version: string, services: EngineServices): number => {
98
+ const blocked = prerequisite(services)
99
+ if (blocked !== undefined) return blocked
61
100
  const { change, err, verbose, warn } = services.logger
62
101
  const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
63
102
 
@@ -83,6 +122,16 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
83
122
  err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
84
123
  return 1
85
124
  }
125
+ return Object.assign(install, { prerequisite })
126
+ }
127
+
128
+ export function ensureRtk(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): number {
129
+ const installer = rtkInstall(ctx, missingCurlContext, missingCurlExit)
130
+ if (ctx.services.deps.probe("rtk").state === "missing") {
131
+ const blocked = installer.prerequisite(ctx.services)
132
+ if (blocked !== undefined) return blocked
133
+ }
134
+ return ensure(ctx, "rtk", installer)
86
135
  }
87
136
 
88
137
  function syncRtk(ctx: Ctx, claudeDir: string): void {
@@ -97,7 +146,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
97
146
  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")
98
147
  return
99
148
  }
100
- } else if (ensure(ctx, "rtk", rtkInstall(ctx)) !== 0) {
149
+ } else if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
101
150
  warn("RTK bootstrap failed — continuing sync without it")
102
151
  }
103
152
 
@@ -117,61 +166,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
117
166
  }
118
167
  }
119
168
 
120
- // ------------------------------------------------------ scripts + hooks ----
169
+ // ----------------------------------------------------------- runtime ----
121
170
 
122
- function syncScripts(ctx: Ctx, claudeDir: string): void {
123
- const { change, echo, verbose } = ctx.services.logger
124
- if (ctx.dryRun) {
125
- echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
171
+ function syncClaudeRuntime(ctx: Ctx, runtime: ClaudeRuntimeState): void {
172
+ const { change, echo, verbose, warn } = ctx.services.logger
173
+ if (runtime.kind === "deferred") {
174
+ warn("Bun unavailable — Claude statusline/hooks migration deferred; install Bun, then re-run sync claude")
126
175
  return
127
176
  }
128
-
129
- let changed = false
130
- for (const script of ["statusline.sh", "fetch-usage.sh"]) {
131
- const src = p(ctx.repoDir, "SoT", ".claude", script)
132
- if (existsSync(src)) {
133
- if (copyFileIfChanged(src, p(claudeDir, script))) changed = true
134
- if (ensureExecutable(p(claudeDir, script))) changed = true
135
- }
136
- }
137
- const mp3 = p(ctx.repoDir, "notification.mp3")
138
- if (existsSync(mp3) && copyFileIfChanged(mp3, p(claudeDir, "notification.mp3"))) changed = true
139
- if (changed) {
140
- change("Scripts synced (statusline, fetch-usage, notification)")
141
- ctx.nextStepTriggers.claudeRestart = true
142
- } else verbose("Scripts already in sync (statusline, fetch-usage, notification)")
143
- }
144
-
145
- function shellScriptCount(hooksDir: string): number {
146
- try {
147
- return readdirSync(hooksDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".sh")).length
148
- } catch {
149
- return 0
150
- }
151
- }
152
-
153
- function syncHooks(ctx: Ctx, claudeDir: string): void {
154
- const { change, echo, verbose } = ctx.services.logger
155
- const sotHooks = p(ctx.repoDir, "SoT", ".claude", "hooks")
156
- if (!existsSync(sotHooks)) return
157
-
158
177
  if (ctx.dryRun) {
159
- echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
178
+ echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
160
179
  return
161
180
  }
162
181
 
163
- const hooksDir = p(claudeDir, "hooks")
164
- mkdirSync(hooksDir, { recursive: true })
165
- let changed = copyTreeIfChanged(sotHooks, hooksDir)
166
- for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
167
- if (e.isFile() && e.name.endsWith(".sh")) {
168
- if (ensureExecutable(p(hooksDir, e.name))) changed = true
169
- }
170
- }
182
+ mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true })
183
+ let changed = false
184
+ for (const [path, source] of [
185
+ [runtime.paths.statusline, "SoT/.claude/bin/statusline.mjs"],
186
+ [runtime.paths.sessionStart, "SoT/.claude/bin/session-start.mjs"],
187
+ [runtime.paths.notify, "SoT/.claude/bin/notify.mjs"]
188
+ ] as const) {
189
+ if (writeTextIfChanged(path, payloadText(source))) changed = true
190
+ }
191
+ if (writeBytesIfChanged(p(ctx.home, ".claude", "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
171
192
  if (changed) {
172
- change(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
193
+ change("Claude runtime synced (statusline, session-start, notify, notification)")
173
194
  ctx.nextStepTriggers.claudeRestart = true
174
- } else verbose(`Hooks already in sync (${shellScriptCount(hooksDir)} scripts)`)
195
+ } else {
196
+ verbose("Claude runtime already in sync (statusline, session-start, notify, notification)")
197
+ }
175
198
  }
176
199
 
177
200
  function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
@@ -193,10 +216,10 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
193
216
  return
194
217
  }
195
218
 
196
- const src = p(ctx.repoDir, "SoT", ".claude", "CLAUDE.md")
219
+ const source = payloadText("SoT/.claude/CLAUDE.md")
197
220
  const stripReason = ctx.skipRtk ? "--skip-rtk" : rtkMdAbsent ? "~/.claude/RTK.md absent (rtk not initialized)" : ""
198
221
  if (stripReason !== "") {
199
- const stripped = readFileSync(src, "utf8")
222
+ const stripped = source
200
223
  .split("\n")
201
224
  .filter((l) => l !== "@RTK.md")
202
225
  .join("\n")
@@ -205,7 +228,7 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
205
228
  } else {
206
229
  verbose("CLAUDE.md already in sync")
207
230
  }
208
- } else if (copyFileIfChanged(src, p(claudeDir, "CLAUDE.md"))) {
231
+ } else if (writeTextIfChanged(p(claudeDir, "CLAUDE.md"), source)) {
209
232
  change("CLAUDE.md synced")
210
233
  } else {
211
234
  verbose("CLAUDE.md already in sync")
@@ -214,54 +237,73 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
214
237
 
215
238
  // ------------------------------------------------------------- settings ----
216
239
 
217
- function syncSettings(ctx: Ctx, claudeDir: string): void {
218
- const { change, echo, err, verbose } = ctx.services.logger
219
- const repoSettings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
220
- const userSettings = p(claudeDir, "settings.json")
240
+ export interface PreparedClaudeSettings {
241
+ readonly path: string
242
+ readonly bytes: string
243
+ readonly previousBytes: string | undefined
244
+ readonly changed: boolean
245
+ }
221
246
 
222
- if (ctx.dryRun) {
223
- if (!existsSync(userSettings)) {
224
- echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
225
- } else if (ctx.reconcile) {
226
- echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
227
- } else {
228
- echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
229
- }
230
- return
231
- }
247
+ function assertMaterializedSettings(bytes: string): void {
248
+ if (bytes.includes("__DOCKS_KIT_")) throw new Error("Claude settings contain unresolved runtime sentinels")
249
+ }
232
250
 
233
- if (!existsSync(userSettings)) {
234
- copyFileSync(repoSettings, userSettings)
235
- change("Settings installed")
236
- ctx.nextStepTriggers.claudeRestart = true
237
- return
251
+ /** Build the candidate settings bytes before the readiness-gated runtime cutover mutates disk. */
252
+ export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json): PreparedClaudeSettings {
253
+ const path = p(claudeDir, "settings.json")
254
+ if (!existsSync(path)) {
255
+ const bytes = jqStringify(repo)
256
+ assertMaterializedSettings(bytes)
257
+ return { path, bytes, previousBytes: undefined, changed: true }
238
258
  }
239
259
 
240
- const user = parseJson(readFileSync(userSettings, "utf8"))
260
+ const previousBytes = readFileSync(path, "utf8")
261
+ const user = parseJson(previousBytes)
241
262
  if (user === undefined) {
242
- // claude::_settings_validate returns 1 claude::sync aborts under set -e.
243
- err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
263
+ ctx.services.logger.err(`Skipping settings sync: ${path} is not valid JSON. Fix it manually or delete it to reinstall.`)
244
264
  throw new ExitError(1)
245
265
  }
246
- const repo = parseJson(readFileSync(repoSettings, "utf8"))!
247
-
248
266
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
249
- const out = jqStringify(merged)
250
- if (out === readFileSync(userSettings, "utf8")) {
267
+ const bytes = jqStringify(merged)
268
+ assertMaterializedSettings(bytes)
269
+ return { path, bytes, previousBytes, changed: bytes !== previousBytes }
270
+ }
271
+
272
+ /** Commit a fully prepared document; callers must finish runtime preparation first. */
273
+ export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings): void {
274
+ const { change, verbose } = ctx.services.logger
275
+ if (!prepared.changed) {
251
276
  verbose("Settings already in sync")
252
277
  return
253
278
  }
254
- copyFileSync(userSettings, `${userSettings}.bak`)
255
- writeFileSync(`${userSettings}.tmp`, out)
256
- renameSync(`${userSettings}.tmp`, userSettings)
279
+
280
+ if (prepared.previousBytes !== undefined) copyFileSync(prepared.path, `${prepared.path}.bak`)
281
+ writeFileSync(`${prepared.path}.tmp`, prepared.bytes)
282
+ renameSync(`${prepared.path}.tmp`, prepared.path)
257
283
  ctx.nextStepTriggers.claudeRestart = true
258
- if (ctx.reconcile) {
284
+ if (prepared.previousBytes === undefined) {
285
+ change("Settings installed")
286
+ } else if (ctx.reconcile) {
259
287
  change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
260
288
  } else {
261
289
  change("Settings merged (backup at settings.json.bak)")
262
290
  }
263
291
  }
264
292
 
293
+ function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
294
+ const { echo } = ctx.services.logger
295
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
296
+ const userSettings = p(claudeDir, "settings.json")
297
+
298
+ if (!existsSync(userSettings)) {
299
+ echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
300
+ } else if (ctx.reconcile) {
301
+ echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
302
+ } else {
303
+ echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
304
+ }
305
+ }
306
+
265
307
  /** Shared shape of the three jq-edit modifiers (compact window, permissive). */
266
308
  function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
267
309
  const { err, warn } = ctx.services.logger
@@ -334,10 +376,8 @@ function syncPermissive(ctx: Ctx, claudeDir: string): void {
334
376
  function syncClaudeJson(ctx: Ctx): void {
335
377
  const { change, echo, err, verbose } = ctx.services.logger
336
378
  const claudeJson = p(ctx.home, ".claude.json")
337
- const mcpSot = p(ctx.repoDir, "SoT", ".claude", "mcp-servers.json")
338
379
 
339
- let mcp: Json | undefined
340
- if (existsSync(mcpSot)) mcp = parseJson(readFileSync(mcpSot, "utf8"))
380
+ const mcp: Json | undefined = parseJson(payloadText("SoT/.claude/mcp-servers.json"))
341
381
  const haveMcp = mcp !== undefined
342
382
 
343
383
  if (ctx.dryRun) {
@@ -456,7 +496,12 @@ const REMOVED_MANIFEST = {
456
496
  "env.CLAUDE_CODE_FORK_SUBAGENT",
457
497
  "env.CLAUDE_CODE_EFFORT_LEVEL"
458
498
  ],
459
- claudeJsonKeys: [] as Array<string>
499
+ claudeJsonKeys: [] as Array<string>,
500
+ runtimeReady: {
501
+ hooks: ["notify.sh"],
502
+ files: ["statusline.sh", "fetch-usage.sh"],
503
+ settingsKeys: ["hooks.Stop"]
504
+ }
460
505
  }
461
506
 
462
507
  /** claude::_prune_json_keys — present-count; deletes when !dryRun. */
@@ -465,18 +510,15 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
465
510
  const doc = parseJson(readFileSync(file, "utf8"))
466
511
  if (doc === undefined) return 0
467
512
 
468
- const getPath = (root: Json, path: Array<string>): Json | undefined => {
469
- let cur: Json | undefined = root
470
- for (const seg of path) {
471
- if (cur === undefined || !isObject(cur)) return undefined
472
- cur = cur[seg]
513
+ const hasPath = (root: Json, path: Array<string>): boolean => {
514
+ let cur: Json = root
515
+ for (const seg of path.slice(0, -1)) {
516
+ if (!isObject(cur) || cur[seg] === undefined) return false
517
+ cur = cur[seg]!
473
518
  }
474
- return cur
519
+ return isObject(cur) && Object.prototype.hasOwnProperty.call(cur, path[path.length - 1]!)
475
520
  }
476
- const presentKeys = keys.filter((k) => {
477
- const v = getPath(doc, k.split("."))
478
- return v !== undefined && v !== null
479
- })
521
+ const presentKeys = keys.filter((k) => hasPath(doc, k.split(".")))
480
522
  if (presentKeys.length === 0) return 0
481
523
 
482
524
  if (!ctx.dryRun) {
@@ -495,12 +537,24 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
495
537
  return presentKeys.length
496
538
  }
497
539
 
498
- function syncRemovals(ctx: Ctx, claudeDir: string): void {
540
+ function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState): void {
499
541
  const { change, echo } = ctx.services.logger
500
542
  let hooksRemoved = 0
501
543
  let filesRemoved = 0
502
-
503
- for (const name of REMOVED_MANIFEST.hooks) {
544
+ const hooks = [
545
+ ...REMOVED_MANIFEST.hooks,
546
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.hooks : [])
547
+ ]
548
+ const files = [
549
+ ...REMOVED_MANIFEST.files,
550
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.files : [])
551
+ ]
552
+ const settingsKeys = [
553
+ ...REMOVED_MANIFEST.settingsKeys,
554
+ ...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.settingsKeys : [])
555
+ ]
556
+
557
+ for (const name of hooks) {
504
558
  const path = p(claudeDir, "hooks", name)
505
559
  if (!existsSync(path)) continue
506
560
  if (ctx.dryRun) {
@@ -510,8 +564,15 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
510
564
  hooksRemoved++
511
565
  }
512
566
  }
567
+ if (!ctx.dryRun && hooksRemoved > 0) {
568
+ try {
569
+ rmdirSync(p(claudeDir, "hooks"))
570
+ } catch {
571
+ // Preserve a non-empty user hooks directory.
572
+ }
573
+ }
513
574
 
514
- for (const rel of REMOVED_MANIFEST.files) {
575
+ for (const rel of files) {
515
576
  const path = p(claudeDir, rel)
516
577
  if (!existsSync(path)) continue
517
578
  if (ctx.dryRun) {
@@ -522,7 +583,7 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
522
583
  }
523
584
  }
524
585
 
525
- const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), REMOVED_MANIFEST.settingsKeys)
586
+ const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), settingsKeys)
526
587
  const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
527
588
 
528
589
  if (ctx.dryRun) {
@@ -564,7 +625,6 @@ function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): b
564
625
 
565
626
  function syncPlugins(ctx: Ctx, claudeDir: string): void {
566
627
  const { change, echo, verbose, warn } = ctx.services.logger
567
- const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
568
628
  const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
569
629
  const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
570
630
 
@@ -589,7 +649,7 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
589
649
  return
590
650
  }
591
651
 
592
- const repoSettings = readJsonFile(repoSettingsFile)
652
+ const repoSettings = parseJson(payloadText("SoT/.claude/settings.json"))
593
653
  const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
594
654
  const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
595
655
  const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
@@ -799,7 +859,7 @@ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
799
859
 
800
860
  function syncLspServers(ctx: Ctx): void {
801
861
  const { change, echo, verbose, warn } = ctx.services.logger
802
- const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
862
+ const sot = parseJson(payloadText("SoT/.claude/settings.json"))
803
863
  const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
804
864
  if (enabled === undefined) return
805
865
  const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
@@ -848,12 +908,16 @@ function syncLspServers(ctx: Ctx): void {
848
908
 
849
909
  // -------------------------------------------------------------- summary ----
850
910
 
851
- export function claudeSummary(ctx: Ctx): void {
911
+ export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
852
912
  const { echo } = ctx.services.logger
853
913
  const claudeDir = p(ctx.home, ".claude")
854
914
  echo(`Claude: ${claudeDir}`)
855
915
  if (!ctx.dryRun) {
856
- echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
916
+ if (runtime.kind === "ready") {
917
+ echo("Hooks: Bun (statusline, session-start, notify)")
918
+ } else {
919
+ echo("Hooks: migration deferred (Bun unavailable; existing hook/statusline settings preserved)")
920
+ }
857
921
  if (ctx.services.deps.probe("rtk").state === "present") {
858
922
  const version = ctx.services.deps.version("rtk")
859
923
  echo(`RTK: ${version !== "" ? version : "installed"}`)