@oh-my-pi/pi-coding-agent 13.16.4 → 13.17.0

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 (47) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/classify-install-target.ts +50 -0
  5. package/src/cli/plugin-cli.ts +245 -31
  6. package/src/commands/plugin.ts +3 -0
  7. package/src/config/model-registry.ts +37 -0
  8. package/src/config/model-resolver.ts +18 -3
  9. package/src/config/settings-schema.ts +24 -13
  10. package/src/cursor.ts +66 -1
  11. package/src/discovery/claude-plugins.ts +95 -5
  12. package/src/discovery/helpers.ts +168 -41
  13. package/src/discovery/plugin-dir-roots.ts +28 -0
  14. package/src/discovery/substitute-plugin-root.ts +29 -0
  15. package/src/extensibility/plugins/index.ts +1 -0
  16. package/src/extensibility/plugins/marketplace/cache.ts +136 -0
  17. package/src/extensibility/plugins/marketplace/fetcher.ts +354 -0
  18. package/src/extensibility/plugins/marketplace/index.ts +6 -0
  19. package/src/extensibility/plugins/marketplace/manager.ts +528 -0
  20. package/src/extensibility/plugins/marketplace/registry.ts +181 -0
  21. package/src/extensibility/plugins/marketplace/source-resolver.ts +147 -0
  22. package/src/extensibility/plugins/marketplace/types.ts +177 -0
  23. package/src/extensibility/skills.ts +3 -3
  24. package/src/internal-urls/index.ts +1 -0
  25. package/src/internal-urls/local-protocol.ts +2 -19
  26. package/src/internal-urls/parse.ts +72 -0
  27. package/src/internal-urls/router.ts +2 -18
  28. package/src/lsp/config.ts +9 -0
  29. package/src/main.ts +50 -1
  30. package/src/modes/components/plugin-selector.ts +86 -0
  31. package/src/modes/components/settings-defs.ts +9 -4
  32. package/src/modes/controllers/event-controller.ts +10 -0
  33. package/src/modes/controllers/mcp-command-controller.ts +14 -0
  34. package/src/modes/controllers/selector-controller.ts +104 -13
  35. package/src/modes/interactive-mode.ts +9 -0
  36. package/src/modes/types.ts +1 -0
  37. package/src/prompts/agents/reviewer.md +3 -4
  38. package/src/prompts/tools/bash.md +3 -3
  39. package/src/sdk.ts +0 -7
  40. package/src/session/agent-session.ts +292 -6
  41. package/src/slash-commands/builtin-registry.ts +273 -0
  42. package/src/tools/bash-skill-urls.ts +48 -5
  43. package/src/tools/bash.ts +2 -0
  44. package/src/tools/read.ts +15 -9
  45. package/src/web/search/code-search.ts +2 -179
  46. package/src/web/search/index.ts +2 -3
  47. package/src/web/search/types.ts +1 -5
@@ -198,6 +198,18 @@ export const SETTINGS_SCHEMA = {
198
198
 
199
199
  extensions: { type: "array", default: EMPTY_STRING_ARRAY },
200
200
 
201
+ "marketplace.autoUpdate": {
202
+ type: "enum",
203
+ values: ["off", "notify", "auto"] as const,
204
+ default: "notify",
205
+ ui: {
206
+ tab: "tools",
207
+ label: "Marketplace Auto-Update",
208
+ description: "Check for plugin updates on startup (off/notify/auto)",
209
+ submenu: true,
210
+ },
211
+ },
212
+
201
213
  enabledModels: { type: "array", default: EMPTY_STRING_ARRAY },
202
214
 
203
215
  disabledProviders: { type: "array", default: EMPTY_STRING_ARRAY },
@@ -525,6 +537,18 @@ export const SETTINGS_SCHEMA = {
525
537
  },
526
538
 
527
539
  "retry.baseDelayMs": { type: "number", default: 2000 },
540
+ "retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
541
+ "retry.fallbackRevertPolicy": {
542
+ type: "enum",
543
+ values: ["cooldown-expiry", "never"] as const,
544
+ default: "cooldown-expiry",
545
+ ui: {
546
+ tab: "model",
547
+ label: "Fallback Revert Policy",
548
+ description: "When to return to the primary model after a fallback",
549
+ submenu: true,
550
+ },
551
+ },
528
552
 
529
553
  // ────────────────────────────────────────────────────────────────────────
530
554
  // Interaction
@@ -1478,19 +1502,6 @@ export const SETTINGS_SCHEMA = {
1478
1502
  submenu: true,
1479
1503
  },
1480
1504
  },
