docks-kit 0.15.1 → 0.15.2

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 CHANGED
@@ -91,9 +91,43 @@ When a kit-mechanic skill, its `references/`, or a wrapper agent (`.claude/agent
91
91
 
92
92
  ## Plans
93
93
 
94
- Use direct implementation for one clear reversible low-risk local diff with one bounded acceptance path; it creates no plan, reviewer, or automatic commit. Canonical plans live in `docs/plans/active/`; lifecycle is frontmatter, and `docs/plans/finished/` is terminal. Exactly three skills own the workflow: `plan-workspace` maintains the workspace, main-context `plan-manager` owns classify → draft/review/one repair → start → implement/delegate → observed acceptance → finish/archive, and internal `plan-reviewer` returns read-only `PlanReviewV1` evidence from one immutable bundle. Only the reviewer has wrappers.
94
+ Use direct implementation for one clear, reversible, low-risk local diff with one
95
+ bounded acceptance path; it creates no tracked plan, reviewer, or automatic
96
+ commit. Use a canonical plan for explicit planning, multi-commit or
97
+ cross-repository work, cold handoff, an unresolved decision, a cross-subsystem or
98
+ public-contract change, security-sensitive or destructive work, or any
99
+ non-`local` effect.
95
100
 
96
- The current record is one compact-JCS `Plan-run: PlanRunV1` line. Schemas 1–6 are historical validation/quarantine only. Every Steps row has `Effect: local|probe|production_access|publish|push|release|deploy`; a persisted requested effect is never live authority. The complete transaction, review-budget, checkpoint, legacy-quarantine, and external-authority contract lives in `docs/plans/AGENTS.md`; `docs/plans/CLAUDE.md` contains only `@AGENTS.md`.
101
+ <constraint>
102
+ Canonical plans live in `docs/plans/active/`; status is frontmatter and
103
+ `docs/plans/finished/` is terminal. Exactly three skills own the workflow:
104
+ `plan-workspace` maintains the workspace; main-context `plan-manager` runs six
105
+ phases — decide, draft, research, one plan review, implement, code review — and
106
+ archives; internal `plan-reviewer` returns a readable pre-implementation verdict.
107
+ Two read-only reviewer wrappers ship, `plan-reviewer` and `code-reviewer`, and
108
+ nothing else in the lifecycle has a wrapper.
109
+ </constraint>
110
+
111
+ The record is markdown only: `plan_contract: v2` frontmatter plus eight `##`
112
+ sections — `## Goal`, `## Research`, `## Steps`, `## Acceptance`,
113
+ `## Do not touch`, `## Open questions`, `## Review`, `## Verification Results`.
114
+ There are no hashes, permits, run identities, locks, bundles, or `v2`/`vN` plan
115
+ files, and the `plan.mjs` shipped inside the installed `plan-lifecycle` plugin
116
+ is the only lifecycle tool. This lifecycle creates zero commits and never
117
+ pushes; commit when the user asks, under `docks:commit-discipline`.
118
+
119
+ Every Steps row carries an `Effect` of exactly
120
+ `local|probe|production_access|publish|push|release|deploy`. A step whose
121
+ `Effect` is not `local` requires an in-session `ask` confirmation immediately
122
+ before it runs; when `ask` is unavailable the step is set `blocked` with
123
+ `blocked_reason` naming the unconfirmed effect. Persisted effects record intent
124
+ only.
125
+
126
+ A plan carrying a `Plan-run:` line is a v1 plan: render it, never parse or
127
+ migrate it, and finish it by hand by moving the file byte-unchanged to
128
+ `docs/plans/finished/<YYYY-MM-DD>-<slug>.md` with a `## Retirement` section
129
+ appended. The complete contract lives in `docs/plans/AGENTS.md`;
130
+ `docs/plans/CLAUDE.md` contains only `@AGENTS.md`.
97
131
 
98
132
  Distinct from per-tool **Open Concerns** sections (wait-on-upstream
99
133
  blockers tied to a vendor shipping a fix — these live inside the per-tool
package/README.md CHANGED
@@ -134,7 +134,7 @@ Details: `docks-kit docs platforms`.
134
134
  Tagging `cli-v*` builds four standalone binaries (Linux x64/arm64 and macOS
135
135
  x64/arm64) plus `SHA256SUMS` and attaches them to the GitHub release; npm
136
136
  publishes the exact package tarball through trusted publishing with OIDC provenance.
137
- Package `docks-kit` 0.15.1 bundles the CLI + generated payload, so npm releases
137
+ Package `docks-kit` 0.15.2 bundles the CLI + generated payload, so npm releases
138
138
  are versioned config snapshots without shipping the authoring `SoT/` tree.
139
139
 
140
140
  ## Deeper docs
@@ -32,6 +32,9 @@ export const modelCommand = Command.make(
32
32
  const dry = [...(config.dryRun ? ["--dry-run"] : []), ...(config.verbose ? ["--verbose"] : [])]
33
33
 
34
34
  if (Option.isSome(config.value)) {
35
+ if (config.value.value.trim() === "") {
36
+ return yield* bail("Model value must not be empty or blank")
37
+ }
35
38
  return yield* engine(["model", t, config.value.value, ...dry])
36
39
  }
37
40
 
@@ -18,6 +18,11 @@ const renderTool = (t: Tool) =>
18
18
  for (const m of catalog.models) {
19
19
  yield* Console.log(` ${m.id.padEnd(28)} ${m.kind.padEnd(6)} ${m.note ?? ""}`)
20
20
  }
21
+ yield* Console.log(
22
+ t === "claude"
23
+ ? " (full claude-* model IDs outside the catalog are accepted with a warning)"
24
+ : " (well-formed IDs outside the catalog are accepted with a warning)"
25
+ )
21
26
  yield* Console.log("")
22
27
  })
23
28
 
@@ -37,9 +42,6 @@ export const modelsCommand = Command.make("models", { tool, json }, (config) =>
37
42
  for (const t of tools) {
38
43
  yield* renderTool(t)
39
44
  }
40
- yield* Console.log(
41
- "Catalog: SoT/models.json (research-verified). Well-formed IDs outside it apply with a warning."
42
- )
43
45
  })
