docks-kit 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SoT/.codex/config.toml +1 -1
- package/SoT/models.json +5 -2
- package/cli/docs/flags.md +1 -0
- package/cli/docs/overview.md +3 -0
- package/cli/docs/platforms.md +6 -5
- package/cli/src/commands/model.ts +7 -3
- package/cli/src/commands/sync.ts +14 -3
- package/cli/src/commands/toolchain.ts +7 -3
- package/cli/src/engine-native/DESIGN.md +75 -2
- package/cli/src/engine-native/claudeModel.ts +9 -3
- package/cli/src/engine-native/claudeSync.ts +203 -97
- package/cli/src/engine-native/codexSync.ts +95 -47
- package/cli/src/engine-native/codexToml.ts +12 -5
- package/cli/src/engine-native/deps.ts +325 -0
- package/cli/src/engine-native/exec.ts +50 -2
- package/cli/src/engine-native/index.ts +45 -9
- package/cli/src/engine-native/logger.ts +35 -0
- package/cli/src/engine-native/models.ts +13 -12
- package/cli/src/engine-native/modes.ts +17 -10
- package/cli/src/engine-native/os.ts +29 -0
- package/cli/src/engine-native/parseArgs.ts +19 -17
- package/cli/src/engine-native/services.ts +96 -0
- package/cli/src/engine-native/skillsSync.ts +70 -51
- package/cli/src/engine-native/toolchain.ts +45 -63
- package/cli/src/engine.ts +11 -2
- package/cli/src/main.ts +3 -2
- package/cli/src/services.ts +34 -0
- package/package.json +1 -1
- package/cli/src/engine-native/output.ts +0 -20
|
@@ -6,40 +6,34 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process"
|
|
7
7
|
import {
|
|
8
8
|
appendFileSync,
|
|
9
|
-
chmodSync,
|
|
10
9
|
copyFileSync,
|
|
11
|
-
cpSync,
|
|
12
10
|
existsSync,
|
|
13
11
|
mkdirSync,
|
|
14
12
|
readdirSync,
|
|
15
13
|
readFileSync,
|
|
16
14
|
renameSync,
|
|
17
15
|
rmSync,
|
|
18
|
-
statSync,
|
|
19
16
|
writeFileSync
|
|
20
17
|
} from "node:fs"
|
|
21
18
|
import { tmpdir } from "node:os"
|
|
22
19
|
import { syncClaudeModel } from "./claudeModel"
|
|
23
|
-
import {
|
|
20
|
+
import { copyFileIfChanged, copyTreeIfChanged, ensureExecutable, p, writeFileIfChanged } from "./exec"
|
|
24
21
|
import type { Ctx } from "./index"
|
|
25
22
|
import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
|
|
26
|
-
import {
|
|
23
|
+
import type { EngineServices } from "./services"
|
|
27
24
|
import { ExitError } from "./parseArgs"
|
|
28
25
|
import { mergeSettings, reconcileSettings } from "./settings"
|
|
29
26
|
import { ensure, field } from "./toolchain"
|
|
30
27
|
|
|
31
28
|
export function claudeSync(ctx: Ctx): void {
|
|
29
|
+
const { warn } = ctx.services.logger
|
|
32
30
|
const claudeDir = p(ctx.home, ".claude")
|
|
33
31
|
|
|
34
32
|
if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
|
|
35
33
|
|
|
36
|
-
if (
|
|
37
|
-
const hint =
|
|
38
|
-
process.platform === "win32"
|
|
39
|
-
? "winget install Anthropic.ClaudeCode"
|
|
40
|
-
: "curl -fsSL https://claude.ai/install.sh -o /tmp/claude-install.sh && bash /tmp/claude-install.sh"
|
|
34
|
+
if (ctx.services.deps.probe("claude").state === "missing") {
|
|
41
35
|
warn(
|
|
42
|
-
`claude CLI not found - config deploys, but plugin passes are skipped. Install Claude Code: ${
|
|
36
|
+
`claude CLI not found - config deploys, but plugin passes are skipped. Install Claude Code: ${ctx.services.deps.spec("claude").installHint()} | docs: https://code.claude.com/docs/en/setup`
|
|
43
37
|
)
|
|
44
38
|
}
|
|
45
39
|
|
|
@@ -62,11 +56,12 @@ export function claudeSync(ctx: Ctx): void {
|
|
|
62
56
|
// ------------------------------------------------------------------ rtk ----
|
|
63
57
|
|
|
64
58
|
/** RTK toolchain install callback. */
|
|
65
|
-
export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
|
|
66
|
-
return (mode, version) => {
|
|
59
|
+
export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string, services: EngineServices) => number {
|
|
60
|
+
return (mode, version, services) => {
|
|
61
|
+
const { change, err, verbose, warn } = services.logger
|
|
67
62
|
const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
|
|
68
63
|
|
|
69
|
-
if (mode === "upgrade")
|
|
64
|
+
if (mode === "upgrade") verbose(`Upgrading RTK${version !== "" ? ` to ${version}` : ""}...`)
|
|
70
65
|
else warn(`RTK not found. Installing${version !== "" ? ` ${version}` : ""}...`)
|
|
71
66
|
const installer = p(tmpdir(), `rtk-install-${process.pid}.sh`)
|
|
72
67
|
const dl = spawnSync("curl", ["-fsSL", `https://raw.githubusercontent.com/rtk-ai/rtk/${installerRef}/install.sh`, "-o", installer], {
|
|
@@ -80,9 +75,9 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
|
|
|
80
75
|
}
|
|
81
76
|
rmSync(installer, { force: true })
|
|
82
77
|
process.env["PATH"] = `${ctx.home}/.local/bin:${ctx.home}/.cargo/bin:${process.env["PATH"] ?? ""}`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
78
|
+
const installed = services.deps.version("rtk")
|
|
79
|
+
if (services.deps.probe("rtk").state === "present") {
|
|
80
|
+
change(`RTK ready (${installed !== "" ? installed : "version unknown"})`)
|
|
86
81
|
return 0
|
|
87
82
|
}
|
|
88
83
|
err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
|
|
@@ -91,13 +86,14 @@ export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: str
|
|
|
91
86
|
}
|
|
92
87
|
|
|
93
88
|
function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
89
|
+
const { change, echo, verbose, warn } = ctx.services.logger
|
|
94
90
|
if (ctx.skipRtk) {
|
|
95
91
|
warn("Skipping RTK (--skip-rtk)")
|
|
96
92
|
return
|
|
97
93
|
}
|
|
98
94
|
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
95
|
+
if (ctx.services.platform.isWindows()) {
|
|
96
|
+
if (ctx.services.deps.probe("rtk").state === "missing") {
|
|
101
97
|
warn("rtk not installed — the kit's auto-install is Unix-only. Install natively (winget, or the rtk-*-windows-msvc.zip release), then re-run sync")
|
|
102
98
|
return
|
|
103
99
|
}
|
|
@@ -105,7 +101,7 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
|
105
101
|
warn("RTK bootstrap failed — continuing sync without it")
|
|
106
102
|
}
|
|
107
103
|
|
|
108
|
-
if (
|
|
104
|
+
if (ctx.services.deps.probe("rtk").state === "missing") return
|
|
109
105
|
if (!existsSync(p(claudeDir, "RTK.md"))) {
|
|
110
106
|
if (ctx.dryRun) {
|
|
111
107
|
echo("[dry-run] rtk init --global (RTK.md missing; runs before the settings merge, which normalizes rtk's settings rewrite)")
|
|
@@ -115,30 +111,35 @@ function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
|
115
111
|
// sync before the success log and before any settings/plugin mutation.
|
|
116
112
|
const res = spawnSync("rtk", ["init", "--global"], { stdio: "inherit" })
|
|
117
113
|
if (res.error !== undefined || res.status !== 0) throw new ExitError(res.status ?? 1)
|
|
118
|
-
|
|
114
|
+
change("RTK initialized (RTK.md generated; the following settings merge re-asserts the SoT hooks)")
|
|
119
115
|
} else if (!ctx.dryRun) {
|
|
120
|
-
|
|
116
|
+
verbose("RTK already initialized")
|
|
121
117
|
}
|
|
122
118
|
}
|
|
123
119
|
|
|
124
120
|
// ------------------------------------------------------ scripts + hooks ----
|
|
125
121
|
|
|
126
122
|
function syncScripts(ctx: Ctx, claudeDir: string): void {
|
|
123
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
127
124
|
if (ctx.dryRun) {
|
|
128
125
|
echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
|
|
129
126
|
return
|
|
130
127
|
}
|
|
131
128
|
|
|
129
|
+
let changed = false
|
|
132
130
|
for (const script of ["statusline.sh", "fetch-usage.sh"]) {
|
|
133
131
|
const src = p(ctx.repoDir, "SoT", ".claude", script)
|
|
134
132
|
if (existsSync(src)) {
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
if (copyFileIfChanged(src, p(claudeDir, script))) changed = true
|
|
134
|
+
if (ensureExecutable(p(claudeDir, script))) changed = true
|
|
137
135
|
}
|
|
138
136
|
}
|
|
139
137
|
const mp3 = p(ctx.repoDir, "notification.mp3")
|
|
140
|
-
if (existsSync(mp3)
|
|
141
|
-
|
|
138
|
+
if (existsSync(mp3) && copyFileIfChanged(mp3, p(claudeDir, "notification.mp3"))) changed = true
|
|
139
|
+
if (changed) {
|
|
140
|
+
change("Scripts synced (statusline, fetch-usage, notification)")
|
|
141
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
142
|
+
} else verbose("Scripts already in sync (statusline, fetch-usage, notification)")
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
function shellScriptCount(hooksDir: string): number {
|
|
@@ -150,6 +151,7 @@ function shellScriptCount(hooksDir: string): number {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
function syncHooks(ctx: Ctx, claudeDir: string): void {
|
|
154
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
153
155
|
const sotHooks = p(ctx.repoDir, "SoT", ".claude", "hooks")
|
|
154
156
|
if (!existsSync(sotHooks)) return
|
|
155
157
|
|
|
@@ -160,19 +162,31 @@ function syncHooks(ctx: Ctx, claudeDir: string): void {
|
|
|
160
162
|
|
|
161
163
|
const hooksDir = p(claudeDir, "hooks")
|
|
162
164
|
mkdirSync(hooksDir, { recursive: true })
|
|
163
|
-
|
|
165
|
+
let changed = copyTreeIfChanged(sotHooks, hooksDir)
|
|
164
166
|
for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
|
|
165
167
|
if (e.isFile() && e.name.endsWith(".sh")) {
|
|
166
|
-
|
|
168
|
+
if (ensureExecutable(p(hooksDir, e.name))) changed = true
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
|
-
|
|
171
|
+
if (changed) {
|
|
172
|
+
change(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
|
|
173
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
174
|
+
} else verbose(`Hooks already in sync (${shellScriptCount(hooksDir)} scripts)`)
|
|
170
175
|
}
|
|
171
176
|
|
|
172
177
|
function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
178
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
179
|
+
// The @RTK.md import only resolves once `rtk init` has generated
|
|
180
|
+
// ~/.claude/RTK.md (the rtk phase runs before this). Deploying the import
|
|
181
|
+
// without the file leaves a dangling reference in every Claude session
|
|
182
|
+
// (seen on Windows, where rtk never auto-installs) — strip it while the
|
|
183
|
+
// file is absent; a later sync after rtk init restores it.
|
|
184
|
+
const rtkMdAbsent = !existsSync(p(claudeDir, "RTK.md"))
|
|
173
185
|
if (ctx.dryRun) {
|
|
174
186
|
if (ctx.skipRtk) {
|
|
175
187
|
echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (stripping @RTK.md import: --skip-rtk)")
|
|
188
|
+
} else if (rtkMdAbsent) {
|
|
189
|
+
echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (would strip @RTK.md import while ~/.claude/RTK.md is absent)")
|
|
176
190
|
} else {
|
|
177
191
|
echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md")
|
|
178
192
|
}
|
|
@@ -180,22 +194,28 @@ function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
const src = p(ctx.repoDir, "SoT", ".claude", "CLAUDE.md")
|
|
183
|
-
|
|
197
|
+
const stripReason = ctx.skipRtk ? "--skip-rtk" : rtkMdAbsent ? "~/.claude/RTK.md absent (rtk not initialized)" : ""
|
|
198
|
+
if (stripReason !== "") {
|
|
184
199
|
const stripped = readFileSync(src, "utf8")
|
|
185
200
|
.split("\n")
|
|
186
201
|
.filter((l) => l !== "@RTK.md")
|
|
187
202
|
.join("\n")
|
|
188
|
-
|
|
189
|
-
|
|
203
|
+
if (writeFileIfChanged(p(claudeDir, "CLAUDE.md"), stripped)) {
|
|
204
|
+
change(`CLAUDE.md synced (@RTK.md import stripped: ${stripReason})`)
|
|
205
|
+
} else {
|
|
206
|
+
verbose("CLAUDE.md already in sync")
|
|
207
|
+
}
|
|
208
|
+
} else if (copyFileIfChanged(src, p(claudeDir, "CLAUDE.md"))) {
|
|
209
|
+
change("CLAUDE.md synced")
|
|
190
210
|
} else {
|
|
191
|
-
|
|
192
|
-
log("CLAUDE.md synced")
|
|
211
|
+
verbose("CLAUDE.md already in sync")
|
|
193
212
|
}
|
|
194
213
|
}
|
|
195
214
|
|
|
196
215
|
// ------------------------------------------------------------- settings ----
|
|
197
216
|
|
|
198
217
|
function syncSettings(ctx: Ctx, claudeDir: string): void {
|
|
218
|
+
const { change, echo, err, verbose } = ctx.services.logger
|
|
199
219
|
const repoSettings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
|
|
200
220
|
const userSettings = p(claudeDir, "settings.json")
|
|
201
221
|
|
|
@@ -212,7 +232,8 @@ function syncSettings(ctx: Ctx, claudeDir: string): void {
|
|
|
212
232
|
|
|
213
233
|
if (!existsSync(userSettings)) {
|
|
214
234
|
copyFileSync(repoSettings, userSettings)
|
|
215
|
-
|
|
235
|
+
change("Settings installed")
|
|
236
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
216
237
|
return
|
|
217
238
|
}
|
|
218
239
|
|
|
@@ -224,35 +245,47 @@ function syncSettings(ctx: Ctx, claudeDir: string): void {
|
|
|
224
245
|
}
|
|
225
246
|
const repo = parseJson(readFileSync(repoSettings, "utf8"))!
|
|
226
247
|
|
|
227
|
-
copyFileSync(userSettings, `${userSettings}.bak`)
|
|
228
248
|
const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
|
|
229
|
-
|
|
249
|
+
const out = jqStringify(merged)
|
|
250
|
+
if (out === readFileSync(userSettings, "utf8")) {
|
|
251
|
+
verbose("Settings already in sync")
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
copyFileSync(userSettings, `${userSettings}.bak`)
|
|
255
|
+
writeFileSync(`${userSettings}.tmp`, out)
|
|
230
256
|
renameSync(`${userSettings}.tmp`, userSettings)
|
|
257
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
231
258
|
if (ctx.reconcile) {
|
|
232
|
-
|
|
259
|
+
change("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
|
|
233
260
|
} else {
|
|
234
|
-
|
|
261
|
+
change("Settings merged (backup at settings.json.bak)")
|
|
235
262
|
}
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
/** Shared shape of the three jq-edit modifiers (compact window, permissive). */
|
|
239
|
-
function jqEditSettings(claudeDir: string, tag: string, edit: (doc: Json) => void):
|
|
266
|
+
function jqEditSettings(ctx: Ctx, claudeDir: string, tag: string, edit: (doc: Json) => void): boolean {
|
|
267
|
+
const { err, warn } = ctx.services.logger
|
|
240
268
|
const userSettings = p(claudeDir, "settings.json")
|
|
241
269
|
if (!existsSync(userSettings)) {
|
|
242
270
|
warn(`(${tag}) ${userSettings} missing — skipped`)
|
|
243
|
-
return
|
|
271
|
+
return false
|
|
244
272
|
}
|
|
245
|
-
const
|
|
273
|
+
const before = readFileSync(userSettings, "utf8")
|
|
274
|
+
const doc = parseJson(before)
|
|
246
275
|
if (doc === undefined) {
|
|
247
276
|
err(`(${tag}) ${userSettings} is not valid JSON — skipped`)
|
|
248
|
-
return
|
|
277
|
+
return false
|
|
249
278
|
}
|
|
250
279
|
edit(doc)
|
|
251
|
-
|
|
280
|
+
const out = jqStringify(doc)
|
|
281
|
+
if (out === before) return false
|
|
282
|
+
writeFileSync(`${userSettings}.tmp`, out)
|
|
252
283
|
renameSync(`${userSettings}.tmp`, userSettings)
|
|
284
|
+
return true
|
|
253
285
|
}
|
|
254
286
|
|
|
255
287
|
function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
|
|
288
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
256
289
|
if (ctx.claudeCompactWindow === "") return
|
|
257
290
|
|
|
258
291
|
if (ctx.dryRun) {
|
|
@@ -260,16 +293,21 @@ function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
|
|
|
260
293
|
return
|
|
261
294
|
}
|
|
262
295
|
|
|
263
|
-
jqEditSettings(claudeDir, "--claude-compact-window", (doc) => {
|
|
296
|
+
const changed = jqEditSettings(ctx, claudeDir, "--claude-compact-window", (doc) => {
|
|
264
297
|
if (!isObject(doc)) return
|
|
265
298
|
const env = isObject(doc["env"]) ? doc["env"] : {}
|
|
266
299
|
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = ctx.claudeCompactWindow
|
|
267
300
|
doc["env"] = env
|
|
268
301
|
})
|
|
269
|
-
|
|
302
|
+
if (changed) {
|
|
303
|
+
change(`Compact window: set to ${ctx.claudeCompactWindow} tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)`)
|
|
304
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
305
|
+
}
|
|
306
|
+
else verbose(`Compact window: already set to ${ctx.claudeCompactWindow} tokens in deployed settings`)
|
|
270
307
|
}
|
|
271
308
|
|
|
272
309
|
function syncPermissive(ctx: Ctx, claudeDir: string): void {
|
|
310
|
+
const { change, echo, verbose } = ctx.services.logger
|
|
273
311
|
if (!ctx.claudePermissive) return
|
|
274
312
|
|
|
275
313
|
if (ctx.dryRun) {
|
|
@@ -277,19 +315,24 @@ function syncPermissive(ctx: Ctx, claudeDir: string): void {
|
|
|
277
315
|
return
|
|
278
316
|
}
|
|
279
317
|
|
|
280
|
-
jqEditSettings(claudeDir, "--claude-permissive", (doc) => {
|
|
318
|
+
const changed = jqEditSettings(ctx, claudeDir, "--claude-permissive", (doc) => {
|
|
281
319
|
if (!isObject(doc)) return
|
|
282
320
|
const permissions = isObject(doc["permissions"]) ? doc["permissions"] : {}
|
|
283
321
|
permissions["ask"] = []
|
|
284
322
|
permissions["deny"] = []
|
|
285
323
|
doc["permissions"] = permissions
|
|
286
324
|
})
|
|
287
|
-
|
|
325
|
+
if (changed) {
|
|
326
|
+
change("Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)")
|
|
327
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
328
|
+
}
|
|
329
|
+
else verbose("Permissive mode: permissions.ask/deny already empty in deployed settings")
|
|
288
330
|
}
|
|
289
331
|
|
|
290
332
|
// ---------------------------------------------------------- claude.json ----
|
|
291
333
|
|
|
292
334
|
function syncClaudeJson(ctx: Ctx): void {
|
|
335
|
+
const { change, echo, err, verbose } = ctx.services.logger
|
|
293
336
|
const claudeJson = p(ctx.home, ".claude.json")
|
|
294
337
|
const mcpSot = p(ctx.repoDir, "SoT", ".claude", "mcp-servers.json")
|
|
295
338
|
|
|
@@ -311,37 +354,49 @@ function syncClaudeJson(ctx: Ctx): void {
|
|
|
311
354
|
}
|
|
312
355
|
}
|
|
313
356
|
|
|
357
|
+
let changed = true
|
|
314
358
|
if (existsSync(claudeJson)) {
|
|
315
|
-
const
|
|
359
|
+
const before = readFileSync(claudeJson, "utf8")
|
|
360
|
+
const doc = parseJson(before)
|
|
316
361
|
if (doc === undefined) {
|
|
317
362
|
err("Skipping ~/.claude.json edit: not valid JSON. Fix or delete it.")
|
|
318
363
|
return
|
|
319
364
|
}
|
|
320
365
|
const obj = isObject(doc) ? doc : {}
|
|
321
366
|
applyFilter(obj)
|
|
322
|
-
|
|
323
|
-
|
|
367
|
+
const out = jqStringify(obj)
|
|
368
|
+
if (out === before) {
|
|
369
|
+
changed = false
|
|
370
|
+
} else {
|
|
371
|
+
writeFileSync(`${claudeJson}.tmp`, out)
|
|
372
|
+
renameSync(`${claudeJson}.tmp`, claudeJson)
|
|
373
|
+
}
|
|
324
374
|
} else {
|
|
325
375
|
const obj: { [k: string]: Json } = {}
|
|
326
376
|
applyFilter(obj)
|
|
327
377
|
writeFileSync(claudeJson, jqStringify(obj))
|
|
328
378
|
}
|
|
329
|
-
|
|
379
|
+
if (changed) {
|
|
380
|
+
change(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
|
|
381
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
382
|
+
}
|
|
383
|
+
else verbose(`~/.claude.json already in sync (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
|
|
330
384
|
}
|
|
331
385
|
|
|
332
386
|
// -------------------------------------------------------- connector env ----
|
|
333
387
|
|
|
334
388
|
function syncConnectorEnv(ctx: Ctx): void {
|
|
389
|
+
const { change, echo, verbose, warn } = ctx.services.logger
|
|
335
390
|
// win32: Claude Code launches from PowerShell/GUI, so the flag must be a
|
|
336
391
|
// real user env var (setx), not a Git-Bash-only shell-rc export. Never
|
|
337
392
|
// clobbers an existing value (set =true yourself to keep connectors).
|
|
338
|
-
if (
|
|
393
|
+
if (ctx.services.platform.isWindows()) {
|
|
339
394
|
const existing = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "ENABLE_CLAUDEAI_MCP_SERVERS"], {
|
|
340
395
|
stdio: "ignore"
|
|
341
396
|
})
|
|
342
397
|
if (existing.error === undefined && existing.status === 0) {
|
|
343
398
|
if (ctx.dryRun) echo("[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in user environment — would skip")
|
|
344
|
-
else
|
|
399
|
+
else verbose("claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in user environment (left as-is)")
|
|
345
400
|
return
|
|
346
401
|
}
|
|
347
402
|
if (ctx.dryRun) {
|
|
@@ -350,7 +405,8 @@ function syncConnectorEnv(ctx: Ctx): void {
|
|
|
350
405
|
}
|
|
351
406
|
const res = spawnSync("setx", ["ENABLE_CLAUDEAI_MCP_SERVERS", "false"], { stdio: "ignore" })
|
|
352
407
|
if (res.error === undefined && res.status === 0) {
|
|
353
|
-
|
|
408
|
+
change("claude.ai connectors disabled via setx (open a new terminal to apply)")
|
|
409
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
354
410
|
} else {
|
|
355
411
|
warn("setx ENABLE_CLAUDEAI_MCP_SERVERS false failed — set it manually in System Properties > Environment Variables")
|
|
356
412
|
}
|
|
@@ -366,7 +422,7 @@ function syncConnectorEnv(ctx: Ctx): void {
|
|
|
366
422
|
if (ctx.dryRun) {
|
|
367
423
|
echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
|
|
368
424
|
} else {
|
|
369
|
-
|
|
425
|
+
verbose(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
|
|
370
426
|
}
|
|
371
427
|
return
|
|
372
428
|
}
|
|
@@ -382,7 +438,8 @@ function syncConnectorEnv(ctx: Ctx): void {
|
|
|
382
438
|
}
|
|
383
439
|
|
|
384
440
|
appendFileSync(target, `\n${marker}\n${line}\n`)
|
|
385
|
-
|
|
441
|
+
change(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
|
|
442
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
386
443
|
}
|
|
387
444
|
|
|
388
445
|
// ------------------------------------------------------------- removals ----
|
|
@@ -439,6 +496,7 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
|
|
|
439
496
|
}
|
|
440
497
|
|
|
441
498
|
function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
499
|
+
const { change, echo } = ctx.services.logger
|
|
442
500
|
let hooksRemoved = 0
|
|
443
501
|
let filesRemoved = 0
|
|
444
502
|
|
|
@@ -474,7 +532,8 @@ function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
|
474
532
|
}
|
|
475
533
|
|
|
476
534
|
if (hooksRemoved + filesRemoved + skeys + cjkeys > 0) {
|
|
477
|
-
|
|
535
|
+
change(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys})`)
|
|
536
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
478
537
|
}
|
|
479
538
|
}
|
|
480
539
|
|
|
@@ -504,6 +563,7 @@ function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): b
|
|
|
504
563
|
}
|
|
505
564
|
|
|
506
565
|
function syncPlugins(ctx: Ctx, claudeDir: string): void {
|
|
566
|
+
const { change, echo, verbose, warn } = ctx.services.logger
|
|
507
567
|
const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
|
|
508
568
|
const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
|
|
509
569
|
const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
|
|
@@ -516,13 +576,16 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
|
|
|
516
576
|
return
|
|
517
577
|
}
|
|
518
578
|
|
|
519
|
-
if (
|
|
579
|
+
if (ctx.services.deps.probe("claude").state === "missing") {
|
|
520
580
|
warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
|
|
521
581
|
return
|
|
522
582
|
}
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
583
|
+
if (ctx.services.deps.probe("git").state === "missing") {
|
|
584
|
+
ctx.services.deps.warnMissing(
|
|
585
|
+
"git",
|
|
586
|
+
ctx.services.logger,
|
|
587
|
+
"plugin marketplaces are git repos — Claude plugin passes skipped; re-run sync after installing"
|
|
588
|
+
)
|
|
526
589
|
return
|
|
527
590
|
}
|
|
528
591
|
|
|
@@ -604,29 +667,37 @@ function syncPlugins(ctx: Ctx, claudeDir: string): void {
|
|
|
604
667
|
}
|
|
605
668
|
|
|
606
669
|
// Pass 6 — re-assert SoT enabled-state in the user settings.
|
|
607
|
-
reassertEnabledState(repoObj, p(claudeDir, "settings.json"))
|
|
670
|
+
if (reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
|
|
671
|
+
change("Plugin enable-state re-asserted from SoT in settings.json")
|
|
672
|
+
ctx.nextStepTriggers.claudePlugins = true
|
|
673
|
+
}
|
|
608
674
|
|
|
609
675
|
const failed = f1 + f2 + f4 + f5
|
|
610
676
|
if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
|
|
611
|
-
|
|
677
|
+
change(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
|
|
678
|
+
ctx.nextStepTriggers.claudePlugins = true
|
|
612
679
|
} else {
|
|
613
|
-
|
|
680
|
+
verbose("Plugins already in sync")
|
|
614
681
|
}
|
|
615
682
|
if (failed > 0) {
|
|
616
683
|
warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
|
|
617
684
|
}
|
|
618
685
|
}
|
|
619
686
|
|
|
620
|
-
function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile: string):
|
|
621
|
-
|
|
687
|
+
function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSettingsFile: string): boolean {
|
|
688
|
+
const { warn } = ctx.services.logger
|
|
689
|
+
if (!existsSync(userSettingsFile)) return false
|
|
622
690
|
const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
|
|
623
691
|
|
|
692
|
+
let cliDisabled = false
|
|
624
693
|
for (const [pluginId, value] of Object.entries(sotPlugins)) {
|
|
625
694
|
if (value !== false) continue
|
|
626
695
|
const user = readJsonFile(userSettingsFile)
|
|
627
696
|
const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
|
|
628
697
|
if (enabled !== true) continue
|
|
629
|
-
if (
|
|
698
|
+
if (cli(["plugin", "disable", pluginId]).ok) {
|
|
699
|
+
cliDisabled = true
|
|
700
|
+
} else {
|
|
630
701
|
warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
|
|
631
702
|
}
|
|
632
703
|
}
|
|
@@ -634,44 +705,66 @@ function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile:
|
|
|
634
705
|
const user = readJsonFile(userSettingsFile)
|
|
635
706
|
if (user === undefined || !isObject(user)) {
|
|
636
707
|
warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
|
|
637
|
-
return
|
|
708
|
+
return false
|
|
638
709
|
}
|
|
710
|
+
const beforeCanonical = jqStringify(user)
|
|
639
711
|
user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
|
|
640
|
-
|
|
712
|
+
const out = jqStringify(user)
|
|
713
|
+
if (out === beforeCanonical) return cliDisabled
|
|
714
|
+
writeFileSync(`${userSettingsFile}.tmp`, out)
|
|
641
715
|
renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
|
|
716
|
+
return true
|
|
642
717
|
}
|
|
643
718
|
|
|
644
719
|
// ------------------------------------------------------ optional plugins ----
|
|
645
720
|
|
|
646
|
-
function enableOptionalPlugin(claudeDir: string, pluginId: string, marketplaceRepo: string):
|
|
721
|
+
function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, marketplaceRepo: string): boolean {
|
|
722
|
+
const { change, verbose, warn } = ctx.services.logger
|
|
647
723
|
const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
|
|
648
724
|
const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
|
|
649
725
|
const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
|
|
650
726
|
|
|
727
|
+
let marketplaceAdded = false
|
|
651
728
|
if (marketplaceRepo !== "") {
|
|
652
729
|
const known = readJsonFile(knownMarketplaces)
|
|
653
730
|
const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
|
|
654
|
-
if (!has
|
|
655
|
-
|
|
656
|
-
|
|
731
|
+
if (!has) {
|
|
732
|
+
if (!cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
|
|
733
|
+
warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
|
|
734
|
+
return false
|
|
735
|
+
}
|
|
736
|
+
marketplaceAdded = true
|
|
657
737
|
}
|
|
658
738
|
}
|
|
659
739
|
|
|
660
|
-
|
|
740
|
+
const wasInstalled = pluginUserScopeInstalled(installedPlugins, pluginId)
|
|
741
|
+
if (!wasInstalled) {
|
|
661
742
|
if (!cli(["plugin", "install", pluginId]).ok) {
|
|
743
|
+
if (marketplaceAdded) change(`Optional plugin ${pluginId}: marketplace added (install failed — will retry next sync)`)
|
|
662
744
|
warn(`Failed to install optional plugin ${pluginId}`)
|
|
663
|
-
return
|
|
745
|
+
return marketplaceAdded
|
|
664
746
|
}
|
|
665
747
|
}
|
|
666
748
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
749
|
+
const settingsDoc = readJsonFile(p(claudeDir, "settings.json"))
|
|
750
|
+
const wasEnabled =
|
|
751
|
+
settingsDoc !== undefined && isObject(settingsDoc) && isObject(settingsDoc["enabledPlugins"])
|
|
752
|
+
? (settingsDoc["enabledPlugins"] as { [k: string]: Json })[pluginId] === true
|
|
753
|
+
: false
|
|
754
|
+
|
|
755
|
+
if (!cli(["plugin", "enable", pluginId]).ok) {
|
|
756
|
+
if (marketplaceAdded || !wasInstalled) change(`Optional plugin ${pluginId}: installed (enable failed — will retry next sync)`)
|
|
670
757
|
warn(`Failed to enable optional plugin ${pluginId}`)
|
|
758
|
+
return marketplaceAdded || !wasInstalled
|
|
671
759
|
}
|
|
760
|
+
const changed = marketplaceAdded || !wasInstalled || !wasEnabled
|
|
761
|
+
if (changed) change(`Optional plugin opted in: ${pluginId}`)
|
|
762
|
+
else verbose(`Optional plugin already opted in: ${pluginId} (enable re-asserted)`)
|
|
763
|
+
return changed
|
|
672
764
|
}
|
|
673
765
|
|
|
674
766
|
function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
|
|
767
|
+
const { echo, warn } = ctx.services.logger
|
|
675
768
|
if (ctx.claudePlugins.length === 0) return
|
|
676
769
|
|
|
677
770
|
if (ctx.dryRun) {
|
|
@@ -684,16 +777,16 @@ function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
|
|
|
684
777
|
return
|
|
685
778
|
}
|
|
686
779
|
|
|
687
|
-
if (
|
|
780
|
+
if (ctx.services.deps.probe("claude").state === "missing") {
|
|
688
781
|
warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
|
|
689
782
|
return
|
|
690
783
|
}
|
|
691
784
|
|
|
692
785
|
if (ctx.claudePlugins.includes("supabase")) {
|
|
693
|
-
enableOptionalPlugin(claudeDir, "supabase@claude-plugins-official", "")
|
|
786
|
+
if (enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
|
|
694
787
|
}
|
|
695
788
|
if (ctx.claudePlugins.includes("n8n")) {
|
|
696
|
-
enableOptionalPlugin(claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")
|
|
789
|
+
if (enableOptionalPlugin(ctx, claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")) ctx.nextStepTriggers.claudePlugins = true
|
|
697
790
|
}
|
|
698
791
|
}
|
|
699
792
|
|
|
@@ -705,6 +798,7 @@ function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
|
|
|
705
798
|
}
|
|
706
799
|
|
|
707
800
|
function syncLspServers(ctx: Ctx): void {
|
|
801
|
+
const { change, echo, verbose, warn } = ctx.services.logger
|
|
708
802
|
const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
|
|
709
803
|
const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
|
|
710
804
|
if (enabled === undefined) return
|
|
@@ -713,17 +807,17 @@ function syncLspServers(ctx: Ctx): void {
|
|
|
713
807
|
if (!hasPhp && !hasTs) return
|
|
714
808
|
|
|
715
809
|
const missing: Array<string> = []
|
|
716
|
-
if (hasPhp &&
|
|
810
|
+
if (hasPhp && ctx.services.deps.probe("intelephense").state === "missing") missing.push(lspPkg(ctx, "intelephense", "intelephense"))
|
|
717
811
|
if (hasTs) {
|
|
718
|
-
if (
|
|
719
|
-
if (
|
|
812
|
+
if (ctx.services.deps.probe("typescript-language-server").state === "missing") missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
|
|
813
|
+
if (ctx.services.deps.probe("tsc").state === "missing") missing.push(lspPkg(ctx, "tsc", "typescript"))
|
|
720
814
|
}
|
|
721
815
|
|
|
722
816
|
if (missing.length === 0) {
|
|
723
817
|
if (ctx.dryRun) {
|
|
724
818
|
echo("[dry-run] LSP server binaries present")
|
|
725
819
|
} else {
|
|
726
|
-
|
|
820
|
+
verbose("LSP server binaries present")
|
|
727
821
|
}
|
|
728
822
|
return
|
|
729
823
|
}
|
|
@@ -734,14 +828,19 @@ function syncLspServers(ctx: Ctx): void {
|
|
|
734
828
|
return
|
|
735
829
|
}
|
|
736
830
|
|
|
737
|
-
if (
|
|
738
|
-
|
|
831
|
+
if (ctx.services.deps.probe("npm").state === "missing") {
|
|
832
|
+
ctx.services.deps.warnMissing(
|
|
833
|
+
"npm",
|
|
834
|
+
ctx.services.logger,
|
|
835
|
+
`cannot install LSP servers (${specs}); the php-lsp/typescript-lsp plugins stay no-ops`
|
|
836
|
+
)
|
|
739
837
|
return
|
|
740
838
|
}
|
|
741
839
|
|
|
742
|
-
|
|
840
|
+
verbose(`Installing LSP servers via npm: ${specs}...`)
|
|
743
841
|
if (spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" }).status === 0) {
|
|
744
|
-
|
|
842
|
+
change(`LSP servers installed (${specs})`)
|
|
843
|
+
ctx.nextStepTriggers.claudeRestart = true
|
|
745
844
|
} else {
|
|
746
845
|
warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
|
|
747
846
|
}
|
|
@@ -750,17 +849,18 @@ function syncLspServers(ctx: Ctx): void {
|
|
|
750
849
|
// -------------------------------------------------------------- summary ----
|
|
751
850
|
|
|
752
851
|
export function claudeSummary(ctx: Ctx): void {
|
|
852
|
+
const { echo } = ctx.services.logger
|
|
753
853
|
const claudeDir = p(ctx.home, ".claude")
|
|
754
854
|
echo(`Claude: ${claudeDir}`)
|
|
755
855
|
if (!ctx.dryRun) {
|
|
756
856
|
echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
|
|
757
|
-
if (
|
|
758
|
-
const
|
|
759
|
-
echo(`RTK: ${
|
|
857
|
+
if (ctx.services.deps.probe("rtk").state === "present") {
|
|
858
|
+
const version = ctx.services.deps.version("rtk")
|
|
859
|
+
echo(`RTK: ${version !== "" ? version : "installed"}`)
|
|
760
860
|
} else {
|
|
761
861
|
echo("RTK: not installed")
|
|
762
862
|
}
|
|
763
|
-
if (
|
|
863
|
+
if (ctx.services.deps.probe("claude").state === "present") {
|
|
764
864
|
const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
|
|
765
865
|
const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
|
|
766
866
|
echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
|
|
@@ -770,7 +870,13 @@ export function claudeSummary(ctx: Ctx): void {
|
|
|
770
870
|
}
|
|
771
871
|
}
|
|
772
872
|
|
|
773
|
-
export function claudeNextSteps():
|
|
774
|
-
|
|
775
|
-
|
|
873
|
+
export function claudeNextSteps(ctx: Ctx): Array<string> {
|
|
874
|
+
const lines: Array<string> = []
|
|
875
|
+
if (ctx.verbose || ctx.nextStepTriggers.claudePlugins) {
|
|
876
|
+
lines.push("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
|
|
877
|
+
}
|
|
878
|
+
if (ctx.verbose || ctx.nextStepTriggers.claudeRestart) {
|
|
879
|
+
lines.push("Restart Claude Code for hook/env-var changes to take effect.")
|
|
880
|
+
}
|
|
881
|
+
return lines
|
|
776
882
|
}
|