1481
-
1482
- "providers.codeSearch": {
1483
- type: "enum",
1484
- values: ["grep", "exa"] as const,
1485
- default: "grep",
1486
- ui: {
1487
- tab: "providers",
1488
- label: "Code Search Provider",
1489
- description: "Provider for code search tool",
1490
- submenu: true,
1491
- },
1492
- },
1493
-
1494
1505
  "providers.image": {
1495
1506
  type: "enum",
1496
1507
  values: ["auto", "gemini", "openrouter"] as const,
package/src/cursor.ts CHANGED
@@ -7,7 +7,12 @@ import type {
7
7
  AgentToolResult,
8
8
  AgentToolUpdateCallback,
9
9
  } from "@oh-my-pi/pi-agent-core";
10
- import type { CursorMcpCall, CursorExecHandlers as ICursorExecHandlers, ToolResultMessage } from "@oh-my-pi/pi-ai";
10
+ import type {
11
+ CursorMcpCall,
12
+ CursorShellStreamCallbacks,
13
+ CursorExecHandlers as ICursorExecHandlers,
14
+ ToolResultMessage,
15
+ } from "@oh-my-pi/pi-ai";
11
16
  import { resolveToCwd } from "./tools/path-utils";
12
17
 
13
18
  interface CursorExecBridgeOptions {
@@ -204,6 +209,66 @@ export class CursorExecHandlers implements ICursorExecHandlers {
204
209
  return toolResultMessage;
205
210
  }
206
211
 
212
+ async shellStream(
213
+ args: Parameters<NonNullable<ICursorExecHandlers["shellStream"]>>[0],
214
+ callbacks: CursorShellStreamCallbacks,
215
+ ) {
216
+ const toolCallId = decodeToolCallId(args.toolCallId);
217
+ const toolName = "bash";
218
+ const tool = this.options.tools.get(toolName);
219
+ if (!tool) {
220
+ const result = buildToolErrorResult(`Tool "${toolName}" not available`);
221
+ return createToolResultMessage(toolCallId, toolName, result, true);
222
+ }
223
+
224
+ const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
225
+ const toolArgs: Record<string, unknown> = {
226
+ command: args.command,
227
+ cwd: args.workingDirectory || undefined,
228
+ timeout: timeoutSeconds,
229
+ };
230
+
231
+ this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs });
232
+
233
+ let result: AgentToolResult<unknown>;
234
+ let isError = false;
235
+
236
+ // Track previously streamed text so we only forward deltas.
237
+ let streamedLen = 0;
238
+ const onUpdate: AgentToolUpdateCallback<unknown> = partialResult => {
239
+ this.options.emitEvent?.({
240
+ type: "tool_execution_update",
241
+ toolCallId,
242
+ toolName,
243
+ args: toolArgs,
244
+ partialResult,
245
+ });
246
+ const text = partialResult.content.map(c => (c.type === "text" ? c.text : "")).join("");
247
+ if (text.length > streamedLen) {
248
+ callbacks.onStdout(text.slice(streamedLen));
249
+ streamedLen = text.length;
250
+ }
251
+ };
252
+
253
+ try {
254
+ result = await tool.execute(toolCallId, toolArgs, undefined, onUpdate, this.options.getToolContext?.());
255
+ } catch (error) {
256
+ const message = error instanceof Error ? error.message : String(error);
257
+ result = buildToolErrorResult(message);
258
+ isError = true;
259
+ }
260
+
261
+ // onUpdate may not fire for every chunk — flush any remaining output
262
+ // from the final result that wasn't already streamed.
263
+ const finalText = result.content.map(c => (c.type === "text" ? c.text : "")).join("");
264
+ if (finalText.length > streamedLen) {
265
+ callbacks.onStdout(finalText.slice(streamedLen));
266
+ }
267
+
268
+ this.options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
269
+ return createToolResultMessage(toolCallId, toolName, result, isError);
270
+ }
271
+
207
272
  async diagnostics(args: Parameters<NonNullable<ICursorExecHandlers["diagnostics"]>>[0]) {
208
273
  const toolCallId = decodeToolCallId(args.toolCallId);
209
274
  const toolResultMessage = await executeTool(this.options, "lsp", toolCallId, {
@@ -5,13 +5,24 @@
5
5
  * Priority: 70 (below claude.ts at 80, so user overrides in .claude/ take precedence)
6
6
  */
7
7
  import * as path from "node:path";
8
+ import { logger } from "@oh-my-pi/pi-utils";
8
9
  import { registerProvider } from "../capability";
10
+ import { readFile } from "../capability/fs";
9
11
  import { type Hook, hookCapability } from "../capability/hook";
12
+ import { type MCPServer, mcpCapability } from "../capability/mcp";
10
13
  import { type Skill, skillCapability } from "../capability/skill";
11
14
  import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
12
15
  import { type CustomTool, toolCapability } from "../capability/tool";
13
16
  import type { LoadContext, LoadResult } from "../capability/types";
14
- import { type ClaudePluginRoot, listClaudePluginRoots, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
17
+ import {
18
+ type ClaudePluginRoot,
19
+ createSourceMeta,
20
+ listClaudePluginRoots,
21
+ loadFilesFromDir,
22
+ scanSkillsFromDir,
23
+ } from "./helpers";
24
+
25
+ import { substitutePluginRoot } from "./substitute-plugin-root";
15
26
 
16
27
  const PROVIDER_ID = "claude-plugins";
17
28
  const DISPLAY_NAME = "Claude Code Marketplace";
@@ -31,16 +42,20 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
31
42
  const results = await Promise.all(
32
43
  roots.map(async root => {
33
44
  const skillsDir = path.join(root.path, "skills");
34
- return scanSkillsFromDir(ctx, {
45
+ const result = await scanSkillsFromDir(ctx, {
35
46
  dir: skillsDir,
36
47
  providerId: PROVIDER_ID,
37
48
  level: root.scope,
38
49
  });
50
+ return { root, result };
39
51
  }),
40
52
  );
41
53
 
42
- for (const result of results) {
43
- items.push(...result.items);
54
+ for (const { root, result } of results) {
55
+ for (const skill of result.items) {
56
+ if (root.plugin) skill.name = `${root.plugin}:${skill.name}`;
57
+ items.push(skill);
58
+ }
44
59
  if (result.warnings) warnings.push(...result.warnings);
45
60
  }
46
61
 
@@ -66,7 +81,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
66
81
  transform: (name, content, filePath, source) => {
67
82
  const cmdName = name.replace(/\.md$/, "");
68
83
  return {
69
- name: cmdName,
84
+ name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
70
85
  path: filePath,
71
86
  content,
72
87
  level: root.scope,
@@ -169,6 +184,73 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
169
184
  return { items, warnings };
170
185
  }
171
186
 
187
+ // =============================================================================
188
+ // MCP Servers
189
+ // =============================================================================
190
+
191
+ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
192
+ const items: MCPServer[] = [];
193
+ const warnings: string[] = [];
194
+
195
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home);
196
+ warnings.push(...rootWarnings);
197
+
198
+ for (const root of roots) {
199
+ const mcpPath = path.join(root.path, ".mcp.json");
200
+ const raw = await readFile(mcpPath);
201
+ if (raw === null) continue; // file absent — skip silently
202
+
203
+ let parsed: unknown;
204
+ try {
205
+ parsed = JSON.parse(raw);
206
+ } catch {
207
+ warnings.push(`[claude-plugins] Invalid JSON in ${mcpPath}`);
208
+ logger.warn(`[claude-plugins] Invalid JSON in ${mcpPath}`);
209
+ continue;
210
+ }
211
+
212
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
213
+ const config = parsed as { mcpServers?: Record<string, unknown> };
214
+ if (!config.mcpServers || typeof config.mcpServers !== "object") continue;
215
+
216
+ for (const [serverName, serverCfg] of Object.entries(config.mcpServers)) {
217
+ if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
218
+ const raw = serverCfg as {
219
+ enabled?: boolean;
220
+ timeout?: number;
221
+ command?: string;
222
+ args?: string[];
223
+ env?: Record<string, string>;
224
+ cwd?: string;
225
+ url?: string;
226
+ headers?: Record<string, string>;
227
+ auth?: MCPServer["auth"];
228
+ oauth?: MCPServer["oauth"];
229
+ type?: string;
230
+ };
231
+ const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
232
+ const server: MCPServer = {
233
+ name: namespacedName,
234
+ ...(raw.enabled !== undefined && { enabled: raw.enabled }),
235
+ ...(raw.timeout !== undefined && { timeout: raw.timeout }),
236
+ ...(raw.command !== undefined && { command: substitutePluginRoot(raw.command, root.path) }),
237
+ ...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }),
238
+ ...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }),
239
+ ...(raw.cwd !== undefined && { cwd: substitutePluginRoot(raw.cwd, root.path) }),
240
+ ...(raw.url !== undefined && { url: raw.url }),
241
+ ...(raw.headers !== undefined && { headers: raw.headers }),
242
+ ...(raw.auth !== undefined && { auth: raw.auth }),
243
+ ...(raw.oauth !== undefined && { oauth: raw.oauth }),
244
+ ...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
245
+ _source: createSourceMeta(PROVIDER_ID, mcpPath, root.scope),
246
+ };
247
+ items.push(server);
248
+ }
249
+ }
250
+
251
+ return { items, warnings };
252
+ }
253
+
172
254
  // =============================================================================
173
255
  // Provider Registration
174
256
  // =============================================================================
@@ -204,3 +286,11 @@ registerProvider<CustomTool>(toolCapability.id, {
204
286
  priority: PRIORITY,
205
287
  load: loadTools,
206
288
  });
289
+
290
+ registerProvider<MCPServer>(mcpCapability.id, {
291
+ id: PROVIDER_ID,
292
+ displayName: DISPLAY_NAME,
293
+ description: "Load MCP servers from marketplace plugin .mcp.json files",
294
+ priority: PRIORITY,
295
+ load: loadMCPServers,
296
+ });
@@ -9,6 +9,7 @@ import type { Skill, SkillFrontmatter } from "../capability/skill";
9
9
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
10
10
  import { parseThinkingLevel } from "../thinking";
11
11
  import { parseFrontmatter } from "../utils/frontmatter";
12
+ import { buildPluginDirRoot } from "./plugin-dir-roots";
12
13
 
13
14
  /**
14
15
  * Standard paths for each config source.
@@ -593,6 +594,7 @@ export interface ClaudePluginEntry {
593
594
  installedAt: string;
594
595
  lastUpdated: string;
595
596
  gitCommitSha?: string;
597
+ enabled?: boolean;
596
598
  }
597
599
 
598
600
  /**
@@ -652,56 +654,106 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
652
654
  const roots: ClaudePluginRoot[] = [];
653
655
  const warnings: string[] = [];
654
656
 
657
+ // ── Claude Code registry ──────────────────────────────────────────────────
655
658
  const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json");
656
659
  const content = await readFile(registryPath);
657
660
 
658
- if (!content) {
659
- // No registry file - not an error, just no plugins
660
- const result = { roots, warnings };
661
- pluginRootsCache.set(home, result);
662
- return result;
663
- }
664
-
665
- const registry = parseClaudePluginsRegistry(content);
666
- if (!registry) {
667
- warnings.push(`Failed to parse Claude Code plugin registry: ${registryPath}`);
668
- const result = { roots, warnings };
669
- pluginRootsCache.set(home, result);
670
- return result;
671
- }
672
-
673
- for (const [pluginId, entries] of Object.entries(registry.plugins)) {
674
- if (!Array.isArray(entries) || entries.length === 0) continue;
675
-
676
- // Parse plugin ID format: "plugin-name@marketplace"
677
- const atIndex = pluginId.lastIndexOf("@");
678
- if (atIndex === -1) {
679
- warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
680
- continue;
661
+ if (content) {
662
+ const registry = parseClaudePluginsRegistry(content);
663
+ if (!registry) {
664
+ warnings.push(`Failed to parse Claude Code plugin registry: ${registryPath}`);
665
+ } else {
666
+ for (const [pluginId, entries] of Object.entries(registry.plugins)) {
667
+ if (!Array.isArray(entries) || entries.length === 0) continue;
668
+
669
+ // Parse plugin ID format: "plugin-name@marketplace"
670
+ const atIndex = pluginId.lastIndexOf("@");
671
+ if (atIndex === -1) {
672
+ warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
673
+ continue;
674
+ }
675
+
676
+ const pluginName = pluginId.slice(0, atIndex);
677
+ const marketplace = pluginId.slice(atIndex + 1);
678
+
679
+ // Process all valid entries, not just the first one.
680
+ // This handles plugins with multiple installs (different scopes/versions).
681
+ for (const entry of entries) {
682
+ if (!entry.installPath || typeof entry.installPath !== "string") {
683
+ warnings.push(`Plugin ${pluginId} entry has no installPath`);
684
+ continue;
685
+ }
686
+ if (entry.enabled === false) continue;
687
+
688
+ roots.push({
689
+ id: pluginId,
690
+ marketplace,
691
+ plugin: pluginName,
692
+ version: entry.version || "unknown",
693
+ path: entry.installPath,
694
+ scope: entry.scope || "user",
695
+ });
696
+ }
697
+ }
681
698
  }
699
+ }
682
700
 
683
- const pluginName = pluginId.slice(0, atIndex);
684
- const marketplace = pluginId.slice(atIndex + 1);
685
-
686
- // Process all valid entries, not just the first one.
687
- // This handles plugins with multiple installs (different scopes/versions).
688
- for (const entry of entries) {
689
- if (!entry.installPath || typeof entry.installPath !== "string") {
690
- warnings.push(`Plugin ${pluginId} entry has no installPath`);
691
- continue;
701
+ // ── OMP installed plugins registry ───────────────────────────────────────
702
+ // OMP registry is authoritative: its entries replace Claude's entries for the same plugin ID.
703
+ // Path derived from `home` (not os.homedir()) so test isolation works when home is overridden.
704
+ const ompRegistryPath = path.join(home, getConfigDirName(), "plugins", "installed_plugins.json");
705
+ const ompContent = await readFile(ompRegistryPath);
706
+ if (ompContent) {
707
+ const ompRegistry = parseClaudePluginsRegistry(ompContent);
708
+ if (ompRegistry) {
709
+ for (const [pluginId, entries] of Object.entries(ompRegistry.plugins)) {
710
+ if (!Array.isArray(entries) || entries.length === 0) continue;
711
+
712
+ const atIndex = pluginId.lastIndexOf("@");
713
+ if (atIndex === -1) {
714
+ warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
715
+ continue;
716
+ }
717
+ const pluginName = pluginId.slice(0, atIndex);
718
+ const marketplace = pluginId.slice(atIndex + 1);
719
+
720
+ // OMP is authoritative: drop all Claude-sourced entries for this plugin ID
721
+ const filtered = roots.filter(r => r.id !== pluginId);
722
+ roots.length = 0;
723
+ roots.push(...filtered);
724
+
725
+ for (const entry of entries) {
726
+ if (!entry.installPath || typeof entry.installPath !== "string") {
727
+ warnings.push(`Plugin ${pluginId} entry has no installPath`);
728
+ continue;
729
+ }
730
+ if (entry.enabled === false) continue;
731
+ // Deduplicate by installPath within same ID
732
+ if (roots.some(r => r.id === pluginId && r.path === entry.installPath)) continue;
733
+
734
+ roots.push({
735
+ id: pluginId,
736
+ marketplace,
737
+ plugin: pluginName,
738
+ version: entry.version || "unknown",
739
+ path: entry.installPath,
740
+ scope: entry.scope || "user",
741
+ });
742
+ }
692
743
  }
693
-
694
- roots.push({
695
- id: pluginId,
696
- marketplace,
697
- plugin: pluginName,
698
- version: entry.version || "unknown",
699
- path: entry.installPath,
700
- scope: entry.scope || "user",
701
- });
744
+ } else {
745
+ warnings.push(`Failed to parse OMP plugin registry: ${ompRegistryPath}`);
702
746
  }
703
747
  }
704
748
 
749
+ // Merge --plugin-dir roots (highest precedence) on every fresh load
750
+ if (injectedPluginDirRoots.length > 0) {
751
+ const injectedIds = new Set(injectedPluginDirRoots.map(r => r.id));
752
+ const filtered = roots.filter(r => !injectedIds.has(r.id));
753
+ roots.length = 0;
754
+ roots.push(...injectedPluginDirRoots, ...filtered);
755
+ }
756
+
705
757
  const result = { roots, warnings };
706
758
  pluginRootsCache.set(home, result);
707
759
  return result;
@@ -712,4 +764,79 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
712
764
  */
713
765
  export function clearClaudePluginRootsCache(): void {
714
766
  pluginRootsCache.clear();
767
+ preloadedPluginRoots = [...injectedPluginDirRoots];
768
+ // Re-warm preloaded roots asynchronously so sync LSP config reads stay valid
769
+ if (lastPreloadHome) {
770
+ void preloadPluginRoots(lastPreloadHome);
771
+ }
772
+ }
773
+
774
+ // ── Preloaded plugin roots (for sync consumers like LSP config) ─────────────
775
+ // Populated at startup by preloadPluginRoots(). Read synchronously by
776
+ // getPreloadedPluginRoots(). Safe degradation: empty array if not warmed.
777
+
778
+ let preloadedPluginRoots: ClaudePluginRoot[] = [];
779
+ let injectedPluginDirRoots: ClaudePluginRoot[] = [];
780
+ let lastPreloadHome: string | undefined;
781
+
782
+ /**
783
+ * Populate the module-level plugin roots cache for sync consumers.
784
+ * Call during session initialization, after dir resolution completes
785
+ * but before any LSP config is read.
786
+ */
787
+ export async function preloadPluginRoots(home: string): Promise<void> {
788
+ lastPreloadHome = home;
789
+ const { roots } = await listClaudePluginRoots(home);
790
+ preloadedPluginRoots = roots;
791
+ }
792
+
793
+ /**
794
+ * Get pre-loaded plugin roots synchronously.
795
+ * Returns empty array if preloadPluginRoots() hasn't been called.
796
+ */
797
+ export function getPreloadedPluginRoots(): readonly ClaudePluginRoot[] {
798
+ return preloadedPluginRoots;
799
+ }
800
+
801
+ // ── --plugin-dir injection ──────────────────────────────────────────────────
802
+
803
+ /**
804
+ * Inject synthetic plugin roots from --plugin-dir paths.
805
+ * These are prepended to the cache with highest precedence (before OMP/Claude entries).
806
+ * Must be called before any listClaudePluginRoots() access.
807
+ */
808
+ export async function injectPluginDirRoots(home: string, dirs: string[]): Promise<void> {
809
+ // Ensure the base cache is populated first
810
+ const { roots, warnings } = await listClaudePluginRoots(home);
811
+
812
+ const injected: ClaudePluginRoot[] = [];
813
+ for (const dir of dirs) {
814
+ const resolved = path.resolve(dir);
815
+ // Read plugin name from manifest
816
+ let pluginName = path.basename(resolved);
817
+ try {
818
+ const manifestPath = path.join(resolved, ".claude-plugin", "plugin.json");
819
+ const content = await Bun.file(manifestPath).text();
820
+ const manifest = JSON.parse(content);
821
+ if (typeof manifest.name === "string" && manifest.name) {
822
+ pluginName = manifest.name;
823
+ }
824
+ } catch {
825
+ // No manifest or invalid — use directory name
826
+ }
827
+
828
+ injected.push(buildPluginDirRoot(resolved, pluginName));
829
+ }
830
+
831
+ // --plugin-dir roots have highest precedence: prepend them,
832
+ // removing any existing entries with the same plugin ID.
833
+ injectedPluginDirRoots = injected;
834
+
835
+ const injectedIds = new Set(injected.map(r => r.id));
836
+ const filtered = roots.filter(r => !injectedIds.has(r.id));
837
+ const merged = [...injected, ...filtered];
838
+
839
+ // Replace the cache entry
840
+ pluginRootsCache.set(home, { roots: merged, warnings });
841
+ preloadedPluginRoots = merged;
715
842
  }
@@ -0,0 +1,28 @@
1
+ import * as path from "node:path";
2
+
3
+ /** Synthetic plugin root for a --plugin-dir path. Shape-compatible with ClaudePluginRoot. */
4
+ export interface PluginDirRoot {
5
+ id: string;
6
+ marketplace: string;
7
+ plugin: string;
8
+ version: string;
9
+ path: string;
10
+ scope: "user" | "project";
11
+ }
12
+
13
+ /**
14
+ * Build a synthetic plugin root from a --plugin-dir resolved path.
15
+ * @param resolvedPath Absolute path to the plugin directory
16
+ * @param manifestName Plugin name from manifest; falls back to directory basename
17
+ */
18
+ export function buildPluginDirRoot(resolvedPath: string, manifestName?: string): PluginDirRoot {
19
+ const pluginName = manifestName || path.basename(resolvedPath);
20
+ return {
21
+ id: `${pluginName}@__local__`,
22
+ marketplace: "__local__",
23
+ plugin: pluginName,
24
+ version: "local",
25
+ path: resolvedPath,
26
+ scope: "user",
27
+ };
28
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT}
3
+ * with the actual plugin root path in strings, arrays, and plain objects.
4
+ */
5
+ // Use concatenation to avoid noTemplateCurlyInString lint rule on literal placeholder names
6
+ const CLAUDE_VAR = "$" + "{CLAUDE_PLUGIN_ROOT}";
7
+ const OMP_VAR = "$" + "{OMP_PLUGIN_ROOT}";
8
+
9
+ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
10
+ if (typeof value === "string") {
11
+ return value.replaceAll(CLAUDE_VAR, rootPath).replaceAll(OMP_VAR, rootPath) as T;
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return value.map(v => substitutePluginRoot(v, rootPath)) as T;
15
+ }
16
+ if (value && typeof value === "object") {
17
+ const result: Record<string, unknown> = Object.create(null);
18
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
19
+ Object.defineProperty(result, k, {
20
+ value: substitutePluginRoot(v, rootPath),
21
+ enumerable: true,
22
+ writable: true,
23
+ configurable: true,
24
+ });
25
+ }
26
+ return result as T;
27
+ }
28
+ return value;
29
+ }
@@ -4,5 +4,6 @@ export * from "./doctor";
4
4
  export * from "./git-url";
5
5
  export * from "./loader";
6
6
  export * from "./manager";
7
+ export * from "./marketplace";
7
8
  export * from "./parser";
8
9
  export type * from "./types";