docks-kit 0.15.1 → 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 (49) hide show
  1. package/AGENTS.md +57 -17
  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/model.ts +3 -0
  11. package/cli/src/commands/models.ts +5 -3
  12. package/cli/src/commands/status.ts +145 -32
  13. package/cli/src/commands/sync.ts +4 -5
  14. package/cli/src/commands/toolchain.ts +4 -7
  15. package/cli/src/commands/update.ts +177 -32
  16. package/cli/src/efforts.ts +5 -5
  17. package/cli/src/engine-native/DESIGN.md +31 -22
  18. package/cli/src/engine-native/bun.ts +42 -14
  19. package/cli/src/engine-native/claudeRuntime.ts +17 -9
  20. package/cli/src/engine-native/claudeSettingsModifiers.ts +29 -11
  21. package/cli/src/engine-native/claudeSync.ts +91 -55
  22. package/cli/src/engine-native/codexSync.ts +173 -49
  23. package/cli/src/engine-native/codexToml.ts +12 -7
  24. package/cli/src/engine-native/deps.ts +36 -95
  25. package/cli/src/engine-native/exec.ts +42 -24
  26. package/cli/src/engine-native/index.ts +17 -6
  27. package/cli/src/engine-native/models.ts +2 -9
  28. package/cli/src/engine-native/modes.ts +54 -35
  29. package/cli/src/engine-native/os/darwin.ts +62 -0
  30. package/cli/src/engine-native/os/index.ts +42 -0
  31. package/cli/src/engine-native/os/linux.ts +62 -0
  32. package/cli/src/engine-native/os/targets.ts +73 -0
  33. package/cli/src/engine-native/os/types.ts +75 -0
  34. package/cli/src/engine-native/os/windows.ts +176 -0
  35. package/cli/src/engine-native/parseArgs.ts +147 -48
  36. package/cli/src/engine-native/services.ts +1 -11
  37. package/cli/src/engine-native/settings.ts +3 -2
  38. package/cli/src/engine-native/skillsSync.ts +141 -89
  39. package/cli/src/engine-native/toolchain.ts +5 -147
  40. package/cli/src/engine.ts +41 -11
  41. package/cli/src/generated/sotPayload.ts +7 -7
  42. package/cli/src/kitHome.ts +42 -5
  43. package/cli/src/main.ts +12 -2
  44. package/cli/src/manifests.ts +28 -11
  45. package/cli/src/payload.ts +2 -5
  46. package/docks-kit +4 -4
  47. package/docks-kit.ps1 +123 -0
  48. package/package.json +9 -5
  49. package/cli/src/engine-native/os.ts +0 -24
@@ -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 the single `process.platform` reader (`platformName`, `isLinux`, shell-rc applicability) |
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
 
@@ -1,28 +1,47 @@
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
+ import { hostOs, type HostOs } from "./os"
7
8
  import { field } from "./toolchain"
8
9
 
9
10
  export type BunRuntimeState =
10
11
  | { readonly kind: "ready"; readonly executable: string }
11
- | { readonly kind: "deferred"; readonly reason: "missing-curl" | "install-failed" }
12
+ | {
13
+ readonly kind: "deferred"
14
+ readonly reason: "missing-curl" | "download-failed" | "installer-failed" | "install-failed"
15
+ }
12
16
 
