@yagni-app/code 1.0.5 → 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 (111) hide show
  1. package/README.md +89 -27
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -3
  5. package/dist/claudePlugins.js +129 -19
  6. package/dist/cli.js +28 -4
  7. package/dist/doctor.d.ts +42 -4
  8. package/dist/doctor.js +150 -15
  9. package/dist/extension/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +18 -5
  12. package/dist/extension/index.js +319 -72
  13. package/dist/extension/mcp/approval.d.ts +45 -0
  14. package/dist/extension/mcp/approval.js +164 -0
  15. package/dist/extension/mcp/auth.d.ts +124 -0
  16. package/dist/extension/mcp/auth.js +560 -0
  17. package/dist/extension/mcp/authStore.d.ts +61 -0
  18. package/dist/extension/mcp/authStore.js +105 -0
  19. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  20. package/dist/extension/mcp/callbackPage.js +222 -0
  21. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  22. package/dist/extension/mcp/cliConfig.js +12 -0
  23. package/dist/extension/mcp/config.d.ts +153 -0
  24. package/dist/extension/mcp/config.js +381 -0
  25. package/dist/extension/mcp/log.d.ts +28 -0
  26. package/dist/extension/mcp/log.js +82 -0
  27. package/dist/extension/mcp/manager.d.ts +100 -0
  28. package/dist/extension/mcp/manager.js +273 -0
  29. package/dist/extension/mcp/names.d.ts +25 -0
  30. package/dist/extension/mcp/names.js +40 -0
  31. package/dist/extension/mcp/panel.d.ts +33 -0
  32. package/dist/extension/mcp/panel.js +268 -0
  33. package/dist/extension/mcp/prompts.d.ts +23 -0
  34. package/dist/extension/mcp/prompts.js +93 -0
  35. package/dist/extension/mcp/startup.d.ts +55 -0
  36. package/dist/extension/mcp/startup.js +152 -0
  37. package/dist/extension/mcp/tools.d.ts +31 -0
  38. package/dist/extension/mcp/tools.js +117 -0
  39. package/dist/extension/mcp/transports.d.ts +17 -0
  40. package/dist/extension/mcp/transports.js +44 -0
  41. package/dist/extension/permission/gate.d.ts +104 -2
  42. package/dist/extension/permission/gate.js +387 -31
  43. package/dist/extension/permission/guardian.d.ts +24 -5
  44. package/dist/extension/permission/guardian.js +162 -24
  45. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  46. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  47. package/dist/extension/permissionRules/engine.d.ts +50 -0
  48. package/dist/extension/permissionRules/engine.js +238 -0
  49. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  50. package/dist/extension/permissionRules/loadConfig.js +90 -0
  51. package/dist/extension/permissionRules/parser.d.ts +38 -0
  52. package/dist/extension/permissionRules/parser.js +136 -0
  53. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  54. package/dist/extension/permissionRules/pathRules.js +120 -0
  55. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  56. package/dist/extension/permissionRules/shellRules.js +221 -0
  57. package/dist/extension/pipeline/invocation.d.ts +3 -6
  58. package/dist/extension/pipeline/invocation.js +3 -6
  59. package/dist/extension/pipeline/personas.js +5 -0
  60. package/dist/extension/pipeline/runner.d.ts +0 -1
  61. package/dist/extension/pipeline/runner.js +6 -14
  62. package/dist/extension/plugins/inventory.d.ts +88 -0
  63. package/dist/extension/plugins/inventory.js +144 -0
  64. package/dist/extension/plugins/panel.d.ts +45 -0
  65. package/dist/extension/plugins/panel.js +293 -0
  66. package/dist/extension/sandbox/bash.d.ts +99 -0
  67. package/dist/extension/sandbox/bash.js +190 -0
  68. package/dist/extension/sandbox/config.d.ts +114 -0
  69. package/dist/extension/sandbox/config.js +366 -0
  70. package/dist/extension/sandbox/manager.d.ts +98 -0
  71. package/dist/extension/sandbox/manager.js +216 -0
  72. package/dist/extension/sandbox/panel.d.ts +111 -0
  73. package/dist/extension/sandbox/panel.js +342 -0
  74. package/dist/extension/sandbox/session.d.ts +85 -0
  75. package/dist/extension/sandbox/session.js +775 -0
  76. package/dist/extension/telemetry/attrs.d.ts +96 -0
  77. package/dist/extension/telemetry/attrs.js +149 -0
  78. package/dist/extension/telemetry/config.d.ts +99 -0
  79. package/dist/extension/telemetry/config.js +193 -0
  80. package/dist/extension/telemetry/index.d.ts +7 -0
  81. package/dist/extension/telemetry/index.js +7 -0
  82. package/dist/extension/telemetry/probe.d.ts +29 -0
  83. package/dist/extension/telemetry/probe.js +122 -0
  84. package/dist/extension/telemetry/register.d.ts +40 -0
  85. package/dist/extension/telemetry/register.js +192 -0
  86. package/dist/extension/telemetry/sdk.d.ts +63 -0
  87. package/dist/extension/telemetry/sdk.js +207 -0
  88. package/dist/extension/telemetry/tracker.d.ts +131 -0
  89. package/dist/extension/telemetry/tracker.js +551 -0
  90. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  91. package/dist/extension/vendor/ignore.d.ts +86 -0
  92. package/dist/extension/vendor/ignore.js +788 -0
  93. package/dist/goHeadless.d.ts +1 -1
  94. package/dist/goHeadless.js +2 -2
  95. package/dist/launch.d.ts +4 -3
  96. package/dist/launch.js +7 -4
  97. package/dist/mcpCommand.d.ts +122 -0
  98. package/dist/mcpCommand.js +787 -0
  99. package/dist/otel.d.ts +69 -63
  100. package/dist/otel.js +154 -119
  101. package/dist/paths.d.ts +13 -0
  102. package/dist/paths.js +18 -0
  103. package/dist/pluginCommand.d.ts +43 -0
  104. package/dist/pluginCommand.js +499 -0
  105. package/dist/pluginStore.d.ts +170 -0
  106. package/dist/pluginStore.js +554 -0
  107. package/dist/upgrade.d.ts +11 -2
  108. package/dist/upgrade.js +48 -8
  109. package/package.json +20 -3
  110. package/dist/extension/mcpTools.d.ts +0 -57
  111. package/dist/extension/mcpTools.js +0 -132
