@bldg-7/cc-plugin-loader 0.1.0 → 0.2.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 CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@bldg-7/cc-plugin-loader",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
- "files": ["src"],
6
+ "files": [
7
+ "src"
8
+ ],
7
9
  "publishConfig": {
8
10
  "access": "public"
9
11
  },
package/src/constants.ts CHANGED
@@ -12,9 +12,18 @@ export const TOOL_NAME_MAP: Record<string, string> = {
12
12
  WebFetch: "webfetch",
13
13
  WebSearch: "websearch",
14
14
  Agent: "agent",
15
- NotebookEdit: "notebookedit",
15
+ TodoWrite: "todowrite",
16
+ Task: "task",
17
+ LS: "ls",
18
+ Skill: "skill",
16
19
  };
17
20
 
21
+ /** OpenCode tool name → Claude Code tool name (reverse of TOOL_NAME_MAP) */
22
+ export const REVERSE_TOOL_NAME_MAP: Record<string, string> =
23
+ Object.fromEntries(
24
+ Object.entries(TOOL_NAME_MAP).map(([cc, oc]) => [oc, cc]),
25
+ );
26
+
18
27
  /** Claude Code model alias → OpenCode model ID */
19
28
  export const MODEL_MAP: Record<string, string> = {
20
29
  sonnet: "anthropic/claude-sonnet-4-6",
@@ -0,0 +1,99 @@
1
+ import { spawn } from "child_process";
2
+ import type { HookStdinPayload, HookStdoutResult } from "./bridge-types.js";
3
+
4
+ const DEFAULT_TIMEOUT = 10_000;
5
+
6
+ /**
7
+ * Execute a hook command as a subprocess.
8
+ *
9
+ * - Writes `payload` as JSON to stdin
10
+ * - Parses stdout as JSON
11
+ * - Kills process on timeout
12
+ * - Returns null on any error (non-zero exit, parse failure, timeout)
13
+ */
14
+ export async function executeHookCommand(
15
+ command: string,
16
+ payload: HookStdinPayload,
17
+ timeout: number = DEFAULT_TIMEOUT,
18
+ env?: Record<string, string>,
19
+ ): Promise<HookStdoutResult | null> {
20
+ return new Promise((resolve) => {
21
+ const child = spawn("sh", ["-c", command], {
22
+ stdio: ["pipe", "pipe", "pipe"],
23
+ env: { ...process.env, ...env },
24
+ cwd: payload.cwd,
25
+ });
26
+
27
+ let stdout = "";
28
+ let stderr = "";
29
+ let settled = false;
30
+
31
+ const timer = setTimeout(() => {
32
+ if (!settled) {
33
+ settled = true;
34
+ child.kill("SIGKILL");
35
+ console.warn(
36
+ `[cc-plugin-loader] Hook command timed out after ${timeout}ms: ${command}`,
37
+ );
38
+ resolve(null);
39
+ }
40
+ }, timeout);
41
+
42
+ child.stdout.on("data", (data: Buffer) => {
43
+ stdout += data.toString();
44
+ });
45
+
46
+ child.stderr.on("data", (data: Buffer) => {
47
+ stderr += data.toString();
48
+ });
49
+
50
+ child.on("error", (err) => {
51
+ if (!settled) {
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ console.warn(
55
+ `[cc-plugin-loader] Hook command failed to spawn: ${command}`,
56
+ err.message,
57
+ );
58
+ resolve(null);
59
+ }
60
+ });
61
+
62
+ child.on("close", (code) => {
63
+ if (settled) return;
64
+ settled = true;
65
+ clearTimeout(timer);
66
+
67
+ if (code !== 0) {
68
+ console.warn(
69
+ `[cc-plugin-loader] Hook command exited with code ${code}: ${command}`,
70
+ stderr || "(no stderr)",
71
+ );
72
+ resolve(null);
73
+ return;
74
+ }
75
+
76
+ const trimmed = stdout.trim();
77
+ if (!trimmed) {
78
+ // Empty stdout is valid — treat as { continue: true }
79
+ resolve({ continue: true });
80
+ return;
81
+ }
82
+
83
+ try {
84
+ const result = JSON.parse(trimmed) as HookStdoutResult;
85
+ resolve(result);
86
+ } catch {
87
+ console.warn(
88
+ `[cc-plugin-loader] Hook command returned invalid JSON: ${command}`,
89
+ trimmed,
90
+ );
91
+ resolve(null);
92
+ }
93
+ });
94
+
95
+ // Write payload to stdin and close
96
+ child.stdin.write(JSON.stringify(payload));
97
+ child.stdin.end();
98
+ });
99
+ }
@@ -0,0 +1,71 @@
1
+ import { readFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import type {
4
+ HooksConfig,
5
+ HookEventName,
6
+ ResolvedHookEntry,
7
+ } from "./bridge-types.js";
8
+
9
+ const DEFAULT_TIMEOUT = 10;
10
+
11
+ /**
12
+ * Load hooks.json from a plugin's hooks/ directory.
13
+ * Returns resolved hook entries with ${CLAUDE_PLUGIN_ROOT} expanded.
14
+ * Filters to command-type hooks only.
15
+ */
16
+ export async function loadHooks(
17
+ installPath: string,
18
+ pluginName: string,
19
+ ): Promise<ResolvedHookEntry[]> {
20
+ const hooksPath = join(installPath, "hooks", "hooks.json");
21
+
22
+ let raw: string;
23
+ try {
24
+ raw = await readFile(hooksPath, "utf-8");
25
+ } catch {
26
+ return [];
27
+ }
28
+
29
+ let config: HooksConfig;
30
+ try {
31
+ config = JSON.parse(raw);
32
+ } catch {
33
+ console.warn(
34
+ `[cc-plugin-loader] Failed to parse hooks.json in ${pluginName}`,
35
+ );
36
+ return [];
37
+ }
38
+
39
+ const entries: ResolvedHookEntry[] = [];
40
+
41
+ for (const [eventName, rules] of Object.entries(config)) {
42
+ if (!rules || !Array.isArray(rules)) continue;
43
+
44
+ for (const rule of rules) {
45
+ if (!rule.hooks || !Array.isArray(rule.hooks)) continue;
46
+
47
+ for (const action of rule.hooks) {
48
+ // Phase 2: command type only
49
+ if (action.type !== "command") continue;
50
+ if (!action.command) continue;
51
+
52
+ // Expand ${CLAUDE_PLUGIN_ROOT}
53
+ const command = action.command.replaceAll(
54
+ "${CLAUDE_PLUGIN_ROOT}",
55
+ installPath,
56
+ );
57
+
58
+ entries.push({
59
+ pluginName,
60
+ installPath,
61
+ event: eventName as HookEventName,
62
+ matcher: rule.matcher,
63
+ command,
64
+ timeout: (action.timeout ?? DEFAULT_TIMEOUT) * 1000,
65
+ });
66
+ }
67
+ }
68
+ }
69
+
70
+ return entries;
71
+ }
@@ -0,0 +1,23 @@
1
+ import type { HookEventName, ResolvedHookEntry } from "./bridge-types.js";
2
+
3
+ /**
4
+ * Filter resolved hook entries by event name and optional tool name.
5
+ *
6
+ * Rules:
7
+ * - Entry must match the given event
8
+ * - If the entry has no matcher, it matches all tool names (wildcard)
9
+ * - Matcher "*" also matches all tool names
10
+ * - Otherwise, matcher must equal toolName exactly (case-sensitive)
11
+ */
12
+ export function matchHooks(
13
+ entries: ResolvedHookEntry[],
14
+ event: HookEventName,
15
+ toolName?: string,
16
+ ): ResolvedHookEntry[] {
17
+ return entries.filter((entry) => {
18
+ if (entry.event !== event) return false;
19
+ if (!entry.matcher || entry.matcher === "*") return true;
20
+ if (!toolName) return false;
21
+ return entry.matcher === toolName;
22
+ });
23
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Tracks "once" hook executions to prevent re-running.
3
+ * Key format: `${pluginName}:${event}:${command}`
4
+ */
5
+ const executed = new Set<string>();
6
+
7
+ export function makeKey(
8
+ pluginName: string,
9
+ event: string,
10
+ command: string,
11
+ ): string {
12
+ return `${pluginName}:${event}:${command}`;
13
+ }
14
+
15
+ export function hasRun(key: string): boolean {
16
+ return executed.has(key);
17
+ }
18
+
19
+ export function markRun(key: string): void {
20
+ executed.add(key);
21
+ }
22
+
23
+ export function reset(): void {
24
+ executed.clear();
25
+ }
@@ -0,0 +1,61 @@
1
+ // ── Claude Code Hooks Protocol Types ──
2
+
3
+ /** Supported hook event names from Claude Code */
4
+ export type HookEventName =
5
+ | "PreToolUse"
6
+ | "PostToolUse"
7
+ | "PermissionRequest"
8
+ | "UserPromptSubmit"
9
+ | "PreCompact";
10
+
11
+ /** A single hook action within a hook rule */
12
+ export interface HookAction {
13
+ type: "command" | "prompt" | "agent" | "http";
14
+ command?: string;
15
+ timeout?: number;
16
+ }
17
+
18
+ /** A hook rule: event matcher + list of actions */
19
+ export interface HookRule {
20
+ matcher?: string;
21
+ hooks: HookAction[];
22
+ }
23
+
24
+ /** The shape of hooks.json */
25
+ export type HooksConfig = Partial<Record<HookEventName, HookRule[]>>;
26
+
27
+ /** A resolved hook entry ready for execution */
28
+ export interface ResolvedHookEntry {
29
+ pluginName: string;
30
+ installPath: string;
31
+ event: HookEventName;
32
+ matcher?: string;
33
+ command: string;
34
+ timeout: number;
35
+ }
36
+
37
+ // ── stdin/stdout protocol types ──
38
+
39
+ /** JSON written to hook subprocess stdin */
40
+ export interface HookStdinPayload {
41
+ session_id: string;
42
+ cwd: string;
43
+ hook_event_name: HookEventName;
44
+ tool_name?: string;
45
+ tool_input?: unknown;
46
+ transcript_path: string;
47
+ }
48
+
49
+ /** hookSpecificOutput in subprocess stdout */
50
+ export interface HookSpecificOutput {
51
+ hookEventName?: string;
52
+ permissionDecision?: "allow" | "deny";
53
+ updatedInput?: Record<string, unknown>;
54
+ additionalContext?: string;
55
+ }
56
+
57
+ /** JSON parsed from hook subprocess stdout */
58
+ export interface HookStdoutResult {
59
+ continue: boolean;
60
+ hookSpecificOutput?: HookSpecificOutput;
61
+ }
@@ -0,0 +1,187 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import type { ParsedPlugin } from "../types.js";
3
+ import type {
4
+ HookEventName,
5
+ HookStdinPayload,
6
+ ResolvedHookEntry,
7
+ } from "./bridge-types.js";
8
+ import { REVERSE_TOOL_NAME_MAP } from "../constants.js";
9
+ import { matchHooks } from "./bridge-matcher.js";
10
+ import { executeHookCommand } from "./bridge-executor.js";
11
+
12
+ /**
13
+ * Create OpenCode hook callbacks from resolved hook entries across all plugins.
14
+ */
15
+ export function createBridgeHooks(plugins: ParsedPlugin[]): Partial<Hooks> {
16
+ const allEntries = plugins.flatMap((p) => p.hookEntries);
17
+ if (!allEntries.length) return {};
18
+
19
+ const hooks: Partial<Hooks> = {};
20
+
21
+ // Check which events have entries
22
+ const eventSet = new Set(allEntries.map((e) => e.event));
23
+
24
+ if (eventSet.has("PreToolUse")) {
25
+ hooks["tool.execute.before"] = async (input, output) => {
26
+ const ccToolName = REVERSE_TOOL_NAME_MAP[input.tool] ?? input.tool;
27
+ const matched = matchHooks(allEntries, "PreToolUse", ccToolName);
28
+ if (!matched.length) return;
29
+
30
+ const payload = buildPayload(
31
+ "PreToolUse",
32
+ input.sessionID,
33
+ ccToolName,
34
+ output.args,
35
+ );
36
+
37
+ for (const entry of matched) {
38
+ const result = await executeHookCommand(
39
+ entry.command,
40
+ payload,
41
+ entry.timeout,
42
+ );
43
+ if (!result) continue;
44
+
45
+ if (!result.continue) {
46
+ console.warn(
47
+ `[cc-plugin-loader] Hook "${entry.command}" returned continue:false, ` +
48
+ `but OpenCode tool.execute.before cannot cancel execution. ` +
49
+ `Consider using permission.ask instead.`,
50
+ );
51
+ }
52
+
53
+ if (result.hookSpecificOutput?.updatedInput) {
54
+ Object.assign(output.args, result.hookSpecificOutput.updatedInput);
55
+ // Update payload for chaining
56
+ payload.tool_input = output.args;
57
+ }
58
+ }
59
+ };
60
+ }
61
+
62
+ if (eventSet.has("PostToolUse")) {
63
+ hooks["tool.execute.after"] = async (input, output) => {
64
+ const ccToolName = REVERSE_TOOL_NAME_MAP[input.tool] ?? input.tool;
65
+ const matched = matchHooks(allEntries, "PostToolUse", ccToolName);
66
+ if (!matched.length) return;
67
+
68
+ const payload = buildPayload(
69
+ "PostToolUse",
70
+ input.sessionID,
71
+ ccToolName,
72
+ input.args,
73
+ );
74
+
75
+ for (const entry of matched) {
76
+ const result = await executeHookCommand(
77
+ entry.command,
78
+ payload,
79
+ entry.timeout,
80
+ );
81
+ if (!result) continue;
82
+
83
+ if (result.hookSpecificOutput?.additionalContext) {
84
+ output.output += "\n" + result.hookSpecificOutput.additionalContext;
85
+ }
86
+ }
87
+ };
88
+ }
89
+
90
+ if (eventSet.has("PermissionRequest")) {
91
+ hooks["permission.ask"] = async (input, output) => {
92
+ const matched = matchHooks(
93
+ allEntries,
94
+ "PermissionRequest",
95
+ input.type,
96
+ );
97
+ if (!matched.length) return;
98
+
99
+ const payload = buildPayload(
100
+ "PermissionRequest",
101
+ input.sessionID,
102
+ input.type,
103
+ );
104
+
105
+ for (const entry of matched) {
106
+ const result = await executeHookCommand(
107
+ entry.command,
108
+ payload,
109
+ entry.timeout,
110
+ );
111
+ if (!result) continue;
112
+
113
+ const decision = result.hookSpecificOutput?.permissionDecision;
114
+ if (decision === "allow") {
115
+ output.status = "allow";
116
+ } else if (decision === "deny") {
117
+ output.status = "deny";
118
+ }
119
+ }
120
+ };
121
+ }
122
+
123
+ if (eventSet.has("UserPromptSubmit")) {
124
+ hooks["chat.message"] = async (input, output) => {
125
+ const matched = matchHooks(allEntries, "UserPromptSubmit");
126
+ if (!matched.length) return;
127
+
128
+ const payload = buildPayload("UserPromptSubmit", input.sessionID);
129
+
130
+ for (const entry of matched) {
131
+ const result = await executeHookCommand(
132
+ entry.command,
133
+ payload,
134
+ entry.timeout,
135
+ );
136
+ if (!result) continue;
137
+
138
+ if (result.hookSpecificOutput?.additionalContext) {
139
+ output.parts.push({
140
+ type: "text",
141
+ text: result.hookSpecificOutput.additionalContext,
142
+ } as never);
143
+ }
144
+ }
145
+ };
146
+ }
147
+
148
+ if (eventSet.has("PreCompact")) {
149
+ hooks["experimental.session.compacting"] = async (input, output) => {
150
+ const matched = matchHooks(allEntries, "PreCompact");
151
+ if (!matched.length) return;
152
+
153
+ const payload = buildPayload("PreCompact", input.sessionID);
154
+
155
+ for (const entry of matched) {
156
+ const result = await executeHookCommand(
157
+ entry.command,
158
+ payload,
159
+ entry.timeout,
160
+ );
161
+ if (!result) continue;
162
+
163
+ if (result.hookSpecificOutput?.additionalContext) {
164
+ output.context.push(result.hookSpecificOutput.additionalContext);
165
+ }
166
+ }
167
+ };
168
+ }
169
+
170
+ return hooks;
171
+ }
172
+
173
+ function buildPayload(
174
+ event: HookEventName,
175
+ sessionId: string,
176
+ toolName?: string,
177
+ toolInput?: unknown,
178
+ ): HookStdinPayload {
179
+ return {
180
+ session_id: sessionId,
181
+ cwd: process.cwd(),
182
+ hook_event_name: event,
183
+ tool_name: toolName,
184
+ tool_input: toolInput,
185
+ transcript_path: "",
186
+ };
187
+ }
@@ -2,15 +2,41 @@ import type { Config } from "@opencode-ai/sdk";
2
2
  import { TOOL_NAME_MAP, MODEL_MAP } from "../constants.js";
3
3
  import type { ParsedPlugin } from "../types.js";
4
4
 
5
+ /** Claude Code tool names that have no OpenCode equivalent — silently dropped */
6
+ const UNMAPPABLE_TOOLS = new Set([
7
+ "NotebookEdit",
8
+ "NotebookRead",
9
+ "KillShell",
10
+ "BashOutput",
11
+ "AskUserQuestion",
12
+ ]);
13
+
14
+ /**
15
+ * Map Claude Code tool names to OpenCode tool names.
16
+ * - Exact match in TOOL_NAME_MAP takes priority
17
+ * - Scoped patterns like "Bash(git:*)" → "bash"
18
+ * - Tools with no OpenCode equivalent are dropped
19
+ */
5
20
  function mapTools(tools: string[]): Record<string, boolean> {
6
21
  const result: Record<string, boolean> = {};
7
22
  for (const tool of tools) {
8
- const mapped = TOOL_NAME_MAP[tool] || tool.toLowerCase();
23
+ // Handle scoped patterns like "Bash(git:*)"
24
+ const baseMatch = tool.match(/^(\w+)\(/);
25
+ const baseName = baseMatch ? baseMatch[1] : tool;
26
+
27
+ if (UNMAPPABLE_TOOLS.has(baseName)) continue;
28
+
29
+ const mapped = TOOL_NAME_MAP[baseName] || baseName.toLowerCase();
9
30
  result[mapped] = true;
10
31
  }
11
32
  return result;
12
33
  }
13
34
 
35
+ function mapModel(model: string): string | undefined {
36
+ if (model === "inherit") return undefined; // let OpenCode use parent model
37
+ return MODEL_MAP[model] || model;
38
+ }
39
+
14
40
  export function createConfigHook(plugins: ParsedPlugin[]) {
15
41
  return async (config: Config): Promise<void> => {
16
42
  if (!config.agent) config.agent = {};
@@ -21,18 +47,21 @@ export function createConfigHook(plugins: ParsedPlugin[]) {
21
47
  // Register agents
22
48
  for (const agent of plugin.agents) {
23
49
  const key = `${plugin.name}-${agent.name}`;
50
+ const model = agent.model ? mapModel(agent.model) : undefined;
24
51
  config.agent[key] = {
25
52
  description: agent.description,
26
53
  mode: "subagent",
27
54
  prompt: agent.prompt,
28
55
  tools: mapTools(agent.tools),
29
- ...(agent.model && { model: MODEL_MAP[agent.model] || agent.model }),
56
+ ...(model && { model }),
30
57
  ...(agent.maxTurns && { maxSteps: agent.maxTurns }),
58
+ ...(agent.color && { color: agent.color }),
31
59
  };
32
60
  }
33
61
 
34
- // Register MCP servers
62
+ // Register MCP servers (skip if already configured natively)
35
63
  for (const mcp of plugin.mcpServers) {
64
+ if (config.mcp[mcp.qualifiedName]) continue;
36
65
  config.mcp[mcp.qualifiedName] = {
37
66
  type: "local" as const,
38
67
  command: [mcp.command, ...mcp.args],
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { createConfigHook } from "./hooks/config.js";
5
5
  import { createSystemHook } from "./hooks/system.js";
6
6
  import { createCommandHook } from "./hooks/command.js";
7
7
  import { createEnvHook } from "./hooks/env.js";
8
+ import { createBridgeHooks } from "./hooks/bridge.js";
8
9
  import type { ParsedPlugin } from "./types.js";
9
10
 
10
11
  const plugin: Plugin = async (input) => {
@@ -37,6 +38,7 @@ const plugin: Plugin = async (input) => {
37
38
  "experimental.chat.system.transform": createSystemHook(plugins),
38
39
  "command.execute.before": createCommandHook(plugins),
39
40
  "shell.env": createEnvHook(plugins),
41
+ ...createBridgeHooks(plugins),
40
42
  };
41
43
  };
42
44
 
package/src/loader.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  ParsedCommand,
15
15
  ParsedMcp,
16
16
  } from "./types.js";
17
+ import { loadHooks } from "./hooks/bridge-loader.js";
17
18
 
18
19
  async function readFileSafe(path: string): Promise<string | null> {
19
20
  try {
@@ -57,13 +58,15 @@ export async function loadPlugin(
57
58
 
58
59
  const pluginName = manifest.name;
59
60
 
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
- ]);
61
+ const [claudeMd, skills, agents, commands, mcpServers, hookEntries] =
62
+ await Promise.all([
63
+ readFileSafe(join(installPath, "CLAUDE.md")),
64
+ loadSkills(installPath, pluginName),
65
+ loadAgents(installPath, pluginName),
66
+ loadCommands(installPath, pluginName),
67
+ loadMcpServers(installPath, pluginName),
68
+ loadHooks(installPath, pluginName),
69
+ ]);
67
70
 
68
71
  return {
69
72
  name: pluginName,
@@ -73,6 +76,7 @@ export async function loadPlugin(
73
76
  agents,
74
77
  commands,
75
78
  mcpServers,
79
+ hookEntries,
76
80
  };
77
81
  }
78
82
 
@@ -146,6 +150,7 @@ async function loadAgents(
146
150
  model: frontmatter.model,
147
151
  tools: normalizeTools(frontmatter),
148
152
  maxTurns: frontmatter.maxTurns,
153
+ color: frontmatter.color,
149
154
  prompt,
150
155
  });
151
156
  } catch (e) {
@@ -219,11 +224,15 @@ async function loadMcpServers(
219
224
 
220
225
  const servers: ParsedMcp[] = [];
221
226
  for (const [serverName, server] of Object.entries(config.mcpServers)) {
222
- // Expand env variables
227
+ // Expand env variables — omit unresolved vars so the MCP server
228
+ // can inherit them from the parent process environment
223
229
  const env: Record<string, string> = {};
224
230
  if (server.env) {
225
231
  for (const [k, v] of Object.entries(server.env)) {
226
- env[k] = expandEnv(v);
232
+ const expanded = expandEnv(v);
233
+ if (expanded !== null) {
234
+ env[k] = expanded;
235
+ }
227
236
  }
228
237
  }
229
238
 
@@ -240,15 +249,27 @@ async function loadMcpServers(
240
249
  return servers;
241
250
  }
242
251
 
243
- function expandEnv(value: string): string {
244
- return value.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
252
+ function expandEnv(value: string): string | null {
253
+ let hasUnresolved = false;
254
+ const result = value.replace(/\$\{([^}]+)\}/g, (_match, varExpr) => {
255
+ // Support ${VAR:-default} syntax
256
+ const colonIdx = varExpr.indexOf(":-");
257
+ const varName = colonIdx >= 0 ? varExpr.slice(0, colonIdx) : varExpr;
258
+ const fallback = colonIdx >= 0 ? varExpr.slice(colonIdx + 2) : undefined;
259
+
245
260
  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;
261
+ if (val !== undefined) return val;
262
+ if (fallback !== undefined) return fallback;
263
+
264
+ console.warn(
265
+ `[cc-plugin-loader] Environment variable ${varName} not set, omitting from MCP config`,
266
+ );
267
+ hasUnresolved = true;
268
+ return "";
253
269
  });
270
+
271
+ // If the entire value was a single unresolved ${VAR}, omit it
272
+ // so the MCP server can inherit from parent environment
273
+ if (hasUnresolved && result === "") return null;
274
+ return result;
254
275
  }
package/src/parser.ts CHANGED
@@ -18,17 +18,22 @@ export function parseFrontmatter<T>(content: string): Parsed<T> {
18
18
  }
19
19
 
20
20
  /**
21
- * Normalize tools from either `allowed-tools` (YAML array) or `tools` (comma-separated string).
21
+ * Normalize tools from either `allowed-tools` (YAML array) or `tools` (array or comma-separated string).
22
22
  */
23
23
  export function normalizeTools(fm: {
24
24
  "allowed-tools"?: string[];
25
- tools?: string;
25
+ tools?: string | string[];
26
26
  }): string[] {
27
27
  if (fm["allowed-tools"] && Array.isArray(fm["allowed-tools"])) {
28
28
  return fm["allowed-tools"];
29
29
  }
30
- if (fm.tools && typeof fm.tools === "string") {
31
- return fm.tools.split(",").map((t) => t.trim()).filter(Boolean);
30
+ if (fm.tools) {
31
+ if (Array.isArray(fm.tools)) {
32
+ return fm.tools;
33
+ }
34
+ if (typeof fm.tools === "string") {
35
+ return fm.tools.split(",").map((t) => t.trim()).filter(Boolean);
36
+ }
32
37
  }
33
38
  return [];
34
39
  }
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ResolvedHookEntry } from "./hooks/bridge-types.js";
2
+
1
3
  // ── Source types (Claude Code plugin format) ──
2
4
 
3
5
  /** V1: each plugin key maps to a single entry (no scope, no array) */
@@ -45,7 +47,7 @@ export interface SkillFrontmatter {
45
47
  name?: string;
46
48
  description?: string;
47
49
  "allowed-tools"?: string[];
48
- tools?: string;
50
+ tools?: string | string[];
49
51
  }
50
52
 
51
53
  export interface AgentFrontmatter {
@@ -53,8 +55,9 @@ export interface AgentFrontmatter {
53
55
  description: string;
54
56
  model?: string;
55
57
  "allowed-tools"?: string[];
56
- tools?: string;
58
+ tools?: string | string[];
57
59
  maxTurns?: number;
60
+ color?: string;
58
61
  }
59
62
 
60
63
  export interface CommandFrontmatter {
@@ -92,6 +95,7 @@ export interface ParsedAgent {
92
95
  model?: string;
93
96
  tools: string[];
94
97
  maxTurns?: number;
98
+ color?: string;
95
99
  prompt: string;
96
100
  }
97
101
 
@@ -121,4 +125,7 @@ export interface ParsedPlugin {
121
125
  agents: ParsedAgent[];
122
126
  commands: ParsedCommand[];
123
127
  mcpServers: ParsedMcp[];
128
+ hookEntries: ResolvedHookEntry[];
124
129
  }
130
+
131
+ export type { ResolvedHookEntry };