13
-
14
- function predictedExecutable(ctx: Ctx): string {
17
+ function predictedExecutable(ctx: Ctx, host: HostOs = hostOs()): string {
15
18
  const root = process.env["BUN_INSTALL"] !== undefined && process.env["BUN_INSTALL"] !== ""
16
19
  ? process.env["BUN_INSTALL"]!
17
20
  : p(ctx.home, ".bun")
18
- return p(root, "bin", "bun")
21
+ return p(root, "bin", host.bunExecutableName)
22
+ }
23
+
24
+ type BunInstallResult =
25
+ | { readonly ok: true }
26
+ | { readonly ok: false; readonly reason: "download-failed" | "installer-failed"; readonly detail: string }
27
+
28
+ function processFailure(result: AsyncProcessResult): string {
29
+ const details = [result.error?.message, result.stderr.trim()].filter((value): value is string => value !== undefined && value !== "")
30
+ return details.join(": ") || (result.exitCode === null ? "the process ended without an exit code" : `exit code ${result.exitCode}`)
19
31
  }
20
32
 
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" })
33
+ async function installBun(pin: string, directory: string, host: HostOs = hostOs()): Promise<BunInstallResult> {
34
+ const options: AsyncProcessOptions = { stdio: ["ignore", "ignore", "pipe"] }
35
+ const installer = host.bunInstaller(pin, directory)
36
+ const download = await spawnProcess(installer.download.command, installer.download.args, options)
37
+ if (download.error !== undefined || download.exitCode !== 0) {
38
+ return { ok: false, reason: "download-failed", detail: processFailure(download) }
39
+ }
40
+ const install = await spawnProcess(installer.run.command, installer.run.args, options)
41
+ if (install.error !== undefined || install.exitCode !== 0) {
42
+ return { ok: false, reason: "installer-failed", detail: processFailure(install) }
25
43
  }
44
+ return { ok: true }
26
45
  }
27
46
 
28
47
  export function bunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRuntimeState> {
@@ -33,6 +52,7 @@ export function bunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRun
33
52
  }
34
53
 
35
54
  async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunRuntimeState> {
55
+ const host = hostOs(services.platform.name())
36
56
 
37
57
  const existing = await services.deps.path("bun")
38
58
  if (existing !== "") return { kind: "ready", executable: existing }
@@ -47,16 +67,24 @@ async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunR
47
67
  return { kind: "deferred", reason: "missing-curl" }
48
68
  }
49
69
  if (ctx.dryRun) {
50
- const executable = predictedExecutable(ctx)
70
+ const executable = predictedExecutable(ctx, host)
51
71
  services.logger.echo(`[dry-run] install Bun ${pin} (kit-verified) -> ${executable}`)
52
72
  return { kind: "ready", executable }
53
73
  }
54
74
  services.logger.warn(`Bun not found — installing Bun ${pin} (kit-verified)...`)
55
- const installer = p(tmpdir(), `bun-install-${process.pid}.sh`)
75
+ const temporaryDir = mkdtempSync(p(tmpdir(), "docks-kit-bun-"))
76
+ let result: BunInstallResult
56
77
  try {
57
- await installBun(pin, installer)
78
+ result = await installBun(pin, temporaryDir, host)
58
79
  } finally {
59
- rmSync(installer, { force: true })
80
+ rmSync(temporaryDir, { recursive: true, force: true })
81
+ }
82
+ if (!result.ok) {
83
+ const stage = result.reason === "download-failed" ? "installer download" : "installer"
84
+ services.logger.warn(
85
+ `Bun ${stage} failed (${result.detail}). Install Bun manually from https://bun.sh/docs/installation, then re-run sync.`
86
+ )
87
+ return { kind: "deferred", reason: result.reason }
60
88
  }
61
89
 
62
90
  const installed = await services.deps.path("bun")
@@ -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]) {
@@ -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)
@@ -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"
@@ -59,14 +60,13 @@ export async function claudeSync(ctx: Ctx): Promise<ClaudeRuntimeState> {
59
60
  template,
60
61
  runtime.kind === "ready" ? runtime.paths : undefined
61
62
  )
62
- const prepared = ctx.dryRun ? undefined : prepareClaudeSettings(ctx, claudeDir, materialized)
63
+ const prepared = prepareClaudeSettings(ctx, claudeDir, materialized)
63
64
 
64
65
  syncClaudeRuntime(ctx, runtime)
65
66
  syncClaudeMd(ctx, claudeDir)
66
67
  if (ctx.dryRun) {
67
68
  describeSettingsSync(ctx, claudeDir)
68
69
  } else {
69
- if (prepared === undefined) throw new Error("Claude settings were not prepared")
70
70
  commitClaudeSettings(ctx, prepared)
71
71
  }
