@bldg-7/cc-plugin-loader 0.1.0 → 0.1.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bldg-7/cc-plugin-loader",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "files": ["src"],
package/src/constants.ts CHANGED
@@ -15,6 +15,12 @@ export const TOOL_NAME_MAP: Record<string, string> = {
15
15
  NotebookEdit: "notebookedit",
16
16
  };
17
17
 
18
+ /** OpenCode tool name → Claude Code tool name (reverse of TOOL_NAME_MAP) */
19
+ export const REVERSE_TOOL_NAME_MAP: Record<string, string> =
20
+ Object.fromEntries(
21
+ Object.entries(TOOL_NAME_MAP).map(([cc, oc]) => [oc, cc]),
22
+ );
23
+
18
24
  /** Claude Code model alias → OpenCode model ID */
19
25
  export const MODEL_MAP: Record<string, string> = {
20
26
  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
+ }
@@ -31,8 +31,9 @@ export function createConfigHook(plugins: ParsedPlugin[]) {
31
31
  };
32
32
  }
33
33
 
34
- // Register MCP servers
34
+ // Register MCP servers (skip if already configured natively)
35
35
  for (const mcp of plugin.mcpServers) {
36
+ if (config.mcp[mcp.qualifiedName]) continue;
36
37
  config.mcp[mcp.qualifiedName] = {
37
38
  type: "local" as const,
38
39
  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
 
@@ -219,11 +223,15 @@ async function loadMcpServers(
219
223
 
220
224
  const servers: ParsedMcp[] = [];
221
225
  for (const [serverName, server] of Object.entries(config.mcpServers)) {
222
- // Expand env variables
226
+ // Expand env variables — omit unresolved vars so the MCP server
227
+ // can inherit them from the parent process environment
223
228
  const env: Record<string, string> = {};
224
229
  if (server.env) {
225
230
  for (const [k, v] of Object.entries(server.env)) {
226
- env[k] = expandEnv(v);
231
+ const expanded = expandEnv(v);
232
+ if (expanded !== null) {
233
+ env[k] = expanded;
234
+ }
227
235
  }
228
236
  }
229
237
 
@@ -240,15 +248,27 @@ async function loadMcpServers(
240
248
  return servers;
241
249
  }
242
250
 
243
- function expandEnv(value: string): string {
244
- return value.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
251
+ function expandEnv(value: string): string | null {
252
+ let hasUnresolved = false;
253
+ const result = value.replace(/\$\{([^}]+)\}/g, (_match, varExpr) => {
254
+ // Support ${VAR:-default} syntax
255
+ const colonIdx = varExpr.indexOf(":-");
256
+ const varName = colonIdx >= 0 ? varExpr.slice(0, colonIdx) : varExpr;
257
+ const fallback = colonIdx >= 0 ? varExpr.slice(colonIdx + 2) : undefined;
258
+
245
259
  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;
260
+ if (val !== undefined) return val;
261
+ if (fallback !== undefined) return fallback;
262
+
263
+ console.warn(
264
+ `[cc-plugin-loader] Environment variable ${varName} not set, omitting from MCP config`,
265
+ );
266
+ hasUnresolved = true;
267
+ return "";
253
268
  });
269
+
270
+ // If the entire value was a single unresolved ${VAR}, omit it
271
+ // so the MCP server can inherit from parent environment
272
+ if (hasUnresolved && result === "") return null;
273
+ return result;
254
274
  }
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) */
@@ -121,4 +123,7 @@ export interface ParsedPlugin {
121
123
  agents: ParsedAgent[];
122
124
  commands: ParsedCommand[];
123
125
  mcpServers: ParsedMcp[];
126
+ hookEntries: ResolvedHookEntry[];
124
127
  }
128
+
129
+ export type { ResolvedHookEntry };