docks-kit 0.16.4 → 0.16.6

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
@@ -66,6 +66,7 @@ omp SoT notes:
66
66
  - `ompSync.ts syncConfig` deep-merges `config.yml` through `mergeOmpConfig`.
67
67
  - `cycleOrder` ends with the `fable` role (`modelRoles.fable` = `anthropic/claude-fable-5-1:medium`, `modelTags.fable` visible, `retry.fallbackChains.fable` empty), so the model switcher reaches Fable 5.1 as its fourth stop and never falls back off it. The hidden `switch_fable` role points at the same model and level.
68
68
  - `modelRoles.task` is `openai-codex/gpt-6-astra:low`, chosen for 2.60 s TTFT and 4k output tokens per task at cost parity with the previous `gpt-5.6-sol:high`. The four reviewer agents in `task.agentModelOverrides` inherit `@task`, and Artificial Analysis publishes no per-level Astra Coding Agent Index score, so reviewer output is the signal to watch.
69
+ - `task.maxEffort` is `low`. The bundled `task` agent runs `thinking-level: auto`, which rated many prompts `high`, and a caller's `effort: hi` also reached Astra `high`; the ceiling clamps both. Verified for the five bundled agents: `task`, `reviewer`, and `security-reviewer` resolve Astra `low`; `scout` and `sonic` (bundled `@smol`, Luna) drop to Luna `low`, the accepted cost. `cli/docs/omp-models.md` records the verification.
69
70
  - `SoT/.omp/AGENTS.md` carries the rule `Please remove all mannered prose.` Anthropic's Fable 5.1 prompting guide documents mannered prose as a Fable 5.1 behavior and gives that sentence as its short-version fix: https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-fable-5-1
70
71
  - `cli/docs/omp-models.md` (topic `omp-models`) records the role map rationale and the Artificial Analysis snapshot behind it. Model choices change with published benchmarks, so update that topic in the same commit as a role change.
71
72
  - Sync registers the `docks` marketplace. It installs or upgrades `docks@docks` and `plan-lifecycle@docks` at user scope.
@@ -128,7 +129,7 @@ The Output Standard wins.
128
129
  Use `unslop` only for its pattern-detection lists.
129
130
 
130
131
  <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.
132
+ 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
133
  </constraint>
133
134
 
134
135
  **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`.
@@ -135,7 +135,7 @@ published numeric cache price. Context 1M.
135
135
  Price: $0.20 in, $1.20 out, $0.02 cache read per 1M; no published cache-write
136
136
  price. Context 1M. AA lists cost per task only for max and xhigh.
137
137
 
138
- ## Why `task` runs Astra low
138
+ ## Why `task` points at Astra low
139
139
 
140
140
  | Metric | Astra low | Sol high | Sol max | Opus 5 high |
141
141
  |---|---:|---:|---:|---:|
@@ -166,6 +166,39 @@ level except max. Watch reviewer output, because the four reviewer agents
166
166
  inherit `@task`. If review quality drops, pin those four agents to
167
167
  `anthropic/claude-opus-5:high` rather than reverting the whole role.
168
168
 
169
+ ## What resolves to Astra low in practice
170
+
171
+ `modelRoles.task` sets the model. The `:low` suffix is the role's own level;
172
+ it does not by itself decide the level of every spawn, and the role does not
173
+ cover every agent.
174
+
175
+ - The bundled `scout` and `sonic` agents carry `model: "@smol"` and
176
+ `thinking-level: medium` in their embedded frontmatter, so they run Luna,
177
+ not Astra. To move them, change `modelRoles.smol` or add a
178
+ `task.agentModelOverrides` entry for the agent name.
179
+ - The bundled `task` agent carries `model: "@task"` and
180
+ `thinking-level: auto`. `auto` classifies each prompt and picks a level for
181
+ the resolved model. With no ceiling, `task` spawns without an `effort` hint
182
+ were observed at both `low` and `high`.
183
+ - `task.enableEffort` is `true`, so a caller can pass `effort: lo`, `med`, or
184
+ `hi`, which overrides `auto`.
185
+ - `task.maxEffort` is `low` in the SoT. This ceiling clamps both the `auto`
186
+ classifier and the `effort` hint. Verified on 2026-09-08 for the five
187
+ bundled agents: `task`, `reviewer`, and `security-reviewer` resolved
188
+ `gpt-6-astra:low` with no hint and with `effort: hi`; `scout` and `sonic`
189
+ with `effort: hi` resolved `gpt-5.6-luna:low`. The `plan-lifecycle` plugin
190
+ agents `code-reviewer` and `plan-reviewer` carry no `model` or
191
+ `thinking-level` frontmatter and inherit `@task`; the cap applies to their
192
+ spawns by the same rule, but they were not probed.
193
+
194
+ So the bundled subagents run their models at `low`. The Astra low figures
195
+ above are the level `task`, `reviewer`, and `security-reviewer` get. The cost
196
+ of the cap is that `scout` and `sonic` also drop from Luna `medium` to Luna
197
+ `low` (index 22 against 26). Raising `task.maxEffort` lets `auto` and
198
+ `effort: hi` reach Astra `high` again ($1.72 per index task, 45.63 s TTFT).
199
+
200
+ omp's model catalog lists Astra with a 272k context window, while AA lists 1M.
201
+
169
202
  ## Maintenance
170
203
 
171
204
  - Refresh the snapshot from the AA release page of each family
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
+ }