@@ -17,8 +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, LSP servers, themes, output styles. 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.
22
33
  *
23
34
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
24
35
  * entries degrade to "that plugin absent" — never a failed launch. Path
@@ -149,10 +160,20 @@ export function readKnownMarketplaces(homeDir) {
149
160
  }
150
161
  return out;
151
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
+ }
152
173
  /** Parse `<root>/.claude-plugin/marketplace.json`; null when absent/unusable. */
153
174
  export function readMarketplace(root) {
154
175
  const parsed = readJsonObject(join(root, ".claude-plugin", "marketplace.json"));
155
- if (!parsed || typeof parsed.name !== "string" || parsed.name.length === 0)
176
+ if (!parsed || !isSafeSegment(parsed.name))
156
177
  return null;
157
178
  if (!Array.isArray(parsed.plugins))
158
179
  return null;
@@ -164,10 +185,16 @@ export function readMarketplace(root) {
164
185
  for (const item of parsed.plugins) {
165
186
  if (!item || typeof item !== "object" || Array.isArray(item))
166
187
  continue;
167
- const name = item.name;
168
- if (typeof name !== "string" || name.length === 0)
188
+ const entry = item;
189
+ const name = entry.name;
190
+ if (!isSafeSegment(name))
169
191
  continue;
170
- 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
+ });
171
198
  }
172
199
  return { name: parsed.name, root, pluginRoot, plugins };
173
200
  }
