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.
- package/AGENTS.md +57 -17
- package/README.md +33 -31
- package/cli/docs/flags.md +0 -1
- package/cli/docs/install.md +28 -13
- package/cli/docs/overview.md +2 -2
- package/cli/docs/platforms.md +5 -2
- package/cli/docs/sync-layers.md +3 -4
- package/cli/docs/toolchain.md +26 -34
- package/cli/src/commands/docs.ts +3 -3
- package/cli/src/commands/model.ts +3 -0
- package/cli/src/commands/models.ts +5 -3
- package/cli/src/commands/status.ts +145 -32
- package/cli/src/commands/sync.ts +4 -5
- package/cli/src/commands/toolchain.ts +4 -7
- package/cli/src/commands/update.ts +177 -32
- package/cli/src/efforts.ts +5 -5
- package/cli/src/engine-native/DESIGN.md +31 -22
- package/cli/src/engine-native/bun.ts +42 -14
- package/cli/src/engine-native/claudeRuntime.ts +17 -9
- package/cli/src/engine-native/claudeSettingsModifiers.ts +29 -11
- package/cli/src/engine-native/claudeSync.ts +91 -55
- package/cli/src/engine-native/codexSync.ts +173 -49
- package/cli/src/engine-native/codexToml.ts +12 -7
- package/cli/src/engine-native/deps.ts +36 -95
- package/cli/src/engine-native/exec.ts +42 -24
- package/cli/src/engine-native/index.ts +17 -6
- package/cli/src/engine-native/models.ts +2 -9
- package/cli/src/engine-native/modes.ts +54 -35
- package/cli/src/engine-native/os/darwin.ts +62 -0
- package/cli/src/engine-native/os/index.ts +42 -0
- package/cli/src/engine-native/os/linux.ts +62 -0
- package/cli/src/engine-native/os/targets.ts +73 -0
- package/cli/src/engine-native/os/types.ts +75 -0
- package/cli/src/engine-native/os/windows.ts +176 -0
- package/cli/src/engine-native/parseArgs.ts +147 -48
- package/cli/src/engine-native/services.ts +1 -11
- package/cli/src/engine-native/settings.ts +3 -2
- package/cli/src/engine-native/skillsSync.ts +141 -89
- package/cli/src/engine-native/toolchain.ts +5 -147
- package/cli/src/engine.ts +41 -11
- package/cli/src/generated/sotPayload.ts +7 -7
- package/cli/src/kitHome.ts +42 -5
- package/cli/src/main.ts +12 -2
- package/cli/src/manifests.ts +28 -11
- package/cli/src/payload.ts +2 -5
- package/docks-kit +4 -4
- package/docks-kit.ps1 +123 -0
- package/package.json +9 -5
- 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
|
|
42
|
-
|
|
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
|
|
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
|
|
120
|
-
`process.platform
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
143
|
-
| `claudeRuntime.ts` | sentinel validation, absolute runtime paths, no-cutover settings projection, and
|
|
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,
|
|
149
|
-
| `toolchain.ts` | tool presence/version probes, verified-version
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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`
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
| {
|
|
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",
|
|
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,
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
75
|
+
const temporaryDir = mkdtempSync(p(tmpdir(), "docks-kit-bun-"))
|
|
76
|
+
let result: BunInstallResult
|
|
56
77
|
try {
|
|
57
|
-
await installBun(pin,
|
|
78
|
+
result = await installBun(pin, temporaryDir, host)
|
|
58
79
|
} finally {
|
|
59
|
-
rmSync(
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 =
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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]
|
|
333
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
662
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
740
|
+
f6++
|
|
711
741
|
}
|
|
712
742
|
}
|
|
713
743
|
}
|
|
714
744
|
|
|
715
|
-
// Pass
|
|
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 +
|
|
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
|
|
846
|
-
|
|
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
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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) {
|