@yagni-app/code 1.0.6 → 1.0.7

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.
Files changed (85) hide show
  1. package/README.md +84 -46
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -5
  5. package/dist/claudePlugins.js +129 -21
  6. package/dist/cli.js +16 -4
  7. package/dist/doctor.d.ts +21 -8
  8. package/dist/doctor.js +53 -28
  9. package/dist/extension/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +13 -0
  12. package/dist/extension/index.js +230 -43
  13. package/dist/extension/mcp/cliConfig.d.ts +1 -1
  14. package/dist/extension/mcp/cliConfig.js +1 -1
  15. package/dist/extension/mcp/config.d.ts +24 -2
  16. package/dist/extension/mcp/config.js +75 -3
  17. package/dist/extension/mcp/manager.d.ts +3 -1
  18. package/dist/extension/mcp/manager.js +2 -2
  19. package/dist/extension/mcp/panel.d.ts +0 -1
  20. package/dist/extension/mcp/panel.js +13 -3
  21. package/dist/extension/mcp/startup.js +8 -6
  22. package/dist/extension/permission/gate.d.ts +97 -2
  23. package/dist/extension/permission/gate.js +375 -26
  24. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  25. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  26. package/dist/extension/permissionRules/engine.d.ts +50 -0
  27. package/dist/extension/permissionRules/engine.js +238 -0
  28. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  29. package/dist/extension/permissionRules/loadConfig.js +90 -0
  30. package/dist/extension/permissionRules/parser.d.ts +38 -0
  31. package/dist/extension/permissionRules/parser.js +136 -0
  32. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  33. package/dist/extension/permissionRules/pathRules.js +120 -0
  34. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  35. package/dist/extension/permissionRules/shellRules.js +221 -0
  36. package/dist/extension/pipeline/invocation.d.ts +3 -6
  37. package/dist/extension/pipeline/invocation.js +3 -6
  38. package/dist/extension/pipeline/runner.d.ts +0 -1
  39. package/dist/extension/pipeline/runner.js +6 -14
  40. package/dist/extension/plugins/inventory.d.ts +88 -0
  41. package/dist/extension/plugins/inventory.js +144 -0
  42. package/dist/extension/plugins/panel.d.ts +45 -0
  43. package/dist/extension/plugins/panel.js +293 -0
  44. package/dist/extension/sandbox/bash.d.ts +99 -0
  45. package/dist/extension/sandbox/bash.js +190 -0
  46. package/dist/extension/sandbox/config.d.ts +114 -0
  47. package/dist/extension/sandbox/config.js +366 -0
  48. package/dist/extension/sandbox/manager.d.ts +98 -0
  49. package/dist/extension/sandbox/manager.js +216 -0
  50. package/dist/extension/sandbox/panel.d.ts +111 -0
  51. package/dist/extension/sandbox/panel.js +342 -0
  52. package/dist/extension/sandbox/session.d.ts +85 -0
  53. package/dist/extension/sandbox/session.js +775 -0
  54. package/dist/extension/telemetry/attrs.d.ts +96 -0
  55. package/dist/extension/telemetry/attrs.js +149 -0
  56. package/dist/extension/telemetry/config.d.ts +99 -0
  57. package/dist/extension/telemetry/config.js +193 -0
  58. package/dist/extension/telemetry/index.d.ts +7 -0
  59. package/dist/extension/telemetry/index.js +7 -0
  60. package/dist/extension/telemetry/probe.d.ts +29 -0
  61. package/dist/extension/telemetry/probe.js +122 -0
  62. package/dist/extension/telemetry/register.d.ts +40 -0
  63. package/dist/extension/telemetry/register.js +192 -0
  64. package/dist/extension/telemetry/sdk.d.ts +63 -0
  65. package/dist/extension/telemetry/sdk.js +207 -0
  66. package/dist/extension/telemetry/tracker.d.ts +131 -0
  67. package/dist/extension/telemetry/tracker.js +551 -0
  68. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  69. package/dist/extension/vendor/ignore.d.ts +86 -0
  70. package/dist/extension/vendor/ignore.js +788 -0
  71. package/dist/goHeadless.d.ts +1 -1
  72. package/dist/goHeadless.js +2 -2
  73. package/dist/launch.d.ts +4 -3
  74. package/dist/launch.js +7 -4
  75. package/dist/mcpCommand.d.ts +10 -1
  76. package/dist/mcpCommand.js +42 -10
  77. package/dist/otel.d.ts +67 -90
  78. package/dist/otel.js +152 -195
  79. package/dist/paths.d.ts +13 -0
  80. package/dist/paths.js +18 -0
  81. package/dist/pluginCommand.d.ts +43 -0
  82. package/dist/pluginCommand.js +499 -0
  83. package/dist/pluginStore.d.ts +170 -0
  84. package/dist/pluginStore.js +554 -0
  85. package/package.json +19 -3
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { Text } from "@earendil-works/pi-tui";
3
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
6
  import { makeChildUsageState } from "./childUsage.js";
