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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { rmSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
|
|
5
|
+
import { p } from "./exec"
|
|
6
|
+
import type { Ctx } from "./index"
|
|
7
|
+
import { encodePowerShellCommand, powerShellLiteral } from "./powershell"
|
|
8
|
+
import type { EngineServices } from "./services"
|
|
9
|
+
import { field } from "./toolchain"
|
|
10
|
+
|
|
11
|
+
export type BunRuntimeState =
|
|
12
|
+
| { readonly kind: "ready"; readonly executable: string }
|
|
13
|
+
| { readonly kind: "deferred"; readonly reason: "missing-curl" | "install-failed" }
|
|
14
|
+
|
|
15
|
+
function remember(ctx: Ctx, state: BunRuntimeState): BunRuntimeState {
|
|
16
|
+
ctx.bunRuntime = state
|
|
17
|
+
return state
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function predictedExecutable(ctx: Ctx, services: EngineServices): string {
|
|
21
|
+
const root = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
|
|
22
|
+
? process.env["BUN_INSTALL"]!
|
|
23
|
+
: p(ctx.home, ".bun")
|
|
24
|
+
return p(root, "bin", services.platform.isWindows() ? "bun.exe" : "bun")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function windowsDownloadScript(installer: string): string {
|
|
28
|
+
return `$ErrorActionPreference = 'Stop'; Invoke-WebRequest -Uri ${powerShellLiteral("https://bun.sh/install.ps1")} -OutFile ${powerShellLiteral(installer)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function installBun(ctx: Ctx, services: EngineServices, pin: string, installer: string): void {
|
|
32
|
+
if (services.platform.isWindows()) {
|
|
33
|
+
const encoded = encodePowerShellCommand(windowsDownloadScript(installer))
|
|
34
|
+
const download = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { stdio: "ignore" })
|
|
35
|
+
if (download.error === undefined && download.status === 0) {
|
|
36
|
+
spawnSync(
|
|
37
|
+
"powershell.exe",
|
|
38
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", installer, "-Version", pin, "-DownloadWithoutCurl"],
|
|
39
|
+
{ stdio: "ignore" }
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const download = spawnSync("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], { stdio: "ignore" })
|
|
46
|
+
if (download.error === undefined && download.status === 0) {
|
|
47
|
+
spawnSync("bash", [installer, `bun-v${pin}`], { stdio: "ignore" })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function bunBootstrap(ctx: Ctx, services: EngineServices): BunRuntimeState {
|
|
52
|
+
if (ctx.bunRuntime !== undefined) return ctx.bunRuntime
|
|
53
|
+
|
|
54
|
+
const existing = services.deps.path("bun")
|
|
55
|
+
if (existing !== "") return remember(ctx, { kind: "ready", executable: existing })
|
|
56
|
+
|
|
57
|
+
const pin = field(ctx, "bun", "verified")
|
|
58
|
+
if (pin === "") {
|
|
59
|
+
services.logger.warn("Bun bootstrap aborted — SoT/toolchain.json has no verified Bun pin")
|
|
60
|
+
return remember(ctx, { kind: "deferred", reason: "install-failed" })
|
|
61
|
+
}
|
|
62
|
+
if (!services.platform.isWindows() && services.deps.probe("curl").state === "missing") {
|
|
63
|
+
services.deps.warnMissing("curl", services.logger, "cannot bootstrap Bun; install Bun manually, then re-run sync")
|
|
64
|
+
return remember(ctx, { kind: "deferred", reason: "missing-curl" })
|
|
65
|
+
}
|
|
66
|
+
if (ctx.dryRun) {
|
|
67
|
+
const executable = predictedExecutable(ctx, services)
|
|
68
|
+
services.logger.echo(`[dry-run] install Bun ${pin} (kit-verified) -> ${executable}`)
|
|
69
|
+
return remember(ctx, { kind: "ready", executable })
|
|
70
|
+
}
|
|
71
|
+
services.logger.warn(`Bun not found — installing Bun ${pin} (kit-verified)...`)
|
|
72
|
+
const installer = p(tmpdir(), `bun-install-${process.pid}.${services.platform.isWindows() ? "ps1" : "sh"}`)
|
|
73
|
+
try {
|
|
74
|
+
installBun(ctx, services, pin, installer)
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(installer, { force: true })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const installed = services.deps.path("bun")
|
|
80
|
+
if (installed === "") {
|
|
81
|
+
services.logger.warn("Bun install failed. Install manually from https://bun.sh/docs/installation, then re-run sync.")
|
|
82
|
+
return remember(ctx, { kind: "deferred", reason: "install-failed" })
|
|
83
|
+
}
|
|
84
|
+
const version = services.deps.version("bun")
|
|
85
|
+
services.logger.change(`Bun installed (${version !== "" ? version : "version unknown"})`)
|
|
86
|
+
return remember(ctx, { kind: "ready", executable: installed })
|
|
87
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { p } from "./exec"
|
|
2
|
+
import { isObject, parseJson, type Json } from "./jq"
|
|
3
|
+
import { encodePowerShellCommand, powerShellLiteral } from "./powershell"
|
|
4
|
+
import type { Platform } from "./services"
|
|
5
|
+
|
|
6
|
+
const BUN_SENTINEL = "__DOCKS_KIT_BUN__"
|
|
7
|
+
const SESSION_START_SENTINEL = "__DOCKS_KIT_SESSION_START__"
|
|
8
|
+
const NOTIFY_SENTINEL = "__DOCKS_KIT_NOTIFY__"
|
|
9
|
+
const STATUSLINE_SENTINEL = "__DOCKS_KIT_STATUSLINE__"
|
|
10
|
+
|
|
11
|
+
export interface ClaudeRuntimePaths {
|
|
12
|
+
readonly bun: string
|
|
13
|
+
readonly statusline: string
|
|
14
|
+
readonly sessionStart: string
|
|
15
|
+
readonly notify: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function claudeRuntimePaths(claudeDir: string, bun: string): ClaudeRuntimePaths {
|
|
19
|
+
return {
|
|
20
|
+
bun,
|
|
21
|
+
statusline: p(claudeDir, "bin", "statusline.mjs"),
|
|
22
|
+
sessionStart: p(claudeDir, "bin", "session-start.mjs"),
|
|
23
|
+
notify: p(claudeDir, "bin", "notify.mjs")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneJson(value: Json): Json {
|
|
28
|
+
const cloned = parseJson(JSON.stringify(value))
|
|
29
|
+
if (cloned === undefined) throw new Error("Claude settings template cannot be serialized")
|
|
30
|
+
return cloned
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function countString(value: Json, expected: string): number {
|
|
34
|
+
if (value === expected) return 1
|
|
35
|
+
if (Array.isArray(value)) return value.reduce<number>((total, item) => total + countString(item, expected), 0)
|
|
36
|
+
if (!isObject(value)) return 0
|
|
37
|
+
return Object.values(value).reduce<number>((total, item) => total + countString(item, expected), 0)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hooksObject(template: Json): { [key: string]: Json } {
|
|
41
|
+
if (!isObject(template) || !isObject(template["hooks"])) throw new Error("Claude settings hooks object is missing")
|
|
42
|
+
return template["hooks"]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commandHandler(template: Json, event: "SessionStart" | "Notification"): { [key: string]: Json } {
|
|
46
|
+
const entries = hooksObject(template)[event]
|
|
47
|
+
if (!Array.isArray(entries) || entries.length !== 1 || !isObject(entries[0])) {
|
|
48
|
+
throw new Error(`${event} sentinel location is invalid`)
|
|
49
|
+
}
|
|
50
|
+
const handlers = entries[0]["hooks"]
|
|
51
|
+
if (!Array.isArray(handlers) || handlers.length !== 1 || !isObject(handlers[0])) {
|
|
52
|
+
throw new Error(`${event} sentinel handler is invalid`)
|
|
53
|
+
}
|
|
54
|
+
return handlers[0]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function oneArg(handler: { [key: string]: Json }, sentinel: string, event: string): void {
|
|
58
|
+
const args = handler["args"]
|
|
59
|
+
if (!Array.isArray(args) || args.length !== 1 || args[0] !== sentinel) {
|
|
60
|
+
throw new Error(`${event} argument sentinel location is invalid`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateTemplate(template: Json): void {
|
|
65
|
+
const sessionStart = commandHandler(template, "SessionStart")
|
|
66
|
+
const notification = commandHandler(template, "Notification")
|
|
67
|
+
if (sessionStart["command"] !== BUN_SENTINEL) throw new Error("SessionStart Bun sentinel location is invalid")
|
|
68
|
+
if (notification["command"] !== BUN_SENTINEL) throw new Error("Notification Bun sentinel location is invalid")
|
|
69
|
+
oneArg(sessionStart, SESSION_START_SENTINEL, "SessionStart")
|
|
70
|
+
oneArg(notification, NOTIFY_SENTINEL, "Notification")
|
|
71
|
+
|
|
72
|
+
const hooks = hooksObject(template)
|
|
73
|
+
if (hooks["Stop"] !== undefined) throw new Error("hooks.Stop must be absent from the Claude settings template")
|
|
74
|
+
if (!isObject(template) || !isObject(template["statusLine"]) || template["statusLine"]["command"] !== STATUSLINE_SENTINEL) {
|
|
75
|
+
throw new Error("Statusline sentinel location is invalid")
|
|
76
|
+
}
|
|
77
|
+
const counts: ReadonlyArray<[string, string, number]> = [
|
|
78
|
+
["Bun", BUN_SENTINEL, 2],
|
|
79
|
+
["SessionStart", SESSION_START_SENTINEL, 1],
|
|
80
|
+
["notify", NOTIFY_SENTINEL, 1],
|
|
81
|
+
["statusline", STATUSLINE_SENTINEL, 1]
|
|
82
|
+
]
|
|
83
|
+
for (const [label, sentinel, expected] of counts) {
|
|
84
|
+
if (countString(template, sentinel) !== expected) throw new Error(`${label} sentinel residue/cardinality is invalid`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function posixLiteral(value: string): string {
|
|
89
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function statusLineCommand(runtime: ClaudeRuntimePaths, platform: Platform): string {
|
|
93
|
+
if (!platform.isWindows()) {
|
|
94
|
+
const bun = posixLiteral(runtime.bun)
|
|
95
|
+
const script = posixLiteral(runtime.statusline)
|
|
96
|
+
return `test -x ${bun} && test -f ${script} && exec ${bun} ${script} || true`
|
|
97
|
+
}
|
|
98
|
+
const bun = powerShellLiteral(runtime.bun.replaceAll("\\", "/"))
|
|
99
|
+
const script = powerShellLiteral(runtime.statusline.replaceAll("\\", "/"))
|
|
100
|
+
const guard = `$ProgressPreference = 'SilentlyContinue'; if ((Test-Path -LiteralPath ${bun} -PathType Leaf) -and (Test-Path -LiteralPath ${script} -PathType Leaf)) { & ${bun} ${script} }`
|
|
101
|
+
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(guard)}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function materializeClaudeSettings(
|
|
105
|
+
template: Json,
|
|
106
|
+
runtime: ClaudeRuntimePaths | undefined,
|
|
107
|
+
platform: Platform
|
|
108
|
+
): Json {
|
|
109
|
+
validateTemplate(template)
|
|
110
|
+
const result = cloneJson(template)
|
|
111
|
+
const hooks = hooksObject(result)
|
|
112
|
+
if (runtime === undefined) {
|
|
113
|
+
delete hooks["SessionStart"]
|
|
114
|
+
delete hooks["Notification"]
|
|
115
|
+
if (!isObject(result)) throw new Error("Claude settings template must be an object")
|
|
116
|
+
delete result["statusLine"]
|
|
117
|
+
} else {
|
|
118
|
+
const sessionStart = commandHandler(result, "SessionStart")
|
|
119
|
+
sessionStart["command"] = runtime.bun
|
|
120
|
+
sessionStart["args"] = [runtime.sessionStart]
|
|
121
|
+
const notification = commandHandler(result, "Notification")
|
|
122
|
+
notification["command"] = runtime.bun
|
|
123
|
+
notification["args"] = [runtime.notify]
|
|
124
|
+
if (!isObject(result) || !isObject(result["statusLine"])) throw new Error("Claude statusLine object is missing")
|
|
125
|
+
result["statusLine"]["command"] = statusLineCommand(runtime, platform)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const sentinel of [BUN_SENTINEL, SESSION_START_SENTINEL, NOTIFY_SENTINEL, STATUSLINE_SENTINEL]) {
|
|
129
|
+
if (countString(result, sentinel) !== 0) throw new Error(`Claude settings sentinel residue: ${sentinel}`)
|
|
130
|
+
}
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Atomic top-level Claude settings modifiers, including direct model mode. */
|
|
2
|
+
import { p } from "./exec"
|
|
3
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
4
|
+
|
|
5
|
+
import { resolveEffort } from "../efforts"
|
|
6
|
+
import type { Ctx } from "./index"
|
|
7
|
+
import { isObject, jqStringify, parseJson } from "./jq"
|
|
8
|
+
|
|
9
|
+
interface ClaudeSettingEdit {
|
|
10
|
+
readonly tag: string
|
|
11
|
+
readonly key: "model" | "effortLevel" | "advisorModel"
|
|
12
|
+
readonly value: string | undefined
|
|
13
|
+
readonly dryRun: string
|
|
14
|
+
readonly changed: string
|
|
15
|
+
readonly unchanged: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function syncClaudeSetting(ctx: Ctx, edit: ClaudeSettingEdit): void {
|
|
19
|
+
const { change, echo, err, verbose, warn } = ctx.services.logger
|
|
20
|
+
const userSettings = p(ctx.home, ".claude", "settings.json")
|
|
21
|
+
|
|
22
|
+
if (ctx.dryRun) {
|
|
23
|
+
echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let text: string
|
|
28
|
+
try {
|
|
29
|
+
text = readFileSync(userSettings, "utf8")
|
|
30
|
+
} catch {
|
|
31
|
+
warn(`(${edit.tag}) ${userSettings} missing — skipped`)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const doc = parseJson(text)
|
|
35
|
+
if (doc === undefined) {
|
|
36
|
+
err(`(${edit.tag}) ${userSettings} is not valid JSON — skipped`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (isObject(doc)) {
|
|
40
|
+
if (edit.value === undefined) delete doc[edit.key]
|
|
41
|
+
else doc[edit.key] = edit.value
|
|
42
|
+
}
|
|
43
|
+
const out = jqStringify(doc)
|
|
44
|
+
if (out === text) {
|
|
45
|
+
verbose(edit.unchanged)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(`${userSettings}.tmp`, out)
|
|
49
|
+
renameSync(`${userSettings}.tmp`, userSettings)
|
|
50
|
+
change(edit.changed)
|
|
51
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function syncClaudeModel(ctx: Ctx, model: string): void {
|
|
55
|
+
if (model === "") return
|
|
56
|
+
const unset = model === "default"
|
|
57
|
+
syncClaudeSetting(ctx, {
|
|
58
|
+
tag: "--claude-model",
|
|
59
|
+
key: "model",
|
|
60
|
+
value: unset ? undefined : model,
|
|
61
|
+
dryRun: unset ? "delete .model (account default applies)" : `set .model=${model}`,
|
|
62
|
+
changed: `Model: deployed settings model set to ${model} (SoT unchanged; flag-less sync reverts)`,
|
|
63
|
+
unchanged: `Model: deployed settings model already ${unset ? "unset (account default)" : model}`
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function syncClaudeEffort(ctx: Ctx, effort: string): void {
|
|
68
|
+
if (effort === "") return
|
|
69
|
+
const resolved = resolveEffort("claude", effort)
|
|
70
|
+
const useDefault = effort === "default"
|
|
71
|
+
syncClaudeSetting(ctx, {
|
|
72
|
+
tag: "--claude-effort",
|
|
73
|
+
key: "effortLevel",
|
|
74
|
+
value: resolved,
|
|
75
|
+
dryRun: `set .effortLevel=${resolved}`,
|
|
76
|
+
changed: useDefault
|
|
77
|
+
? `Effort: deployed settings effortLevel set to ${resolved} (SoT default)`
|
|
78
|
+
: `Effort: deployed settings effortLevel set to ${resolved} (SoT unchanged; flag-less sync reverts)`,
|
|
79
|
+
unchanged: `Effort: deployed settings effortLevel already ${resolved}`
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function syncClaudeAdvisor(ctx: Ctx, state: string): void {
|
|
84
|
+
if (state === "") return
|
|
85
|
+
const enabled = state === "on"
|
|
86
|
+
const useDefault = state === "default"
|
|
87
|
+
syncClaudeSetting(ctx, {
|
|
88
|
+
tag: "--claude-advisor",
|
|
89
|
+
key: "advisorModel",
|
|
90
|
+
value: enabled ? "fable" : undefined,
|
|
91
|
+
dryRun: enabled ? "set .advisorModel=fable" : "delete .advisorModel (advisor disabled)",
|
|
92
|
+
changed: enabled
|
|
93
|
+
? "Advisor: deployed settings advisorModel set to fable (SoT unchanged; flag-less sync reverts)"
|
|
94
|
+
: useDefault
|
|
95
|
+
? "Advisor: deployed settings advisorModel unset (SoT default: off)"
|
|
96
|
+
: "Advisor: deployed settings advisorModel unset (--claude-advisor=off; SoT unchanged)",
|
|
97
|
+
unchanged: enabled
|
|
98
|
+
? "Advisor: deployed settings advisorModel already fable"
|
|
99
|
+
: `Advisor: deployed settings advisorModel already unset (${useDefault ? "SoT default: off" : "advisor off"})`
|
|
100
|
+
})
|
|
101
|
+
}
|