44
46
  ).pipe(
45
47
  Command.withDescription("List kit-verified Claude and Codex models (SoT/models.json).")
@@ -1,6 +1,6 @@
1
1
  import { Command, Flag } from "effect/unstable/cli"
2
2
  import { Console, Effect } from "effect"
3
- import { engineCapture } from "../engine"
3
+ import { engineCapture, type EngineCaptureError } from "../engine"
4
4
  import {
5
5
  deployedClaudeSettings,
6
6
  deployedCodexModel,
@@ -22,55 +22,168 @@ interface Drift {
22
22
  readonly drifted: boolean
23
23
  }
24
24
 
25
- const gatherDrift = (): Array<Drift> => {
25
+ type ClaudeDeployment =
26
+ | { readonly state: "absent" }
27
+ | { readonly state: "valid"; readonly settings: Record<string, unknown> }
28
+ | { readonly state: "malformed"; readonly diagnostic: string }
29
+
30
+ const captureToolchainStatus = () =>
31
+ engineCapture(["toolchain", "check"]).pipe(
32
+ Effect.map((table) => ({ state: "valid" as const, table })),
33
+ Effect.catch((error: EngineCaptureError) =>
34
+ Effect.succeed({
35
+ state: "failed" as const,
36
+ table: "",
37
+ diagnostic: error.diagnostic,
38
+ exitCode: error.code
39
+ })
40
+ )
41
+ )
42
+
43
+ const readClaudeDeployment = (): ClaudeDeployment => {
44
+ try {
45
+ const settings: unknown = deployedClaudeSettings()
46
+ if (settings === undefined) return { state: "absent" }
47
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) {
48
+ return {
49
+ state: "malformed",
50
+ diagnostic: "deployed Claude settings must contain a JSON object"
51
+ }
52
+ }
53
+ return { state: "valid", settings: settings as Record<string, unknown> }
54
+ } catch (error) {
55
+ const detail = error instanceof Error ? error.message : String(error)
56
+ return {
57
+ state: "malformed",
58
+ diagnostic: `deployed Claude settings contain invalid JSON: ${detail}`
59
+ }
60
+ }
61
+ }
62
+
63
+ const gatherDrift = (): {
64
+ readonly drift: Array<Drift>
65
+ readonly claudeDeployment: ClaudeDeployment
66
+ } => {
26
67
  const sot = sotClaudeSettings()
27
- const dep = deployedClaudeSettings() ?? {}
68
+ const claudeDeployment = readClaudeDeployment()
28
69
  const row = (setting: string, deployed: unknown, sotVal: unknown): Drift => {
29
70
  const d = String(deployed ?? "(unset)")
30
71
  const s = String(sotVal ?? "(unset)")
31
72
  return { setting, deployed: d, sot: s, drifted: d !== s }
32
73
  }
33
- return [
34
- row("claude.model", dep.model, sot.model),
35
- row("claude.effortLevel", dep.effortLevel, sot.effortLevel),
36
- row(
37
- "claude.compactWindow",
38
- dep.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
39
- sot.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW
40
- ),
41
- row("codex.model", deployedCodexModel(), sotCodexModel())
42
- ]
74
+ const codex = row("codex.model", deployedCodexModel(), sotCodexModel())
75
+ if (claudeDeployment.state === "absent") {
76
+ return {
77
+ claudeDeployment,
78
+ drift: [
79
+ { setting: "claude.settings", deployed: "(absent)", sot: "present", drifted: true },
80
+ codex
81
+ ]
82
+ }
83
+ }
84
+ if (claudeDeployment.state === "malformed") {
85
+ return {
86
+ claudeDeployment,
87
+ drift: [
88
+ { setting: "claude.settings", deployed: "(malformed)", sot: "present", drifted: true },
89
+ codex
90
+ ]
91
+ }
92
+ }
93
+
94
+ const dep = claudeDeployment.settings
95
+ const env =
96
+ dep.env !== null && typeof dep.env === "object" && !Array.isArray(dep.env)
97
+ ? (dep.env as Record<string, unknown>)
98
+ : {}
99
+ return {
100
+ claudeDeployment,
101
+ drift: [
102
+ row("claude.model", dep.model, sot.model),
103
+ row("claude.effortLevel", dep.effortLevel, sot.effortLevel),
104
+ row(
105
+ "claude.compactWindow",
106
+ env.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
107
+ sot.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW
108
+ ),
109
+ codex
110
+ ]
111
+ }
43
112
  }
44
113
 
45
114
  export const statusCommand = Command.make("status", { json }, (config) =>
46
115
  Effect.gen(function* () {
47
- const drift = gatherDrift()
116
+ const { drift, claudeDeployment } = gatherDrift()
48
117
  const plugins = pluginsView()
49
118
  const skills = skillsView()
50
- const toolchainTable = yield* engineCapture(["toolchain", "check"])
119
+ const toolchain = yield* captureToolchainStatus()
120
+ const diagnostics = new Array<{ source: string; message: string; exitCode: number }>()
121
+ if (claudeDeployment.state === "malformed") {
122
+ diagnostics.push({
123
+ source: "claude.settings",
124
+ message: claudeDeployment.diagnostic,
125
+ exitCode: 1
126
+ })
127
+ }
128
+ if (toolchain.state === "failed") {
129
+ diagnostics.push({
130
+ source: "toolchain",
131
+ message: toolchain.diagnostic ?? "toolchain capture failed",
132
+ exitCode: toolchain.exitCode ?? 1
133
+ })
134
+ }
51
135
 
52
136
  if (config.json) {
53
- return yield* Console.log(
54
- JSON.stringify({ kitHome: kitHome(), drift, plugins, skills, toolchainTable }, null, 2)
137
+ const deployment =
138
+ claudeDeployment.state === "malformed"
139
+ ? { claude: { state: claudeDeployment.state, diagnostic: claudeDeployment.diagnostic } }
140
+ : { claude: { state: claudeDeployment.state } }
141
+ yield* Console.log(
142
+ JSON.stringify(
143
+ {
144
+ kitHome: kitHome(),
145
+ deployment,
146
+ drift,
147
+ plugins,
148
+ skills,
149
+ toolchain,
150
+ diagnostics
151
+ },
152
+ null,
153
+ 2
154
+ )
155
+ )
156
+ } else {
157
+ yield* Console.log(`Kit home: ${kitHome()}\n`)
158
+ yield* Console.log("Deployed vs SoT (drift is expected for deploy-time modifiers):")
159
+ for (const d of drift) {
160
+ const mark = d.drifted ? "≠" : "="
161
+ yield* Console.log(` ${d.setting.padEnd(22)} deployed=${d.deployed} ${mark} SoT=${d.sot}`)
162
+ }
163
+ if (claudeDeployment.state === "malformed") {
164
+ yield* Console.log(` ERROR claude.settings: ${claudeDeployment.diagnostic}`)
165
+ }
166
+ yield* Console.log("\nToolchain:")
167
+ if (toolchain.state === "failed") {
168
+ yield* Console.log(` ERROR: ${toolchain.diagnostic}`)
169
+ } else {
170
+ yield* Console.log(toolchain.table.trimEnd())
171
+ }
172
+ const enabled = plugins.filter((p) => p.sot === "true").length
173
+ yield* Console.log(
174
+ `\nPlugins: ${plugins.length} known (${enabled} SoT-enabled) — details: docks-kit plugins list`
175
+ )
176
+ const installed = skills.filter((s) => s.installed).length
177
+ yield* Console.log(
178
+ `Skills: ${skills.length} known (${installed} installed) — details: docks-kit skills list`
55
179
  )
56
180
  }
57
181
 
58
- yield* Console.log(`Kit home: ${kitHome()}\n`)
59
- yield* Console.log("Deployed vs SoT (drift is expected for deploy-time modifiers):")
60
- for (const d of drift) {
61
- const mark = d.drifted ? "≠" : "="
62
- yield* Console.log(` ${d.setting.padEnd(22)} deployed=${d.deployed} ${mark} SoT=${d.sot}`)
182
+ if (diagnostics.length > 0) {
183
+ yield* Effect.sync(() => {
184
+ process.exitCode = diagnostics[0]?.exitCode ?? 1
185
+ })
63
186
  }
64
- yield* Console.log("\nToolchain:")
65
- yield* Console.log(toolchainTable.trimEnd())
66
- const enabled = plugins.filter((p) => p.sot === "true").length
67
- yield* Console.log(
68
- `\nPlugins: ${plugins.length} known (${enabled} SoT-enabled) — details: docks-kit plugins list`
69
- )
70
- const installed = skills.filter((s) => s.installed).length
71
- yield* Console.log(
72
- `Skills: ${skills.length} known (${installed} installed) — details: docks-kit skills list`
73
- )
74
187
  })
75
188
  ).pipe(
76
189
  Command.withDescription(
@@ -138,6 +138,10 @@ export const syncCommand = Command.make(
138
138
  Option.map(config.codexModel, (m) => args.push(`--codex-model=${m}`))
139
139
  Option.map(config.codexEffort, (level) => args.push(`--codex-effort=${level}`))
140
140
  for (const occurrence of config.claudePlugin) {
141
+ if (occurrence.trim() === "") {
142
+ args.push("--claude-plugin=")
143
+ continue
144
+ }
141
145
  occurrence
142
146
  .split(",")
143
147
  .map((p) => p.trim())
@@ -42,11 +42,99 @@ const readPackageVersion = (home: string): string => {
42
42
  }
43
43
  }
44
44
 
45
+ export type PackageManager = "bun" | "npm"
46
+
47
+ interface PackageRootCapture {
48
+ readonly status: number | null
49
+ readonly stdout: string
50
+ readonly error?: Error
51
+ }
52
+
53
+ type CapturePackageRoot = (
54
+ command: string,
55
+ args: ReadonlyArray<string>
56
+ ) => PackageRootCapture
57
+
58
+ const capturePackageRoot: CapturePackageRoot = (command, args) => {
59
+ const res = spawnSync(command, [...args], {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "pipe"]
62
+ })
63
+ return {
64
+ status: res.status,
65
+ stdout: res.stdout ?? "",
66
+ ...(res.error === undefined ? {} : { error: res.error })
67
+ }
68
+ }
69
+
70
+ export const packageManagerForHome = (
71
+ home: string,
72
+ environment: NodeJS.ProcessEnv = process.env
73
+ ): PackageManager => {
74
+ const underEnvironmentRoot = (name: "BUN_INSTALL_GLOBAL_DIR" | "BUN_INSTALL"): boolean => {
75
+ const root = environment[name]?.trim()
76
+ return root !== undefined && root !== "" && (home === root || home.startsWith(`${root}/`))
77
+ }
78
+ return home.includes("/.bun/") ||
79
+ underEnvironmentRoot("BUN_INSTALL_GLOBAL_DIR") ||
80
+ underEnvironmentRoot("BUN_INSTALL")
81
+ ? "bun"
82
+ : "npm"
83
+ }
84
+
85
+ export type GlobalPackageHome =
86
+ | { readonly ok: true; readonly home: string }
87
+ | { readonly ok: false; readonly diagnostic: string }
88
+
89
+ export const resolveGlobalPackageHome = (
90
+ manager: PackageManager,
91
+ capture: CapturePackageRoot = capturePackageRoot
92
+ ): GlobalPackageHome => {
93
+ const commandArgs = manager === "bun" ? ["pm", "-g", "ls"] : ["root", "-g"]
94
+ const result = capture(manager, commandArgs)
95
+ if (result.error !== undefined || result.status !== 0) {
96
+ const detail =
97
+ result.error !== undefined
98
+ ? result.error.message
99
+ : `exit ${result.status ?? "without status"}`
100
+ return {
101
+ ok: false,
102
+ diagnostic: `${manager} ${commandArgs.join(" ")} failed: ${detail}`
103
+ }
104
+ }
105
+
106
+ if (manager === "npm") {
107
+ const root = result.stdout.trim()
108
+ return root === ""
109
+ ? { ok: false, diagnostic: "npm root -g failed: empty output" }
110
+ : { ok: true, home: join(root, "docks-kit") }
111
+ }
112
+
113
+ const globalHeader = result.stdout
114
+ .split(/\r?\n/)
115
+ .map((line) => line.trim())
116
+ .find((line) => / node_modules(?: \(\d+\))?$/.test(line))
117
+ const globalDir =
118
+ globalHeader === undefined
119
+ ? undefined
120
+ : /^(.*) node_modules(?: \(\d+\))?$/.exec(globalHeader)?.[1]
121
+ return globalDir === undefined || globalDir === ""
122
+ ? { ok: false, diagnostic: "bun pm -g ls did not report its global package root" }
123
+ : { ok: true, home: join(globalDir, "node_modules", "docks-kit") }
124
+ }
125
+
45
126
  export const packageUpdateResult = (
46
127
  before: string,
47
- after: string
128
+ after: string,
129
+ samePackageRoot = true
48
130
  ): { alreadyCurrent: boolean; message: string } => {
49
131
  if (before === "" || after === "") return { alreadyCurrent: false, message: "" }
132
+ if (!samePackageRoot) {
133
+ return {
134
+ alreadyCurrent: false,
135
+ message: `Installed ${after} in the selected global package root.`
136
+ }
137
+ }
50
138
  if (before === after) {
51
139
  return { alreadyCurrent: true, message: `Already at the latest version (${after}).` }
52
140
  }
@@ -99,33 +187,39 @@ const updateCheckout = (home: string, skipSync: boolean) =>
99
187
 
100
188
  const updatePackage = (home: string, skipSync: boolean) =>
101
189
  Effect.gen(function* () {
102
- // Bun's global dir is configurable (BUN_INSTALL_GLOBAL_DIR / BUN_INSTALL),
103
- // so the ~/.bun path shape alone under-detects Bun installs.
104
- const underEnvDir = (v: string): boolean => {
105
- const dir = process.env[v]
106
- return dir !== undefined && dir !== "" && home.startsWith(dir)
107
- }
108
- const viaBun =
109
- home.includes("/.bun/") ||
110
- home.includes("\\.bun\\") ||
111
- underEnvDir("BUN_INSTALL_GLOBAL_DIR") ||
112
- underEnvDir("BUN_INSTALL")
190
+ const manager = packageManagerForHome(home)
113
191
  const beforeVersion = readPackageVersion(home)
114
- const res = viaBun
115
- ? spawnSync("bun", ["add", "-g", "docks-kit@latest"], { stdio: "inherit" })
116
- : spawnSync("npm", ["install", "-g", "docks-kit@latest"], { stdio: "inherit" })
192
+ const res =
193
+ manager === "bun"
194
+ ? spawnSync("bun", ["add", "-g", "docks-kit@latest"], { stdio: "inherit" })
195
+ : spawnSync("npm", ["install", "-g", "docks-kit@latest"], { stdio: "inherit" })
117
196
  if (res.error !== undefined || res.status !== 0) {
118
- return yield* bail(`global package update failed (${viaBun ? "bun add -g" : "npm install -g"} docks-kit@latest)`, 1)
197
+ return yield* bail(
198
+ `global package update failed (${manager === "bun" ? "bun add -g" : "npm install -g"} docks-kit@latest)`,
199
+ 1
200
+ )
119
201
  }
120
202
 
121
- const result = packageUpdateResult(beforeVersion, readPackageVersion(home))
203
+ const updated = resolveGlobalPackageHome(manager)
204
+ if (!updated.ok) {
205
+ return yield* bail(
206
+ `global package update completed, but the updated package root could not be resolved: ${updated.diagnostic}`,
207
+ 1
208
+ )
209
+ }
210
+ const afterVersion = readPackageVersion(updated.home)
211
+ if (afterVersion === "") {
212
+ return yield* bail(
213
+ `global package update completed, but ${join(updated.home, "package.json")} has no readable version`,
214
+ 1
215
+ )
216
+ }
217
+ const result = packageUpdateResult(beforeVersion, afterVersion, home === updated.home)
122
218
  if (result.message !== "") yield* Console.log(result.message)
123
219
  if (result.alreadyCurrent) return
124
220
  if (skipSync) return yield* Console.log("Kit updated. Run: docks-kit sync")
125
221
  yield* Console.log("Kit updated - running sync with the new version...")
126
- // Chain through the package dir just updated (global installs update in
127
- // place) — a bare `docks-kit` PATH lookup could hit a different shim.
128
- return yield* chainSync(process.execPath, updateSyncArgs(home))
222
+ return yield* chainSync(process.execPath, updateSyncArgs(updated.home))
129
223
  })
130
224
 
131
225
  export const updateCommand = Command.make("update", { noSync }, (config) =>
@@ -1,4 +1,4 @@
1
- import { sotClaudeSettings, type Tool } from "./manifests"
1
+ import { sotClaudeSettings, topLevelTomlString, type Tool } from "./manifests"
2
2
  import { payloadText } from "./payload"
3
3
 
4
4
  export const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh"] as const
@@ -53,12 +53,12 @@ export function validateEffortDefault(tool: Tool, value: unknown): string {
53
53
  return value
54
54
  }
55
55
 
56
- function codexSotEffort(): string | undefined {
57
- return payloadText("SoT/.codex/config.toml").match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1]
58
- }
59
56
 
60
57
  export function sotEffort(tool: Tool): string {
61
- const value = tool === "claude" ? sotClaudeSettings().effortLevel : codexSotEffort()
58
+ const value =
59
+ tool === "claude"
60
+ ? sotClaudeSettings().effortLevel
61
+ : topLevelTomlString(payloadText("SoT/.codex/config.toml"), "model_reasoning_effort")
62
62
  return validateEffortDefault(tool, value)
63
63
  }
64
64
 
@@ -153,7 +153,7 @@ active logger binding.
153
153
  | `exec.ts` | slash-stable path helpers, POSIX command probes, capture/spawn wrappers, and change-detecting write/copy helpers |
154
154
  | `logger.ts` | Logger shape + stable raw stdout/stderr sink factory; the run-scoped verbosity gate lives in `index.ts` |
155
155
  | `deps.ts` | external-tool registry: identity, requirement class, presence probe, supported-host install hints, per-manager missing-tool dedup; callers supply the current run Logger to `warnMissing` |
156
- | `os.ts` | platform capability seam — the single `process.platform` reader (`platformName`, `isLinux`, shell-rc applicability) |
156
+ | `os.ts` | platform capability seam — host reader and injected platform normalization (`rawPlatform`, `platformName`) |
157
157
  | `services.ts` | shared raw-Logger + DependencyManager + Platform factory; wrapped in Effect Layers at `cli/src/services.ts`, with the run-scoped Logger gate applied only by `runEngineNative` |
158
158
 
159
159
  ## Platform Support
@@ -1,15 +1,17 @@
1
- import { rmSync } from "node:fs"
1
+ import { mkdtempSync, rmSync } from "node:fs"
2
2
  import { tmpdir } from "node:os"
3
3
 
4
- import { p, spawnProcess } from "./exec"
4
+ import { p, spawnProcess, type AsyncProcessOptions, type AsyncProcessResult } from "./exec"
5
5
  import type { Ctx } from "./index"
6
6
  import type { EngineServices } from "./services"
7
7
  import { field } from "./toolchain"
8
8
 
9
9
  export type BunRuntimeState =
10
10
  | { readonly kind: "ready"; readonly executable: string }
11
- | { readonly kind: "deferred"; readonly reason: "missing-curl" | "install-failed" }
12
-
11
+ | {
12
+ readonly kind: "deferred"
13
+ readonly reason: "missing-curl" | "download-failed" | "installer-failed" | "install-failed"
14
+ }
13
15
 
14
16
  function predictedExecutable(ctx: Ctx): string {
15
17
  const root = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
@@ -18,11 +20,26 @@ function predictedExecutable(ctx: Ctx): string {
18
20
  return p(root, "bin", "bun")
19
21
  }
20
22
 
21
- async function installBun(pin: string, installer: string): Promise<void> {
22
- const download = await spawnProcess("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], { stdio: "ignore" })
23
- if (download.error === undefined && download.exitCode === 0) {
24
- await spawnProcess("bash", [installer, `bun-v${pin}`], { stdio: "ignore" })
23
+ type BunInstallResult =
24
+ | { readonly ok: true }
25
+ | { readonly ok: false; readonly reason: "download-failed" | "installer-failed"; readonly detail: string }
26
+
27
+ function processFailure(result: AsyncProcessResult): string {
28
+ const details = [result.error?.message, result.stderr.trim()].filter((value): value is string => value !== undefined && value !== "")
29
+ return details.join(": ") || (result.exitCode === null ? "the process ended without an exit code" : `exit code ${result.exitCode}`)
30
+ }
31
+
32
+ async function installBun(pin: string, installer: string): Promise<BunInstallResult> {
33
+ const options: AsyncProcessOptions = { stdio: ["ignore", "ignore", "pipe"] }
34
+ const download = await spawnProcess("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], options)
35
+ if (download.error !== undefined || download.exitCode !== 0) {
36
+ return { ok: false, reason: "download-failed", detail: processFailure(download) }
37
+ }
38
+ const install = await spawnProcess("bash", [installer, `bun-v${pin}`], options)
39
+ if (install.error !== undefined || install.exitCode !== 0) {
40
+ return { ok: false, reason: "installer-failed", detail: processFailure(install) }
25
41
  }
42
+ return { ok: true }
26
43
  }
27
44
 
28
45
  export function bunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRuntimeState> {
@@ -52,11 +69,20 @@ async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunR
52
69
  return { kind: "ready", executable }
53
70
  }
54
71
  services.logger.warn(`Bun not found — installing Bun ${pin} (kit-verified)...`)
55
- const installer = p(tmpdir(), `bun-install-${process.pid}.sh`)
72
+ const temporaryDir = mkdtempSync(p(tmpdir(), "docks-kit-bun-"))
73
+ const installer = p(temporaryDir, "install.sh")
74
+ let result: BunInstallResult
56
75
  try {
57
- await installBun(pin, installer)
76
+ result = await installBun(pin, installer)
58
77
  } finally {
59
- rmSync(installer, { force: true })
78
+ rmSync(temporaryDir, { recursive: true, force: true })
79
+ }
80
+ if (!result.ok) {
81
+ const stage = result.reason === "download-failed" ? "installer download" : "installer"
82
+ services.logger.warn(
83
+ `Bun ${stage} failed (${result.detail}). Install Bun manually from https://bun.sh/docs/installation, then re-run sync.`
84
+ )
85
+ return { kind: "deferred", reason: result.reason }
60
86
  }
61
87
 
62
88
  const installed = await services.deps.path("bun")
@@ -4,6 +4,7 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs"
4
4
 
5
5
  import { resolveEffort } from "../efforts"
6
6
  import type { Ctx } from "./index"
7
+ import { ExitError } from "./parseArgs"
7
8
  import { isObject, jqStringify, parseJson } from "./jq"
8
9
 
9
10
  interface ClaudeSettingEdit {
@@ -19,27 +20,44 @@ function syncClaudeSetting(ctx: Ctx, edit: ClaudeSettingEdit): void {
19
20
  const { change, echo, err, verbose, warn } = ctx.services.logger
20
21
  const userSettings = p(ctx.home, ".claude", "settings.json")
21
22
 
22
- if (ctx.dryRun) {
23
- echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
24
- return
25
- }
26
-
27
23
  let text: string
28
24
  try {
29
25
  text = readFileSync(userSettings, "utf8")
30
- } catch {
31
- warn(`(${edit.tag}) ${userSettings} missing skipped`)
32
- return
26
+ } catch (error) {
27
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
28
+ // A dry-run sync writes nothing, so an absent file here does not mean the
29
+ // edit is skipped: the same run already previewed installing the file,
30
+ // and the real run applies the edit to it. Outside a sync, such as
31
+ // `docks-kit model claude <m>`, nothing creates the file and the skip
32
+ // stands.
33
+ if (ctx.dryRun && ctx.syncClaude) {
34
+ echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
35
+ return
36
+ }
37
+ warn(`(${edit.tag}) ${userSettings} missing — skipped`)
38
+ return
39
+ }
40
+ const cause = error instanceof Error ? error.message : String(error)
41
+ err(`(${edit.tag}) could not read ${userSettings}: ${cause}`)
42
+ throw new ExitError(1)
33
43
  }
34
44
  const doc = parseJson(text)
35
45
  if (doc === undefined) {
36
46
  err(`(${edit.tag}) ${userSettings} is not valid JSON — skipped`)
37
47
  return
38
48
  }
39
- if (isObject(doc)) {
40
- if (edit.value === undefined) delete doc[edit.key]
41
- else doc[edit.key] = edit.value
49
+ if (!isObject(doc)) {
50
+ err(`(${edit.tag}) ${userSettings} must contain a JSON object — aborting`)
51
+ throw new ExitError(1)
42
52
  }
53
+
54
+ if (ctx.dryRun) {
55
+ echo(`[dry-run] (${edit.tag}) ${edit.dryRun} in ${userSettings}`)
56
+ return
57
+ }
58
+
59
+ if (edit.value === undefined) delete doc[edit.key]
60
+ else doc[edit.key] = edit.value
43
61
  const out = jqStringify(doc)
44
62
  if (out === text) {
45
63
  verbose(edit.unchanged)