docks-kit 0.1.0 → 0.1.1
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 +14 -15
- package/README.md +12 -11
- package/SoT/.agents/skills.txt +3 -3
- package/SoT/.claude/settings.json +3 -26
- package/SoT/models.json +1 -1
- package/SoT/toolchain.json +3 -3
- package/cli/docs/install.md +39 -12
- package/cli/docs/overview.md +3 -4
- package/cli/docs/platforms.md +35 -22
- package/cli/src/commands/sync.ts +32 -4
- package/cli/src/commands/update.ts +116 -0
- package/cli/src/engine-native/DESIGN.md +89 -0
- package/cli/src/engine-native/claudeModel.ts +48 -0
- package/cli/src/engine-native/claudeSync.ts +746 -0
- package/cli/src/engine-native/codexSync.ts +439 -0
- package/cli/src/engine-native/codexToml.ts +67 -0
- package/cli/src/engine-native/exec.ts +54 -0
- package/cli/src/engine-native/index.ts +109 -0
- package/cli/src/engine-native/jq.ts +63 -0
- package/cli/src/engine-native/models.ts +73 -0
- package/cli/src/engine-native/modes.ts +133 -0
- package/cli/src/engine-native/output.ts +20 -0
- package/cli/src/engine-native/parseArgs.ts +218 -0
- package/cli/src/engine-native/settings.ts +41 -0
- package/cli/src/engine-native/skillsSync.ts +369 -0
- package/cli/src/engine-native/toolchain.ts +257 -0
- package/cli/src/engine.ts +35 -18
- package/cli/src/kitHome.ts +4 -4
- package/cli/src/main.ts +14 -1
- package/cli/src/manifests.ts +3 -2
- package/cli/tsconfig.json +1 -1
- package/docks-kit +6 -3
- package/package.json +8 -4
- package/lib/claude.sh +0 -846
- package/lib/codex.sh +0 -518
- package/lib/common.sh +0 -229
- package/lib/engine.sh +0 -153
- package/lib/skills.sh +0 -373
- package/lib/toolchain.sh +0 -222
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineNative `sync claude` pipeline. Step order is load-bearing: rtk BEFORE
|
|
3
|
+
* the settings merge, modifiers after it, removals before plugins. Message
|
|
4
|
+
* strings, guard order, JSON semantics, and spawned argv are golden-tested.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process"
|
|
7
|
+
import {
|
|
8
|
+
appendFileSync,
|
|
9
|
+
chmodSync,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
cpSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync
|
|
20
|
+
} from "node:fs"
|
|
21
|
+
import { tmpdir } from "node:os"
|
|
22
|
+
import { syncClaudeModel } from "./claudeModel"
|
|
23
|
+
import { capture, commandExists, p } from "./exec"
|
|
24
|
+
import type { Ctx } from "./index"
|
|
25
|
+
import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
|
|
26
|
+
import { echo, err, log, warn } from "./output"
|
|
27
|
+
import { ExitError } from "./parseArgs"
|
|
28
|
+
import { mergeSettings, reconcileSettings } from "./settings"
|
|
29
|
+
import { ensure, field } from "./toolchain"
|
|
30
|
+
|
|
31
|
+
export function claudeSync(ctx: Ctx): void {
|
|
32
|
+
const claudeDir = p(ctx.home, ".claude")
|
|
33
|
+
|
|
34
|
+
if (!ctx.dryRun) mkdirSync(claudeDir, { recursive: true })
|
|
35
|
+
|
|
36
|
+
if (!commandExists("claude")) {
|
|
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"
|
|
41
|
+
warn(
|
|
42
|
+
`claude CLI not found - config deploys, but plugin passes are skipped. Install Claude Code: ${hint} | docs: https://code.claude.com/docs/en/setup`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
syncRtk(ctx, claudeDir)
|
|
47
|
+
syncScripts(ctx, claudeDir)
|
|
48
|
+
syncHooks(ctx, claudeDir)
|
|
49
|
+
syncClaudeMd(ctx, claudeDir)
|
|
50
|
+
syncSettings(ctx, claudeDir)
|
|
51
|
+
syncCompactWindow(ctx, claudeDir)
|
|
52
|
+
syncPermissive(ctx, claudeDir)
|
|
53
|
+
syncClaudeModel(ctx, ctx.claudeModel)
|
|
54
|
+
syncClaudeJson(ctx)
|
|
55
|
+
syncConnectorEnv(ctx)
|
|
56
|
+
syncRemovals(ctx, claudeDir)
|
|
57
|
+
syncPlugins(ctx, claudeDir)
|
|
58
|
+
syncOptionalPlugins(ctx, claudeDir)
|
|
59
|
+
syncLspServers(ctx)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ------------------------------------------------------------------ rtk ----
|
|
63
|
+
|
|
64
|
+
/** RTK toolchain install callback. */
|
|
65
|
+
export function rtkInstall(ctx: Ctx): (mode: "install" | "upgrade", version: string) => number {
|
|
66
|
+
return (mode, version) => {
|
|
67
|
+
const installerRef = version !== "" ? `refs/tags/v${version}` : "refs/heads/master"
|
|
68
|
+
|
|
69
|
+
if (mode === "upgrade") log(`Upgrading RTK${version !== "" ? ` to ${version}` : ""}...`)
|
|
70
|
+
else warn(`RTK not found. Installing${version !== "" ? ` ${version}` : ""}...`)
|
|
71
|
+
const installer = p(tmpdir(), `rtk-install-${process.pid}.sh`)
|
|
72
|
+
const dl = spawnSync("curl", ["-fsSL", `https://raw.githubusercontent.com/rtk-ai/rtk/${installerRef}/install.sh`, "-o", installer], {
|
|
73
|
+
stdio: "inherit"
|
|
74
|
+
})
|
|
75
|
+
if (dl.error === undefined && dl.status === 0) {
|
|
76
|
+
spawnSync("bash", [installer], {
|
|
77
|
+
stdio: "inherit",
|
|
78
|
+
env: { ...process.env, RTK_VERSION: version !== "" ? `v${version}` : "" }
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
rmSync(installer, { force: true })
|
|
82
|
+
process.env["PATH"] = `${ctx.home}/.local/bin:${ctx.home}/.cargo/bin:${process.env["PATH"] ?? ""}`
|
|
83
|
+
if (commandExists("rtk")) {
|
|
84
|
+
const v = capture("rtk", ["--version"])
|
|
85
|
+
log(`RTK ready (${v !== "" ? v : "version unknown"})`)
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
err("RTK install failed. Install manually: https://github.com/rtk-ai/rtk")
|
|
89
|
+
return 1
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function syncRtk(ctx: Ctx, claudeDir: string): void {
|
|
94
|
+
if (ctx.skipRtk) {
|
|
95
|
+
warn("Skipping RTK (--skip-rtk)")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (process.platform === "win32") {
|
|
100
|
+
if (!commandExists("rtk")) {
|
|
101
|
+
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
|
+
return
|
|
103
|
+
}
|
|
104
|
+
} else if (ensure(ctx, "rtk", rtkInstall(ctx)) !== 0) {
|
|
105
|
+
warn("RTK bootstrap failed — continuing sync without it")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!commandExists("rtk")) return
|
|
109
|
+
if (!existsSync(p(claudeDir, "RTK.md"))) {
|
|
110
|
+
if (ctx.dryRun) {
|
|
111
|
+
echo("[dry-run] rtk init --global (RTK.md missing; runs before the settings merge, which normalizes rtk's settings rewrite)")
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
// Plain command under bash set -e: a nonzero `rtk init` aborts the whole
|
|
115
|
+
// sync before the success log and before any settings/plugin mutation.
|
|
116
|
+
const res = spawnSync("rtk", ["init", "--global"], { stdio: "inherit" })
|
|
117
|
+
if (res.error !== undefined || res.status !== 0) throw new ExitError(res.status ?? 1)
|
|
118
|
+
log("RTK initialized (RTK.md generated; the following settings merge re-asserts the SoT hooks)")
|
|
119
|
+
} else if (!ctx.dryRun) {
|
|
120
|
+
log("RTK already initialized")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ------------------------------------------------------ scripts + hooks ----
|
|
125
|
+
|
|
126
|
+
function syncScripts(ctx: Ctx, claudeDir: string): void {
|
|
127
|
+
if (ctx.dryRun) {
|
|
128
|
+
echo("[dry-run] cp statusline.sh, fetch-usage.sh, notification.mp3")
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const script of ["statusline.sh", "fetch-usage.sh"]) {
|
|
133
|
+
const src = p(ctx.repoDir, "SoT", ".claude", script)
|
|
134
|
+
if (existsSync(src)) {
|
|
135
|
+
copyFileSync(src, p(claudeDir, script))
|
|
136
|
+
chmodSync(p(claudeDir, script), statSync(p(claudeDir, script)).mode | 0o111)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const mp3 = p(ctx.repoDir, "notification.mp3")
|
|
140
|
+
if (existsSync(mp3)) copyFileSync(mp3, p(claudeDir, "notification.mp3"))
|
|
141
|
+
log("Scripts synced (statusline, fetch-usage, notification)")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function shellScriptCount(hooksDir: string): number {
|
|
145
|
+
try {
|
|
146
|
+
return readdirSync(hooksDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".sh")).length
|
|
147
|
+
} catch {
|
|
148
|
+
return 0
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function syncHooks(ctx: Ctx, claudeDir: string): void {
|
|
153
|
+
const sotHooks = p(ctx.repoDir, "SoT", ".claude", "hooks")
|
|
154
|
+
if (!existsSync(sotHooks)) return
|
|
155
|
+
|
|
156
|
+
if (ctx.dryRun) {
|
|
157
|
+
echo(`[dry-run] cp -R ${sotHooks}/. ${claudeDir}/hooks/`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const hooksDir = p(claudeDir, "hooks")
|
|
162
|
+
mkdirSync(hooksDir, { recursive: true })
|
|
163
|
+
cpSync(sotHooks, hooksDir, { recursive: true })
|
|
164
|
+
for (const e of readdirSync(hooksDir, { withFileTypes: true })) {
|
|
165
|
+
if (e.isFile() && e.name.endsWith(".sh")) {
|
|
166
|
+
chmodSync(p(hooksDir, e.name), statSync(p(hooksDir, e.name)).mode | 0o111)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
log(`Hooks synced (${shellScriptCount(hooksDir)} scripts)`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function syncClaudeMd(ctx: Ctx, claudeDir: string): void {
|
|
173
|
+
if (ctx.dryRun) {
|
|
174
|
+
if (ctx.skipRtk) {
|
|
175
|
+
echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md (stripping @RTK.md import: --skip-rtk)")
|
|
176
|
+
} else {
|
|
177
|
+
echo("[dry-run] cp SoT/.claude/CLAUDE.md -> ~/.claude/CLAUDE.md")
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const src = p(ctx.repoDir, "SoT", ".claude", "CLAUDE.md")
|
|
183
|
+
if (ctx.skipRtk) {
|
|
184
|
+
const stripped = readFileSync(src, "utf8")
|
|
185
|
+
.split("\n")
|
|
186
|
+
.filter((l) => l !== "@RTK.md")
|
|
187
|
+
.join("\n")
|
|
188
|
+
writeFileSync(p(claudeDir, "CLAUDE.md"), stripped)
|
|
189
|
+
log("CLAUDE.md synced (@RTK.md import stripped: --skip-rtk)")
|
|
190
|
+
} else {
|
|
191
|
+
copyFileSync(src, p(claudeDir, "CLAUDE.md"))
|
|
192
|
+
log("CLAUDE.md synced")
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------------------- settings ----
|
|
197
|
+
|
|
198
|
+
function syncSettings(ctx: Ctx, claudeDir: string): void {
|
|
199
|
+
const repoSettings = p(ctx.repoDir, "SoT", ".claude", "settings.json")
|
|
200
|
+
const userSettings = p(claudeDir, "settings.json")
|
|
201
|
+
|
|
202
|
+
if (ctx.dryRun) {
|
|
203
|
+
if (!existsSync(userSettings)) {
|
|
204
|
+
echo(`[dry-run] install ${repoSettings} -> ${userSettings}`)
|
|
205
|
+
} else if (ctx.reconcile) {
|
|
206
|
+
echo(`[dry-run] reconcile ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays replaced; user-only keys preserved)`)
|
|
207
|
+
} else {
|
|
208
|
+
echo(`[dry-run] merge ${repoSettings} -> ${userSettings} (SoT keys win; permissions arrays unioned; user-only keys preserved)`)
|
|
209
|
+
}
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!existsSync(userSettings)) {
|
|
214
|
+
copyFileSync(repoSettings, userSettings)
|
|
215
|
+
log("Settings installed")
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const user = parseJson(readFileSync(userSettings, "utf8"))
|
|
220
|
+
if (user === undefined) {
|
|
221
|
+
// claude::_settings_validate returns 1 → claude::sync aborts under set -e.
|
|
222
|
+
err(`Skipping settings sync: ${userSettings} is not valid JSON. Fix it manually or delete it to reinstall.`)
|
|
223
|
+
throw new ExitError(1)
|
|
224
|
+
}
|
|
225
|
+
const repo = parseJson(readFileSync(repoSettings, "utf8"))!
|
|
226
|
+
|
|
227
|
+
copyFileSync(userSettings, `${userSettings}.bak`)
|
|
228
|
+
const merged = ctx.reconcile ? reconcileSettings(repo, user) : mergeSettings(repo, user)
|
|
229
|
+
writeFileSync(`${userSettings}.tmp`, jqStringify(merged))
|
|
230
|
+
renameSync(`${userSettings}.tmp`, userSettings)
|
|
231
|
+
if (ctx.reconcile) {
|
|
232
|
+
log("Settings reconciled (backup at settings.json.bak; user-only keys preserved, permissions arrays replaced by SoT)")
|
|
233
|
+
} else {
|
|
234
|
+
log("Settings merged (backup at settings.json.bak)")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Shared shape of the three jq-edit modifiers (compact window, permissive). */
|
|
239
|
+
function jqEditSettings(claudeDir: string, tag: string, edit: (doc: Json) => void): void {
|
|
240
|
+
const userSettings = p(claudeDir, "settings.json")
|
|
241
|
+
if (!existsSync(userSettings)) {
|
|
242
|
+
warn(`(${tag}) ${userSettings} missing — skipped`)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const doc = parseJson(readFileSync(userSettings, "utf8"))
|
|
246
|
+
if (doc === undefined) {
|
|
247
|
+
err(`(${tag}) ${userSettings} is not valid JSON — skipped`)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
edit(doc)
|
|
251
|
+
writeFileSync(`${userSettings}.tmp`, jqStringify(doc))
|
|
252
|
+
renameSync(`${userSettings}.tmp`, userSettings)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function syncCompactWindow(ctx: Ctx, claudeDir: string): void {
|
|
256
|
+
if (ctx.claudeCompactWindow === "") return
|
|
257
|
+
|
|
258
|
+
if (ctx.dryRun) {
|
|
259
|
+
echo(`[dry-run] (--claude-compact-window) set env.CLAUDE_CODE_AUTO_COMPACT_WINDOW=${ctx.claudeCompactWindow} in ${p(claudeDir, "settings.json")}`)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
jqEditSettings(claudeDir, "--claude-compact-window", (doc) => {
|
|
264
|
+
if (!isObject(doc)) return
|
|
265
|
+
const env = isObject(doc["env"]) ? doc["env"] : {}
|
|
266
|
+
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = ctx.claudeCompactWindow
|
|
267
|
+
doc["env"] = env
|
|
268
|
+
})
|
|
269
|
+
log(`Compact window: set to ${ctx.claudeCompactWindow} tokens in deployed settings (SoT and model unchanged; flag-less sync reverts)`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function syncPermissive(ctx: Ctx, claudeDir: string): void {
|
|
273
|
+
if (!ctx.claudePermissive) return
|
|
274
|
+
|
|
275
|
+
if (ctx.dryRun) {
|
|
276
|
+
echo(`[dry-run] (--claude-permissive) empty permissions.ask and permissions.deny in ${p(claudeDir, "settings.json")}`)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
jqEditSettings(claudeDir, "--claude-permissive", (doc) => {
|
|
281
|
+
if (!isObject(doc)) return
|
|
282
|
+
const permissions = isObject(doc["permissions"]) ? doc["permissions"] : {}
|
|
283
|
+
permissions["ask"] = []
|
|
284
|
+
permissions["deny"] = []
|
|
285
|
+
doc["permissions"] = permissions
|
|
286
|
+
})
|
|
287
|
+
log("Permissive mode: permissions.ask/deny emptied in deployed settings (sandbox use; SoT unchanged)")
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------- claude.json ----
|
|
291
|
+
|
|
292
|
+
function syncClaudeJson(ctx: Ctx): void {
|
|
293
|
+
const claudeJson = p(ctx.home, ".claude.json")
|
|
294
|
+
const mcpSot = p(ctx.repoDir, "SoT", ".claude", "mcp-servers.json")
|
|
295
|
+
|
|
296
|
+
let mcp: Json | undefined
|
|
297
|
+
if (existsSync(mcpSot)) mcp = parseJson(readFileSync(mcpSot, "utf8"))
|
|
298
|
+
const haveMcp = mcp !== undefined
|
|
299
|
+
|
|
300
|
+
if (ctx.dryRun) {
|
|
301
|
+
echo("[dry-run] set showTurnDuration=true in ~/.claude.json")
|
|
302
|
+
if (haveMcp) echo("[dry-run] merge mcpServers from SoT/.claude/mcp-servers.json into ~/.claude.json")
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const applyFilter = (doc: { [k: string]: Json }): void => {
|
|
307
|
+
doc["showTurnDuration"] = true
|
|
308
|
+
if (haveMcp) {
|
|
309
|
+
const sotServers = isObject(mcp!) ? mcp!["mcpServers"] ?? {} : {}
|
|
310
|
+
doc["mcpServers"] = deepMerge(isObject(doc["mcpServers"]) ? doc["mcpServers"] : {}, sotServers)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (existsSync(claudeJson)) {
|
|
315
|
+
const doc = parseJson(readFileSync(claudeJson, "utf8"))
|
|
316
|
+
if (doc === undefined) {
|
|
317
|
+
err("Skipping ~/.claude.json edit: not valid JSON. Fix or delete it.")
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const obj = isObject(doc) ? doc : {}
|
|
321
|
+
applyFilter(obj)
|
|
322
|
+
writeFileSync(`${claudeJson}.tmp`, jqStringify(obj))
|
|
323
|
+
renameSync(`${claudeJson}.tmp`, claudeJson)
|
|
324
|
+
} else {
|
|
325
|
+
const obj: { [k: string]: Json } = {}
|
|
326
|
+
applyFilter(obj)
|
|
327
|
+
writeFileSync(claudeJson, jqStringify(obj))
|
|
328
|
+
}
|
|
329
|
+
log(`~/.claude.json updated (showTurnDuration${haveMcp ? ", mcpServers" : ""})`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -------------------------------------------------------- connector env ----
|
|
333
|
+
|
|
334
|
+
function syncConnectorEnv(ctx: Ctx): void {
|
|
335
|
+
const line = "export ENABLE_CLAUDEAI_MCP_SERVERS=false"
|
|
336
|
+
const marker = "# docks-kit: disable claude.ai cloud MCP connectors (set =true to keep them)"
|
|
337
|
+
const candidates = [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zshenv"].map((f) => p(ctx.home, f))
|
|
338
|
+
|
|
339
|
+
for (const f of candidates) {
|
|
340
|
+
if (existsSync(f) && readFileSync(f, "utf8").includes("ENABLE_CLAUDEAI_MCP_SERVERS")) {
|
|
341
|
+
if (ctx.dryRun) {
|
|
342
|
+
echo(`[dry-run] ENABLE_CLAUDEAI_MCP_SERVERS already in ${f} — would skip`)
|
|
343
|
+
} else {
|
|
344
|
+
log(`claude.ai connectors: ENABLE_CLAUDEAI_MCP_SERVERS already set in ${f} (left as-is)`)
|
|
345
|
+
}
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const shell = process.env["SHELL"] ?? "bash"
|
|
351
|
+
const shellName = shell.slice(shell.lastIndexOf("/") + 1)
|
|
352
|
+
const target = shellName === "zsh" ? p(ctx.home, ".zshrc") : shellName === "bash" ? p(ctx.home, ".bashrc") : p(ctx.home, ".profile")
|
|
353
|
+
|
|
354
|
+
if (ctx.dryRun) {
|
|
355
|
+
echo(`[dry-run] append 'export ENABLE_CLAUDEAI_MCP_SERVERS=false' to ${target}`)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
appendFileSync(target, `\n${marker}\n${line}\n`)
|
|
360
|
+
log(`claude.ai connectors disabled via ${target} (start a new shell to apply)`)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ------------------------------------------------------------- removals ----
|
|
364
|
+
|
|
365
|
+
const REMOVED_MANIFEST = {
|
|
366
|
+
hooks: ["disable-claudeai-connectors.sh"],
|
|
367
|
+
files: ["alert_bubble.mp3"],
|
|
368
|
+
settingsKeys: [
|
|
369
|
+
"showTurnDuration",
|
|
370
|
+
"env.CLAUDE_CODE_SUBAGENT_MODEL",
|
|
371
|
+
"env.ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
372
|
+
"env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
|
|
373
|
+
"env.CLAUDE_CODE_DISABLE_1M_CONTEXT",
|
|
374
|
+
"env.CLAUDE_CODE_FORK_SUBAGENT",
|
|
375
|
+
"env.CLAUDE_CODE_EFFORT_LEVEL"
|
|
376
|
+
],
|
|
377
|
+
claudeJsonKeys: [] as Array<string>
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** claude::_prune_json_keys — present-count; deletes when !dryRun. */
|
|
381
|
+
function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
|
|
382
|
+
if (keys.length === 0 || !existsSync(file)) return 0
|
|
383
|
+
const doc = parseJson(readFileSync(file, "utf8"))
|
|
384
|
+
if (doc === undefined) return 0
|
|
385
|
+
|
|
386
|
+
const getPath = (root: Json, path: Array<string>): Json | undefined => {
|
|
387
|
+
let cur: Json | undefined = root
|
|
388
|
+
for (const seg of path) {
|
|
389
|
+
if (cur === undefined || !isObject(cur)) return undefined
|
|
390
|
+
cur = cur[seg]
|
|
391
|
+
}
|
|
392
|
+
return cur
|
|
393
|
+
}
|
|
394
|
+
const presentKeys = keys.filter((k) => {
|
|
395
|
+
const v = getPath(doc, k.split("."))
|
|
396
|
+
return v !== undefined && v !== null
|
|
397
|
+
})
|
|
398
|
+
if (presentKeys.length === 0) return 0
|
|
399
|
+
|
|
400
|
+
if (!ctx.dryRun) {
|
|
401
|
+
for (const k of presentKeys) {
|
|
402
|
+
const path = k.split(".")
|
|
403
|
+
let cur: Json = doc
|
|
404
|
+
for (const seg of path.slice(0, -1)) {
|
|
405
|
+
if (!isObject(cur)) break
|
|
406
|
+
cur = cur[seg]!
|
|
407
|
+
}
|
|
408
|
+
if (isObject(cur)) delete cur[path[path.length - 1]!]
|
|
409
|
+
}
|
|
410
|
+
writeFileSync(`${file}.tmp`, jqStringify(doc))
|
|
411
|
+
renameSync(`${file}.tmp`, file)
|
|
412
|
+
}
|
|
413
|
+
return presentKeys.length
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function syncRemovals(ctx: Ctx, claudeDir: string): void {
|
|
417
|
+
let hooksRemoved = 0
|
|
418
|
+
let filesRemoved = 0
|
|
419
|
+
|
|
420
|
+
for (const name of REMOVED_MANIFEST.hooks) {
|
|
421
|
+
const path = p(claudeDir, "hooks", name)
|
|
422
|
+
if (!existsSync(path)) continue
|
|
423
|
+
if (ctx.dryRun) {
|
|
424
|
+
echo(`[dry-run] rm ${path}`)
|
|
425
|
+
} else {
|
|
426
|
+
rmSync(path, { force: true })
|
|
427
|
+
hooksRemoved++
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const rel of REMOVED_MANIFEST.files) {
|
|
432
|
+
const path = p(claudeDir, rel)
|
|
433
|
+
if (!existsSync(path)) continue
|
|
434
|
+
if (ctx.dryRun) {
|
|
435
|
+
echo(`[dry-run] rm ${path}`)
|
|
436
|
+
} else {
|
|
437
|
+
rmSync(path, { force: true })
|
|
438
|
+
filesRemoved++
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const skeys = pruneJsonKeys(ctx, p(claudeDir, "settings.json"), REMOVED_MANIFEST.settingsKeys)
|
|
443
|
+
const cjkeys = pruneJsonKeys(ctx, p(ctx.home, ".claude.json"), REMOVED_MANIFEST.claudeJsonKeys)
|
|
444
|
+
|
|
445
|
+
if (ctx.dryRun) {
|
|
446
|
+
if (skeys > 0) echo(`[dry-run] del ${skeys} stale key(s) from ${p(claudeDir, "settings.json")}`)
|
|
447
|
+
if (cjkeys > 0) echo(`[dry-run] del ${cjkeys} stale key(s) from ${p(ctx.home, ".claude.json")}`)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (hooksRemoved + filesRemoved + skeys + cjkeys > 0) {
|
|
452
|
+
log(`Pruned stale artifacts (hooks: ${hooksRemoved}, files: ${filesRemoved}, settings keys: ${skeys}, claude.json keys: ${cjkeys})`)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// -------------------------------------------------------------- plugins ----
|
|
457
|
+
|
|
458
|
+
function cli(args: Array<string>): { ok: boolean; out: string } {
|
|
459
|
+
const res = spawnSync("claude", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
460
|
+
return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}` }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function readJsonFile(file: string): Json | undefined {
|
|
464
|
+
return existsSync(file) ? parseJson(readFileSync(file, "utf8")) : undefined
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function sortedKeys(obj: Json | undefined): Array<string> {
|
|
468
|
+
return obj !== undefined && isObject(obj) ? Object.keys(obj).sort(compareCodepoints) : []
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** claude::_plugin_user_scope_installed. */
|
|
472
|
+
function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
|
|
473
|
+
const doc = readJsonFile(installedPlugins)
|
|
474
|
+
if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
|
|
475
|
+
const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
|
|
476
|
+
if (rec === undefined || rec === null) return false
|
|
477
|
+
const records = Array.isArray(rec) ? rec : [rec]
|
|
478
|
+
return records.some((r) => isObject(r) && r["scope"] === "user")
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function syncPlugins(ctx: Ctx, claudeDir: string): void {
|
|
482
|
+
const repoSettingsFile = p(ctx.repoDir, "SoT", ".claude", "settings.json")
|
|
483
|
+
const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
|
|
484
|
+
const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
|
|
485
|
+
|
|
486
|
+
if (ctx.dryRun) {
|
|
487
|
+
echo("[dry-run] bootstrap + update plugin marketplaces + plugins from SoT")
|
|
488
|
+
if (ctx.prune) {
|
|
489
|
+
echo("[dry-run] (--prune) would also uninstall plugins not in SoT and remove extra marketplaces")
|
|
490
|
+
}
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!commandExists("claude")) {
|
|
495
|
+
warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const repoSettings = readJsonFile(repoSettingsFile)
|
|
500
|
+
const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
|
|
501
|
+
const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
|
|
502
|
+
const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
|
|
503
|
+
|
|
504
|
+
// Pass 1 — add missing marketplaces (SoT insertion order, like to_entries).
|
|
505
|
+
let addedMp = 0
|
|
506
|
+
let f1 = 0
|
|
507
|
+
for (const [mpName, mpValue] of Object.entries(sotMarketplaces)) {
|
|
508
|
+
const known = readJsonFile(knownMarketplaces)
|
|
509
|
+
if (known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false) continue
|
|
510
|
+
const repo = isObject(mpValue) && isObject(mpValue["source"]) ? String((mpValue["source"] as { [k: string]: Json })["repo"] ?? "") : ""
|
|
511
|
+
if (cli(["plugin", "marketplace", "add", repo]).ok) {
|
|
512
|
+
addedMp++
|
|
513
|
+
} else {
|
|
514
|
+
warn(`Failed to add marketplace: ${mpName} (${repo})`)
|
|
515
|
+
f1++
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Pass 2 — install SoT-enabled plugins missing at user scope (jq keys[] sorts).
|
|
520
|
+
let addedPl = 0
|
|
521
|
+
let f2 = 0
|
|
522
|
+
let refreshed = false
|
|
523
|
+
for (const pluginId of sortedKeys(sotPlugins)) {
|
|
524
|
+
if (pluginUserScopeInstalled(installedPlugins, pluginId)) continue
|
|
525
|
+
if (!refreshed) {
|
|
526
|
+
cli(["plugin", "marketplace", "update"])
|
|
527
|
+
refreshed = true
|
|
528
|
+
}
|
|
529
|
+
if (cli(["plugin", "install", pluginId]).ok) {
|
|
530
|
+
addedPl++
|
|
531
|
+
} else {
|
|
532
|
+
warn(`Failed to install plugin: ${pluginId}`)
|
|
533
|
+
f2++
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Pass 3 — refresh every installed plugin.
|
|
538
|
+
cli(["plugin", "marketplace", "update"])
|
|
539
|
+
let updatedPl = 0
|
|
540
|
+
const installedDoc = readJsonFile(installedPlugins)
|
|
541
|
+
const installedKeys = installedDoc !== undefined && isObject(installedDoc) ? sortedKeys(installedDoc["plugins"]) : []
|
|
542
|
+
for (const pluginId of installedKeys) {
|
|
543
|
+
if (cli(["plugin", "update", pluginId]).out.includes("Successfully updated")) updatedPl++
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Passes 4 + 5 — prune-gated uninstall + marketplace removal.
|
|
547
|
+
let removedPl = 0
|
|
548
|
+
let removedMp = 0
|
|
549
|
+
let f4 = 0
|
|
550
|
+
let f5 = 0
|
|
551
|
+
if (ctx.prune) {
|
|
552
|
+
for (const pluginId of installedKeys) {
|
|
553
|
+
if (isObject(sotPlugins) && Object.prototype.hasOwnProperty.call(sotPlugins, pluginId)) continue
|
|
554
|
+
if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
|
|
555
|
+
if (cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId]).ok) {
|
|
556
|
+
removedPl++
|
|
557
|
+
} else {
|
|
558
|
+
warn(`Failed to uninstall plugin: ${pluginId}`)
|
|
559
|
+
f4++
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const known = readJsonFile(knownMarketplaces)
|
|
563
|
+
for (const mpName of sortedKeys(known)) {
|
|
564
|
+
if (mpName === "claude-plugins-official") continue
|
|
565
|
+
const declared = isObject(sotMarketplaces) ? sotMarketplaces[mpName] : undefined
|
|
566
|
+
if (declared !== undefined && declared !== null && declared !== false) continue
|
|
567
|
+
if (cli(["plugin", "marketplace", "remove", mpName]).ok) {
|
|
568
|
+
removedMp++
|
|
569
|
+
} else {
|
|
570
|
+
warn(`Failed to remove marketplace: ${mpName}`)
|
|
571
|
+
f5++
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Pass 6 — re-assert SoT enabled-state in the user settings.
|
|
577
|
+
reassertEnabledState(repoObj, p(claudeDir, "settings.json"))
|
|
578
|
+
|
|
579
|
+
const failed = f1 + f2 + f4 + f5
|
|
580
|
+
if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
|
|
581
|
+
log(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
|
|
582
|
+
} else {
|
|
583
|
+
log("Plugins already in sync")
|
|
584
|
+
}
|
|
585
|
+
if (failed > 0) {
|
|
586
|
+
warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function reassertEnabledState(repoObj: { [k: string]: Json }, userSettingsFile: string): void {
|
|
591
|
+
if (!existsSync(userSettingsFile)) return
|
|
592
|
+
const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
|
|
593
|
+
|
|
594
|
+
for (const [pluginId, value] of Object.entries(sotPlugins)) {
|
|
595
|
+
if (value !== false) continue
|
|
596
|
+
const user = readJsonFile(userSettingsFile)
|
|
597
|
+
const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
|
|
598
|
+
if (enabled !== true) continue
|
|
599
|
+
if (!cli(["plugin", "disable", pluginId]).ok) {
|
|
600
|
+
warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const user = readJsonFile(userSettingsFile)
|
|
605
|
+
if (user === undefined || !isObject(user)) {
|
|
606
|
+
warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
|
|
610
|
+
writeFileSync(`${userSettingsFile}.tmp`, jqStringify(user))
|
|
611
|
+
renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ------------------------------------------------------ optional plugins ----
|
|
615
|
+
|
|
616
|
+
function enableOptionalPlugin(claudeDir: string, pluginId: string, marketplaceRepo: string): void {
|
|
617
|
+
const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
|
|
618
|
+
const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
|
|
619
|
+
const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
|
|
620
|
+
|
|
621
|
+
if (marketplaceRepo !== "") {
|
|
622
|
+
const known = readJsonFile(knownMarketplaces)
|
|
623
|
+
const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
|
|
624
|
+
if (!has && !cli(["plugin", "marketplace", "add", marketplaceRepo]).ok) {
|
|
625
|
+
warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!pluginUserScopeInstalled(installedPlugins, pluginId)) {
|
|
631
|
+
if (!cli(["plugin", "install", pluginId]).ok) {
|
|
632
|
+
warn(`Failed to install optional plugin ${pluginId}`)
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (cli(["plugin", "enable", pluginId]).ok) {
|
|
638
|
+
log(`Optional plugin opted in: ${pluginId}`)
|
|
639
|
+
} else {
|
|
640
|
+
warn(`Failed to enable optional plugin ${pluginId}`)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function syncOptionalPlugins(ctx: Ctx, claudeDir: string): void {
|
|
645
|
+
if (ctx.claudePlugins.length === 0) return
|
|
646
|
+
|
|
647
|
+
if (ctx.dryRun) {
|
|
648
|
+
if (ctx.claudePlugins.includes("supabase")) {
|
|
649
|
+
echo("[dry-run] (--claude-plugin=supabase) install + enable supabase@claude-plugins-official in deployed settings")
|
|
650
|
+
}
|
|
651
|
+
if (ctx.claudePlugins.includes("n8n")) {
|
|
652
|
+
echo("[dry-run] (--claude-plugin=n8n) add czlonkowski/n8n-skills marketplace + install + enable n8n-mcp-skills@n8n-mcp-skills")
|
|
653
|
+
}
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!commandExists("claude")) {
|
|
658
|
+
warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (ctx.claudePlugins.includes("supabase")) {
|
|
663
|
+
enableOptionalPlugin(claudeDir, "supabase@claude-plugins-official", "")
|
|
664
|
+
}
|
|
665
|
+
if (ctx.claudePlugins.includes("n8n")) {
|
|
666
|
+
enableOptionalPlugin(claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------- LSP servers ----
|
|
671
|
+
|
|
672
|
+
function lspPkg(ctx: Ctx, tool: string, pkg: string): string {
|
|
673
|
+
const v = field(ctx, tool, "verified")
|
|
674
|
+
return v !== "" ? `${pkg}@${v}` : pkg
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function syncLspServers(ctx: Ctx): void {
|
|
678
|
+
const sot = readJsonFile(p(ctx.repoDir, "SoT", ".claude", "settings.json"))
|
|
679
|
+
const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
|
|
680
|
+
if (enabled === undefined) return
|
|
681
|
+
const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
|
|
682
|
+
const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
|
|
683
|
+
if (!hasPhp && !hasTs) return
|
|
684
|
+
|
|
685
|
+
const missing: Array<string> = []
|
|
686
|
+
if (hasPhp && !commandExists("intelephense")) missing.push(lspPkg(ctx, "intelephense", "intelephense"))
|
|
687
|
+
if (hasTs) {
|
|
688
|
+
if (!commandExists("typescript-language-server")) missing.push(lspPkg(ctx, "typescript-language-server", "typescript-language-server"))
|
|
689
|
+
if (!commandExists("tsc")) missing.push(lspPkg(ctx, "tsc", "typescript"))
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (missing.length === 0) {
|
|
693
|
+
if (ctx.dryRun) {
|
|
694
|
+
echo("[dry-run] LSP server binaries present")
|
|
695
|
+
} else {
|
|
696
|
+
log("LSP server binaries present")
|
|
697
|
+
}
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const specs = missing.join(" ")
|
|
702
|
+
if (ctx.dryRun) {
|
|
703
|
+
echo(`[dry-run] would install: npm install -g ${specs}`)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (!commandExists("npm")) {
|
|
708
|
+
warn(`npm not found — cannot install LSP servers (${specs}); the php-lsp/typescript-lsp plugins stay no-ops. Install Node.js, then re-run sync.`)
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
log(`Installing LSP servers via npm: ${specs}...`)
|
|
713
|
+
if (spawnSync("npm", ["install", "-g", ...missing], { stdio: "ignore" }).status === 0) {
|
|
714
|
+
log(`LSP servers installed (${specs})`)
|
|
715
|
+
} else {
|
|
716
|
+
warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// -------------------------------------------------------------- summary ----
|
|
721
|
+
|
|
722
|
+
export function claudeSummary(ctx: Ctx): void {
|
|
723
|
+
const claudeDir = p(ctx.home, ".claude")
|
|
724
|
+
echo(`Claude: ${claudeDir}`)
|
|
725
|
+
if (!ctx.dryRun) {
|
|
726
|
+
echo(`Hooks: ${shellScriptCount(p(claudeDir, "hooks"))} scripts`)
|
|
727
|
+
if (commandExists("rtk")) {
|
|
728
|
+
const v = capture("rtk", ["--version"])
|
|
729
|
+
echo(`RTK: ${v !== "" ? v : "installed"}`)
|
|
730
|
+
} else {
|
|
731
|
+
echo("RTK: not installed")
|
|
732
|
+
}
|
|
733
|
+
if (commandExists("claude")) {
|
|
734
|
+
const installed = readJsonFile(p(claudeDir, "plugins", "installed_plugins.json"))
|
|
735
|
+
const count = installed !== undefined && isObject(installed) && isObject(installed["plugins"]) ? Object.keys(installed["plugins"]).length : 0
|
|
736
|
+
echo(`Plugins: ${count} installed (from SoT enabledPlugins + Anthropic auto-installs)`)
|
|
737
|
+
} else {
|
|
738
|
+
echo("Plugins: skipped - claude CLI not installed")
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function claudeNextSteps(): void {
|
|
744
|
+
echo("In a Claude Code session, run /reload-plugins to pick up newly installed plugins.")
|
|
745
|
+
echo("Restart Claude Code for hook/env-var changes to take effect.")
|
|
746
|
+
}
|