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 CHANGED
@@ -1,12 +1,20 @@
1
1
  # agy-plugin-porter
2
2
 
3
+ <p>
4
+ <a href="https://www.npmjs.com/package/agy-plugin-porter"><img src="https://img.shields.io/npm/v/agy-plugin-porter?logo=npm&color=cb3837" alt="npm version" height="20"></a>
5
+ <a href="https://github.com/arthurcavallari/agy-plugin-porter/actions/workflows/test.yml"><img src="https://github.com/arthurcavallari/agy-plugin-porter/actions/workflows/test.yml/badge.svg" alt="Tests" height="20"></a>
6
+ <img src="https://img.shields.io/node/v/agy-plugin-porter?logo=nodedotjs&logoColor=white&color=339933" alt="Node.js version" height="20">
7
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" height="20"></a>
8
+ <a href="https://github.com/sponsors/arthurcavallari"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors&logoColor=white" alt="Sponsor on GitHub" height="20"></a>
9
+ </p>
10
+
3
11
  > Ports Claude Code, Gemini CLI, Cursor, Roo/Kilo and Codex plugins to native **Google Antigravity** plugins, and tells you exactly what did and didn't carry over.
4
12
 
5
13
  `agy plugin install` already stages Claude Code and Gemini CLI plugins, but much of what it stages never runs. Hooks keep their foreign shape, `${CLAUDE_PLUGIN_ROOT}` stays unexpanded, and Claude agents are skipped by the loader. `CLAUDE.md` is never loaded either. agy still reports success. Cursor, Roo/Kilo and Codex sources aren't detected at all.
6
14
 
