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.
- package/AGENTS.md +5 -3
- package/README.md +15 -7
- package/cli/docs/install.md +12 -12
- package/cli/docs/overview.md +7 -1
- package/cli/docs/platforms.md +8 -7
- package/cli/docs/sync-layers.md +15 -7
- package/cli/docs/toolchain.md +8 -2
- package/cli/src/engine-native/DESIGN.md +25 -14
- package/cli/src/engine-native/bun.ts +87 -0
- package/cli/src/engine-native/claudeRuntime.ts +132 -0
- package/cli/src/engine-native/claudeSync.ts +179 -115
- package/cli/src/engine-native/codexSync.ts +42 -36
- package/cli/src/engine-native/deps.ts +27 -10
- package/cli/src/engine-native/exec.ts +8 -23
- package/cli/src/engine-native/index.ts +8 -8
- package/cli/src/engine-native/models.ts +14 -16
- package/cli/src/engine-native/modes.ts +18 -9
- package/cli/src/engine-native/parseArgs.ts +1 -17
- package/cli/src/engine-native/powershell.ts +11 -0
- package/cli/src/engine-native/skillsSync.ts +11 -46
- package/cli/src/engine-native/toolchain.ts +6 -6
- package/cli/src/generated/sotPayload.ts +41 -0
- package/cli/src/kitHome.ts +15 -11
- package/cli/src/manifests.ts +17 -15
- package/cli/src/payload.ts +28 -0
- package/docks-kit +6 -6
- package/package.json +2 -3
- package/SoT/.agents/skills.txt +0 -14
- package/SoT/.claude/CLAUDE.md +0 -146
- package/SoT/.claude/fetch-usage.sh +0 -66
- package/SoT/.claude/hooks/notify.sh +0 -14
- package/SoT/.claude/mcp-servers.json +0 -10
- package/SoT/.claude/settings.json +0 -235
- package/SoT/.claude/statusline.sh +0 -175
- package/SoT/.codex/AGENTS.md +0 -75
- package/SoT/.codex/agents/.gitkeep +0 -1
- package/SoT/.codex/config.toml +0 -45
- package/SoT/.codex/plugins/marketplace.json +0 -50
- package/SoT/.codex/rules/docks.rules +0 -116
- package/SoT/models.json +0 -31
- package/SoT/toolchain.json +0 -27
- 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 {
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 (
|
|
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
|
-
//
|
|
169
|
+
// ----------------------------------------------------------- runtime ----
|
|
121
170
|
|
|
122
|
-
function
|
|
123
|
-
const { change, echo, verbose } = ctx.services.logger
|
|
124
|
-
if (
|
|
125
|
-
|
|
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(
|
|
178
|
+
echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
|
|
160
179
|
return
|
|
161
180
|
}
|
|
162
181
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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(
|
|
193
|
+
change("Claude runtime synced (statusline, session-start, notify, notification)")
|
|
173
194
|
ctx.nextStepTriggers.claudeRestart = true
|
|
174
|
-
} else
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
260
|
+
const previousBytes = readFileSync(path, "utf8")
|
|
261
|
+
const user = parseJson(previousBytes)
|
|
241
262
|
if (user === undefined) {
|
|
242
|
-
|
|
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
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
469
|
-
let cur: Json
|
|
470
|
-
for (const seg of path) {
|
|
471
|
-
if (cur === 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
|
-
|
|
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
|
|
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"),
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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"}`)
|