agy-plugin-porter 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +100 -2
- package/bin/agy-porter.mjs +91 -0
- package/lib/detector.mjs +91 -0
- package/lib/hook-compiler.mjs +172 -0
- package/lib/index.mjs +29 -0
- package/lib/manifest-sanitizer.mjs +151 -0
- package/lib/mode-compiler.mjs +185 -0
- package/lib/rules-compiler.mjs +144 -0
- package/lib/skill-compiler.mjs +132 -0
- package/lib/source.mjs +58 -0
- package/lib/static-auditor.mjs +140 -0
- package/lib/tool-map.mjs +47 -0
- package/lib/transpiler-engine.mjs +158 -0
- package/lib/yaml-lite.mjs +190 -0
- package/package.json +62 -4
- package/scripts/hook-shim.mjs +244 -0
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,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agy-plugin-porter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 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
|
+
|
|
5
|
+
`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
|
+
|
|
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.
|
|
8
|
+
|
|
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).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx agy-plugin-porter --source ./my-claude-plugin --out ./ported --validate
|
|
17
|
+
agy plugin install ./ported
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`--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.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
agy-porter --source <path|owner/repo@ref> [--path <dir>] [--slug <name>] [--out <dir>]
|
|
24
|
+
[--force] [--validate] [--install] [--agy <path>] [--json]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every run prints a **port report**:
|
|
28
|
+
- what was ported
|
|
29
|
+
- what wasn't, with the reason
|
|
30
|
+
- warnings
|
|
31
|
+
- static-audit flags
|
|
32
|
+
- an inferred capability tier, using the same `TIER_1_TELEMETRY` / `TIER_2_LOCAL_TOOLING` / `TIER_3_AGENT_CONTROL` meaning as Hangar
|
|
33
|
+
|
|
34
|
+
Nothing is dropped silently.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Node.js 20 or newer.
|
|
39
|
+
- `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.
|
|
40
|
+
|
|
41
|
+
## What gets ported
|
|
42
|
+
|
|
43
|
+
| Source | Becomes |
|
|
44
|
+
| :-- | :-- |
|
|
45
|
+
| 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`. |
|
|
46
|
+
| Claude/Gemini commands | Skills with a proper `name`. Claude-only frontmatter is stripped, and argument placeholders are rewritten. |
|
|
47
|
+
| `.mcp.json`, inline `mcpServers`, extension MCP | `mcp_config.json`, with `${CLAUDE_PLUGIN_ROOT}` / `${extensionPath}` → `${PLUGIN_ROOT}` |
|
|
48
|
+
| Claude `agents/*.md` | agy agents, with agy tool names and model tiers |
|
|
49
|
+
| Roo/Kilo `.roomodes` | agy agents. Tool groups are mapped, and mode rules are folded into the body. |
|
|
50
|
+
| `CLAUDE.md`, `GEMINI.md`, Codex `AGENTS.md` | `rules/AGENTS.md` |
|
|
51
|
+
| Cursor `.mdc` | Always-on rules, `model_decision` rules, or skills (glob-scoped and manual rules) |
|
|
52
|
+
| `.cursorrules`, Roo/Cline/Kilo rules | Always-on rules |
|
|
53
|
+
| Codex `.agents/skills` | Skills |
|
|
54
|
+
| Claude Code UI mods (`hooks.json` with `modules`) | Detected and reported as unported. Nothing is copied or run. |
|
|
55
|
+
|
|
56
|
+
### The hook shim
|
|
57
|
+
|
|
58
|
+
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:
|
|
59
|
+
- **Payload:** translates it to the source's snake_case shape, with the source's tool names and argument names.
|
|
60
|
+
- **Plugin paths:** expands `${CLAUDE_PLUGIN_ROOT}` and friends.
|
|
61
|
+
- **Results:**
|
|
62
|
+
- exit 2 → `{"decision":"deny","reason":<stderr>}`
|
|
63
|
+
- plain SessionStart text → injected context
|
|
64
|
+
- other failures → allow plus a warning, matching the source's non-blocking semantics
|
|
65
|
+
|
|
66
|
+
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
|
+
|
|
68
|
+
### Static audit
|
|
69
|
+
|
|
70
|
+
Before writing the output, the porter:
|
|
71
|
+
- strips Unicode tag characters (U+E0000–E007F)
|
|
72
|
+
- flags `curl | sh`-style pipes, decode-and-execute patterns, encoded PowerShell, bidi controls and high-entropy strings
|
|
73
|
+
- checks agy's 24 KB per-rule and 20k-token always-on rules budgets
|
|
74
|
+
|
|
75
|
+
## Library
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { portPackage } from 'agy-plugin-porter';
|
|
79
|
+
|
|
80
|
+
const { report, tier } = await portPackage({
|
|
81
|
+
source: 'owner/repo@v1.2.3', // or srcDir: '/path/to/plugin'
|
|
82
|
+
outDir: '/tmp/ported',
|
|
83
|
+
slug: 'my-plugin',
|
|
84
|
+
validate: true,
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Subpath exports cover the individual stages: `./detector`, `./hook-compiler`, `./manifest-sanitizer`, `./skill-compiler`, `./rules-compiler`, `./mode-compiler`, `./static-auditor` and `./transpiler-engine`.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm test # unit + end-to-end tests over test/fixtures/
|
|
94
|
+
npm run test:pack # packs the tarball, installs it in an empty project, and runs it
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[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 { spawnSync } from 'node:child_process';
|
|
5
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
|
|
10
|
+
import { portPackage } from '../lib/index.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 = spawnSync(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();
|
package/lib/detector.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Compiles Claude Code / Gemini CLI hook configs into Antigravity's named-hook
|
|
2
|
+
// shape. Every handler is routed through scripts/hook-shim.mjs, which looks up
|
|
3
|
+
// the original command in scripts/hook-shim.json. The original command never
|
|
4
|
+
// goes on agy's command line, so agy's Windows quote mangling can't reach it.
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, posix } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { isUiMod } from './detector.mjs';
|
|
10
|
+
import { AGY_TOOLS, TOOLS, unmappedToolReason } from './tool-map.mjs';
|
|
11
|
+
|
|
12
|
+
export const SHIM_PATH = 'scripts/hook-shim.mjs';
|
|
13
|
+
export const SHIM_CONFIG_PATH = 'scripts/hook-shim.json';
|
|
14
|
+
const SHIM_SOURCE = fileURLToPath(new URL('../scripts/hook-shim.mjs', import.meta.url));
|
|
15
|
+
|
|
16
|
+
// Events agy has, keyed by source event name. Matcher-grouped events first.
|
|
17
|
+
const GROUPED = new Set(['PreToolUse', 'PostToolUse']);
|
|
18
|
+
const EVENTS = {
|
|
19
|
+
claude: { PreToolUse: 'PreToolUse', PostToolUse: 'PostToolUse', PostToolUseFailure: 'PostToolUse', SessionStart: 'SessionStart', Stop: 'Stop' },
|
|
20
|
+
gemini: { BeforeTool: 'PreToolUse', AfterTool: 'PostToolUse', SessionStart: 'SessionStart' },
|
|
21
|
+
};
|
|
22
|
+
const UNPORTED_EVENTS = {
|
|
23
|
+
UserPromptSubmit: 'agy has no user-prompt event (PreInvocation runs before every model call and carries no prompt)',
|
|
24
|
+
BeforeAgent: 'agy has no user-prompt event (PreInvocation runs before every model call and carries no prompt)',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const REGEX_META = /[.*+?^${}()[\]\\]/;
|
|
28
|
+
|
|
29
|
+
// Rewrites a source matcher into an agy matcher. Returns the agy matcher (or
|
|
30
|
+
// null when nothing maps) and the alternatives that didn't map.
|
|
31
|
+
export function remapMatcher(matcher, dialect = 'claude') {
|
|
32
|
+
const raw = (matcher ?? '').trim();
|
|
33
|
+
if (raw === '' || raw === '*' || raw === '.*') return { matcher: '*', unmapped: [] };
|
|
34
|
+
const map = TOOLS[dialect];
|
|
35
|
+
const alternatives = raw.includes('(') ? [raw] : raw.split('|').map((a) => a.trim()).filter(Boolean);
|
|
36
|
+
const mapped = new Set();
|
|
37
|
+
const unmapped = [];
|
|
38
|
+
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)) {
|
|
42
|
+
let re;
|
|
43
|
+
try { re = new RegExp(`^(?:${alt})$`); } catch { unmapped.push(alt); continue; }
|
|
44
|
+
const hits = Object.keys(map).filter((name) => re.test(name));
|
|
45
|
+
if (hits.length === 0) unmapped.push(alt);
|
|
46
|
+
hits.forEach((name) => map[name].forEach((t) => mapped.add(t)));
|
|
47
|
+
} else unmapped.push(alt);
|
|
48
|
+
}
|
|
49
|
+
return { matcher: mapped.size ? [...mapped].sort().join('|') : null, unmapped };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Claude SessionStart matchers name the session source; agy only fires on new sessions.
|
|
53
|
+
function sessionStartFilter(matcher) {
|
|
54
|
+
const raw = (matcher ?? '').trim();
|
|
55
|
+
if (raw === '' || raw === '*') return { ok: true };
|
|
56
|
+
const sources = raw.split('|').map((s) => s.trim());
|
|
57
|
+
if (!sources.includes('startup')) return { ok: false };
|
|
58
|
+
return { ok: true, dropped: sources.filter((s) => s !== 'startup') };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Seconds the shim gives the script. Claude counts seconds, Gemini milliseconds.
|
|
62
|
+
function scriptTimeout(handler, dialect) {
|
|
63
|
+
const t = Number(handler.timeout);
|
|
64
|
+
if (!Number.isFinite(t) || t <= 0) return 60;
|
|
65
|
+
return dialect === 'gemini' ? Math.max(1, Math.ceil(t / 1000)) : t;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function compileHooks(sources, { slug }) {
|
|
69
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug ?? '')) throw new Error(`invalid slug: ${slug}`);
|
|
70
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
71
|
+
const events = {};
|
|
72
|
+
const shimHooks = {};
|
|
73
|
+
const counters = {};
|
|
74
|
+
const warnOnce = (text) => { if (!report.warnings.includes(text)) report.warnings.push(text); };
|
|
75
|
+
|
|
76
|
+
for (const source of sources) {
|
|
77
|
+
const dialect = source.dialect ?? 'claude';
|
|
78
|
+
if (source.error) {
|
|
79
|
+
report.unported.push({ item: `hooks:${source.path}`, reason: `can't parse: ${source.error}` });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
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
|
+
}
|
|
90
|
+
for (const key of Object.keys(config)) {
|
|
91
|
+
if (!['hooks', 'modules', 'description'].includes(key)) warnOnce(`${source.path}: ignored top-level key "${key}"`);
|
|
92
|
+
}
|
|
93
|
+
const sourceEvents = config.hooks ?? {};
|
|
94
|
+
for (const [sourceEvent, groups] of Object.entries(sourceEvents)) {
|
|
95
|
+
const event = EVENTS[dialect][sourceEvent];
|
|
96
|
+
if (!event) {
|
|
97
|
+
report.unported.push({
|
|
98
|
+
item: `hook:${sourceEvent}`,
|
|
99
|
+
reason: UNPORTED_EVENTS[sourceEvent] ?? `agy has no ${sourceEvent} event`,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
(Array.isArray(groups) ? groups : []).forEach((group, gi) => {
|
|
104
|
+
const label = `hook:${sourceEvent}#${gi}${group.matcher ? ` (${group.matcher})` : ''}`;
|
|
105
|
+
let matcher;
|
|
106
|
+
if (GROUPED.has(event)) {
|
|
107
|
+
const remap = remapMatcher(group.matcher, dialect);
|
|
108
|
+
for (const name of remap.unmapped) {
|
|
109
|
+
report.unported.push({ item: `${label} matcher ${name}`, reason: unmappedToolReason(name) });
|
|
110
|
+
}
|
|
111
|
+
if (!remap.matcher) return;
|
|
112
|
+
matcher = remap.matcher;
|
|
113
|
+
} else if (event === 'SessionStart' && dialect === 'claude') {
|
|
114
|
+
const filter = sessionStartFilter(group.matcher);
|
|
115
|
+
if (!filter.ok) {
|
|
116
|
+
report.unported.push({ item: label, reason: 'agy SessionStart fires only on new sessions (startup)' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (filter.dropped?.length) warnOnce(`${label}: fires on startup only; ${filter.dropped.join(', ')} have no agy equivalent`);
|
|
120
|
+
}
|
|
121
|
+
const handlers = [];
|
|
122
|
+
(Array.isArray(group.hooks) ? group.hooks : []).forEach((handler, hi) => {
|
|
123
|
+
const item = `${label}.${hi}`;
|
|
124
|
+
if ((handler.type ?? 'command') !== 'command' || typeof handler.command !== 'string' || !handler.command.trim()) {
|
|
125
|
+
report.unported.push({ item, reason: `only command hooks can be ported (type: ${handler.type ?? 'none'})` });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
counters[event] = (counters[event] ?? 0) + 1;
|
|
129
|
+
const id = `${event.toLowerCase()}-${counters[event]}`;
|
|
130
|
+
const timeout = scriptTimeout(handler, dialect);
|
|
131
|
+
shimHooks[id] = { event, sourceEvent, dialect, command: handler.command, timeout };
|
|
132
|
+
handlers.push({ type: 'command', command: `node ${SHIM_PATH} --event ${event} --hook ${id}`, timeout: timeout + 5 });
|
|
133
|
+
report.ported.push(item);
|
|
134
|
+
});
|
|
135
|
+
if (handlers.length === 0) return;
|
|
136
|
+
events[event] ??= [];
|
|
137
|
+
// agy rejects the whole file if a non-tool event uses the grouped shape.
|
|
138
|
+
if (GROUPED.has(event)) events[event].push({ matcher, hooks: handlers });
|
|
139
|
+
else events[event].push(...handlers);
|
|
140
|
+
if (event === 'PostToolUse') warnOnce('PostToolUse: agy sends no tool result, so tool_response is empty and block feedback is dropped');
|
|
141
|
+
if (event === 'Stop') warnOnce('Stop: stop_hook_active is approximated from agy\'s executionNum');
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hasHooks = Object.keys(shimHooks).length > 0;
|
|
147
|
+
return {
|
|
148
|
+
hooks: hasHooks ? { [slug]: events } : null,
|
|
149
|
+
shimConfig: hasHooks ? { version: 1, hooks: shimHooks } : null,
|
|
150
|
+
report,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Writes the compiled hooks into a staged plugin dir: root hooks.json, the shim
|
|
155
|
+
// 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).
|
|
157
|
+
export function writeHookPort(pluginDir, compiled) {
|
|
158
|
+
const rootHooks = join(pluginDir, 'hooks.json');
|
|
159
|
+
const shimTarget = join(pluginDir, SHIM_PATH);
|
|
160
|
+
if (!compiled.hooks) {
|
|
161
|
+
rmSync(rootHooks, { force: true });
|
|
162
|
+
} else {
|
|
163
|
+
if (existsSync(shimTarget) && readFileSync(shimTarget, 'utf8') !== readFileSync(SHIM_SOURCE, 'utf8')) {
|
|
164
|
+
throw new Error(`${SHIM_PATH} already exists in the plugin and isn't the porter's shim`);
|
|
165
|
+
}
|
|
166
|
+
mkdirSync(dirname(shimTarget), { recursive: true });
|
|
167
|
+
copyFileSync(SHIM_SOURCE, shimTarget);
|
|
168
|
+
writeFileSync(join(pluginDir, SHIM_CONFIG_PATH), `${JSON.stringify(compiled.shimConfig, null, 2)}\n`);
|
|
169
|
+
writeFileSync(rootHooks, `${JSON.stringify(compiled.hooks, null, 2)}\n`);
|
|
170
|
+
}
|
|
171
|
+
rmSync(join(pluginDir, 'hooks', 'hooks.json'), { force: true });
|
|
172
|
+
}
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Public API of agy-plugin-porter.
|
|
2
|
+
import { resolveSource } from './source.mjs';
|
|
3
|
+
import { transpile } from './transpiler-engine.mjs';
|
|
4
|
+
|
|
5
|
+
export { detect } from './detector.mjs';
|
|
6
|
+
export { compileHooks, remapMatcher, writeHookPort } from './hook-compiler.mjs';
|
|
7
|
+
export { sanitizeManifest } from './manifest-sanitizer.mjs';
|
|
8
|
+
export { compileModes } from './mode-compiler.mjs';
|
|
9
|
+
export { compileRules } from './rules-compiler.mjs';
|
|
10
|
+
export { compileSkills } from './skill-compiler.mjs';
|
|
11
|
+
export { auditPlugin, inferTier, TIERS } from './static-auditor.mjs';
|
|
12
|
+
export { resolveSource } from './source.mjs';
|
|
13
|
+
export { transpile, validateWithAgy } from './transpiler-engine.mjs';
|
|
14
|
+
|
|
15
|
+
// portPackage({srcDir, outDir, slug}) -> {report, tier}. `srcDir` is a local path
|
|
16
|
+
// (or, with `source`, a path or owner/repo@ref to fetch first; `path` picks a
|
|
17
|
+
// subfolder). Pass `validate: true` to run `agy plugin validate` on the output.
|
|
18
|
+
export async function portPackage({ srcDir, source, path, outDir, slug, agy, force = false, validate = false }) {
|
|
19
|
+
if (!outDir) throw new Error('outDir is required');
|
|
20
|
+
const resolved = resolveSource(source ?? srcDir, { subPath: path });
|
|
21
|
+
try {
|
|
22
|
+
const report = transpile({ srcDir: resolved.dir, outDir, slug, agy, force, validate });
|
|
23
|
+
if (resolved.commit) report.sourceCommit = resolved.commit;
|
|
24
|
+
if (resolved.unpinned) report.warnings.push('source: no @ref given, so the port used the default branch; pin a tag or SHA for reproducible ports');
|
|
25
|
+
return { report, tier: report.tier };
|
|
26
|
+
} finally {
|
|
27
|
+
resolved.cleanup();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Writes the 7-key plugin.json and fixes MCP configs for agy:
|
|
2
|
+
// - rescues Claude's inline `mcpServers`, which `agy plugin install` drops
|
|
3
|
+
// - rewrites ${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PLUGIN_DATA} to ${PLUGIN_ROOT} /
|
|
4
|
+
// ${PLUGIN_DATA}, which agy expands in `args`, `env` and `cwd` (live-tested
|
|
5
|
+
// for args on 1.4.5)
|
|
6
|
+
// - turns absolute staging paths (agy expands Gemini's ${extensionPath} at
|
|
7
|
+
// install time) back into ${PLUGIN_ROOT}, so the output doesn't point into
|
|
8
|
+
// the throwaway staging home
|
|
9
|
+
// - drops the source manifests agy leaves behind
|
|
10
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { extname, isAbsolute, join, normalize } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { readJson } from './detector.mjs';
|
|
14
|
+
|
|
15
|
+
export const MANIFEST_KEYS = ['name', 'displayName', 'description', 'version', 'logo', 'suggestedPrompts', 'disabled'];
|
|
16
|
+
const LOGO_EXT = new Set(['.png', '.svg', '.jpg', '.jpeg', '.webp']);
|
|
17
|
+
const SOURCE_ARTIFACTS = ['.claude-plugin', 'gemini-extension.json', '.mcp.json'];
|
|
18
|
+
|
|
19
|
+
function readIf(file) {
|
|
20
|
+
try { return existsSync(file) ? readJson(file) : null; } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sourceManifest(srcDir, ecosystems) {
|
|
24
|
+
if (ecosystems.includes('claude')) return readIf(join(srcDir, '.claude-plugin/plugin.json')) ?? {};
|
|
25
|
+
if (ecosystems.includes('gemini')) return readIf(join(srcDir, 'gemini-extension.json')) ?? {};
|
|
26
|
+
return readIf(join(srcDir, 'plugin.json')) ?? {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildManifest(source, { slug, ecosystems, pluginDir, report }) {
|
|
30
|
+
const out = { name: slug };
|
|
31
|
+
if (typeof source.displayName === 'string') out.displayName = source.displayName;
|
|
32
|
+
else if (typeof source.name === 'string' && source.name !== slug) out.displayName = source.name;
|
|
33
|
+
out.description = typeof source.description === 'string' && source.description.trim()
|
|
34
|
+
? source.description.trim()
|
|
35
|
+
: `Ported from ${ecosystems.join(' + ')} by agy-plugin-porter.`;
|
|
36
|
+
if (typeof source.version === 'string') out.version = source.version;
|
|
37
|
+
if (typeof source.logo === 'string') {
|
|
38
|
+
const ok = !isAbsolute(source.logo) && !/^[a-z]+:/i.test(source.logo) && LOGO_EXT.has(extname(source.logo).toLowerCase())
|
|
39
|
+
&& existsSync(join(pluginDir, source.logo));
|
|
40
|
+
if (ok) out.logo = source.logo;
|
|
41
|
+
else report.warnings.push(`plugin.json: dropped logo "${source.logo}" (must be a relative .png/.svg/.jpg/.jpeg/.webp in the plugin)`);
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(source.suggestedPrompts)) {
|
|
44
|
+
const prompts = source.suggestedPrompts.filter((p) => typeof p === 'string' && p.trim()).slice(0, 3);
|
|
45
|
+
if (prompts.length) out.suggestedPrompts = prompts;
|
|
46
|
+
}
|
|
47
|
+
if (typeof source.disabled === 'boolean') out.disabled = source.disabled;
|
|
48
|
+
// Keys the porter consumes elsewhere aren't "dropped".
|
|
49
|
+
const consumed = new Set([...MANIFEST_KEYS, 'mcpServers', 'hooks', 'contextFileName', 'types', '$schema']);
|
|
50
|
+
const dropped = Object.keys(source).filter((k) => !consumed.has(k));
|
|
51
|
+
if (dropped.length) report.warnings.push(`plugin.json: dropped ${dropped.map((k) => `"${k}"`).join(', ')} (agy reads only ${MANIFEST_KEYS.join(', ')})`);
|
|
52
|
+
for (const key of ['commands', 'agents', 'skills', 'outputStyles']) {
|
|
53
|
+
if (key in source && typeof source[key] !== 'boolean') {
|
|
54
|
+
report.warnings.push(`plugin.json: custom "${key}" paths aren't followed; only the default folders are ported`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Inline servers in Claude's manifest: an object, or a path to a JSON file.
|
|
61
|
+
function inlineServers(source, srcDir, report) {
|
|
62
|
+
const declared = source.mcpServers;
|
|
63
|
+
if (!declared) return {};
|
|
64
|
+
if (typeof declared === 'object' && !Array.isArray(declared)) return declared;
|
|
65
|
+
if (typeof declared === 'string') {
|
|
66
|
+
const file = readIf(join(srcDir, declared));
|
|
67
|
+
if (file) return file.mcpServers ?? file;
|
|
68
|
+
report.unported.push({ item: `mcp:${declared}`, reason: "can't read the MCP config the manifest points to" });
|
|
69
|
+
}
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ROOT_VARS = [
|
|
74
|
+
[/\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT\b/g, '${PLUGIN_ROOT}'],
|
|
75
|
+
[/\$\{CLAUDE_PLUGIN_DATA\}|\$CLAUDE_PLUGIN_DATA\b/g, '${PLUGIN_DATA}'],
|
|
76
|
+
[/\$\{extensionPath\}/g, '${PLUGIN_ROOT}'],
|
|
77
|
+
[/\$\{\/\}/g, '/'],
|
|
78
|
+
];
|
|
79
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
+
|
|
81
|
+
function rewriteString(value, stagedPaths) {
|
|
82
|
+
let out = value;
|
|
83
|
+
for (const [re, to] of ROOT_VARS) out = out.replace(re, to);
|
|
84
|
+
for (const staged of stagedPaths) {
|
|
85
|
+
for (const form of new Set([staged, staged.replace(/\\/g, '/'), staged.replace(/\//g, '\\')])) {
|
|
86
|
+
out = out.replace(new RegExp(escapeRe(form), process.platform === 'win32' ? 'gi' : 'g'), '${PLUGIN_ROOT}');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PLACEHOLDER = /\$\{(?!PLUGIN_ROOT\}|PLUGIN_DATA\})([^}]+)\}/g;
|
|
93
|
+
|
|
94
|
+
export function fixMcpServers(servers, { stagedPaths = [], report }) {
|
|
95
|
+
const fixed = {};
|
|
96
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
97
|
+
if (!server || typeof server !== 'object') continue;
|
|
98
|
+
const s = structuredClone(server);
|
|
99
|
+
if (Array.isArray(s.args)) s.args = s.args.map((a) => (typeof a === 'string' ? rewriteString(a, stagedPaths) : a));
|
|
100
|
+
if (typeof s.cwd === 'string') s.cwd = rewriteString(s.cwd, stagedPaths);
|
|
101
|
+
if (s.env && typeof s.env === 'object') {
|
|
102
|
+
s.env = Object.fromEntries(Object.entries(s.env).map(([k, v]) => [k, typeof v === 'string' ? rewriteString(v, stagedPaths) : v]));
|
|
103
|
+
}
|
|
104
|
+
if (typeof s.command === 'string') {
|
|
105
|
+
const command = rewriteString(s.command, stagedPaths);
|
|
106
|
+
if (command.includes('${PLUGIN_ROOT}') || command.includes('${PLUGIN_DATA}')) {
|
|
107
|
+
report.unported.push({ item: `mcp:${name}`, reason: 'the command uses the plugin path, which agy expands only in args, env and cwd' });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
s.command = command;
|
|
111
|
+
}
|
|
112
|
+
const others = new Set();
|
|
113
|
+
for (const text of [s.command, s.url, ...(s.args ?? []), ...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})]) {
|
|
114
|
+
if (typeof text === 'string') for (const m of text.matchAll(PLACEHOLDER)) others.add(m[1]);
|
|
115
|
+
}
|
|
116
|
+
if (others.size) {
|
|
117
|
+
report.warnings.push(`mcp:${name}: ${[...others].map((v) => `\${${v}}`).join(', ')} is passed literally unless declared in agy's "variables" (format unverified)`);
|
|
118
|
+
}
|
|
119
|
+
fixed[name] = s;
|
|
120
|
+
report.ported.push(`mcp:${name}`);
|
|
121
|
+
}
|
|
122
|
+
return fixed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Rewrites plugin.json and mcp_config.json in a staged plugin dir.
|
|
126
|
+
export function sanitizeManifest(pluginDir, { srcDir, ecosystems, slug, stagedPaths = [] }) {
|
|
127
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
128
|
+
const source = sourceManifest(srcDir, ecosystems);
|
|
129
|
+
writeFileSync(join(pluginDir, 'plugin.json'), `${JSON.stringify(buildManifest(source, { slug, ecosystems, pluginDir, report }), null, 2)}\n`);
|
|
130
|
+
report.ported.push('manifest');
|
|
131
|
+
|
|
132
|
+
const configFile = join(pluginDir, 'mcp_config.json');
|
|
133
|
+
const servers = { ...(readIf(configFile)?.mcpServers ?? {}) };
|
|
134
|
+
if (ecosystems.includes('claude')) {
|
|
135
|
+
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;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (ecosystems.includes('gemini')) {
|
|
141
|
+
for (const key of ['excludeTools', 'settings']) {
|
|
142
|
+
if (source[key]) report.unported.push({ item: `gemini:${key}`, reason: `agy has no equivalent of gemini-extension.json "${key}"` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const fixed = fixMcpServers(servers, { stagedPaths: stagedPaths.map(normalize), report });
|
|
146
|
+
if (Object.keys(fixed).length) writeFileSync(configFile, `${JSON.stringify({ mcpServers: fixed }, null, 2)}\n`);
|
|
147
|
+
else rmSync(configFile, { force: true });
|
|
148
|
+
|
|
149
|
+
for (const artifact of SOURCE_ARTIFACTS) rmSync(join(pluginDir, artifact), { recursive: true, force: true });
|
|
150
|
+
return report;
|
|
151
|
+
}
|