7
- `agy-plugin-porter` fills those gaps and audits the result. It's zero-dependency, pure Node.js (`>= 20.0.0`) ESM, and it's built to be the porting engine for [**Antigravity Hangar**](https://github.com/arthurcavallari/antigravity-hangar)'s plugin installs. That integration is planned and not built yet.
15
+ `agy-plugin-porter` fills those gaps and audits the result. It's zero-dependency (`esbuild` is optional, for TS/TSX UI mods), pure Node.js (`>= 20.0.0`) ESM, and it's the porting engine behind [**Antigravity Hangar**](https://github.com/arthurcavallari/antigravity-hangar)'s plugin installs (Hangar 1.2.0 and later).
8
16
 
9
- **Status: 0.x.** Phases 0–2 of [`docs/plan.md`](docs/plan.md) are done. What agy itself handles was measured against agy 1.4.5 and is recorded in [`docs/agy-baseline.md`](docs/agy-baseline.md).
17
+ **Status: 0.x.** Phases 0–2 of [`docs/plan.md`](docs/plan.md) are done, plus Phase 2.5 (Claude Code UI mods, [`docs/phase-2.5-ui-mods-plan.md`](docs/phase-2.5-ui-mods-plan.md)) and Phase 4 ([the porter inside Antigravity](#the-porter-inside-antigravity)). What agy itself handles was measured against agy 1.4.5 and is recorded in [`docs/agy-baseline.md`](docs/agy-baseline.md).
10
18
 
11
19
  ---
12
20
 
@@ -51,7 +59,7 @@ Nothing is dropped silently.
51
59
  | Cursor `.mdc` | Always-on rules, `model_decision` rules, or skills (glob-scoped and manual rules) |
52
60
  | `.cursorrules`, Roo/Cline/Kilo rules | Always-on rules |
53
61
  | Codex `.agents/skills` | Skills |
54
- | Claude Code UI mods (`hooks.json` with `modules`) | Detected and reported as unported. Nothing is copied or run. |
62
+ | Claude Code UI mods (`hooks.json` with `modules`) | A plugin sidecar per mod, with its pane in Antigravity's side panel. See [UI mods](#ui-mods). |
55
63
 
56
64
  ### The hook shim
57
65
 
@@ -65,6 +73,20 @@ agy's hook contract differs from Claude Code's and Gemini CLI's: camelCase paylo
65
73
 
66
74
  The original commands live in `scripts/hook-shim.json`, so they never pass through agy's command line, which mangles quoted paths on Windows.
67
75
 
76
+ ### UI mods
77
+
78
+ A Claude Code UI mod is a JS/TS module whose `register(on)` draws panes and hooks into the agent loop. The porter compiles each one into a plugin sidecar:
79
+ - **Pane:** the mod's `Pane` renders in a view in Antigravity's side panel. Open it from the `sidecar://` link a command's reply carries, or from its tab.
80
+ - **Events:** `session.start`, `turn.start`, `turn.complete` and `tool.call` run from agy hooks forwarded to the sidecar. A `tool.call` handler can observe and deny built-in tool calls (live-tested). It can also rewrite their input, which is implemented but not yet live-tested.
81
+ - **Commands and tools:** each `$.command.register` becomes a skill plus a button in the view's toolbar. Each `$.tool.register` becomes an MCP tool.
82
+ - **Report:** a mod is ported clean, partially (each fallback listed), or refused (for example, a mod that only draws into the transcript). A static scan lists every `$` call, and the runtime exposes only those.
83
+
84
+ What maps fully, partially and not at all, tested live against three real mods, is in [`docs/ui-mods-baseline.md`](docs/ui-mods-baseline.md).
85
+
86
+ TS/TSX mods need [`esbuild`](https://esbuild.github.io/), an optional dependency installed with the porter. Without it, those mods are reported unported. Plain JS mods need nothing. A ported mod is a full Node process outside agy's permission prompts, so it's always at least Tier 2.
87
+
88
+ **Uninstalling:** switch the plugin off in **Customizations > Plugins** before `agy plugin uninstall`. On Windows a running sidecar holds its folder open, and the uninstall fails halfway.
89
+
68
90
  ### Static audit
69
91
 
70
92
  Before writing the output, the porter:
@@ -72,6 +94,33 @@ Before writing the output, the porter:
72
94
  - flags `curl | sh`-style pipes, decode-and-execute patterns, encoded PowerShell, bidi controls and high-entropy strings
73
95
  - checks agy's 24 KB per-rule and 20k-token always-on rules budgets
74
96
 
97
+ ## The porter inside Antigravity
98
+
99
+ The porter also ships as an Antigravity plugin, so you can port from inside the app. GitHub releases after 0.1.0 carry it as `agy-plugin-porter-plugin-<version>.tgz`, with a `.sha256` beside it. Unpack it and install the folder:
100
+
101
+ ```bash
102
+ tar -xzf agy-plugin-porter-plugin-<version>.tgz
103
+ agy plugin install ./agy-plugin-porter
104
+ ```
105
+
106
+ To build it from a checkout instead, run `npm run build:plugin` and install `dist/plugin/agy-plugin-porter`.
107
+
108
+ The plugin adds:
109
+ - **A detector.** When a conversation starts in a workspace that holds a Claude Code, Gemini CLI, Cursor, Roo/Kilo or Codex plugin, the agent is told once per workspace. It mentions the plugin and doesn't port anything unasked.
110
+ - **A skill, an agent and rules** for porting with the porter, never by hand.
111
+ - **Two MCP tools**, `port_preview` and `port_install` (server `agy-plugin-porter_cli`).
112
+ - **The Plugin Porter pane.** Enter a folder or `owner/repo@<tag>`, read the full port report, then install.
113
+
114
+ **Consent.** Every install starts with a preview, which ports into a temp folder and shows the report. An install takes only that preview's output, and only when it gets back the preview's tier and audit-findings digest. That's the same rule Hangar's installer uses. The pane asks you in a confirm dialog. The agent is told to show you the report and wait for your yes, but only the pane's dialog is enforced. Previews are kept in a folder only you can access (`%LOCALAPPDATA%\agy-plugin-porter\previews` on Windows, `~/.cache/agy-plugin-porter/previews` elsewhere), never in the shared temp folder, and expire after an hour.
115
+
116
+ **Limits:**
117
+ - There's no `esbuild` inside the plugin, so TS/TSX UI mods are reported unported. Port those with `npx agy-plugin-porter`.
118
+ - Claude Code and Gemini CLI sources need `agy` on the app's PATH, and GitHub sources need `git`. Both were there in the live run.
119
+
120
+ **Uninstalling:** switch it off in **Customizations > Plugins** first, then run `agy plugin uninstall agy-plugin-porter`.
121
+
122
+ What was tested live, and what the app leaves behind after an uninstall, is in [`docs/plugin-baseline.md`](docs/plugin-baseline.md).
123
+
75
124
  ## Library
76
125
 
77
126
  ```js
@@ -85,7 +134,7 @@ const { report, tier } = await portPackage({
85
134
  });
86
135
  ```
87
136
 
88
- Subpath exports cover the individual stages: `./detector`, `./hook-compiler`, `./manifest-sanitizer`, `./skill-compiler`, `./rules-compiler`, `./mode-compiler`, `./static-auditor` and `./transpiler-engine`.
137
+ Subpath exports cover the individual stages: `./detector`, `./hook-compiler`, `./manifest-sanitizer`, `./mod-compiler`, `./skill-compiler`, `./rules-compiler`, `./mode-compiler`, `./static-auditor` and `./transpiler-engine`.
89
138
 
90
139
  ## Development
91
140
 
@@ -96,6 +145,12 @@ npm run test:pack # packs the tarball, installs it in an empty project, and run
96
145
 
97
146
  The tests that need agy (Claude/Gemini staging and `agy plugin validate`) skip when `agy` isn't on `PATH`, as on the CI runners. Run `npm test` locally with agy installed to cover them. Releases publish from a `v*` tag through npm trusted publishing (`.github/workflows/publish.yml`).
98
147
 
148
+ ## Support
149
+
150
+ Built and maintained in my spare time (with constant keyboard supervision from my three cats, **Snow** 🤍, **Eevee** 🦊, and **Merlin** 🧙‍♂️).
151
+
152
+ If **agy-plugin-porter** saves you time porting and auditing plugins for Antigravity, consider [❤️ sponsoring me on GitHub Sponsors](https://github.com/sponsors/arthurcavallari) to help fund ongoing maintenance, new transpiler support, and the household Churu treat budget!
153
+
99
154
  ## License
100
155
 
101
156
  [MIT](LICENSE)
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // agy-porter --source <path|owner/repo@ref> [--path <dir>] [--slug <s>] [--out <dir>]
3
3
  // [--force] [--validate] [--install] [--json]
4
- import { spawnSync } from 'node:child_process';
5
4
  import { mkdtempSync, rmSync } from 'node:fs';
6
5
  import { tmpdir } from 'node:os';
7
6
  import { join } from 'node:path';
8
7
  import { parseArgs } from 'node:util';
9
8
 
10
9
  import { portPackage } from '../lib/index.mjs';
10
+ import { spawnAgy } from '../lib/transpiler-engine.mjs';
11
11
 
12
12
  const USAGE = `Usage: agy-porter --source <path|owner/repo@ref> [options]
13
13
 
@@ -75,7 +75,7 @@ async function main() {
75
75
  }
76
76
  if (report.validation && !report.validation.ok) return 2;
77
77
  if (opts.install) {
78
- const run = spawnSync(opts.agy ?? 'agy', ['plugin', 'install', outDir], { stdio: 'inherit' });
78
+ const run = spawnAgy(opts.agy ?? 'agy', ['plugin', 'install', outDir], { stdio: 'inherit' });
79
79
  if (run.status !== 0) return run.status ?? 1;
80
80
  console.log('Installed. Restart Antigravity to load a new plugin directory.');
81
81
  }
@@ -0,0 +1,122 @@
1
+ // Previews and consent for installing a port into the user's Antigravity profile.
2
+ // A preview ports a source into a private folder and records its report; an install
3
+ // takes only that folder, and only when the caller hands back the tier and the
4
+ // audit-findings digest the preview showed (the same rule as Hangar's installer).
5
+ import { spawn } from 'node:child_process';
6
+ import { createHash, randomBytes } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { removeTree } from './source.mjs';
13
+ import { TIERS } from './static-auditor.mjs';
14
+ import { spawnAgy } from './transpiler-engine.mjs';
15
+
16
+ const BIN = fileURLToPath(new URL('../bin/agy-porter.mjs', import.meta.url));
17
+ const ID = /^[a-f0-9]{16}$/;
18
+ export const PREVIEW_TTL_MS = 60 * 60 * 1000;
19
+
20
+ // Previews live in a per-user cache folder, never the shared temp dir: on a
21
+ // multi-user machine, another user who made /tmp/agy-plugin-porter first could
22
+ // swap a preview's report or output between the preview and the install.
23
+ export function previewsDir() {
24
+ if (process.env.AGY_PORTER_PREVIEWS_DIR) return process.env.AGY_PORTER_PREVIEWS_DIR;
25
+ const base = process.platform === 'win32'
26
+ ? process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
27
+ : process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
28
+ return join(base, 'agy-plugin-porter', 'previews');
29
+ }
30
+ export const agyCommand = () => process.env.AGY_PORTER_AGY || 'agy';
31
+ const hasAgy = (agy) => spawnAgy(agy, ['--version'], { encoding: 'utf8', timeout: 15_000 }).status === 0;
32
+
33
+ // Same digest as Hangar's flagsDigest (antigravity-sidecar-hub lib/plugin-installer.mjs),
34
+ // so consent given in one can be checked by the other.
35
+ export function flagsDigest(flags = []) {
36
+ if (!flags.length) return '';
37
+ const keys = flags.map((f) => [f.kind, f.severity, f.file || '', f.line || 0].join(':')).sort();
38
+ return createHash('sha256').update(keys.join('\n')).digest('hex').slice(0, 16);
39
+ }
40
+
41
+ // Deletes previews older than the TTL. Returns the ones left, newest first.
42
+ export function listPreviews({ now = Date.now() } = {}) {
43
+ const dir = previewsDir();
44
+ if (!existsSync(dir)) return [];
45
+ const kept = [];
46
+ for (const id of readdirSync(dir)) {
47
+ const preview = readPreview(id);
48
+ if (!preview || now - preview.createdAt > PREVIEW_TTL_MS) removeTree(join(dir, id));
49
+ else kept.push(preview);
50
+ }
51
+ return kept.sort((a, b) => b.createdAt - a.createdAt);
52
+ }
53
+
54
+ export function readPreview(id) {
55
+ if (!ID.test(String(id))) return null;
56
+ try { return JSON.parse(readFileSync(join(previewsDir(), id, 'preview.json'), 'utf8')); } catch { return null; }
57
+ }
58
+
59
+ // Runs the porter's CLI in a child process, so the caller's event loop stays free.
60
+ function runPorter(args) {
61
+ return new Promise((resolve) => {
62
+ const child = spawn(process.execPath, [BIN, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
63
+ let stdout = '';
64
+ let stderr = '';
65
+ child.stdout.on('data', (d) => { stdout += d; });
66
+ child.stderr.on('data', (d) => { stderr += d; });
67
+ child.on('error', (err) => resolve({ code: 1, stdout, stderr: err.message }));
68
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
69
+ });
70
+ }
71
+
72
+ // Ports `source` (a path or owner/repo@ref) into a fresh preview folder. Validates
73
+ // with agy when it's on PATH. Resolves to the preview record; rejects with the
74
+ // porter's error when the port fails.
75
+ export async function createPreview({ source, path, slug } = {}) {
76
+ if (typeof source !== 'string' || !source.trim()) throw new Error('source is required: a folder path or owner/repo@ref');
77
+ listPreviews();
78
+ const id = randomBytes(8).toString('hex');
79
+ const dir = join(previewsDir(), id);
80
+ const outDir = join(dir, 'out');
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ const agy = agyCommand();
83
+ const validate = hasAgy(agy);
84
+ const args = ['--source', source.trim(), '--out', outDir, '--json', '--agy', agy];
85
+ if (path) args.push('--path', String(path));
86
+ if (slug) args.push('--slug', String(slug));
87
+ if (validate) args.push('--validate');
88
+ const run = await runPorter(args);
89
+ let report = null;
90
+ try { report = JSON.parse(run.stdout); } catch {}
91
+ if (!report) {
92
+ removeTree(dir);
93
+ throw new Error((run.stderr || run.stdout).trim().replace(/^agy-porter: /, '') || `the porter exited with ${run.code}`);
94
+ }
95
+ delete report.outDir;
96
+ const preview = {
97
+ id, createdAt: Date.now(), source: source.trim(), path: path || null, slug: report.slug,
98
+ tier: report.tier, flagsDigest: flagsDigest(report.flags), validated: validate, report,
99
+ };
100
+ writeFileSync(join(dir, 'preview.json'), JSON.stringify(preview, null, 2));
101
+ return preview;
102
+ }
103
+
104
+ // Installs a preview's output with `agy plugin install`, if the caller's consent
105
+ // covers it. Resolves to {slug, output}; rejects with the reason it refused.
106
+ export async function installPreview({ previewId, acceptedTier, acceptedFlags = '' } = {}) {
107
+ const preview = readPreview(previewId);
108
+ if (!preview || Date.now() - preview.createdAt > PREVIEW_TTL_MS) throw new Error('no such preview (they expire after an hour): run a new preview');
109
+ const accepted = TIERS.indexOf(acceptedTier);
110
+ if (accepted < TIERS.indexOf(preview.tier)) throw new Error(`the port is ${preview.tier}, which is above the accepted ${acceptedTier || 'none'}`);
111
+ if ((acceptedFlags || '') !== preview.flagsDigest) throw new Error('the accepted audit findings differ from this preview\'s: show the user the preview again');
112
+ if (!preview.validated) throw new Error('this preview wasn\'t validated, because agy isn\'t on PATH; agy is needed to install');
113
+ if (!preview.report.validation?.ok) throw new Error('the port failed `agy plugin validate`, so it can\'t be installed');
114
+ const dir = join(previewsDir(), preview.id);
115
+ const outDir = join(dir, 'out');
116
+ if (!existsSync(outDir) || !statSync(outDir).isDirectory()) throw new Error('the preview\'s output is gone: run a new preview');
117
+ const run = spawnAgy(agyCommand(), ['plugin', 'install', outDir], { encoding: 'utf8', timeout: 120_000 });
118
+ const output = `${run.stdout ?? ''}${run.stderr ?? ''}`.trim();
119
+ if (run.error || run.status !== 0) throw new Error(`agy plugin install failed: ${run.error?.message ?? output}`);
120
+ removeTree(dir);
121
+ return { slug: preview.slug, output };
122
+ }
package/lib/detector.mjs CHANGED
@@ -87,5 +87,6 @@ function hasClaudeComponents(srcDir) {
87
87
  || list('agents').some((f) => f.endsWith('.md'))
88
88
  || list('skills').some((d) => existsSync(join(srcDir, 'skills', d, 'SKILL.md')))
89
89
  || existsSync(join(srcDir, 'hooks/hooks.json'))
90
- || existsSync(join(srcDir, '.mcp.json'));
90
+ || existsSync(join(srcDir, '.mcp.json'))
91
+ || existsSync(join(srcDir, 'mcp.json'));
91
92
  }
@@ -3,10 +3,9 @@
3
3
  // the original command in scripts/hook-shim.json. The original command never
4
4
  // goes on agy's command line, so agy's Windows quote mangling can't reach it.
5
5
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
- import { dirname, join, posix } from 'node:path';
6
+ import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- import { isUiMod } from './detector.mjs';
10
9
  import { AGY_TOOLS, TOOLS, unmappedToolReason } from './tool-map.mjs';
11
10
 
12
11
  export const SHIM_PATH = 'scripts/hook-shim.mjs';
@@ -25,6 +24,30 @@ const UNPORTED_EVENTS = {
25
24
  };
26
25
 
27
26
  const REGEX_META = /[.*+?^${}()[\]\\]/;
27
+ const MCP_PREFIX_RE = /^(?:\^|\(\?:|\()*mcp__/;
28
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
29
+
30
+ // Splits a matcher on top-level `|` only (outside `(...)` and `[...]`).
31
+ function splitTopLevelAlternatives(raw) {
32
+ const out = [];
33
+ let paren = 0;
34
+ let bracket = false;
35
+ let start = 0;
36
+ for (let i = 0; i < raw.length; i++) {
37
+ const ch = raw[i];
38
+ if (ch === '\\') { i++; continue; }
39
+ if (ch === '[' && !bracket) bracket = true;
40
+ else if (ch === ']' && bracket) bracket = false;
41
+ else if (!bracket && ch === '(') paren++;
42
+ else if (!bracket && ch === ')') paren = Math.max(0, paren - 1);
43
+ else if (!bracket && paren === 0 && ch === '|') {
44
+ out.push(raw.slice(start, i).trim());
45
+ start = i + 1;
46
+ }
47
+ }
48
+ out.push(raw.slice(start).trim());
49
+ return out.filter(Boolean);
50
+ }
28
51
 
29
52
  // Rewrites a source matcher into an agy matcher. Returns the agy matcher (or
30
53
  // null when nothing maps) and the alternatives that didn't map.
@@ -32,17 +55,30 @@ export function remapMatcher(matcher, dialect = 'claude') {
32
55
  const raw = (matcher ?? '').trim();
33
56
  if (raw === '' || raw === '*' || raw === '.*') return { matcher: '*', unmapped: [] };
34
57
  const map = TOOLS[dialect];
35
- const alternatives = raw.includes('(') ? [raw] : raw.split('|').map((a) => a.trim()).filter(Boolean);
58
+ const alternatives = splitTopLevelAlternatives(raw);
36
59
  const mapped = new Set();
37
60
  const unmapped = [];
38
61
  for (const alt of alternatives) {
39
- if (map[alt]) map[alt].forEach((t) => mapped.add(t));
40
- else if (AGY_TOOLS.has(alt)) mapped.add(alt);
41
- else if (!alt.startsWith('mcp__') && REGEX_META.test(alt)) {
62
+ if (Object.hasOwn(map, alt)) map[alt].forEach((t) => mapped.add(t));
63
+ else if (AGY_TOOLS.has(alt) || alt === 'call_mcp_tool' || alt === 'mcp_tool') mapped.add(alt);
64
+ else if (MCP_PREFIX_RE.test(alt)) {
65
+ if (REGEX_META.test(alt)) {
66
+ try { new RegExp(`^(?:${alt})$`); } catch { unmapped.push(alt); continue; }
67
+ }
68
+ mapped.add('call_mcp_tool');
69
+ mapped.add('mcp_tool');
70
+ const eager = alt.replace(/^\^?mcp__/, 'mcp_').replace(/__/g, '_');
71
+ mapped.add(eager.endsWith('_') ? `${eager}.*` : eager);
72
+ } else if (REGEX_META.test(alt)) {
42
73
  let re;
43
74
  try { re = new RegExp(`^(?:${alt})$`); } catch { unmapped.push(alt); continue; }
44
75
  const hits = Object.keys(map).filter((name) => re.test(name));
45
- if (hits.length === 0) unmapped.push(alt);
76
+ const matchesMcp = /\bmcp__/.test(alt) && (re.test('mcp__server__tool') || MCP_PREFIX_RE.test(alt));
77
+ if (matchesMcp) {
78
+ mapped.add('call_mcp_tool');
79
+ mapped.add('mcp_tool');
80
+ }
81
+ if (hits.length === 0 && !matchesMcp) unmapped.push(alt);
46
82
  hits.forEach((name) => map[name].forEach((t) => mapped.add(t)));
47
83
  } else unmapped.push(alt);
48
84
  }
@@ -80,23 +116,19 @@ export function compileHooks(sources, { slug }) {
80
116
  continue;
81
117
  }
82
118
  const config = source.config ?? {};
83
- if (isUiMod(config)) {
84
- const base = posix.dirname(source.path.split('#')[0]);
85
- for (const mod of config.modules) {
86
- const path = posix.normalize(posix.join(base, String(mod)));
87
- report.unported.push({ item: `ui-mod:${path}`, reason: 'Claude Code UI mods are not ported yet' });
88
- }
89
- }
119
+ // A config's "modules" (Claude Code UI mods) are lib/mod-compiler.mjs's job.
90
120
  for (const key of Object.keys(config)) {
91
121
  if (!['hooks', 'modules', 'description'].includes(key)) warnOnce(`${source.path}: ignored top-level key "${key}"`);
92
122
  }
93
123
  const sourceEvents = config.hooks ?? {};
94
124
  for (const [sourceEvent, groups] of Object.entries(sourceEvents)) {
95
- const event = EVENTS[dialect][sourceEvent];
125
+ if (UNSAFE_KEYS.has(sourceEvent)) continue;
126
+ const dialectEvents = EVENTS[dialect] ?? {};
127
+ const event = Object.hasOwn(dialectEvents, sourceEvent) ? dialectEvents[sourceEvent] : null;
96
128
  if (!event) {
97
129
  report.unported.push({
98
130
  item: `hook:${sourceEvent}`,
99
- reason: UNPORTED_EVENTS[sourceEvent] ?? `agy has no ${sourceEvent} event`,
131
+ reason: Object.hasOwn(UNPORTED_EVENTS, sourceEvent) ? UNPORTED_EVENTS[sourceEvent] : `agy has no ${sourceEvent} event`,
100
132
  });
101
133
  continue;
102
134
  }
@@ -128,7 +160,11 @@ export function compileHooks(sources, { slug }) {
128
160
  counters[event] = (counters[event] ?? 0) + 1;
129
161
  const id = `${event.toLowerCase()}-${counters[event]}`;
130
162
  const timeout = scriptTimeout(handler, dialect);
131
- shimHooks[id] = { event, sourceEvent, dialect, command: handler.command, timeout };
163
+ const rawMatcher = GROUPED.has(event) && typeof group.matcher === 'string' ? group.matcher.trim() : '';
164
+ shimHooks[id] = {
165
+ event, sourceEvent, dialect, command: handler.command, timeout,
166
+ ...(rawMatcher && rawMatcher !== '*' && rawMatcher !== '.*' ? { matcher: rawMatcher } : {}),
167
+ };
132
168
  handlers.push({ type: 'command', command: `node ${SHIM_PATH} --event ${event} --hook ${id}`, timeout: timeout + 5 });
133
169
  report.ported.push(item);
134
170
  });
@@ -153,7 +189,7 @@ export function compileHooks(sources, { slug }) {
153
189
 
154
190
  // Writes the compiled hooks into a staged plugin dir: root hooks.json, the shim
155
191
  // and its config. Removes the foreign-shaped copies agy staged, including a
156
- // UI mod's hooks/hooks.json (UI mods are reported, never copied).
192
+ // UI mod's hooks/hooks.json (lib/mod-compiler.mjs writes a mod's hooks itself).
157
193
  export function writeHookPort(pluginDir, compiled) {
158
194
  const rootHooks = join(pluginDir, 'hooks.json');
159
195
  const shimTarget = join(pluginDir, SHIM_PATH);
package/lib/index.mjs CHANGED
@@ -3,8 +3,11 @@ import { resolveSource } from './source.mjs';
3
3
  import { transpile } from './transpiler-engine.mjs';
4
4
 
5
5
  export { detect } from './detector.mjs';
6
+ export { createPreview, flagsDigest, installPreview, listPreviews, readPreview } from './consent.mjs';
6
7
  export { compileHooks, remapMatcher, writeHookPort } from './hook-compiler.mjs';
7
8
  export { sanitizeManifest } from './manifest-sanitizer.mjs';
9
+ export { compileMods } from './mod-compiler.mjs';
10
+ export { assess as assessMod, scanMod } from './mod-scan.mjs';
8
11
  export { compileModes } from './mode-compiler.mjs';
9
12
  export { compileRules } from './rules-compiler.mjs';
10
13
  export { compileSkills } from './skill-compiler.mjs';
@@ -14,7 +14,7 @@ import { readJson } from './detector.mjs';
14
14
 
15
15
  export const MANIFEST_KEYS = ['name', 'displayName', 'description', 'version', 'logo', 'suggestedPrompts', 'disabled'];
16
16
  const LOGO_EXT = new Set(['.png', '.svg', '.jpg', '.jpeg', '.webp']);
17
- const SOURCE_ARTIFACTS = ['.claude-plugin', 'gemini-extension.json', '.mcp.json'];
17
+ const SOURCE_ARTIFACTS = ['.claude-plugin', 'gemini-extension.json', '.mcp.json', 'mcp.json'];
18
18
 
19
19
  function readIf(file) {
20
20
  try { return existsSync(file) ? readJson(file) : null; } catch { return null; }
@@ -57,14 +57,24 @@ export function buildManifest(source, { slug, ecosystems, pluginDir, report }) {
57
57
  return out;
58
58
  }
59
59
 
60
+ export function extractServerMap(obj) {
61
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return {};
62
+ if (obj.mcpServers && typeof obj.mcpServers === 'object' && !Array.isArray(obj.mcpServers)) return obj.mcpServers;
63
+ const entries = Object.entries(obj).filter(([k, v]) =>
64
+ !k.startsWith('$') && !['version', 'description', 'name'].includes(k)
65
+ && v && typeof v === 'object' && !Array.isArray(v)
66
+ && ('command' in v || 'url' in v || 'httpUrl' in v || 'serverUrl' in v));
67
+ return Object.fromEntries(entries);
68
+ }
69
+
60
70
  // Inline servers in Claude's manifest: an object, or a path to a JSON file.
61
71
  function inlineServers(source, srcDir, report) {
62
72
  const declared = source.mcpServers;
63
73
  if (!declared) return {};
64
- if (typeof declared === 'object' && !Array.isArray(declared)) return declared;
74
+ if (typeof declared === 'object' && !Array.isArray(declared)) return extractServerMap({ mcpServers: declared });
65
75
  if (typeof declared === 'string') {
66
76
  const file = readIf(join(srcDir, declared));
67
- if (file) return file.mcpServers ?? file;
77
+ if (file) return extractServerMap(file);
68
78
  report.unported.push({ item: `mcp:${declared}`, reason: "can't read the MCP config the manifest points to" });
69
79
  }
70
80
  return {};
@@ -72,8 +82,11 @@ function inlineServers(source, srcDir, report) {
72
82
 
73
83
  const ROOT_VARS = [
74
84
  [/\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT\b/g, '${PLUGIN_ROOT}'],
85
+ [/\$PLUGIN_ROOT\b/g, '${PLUGIN_ROOT}'],
75
86
  [/\$\{CLAUDE_PLUGIN_DATA\}|\$CLAUDE_PLUGIN_DATA\b/g, '${PLUGIN_DATA}'],
76
- [/\$\{extensionPath\}/g, '${PLUGIN_ROOT}'],
87
+ [/\$PLUGIN_DATA\b/g, '${PLUGIN_DATA}'],
88
+ [/\$\{extensionPath\}|\$extensionPath\b/g, '${PLUGIN_ROOT}'],
89
+ [/\$\{GEMINI_PLUGIN_PATH\}|\$GEMINI_PLUGIN_PATH\b/g, '${PLUGIN_ROOT}'],
77
90
  [/\$\{\/\}/g, '/'],
78
91
  ];
79
92
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -94,13 +107,30 @@ const PLACEHOLDER = /\$\{(?!PLUGIN_ROOT\}|PLUGIN_DATA\})([^}]+)\}/g;
94
107
  export function fixMcpServers(servers, { stagedPaths = [], report }) {
95
108
  const fixed = {};
96
109
  for (const [name, server] of Object.entries(servers)) {
97
- if (!server || typeof server !== 'object') continue;
110
+ if (!server || typeof server !== 'object' || Array.isArray(server)) continue;
111
+ if (name.startsWith('$') || ['__proto__', 'constructor', 'prototype'].includes(name)) continue;
98
112
  const s = structuredClone(server);
99
113
  if (Array.isArray(s.args)) s.args = s.args.map((a) => (typeof a === 'string' ? rewriteString(a, stagedPaths) : a));
100
114
  if (typeof s.cwd === 'string') s.cwd = rewriteString(s.cwd, stagedPaths);
101
115
  if (s.env && typeof s.env === 'object') {
102
116
  s.env = Object.fromEntries(Object.entries(s.env).map(([k, v]) => [k, typeof v === 'string' ? rewriteString(v, stagedPaths) : v]));
103
117
  }
118
+ if (typeof s.url === 'string' && !s.url.trim()) delete s.url;
119
+ if (typeof s.httpUrl === 'string' && !s.httpUrl.trim()) delete s.httpUrl;
120
+ const rawRemoteUrl = typeof s.url === 'string' ? s.url : (typeof s.httpUrl === 'string' ? s.httpUrl : null);
121
+ if (!s.serverUrl && rawRemoteUrl) {
122
+ s.serverUrl = rewriteString(rawRemoteUrl, stagedPaths);
123
+ delete s.url;
124
+ delete s.httpUrl;
125
+ } else if (typeof s.serverUrl === 'string') {
126
+ s.serverUrl = rewriteString(s.serverUrl, stagedPaths);
127
+ }
128
+ if (typeof s.serverUrl === 'string' && ['http', 'streamable-http', 'sse'].includes(s.type)) {
129
+ delete s.type;
130
+ }
131
+ if (s.headers && typeof s.headers === 'object') {
132
+ s.headers = Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [k, typeof v === 'string' ? rewriteString(v, stagedPaths) : v]));
133
+ }
104
134
  if (typeof s.command === 'string') {
105
135
  const command = rewriteString(s.command, stagedPaths);
106
136
  if (command.includes('${PLUGIN_ROOT}') || command.includes('${PLUGIN_DATA}')) {
@@ -110,7 +140,7 @@ export function fixMcpServers(servers, { stagedPaths = [], report }) {
110
140
  s.command = command;
111
141
  }
112
142
  const others = new Set();
113
- for (const text of [s.command, s.url, ...(s.args ?? []), ...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})]) {
143
+ for (const text of [s.command, s.url, s.serverUrl, ...(s.args ?? []), ...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})]) {
114
144
  if (typeof text === 'string') for (const m of text.matchAll(PLACEHOLDER)) others.add(m[1]);
115
145
  }
116
146
  if (others.size) {
@@ -130,11 +160,17 @@ export function sanitizeManifest(pluginDir, { srcDir, ecosystems, slug, stagedPa
130
160
  report.ported.push('manifest');
131
161
 
132
162
  const configFile = join(pluginDir, 'mcp_config.json');
133
- const servers = { ...(readIf(configFile)?.mcpServers ?? {}) };
134
- if (ecosystems.includes('claude')) {
163
+ const servers = {
164
+ ...(srcDir ? extractServerMap(readIf(join(srcDir, 'mcp.json'))) : {}),
165
+ ...(srcDir ? extractServerMap(readIf(join(srcDir, '.mcp.json'))) : {}),
166
+ ...extractServerMap(readIf(join(pluginDir, 'mcp.json'))),
167
+ ...extractServerMap(readIf(join(pluginDir, '.mcp.json'))),
168
+ ...extractServerMap(readIf(configFile)),
169
+ };
170
+ if (ecosystems.includes('claude') || ecosystems.includes('gemini')) {
135
171
  for (const [name, server] of Object.entries(inlineServers(source, srcDir, report))) {
136
- if (name in servers) report.warnings.push(`mcp:${name}: defined in both .mcp.json and plugin.json; kept .mcp.json's`);
137
- else servers[name] = server;
172
+ if (name in servers && ecosystems.includes('claude')) report.warnings.push(`mcp:${name}: defined in both .mcp.json and plugin.json; kept .mcp.json's`);
173
+ else if (!(name in servers)) servers[name] = server;
138
174
  }
139
175
  }
140
176
  if (ecosystems.includes('gemini')) {