docks-kit 0.3.0 → 0.5.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 +7 -4
- package/README.md +9 -3
- package/cli/docs/flags.md +6 -1
- package/cli/docs/install.md +27 -1
- package/cli/docs/models.md +8 -8
- package/cli/docs/modifiers.md +20 -3
- package/cli/docs/overview.md +4 -1
- package/cli/docs/platforms.md +5 -2
- package/cli/docs/sync-layers.md +22 -11
- package/cli/docs/toolchain.md +8 -2
- package/cli/src/commands/sync.ts +34 -3
- package/cli/src/efforts.ts +91 -0
- 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/claudeSettingsModifiers.ts +101 -0
- package/cli/src/engine-native/claudeSync.ts +177 -104
- package/cli/src/engine-native/codexSync.ts +2 -1
- package/cli/src/engine-native/codexToml.ts +41 -8
- package/cli/src/engine-native/deps.ts +27 -10
- package/cli/src/engine-native/index.ts +22 -5
- package/cli/src/engine-native/modes.ts +6 -6
- package/cli/src/engine-native/parseArgs.ts +113 -22
- package/cli/src/engine-native/powershell.ts +11 -0
- package/cli/src/engine-native/skillsSync.ts +4 -36
- package/cli/src/generated/sotPayload.ts +14 -12
- package/cli/src/main.ts +19 -9
- package/package.json +1 -1
- package/cli/src/engine-native/claudeModel.ts +0 -54
|
@@ -9,15 +9,21 @@ 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 {
|
|
20
|
-
import {
|
|
19
|
+
import { bunBootstrap } from "./bun"
|
|
20
|
+
import {
|
|
21
|
+
syncClaudeAdvisor,
|
|
22
|
+
syncClaudeEffort,
|
|
23
|
+
syncClaudeModel
|
|
24
|
+
} from "./claudeSettingsModifiers"
|
|
25
|
+
import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
|
|
26
|
+
import { p, writeBytesIfChanged, writeFileIfChanged, writeTextIfChanged } from "./exec"
|
|
21
27
|
import type { Ctx } from "./index"
|
|
22
28
|
import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
|
|
23
29
|
import type { EngineServices } from "./services"
|
|
@@ -26,8 +32,12 @@ import { mergeSettings, reconcileSettings } from "./settings"
|
|
|
26
32
|
import { ensure, field } from "./toolchain"
|
|
27
33
|
import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
|
|
28
34
|
|
|
29
|
-
export
|
|
30
|
-
|
|
35
|
+
export type ClaudeRuntimeState =
|
|
36
|
+
| { readonly kind: "ready"; readonly paths: ClaudeRuntimePaths }
|
|
37
|
+
| { readonly kind: "deferred"; readonly reason: "bun-unavailable" }
|
|
38
|
+
|
|
39
|
+
export function claudeSync(ctx: Ctx): ClaudeRuntimeState {
|
|
40
|
+
const { err, warn } = ctx.services.logger
|
|
31
41
|
const claudeDir = p(ctx.home, ".claude")
|
|
32
42
|
|
|
33
43
|
if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
|
|
@@ -39,26 +49,60 @@ export function claudeSync(ctx: Ctx): void {
|
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
syncRtk(ctx, claudeDir)
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
const bun = bunBootstrap(ctx, ctx.services)
|
|
53
|
+
const runtime: ClaudeRuntimeState = bun.kind === "ready"
|
|
54
|
+
? { kind: "ready", paths: claudeRuntimePaths(claudeDir, bun.executable) }
|
|
55
|
+
: { kind: "deferred", reason: "bun-unavailable" }
|
|
56
|
+
const template = parseJson(payloadText("SoT/.claude/settings.json"))
|
|
57
|
+
if (template === undefined) {
|
|
58
|
+
err("Embedded SoT/.claude/settings.json is not valid JSON")
|
|
59
|
+
throw new ExitError(1)
|
|
60
|
+
}
|
|
61
|
+
const materialized = materializeClaudeSettings(
|
|
62
|
+
template,
|
|
63
|
+
runtime.kind === "ready" ? runtime.paths : undefined,
|
|
64
|
+
ctx.services.platform
|
|
65
|
+
)
|
|
66
|
+
const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
|
|
67
|
+
|
|
68
|
+
syncClaudeRuntime(ctx, runtime)
|
|
44
69
|
syncClaudeMd(ctx, claudeDir)
|
|
45
|
-
|
|
70
|
+
if (ctx.dryRun) {
|
|
71
|
+
describeSettingsSync(ctx, claudeDir)
|
|
72
|
+
} else {
|
|
73
|
+
if (prepared === undefined) throw new Error("Claude settings were not prepared")
|
|
74
|
+
commitClaudeSettings(ctx, prepared)
|
|
75
|
+
}
|
|
76
|
+
syncRemovals(ctx, claudeDir, runtime)
|
|
46
77
|
syncCompactWindow(ctx, claudeDir)
|
|
47
78
|
syncPermissive(ctx, claudeDir)
|
|
48
79
|
syncClaudeModel(ctx, ctx.claudeModel)
|
|
80
|
+
syncClaudeEffort(ctx, ctx.claudeEffort)
|
|
81
|
+
syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
|
|
49
82
|
syncClaudeJson(ctx)
|
|
50
83
|
syncConnectorEnv(ctx)
|
|
51
|
-
syncRemovals(ctx, claudeDir)
|
|
52
84
|
syncPlugins(ctx, claudeDir)
|
|
53
85
|
syncOptionalPlugins(ctx, claudeDir)
|
|
54
86
|
syncLspServers(ctx)
|
|
87
|
+
return runtime
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
// ------------------------------------------------------------------ rtk ----
|
|
58
91
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
92
|
+
type RtkInstaller = ((mode: "install" | "upgrade", version: string, services: EngineServices) => number) & {
|
|
93
|
+
readonly prerequisite: (services: EngineServices) => number | undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** RTK toolchain install callback with the shared contextual curl boundary. */
|
|
97
|
+
export function rtkInstall(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): RtkInstaller {
|
|
98
|
+
const prerequisite = (services: EngineServices): number | undefined => {
|
|
99
|
+
if (services.deps.probe("curl").state === "present") return undefined
|
|
100
|
+
services.deps.warnMissing("curl", services.logger, missingCurlContext)
|
|
101
|
+
return missingCurlExit
|
|
102
|
+
}
|
|
103
|
+
const install = (mode: "install" | "upgrade", version: string, services: EngineServices): number => {
|
|
104
|
+
const blocked = prerequisite(services)
|
|
105
|
+
if (blocked !== undefined) return blocked
|
|
62
106
|
const { change, err, verbose, warn } = services.logger
|
|
63
107
|
const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
|
|
64
108
|
|
|
@@ -84,6 +128,16 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
|
|
|
84
128
|
err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
|
|
85
129
|
return 1
|
|
86
130
|
}
|
|
131
|
+
return Object.assign(install, { prerequisite })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ensureRtk(ctx: Ctx, missingCurlContext: string, missingCurlExit: number): number {
|
|
135
|
+
const installer = rtkInstall(ctx, missingCurlContext, missingCurlExit)
|
|
136
|
+
if (ctx.services.deps.probe("rtk").state === "missing") {
|
|
137
|
+
const blocked = installer.prerequisite(ctx.services)
|
|
138
|
+
if (blocked !== undefined) return blocked
|
|
139
|
+
}
|
|
140
|
+
return ensure(ctx, "rtk", installer)
|
|
87
141
|
}
|
|
88
142
|
|
|
89
143
|
function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
@@ -98,7 +152,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
|
98
152
|
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
153
|
return
|
|
100
154
|
}
|
|
101
|
-
} else if (
|
|
155
|
+
} else if (ensureRtk(ctx, "cannot download RTK installer; continuing sync without RTK", 0) !== 0) {
|
|
102
156
|
warn("RTK bootstrap failed — continuing sync without it")
|
|
103
157
|
}
|
|
104
158
|
|
|
@@ -118,60 +172,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
|
118
172
|
}
|
|
119
173
|
}
|
|
120
174
|
|
|
121
|
-
//
|
|
175
|
+
// ----------------------------------------------------------- runtime ----
|
|
122
176
|
|
|
123
|
-
function
|
|
124
|
-
const { change, echo, verbose } = ctx.services.logger
|
|
177
|
+
function syncClaudeRuntime(ctx: Ctx, runtime: ClaudeRuntimeState): void {
|
|
178
|
+
const { change, echo, verbose, warn } = ctx.services.logger
|
|
179
|
+
if (runtime.kind === "deferred") {
|
|
180
|
+
warn("Bun unavailable — Claude statusline/hooks migration deferred; install Bun, then re-run sync claude")
|
|
181
|
+
return
|
|
182
|
+
}
|
|
125
183
|
if (ctx.dryRun) {
|
|
126
|
-
echo("[dry-run]
|
|
184
|
+
echo("[dry-run] install statusline.mjs, session-start.mjs, notify.mjs, notification.mp3")
|
|
127
185
|
return
|
|
128
186
|
}
|
|
129
187
|
|
|
188
|
+
mkdirSync(p(ctx.home, ".claude", "bin"), { recursive: true })
|
|
130
189
|
let changed = false
|
|
131
|
-
for (const [
|
|
132
|
-
[
|
|
133
|
-
[
|
|
190
|
+
for (const [path, source] of [
|
|
191
|
+
[runtime.paths.statusline, "SoT/.claude/bin/statusline.mjs"],
|
|
192
|
+
[runtime.paths.sessionStart, "SoT/.claude/bin/session-start.mjs"],
|
|
193
|
+
[runtime.paths.notify, "SoT/.claude/bin/notify.mjs"]
|
|
134
194
|
] as const) {
|
|
135
|
-
const path = p(claudeDir, script)
|
|
136
195
|
if (writeTextIfChanged(path, payloadText(source))) changed = true
|
|
137
|
-
if (ensureExecutable(path)) changed = true
|
|
138
196
|
}
|
|
139
|
-
if (writeBytesIfChanged(p(
|
|
197
|
+
if (writeBytesIfChanged(p(ctx.home, ".claude", "notification.mp3"), payloadBytes("notification.mp3"))) changed = true
|
|
140
198
|
if (changed) {
|
|
141
|
-
change("
|
|
199
|
+
change("Claude runtime synced (statusline, session-start, notify, notification)")
|
|
142
200
|
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
|
-
}
|
|
201
|
+
} else {
|
|
202
|
+
verbose("Claude runtime already in sync (statusline, session-start, notify, notification)")
|
|
170
203
|
}
|
|
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
204
|
}
|
|
176
205
|
|
|
177
206
|
function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
@@ -214,55 +243,73 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
|
214
243
|
|
|
215
244
|
// ------------------------------------------------------------- settings ----
|
|
216
245
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
246
|
+
export interface PreparedClaudeSettings {
|
|
247
|
+
readonly path: string
|
|
248
|
+
readonly bytes: string
|
|
249
|
+
readonly previousBytes: string | undefined
|
|
250
|
+
readonly changed: boolean
|
|
251
|
+
}
|
|
222
252
|
|
|
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
|
-
}
|
|
253
|
+
function assertMaterializedSettings(bytes: string): void {
|
|
254
|
+
if (bytes.includes("__DOCKS_KIT_")) throw new Error("Claude settings contain unresolved runtime sentinels")
|
|
255
|
+
}
|
|
233
256
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
257
|
+
/** Build the candidate settings bytes before the readiness-gated runtime cutover mutates disk. */
|
|
258
|
+
export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json): PreparedClaudeSettings {
|
|
259
|
+
const path = p(claudeDir, "settings.json")
|
|
260
|
+
if (!existsSync(path)) {
|
|
261
|
+
const bytes = jqStringify(repo)
|
|
262
|
+
assertMaterializedSettings(bytes)
|
|
263
|
+
return { path, bytes, previousBytes: undefined, changed: true }
|
|
239
264
|
}
|
|
240
265
|
|
|
241
|
-
const
|
|
266
|
+
const previousBytes = readFileSync(path, "utf8")
|
|
267
|
+
const user = parseJson(previousBytes)
|
|
242
268
|
if (user === undefined) {
|
|
243
|
-
|
|
244
|
-
err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
|
|
269
|
+
ctx.services.logger.err(`Skipping settings sync: ${path} is not valid JSON. Fix it manually or delete it to reinstall.`)
|
|
245
270
|
throw new ExitError(1)
|
|
246
271
|
}
|
|
247
|
-
const repo = parseJson(repoSettingsText)!
|
|
248
|
-
|
|
249
272
|
const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
|
|
250
|
-
const
|
|
251
|
-
|
|
273
|
+
const bytes = jqStringify(merged)
|
|
274
|
+
assertMaterializedSettings(bytes)
|
|
275
|
+
return { path, bytes, previousBytes, changed: bytes !== previousBytes }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Commit a fully prepared document; callers must finish runtime preparation first. */
|
|
279
|
+
export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings): void {
|
|
280
|
+
const { change, verbose } = ctx.services.logger
|
|
281
|
+
if (!prepared.changed) {
|
|
252
282
|
verbose("Settings already in sync")
|
|
253
283
|
return
|
|
254
284
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
285
|
+
|
|
286
|
+
if (prepared.previousBytes !== undefined) copyFileSync(prepared.path, `${prepared.path}.bak`)
|
|
287
|
+
writeFileSync(`${prepared.path}.tmp`, prepared.bytes)
|
|
288
|
+
renameSync(`${prepared.path}.tmp`, prepared.path)
|
|
258
289
|
ctx.nextStepTriggers.claudeRestart = true
|
|
259
|
-
if (
|
|
290
|
+
if (prepared.previousBytes === undefined) {
|
|
291
|
+
change("Settings installed")
|
|
292
|
+
} else if (ctx.reconcile) {
|
|
260
293
|
change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
|
|
261
294
|
} else {
|
|
262
295
|
change("Settings merged (backup at settings.json.bak)")
|
|
263
296
|
}
|
|
264
297
|
}
|
|
265
298
|
|
|
299
|
+
function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
|
|
300
|
+
const { echo } = ctx.services.logger
|
|
301
|
+
const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
|
|
302
|
+
const userSettings = p(claudeDir, "settings.json")
|
|
303
|
+
|
|
304
|
+
if (!existsSync(userSettings)) {
|
|
305
|
+
echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
|
|
306
|
+
} else if (ctx.reconcile) {
|
|
307
|
+
echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
|
|
308
|
+
} else {
|
|
309
|
+
echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
266
313
|
/** Shared shape of the three jq-edit modifiers (compact window, permissive). */
|
|
267
314
|
function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
|
|
268
315
|
const { err, warn } = ctx.services.logger
|
|
@@ -448,6 +495,7 @@ const REMOVED_MANIFEST = {
|
|
|
448
495
|
files: ["alert_bubble.mp3"],
|
|
449
496
|
settingsKeys: [
|
|
450
497
|
"showTurnDuration",
|
|
498
|
+
"advisorModel",
|
|
451
499
|
"env.CLAUDE_CODE_SUBAGENT_MODEL",
|
|
452
500
|
"env.ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
453
501
|
"env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
|
|
@@ -455,7 +503,12 @@ const REMOVED_MANIFEST = {
|
|
|
455
503
|
"env.CLAUDE_CODE_FORK_SUBAGENT",
|
|
456
504
|
"env.CLAUDE_CODE_EFFORT_LEVEL"
|
|
457
505
|
],
|
|
458
|
-
claudeJsonKeys: [] as Array<string
|
|
506
|
+
claudeJsonKeys: [] as Array<string>,
|
|
507
|
+
runtimeReady: {
|
|
508
|
+
hooks: ["notify.sh"],
|
|
509
|
+
files: ["statusline.sh", "fetch-usage.sh"],
|
|
510
|
+
settingsKeys: ["hooks.Stop"]
|
|
511
|
+
}
|
|
459
512
|
}
|
|
460
513
|
|
|
461
514
|
/** claude::_prune_json_keys — present-count; deletes when !dryRun. */
|
|
@@ -464,18 +517,15 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
|
|
|
464
517
|
const doc = parseJson(readFileSync(file, "utf8"))
|
|
465
518
|
if (doc === undefined) return 0
|
|
466
519
|
|
|
467
|
-
const
|
|
468
|
-
let cur: Json
|
|
469
|
-
for (const seg of path) {
|
|
470
|
-
if (cur === undefined
|
|
471
|
-
cur = cur[seg]
|
|
520
|
+
const hasPath = (root: Json, path: Array<string>): boolean => {
|
|
521
|
+
let cur: Json = root
|
|
522
|
+
for (const seg of path.slice(0, -1)) {
|
|
523
|
+
if (!isObject(cur) || cur[seg] === undefined) return false
|
|
524
|
+
cur = cur[seg]!
|
|
472
525
|
}
|
|
473
|
-
return cur
|
|
526
|
+
return isObject(cur) && Object.prototype.hasOwnProperty.call(cur, path[path.length - 1]!)
|
|
474
527
|
}
|
|
475
|
-
const presentKeys = keys.filter((k) =>
|
|
476
|
-
const v = getPath(doc, k.split("."))
|
|
477
|
-
return v !== undefined && v !== null
|
|
478
|
-
})
|
|
528
|
+
const presentKeys = keys.filter((k) => hasPath(doc, k.split(".")))
|
|
479
529
|
if (presentKeys.length === 0) return 0
|
|
480
530
|
|
|
481
531
|
if (!ctx.dryRun) {
|
|
@@ -494,12 +544,24 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
|
|
|
494
544
|
return presentKeys.length
|
|
495
545
|
}
|
|
496
546
|
|
|
497
|
-
function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
547
|
+
function syncRemovals(ctx: Ctx, claudeDir: string, runtime: ClaudeRuntimeState): void {
|
|
498
548
|
const { change, echo } = ctx.services.logger
|
|
499
549
|
let hooksRemoved = 0
|
|
500
550
|
let filesRemoved = 0
|
|
501
|
-
|
|
502
|
-
|
|
551
|
+
const hooks = [
|
|
552
|
+
...REMOVED_MANIFEST.hooks,
|
|
553
|
+
...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.hooks : [])
|
|
554
|
+
]
|
|
555
|
+
const files = [
|
|
556
|
+
...REMOVED_MANIFEST.files,
|
|
557
|
+
...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.files : [])
|
|
558
|
+
]
|
|
559
|
+
const settingsKeys = [
|
|
560
|
+
...REMOVED_MANIFEST.settingsKeys.filter((key) => key !== "advisorModel" || ctx.claudeAdvisor === ""),
|
|
561
|
+
...(runtime.kind === "ready" ? REMOVED_MANIFEST.runtimeReady.settingsKeys : [])
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
for (const name of hooks) {
|
|
503
565
|
const path = p(claudeDir, "hooks", name)
|
|
504
566
|
if (!existsSync(path)) continue
|
|
505
567
|
if (ctx.dryRun) {
|
|
@@ -509,8 +571,15 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
|
509
571
|
hooksRemoved++
|
|
510
572
|
}
|
|
511
573
|
}
|
|
574
|
+
if (!ctx.dryRun && hooksRemoved > 0) {
|
|
575
|
+
try {
|
|
576
|
+
rmdirSync(p(claudeDir, "hooks"))
|
|
577
|
+
} catch {
|
|
578
|
+
// Preserve a non-empty user hooks directory.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
512
581
|
|
|
513
|
-
for (const rel of
|
|
582
|
+
for (const rel of files) {
|
|
514
583
|
const path = p(claudeDir, rel)
|
|
515
584
|
if (!existsSync(path)) continue
|
|
516
585
|
if (ctx.dryRun) {
|
|
@@ -521,7 +590,7 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
|
521
590
|
}
|
|
522
591
|
}
|
|
523
592
|
|
|
524
|
-
const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"),
|
|
593
|
+
const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), settingsKeys)
|
|
525
594
|
const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
|
|
526
595
|
|
|
527
596
|
if (ctx.dryRun) {
|
|
@@ -846,12 +915,16 @@ function syncLspServers(ctx: Ctx): void {
|
|
|
846
915
|
|
|
847
916
|
// -------------------------------------------------------------- summary ----
|
|
848
917
|
|
|
849
|
-
export function claudeSummary(ctx: Ctx): void {
|
|
918
|
+
export function claudeSummary(ctx: Ctx, runtime: ClaudeRuntimeState): void {
|
|
850
919
|
const { echo } = ctx.services.logger
|
|
851
920
|
const claudeDir = p(ctx.home, ".claude")
|
|
852
921
|
echo(`Claude: ${claudeDir}`)
|
|
853
922
|
if (!ctx.dryRun) {
|
|
854
|
-
|
|
923
|
+
if (runtime.kind === "ready") {
|
|
924
|
+
echo("Hooks: Bun (statusline, session-start, notify)")
|
|
925
|
+
} else {
|
|
926
|
+
echo("Hooks: migration deferred (Bun unavailable; existing hook/statusline settings preserved)")
|
|
927
|
+
}
|
|
855
928
|
if (ctx.services.deps.probe("rtk").state === "present") {
|
|
856
929
|
const version = ctx.services.deps.version("rtk")
|
|
857
930
|
echo(`RTK: ${version !== "" ? version : "installed"}`)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process"
|
|
7
7
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
8
8
|
|
|
9
|
-
import { syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
|
|
9
|
+
import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
|
|
10
10
|
import { p } from "./exec"
|
|
11
11
|
import type { Ctx } from "./index"
|
|
12
12
|
import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
|
|
@@ -21,6 +21,7 @@ export function codexSync(ctx: Ctx): void {
|
|
|
21
21
|
if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
|
|
22
22
|
syncConfig(ctx, sotConfig, userConfig)
|
|
23
23
|
syncCodexModel(ctx, ctx.codexModel)
|
|
24
|
+
syncCodexEffort(ctx, ctx.codexEffort)
|
|
24
25
|
syncRules(ctx, payloadPaths("SoT/.codex/rules/"), p(codexDir, "rules"))
|
|
25
26
|
syncAgentsMd(ctx, payloadText("SoT/.codex/AGENTS.md"), p(codexDir, "AGENTS.md"))
|
|
26
27
|
syncMarketplace(ctx, payloadText("SoT/.codex/plugins/marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { p } from "./exec"
|
|
8
8
|
import { readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
9
9
|
|
|
10
|
+
import { resolveEffort } from "../efforts"
|
|
10
11
|
import type { Ctx } from "./index"
|
|
11
12
|
|
|
12
13
|
export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
|
|
@@ -48,27 +49,59 @@ export function replaceTopLevelSettingInFile(file: string, key: string, replacem
|
|
|
48
49
|
return true
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
interface CodexSettingEdit {
|
|
53
|
+
readonly tag: string
|
|
54
|
+
readonly key: "model" | "model_reasoning_effort"
|
|
55
|
+
readonly value: string
|
|
56
|
+
readonly changed: string
|
|
57
|
+
readonly unchanged: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function syncCodexSetting(ctx: Ctx, edit: CodexSettingEdit): void {
|
|
52
61
|
const { change, echo, verbose, warn } = ctx.services.logger
|
|
53
62
|
const userCodexSettings = p(ctx.home, ".codex", "config.toml")
|
|
54
63
|
|
|
55
|
-
if (model === "") return
|
|
56
|
-
|
|
57
64
|
if (ctx.dryRun) {
|
|
58
|
-
echo(`[dry-run] (
|
|
65
|
+
echo(`[dry-run] (${edit.tag}) set ${edit.key} = "${edit.value}" in ${userCodexSettings}`)
|
|
59
66
|
return
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
try {
|
|
63
70
|
readFileSync(userCodexSettings)
|
|
64
71
|
} catch {
|
|
65
|
-
warn(`(
|
|
72
|
+
warn(`(${edit.tag}) ${userCodexSettings} missing — skipped`)
|
|
66
73
|
return
|
|
67
74
|
}
|
|
68
|
-
if (replaceTopLevelSettingInFile(userCodexSettings,
|
|
69
|
-
change(
|
|
75
|
+
if (replaceTopLevelSettingInFile(userCodexSettings, edit.key, `${edit.key} = "${edit.value}"`)) {
|
|
76
|
+
change(edit.changed)
|
|
70
77
|
ctx.nextStepTriggers.codexRestart = true
|
|
71
78
|
} else {
|
|
72
|
-
verbose(
|
|
79
|
+
verbose(edit.unchanged)
|
|
73
80
|
}
|
|
74
81
|
}
|
|
82
|
+
|
|
83
|
+
export function syncCodexModel(ctx: Ctx, model: string): void {
|
|
84
|
+
if (model === "") return
|
|
85
|
+
syncCodexSetting(ctx, {
|
|
86
|
+
tag: "--codex-model",
|
|
87
|
+
key: "model",
|
|
88
|
+
value: model,
|
|
89
|
+
changed: `Model: deployed Codex model set to ${model} (SoT unchanged; flag-less sync reverts)`,
|
|
90
|
+
unchanged: `Model: deployed Codex model already ${model}`
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function syncCodexEffort(ctx: Ctx, effort: string): void {
|
|
95
|
+
if (effort === "") return
|
|
96
|
+
const resolved = resolveEffort("codex", effort)
|
|
97
|
+
const useDefault = effort === "default"
|
|
98
|
+
syncCodexSetting(ctx, {
|
|
99
|
+
tag: "--codex-effort",
|
|
100
|
+
key: "model_reasoning_effort",
|
|
101
|
+
value: resolved,
|
|
102
|
+
changed: useDefault
|
|
103
|
+
? `Effort: deployed Codex model_reasoning_effort set to ${resolved} (SoT default)`
|
|
104
|
+
: `Effort: deployed Codex model_reasoning_effort set to ${resolved} (SoT unchanged; flag-less sync reverts)`,
|
|
105
|
+
unchanged: `Effort: deployed Codex model_reasoning_effort already ${resolved}`
|
|
106
|
+
})
|
|
107
|
+
}
|
|
@@ -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)"),
|