@oh-my-pi/pi-coding-agent 13.16.5 → 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 +45 -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/settings-schema.ts +12 -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/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 +0 -4
- 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 +4 -0
- package/src/modes/types.ts +1 -0
- package/src/prompts/agents/reviewer.md +3 -4
- package/src/sdk.ts +0 -7
- package/src/slash-commands/builtin-registry.ts +273 -0
- package/src/tools/bash-skill-urls.ts +48 -5
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin cache management.
|
|
3
|
+
*
|
|
4
|
+
* Cache layout: `<cacheDir>/<marketplace>___<pluginName>___<version>/`
|
|
5
|
+
*
|
|
6
|
+
* All three components are validated before any filesystem operation:
|
|
7
|
+
* - marketplace / pluginName: isValidNameSegment (lowercase alnum + hyphens, max 64)
|
|
8
|
+
* - version: isValidVersionForCache (alnum + ._+-, max 128)
|
|
9
|
+
*
|
|
10
|
+
* This ensures cache paths cannot be crafted to escape the cache directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as nodeFs from "node:fs";
|
|
14
|
+
import * as fs from "node:fs/promises";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
18
|
+
|
|
19
|
+
import { isValidNameSegment } from "./types";
|
|
20
|
+
|
|
21
|
+
// Reject anything that could be used for path traversal or shell injection in
|
|
22
|
+
// version strings. Only printable, unambiguous characters are allowed.
|
|
23
|
+
const VERSION_RE = /^[a-zA-Z0-9._+-]+$/;
|
|
24
|
+
|
|
25
|
+
/** Return true when `version` is safe for use as a cache path component. */
|
|
26
|
+
export function isValidVersionForCache(version: string): boolean {
|
|
27
|
+
// prevent path-traversal sequences like ".." or "1..2"
|
|
28
|
+
return version.length > 0 && version.length <= 128 && VERSION_RE.test(version) && !version.includes("..");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateCacheComponents(marketplace: string, pluginName: string, version: string): void {
|
|
32
|
+
if (!isValidNameSegment(marketplace)) {
|
|
33
|
+
throw new Error(`Invalid marketplace name for cache: "${marketplace}"`);
|
|
34
|
+
}
|
|
35
|
+
if (!isValidNameSegment(pluginName)) {
|
|
36
|
+
throw new Error(`Invalid plugin name for cache: "${pluginName}"`);
|
|
37
|
+
}
|
|
38
|
+
if (!isValidVersionForCache(version)) {
|
|
39
|
+
throw new Error(`Invalid version for cache: "${version}"`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return the absolute path for a cached plugin directory.
|
|
45
|
+
* Throws if any component fails validation.
|
|
46
|
+
*/
|
|
47
|
+
export function getCachedPluginPath(
|
|
48
|
+
cacheDir: string,
|
|
49
|
+
marketplace: string,
|
|
50
|
+
pluginName: string,
|
|
51
|
+
version: string,
|
|
52
|
+
): string {
|
|
53
|
+
validateCacheComponents(marketplace, pluginName, version);
|
|
54
|
+
return path.join(cacheDir, `${marketplace}___${pluginName}___${version}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Copy `sourcePath` into the cache, returning the absolute cache path.
|
|
59
|
+
*
|
|
60
|
+
* Idempotent: if the target already exists it is removed before copying,
|
|
61
|
+
* so a partial previous cache is never silently reused.
|
|
62
|
+
*/
|
|
63
|
+
export async function cachePlugin(
|
|
64
|
+
sourcePath: string,
|
|
65
|
+
cacheDir: string,
|
|
66
|
+
marketplace: string,
|
|
67
|
+
pluginName: string,
|
|
68
|
+
version: string,
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const targetPath = getCachedPluginPath(cacheDir, marketplace, pluginName, version);
|
|
71
|
+
|
|
72
|
+
// Ensure cache directory exists before writing into it
|
|
73
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
// Copy to a staging directory first, then atomically rename into place.
|
|
76
|
+
// This prevents destroying an active install if fs.cp fails mid-copy.
|
|
77
|
+
const stagingPath = `${targetPath}.staging-${Date.now()}`;
|
|
78
|
+
try {
|
|
79
|
+
await fs.cp(sourcePath, stagingPath, { recursive: true });
|
|
80
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
81
|
+
await fs.rename(stagingPath, targetPath);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Clean up staging dir on any failure; leave existing targetPath intact
|
|
84
|
+
await fs.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return targetPath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Synchronous check — true when the cache directory exists on disk.
|
|
93
|
+
* Uses `existsSync` because callers may need to run this check inline without async.
|
|
94
|
+
*/
|
|
95
|
+
export function isCached(cacheDir: string, marketplace: string, pluginName: string, version: string): boolean {
|
|
96
|
+
const targetPath = getCachedPluginPath(cacheDir, marketplace, pluginName, version);
|
|
97
|
+
return nodeFs.existsSync(targetPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove a single cached plugin directory. No-op if it does not exist. */
|
|
101
|
+
export async function removeCachedPlugin(
|
|
102
|
+
cacheDir: string,
|
|
103
|
+
marketplace: string,
|
|
104
|
+
pluginName: string,
|
|
105
|
+
version: string,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const targetPath = getCachedPluginPath(cacheDir, marketplace, pluginName, version);
|
|
108
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remove all cache entries whose full path is not in `installedPaths`.
|
|
113
|
+
*
|
|
114
|
+
* Returns the count of removed directories. If `cacheDir` does not exist,
|
|
115
|
+
* returns `{ removed: 0 }` rather than throwing.
|
|
116
|
+
*/
|
|
117
|
+
export async function cleanOrphanedCache(cacheDir: string, installedPaths: Set<string>): Promise<{ removed: number }> {
|
|
118
|
+
let entries: string[];
|
|
119
|
+
try {
|
|
120
|
+
entries = await fs.readdir(cacheDir);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (isEnoent(err)) return { removed: 0 };
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let removed = 0;
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const fullPath = path.join(cacheDir, entry);
|
|
129
|
+
if (!installedPaths.has(fullPath)) {
|
|
130
|
+
await fs.rm(fullPath, { recursive: true, force: true });
|
|
131
|
+
removed++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { removed };
|
|
136
|
+
}
|