@yagni-app/code-staging 1.0.6-staging.1255.1 → 1.0.6-staging.1257.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.
@@ -34,6 +34,23 @@ export declare const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
34
34
  export declare const CLAUDE_AGENT_DIRS_ENV = "YAGNI_CLAUDE_AGENT_DIRS";
35
35
  /** `.claude/rules` dirs for the extension's rules injection (delimiter-joined). */
36
36
  export declare const CLAUDE_RULES_DIRS_ENV = "YAGNI_CLAUDE_RULES_DIRS";
37
+ /**
38
+ * Plugin MCP servers for the extension's MCP client: a JSON array of
39
+ * `{ plugin, sourcePath, gated, servers }`. `${CLAUDE_PLUGIN_ROOT}` is already
40
+ * expanded to the plugin's install dir; other `${VAR}` references are left for
41
+ * the extension's normal env expansion. `gated` marks servers that came from
42
+ * repo config (repo marketplace, project-scope `enabledPlugins`) and must pass
43
+ * the same approval the repo's `.mcp.json` would.
44
+ */
45
+ export declare const CLAUDE_PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
46
+ export interface PluginMcpEnvEntry {
47
+ plugin: string;
48
+ sourcePath: string;
49
+ gated: boolean;
50
+ servers: Record<string, unknown>;
51
+ }
52
+ /** The MCP payload entries for a set of plugins (those without servers are skipped). */
53
+ export declare function pluginMcpEntries(plugins: PluginAssets[], gated: boolean): PluginMcpEnvEntry[];
37
54
  export interface ClaudeAssetDirs {
38
55
  skills: string | null;
39
56
  commands: string | null;
@@ -61,6 +78,9 @@ export interface ClaudeCompatDecision {
61
78
  /** `.claude/rules` dirs, user first so project rules win in the prompt. */
62
79
  userRulesDirs: string[];
63
80
  projectRulesDirs: string[];
81
+ /** Plugin MCP servers, split by the same trust posture (project ones are approval-gated too). */
82
+ userMcp: PluginMcpEnvEntry[];
83
+ projectMcp: PluginMcpEnvEntry[];
64
84
  /** Ask the user for a trust decision before wiring any project content. */
65
85
  needsPrompt: boolean;
66
86
  }
@@ -32,11 +32,50 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSyn
32
32
  import { homedir } from "node:os";
33
33
  import { delimiter, dirname, join, resolve } from "node:path";
34
34
  import { discoverClaudePlugins } from "./claudePlugins.js";
35
+ import { pluginsHome, readLedger } from "./pluginStore.js";
35
36
  export const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
36
37
  /** Plugin `agents/` dirs for the extension's subagent discovery (delimiter-joined). */
37
38
  export const CLAUDE_AGENT_DIRS_ENV = "YAGNI_CLAUDE_AGENT_DIRS";
38
39
  /** `.claude/rules` dirs for the extension's rules injection (delimiter-joined). */
39
40
  export const CLAUDE_RULES_DIRS_ENV = "YAGNI_CLAUDE_RULES_DIRS";
41
+ /**
42
+ * Plugin MCP servers for the extension's MCP client: a JSON array of
43
+ * `{ plugin, sourcePath, gated, servers }`. `${CLAUDE_PLUGIN_ROOT}` is already
44
+ * expanded to the plugin's install dir; other `${VAR}` references are left for
45
+ * the extension's normal env expansion. `gated` marks servers that came from
46
+ * repo config (repo marketplace, project-scope `enabledPlugins`) and must pass
47
+ * the same approval the repo's `.mcp.json` would.
48
+ */
49
+ export const CLAUDE_PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
50
+ const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}";
51
+ function expandPluginRoot(value, root) {
52
+ if (typeof value === "string")
53
+ return value.split(PLUGIN_ROOT_VAR).join(root);
54
+ if (Array.isArray(value))
55
+ return value.map((v) => expandPluginRoot(v, root));
56
+ if (value && typeof value === "object") {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(value))
59
+ out[k] = expandPluginRoot(v, root);
60
+ return out;
61
+ }
62
+ return value;
63
+ }
64
+ /** The MCP payload entries for a set of plugins (those without servers are skipped). */
65
+ export function pluginMcpEntries(plugins, gated) {
66
+ const out = [];
67
+ for (const plugin of plugins) {
68
+ if (!plugin.mcp)
69
+ continue;
70
+ out.push({
71
+ plugin: plugin.key,
72
+ sourcePath: plugin.mcp.sourcePath,
73
+ gated,
74
+ servers: expandPluginRoot(plugin.mcp.servers, plugin.root),
75
+ });
76
+ }
77
+ return out;
78
+ }
40
79
  function dirsToArgv(dirs) {
41
80
  const argv = [];
42
81
  if (dirs.skills)
@@ -60,7 +99,8 @@ export function decideClaudeCompat(probe) {
60
99
  const projectArgv = [...dirsToArgv(probe.project), ...pluginsToArgv(probe.projectPlugins)];
61
100
  const projectAgentDirs = probe.projectPlugins.flatMap((p) => p.agentDirs);
62
101
  const projectRulesDirs = probe.project.rules ? [probe.project.rules] : [];
63
- const hasProjectContent = projectArgv.length > 0 || projectAgentDirs.length > 0 || projectRulesDirs.length > 0;
102
+ const projectMcp = pluginMcpEntries(probe.projectPlugins, true);
103
+ const hasProjectContent = projectArgv.length > 0 || projectAgentDirs.length > 0 || projectRulesDirs.length > 0 || projectMcp.length > 0;
64
104
  return {
65
105
  userArgv: [...dirsToArgv(probe.user), ...pluginsToArgv(probe.userPlugins)],
66
106
  projectArgv,
@@ -68,6 +108,8 @@ export function decideClaudeCompat(probe) {
68
108
  projectAgentDirs,
69
109
  userRulesDirs: probe.user.rules ? [probe.user.rules] : [],
70
110
  projectRulesDirs,
111
+ userMcp: pluginMcpEntries(probe.userPlugins, false),
112
+ projectMcp,
71
113
  needsPrompt: probe.interactive && hasProjectContent && probe.projectTrust === null,
72
114
  };
73
115
  }
@@ -210,7 +252,11 @@ export async function claudeCompatArgs(deps) {
210
252
  const projectTrust = readTrustDecision(deps.agentDir, deps.cwd);
211
253
  let plugins;
212
254
  try {
213
- plugins = discoverClaudePlugins({ cwd: deps.cwd, homeDir: home });
255
+ plugins = discoverClaudePlugins({
256
+ cwd: deps.cwd,
257
+ homeDir: home,
258
+ yagniLedger: readLedger(pluginsHome(env, home)),
259
+ });
214
260
  }
215
261
  catch {
216
262
  plugins = { user: [], project: [] };
@@ -246,11 +292,17 @@ export async function claudeCompatArgs(deps) {
246
292
  ...decision.userRulesDirs,
247
293
  ...(trusted === true ? decision.projectRulesDirs : []),
248
294
  ];
295
+ const mcpEntries = [
296
+ ...decision.userMcp,
297
+ ...(trusted === true ? decision.projectMcp : []),
298
+ ];
249
299
  const extraEnv = {};
250
300
  if (agentDirs.length > 0)
251
301
  extraEnv[CLAUDE_AGENT_DIRS_ENV] = agentDirs.join(delimiter);
252
302
  if (rulesDirs.length > 0)
253
303
  extraEnv[CLAUDE_RULES_DIRS_ENV] = rulesDirs.join(delimiter);
304
+ if (mcpEntries.length > 0)
305
+ extraEnv[CLAUDE_PLUGIN_MCP_ENV] = JSON.stringify(mcpEntries);
254
306
  return { argv, env: extraEnv };
255
307
  }
256
308
  /** Whether pi's trust file exists yet (used only for messaging). */
@@ -17,10 +17,19 @@
17
17
  * relative-path source loads on the project side, unless explicitly
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
- * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
21
- * servers (standard MCP support lives in `src/mcpCommand.ts` + the extension's
22
- * `mcp/` module see `yagni mcp --help`), LSP servers, themes, output styles.
23
- * Discovery is read-only.
20
+ * Three sources, first wins on the same `name@marketplace`:
21
+ *
22
+ * 0. YAGNI's own ledger (`~/.yagni-code/plugins`, written by `yagni plugin`;
23
+ * see pluginStore.ts) handed in by the caller as `yagniLedger`.
24
+ * 1. Claude Code's ledger + `enabledPlugins` (mirrored read-only).
25
+ * 2. The repo's own `.claude-plugin/marketplace.json`.
26
+ *
27
+ * Plugin MCP servers (`.mcp.json` at the plugin root, or the manifest's
28
+ * `mcpServers`) are collected here and bridged by the launcher into the
29
+ * extension's MCP client (see claudeCompat.ts). Deliberately NOT here:
30
+ * network installation (that is pluginStore.ts), plugin hooks, LSP servers,
31
+ * themes, output styles — those are reported as `unsupported` so `yagni
32
+ * plugin list` can say so. Discovery is read-only.
24
33
  *
25
34
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
26
35
  * entries degrade to "that plugin absent" — never a failed launch. Path
@@ -28,9 +37,21 @@
28
37
  * inside the expected root) so a malicious `source` or component override
29
38
  * cannot reach outside its repo/plugin.
30
39
  */
40
+ import type { PluginLedger } from "./pluginStore.js";
41
+ export type PluginOrigin = "yagni" | "claude-code" | "repo-marketplace";
42
+ export interface PluginMcp {
43
+ /** File the servers came from (plugin `.mcp.json` or `plugin.json`). */
44
+ sourcePath: string;
45
+ /** Raw `mcpServers` map; validated by the extension, `${CLAUDE_PLUGIN_ROOT}` expanded by the launcher. */
46
+ servers: Record<string, unknown>;
47
+ }
31
48
  export interface PluginAssets {
32
49
  /** The plugin's name (marketplace-entry name — the `enabledPlugins` key half). */
33
50
  name: string;
51
+ /** `name@marketplace` when the marketplace is known; else the bare name. */
52
+ key: string;
53
+ origin: PluginOrigin;
54
+ version: string | null;
34
55
  /** Absolute plugin root directory. */
35
56
  root: string;
36
57
  /** Dirs (or a lone root SKILL.md file) for pi `--skill`. */
@@ -39,6 +60,14 @@ export interface PluginAssets {
39
60
  commandPaths: string[];
40
61
  /** Dirs of Claude Code-format agent markdown for subagent discovery. */
41
62
  agentDirs: string[];
63
+ mcp: PluginMcp | null;
64
+ /** Component kinds present in the plugin that YAGNI Code does not bridge. */
65
+ unsupported: string[];
66
+ }
67
+ export interface PluginMeta {
68
+ key?: string;
69
+ origin?: PluginOrigin;
70
+ version?: string | null;
42
71
  }
43
72
  export interface DiscoveredClaudePlugins {
44
73
  /** Enabled via the user's own `~/.claude` config; loads without ceremony. */
@@ -71,9 +100,18 @@ export interface EnableState {
71
100
  export declare function readEnabledPlugins(cwd: string, homeDir: string): Map<string, EnableState>;
72
101
  /** `~/.claude/plugins/known_marketplaces.json` → marketplace name → checkout dir. */
73
102
  export declare function readKnownMarketplaces(homeDir: string): Map<string, string>;
103
+ /**
104
+ * Names and versions from marketplace/plugin JSON become path segments under
105
+ * the plugin home (`cache/<mp>/<plugin>/<version>`), so they must be plain
106
+ * single segments: no separators, no `.`/`..`, nothing starting with `-`.
107
+ */
108
+ export declare const SAFE_SEGMENT: RegExp;
109
+ export declare function isSafeSegment(value: unknown): value is string;
74
110
  export interface MarketplaceEntry {
75
111
  name: string;
76
112
  source: unknown;
113
+ version?: string;
114
+ description?: string;
77
115
  }
78
116
  export interface Marketplace {
79
117
  name: string;
@@ -98,10 +136,12 @@ export declare function resolveLocalPluginRoot(mp: Marketplace, entry: Marketpla
98
136
  * discovery reads whole dirs), root `SKILL.md` fallback. Null when the plugin
99
137
  * has nothing we can bridge.
100
138
  */
101
- export declare function pluginAssets(root: string, name: string): PluginAssets | null;
139
+ export declare function pluginAssets(root: string, name: string, meta?: PluginMeta): PluginAssets | null;
102
140
  export interface DiscoverPluginsDeps {
103
141
  cwd: string;
104
142
  homeDir: string;
143
+ /** YAGNI's own ledger (`readLedger` from pluginStore.ts); wins over the mirrored Claude Code state. */
144
+ yagniLedger?: PluginLedger;
105
145
  }
106
146
  /**
107
147
  * All locally-present Claude Code plugin content relevant to `cwd`, split by
@@ -17,10 +17,19 @@
17
17
  * relative-path source loads on the project side, unless explicitly
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
- * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
21
- * servers (standard MCP support lives in `src/mcpCommand.ts` + the extension's
22
- * `mcp/` module see `yagni mcp --help`), LSP servers, themes, output styles.
23
- * Discovery is read-only.
20
+ * Three sources, first wins on the same `name@marketplace`:
21
+ *
22
+ * 0. YAGNI's own ledger (`~/.yagni-code/plugins`, written by `yagni plugin`;
23
+ * see pluginStore.ts) handed in by the caller as `yagniLedger`.
24
+ * 1. Claude Code's ledger + `enabledPlugins` (mirrored read-only).
25
+ * 2. The repo's own `.claude-plugin/marketplace.json`.
26
+ *
27
+ * Plugin MCP servers (`.mcp.json` at the plugin root, or the manifest's
28
+ * `mcpServers`) are collected here and bridged by the launcher into the
29
+ * extension's MCP client (see claudeCompat.ts). Deliberately NOT here:
30
+ * network installation (that is pluginStore.ts), plugin hooks, LSP servers,
31
+ * themes, output styles — those are reported as `unsupported` so `yagni
32
+ * plugin list` can say so. Discovery is read-only.
24
33
  *
25
34
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
26
35
  * entries degrade to "that plugin absent" — never a failed launch. Path
@@ -151,10 +160,20 @@ export function readKnownMarketplaces(homeDir) {
151
160
  }
152
161
  return out;
153
162
  }
163
+ // ── marketplace file ────────────────────────────────────────────────────────
164
+ /**
165
+ * Names and versions from marketplace/plugin JSON become path segments under
166
+ * the plugin home (`cache/<mp>/<plugin>/<version>`), so they must be plain
167
+ * single segments: no separators, no `.`/`..`, nothing starting with `-`.
168
+ */
169
+ export const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
170
+ export function isSafeSegment(value) {
171
+ return typeof value === "string" && SAFE_SEGMENT.test(value);
172
+ }
154
173
  /** Parse `<root>/.claude-plugin/marketplace.json`; null when absent/unusable. */
155
174
  export function readMarketplace(root) {
156
175
  const parsed = readJsonObject(join(root, ".claude-plugin", "marketplace.json"));
157
- if (!parsed || typeof parsed.name !== "string" || parsed.name.length === 0)
176
+ if (!parsed || !isSafeSegment(parsed.name))
158
177
  return null;
159
178
  if (!Array.isArray(parsed.plugins))
160
179
  return null;
@@ -166,10 +185,16 @@ export function readMarketplace(root) {
166
185
  for (const item of parsed.plugins) {
167
186
  if (!item || typeof item !== "object" || Array.isArray(item))
168
187
  continue;
169
- const name = item.name;
170
- if (typeof name !== "string" || name.length === 0)
188
+ const entry = item;
189
+ const name = entry.name;
190
+ if (!isSafeSegment(name))
171
191
  continue;
172
- plugins.push({ name, source: item.source });
192
+ plugins.push({
193
+ name,
194
+ source: entry.source,
195
+ ...(isSafeSegment(entry.version) ? { version: entry.version } : {}),
196
+ ...(typeof entry.description === "string" ? { description: entry.description } : {}),
197
+ });
173
198
  }
174
199
  return { name: parsed.name, root, pluginRoot, plugins };
175
200
  }
@@ -212,13 +237,17 @@ function asStringArray(value) {
212
237
  * discovery reads whole dirs), root `SKILL.md` fallback. Null when the plugin
213
238
  * has nothing we can bridge.
214
239
  */
215
- export function pluginAssets(root, name) {
240
+ export function pluginAssets(root, name, meta = {}) {
216
241
  if (!isDirectory(root))
217
242
  return null;
218
- const manifest = readJsonObject(join(root, ".claude-plugin", "plugin.json")) ?? {};
243
+ const manifestPath = join(root, ".claude-plugin", "plugin.json");
244
+ const manifest = readJsonObject(manifestPath) ?? {};
245
+ // Default component locations go through the same containment check as
246
+ // manifest-declared ones: a `skills` symlink pointing outside the plugin
247
+ // must not hand that directory to pi.
219
248
  const skillPaths = [];
220
- const defaultSkills = join(root, "skills");
221
- if (isDirectory(defaultSkills))
249
+ const defaultSkills = containedExistingPath(root, "skills");
250
+ if (defaultSkills && isDirectory(defaultSkills))
222
251
  skillPaths.push(defaultSkills);
223
252
  const manifestSkills = asStringArray(manifest.skills);
224
253
  for (const rel of manifestSkills) {
@@ -227,8 +256,8 @@ export function pluginAssets(root, name) {
227
256
  skillPaths.push(contained);
228
257
  }
229
258
  if (skillPaths.length === 0 && manifestSkills.length === 0) {
230
- const rootSkill = join(root, "SKILL.md");
231
- if (isFile(rootSkill))
259
+ const rootSkill = containedExistingPath(root, "SKILL.md");
260
+ if (rootSkill && isFile(rootSkill))
232
261
  skillPaths.push(rootSkill);
233
262
  }
234
263
  const componentPaths = (manifestValue, defaultDir) => {
@@ -242,14 +271,62 @@ export function pluginAssets(root, name) {
242
271
  }
243
272
  return out;
244
273
  }
245
- const def = join(root, defaultDir);
246
- return isDirectory(def) ? [def] : [];
274
+ const def = containedExistingPath(root, defaultDir);
275
+ return def && isDirectory(def) ? [def] : [];
247
276
  };
248
277
  const commandPaths = componentPaths(manifest.commands, "commands");
249
278
  const agentDirs = componentPaths(manifest.agents, "agents").filter(isDirectory);
250
- if (skillPaths.length === 0 && commandPaths.length === 0 && agentDirs.length === 0)
279
+ const mcp = pluginMcp(root, manifest, manifestPath);
280
+ const unsupported = [];
281
+ if (manifest.hooks !== undefined || isDirectory(join(root, "hooks")) || isFile(join(root, "hooks.json")))
282
+ unsupported.push("hooks");
283
+ if (manifest.lspServers !== undefined || isFile(join(root, ".lsp.json")))
284
+ unsupported.push("lsp");
285
+ if (manifest.outputStyles !== undefined)
286
+ unsupported.push("output styles");
287
+ if (skillPaths.length === 0 && commandPaths.length === 0 && agentDirs.length === 0 && !mcp)
251
288
  return null;
252
- return { name, root, skillPaths, commandPaths, agentDirs };
289
+ const version = meta.version ?? (typeof manifest.version === "string" ? manifest.version : null);
290
+ return {
291
+ name,
292
+ key: meta.key ?? name,
293
+ origin: meta.origin ?? "claude-code",
294
+ version,
295
+ root,
296
+ skillPaths,
297
+ commandPaths,
298
+ agentDirs,
299
+ mcp,
300
+ unsupported,
301
+ };
302
+ }
303
+ /**
304
+ * A plugin's MCP servers, per Claude Code's rules: the manifest `mcpServers`
305
+ * field (an inline map, or a contained path to a JSON file with one), else
306
+ * `.mcp.json` at the plugin root. Malformed → null (fail-soft).
307
+ */
308
+ function pluginMcp(root, manifest, manifestPath) {
309
+ const field = manifest.mcpServers;
310
+ if (field && typeof field === "object" && !Array.isArray(field)) {
311
+ return { sourcePath: manifestPath, servers: field };
312
+ }
313
+ const candidates = [];
314
+ if (typeof field === "string") {
315
+ const contained = containedExistingPath(root, field);
316
+ if (contained && isFile(contained))
317
+ candidates.push(contained);
318
+ }
319
+ const rootFile = containedExistingPath(root, ".mcp.json");
320
+ if (rootFile && isFile(rootFile))
321
+ candidates.push(rootFile);
322
+ for (const path of candidates) {
323
+ const parsed = readJsonObject(path);
324
+ const servers = parsed?.mcpServers;
325
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
326
+ return { sourcePath: path, servers: servers };
327
+ }
328
+ }
329
+ return null;
253
330
  }
254
331
  function resolveInstalledRoot(key, installed, marketplaces, realCwd) {
255
332
  const entries = (installed.get(key) ?? []).filter((e) => isDirectory(e.installPath));
@@ -295,14 +372,40 @@ export function discoverClaudePlugins(deps) {
295
372
  const user = [];
296
373
  const project = [];
297
374
  const seen = new Set();
375
+ // YAGNI-installed plugins are the user's own explicit action (`yagni
376
+ // plugin install`), so both scopes load without ceremony; a project-scope
377
+ // install applies only inside its repo.
378
+ for (const [key, entries] of Object.entries(deps.yagniLedger?.plugins ?? {})) {
379
+ const applicable = entries.filter((e) => {
380
+ if (!e.enabled || !isDirectory(e.installPath))
381
+ return false;
382
+ if (e.scope === "user")
383
+ return true;
384
+ const real = typeof e.projectPath === "string" ? realOrNull(e.projectPath) : null;
385
+ return !!real && (realCwd === real || realCwd.startsWith(real + sep));
386
+ });
387
+ const chosen = applicable.find((e) => e.scope === "project") ?? applicable[0];
388
+ if (!chosen)
389
+ continue;
390
+ const at = key.lastIndexOf("@");
391
+ const assets = pluginAssets(chosen.installPath, at > 0 ? key.slice(0, at) : key, {
392
+ key,
393
+ origin: "yagni",
394
+ version: chosen.version,
395
+ });
396
+ if (!assets)
397
+ continue;
398
+ seen.add(key);
399
+ user.push(assets);
400
+ }
298
401
  for (const [key, state] of enabled) {
299
- if (!state.enabled)
402
+ if (!state.enabled || seen.has(key))
300
403
  continue;
301
404
  const root = resolveInstalledRoot(key, installed, marketplaces, realCwd);
302
405
  if (!root)
303
406
  continue;
304
407
  const at = key.lastIndexOf("@");
305
- const assets = pluginAssets(root, at > 0 ? key.slice(0, at) : key);
408
+ const assets = pluginAssets(root, at > 0 ? key.slice(0, at) : key, { key, origin: "claude-code" });
306
409
  if (!assets)
307
410
  continue;
308
411
  seen.add(key);
@@ -322,7 +425,12 @@ export function discoverClaudePlugins(deps) {
322
425
  resolveInstalledRoot(key, installed, marketplaces, realCwd);
323
426
  if (!root)
324
427
  continue;
325
- const assets = pluginAssets(root, entry.name);
428
+ const declared = entry.version;
429
+ const assets = pluginAssets(root, entry.name, {
430
+ key,
431
+ origin: "repo-marketplace",
432
+ version: typeof declared === "string" ? declared : null,
433
+ });
326
434
  if (!assets)
327
435
  continue;
328
436
  seen.add(key);
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { DISTRIBUTION } from "./distribution.js";
24
24
  import { connectCommand } from "./connectClaudeCode.js";
25
25
  import { goCommand } from "./goHeadless.js";
26
26
  import { mcpCommand } from "./mcpCommand.js";
27
+ import { pluginCommand } from "./pluginCommand.js";
27
28
  import { login } from "./login.js";
28
29
  import { logout } from "./logout.js";
29
30
  import { tokenCommand } from "./token.js";
@@ -520,6 +521,10 @@ export const HELP_TEXT = [
520
521
  " add-from-claude, and more — run `yagni mcp` for",
521
522
  " details. OAuth servers authenticate via the",
522
523
  " /mcp panel in a session.",
524
+ " yagni plugin <subcommand> Claude Code plugins from a marketplace: marketplace",
525
+ " add, install, uninstall, update, list — run",
526
+ " `yagni plugin` for details. Skills, commands,",
527
+ " agents, and bundled MCP servers load on launch.",
523
528
  " yagni token Output the active environment's API token (for helpers).",
524
529
  " yagni use <name> Switch the active environment (sticky).",
525
530
  " Presets: prod, local. Others need --base-url <url>.",
@@ -665,6 +670,11 @@ export async function main(argv) {
665
670
  if (command === "mcp") {
666
671
  return mcpCommand(rest);
667
672
  }
673
+ // Claude Code plugin marketplaces: install/remove/update from YAGNI itself
674
+ // (state under ~/.yagni-code/plugins; Claude Code's own installs still mirror).
675
+ if (command === "plugin") {
676
+ return pluginCommand(rest);
677
+ }
668
678
  // The headless pipeline entry. `go` is a real subcommand, not passthrough:
669
679
  // the interactive run stays `/go` inside a session, and a `yagni go` without
670
680
  // --headless is refused with usage rather than launched as an agent prompt.
package/dist/doctor.js CHANGED
@@ -422,7 +422,7 @@ async function defaultProbeMcp(env = process.env) {
422
422
  const repoRoot = mod.resolveProjectRoot(process.cwd());
423
423
  const { state } = mod.readProjectApproval(repoRoot);
424
424
  const undecided = loaded.servers
425
- .filter((s) => s.scope === "project" && mod.decisionFor(state, s.name) === "undecided")
425
+ .filter((s) => (s.scope === "project" || s.gated === true) && mod.decisionFor(state, s.name) === "undecided")
426
426
  .map((s) => s.name);
427
427
  return {
428
428
  disabled: false,
@@ -4,7 +4,7 @@
4
4
  * from its bundled copy). Pure config I/O — no pi imports, no TUI, no network —
5
5
  * so importing it from the launcher is side-effect-free.
6
6
  */
7
- export { McpServerConfig, McpScope, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
7
+ export { McpServerConfig, McpScope, PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
8
8
  export { ProjectApprovalState, decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
9
9
  export { McpAuthFile, StoredOAuthEntry, deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
10
10
  export { revokeTokensOnRemove } from "./auth.js";
@@ -4,7 +4,7 @@
4
4
  * from its bundled copy). Pure config I/O — no pi imports, no TUI, no network —
5
5
  * so importing it from the launcher is side-effect-free.
6
6
  */
7
- export { PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
7
+ export { PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
8
8
  export { decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
9
9
  export { deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
10
10
  export { revokeTokensOnRemove } from "./auth.js";
@@ -6,13 +6,18 @@
6
6
  * A repo configured for Claude Code works here with zero changes.
7
7
  * - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
8
8
  * - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
9
+ * - plugin: servers bundled in Claude Code plugins (`.mcp.json` at the plugin
10
+ * root), handed over by the yagni launcher as JSON in
11
+ * `YAGNI_CLAUDE_PLUGIN_MCP` (see yagni-code-cli claudeCompat.ts). Entries
12
+ * marked `gated` came from repo config and ride the project approval gate.
9
13
  *
10
- * Merge precedence (Claude Code parity): user < project < local — later wins.
14
+ * Merge precedence (Claude Code parity): plugin < user < project < local —
15
+ * later wins, so an explicit user entry beats a plugin's.
11
16
  * Writes are atomic (temp file + rename) so two concurrent sessions can never
12
17
  * interleave into a corrupt config. Validation errors are collected and
13
18
  * surfaced, never fatal: one malformed server entry must not hide the rest.
14
19
  */
15
- export type McpScope = "user" | "project" | "local";
20
+ export type McpScope = "user" | "project" | "local" | "plugin";
16
21
  export interface McpStdioServerConfig {
17
22
  type?: "stdio";
18
23
  command: string;
@@ -39,7 +44,24 @@ export interface ScopedMcpServerConfig {
39
44
  scope: McpScope;
40
45
  /** Where this entry came from (file path) — surfaced in /mcp and `yagni mcp get`. */
41
46
  sourcePath: string;
47
+ /** `name@marketplace` of the plugin that bundles this server (plugin scope). */
48
+ plugin?: string;
49
+ /** Plugin server that came from repo config: needs the project approval gate. */
50
+ gated?: boolean;
42
51
  }
52
+ /** Launcher → extension: plugin-bundled MCP servers (JSON array). */
53
+ export declare const PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
54
+ export interface PluginMcpEnvEntry {
55
+ plugin: string;
56
+ sourcePath: string;
57
+ gated: boolean;
58
+ servers: Record<string, unknown>;
59
+ }
60
+ /** Parse the launcher's plugin MCP payload; malformed input degrades to errors, never a throw. */
61
+ export declare function readPluginMcpServers(env?: NodeJS.ProcessEnv): {
62
+ servers: ScopedMcpServerConfig[];
63
+ errors: McpConfigError[];
64
+ };
43
65
  export interface McpConfigError {
44
66
  /** File the problem was found in. */
45
67
  sourcePath: string;
@@ -6,8 +6,13 @@
6
6
  * A repo configured for Claude Code works here with zero changes.
7
7
  * - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
8
8
  * - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
9
+ * - plugin: servers bundled in Claude Code plugins (`.mcp.json` at the plugin
10
+ * root), handed over by the yagni launcher as JSON in
11
+ * `YAGNI_CLAUDE_PLUGIN_MCP` (see yagni-code-cli claudeCompat.ts). Entries
12
+ * marked `gated` came from repo config and ride the project approval gate.
9
13
  *
10
- * Merge precedence (Claude Code parity): user < project < local — later wins.
14
+ * Merge precedence (Claude Code parity): plugin < user < project < local —
15
+ * later wins, so an explicit user entry beats a plugin's.
11
16
  * Writes are atomic (temp file + rename) so two concurrent sessions can never
12
17
  * interleave into a corrupt config. Validation errors are collected and
13
18
  * surfaced, never fatal: one malformed server entry must not hide the rest.
@@ -15,6 +20,70 @@
15
20
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
21
  import { dirname, join } from "node:path";
17
22
  import { codeStateHome } from "../stateHome.js";
23
+ /** Launcher → extension: plugin-bundled MCP servers (JSON array). */
24
+ export const PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
25
+ const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
26
+ /** Parse the launcher's plugin MCP payload; malformed input degrades to errors, never a throw. */
27
+ export function readPluginMcpServers(env = process.env) {
28
+ const disabled = env[CLAUDE_COMPAT_DISABLE_ENV];
29
+ if (disabled !== undefined && disabled !== "" && disabled !== "0")
30
+ return { servers: [], errors: [] };
31
+ const raw = env[PLUGIN_MCP_ENV];
32
+ if (!raw)
33
+ return { servers: [], errors: [] };
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch (err) {
39
+ return {
40
+ servers: [],
41
+ errors: [{ sourcePath: PLUGIN_MCP_ENV, message: `could not parse plugin MCP payload (${err instanceof Error ? err.message : String(err)})` }],
42
+ };
43
+ }
44
+ if (!Array.isArray(parsed))
45
+ return { servers: [], errors: [{ sourcePath: PLUGIN_MCP_ENV, message: "plugin MCP payload is not an array" }] };
46
+ const servers = [];
47
+ const errors = [];
48
+ const seen = new Map();
49
+ for (const item of parsed) {
50
+ if (!item || typeof item !== "object")
51
+ continue;
52
+ const entry = item;
53
+ if (typeof entry.plugin !== "string" || !entry.servers || typeof entry.servers !== "object")
54
+ continue;
55
+ const sourcePath = typeof entry.sourcePath === "string" ? entry.sourcePath : entry.plugin;
56
+ for (const [name, value] of Object.entries(entry.servers)) {
57
+ const validation = validateServerConfig(value);
58
+ if (!validation.ok) {
59
+ errors.push({ sourcePath, serverName: name, message: `${validation.message} (plugin ${entry.plugin})` });
60
+ continue;
61
+ }
62
+ const owner = seen.get(name);
63
+ if (owner) {
64
+ errors.push({
65
+ sourcePath,
66
+ serverName: name,
67
+ message: `also defined by plugin ${owner} — keeping that one, skipping ${entry.plugin}'s`,
68
+ });
69
+ continue;
70
+ }
71
+ const config = value;
72
+ const expanded = expandServerEnv(config, env);
73
+ if (expanded.missingVars.length > 0) {
74
+ errors.push({
75
+ sourcePath,
76
+ serverName: name,
77
+ message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
78
+ });
79
+ continue;
80
+ }
81
+ seen.set(name, entry.plugin);
82
+ servers.push({ name, config, scope: "plugin", sourcePath, plugin: entry.plugin, gated: entry.gated === true });
83
+ }
84
+ }
85
+ return { servers, errors };
86
+ }
18
87
  export const PROJECT_CONFIG_FILENAME = ".mcp.json";
19
88
  /**
20
89
  * Expand `${VAR}` / `${VAR:-default}` in a config string (Claude Code parity —
@@ -239,7 +308,10 @@ function defaultExists(p) {
239
308
  export function loadMcpServers(cwd, env = process.env) {
240
309
  const repoRoot = resolveProjectRoot(cwd);
241
310
  const errors = [];
242
- const byScope = { user: [], project: [], local: [] };
311
+ const byScope = { plugin: [], user: [], project: [], local: [] };
312
+ const pluginLoad = readPluginMcpServers(env);
313
+ errors.push(...pluginLoad.errors);
314
+ byScope.plugin.push(...pluginLoad.servers);
243
315
  const { file: userFile, errors: userErrors } = readUserMcpConfig();
244
316
  errors.push(...userErrors);
245
317
  const userServers = userFile.mcpServers ?? {};
@@ -300,7 +372,7 @@ export function loadMcpServers(cwd, env = process.env) {
300
372
  }
301
373
  }
302
374
  const merged = new Map();
303
- for (const scope of ["user", "project", "local"]) {
375
+ for (const scope of ["plugin", "user", "project", "local"]) {
304
376
  for (const server of byScope[scope])
305
377
  merged.set(server.name, server);
306
378
  }