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
package/lib/rules-compiler.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// - .cursorrules and Roo/Cline/Kilo workspace rules -> always_on rules
|
|
10
10
|
// A rule over agy's 24 KB per-file limit is reported unported instead of being
|
|
11
11
|
// silently truncated.
|
|
12
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { basename, join } from 'node:path';
|
|
14
14
|
|
|
15
15
|
import { readJson } from './detector.mjs';
|
|
@@ -20,10 +20,19 @@ export const RULE_BYTES = 24_000;
|
|
|
20
20
|
export const RULES_BUDGET_TOKENS = 20_000;
|
|
21
21
|
|
|
22
22
|
const read = (file) => readFileSync(file, 'utf8').replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
23
|
-
const isFile = (file) => existsSync(file) && statSync(file).isFile();
|
|
24
|
-
const mdFiles = (dir) =>
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const isFile = (file) => existsSync(file) && !lstatSync(file).isSymbolicLink() && statSync(file).isFile();
|
|
24
|
+
const mdFiles = (dir) => {
|
|
25
|
+
if (!existsSync(dir) || lstatSync(dir).isSymbolicLink() || !statSync(dir).isDirectory()) return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
28
|
+
const full = join(dir, entry);
|
|
29
|
+
if (lstatSync(full).isSymbolicLink()) continue;
|
|
30
|
+
const st = statSync(full);
|
|
31
|
+
if (st.isDirectory()) out.push(...mdFiles(full));
|
|
32
|
+
else if (/\.(md|mdc|txt)$/i.test(entry)) out.push(full);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
27
36
|
|
|
28
37
|
function geminiContextFiles(srcDir) {
|
|
29
38
|
let names = ['GEMINI.md'];
|
|
@@ -44,15 +53,39 @@ export function cursorTrigger(data) {
|
|
|
44
53
|
return { trigger: 'manual' };
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
const RULE_VAR_RE = /(?:\$\{(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\}|\$(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\b)((?:[\\/]|\$\{\/\})+)([^\s"'`<>|*?()[\]{}]*)/g;
|
|
57
|
+
const RULE_VAR_BARE_RE = /\$\{(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\}|\$(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\b/g;
|
|
58
|
+
|
|
59
|
+
export function rewriteRuleVars(text, label, report) {
|
|
60
|
+
if (typeof text !== 'string') return text;
|
|
61
|
+
let count = 0;
|
|
62
|
+
let out = text.replace(RULE_VAR_RE, (_, _sep, tail) => {
|
|
63
|
+
count++;
|
|
64
|
+
const normalizedTail = (tail ?? '').replace(/(?:\$\{\/\}|[\\/])+/g, '/');
|
|
65
|
+
return `./${normalizedTail}`;
|
|
66
|
+
});
|
|
67
|
+
out = out.replace(RULE_VAR_BARE_RE, () => {
|
|
68
|
+
count++;
|
|
69
|
+
return '.';
|
|
70
|
+
});
|
|
71
|
+
if (count > 0 && report) {
|
|
72
|
+
const msg = `${label}: rewrote plugin-root variable(s) to relative path(s)`;
|
|
73
|
+
if (!report.warnings.includes(msg)) report.warnings.push(msg);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
// A glob-scoped or manual Cursor rule becomes a skill the agent can pull in.
|
|
48
79
|
function mdcToSkill(pluginDir, base, trigger, body, item, report) {
|
|
49
80
|
let name = slugifyName(base);
|
|
50
81
|
for (let n = 2; existsSync(join(pluginDir, 'skills', name)); n++) name = `${slugifyName(base)}-${n}`;
|
|
51
|
-
const
|
|
82
|
+
const rawDescription = trigger.trigger === 'glob'
|
|
52
83
|
? `Use when working on files matching ${trigger.globs}.${trigger.description ? ` ${trigger.description}` : ''}`
|
|
53
84
|
: `Use only when the user asks for the "${base}" rule.`;
|
|
85
|
+
const description = rewriteRuleVars(rawDescription, item, report);
|
|
86
|
+
const cleanBody = rewriteRuleVars(body, item, report);
|
|
54
87
|
mkdirSync(join(pluginDir, 'skills', name), { recursive: true });
|
|
55
|
-
writeFileSync(join(pluginDir, 'skills', name, 'SKILL.md'), formatFrontmatter({ name, description }, `\n${
|
|
88
|
+
writeFileSync(join(pluginDir, 'skills', name, 'SKILL.md'), formatFrontmatter({ name, description }, `\n${cleanBody.trim()}\n`));
|
|
56
89
|
report.ported.push(`${item} → skills/${name}`);
|
|
57
90
|
}
|
|
58
91
|
|
|
@@ -61,7 +94,8 @@ export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
|
61
94
|
const rulesDir = join(pluginDir, 'rules');
|
|
62
95
|
const outputs = [];
|
|
63
96
|
const used = new Set(existsSync(rulesDir) ? readdirSync(rulesDir).map((f) => f.toLowerCase()) : []);
|
|
64
|
-
const add = (file,
|
|
97
|
+
const add = (file, rawContent, item, alwaysOn) => {
|
|
98
|
+
const content = rewriteRuleVars(rawContent, item, report);
|
|
65
99
|
if (Buffer.byteLength(content) > RULE_BYTES) {
|
|
66
100
|
report.unported.push({ item, reason: `over agy's 24 KB per-rule limit (${Buffer.byteLength(content)} bytes); agy would truncate it` });
|
|
67
101
|
return;
|
|
@@ -73,6 +107,10 @@ export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
|
73
107
|
report.ported.push(`${item} → rules/${name}`);
|
|
74
108
|
};
|
|
75
109
|
|
|
110
|
+
const preExisting = existsSync(rulesDir)
|
|
111
|
+
? readdirSync(rulesDir).filter((f) => /\.md$/i.test(f) && isFile(join(rulesDir, f)))
|
|
112
|
+
: [];
|
|
113
|
+
|
|
76
114
|
// Always-on context files become one rules/AGENTS.md.
|
|
77
115
|
const context = [];
|
|
78
116
|
if (ecosystems.includes('claude')) context.push('CLAUDE.md');
|
|
@@ -96,6 +134,27 @@ export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
|
96
134
|
add('AGENTS.md', `${sections.join('\n\n')}\n`, sources.join(' + '), true);
|
|
97
135
|
}
|
|
98
136
|
|
|
137
|
+
for (const f of preExisting) {
|
|
138
|
+
if (sections.length && f.toLowerCase() === 'agents.md') continue;
|
|
139
|
+
const full = join(rulesDir, f);
|
|
140
|
+
const raw = read(full);
|
|
141
|
+
const item = `rules/${f}`;
|
|
142
|
+
const content = rewriteRuleVars(raw, item, report);
|
|
143
|
+
if (Buffer.byteLength(content) > RULE_BYTES) {
|
|
144
|
+
rmSync(full, { force: true });
|
|
145
|
+
used.delete(f.toLowerCase());
|
|
146
|
+
report.unported.push({ item, reason: `over agy's 24 KB per-rule limit (${Buffer.byteLength(content)} bytes); agy would truncate it` });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (content !== raw) writeFileSync(full, content);
|
|
150
|
+
let alwaysOn = true;
|
|
151
|
+
try {
|
|
152
|
+
const fm = splitFrontmatter(content);
|
|
153
|
+
if (fm.hasFrontmatter && fm.data.trigger && fm.data.trigger !== 'always_on') alwaysOn = false;
|
|
154
|
+
} catch {}
|
|
155
|
+
outputs.push({ name: f, content, alwaysOn, existing: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
99
158
|
if (ecosystems.includes('cursor')) {
|
|
100
159
|
for (const file of mdFiles(join(srcDir, '.cursor/rules')).filter((f) => f.endsWith('.mdc'))) {
|
|
101
160
|
let parsed;
|
|
@@ -110,6 +169,7 @@ export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
|
110
169
|
mdcToSkill(pluginDir, basename(file, '.mdc'), trigger, parsed.body, item, report);
|
|
111
170
|
continue;
|
|
112
171
|
}
|
|
172
|
+
if (trigger.description) trigger.description = rewriteRuleVars(trigger.description, item, report);
|
|
113
173
|
add(`${slugifyName(basename(file, '.mdc'))}.md`, formatFrontmatter(trigger, parsed.body.trim() + '\n'), item, trigger.trigger === 'always_on');
|
|
114
174
|
}
|
|
115
175
|
if (isFile(join(srcDir, '.cursorrules'))) {
|
|
@@ -133,8 +193,9 @@ export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
|
133
193
|
for (const name of ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md', ...geminiContextFiles(srcDir)]) {
|
|
134
194
|
rmSync(join(pluginDir, name), { force: true });
|
|
135
195
|
}
|
|
136
|
-
|
|
137
|
-
|
|
196
|
+
const newOutputs = outputs.filter((o) => !o.existing);
|
|
197
|
+
if (newOutputs.length) mkdirSync(rulesDir, { recursive: true });
|
|
198
|
+
for (const { name, content } of newOutputs) writeFileSync(join(rulesDir, name), content);
|
|
138
199
|
|
|
139
200
|
const alwaysOnTokens = Math.ceil(outputs.filter((o) => o.alwaysOn).reduce((n, o) => n + Buffer.byteLength(o.content), 0) / 4);
|
|
140
201
|
if (alwaysOnTokens > RULES_BUDGET_TOKENS) {
|
package/lib/skill-compiler.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { cpSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
|
|
9
|
+
import { rewriteRuleVars } from './rules-compiler.mjs';
|
|
9
10
|
import { formatFrontmatter, splitFrontmatter } from './yaml-lite.mjs';
|
|
10
11
|
|
|
11
12
|
export const SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -33,7 +34,7 @@ export function normalizeSkill(skillsDir, folder, report, { fromCommand = false
|
|
|
33
34
|
let { data, body, hasFrontmatter } = (() => {
|
|
34
35
|
try { return splitFrontmatter(text); } catch (err) {
|
|
35
36
|
report.warnings.push(`skill:${folder}: frontmatter isn't parseable (${err.message}); rebuilt from the folder name`);
|
|
36
|
-
return { data: {}, body: text.replace(/^---\n[\s\S]*?\n---\n?/, ''), hasFrontmatter: false };
|
|
37
|
+
return { data: {}, body: text.replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n?/, ''), hasFrontmatter: false };
|
|
37
38
|
}
|
|
38
39
|
})();
|
|
39
40
|
const notes = [];
|
|
@@ -73,6 +74,8 @@ export function normalizeSkill(skillsDir, folder, report, { fromCommand = false
|
|
|
73
74
|
if (/^!`|!\{[^}]*\}/m.test(body)) {
|
|
74
75
|
report.warnings.push(`skill:${name}: inline shell execution (!\`…\` / !{…}) isn't supported by agy and is left as text`);
|
|
75
76
|
}
|
|
77
|
+
out.description = rewriteRuleVars(out.description, `skill:${name}`, report);
|
|
78
|
+
body = rewriteRuleVars(body, `skill:${name}`, report);
|
|
76
79
|
const rewritten = formatFrontmatter(out, body);
|
|
77
80
|
const changed = !hasFrontmatter || rewritten !== text.replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
78
81
|
if (changed) writeFileSync(join(skillsDir, finalFolder, 'SKILL.md'), rewritten);
|
package/lib/source.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Resolves a --source: a local path, or `owner/repo[@ref]` fetched from GitHub
|
|
2
2
|
// at that ref (tag, branch or commit SHA) into a temp dir.
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
|
|
@@ -17,6 +17,27 @@ function git(args, cwd) {
|
|
|
17
17
|
return run.stdout.trim();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function makeWritable(dir) {
|
|
21
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
22
|
+
const p = join(dir, e.name);
|
|
23
|
+
if (e.isDirectory()) makeWritable(p);
|
|
24
|
+
else if (e.isFile()) chmodSync(p, 0o666);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// rmSync -r -f, except that Node 24's rmSync (agy-node's) can't delete read-only
|
|
29
|
+
// files on Windows, which a git clone's pack files are: on EPERM, make every file
|
|
30
|
+
// writable and try once more.
|
|
31
|
+
export function removeTree(dir) {
|
|
32
|
+
try {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err.code !== 'EPERM' || !existsSync(dir)) throw err;
|
|
36
|
+
makeWritable(dir);
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
export function parseSourceSpec(spec) {
|
|
21
42
|
if (existsSync(spec)) return { kind: 'path', path: resolve(spec) };
|
|
22
43
|
const m = GITHUB.exec(spec);
|
|
@@ -34,11 +55,13 @@ export function resolveSource(spec, { subPath } = {}) {
|
|
|
34
55
|
root = parsed.path;
|
|
35
56
|
} else {
|
|
36
57
|
root = mkdtempSync(join(tmpdir(), 'agy-porter-src-'));
|
|
37
|
-
cleanup = () =>
|
|
58
|
+
cleanup = () => removeTree(root);
|
|
38
59
|
try {
|
|
39
60
|
const ref = parsed.ref ?? 'HEAD';
|
|
40
61
|
if (ref.startsWith('-')) throw new Error(`invalid ref "${ref}"`);
|
|
41
62
|
git(['init', '-q'], root);
|
|
63
|
+
// Keep the upstream's line endings: on Windows, autocrlf would rewrite every file.
|
|
64
|
+
git(['config', 'core.autocrlf', 'false'], root);
|
|
42
65
|
// "--": git reads everything after it as a repository or refspec, never an option.
|
|
43
66
|
git(['fetch', '-q', '--depth', '1', '--', `https://github.com/${parsed.owner}/${parsed.repo}.git`, ref], root);
|
|
44
67
|
git(['checkout', '-q', 'FETCH_HEAD'], root);
|
package/lib/static-auditor.mjs
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// characters (U+E0000–E007F, invisible prompt-injection carriers), flags risky
|
|
3
3
|
// patterns, checks agy's rule and skill size limits, and infers the capability
|
|
4
4
|
// tier with the same names and meaning as Hangar's TIER_*.
|
|
5
|
-
import { closeSync, existsSync, fstatSync, ftruncateSync, openSync, readdirSync, readFileSync,
|
|
5
|
+
import { closeSync, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readdirSync, readFileSync, writeSync } from 'node:fs';
|
|
6
6
|
import { extname, join } from 'node:path';
|
|
7
7
|
|
|
8
|
+
import { extractServerMap } from './manifest-sanitizer.mjs';
|
|
8
9
|
import { RULE_BYTES, RULES_BUDGET_TOKENS } from './rules-compiler.mjs';
|
|
9
10
|
import { splitFrontmatter } from './yaml-lite.mjs';
|
|
10
11
|
|
|
@@ -14,7 +15,7 @@ const BINARY_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.
|
|
|
14
15
|
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
15
16
|
|
|
16
17
|
const TAGS = /[\u{E0000}-\u{E007F}]/gu;
|
|
17
|
-
const BIDI = /[
|
|
18
|
+
const BIDI = /[\u202A-\u202E\u2066-\u2069]/;
|
|
18
19
|
const PATTERNS = [
|
|
19
20
|
['pipe-to-shell', 'high', /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/i],
|
|
20
21
|
['pipe-to-shell', 'high', /\b(iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^\n|]*\|\s*(iex|Invoke-Expression)\b/i],
|
|
@@ -49,9 +50,12 @@ function* walk(dir, rel = '') {
|
|
|
49
50
|
for (const entry of readdirSync(dir)) {
|
|
50
51
|
if (SKIP_DIRS.has(entry)) continue;
|
|
51
52
|
const full = join(dir, entry);
|
|
53
|
+
let lst;
|
|
54
|
+
try { lst = lstatSync(full); } catch { continue; }
|
|
55
|
+
if (lst.isSymbolicLink()) continue;
|
|
52
56
|
const path = rel ? `${rel}/${entry}` : entry;
|
|
53
|
-
if (
|
|
54
|
-
else yield { full, path };
|
|
57
|
+
if (lst.isDirectory()) yield* walk(full, path);
|
|
58
|
+
else if (lst.isFile()) yield { full, path };
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -80,16 +84,41 @@ function readJsonIf(file) {
|
|
|
80
84
|
try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return null; }
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
function collectHookEvents(obj, events) {
|
|
88
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
89
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
90
|
+
if (k === 'enabled' || k === 'description' || k === 'modules') continue;
|
|
91
|
+
if (Array.isArray(v)) events.add(k);
|
|
92
|
+
else if (v && typeof v === 'object') collectHookEvents(v, events);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// TIER_2: anything that runs a local command (hooks, stdio MCP servers, sidecars).
|
|
84
97
|
// TIER_3: subagents, or hooks that steer the agent loop (Stop, PostInvocation).
|
|
85
98
|
export function inferTier(pluginDir) {
|
|
86
99
|
const reasons = [];
|
|
87
100
|
let rank = 0;
|
|
88
|
-
const
|
|
89
|
-
const
|
|
101
|
+
const events = new Set();
|
|
102
|
+
for (const rel of ['hooks.json', 'hooks/hooks.json', '.claude-plugin/hooks.json']) {
|
|
103
|
+
collectHookEvents(readJsonIf(join(pluginDir, rel)), events);
|
|
104
|
+
}
|
|
105
|
+
for (const rel of ['plugin.json', '.claude-plugin/plugin.json']) {
|
|
106
|
+
const manifest = readJsonIf(join(pluginDir, rel));
|
|
107
|
+
if (manifest?.hooks && typeof manifest.hooks === 'object') collectHookEvents(manifest.hooks, events);
|
|
108
|
+
}
|
|
90
109
|
if (events.size) { rank = Math.max(rank, 1); reasons.push(`hooks: ${[...events].join(', ')}`); }
|
|
91
110
|
if (events.has('Stop') || events.has('PostInvocation')) { rank = 2; reasons.push('a hook can keep the agent running'); }
|
|
92
|
-
const
|
|
111
|
+
const mcpMap = {};
|
|
112
|
+
for (const rel of ['mcp_config.json', '.mcp.json', 'mcp.json']) {
|
|
113
|
+
Object.assign(mcpMap, extractServerMap(readJsonIf(join(pluginDir, rel))));
|
|
114
|
+
}
|
|
115
|
+
for (const rel of ['plugin.json', '.claude-plugin/plugin.json', 'gemini-extension.json']) {
|
|
116
|
+
const manifest = readJsonIf(join(pluginDir, rel));
|
|
117
|
+
if (manifest?.mcpServers && typeof manifest.mcpServers === 'object' && !Array.isArray(manifest.mcpServers)) {
|
|
118
|
+
Object.assign(mcpMap, extractServerMap({ mcpServers: manifest.mcpServers }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const servers = Object.entries(mcpMap);
|
|
93
122
|
if (servers.length) {
|
|
94
123
|
rank = Math.max(rank, 1);
|
|
95
124
|
reasons.push(`MCP servers: ${servers.map(([n]) => n).join(', ')}`);
|
|
@@ -97,6 +126,16 @@ export function inferTier(pluginDir) {
|
|
|
97
126
|
const agentsDir = join(pluginDir, 'agents');
|
|
98
127
|
const agents = existsSync(agentsDir) ? readdirSync(agentsDir).filter((f) => f.endsWith('.md')) : [];
|
|
99
128
|
if (agents.length) { rank = 2; reasons.push(`agents: ${agents.map((f) => f.replace(/\.md$/, '')).join(', ')}`); }
|
|
129
|
+
// Sidecars, including ported UI mods, whose mod.json lists their scan's Tier 3 reasons.
|
|
130
|
+
const sidecarsDir = join(pluginDir, 'sidecars');
|
|
131
|
+
const sidecars = existsSync(sidecarsDir) ? readdirSync(sidecarsDir).filter((d) => existsSync(join(sidecarsDir, d, 'sidecar.json'))) : [];
|
|
132
|
+
if (sidecars.length) { rank = Math.max(rank, 1); reasons.push(`sidecars: ${sidecars.join(', ')} (a full Node process outside agy's permission prompts)`); }
|
|
133
|
+
for (const id of sidecars) {
|
|
134
|
+
const mod = readJsonIf(join(sidecarsDir, id, 'mod.json'));
|
|
135
|
+
if (mod?.tier !== TIERS[2]) continue;
|
|
136
|
+
rank = 2;
|
|
137
|
+
reasons.push(`UI mod ${id}: ${(mod.tierReasons ?? []).slice(1).join(', ')}`);
|
|
138
|
+
}
|
|
100
139
|
return { tier: TIERS[rank], reasons };
|
|
101
140
|
}
|
|
102
141
|
|
package/lib/tool-map.mjs
CHANGED
|
@@ -41,7 +41,6 @@ export const ROO_GROUPS = {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export function unmappedToolReason(name) {
|
|
44
|
-
if (name.startsWith('mcp__')) return 'MCP tool names are not mapped yet (agy MCP step names are unverified)';
|
|
45
44
|
if (name === 'TodoWrite' || /^Task(Create|Get|List|Update|Output|Stop)$/.test(name)) return 'agy has no built-in tool that does the same job';
|
|
46
45
|
return 'no agy tool with this name';
|
|
47
46
|
}
|
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
// plugin dir. agy only ever runs with HOME/USERPROFILE pointed at a temp dir,
|
|
6
6
|
// so the user's real profile is never read or written.
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
|
-
import { basename, join,
|
|
10
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
11
11
|
|
|
12
12
|
import { detect } from './detector.mjs';
|
|
13
13
|
import { compileHooks, writeHookPort } from './hook-compiler.mjs';
|
|
14
14
|
import { sanitizeManifest, sourceManifest } from './manifest-sanitizer.mjs';
|
|
15
|
+
import { compileMods, userConfigDefaults } from './mod-compiler.mjs';
|
|
15
16
|
import { compileModes } from './mode-compiler.mjs';
|
|
16
17
|
import { compileRules } from './rules-compiler.mjs';
|
|
17
18
|
import { compileSkills, slugifyName } from './skill-compiler.mjs';
|
|
19
|
+
import { removeTree } from './source.mjs';
|
|
18
20
|
import { auditPlugin } from './static-auditor.mjs';
|
|
19
21
|
|
|
20
22
|
const AGY_STAGED = ['claude', 'gemini'];
|
|
@@ -23,8 +25,13 @@ export function throwawayEnv(home) {
|
|
|
23
25
|
return { ...process.env, HOME: home, USERPROFILE: home, NO_COLOR: '1' };
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// Spawns agy. An `agy` that names a .mjs/.js file runs under this Node (tests use a stand-in).
|
|
29
|
+
export function spawnAgy(agy, args, options) {
|
|
30
|
+
return /\.m?js$/i.test(agy) ? spawnSync(process.execPath, [agy, ...args], options) : spawnSync(agy, args, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export function runAgy(args, { agy = 'agy', home }) {
|
|
27
|
-
const run =
|
|
34
|
+
const run = spawnAgy(agy, args, { encoding: 'utf8', env: throwawayEnv(home), timeout: 120_000 });
|
|
28
35
|
if (run.error) throw new Error(`can't run ${agy}: ${run.error.message}`);
|
|
29
36
|
return run;
|
|
30
37
|
}
|
|
@@ -63,8 +70,27 @@ export function defaultSlug(srcDir, ecosystems) {
|
|
|
63
70
|
return slugifyName(typeof name === 'string' && name ? name : basename(resolve(srcDir)));
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
function makeCopyFilter(rootDir, skipRe) {
|
|
74
|
+
let rootReal = '';
|
|
75
|
+
try { rootReal = realpathSync(rootDir); } catch { rootReal = resolve(rootDir); }
|
|
76
|
+
return (p) => {
|
|
77
|
+
if (skipRe.test(p)) return false;
|
|
78
|
+
try {
|
|
79
|
+
const lst = lstatSync(p);
|
|
80
|
+
if (lst.isSymbolicLink()) {
|
|
81
|
+
const targetReal = realpathSync(p);
|
|
82
|
+
if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) return false;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Ports srcDir into outDir. Returns the port report. `esbuild` overrides the optional
|
|
92
|
+
// esbuild the mod compiler loads for TS/TSX mods (null: act as if it's missing).
|
|
93
|
+
export function transpile({ srcDir, outDir, slug, agy = 'agy', force = false, validate = false, esbuild }) {
|
|
68
94
|
srcDir = resolve(srcDir);
|
|
69
95
|
const detection = detect(srcDir);
|
|
70
96
|
const { ecosystems } = detection;
|
|
@@ -80,14 +106,14 @@ export function transpile({ srcDir, outDir, slug, agy = 'agy', force = false, va
|
|
|
80
106
|
const pluginDir = join(workDir, 'plugin');
|
|
81
107
|
const stagedPaths = [];
|
|
82
108
|
if (ecosystems.includes('antigravity')) {
|
|
83
|
-
cpSync(srcDir, pluginDir, { recursive: true, filter: (
|
|
109
|
+
cpSync(srcDir, pluginDir, { recursive: true, filter: makeCopyFilter(srcDir, /[\\/](\.git|node_modules)$/) });
|
|
84
110
|
report.ported.push('native plugin (copied as is)');
|
|
85
111
|
} else if (ecosystems.some((e) => AGY_STAGED.includes(e))) {
|
|
86
112
|
let stageFrom = srcDir;
|
|
87
113
|
if (detection.manifestless) {
|
|
88
114
|
// agy only detects Claude plugins by their manifest, so stage a copy with one.
|
|
89
115
|
stageFrom = join(workDir, 'src');
|
|
90
|
-
cpSync(srcDir, stageFrom, { recursive: true, filter: (
|
|
116
|
+
cpSync(srcDir, stageFrom, { recursive: true, filter: makeCopyFilter(srcDir, /[\\/](\.git|node_modules)$/) });
|
|
91
117
|
mkdirSync(join(stageFrom, '.claude-plugin'));
|
|
92
118
|
writeFileSync(join(stageFrom, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: slug }));
|
|
93
119
|
report.warnings.push('no .claude-plugin/plugin.json: ported as a Claude Code plugin from its default folders');
|
|
@@ -95,7 +121,7 @@ export function transpile({ srcDir, outDir, slug, agy = 'agy', force = false, va
|
|
|
95
121
|
const staged = stageWithAgy(stageFrom, { agy, workDir });
|
|
96
122
|
// agy may write the resolved path (macOS /var -> /private/var, Windows 8.3 names).
|
|
97
123
|
stagedPaths.push(...new Set([staged, realpathSync.native(staged)]));
|
|
98
|
-
cpSync(staged, pluginDir, { recursive: true, filter: (
|
|
124
|
+
cpSync(staged, pluginDir, { recursive: true, filter: makeCopyFilter(staged, /[\\/]\.git$/) });
|
|
99
125
|
} else {
|
|
100
126
|
mkdirSync(pluginDir);
|
|
101
127
|
}
|
|
@@ -105,12 +131,18 @@ export function transpile({ srcDir, outDir, slug, agy = 'agy', force = false, va
|
|
|
105
131
|
const hooks = compileHooks(detection.hookSources, { slug });
|
|
106
132
|
writeHookPort(pluginDir, hooks);
|
|
107
133
|
merge(report, hooks.report);
|
|
108
|
-
// UI mods
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
// UI mods become sidecars (it merges its hooks into hooks.json, so it runs after
|
|
135
|
+
// writeHookPort). The raw module files agy staged are dropped either way.
|
|
136
|
+
if (detection.uiMods.length) {
|
|
137
|
+
const manifest = sourceManifest(srcDir, ecosystems);
|
|
138
|
+
merge(report, compileMods(detection.uiMods, {
|
|
139
|
+
srcDir, pluginDir, slug, esbuild,
|
|
140
|
+
sourceName: typeof manifest.name === 'string' ? manifest.name : slug,
|
|
141
|
+
title: typeof manifest.displayName === 'string' ? manifest.displayName : undefined,
|
|
142
|
+
version: typeof manifest.version === 'string' ? manifest.version : undefined,
|
|
143
|
+
options: userConfigDefaults(manifest.userConfig),
|
|
144
|
+
}));
|
|
145
|
+
if (typeof manifest.types === 'string') rmSync(join(pluginDir, manifest.types), { force: true });
|
|
114
146
|
}
|
|
115
147
|
merge(report, compileSkills(pluginDir, { srcDir, ecosystems }));
|
|
116
148
|
merge(report, compileRules(pluginDir, { srcDir, ecosystems }));
|
|
@@ -124,13 +156,13 @@ export function transpile({ srcDir, outDir, slug, agy = 'agy', force = false, va
|
|
|
124
156
|
report.tier = audit.tier;
|
|
125
157
|
report.tierReasons = audit.reasons;
|
|
126
158
|
|
|
127
|
-
|
|
159
|
+
removeTree(outDir);
|
|
128
160
|
mkdirSync(outDir, { recursive: true });
|
|
129
161
|
cpSync(pluginDir, outDir, { recursive: true });
|
|
130
162
|
if (validate) report.validation = validateWithAgy(outDir, { agy, workDir });
|
|
131
163
|
return report;
|
|
132
164
|
} finally {
|
|
133
|
-
|
|
165
|
+
removeTree(workDir);
|
|
134
166
|
}
|
|
135
167
|
}
|
|
136
168
|
|
|
@@ -150,9 +182,9 @@ export function validateWithAgy(pluginDir, { agy = 'agy', workDir } = {}) {
|
|
|
150
182
|
ok: run.status === 0,
|
|
151
183
|
components: result.components,
|
|
152
184
|
errors: findings.filter((f) => f.severity === 'error'),
|
|
153
|
-
warnings: findings.filter((f) => f.severity
|
|
185
|
+
warnings: findings.filter((f) => f.severity === 'warning'),
|
|
154
186
|
};
|
|
155
187
|
} finally {
|
|
156
|
-
if (ownDir)
|
|
188
|
+
if (ownDir) removeTree(dir);
|
|
157
189
|
}
|
|
158
190
|
}
|
package/lib/yaml-lite.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// A YAML subset, enough for Markdown frontmatter and `.roomodes`: block maps and
|
|
2
|
-
// sequences, flow `[a, b]` / `{k: v}
|
|
2
|
+
// sequences, flow `[a, b]` / `{k: v}` (on one line or several), quoted and plain scalars, `|` / `>` block
|
|
3
3
|
// scalars, multi-line plain scalars and comments. Anchors, tags and multiple
|
|
4
4
|
// documents are not supported. Like agy's loader, it tolerates `: ` inside an
|
|
5
5
|
// unquoted value (`description: Runs checks: lint`).
|
|
6
6
|
|
|
7
7
|
const KEY = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s"'#\-[{][^:#]*?|-[^\s:#][^:#]*?)\s*:(?=\s|$)/;
|
|
8
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
8
9
|
|
|
9
10
|
function unquote(s) {
|
|
10
11
|
if (s.startsWith('"')) {
|
|
@@ -46,6 +47,19 @@ function splitFlow(s) {
|
|
|
46
47
|
return parts.map((p) => p.trim()).filter((p) => p !== '');
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// True once every `[` and `{` outside quotes is closed.
|
|
51
|
+
function flowClosed(s) {
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let quote = null;
|
|
54
|
+
for (const c of s) {
|
|
55
|
+
if (quote) { if (c === quote) quote = null; continue; }
|
|
56
|
+
if (c === '"' || c === "'") quote = c;
|
|
57
|
+
else if (c === '[' || c === '{') depth++;
|
|
58
|
+
else if (c === ']' || c === '}') depth--;
|
|
59
|
+
}
|
|
60
|
+
return depth <= 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
export function parseScalar(raw) {
|
|
50
64
|
const s = stripComment(raw.trim());
|
|
51
65
|
if (s === '') return null;
|
|
@@ -55,7 +69,10 @@ export function parseScalar(raw) {
|
|
|
55
69
|
const obj = {};
|
|
56
70
|
for (const part of splitFlow(s.slice(1, -1))) {
|
|
57
71
|
const m = KEY.exec(part);
|
|
58
|
-
if (m)
|
|
72
|
+
if (m) {
|
|
73
|
+
const k = unquote(m[1]);
|
|
74
|
+
if (!UNSAFE_KEYS.has(k)) obj[k] = parseScalar(part.slice(m[0].length));
|
|
75
|
+
}
|
|
59
76
|
}
|
|
60
77
|
return obj;
|
|
61
78
|
}
|
|
@@ -99,9 +116,19 @@ export function parseYaml(text) {
|
|
|
99
116
|
return trimmed === '' ? '' : `${trimmed}\n`;
|
|
100
117
|
}
|
|
101
118
|
|
|
119
|
+
// A flow `[...]` or `{...}` that spans lines (`allowed-tools:\n [\n "Read",\n ]`):
|
|
120
|
+
// joins lines until its brackets close.
|
|
121
|
+
function flowValue(first) {
|
|
122
|
+
let text = stripComment(first);
|
|
123
|
+
while (!flowClosed(text) && i < lines.length) text += ` ${stripComment(lines[i++].text)}`;
|
|
124
|
+
if (!flowClosed(text)) throw new Error(`yaml: unclosed ${text[0]} at the end of the document`);
|
|
125
|
+
return parseScalar(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
102
128
|
// A value on the same line as its key or dash, with continuation lines for plain scalars.
|
|
103
129
|
function inlineValue(rest, indent) {
|
|
104
130
|
if (/^[|>][-+0-9]*$/.test(stripComment(rest))) return blockScalar(rest, indent);
|
|
131
|
+
if (/^[[{]/.test(rest) && !flowClosed(stripComment(rest))) return flowValue(rest);
|
|
105
132
|
const value = parseScalar(rest);
|
|
106
133
|
if (typeof value !== 'string' || /^["'[{]/.test(rest)) return value;
|
|
107
134
|
let joined = value;
|
|
@@ -114,6 +141,7 @@ export function parseYaml(text) {
|
|
|
114
141
|
function node(indent) {
|
|
115
142
|
skip();
|
|
116
143
|
if (i >= lines.length || lines[i].indent < indent) return null;
|
|
144
|
+
if (/^[[{]/.test(lines[i].text)) return flowValue(lines[i++].text);
|
|
117
145
|
return isSeq(lines[i].text) ? seq(lines[i].indent) : map(lines[i].indent);
|
|
118
146
|
}
|
|
119
147
|
|
|
@@ -147,8 +175,12 @@ export function parseYaml(text) {
|
|
|
147
175
|
if (rest === '' || rest.startsWith('#')) {
|
|
148
176
|
skip();
|
|
149
177
|
const next = lines[i];
|
|
150
|
-
|
|
151
|
-
|
|
178
|
+
const val = next && (next.indent > indent || (next.indent === indent && isSeq(next.text))) ? node(next.indent) : null;
|
|
179
|
+
if (!UNSAFE_KEYS.has(key)) obj[key] = val;
|
|
180
|
+
} else {
|
|
181
|
+
const val = inlineValue(rest, indent);
|
|
182
|
+
if (!UNSAFE_KEYS.has(key)) obj[key] = val;
|
|
183
|
+
}
|
|
152
184
|
}
|
|
153
185
|
return obj;
|
|
154
186
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agy-plugin-porter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Ports Claude Code, Gemini CLI, Cursor, Roo/Kilo and Codex plugins to native Antigravity plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"bugs": {
|
|
17
17
|
"url": "https://github.com/arthurcavallari/agy-plugin-porter/issues"
|
|
18
18
|
},
|
|
19
|
+
"funding": {
|
|
20
|
+
"type": "github",
|
|
21
|
+
"url": "https://github.com/sponsors/arthurcavallari"
|
|
22
|
+
},
|
|
19
23
|
"keywords": [
|
|
20
24
|
"antigravity",
|
|
21
25
|
"google-antigravity",
|
|
@@ -38,18 +42,24 @@
|
|
|
38
42
|
"bin": {
|
|
39
43
|
"agy-porter": "bin/agy-porter.mjs"
|
|
40
44
|
},
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"esbuild": "0.28.2"
|
|
47
|
+
},
|
|
41
48
|
"files": [
|
|
42
49
|
"bin",
|
|
43
50
|
"lib",
|
|
44
51
|
"scripts/hook-shim.mjs",
|
|
52
|
+
"scripts/mod-runtime",
|
|
45
53
|
"README.md",
|
|
46
54
|
"LICENSE"
|
|
47
55
|
],
|
|
48
56
|
"exports": {
|
|
49
57
|
".": "./lib/index.mjs",
|
|
58
|
+
"./consent": "./lib/consent.mjs",
|
|
50
59
|
"./detector": "./lib/detector.mjs",
|
|
51
60
|
"./hook-compiler": "./lib/hook-compiler.mjs",
|
|
52
61
|
"./manifest-sanitizer": "./lib/manifest-sanitizer.mjs",
|
|
62
|
+
"./mod-compiler": "./lib/mod-compiler.mjs",
|
|
53
63
|
"./skill-compiler": "./lib/skill-compiler.mjs",
|
|
54
64
|
"./rules-compiler": "./lib/rules-compiler.mjs",
|
|
55
65
|
"./mode-compiler": "./lib/mode-compiler.mjs",
|
|
@@ -58,7 +68,8 @@
|
|
|
58
68
|
"./package.json": "./package.json"
|
|
59
69
|
},
|
|
60
70
|
"scripts": {
|
|
61
|
-
"test": "node --test test/yaml-lite.test.mjs test/detector.test.mjs test/hook-compiler.test.mjs test/hook-shim.test.mjs test/compilers.test.mjs test/transpiler.test.mjs",
|
|
62
|
-
"test:pack": "node scripts/pack-smoke.mjs"
|
|
71
|
+
"test": "node --test test/yaml-lite.test.mjs test/detector.test.mjs test/hook-compiler.test.mjs test/hook-shim.test.mjs test/compilers.test.mjs test/transpiler.test.mjs test/mod-scan.test.mjs test/mod-compiler.test.mjs test/mod-continuation.test.mjs test/mod-runtime.test.mjs test/mod-stage.test.mjs test/mod-e2e.test.mjs test/consent.test.mjs test/session-start.test.mjs test/plugin-build.test.mjs test/plugin-mcp.test.mjs test/cockpit.test.mjs",
|
|
72
|
+
"test:pack": "node scripts/pack-smoke.mjs",
|
|
73
|
+
"build:plugin": "node scripts/build-plugin.mjs"
|
|
63
74
|
}
|
|
64
75
|
}
|