@bldg-7/cc-plugin-loader 0.1.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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@bldg-7/cc-plugin-loader",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "files": ["src"],
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "changeset": "changeset",
13
+ "version": "changeset version",
14
+ "release": "npm publish"
15
+ },
16
+ "dependencies": {
17
+ "@opencode-ai/plugin": "latest",
18
+ "js-yaml": "^4.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@changesets/changelog-github": "^0.6.0",
22
+ "@changesets/cli": "^2.30.0",
23
+ "@types/js-yaml": "^4.0.9",
24
+ "bun-types": "latest",
25
+ "typescript": "^5.7.0"
26
+ }
27
+ }
@@ -0,0 +1,30 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+
4
+ /** Claude Code tool name → OpenCode tool name */
5
+ export const TOOL_NAME_MAP: Record<string, string> = {
6
+ Bash: "bash",
7
+ Read: "read",
8
+ Write: "write",
9
+ Edit: "edit",
10
+ Glob: "glob",
11
+ Grep: "grep",
12
+ WebFetch: "webfetch",
13
+ WebSearch: "websearch",
14
+ Agent: "agent",
15
+ NotebookEdit: "notebookedit",
16
+ };
17
+
18
+ /** Claude Code model alias → OpenCode model ID */
19
+ export const MODEL_MAP: Record<string, string> = {
20
+ sonnet: "anthropic/claude-sonnet-4-6",
21
+ opus: "anthropic/claude-opus-4-6",
22
+ haiku: "anthropic/claude-haiku-4-5-20251001",
23
+ };
24
+
25
+ export const INSTALLED_PLUGINS_PATH = join(
26
+ homedir(),
27
+ ".claude",
28
+ "plugins",
29
+ "installed_plugins.json",
30
+ );
@@ -0,0 +1,49 @@
1
+ import { readFile } from "fs/promises";
2
+ import { parseFrontmatter } from "../parser.js";
3
+ import type { ParsedPlugin, ParsedSkill, ParsedCommand } from "../types.js";
4
+
5
+ type Loadable = ParsedSkill | ParsedCommand;
6
+
7
+ export function createCommandHook(plugins: ParsedPlugin[]) {
8
+ // Build lookup map: qualifiedName → Loadable
9
+ const lookup = new Map<string, Loadable>();
10
+
11
+ for (const plugin of plugins) {
12
+ for (const skill of plugin.skills) {
13
+ lookup.set(skill.qualifiedName, skill);
14
+ }
15
+ for (const cmd of plugin.commands) {
16
+ lookup.set(cmd.qualifiedName, cmd);
17
+ }
18
+ }
19
+
20
+ return async (
21
+ input: { command: string; sessionID: string; arguments: string },
22
+ output: { parts: Array<{ type: string; [key: string]: unknown }> },
23
+ ): Promise<void> => {
24
+ const item = lookup.get(input.command);
25
+ if (!item) return; // Not our command, leave parts untouched
26
+
27
+ // Lazy-load content
28
+ let content: string;
29
+ if (item._content) {
30
+ content = item._content;
31
+ } else {
32
+ try {
33
+ const raw = await readFile(item.contentPath, "utf-8");
34
+ const { body } = parseFrontmatter<unknown>(raw);
35
+ item._content = body;
36
+ content = body;
37
+ } catch (e) {
38
+ content = `[cc-plugin-loader] Failed to load ${item.contentPath}: ${e}`;
39
+ }
40
+ }
41
+
42
+ // Append ARGUMENTS (Claude Code pattern)
43
+ const fullContent = input.arguments
44
+ ? `${content}\n\nARGUMENTS: ${input.arguments}`
45
+ : content;
46
+
47
+ output.parts = [{ type: "text", text: fullContent }];
48
+ };
49
+ }
@@ -0,0 +1,60 @@
1
+ import type { Config } from "@opencode-ai/sdk";
2
+ import { TOOL_NAME_MAP, MODEL_MAP } from "../constants.js";
3
+ import type { ParsedPlugin } from "../types.js";
4
+
5
+ function mapTools(tools: string[]): Record<string, boolean> {
6
+ const result: Record<string, boolean> = {};
7
+ for (const tool of tools) {
8
+ const mapped = TOOL_NAME_MAP[tool] || tool.toLowerCase();
9
+ result[mapped] = true;
10
+ }
11
+ return result;
12
+ }
13
+
14
+ export function createConfigHook(plugins: ParsedPlugin[]) {
15
+ return async (config: Config): Promise<void> => {
16
+ if (!config.agent) config.agent = {};
17
+ if (!config.mcp) config.mcp = {};
18
+ if (!config.command) config.command = {};
19
+
20
+ for (const plugin of plugins) {
21
+ // Register agents
22
+ for (const agent of plugin.agents) {
23
+ const key = `${plugin.name}-${agent.name}`;
24
+ config.agent[key] = {
25
+ description: agent.description,
26
+ mode: "subagent",
27
+ prompt: agent.prompt,
28
+ tools: mapTools(agent.tools),
29
+ ...(agent.model && { model: MODEL_MAP[agent.model] || agent.model }),
30
+ ...(agent.maxTurns && { maxSteps: agent.maxTurns }),
31
+ };
32
+ }
33
+
34
+ // Register MCP servers
35
+ for (const mcp of plugin.mcpServers) {
36
+ config.mcp[mcp.qualifiedName] = {
37
+ type: "local" as const,
38
+ command: [mcp.command, ...mcp.args],
39
+ environment: mcp.env,
40
+ };
41
+ }
42
+
43
+ // Register skills as commands
44
+ for (const skill of plugin.skills) {
45
+ config.command[skill.qualifiedName] = {
46
+ template: "$ARGUMENTS",
47
+ description: skill.description,
48
+ };
49
+ }
50
+
51
+ // Register commands
52
+ for (const cmd of plugin.commands) {
53
+ config.command[cmd.qualifiedName] = {
54
+ template: "$ARGUMENTS",
55
+ description: cmd.description,
56
+ };
57
+ }
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,20 @@
1
+ import type { ParsedPlugin } from "../types.js";
2
+
3
+ export function createEnvHook(plugins: ParsedPlugin[]) {
4
+ // Pre-build env map
5
+ const envVars: Record<string, string> = {};
6
+
7
+ for (const plugin of plugins) {
8
+ const safeName = plugin.name.replace(/-/g, "_").toUpperCase();
9
+ envVars[`CLAUDE_PLUGIN_ROOT_${safeName}`] = plugin.installPath;
10
+ // Last plugin wins for backwards compat
11
+ envVars["CLAUDE_PLUGIN_ROOT"] = plugin.installPath;
12
+ }
13
+
14
+ return async (
15
+ _input: { cwd: string; sessionID?: string; callID?: string },
16
+ output: { env: Record<string, string> },
17
+ ): Promise<void> => {
18
+ Object.assign(output.env, envVars);
19
+ };
20
+ }
@@ -0,0 +1,40 @@
1
+ import type { ParsedPlugin } from "../types.js";
2
+
3
+ export function createSystemHook(plugins: ParsedPlugin[]) {
4
+ // Pre-build static strings at init time
5
+ const systemParts: string[] = [];
6
+
7
+ for (const plugin of plugins) {
8
+ // Inject CLAUDE.md
9
+ if (plugin.claudeMd) {
10
+ systemParts.push(
11
+ `# ${plugin.name} Plugin Instructions\n\n${plugin.claudeMd}`,
12
+ );
13
+ }
14
+
15
+ // Build skill/command summary
16
+ const entries: string[] = [];
17
+
18
+ for (const skill of plugin.skills) {
19
+ entries.push(`- ${plugin.name}:${skill.name}: ${skill.description}`);
20
+ }
21
+ for (const cmd of plugin.commands) {
22
+ entries.push(`- ${plugin.name}:${cmd.name}: ${cmd.description}`);
23
+ }
24
+
25
+ if (entries.length > 0) {
26
+ systemParts.push(
27
+ `# Available commands from ${plugin.name} plugin\n\nThe following skills are available for use with the Skill tool:\n\n${entries.join("\n")}`,
28
+ );
29
+ }
30
+ }
31
+
32
+ return async (
33
+ _input: { sessionID?: string; model: unknown },
34
+ output: { system: string[] },
35
+ ): Promise<void> => {
36
+ for (const part of systemParts) {
37
+ output.system.push(part);
38
+ }
39
+ };
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { loadInstalledPlugins } from "./registry.js";
3
+ import { loadPlugin } from "./loader.js";
4
+ import { createConfigHook } from "./hooks/config.js";
5
+ import { createSystemHook } from "./hooks/system.js";
6
+ import { createCommandHook } from "./hooks/command.js";
7
+ import { createEnvHook } from "./hooks/env.js";
8
+ import type { ParsedPlugin } from "./types.js";
9
+
10
+ const plugin: Plugin = async (input) => {
11
+ const installations = await loadInstalledPlugins({
12
+ directory: input.directory,
13
+ worktree: input.worktree,
14
+ });
15
+
16
+ if (!installations.length) {
17
+ console.warn("[cc-plugin-loader] No matching plugins found");
18
+ return {};
19
+ }
20
+
21
+ const loaded = await Promise.all(installations.map(loadPlugin));
22
+ const plugins: ParsedPlugin[] = loaded.filter(
23
+ (p): p is ParsedPlugin => p !== null,
24
+ );
25
+
26
+ if (!plugins.length) {
27
+ console.warn("[cc-plugin-loader] All plugins failed to load");
28
+ return {};
29
+ }
30
+
31
+ console.log(
32
+ `[cc-plugin-loader] Loaded ${plugins.length} plugin(s): ${plugins.map((p) => p.name).join(", ")}`,
33
+ );
34
+
35
+ return {
36
+ config: createConfigHook(plugins),
37
+ "experimental.chat.system.transform": createSystemHook(plugins),
38
+ "command.execute.before": createCommandHook(plugins),
39
+ "shell.env": createEnvHook(plugins),
40
+ };
41
+ };
42
+
43
+ export default plugin;
package/src/loader.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { readFile, readdir, stat } from "fs/promises";
2
+ import { join, basename } from "path";
3
+ import { parseFrontmatter, normalizeTools } from "./parser.js";
4
+ import type {
5
+ PluginManifest,
6
+ PluginInstallation,
7
+ SkillFrontmatter,
8
+ AgentFrontmatter,
9
+ CommandFrontmatter,
10
+ McpConfig,
11
+ ParsedPlugin,
12
+ ParsedSkill,
13
+ ParsedAgent,
14
+ ParsedCommand,
15
+ ParsedMcp,
16
+ } from "./types.js";
17
+
18
+ async function readFileSafe(path: string): Promise<string | null> {
19
+ try {
20
+ return await readFile(path, "utf-8");
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ async function dirExists(path: string): Promise<boolean> {
27
+ try {
28
+ return (await stat(path)).isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function loadPlugin(
35
+ inst: PluginInstallation & { pluginKey: string },
36
+ ): Promise<ParsedPlugin | null> {
37
+ const { installPath, pluginKey } = inst;
38
+
39
+ // Read manifest
40
+ const manifestRaw = await readFileSafe(
41
+ join(installPath, ".claude-plugin", "plugin.json"),
42
+ );
43
+ let manifest: PluginManifest;
44
+ if (manifestRaw) {
45
+ try {
46
+ manifest = JSON.parse(manifestRaw);
47
+ } catch {
48
+ console.warn(
49
+ `[cc-plugin-loader] Failed to parse plugin.json for ${pluginKey}`,
50
+ );
51
+ return null;
52
+ }
53
+ } else {
54
+ // Derive name from key (e.g. "my-plugin@registry" → "my-plugin")
55
+ manifest = { name: pluginKey.split("@")[0] };
56
+ }
57
+
58
+ const pluginName = manifest.name;
59
+
60
+ const [claudeMd, skills, agents, commands, mcpServers] = await Promise.all([
61
+ readFileSafe(join(installPath, "CLAUDE.md")),
62
+ loadSkills(installPath, pluginName),
63
+ loadAgents(installPath, pluginName),
64
+ loadCommands(installPath, pluginName),
65
+ loadMcpServers(installPath, pluginName),
66
+ ]);
67
+
68
+ return {
69
+ name: pluginName,
70
+ installPath,
71
+ claudeMd: claudeMd ?? undefined,
72
+ skills,
73
+ agents,
74
+ commands,
75
+ mcpServers,
76
+ };
77
+ }
78
+
79
+ async function loadSkills(
80
+ installPath: string,
81
+ pluginName: string,
82
+ ): Promise<ParsedSkill[]> {
83
+ const skillsDir = join(installPath, "skills");
84
+ if (!(await dirExists(skillsDir))) return [];
85
+
86
+ const entries = await readdir(skillsDir, { withFileTypes: true });
87
+ const skills: ParsedSkill[] = [];
88
+
89
+ for (const entry of entries) {
90
+ if (!entry.isDirectory()) continue;
91
+
92
+ const skillMdPath = join(skillsDir, entry.name, "SKILL.md");
93
+ const raw = await readFileSafe(skillMdPath);
94
+ if (!raw) continue;
95
+
96
+ try {
97
+ const { frontmatter } = parseFrontmatter<SkillFrontmatter>(raw);
98
+ const name = frontmatter.name || entry.name;
99
+ skills.push({
100
+ pluginName,
101
+ name,
102
+ qualifiedName: `${pluginName}:${name}`,
103
+ description: frontmatter.description || "",
104
+ tools: normalizeTools(frontmatter),
105
+ contentPath: skillMdPath,
106
+ });
107
+ } catch (e) {
108
+ console.warn(
109
+ `[cc-plugin-loader] Failed to parse skill ${entry.name} in ${pluginName}:`,
110
+ e,
111
+ );
112
+ }
113
+ }
114
+
115
+ return skills;
116
+ }
117
+
118
+ async function loadAgents(
119
+ installPath: string,
120
+ pluginName: string,
121
+ ): Promise<ParsedAgent[]> {
122
+ const agentsDir = join(installPath, "agents");
123
+ if (!(await dirExists(agentsDir))) return [];
124
+
125
+ const entries = await readdir(agentsDir, { withFileTypes: true });
126
+ const agents: ParsedAgent[] = [];
127
+
128
+ for (const entry of entries) {
129
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
130
+
131
+ const agentPath = join(agentsDir, entry.name);
132
+ const raw = await readFileSafe(agentPath);
133
+ if (!raw) continue;
134
+
135
+ try {
136
+ const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(raw);
137
+ const name = frontmatter.name || basename(entry.name, ".md");
138
+
139
+ // Replace ${CLAUDE_PLUGIN_ROOT} with actual installPath
140
+ const prompt = body.replaceAll("${CLAUDE_PLUGIN_ROOT}", installPath);
141
+
142
+ agents.push({
143
+ pluginName,
144
+ name,
145
+ description: frontmatter.description || "",
146
+ model: frontmatter.model,
147
+ tools: normalizeTools(frontmatter),
148
+ maxTurns: frontmatter.maxTurns,
149
+ prompt,
150
+ });
151
+ } catch (e) {
152
+ console.warn(
153
+ `[cc-plugin-loader] Failed to parse agent ${entry.name} in ${pluginName}:`,
154
+ e,
155
+ );
156
+ }
157
+ }
158
+
159
+ return agents;
160
+ }
161
+
162
+ async function loadCommands(
163
+ installPath: string,
164
+ pluginName: string,
165
+ ): Promise<ParsedCommand[]> {
166
+ const commandsDir = join(installPath, "commands");
167
+ if (!(await dirExists(commandsDir))) return [];
168
+
169
+ const entries = await readdir(commandsDir, { withFileTypes: true });
170
+ const commands: ParsedCommand[] = [];
171
+
172
+ for (const entry of entries) {
173
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
174
+
175
+ const cmdPath = join(commandsDir, entry.name);
176
+ const raw = await readFileSafe(cmdPath);
177
+ if (!raw) continue;
178
+
179
+ try {
180
+ const { frontmatter } = parseFrontmatter<CommandFrontmatter>(raw);
181
+ const name = frontmatter.name || basename(entry.name, ".md");
182
+ commands.push({
183
+ pluginName,
184
+ name,
185
+ qualifiedName: `${pluginName}:${name}`,
186
+ description: frontmatter.description || "",
187
+ contentPath: cmdPath,
188
+ });
189
+ } catch (e) {
190
+ console.warn(
191
+ `[cc-plugin-loader] Failed to parse command ${entry.name} in ${pluginName}:`,
192
+ e,
193
+ );
194
+ }
195
+ }
196
+
197
+ return commands;
198
+ }
199
+
200
+ async function loadMcpServers(
201
+ installPath: string,
202
+ pluginName: string,
203
+ ): Promise<ParsedMcp[]> {
204
+ const mcpPath = join(installPath, ".mcp.json");
205
+ const raw = await readFileSafe(mcpPath);
206
+ if (!raw) return [];
207
+
208
+ let config: McpConfig;
209
+ try {
210
+ config = JSON.parse(raw);
211
+ } catch {
212
+ console.warn(
213
+ `[cc-plugin-loader] Failed to parse .mcp.json in ${pluginName}`,
214
+ );
215
+ return [];
216
+ }
217
+
218
+ if (!config.mcpServers) return [];
219
+
220
+ const servers: ParsedMcp[] = [];
221
+ for (const [serverName, server] of Object.entries(config.mcpServers)) {
222
+ // Expand env variables
223
+ const env: Record<string, string> = {};
224
+ if (server.env) {
225
+ for (const [k, v] of Object.entries(server.env)) {
226
+ env[k] = expandEnv(v);
227
+ }
228
+ }
229
+
230
+ servers.push({
231
+ pluginName,
232
+ serverName,
233
+ qualifiedName: `${pluginName}-${serverName}`,
234
+ command: server.command,
235
+ args: server.args || [],
236
+ env,
237
+ });
238
+ }
239
+
240
+ return servers;
241
+ }
242
+
243
+ function expandEnv(value: string): string {
244
+ return value.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
245
+ const val = process.env[varName];
246
+ if (val === undefined) {
247
+ console.warn(
248
+ `[cc-plugin-loader] Environment variable ${varName} is not set`,
249
+ );
250
+ return "";
251
+ }
252
+ return val;
253
+ });
254
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,34 @@
1
+ import yaml from "js-yaml";
2
+
3
+ export interface Parsed<T> {
4
+ frontmatter: T;
5
+ body: string;
6
+ }
7
+
8
+ const FM_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
9
+
10
+ export function parseFrontmatter<T>(content: string): Parsed<T> {
11
+ const match = content.match(FM_REGEX);
12
+ if (!match) {
13
+ return { frontmatter: {} as T, body: content };
14
+ }
15
+ const frontmatter = (yaml.load(match[1]) ?? {}) as T;
16
+ const body = match[2].trim();
17
+ return { frontmatter, body };
18
+ }
19
+
20
+ /**
21
+ * Normalize tools from either `allowed-tools` (YAML array) or `tools` (comma-separated string).
22
+ */
23
+ export function normalizeTools(fm: {
24
+ "allowed-tools"?: string[];
25
+ tools?: string;
26
+ }): string[] {
27
+ if (fm["allowed-tools"] && Array.isArray(fm["allowed-tools"])) {
28
+ return fm["allowed-tools"];
29
+ }
30
+ if (fm.tools && typeof fm.tools === "string") {
31
+ return fm.tools.split(",").map((t) => t.trim()).filter(Boolean);
32
+ }
33
+ return [];
34
+ }
@@ -0,0 +1,118 @@
1
+ import { readFile } from "fs/promises";
2
+ import { join, resolve } from "path";
3
+ import { INSTALLED_PLUGINS_PATH } from "./constants.js";
4
+ import type {
5
+ InstalledPluginsFile,
6
+ InstalledPluginsFileV1,
7
+ InstalledPluginsFileV2,
8
+ PluginInstallation,
9
+ } from "./types.js";
10
+
11
+ interface RegistryContext {
12
+ directory: string;
13
+ worktree: string;
14
+ }
15
+
16
+ /**
17
+ * Convert V1 format to V2.
18
+ * V1 has no scope — all entries become scope:"user".
19
+ * V1 may or may not have installPath; if missing, derive from plugin key + version.
20
+ */
21
+ function convertV1toV2(v1: InstalledPluginsFileV1): InstalledPluginsFileV2 {
22
+ const plugins: Record<string, PluginInstallation[]> = {};
23
+ for (const [key, entry] of Object.entries(v1.plugins)) {
24
+ const installPath =
25
+ entry.installPath || deriveInstallPath(key, entry.version);
26
+ plugins[key] = [
27
+ {
28
+ scope: "user",
29
+ installPath,
30
+ version: entry.version,
31
+ installedAt: entry.installedAt,
32
+ lastUpdated: entry.lastUpdated,
33
+ gitCommitSha: entry.gitCommitSha,
34
+ },
35
+ ];
36
+ }
37
+ return { version: 2, plugins };
38
+ }
39
+
40
+ /** Replicate Claude Code's cache path derivation: ~/.claude/plugins/cache/{marketplace}/{name}/{version} */
41
+ function deriveInstallPath(key: string, version: string): string {
42
+ const [name, marketplace] = key.includes("@")
43
+ ? key.split("@")
44
+ : [key, "unknown"];
45
+ const safeMp = (marketplace || "unknown").replace(/[^a-zA-Z0-9\-_]/g, "-");
46
+ const safeName = (name || key).replace(/[^a-zA-Z0-9\-_]/g, "-");
47
+ const safeVer = version.replace(/[^a-zA-Z0-9\-_.]/g, "-");
48
+ return join(INSTALLED_PLUGINS_PATH, "..", "cache", safeMp, safeName, safeVer);
49
+ }
50
+
51
+ function normalizeFile(raw: unknown): InstalledPluginsFileV2 {
52
+ const data = raw as InstalledPluginsFile;
53
+ const version = typeof data?.version === "number" ? data.version : 1;
54
+ if (version === 1) {
55
+ console.log("[cc-plugin-loader] Converting V1 installed_plugins.json to V2");
56
+ return convertV1toV2(data as InstalledPluginsFileV1);
57
+ }
58
+ return data as InstalledPluginsFileV2;
59
+ }
60
+
61
+ export async function loadInstalledPlugins(
62
+ ctx: RegistryContext,
63
+ ): Promise<(PluginInstallation & { pluginKey: string })[]> {
64
+ let data: InstalledPluginsFileV2;
65
+ try {
66
+ const raw = await readFile(INSTALLED_PLUGINS_PATH, "utf-8");
67
+ data = normalizeFile(JSON.parse(raw));
68
+ } catch {
69
+ console.warn(
70
+ `[cc-plugin-loader] installed_plugins.json not found at ${INSTALLED_PLUGINS_PATH}`,
71
+ );
72
+ return [];
73
+ }
74
+
75
+ const dir = resolve(ctx.directory);
76
+ const wt = resolve(ctx.worktree);
77
+ const seen = new Set<string>();
78
+ const result: (PluginInstallation & { pluginKey: string })[] = [];
79
+
80
+ for (const [key, installations] of Object.entries(data.plugins)) {
81
+ for (const inst of installations) {
82
+ if (!matchesScope(inst, dir, wt)) continue;
83
+
84
+ // Deduplicate by installPath
85
+ const dedup = `${key}::${inst.installPath}`;
86
+ if (seen.has(dedup)) continue;
87
+ seen.add(dedup);
88
+
89
+ result.push({ ...inst, pluginKey: key });
90
+ }
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ function matchesScope(
97
+ inst: PluginInstallation,
98
+ dir: string,
99
+ worktree: string,
100
+ ): boolean {
101
+ switch (inst.scope) {
102
+ case "user":
103
+ return true;
104
+ case "project": {
105
+ if (!inst.projectPath) return false;
106
+ const pp = resolve(inst.projectPath);
107
+ return dir === pp || worktree === pp;
108
+ }
109
+ case "local": {
110
+ if (!inst.projectPath) return false;
111
+ const pp = resolve(inst.projectPath);
112
+ return dir.startsWith(pp + "/") || dir === pp ||
113
+ worktree.startsWith(pp + "/") || worktree === pp;
114
+ }
115
+ default:
116
+ return false;
117
+ }
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,124 @@
1
+ // ── Source types (Claude Code plugin format) ──
2
+
3
+ /** V1: each plugin key maps to a single entry (no scope, no array) */
4
+ export interface InstalledPluginsFileV1 {
5
+ version: 1;
6
+ plugins: Record<string, PluginInstallationV1>;
7
+ }
8
+
9
+ export interface PluginInstallationV1 {
10
+ version: string;
11
+ installedAt: string;
12
+ lastUpdated?: string;
13
+ installPath: string;
14
+ gitCommitSha?: string;
15
+ }
16
+
17
+ /** V2: each plugin key maps to an array of scoped entries */
18
+ export interface InstalledPluginsFileV2 {
19
+ version: 2;
20
+ plugins: Record<string, PluginInstallation[]>;
21
+ }
22
+
23
+ export type InstalledPluginsFile = InstalledPluginsFileV1 | InstalledPluginsFileV2;
24
+
25
+ export interface PluginInstallation {
26
+ scope: "user" | "project" | "local";
27
+ installPath: string;
28
+ projectPath?: string;
29
+ version: string;
30
+ installedAt: string;
31
+ lastUpdated?: string;
32
+ gitCommitSha?: string;
33
+ }
34
+
35
+ export interface PluginManifest {
36
+ name: string;
37
+ description?: string;
38
+ version?: string;
39
+ author?: { name: string };
40
+ repository?: string;
41
+ license?: string;
42
+ }
43
+
44
+ export interface SkillFrontmatter {
45
+ name?: string;
46
+ description?: string;
47
+ "allowed-tools"?: string[];
48
+ tools?: string;
49
+ }
50
+
51
+ export interface AgentFrontmatter {
52
+ name: string;
53
+ description: string;
54
+ model?: string;
55
+ "allowed-tools"?: string[];
56
+ tools?: string;
57
+ maxTurns?: number;
58
+ }
59
+
60
+ export interface CommandFrontmatter {
61
+ name?: string;
62
+ description?: string;
63
+ }
64
+
65
+ export interface McpServerConfig {
66
+ type: "stdio";
67
+ command: string;
68
+ args?: string[];
69
+ env?: Record<string, string>;
70
+ }
71
+
72
+ export interface McpConfig {
73
+ mcpServers: Record<string, McpServerConfig>;
74
+ }
75
+
76
+ // ── Parsed types (intermediate representation) ──
77
+
78
+ export interface ParsedSkill {
79
+ pluginName: string;
80
+ name: string;
81
+ qualifiedName: string;
82
+ description: string;
83
+ tools: string[];
84
+ contentPath: string;
85
+ _content?: string;
86
+ }
87
+
88
+ export interface ParsedAgent {
89
+ pluginName: string;
90
+ name: string;
91
+ description: string;
92
+ model?: string;
93
+ tools: string[];
94
+ maxTurns?: number;
95
+ prompt: string;
96
+ }
97
+
98
+ export interface ParsedCommand {
99
+ pluginName: string;
100
+ name: string;
101
+ qualifiedName: string;
102
+ description: string;
103
+ contentPath: string;
104
+ _content?: string;
105
+ }
106
+
107
+ export interface ParsedMcp {
108
+ pluginName: string;
109
+ serverName: string;
110
+ qualifiedName: string;
111
+ command: string;
112
+ args: string[];
113
+ env: Record<string, string>;
114
+ }
115
+
116
+ export interface ParsedPlugin {
117
+ name: string;
118
+ installPath: string;
119
+ claudeMd?: string;
120
+ skills: ParsedSkill[];
121
+ agents: ParsedAgent[];
122
+ commands: ParsedCommand[];
123
+ mcpServers: ParsedMcp[];
124
+ }