docks-kit 0.15.2 → 0.15.3

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.
Files changed (38) hide show
  1. package/AGENTS.md +21 -15
  2. package/README.md +33 -31
  3. package/cli/docs/flags.md +0 -1
  4. package/cli/docs/install.md +28 -13
  5. package/cli/docs/overview.md +2 -2
  6. package/cli/docs/platforms.md +5 -2
  7. package/cli/docs/sync-layers.md +3 -4
  8. package/cli/docs/toolchain.md +26 -34
  9. package/cli/src/commands/docs.ts +3 -3
  10. package/cli/src/commands/sync.ts +0 -5
  11. package/cli/src/commands/toolchain.ts +4 -7
  12. package/cli/src/commands/update.ts +77 -26
  13. package/cli/src/engine-native/DESIGN.md +31 -22
  14. package/cli/src/engine-native/bun.ts +10 -8
  15. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  16. package/cli/src/engine-native/claudeSync.ts +52 -24
  17. package/cli/src/engine-native/codexSync.ts +10 -5
  18. package/cli/src/engine-native/deps.ts +27 -88
  19. package/cli/src/engine-native/exec.ts +41 -10
  20. package/cli/src/engine-native/index.ts +0 -2
  21. package/cli/src/engine-native/modes.ts +23 -14
  22. package/cli/src/engine-native/os/darwin.ts +62 -0
  23. package/cli/src/engine-native/os/index.ts +42 -0
  24. package/cli/src/engine-native/os/linux.ts +62 -0
  25. package/cli/src/engine-native/os/targets.ts +73 -0
  26. package/cli/src/engine-native/os/types.ts +75 -0
  27. package/cli/src/engine-native/os/windows.ts +176 -0
  28. package/cli/src/engine-native/parseArgs.ts +0 -4
  29. package/cli/src/engine-native/services.ts +0 -6
  30. package/cli/src/engine-native/skillsSync.ts +125 -77
  31. package/cli/src/engine-native/toolchain.ts +4 -150
  32. package/cli/src/engine.ts +3 -2
  33. package/cli/src/generated/sotPayload.ts +6 -6
  34. package/cli/src/manifests.ts +12 -2
  35. package/docks-kit +1 -1
  36. package/docks-kit.ps1 +123 -0
  37. package/package.json +9 -5
  38. package/cli/src/engine-native/os.ts +0 -16
@@ -1,20 +1,62 @@
1
1
  import { Command, Flag } from "effect/unstable/cli"
2
2
  import { Console, Effect } from "effect"
