agy-plugin-porter 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/README.md +59 -4
- package/bin/agy-porter.mjs +2 -2
- package/lib/consent.mjs +122 -0
- package/lib/detector.mjs +2 -1
- package/lib/hook-compiler.mjs +54 -18
- package/lib/index.mjs +3 -0
- package/lib/manifest-sanitizer.mjs +46 -10
- 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 +14 -4
- package/lib/rules-compiler.mjs +71 -10
- package/lib/skill-compiler.mjs +4 -1
- package/lib/source.mjs +25 -2
- package/lib/static-auditor.mjs +47 -8
- package/lib/tool-map.mjs +0 -1
- package/lib/transpiler-engine.mjs +50 -18
- package/lib/yaml-lite.mjs +36 -4
- package/package.json +14 -3
- package/scripts/hook-shim.mjs +198 -24
- 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,210 @@
|
|
|
1
|
+
// Compiles Claude Code UI mods into Antigravity plugin sidecars (plan §5, §6). Each
|
|
2
|
+
// module listed under a hooks config's "modules" becomes sidecars/<id>/: the porter's
|
|
3
|
+
// runtime (scripts/mod-runtime/), the transformed mod and a mod.json with what the
|
|
4
|
+
// static scan found. It also writes the mod's MCP server, one skill per command and
|
|
5
|
+
// the hooks that forward agy events to the sidecar.
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, posix } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { remapMatcher } from './hook-compiler.mjs';
|
|
11
|
+
import { assess, importsOf, scanMod } from './mod-scan.mjs';
|
|
12
|
+
import { collectGraph, loadEsbuild, needsTransform, outName, transformFile } from './mod-transform.mjs';
|
|
13
|
+
import { slugifyName } from './skill-compiler.mjs';
|
|
14
|
+
import { unmappedToolReason } from './tool-map.mjs';
|
|
15
|
+
|
|
16
|
+
export const RUNTIME_DIR = fileURLToPath(new URL('../scripts/mod-runtime/', import.meta.url));
|
|
17
|
+
const LICENSES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'];
|
|
18
|
+
|
|
19
|
+
const readJsonIf = (file) => { try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return null; } };
|
|
20
|
+
const writeJson = (file, value) => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); };
|
|
21
|
+
|
|
22
|
+
// The agy hooks a scanned mod needs (plan §5.4). Returns {events, unmapped}.
|
|
23
|
+
export function modHooks(scan, id, ownMcpServer = null) {
|
|
24
|
+
const has = (name) => scan.events.some((e) => e.event === name);
|
|
25
|
+
const cmd = (event, timeout) => ({ type: 'command', command: `agy-node sidecars/${id}/hook.mjs ${event}`, timeout });
|
|
26
|
+
const ownPrefix = ownMcpServer ? `mcp__${ownMcpServer}__` : null;
|
|
27
|
+
const ownToolNames = new Set((scan.tools ?? []).map((t) => t.name));
|
|
28
|
+
const isOwnMcpTool = (t) => {
|
|
29
|
+
if (!t.startsWith('mcp__')) return false;
|
|
30
|
+
if (ownPrefix && t.startsWith(ownPrefix)) return true;
|
|
31
|
+
const parts = t.split('__');
|
|
32
|
+
return parts.length >= 3 && ownToolNames.has(parts.slice(2).join('__'));
|
|
33
|
+
};
|
|
34
|
+
const calls = scan.events.filter((e) => e.event === 'tool.call');
|
|
35
|
+
const tools = new Set();
|
|
36
|
+
const unmapped = [];
|
|
37
|
+
for (const call of calls) {
|
|
38
|
+
const rawMatcher = String(call.matcher?.tool ?? '*').trim() || '*';
|
|
39
|
+
const alts = rawMatcher.split('|').map((a) => a.trim()).filter((a) => a && !isOwnMcpTool(a));
|
|
40
|
+
if (alts.length === 0) continue;
|
|
41
|
+
const remap = remapMatcher(alts.join('|'), 'claude');
|
|
42
|
+
unmapped.push(...remap.unmapped);
|
|
43
|
+
for (const t of remap.matcher?.split('|') ?? []) tools.add(t);
|
|
44
|
+
}
|
|
45
|
+
// SessionStart, PreInvocation and Stop are flat; tool events are grouped by matcher.
|
|
46
|
+
const events = { SessionStart: [cmd('SessionStart', 5)] };
|
|
47
|
+
const perTurn = has('turn.start') || has('prompt.context');
|
|
48
|
+
if (perTurn) events.PreInvocation = [cmd('PreInvocation', 5)];
|
|
49
|
+
if (tools.size) {
|
|
50
|
+
const matcher = tools.has('*') ? '*' : [...tools].sort().join('|');
|
|
51
|
+
events.PreToolUse = [{ matcher, hooks: [cmd('PreToolUse', 10)] }];
|
|
52
|
+
events.PostToolUse = [{ matcher, hooks: [cmd('PostToolUse', 10)] }];
|
|
53
|
+
}
|
|
54
|
+
// Stop ends the turn, so the next PreInvocation counts as a turn's first (live, 2.5B)
|
|
55
|
+
if (perTurn || has('turn.complete') || tools.size) events.Stop = [cmd('Stop', 5)];
|
|
56
|
+
return { events, unmapped: [...new Set(unmapped)] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function skillFor(c, { title, slug, id }) {
|
|
60
|
+
return [
|
|
61
|
+
'---',
|
|
62
|
+
`name: ${slugifyName(c.name)}`,
|
|
63
|
+
`description: Runs the /${c.name} command of the ${title} mod.${c.description ? ` ${c.description}` : ''} Use when the user types /${c.name} or asks for ${title}.`.replace(/\s+/g, ' '),
|
|
64
|
+
'---',
|
|
65
|
+
'',
|
|
66
|
+
`# /${c.name}`,
|
|
67
|
+
'',
|
|
68
|
+
`This command belongs to ${title}, a Claude Code mod ported to Antigravity. Its pane is at \`sidecar://${slug}/${id}/ui.html\`.`,
|
|
69
|
+
'',
|
|
70
|
+
`1. Call the \`slash_${c.name}\` tool. Pass the text after \`/${c.name}\` as \`args\`${c.argumentHint ? ` (${c.argumentHint})` : ''}, or an empty string.`,
|
|
71
|
+
"2. Reply with the tool's text. It ends with a `sidecar://` link that opens the pane: put that link on its own line, exactly as given.",
|
|
72
|
+
'',
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Writes one compiled mod into pluginDir. Returns its MCP server name, or null.
|
|
77
|
+
function emit(pluginDir, { id, files, transformed, scan, verdict, meta, report, item }) {
|
|
78
|
+
const side = join(pluginDir, 'sidecars', id);
|
|
79
|
+
rmSync(side, { recursive: true, force: true });
|
|
80
|
+
mkdirSync(join(side, 'mod'), { recursive: true });
|
|
81
|
+
for (const f of readdirSync(RUNTIME_DIR)) copyFileSync(join(RUNTIME_DIR, f), join(side, f));
|
|
82
|
+
files.forEach((f, i) => { const out = join(side, 'mod', outName(f.rel)); mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, transformed[i]); });
|
|
83
|
+
const license = LICENSES.find((l) => existsSync(join(meta.srcDir, l)));
|
|
84
|
+
if (license) copyFileSync(join(meta.srcDir, license), join(side, 'mod', license));
|
|
85
|
+
|
|
86
|
+
const hasServer = scan.tools.length > 0 || scan.commands.length > 0;
|
|
87
|
+
const config = readJsonIf(join(pluginDir, 'mcp_config.json')) ?? { mcpServers: {} };
|
|
88
|
+
config.mcpServers ??= {};
|
|
89
|
+
let mcpServer = meta.mcpServer;
|
|
90
|
+
if (hasServer && Object.hasOwn(config.mcpServers, mcpServer)) {
|
|
91
|
+
report.warnings.push(`${item}: an MCP server named ${mcpServer} exists already, so the mod's is ${mcpServer}-mod and its mcp__${mcpServer}__* tool names change`);
|
|
92
|
+
mcpServer = `${mcpServer}-mod`;
|
|
93
|
+
}
|
|
94
|
+
writeJson(join(side, 'mod.json'), {
|
|
95
|
+
plugin: meta.slug, sidecar: id, title: meta.title, pluginName: meta.sourceName, version: meta.version, mcpServer,
|
|
96
|
+
entry: `mod/${outName(meta.entryRel)}`, hasPane: verdict.hasPane, allow: [...scan.calls].sort(), options: meta.options,
|
|
97
|
+
commands: scan.commands, tools: scan.tools, verdict: verdict.verdict, tier: verdict.tier, tierReasons: verdict.tierReasons,
|
|
98
|
+
});
|
|
99
|
+
writeJson(join(side, 'sidecar.json'), {
|
|
100
|
+
command: 'node', args: ['server.mjs'], description: `${meta.title}, a Claude Code mod ported by agy-plugin-porter.`,
|
|
101
|
+
display_name: meta.title, has_web_ui: true, restart_policy: 'always',
|
|
102
|
+
ui_config: { display_name: meta.title, views: [{ entrypoint: 'SIDECAR_UI_ENTRYPOINT_AUX_PANE', path: '/ui.html', title: meta.title }] },
|
|
103
|
+
});
|
|
104
|
+
if (hasServer) {
|
|
105
|
+
config.mcpServers[mcpServer] = { command: 'agy-node', args: [`\${PLUGIN_ROOT}/sidecars/${id}/mcp-entry.mjs`] };
|
|
106
|
+
writeJson(join(pluginDir, 'mcp_config.json'), config);
|
|
107
|
+
report.ported.push(`mcp:${mcpServer}`);
|
|
108
|
+
}
|
|
109
|
+
for (const c of scan.commands) {
|
|
110
|
+
const file = join(pluginDir, 'skills', slugifyName(c.name), 'SKILL.md');
|
|
111
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(file, skillFor(c, { title: meta.title, slug: meta.slug, id }), { flag: 'wx' }); // never replaces the plugin's own skill
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code !== 'EEXIST') throw err;
|
|
116
|
+
report.warnings.push(`${item}: skills/${slugifyName(c.name)} exists already, so /${c.name} has no skill (the pane's button still runs it)`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
report.ported.push(`command:/${c.name}`);
|
|
120
|
+
}
|
|
121
|
+
const hooks = modHooks(scan, id, mcpServer);
|
|
122
|
+
for (const name of hooks.unmapped) report.unported.push({ item: `${item} tool.call ${name}`, reason: unmappedToolReason(name) });
|
|
123
|
+
const all = readJsonIf(join(pluginDir, 'hooks.json')) ?? {};
|
|
124
|
+
all[`${meta.slug}-mod-${id}`] = hooks.events;
|
|
125
|
+
writeJson(join(pluginDir, 'hooks.json'), all);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A Claude manifest's userConfig defaults, which Claude Code passes to register(on,
|
|
129
|
+
// options). Antigravity has no settings UI for them, so the port uses the defaults.
|
|
130
|
+
export function userConfigDefaults(userConfig) {
|
|
131
|
+
if (!userConfig || typeof userConfig !== 'object') return {};
|
|
132
|
+
return Object.fromEntries(Object.entries(userConfig).filter(([, v]) => v && typeof v === 'object' && 'default' in v).map(([k, v]) => [k, v.default]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// uiMods: detect()'s list ({path, modules}). Returns {ported, unported, warnings}.
|
|
136
|
+
export function compileMods(uiMods, { srcDir, pluginDir, slug, sourceName, title, version, esbuild, options = {} }) {
|
|
137
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
138
|
+
const entries = uiMods.flatMap((mod) => mod.modules.map((m) => posix.normalize(posix.join(posix.dirname(mod.path.split('#')[0]), String(m)))));
|
|
139
|
+
let esb = esbuild;
|
|
140
|
+
const getEsbuild = () => (esb === undefined ? (esb = loadEsbuild()) : esb);
|
|
141
|
+
const name = sourceName || slug;
|
|
142
|
+
const ids = new Set();
|
|
143
|
+
entries.forEach((entryRel, n) => {
|
|
144
|
+
const item = `ui-mod:${entryRel}`;
|
|
145
|
+
const stem = posix.basename(entryRel).replace(/\.[^.]+$/, '');
|
|
146
|
+
let id = entries.length === 1 ? 'pane' : `pane-${slugifyName(stem)}`;
|
|
147
|
+
if (ids.has(id)) id = `${id}-${n + 1}`;
|
|
148
|
+
ids.add(id);
|
|
149
|
+
let files;
|
|
150
|
+
let transformed;
|
|
151
|
+
try {
|
|
152
|
+
files = collectGraph(srcDir, entryRel);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
report.unported.push({ item, reason: `can't read the mod: ${err.message}` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const f of files) rmSync(join(pluginDir, f.rel), { force: true }); // agy staged the raw source
|
|
158
|
+
const scan = scanMod(files.map((f) => ({ path: f.rel, src: f.src })));
|
|
159
|
+
const verdict = assess(scan);
|
|
160
|
+
if (verdict.verdict === 'refused') {
|
|
161
|
+
report.unported.push({ item, reason: `not ported: it ${verdict.refusals.join('; it ')}` });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const tool = needsTransform(files) ? getEsbuild() : null;
|
|
166
|
+
transformed = files.map((f) => transformFile(srcDir, f, tool));
|
|
167
|
+
} catch (err) {
|
|
168
|
+
report.unported.push({ item, reason: err.message });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (entries.length > 1) report.warnings.push(`${item}: one of several mods, so its MCP server is ${name}-${id}`);
|
|
172
|
+
const meta = {
|
|
173
|
+
srcDir, slug, sourceName: name, title: entries.length === 1 ? title || name : `${title || name} (${stem})`,
|
|
174
|
+
version: version ?? '0.0.0', entryRel, mcpServer: entries.length === 1 ? name : `${name}-${id}`, options,
|
|
175
|
+
};
|
|
176
|
+
if (Object.keys(options).length) report.warnings.push(`${item}: userConfig (${Object.keys(options).join(', ')}) uses its defaults; Antigravity has no settings for it`);
|
|
177
|
+
emit(pluginDir, { id, files, transformed, scan, verdict, meta, report, item });
|
|
178
|
+
report.ported.push(`${item} (${verdict.verdict}, sidecar ${id}, sidecar://${slug}/${id}/ui.html)`);
|
|
179
|
+
for (const f of verdict.findings) {
|
|
180
|
+
if (f.level === 'none') report.unported.push({ item: `${item} ${f.item}`, reason: f.reason });
|
|
181
|
+
else report.warnings.push(`${item}: ${f.item}${f.level === 'partial' ? ' is partial' : ''}: ${f.reason}`);
|
|
182
|
+
}
|
|
183
|
+
for (const w of verdict.warnings) report.warnings.push(`${item}: ${w}`);
|
|
184
|
+
});
|
|
185
|
+
if (entries.length) dropModLeftovers(pluginDir, entries, report);
|
|
186
|
+
return report;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const CODE = /\.(m?[jt]sx?|cjs|mts)$/;
|
|
190
|
+
const walk = (dir, rel = '') => readdirSync(join(dir, rel), { withFileTypes: true }).flatMap((e) => {
|
|
191
|
+
const path = rel ? `${rel}/${e.name}` : e.name;
|
|
192
|
+
if (e.isDirectory()) return ['sidecars', 'node_modules', '.git'].includes(path) ? [] : walk(dir, path);
|
|
193
|
+
return [path];
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Staged files that only run inside Claude Code: the mod's tests and helpers that
|
|
197
|
+
// import 'claude-code' (or 'claude-code/testing') or the mod, and its tsconfig.json. The ported mod lives in
|
|
198
|
+
// sidecars/ now, so these would ship raw source nothing can run.
|
|
199
|
+
function dropModLeftovers(pluginDir, entries, report) {
|
|
200
|
+
const modFiles = new Set(entries);
|
|
201
|
+
const dropped = [];
|
|
202
|
+
for (const rel of walk(pluginDir).filter((p) => CODE.test(p))) {
|
|
203
|
+
let specs;
|
|
204
|
+
try { specs = importsOf(readFileSync(join(pluginDir, rel), 'utf8')); } catch { continue; }
|
|
205
|
+
const usesMod = specs.some((s) => s === 'claude-code' || s.startsWith('claude-code/') || (/^\.{1,2}\//.test(s) && [...modFiles].some((m) => posix.normalize(posix.join(posix.dirname(rel), s)).replace(/\.[^./]+$/, '') === m.replace(/\.[^./]+$/, ''))));
|
|
206
|
+
if (usesMod) { rmSync(join(pluginDir, rel), { force: true }); dropped.push(rel); }
|
|
207
|
+
}
|
|
208
|
+
if (existsSync(join(pluginDir, 'tsconfig.json'))) { rmSync(join(pluginDir, 'tsconfig.json')); dropped.push('tsconfig.json'); }
|
|
209
|
+
if (dropped.length) report.warnings.push(`dropped files that only run inside Claude Code: ${dropped.sort().join(', ')}`);
|
|
210
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// A small JS/TS lexer for the mod scanner. It doesn't parse: it blanks comments and
|
|
2
|
+
// the insides of strings, template text and regex literals, so structure (on(...),
|
|
3
|
+
// $.noun.method, brackets) can be matched with plain regexes on the result, and
|
|
4
|
+
// literal values read back from the source at the same offsets.
|
|
5
|
+
// A `/` after one of these starts a regex. `<` is left out so JSX's `</Tag>` stays code.
|
|
6
|
+
const REGEX_BEFORE = /[(,=:[!&|?{};+\-*%~^]$|(?:^|[^\w$])(?:return|typeof|case|do|else|in|of|void|yield|await)$/;
|
|
7
|
+
|
|
8
|
+
export function blank(src) {
|
|
9
|
+
const out = src.split('');
|
|
10
|
+
const fill = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
11
|
+
const templates = []; // brace depth at each open `${`
|
|
12
|
+
let depth = 0;
|
|
13
|
+
let i = 0;
|
|
14
|
+
const skipString = (q) => {
|
|
15
|
+
const start = i++;
|
|
16
|
+
while (i < src.length && src[i] !== q && src[i] !== '\n') i += src[i] === '\\' ? 2 : 1;
|
|
17
|
+
fill(start + 1, i);
|
|
18
|
+
i++;
|
|
19
|
+
};
|
|
20
|
+
// Template text from i (just past ` or }) to the closing ` or the next ${.
|
|
21
|
+
const skipTemplate = () => {
|
|
22
|
+
const start = i;
|
|
23
|
+
while (i < src.length && src[i] !== '`' && !(src[i] === '$' && src[i + 1] === '{')) i += src[i] === '\\' ? 2 : 1;
|
|
24
|
+
fill(start, i);
|
|
25
|
+
if (src[i] === '`') { i++; return; }
|
|
26
|
+
templates.push(depth);
|
|
27
|
+
depth++;
|
|
28
|
+
i += 2;
|
|
29
|
+
};
|
|
30
|
+
while (i < src.length) {
|
|
31
|
+
const c = src[i];
|
|
32
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
33
|
+
const end = src.indexOf('\n', i);
|
|
34
|
+
fill(i, end < 0 ? src.length : end);
|
|
35
|
+
i = end < 0 ? src.length : end;
|
|
36
|
+
} else if (c === '/' && src[i + 1] === '*') {
|
|
37
|
+
const end = src.indexOf('*/', i + 2);
|
|
38
|
+
fill(i, end < 0 ? src.length : end + 2);
|
|
39
|
+
i = end < 0 ? src.length : end + 2;
|
|
40
|
+
} else if (c === '"' || c === "'") {
|
|
41
|
+
skipString(c);
|
|
42
|
+
} else if (c === '`') {
|
|
43
|
+
i++;
|
|
44
|
+
skipTemplate();
|
|
45
|
+
} else if (c === '/' && REGEX_BEFORE.test(out.slice(Math.max(0, i - 12), i).join('').trimEnd() || ';')) {
|
|
46
|
+
const start = i++;
|
|
47
|
+
let inClass = false;
|
|
48
|
+
while (i < src.length && src[i] !== '\n' && (inClass || src[i] !== '/')) {
|
|
49
|
+
if (src[i] === '\\') i++;
|
|
50
|
+
else if (src[i] === '[') inClass = true;
|
|
51
|
+
else if (src[i] === ']') inClass = false;
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
fill(start + 1, i);
|
|
55
|
+
i++;
|
|
56
|
+
} else {
|
|
57
|
+
if (c === '{') depth++;
|
|
58
|
+
else if (c === '}') {
|
|
59
|
+
depth--;
|
|
60
|
+
if (templates.length && templates.at(-1) === depth) {
|
|
61
|
+
templates.pop();
|
|
62
|
+
i++;
|
|
63
|
+
skipTemplate();
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out.join('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PAIRS = { '(': ')', '[': ']', '{': '}' };
|
|
74
|
+
|
|
75
|
+
// Index of the bracket that closes the one at `open` in blanked code, or -1.
|
|
76
|
+
export function closeOf(code, open) {
|
|
77
|
+
const stack = [];
|
|
78
|
+
for (let i = open; i < code.length; i++) {
|
|
79
|
+
const c = code[i];
|
|
80
|
+
if (PAIRS[c]) stack.push(PAIRS[c]);
|
|
81
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
82
|
+
if (stack.pop() !== c) return -1;
|
|
83
|
+
if (!stack.length) return i;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return -1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The string literal whose opening quote is at `at` in code: {value, end} or null.
|
|
90
|
+
// Template literals count only without ${...} parts.
|
|
91
|
+
export function literalAt(code, src, at) {
|
|
92
|
+
const q = code[at];
|
|
93
|
+
if (q !== '"' && q !== "'" && q !== '`') return null;
|
|
94
|
+
const end = code.indexOf(q, at + 1);
|
|
95
|
+
if (end < 0) return null;
|
|
96
|
+
const body = src.slice(at + 1, end);
|
|
97
|
+
if (q === '`' && body.includes('${')) return null;
|
|
98
|
+
const value = body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_, e) => {
|
|
99
|
+
if (e[0] === 'u' || e[0] === 'x') return String.fromCodePoint(Number.parseInt(e.replace(/[ux{}]/g, ''), 16));
|
|
100
|
+
return { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0', '\n': '' }[e] ?? e;
|
|
101
|
+
});
|
|
102
|
+
return { value, end };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Top-level fields of the object literal at `open` (a `{` in code). Each value is
|
|
106
|
+
// {literal}, {ident} or {other}.
|
|
107
|
+
export function objectFields(code, src, open) {
|
|
108
|
+
const close = closeOf(code, open);
|
|
109
|
+
const fields = {};
|
|
110
|
+
if (close < 0) return fields;
|
|
111
|
+
let i = open + 1;
|
|
112
|
+
while (i < close) {
|
|
113
|
+
const key = /^\s*(?:([A-Za-z_$][\w$]*)|(["']))\s*/.exec(code.slice(i, close));
|
|
114
|
+
if (!key) break;
|
|
115
|
+
let name = key[1];
|
|
116
|
+
let j = i + key[0].length;
|
|
117
|
+
if (!name) {
|
|
118
|
+
const lit = literalAt(code, src, i + key[0].indexOf(key[2]));
|
|
119
|
+
if (!lit) break;
|
|
120
|
+
name = lit.value;
|
|
121
|
+
j = lit.end + 1;
|
|
122
|
+
j += /^\s*/.exec(code.slice(j))[0].length;
|
|
123
|
+
}
|
|
124
|
+
let end = j;
|
|
125
|
+
if (code[j] === ':') {
|
|
126
|
+
end = j + 1 + /^\s*/.exec(code.slice(j + 1))[0].length;
|
|
127
|
+
const lit = literalAt(code, src, end);
|
|
128
|
+
const ident = /^[A-Za-z_$][\w$]*/.exec(code.slice(end));
|
|
129
|
+
const after = (k) => /^\s*(?:as\s+const\s*)?[,}]/.test(code.slice(k, close + 1));
|
|
130
|
+
if (lit && after(lit.end + 1)) fields[name] = { literal: lit.value };
|
|
131
|
+
else if (ident && after(end + ident[0].length)) fields[name] = { ident: ident[0] };
|
|
132
|
+
else fields[name] = { other: true };
|
|
133
|
+
} else {
|
|
134
|
+
fields[name] = { ident: name }; // shorthand { name }
|
|
135
|
+
}
|
|
136
|
+
// Skip to the next top-level comma.
|
|
137
|
+
for (let k = end; k <= close; k++) {
|
|
138
|
+
if (PAIRS[code[k]]) { k = closeOf(code, k); if (k < 0) return fields; continue; }
|
|
139
|
+
if (code[k] === ',' || k === close) { i = k + 1; break; }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return fields;
|
|
143
|
+
}
|
package/lib/mod-scan.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Static scan of a Claude Code UI mod. Claude Code reads `on(...)` and
|
|
2
|
+
// `$.noun.method(...)` literally from a mod's source (blast-radius's header), so the
|
|
3
|
+
// porter can list a mod's events, host calls, commands, tools and render sites
|
|
4
|
+
// without running it, then judge how well it ports (plan §5.5, §5.6).
|
|
5
|
+
import { blank, closeOf, literalAt, objectFields } from './mod-lexer.mjs';
|
|
6
|
+
|
|
7
|
+
// Antigravity's side of each Claude event (plan §5.4, scorecard in ui-mods-baseline.md).
|
|
8
|
+
const EVENTS = {
|
|
9
|
+
'session.start': null,
|
|
10
|
+
'turn.start': null,
|
|
11
|
+
'turn.complete': null,
|
|
12
|
+
'tool.call': null,
|
|
13
|
+
'ui.render': null,
|
|
14
|
+
'command.run': ['partial', 'a generated skill calls its slash_<name> tool (one model turn), plus a button in the pane'],
|
|
15
|
+
'tool.describe': ['partial', 'applied once at tools/list; isDeferred has no equivalent'],
|
|
16
|
+
'prompt.context': ['partial', 'added once per turn as a systemMessage step, which shows in the transcript'],
|
|
17
|
+
'ui.close': ['partial', "only the pane's own × reports a close; closing Antigravity's pane tab sends nothing"],
|
|
18
|
+
'prompt.submit': ['none', 'agy has no user-prompt event'],
|
|
19
|
+
'prompt.compose': ['none', 'agy has no user-prompt event'],
|
|
20
|
+
};
|
|
21
|
+
const WORKS = new Set(['ui.resolve', 'ui.invalidate', 'ui.close', 'ui.toast', 'ui.status', 'ui.copy', 'store.keys', 'store.get',
|
|
22
|
+
'store.set', 'store.delete', 'state.get', 'state.set', 'clock.now', 'fs.read', 'session.cwd', 'session.id',
|
|
23
|
+
'command.register', 'tool.register', 'plugin.name']);
|
|
24
|
+
const PARTIAL = {
|
|
25
|
+
'ui.open': 'only the person can show the pane, through a sidecar:// link or its tab',
|
|
26
|
+
'ui.panes': 'isPlaced and isShown count only views the person can see',
|
|
27
|
+
'prompt.fill': 'copies the text and asks the person to paste it; it never drafts or sends',
|
|
28
|
+
};
|
|
29
|
+
const NO_OPS = new Set(['ui.blit', 'ui.selection', 'session.messages']);
|
|
30
|
+
const NO_OP_NOUNS = new Set(['model', 'agent']);
|
|
31
|
+
const TIER3 = [/^process\.run$/, /^fs\.write$/, /^http\./, /^prompt\./];
|
|
32
|
+
// Every ported pane mod gets these; they don't make a port partial (scorecard: task-board is clean).
|
|
33
|
+
const FALLBACKS = new Set(['event command.run', '$.ui.open']);
|
|
34
|
+
const SITES = { Pane: null, AbovePrompt: 'drawn as a strip above the pane', PromptHint: 'drawn as a strip above the pane' };
|
|
35
|
+
|
|
36
|
+
export const isTranscriptSite = (component) => component !== '*' && !Object.hasOwn(SITES, component);
|
|
37
|
+
|
|
38
|
+
function constants(code, src) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const m of code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g)) {
|
|
41
|
+
const lit = literalAt(code, src, m.index + m[0].length);
|
|
42
|
+
if (lit && /^\s*(?:as\s+const\s*)?(?:;|\n|$)/.test(code.slice(lit.end + 1))) out[m[1]] = lit.value;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const valueOf = (field, consts) => (field?.literal !== undefined ? field.literal : field?.ident && Object.hasOwn(consts, field.ident) ? consts[field.ident] : undefined);
|
|
48
|
+
|
|
49
|
+
// Specifiers a file imports at runtime (type-only imports are erased by the transform).
|
|
50
|
+
export function importsOf(src) {
|
|
51
|
+
const code = blank(src);
|
|
52
|
+
const out = [];
|
|
53
|
+
const at = (i) => literalAt(code, src, i)?.value;
|
|
54
|
+
// The clause between the keyword and the next quote is checked with plain string
|
|
55
|
+
// tests, not one backtracking regex: `import x from 'y'`, `import 'y'`, `export * from 'y'`.
|
|
56
|
+
for (const m of code.matchAll(/\b(import|export)\b/g)) {
|
|
57
|
+
const start = m.index + m[0].length;
|
|
58
|
+
const quote = [code.indexOf("'", start), code.indexOf('"', start)].filter((i) => i >= 0);
|
|
59
|
+
if (!quote.length) continue;
|
|
60
|
+
const q = Math.min(...quote);
|
|
61
|
+
const clause = code.slice(start, q);
|
|
62
|
+
if (!/^[\w$*{}\s,]*$/.test(clause)) continue;
|
|
63
|
+
const from = /\bfrom\s*$/.test(clause);
|
|
64
|
+
if (m[1] === 'export' ? !from : !from && clause.trim() !== '') continue;
|
|
65
|
+
if (/^\s+type\s/.test(clause)) continue; // type-only
|
|
66
|
+
const spec = at(q);
|
|
67
|
+
if (spec !== undefined) out.push(spec);
|
|
68
|
+
}
|
|
69
|
+
for (const m of code.matchAll(/(?<![\w$.])(?:import|require)\s*\(\s*(?=['"`])/g)) {
|
|
70
|
+
const spec = at(m.index + m[0].length);
|
|
71
|
+
if (spec !== undefined) out.push(spec);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// files: [{path, src}], the mod's module and the helpers it imports.
|
|
77
|
+
export function scanMod(files) {
|
|
78
|
+
const scan = { events: [], calls: new Set(), commands: [], tools: [], renderSites: new Set(), imports: [], warnings: [], steersBuiltinTools: false };
|
|
79
|
+
for (const { path, src } of files) {
|
|
80
|
+
const code = blank(src);
|
|
81
|
+
const consts = constants(code, src);
|
|
82
|
+
for (const spec of importsOf(src)) scan.imports.push({ file: path, spec });
|
|
83
|
+
|
|
84
|
+
for (const m of code.matchAll(/(?<![\w$.])on\(\s*(?=['"])/g)) {
|
|
85
|
+
const lit = literalAt(code, src, m.index + m[0].length);
|
|
86
|
+
if (!lit) continue;
|
|
87
|
+
const paren = code.lastIndexOf('(', m.index + m[0].length);
|
|
88
|
+
const close = closeOf(code, paren);
|
|
89
|
+
const rest = /^\s*,\s*/.exec(code.slice(lit.end + 1));
|
|
90
|
+
const matcher = {};
|
|
91
|
+
if (rest && code[lit.end + 1 + rest[0].length] === '{') {
|
|
92
|
+
for (const [k, f] of Object.entries(objectFields(code, src, lit.end + 1 + rest[0].length))) {
|
|
93
|
+
const v = valueOf(f, consts);
|
|
94
|
+
matcher[k] = v ?? null;
|
|
95
|
+
if (v === undefined) scan.warnings.push(`${path}: on('${lit.value}') matcher "${k}" isn't a literal`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const body = close > 0 ? code.slice(lit.end, close) : '';
|
|
99
|
+
scan.events.push({ event: lit.value, matcher, file: path });
|
|
100
|
+
if (lit.value === 'ui.render') scan.renderSites.add(matcher.component ?? '*');
|
|
101
|
+
const builtin = lit.value === 'tool.call' && !String(matcher.tool ?? '').startsWith('mcp__');
|
|
102
|
+
if (builtin && /\bdeny\s*:|\bnext\(\s*\{/.test(body)) scan.steersBuiltinTools = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const m of code.matchAll(/(?<![\w$.])\$\s*\.\s*([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))?/g)) {
|
|
106
|
+
if (m[2]) scan.calls.add(`${m[1]}.${m[2]}`);
|
|
107
|
+
else scan.warnings.push(`${path}: $.${m[1]} is used without a method, so its calls can't be scanned and will throw`);
|
|
108
|
+
}
|
|
109
|
+
if (/(?<![\w$.])\$\s*\[/.test(code) || /\}\s*=\s*\$(?![\w$.])/.test(code)) {
|
|
110
|
+
scan.warnings.push(`${path}: $ is accessed dynamically; calls the scan can't see will throw`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [kind, list] of [['command', scan.commands], ['tool', scan.tools]]) {
|
|
114
|
+
for (const m of code.matchAll(new RegExp(`(?<![\\w$.])\\$\\.${kind}\\.register\\(\\s*`, 'g'))) {
|
|
115
|
+
const at = m.index + m[0].length;
|
|
116
|
+
if (code[at] !== '{') { scan.warnings.push(`${path}: a $.${kind}.register argument isn't an object literal`); continue; }
|
|
117
|
+
const f = objectFields(code, src, at);
|
|
118
|
+
const def = { name: valueOf(f.name, consts), description: valueOf(f.description, consts), argumentHint: valueOf(f.argumentHint, consts) };
|
|
119
|
+
if (typeof def.name !== 'string') { scan.warnings.push(`${path}: a $.${kind}.register name isn't a literal`); continue; }
|
|
120
|
+
if (kind === 'tool') delete def.argumentHint;
|
|
121
|
+
if (!list.some((d) => d.name === def.name)) list.push(Object.fromEntries(Object.entries(def).filter(([, v]) => typeof v === 'string')));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// The runtime's 'claude-code' stand-in keeps atoms in $.state.
|
|
126
|
+
if (scan.imports.some((i) => i.spec === 'claude-code')) { scan.calls.add('state.get'); scan.calls.add('state.set'); }
|
|
127
|
+
scan.warnings = [...new Set(scan.warnings)];
|
|
128
|
+
return scan;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Verdict, per-item findings and tier for a scan. `findings` are {item, level, reason}:
|
|
132
|
+
// 'fallback' (every pane mod gets it, and the scorecard still calls the mod clean),
|
|
133
|
+
// 'partial' or 'none'.
|
|
134
|
+
export function assess(scan) {
|
|
135
|
+
const findings = [];
|
|
136
|
+
const refusals = [];
|
|
137
|
+
const add = (item, level, reason) => { if (FALLBACKS.has(item)) level = 'fallback'; if (!findings.some((f) => f.item === item)) findings.push({ item, level, reason }); };
|
|
138
|
+
for (const { event } of scan.events) {
|
|
139
|
+
const known = Object.hasOwn(EVENTS, event) ? EVENTS[event] : ['none', 'no Antigravity equivalent; the handler never runs'];
|
|
140
|
+
if (known) add(`event ${event}`, ...known);
|
|
141
|
+
}
|
|
142
|
+
for (const call of scan.calls) {
|
|
143
|
+
const [noun] = call.split('.');
|
|
144
|
+
if (WORKS.has(call)) continue;
|
|
145
|
+
if (Object.hasOwn(PARTIAL, call)) add(`$.${call}`, 'partial', PARTIAL[call]);
|
|
146
|
+
else if (NO_OPS.has(call) || NO_OP_NOUNS.has(noun)) add(`$.${call}`, 'partial', 'returns nothing in the Antigravity port');
|
|
147
|
+
else add(`$.${call}`, 'none', 'not available in the Antigravity port (calls reject)');
|
|
148
|
+
}
|
|
149
|
+
const sites = [...scan.renderSites];
|
|
150
|
+
for (const site of sites) {
|
|
151
|
+
if (isTranscriptSite(site)) add(`render ${site}`, 'none', 'transcript render sites have no Antigravity equivalent');
|
|
152
|
+
else if (SITES[site]) add(`render ${site}`, 'partial', SITES[site]);
|
|
153
|
+
}
|
|
154
|
+
if (sites.length && sites.every(isTranscriptSite)) {
|
|
155
|
+
refusals.push(`draws only into transcript sites (${sites.join(', ')}), which Antigravity has no equivalent for`);
|
|
156
|
+
}
|
|
157
|
+
const bare = [...new Set(scan.imports.filter((i) => !/^\.{1,2}\//.test(i.spec) && i.spec !== 'claude-code').map((i) => i.spec))];
|
|
158
|
+
if (bare.length) refusals.push(`imports ${bare.join(', ')}, which a ported mod can't load (only relative files and 'claude-code')`);
|
|
159
|
+
const hasPane = scan.renderSites.has('Pane') || scan.renderSites.has('*');
|
|
160
|
+
const warnings = [...scan.warnings];
|
|
161
|
+
if (!hasPane && sites.some((s) => SITES[s])) warnings.push('no Pane: AbovePrompt/PromptHint draw only while the pane is open');
|
|
162
|
+
|
|
163
|
+
const tierReasons = ['a sidecar is a full Node process outside agy\'s permission prompts'];
|
|
164
|
+
for (const call of scan.calls) if (TIER3.some((re) => re.test(call))) tierReasons.push(`$.${call}`);
|
|
165
|
+
if (scan.steersBuiltinTools) tierReasons.push('tool.call can deny or rewrite built-in tool calls');
|
|
166
|
+
const tier = tierReasons.length > 1 ? 'TIER_3_AGENT_CONTROL' : 'TIER_2_LOCAL_TOOLING';
|
|
167
|
+
const verdict = refusals.length ? 'refused' : findings.some((f) => f.level !== 'fallback') ? 'partial' : 'clean';
|
|
168
|
+
return { verdict, refusals, findings, warnings, hasPane, tier, tierReasons };
|
|
169
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Collects a UI mod's module graph and turns it into files a sidecar can import.
|
|
2
|
+
// TS/TSX/JSX go through esbuild, an optional dependency of the porter loaded only for
|
|
3
|
+
// them (plan §5.8, picked by the 2.5A scorecard). Plain .js/.mjs mods need nothing.
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { extname, join, posix, relative } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { importsOf } from './mod-scan.mjs';
|
|
9
|
+
|
|
10
|
+
export const NEEDS_TRANSFORM = new Set(['.ts', '.tsx', '.jsx', '.mts']);
|
|
11
|
+
const TRY_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.mjs'];
|
|
12
|
+
|
|
13
|
+
// esbuild, or null when it isn't installed.
|
|
14
|
+
export function loadEsbuild() {
|
|
15
|
+
try { return createRequire(import.meta.url)('esbuild'); } catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const isFile = (p) => existsSync(p) && statSync(p).isFile();
|
|
19
|
+
|
|
20
|
+
// Resolves a relative specifier from `fromRel` (posix, relative to root). A `.js`
|
|
21
|
+
// specifier may name a `.ts` file (TypeScript's convention). Type declarations
|
|
22
|
+
// (.d.ts) resolve to null: imports of them are erased by the transform.
|
|
23
|
+
export function resolveSpec(root, fromRel, spec) {
|
|
24
|
+
const base = posix.normalize(posix.join(posix.dirname(fromRel), spec));
|
|
25
|
+
if (base.startsWith('../') || base === '..') throw new Error(`${fromRel} imports ${spec}, outside the plugin`);
|
|
26
|
+
const stem = /\.(m?js|jsx)$/.test(base) ? base.replace(/\.(m?js|jsx)$/, '') : null;
|
|
27
|
+
for (const cand of [...TRY_EXT.map((e) => base + e), ...(stem ? ['.ts', '.tsx', '.mts'].map((e) => stem + e) : [])]) {
|
|
28
|
+
if (cand.endsWith('.d.ts')) continue;
|
|
29
|
+
if (isFile(join(root, cand))) return cand;
|
|
30
|
+
}
|
|
31
|
+
for (const e of ['.d.ts', '/index.d.ts']) if (isFile(join(root, base + e))) return null;
|
|
32
|
+
throw new Error(`${fromRel} imports ${spec}, which isn't in the plugin`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The module and every relative file it imports at runtime: [{rel, src}].
|
|
36
|
+
export function collectGraph(root, entryRel) {
|
|
37
|
+
const files = new Map();
|
|
38
|
+
const queue = [entryRel];
|
|
39
|
+
while (queue.length) {
|
|
40
|
+
const rel = queue.shift();
|
|
41
|
+
if (files.has(rel)) continue;
|
|
42
|
+
const src = readFileSync(join(root, rel), 'utf8').replace(/^\uFEFF/, '');
|
|
43
|
+
files.set(rel, src);
|
|
44
|
+
for (const spec of importsOf(src)) {
|
|
45
|
+
if (!/^\.{1,2}\//.test(spec)) continue;
|
|
46
|
+
const target = resolveSpec(root, rel, spec);
|
|
47
|
+
if (target) queue.push(target);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...files].map(([rel, src]) => ({ rel, src }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Output name inside the sidecar's mod/ folder: always .mjs, so Node loads every file
|
|
54
|
+
// as ESM whatever its version.
|
|
55
|
+
export const outName = (rel) => rel.replace(/\.(tsx?|mts|jsx|m?js)$/, '') + '.mjs';
|
|
56
|
+
|
|
57
|
+
export const needsTransform = (files) => files.some((f) => NEEDS_TRANSFORM.has(extname(f.rel)));
|
|
58
|
+
|
|
59
|
+
// Transforms one file and points its imports at the output names: relative files at
|
|
60
|
+
// their .mjs copies, 'claude-code' at the runtime's stand-in next to mod/.
|
|
61
|
+
export function transformFile(root, file, esbuild) {
|
|
62
|
+
const ext = extname(file.rel);
|
|
63
|
+
let code = file.src;
|
|
64
|
+
if (NEEDS_TRANSFORM.has(ext)) {
|
|
65
|
+
if (!esbuild) throw new Error('needs esbuild to transform TS/TSX: port it with `npx agy-plugin-porter`, which installs esbuild as an optional dependency');
|
|
66
|
+
code = esbuild.transformSync(file.src, {
|
|
67
|
+
loader: ext === '.mts' ? 'ts' : ext.slice(1), format: 'esm', jsx: 'transform', jsxFactory: 'h', jsxFragment: 'Fragment',
|
|
68
|
+
target: 'node20', sourcefile: file.rel,
|
|
69
|
+
}).code;
|
|
70
|
+
}
|
|
71
|
+
const outDir = posix.dirname(`mod/${outName(file.rel)}`);
|
|
72
|
+
const stand = posix.relative(outDir, 'claude-code.mjs');
|
|
73
|
+
const rewrite = (spec) => {
|
|
74
|
+
if (spec === 'claude-code') return stand.startsWith('.') ? stand : `./${stand}`;
|
|
75
|
+
if (!/^\.{1,2}\//.test(spec)) return spec;
|
|
76
|
+
const target = resolveSpec(root, file.rel, spec);
|
|
77
|
+
if (!target) return spec;
|
|
78
|
+
const r = posix.relative(posix.dirname(file.rel), outName(target));
|
|
79
|
+
return r.startsWith('.') ? r : `./${r}`;
|
|
80
|
+
};
|
|
81
|
+
return code.replace(/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])([^'"\n]+)\2/g, (whole, lead, q, spec) => `${lead}${q}${rewrite(spec)}${q}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const relPosix = (from, to) => relative(from, to).split('\\').join('/');
|
package/lib/mode-compiler.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { basename, join } from 'node:path';
|
|
8
8
|
|
|
9
|
+
import { rewriteRuleVars } from './rules-compiler.mjs';
|
|
9
10
|
import { slugifyName } from './skill-compiler.mjs';
|
|
10
11
|
import { ROO_GROUPS, TOOLS, unmappedToolReason } from './tool-map.mjs';
|
|
11
12
|
import { formatFrontmatter, parseYaml, splitFrontmatter } from './yaml-lite.mjs';
|
|
@@ -75,7 +76,8 @@ function compileClaudeAgents(pluginDir, { slug, report, taken }) {
|
|
|
75
76
|
}
|
|
76
77
|
const { data, body } = parsed;
|
|
77
78
|
const name = agentName(data.name ?? basename(file, '.md'), slug, report, item);
|
|
78
|
-
const
|
|
79
|
+
const rawDesc = typeof data.description === 'string' && data.description.trim() ? data.description : `Ported agent ${name}.`;
|
|
80
|
+
const out = { name, description: rewriteRuleVars(rawDesc, item, report) };
|
|
79
81
|
if (data.tools !== undefined && data.tools !== null && data.tools !== '*') {
|
|
80
82
|
const mapped = mapClaudeTools(data.tools);
|
|
81
83
|
out.tools = mapped.tools;
|
|
@@ -92,7 +94,8 @@ function compileClaudeAgents(pluginDir, { slug, report, taken }) {
|
|
|
92
94
|
const extra = Object.keys(data).filter((k) => !CLAUDE_AGENT_KEYS.has(k));
|
|
93
95
|
if (extra.length) report.warnings.push(`${item}: dropped ${extra.join(', ')}`);
|
|
94
96
|
rmSync(join(dir, file));
|
|
95
|
-
|
|
97
|
+
const cleanBody = rewriteRuleVars(body, item, report);
|
|
98
|
+
writeFileSync(join(dir, `${name}.md`), formatFrontmatter(out, cleanBody));
|
|
96
99
|
taken.add(name);
|
|
97
100
|
report.ported.push(`${item} → agents/${name}.md`);
|
|
98
101
|
}
|
|
@@ -166,13 +169,20 @@ function compileRooModes(pluginDir, { srcDir, slug, report, taken }) {
|
|
|
166
169
|
continue;
|
|
167
170
|
}
|
|
168
171
|
const { tools, mcp } = mapRooGroups(mode.groups, item, report);
|
|
169
|
-
const
|
|
172
|
+
const rawDesc = mode.whenToUse || mode.description || mode.roleDefinition.split(/(?<=\.)\s/)[0];
|
|
173
|
+
const description = rewriteRuleVars(rawDesc, item, report);
|
|
170
174
|
const out = { name, description, tools, ...(mcp ? { inheritMcp: true } : {}), mainAgent: true, subagent: true };
|
|
171
|
-
const
|
|
175
|
+
const rawBody = [`# ${mode.name ?? mode.slug}`, mode.roleDefinition.trim(), mode.customInstructions?.trim(), ...modeRules(srcDir, mode.slug)]
|
|
172
176
|
.filter(Boolean).join('\n\n');
|
|
177
|
+
const body = rewriteRuleVars(rawBody, item, report);
|
|
173
178
|
writeFileSync(join(dir, `${name}.md`), formatFrontmatter(out, `\n${body}\n`));
|
|
174
179
|
taken.add(name);
|
|
175
180
|
report.ported.push(`${item} → agents/${name}.md`);
|
|
181
|
+
for (const f of [`.roo/system-prompt-${mode.slug}`, `.kilocode/system-prompt-${mode.slug}`]) {
|
|
182
|
+
if (existsSync(join(srcDir, f))) {
|
|
183
|
+
report.unported.push({ item: `${item} ${f}`, reason: "it replaces the whole system prompt, which an agy agent can't do; the agent has only the mode's role definition and instructions" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
176
186
|
}
|
|
177
187
|
}
|
|
178
188
|
|