@yagni-app/code-staging 1.0.6-staging.1257.1 → 1.0.6-staging.1261.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.
@@ -43,6 +43,13 @@ export declare const CLAUDE_RULES_DIRS_ENV = "YAGNI_CLAUDE_RULES_DIRS";
43
43
  * the same approval the repo's `.mcp.json` would.
44
44
  */
45
45
  export declare const CLAUDE_PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
46
+ /**
47
+ * `name@marketplace` keys of the plugins wired at launch (delimiter-joined).
48
+ * `/reload-plugins` diffs a fresh inventory against this baseline: pi already
49
+ * holds the launch-time plugins through argv, so only newer ones need the
50
+ * extension's `resources_discover` contribution.
51
+ */
52
+ export declare const CLAUDE_PLUGIN_KEYS_ENV = "YAGNI_CLAUDE_PLUGIN_KEYS";
46
53
  export interface PluginMcpEnvEntry {
47
54
  plugin: string;
48
55
  sourcePath: string;
@@ -124,6 +131,38 @@ export interface ClaudeCompatLaunch {
124
131
  * compat can only add flags/env, never break a launch.
125
132
  */
126
133
  export declare function claudeCompatArgs(deps: ClaudeCompatDeps): Promise<ClaudeCompatLaunch>;
134
+ /** One plugin as the in-session `/plugin` panel sees it. */
135
+ export interface InventoryPlugin {
136
+ key: string;
137
+ name: string;
138
+ origin: PluginAssets["origin"];
139
+ version: string | null;
140
+ root: string;
141
+ /** Repo-sourced: its MCP servers ride the project approval gate. */
142
+ gated: boolean;
143
+ skillPaths: string[];
144
+ commandPaths: string[];
145
+ agentDirs: string[];
146
+ mcpServers: string[];
147
+ unsupported: string[];
148
+ }
149
+ export interface PluginInventory {
150
+ /** Plugins that load for `cwd` right now (trust respected, never prompting). */
151
+ plugins: InventoryPlugin[];
152
+ /** The MCP payload those plugins contribute (same shape as YAGNI_CLAUDE_PLUGIN_MCP). */
153
+ mcp: PluginMcpEnvEntry[];
154
+ /** Repo-sourced plugins held back because the folder is not trusted yet. */
155
+ pendingTrust: string[];
156
+ /** Plugin agent dirs, user first (same order as YAGNI_CLAUDE_AGENT_DIRS). */
157
+ agentDirs: string[];
158
+ }
159
+ /**
160
+ * The plugin inventory a launch from `cwd` would wire, computed without
161
+ * prompting: the user's own plugins always, repo-sourced ones only once the
162
+ * folder is trusted. Backs `yagni plugin inventory --json`, which the
163
+ * in-session `/plugin` panel and `/reload-plugins` call.
164
+ */
165
+ export declare function pluginInventory(deps: Omit<ClaudeCompatDeps, "confirm" | "interactive">): PluginInventory;
127
166
  /** Whether pi's trust file exists yet (used only for messaging). */
128
167
  export declare function trustFileExists(agentDir: string): boolean;
129
168
  //# sourceMappingURL=claudeCompat.d.ts.map
@@ -47,6 +47,13 @@ export const CLAUDE_RULES_DIRS_ENV = "YAGNI_CLAUDE_RULES_DIRS";
47
47
  * the same approval the repo's `.mcp.json` would.
48
48
  */
49
49
  export const CLAUDE_PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
50
+ /**
51
+ * `name@marketplace` keys of the plugins wired at launch (delimiter-joined).
52
+ * `/reload-plugins` diffs a fresh inventory against this baseline: pi already
53
+ * holds the launch-time plugins through argv, so only newer ones need the
54
+ * extension's `resources_discover` contribution.
55
+ */
56
+ export const CLAUDE_PLUGIN_KEYS_ENV = "YAGNI_CLAUDE_PLUGIN_KEYS";
50
57
  const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}";
51
58
  function expandPluginRoot(value, root) {
52
59
  if (typeof value === "string")
@@ -296,6 +303,10 @@ export async function claudeCompatArgs(deps) {
296
303
  ...decision.userMcp,
297
304
  ...(trusted === true ? decision.projectMcp : []),
298
305
  ];
306
+ const loadedKeys = [
307
+ ...plugins.user.map((p) => p.key),
308
+ ...(trusted === true ? plugins.project.map((p) => p.key) : []),
309
+ ];
299
310
  const extraEnv = {};
300
311
  if (agentDirs.length > 0)
301
312
  extraEnv[CLAUDE_AGENT_DIRS_ENV] = agentDirs.join(delimiter);
@@ -303,8 +314,52 @@ export async function claudeCompatArgs(deps) {
303
314
  extraEnv[CLAUDE_RULES_DIRS_ENV] = rulesDirs.join(delimiter);
304
315
  if (mcpEntries.length > 0)
305
316
  extraEnv[CLAUDE_PLUGIN_MCP_ENV] = JSON.stringify(mcpEntries);
317
+ if (loadedKeys.length > 0)
318
+ extraEnv[CLAUDE_PLUGIN_KEYS_ENV] = loadedKeys.join(delimiter);
306
319
  return { argv, env: extraEnv };
307
320
  }
321
+ function toInventory(plugin, gated) {
322
+ return {
323
+ key: plugin.key,
324
+ name: plugin.name,
325
+ origin: plugin.origin,
326
+ version: plugin.version,
327
+ root: plugin.root,
328
+ gated,
329
+ skillPaths: plugin.skillPaths,
330
+ commandPaths: plugin.commandPaths,
331
+ agentDirs: plugin.agentDirs,
332
+ mcpServers: plugin.mcp ? Object.keys(plugin.mcp.servers) : [],
333
+ unsupported: plugin.unsupported,
334
+ };
335
+ }
336
+ /**
337
+ * The plugin inventory a launch from `cwd` would wire, computed without
338
+ * prompting: the user's own plugins always, repo-sourced ones only once the
339
+ * folder is trusted. Backs `yagni plugin inventory --json`, which the
340
+ * in-session `/plugin` panel and `/reload-plugins` call.
341
+ */
342
+ export function pluginInventory(deps) {
343
+ const env = deps.env ?? process.env;
344
+ if (compatDisabled(env))
345
+ return { plugins: [], mcp: [], pendingTrust: [], agentDirs: [] };
346
+ const home = deps.homeDir ?? homedir();
347
+ const trusted = readTrustDecision(deps.agentDir, deps.cwd);
348
+ let plugins;
349
+ try {
350
+ plugins = discoverClaudePlugins({ cwd: deps.cwd, homeDir: home, yagniLedger: readLedger(pluginsHome(env, home)) });
351
+ }
352
+ catch {
353
+ plugins = { user: [], project: [] };
354
+ }
355
+ const project = trusted === true ? plugins.project : [];
356
+ return {
357
+ plugins: [...plugins.user.map((p) => toInventory(p, false)), ...project.map((p) => toInventory(p, true))],
358
+ mcp: [...pluginMcpEntries(plugins.user, false), ...pluginMcpEntries(project, true)],
359
+ pendingTrust: trusted === true ? [] : plugins.project.map((p) => p.key),
360
+ agentDirs: [...plugins.user, ...project].flatMap((p) => p.agentDirs),
361
+ };
362
+ }
308
363
  /** Whether pi's trust file exists yet (used only for messaging). */
309
364
  export function trustFileExists(agentDir) {
310
365
  return existsSync(trustPath(agentDir));
package/dist/cli.js CHANGED
@@ -547,6 +547,8 @@ export const HELP_TEXT = [
547
547
  " /todos Show the agent's live task list.",
548
548
  " /cost Session usage and credit headroom.",
549
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.",
550
552
  "",
551
553
  "The active environment is sticky; `use` switches it (prod is the default).",
552
554
  "Set YAGNI_BASE_URL to override the base URL for a single run.",
@@ -3,6 +3,7 @@ import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
4
  import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
5
  import { type GuardianGateEvent } from "./permission/gate.js";
6
+ import { type PluginCliRunner } from "./plugins/panel.js";
6
7
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
7
8
  import { type TokenProvider } from "./tokenProvider.js";
8
9
  import { type CatalogResult, type ContextBrief } from "./config.js";
@@ -59,6 +60,8 @@ export interface RegisterYagniDeps {
59
60
  * homes or spawning servers. Default runs the real startup.
60
61
  */
61
62
  startMcp?: typeof defaultStartMcp;
63
+ /** Test seam for the /plugin panel: runs `yagni plugin …` (defaults to spawning YAGNI_CLI_PATH). */
64
+ runPluginCli?: PluginCliRunner;
62
65
  /**
63
66
  * The CLI init pass (Onramp Door B). Injectable so tests assert it fires on a
64
67
  * fresh-workspace first-run and is skipped otherwise, without a network or disk.
@@ -41,6 +41,7 @@ import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
41
41
  import { registerTelemetry } from "./telemetry/register.js";
42
42
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
43
43
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
44
+ import { registerPluginPanel } from "./plugins/panel.js";
44
45
  import { registerSubagents } from "./subagents.js";
45
46
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
46
47
  import { registerTodos } from "./todos.js";
@@ -278,6 +279,9 @@ export async function registerYagni(pi, deps = {}) {
278
279
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
279
280
  const ultraHolder = createUltraHolder();
280
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 });
281
285
  registerUltraCommand(pi, ultraHolder);
282
286
  // Shared fetch timeout for the small, interactive display-path reads below
283
287
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The session's view of Claude Code plugins, and how a mid-session change
3
+ * reaches pi without a restart.
4
+ *
5
+ * The launcher (yagni-code-cli) owns discovery, trust, and the ledger. This
6
+ * module only talks to it: `yagni plugin inventory --json` (via
7
+ * `YAGNI_CLI_PATH`) returns what would load for the cwd right now. Reload is
8
+ * pi's own `/reload` flow: before calling it we rewrite this process's env so
9
+ * the re-created extension runtime sees the fresh MCP payload and agent dirs,
10
+ * and `resources_discover` hands pi the skill/prompt paths of plugins that
11
+ * were not in the launch argv (see panel.ts). Everything is fail-soft.
12
+ */
13
+ import { type PluginMcpEnvEntry } from "../mcp/config.js";
14
+ /** Launcher → extension: the yagni CLI entry to run `plugin …` with. */
15
+ export declare const CLI_PATH_ENV = "YAGNI_CLI_PATH";
16
+ /** Launcher → extension: `name@marketplace` keys wired through argv at launch. */
17
+ export declare const PLUGIN_KEYS_ENV = "YAGNI_CLAUDE_PLUGIN_KEYS";
18
+ /** Set by /reload-plugins before pi reloads: the fresh inventory (JSON). */
19
+ export declare const INVENTORY_ENV = "YAGNI_CLAUDE_PLUGIN_INVENTORY";
20
+ export interface InventoryPlugin {
21
+ key: string;
22
+ name: string;
23
+ origin: "yagni" | "claude-code" | "repo-marketplace";
24
+ version: string | null;
25
+ root: string;
26
+ gated: boolean;
27
+ skillPaths: string[];
28
+ commandPaths: string[];
29
+ agentDirs: string[];
30
+ mcpServers: string[];
31
+ unsupported: string[];
32
+ }
33
+ export interface PluginInventory {
34
+ plugins: InventoryPlugin[];
35
+ mcp: PluginMcpEnvEntry[];
36
+ pendingTrust: string[];
37
+ agentDirs: string[];
38
+ }
39
+ export interface CliResult {
40
+ code: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ }
44
+ export type CliRunner = (args: string[], opts: {
45
+ cliPath: string;
46
+ cwd: string;
47
+ env: NodeJS.ProcessEnv;
48
+ }) => Promise<CliResult>;
49
+ export declare const defaultCliRunner: CliRunner;
50
+ export declare function cliPath(env: NodeJS.ProcessEnv): string | null;
51
+ export declare function launchKeys(env: NodeJS.ProcessEnv): Set<string>;
52
+ /** The inventory recorded by the last /reload-plugins in this process, if any. */
53
+ export declare function storedInventory(env: NodeJS.ProcessEnv): PluginInventory | null;
54
+ export declare function parseInventory(value: unknown): PluginInventory;
55
+ export declare function fetchInventory(opts: {
56
+ cwd: string;
57
+ env: NodeJS.ProcessEnv;
58
+ runCli?: CliRunner;
59
+ }): Promise<PluginInventory>;
60
+ /**
61
+ * The plugins the session knows about right now: launch-time keys plus
62
+ * anything a previous /reload-plugins in this process brought in.
63
+ */
64
+ export declare function knownKeys(env: NodeJS.ProcessEnv): Set<string>;
65
+ export interface ReloadPlan {
66
+ added: InventoryPlugin[];
67
+ removed: string[];
68
+ /** Env values to set on this process before pi reloads. */
69
+ env: Record<string, string | undefined>;
70
+ }
71
+ /**
72
+ * Diff a fresh inventory against what the session already has, and the env
73
+ * rewrite that makes the reloaded runtime pick the fresh state up: the MCP
74
+ * payload and agent dirs are re-read by the extension on startup, and the
75
+ * inventory itself feeds `resources_discover` for skills/prompts.
76
+ */
77
+ export declare function planReload(inventory: PluginInventory, env: NodeJS.ProcessEnv): ReloadPlan;
78
+ export declare function applyEnv(target: NodeJS.ProcessEnv, values: Record<string, string | undefined>): void;
79
+ /**
80
+ * Skill/prompt paths pi does not already hold through the launch argv: the
81
+ * plugins that arrived via /reload-plugins. Returned from `resources_discover`
82
+ * on every reason so a `/new` after a reload keeps them too.
83
+ */
84
+ export declare function discoverPaths(env: NodeJS.ProcessEnv): {
85
+ skillPaths: string[];
86
+ promptPaths: string[];
87
+ };
88
+ //# sourceMappingURL=inventory.d.ts.map
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The session's view of Claude Code plugins, and how a mid-session change
3
+ * reaches pi without a restart.
4
+ *
5
+ * The launcher (yagni-code-cli) owns discovery, trust, and the ledger. This
6
+ * module only talks to it: `yagni plugin inventory --json` (via
7
+ * `YAGNI_CLI_PATH`) returns what would load for the cwd right now. Reload is
8
+ * pi's own `/reload` flow: before calling it we rewrite this process's env so
9
+ * the re-created extension runtime sees the fresh MCP payload and agent dirs,
10
+ * and `resources_discover` hands pi the skill/prompt paths of plugins that
11
+ * were not in the launch argv (see panel.ts). Everything is fail-soft.
12
+ */
13
+ import { execFile } from "node:child_process";
14
+ import { delimiter } from "node:path";
15
+ import { PLUGIN_MCP_ENV } from "../mcp/config.js";
16
+ import { CLAUDE_AGENT_DIRS_ENV } from "../subagents.js";
17
+ /** Launcher → extension: the yagni CLI entry to run `plugin …` with. */
18
+ export const CLI_PATH_ENV = "YAGNI_CLI_PATH";
19
+ /** Launcher → extension: `name@marketplace` keys wired through argv at launch. */
20
+ export const PLUGIN_KEYS_ENV = "YAGNI_CLAUDE_PLUGIN_KEYS";
21
+ /** Set by /reload-plugins before pi reloads: the fresh inventory (JSON). */
22
+ export const INVENTORY_ENV = "YAGNI_CLAUDE_PLUGIN_INVENTORY";
23
+ export const defaultCliRunner = (args, opts) => new Promise((resolve) => {
24
+ execFile(process.execPath, [opts.cliPath, ...args], { cwd: opts.cwd, env: { ...opts.env, YAGNI_DISABLE_UPDATE_CHECK: "1" }, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
25
+ const code = err && typeof err.code === "number" ? (err.code) : err ? 1 : 0;
26
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? (err?.message ?? "")) });
27
+ });
28
+ });
29
+ export function cliPath(env) {
30
+ const value = env[CLI_PATH_ENV];
31
+ return value && value.length > 0 ? value : null;
32
+ }
33
+ export function launchKeys(env) {
34
+ const raw = env[PLUGIN_KEYS_ENV];
35
+ return new Set(raw ? raw.split(delimiter).filter(Boolean) : []);
36
+ }
37
+ /** The inventory recorded by the last /reload-plugins in this process, if any. */
38
+ export function storedInventory(env) {
39
+ const raw = env[INVENTORY_ENV];
40
+ if (!raw)
41
+ return null;
42
+ try {
43
+ return parseInventory(JSON.parse(raw));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export function parseInventory(value) {
50
+ if (!value || typeof value !== "object")
51
+ throw new Error("inventory is not an object");
52
+ const v = value;
53
+ const plugins = Array.isArray(v.plugins) ? v.plugins.filter((p) => !!p && typeof p === "object" && typeof p.key === "string") : [];
54
+ return {
55
+ plugins: plugins.map((p) => ({
56
+ key: p.key,
57
+ name: typeof p.name === "string" ? p.name : p.key,
58
+ origin: p.origin === "yagni" || p.origin === "repo-marketplace" ? p.origin : "claude-code",
59
+ version: typeof p.version === "string" ? p.version : null,
60
+ root: typeof p.root === "string" ? p.root : "",
61
+ gated: p.gated === true,
62
+ skillPaths: stringList(p.skillPaths),
63
+ commandPaths: stringList(p.commandPaths),
64
+ agentDirs: stringList(p.agentDirs),
65
+ mcpServers: stringList(p.mcpServers),
66
+ unsupported: stringList(p.unsupported),
67
+ })),
68
+ mcp: Array.isArray(v.mcp) ? v.mcp : [],
69
+ pendingTrust: stringList(v.pendingTrust),
70
+ agentDirs: stringList(v.agentDirs),
71
+ };
72
+ }
73
+ function stringList(value) {
74
+ return Array.isArray(value) ? value.filter((s) => typeof s === "string") : [];
75
+ }
76
+ export async function fetchInventory(opts) {
77
+ const path = cliPath(opts.env);
78
+ if (!path)
79
+ throw new Error("This session was not started by the yagni launcher (YAGNI_CLI_PATH is unset); run `yagni plugin …` in a terminal instead.");
80
+ const result = await (opts.runCli ?? defaultCliRunner)(["plugin", "inventory", "--json"], { cliPath: path, cwd: opts.cwd, env: opts.env });
81
+ if (result.code !== 0)
82
+ throw new Error(result.stderr.trim() || `yagni plugin inventory exited ${result.code}`);
83
+ return parseInventory(JSON.parse(result.stdout));
84
+ }
85
+ /**
86
+ * The plugins the session knows about right now: launch-time keys plus
87
+ * anything a previous /reload-plugins in this process brought in.
88
+ */
89
+ export function knownKeys(env) {
90
+ const keys = launchKeys(env);
91
+ for (const p of storedInventory(env)?.plugins ?? [])
92
+ keys.add(p.key);
93
+ return keys;
94
+ }
95
+ /**
96
+ * Diff a fresh inventory against what the session already has, and the env
97
+ * rewrite that makes the reloaded runtime pick the fresh state up: the MCP
98
+ * payload and agent dirs are re-read by the extension on startup, and the
99
+ * inventory itself feeds `resources_discover` for skills/prompts.
100
+ */
101
+ export function planReload(inventory, env) {
102
+ const before = knownKeys(env);
103
+ const now = new Set(inventory.plugins.map((p) => p.key));
104
+ const added = inventory.plugins.filter((p) => !before.has(p.key));
105
+ const removed = [...before].filter((key) => !now.has(key));
106
+ return {
107
+ added,
108
+ removed,
109
+ env: {
110
+ [PLUGIN_MCP_ENV]: inventory.mcp.length > 0 ? JSON.stringify(inventory.mcp) : undefined,
111
+ [CLAUDE_AGENT_DIRS_ENV]: inventory.agentDirs.length > 0 ? inventory.agentDirs.join(delimiter) : undefined,
112
+ [INVENTORY_ENV]: JSON.stringify(inventory),
113
+ },
114
+ };
115
+ }
116
+ export function applyEnv(target, values) {
117
+ for (const [key, value] of Object.entries(values)) {
118
+ if (value === undefined)
119
+ delete target[key];
120
+ else
121
+ target[key] = value;
122
+ }
123
+ }
124
+ /**
125
+ * Skill/prompt paths pi does not already hold through the launch argv: the
126
+ * plugins that arrived via /reload-plugins. Returned from `resources_discover`
127
+ * on every reason so a `/new` after a reload keeps them too.
128
+ */
129
+ export function discoverPaths(env) {
130
+ const inventory = storedInventory(env);
131
+ if (!inventory)
132
+ return { skillPaths: [], promptPaths: [] };
133
+ const launched = launchKeys(env);
134
+ const skillPaths = [];
135
+ const promptPaths = [];
136
+ for (const plugin of inventory.plugins) {
137
+ if (launched.has(plugin.key))
138
+ continue;
139
+ skillPaths.push(...plugin.skillPaths);
140
+ promptPaths.push(...plugin.commandPaths);
141
+ }
142
+ return { skillPaths, promptPaths };
143
+ }
144
+ //# sourceMappingURL=inventory.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `/plugin` and `/reload-plugins` — Claude Code marketplace plugins managed
3
+ * from inside a session.
4
+ *
5
+ * `/plugin` mirrors Claude Code's panel: the installed inventory (origin,
6
+ * version, what loads, what is not bridged), the marketplace catalog with
7
+ * one-keystroke install, and per-plugin uninstall/enable/disable. Every
8
+ * mutation runs the launcher's `yagni plugin …` (the single owner of the
9
+ * ledger, trust, and discovery) and then reloads.
10
+ *
11
+ * `/reload-plugins` is pi's own `/reload` with the plugin state refreshed
12
+ * first: MCP servers and agents re-read their env on the new runtime, and
13
+ * skills/commands of plugins that were not in the launch argv reach pi
14
+ * through `resources_discover`. Removed plugins stop contributing MCP servers
15
+ * and agents at once; their skills/commands stay until a restart, and the
16
+ * summary says so.
17
+ */
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { type CliRunner, type InventoryPlugin, type PluginInventory, storedInventory } from "./inventory.js";
20
+ export type PluginCliRunner = CliRunner;
21
+ export interface PluginPanelDeps {
22
+ env?: NodeJS.ProcessEnv;
23
+ runCli?: CliRunner;
24
+ }
25
+ export declare function describePlugin(p: InventoryPlugin): string;
26
+ export declare function renderInventory(inventory: PluginInventory, env: NodeJS.ProcessEnv): string;
27
+ export declare function renderReloadSummary(added: InventoryPlugin[], removed: string[]): string;
28
+ /** Wire /plugin, /reload-plugins, and the resources_discover contribution. */
29
+ export declare function registerPluginPanel(pi: ExtensionAPI, deps?: PluginPanelDeps): void;
30
+ export interface CatalogMarketplace {
31
+ name: string;
32
+ plugins: {
33
+ name: string;
34
+ version: string | null;
35
+ installable: boolean;
36
+ }[];
37
+ }
38
+ /**
39
+ * Parse `yagni plugin list --available` text (the CLI's stable, tested
40
+ * shape: `<marketplace>:` headers, then ` <name> v<ver> [flags]` lines).
41
+ */
42
+ export declare function parseCatalog(text: string): CatalogMarketplace[];
43
+ /** Exported for tests: the current stored inventory, if a reload happened. */
44
+ export { storedInventory };
45
+ //# sourceMappingURL=panel.d.ts.map
@@ -0,0 +1,293 @@
1
+ /**
2
+ * `/plugin` and `/reload-plugins` — Claude Code marketplace plugins managed
3
+ * from inside a session.
4
+ *
5
+ * `/plugin` mirrors Claude Code's panel: the installed inventory (origin,
6
+ * version, what loads, what is not bridged), the marketplace catalog with
7
+ * one-keystroke install, and per-plugin uninstall/enable/disable. Every
8
+ * mutation runs the launcher's `yagni plugin …` (the single owner of the
9
+ * ledger, trust, and discovery) and then reloads.
10
+ *
11
+ * `/reload-plugins` is pi's own `/reload` with the plugin state refreshed
12
+ * first: MCP servers and agents re-read their env on the new runtime, and
13
+ * skills/commands of plugins that were not in the launch argv reach pi
14
+ * through `resources_discover`. Removed plugins stop contributing MCP servers
15
+ * and agents at once; their skills/commands stay until a restart, and the
16
+ * summary says so.
17
+ */
18
+ import { applyEnv, cliPath, defaultCliRunner, discoverPaths, fetchInventory, planReload, storedInventory, } from "./inventory.js";
19
+ const USAGE = [
20
+ "/plugin Browse installed plugins and marketplaces.",
21
+ "/plugin list Show what loads here.",
22
+ "/plugin install <plugin>[@marketplace] [-s user|project]",
23
+ "/plugin uninstall <plugin> (also: enable, disable, update [plugin])",
24
+ "/plugin marketplace add <owner/repo | git-url | path> (also: list, update, remove)",
25
+ "/reload-plugins Apply plugin changes without restarting.",
26
+ ].join("\n");
27
+ const MUTATING = new Set(["install", "add", "uninstall", "remove", "enable", "disable", "update"]);
28
+ export function describePlugin(p) {
29
+ const parts = [];
30
+ if (p.skillPaths.length)
31
+ parts.push(`${p.skillPaths.length} skill dir${p.skillPaths.length === 1 ? "" : "s"}`);
32
+ if (p.commandPaths.length)
33
+ parts.push(`${p.commandPaths.length} command dir${p.commandPaths.length === 1 ? "" : "s"}`);
34
+ if (p.agentDirs.length)
35
+ parts.push(`${p.agentDirs.length} agent dir${p.agentDirs.length === 1 ? "" : "s"}`);
36
+ if (p.mcpServers.length)
37
+ parts.push(`MCP: ${p.mcpServers.join(", ")}`);
38
+ const origin = p.origin === "yagni" ? "installed via yagni" : p.origin === "claude-code" ? "mirrored from Claude Code" : "from this repo's marketplace";
39
+ const lines = [`${p.key}${p.version ? ` v${p.version}` : ""} ${origin}`, ` ${parts.length ? parts.join(" · ") : "nothing loadable"}`];
40
+ if (p.unsupported.length)
41
+ lines.push(` not bridged: ${p.unsupported.join(", ")}`);
42
+ return lines.join("\n");
43
+ }
44
+ export function renderInventory(inventory, env) {
45
+ const lines = [];
46
+ if (inventory.plugins.length === 0) {
47
+ lines.push("No plugins load here.");
48
+ lines.push("Add a marketplace with `/plugin marketplace add <owner/repo | git-url | path>`, then `/plugin install <plugin>`.");
49
+ }
50
+ else {
51
+ lines.push(`Plugins (${inventory.plugins.length}):`);
52
+ for (const p of inventory.plugins)
53
+ lines.push(describePlugin(p));
54
+ }
55
+ if (inventory.pendingTrust.length > 0) {
56
+ lines.push(`Held back until this folder is trusted: ${inventory.pendingTrust.join(", ")}`);
57
+ }
58
+ const stale = staleSince(inventory, env);
59
+ if (stale.added.length > 0 || stale.removed.length > 0) {
60
+ lines.push(`Not yet applied to this session: ${[...stale.added.map((k) => `+${k}`), ...stale.removed.map((k) => `-${k}`)].join(", ")} — run /reload-plugins.`);
61
+ }
62
+ return lines.join("\n");
63
+ }
64
+ /** Plugins the inventory has that the session does not (and vice versa). */
65
+ function staleSince(inventory, env) {
66
+ const plan = planReload(inventory, env);
67
+ return { added: plan.added.map((p) => p.key), removed: plan.removed };
68
+ }
69
+ export function renderReloadSummary(added, removed) {
70
+ const lines = [];
71
+ if (added.length === 0 && removed.length === 0) {
72
+ lines.push("Plugins reloaded — nothing changed since launch.");
73
+ return lines.join("\n");
74
+ }
75
+ if (added.length > 0) {
76
+ lines.push(`Loaded: ${added.map((p) => p.key).join(", ")}`);
77
+ const mcp = added.flatMap((p) => p.mcpServers);
78
+ if (mcp.length > 0)
79
+ lines.push(` MCP servers connecting: ${mcp.join(", ")} (see /mcp)`);
80
+ const commands = added.filter((p) => p.commandPaths.length > 0).map((p) => p.key);
81
+ if (commands.length > 0)
82
+ lines.push(` Commands and skills from ${commands.join(", ")} are available now.`);
83
+ }
84
+ if (removed.length > 0) {
85
+ lines.push(`Removed: ${removed.join(", ")}`);
86
+ lines.push(" Their MCP servers and agents are gone now; skills and commands stay until you restart (`yagni -c` keeps this conversation).");
87
+ }
88
+ return lines.join("\n");
89
+ }
90
+ /** Wire /plugin, /reload-plugins, and the resources_discover contribution. */
91
+ export function registerPluginPanel(pi, deps = {}) {
92
+ const env = deps.env ?? process.env;
93
+ const runCli = deps.runCli ?? defaultCliRunner;
94
+ // Skills/prompts of plugins that arrived via /reload-plugins: pi re-runs
95
+ // this on every reload and session start, so they persist for the process.
96
+ pi.on("resources_discover", async () => discoverPaths(env));
97
+ async function runPlugin(args, cwd, ui) {
98
+ const path = cliPath(env);
99
+ if (!path) {
100
+ ui.notify("Plugin management needs the yagni launcher (YAGNI_CLI_PATH is unset in this session). Run `yagni plugin …` in a terminal.", "warning");
101
+ return false;
102
+ }
103
+ const result = await runCli(["plugin", ...args], { cliPath: path, cwd, env });
104
+ const out = result.stdout.trim();
105
+ const err = result.stderr.trim();
106
+ if (result.code === 0) {
107
+ if (out)
108
+ ui.notify(out, "info");
109
+ if (err)
110
+ ui.notify(err, "warning");
111
+ return true;
112
+ }
113
+ ui.notify(err || out || `yagni plugin ${args.join(" ")} failed (exit ${result.code})`, "error");
114
+ return false;
115
+ }
116
+ async function reload(ctx) {
117
+ let inventory;
118
+ try {
119
+ inventory = await fetchInventory({ cwd: ctx.cwd, env, runCli });
120
+ }
121
+ catch (err) {
122
+ ctx.ui.notify(`Could not read the plugin inventory — ${err instanceof Error ? err.message : String(err)}`, "error");
123
+ return;
124
+ }
125
+ const plan = planReload(inventory, env);
126
+ applyEnv(env, plan.env);
127
+ ctx.ui.notify(renderReloadSummary(plan.added, plan.removed), "info");
128
+ // Treat reload as terminal for this handler (pi docs): the runtime that
129
+ // registered us is torn down and re-created from the rewritten env.
130
+ await ctx.reload();
131
+ }
132
+ async function listing(ctx) {
133
+ try {
134
+ const inventory = await fetchInventory({ cwd: ctx.cwd, env, runCli });
135
+ return inventory;
136
+ }
137
+ catch (err) {
138
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
139
+ return null;
140
+ }
141
+ }
142
+ pi.registerCommand("reload-plugins", {
143
+ description: "Apply plugin installs/removals to this session without restarting.",
144
+ handler: async (_args, ctx) => {
145
+ await reload(ctx);
146
+ },
147
+ });
148
+ pi.registerCommand("plugin", {
149
+ description: "Manage Claude Code plugins: browse marketplaces, install, uninstall, enable/disable.",
150
+ handler: async (args, rawCtx) => {
151
+ const ctx = rawCtx;
152
+ const sub = args.trim().split(/\s+/).filter(Boolean);
153
+ const [verb] = sub;
154
+ if (verb === "help" || verb === "--help" || verb === "-h") {
155
+ ctx.ui.notify(USAGE, "info");
156
+ return;
157
+ }
158
+ if (verb === "list" || (!verb && !ctx.hasUI)) {
159
+ const inventory = await listing(ctx);
160
+ if (inventory)
161
+ ctx.ui.notify(renderInventory(inventory, env), "info");
162
+ return;
163
+ }
164
+ if (!verb) {
165
+ await interactive(ctx);
166
+ return;
167
+ }
168
+ if (verb === "reload") {
169
+ await reload(ctx);
170
+ return;
171
+ }
172
+ // Everything else is the CLI's grammar verbatim (install, uninstall,
173
+ // enable, disable, update, marketplace …); mutations reload afterwards.
174
+ const ok = await runPlugin(sub, ctx.cwd, ctx.ui);
175
+ const mutates = MUTATING.has(verb) || (verb === "marketplace" && sub[1] !== "list");
176
+ if (ok && mutates)
177
+ await reload(ctx);
178
+ },
179
+ });
180
+ async function interactive(ctx) {
181
+ const inventory = await listing(ctx);
182
+ if (!inventory)
183
+ return;
184
+ const choice = await ctx.ui.select("Plugins", [`Installed plugins (${inventory.plugins.length})`, "Browse marketplaces", "Update all", "Reload plugins", "Exit"], ctx.signal ? { signal: ctx.signal } : undefined);
185
+ if (!choice || choice === "Exit")
186
+ return;
187
+ if (choice.startsWith("Installed")) {
188
+ await installedMenu(ctx, inventory);
189
+ return;
190
+ }
191
+ if (choice === "Browse marketplaces") {
192
+ await browseMenu(ctx, inventory);
193
+ return;
194
+ }
195
+ if (choice === "Update all") {
196
+ if (await runPlugin(["update"], ctx.cwd, ctx.ui))
197
+ await reload(ctx);
198
+ return;
199
+ }
200
+ if (choice === "Reload plugins") {
201
+ await reload(ctx);
202
+ return;
203
+ }
204
+ }
205
+ async function installedMenu(ctx, inventory) {
206
+ if (inventory.plugins.length === 0) {
207
+ ctx.ui.notify(renderInventory(inventory, env), "info");
208
+ return;
209
+ }
210
+ const options = inventory.plugins.map((p) => `${p.key}${p.version ? ` · v${p.version}` : ""}`);
211
+ const choice = await ctx.ui.select("Installed plugins", [...options, "Back"], ctx.signal ? { signal: ctx.signal } : undefined);
212
+ if (!choice || choice === "Back")
213
+ return;
214
+ const plugin = inventory.plugins[options.indexOf(choice)];
215
+ if (!plugin)
216
+ return;
217
+ const actions = ["View details"];
218
+ if (plugin.origin === "yagni")
219
+ actions.push("Uninstall", "Disable");
220
+ actions.push("Back");
221
+ const action = await ctx.ui.select(describePlugin(plugin).split("\n")[0], actions, ctx.signal ? { signal: ctx.signal } : undefined);
222
+ if (!action || action === "Back")
223
+ return;
224
+ if (action === "View details") {
225
+ ctx.ui.notify(describePlugin(plugin), "info");
226
+ return;
227
+ }
228
+ if (action === "Uninstall") {
229
+ if (await runPlugin(["uninstall", plugin.key], ctx.cwd, ctx.ui))
230
+ await reload(ctx);
231
+ return;
232
+ }
233
+ if (action === "Disable") {
234
+ if (await runPlugin(["disable", plugin.key], ctx.cwd, ctx.ui))
235
+ await reload(ctx);
236
+ }
237
+ }
238
+ async function browseMenu(ctx, inventory) {
239
+ const path = cliPath(env);
240
+ if (!path) {
241
+ ctx.ui.notify("Plugin management needs the yagni launcher (YAGNI_CLI_PATH is unset in this session).", "warning");
242
+ return;
243
+ }
244
+ const result = await runCli(["plugin", "list", "--available"], { cliPath: path, cwd: ctx.cwd, env });
245
+ const catalog = parseCatalog(result.stdout);
246
+ if (catalog.length === 0) {
247
+ ctx.ui.notify(result.stdout.trim() || "No marketplaces added. Use `/plugin marketplace add <owner/repo | git-url | path>`.", "info");
248
+ return;
249
+ }
250
+ const installed = new Set(inventory.plugins.map((p) => p.key));
251
+ const entries = catalog.flatMap((mp) => mp.plugins.map((p) => ({ key: `${p.name}@${mp.name}`, label: `${p.name}@${mp.name}${p.version ? ` · v${p.version}` : ""}${installed.has(`${p.name}@${mp.name}`) ? " (installed)" : ""}${p.installable ? "" : " (remote source, not installable)"}` })));
252
+ const choice = await ctx.ui.select("Marketplace plugins", [...entries.map((e) => e.label), "Back"], ctx.signal ? { signal: ctx.signal } : undefined);
253
+ if (!choice || choice === "Back")
254
+ return;
255
+ const entry = entries[entries.map((e) => e.label).indexOf(choice)];
256
+ if (!entry)
257
+ return;
258
+ const scope = await ctx.ui.select(`Install ${entry.key}`, ["For all my projects (user)", "Only this repo (project)", "Back"], ctx.signal ? { signal: ctx.signal } : undefined);
259
+ if (!scope || scope === "Back")
260
+ return;
261
+ const args = ["install", entry.key, ...(scope.includes("project") ? ["-s", "project"] : [])];
262
+ if (await runPlugin(args, ctx.cwd, ctx.ui))
263
+ await reload(ctx);
264
+ }
265
+ }
266
+ /**
267
+ * Parse `yagni plugin list --available` text (the CLI's stable, tested
268
+ * shape: `<marketplace>:` headers, then ` <name> v<ver> [flags]` lines).
269
+ */
270
+ export function parseCatalog(text) {
271
+ const out = [];
272
+ let current = null;
273
+ for (const raw of text.split("\n")) {
274
+ const header = /^(\S.*):$/.exec(raw);
275
+ if (header) {
276
+ current = { name: header[1], plugins: [] };
277
+ out.push(current);
278
+ continue;
279
+ }
280
+ const entry = /^ (\S+)(?:\s+v(\S+))?(?:\s+\[([^\]]*)\])?\s*$/.exec(raw);
281
+ if (entry && current) {
282
+ current.plugins.push({
283
+ name: entry[1],
284
+ version: entry[2] ?? null,
285
+ installable: !(entry[3] ?? "").includes("not installable"),
286
+ });
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+ /** Exported for tests: the current stored inventory, if a reload happened. */
292
+ export { storedInventory };
293
+ //# sourceMappingURL=panel.js.map
package/dist/launch.js CHANGED
@@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import { agentDirEnvVar } from "./branding.js";
10
10
  import { otelChildEnv } from "./otel.js";
11
11
  import { PAD_X_ENV, resolvePadX } from "./padding.js";
12
+ import { resolveCliPath } from "./paths.js";
12
13
  import { ENGINEERING_PRACTICE_SECTION, promptEnrichmentDisabled } from "./promptEnrichment.js";
13
14
  /**
14
15
  * How close to expiry the token can be before launch warns. A coding session
@@ -76,6 +77,9 @@ export function buildLaunch(creds, passthroughArgs, opts) {
76
77
  // the launcher read (0600). Absent when the caller can't resolve it.
77
78
  ...(opts.profilePath ? { YAGNI_PROFILE_PATH: opts.profilePath } : {}),
78
79
  ...(opts.stateDir ? { YAGNI_CODE_HOME: opts.stateDir } : {}),
80
+ // The launcher's own entry, so the in-session /plugin panel and
81
+ // /reload-plugins can run `yagni plugin …` with the same binary.
82
+ YAGNI_CLI_PATH: resolveCliPath(),
79
83
  // Forward the launcher's version so the extension's crash reports carry
80
84
  // the shipping version (the bundled extension has no package.json to read).
81
85
  ...(opts.cliVersion ? { YAGNI_CODE_VERSION: opts.cliVersion } : {}),
package/dist/paths.d.ts CHANGED
@@ -19,6 +19,12 @@
19
19
  * fall back to resolving the workspace-linked package, which keeps `pnpm test`
20
20
  * and a fresh checkout working before anything is bundled.
21
21
  */
22
+ /**
23
+ * Absolute path to this CLI's entry (`dist/cli.js`). Forwarded to sessions as
24
+ * `YAGNI_CLI_PATH` so the extension's `/plugin` panel can run
25
+ * `yagni plugin …` with the exact binary that launched it.
26
+ */
27
+ export declare function resolveCliPath(): string;
22
28
  export declare function resolveExtensionPath(): string;
23
29
  /**
24
30
  * Absolute path to the extension's headless pipeline entry
package/dist/paths.js CHANGED
@@ -25,6 +25,14 @@ function resolveModulePath(specifier) {
25
25
  * fall back to resolving the workspace-linked package, which keeps `pnpm test`
26
26
  * and a fresh checkout working before anything is bundled.
27
27
  */
28
+ /**
29
+ * Absolute path to this CLI's entry (`dist/cli.js`). Forwarded to sessions as
30
+ * `YAGNI_CLI_PATH` so the extension's `/plugin` panel can run
31
+ * `yagni plugin …` with the exact binary that launched it.
32
+ */
33
+ export function resolveCliPath() {
34
+ return fileURLToPath(new URL("./cli.js", import.meta.url));
35
+ }
28
36
  export function resolveExtensionPath() {
29
37
  const bundled = fileURLToPath(new URL("./extension/index.js", import.meta.url));
30
38
  if (existsSync(bundled))
@@ -29,6 +29,7 @@ export interface ParsedPluginArgs {
29
29
  scope: PluginInstallScope;
30
30
  scopeExplicit: boolean;
31
31
  available: boolean;
32
+ json: boolean;
32
33
  }
33
34
  export declare function parsePluginArgs(args: string[]): ParsedPluginArgs;
34
35
  /** The repo root a project-scope install applies to: nearest `.git` ancestor, else cwd. */
@@ -12,7 +12,7 @@
12
12
  import { existsSync, readdirSync, statSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { delimiter, dirname, isAbsolute, join, sep } from "node:path";
15
- import { readTrustDecision } from "./claudeCompat.js";
15
+ import { pluginInventory, readTrustDecision } from "./claudeCompat.js";
16
16
  import { discoverClaudePlugins } from "./claudePlugins.js";
17
17
  import { agentDir } from "./credentials.js";
18
18
  import { DISTRIBUTION } from "./distribution.js";
@@ -30,6 +30,10 @@ const USAGE = `Usage:
30
30
  ${cmd} plugin disable <plugin>[@marketplace]
31
31
  ${cmd} plugin update [<plugin>[@marketplace]]
32
32
  ${cmd} plugin list [--available]
33
+ ${cmd} plugin inventory --json (machine-readable; backs the in-session /plugin panel)
34
+
35
+ In a session: /plugin opens the same panel; /reload-plugins applies changes
36
+ without restarting.
33
37
 
34
38
  Scopes (install/uninstall):
35
39
  user available in all your projects (default)
@@ -46,7 +50,14 @@ Examples:
46
50
  ${cmd} plugin update
47
51
  `;
48
52
  export function parsePluginArgs(args) {
49
- const out = { subcommand: undefined, positionals: [], scope: "user", scopeExplicit: false, available: false };
53
+ const out = {
54
+ subcommand: undefined,
55
+ positionals: [],
56
+ scope: "user",
57
+ scopeExplicit: false,
58
+ available: false,
59
+ json: false,
60
+ };
50
61
  const scopeFrom = (value) => {
51
62
  if (value === "user" || value === "project")
52
63
  return value;
@@ -68,6 +79,10 @@ export function parsePluginArgs(args) {
68
79
  out.available = true;
69
80
  continue;
70
81
  }
82
+ if (arg === "--json") {
83
+ out.json = true;
84
+ continue;
85
+ }
71
86
  if (arg === "-h" || arg === "--help") {
72
87
  out.subcommand = "help";
73
88
  continue;
@@ -138,6 +153,8 @@ export async function pluginCommand(args, deps = {}) {
138
153
  return await update(parsed, io);
139
154
  case "list":
140
155
  return parsed.available ? listAvailable(io) : await list(io);
156
+ case "inventory":
157
+ return await inventory(io);
141
158
  default:
142
159
  stderr(`Unknown plugin subcommand "${parsed.subcommand}".\n${USAGE}`);
143
160
  return 1;
@@ -289,6 +306,12 @@ async function update(parsed, io) {
289
306
  io.stdout(`Takes effect on the next ${cmd} launch.\n`);
290
307
  return report.errors.length > 0 ? 1 : 0;
291
308
  }
309
+ // ── inventory (machine-readable, for the in-session panel) ─────────────────
310
+ async function inventory(io) {
311
+ const result = pluginInventory({ cwd: io.cwd, agentDir: await io.agentDir(), homeDir: io.home, env: io.env });
312
+ io.stdout(`${JSON.stringify(result)}\n`);
313
+ return 0;
314
+ }
292
315
  // ── list ────────────────────────────────────────────────────────────────────
293
316
  async function list(io) {
294
317
  const discovered = discoverClaudePlugins({ cwd: io.cwd, homeDir: io.home, yagniLedger: readLedger(io.store.home) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.6-staging.1257.1",
3
+ "version": "1.0.6-staging.1261.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "62c167d693dbb2ae2209ae48ab39078b14995fd0"
61
+ "yagniSourceSha": "4f16be93d0007e813ca15b60ac80c1ce08fd04b4"
62
62
  }