3
- import { spawnSync } from "node:child_process"
3
+ import { spawnSync, type SpawnSyncOptions, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process"
4
4
  import { existsSync, readFileSync } from "node:fs"
5
- import { join } from "node:path"
6
5
  import { bail, compiled } from "../engine"
7
6
  import { kitHome } from "../kitHome"
7
+ import { p, which } from "../engine-native/exec"
8
+ import { hostOs, type HostOs, type Invocation } from "../engine-native/os"
8
9
 
9
10
  const noSync = Flag.boolean("no-sync").pipe(
10
11
  Flag.withDescription("Update the kit only; skip the chained flag-less sync")
11
12
  )
12
13
 
13
- const git = (home: string, args: Array<string>): { ok: boolean; out: string } => {
14
- const res = spawnSync("git", ["-C", home, ...args], {
14
+ /** A tool this host cannot resolve, shaped like the failed spawn it replaces. */
15
+ const notFound = (command: string): SpawnSyncReturns<string> => ({
16
+ pid: 0,
17
+ output: [],
18
+ stdout: "",
19
+ stderr: "",
20
+ status: null,
21
+ signal: null,
22
+ error: new Error(`command not found on PATH: ${command}`)
23
+ })
24
+
25
+ /**
26
+ * Every child in this command starts here, because two host facts must never be
27
+ * separated from the argv they describe: a Windows shim invocation is only
28
+ * correct with the verbatim-arguments flag, and a pathless name would let
29
+ * CreateProcess search the parent's current directory before the system one.
30
+ */
31
+ export const spawnUpdate = (
32
+ command: string,
33
+ args: ReadonlyArray<string>,
34
+ overrides: SpawnSyncOptions = {},
35
+ host: HostOs = hostOs()
36
+ ): SpawnSyncReturns<string> => {
37
+ const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
38
+ const executablePath = resolvesSuffixes ? which(command, host.executableSuffixes) : command
39
+ if (executablePath === "") return notFound(command)
40
+ let invocation: Invocation
41
+ try {
42
+ invocation = host.invoke(executablePath, args)
43
+ } catch (cause) {
44
+ // A value this host cannot put on a command line at all. Print the encoder's
45
+ // reason and exit, matching how this command reports a failed child.
46
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
47
+ return process.exit(2)
48
+ }
49
+ const options: SpawnSyncOptionsWithStringEncoding = {
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ ...overrides,
15
52
  encoding: "utf8",
16
- stdio: ["ignore", "pipe", "pipe"]
17
- })
53
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
54
+ }
55
+ return spawnSync(invocation.command, [...invocation.args], options)
56
+ }
57
+
58
+ const git = (home: string, args: Array<string>): { ok: boolean; out: string } => {
59
+ const res = spawnUpdate("git", ["-C", home, ...args])
18
60
  return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() }
19
61
  }
20
62
 
@@ -22,19 +64,19 @@ const git = (home: string, args: Array<string>): { ok: boolean; out: string } =>
22
64
  * version loaded, so the chained sync must be a new process. */
23
65
  const chainSync = (argv0: string, args: Array<string>): Effect.Effect<void> =>
24
66
  Effect.sync(() => {
25
- const res = spawnSync(argv0, args, { stdio: "inherit" })
67
+ const res = spawnUpdate(argv0, args, { stdio: "inherit" })
26
68
  if (res.error !== undefined || res.status !== 0) process.exit(res.status ?? 1)
27
69
  })
28
70
 
29
71
  export const updateSyncArgs = (home: string): Array<string> => [
30
- join(home, "cli/src/main.ts"),
72
+ p(home, "cli/src/main.ts"),
31
73
  "sync",
32
74
  "--skip-plugin-refresh"
33
75
  ]
34
76
 
35
77
  const readPackageVersion = (home: string): string => {
36
78
  try {
37
- const doc: unknown = JSON.parse(readFileSync(join(home, "package.json"), "utf8"))
79
+ const doc: unknown = JSON.parse(readFileSync(p(home, "package.json"), "utf8"))
38
80
  if (doc === null || typeof doc !== "object" || !("version" in doc)) return ""
39
81
  return typeof doc.version === "string" ? doc.version : ""
40
82
  } catch {
@@ -56,10 +98,7 @@ type CapturePackageRoot = (
56
98
  ) => PackageRootCapture
57
99
 
58
100
  const capturePackageRoot: CapturePackageRoot = (command, args) => {
59
- const res = spawnSync(command, [...args], {
60
- encoding: "utf8",
61
- stdio: ["ignore", "pipe", "pipe"]
62
- })
101
+ const res = spawnUpdate(command, args)
63
102
  return {
64
103
  status: res.status,
65
104
  stdout: res.stdout ?? "",
@@ -67,15 +106,27 @@ const capturePackageRoot: CapturePackageRoot = (command, args) => {
67
106
  }
68
107
  }
69
108
 
109
+ /**
110
+ * A Bun global home is `<root>/.bun/install/global/node_modules/<pkg>`. Windows
111
+ * reports that path with backslashes, so containment is tested on a normalized
112
+ * copy — but only on Windows, because a backslash is a legal POSIX filename
113
+ * character and must never be read as a separator there.
114
+ */
70
115
  export const packageManagerForHome = (
71
116
  home: string,
72
- environment: NodeJS.ProcessEnv = process.env
117
+ environment: NodeJS.ProcessEnv = process.env,
118
+ host: HostOs = hostOs()
73
119
  ): PackageManager => {
120
+ const normalize = (value: string): string =>
121
+ host.id === "windows" ? value.replaceAll("\\", "/") : value
122
+ const normalizedHome = normalize(home)
74
123
  const underEnvironmentRoot = (name: "BUN_INSTALL_GLOBAL_DIR" | "BUN_INSTALL"): boolean => {
75
124
  const root = environment[name]?.trim()
76
- return root !== undefined && root !== "" && (home === root || home.startsWith(`${root}/`))
125
+ if (root === undefined || root === "") return false
126
+ const normalizedRoot = normalize(root)
127
+ return normalizedHome === normalizedRoot || normalizedHome.startsWith(`${normalizedRoot}/`)
77
128
  }
78
- return home.includes("/.bun/") ||
129
+ return normalizedHome.includes("/.bun/") ||
79
130
  underEnvironmentRoot("BUN_INSTALL_GLOBAL_DIR") ||
80
131
  underEnvironmentRoot("BUN_INSTALL")
81
132
  ? "bun"
@@ -107,7 +158,7 @@ export const resolveGlobalPackageHome = (
107
158
  const root = result.stdout.trim()
108
159
  return root === ""
109
160
  ? { ok: false, diagnostic: "npm root -g failed: empty output" }
110
- : { ok: true, home: join(root, "docks-kit") }
161
+ : { ok: true, home: p(root, "docks-kit") }
111
162
  }
112
163
 
113
164
  const globalHeader = result.stdout
@@ -120,7 +171,7 @@ export const resolveGlobalPackageHome = (
120
171
  : /^(.*) node_modules(?: \(\d+\))?$/.exec(globalHeader)?.[1]
121
172
  return globalDir === undefined || globalDir === ""
122
173
  ? { ok: false, diagnostic: "bun pm -g ls did not report its global package root" }
123
- : { ok: true, home: join(globalDir, "node_modules", "docks-kit") }
174
+ : { ok: true, home: p(globalDir, "node_modules", "docks-kit") }
124
175
  }
125
176
 
126
177
  export const packageUpdateResult = (
@@ -143,7 +194,7 @@ export const packageUpdateResult = (
143
194
 
144
195
  const updateCheckout = (home: string, skipSync: boolean) =>
145
196
  Effect.gen(function* () {
146
- if (spawnSync("git", ["--version"], { stdio: "ignore" }).status !== 0) {
197
+ if (spawnUpdate("git", ["--version"], { stdio: "ignore" }).status !== 0) {
147
198
  return yield* bail("git not found - cannot update the kit checkout")
148
199
  }
149
200
  const dirty = git(home, ["status", "--porcelain"])
@@ -169,7 +220,7 @@ const updateCheckout = (home: string, skipSync: boolean) =>
169
220
 
170
221
  const touched = git(home, ["diff", "--name-only", before, after]).out.split("\n")
171
222
  if (touched.includes("bun.lock") || touched.includes("package.json")) {
172
- const res = spawnSync("bun", ["install", "--frozen-lockfile"], { cwd: home, stdio: "inherit" })
223
+ const res = spawnUpdate("bun", ["install", "--frozen-lockfile"], { cwd: home, stdio: "inherit" })
173
224
  if (res.error !== undefined || res.status !== 0) {
174
225
  return yield* bail("dependencies changed but 'bun install --frozen-lockfile' failed - fix that, then run docks-kit sync", 1)
175
226
  }
@@ -189,10 +240,10 @@ const updatePackage = (home: string, skipSync: boolean) =>
189
240
  Effect.gen(function* () {
190
241
  const manager = packageManagerForHome(home)
191
242
  const beforeVersion = readPackageVersion(home)
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" })
243
+ const updateArgs = manager === "bun"
244
+ ? ["add", "-g", "docks-kit@latest"]
245
+ : ["install", "-g", "docks-kit@latest"]
246
+ const res = spawnUpdate(manager, updateArgs, { stdio: "inherit" })
196
247
  if (res.error !== undefined || res.status !== 0) {
197
248
  return yield* bail(
198
249
  `global package update failed (${manager === "bun" ? "bun add -g" : "npm install -g"} docks-kit@latest)`,
@@ -210,7 +261,7 @@ const updatePackage = (home: string, skipSync: boolean) =>
210
261
  const afterVersion = readPackageVersion(updated.home)
211
262
  if (afterVersion === "") {
212
263
  return yield* bail(
213
- `global package update completed, but ${join(updated.home, "package.json")} has no readable version`,
264
+ `global package update completed, but ${p(updated.home, "package.json")} has no readable version`,
214
265
  1
215
266
  )
216
267
  }
@@ -225,7 +276,7 @@ const updatePackage = (home: string, skipSync: boolean) =>
225
276
  export const updateCommand = Command.make("update", { noSync }, (config) =>
226
277
  Effect.gen(function* () {
227
278
  const home = kitHome()
228
- if (existsSync(join(home, ".git"))) {
279
+ if (existsSync(p(home, ".git"))) {
229
280
  return yield* updateCheckout(home, config.noSync)
230
281
  }
231
282
  if (home.includes("node_modules")) {
@@ -38,8 +38,8 @@ explicit removed-engine diagnostic and exits 2 with the recovery tag message.
38
38
  serial, and summaries retain canonical Claude, Codex, skills order.
39
39
  - **External CLIs stay external.** `claude`, `codex`, `npx`, `npm`, `bun`,
40
40
  `curl`, and platform package managers are spawned with argv arrays,
41
- not shell command strings except where the external installer contract is a
42
- shell script.
41
+ not shell command strings except where an external installer contract
42
+ requires a script interpreter.
43
43
  - **Backups precede mutation.** Deployed settings/config files write `.bak`
44
44
  backups before replacement.
45
45
  - **Runtime payload is in memory.** `SoT/` remains the reviewed authoring tree;
@@ -97,7 +97,7 @@ each such skip is an intentional behavior change named in its golden diff.
97
97
  Exactly one deduplicated warn per requested missing tool per run, uniform shape:
98
98
  `[warn] <tool> not installed — <platform-correct install command>`, sourced
99
99
  from the dependency registry (`deps.ts`). jq and curl are optional report rows:
100
- jq has no runtime consumer, while curl warns only when a requested POSIX Bun
100
+ jq has no runtime consumer, while curl warns only when a requested Bun
101
101
  bootstrap needs an installer download. A missing Bun defers Claude runtime
102
102
  migration without deleting working legacy hooks or statusline files.
103
103
 
@@ -116,10 +116,11 @@ changed → restart line; skills changed → discovery line) or under `--verbose
116
116
 
117
117
  ### Platform seam
118
118
 
119
- All host detection routes through `os.ts`, the engine module that reads
120
- `process.platform`. `exec.ts` contains only POSIX executable and PATH probes.
121
- `deps.ts` install hints default their platform from `os.ts` and keep the
122
- parameter injectable for tests.
119
+ All host detection routes through `os/index.ts`, which holds the engine's only
120
+ `process.platform` read and keeps platform normalization injectable for tests.
121
+ Per-OS facts live in `os/linux.ts`, `os/darwin.ts`, and `os/windows.ts` behind
122
+ `HostOs`; consumers select those facts through the package rather than branching
123
+ on the host directly.
123
124
 
124
125
  ### Verbosity plumbing
125
126
 
@@ -139,43 +140,51 @@ active logger binding.
139
140
  | `index.ts` | sync orchestration, target dispatch, run summary and next-step blocks |
140
141
  | `../payload.ts` | generated text/byte payload reads and presentation-only source labels |
141
142
  | `claudeSync.ts` | Claude pipeline: Bun bootstrap, prepared settings transaction, runtime assets, deploy-time modifiers, `~/.claude.json`, readiness-gated removed artifacts, plugins, optional plugins, LSP binaries |
142
- | `bun.ts` | per-run memoized Bun resolution/bootstrap shared by Claude runtime, effect-solutions, and direct toolchain ensure |
143
- | `claudeRuntime.ts` | sentinel validation, absolute runtime paths, no-cutover settings projection, and POSIX statusline commands |
143
+ | `bun.ts` | per-run memoized Bun resolution/bootstrap shared by the Claude runtime and direct toolchain ensure |
144
+ | `claudeRuntime.ts` | sentinel validation, absolute runtime paths, no-cutover settings projection, and per-host statusline and failure-hook command materialization |
144
145
  | `settings.ts` | pure Claude settings merge/reconcile semantics and permission-array union |
145
146
  | `claudeModel.ts` | deployed Claude model modifier and direct `model claude` write path |
146
147
  | `codexSync.ts` | Codex pipeline: bubblewrap check, config merge, rules, AGENTS.md, personal marketplace, plugin refresh |
147
148
  | `codexToml.ts` | line-based top-level TOML replacement and deployed Codex model modifier |
148
- | `skillsSync.ts` | universal skill install/prune, Claude symlink healing, effect-solutions callback, managed-skill snapshot |
149
- | `toolchain.ts` | tool presence/version probes, verified-version gate, managed install/upgrade orchestration, report table |
149
+ | `skillsSync.ts` | universal skill install/prune, ordered symlink/junction/copy fallback, Claude entry healing, managed-skill snapshot |
150
+ | `toolchain.ts` | tool presence/version probes, verified-version floor reporting, report table |
150
151
  | `modes.ts` | direct `model` and `toolchain` modes |
151
152
  | `models.ts` | model catalog listing and validation |
152
153
  | `jq.ts` | JSON helpers that preserve jq-style merge/order/stringify behavior where the deployed file contract needs it |
153
- | `exec.ts` | slash-stable path helpers, POSIX command probes, capture/spawn wrappers, and change-detecting write/copy helpers |
154
+ | `exec.ts` | slash-stable path helpers, host-aware PATH probes and invocation, capture/spawn wrappers, and change-detecting write/copy helpers |
154
155
  | `logger.ts` | Logger shape + stable raw stdout/stderr sink factory; the run-scoped verbosity gate lives in `index.ts` |
155
156
  | `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 — host reader and injected platform normalization (`rawPlatform`, `platformName`) |
157
+ | `os/` | host reader, injected platform normalization, and per-OS `HostOs` fact modules |
157
158
  | `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
159
 
159
160
  ## Platform Support
160
161
 
161
- - EngineNative supports Linux and macOS on x64 and arm64.
162
+ - EngineNative supports Linux, macOS, and Windows on x64 and arm64.
162
163
  - Unsupported hosts fail before launcher fallback, dependency probes, downloads,
163
164
  settings writes, or sync work.
164
- - Runtime hooks use POSIX commands and absolute Bun paths.
165
- - Symlink creation remains capability-driven: permission or filesystem failures
166
- fall back to copy without predicting the host.
165
+ - Claude runtime settings use absolute Bun/script paths and materialize
166
+ statusline and failure-hook commands through the selected `HostOs`.
167
+ - Directory linking is capability-driven. The host module supplies only the
168
+ order while the runtime decides the outcome: symlink, then a Windows junction
169
+ with an absolute target, then a recursive copy. Copies carry a kit marker so
170
+ later sync can heal them back to a real link and prune can reclaim them
171
+ without touching user-owned directories.
167
172
 
168
173
  ## Tests
169
174
 
170
- - `bun run test:unit` covers pure JSON merge semantics and jq-oracle cases.
175
+ - `bun run test:unit` covers pure JSON merge semantics, jq-oracle cases, and
176
+ the three `HostOs` modules independently of the current host.
171
177
  - `bun run golden:dryrun` compares live native dry-run output to
172
178
  `cli/test/goldens/dryrun.json`.
173
179
  - `bun run golden:mutation` compares live native mutation snapshots, argv logs,
174
180
  output, and TOML invariants to `cli/test/goldens/mutation.json`.
175
- - `.github/workflows/parity.yml` is the golden-regression workflow: Linux runs
176
- unit + golden + prove-red plus the exact materialized POSIX runtime commands.
177
- - `.github/workflows/release-cli.yml` publishes the four Linux/macOS x64/arm64
178
- binaries, `SHA256SUMS`, and the npm package.
181
+ - `.github/workflows/parity.yml` runs its portable lane on `ubuntu-24.04`,
182
+ `macos-26`, and `windows-2025`: typecheck, unit tests, host-specific runtime
183
+ command checks, and compilation and execution of the native host artifact.
184
+ The Linux-canonical snapshot lane stays on Ubuntu and runs both golden suites
185
+ plus their prove-red checks.
186
+ - `.github/workflows/release-cli.yml` publishes six binaries for Linux, macOS,
187
+ and Windows on x64 and arm64, plus `SHA256SUMS` and the npm package.
179
188
 
180
189
  ## Non-Goals
181
190
 
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
4
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
+ import { hostOs, type HostOs } from "./os"
7
8
  import { field } from "./toolchain"
8
9
 
9
10
  export type BunRuntimeState =
@@ -13,11 +14,11 @@ export type BunRuntimeState =
13
14
  readonly reason: "missing-curl" | "download-failed" | "installer-failed" | "install-failed"
14
15
  }
15
16
 
16
- function predictedExecutable(ctx: Ctx): string {
17
+ function predictedExecutable(ctx: Ctx, host: HostOs = hostOs()): string {
17
18
  const root = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
18
19
  ? process.env["BUN_INSTALL"]!
19
20
  : p(ctx.home, ".bun")
20
- return p(root, "bin", "bun")
21
+ return p(root, "bin", host.bunExecutableName)
21
22
  }
22
23
 
23
24
  type BunInstallResult =
@@ -29,13 +30,14 @@ function processFailure(result: AsyncProcessResult): string {
29
30
  return details.join(": ") || (result.exitCode === null ? "the process ended without an exit code" : `exit code ${result.exitCode}`)
30
31
  }
31
32
 
32
- async function installBun(pin: string, installer: string): Promise<BunInstallResult> {
33
+ async function installBun(pin: string, directory: string, host: HostOs = hostOs()): Promise<BunInstallResult> {
33
34
  const options: AsyncProcessOptions = { stdio: ["ignore", "ignore", "pipe"] }
34
- const download = await spawnProcess("curl", ["-fsSL", "https://bun.sh/install", "-o", installer], options)
35
+ const installer = host.bunInstaller(pin, directory)
36
+ const download = await spawnProcess(installer.download.command, installer.download.args, options)
35
37
  if (download.error !== undefined || download.exitCode !== 0) {
36
38
  return { ok: false, reason: "download-failed", detail: processFailure(download) }
37
39
  }
38
- const install = await spawnProcess("bash", [installer, `bun-v${pin}`], options)
40
+ const install = await spawnProcess(installer.run.command, installer.run.args, options)
39
41
  if (install.error !== undefined || install.exitCode !== 0) {
40
42
  return { ok: false, reason: "installer-failed", detail: processFailure(install) }
41
43
  }
@@ -50,6 +52,7 @@ export function bunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRun
50
52
  }
51
53
 
52
54
  async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRuntimeState> {
55
+ const host = hostOs(services.platform.name())
53
56
 
54
57
  const existing = await services.deps.path("bun")
55
58
  if (existing !== "") return { kind: "ready", executable: existing }
@@ -64,16 +67,15 @@ async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunR
64
67
  return { kind: "deferred", reason: "missing-curl" }
65
68
  }
66
69
  if (ctx.dryRun) {
67
- const executable = predictedExecutable(ctx)
70
+ const executable = predictedExecutable(ctx, host)
68
71
  services.logger.echo(`[dry-run] install Bun ${pin} (kit-verified) -> ${executable}`)
69
72
  return { kind: "ready", executable }
70
73
  }
71
74
  services.logger.warn(`Bun not found — installing Bun ${pin} (kit-verified)...`)
72
75
  const temporaryDir = mkdtempSync(p(tmpdir(), "docks-kit-bun-"))
73
- const installer = p(temporaryDir, "install.sh")
74
76
  let result: BunInstallResult
75
77
  try {
76
- result = await installBun(pin, installer)
78
+ result = await installBun(pin, temporaryDir, host)
77
79
  } finally {
78
80
  rmSync(temporaryDir, { recursive: true, force: true })
79
81
  }
@@ -1,5 +1,6 @@
1
1
  import { p } from "./exec"
2
2
  import { isObject, parseJson, type Json } from "./jq"
3
+ import { hostOs, type HostOs } from "./os"
3
4
 
4
5
  const BUN_SENTINEL = "__DOCKS_KIT_BUN__"
5
6
  const SESSION_START_SENTINEL = "__DOCKS_KIT_SESSION_START__"
@@ -83,23 +84,30 @@ function validateTemplate(template: Json): void {
83
84
  }
84
85
  }
85
86
 
86
- function posixLiteral(value: string): string {
87
- return `'${value.replaceAll("'", `'"'"'`)}'`
88
- }
89
87
 
90
- export function statusLineCommand(runtime: ClaudeRuntimePaths): string {
91
- const bun = posixLiteral(runtime.bun)
92
- const script = posixLiteral(runtime.statusline)
93
- return `test -x ${bun} && test -f ${script} && exec ${bun} ${script} || true`
88
+ export function statusLineCommand(runtime: ClaudeRuntimePaths, host: HostOs = hostOs()): string {
89
+ return host.statusLineCommand(runtime.bun, runtime.statusline)
94
90
  }
95
91
 
96
92
  export function materializeClaudeSettings(
97
93
  template: Json,
98
- runtime: ClaudeRuntimePaths | undefined
94
+ runtime: ClaudeRuntimePaths | undefined,
95
+ host: HostOs = hostOs()
99
96
  ): Json {
100
97
  validateTemplate(template)
101
98
  const result = cloneJson(template)
102
99
  const hooks = hooksObject(result)
100
+ const failureGroups = hooks["PostToolUseFailure"]
101
+ if (Array.isArray(failureGroups)) {
102
+ for (const group of failureGroups) {
103
+ if (!isObject(group) || !Array.isArray(group["hooks"])) continue
104
+ for (const handler of group["hooks"]) {
105
+ if (isObject(handler) && handler["type"] === "command" && typeof handler["command"] === "string") {
106
+ handler["command"] = host.failureHookCommand(handler["command"])
107
+ }
108
+ }
109
+ }
110
+ }
103
111
  if (runtime === undefined) {
104
112
  delete hooks["SessionStart"]
105
113
  delete hooks["Notification"]
@@ -113,7 +121,7 @@ export function materializeClaudeSettings(
113
121
  notification["command"] = runtime.bun
114
122
  notification["args"] = [runtime.notify]
115
123
  if (!isObject(result) || !isObject(result["statusLine"])) throw new Error("Claude statusLine object is missing")
116
- result["statusLine"]["command"] = statusLineCommand(runtime)
124
+ result["statusLine"]["command"] = statusLineCommand(runtime, host)
117
125
  }
118
126
 
119
127
  for (const sentinel of [BUN_SENTINEL, SESSION_START_SENTINEL, NOTIFY_SENTINEL, STATUSLINE_SENTINEL]) {
@@ -26,6 +26,7 @@ import { p, spawnProcess, writeBytesIfChanged, writeTextIfChanged } from "./exec
26
26
  import type { Ctx } from "./index"
27
27
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
28
28
  import { ExitError } from "./parseArgs"
29
+ import { hostOs } from "./os"
29
30
  import { mergeSettings, reconcileSettings } from "./settings"
30
31
  import { field } from "./toolchain"
31
32
  import { payloadBytes, payloadDisplayPath, payloadText } from "../payload"
@@ -75,7 +76,7 @@ export async function claudeSync(ctx: Ctx): Promise<ClaudeRuntimeState> {
75
76
  syncClaudeEffort(ctx, ctx.claudeEffort)
76
77
  syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
77
78
  syncClaudeJson(ctx)
78
- syncConnectorEnv(ctx)
79
+ await syncConnectorEnv(ctx)
79
80
  await syncPlugins(ctx, claudeDir)
80
81
  await syncOptionalPlugins(ctx, claudeDir)
81
82
  await syncLspServers(ctx)
@@ -320,36 +321,62 @@ function syncClaudeJson(ctx: Ctx): void {
320
321
 
321
322
  // -------------------------------------------------------- connector env ----
322
323
 
323
- function syncConnectorEnv(ctx: Ctx): void {
324
- const { change, echo, verbose } = ctx.services.logger
325
-
326
- const line = "export ENABLE_CLAUDEAI_MCP_SERVERS=false"
327
- const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
328
- const candidates = [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"].map((f) => p(ctx.home, f))
324
+ async function syncConnectorEnv(ctx: Ctx): Promise<void> {
325
+ const { change, echo, verbose, warn } = ctx.services.logger
326
+ const name = "ENABLE_CLAUDEAI_MCP_SERVERS"
327
+ const setting = hostOs().environmentSetting(name, "false")
328
+
329
+ switch (setting.kind) {
330
+ case "profile": {
331
+ const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
332
+ const candidates = setting.candidates.map((candidate) => p(ctx.home, candidate))
333
+
334
+ for (const f of candidates) {
335
+ if (existsSync(f) && readFileSync(f, "utf8").includes(name)) {
336
+ if (ctx.dryRun) {
337
+ echo(`[dry-run] ${name} already in ${f} — would skip`)
338
+ } else {
339
+ verbose(`claude.ai connectors: ${name} already set in ${f} (left as-is)`)
340
+ }
341
+ return
342
+ }
343
+ }
329
344
 
330
- for (const f of candidates) {
331
- if (existsSync(f) && readFileSync(f, "utf8").includes("ENABLE_CLAUDEAI_MCP_SERVERS")) {
345
+ const target = p(ctx.home, setting.target(process.env["SHELL"]))
332
346
  if (ctx.dryRun) {
333
- echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
334
- } else {
335
- verbose(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
347
+ echo(`[dry-run] append '${setting.line}' to ${target}`)
348
+ return
336
349
  }
350
+
351
+ appendFileSync(target, `\n${marker}\n${setting.line}\n`)
352
+ change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
353
+ ctx.nextStepTriggers.claudeRestart = true
337
354
  return
338
355
  }
339
- }
356
+ case "command": {
357
+ const existing = await spawnProcess(setting.probe.command, setting.probe.args, { stdio: "ignore" })
358
+ if (existing.error === undefined && existing.exitCode === 0) {
359
+ if (ctx.dryRun) echo(`[dry-run] ${name} already in ${setting.location} — would skip`)
360
+ else verbose(`claude.ai connectors: ${name} already set in ${setting.location} (left as-is)`)
361
+ return
362
+ }
340
363
 
341
- const shell = process.env["SHELL"] ?? "bash"
342
- const shellName = shell.slice(shell.lastIndexOf("/") + 1)
343
- const target = shellName === "zsh" ? p(ctx.home, ".zshrc") : shellName === "bash" ? p(ctx.home, ".bashrc") : p(ctx.home, ".profile")
364
+ const applyCommand = [setting.apply.command, ...setting.apply.args].join(" ")
365
+ if (ctx.dryRun) {
366
+ echo(`[dry-run] ${applyCommand} (${setting.location})`)
367
+ return
368
+ }
344
369
 
345
- if (ctx.dryRun) {
346
- echo(`[dry-run] append 'export ENABLE_CLAUDEAI_MCP_SERVERS=false' to ${target}`)
347
- return
370
+ const applied = await spawnProcess(setting.apply.command, setting.apply.args, { stdio: "ignore" })
371
+ if (applied.error === undefined && applied.exitCode === 0) {
372
+ change(`claude.ai connectors disabled via ${setting.apply.command} (open a new terminal to apply)`)
373
+ ctx.nextStepTriggers.claudeRestart = true
374
+ } else {
375
+ warn(`${applyCommand} failed — ${setting.manualHint}`)
376
+ }
377
+ return
378
+ }
348
379
  }
349
-
350
- appendFileSync(target, `\n${marker}\n${line}\n`)
351
- change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
352
- ctx.nextStepTriggers.claudeRestart = true
353
380
  }
354
381
 
355
382
  // ------------------------------------------------------------- removals ----
@@ -366,6 +393,7 @@ const REMOVED_MANIFEST = {
366
393
  "env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
367
394
  "env.CLAUDE_CODE_FORK_SUBAGENT",
368
395
  "env.CLAUDE_CODE_EFFORT_LEVEL",
396
+ "enabledPlugins.effect-kit@docks",
369
397
  "enabledPlugins.session-relay@docks",
370
398
  "hooks.PreToolUse"
371
399
  ],
@@ -375,7 +403,7 @@ const REMOVED_MANIFEST = {
375
403
  },
376
404
  claudeJsonKeys: [] as Array<string>,
377
405
  /** Home-relative artifacts the kit installed outside ~/.claude. */
378
- homeFiles: [".local/bin/session-relay"],
406
+ homeFiles: [".local/bin/effect-solutions", ".local/bin/session-relay"],
379
407
  runtimeReady: {
380
408
  hooks: ["notify.sh"],
381
409
  files: ["statusline.sh", "fetch-usage.sh"],
@@ -9,6 +9,7 @@ import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from ".
9
9
  import { p, spawnProcess } from "./exec"
10
10
  import type { Ctx } from "./index"
11
11
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
12
+ import { hostOs } from "./os"
12
13
  import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
13
14
 
14
15
  export async function codexSync(ctx: Ctx): Promise<void> {
@@ -84,10 +85,11 @@ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
84
85
 
85
86
  function bwrapSupportedOs(ctx: Ctx): boolean {
86
87
  const { warn } = ctx.services.logger
87
- const pn = ctx.services.platform.name()
88
- if (pn === "linux") return true
89
- if (pn === "darwin") return false
90
- warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
88
+ const os = hostOs(ctx.services.platform.name())
89
+ if (os.supportsBubblewrap) return true
90
+ if (os.id === "unknown") {
91
+ warn("Unknown OS — skipping bubblewrap check; Codex sandbox may not work")
92
+ }
91
93
  return false
92
94
  }
93
95
 
@@ -202,7 +204,10 @@ function scrubDeprecatedFeatures(ctx: Ctx, userConfig: string): void {
202
204
 
203
205
  const PLUGIN_TABLE_HEADER = /^\[plugins\."([^"]+)"\][ \t]*$/
204
206
  /** Plugin ids the kit retired; their deployed tables are stripped on every sync. */
205
- const RETIRED_PLUGIN_IDS: Readonly<Record<string, true>> = { "session-relay@docks": true }
207
+ const RETIRED_PLUGIN_IDS: Readonly<Record<string, true>> = {
208
+ "effect-kit@docks": true,
209
+ "session-relay@docks": true
210
+ }
206
211
 
207
212
  /** codex::remove_retired_plugin_tables — drop [plugins."<id>"] blocks for retired ids. */
208
213
  export function removeRetiredPluginTablesText(content: string): string {