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.
@@ -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) => (existsSync(dir) && statSync(dir).isDirectory()
25
- ? readdirSync(dir).filter((f) => /\.(md|mdc|txt)$/i.test(f)).sort().map((f) => join(dir, f))
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 description = trigger.trigger === 'glob'
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${body.trim()}\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, content, item, alwaysOn) => {
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
- if (outputs.length) mkdirSync(rulesDir, { recursive: true });
137
- for (const { name, content } of outputs) writeFileSync(join(rulesDir, name), content);
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) {
@@ -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 = () => rmSync(root, { recursive: true, force: true });
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);
@@ -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, statSync, writeSync } from 'node:fs';
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 (statSync(full).isDirectory()) yield* walk(full, path);
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
- // TIER_2: anything that runs a local command (hooks, stdio MCP servers).
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 hooks = readJsonIf(join(pluginDir, 'hooks.json')) ?? {};
89
- const events = new Set(Object.values(hooks).flatMap((h) => Object.keys(h ?? {})).filter((e) => e !== 'enabled'));
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 servers = Object.entries(readJsonIf(join(pluginDir, 'mcp_config.json'))?.mcpServers ?? {});
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, posix, resolve } from 'node:path';
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 = spawnSync(agy, args, { encoding: 'utf8', env: throwawayEnv(home), timeout: 120_000 });
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
- // Ports srcDir into outDir. Returns the port report.
67
- export function transpile({ srcDir, outDir, slug, agy = 'agy', force = false, validate = false }) {
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: (p) => !/[\\/](\.git|node_modules)$/.test(p) });
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: (p) => !/[\\/](\.git|node_modules)$/.test(p) });
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: (p) => !/[\\/]\.git$/.test(p) });
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 are reported, never copied: drop the module files agy staged.
109
- for (const mod of detection.uiMods) {
110
- const base = posix.dirname(mod.path.split('#')[0]);
111
- for (const m of mod.modules) rmSync(join(pluginDir, posix.join(base, String(m))), { force: true });
112
- const types = sourceManifest(srcDir, ecosystems).types;
113
- if (typeof types === 'string') rmSync(join(pluginDir, types), { force: true });
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
- rmSync(outDir, { recursive: true, force: true });
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
- rmSync(workDir, { recursive: true, force: true });
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 !== 'error'),
185
+ warnings: findings.filter((f) => f.severity === 'warning'),
154
186
  };
155
187
  } finally {
156
- if (ownDir) rmSync(dir, { recursive: true, force: true });
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}`, quoted and plain scalars, `|` / `>` block
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) obj[unquote(m[1])] = parseScalar(part.slice(m[0].length));
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
- obj[key] = next && (next.indent > indent || (next.indent === indent && isSeq(next.text))) ? node(next.indent) : null;
151
- } else obj[key] = inlineValue(rest, indent);
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.1.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
  }