docks-kit 0.3.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 +4 -2
- package/README.md +9 -3
- package/cli/docs/install.md +3 -1
- package/cli/docs/overview.md +4 -1
- package/cli/docs/platforms.md +5 -2
- package/cli/docs/sync-layers.md +15 -7
- package/cli/docs/toolchain.md +8 -2
- package/cli/src/engine-native/DESIGN.md +21 -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 +169 -103
- package/cli/src/engine-native/deps.ts +27 -10
- package/cli/src/engine-native/index.ts +5 -4
- package/cli/src/engine-native/modes.ts +5 -5
- 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 +4 -36
- package/cli/src/generated/sotPayload.ts +9 -9
- package/package.json +1 -1
|
@@ -9,15 +9,17 @@ 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"
|
|
@@ -26,8 +28,12 @@ import { mergeSettings, reconcileSettings } from "./settings"
|
|
|
26
28
|
import { ensure, field } from "./toolchain"
|
|
27
29
|
import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
|
|
28
30
|
|
|
29
|
-
export
|
|
30
|
-
|
|
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
|
|
31
37
|
const claudeDir = p(ctx.home, ".claude")
|
|
32
38
|
|
|
33
39
|
if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
|
|
@@ -39,26 +45,58 @@ export function claudeSync(ctx: Ctx): void {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
syncRtk(ctx, claudeDir)
|
|
42
|
-
|
|
43
|
-
|
|
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)
|
|
44
65
|
syncClaudeMd(ctx, claudeDir)
|
|
45
|
-
|
|
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)
|
|
46
73
|
syncCompactWindow(ctx, claudeDir)
|
|
47
74
|
syncPermissive(ctx, claudeDir)
|
|
48
75
|
syncClaudeModel(ctx, ctx.claudeModel)
|
|
49
76
|
syncClaudeJson(ctx)
|
|
50
77
|
syncConnectorEnv(ctx)
|
|
51
|
-
syncRemovals(ctx, claudeDir)
|
|
52
78
|
syncPlugins(ctx, claudeDir)
|
|
53
79
|
syncOptionalPlugins(ctx, claudeDir)
|
|
54
80
|
syncLspServers(ctx)
|
|
81
|
+
return runtime
|
|
55
82
|
}
|
|
56
83
|
|
|
57
84
|
// ------------------------------------------------------------------ rtk ----
|
|
58
85
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
62
100
|
const { change, err, verbose, warn } = services.logger
|
|
63
101
|
const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
|
|
64
102
|
|
|
@@ -84,6 +122,16 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
|
|
|
84
122
|
err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
|
|
85
123
|
return 1
|
|
86
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)
|
|
87
135
|
}
|
|
88
136
|
|
|
89
137
|
function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
@@ -98,7 +146,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
|
98
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")
|
|
99
147
|
return
|
|
100
148
|
}
|
|
101
|
-
} else if (
|
|
149
|
+
} else if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
|
|
102
150
|
warn("RTK bootstrap failed — continuing sync without it")
|
|
103
151
|
}
|
|
104
152
|
|
|
@@ -118,60 +166,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
|
118
166
|
}
|
|
119
167
|
}
|
|
120
168
|
|
|
121
|
-
//
|
|
169
|
+
// ----------------------------------------------------------- runtime ----
|
|
122
170
|
|
|
123
|
-
function
|
|
124
|
-
const { change, echo, verbose } = ctx.services.logger
|
|
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")
|
|
175
|
+
return
|
|
176
|
+
}
|
|
125
177
|
if (ctx.dryRun) {
|
|
126
|
-
echo("[dry-run]
|
|
178
|
+
echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
|
|
127
179
|
return
|
|
128
180
|
}
|
|
129
181
|
|
|
182
|
+
mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true })
|
|
130
183
|
let changed = false
|
|
131
|
-
for (const [
|
|
132
|
-
[
|
|
133
|
-
[
|
|
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"]
|
|
134
188
|
] as const) {
|
|
135
|
-
const path = p(claudeDir, script)
|
|
136
189
|
if (writeTextIfChanged(path, payloadText(source))) changed = true
|
|
137
|
-
if (ensureExecutable(path)) changed = true
|
|
138
190
|
}
|
|
139
|
-
if (writeBytesIfChanged(p(
|
|
191
|
+
if (writeBytesIfChanged(p(ctx.home, ".claude", "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
|
|
140
192
|
if (changed) {
|
|
141
|
-
change("
|
|
193
|
+
change("Claude runtime synced (statusline, session-start, notify, notification)")
|
|
142
194
|
ctx.nextStepTriggers.claudeRestart = true
|
|
143
|
-
} else
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
function shellScriptCount(hooksDir: string): number {
|
|
147
|
-
try {
|
|
148
|
-
return readdirSync(hooksDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".sh")).length
|
|
149
|
-
} catch {
|
|
150
|
-
return 0
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function syncHooks(ctx: Ctx, claudeDir: string): void {
|
|
155
|
-
const { change, echo, verbose } = ctx.services.logger
|
|
156
|
-
const sotHooks = payloadDisplayPath("SoT/.claude/hooks/notify.sh", ctx.repoDir).replace(/\/notify\.sh$/, "")
|
|
157
|
-
|
|
158
|
-
if (ctx.dryRun) {
|
|
159
|
-
echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const hooksDir = p(claudeDir, "hooks")
|
|
164
|
-
mkdirSync(hooksDir, { recursive: true })
|
|
165
|
-
let changed = writeTextIfChanged(p(hooksDir, "notify.sh"), payloadText("SoT/.claude/hooks/notify.sh"))
|
|
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
|
-
}
|
|
195
|
+
} else {
|
|
196
|
+
verbose("Claude runtime already in sync (statusline, session-start, notify, notification)")
|
|
170
197
|
}
|
|
171
|
-
if (changed) {
|
|
172
|
-
change(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
|
|
173
|
-
ctx.nextStepTriggers.claudeRestart = true
|
|
174
|
-
} else verbose(`Hooks already in sync (${shellScriptCount(hooksDir)} scripts)`)
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
@@ -214,55 +237,73 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
|
214
237
|
|
|
215
238
|
// ------------------------------------------------------------- settings ----
|
|
216
239
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
240
|
+
export interface PreparedClaudeSettings {
|
|
241
|
+
readonly path: string
|
|
242
|
+
readonly bytes: string
|
|
243
|
+
readonly previousBytes: string | undefined
|
|
244
|
+
readonly changed: boolean
|
|
245
|
+
}
|
|
222
246
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
} else if (ctx.reconcile) {
|
|
227
|
-
echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
|
|
228
|
-
} else {
|
|
229
|
-
echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
|
|
230
|
-
}
|
|
231
|
-
return
|
|
232
|
-
}
|
|
247
|
+
function assertMaterializedSettings(bytes: string): void {
|
|
248
|
+
if (bytes.includes("__DOCKS_KIT_")) throw new Error("Claude settings contain unresolved runtime sentinels")
|
|
249
|
+
}
|
|
233
250
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 }
|
|
239
258
|
}
|
|
240
259
|
|
|
241
|
-
const
|
|
260
|
+
const previousBytes = readFileSync(path, "utf8")
|
|
261
|
+
const user = parseJson(previousBytes)
|
|
242
262
|
if (user === undefined) {
|
|
243
|
-
|
|
244
|
-
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.`)
|
|
245
264
|
throw new ExitError(1)
|
|
246
265
|
}
|
|
247
|
-
const repo = parseJson(repoSettingsText)!
|
|
248
|
-
|
|
249
266
|
const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
|
|
250
|
-
const
|
|
251
|
-
|
|
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) {
|
|
252
276
|
verbose("Settings already in sync")
|
|
253
277
|
return
|
|
254
278
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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)
|
|
258
283
|
ctx.nextStepTriggers.claudeRestart = true
|
|
259
|
-
if (
|
|
284
|
+
if (prepared.previousBytes === undefined) {
|
|
285
|
+
change("Settings installed")
|
|
286
|
+
} else if (ctx.reconcile) {
|
|
260
287
|
change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
|
|
261
288
|
} else {
|
|
262
289
|
change("Settings merged (backup at settings.json.bak)")
|
|
263
290
|
}
|
|
264
291
|
}
|
|
265
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
|
+
|
|
266
307
|
/** Shared shape of the three jq-edit modifiers (compact window, permissive). */
|
|
267
308
|
function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
|
|
268
309
|
const { err, warn } = ctx.services.logger
|
|
@@ -455,7 +496,12 @@ const REMOVED_MANIFEST = {
|
|
|
455
496
|
"env.CLAUDE_CODE_FORK_SUBAGENT",
|
|
456
497
|
"env.CLAUDE_CODE_EFFORT_LEVEL"
|
|
457
498
|
],
|
|
458
|
-
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
|
+
}
|
|
459
505
|
}
|
|
460
506
|
|
|
461
507
|
/** claude::_prune_json_keys — present-count; deletes when !dryRun. */
|
|
@@ -464,18 +510,15 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
|
|
|
464
510
|
const doc = parseJson(readFileSync(file, "utf8"))
|
|
465
511
|
if (doc === undefined) return 0
|
|
466
512
|
|
|
467
|
-
const
|
|
468
|
-
let cur: Json
|
|
469
|
-
for (const seg of path) {
|
|
470
|
-
if (cur === undefined
|
|
471
|
-
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]!
|
|
472
518
|
}
|
|
473
|
-
return cur
|
|
519
|
+
return isObject(cur) && Object.prototype.hasOwnProperty.call(cur, path[path.length - 1]!)
|
|
474
520
|
}
|
|
475
|
-
const presentKeys = keys.filter((k) =>
|
|
476
|
-
const v = getPath(doc, k.split("."))
|
|
477
|
-
return v !== undefined && v !== null
|
|
478
|
-
})
|
|
521
|
+
const presentKeys = keys.filter((k) => hasPath(doc, k.split(".")))
|
|
479
522
|
if (presentKeys.length === 0) return 0
|
|
480
523
|
|
|
481
524
|
if (!ctx.dryRun) {
|
|
@@ -494,12 +537,24 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
|
|
|
494
537
|
return presentKeys.length
|
|
495
538
|
}
|
|
496
539
|
|
|
497
|
-
function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
540
|
+
function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState): void {
|
|
498
541
|
const { change, echo } = ctx.services.logger
|
|
499
542
|
let hooksRemoved = 0
|
|
500
543
|
let filesRemoved = 0
|
|
501
|
-
|
|
502
|
-
|
|
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) {
|
|
503
558
|
const path = p(claudeDir, "hooks", name)
|
|
504
559
|
if (!existsSync(path)) continue
|
|
505
560
|
if (ctx.dryRun) {
|
|
@@ -509,8 +564,15 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
|
509
564
|
hooksRemoved++
|
|
510
565
|
}
|
|
511
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
|
+
}
|
|
512
574
|
|
|
513
|
-
for (const rel of
|
|
575
|
+
for (const rel of files) {
|
|
514
576
|
const path = p(claudeDir, rel)
|
|
515
577
|
if (!existsSync(path)) continue
|
|
516
578
|
if (ctx.dryRun) {
|
|
@@ -521,7 +583,7 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
|
521
583
|
}
|
|
522
584
|
}
|
|
523
585
|
|
|
524
|
-
const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"),
|
|
586
|
+
const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), settingsKeys)
|
|
525
587
|
const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
|
|
526
588
|
|
|
527
589
|
if (ctx.dryRun) {
|
|
@@ -846,12 +908,16 @@ function syncLspServers(ctx: Ctx): void {
|
|
|
846
908
|
|
|
847
909
|
// -------------------------------------------------------------- summary ----
|
|
848
910
|
|
|
849
|
-
export function claudeSummary(ctx: Ctx): void {
|
|
911
|
+
export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
|
|
850
912
|
const { echo } = ctx.services.logger
|
|
851
913
|
const claudeDir = p(ctx.home, ".claude")
|
|
852
914
|
echo(`Claude: ${claudeDir}`)
|
|
853
915
|
if (!ctx.dryRun) {
|
|
854
|
-
|
|
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
|
+
}
|
|
855
921
|
if (ctx.services.deps.probe("rtk").state === "present") {
|
|
856
922
|
const version = ctx.services.deps.version("rtk")
|
|
857
923
|
echo(`RTK: ${version !== "" ? version : "installed"}`)
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* command that installs a missing one.
|
|
9
9
|
*/
|
|
10
10
|
import { homedir } from "node:os"
|
|
11
|
+
import { isAbsolute } from "node:path"
|
|
11
12
|
import { existsSync, readdirSync } from "node:fs"
|
|
12
13
|
|
|
13
14
|
import { capture, commandExists, p, which } from "./exec"
|
|
@@ -110,21 +111,34 @@ const home = (): string => {
|
|
|
110
111
|
return envHome !== undefined && envHome !== "" ? envHome : homedir()
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
const
|
|
114
|
+
const absoluteWindowsExe = (path: string): boolean =>
|
|
115
|
+
/\.exe$/i.test(path) && (/^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path))
|
|
116
|
+
|
|
117
|
+
// The resolved path gets persisted into global direct-exec hooks, so a
|
|
118
|
+
// relative `which` hit (relative PATH entry, relative BUN_INSTALL) would
|
|
119
|
+
// break outside the sync working directory.
|
|
120
|
+
const absoluteExecutable = (path: string, platform: NodeJS.Platform): boolean =>
|
|
121
|
+
platform === "win32" ? absoluteWindowsExe(path) : isAbsolute(path)
|
|
122
|
+
|
|
123
|
+
const findBun = (exec: ProbeExecutor, platform: NodeJS.Platform = rawPlatform()): { command: string; path: string } | undefined => {
|
|
114
124
|
const onPath = exec.which("bun")
|
|
115
|
-
if (onPath !== ""
|
|
125
|
+
if (onPath !== "" && absoluteExecutable(onPath, platform)) {
|
|
126
|
+
return { command: platform === "win32" ? onPath : "bun", path: onPath }
|
|
127
|
+
}
|
|
116
128
|
const root =
|
|
117
129
|
process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
|
|
118
130
|
? process.env["BUN_INSTALL"]!
|
|
119
131
|
: p(home(), ".bun")
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
const name = platform === "win32" ? "bun.exe" : "bun"
|
|
133
|
+
for (const candidate of [p(root, "bin", name), p(home(), ".bun", "bin", name)]) {
|
|
134
|
+
const found = exec.which(candidate)
|
|
135
|
+
if (found !== "" && absoluteExecutable(found, platform)) return { command: found, path: found }
|
|
122
136
|
}
|
|
123
137
|
return undefined
|
|
124
138
|
}
|
|
125
139
|
|
|
126
|
-
const resolveBun = (exec: ProbeExecutor): ProbeResult => {
|
|
127
|
-
const bun = findBun(exec)
|
|
140
|
+
const resolveBun = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeResult => {
|
|
141
|
+
const bun = findBun(exec, platform)
|
|
128
142
|
return bun === undefined
|
|
129
143
|
? { state: "missing" }
|
|
130
144
|
: { state: "present", path: bun.path }
|
|
@@ -147,7 +161,9 @@ const versionEffectSolutions = (exec: ProbeExecutor): string => {
|
|
|
147
161
|
}
|
|
148
162
|
|
|
149
163
|
const locateEffectSolutions = (exec: ProbeExecutor, platform: NodeJS.Platform): DependencyLocation => {
|
|
150
|
-
const
|
|
164
|
+
const strictBun = findBun(exec, platform)
|
|
165
|
+
const pathBun = exec.which("bun")
|
|
166
|
+
const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
|
|
151
167
|
if (bun === undefined) return { path: "", binDir: "" }
|
|
152
168
|
const globalBin = exec.capture(bun.command, ["pm", "-g", "bin"])
|
|
153
169
|
const names =
|
|
@@ -180,6 +196,7 @@ const resolveChrome = (exec: ProbeExecutor, platform: NodeJS.Platform): ProbeRes
|
|
|
180
196
|
}
|
|
181
197
|
|
|
182
198
|
const latestRtk = (exec: ProbeExecutor): string => {
|
|
199
|
+
if (!exec.commandExists("curl")) return ""
|
|
183
200
|
const doc = parseJson(
|
|
184
201
|
exec.capture("curl", ["-fsSL", "--max-time", "5", "https://api.github.com/repos/rtk-ai/rtk/releases/latest"])
|
|
185
202
|
)
|
|
@@ -204,7 +221,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
|
204
221
|
: "sudo apt install -y git (or your distro's package manager)",
|
|
205
222
|
{ version: versionProbe("git") }
|
|
206
223
|
),
|
|
207
|
-
jq: spec("jq", "
|
|
224
|
+
jq: spec("jq", "optional", (pf = rawPlatform()) =>
|
|
208
225
|
pf === "win32"
|
|
209
226
|
? "winget install jqlang.jq (then open a new terminal)"
|
|
210
227
|
: pf === "darwin"
|
|
@@ -212,7 +229,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
|
212
229
|
: "sudo apt install -y jq",
|
|
213
230
|
{ version: versionProbe("jq") }
|
|
214
231
|
),
|
|
215
|
-
curl: spec("curl", "
|
|
232
|
+
curl: spec("curl", "optional", (pf = rawPlatform()) =>
|
|
216
233
|
pf === "win32" ? "winget install cURL.cURL" : pf === "darwin" ? "brew install curl" : "sudo apt install -y curl",
|
|
217
234
|
{ version: versionProbe("curl") }
|
|
218
235
|
),
|
|
@@ -251,7 +268,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
|
|
|
251
268
|
{
|
|
252
269
|
resolve: resolveBun,
|
|
253
270
|
version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
|
|
254
|
-
locate: (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
|
|
271
|
+
locate: (exec, platform) => ({ path: findBun(exec, platform)?.path ?? "", binDir: "" })
|
|
255
272
|
}
|
|
256
273
|
),
|
|
257
274
|
bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)"),
|
|
@@ -10,11 +10,12 @@ import { homedir } from "node:os"
|
|
|
10
10
|
|
|
11
11
|
import { kitHome } from "../kitHome"
|
|
12
12
|
import { makeEngineServices, type EngineServices, type Logger } from "./services"
|
|
13
|
+
import type { BunRuntimeState } from "./bun"
|
|
13
14
|
import { claudeNextSteps, claudeSummary, claudeSync } from "./claudeSync"
|
|
14
15
|
import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
|
|
15
16
|
import { skillsNextSteps, skillsSummary, skillsSync } from "./skillsSync"
|
|
16
17
|
import { modeModel, modeToolchain } from "./modes"
|
|
17
|
-
import { ExitError, parseArgs,
|
|
18
|
+
import { ExitError, parseArgs, validateModelFlags } from "./parseArgs"
|
|
18
19
|
|
|
19
20
|
export interface Ctx {
|
|
20
21
|
readonly repoDir: string
|
|
@@ -33,6 +34,7 @@ export interface Ctx {
|
|
|
33
34
|
codexModel: string
|
|
34
35
|
/** Injected capability seam (logger/deps/platform) — see services.ts. */
|
|
35
36
|
readonly services: EngineServices
|
|
37
|
+
bunRuntime?: BunRuntimeState
|
|
36
38
|
targetFilterSet: boolean
|
|
37
39
|
syncClaude: boolean
|
|
38
40
|
syncCodex: boolean
|
|
@@ -77,11 +79,10 @@ function makeCtx(services: EngineServices): Ctx {
|
|
|
77
79
|
function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
|
|
78
80
|
const { echo } = ctx.services.logger
|
|
79
81
|
parseArgs(ctx, args)
|
|
80
|
-
preflight(ctx)
|
|
81
82
|
validateModelFlags(ctx)
|
|
82
83
|
|
|
83
84
|
const claudeRan = ctx.syncClaude
|
|
84
|
-
|
|
85
|
+
const claudeRuntime = claudeRan ? claudeSync(ctx) : undefined
|
|
85
86
|
|
|
86
87
|
const codexRan = ctx.syncCodex
|
|
87
88
|
if (codexRan) codexSync(ctx)
|
|
@@ -91,7 +92,7 @@ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
|
|
|
91
92
|
echo("")
|
|
92
93
|
echo("--- Sync complete ---")
|
|
93
94
|
echo(`Repo: ${ctx.repoDir}`)
|
|
94
|
-
if (
|
|
95
|
+
if (claudeRuntime !== undefined) claudeSummary(ctx, claudeRuntime)
|
|
95
96
|
if (codexRan) codexSummary(ctx)
|
|
96
97
|
if (skillsState !== undefined) skillsSummary(ctx, skillsState)
|
|
97
98
|
|
|
@@ -11,8 +11,9 @@ import { syncCodexModel } from "./codexToml"
|
|
|
11
11
|
import type { Ctx } from "./index"
|
|
12
12
|
import { isObject, parseJson, type Json } from "./jq"
|
|
13
13
|
import { printModels, validateClaudeModel, validateCodexModel } from "./models"
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { ensureRtk } from "./claudeSync"
|
|
15
|
+
import { bunBootstrap } from "./bun"
|
|
16
|
+
import { agentBrowserInstall, effectSolutionsInstall } from "./skillsSync"
|
|
16
17
|
import { ensure, report } from "./toolchain"
|
|
17
18
|
|
|
18
19
|
export function modeModel(ctx: Ctx, args: ReadonlyArray<string>): number {
|
|
@@ -134,10 +135,9 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
|
|
|
134
135
|
}
|
|
135
136
|
switch (tool) {
|
|
136
137
|
case "rtk":
|
|
137
|
-
return
|
|
138
|
+
return ensureRtk(ctx, "cannot download RTK installer; toolchain ensure rtk aborted", 1)
|
|
138
139
|
case "bun":
|
|
139
|
-
|
|
140
|
-
return bunBootstrap(ctx, ctx.services) !== "" ? 0 : 1
|
|
140
|
+
return bunBootstrap(ctx, ctx.services).kind === "ready" ? 0 : 1
|
|
141
141
|
case "effect-solutions":
|
|
142
142
|
return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
|
|
143
143
|
case "agent-browser":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EngineNative flag layer: usage / target selection / compact-window parsing /
|
|
3
|
-
* optional-plugin parsing / model validation
|
|
3
|
+
* optional-plugin parsing / model validation. ExitError mirrors an
|
|
4
4
|
* early parser exit and is caught once in runEngineNative.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -184,22 +184,6 @@ export function parseArgs(ctx: Ctx, args: ReadonlyArray<string>): void {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
export function preflight(ctx: Ctx): void {
|
|
188
|
-
const { err } = ctx.services.logger
|
|
189
|
-
if (ctx.syncClaude || ctx.syncCodex) {
|
|
190
|
-
if (ctx.services.deps.probe("jq").state === "missing") {
|
|
191
|
-
err(`jq is required (deployed statusline/hooks call it). Install: ${ctx.services.deps.spec("jq").installHint()}`)
|
|
192
|
-
throw new ExitError(1)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (ctx.syncClaude) {
|
|
196
|
-
if (ctx.services.deps.probe("curl").state === "missing") {
|
|
197
|
-
err(`curl is required. Install: ${ctx.services.deps.spec("curl").installHint()}`)
|
|
198
|
-
throw new ExitError(1)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
187
|
export function validateModelFlags(ctx: Ctx): void {
|
|
204
188
|
const { err, warn } = ctx.services.logger
|
|
205
189
|
if (ctx.claudeModel !== "") {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function powerShellLiteral(value: string): string {
|
|
2
|
+
return `'${value.replaceAll("'", "''")}'`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function encodePowerShellCommand(script: string): string {
|
|
6
|
+
return Buffer.from(script, "utf16le").toString("base64")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function decodePowerShellCommand(encoded: string): string {
|
|
10
|
+
return Buffer.from(encoded, "base64").toString("utf16le")
|
|
11
|
+
}
|