docks-kit 0.16.4 → 0.16.5

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 CHANGED
@@ -128,7 +128,7 @@ The Output Standard wins.
128
128
  Use `unslop` only for its pattern-detection lists.
129
129
 
130
130
  <constraint>
131
- When a kit-mechanic skill, its `references/`, or a wrapper agent (`.claude/agents/*.md` + its `.codex/agents/*.toml` twin) cites EngineNative internals, name the **module + exported/local function + semantic anchor** (e.g. `claudeSync.ts syncPlugins, pass 5 uninstall guard`) — never a raw `file:NNN` line number, which goes stale on every refactor. Keep exactly one coarse `metadata.source_files[].lines` range per skill file as the sole intentional line-number touchpoint.
131
+ When a kit-mechanic skill, its `references/`, or a wrapper agent (`.claude/agents/*.md` + its `.codex/agents/*.toml` twin) cites EngineNative internals, name the **module + exported/local function + semantic anchor** (e.g. `claudePlugins.ts syncPlugins, pass 5 uninstall guard`) — never a raw `file:NNN` line number, which goes stale on every refactor. Keep exactly one coarse `metadata.source_files[].lines` range per skill file as the sole intentional line-number touchpoint.
132
132
  </constraint>
133
133
 
134
134
  **Universal-skill bootstrap.** `SoT/.agents/skills.txt` is intentionally empty, so the default sync exposes no universal skills. The generic opt-in contract remains: each [agentskills.io](https://agentskills.io/specification) slug is installed by `skillsSync.ts` with `npx skills add <slug> -g -y -a claude-code codex`. The slug comes first because `-a/--agent` is variadic; naming both supported agents preserves the canonical `~/.agents/skills/<name>/SKILL.md` plus Claude's `~/.claude/skills/<name>` symlink. Existing canonical directories are reused idempotently, `--prune` removes only entries tracked in `~/.agents/.kit-managed-skills`, and skills requiring a separate CLI retain explicit helpers in `skillsSync.ts`.
package/cli/src/argv.ts CHANGED
@@ -15,7 +15,8 @@ import {
15
15
  effortFlagGrammar
16
16
  } from "./efforts"
17
17
  import { KNOWN_CLAUDE_OPTIN_PLUGINS } from "./engine-native/parseArgs"
18
- import { modelCatalog, type Tool } from "./manifests"
18
+ import { modelCatalog } from "./engine-native/models"
19
+ import type { Tool } from "./manifests"
19
20
 
20
21
  export type ArgvOutcome =
21
22
  | { readonly kind: "reject"; readonly message: string; readonly exitCode: number }
@@ -1,7 +1,8 @@
1
1
  import { Argument, Command, Flag, Prompt } from "effect/unstable/cli"
2
2
  import { Effect, Option } from "effect"
3
3
  import { bail, engine } from "../engine"
4
- import { modelCatalog, type Tool } from "../manifests"
4
+ import { modelCatalog } from "../engine-native/models"
5
+ import type { Tool } from "../manifests"
5
6
 
6
7
  const tool = Argument.string("tool").pipe(
7
8
  Argument.withDescription("Which tool: claude | codex")
@@ -1,7 +1,8 @@
1
1
  import { Argument, Command, Flag } from "effect/unstable/cli"
2
2
  import { Console, Effect, Option } from "effect"
3
3
  import { bail } from "../engine"
4
- import { modelCatalog, type Tool } from "../manifests"
4
+ import { modelCatalog } from "../engine-native/models"
5
+ import type { Tool } from "../manifests"
5
6
 
6
7
  const tool = Argument.string("tool").pipe(
7
8
  Argument.withDescription("claude | codex (omit for both tool catalogs)"),
@@ -72,20 +72,11 @@ const gatherDrift = (): {
72
72
  return { setting, deployed: d, sot: s, drifted: d !== s }
73
73
  }
74
74
  const codex = row("codex.model", deployedCodexModel(), sotCodexModel())
75
- if (claudeDeployment.state === "absent") {
75
+ if (claudeDeployment.state !== "valid") {
76
76
  return {
77
77
  claudeDeployment,
78
78
  drift: [
79
- { setting: "claude.settings", deployed: "(absent)", sot: "present", drifted: true },
80
- codex
81
- ]
82
- }
83
- }
84
- if (claudeDeployment.state === "malformed") {
85
- return {
86
- claudeDeployment,
87
- drift: [
88
- { setting: "claude.settings", deployed: "(malformed)", sot: "present", drifted: true },
79
+ { setting: "claude.settings", deployed: `(${claudeDeployment.state})`, sot: "present", drifted: true },
89
80
  codex
90
81
  ]
91
82
  }
@@ -17,10 +17,6 @@ export const CLAUDE_ADVISOR_STATES = ["on", "off", "default"] as const
17
17
  const VERIFIED = "2026-07-10"
18
18
  const DEFAULT = "default"
19
19
 
20
- export type ClaudeEffortLevel = typeof CLAUDE_EFFORT_LEVELS[number]
21
- export type CodexReasoningEffort = typeof CODEX_REASONING_EFFORTS[number]
22
- export type ClaudeAdvisorState = typeof CLAUDE_ADVISOR_STATES[number]
23
-
24
20
  const upstreamEfforts = (tool: Tool): ReadonlyArray<string> =>
25
21
  tool === "claude" ? CLAUDE_EFFORT_LEVELS : CODEX_REASONING_EFFORTS
26
22
 
@@ -57,7 +57,7 @@ async function runBunBootstrap(ctx: Ctx, services: EngineServices): Promise<BunR
57
57
  const existing = await services.deps.path("bun")
58
58
  if (existing !== "") return { kind: "ready", executable: existing }
59
59
 
60
- const pin = field(ctx, "bun", "verified")
60
+ const pin = field("bun", "verified")
61
61
  if (pin === "") {
62
62
  services.logger.warn("Bun bootstrap aborted — SoT/toolchain.json has no verified Bun pin")
63
63
  return { kind: "deferred", reason: "install-failed" }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * EngineNative `sync claude` plugin, optional-plugin, and LSP passes. Pass
3
+ * order inside `syncPlugins` is load-bearing and golden-tested; message
4
+ * strings and spawned argv are part of the contract.
5
+ */
6
+ import { existsSync, renameSync, writeFileSync } from "node:fs"
7
+ import { p, spawnProcess } from "./exec"
8
+ import type { Ctx } from "./index"
9
+ import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, readJsonFile, type Json } from "./jq"
10
+ import { field } from "./toolchain"
11
+ import { payloadText } from "../payload"
12
+
13
+ async function cli(args: Array<string>): Promise<{ ok: boolean; out: string }> {
14
+ const res = await spawnProcess("claude", args, { stdio: ["ignore", "pipe", "pipe"] })
15
+ return { ok: res.error === undefined && res.exitCode === 0, out: `${res.stdout}${res.stderr}` }
16
+ }
17
+
18
+ function sortedKeys(obj: Json | undefined): Array<string> {
19
+ return obj !== undefined && isObject(obj) ? Object.keys(obj).sort(compareCodepoints) : []
20
+ }
21
+
22
+ /** claude::_plugin_user_scope_installed. */
23
+ export function pluginUserScopeInstalled(installedPlugins: string, pluginId: string): boolean {
24
+ const doc = readJsonFile(installedPlugins)
25
+ if (doc === undefined || !isObject(doc) || !isObject(doc["plugins"])) return false
26
+ const rec = (doc["plugins"] as { [k: string]: Json })[pluginId]
27
+ if (rec === undefined || rec === null) return false
28
+ const records = Array.isArray(rec) ? rec : [rec]
29
+ return records.some((r) => isObject(r) && r["scope"] === "user")
30
+ }
31
+
32
+ function nonUserScopeMarketplaces(installedDoc: Json | undefined): Set<string> {
33
+ const marketplaces = new Set<string>()
34
+ if (installedDoc === undefined || !isObject(installedDoc) || !isObject(installedDoc["plugins"])) {
35
+ return marketplaces
36
+ }
37
+
38
+ for (const [pluginId, value] of Object.entries(installedDoc["plugins"])) {
39
+ const records = Array.isArray(value) ? value : [value]
40
+ const hasNonUserInstall = records.some(
41
+ (record) => isObject(record) && (record["scope"] === "project" || record["scope"] === "local")
42
+ )
43
+ if (!hasNonUserInstall) continue
44
+ const separator = pluginId.lastIndexOf("@")
45
+ if (separator > 0) marketplaces.add(pluginId.slice(separator + 1))
46
+ }
47
+ return marketplaces
48
+ }
49
+
50
+ export async function syncPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
51
+ const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
52
+ const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
53
+ const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
54
+
55
+ if (ctx.dryRun) {
56
+ echo(
57
+ ctx.skipPluginRefresh
58
+ ? "[dry-run] bootstrap + install missing plugins from SoT; skip refresh-only plugin updates"
59
+ : "[dry-run] bootstrap + update plugin marketplaces + plugins from SoT"
60
+ )
61
+ if (ctx.prune) {
62
+ echo("[dry-run] (--prune) would also uninstall plugins not in SoT and remove extra marketplaces")
63
+ }
64
+ return
65
+ }
66
+
67
+ if (ctx.services.deps.probe("claude").state === "missing") {
68
+ warn("claude CLI not in PATH — skipping plugin reconcile (run /plugin marketplace add + /plugin install manually)")
69
+ return
70
+ }
71
+ if (ctx.services.deps.probe("git").state === "missing") {
72
+ ctx.services.deps.warnMissing(
73
+ "git",
74
+ ctx.services.logger,
75
+ "plugin marketplaces are git repos — Claude plugin passes skipped; re-run sync after installing"
76
+ )
77
+ return
78
+ }
79
+
80
+ const repoSettings = parseJson(payloadText("SoT/.claude/settings.json"))
81
+ const repoObj = repoSettings !== undefined && isObject(repoSettings) ? repoSettings : {}
82
+ const sotMarketplaces = isObject(repoObj["extraKnownMarketplaces"]) ? repoObj["extraKnownMarketplaces"] : {}
83
+ const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
84
+
85
+ // Pass 1 — add missing marketplaces (SoT insertion order, like to_entries).
86
+ let addedMp = 0
87
+ let f1 = 0
88
+ for (const [mpName, mpValue] of Object.entries(sotMarketplaces)) {
89
+ const known = readJsonFile(knownMarketplaces)
90
+ if (known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false) continue
91
+ const repo = isObject(mpValue) && isObject(mpValue["source"]) ? String((mpValue["source"] as { [k: string]: Json })["repo"] ?? "") : ""
92
+ progress(`Adding marketplace ${mpName}...`)
93
+ const marketplaceResult = await cli(["plugin", "marketplace", "add", repo])
94
+ clearProgress()
95
+ if (marketplaceResult.ok) {
96
+ addedMp++
97
+ } else {
98
+ warn(`Failed to add marketplace: ${mpName} (${repo})`)
99
+ f1++
100
+ }
101
+ }
102
+
103
+ // Pass 2 — install SoT-enabled plugins missing at user scope (jq keys[] sorts),
104
+ // refreshing each source marketplace once so the install resolves a current snapshot.
105
+ let addedPl = 0
106
+ let f2 = 0
107
+ const refreshedMarketplaces = new Set<string>()
108
+ for (const pluginId of sortedKeys(sotPlugins)) {
109
+ if (pluginUserScopeInstalled(installedPlugins, pluginId)) continue
110
+ const separator = pluginId.lastIndexOf("@")
111
+ const mpName = separator > 0 ? pluginId.slice(separator + 1) : ""
112
+ if (mpName !== "" && !refreshedMarketplaces.has(mpName)) {
113
+ progress(`Refreshing marketplace ${mpName}...`)
114
+ await cli(["plugin", "marketplace", "update", mpName])
115
+ clearProgress()
116
+ refreshedMarketplaces.add(mpName)
117
+ }
118
+ progress(`Installing plugin ${pluginId}...`)
119
+ const installResult = await cli(["plugin", "install", pluginId])
120
+ clearProgress()
121
+ if (installResult.ok) {
122
+ addedPl++
123
+ } else {
124
+ warn(`Failed to install plugin: ${pluginId}`)
125
+ f2++
126
+ }
127
+ }
128
+
129
+ let updatedPl = 0
130
+ const installedDoc = readJsonFile(installedPlugins)
131
+ const installedKeys = installedDoc !== undefined && isObject(installedDoc) ? sortedKeys(installedDoc["plugins"]) : []
132
+ const nonUserMarketplaces = nonUserScopeMarketplaces(installedDoc)
133
+ // Kit-owned plugin IDs: SoT-declared plus this run's --claude-plugin opt-ins.
134
+ const kitPluginIds = new Set<string>(Object.keys(sotPlugins))
135
+ if (ctx.claudePlugins.includes("supabase")) kitPluginIds.add("supabase@claude-plugins-official")
136
+ if (ctx.claudePlugins.includes("n8n")) kitPluginIds.add("n8n-mcp-skills@n8n-mcp-skills")
137
+
138
+ // Kit-owned marketplaces: SoT-declared plus every marketplace those plugins come from.
139
+ const kitMarketplaces = new Set<string>(Object.keys(sotMarketplaces))
140
+ for (const pluginId of kitPluginIds) {
141
+ const separator = pluginId.lastIndexOf("@")
142
+ if (separator > 0) kitMarketplaces.add(pluginId.slice(separator + 1))
143
+ }
144
+
145
+ // Pass 3 — refresh the kit-owned marketplaces unless the update command
146
+ // selected its install-missing-only fast path.
147
+ if (!ctx.skipPluginRefresh) {
148
+ for (const mpName of [...kitMarketplaces].sort(compareCodepoints)) {
149
+ progress(`Refreshing marketplace ${mpName}...`)
150
+ await cli(["plugin", "marketplace", "update", mpName])
151
+ clearProgress()
152
+ }
153
+ // Pass 4 — update the kit-owned installed plugins.
154
+ for (const pluginId of [...kitPluginIds].sort(compareCodepoints)) {
155
+ if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
156
+ progress(`Updating plugin ${pluginId}...`)
157
+ const updateResult = await cli(["plugin", "update", pluginId, "--scope", "user"])
158
+ clearProgress()
159
+ if (updateResult.out.includes("Successfully updated")) updatedPl++
160
+ }
161
+ }
162
+
163
+ // Pass 5 — prune-gated user-scope plugin uninstall.
164
+ let removedPl = 0
165
+ let removedMp = 0
166
+ let f5 = 0
167
+ let f6 = 0
168
+ if (ctx.prune) {
169
+ for (const pluginId of installedKeys) {
170
+ if (kitPluginIds.has(pluginId)) continue
171
+ if (!pluginUserScopeInstalled(installedPlugins, pluginId)) continue
172
+ progress(`Uninstalling plugin ${pluginId}...`)
173
+ const uninstallResult = await cli(["plugin", "uninstall", "-y", "--scope", "user", pluginId])
174
+ clearProgress()
175
+ if (uninstallResult.ok) {
176
+ removedPl++
177
+ } else {
178
+ warn(`Failed to uninstall plugin: ${pluginId}`)
179
+ f5++
180
+ }
181
+ }
182
+ // Pass 6 — prune-gated marketplace removal.
183
+ const known = readJsonFile(knownMarketplaces)
184
+ for (const mpName of sortedKeys(known)) {
185
+ if (mpName === "claude-plugins-official") continue
186
+ if (nonUserMarketplaces.has(mpName)) continue
187
+ if (kitMarketplaces.has(mpName)) continue
188
+ progress(`Removing marketplace ${mpName}...`)
189
+ const removeResult = await cli(["plugin", "marketplace", "remove", mpName])
190
+ clearProgress()
191
+ if (removeResult.ok) {
192
+ removedMp++
193
+ } else {
194
+ warn(`Failed to remove marketplace: ${mpName}`)
195
+ f6++
196
+ }
197
+ }
198
+ }
199
+
200
+ // Pass 7 — re-assert SoT enabled-state in the user settings.
201
+ if (await reassertEnabledState(ctx, repoObj, p(claudeDir, "settings.json"))) {
202
+ change("Plugin enable-state re-asserted from SoT in settings.json")
203
+ ctx.nextStepTriggers.claudePlugins = true
204
+ }
205
+
206
+ const failed = f1 + f2 + f5 + f6
207
+ if (addedMp > 0 || addedPl > 0 || updatedPl > 0 || removedPl > 0 || removedMp > 0) {
208
+ change(`Plugins synced (marketplaces: +${addedMp} -${removedMp}, plugins: +${addedPl} ~${updatedPl} -${removedPl})`)
209
+ ctx.nextStepTriggers.claudePlugins = true
210
+ } else {
211
+ verbose("Plugins already in sync")
212
+ }
213
+ if (failed > 0) {
214
+ warn(`${failed} plugin operation(s) failed — re-run sync or install manually`)
215
+ }
216
+ }
217
+
218
+ async function reassertEnabledState(ctx: Ctx, repoObj: { [k: string]: Json }, userSettingsFile: string): Promise<boolean> {
219
+ const { warn } = ctx.services.logger
220
+ if (!existsSync(userSettingsFile)) return false
221
+ const sotPlugins = isObject(repoObj["enabledPlugins"]) ? repoObj["enabledPlugins"] : {}
222
+
223
+ let cliDisabled = false
224
+ for (const [pluginId, value] of Object.entries(sotPlugins)) {
225
+ if (value !== false) continue
226
+ const user = readJsonFile(userSettingsFile)
227
+ const enabled = user !== undefined && isObject(user) && isObject(user["enabledPlugins"]) ? (user["enabledPlugins"] as { [k: string]: Json })[pluginId] : undefined
228
+ if (enabled !== true) continue
229
+ if ((await cli(["plugin", "disable", pluginId])).ok) {
230
+ cliDisabled = true
231
+ } else {
232
+ warn(`Failed to disable SoT-false plugin: ${pluginId} (will retry next sync)`)
233
+ }
234
+ }
235
+
236
+ const user = readJsonFile(userSettingsFile)
237
+ if (user === undefined || !isObject(user)) {
238
+ warn("enabledPlugins re-assert failed — false-keyed plugins may be left enabled")
239
+ return false
240
+ }
241
+ const beforeCanonical = jqStringify(user)
242
+ user["enabledPlugins"] = deepMerge(isObject(user["enabledPlugins"]) ? user["enabledPlugins"] : {}, sotPlugins)
243
+ const out = jqStringify(user)
244
+ if (out === beforeCanonical) return cliDisabled
245
+ writeFileSync(`${userSettingsFile}.tmp`, out)
246
+ renameSync(`${userSettingsFile}.tmp`, userSettingsFile)
247
+ return true
248
+ }
249
+
250
+ // ------------------------------------------------------ optional plugins ----
251
+
252
+ async function enableOptionalPlugin(ctx: Ctx, claudeDir: string, pluginId: string, marketplaceRepo: string): Promise<boolean> {
253
+ const { change, clearProgress, progress, verbose, warn } = ctx.services.logger
254
+ const installedPlugins = p(claudeDir, "plugins", "installed_plugins.json")
255
+ const knownMarketplaces = p(claudeDir, "plugins", "known_marketplaces.json")
256
+ const mpName = pluginId.slice(pluginId.lastIndexOf("@") + 1)
257
+
258
+ let marketplaceAdded = false
259
+ if (marketplaceRepo !== "") {
260
+ const known = readJsonFile(knownMarketplaces)
261
+ const has = known !== undefined && isObject(known) && known[mpName] !== undefined && known[mpName] !== null && known[mpName] !== false
262
+ if (!has) {
263
+ if (!(await cli(["plugin", "marketplace", "add", marketplaceRepo])).ok) {
264
+ warn(`Failed to add marketplace ${marketplaceRepo} for ${pluginId}`)
265
+ return false
266
+ }
267
+ marketplaceAdded = true
268
+ }
269
+ }
270
+
271
+ const wasInstalled = pluginUserScopeInstalled(installedPlugins, pluginId)
272
+ if (!wasInstalled) {
273
+ progress(`Installing plugin ${pluginId}...`)
274
+ const installResult = await cli(["plugin", "install", pluginId])
275
+ clearProgress()
276
+ if (!installResult.ok) {
277
+ if (marketplaceAdded) change(`Optional plugin ${pluginId}: marketplace added (install failed — will retry next sync)`)
278
+ warn(`Failed to install optional plugin ${pluginId}`)
279
+ return marketplaceAdded
280
+ }
281
+ }
282
+
283
+ const settingsDoc = readJsonFile(p(claudeDir, "settings.json"))
284
+ const wasEnabled =
285
+ settingsDoc !== undefined && isObject(settingsDoc) && isObject(settingsDoc["enabledPlugins"])
286
+ ? (settingsDoc["enabledPlugins"] as { [k: string]: Json })[pluginId] === true
287
+ : false
288
+
289
+ if (!(await cli(["plugin", "enable", pluginId])).ok) {
290
+ if (marketplaceAdded || !wasInstalled) change(`Optional plugin ${pluginId}: installed (enable failed — will retry next sync)`)
291
+ warn(`Failed to enable optional plugin ${pluginId}`)
292
+ return marketplaceAdded || !wasInstalled
293
+ }
294
+ const changed = marketplaceAdded || !wasInstalled || !wasEnabled
295
+ if (changed) change(`Optional plugin opted in: ${pluginId}`)
296
+ else verbose(`Optional plugin already opted in: ${pluginId} (enable re-asserted)`)
297
+ return changed
298
+ }
299
+
300
+ export async function syncOptionalPlugins(ctx: Ctx, claudeDir: string): Promise<void> {
301
+ const { echo, warn } = ctx.services.logger
302
+ if (ctx.claudePlugins.length === 0) return
303
+
304
+ if (ctx.dryRun) {
305
+ if (ctx.claudePlugins.includes("supabase")) {
306
+ echo("[dry-run] (--claude-plugin=supabase) install + enable supabase@claude-plugins-official in deployed settings")
307
+ }
308
+ if (ctx.claudePlugins.includes("n8n")) {
309
+ echo("[dry-run] (--claude-plugin=n8n) add czlonkowski/n8n-skills marketplace + install + enable n8n-mcp-skills@n8n-mcp-skills")
310
+ }
311
+ return
312
+ }
313
+
314
+ if (ctx.services.deps.probe("claude").state === "missing") {
315
+ warn("claude CLI not in PATH — cannot opt in optional plugins (--claude-plugin)")
316
+ return
317
+ }
318
+
319
+ if (ctx.claudePlugins.includes("supabase")) {
320
+ if (await enableOptionalPlugin(ctx, claudeDir, "supabase@claude-plugins-official", "")) ctx.nextStepTriggers.claudePlugins = true
321
+ }
322
+ if (ctx.claudePlugins.includes("n8n")) {
323
+ if (await enableOptionalPlugin(ctx, claudeDir, "n8n-mcp-skills@n8n-mcp-skills", "czlonkowski/n8n-skills")) ctx.nextStepTriggers.claudePlugins = true
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------- LSP servers ----
328
+
329
+ function lspPkg(ctx: Ctx, tool: string, pkg: string): string | undefined {
330
+ const version = field(tool, "verified")
331
+ if (version !== "") return `${pkg}@${version}`
332
+ ctx.services.logger.warn(`Skipping ${pkg} install: ${tool} has no verified version in SoT/toolchain.json`)
333
+ return undefined
334
+ }
335
+
336
+ export async function syncLspServers(ctx: Ctx): Promise<void> {
337
+ const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
338
+ const sot = parseJson(payloadText("SoT/.claude/settings.json"))
339
+ const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
340
+ if (enabled === undefined) return
341
+ const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
342
+ const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
343
+ if (!hasPhp && !hasTs) return
344
+
345
+ const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
346
+ const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
347
+ const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
348
+ const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing)
349
+ const missing = [
350
+ phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
351
+ tsServerMissing ? lspPkg(ctx, "typescript-language-server", "typescript-language-server") : undefined,
352
+ tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
353
+ ].filter((spec): spec is string => spec !== undefined)
354
+
355
+ if (missingToolCount === 0) {
356
+ if (ctx.dryRun) {
357
+ echo("[dry-run] LSP server binaries present")
358
+ } else {
359
+ verbose("LSP server binaries present")
360
+ }
361
+ return
362
+ }
363
+ if (missing.length === 0) return
364
+
365
+ const specs = missing.join(" ")
366
+ if (ctx.dryRun) {
367
+ echo(`[dry-run] would install: npm install -g ${specs}`)
368
+ return
369
+ }
370
+
371
+ if (ctx.services.deps.probe("npm").state === "missing") {
372
+ ctx.services.deps.warnMissing(
373
+ "npm",
374
+ ctx.services.logger,
375
+ `cannot install LSP servers (${specs}); the php-lsp/typescript-lsp plugins stay no-ops`
376
+ )
377
+ return
378
+ }
379
+
380
+ verbose(`Installing LSP servers via npm: ${specs}...`)
381
+ progress(`Installing LSP servers via npm: ${specs}...`)
382
+ const installResult = await spawnProcess("npm", ["install", "-g", ...missing], { stdio: "ignore" })
383
+ clearProgress()
384
+ if (installResult.exitCode === 0) {
385
+ change(`LSP servers installed (${specs})`)
386
+ ctx.nextStepTriggers.claudeRestart = true
387
+ } else {
388
+ warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
389
+ }
390
+ }