agy-plugin-porter 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/bin/agy-porter.mjs +91 -0
- package/lib/detector.mjs +91 -0
- package/lib/hook-compiler.mjs +172 -0
- package/lib/index.mjs +29 -0
- package/lib/manifest-sanitizer.mjs +151 -0
- package/lib/mode-compiler.mjs +185 -0
- package/lib/rules-compiler.mjs +144 -0
- package/lib/skill-compiler.mjs +132 -0
- package/lib/source.mjs +58 -0
- package/lib/static-auditor.mjs +140 -0
- package/lib/tool-map.mjs +47 -0
- package/lib/transpiler-engine.mjs +158 -0
- package/lib/yaml-lite.mjs +190 -0
- package/package.json +62 -4
- package/scripts/hook-shim.mjs +244 -0
|
@@ -0,0 +1,244 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
15
|
+
import { sep } from 'node:path';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } 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) {
|
|
60
|
+
const [name, renames] = REVERSE[dialect]?.[toolCall?.name] ?? [toolCall?.name ?? '', {}];
|
|
61
|
+
return { tool_name: name, tool_input: renameKeys(toolCall?.args, renames) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Source payload (snake_case) from agy's camelCase protojson payload.
|
|
65
|
+
export function toSourcePayload(entry, payload) {
|
|
66
|
+
const base = {
|
|
67
|
+
session_id: payload.conversationId ?? '',
|
|
68
|
+
transcript_path: payload.transcriptPath ?? '',
|
|
69
|
+
cwd: payload.workspacePaths?.[0] ?? process.cwd(),
|
|
70
|
+
hook_event_name: entry.sourceEvent,
|
|
71
|
+
};
|
|
72
|
+
if (entry.dialect === 'gemini') base.timestamp = new Date().toISOString();
|
|
73
|
+
else base.permission_mode = 'default';
|
|
74
|
+
switch (entry.event) {
|
|
75
|
+
case 'PreToolUse':
|
|
76
|
+
return { ...base, ...toSourceTool(payload.toolCall, entry.dialect) };
|
|
77
|
+
case 'PostToolUse':
|
|
78
|
+
if (entry.sourceEvent === 'PostToolUseFailure') return { ...base, ...toSourceTool(payload.toolCall, entry.dialect), error: payload.error ?? '' };
|
|
79
|
+
return { ...base, ...toSourceTool(payload.toolCall, entry.dialect), tool_response: payload.error ? { error: payload.error } : {} };
|
|
80
|
+
case 'SessionStart':
|
|
81
|
+
return { ...base, source: 'startup' };
|
|
82
|
+
case 'Stop':
|
|
83
|
+
return { ...base, stop_hook_active: Number(payload.executionNum ?? 1) > 1 };
|
|
84
|
+
default:
|
|
85
|
+
return base;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Expands the plugin-path variables Claude Code and Gemini CLI provide, so the
|
|
90
|
+
// command works under cmd.exe too. Unknown variables are left to the shell.
|
|
91
|
+
export function expandCommand(command, vars) {
|
|
92
|
+
const values = { ...vars, '/': sep };
|
|
93
|
+
return command.replace(/\$\{([A-Za-z_]\w*|\/)\}|\$([A-Za-z_]\w*)/g, (whole, braced, bare) => {
|
|
94
|
+
const name = braced ?? bare;
|
|
95
|
+
return Object.hasOwn(values, name) ? values[name] : whole;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NO_OPINION = { PreToolUse: '' };
|
|
100
|
+
export const noOpinion = (event) => NO_OPINION[event] ?? '{}';
|
|
101
|
+
|
|
102
|
+
// agy has one PostToolUse with `error` set when the tool failed (empty on
|
|
103
|
+
// success, live-tested). Claude splits it: PostToolUse runs on success only,
|
|
104
|
+
// PostToolUseFailure on failure only.
|
|
105
|
+
export function shouldRun(entry, payload) {
|
|
106
|
+
if (entry.event !== 'PostToolUse' || entry.dialect !== 'claude') return true;
|
|
107
|
+
return Boolean(payload.error) === (entry.sourceEvent === 'PostToolUseFailure');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseJson(text) {
|
|
111
|
+
if (!text.startsWith('{')) return null;
|
|
112
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Turns the script's {code, stdout, stderr} into agy's stdout. Returns
|
|
116
|
+
// {stdout, warning?}. A nonzero exit other than 2 (or a timeout) never blocks:
|
|
117
|
+
// that matches Claude Code and Gemini CLI, where only exit 2 is blocking.
|
|
118
|
+
export function translateResult(entry, result) {
|
|
119
|
+
const event = entry.event;
|
|
120
|
+
const stdout = (result.stdout ?? '').replace(/^/, '').replace(/\r\n/g, '\n').trim();
|
|
121
|
+
const stderr = (result.stderr ?? '').replace(/^/, '').replace(/\r\n/g, '\n').trim();
|
|
122
|
+
if (result.timedOut || result.spawnError || (result.code !== 0 && result.code !== 2)) {
|
|
123
|
+
const why = result.timedOut ? `timed out after ${entry.timeout}s`
|
|
124
|
+
: result.spawnError ? `failed to start: ${result.spawnError}` : `exited with code ${result.code}`;
|
|
125
|
+
return { stdout: noOpinion(event), warning: `${why}; not blocking${stderr ? `. stderr: ${stderr}` : ''}` };
|
|
126
|
+
}
|
|
127
|
+
const blocked = result.code === 2;
|
|
128
|
+
const json = blocked ? null : parseJson(stdout);
|
|
129
|
+
const specific = json?.hookSpecificOutput ?? {};
|
|
130
|
+
|
|
131
|
+
if (event === 'PreToolUse') {
|
|
132
|
+
const reasonOr = (r, fallback) => (typeof r === 'string' && r.trim() ? r.trim() : fallback);
|
|
133
|
+
if (blocked) return { stdout: JSON.stringify({ decision: 'deny', reason: reasonOr(stderr, 'Blocked by hook') }) };
|
|
134
|
+
if (!json) return { stdout: '' };
|
|
135
|
+
let decision = ['allow', 'deny', 'ask'].includes(specific.permissionDecision) ? specific.permissionDecision : null;
|
|
136
|
+
let reason = specific.permissionDecisionReason;
|
|
137
|
+
if (!decision && typeof json.decision === 'string') {
|
|
138
|
+
decision = { block: 'deny', deny: 'deny', approve: 'allow', allow: 'allow', ask: 'ask' }[json.decision] ?? null;
|
|
139
|
+
reason = json.reason;
|
|
140
|
+
}
|
|
141
|
+
if (!decision && json.continue === false) {
|
|
142
|
+
decision = 'deny';
|
|
143
|
+
reason = json.stopReason;
|
|
144
|
+
}
|
|
145
|
+
if (!decision) return { stdout: '' };
|
|
146
|
+
const out = { decision };
|
|
147
|
+
if (decision === 'deny') out.reason = reasonOr(reason, 'Blocked by hook');
|
|
148
|
+
else if (typeof reason === 'string' && reason) out.reason = reason;
|
|
149
|
+
if (decision !== 'deny' && specific.updatedInput && typeof specific.updatedInput === 'object') {
|
|
150
|
+
const renames = REVERSE[entry.dialect]?.[entry.toolName]?.[1] ?? {};
|
|
151
|
+
out.overwrite = renameKeys(specific.updatedInput, invert(renames));
|
|
152
|
+
}
|
|
153
|
+
return { stdout: JSON.stringify(out) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (event === 'SessionStart') {
|
|
157
|
+
const text = json ? specific.additionalContext : (blocked ? '' : stdout);
|
|
158
|
+
if (typeof text !== 'string' || !text.trim()) return { stdout: '{}' };
|
|
159
|
+
return { stdout: JSON.stringify({ injectSteps: [{ systemMessage: { systemMessage: text.trim() } }] }) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (event === 'Stop') {
|
|
163
|
+
if (blocked) return { stdout: JSON.stringify({ decision: 'continue', reason: stderr || 'A stop hook asked to continue.' }) };
|
|
164
|
+
if (json?.decision === 'block') return { stdout: JSON.stringify({ decision: 'continue', reason: json.reason ?? 'A stop hook asked to continue.' }) };
|
|
165
|
+
return { stdout: '{}' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// PostToolUse: agy only takes {}. Feedback for the model has nowhere to go.
|
|
169
|
+
const feedback = blocked ? stderr : (json?.decision === 'block' ? json.reason : '');
|
|
170
|
+
return { stdout: '{}', ...(feedback ? { warning: `PostToolUse feedback dropped (agy has no channel for it): ${feedback}` } : {}) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function runCommand(command, { cwd, env, input, timeoutMs }) {
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
let stdout = '';
|
|
176
|
+
let stderr = '';
|
|
177
|
+
let done = false;
|
|
178
|
+
let child;
|
|
179
|
+
const finish = (extra) => {
|
|
180
|
+
if (done) return;
|
|
181
|
+
done = true;
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
resolve({ stdout, stderr, ...extra });
|
|
184
|
+
};
|
|
185
|
+
const timer = setTimeout(() => { child?.kill(); finish({ timedOut: true }); }, timeoutMs);
|
|
186
|
+
try {
|
|
187
|
+
child = spawn(command, { cwd, env, shell: true, windowsHide: true });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
finish({ spawnError: err.message });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
193
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
194
|
+
child.on('error', (err) => finish({ spawnError: err.message }));
|
|
195
|
+
child.on('close', (code) => finish({ code }));
|
|
196
|
+
child.stdin.on('error', () => {});
|
|
197
|
+
child.stdin.end(input);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseArgs(argv) {
|
|
202
|
+
const opts = {};
|
|
203
|
+
for (let i = 0; i < argv.length; i++) {
|
|
204
|
+
if (argv[i] === '--event') opts.event = argv[++i];
|
|
205
|
+
else if (argv[i] === '--hook') opts.hook = argv[++i];
|
|
206
|
+
}
|
|
207
|
+
return opts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function main() {
|
|
211
|
+
const { event = 'PreToolUse', hook } = parseArgs(process.argv.slice(2));
|
|
212
|
+
const warn = (text) => process.stderr.write(`agy-porter hook-shim [${hook ?? '?'}]: ${text}\n`);
|
|
213
|
+
let out = noOpinion(event);
|
|
214
|
+
try {
|
|
215
|
+
let payload = {};
|
|
216
|
+
try { payload = JSON.parse(readFileSync(0, 'utf8').replace(/^/, '') || '{}'); } catch {}
|
|
217
|
+
const pluginRoot = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]$/, '');
|
|
218
|
+
const config = JSON.parse(readFileSync(new URL('./hook-shim.json', import.meta.url), 'utf8'));
|
|
219
|
+
const entry = config.hooks?.[hook];
|
|
220
|
+
if (!entry) throw new Error(`no hook "${hook}" in hook-shim.json`);
|
|
221
|
+
if (!shouldRun(entry, payload)) {
|
|
222
|
+
process.stdout.write(out, () => process.exit(0));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const projectDir = payload.workspacePaths?.[0] ?? process.cwd();
|
|
226
|
+
const vars = entry.dialect === 'gemini'
|
|
227
|
+
? { extensionPath: pluginRoot, GEMINI_PROJECT_DIR: projectDir, CLAUDE_PROJECT_DIR: projectDir }
|
|
228
|
+
: { CLAUDE_PLUGIN_ROOT: pluginRoot, CLAUDE_PROJECT_DIR: projectDir };
|
|
229
|
+
const result = await runCommand(expandCommand(entry.command, vars), {
|
|
230
|
+
cwd: pluginRoot,
|
|
231
|
+
env: { ...process.env, ...vars },
|
|
232
|
+
input: JSON.stringify(toSourcePayload(entry, payload)),
|
|
233
|
+
timeoutMs: (entry.timeout ?? 60) * 1000,
|
|
234
|
+
});
|
|
235
|
+
const translated = translateResult({ ...entry, toolName: payload.toolCall?.name }, result);
|
|
236
|
+
out = translated.stdout;
|
|
237
|
+
if (translated.warning) warn(translated.warning);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
warn(`${err.message}; not blocking`);
|
|
240
|
+
}
|
|
241
|
+
process.stdout.write(out, () => process.exit(0));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|