agy-plugin-porter 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arthur Cavallari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # agy-plugin-porter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
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.
12
+
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.
14
+
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).
16
+
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).
18
+
19
+ ---
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ npx agy-plugin-porter --source ./my-claude-plugin --out ./ported --validate
25
+ agy plugin install ./ported
26
+ ```
27
+
28
+ `--source` also takes a GitHub repo pinned to a tag, branch or commit: `owner/repo@<ref>`, plus `--path <subfolder>` for monorepos. `--install` runs `agy plugin install` on the output for you. Restart Antigravity afterwards so it discovers the new plugin directory.
29
+
30
+ ```
31
+ agy-porter --source <path|owner/repo@ref> [--path <dir>] [--slug <name>] [--out <dir>]
32
+ [--force] [--validate] [--install] [--agy <path>] [--json]
33
+ ```
34
+
35
+ Every run prints a **port report**:
36
+ - what was ported
37
+ - what wasn't, with the reason
38
+ - warnings
39
+ - static-audit flags
40
+ - an inferred capability tier, using the same `TIER_1_TELEMETRY` / `TIER_2_LOCAL_TOOLING` / `TIER_3_AGENT_CONTROL` meaning as Hangar
41
+
42
+ Nothing is dropped silently.
43
+
44
+ ## Requirements
45
+
46
+ - Node.js 20 or newer.
47
+ - `agy` on `PATH` for Claude Code and Gemini CLI sources. The porter stages them with `agy plugin install`, always against a **throwaway home** (`HOME`/`USERPROFILE` set to a temp dir), so your real Antigravity profile is never read or written. Cursor, Roo/Kilo and Codex sources don't need agy. `--validate` runs `agy plugin validate`, also in a throwaway home.
48
+
49
+ ## What gets ported
50
+
51
+ | Source | Becomes |
52
+ | :-- | :-- |
53
+ | Claude `hooks/hooks.json`, Gemini hooks | Native named-hook `hooks.json`. Events and tool matchers are remapped (`Bash` → `run_command`, `Edit\|Write` → …), and every handler runs through `scripts/hook-shim.mjs`. |
54
+ | Claude/Gemini commands | Skills with a proper `name`. Claude-only frontmatter is stripped, and argument placeholders are rewritten. |
55
+ | `.mcp.json`, inline `mcpServers`, extension MCP | `mcp_config.json`, with `${CLAUDE_PLUGIN_ROOT}` / `${extensionPath}` → `${PLUGIN_ROOT}` |
56
+ | Claude `agents/*.md` | agy agents, with agy tool names and model tiers |
57
+ | Roo/Kilo `.roomodes` | agy agents. Tool groups are mapped, and mode rules are folded into the body. |
58
+ | `CLAUDE.md`, `GEMINI.md`, Codex `AGENTS.md` | `rules/AGENTS.md` |
59
+ | Cursor `.mdc` | Always-on rules, `model_decision` rules, or skills (glob-scoped and manual rules) |
60
+ | `.cursorrules`, Roo/Cline/Kilo rules | Always-on rules |
61
+ | Codex `.agents/skills` | Skills |
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). |
63
+
64
+ ### The hook shim
65
+
66
+ agy's hook contract differs from Claude Code's and Gemini CLI's: camelCase payloads, a JSON decision on stdout, and fail-closed on any nonzero exit. The shim ships inside each ported plugin and bridges the two:
67
+ - **Payload:** translates it to the source's snake_case shape, with the source's tool names and argument names.
68
+ - **Plugin paths:** expands `${CLAUDE_PLUGIN_ROOT}` and friends.
69
+ - **Results:**
70
+ - exit 2 → `{"decision":"deny","reason":<stderr>}`
71
+ - plain SessionStart text → injected context
72
+ - other failures → allow plus a warning, matching the source's non-blocking semantics
73
+
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.
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
+
90
+ ### Static audit
91
+
92
+ Before writing the output, the porter:
93
+ - strips Unicode tag characters (U+E0000–E007F)
94
+ - flags `curl | sh`-style pipes, decode-and-execute patterns, encoded PowerShell, bidi controls and high-entropy strings
95
+ - checks agy's 24 KB per-rule and 20k-token always-on rules budgets
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
+
124
+ ## Library
125
+
126
+ ```js
127
+ import { portPackage } from 'agy-plugin-porter';
128
+
129
+ const { report, tier } = await portPackage({
130
+ source: 'owner/repo@v1.2.3', // or srcDir: '/path/to/plugin'
131
+ outDir: '/tmp/ported',
132
+ slug: 'my-plugin',
133
+ validate: true,
134
+ });
135
+ ```
136
+
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`.
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ npm test # unit + end-to-end tests over test/fixtures/
143
+ npm run test:pack # packs the tarball, installs it in an empty project, and runs it
144
+ ```
145
+
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`).
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
+
154
+ ## License
155
+
156
+ [MIT](LICENSE)
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ // agy-porter --source <path|owner/repo@ref> [--path <dir>] [--slug <s>] [--out <dir>]
3
+ // [--force] [--validate] [--install] [--json]
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+
9
+ import { portPackage } from '../lib/index.mjs';
10
+ import { spawnAgy } from '../lib/transpiler-engine.mjs';
11
+
12
+ const USAGE = `Usage: agy-porter --source <path|owner/repo@ref> [options]
13
+
14
+ Ports a Claude Code, Gemini CLI, Cursor, Roo/Kilo or Codex plugin into a native
15
+ Antigravity plugin and prints a port report.
16
+
17
+ Options:
18
+ --source <spec> local folder, or GitHub owner/repo[@tag|branch|sha]
19
+ --path <dir> subfolder of the source that holds the plugin
20
+ --slug <name> plugin name in Antigravity (default: the source's name)
21
+ --out <dir> where to write the ported plugin (required unless --install)
22
+ --force replace a non-empty --out
23
+ --validate run \`agy plugin validate\` on the output (throwaway home)
24
+ --install install the output with \`agy plugin install\` (your real profile)
25
+ --agy <path> agy executable (default: agy)
26
+ --json print the report as JSON
27
+ -h, --help show this help`;
28
+
29
+ function printReport(report) {
30
+ const lines = [`agy-porter: ${report.slug} (${report.ecosystems.join(' + ')}), ${report.tier}`];
31
+ if (report.sourceCommit) lines.push(` source commit ${report.sourceCommit}`);
32
+ lines.push(` ported (${report.ported.length}):`, ...report.ported.map((p) => ` ✔ ${p}`));
33
+ if (report.unported.length) lines.push(` unported (${report.unported.length}):`, ...report.unported.map((u) => ` ✗ ${u.item}: ${u.reason}`));
34
+ if (report.warnings.length) lines.push(` warnings (${report.warnings.length}):`, ...report.warnings.map((w) => ` ⚠ ${w}`));
35
+ if (report.flags.length) {
36
+ lines.push(` audit flags (${report.flags.length}):`,
37
+ ...report.flags.map((f) => ` ! [${f.severity}] ${f.kind}${f.file ? ` ${f.file}${f.line ? `:${f.line}` : ''}` : ''}${f.excerpt ? ` ${f.excerpt}` : ''}`));
38
+ }
39
+ if (report.tierReasons.length) lines.push(` tier because: ${report.tierReasons.join('; ')}`);
40
+ if (report.validation) {
41
+ const v = report.validation;
42
+ lines.push(` agy plugin validate: ${v.ok ? 'passed' : 'FAILED'} (${v.errors.length} errors, ${v.warnings.length} warnings)`);
43
+ for (const f of [...v.errors, ...v.warnings]) lines.push(` ${f.severity === 'error' ? '✗' : '⚠'} ${f.file ?? ''}: ${f.message}`);
44
+ if (v.output) lines.push(` ${v.output}`);
45
+ }
46
+ console.log(lines.join('\n'));
47
+ }
48
+
49
+ async function main() {
50
+ let opts;
51
+ try {
52
+ ({ values: opts } = parseArgs({
53
+ options: {
54
+ source: { type: 'string' }, path: { type: 'string' }, slug: { type: 'string' }, out: { type: 'string' },
55
+ force: { type: 'boolean' }, validate: { type: 'boolean' }, install: { type: 'boolean' },
56
+ agy: { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
57
+ },
58
+ }));
59
+ } catch (err) {
60
+ console.error(`${err.message}\n\n${USAGE}`);
61
+ return 1;
62
+ }
63
+ if (opts.help) { console.log(USAGE); return 0; }
64
+ if (!opts.source || (!opts.out && !opts.install)) { console.error(USAGE); return 1; }
65
+
66
+ const outDir = opts.out ?? mkdtempSync(join(tmpdir(), 'agy-porter-out-'));
67
+ try {
68
+ const { report } = await portPackage({
69
+ source: opts.source, path: opts.path, outDir, slug: opts.slug, agy: opts.agy, force: opts.force, validate: opts.validate,
70
+ });
71
+ if (opts.json) console.log(JSON.stringify({ outDir: opts.out ?? null, ...report }, null, 2));
72
+ else {
73
+ printReport(report);
74
+ if (opts.out) console.log(` written to ${outDir}`);
75
+ }
76
+ if (report.validation && !report.validation.ok) return 2;
77
+ if (opts.install) {
78
+ const run = spawnAgy(opts.agy ?? 'agy', ['plugin', 'install', outDir], { stdio: 'inherit' });
79
+ if (run.status !== 0) return run.status ?? 1;
80
+ console.log('Installed. Restart Antigravity to load a new plugin directory.');
81
+ }
82
+ return 0;
83
+ } catch (err) {
84
+ console.error(`agy-porter: ${err.message}`);
85
+ return 1;
86
+ } finally {
87
+ if (!opts.out) rmSync(outDir, { recursive: true, force: true });
88
+ }
89
+ }
90
+
91
+ process.exitCode = await main();
@@ -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
+ }
@@ -0,0 +1,92 @@
1
+ // Works out which plugin ecosystem(s) a source directory contains, where its
2
+ // hook configs live, and which of those are Claude Code UI mods.
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+
6
+ const MARKERS = {
7
+ claude: ['.claude-plugin/plugin.json'],
8
+ gemini: ['gemini-extension.json'],
9
+ cursor: ['.cursor/rules', '.cursorrules'],
10
+ roo: ['.roomodes', '.kilocodemodes'],
11
+ codex: ['.agents/skills'],
12
+ };
13
+
14
+ export function readJson(file) {
15
+ const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
16
+ return JSON.parse(text);
17
+ }
18
+
19
+ const toPosix = (p) => p.split('\\').join('/');
20
+
21
+ // A Claude Code UI mod uses the classic hooks file name with a `modules` key.
22
+ export function isUiMod(config) {
23
+ return Boolean(config) && typeof config === 'object' && Array.isArray(config.modules);
24
+ }
25
+
26
+ // Hook configs a Claude plugin can declare: hooks/hooks.json, plus the
27
+ // manifest's `hooks` field (a path, a list of paths, or an inline object).
28
+ function claudeHookSources(srcDir, manifest) {
29
+ const sources = [];
30
+ const seen = new Set();
31
+ const addFile = (rel) => {
32
+ const file = join(srcDir, rel);
33
+ const key = toPosix(relative(srcDir, file));
34
+ if (seen.has(key) || !existsSync(file)) return;
35
+ seen.add(key);
36
+ let config = null;
37
+ let error = null;
38
+ try { config = readJson(file); } catch (err) { error = err.message; }
39
+ sources.push({ path: key, config, error });
40
+ };
41
+ addFile('hooks/hooks.json');
42
+ const declared = manifest?.hooks;
43
+ if (typeof declared === 'string') addFile(declared);
44
+ else if (Array.isArray(declared)) declared.filter((p) => typeof p === 'string').forEach(addFile);
45
+ else if (declared && typeof declared === 'object') {
46
+ sources.push({ path: '.claude-plugin/plugin.json#hooks', config: declared, error: null });
47
+ }
48
+ return sources;
49
+ }
50
+
51
+ export function detect(srcDir) {
52
+ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
53
+ throw new Error(`not a directory: ${srcDir}`);
54
+ }
55
+ const ecosystems = Object.keys(MARKERS)
56
+ .filter((eco) => MARKERS[eco].some((m) => existsSync(join(srcDir, m))));
57
+ if (ecosystems.length === 0 && existsSync(join(srcDir, 'plugin.json'))) ecosystems.push('antigravity');
58
+ // Claude Code's manifest is optional: marketplace entries can declare a
59
+ // plugin that only has the default component folders.
60
+ const manifestless = ecosystems.length === 0 && hasClaudeComponents(srcDir);
61
+ if (manifestless) ecosystems.push('claude');
62
+
63
+ const hookSources = [];
64
+ if (ecosystems.includes('claude')) {
65
+ let manifest = null;
66
+ try { manifest = readJson(join(srcDir, '.claude-plugin/plugin.json')); } catch {}
67
+ hookSources.push(...claudeHookSources(srcDir, manifest).map((s) => ({ ...s, dialect: 'claude' })));
68
+ }
69
+ if (ecosystems.includes('gemini') && existsSync(join(srcDir, 'hooks/hooks.json'))
70
+ && !hookSources.some((s) => s.path === 'hooks/hooks.json')) {
71
+ let config = null;
72
+ let error = null;
73
+ try { config = readJson(join(srcDir, 'hooks/hooks.json')); } catch (err) { error = err.message; }
74
+ hookSources.push({ path: 'hooks/hooks.json', config, error, dialect: 'gemini' });
75
+ }
76
+
77
+ const uiMods = hookSources
78
+ .filter((s) => isUiMod(s.config))
79
+ .map((s) => ({ path: s.path, modules: s.config.modules, hasHooks: Boolean(s.config.hooks) }));
80
+
81
+ return { ecosystems, hookSources, uiMods, manifestless };
82
+ }
83
+
84
+ function hasClaudeComponents(srcDir) {
85
+ const list = (rel) => (existsSync(join(srcDir, rel)) && statSync(join(srcDir, rel)).isDirectory() ? readdirSync(join(srcDir, rel)) : []);
86
+ return list('commands').some((f) => f.endsWith('.md'))
87
+ || list('agents').some((f) => f.endsWith('.md'))
88
+ || list('skills').some((d) => existsSync(join(srcDir, 'skills', d, 'SKILL.md')))
89
+ || existsSync(join(srcDir, 'hooks/hooks.json'))
90
+ || existsSync(join(srcDir, '.mcp.json'))
91
+ || existsSync(join(srcDir, 'mcp.json'));
92
+ }