@@ -210,13 +237,17 @@ function asStringArray(value) {
210
237
  * discovery reads whole dirs), root `SKILL.md` fallback. Null when the plugin
211
238
  * has nothing we can bridge.
212
239
  */
213
- export function pluginAssets(root, name) {
240
+ export function pluginAssets(root, name, meta = {}) {
214
241
  if (!isDirectory(root))
215
242
  return null;
216
- 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.
217
248
  const skillPaths = [];
218
- const defaultSkills = join(root, "skills");
219
- if (isDirectory(defaultSkills))
249
+ const defaultSkills = containedExistingPath(root, "skills");
250
+ if (defaultSkills && isDirectory(defaultSkills))
220
251
  skillPaths.push(defaultSkills);
221
252
  const manifestSkills = asStringArray(manifest.skills);
222
253
  for (const rel of manifestSkills) {
@@ -225,8 +256,8 @@ export function pluginAssets(root, name) {
225
256
  skillPaths.push(contained);
226
257
  }
227
258
  if (skillPaths.length === 0 && manifestSkills.length === 0) {
228
- const rootSkill = join(root, "SKILL.md");
229
- if (isFile(rootSkill))
259
+ const rootSkill = containedExistingPath(root, "SKILL.md");
260
+ if (rootSkill && isFile(rootSkill))
230
261
  skillPaths.push(rootSkill);
231
262
  }
232
263
  const componentPaths = (manifestValue, defaultDir) => {
@@ -240,14 +271,62 @@ export function pluginAssets(root, name) {
240
271
  }
241
272
  return out;
242
273
  }
243
- const def = join(root, defaultDir);
244
- return isDirectory(def) ? [def] : [];
274
+ const def = containedExistingPath(root, defaultDir);
275
+ return def && isDirectory(def) ? [def] : [];
245
276
  };
246
277
  const commandPaths = componentPaths(manifest.commands, "commands");
247
278
  const agentDirs = componentPaths(manifest.agents, "agents").filter(isDirectory);
248
- 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)
249
288
  return null;
250
- 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;
251
330
  }
