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/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
|
|
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`) |
|
|
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)
|
package/bin/agy-porter.mjs
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/lib/consent.mjs
ADDED
|
@@ -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
|
}
|
package/lib/hook-compiler.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
40
|
-
else if (AGY_TOOLS.has(alt)) mapped.add(alt);
|
|
41
|
-
else if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
[
|
|
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 = {
|
|
134
|
-
|
|
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')) {
|