@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 +1 -1
- package/src/constants.ts +6 -0
- package/src/hooks/bridge-executor.ts +99 -0
- package/src/hooks/bridge-loader.ts +71 -0
- package/src/hooks/bridge-matcher.ts +23 -0
- package/src/hooks/bridge-state.ts +25 -0
- package/src/hooks/bridge-types.ts +61 -0
- package/src/hooks/bridge.ts +187 -0
- package/src/hooks/config.ts +2 -1
- package/src/index.ts +2 -0
- package/src/loader.ts +38 -18
- package/src/types.ts +5 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/hooks/config.ts
CHANGED
|
@@ -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] =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 };
|