252
331
  function resolveInstalledRoot(key, installed, marketplaces, realCwd) {
253
332
  const entries = (installed.get(key) ?? []).filter((e) => isDirectory(e.installPath));
@@ -293,14 +372,40 @@ export function discoverClaudePlugins(deps) {
293
372
  const user = [];
294
373
  const project = [];
295
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
+ }
296
401
  for (const [key, state] of enabled) {
297
- if (!state.enabled)
402
+ if (!state.enabled || seen.has(key))
298
403
  continue;
299
404
  const root = resolveInstalledRoot(key, installed, marketplaces, realCwd);
300
405
  if (!root)
301
406
  continue;
302
407
  const at = key.lastIndexOf("@");
303
- 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" });
304
409
  if (!assets)
305
410
  continue;
306
411
  seen.add(key);
@@ -320,7 +425,12 @@ export function discoverClaudePlugins(deps) {
320
425
  resolveInstalledRoot(key, installed, marketplaces, realCwd);
321
426
  if (!root)
322
427
  continue;
323
- 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
+ });
324
434
  if (!assets)
325
435
  continue;
326
436
  seen.add(key);
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ import { agentDir, credentialsDir, piPackageDir } from "./credentials.js";
23
23
  import { DISTRIBUTION } from "./distribution.js";
24
24
  import { connectCommand } from "./connectClaudeCode.js";
25
25
  import { goCommand } from "./goHeadless.js";
26
+ import { mcpCommand } from "./mcpCommand.js";
27
+ import { pluginCommand } from "./pluginCommand.js";
26
28
  import { login } from "./login.js";
27
29
  import { logout } from "./logout.js";
28
30
  import { tokenCommand } from "./token.js";
@@ -246,10 +248,10 @@ async function runDefault(passthroughArgs) {
246
248
  catch {
247
249
  compat = { argv: [], env: {} };
248
250
  }
249
- // OTel export: load pi-otel only when an OTLP endpoint is configured — the
250
- // user's env, the workspace's admin-set config (fetched fail-soft, cached),
251
- // or the repo's .pi/settings.json, in that order. undefined keeps the
252
- // launch untouched. See otel.ts for the policy.
251
+ // OTel export: on only when an OTLP endpoint is configured — the user's
252
+ // env, the workspace's admin-set config (fetched fail-soft, cached), or the
253
+ // repo's .pi/settings.json, in that order. undefined keeps the launch
254
+ // untouched. See otel.ts for the policy.
253
255
  const otel = await resolveOtelLaunchWithWorkspace({
254
256
  env: process.env,
255
257
  cwd: process.cwd(),
@@ -515,6 +517,14 @@ export const HELP_TEXT = [
515
517
  " yagni connect claude-code Route Claude Code through the YAGNI model proxy",
516
518
  " (--project scopes to this repo; --off disconnects).",
517
519
  " yagni connect codex Route Codex CLI through the YAGNI model proxy.",
520
+ " yagni mcp <subcommand> Manage MCP servers: add, remove, list, get,",
521
+ " add-from-claude, and more — run `yagni mcp` for",
522
+ " details. OAuth servers authenticate via the",
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.",
518
528
  " yagni token Output the active environment's API token (for helpers).",
519
529
  " yagni use <name> Switch the active environment (sticky).",
520
530
  " Presets: prod, local. Others need --base-url <url>.",
@@ -536,6 +546,9 @@ export const HELP_TEXT = [
536
546
  " /mode plan|review|auto Hold writes for planning, or confirm each change.",
537
547
  " /todos Show the agent's live task list.",
538
548
  " /cost Session usage and credit headroom.",
549
+ " /mcp Manage MCP servers; authenticate OAuth servers.",
550
+ " /plugin Browse, install, and remove Claude Code plugins.",
551
+ " /reload-plugins Apply plugin changes without restarting.",
539
552
  "",
540
553
  "The active environment is sticky; `use` switches it (prod is the default).",
541
554
  "Set YAGNI_BASE_URL to override the base URL for a single run.",
@@ -653,6 +666,17 @@ export async function main(argv) {
653
666
  if (command === "connect") {
654
667
  return connectCommand(rest);
655
668
  }
669
+ // MCP server configuration: same three-scope model and file homes the
670
+ // in-session /mcp panel uses, shared code via the bundled extension's
671
+ // config entry. `list` runs real health-check connections.
672
+ if (command === "mcp") {
673
+ return mcpCommand(rest);
674
+ }
675
+ // Claude Code plugin marketplaces: install/remove/update from YAGNI itself
676
+ // (state under ~/.yagni-code/plugins; Claude Code's own installs still mirror).
677
+ if (command === "plugin") {
678
+ return pluginCommand(rest);
679
+ }
656
680
  // The headless pipeline entry. `go` is a real subcommand, not passthrough:
657
681
  // the interactive run stays `/go` inside a session, and a `yagni go` without
658
682
  // --headless is refused with usage rather than launched as an agent prompt.
package/dist/doctor.d.ts CHANGED
@@ -61,12 +61,44 @@ export declare function checkCliUpdate(probe: {
61
61
  latest: string | null;
62
62
  }): CheckResult;
63
63
  export declare function checkGh(onPath: boolean): CheckResult;
64
+ /** One signal's verdict from the extension's probe (see telemetry/probe.ts). */
65
+ export interface OtelProbeSignal {
66
+ signal: "traces" | "metrics" | "logs";
67
+ url: string;
68
+ protocol: string;
69
+ ok: boolean;
70
+ error?: string;
71
+ }
72
+ /**
73
+ * Advisory OTel-export line: says whether sessions will stream telemetry to
74
+ * an OTLP collector, from which config source, and — when a collector is
75
+ * configured — whether it ACCEPTED a real record on each signal (a wrong API
76
+ * key, a wrong path, or a protocol mismatch shows up as the collector's own
77
+ * error). Never flips the exit code — most machines have no collector, and
78
+ * that is the healthy default.
79
+ */
80
+ export declare function checkOtelExport(config: OtelLaunchConfig | undefined, deps?: {
81
+ probe?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
82
+ baseEnv?: NodeJS.ProcessEnv;
83
+ }): Promise<CheckResult>;
84
+ /** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
85
+ export interface McpProbe {
86
+ /** YAGNI_CODE_MCP_DISABLED kill-switch is active. */
87
+ disabled: boolean;
88
+ /** Count of configured servers (post-merge, before the approval gate). */
89
+ serverCount: number;
90
+ /** Project-scope servers that are neither enabled nor disabled. */
91
+ undecidedProjectServers: string[];
92
+ /** Config parse/validation errors (already human-readable). */
93
+ errors: string[];
94
+ }
64
95
  /**
65
- * Advisory OTel-export line: says whether sessions will stream traces to an
66
- * OTLP collector, and from which config source. Never flips the exit code
67
- * most machines have no collector, and that is the healthy default.
96
+ * Advisory (never required) MCP line: reports config health without connecting
97
+ * to anything `yagni mcp list`/`get` already run the real health checks, and
98
+ * doctor must never spawn an unapproved server process. The kill-switch and
99
+ * pending-approval cases read as `warn`, config errors as `fail` (non-required).
68
100
  */
69
- export declare function checkOtelExport(config: OtelLaunchConfig | undefined): CheckResult;
101
+ export declare function checkMcpConfig(probe: McpProbe): CheckResult;
70
102
  /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
71
103
  export interface BashProbe {
72
104
  found: boolean;
@@ -95,6 +127,12 @@ export interface DoctorDeps {
95
127
  probeBash?: () => BashProbe;
96
128
  currentVersion?: string;
97
129
  probeLatestVersion?: () => Promise<string | null>;
130
+ /** MCP config snapshot (config-only, no connections). */
131
+ probeMcp?: () => Promise<McpProbe>;
132
+ /** OTel gate resolution (env / workspace / repo settings); tests stub it. */
133
+ resolveOtel?: (profile: Profile) => Promise<OtelLaunchConfig | undefined>;
134
+ /** OTel export probe: one real record per signal against the session env. */
135
+ probeOtel?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
98
136
  log?: (msg: string) => void;
99
137
  }
100
138
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
package/dist/doctor.js CHANGED
@@ -17,9 +17,10 @@ import { delimiter, join } from "node:path";
17
17
  import { credentialsDir } from "./credentials.js";
18
18
  import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
19
19
  import { classifyTokenExpiry } from "./launch.js";
20
- import { resolveOtelLaunchWithWorkspace } from "./otel.js";
21
- import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
20
+ import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
21
+ import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
22
22
  import { readActiveProfile } from "./profiles.js";
23
+ import { resolveMcpConfigPath } from "./mcpCommand.js";
23
24
  // ── Pure check builders ─────────────────────────────────────────────────────
24
25
  export function checkPiEngine(probe) {
25
26
  if (!probe.binPath || !probe.binExists) {
@@ -200,11 +201,24 @@ export function checkGh(onPath) {
200
201
  };
201
202
  }
202
203
  /**
203
- * Advisory OTel-export line: says whether sessions will stream traces to an
204
- * OTLP collector, and from which config source. Never flips the exit code
205
- * most machines have no collector, and that is the healthy default.
204
+ * Run the extension's telemetry probe against the exact env a session would
205
+ * get: one real span, one metric point, one log record, each reported with
206
+ * the collector's actual verdict. Dynamic import so `doctor` on a machine
207
+ * with no collector never loads the OTel SDK graph.
206
208
  */
207
- export function checkOtelExport(config) {
209
+ async function defaultProbeOtel(env) {
210
+ const mod = (await import(resolveTelemetryProbePath()));
211
+ return (await mod.probeTelemetryExport(env)).results;
212
+ }
213
+ /**
214
+ * Advisory OTel-export line: says whether sessions will stream telemetry to
215
+ * an OTLP collector, from which config source, and — when a collector is
216
+ * configured — whether it ACCEPTED a real record on each signal (a wrong API
217
+ * key, a wrong path, or a protocol mismatch shows up as the collector's own
218
+ * error). Never flips the exit code — most machines have no collector, and
219
+ * that is the healthy default.
220
+ */
221
+ export async function checkOtelExport(config, deps = {}) {
208
222
  if (!config) {
209
223
  return {
210
224
  name: "otel export (optional)",
@@ -214,14 +228,97 @@ export function checkOtelExport(config) {
214
228
  };
215
229
  }
216
230
  const source = config.source === "env"
217
- ? "OTEL_EXPORTER_OTLP_ENDPOINT"
231
+ ? (config.envVar ?? "OTEL_EXPORTER_OTLP_ENDPOINT")
218
232
  : config.source === "workspace"
219
233
  ? "workspace settings"
220
234
  : ".pi/settings.json";
235
+ const base = `on → ${config.endpoint} (${source}, metadata-only)`;
236
+ const baseEnv = deps.baseEnv ?? process.env;
237
+ const env = { ...baseEnv, ...otelChildEnv(config, baseEnv) };
238
+ const doProbe = deps.probe ?? defaultProbeOtel;
239
+ try {
240
+ const results = await doProbe(env);
241
+ const accepted = results.filter((r) => r.ok).map((r) => r.signal);
242
+ const failed = results.filter((r) => !r.ok);
243
+ const summary = results.map((r) => `${r.signal} ${r.ok ? "accepted" : "FAILED"}`).join(", ");
244
+ if (failed.length === 0) {
245
+ return {
246
+ name: "otel export (optional)",
247
+ status: "ok",
248
+ detail: `${base}; ${summary}`,
249
+ required: false,
250
+ };
251
+ }
252
+ const reasons = failed.map((r) => `${r.signal} → ${r.url} (${r.protocol}): ${r.error ?? "rejected"}`).join("; ");
253
+ return {
254
+ name: "otel export (optional)",
255
+ status: "warn",
256
+ detail: `${base}; ${summary}`,
257
+ hint: `${reasons}. A 401/403 means the collector key is wrong, a 404 the path or site is wrong, ` +
258
+ `a connection error the collector is unreachable from this machine` +
259
+ (accepted.length > 0 ? `; ${accepted.join(" and ")} still export` : ""),
260
+ required: false,
261
+ };
262
+ }
263
+ catch {
264
+ // The probe itself errored (not "endpoint down" — the check could not
265
+ // run). Distinct from a verified-healthy line so nobody reads an
266
+ // unverified config as checked-and-good.
267
+ return {
268
+ name: "otel export (optional)",
269
+ status: "warn",
270
+ detail: `${base}, export probe failed`,
271
+ hint: "the export probe could not run (the bundled extension is missing or the OTel SDK failed to load) — export is configured but unverified; sessions may still export normally",
272
+ required: false,
273
+ };
274
+ }
275
+ }
276
+ /**
277
+ * Advisory (never required) MCP line: reports config health without connecting
278
+ * to anything — `yagni mcp list`/`get` already run the real health checks, and
279
+ * doctor must never spawn an unapproved server process. The kill-switch and
280
+ * pending-approval cases read as `warn`, config errors as `fail` (non-required).
281
+ */
282
+ export function checkMcpConfig(probe) {
283
+ if (probe.disabled) {
284
+ return {
285
+ name: "mcp servers",
286
+ status: "warn",
287
+ detail: "disabled (YAGNI_CODE_MCP_DISABLED=1)",
288
+ hint: "unset YAGNI_CODE_MCP_DISABLED to re-enable MCP servers",
289
+ required: false,
290
+ };
291
+ }
292
+ if (probe.errors.length > 0) {
293
+ return {
294
+ name: "mcp servers",
295
+ status: "fail",
296
+ detail: `${probe.serverCount} configured, ${probe.errors.length} config error${probe.errors.length === 1 ? "" : "s"}`,
297
+ hint: `run \`yagni mcp list\` (first error: ${probe.errors[0]})`,
298
+ required: false,
299
+ };
300
+ }
301
+ if (probe.serverCount === 0) {
302
+ return {
303
+ name: "mcp servers",
304
+ status: "ok",
305
+ detail: "none configured",
306
+ required: false,
307
+ };
308
+ }
309
+ if (probe.undecidedProjectServers.length > 0) {
310
+ return {
311
+ name: "mcp servers",
312
+ status: "warn",
313
+ detail: `${probe.serverCount} configured, ${probe.undecidedProjectServers.length} awaiting approval`,
314
+ hint: "run /mcp to approve the project servers",
315
+ required: false,
316
+ };
317
+ }
221
318
  return {
222
- name: "otel export (optional)",
319
+ name: "mcp servers",
223
320
  status: "ok",
224
- detail: `on → ${config.endpoint} (${source}, metadata-only)`,
321
+ detail: `${probe.serverCount} configured`,
225
322
  required: false,
226
323
  };
227
324
  }
@@ -312,6 +409,37 @@ function defaultProbeStateDir() {
312
409
  return { path, exists: false, mode: null };
313
410
  }
314
411
  }
412
+ async function defaultProbeMcp(env = process.env) {
413
+ const disabled = env.YAGNI_CODE_MCP_DISABLED === "1";
414
+ if (disabled)
415
+ return { disabled: true, serverCount: 0, undecidedProjectServers: [], errors: [] };
416
+ try {
417
+ const mod = (await import(resolveMcpConfigPath()));
418
+ if (typeof mod?.loadMcpServers !== "function") {
419
+ return { disabled: false, serverCount: 0, undecidedProjectServers: [], errors: ["extension MCP config entry missing"] };
420
+ }
421
+ const loaded = mod.loadMcpServers(process.cwd(), env);
422
+ const repoRoot = mod.resolveProjectRoot(process.cwd());
423
+ const { state } = mod.readProjectApproval(repoRoot);
424
+ const undecided = loaded.servers
425
+ .filter((s) => (s.scope === "project" || s.gated === true) && mod.decisionFor(state, s.name) === "undecided")
426
+ .map((s) => s.name);
427
+ return {
428
+ disabled: false,
429
+ serverCount: loaded.servers.length,
430
+ undecidedProjectServers: undecided,
431
+ errors: loaded.errors.map((e) => (e.serverName ? `${e.serverName}: ${e.message}` : e.message)),
432
+ };
433
+ }
434
+ catch (err) {
435
+ return {
436
+ disabled: false,
437
+ serverCount: 0,
438
+ undecidedProjectServers: [],
439
+ errors: [err instanceof Error ? err.message : String(err)],
440
+ };
441
+ }
442
+ }
315
443
  async function defaultProbeBackend(baseUrl, token) {
316
444
  if (!token)
317
445
  return { kind: "skipped" };
@@ -378,6 +506,7 @@ export async function gatherChecks(deps = {}) {
378
506
  const readProfile = deps.readActiveProfile ?? readActiveProfile;
379
507
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
380
508
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
509
+ const probeMcp = deps.probeMcp ?? (() => defaultProbeMcp());
381
510
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
382
511
  const platform = deps.platform ?? process.platform;
383
512
  const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
@@ -406,12 +535,18 @@ export async function gatherChecks(deps = {}) {
406
535
  checks.push(checkBackend(backend));
407
536
  checks.push(checkStateDir(probeStateDir()));
408
537
  checks.push(checkGh(ghOnPath()));
409
- checks.push(checkOtelExport(await resolveOtelLaunchWithWorkspace({
410
- env: process.env,
411
- cwd: process.cwd(),
412
- creds: profile.token ? { baseUrl: profile.baseUrl, token: profile.token } : null,
413
- profileName: profile.name,
414
- })));
538
+ checks.push(checkMcpConfig(await probeMcp()));
539
+ const resolveOtel = deps.resolveOtel ??
540
+ ((p) => resolveOtelLaunchWithWorkspace({
541
+ env: process.env,
542
+ cwd: process.cwd(),
543
+ creds: p.token ? { baseUrl: p.baseUrl, token: p.token } : null,
544
+ profileName: p.name,
545
+ }));
546
+ checks.push(await checkOtelExport(await resolveOtel(profile), {
547
+ ...(deps.probeOtel ? { probe: deps.probeOtel } : {}),
548
+ baseEnv: process.env,
549
+ }));
415
550
  return checks;
416
551
  }
417
552
  /**
@@ -24,7 +24,7 @@
24
24
  * pattern) so tests run against plain text. Renderer exceptions are swallowed
25
25
  * by pi (degrading to the built-in fallback), so everything stays total.
26
26
  */
27
- import { SettingsManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { SettingsManager, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
28
28
  import type { RenderTheme } from "./subagentRender.js";
29
29
  import { ToolRunTracker } from "./toolRuns.js";
30
30
  /** Lines of write content shown collapsed (mirrors Claude Code's preview). */
@@ -87,6 +87,17 @@ export interface RegisterCondensedToolsDeps {
87
87
  * image autoResize. Injectable so tests never touch the config dir.
88
88
  */
89
89
  loadSettings?: (cwd: string) => SettingsManager;
90
+ /**
91
+ * sandbox composition: when present, the bash definition is passed
92
+ * through this wrap before registration — the sandbox adds its schema field
93
+ * (dangerouslyDisableSandbox), wrap-on-execute, and violation annotation
94
+ * while condensed keeps its rendering. The compose fn is built by
95
+ * sandbox/session.ts (makeSandboxBashComposition) and is a no-op when the
96
+ * sandbox is disabled. Condensed registers LAST among the bash-defining
97
+ * sites, so composing HERE is what makes the sandbox actually reach the
98
+ * model's tool calls.
99
+ */
100
+ wrapBash?: (def: ToolDefinition) => ToolDefinition;
90
101
  }
91
102
  /**
92
103
  * Re-register the seven built-ins with condensed renderers. Returns the
@@ -291,15 +291,23 @@ export function registerCondensedTools(pi, deps = {}) {
291
291
  catch {
292
292
  toolOptions = { bash: {}, read: {} };
293
293
  }
294
- const buildDefinitions = (cwd) => ({
295
- read: createReadToolDefinition(cwd, toolOptions.read),
296
- bash: createBashToolDefinition(cwd, toolOptions.bash),
297
- edit: createEditToolDefinition(cwd),
298
- write: createWriteToolDefinition(cwd),
299
- grep: createGrepToolDefinition(cwd),
300
- find: createFindToolDefinition(cwd),
301
- ls: createLsToolDefinition(cwd),
302
- });
294
+ const buildDefinitions = (cwd) => {
295
+ const defs = {
296
+ read: createReadToolDefinition(cwd, toolOptions.read),
297
+ bash: createBashToolDefinition(cwd, toolOptions.bash),
298
+ edit: createEditToolDefinition(cwd),
299
+ write: createWriteToolDefinition(cwd),
300
+ grep: createGrepToolDefinition(cwd),
301
+ find: createFindToolDefinition(cwd),
302
+ ls: createLsToolDefinition(cwd),
303
+ };
304
+ // the sandbox wraps the bash DEFINITION (not the registration
305
+ // wrapper) so the cwd-delegating execute below still lands on the
306
+ // sandbox-aware tool for every cwd the session visits.
307
+ if (defs.bash && deps.wrapBash)
308
+ defs.bash = deps.wrapBash(defs.bash);
309
+ return defs;
310
+ };
303
311
  const definitionsByCwd = new Map();
304
312
  const definitionsFor = (cwd) => {
305
313
  let defs = definitionsByCwd.get(cwd);