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.
@@ -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
+ }
@@ -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('/');
@@ -0,0 +1,195 @@
1
+ // Turns Claude Code agents and Roo/Kilo modes into agy agents (agents/<name>.md).
2
+ // agy 1.4.x skips a Claude agent outright: `tools: Read, Grep` is a string
3
+ // where agy wants a list, and `model: sonnet` isn't an agy model tier. So
4
+ // tools are mapped to agy tool names, and models to agy's tiers (pro, flash,
5
+ // flash_lite, inherit). Anything that can't be mapped is reported.
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { basename, join } from 'node:path';
8
+
9
+ import { rewriteRuleVars } from './rules-compiler.mjs';
10
+ import { slugifyName } from './skill-compiler.mjs';
11
+ import { ROO_GROUPS, TOOLS, unmappedToolReason } from './tool-map.mjs';
12
+ import { formatFrontmatter, parseYaml, splitFrontmatter } from './yaml-lite.mjs';
13
+
14
+ // Names agy reserves for built-in agents (seen in agy 1.4.5's subagent list).
15
+ const RESERVED = new Set(['self', 'research', 'image-generator']);
16
+ const CLAUDE_AGENT_KEYS = new Set(['name', 'description', 'tools', 'model']);
17
+
18
+ export function mapClaudeModel(model) {
19
+ if (model === undefined || model === null || model === '') return { tier: undefined };
20
+ const m = String(model).toLowerCase();
21
+ if (m === 'inherit') return { tier: 'inherit' };
22
+ if (m.includes('haiku')) return { tier: 'flash' };
23
+ if (m.includes('opus') || m.includes('sonnet')) return { tier: 'pro' };
24
+ return { tier: undefined, dropped: true };
25
+ }
26
+
27
+ // Splits `Read, Bash(git diff:*), Agent(a, b)` on top-level commas only.
28
+ export function splitToolList(text) {
29
+ const parts = [];
30
+ let depth = 0;
31
+ let start = 0;
32
+ for (let i = 0; i < text.length; i++) {
33
+ if (text[i] === '(') depth++;
34
+ else if (text[i] === ')') depth = Math.max(0, depth - 1);
35
+ else if (text[i] === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
36
+ }
37
+ parts.push(text.slice(start));
38
+ return parts;
39
+ }
40
+
41
+ export function mapClaudeTools(tools) {
42
+ const list = Array.isArray(tools) ? tools : splitToolList(String(tools));
43
+ const mapped = new Set();
44
+ const unmapped = [];
45
+ let mcp = false;
46
+ for (const raw of list.map((t) => String(t).trim()).filter(Boolean)) {
47
+ // `Bash(git diff:*)` -> `Bash`, without a regex that backtracks over "(".
48
+ const paren = raw.indexOf('(');
49
+ const name = (paren > 0 && raw.endsWith(')') ? raw.slice(0, paren) : raw).trim();
50
+ if (name.startsWith('mcp__')) mcp = true;
51
+ else if (TOOLS.claude[name]) TOOLS.claude[name].forEach((t) => mapped.add(t));
52
+ else unmapped.push(name);
53
+ }
54
+ return { tools: [...mapped].sort(), unmapped, mcp };
55
+ }
56
+
57
+ function agentName(raw, slug, report, item) {
58
+ let name = slugifyName(raw);
59
+ if (RESERVED.has(name)) {
60
+ report.warnings.push(`${item}: "${name}" is a built-in agy agent name; renamed to "${slug}-${name}"`);
61
+ name = `${slug}-${name}`;
62
+ }
63
+ return name;
64
+ }
65
+
66
+ function compileClaudeAgents(pluginDir, { slug, report, taken }) {
67
+ const dir = join(pluginDir, 'agents');
68
+ if (!existsSync(dir)) return;
69
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
70
+ const item = `agent:${basename(file, '.md')}`;
71
+ let parsed;
72
+ try { parsed = splitFrontmatter(readFileSync(join(dir, file), 'utf8')); } catch (err) {
73
+ report.unported.push({ item, reason: `frontmatter isn't parseable: ${err.message}` });
74
+ rmSync(join(dir, file));
75
+ continue;
76
+ }
77
+ const { data, body } = parsed;
78
+ const name = agentName(data.name ?? basename(file, '.md'), slug, report, item);
79
+ const rawDesc = typeof data.description === 'string' && data.description.trim() ? data.description : `Ported agent ${name}.`;
80
+ const out = { name, description: rewriteRuleVars(rawDesc, item, report) };
81
+ if (data.tools !== undefined && data.tools !== null && data.tools !== '*') {
82
+ const mapped = mapClaudeTools(data.tools);
83
+ out.tools = mapped.tools;
84
+ for (const t of mapped.unmapped) report.unported.push({ item: `${item} tool ${t}`, reason: unmappedToolReason(t) });
85
+ if (mapped.mcp) {
86
+ out.inheritMcp = true;
87
+ report.warnings.push(`${item}: MCP tools in "tools" became inheritMcp: true (all MCP tools, not just the listed ones)`);
88
+ }
89
+ }
90
+ const model = mapClaudeModel(data.model);
91
+ if (model.tier) out.model = model.tier;
92
+ if (model.dropped) report.warnings.push(`${item}: model "${data.model}" has no agy tier; the agent inherits the session's model`);
93
+ out.subagent = true;
94
+ const extra = Object.keys(data).filter((k) => !CLAUDE_AGENT_KEYS.has(k));
95
+ if (extra.length) report.warnings.push(`${item}: dropped ${extra.join(', ')}`);
96
+ rmSync(join(dir, file));
97
+ const cleanBody = rewriteRuleVars(body, item, report);
98
+ writeFileSync(join(dir, `${name}.md`), formatFrontmatter(out, cleanBody));
99
+ taken.add(name);
100
+ report.ported.push(`${item} → agents/${name}.md`);
101
+ }
102
+ }
103
+
104
+ function readModes(srcDir, report) {
105
+ const modes = [];
106
+ for (const file of ['.roomodes', '.kilocodemodes']) {
107
+ const path = join(srcDir, file);
108
+ if (!existsSync(path)) continue;
109
+ const text = readFileSync(path, 'utf8').replace(/^/, '');
110
+ let parsed;
111
+ try { parsed = text.trim().startsWith('{') ? JSON.parse(text) : parseYaml(text); } catch (err) {
112
+ report.unported.push({ item: file, reason: `can't parse: ${err.message}` });
113
+ continue;
114
+ }
115
+ for (const mode of parsed?.customModes ?? []) modes.push({ ...mode, file });
116
+ }
117
+ return modes;
118
+ }
119
+
120
+ function modeRules(srcDir, slug) {
121
+ const texts = [];
122
+ for (const dir of [`.roo/rules-${slug}`, `.kilocode/rules-${slug}`]) {
123
+ const full = join(srcDir, dir);
124
+ if (!existsSync(full)) continue;
125
+ for (const f of readdirSync(full).filter((x) => /\.(md|txt)$/i.test(x)).sort()) texts.push(readFileSync(join(full, f), 'utf8').trim());
126
+ }
127
+ for (const f of [`.roorules-${slug}`, `.clinerules-${slug}`]) {
128
+ if (existsSync(join(srcDir, f))) texts.push(readFileSync(join(srcDir, f), 'utf8').trim());
129
+ }
130
+ return texts.filter(Boolean);
131
+ }
132
+
133
+ // Restricted edit groups (`fileRegex`) aren't granted: agy agents can't limit
134
+ // edits to a pattern, and widening a mode's permissions silently is worse than
135
+ // reporting the gap.
136
+ export function mapRooGroups(groups, item, report) {
137
+ const tools = new Set();
138
+ let mcp = false;
139
+ for (const group of Array.isArray(groups) ? groups : []) {
140
+ const [kind, options] = Array.isArray(group) ? group : [group, null];
141
+ if (kind === 'mcp') { mcp = true; continue; }
142
+ if (!ROO_GROUPS[kind]) {
143
+ report.unported.push({ item: `${item} group ${kind}`, reason: kind === 'browser' ? "agy's browser tool names aren't mapped yet" : 'unknown tool group' });
144
+ continue;
145
+ }
146
+ if (options?.fileRegex) {
147
+ report.unported.push({ item: `${item} group ${kind} (${options.fileRegex})`, reason: "agy agents can't restrict edits to a file pattern, so the group isn't granted" });
148
+ continue;
149
+ }
150
+ ROO_GROUPS[kind].forEach((t) => tools.add(t));
151
+ }
152
+ return { tools: [...tools].sort(), mcp };
153
+ }
154
+
155
+ function compileRooModes(pluginDir, { srcDir, slug, report, taken }) {
156
+ const modes = readModes(srcDir, report);
157
+ if (!modes.length) return;
158
+ const dir = join(pluginDir, 'agents');
159
+ mkdirSync(dir, { recursive: true });
160
+ for (const mode of modes) {
161
+ const item = `mode:${mode.slug ?? '?'}`;
162
+ if (typeof mode.slug !== 'string' || typeof mode.roleDefinition !== 'string') {
163
+ report.unported.push({ item, reason: 'a mode needs slug and roleDefinition' });
164
+ continue;
165
+ }
166
+ const name = agentName(mode.slug, slug, report, item);
167
+ if (taken.has(name)) {
168
+ report.unported.push({ item, reason: `an agent named ${name} already exists in the plugin` });
169
+ continue;
170
+ }
171
+ const { tools, mcp } = mapRooGroups(mode.groups, item, report);
172
+ const rawDesc = mode.whenToUse || mode.description || mode.roleDefinition.split(/(?<=\.)\s/)[0];
173
+ const description = rewriteRuleVars(rawDesc, item, report);
174
+ const out = { name, description, tools, ...(mcp ? { inheritMcp: true } : {}), mainAgent: true, subagent: true };
175
+ const rawBody = [`# ${mode.name ?? mode.slug}`, mode.roleDefinition.trim(), mode.customInstructions?.trim(), ...modeRules(srcDir, mode.slug)]
176
+ .filter(Boolean).join('\n\n');
177
+ const body = rewriteRuleVars(rawBody, item, report);
178
+ writeFileSync(join(dir, `${name}.md`), formatFrontmatter(out, `\n${body}\n`));
179
+ taken.add(name);
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
+ }
186
+ }
187
+ }
188
+
189
+ export function compileModes(pluginDir, { srcDir, ecosystems, slug }) {
190
+ const report = { ported: [], unported: [], warnings: [] };
191
+ const taken = new Set();
192
+ if (ecosystems.includes('claude')) compileClaudeAgents(pluginDir, { slug, report, taken });
193
+ if (ecosystems.includes('roo')) compileRooModes(pluginDir, { srcDir, slug, report, taken });
194
+ return report;
195
+ }