@@ -34,7 +36,12 @@ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNot
34
36
  import { registerGoCommand } from "./pipeline/goCommand.js";
35
37
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
36
38
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
39
+ import { registerSandbox } from "./sandbox/session.js";
40
+ import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
41
+ import { registerTelemetry } from "./telemetry/register.js";
37
42
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
43
+ import { loadPermissionRules } from "./permissionRules/loadConfig.js";
44
+ import { registerPluginPanel } from "./plugins/panel.js";
38
45
  import { registerSubagents } from "./subagents.js";
39
46
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
40
47
  import { registerTodos } from "./todos.js";
@@ -99,6 +106,39 @@ export function parseSpendResponse(data) {
99
106
  return null;
100
107
  return d;
101
108
  }
109
+ /**
110
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
111
+ * always on so e2e and support can see what the gate did even when the
112
+ * storage tier is off. Decision K: the always-on line carries outcome
113
+ * metadata ONLY — no command content. The command prefix and the exec
114
+ * justification are command/model content and ride the debug tier (never
115
+ * uploaded by /feedback) plus the opt-in storage stream.
116
+ */
117
+ export function logGateOutcomeTrail(ev, env) {
118
+ logEvent({
119
+ source: "guardian",
120
+ level: "info",
121
+ event: "gate_outcome",
122
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
123
+ fields: {
124
+ outcome: ev.outcome,
125
+ mode: ev.mode,
126
+ consulted: ev.consulted,
127
+ },
128
+ });
129
+ if (isDebug(env)) {
130
+ logEvent({
131
+ source: "guardian",
132
+ level: "debug",
133
+ event: "gate_outcome",
134
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
135
+ fields: {
136
+ commandPrefix: storagePrefix(ev.command),
137
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
138
+ },
139
+ });
140
+ }
141
+ }
102
142
  export async function registerYagni(pi, deps = {}) {
103
143
  const baseUrl = deps.baseUrl ?? resolveBaseUrl();
104
144
  const now = deps.now ?? (() => Date.now());
@@ -239,6 +279,9 @@ export async function registerYagni(pi, deps = {}) {
239
279
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
240
280
  const ultraHolder = createUltraHolder();
241
281
  registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
282
+ // /plugin + /reload-plugins: manage Claude Code marketplace plugins from the
283
+ // session, driving the launcher's `yagni plugin …` (YAGNI_CLI_PATH).
284
+ registerPluginPanel(pi, { env: deps.env, runCli: deps.runPluginCli });
242
285
  registerUltraCommand(pi, ultraHolder);
243
286
  // Shared fetch timeout for the small, interactive display-path reads below
244
287
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -334,6 +377,8 @@ export async function registerYagni(pi, deps = {}) {
334
377
  // The static queue only carries non-connectivity notices (e.g. the
335
378
  // `.mcp.json` approval prompt), which stay one-shot.
336
379
  let connectivityNoticesFlushed = false;
380
+ // permission-rule notices are one-shot like connectivity notices.
381
+ let rulesNoticesFlushed = false;
337
382
  pi.on("after_provider_response", (_event, ctx) => {
338
383
  try {
339
384
  if (!ctx.hasUI)
@@ -347,6 +392,21 @@ export async function registerYagni(pi, deps = {}) {
347
392
  while (mcpApprovalNotices.length > 0) {
348
393
  ctx.ui.notify(mcpApprovalNotices.shift(), "info");
349
394
  }
395
+ // one-shot permission-rule config warnings (bad files,
396
+ // never-consulted tools). One consolidated line, never a wall.
397
+ if (!rulesNoticesFlushed) {
398
+ rulesNoticesFlushed = true;
399
+ const parts = [];
400
+ if (loadedRules.diagnostics.warnings.length > 0) {
401
+ parts.push(`${loadedRules.diagnostics.warnings.length} permission-rule config warning(s)`);
402
+ }
403
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
404
+ parts.push(`rules for unknown tools: ${loadedRules.diagnostics.neverConsultedTools.join(", ")} (ignored)`);
405
+ }
406
+ if (parts.length > 0) {
407
+ ctx.ui.notify(`Permission rules: ${parts.join(" · ")}`, "warning");
408
+ }
409
+ }
350
410
  }
351
411
  catch {
352
412
  // A notice must never break a turn.
@@ -359,6 +419,18 @@ export async function registerYagni(pi, deps = {}) {
359
419
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
360
420
  // holds them, review mode confirms them.
361
421
  const modeHolder = createModeHolder();
422
+ // OTel export (traces, metrics, log events; Claude Code parity). A no-op
423
+ // unless the launcher's gate set YAGNI_OTEL_EXPORT=1 — see the CLI's
424
+ // otel.ts for the sources and src/telemetry for the exporter.
425
+ const telemetry = registerTelemetry(pi, { env });
426
+ {
427
+ let previousMode = modeHolder.get();
428
+ modeHolder.onSet((m) => {
429
+ if (m !== previousMode)
430
+ telemetry.permissionModeChanged(previousMode, m);
431
+ previousMode = m;
432
+ });
433
+ }
362
434
  const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
363
435
  const guardianState = makeGuardianState();
364
436
  // Disabled by the local env override OR the workspace kill switch
@@ -408,6 +480,25 @@ export async function registerYagni(pi, deps = {}) {
408
480
  // the startup load is the trust boundary; live reload was reviewed and
409
481
  // rejected as a same-session self-authorization path, PR #1698).
410
482
  const sessionGrants = evalMode ? [] : loadGrants();
483
+ // settings-based permission rules, loaded once at startup (same
484
+ // trust-boundary posture as grants: no mid-session reload). User config
485
+ // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
486
+ // project allow rules are trust-gated at evaluation time, deny/ask always.
487
+ const loadedRules = evalMode
488
+ ? { rules: [], diagnostics: { warnings: [], neverConsultedTools: [] } }
489
+ : loadPermissionRules({ cwd: process.cwd() });
490
+ for (const w of loadedRules.diagnostics.warnings) {
491
+ logEvent({ source: "permission-rules", level: "warn", event: "config_warning", fields: { warning: w } });
492
+ }
493
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
494
+ logEvent({
495
+ source: "permission-rules",
496
+ level: "warn",
497
+ event: "never_consulted_tools",
498
+ fields: { tools: loadedRules.diagnostics.neverConsultedTools.join(", ") },
499
+ });
500
+ }
501
+ const rulesStateHome = codeStateHome(null, env);
411
502
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
412
503
  // YAG-506: load user-configurable lifecycle hooks config and create the
413
504
  // hook runner for the permission gate. Skipped in eval mode.
@@ -415,11 +506,56 @@ export async function registerYagni(pi, deps = {}) {
415
506
  const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
416
507
  if (!evalMode)
417
508
  registerHooks(pi, { config: hooksConfig });
509
+ // OS-level bash sandboxing. Registers the sandbox-aware bash tool
510
+ // (dangerouslyDisableSandbox param + wrap + violation annotation) and the
511
+ // session lifecycle (init/reset, fail-soft unless failIfUnavailable).
512
+ // Null on unsupported platforms — stock bash stays. Shares the loaded
513
+ // permission rules so sandbox config and the gate read one rule source.
514
+ const sandboxHandle = evalMode
515
+ ? null
516
+ : registerSandbox(pi, {
517
+ cwd: process.cwd(),
518
+ env,
519
+ hasUI: (ctx) => ctx.hasUI,
520
+ // Anchors project-protected paths (.yagni-code + its config.json in
521
+ // denyWrite) and project-sourced permission rules to the repo the
522
+ // session runs in — same root the gate uses for its rule anchoring.
523
+ projectRoot: process.cwd(),
524
+ });
525
+ if (sandboxHandle)
526
+ sandboxHandle.setRules(loadedRules.rules);
418
527
  registerPermissionGate(pi, {
528
+ ...(sandboxHandle
529
+ ? {
530
+ sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
531
+ }
532
+ : {}),
419
533
  modeHolder,
420
534
  guardianState,
421
535
  guardianLimits,
422
536
  guardianTier,
537
+ onToolDecision: (ev) => telemetry.toolDecision(ev),
538
+ ...(loadedRules.rules.length > 0
539
+ ? {
540
+ permissionRules: loadedRules.rules,
541
+ rulesUserStateHome: rulesStateHome,
542
+ rulesProjectRoot: process.cwd(),
543
+ onRuleVerdict: (ev) => {
544
+ logEvent({
545
+ source: "permission-rules",
546
+ level: "debug",
547
+ event: `rule_${ev.verdict}`,
548
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
549
+ fields: {
550
+ tool: ev.toolName,
551
+ via: ev.matchedVia,
552
+ source: ev.rule.source,
553
+ rule: ev.rule.raw,
554
+ },
555
+ });
556
+ },
557
+ }
558
+ : {}),
423
559
  guardianDisabled,
424
560
  childUsage,
425
561
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
@@ -471,57 +607,95 @@ export async function registerYagni(pi, deps = {}) {
471
607
  if (!evalMode)
472
608
  appendGrant(grant);
473
609
  },
610
+ // persist a user-level allow rule (Guardian ask dialog's third
611
+ // option). Atomic write, never overwrites other keys, fail-soft.
612
+ persistUserRule: (ruleString) => {
613
+ if (evalMode)
614
+ return;
615
+ try {
616
+ const userPath = join(rulesStateHome, "config.json");
617
+ let parsed = {};
618
+ if (existsSync(userPath)) {
619
+ parsed = JSON.parse(readFileSync(userPath, "utf-8"));
620
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
621
+ return;
622
+ }
623
+ const perms = (parsed.permissions ?? {});
624
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
625
+ if (!allow.includes(ruleString))
626
+ allow.push(ruleString);
627
+ perms.allow = allow;
628
+ parsed.permissions = perms;
629
+ const tmp = join(rulesStateHome, `.config.json.yagni-${process.pid}-${Date.now()}.tmp`);
630
+ writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
631
+ renameSync(tmp, userPath);
632
+ logEvent({
633
+ source: "permission-rules",
634
+ level: "info",
635
+ event: "user_rule_saved",
636
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
637
+ fields: { rule: ruleString },
638
+ });
639
+ }
640
+ catch (err) {
641
+ logEvent({
642
+ source: "permission-rules",
643
+ level: "warn",
644
+ event: "user_rule_save_failed",
645
+ fields: { message: err instanceof Error ? err.message : "unknown" },
646
+ });
647
+ }
648
+ },
474
649
  // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
475
650
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
476
651
  // metadata, no command content; "raw" → adds client-REDACTED command and
477
652
  // rationale. Fire-and-forget: one attempt, short timeout, failures logged
478
653
  // fail-soft to the unified sink (source:"guardian") — a storage outage
479
654
  // never touches the session.
480
- onGuardianEvent: guardianStorageTier === "off" || evalMode
481
- ? undefined
482
- : (ev) => {
483
- void (async () => {
484
- try {
485
- const body = {
486
- sessionId: env.YAGNI_SESSION_ID ?? null,
487
- commandHash: createHash("sha256").update(ev.command).digest("hex"),
488
- commandPrefix: storagePrefix(ev.command),
489
- outcome: ev.outcome,
490
- mode: ev.mode,
491
- ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
492
- ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
493
- ...(ev.tier ? { tier: ev.tier } : {}),
494
- ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
495
- };
496
- if (guardianStorageTier === "raw") {
497
- body.command = redactCommand(ev.command);
498
- if (ev.rationale)
499
- body.rationale = redactCommand(ev.rationale);
500
- }
501
- const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
502
- method: "POST",
503
- headers: {
504
- "content-type": "application/json",
505
- authorization: `Bearer ${getTokenFn() ?? ""}`,
506
- ...attributionHeaders(deps.env),
507
- },
508
- body: JSON.stringify(body),
509
- }, {
510
- fetchImpl: authedFetch,
511
- policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
512
- });
513
- if (!res.ok) {
514
- guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
515
- }
655
+ onGuardianEvent: (ev) => {
656
+ logGateOutcomeTrail(ev, env);
657
+ // Opt-in storage stream (YAG-510): tier decides what leaves the machine.
658
+ if (guardianStorageTier === "off" || evalMode)
659
+ return;
660
+ void (async () => {
661
+ try {
662
+ const body = {
663
+ sessionId: env.YAGNI_SESSION_ID ?? null,
664
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
665
+ commandPrefix: storagePrefix(ev.command),
666
+ outcome: ev.outcome,
667
+ mode: ev.mode,
668
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
669
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
670
+ ...(ev.tier ? { tier: ev.tier } : {}),
671
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
672
+ };
673
+ if (guardianStorageTier === "raw") {
674
+ body.command = redactCommand(ev.command);
675
+ if (ev.rationale)
676
+ body.rationale = redactCommand(ev.rationale);
516
677
  }
517
- catch {
518
- guardianLogSink({
519
- event: "guardian_event_post_failed",
520
- kind: "network",
521
- });
678
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
679
+ method: "POST",
680
+ headers: {
681
+ "content-type": "application/json",
682
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
683
+ ...attributionHeaders(deps.env),
684
+ },
685
+ body: JSON.stringify(body),
686
+ }, {
687
+ fetchImpl: authedFetch,
688
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
689
+ });
690
+ if (!res.ok) {
691
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
522
692
  }
523
- })();
524
- },
693
+ }
694
+ catch {
695
+ guardianLogSink({ event: "guardian_event_post_failed", kind: "network" });
696
+ }
697
+ })();
698
+ },
525
699
  ...(mcpMutatingSet.size > 0 || mcpOutcome.manager
526
700
  ? {
527
701
  policy: {
@@ -753,12 +927,25 @@ export async function registerYagni(pi, deps = {}) {
753
927
  try {
754
928
  registerCondensedTools(pi, {
755
929
  ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
930
+ // condensed owns the FINAL bash registration (it re-registers
931
+ // built-ins last), so the sandbox composes INTO it here. Identity when
932
+ // the sandbox is off.
933
+ ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
756
934
  });
757
935
  }
758
936
  catch {
759
937
  // Rendering must never break activation; pi's built-ins remain.
760
938
  }
761
939
  }
940
+ else if (sandboxHandle && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
941
+ // Condensed inactive (eval mode / desktop): the sandbox's own
942
+ // registration is the one that reaches the tool registry. NOT under the
943
+ // classic-rows escape hatch — classic bash must stay pi's stock
944
+ // registration (the hatch's contract); a competing one would replace
945
+ // classic rendering with the sandbox's wrap. Desktop/eval still need
946
+ // the sandbox to reach bash, so the hatch is the one carve-out.
947
+ sandboxHandle.registerOwnBash();
948
+ }
762
949
  // Own the identity + inject live company context (and repo rules) on every
763
950
  // turn. The extension loads identically in every pi process this app spawns —
764
951
  // the interactive driver AND every `/go` stage child, subagent, and advisor
@@ -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
  }
@@ -20,6 +20,8 @@ export interface ManagedServer {
20
20
  scope: McpScope;
21
21
  config: McpStdioServerConfig | McpHttpServerConfig;
22
22
  status: McpServerStatus;
23
+ /** Plugin server sourced from repo config: rides the project approval gate. */
24
+ gated?: boolean;
23
25
  /** Failure detail for the failed/needs_auth states (panel + logs). */
24
26
  error?: string;
25
27
  client?: Client;
@@ -51,7 +53,7 @@ export declare class McpManager {
51
53
  list(): ManagedServer[];
52
54
  get(name: string): ManagedServer | undefined;
53
55
  /** Registers a server without connecting (panel listing, kill-switch states). */
54
- register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus): ManagedServer;
56
+ register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus, gated?: boolean): ManagedServer;
55
57
  connect(name: string): Promise<ManagedServer | undefined>;
56
58
  disconnect(name: string): Promise<void>;
57
59
  /** Reconnect with bounded attempts, panel-visible progress. */
@@ -47,11 +47,11 @@ export class McpManager {
47
47
  return this.servers.get(name);
48
48
  }
49
49
  /** Registers a server without connecting (panel listing, kill-switch states). */
50
- register(name, scope, config, status = "disabled") {
50
+ register(name, scope, config, status = "disabled", gated = false) {
51
51
  const existing = this.servers.get(name);
52
52
  if (existing)
53
53
  return existing;
54
- const server = { name, scope, config, status };
54
+ const server = { name, scope, config, status, ...(gated ? { gated } : {}) };
55
55
  this.servers.set(name, server);
56
56
  return server;
57
57
  }
@@ -21,7 +21,6 @@ export interface PanelServerInfo {
21
21
  }
22
22
  export declare function statusIcon(status: string): string;
23
23
  export declare function statusText(server: ManagedServer): string;
24
- /** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
25
24
  export declare function renderPanelListing(servers: ManagedServer[], errors: string[]): string;
26
25
  export interface PanelDeps {
27
26
  cwd: string;
@@ -10,6 +10,7 @@
10
10
  * a `disabledMcpServers` list under projects[absPath]). A disable/enable
11
11
  * toggle reconnects or disconnects the live client and refreshes tools.
12
12
  */
13
+ import { recordProjectDecision } from "./approval.js";
13
14
  import { readUserMcpConfig, resolveProjectRoot, writeUserMcpConfig } from "./config.js";
14
15
  import { registerServerPrompts } from "./prompts.js";
15
16
  import { registerServerTools } from "./tools.js";
@@ -39,6 +40,7 @@ export function statusText(server) {
39
40
  return STATUS_TEXT[server.status] ?? server.status;
40
41
  }
41
42
  /** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
43
+ const SCOPE_LABELS = { local: "Local", project: "Project", user: "User", plugin: "Plugin" };
42
44
  export function renderPanelListing(servers, errors) {
43
45
  if (servers.length === 0 && errors.length === 0) {
44
46
  return [
@@ -47,14 +49,14 @@ export function renderPanelListing(servers, errors) {
47
49
  ].join("\n");
48
50
  }
49
51
  const lines = [];
50
- const byScope = { user: [], project: [], local: [] };
52
+ const byScope = { user: [], project: [], local: [], plugin: [] };
51
53
  for (const s of servers)
52
54
  (byScope[s.scope] ??= []).push(s);
53
- for (const scope of ["local", "project", "user"]) {
55
+ for (const scope of ["local", "project", "user", "plugin"]) {
54
56
  const group = byScope[scope];
55
57
  if (!group?.length)
56
58
  continue;
57
- lines.push(`${scope === "local" ? "Local" : scope === "project" ? "Project" : "User"} servers:`);
59
+ lines.push(`${SCOPE_LABELS[scope]} servers:`);
58
60
  for (const s of group) {
59
61
  lines.push(` ${s.name} · ${statusIcon(s.status)} ${statusText(s)}${s.error ? ` — ${s.error}` : ""}`);
60
62
  }
@@ -163,13 +165,21 @@ export function registerMcpPanel(pi, manager, configErrors) {
163
165
  ui.notify(`No MCP server named "${name}".`, "error");
164
166
  continue;
165
167
  }
168
+ // Repo-sourced servers (.mcp.json, or a repo plugin's bundle) carry an
169
+ // approval decision that must outlive this session — record it, so the
170
+ // startup gate stops re-asking.
171
+ const approvalGated = server.scope === "project" || server.gated === true;
166
172
  if (action === "disable") {
167
173
  await manager.disconnect(name);
168
174
  persistDisabled(name, true);
175
+ if (approvalGated)
176
+ recordProjectDecision(repoRoot(), name, "disabled");
169
177
  ui.notify(`○ ${name} disabled`, "info");
170
178
  }
171
179
  else {
172
180
  persistDisabled(name, false);
181
+ if (approvalGated)
182
+ recordProjectDecision(repoRoot(), name, "enabled");
173
183
  const result = await manager.connect(name);
174
184
  if (result?.status === "connected") {
175
185
  await refreshForServer(name);