@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.
- package/CHANGELOG.md +51 -0
- package/package.json +7 -7
- package/src/cli/args.ts +7 -0
- package/src/cli/classify-install-target.ts +50 -0
- package/src/cli/plugin-cli.ts +245 -31
- package/src/commands/plugin.ts +3 -0
- package/src/config/model-registry.ts +37 -0
- package/src/config/model-resolver.ts +18 -3
- package/src/config/settings-schema.ts +24 -13
- package/src/cursor.ts +66 -1
- package/src/discovery/claude-plugins.ts +95 -5
- package/src/discovery/helpers.ts +168 -41
- package/src/discovery/plugin-dir-roots.ts +28 -0
- package/src/discovery/substitute-plugin-root.ts +29 -0
- package/src/extensibility/plugins/index.ts +1 -0
- package/src/extensibility/plugins/marketplace/cache.ts +136 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +354 -0
- package/src/extensibility/plugins/marketplace/index.ts +6 -0
- package/src/extensibility/plugins/marketplace/manager.ts +528 -0
- package/src/extensibility/plugins/marketplace/registry.ts +181 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +147 -0
- package/src/extensibility/plugins/marketplace/types.ts +177 -0
- package/src/extensibility/skills.ts +3 -3
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/local-protocol.ts +2 -19
- package/src/internal-urls/parse.ts +72 -0
- package/src/internal-urls/router.ts +2 -18
- package/src/lsp/config.ts +9 -0
- package/src/main.ts +50 -1
- package/src/modes/components/plugin-selector.ts +86 -0
- package/src/modes/components/settings-defs.ts +9 -4
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/mcp-command-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +104 -13
- package/src/modes/interactive-mode.ts +9 -0
- package/src/modes/types.ts +1 -0
- package/src/prompts/agents/reviewer.md +3 -4
- package/src/prompts/tools/bash.md +3 -3
- package/src/sdk.ts +0 -7
- package/src/session/agent-session.ts +292 -6
- package/src/slash-commands/builtin-registry.ts +273 -0
- package/src/tools/bash-skill-urls.ts +48 -5
- package/src/tools/bash.ts +2 -0
- package/src/tools/read.ts +15 -9
- package/src/web/search/code-search.ts +2 -179
- package/src/web/search/index.ts +2 -3
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -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 (
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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
|
+
}
|