72
72
  syncRemovals(ctx, claudeDir, runtime)
@@ -76,7 +76,7 @@ export async function claudeSync(ctx: Ctx): Promise<ClaudeRuntimeState> {
76
76
  syncClaudeEffort(ctx, ctx.claudeEffort)
77
77
  syncClaudeAdvisor(ctx, ctx.claudeAdvisor)
78
78
  syncClaudeJson(ctx)
79
- syncConnectorEnv(ctx)
79
+ await syncConnectorEnv(ctx)
80
80
  await syncPlugins(ctx, claudeDir)
81
81
  await syncOptionalPlugins(ctx, claudeDir)
82
82
  await syncLspServers(ctx)
@@ -153,8 +153,9 @@ export function prepareClaudeSettings(ctx: Ctx, claudeDir: string, repo: Json):
153
153
 
154
154
  const previousBytes = readFileSync(path, "utf8")
155
155
  const user = parseJson(previousBytes)
156
- if (user === undefined) {
157
- ctx.services.logger.err(`Skipping settings sync: ${path} is not valid JSON. Fix it manually or delete it to reinstall.`)
156
+ if (user === undefined || !isObject(user)) {
157
+ const reason = user === undefined ? "is not valid JSON" : "must contain a JSON object"
158
+ ctx.services.logger.err(`Aborting sync: ${path} ${reason}. Fix it manually or delete it to reinstall.`)
158
159
  throw new ExitError(1)
159
160
  }
160
161
  const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
@@ -186,7 +187,7 @@ export function commitClaudeSettings(ctx: Ctx, prepared: PreparedClaudeSettings)
186
187
 
187
188
  function describeSettingsSync(ctx: Ctx, claudeDir: string): void {
188
189
  const { echo } = ctx.services.logger
189
- const repoSettings = payloadDisplayPath("SoT/.claude/settings.json", ctx.repoDir)
190
+ const repoSettings = payloadDisplayPath("SoT/.claude/settings.json")
190
191
  const userSettings = p(claudeDir, "settings.json")
191
192
 
192
193
  if (!existsSync(userSettings)) {
@@ -292,11 +293,12 @@ function syncClaudeJson(ctx: Ctx): void {
292
293
  if (existsSync(claudeJson)) {
293
294
  const before = readFileSync(claudeJson, "utf8")
294
295
  const doc = parseJson(before)
295
- if (doc === undefined) {
296
- err("Skipping ~/.claude.json edit: not valid JSON. Fix or delete it.")
296
+ if (doc === undefined || !isObject(doc)) {
297
+ const reason = doc === undefined ? "not valid JSON" : "root must be a JSON object"
298
+ err(`Skipping ~/.claude.json edit: ${reason}. Fix or delete it.`)
297
299
  return
298
300
  }
299
- const obj = isObject(doc) ? doc : {}
301
+ const obj = doc
300
302
  applyFilter(obj)
301
303
  const out = jqStringify(obj)
302
304
  if (out === before) {
@@ -319,36 +321,62 @@ function syncClaudeJson(ctx: Ctx): void {
319
321
 
320
322
  // -------------------------------------------------------- connector env ----
321
323
 
322
- function syncConnectorEnv(ctx: Ctx): void {
323
- const { change, echo, verbose } = ctx.services.logger
324
-
325
- const line = "export ENABLE_CLAUDEAI_MCP_SERVERS=false"
326
- const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
327
- 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
+ }
328
344
 
329
- for (const f of candidates) {
330
- if (existsSync(f) && readFileSync(f, "utf8").includes("ENABLE_CLAUDEAI_MCP_SERVERS")) {
345
+ const target = p(ctx.home, setting.target(process.env["SHELL"]))
331
346
  if (ctx.dryRun) {
332
- echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
333
- } else {
334
- 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
335
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
336
354
  return
337
355
  }
338
- }
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
+ }
339
363
 
340
- const shell = process.env["SHELL"] ?? "bash"
341
- const shellName = shell.slice(shell.lastIndexOf("/") + 1)
342
- 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
+ }
343
369
 
344
- if (ctx.dryRun) {
345
- echo(`[dry-run] append 'export ENABLE_CLAUDEAI_MCP_SERVERS=false' to ${target}`)
346
- 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
+ }
347
379
  }
348
-
349
- appendFileSync(target, `\n${marker}\n${line}\n`)
350
- change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
351
- ctx.nextStepTriggers.claudeRestart = true
352
380
  }
353
381
 
354
382
  // ------------------------------------------------------------- removals ----
@@ -365,6 +393,7 @@ const REMOVED_MANIFEST = {
365
393
  "env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
366
394
  "env.CLAUDE_CODE_FORK_SUBAGENT",
367
395
  "env.CLAUDE_CODE_EFFORT_LEVEL",
396
+ "enabledPlugins.effect-kit@docks",
368
397
  "enabledPlugins.session-relay@docks",
369
398
  "hooks.PreToolUse"
370
399
  ],
@@ -374,7 +403,7 @@ const REMOVED_MANIFEST = {
374
403
  },
375
404
  claudeJsonKeys: [] as Array<string>,
376
405
  /** Home-relative artifacts the kit installed outside ~/.claude. */
377
- homeFiles: [".local/bin/session-relay"],
406
+ homeFiles: [".local/bin/effect-solutions", ".local/bin/session-relay"],
378
407
  runtimeReady: {
379
408
  hooks: ["notify.sh"],
380
409
  files: ["statusline.sh", "fetch-usage.sh"],
@@ -536,7 +565,7 @@ function sortedKeys(obj: Json | undefined): Array<string> {
536
565
  }
537
566
 
538
567
  /** claude::_plugin_user_scope_installed. */
539
- function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
568
+ export function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
540
569
  const doc = readJsonFile(installedPlugins)
541
570
  if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
542
571
  const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
@@ -658,14 +687,15 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
658
687
  if (separator > 0) kitMarketplaces.add(pluginId.slice(separator + 1))
659
688
  }
660
689
 
661
- // Pass 3 — refresh the kit-owned marketplaces and plugins unless the update
662
- // command selected its install-missing-only fast path.
690
+ // Pass 3 — refresh the kit-owned marketplaces unless the update command
691
+ // selected its install-missing-only fast path.
663
692
  if (!ctx.skipPluginRefresh) {
664
693
  for (const mpName of [...kitMarketplaces].sort(compareCodepoints)) {
665
694
  progress(`Refreshing marketplace ${mpName}...`)
666
695
  await cli(["plugin", "marketplace", "update", mpName])
667
696
  clearProgress()
668
697
  }
698
+ // Pass 4 — update the kit-owned installed plugins.
669
699
  for (const pluginId of [...kitPluginIds].sort(compareCodepoints)) {
670
700
  if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
671
701
  progress(`Updating plugin ${pluginId}...`)
@@ -675,14 +705,14 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
675
705
  }
676
706
  }
677
707
 
678
- // Passes 4 + 5 — prune-gated uninstall + marketplace removal.
708
+ // Pass 5 — prune-gated user-scope plugin uninstall.
679
709
  let removedPl = 0
680
710
  let removedMp = 0
681
- let f4 = 0
682
711
  let f5 = 0
712
+ let f6 = 0
683
713
  if (ctx.prune) {
684
714
  for (const pluginId of installedKeys) {
685
- if (isObject(sotPlugins) && Object.prototype.hasOwnProperty.call(sotPlugins, pluginId)) continue
715
+ if (kitPluginIds.has(pluginId)) continue
686
716
  if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
687
717
  progress(`Uninstalling plugin ${pluginId}...`)
688
718
  const uninstallResult = await cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId])
@@ -691,15 +721,15 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
691
721
  removedPl++
692
722
  } else {
693
723
  warn(`Failed to uninstall plugin: ${pluginId}`)
694
- f4++
724
+ f5++
695
725
  }
696
726
  }
727
+ // Pass 6 — prune-gated marketplace removal.
697
728
  const known = readJsonFile(knownMarketplaces)
698
729
  for (const mpName of sortedKeys(known)) {
699
730
  if (mpName === "claude-plugins-official") continue
700
731
  if (nonUserMarketplaces.has(mpName)) continue
701
- const declared = isObject(sotMarketplaces) ? sotMarketplaces[mpName] : undefined
702
- if (declared !== undefined && declared !== null && declared !== false) continue
732
+ if (kitMarketplaces.has(mpName)) continue
703
733
  progress(`Removing marketplace ${mpName}...`)
704
734
  const removeResult = await cli(["plugin", "marketplace", "remove", mpName])
705
735
  clearProgress()
@@ -707,18 +737,18 @@ async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
707
737
  removedMp++
708
738
  } else {
709
739
  warn(`Failed to remove marketplace: ${mpName}`)
710
- f5++
740
+ f6++
711
741
  }
712
742
  }
713
743
  }
714
744
 
715
- // Pass 6 — re-assert SoT enabled-state in the user settings.
745
+ // Pass 7 — re-assert SoT enabled-state in the user settings.
716
746
  if (await reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
717
747
  change("Plugin enable-state re-asserted from SoT in settings.json")
718
748
  ctx.nextStepTriggers.claudePlugins = true
719
749
  }
720
750
 
721
- const failed = f1 + f2 + f4 + f5
751
+ const failed = f1 + f2 + f5 + f6
722
752
  if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
723
753
  change(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
724
754
  ctx.nextStepTriggers.claudePlugins = true
@@ -841,9 +871,11 @@ async function syncOptionalPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
841
871
 
842
872
  // ---------------------------------------------------------- LSP servers ----
843
873
 
844
- function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
845
- const v = field(ctx, tool, "verified")
846
- return v !== "" ? `${pkg}@${v}` : pkg
874
+ function lspPkg(ctx: Ctx, tool: string, pkg: string): string | undefined {
875
+ const version = field(ctx, tool, "verified")
876
+ if (version !== "") return `${pkg}@${version}`
877
+ ctx.services.logger.warn(`Skipping ${pkg} install: ${tool} has no verified version in SoT/toolchain.json`)
878
+ return undefined
847
879
  }
848
880
 
849
881
  async function syncLspServers(ctx: Ctx): Promise<void> {
@@ -855,14 +887,17 @@ async function syncLspServers(ctx: Ctx): Promise<void> {
855
887
  const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
856
888
  if (!hasPhp && !hasTs) return
857
889
 
858
- const missing: Array<string> = []
859
- if (hasPhp && ctx.services.deps.probe("intelephense").state === "missing") missing.push(lspPkg(ctx, "intelephense", "intelephense"))
860
- if (hasTs) {
861
- if (ctx.services.deps.probe("typescript-language-server").state === "missing") missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
862
- if (ctx.services.deps.probe("tsc").state === "missing") missing.push(lspPkg(ctx, "tsc", "typescript"))
863
- }
864
-
865
- if (missing.length === 0) {
890
+ const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
891
+ const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
892
+ const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
893
+ const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing)
894
+ const missing = [
895
+ phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
896
+ tsServerMissing ? lspPkg(ctx, "typescript-language-server", "typescript-language-server") : undefined,
897
+ tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
898
+ ].filter((spec): spec is string => spec !== undefined)
899
+
900
+ if (missingToolCount === 0) {
866
901
  if (ctx.dryRun) {
867
902
  echo("[dry-run] LSP server binaries present")
868
903
  } else {
@@ -870,6 +905,7 @@ async function syncLspServers(ctx: Ctx): Promise<void> {
870
905
  }
871
906
  return
872
907
  }
908
+ if (missing.length === 0) return
873
909
 
874
910
  const specs = missing.join(" ")
875
911
  if (ctx.dryRun) {