agy-plugin-porter 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +155 -2
- package/bin/agy-porter.mjs +91 -0
- package/lib/consent.mjs +122 -0
- package/lib/detector.mjs +92 -0
- package/lib/hook-compiler.mjs +208 -0
- package/lib/index.mjs +32 -0
- package/lib/manifest-sanitizer.mjs +187 -0
- package/lib/mod-compiler.mjs +210 -0
- package/lib/mod-lexer.mjs +143 -0
- package/lib/mod-scan.mjs +169 -0
- package/lib/mod-transform.mjs +84 -0
- package/lib/mode-compiler.mjs +195 -0
- package/lib/rules-compiler.mjs +205 -0
- package/lib/skill-compiler.mjs +135 -0
- package/lib/source.mjs +81 -0
- package/lib/static-auditor.mjs +179 -0
- package/lib/tool-map.mjs +46 -0
- package/lib/transpiler-engine.mjs +190 -0
- package/lib/yaml-lite.mjs +222 -0
- package/package.json +73 -4
- package/scripts/hook-shim.mjs +418 -0
- package/scripts/mod-runtime/claude-code.mjs +20 -0
- package/scripts/mod-runtime/elements.mjs +35 -0
- package/scripts/mod-runtime/engine.mjs +129 -0
- package/scripts/mod-runtime/hook.mjs +24 -0
- package/scripts/mod-runtime/instance.mjs +250 -0
- package/scripts/mod-runtime/lifecycle.mjs +45 -0
- package/scripts/mod-runtime/locate.mjs +48 -0
- package/scripts/mod-runtime/mcp-entry.mjs +55 -0
- package/scripts/mod-runtime/server.mjs +178 -0
- package/scripts/mod-runtime/tool-calls.mjs +83 -0
- package/scripts/mod-runtime/ui-draw.js +122 -0
- package/scripts/mod-runtime/ui.html +61 -0
- package/scripts/mod-runtime/ui.js +149 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runtime bridge shipped into each ported plugin by agy-plugin-porter.
|
|
3
|
+
// agy runs `node scripts/hook-shim.mjs --event <Event> --hook <id>` with the
|
|
4
|
+
// plugin root as cwd. The shim looks up the original Claude Code / Gemini CLI
|
|
5
|
+
// command in hook-shim.json, feeds it a translated payload, and turns its exit
|
|
6
|
+
// code and output back into agy's hook contract. It has no dependencies.
|
|
7
|
+
//
|
|
8
|
+
// agy contract (live-tested on agy 1.4.5, see docs/agy-baseline.md):
|
|
9
|
+
// - PreToolUse: empty stdout is "no opinion"; `{}` is a deny; any nonzero exit
|
|
10
|
+
// fails the tool call. So the shim always exits 0 and prints nothing unless
|
|
11
|
+
// the script decided something.
|
|
12
|
+
// - SessionStart / Stop / PostToolUse: stdout must be a JSON object.
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
// agy tool name -> source tool name and arg renames. Arg names are the agy CLI's
|
|
19
|
+
// as seen live; the IDE-named tools reuse them unverified. Unknown args pass through.
|
|
20
|
+
const FILE_ARGS = { Path: 'file_path' };
|
|
21
|
+
const REVERSE = {
|
|
22
|
+
claude: {
|
|
23
|
+
run_command: ['Bash', { CommandLine: 'command' }],
|
|
24
|
+
view_file: ['Read', FILE_ARGS],
|
|
25
|
+
read_file: ['Read', FILE_ARGS],
|
|
26
|
+
write_to_file: ['Write', { ...FILE_ARGS, Content: 'content' }],
|
|
27
|
+
write_file: ['Write', { ...FILE_ARGS, Content: 'content' }],
|
|
28
|
+
replace_file_content: ['Edit', { ...FILE_ARGS, Target: 'old_string', Replacement: 'new_string' }],
|
|
29
|
+
edit_file: ['Edit', { ...FILE_ARGS, Target: 'old_string', Replacement: 'new_string' }],
|
|
30
|
+
multi_replace_file_content: ['MultiEdit', FILE_ARGS],
|
|
31
|
+
grep_search: ['Grep', {}],
|
|
32
|
+
find_by_name: ['Glob', {}],
|
|
33
|
+
list_dir: ['LS', {}],
|
|
34
|
+
read_url_content: ['WebFetch', {}],
|
|
35
|
+
search_web: ['WebSearch', {}],
|
|
36
|
+
notebook_edit: ['NotebookEdit', {}],
|
|
37
|
+
invoke_subagent: ['Task', {}],
|
|
38
|
+
},
|
|
39
|
+
gemini: {
|
|
40
|
+
run_command: ['run_shell_command', { CommandLine: 'command' }],
|
|
41
|
+
view_file: ['read_file', { Path: 'absolute_path' }],
|
|
42
|
+
read_file: ['read_file', { Path: 'absolute_path' }],
|
|
43
|
+
write_to_file: ['write_file', { ...FILE_ARGS, Content: 'content' }],
|
|
44
|
+
write_file: ['write_file', { ...FILE_ARGS, Content: 'content' }],
|
|
45
|
+
replace_file_content: ['replace', { ...FILE_ARGS, Target: 'old_string', Replacement: 'new_string' }],
|
|
46
|
+
edit_file: ['replace', { ...FILE_ARGS, Target: 'old_string', Replacement: 'new_string' }],
|
|
47
|
+
grep_search: ['search_file_content', {}],
|
|
48
|
+
find_by_name: ['glob', {}],
|
|
49
|
+
list_dir: ['list_directory', {}],
|
|
50
|
+
read_url_content: ['web_fetch', {}],
|
|
51
|
+
search_web: ['google_web_search', {}],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const renameKeys = (obj, renames) => Object.fromEntries(
|
|
56
|
+
Object.entries(obj ?? {}).map(([k, v]) => [renames[k] ?? k, v]));
|
|
57
|
+
const invert = (renames) => Object.fromEntries(Object.entries(renames).map(([k, v]) => [v, k]));
|
|
58
|
+
|
|
59
|
+
export function toSourceTool(toolCall, dialect, matcher) {
|
|
60
|
+
if ((toolCall?.name === 'call_mcp_tool' || toolCall?.name === 'mcp_tool') && toolCall?.args) {
|
|
61
|
+
const server = toolCall.args.ServerName ?? toolCall.args.server_name ?? '';
|
|
62
|
+
const tool = toolCall.args.ToolName ?? toolCall.args.tool_name ?? '';
|
|
63
|
+
const rawArgs = toolCall.args.Arguments ?? toolCall.args.arguments ?? {};
|
|
64
|
+
return {
|
|
65
|
+
tool_name: server && tool ? `mcp__${server}__${tool}` : toolCall.name,
|
|
66
|
+
tool_input: typeof rawArgs === 'string' ? (parseJson(rawArgs.trim()) ?? {}) : (rawArgs ?? {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (dialect !== 'gemini' && typeof toolCall?.name === 'string' && toolCall.name.startsWith('mcp_') && toolCall.name !== 'mcp_tool') {
|
|
70
|
+
const rest = toolCall.name.slice(4);
|
|
71
|
+
if (typeof matcher === 'string') {
|
|
72
|
+
for (const m of matcher.matchAll(/mcp__([A-Za-z0-9._-]+)__/g)) {
|
|
73
|
+
const srv = m[1];
|
|
74
|
+
const normSrv = srv.replace(/-/g, '_');
|
|
75
|
+
if (rest.startsWith(`${srv}_`)) {
|
|
76
|
+
return { tool_name: `mcp__${srv}__${rest.slice(srv.length + 1)}`, tool_input: toolCall.args ?? {} };
|
|
77
|
+
}
|
|
78
|
+
if (rest.startsWith(`${normSrv}_`)) {
|
|
79
|
+
return { tool_name: `mcp__${srv}__${rest.slice(normSrv.length + 1)}`, tool_input: toolCall.args ?? {} };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const idx = rest.indexOf('_');
|
|
84
|
+
if (idx > 0) {
|
|
85
|
+
return { tool_name: `mcp__${rest.slice(0, idx)}__${rest.slice(idx + 1)}`, tool_input: toolCall.args ?? {} };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const [name, renames] = REVERSE[dialect]?.[toolCall?.name] ?? [toolCall?.name ?? '', {}];
|
|
89
|
+
return { tool_name: name, tool_input: renameKeys(toolCall?.args, renames) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Source payload (snake_case) from agy's camelCase protojson payload.
|
|
93
|
+
export function toSourcePayload(entry, payload) {
|
|
94
|
+
const base = {
|
|
95
|
+
session_id: payload.conversationId ?? '',
|
|
96
|
+
transcript_path: payload.transcriptPath ?? '',
|
|
97
|
+
cwd: payload.workspacePaths?.[0] ?? process.cwd(),
|
|
98
|
+
hook_event_name: entry.sourceEvent,
|
|
99
|
+
};
|
|
100
|
+
if (entry.dialect === 'gemini') base.timestamp = new Date().toISOString();
|
|
101
|
+
else base.permission_mode = 'default';
|
|
102
|
+
switch (entry.event) {
|
|
103
|
+
case 'PreToolUse':
|
|
104
|
+
return { ...base, ...toSourceTool(payload.toolCall, entry.dialect, entry.matcher) };
|
|
105
|
+
case 'PostToolUse':
|
|
106
|
+
if (entry.sourceEvent === 'PostToolUseFailure') return { ...base, ...toSourceTool(payload.toolCall, entry.dialect, entry.matcher), error: payload.error ?? '' };
|
|
107
|
+
return { ...base, ...toSourceTool(payload.toolCall, entry.dialect, entry.matcher), tool_response: payload.error ? { error: payload.error } : {} };
|
|
108
|
+
case 'SessionStart':
|
|
109
|
+
return { ...base, source: 'startup' };
|
|
110
|
+
case 'Stop':
|
|
111
|
+
return { ...base, stop_hook_active: Number(payload.executionNum ?? 1) > 1 };
|
|
112
|
+
default:
|
|
113
|
+
return base;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Expands the plugin-path variables Claude Code and Gemini CLI provide, so the
|
|
118
|
+
// command works under cmd.exe too. Unknown variables are left to the shell.
|
|
119
|
+
export function expandCommand(command, vars, { posix = false } = {}) {
|
|
120
|
+
const slash = posix ? '/' : sep;
|
|
121
|
+
const norm = (v) => (posix && typeof v === 'string' ? v.replace(/\\/g, '/') : v);
|
|
122
|
+
const values = { ...Object.fromEntries(Object.entries(vars ?? {}).map(([k, v]) => [k, norm(v)])), '/': slash };
|
|
123
|
+
const VAR_RE = /\$\{([A-Za-z_]\w*|\/)\}|\$([A-Za-z_]\w*)/g;
|
|
124
|
+
return command.replace(/"[^"]*"|'[^']*'|\S+/g, (token) => {
|
|
125
|
+
let expandedAny = false;
|
|
126
|
+
const expanded = token.replace(VAR_RE, (whole, braced, bare) => {
|
|
127
|
+
const name = braced ?? bare;
|
|
128
|
+
if (Object.hasOwn(values, name)) {
|
|
129
|
+
if (name !== '/') expandedAny = true;
|
|
130
|
+
return values[name];
|
|
131
|
+
}
|
|
132
|
+
return whole;
|
|
133
|
+
});
|
|
134
|
+
const isQuoted = (token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"));
|
|
135
|
+
if (expandedAny && !isQuoted && !token.includes('"') && /\s/.test(expanded)) {
|
|
136
|
+
return `"${expanded}"`;
|
|
137
|
+
}
|
|
138
|
+
return expanded;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const NO_OPINION = { PreToolUse: '' };
|
|
143
|
+
export const noOpinion = (event) => NO_OPINION[event] ?? '{}';
|
|
144
|
+
|
|
145
|
+
function splitTopLevelAlternatives(raw) {
|
|
146
|
+
const out = [];
|
|
147
|
+
let bracket = false;
|
|
148
|
+
let paren = 0;
|
|
149
|
+
let start = 0;
|
|
150
|
+
for (let i = 0; i < raw.length; i++) {
|
|
151
|
+
const ch = raw[i];
|
|
152
|
+
if (ch === '\\') { i++; continue; }
|
|
153
|
+
if (ch === '[' && !bracket) bracket = true;
|
|
154
|
+
else if (ch === ']' && bracket) bracket = false;
|
|
155
|
+
else if (!bracket && ch === '(') paren++;
|
|
156
|
+
else if (!bracket && ch === ')') paren = Math.max(0, paren - 1);
|
|
157
|
+
else if (!bracket && paren === 0 && ch === '|') {
|
|
158
|
+
out.push(raw.slice(start, i).trim());
|
|
159
|
+
start = i + 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
out.push(raw.slice(start).trim());
|
|
163
|
+
return out.filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function matchesSourceMatcher(matcher, toolName, rawToolName) {
|
|
167
|
+
if (!matcher || matcher === '*' || matcher === '.*') return true;
|
|
168
|
+
const effectiveRaw = (rawToolName === 'call_mcp_tool' || rawToolName === 'mcp_tool') && toolName !== rawToolName
|
|
169
|
+
? ''
|
|
170
|
+
: (rawToolName ?? '');
|
|
171
|
+
if (matcher === toolName || (effectiveRaw && matcher === effectiveRaw)) return true;
|
|
172
|
+
try {
|
|
173
|
+
for (const alt of splitTopLevelAlternatives(matcher)) {
|
|
174
|
+
if (alt === '*' || alt === '.*' || alt === toolName || (effectiveRaw && alt === effectiveRaw)) return true;
|
|
175
|
+
const hasAnchor = /^\(*\^/.test(alt) || /\$\)*$/.test(alt);
|
|
176
|
+
const isMcpPrefix = /^\^?mcp__.*__$/.test(alt);
|
|
177
|
+
const re = hasAnchor
|
|
178
|
+
? new RegExp(alt)
|
|
179
|
+
: isMcpPrefix
|
|
180
|
+
? new RegExp(`^(?:${alt})`)
|
|
181
|
+
: new RegExp(`^(?:${alt})$`);
|
|
182
|
+
if (re.test(toolName) || (effectiveRaw && re.test(effectiveRaw))) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
} catch {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// agy has one PostToolUse with `error` set when the tool failed (empty on
|
|
191
|
+
// success, live-tested). Claude splits it: PostToolUse runs on success only,
|
|
192
|
+
// PostToolUseFailure on failure only.
|
|
193
|
+
export function shouldRun(entry, payload) {
|
|
194
|
+
if (entry.event === 'PostToolUse' && entry.dialect === 'claude') {
|
|
195
|
+
if (Boolean(payload.error) !== (entry.sourceEvent === 'PostToolUseFailure')) return false;
|
|
196
|
+
}
|
|
197
|
+
if ((entry.event === 'PreToolUse' || entry.event === 'PostToolUse') && entry.matcher) {
|
|
198
|
+
const { tool_name } = toSourceTool(payload?.toolCall, entry.dialect, entry.matcher);
|
|
199
|
+
if (!matchesSourceMatcher(entry.matcher, tool_name, payload?.toolCall?.name)) return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseJson(text) {
|
|
205
|
+
if (!text.startsWith('{')) return null;
|
|
206
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Turns the script's {code, stdout, stderr} into agy's stdout. Returns
|
|
210
|
+
// {stdout, warning?}. A nonzero exit other than 2 (or a timeout) never blocks:
|
|
211
|
+
// that matches Claude Code and Gemini CLI, where only exit 2 is blocking.
|
|
212
|
+
export function translateResult(entry, result) {
|
|
213
|
+
const event = entry.event;
|
|
214
|
+
const stdout = (result.stdout ?? '').replace(/^/, '').replace(/\r\n/g, '\n').trim();
|
|
215
|
+
const stderr = (result.stderr ?? '').replace(/^/, '').replace(/\r\n/g, '\n').trim();
|
|
216
|
+
if (result.timedOut || result.spawnError || (result.code !== 0 && result.code !== 2)) {
|
|
217
|
+
const why = result.timedOut ? `timed out after ${entry.timeout}s`
|
|
218
|
+
: result.spawnError ? `failed to start: ${result.spawnError}` : `exited with code ${result.code}`;
|
|
219
|
+
return { stdout: noOpinion(event), warning: `${why}; not blocking${stderr ? `. stderr: ${stderr}` : ''}` };
|
|
220
|
+
}
|
|
221
|
+
const blocked = result.code === 2;
|
|
222
|
+
const json = blocked ? null : parseJson(stdout);
|
|
223
|
+
const specific = json?.hookSpecificOutput ?? {};
|
|
224
|
+
|
|
225
|
+
if (event === 'PreToolUse') {
|
|
226
|
+
const reasonOr = (r, fallback) => (typeof r === 'string' && r.trim() ? r.trim() : fallback);
|
|
227
|
+
if (blocked) return { stdout: JSON.stringify({ decision: 'deny', reason: reasonOr(stderr, 'Blocked by hook') }) };
|
|
228
|
+
if (!json) return { stdout: '' };
|
|
229
|
+
let decision = ['allow', 'deny', 'ask', 'force_ask'].includes(specific.permissionDecision) ? specific.permissionDecision : null;
|
|
230
|
+
let reason = specific.permissionDecisionReason;
|
|
231
|
+
if (!decision && typeof json.decision === 'string') {
|
|
232
|
+
decision = { block: 'deny', deny: 'deny', approve: 'allow', allow: 'allow', ask: 'ask', force_ask: 'force_ask' }[json.decision] ?? null;
|
|
233
|
+
reason = json.reason;
|
|
234
|
+
}
|
|
235
|
+
if (!decision && json.continue === false) {
|
|
236
|
+
decision = 'deny';
|
|
237
|
+
reason = json.stopReason;
|
|
238
|
+
}
|
|
239
|
+
if (!decision) return { stdout: '' };
|
|
240
|
+
const out = { decision };
|
|
241
|
+
if (decision === 'deny') out.reason = reasonOr(reason, 'Blocked by hook');
|
|
242
|
+
else if (typeof reason === 'string' && reason) out.reason = reason;
|
|
243
|
+
if (decision !== 'deny' && specific.updatedInput && typeof specific.updatedInput === 'object') {
|
|
244
|
+
if (entry.toolName === 'call_mcp_tool' || entry.toolName === 'mcp_tool') {
|
|
245
|
+
const rawArgs = entry.toolArgs && typeof entry.toolArgs === 'object' ? entry.toolArgs : {};
|
|
246
|
+
const argKey = 'Arguments' in rawArgs ? 'Arguments' : ('arguments' in rawArgs ? 'arguments' : 'Arguments');
|
|
247
|
+
const wasString = typeof rawArgs[argKey] === 'string';
|
|
248
|
+
out.overwrite = {
|
|
249
|
+
...rawArgs,
|
|
250
|
+
[argKey]: wasString ? JSON.stringify(specific.updatedInput) : specific.updatedInput,
|
|
251
|
+
};
|
|
252
|
+
} else {
|
|
253
|
+
const renames = REVERSE[entry.dialect]?.[entry.toolName]?.[1] ?? {};
|
|
254
|
+
out.overwrite = renameKeys(specific.updatedInput, invert(renames));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { stdout: JSON.stringify(out) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (event === 'SessionStart') {
|
|
261
|
+
const text = json ? specific.additionalContext : (blocked ? '' : stdout);
|
|
262
|
+
if (typeof text !== 'string' || !text.trim()) return { stdout: '{}' };
|
|
263
|
+
return { stdout: JSON.stringify({ injectSteps: [{ systemMessage: { systemMessage: text.trim() } }] }) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (event === 'Stop') {
|
|
267
|
+
if (blocked) return { stdout: JSON.stringify({ decision: 'continue', reason: stderr || 'A stop hook asked to continue.' }) };
|
|
268
|
+
if (json?.decision === 'block') return { stdout: JSON.stringify({ decision: 'continue', reason: json.reason ?? 'A stop hook asked to continue.' }) };
|
|
269
|
+
return { stdout: '{}' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// PostToolUse: agy only takes {}. Feedback for the model has nowhere to go.
|
|
273
|
+
const feedback = blocked ? stderr : (json?.decision === 'block' ? json.reason : '');
|
|
274
|
+
return { stdout: '{}', ...(feedback ? { warning: `PostToolUse feedback dropped (agy has no channel for it): ${feedback}` } : {}) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const POSIX_SHELL_RE = /\b(?:bash|sh)\s+|\.sh\b|\$\(|\/dev\/null\b|\bexport\s+[A-Za-z_]\w*=|^\s*\[[\s!]/;
|
|
278
|
+
|
|
279
|
+
function hasPosixShebang(command, cwd) {
|
|
280
|
+
const m = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command ?? '');
|
|
281
|
+
const token = m?.[1] ?? m?.[2] ?? m?.[3];
|
|
282
|
+
if (!token || /\.(?:exe|cmd|bat|ps1)$/i.test(token)) return false;
|
|
283
|
+
const candidate = isAbsolute(token) ? token : (cwd ? resolve(cwd, token) : token);
|
|
284
|
+
try {
|
|
285
|
+
if (!existsSync(candidate)) return false;
|
|
286
|
+
const head = readFileSync(candidate, 'utf8').slice(0, 128);
|
|
287
|
+
return /^#!\s*\//m.test(head);
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function findGitBash() {
|
|
294
|
+
const candidates = [
|
|
295
|
+
process.env.AGY_GIT_BASH,
|
|
296
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
297
|
+
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
298
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
299
|
+
process.env.LOCALAPPDATA && resolve(process.env.LOCALAPPDATA, 'Programs\\Git\\bin\\bash.exe'),
|
|
300
|
+
].filter(Boolean);
|
|
301
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function resolveHookSpawn(command, platform = process.platform, cwd = process.cwd()) {
|
|
305
|
+
if (platform === 'win32' && (POSIX_SHELL_RE.test(command) || hasPosixShebang(command, cwd))) {
|
|
306
|
+
const gitBash = findGitBash();
|
|
307
|
+
if (gitBash) {
|
|
308
|
+
return { file: gitBash, args: ['-c', command], shell: false };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { file: command, args: [], shell: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function killProcessTree(child) {
|
|
315
|
+
if (!child?.pid) return;
|
|
316
|
+
try { child.stdout?.destroy(); } catch {}
|
|
317
|
+
try { child.stderr?.destroy(); } catch {}
|
|
318
|
+
try { child.stdin?.destroy(); } catch {}
|
|
319
|
+
if (process.platform === 'win32') {
|
|
320
|
+
try {
|
|
321
|
+
const tk = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
322
|
+
tk.on('error', () => { try { child.kill(); } catch {} });
|
|
323
|
+
tk.unref();
|
|
324
|
+
return;
|
|
325
|
+
} catch {}
|
|
326
|
+
}
|
|
327
|
+
try { child.kill(); } catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function runCommand(command, { cwd, env, input, timeoutMs }) {
|
|
331
|
+
return new Promise((resolve) => {
|
|
332
|
+
let stdout = '';
|
|
333
|
+
let stderr = '';
|
|
334
|
+
let done = false;
|
|
335
|
+
let child;
|
|
336
|
+
const finish = (extra) => {
|
|
337
|
+
if (done) return;
|
|
338
|
+
done = true;
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
resolve({ stdout, stderr, ...extra });
|
|
341
|
+
};
|
|
342
|
+
const timer = setTimeout(() => { killProcessTree(child); finish({ timedOut: true }); }, timeoutMs);
|
|
343
|
+
try {
|
|
344
|
+
const spec = resolveHookSpawn(command, process.platform, cwd);
|
|
345
|
+
child = spec.shell
|
|
346
|
+
? spawn(spec.file, { cwd, env, shell: true, windowsHide: true })
|
|
347
|
+
: spawn(spec.file, spec.args, { cwd, env, windowsHide: true });
|
|
348
|
+
} catch (err) {
|
|
349
|
+
finish({ spawnError: err.message });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
353
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
354
|
+
child.on('error', (err) => finish({ spawnError: err.message }));
|
|
355
|
+
child.on('close', (code) => finish({ code }));
|
|
356
|
+
child.stdin.on('error', () => {});
|
|
357
|
+
child.stdin.end(input);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseArgs(argv) {
|
|
362
|
+
const opts = {};
|
|
363
|
+
for (let i = 0; i < argv.length; i++) {
|
|
364
|
+
if (argv[i] === '--event') opts.event = argv[++i];
|
|
365
|
+
else if (argv[i] === '--hook') opts.hook = argv[++i];
|
|
366
|
+
}
|
|
367
|
+
return opts;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function main() {
|
|
371
|
+
const { event = 'PreToolUse', hook } = parseArgs(process.argv.slice(2));
|
|
372
|
+
const warn = (text) => process.stderr.write(`agy-porter hook-shim [${hook ?? '?'}]: ${text}\n`);
|
|
373
|
+
let out = noOpinion(event);
|
|
374
|
+
try {
|
|
375
|
+
let payload = {};
|
|
376
|
+
try { payload = JSON.parse(readFileSync(0, 'utf8').replace(/^/, '') || '{}'); } catch {}
|
|
377
|
+
const pluginRoot = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]$/, '');
|
|
378
|
+
const config = JSON.parse(readFileSync(new URL('./hook-shim.json', import.meta.url), 'utf8'));
|
|
379
|
+
const entry = config.hooks?.[hook];
|
|
380
|
+
if (!entry) throw new Error(`no hook "${hook}" in hook-shim.json`);
|
|
381
|
+
if (!shouldRun(entry, payload)) {
|
|
382
|
+
process.stdout.write(out, () => process.exit(0));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const projectDir = payload.workspacePaths?.[0] ?? process.cwd();
|
|
386
|
+
const vars = entry.dialect === 'gemini'
|
|
387
|
+
? { extensionPath: pluginRoot, GEMINI_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir }
|
|
388
|
+
: { CLAUDE_PLUGIN_ROOT: pluginRoot, CLAUDE_PROJECT_DIR: projectDir };
|
|
389
|
+
const probeSpec = resolveHookSpawn(expandCommand(entry.command, vars, { posix: true }), process.platform, pluginRoot);
|
|
390
|
+
const usePosix = process.platform === 'win32' && !probeSpec.shell;
|
|
391
|
+
const envVars = usePosix ? Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.replace(/\\/g, '/')])) : vars;
|
|
392
|
+
const result = await runCommand(expandCommand(entry.command, vars, { posix: usePosix }), {
|
|
393
|
+
cwd: pluginRoot,
|
|
394
|
+
env: { ...process.env, ...envVars },
|
|
395
|
+
input: JSON.stringify(toSourcePayload(entry, payload)),
|
|
396
|
+
timeoutMs: (entry.timeout ?? 60) * 1000,
|
|
397
|
+
});
|
|
398
|
+
const translated = translateResult({ ...entry, toolName: payload.toolCall?.name, toolArgs: payload.toolCall?.args }, result);
|
|
399
|
+
out = translated.stdout;
|
|
400
|
+
if (translated.warning) warn(translated.warning);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
warn(`${err.message}; not blocking`);
|
|
403
|
+
}
|
|
404
|
+
process.stdout.write(out, () => process.exit(0));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Node resolves symlinks in the main module's URL but not in argv[1], so compare
|
|
408
|
+
// real paths. Otherwise a plugin reached through a symlink (macOS's /var, a linked
|
|
409
|
+
// home or .gemini) never runs its hooks.
|
|
410
|
+
function isMain() {
|
|
411
|
+
try {
|
|
412
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
413
|
+
} catch {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (isMain()) main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Stand-in for the 'claude-code' module a TSX mod imports. Atoms live in the
|
|
2
|
+
// conversation's $.state (engine.mjs), so they are per conversation, like Claude
|
|
3
|
+
// Code's per-session state. derive and memberOf aren't used by the spike's mods.
|
|
4
|
+
const key = (ref) => `${ref.plugin}/${ref.key}${ref.id === undefined ? '' : `#${ref.id}`}`;
|
|
5
|
+
|
|
6
|
+
export const atom = (ref, initial, options) => Object.freeze({ ref: Object.freeze({ ...ref }), initial, shape: options?.shape });
|
|
7
|
+
|
|
8
|
+
export async function read($, a) {
|
|
9
|
+
const value = await $.state.get(key(a.ref));
|
|
10
|
+
return value === undefined ? a.initial : value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function update($, a, fn) {
|
|
14
|
+
const next = fn(await read($, a));
|
|
15
|
+
await $.state.set(key(a.ref), next);
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function derive() { throw new Error('derive is not available in the Antigravity port yet'); }
|
|
20
|
+
export function memberOf() { throw new Error('memberOf is not available in the Antigravity port yet'); }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Element trees for a ported Claude Code mod. Mods compile against bare `h` and
|
|
2
|
+
// `Fragment` (esbuild's classic JSX runtime), and get components from $.ui.resolve(e).
|
|
3
|
+
// The view (ui-draw.js) decides how each element name is drawn.
|
|
4
|
+
const normalize = (c) => (c == null || typeof c === 'boolean' ? [] : Array.isArray(c) ? c.flatMap(normalize)
|
|
5
|
+
: typeof c === 'number' ? [String(c)] : [c]);
|
|
6
|
+
|
|
7
|
+
export const element = (type, props) => {
|
|
8
|
+
const { children, ...rest } = props ?? {};
|
|
9
|
+
return { type, props: rest, children: normalize(children) };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function installJsxGlobals() {
|
|
13
|
+
globalThis.h = (tag, props, ...children) => {
|
|
14
|
+
const all = { ...(props ?? {}) };
|
|
15
|
+
if (children.length) all.children = children;
|
|
16
|
+
return typeof tag === 'function' ? tag(all) : element(String(tag), all);
|
|
17
|
+
};
|
|
18
|
+
globalThis.Fragment = (props) => element('Box', { flexDirection: 'column', ...props });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// $.ui.resolve(e): every element name is a constructor.
|
|
22
|
+
export const ELEMENTS = new Proxy({}, {
|
|
23
|
+
get: (_, name) => (typeof name === 'string' && name !== 'then' ? (props) => element(name, props) : undefined),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// A tree as JSON for the view. Functions (onPress, onSubmit, ...) become {$cb: id}.
|
|
27
|
+
export function serialize(node, cb) {
|
|
28
|
+
if (node == null || typeof node === 'boolean') return null;
|
|
29
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
30
|
+
const kids = (list) => list.map((c) => serialize(c, cb)).filter((c) => c !== null);
|
|
31
|
+
if (Array.isArray(node)) return { type: 'Box', props: { flexDirection: 'column' }, children: kids(node) };
|
|
32
|
+
const props = {};
|
|
33
|
+
for (const [k, v] of Object.entries(node.props ?? {})) props[k] = typeof v === 'function' ? { $cb: cb(v) } : v;
|
|
34
|
+
return { type: node.type, props, children: kids(node.children ?? []) };
|
|
35
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Hosts a ported Claude Code mod for every Antigravity conversation: one Instance
|
|
2
|
+
// per conversation, agy hook events mapped to the mod's events (plan §5.4), and the
|
|
3
|
+
// MCP side (the mod's $.tool.register tools plus one slash_<name> tool per command).
|
|
4
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { installJsxGlobals } from './elements.mjs';
|
|
8
|
+
import { Instance } from './instance.mjs';
|
|
9
|
+
|
|
10
|
+
installJsxGlobals();
|
|
11
|
+
|
|
12
|
+
export class Engine {
|
|
13
|
+
constructor({ mod, modUrl, dataDir, emit, visibleViews, log, preToolWaitMs = 1500, pendingMaxMs = 15 * 60_000 }) {
|
|
14
|
+
Object.assign(this, { mod, modUrl, emit, visibleViews, log, preToolWaitMs, pendingMaxMs });
|
|
15
|
+
this.options = mod.options ?? {};
|
|
16
|
+
this.instances = new Map();
|
|
17
|
+
const file = join(dataDir, 'store.json');
|
|
18
|
+
let data = {};
|
|
19
|
+
try { data = JSON.parse(readFileSync(file, 'utf8')); } catch {}
|
|
20
|
+
this.store = {
|
|
21
|
+
data: Object.assign(Object.create(null), data),
|
|
22
|
+
save() {
|
|
23
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
24
|
+
const payload = JSON.stringify(this.data, null, 2);
|
|
25
|
+
writeFileSync(tmp, payload);
|
|
26
|
+
try {
|
|
27
|
+
renameSync(tmp, file);
|
|
28
|
+
} catch {
|
|
29
|
+
try { writeFileSync(file, payload); } catch {}
|
|
30
|
+
try { rmSync(tmp, { force: true }); } catch {}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The conversation's instance, loaded on first use. A cwd learned later reruns
|
|
37
|
+
// session.start, as a Claude Code reload does (live, Phase 2.5A).
|
|
38
|
+
async get(conv, cwd) {
|
|
39
|
+
// Hooks report C:\..., the view's workspace URI gives C:/...: use one spelling.
|
|
40
|
+
if (cwd && /^[A-Za-z]:[\\/]/.test(cwd)) cwd = cwd.replace(/\//g, '\\');
|
|
41
|
+
let entry = this.instances.get(conv);
|
|
42
|
+
if (!entry) {
|
|
43
|
+
const inst = new Instance(this, conv, cwd);
|
|
44
|
+
entry = { inst, ready: inst.load() };
|
|
45
|
+
this.instances.set(conv, entry);
|
|
46
|
+
this.log('instance', { conv, cwd: cwd ?? '' });
|
|
47
|
+
}
|
|
48
|
+
await entry.ready;
|
|
49
|
+
const { inst } = entry;
|
|
50
|
+
if (cwd && !inst.cwd) {
|
|
51
|
+
inst.cwd = cwd;
|
|
52
|
+
entry.ready = inst.dispatch('session.start', { cwd, source: 'resume' }, () => ({}));
|
|
53
|
+
await entry.ready;
|
|
54
|
+
inst.scheduleRender();
|
|
55
|
+
}
|
|
56
|
+
return inst;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// agy hook events, forwarded by hook.mjs. Returns what the hook prints.
|
|
60
|
+
async hook(event, payload) {
|
|
61
|
+
const inst = await this.get(typeof payload?.conversationId === 'string' ? payload.conversationId : '', payload?.workspacePaths?.[0]);
|
|
62
|
+
switch (event) {
|
|
63
|
+
case 'PreInvocation': {
|
|
64
|
+
// PreInvocation runs before every model call: only the first one of a turn
|
|
65
|
+
// starts it, and only that one adds prompt.context.
|
|
66
|
+
if (inst.inTurn) return { stdout: '{}' };
|
|
67
|
+
inst.inTurn = true;
|
|
68
|
+
await inst.dispatch('turn.start', {}, () => undefined);
|
|
69
|
+
return { stdout: await this.promptContext(inst) };
|
|
70
|
+
}
|
|
71
|
+
case 'PreToolUse': {
|
|
72
|
+
const { decision, path } = await inst.preToolUse(payload);
|
|
73
|
+
return { stdout: decision ? JSON.stringify(decision) : '', path };
|
|
74
|
+
}
|
|
75
|
+
case 'PostToolUse':
|
|
76
|
+
return { stdout: '{}', path: inst.postToolUse(payload) };
|
|
77
|
+
case 'Stop':
|
|
78
|
+
inst.dropPending('no PostToolUse before Stop');
|
|
79
|
+
await new Promise((r) => setImmediate(r));
|
|
80
|
+
inst.inTurn = false;
|
|
81
|
+
await inst.dispatch('turn.complete', { agentId: undefined }, () => undefined);
|
|
82
|
+
return { stdout: '{}' };
|
|
83
|
+
default:
|
|
84
|
+
return { stdout: '{}' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only the systemMessage step is proven live. A step type agy doesn't know ends
|
|
89
|
+
// the whole agent turn (live: hookEphemeralMessage), so nothing else is emitted.
|
|
90
|
+
async promptContext(inst) {
|
|
91
|
+
if (!inst.has('prompt.context')) return '{}';
|
|
92
|
+
const r = await inst.dispatch('prompt.context', { blocks: [] }, (x) => ({ blocks: x.blocks }));
|
|
93
|
+
const text = (Array.isArray(r?.blocks) ? r.blocks : []).map((b) => b?.text).filter((t) => typeof t === 'string' && t).join('\n\n');
|
|
94
|
+
return text ? JSON.stringify({ injectSteps: [{ systemMessage: { systemMessage: text } }] }) : '{}';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listTools() {
|
|
98
|
+
const inst = await this.get('');
|
|
99
|
+
const tools = [];
|
|
100
|
+
for (const t of inst.tools.values()) {
|
|
101
|
+
const full = `mcp__${this.mod.mcpServer}__${t.name}`;
|
|
102
|
+
const d = await inst.dispatch('tool.describe', { tool: full, description: t.description ?? '', inputSchema: t.inputSchema },
|
|
103
|
+
(x) => ({ description: x.description, inputSchema: x.inputSchema }));
|
|
104
|
+
tools.push({ name: t.name, description: d?.description ?? t.description ?? '', inputSchema: d?.inputSchema ?? t.inputSchema ?? { type: 'object', properties: {} } });
|
|
105
|
+
}
|
|
106
|
+
for (const c of inst.commands.values()) tools.push(commandTool(c));
|
|
107
|
+
return tools;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async callTool(conv, name, args) {
|
|
111
|
+
const inst = await this.get(conv ?? '');
|
|
112
|
+
if (name.startsWith('slash_')) return this.runCommand(inst, name.slice(6), args?.args ?? '', 'mcp');
|
|
113
|
+
const full = `mcp__${this.mod.mcpServer}__${name}`;
|
|
114
|
+
const r = await inst.dispatch('tool.call', { tool: full, ...args }, () => ({ deny: `${name} has no handler in this mod` }));
|
|
115
|
+
if (r?.deny) return { isError: true, text: String(r.deny) };
|
|
116
|
+
return { isError: false, text: typeof r?.result === 'string' ? r.result : JSON.stringify(r?.result ?? null) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async runCommand(inst, command, args, from) {
|
|
120
|
+
const r = await inst.dispatch('command.run', { command, args: String(args ?? ''), from }, () => ({ text: `Unknown command /${command}` }));
|
|
121
|
+
return { isError: false, text: typeof r?.text === 'string' ? r.text : '' };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const commandTool = (c) => ({
|
|
126
|
+
name: `slash_${c.name}`,
|
|
127
|
+
description: `Runs the /${c.name} command: ${c.description ?? ''}`.trim(),
|
|
128
|
+
inputSchema: { type: 'object', properties: { args: { type: 'string', description: `What follows /${c.name}${c.argumentHint ? ` (${c.argumentHint})` : ''}; empty for none.` } } },
|
|
129
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// agy hook -> sidecar: `agy-node sidecars/<id>/hook.mjs <Event>`, cwd = plugin root
|
|
2
|
+
// (live, Phase 2.5A). Prints what the sidecar answers. Fails open: if the sidecar is
|
|
3
|
+
// down, slow or broken, it prints "no opinion" (nothing for PreToolUse, `{}`
|
|
4
|
+
// otherwise) and exits 0, since agy fails a tool call on any nonzero PreToolUse exit
|
|
5
|
+
// and reads `{}` from PreToolUse as a deny (live, agy-baseline.md).
|
|
6
|
+
import { callSidecar } from './locate.mjs';
|
|
7
|
+
|
|
8
|
+
const event = process.argv[2] || 'PreToolUse';
|
|
9
|
+
const noOpinion = event === 'PreToolUse' ? '' : '{}';
|
|
10
|
+
const finish = (out) => process.stdout.write(out, () => process.exit(0));
|
|
11
|
+
process.on('uncaughtException', () => finish(noOpinion));
|
|
12
|
+
setTimeout(() => finish(noOpinion), 4000).unref();
|
|
13
|
+
|
|
14
|
+
let raw = '';
|
|
15
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
16
|
+
let payload = {};
|
|
17
|
+
try { payload = JSON.parse(raw.replace(/^/, '') || '{}'); } catch {}
|
|
18
|
+
try {
|
|
19
|
+
const r = await callSidecar('/api/hook', { event, payload });
|
|
20
|
+
finish(typeof r.stdout === 'string' ? r.stdout : noOpinion);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
process.stderr.write(`agy-porter mod hook: ${err.message}; not blocking\n`);
|
|
23
|
+
finish(noOpinion);
|
|
24
|
+
}
|