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.
@@ -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
+ }