@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +45 -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/settings-schema.ts +12 -13
  8. package/src/cursor.ts +66 -1
  9. package/src/discovery/claude-plugins.ts +95 -5
  10. package/src/discovery/helpers.ts +168 -41
  11. package/src/discovery/plugin-dir-roots.ts +28 -0
  12. package/src/discovery/substitute-plugin-root.ts +29 -0
  13. package/src/extensibility/plugins/index.ts +1 -0
  14. package/src/extensibility/plugins/marketplace/cache.ts +136 -0
  15. package/src/extensibility/plugins/marketplace/fetcher.ts +354 -0
  16. package/src/extensibility/plugins/marketplace/index.ts +6 -0
  17. package/src/extensibility/plugins/marketplace/manager.ts +528 -0
  18. package/src/extensibility/plugins/marketplace/registry.ts +181 -0
  19. package/src/extensibility/plugins/marketplace/source-resolver.ts +147 -0
  20. package/src/extensibility/plugins/marketplace/types.ts +177 -0
  21. package/src/internal-urls/index.ts +1 -0
  22. package/src/internal-urls/local-protocol.ts +2 -19
  23. package/src/internal-urls/parse.ts +72 -0
  24. package/src/internal-urls/router.ts +2 -18
  25. package/src/lsp/config.ts +9 -0
  26. package/src/main.ts +50 -1
  27. package/src/modes/components/plugin-selector.ts +86 -0
  28. package/src/modes/components/settings-defs.ts +0 -4
  29. package/src/modes/controllers/mcp-command-controller.ts +14 -0
  30. package/src/modes/controllers/selector-controller.ts +104 -13
  31. package/src/modes/interactive-mode.ts +4 -0
  32. package/src/modes/types.ts +1 -0
  33. package/src/prompts/agents/reviewer.md +3 -4
  34. package/src/sdk.ts +0 -7
  35. package/src/slash-commands/builtin-registry.ts +273 -0
  36. package/src/tools/bash-skill-urls.ts +48 -5
  37. package/src/tools/read.ts +15 -9
  38. package/src/web/search/code-search.ts +2 -179
  39. package/src/web/search/index.ts +2 -3
  40. 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 { 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";
@@ -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
+ }