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 +21 -0
- package/README.md +155 -2
- package/bin/agy-porter.mjs +91 -0
- package/lib/consent.mjs +122 -0
- package/lib/detector.mjs +92 -0
- package/lib/hook-compiler.mjs +208 -0
- package/lib/index.mjs +32 -0
- package/lib/manifest-sanitizer.mjs +187 -0
- 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 +195 -0
- package/lib/rules-compiler.mjs +205 -0
- package/lib/skill-compiler.mjs +135 -0
- package/lib/source.mjs +81 -0
- package/lib/static-auditor.mjs +179 -0
- package/lib/tool-map.mjs +46 -0
- package/lib/transpiler-engine.mjs +190 -0
- package/lib/yaml-lite.mjs +222 -0
- package/package.json +73 -4
- package/scripts/hook-shim.mjs +418 -0
- 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
|
@@ -0,0 +1,208 @@
|
|
|
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 } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { AGY_TOOLS, TOOLS, unmappedToolReason } from './tool-map.mjs';
|
|
10
|
+
|
|
11
|
+
export const SHIM_PATH = 'scripts/hook-shim.mjs';
|
|
12
|
+
export const SHIM_CONFIG_PATH = 'scripts/hook-shim.json';
|
|
13
|
+
const SHIM_SOURCE = fileURLToPath(new URL('../scripts/hook-shim.mjs', import.meta.url));
|
|
14
|
+
|
|
15
|
+
// Events agy has, keyed by source event name. Matcher-grouped events first.
|
|
16
|
+
const GROUPED = new Set(['PreToolUse', 'PostToolUse']);
|
|
17
|
+
const EVENTS = {
|
|
18
|
+
claude: { PreToolUse: 'PreToolUse', PostToolUse: 'PostToolUse', PostToolUseFailure: 'PostToolUse', SessionStart: 'SessionStart', Stop: 'Stop' },
|
|
19
|
+
gemini: { BeforeTool: 'PreToolUse', AfterTool: 'PostToolUse', SessionStart: 'SessionStart' },
|
|
20
|
+
};
|
|
21
|
+
const UNPORTED_EVENTS = {
|
|
22
|
+
UserPromptSubmit: 'agy has no user-prompt event (PreInvocation runs before every model call and carries no prompt)',
|
|
23
|
+
BeforeAgent: 'agy has no user-prompt event (PreInvocation runs before every model call and carries no prompt)',
|
|
24
|
+
};
|
|
25
|
+
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
// Rewrites a source matcher into an agy matcher. Returns the agy matcher (or
|
|
53
|
+
// null when nothing maps) and the alternatives that didn't map.
|
|
54
|
+
export function remapMatcher(matcher, dialect = 'claude') {
|
|
55
|
+
const raw = (matcher ?? '').trim();
|
|
56
|
+
if (raw === '' || raw === '*' || raw === '.*') return { matcher: '*', unmapped: [] };
|
|
57
|
+
const map = TOOLS[dialect];
|
|
58
|
+
const alternatives = splitTopLevelAlternatives(raw);
|
|
59
|
+
const mapped = new Set();
|
|
60
|
+
const unmapped = [];
|
|
61
|
+
for (const alt of alternatives) {
|
|
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)) {
|
|
73
|
+
let re;
|
|
74
|
+
try { re = new RegExp(`^(?:${alt})$`); } catch { unmapped.push(alt); continue; }
|
|
75
|
+
const hits = Object.keys(map).filter((name) => re.test(name));
|
|
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);
|
|
82
|
+
hits.forEach((name) => map[name].forEach((t) => mapped.add(t)));
|
|
83
|
+
} else unmapped.push(alt);
|
|
84
|
+
}
|
|
85
|
+
return { matcher: mapped.size ? [...mapped].sort().join('|') : null, unmapped };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Claude SessionStart matchers name the session source; agy only fires on new sessions.
|
|
89
|
+
function sessionStartFilter(matcher) {
|
|
90
|
+
const raw = (matcher ?? '').trim();
|
|
91
|
+
if (raw === '' || raw === '*') return { ok: true };
|
|
92
|
+
const sources = raw.split('|').map((s) => s.trim());
|
|
93
|
+
if (!sources.includes('startup')) return { ok: false };
|
|
94
|
+
return { ok: true, dropped: sources.filter((s) => s !== 'startup') };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Seconds the shim gives the script. Claude counts seconds, Gemini milliseconds.
|
|
98
|
+
function scriptTimeout(handler, dialect) {
|
|
99
|
+
const t = Number(handler.timeout);
|
|
100
|
+
if (!Number.isFinite(t) || t <= 0) return 60;
|
|
101
|
+
return dialect === 'gemini' ? Math.max(1, Math.ceil(t / 1000)) : t;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function compileHooks(sources, { slug }) {
|
|
105
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug ?? '')) throw new Error(`invalid slug: ${slug}`);
|
|
106
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
107
|
+
const events = {};
|
|
108
|
+
const shimHooks = {};
|
|
109
|
+
const counters = {};
|
|
110
|
+
const warnOnce = (text) => { if (!report.warnings.includes(text)) report.warnings.push(text); };
|
|
111
|
+
|
|
112
|
+
for (const source of sources) {
|
|
113
|
+
const dialect = source.dialect ?? 'claude';
|
|
114
|
+
if (source.error) {
|
|
115
|
+
report.unported.push({ item: `hooks:${source.path}`, reason: `can't parse: ${source.error}` });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const config = source.config ?? {};
|
|
119
|
+
// A config's "modules" (Claude Code UI mods) are lib/mod-compiler.mjs's job.
|
|
120
|
+
for (const key of Object.keys(config)) {
|
|
121
|
+
if (!['hooks', 'modules', 'description'].includes(key)) warnOnce(`${source.path}: ignored top-level key "${key}"`);
|
|
122
|
+
}
|
|
123
|
+
const sourceEvents = config.hooks ?? {};
|
|
124
|
+
for (const [sourceEvent, groups] of Object.entries(sourceEvents)) {
|
|
125
|
+
if (UNSAFE_KEYS.has(sourceEvent)) continue;
|
|
126
|
+
const dialectEvents = EVENTS[dialect] ?? {};
|
|
127
|
+
const event = Object.hasOwn(dialectEvents, sourceEvent) ? dialectEvents[sourceEvent] : null;
|
|
128
|
+
if (!event) {
|
|
129
|
+
report.unported.push({
|
|
130
|
+
item: `hook:${sourceEvent}`,
|
|
131
|
+
reason: Object.hasOwn(UNPORTED_EVENTS, sourceEvent) ? UNPORTED_EVENTS[sourceEvent] : `agy has no ${sourceEvent} event`,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
(Array.isArray(groups) ? groups : []).forEach((group, gi) => {
|
|
136
|
+
const label = `hook:${sourceEvent}#${gi}${group.matcher ? ` (${group.matcher})` : ''}`;
|
|
137
|
+
let matcher;
|
|
138
|
+
if (GROUPED.has(event)) {
|
|
139
|
+
const remap = remapMatcher(group.matcher, dialect);
|
|
140
|
+
for (const name of remap.unmapped) {
|
|
141
|
+
report.unported.push({ item: `${label} matcher ${name}`, reason: unmappedToolReason(name) });
|
|
142
|
+
}
|
|
143
|
+
if (!remap.matcher) return;
|
|
144
|
+
matcher = remap.matcher;
|
|
145
|
+
} else if (event === 'SessionStart' && dialect === 'claude') {
|
|
146
|
+
const filter = sessionStartFilter(group.matcher);
|
|
147
|
+
if (!filter.ok) {
|
|
148
|
+
report.unported.push({ item: label, reason: 'agy SessionStart fires only on new sessions (startup)' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (filter.dropped?.length) warnOnce(`${label}: fires on startup only; ${filter.dropped.join(', ')} have no agy equivalent`);
|
|
152
|
+
}
|
|
153
|
+
const handlers = [];
|
|
154
|
+
(Array.isArray(group.hooks) ? group.hooks : []).forEach((handler, hi) => {
|
|
155
|
+
const item = `${label}.${hi}`;
|
|
156
|
+
if ((handler.type ?? 'command') !== 'command' || typeof handler.command !== 'string' || !handler.command.trim()) {
|
|
157
|
+
report.unported.push({ item, reason: `only command hooks can be ported (type: ${handler.type ?? 'none'})` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
counters[event] = (counters[event] ?? 0) + 1;
|
|
161
|
+
const id = `${event.toLowerCase()}-${counters[event]}`;
|
|
162
|
+
const timeout = scriptTimeout(handler, dialect);
|
|
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
|
+
};
|
|
168
|
+
handlers.push({ type: 'command', command: `node ${SHIM_PATH} --event ${event} --hook ${id}`, timeout: timeout + 5 });
|
|
169
|
+
report.ported.push(item);
|
|
170
|
+
});
|
|
171
|
+
if (handlers.length === 0) return;
|
|
172
|
+
events[event] ??= [];
|
|
173
|
+
// agy rejects the whole file if a non-tool event uses the grouped shape.
|
|
174
|
+
if (GROUPED.has(event)) events[event].push({ matcher, hooks: handlers });
|
|
175
|
+
else events[event].push(...handlers);
|
|
176
|
+
if (event === 'PostToolUse') warnOnce('PostToolUse: agy sends no tool result, so tool_response is empty and block feedback is dropped');
|
|
177
|
+
if (event === 'Stop') warnOnce('Stop: stop_hook_active is approximated from agy\'s executionNum');
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const hasHooks = Object.keys(shimHooks).length > 0;
|
|
183
|
+
return {
|
|
184
|
+
hooks: hasHooks ? { [slug]: events } : null,
|
|
185
|
+
shimConfig: hasHooks ? { version: 1, hooks: shimHooks } : null,
|
|
186
|
+
report,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Writes the compiled hooks into a staged plugin dir: root hooks.json, the shim
|
|
191
|
+
// and its config. Removes the foreign-shaped copies agy staged, including a
|
|
192
|
+
// UI mod's hooks/hooks.json (lib/mod-compiler.mjs writes a mod's hooks itself).
|
|
193
|
+
export function writeHookPort(pluginDir, compiled) {
|
|
194
|
+
const rootHooks = join(pluginDir, 'hooks.json');
|
|
195
|
+
const shimTarget = join(pluginDir, SHIM_PATH);
|
|
196
|
+
if (!compiled.hooks) {
|
|
197
|
+
rmSync(rootHooks, { force: true });
|
|
198
|
+
} else {
|
|
199
|
+
if (existsSync(shimTarget) && readFileSync(shimTarget, 'utf8') !== readFileSync(SHIM_SOURCE, 'utf8')) {
|
|
200
|
+
throw new Error(`${SHIM_PATH} already exists in the plugin and isn't the porter's shim`);
|
|
201
|
+
}
|
|
202
|
+
mkdirSync(dirname(shimTarget), { recursive: true });
|
|
203
|
+
copyFileSync(SHIM_SOURCE, shimTarget);
|
|
204
|
+
writeFileSync(join(pluginDir, SHIM_CONFIG_PATH), `${JSON.stringify(compiled.shimConfig, null, 2)}\n`);
|
|
205
|
+
writeFileSync(rootHooks, `${JSON.stringify(compiled.hooks, null, 2)}\n`);
|
|
206
|
+
}
|
|
207
|
+
rmSync(join(pluginDir, 'hooks', 'hooks.json'), { force: true });
|
|
208
|
+
}
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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 { createPreview, flagsDigest, installPreview, listPreviews, readPreview } from './consent.mjs';
|
|
7
|
+
export { compileHooks, remapMatcher, writeHookPort } from './hook-compiler.mjs';
|
|
8
|
+
export { sanitizeManifest } from './manifest-sanitizer.mjs';
|
|
9
|
+
export { compileMods } from './mod-compiler.mjs';
|
|
10
|
+
export { assess as assessMod, scanMod } from './mod-scan.mjs';
|
|
11
|
+
export { compileModes } from './mode-compiler.mjs';
|
|
12
|
+
export { compileRules } from './rules-compiler.mjs';
|
|
13
|
+
export { compileSkills } from './skill-compiler.mjs';
|
|
14
|
+
export { auditPlugin, inferTier, TIERS } from './static-auditor.mjs';
|
|
15
|
+
export { resolveSource } from './source.mjs';
|
|
16
|
+
export { transpile, validateWithAgy } from './transpiler-engine.mjs';
|
|
17
|
+
|
|
18
|
+
// portPackage({srcDir, outDir, slug}) -> {report, tier}. `srcDir` is a local path
|
|
19
|
+
// (or, with `source`, a path or owner/repo@ref to fetch first; `path` picks a
|
|
20
|
+
// subfolder). Pass `validate: true` to run `agy plugin validate` on the output.
|
|
21
|
+
export async function portPackage({ srcDir, source, path, outDir, slug, agy, force = false, validate = false }) {
|
|
22
|
+
if (!outDir) throw new Error('outDir is required');
|
|
23
|
+
const resolved = resolveSource(source ?? srcDir, { subPath: path });
|
|
24
|
+
try {
|
|
25
|
+
const report = transpile({ srcDir: resolved.dir, outDir, slug, agy, force, validate });
|
|
26
|
+
if (resolved.commit) report.sourceCommit = resolved.commit;
|
|
27
|
+
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');
|
|
28
|
+
return { report, tier: report.tier };
|
|
29
|
+
} finally {
|
|
30
|
+
resolved.cleanup();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
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', '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
|
+
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
|
+
|
|
70
|
+
// Inline servers in Claude's manifest: an object, or a path to a JSON file.
|
|
71
|
+
function inlineServers(source, srcDir, report) {
|
|
72
|
+
const declared = source.mcpServers;
|
|
73
|
+
if (!declared) return {};
|
|
74
|
+
if (typeof declared === 'object' && !Array.isArray(declared)) return extractServerMap({ mcpServers: declared });
|
|
75
|
+
if (typeof declared === 'string') {
|
|
76
|
+
const file = readIf(join(srcDir, declared));
|
|
77
|
+
if (file) return extractServerMap(file);
|
|
78
|
+
report.unported.push({ item: `mcp:${declared}`, reason: "can't read the MCP config the manifest points to" });
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ROOT_VARS = [
|
|
84
|
+
[/\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT\b/g, '${PLUGIN_ROOT}'],
|
|
85
|
+
[/\$PLUGIN_ROOT\b/g, '${PLUGIN_ROOT}'],
|
|
86
|
+
[/\$\{CLAUDE_PLUGIN_DATA\}|\$CLAUDE_PLUGIN_DATA\b/g, '${PLUGIN_DATA}'],
|
|
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}'],
|
|
90
|
+
[/\$\{\/\}/g, '/'],
|
|
91
|
+
];
|
|
92
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
+
|
|
94
|
+
function rewriteString(value, stagedPaths) {
|
|
95
|
+
let out = value;
|
|
96
|
+
for (const [re, to] of ROOT_VARS) out = out.replace(re, to);
|
|
97
|
+
for (const staged of stagedPaths) {
|
|
98
|
+
for (const form of new Set([staged, staged.replace(/\\/g, '/'), staged.replace(/\//g, '\\')])) {
|
|
99
|
+
out = out.replace(new RegExp(escapeRe(form), process.platform === 'win32' ? 'gi' : 'g'), '${PLUGIN_ROOT}');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PLACEHOLDER = /\$\{(?!PLUGIN_ROOT\}|PLUGIN_DATA\})([^}]+)\}/g;
|
|
106
|
+
|
|
107
|
+
export function fixMcpServers(servers, { stagedPaths = [], report }) {
|
|
108
|
+
const fixed = {};
|
|
109
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
110
|
+
if (!server || typeof server !== 'object' || Array.isArray(server)) continue;
|
|
111
|
+
if (name.startsWith('$') || ['__proto__', 'constructor', 'prototype'].includes(name)) continue;
|
|
112
|
+
const s = structuredClone(server);
|
|
113
|
+
if (Array.isArray(s.args)) s.args = s.args.map((a) => (typeof a === 'string' ? rewriteString(a, stagedPaths) : a));
|
|
114
|
+
if (typeof s.cwd === 'string') s.cwd = rewriteString(s.cwd, stagedPaths);
|
|
115
|
+
if (s.env && typeof s.env === 'object') {
|
|
116
|
+
s.env = Object.fromEntries(Object.entries(s.env).map(([k, v]) => [k, typeof v === 'string' ? rewriteString(v, stagedPaths) : v]));
|
|
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
|
+
}
|
|
134
|
+
if (typeof s.command === 'string') {
|
|
135
|
+
const command = rewriteString(s.command, stagedPaths);
|
|
136
|
+
if (command.includes('${PLUGIN_ROOT}') || command.includes('${PLUGIN_DATA}')) {
|
|
137
|
+
report.unported.push({ item: `mcp:${name}`, reason: 'the command uses the plugin path, which agy expands only in args, env and cwd' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
s.command = command;
|
|
141
|
+
}
|
|
142
|
+
const others = new Set();
|
|
143
|
+
for (const text of [s.command, s.url, s.serverUrl, ...(s.args ?? []), ...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})]) {
|
|
144
|
+
if (typeof text === 'string') for (const m of text.matchAll(PLACEHOLDER)) others.add(m[1]);
|
|
145
|
+
}
|
|
146
|
+
if (others.size) {
|
|
147
|
+
report.warnings.push(`mcp:${name}: ${[...others].map((v) => `\${${v}}`).join(', ')} is passed literally unless declared in agy's "variables" (format unverified)`);
|
|
148
|
+
}
|
|
149
|
+
fixed[name] = s;
|
|
150
|
+
report.ported.push(`mcp:${name}`);
|
|
151
|
+
}
|
|
152
|
+
return fixed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rewrites plugin.json and mcp_config.json in a staged plugin dir.
|
|
156
|
+
export function sanitizeManifest(pluginDir, { srcDir, ecosystems, slug, stagedPaths = [] }) {
|
|
157
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
158
|
+
const source = sourceManifest(srcDir, ecosystems);
|
|
159
|
+
writeFileSync(join(pluginDir, 'plugin.json'), `${JSON.stringify(buildManifest(source, { slug, ecosystems, pluginDir, report }), null, 2)}\n`);
|
|
160
|
+
report.ported.push('manifest');
|
|
161
|
+
|
|
162
|
+
const configFile = join(pluginDir, 'mcp_config.json');
|
|
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')) {
|
|
171
|
+
for (const [name, server] of Object.entries(inlineServers(source, srcDir, report))) {
|
|
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;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (ecosystems.includes('gemini')) {
|
|
177
|
+
for (const key of ['excludeTools', 'settings']) {
|
|
178
|
+
if (source[key]) report.unported.push({ item: `gemini:${key}`, reason: `agy has no equivalent of gemini-extension.json "${key}"` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const fixed = fixMcpServers(servers, { stagedPaths: stagedPaths.map(normalize), report });
|
|
182
|
+
if (Object.keys(fixed).length) writeFileSync(configFile, `${JSON.stringify({ mcpServers: fixed }, null, 2)}\n`);
|
|
183
|
+
else rmSync(configFile, { force: true });
|
|
184
|
+
|
|
185
|
+
for (const artifact of SOURCE_ARTIFACTS) rmSync(join(pluginDir, artifact), { recursive: true, force: true });
|
|
186
|
+
return report;
|
|
187
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Compiles Claude Code UI mods into Antigravity plugin sidecars (plan §5, §6). Each
|
|
2
|
+
// module listed under a hooks config's "modules" becomes sidecars/<id>/: the porter's
|
|
3
|
+
// runtime (scripts/mod-runtime/), the transformed mod and a mod.json with what the
|
|
4
|
+
// static scan found. It also writes the mod's MCP server, one skill per command and
|
|
5
|
+
// the hooks that forward agy events to the sidecar.
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, posix } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { remapMatcher } from './hook-compiler.mjs';
|
|
11
|
+
import { assess, importsOf, scanMod } from './mod-scan.mjs';
|
|
12
|
+
import { collectGraph, loadEsbuild, needsTransform, outName, transformFile } from './mod-transform.mjs';
|
|
13
|
+
import { slugifyName } from './skill-compiler.mjs';
|
|
14
|
+
import { unmappedToolReason } from './tool-map.mjs';
|
|
15
|
+
|
|
16
|
+
export const RUNTIME_DIR = fileURLToPath(new URL('../scripts/mod-runtime/', import.meta.url));
|
|
17
|
+
const LICENSES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'];
|
|
18
|
+
|
|
19
|
+
const readJsonIf = (file) => { try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return null; } };
|
|
20
|
+
const writeJson = (file, value) => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); };
|
|
21
|
+
|
|
22
|
+
// The agy hooks a scanned mod needs (plan §5.4). Returns {events, unmapped}.
|
|
23
|
+
export function modHooks(scan, id, ownMcpServer = null) {
|
|
24
|
+
const has = (name) => scan.events.some((e) => e.event === name);
|
|
25
|
+
const cmd = (event, timeout) => ({ type: 'command', command: `agy-node sidecars/${id}/hook.mjs ${event}`, timeout });
|
|
26
|
+
const ownPrefix = ownMcpServer ? `mcp__${ownMcpServer}__` : null;
|
|
27
|
+
const ownToolNames = new Set((scan.tools ?? []).map((t) => t.name));
|
|
28
|
+
const isOwnMcpTool = (t) => {
|
|
29
|
+
if (!t.startsWith('mcp__')) return false;
|
|
30
|
+
if (ownPrefix && t.startsWith(ownPrefix)) return true;
|
|
31
|
+
const parts = t.split('__');
|
|
32
|
+
return parts.length >= 3 && ownToolNames.has(parts.slice(2).join('__'));
|
|
33
|
+
};
|
|
34
|
+
const calls = scan.events.filter((e) => e.event === 'tool.call');
|
|
35
|
+
const tools = new Set();
|
|
36
|
+
const unmapped = [];
|
|
37
|
+
for (const call of calls) {
|
|
38
|
+
const rawMatcher = String(call.matcher?.tool ?? '*').trim() || '*';
|
|
39
|
+
const alts = rawMatcher.split('|').map((a) => a.trim()).filter((a) => a && !isOwnMcpTool(a));
|
|
40
|
+
if (alts.length === 0) continue;
|
|
41
|
+
const remap = remapMatcher(alts.join('|'), 'claude');
|
|
42
|
+
unmapped.push(...remap.unmapped);
|
|
43
|
+
for (const t of remap.matcher?.split('|') ?? []) tools.add(t);
|
|
44
|
+
}
|
|
45
|
+
// SessionStart, PreInvocation and Stop are flat; tool events are grouped by matcher.
|
|
46
|
+
const events = { SessionStart: [cmd('SessionStart', 5)] };
|
|
47
|
+
const perTurn = has('turn.start') || has('prompt.context');
|
|
48
|
+
if (perTurn) events.PreInvocation = [cmd('PreInvocation', 5)];
|
|
49
|
+
if (tools.size) {
|
|
50
|
+
const matcher = tools.has('*') ? '*' : [...tools].sort().join('|');
|
|
51
|
+
events.PreToolUse = [{ matcher, hooks: [cmd('PreToolUse', 10)] }];
|
|
52
|
+
events.PostToolUse = [{ matcher, hooks: [cmd('PostToolUse', 10)] }];
|
|
53
|
+
}
|
|
54
|
+
// Stop ends the turn, so the next PreInvocation counts as a turn's first (live, 2.5B)
|
|
55
|
+
if (perTurn || has('turn.complete') || tools.size) events.Stop = [cmd('Stop', 5)];
|
|
56
|
+
return { events, unmapped: [...new Set(unmapped)] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function skillFor(c, { title, slug, id }) {
|
|
60
|
+
return [
|
|
61
|
+
'---',
|
|
62
|
+
`name: ${slugifyName(c.name)}`,
|
|
63
|
+
`description: Runs the /${c.name} command of the ${title} mod.${c.description ? ` ${c.description}` : ''} Use when the user types /${c.name} or asks for ${title}.`.replace(/\s+/g, ' '),
|
|
64
|
+
'---',
|
|
65
|
+
'',
|
|
66
|
+
`# /${c.name}`,
|
|
67
|
+
'',
|
|
68
|
+
`This command belongs to ${title}, a Claude Code mod ported to Antigravity. Its pane is at \`sidecar://${slug}/${id}/ui.html\`.`,
|
|
69
|
+
'',
|
|
70
|
+
`1. Call the \`slash_${c.name}\` tool. Pass the text after \`/${c.name}\` as \`args\`${c.argumentHint ? ` (${c.argumentHint})` : ''}, or an empty string.`,
|
|
71
|
+
"2. Reply with the tool's text. It ends with a `sidecar://` link that opens the pane: put that link on its own line, exactly as given.",
|
|
72
|
+
'',
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Writes one compiled mod into pluginDir. Returns its MCP server name, or null.
|
|
77
|
+
function emit(pluginDir, { id, files, transformed, scan, verdict, meta, report, item }) {
|
|
78
|
+
const side = join(pluginDir, 'sidecars', id);
|
|
79
|
+
rmSync(side, { recursive: true, force: true });
|
|
80
|
+
mkdirSync(join(side, 'mod'), { recursive: true });
|
|
81
|
+
for (const f of readdirSync(RUNTIME_DIR)) copyFileSync(join(RUNTIME_DIR, f), join(side, f));
|
|
82
|
+
files.forEach((f, i) => { const out = join(side, 'mod', outName(f.rel)); mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, transformed[i]); });
|
|
83
|
+
const license = LICENSES.find((l) => existsSync(join(meta.srcDir, l)));
|
|
84
|
+
if (license) copyFileSync(join(meta.srcDir, license), join(side, 'mod', license));
|
|
85
|
+
|
|
86
|
+
const hasServer = scan.tools.length > 0 || scan.commands.length > 0;
|
|
87
|
+
const config = readJsonIf(join(pluginDir, 'mcp_config.json')) ?? { mcpServers: {} };
|
|
88
|
+
config.mcpServers ??= {};
|
|
89
|
+
let mcpServer = meta.mcpServer;
|
|
90
|
+
if (hasServer && Object.hasOwn(config.mcpServers, mcpServer)) {
|
|
91
|
+
report.warnings.push(`${item}: an MCP server named ${mcpServer} exists already, so the mod's is ${mcpServer}-mod and its mcp__${mcpServer}__* tool names change`);
|
|
92
|
+
mcpServer = `${mcpServer}-mod`;
|
|
93
|
+
}
|
|
94
|
+
writeJson(join(side, 'mod.json'), {
|
|
95
|
+
plugin: meta.slug, sidecar: id, title: meta.title, pluginName: meta.sourceName, version: meta.version, mcpServer,
|
|
96
|
+
entry: `mod/${outName(meta.entryRel)}`, hasPane: verdict.hasPane, allow: [...scan.calls].sort(), options: meta.options,
|
|
97
|
+
commands: scan.commands, tools: scan.tools, verdict: verdict.verdict, tier: verdict.tier, tierReasons: verdict.tierReasons,
|
|
98
|
+
});
|
|
99
|
+
writeJson(join(side, 'sidecar.json'), {
|
|
100
|
+
command: 'node', args: ['server.mjs'], description: `${meta.title}, a Claude Code mod ported by agy-plugin-porter.`,
|
|
101
|
+
display_name: meta.title, has_web_ui: true, restart_policy: 'always',
|
|
102
|
+
ui_config: { display_name: meta.title, views: [{ entrypoint: 'SIDECAR_UI_ENTRYPOINT_AUX_PANE', path: '/ui.html', title: meta.title }] },
|
|
103
|
+
});
|
|
104
|
+
if (hasServer) {
|
|
105
|
+
config.mcpServers[mcpServer] = { command: 'agy-node', args: [`\${PLUGIN_ROOT}/sidecars/${id}/mcp-entry.mjs`] };
|
|
106
|
+
writeJson(join(pluginDir, 'mcp_config.json'), config);
|
|
107
|
+
report.ported.push(`mcp:${mcpServer}`);
|
|
108
|
+
}
|
|
109
|
+
for (const c of scan.commands) {
|
|
110
|
+
const file = join(pluginDir, 'skills', slugifyName(c.name), 'SKILL.md');
|
|
111
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(file, skillFor(c, { title: meta.title, slug: meta.slug, id }), { flag: 'wx' }); // never replaces the plugin's own skill
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code !== 'EEXIST') throw err;
|
|
116
|
+
report.warnings.push(`${item}: skills/${slugifyName(c.name)} exists already, so /${c.name} has no skill (the pane's button still runs it)`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
report.ported.push(`command:/${c.name}`);
|
|
120
|
+
}
|
|
121
|
+
const hooks = modHooks(scan, id, mcpServer);
|
|
122
|
+
for (const name of hooks.unmapped) report.unported.push({ item: `${item} tool.call ${name}`, reason: unmappedToolReason(name) });
|
|
123
|
+
const all = readJsonIf(join(pluginDir, 'hooks.json')) ?? {};
|
|
124
|
+
all[`${meta.slug}-mod-${id}`] = hooks.events;
|
|
125
|
+
writeJson(join(pluginDir, 'hooks.json'), all);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A Claude manifest's userConfig defaults, which Claude Code passes to register(on,
|
|
129
|
+
// options). Antigravity has no settings UI for them, so the port uses the defaults.
|
|
130
|
+
export function userConfigDefaults(userConfig) {
|
|
131
|
+
if (!userConfig || typeof userConfig !== 'object') return {};
|
|
132
|
+
return Object.fromEntries(Object.entries(userConfig).filter(([, v]) => v && typeof v === 'object' && 'default' in v).map(([k, v]) => [k, v.default]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// uiMods: detect()'s list ({path, modules}). Returns {ported, unported, warnings}.
|
|
136
|
+
export function compileMods(uiMods, { srcDir, pluginDir, slug, sourceName, title, version, esbuild, options = {} }) {
|
|
137
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
138
|
+
const entries = uiMods.flatMap((mod) => mod.modules.map((m) => posix.normalize(posix.join(posix.dirname(mod.path.split('#')[0]), String(m)))));
|
|
139
|
+
let esb = esbuild;
|
|
140
|
+
const getEsbuild = () => (esb === undefined ? (esb = loadEsbuild()) : esb);
|
|
141
|
+
const name = sourceName || slug;
|
|
142
|
+
const ids = new Set();
|
|
143
|
+
entries.forEach((entryRel, n) => {
|
|
144
|
+
const item = `ui-mod:${entryRel}`;
|
|
145
|
+
const stem = posix.basename(entryRel).replace(/\.[^.]+$/, '');
|
|
146
|
+
let id = entries.length === 1 ? 'pane' : `pane-${slugifyName(stem)}`;
|
|
147
|
+
if (ids.has(id)) id = `${id}-${n + 1}`;
|
|
148
|
+
ids.add(id);
|
|
149
|
+
let files;
|
|
150
|
+
let transformed;
|
|
151
|
+
try {
|
|
152
|
+
files = collectGraph(srcDir, entryRel);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
report.unported.push({ item, reason: `can't read the mod: ${err.message}` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const f of files) rmSync(join(pluginDir, f.rel), { force: true }); // agy staged the raw source
|
|
158
|
+
const scan = scanMod(files.map((f) => ({ path: f.rel, src: f.src })));
|
|
159
|
+
const verdict = assess(scan);
|
|
160
|
+
if (verdict.verdict === 'refused') {
|
|
161
|
+
report.unported.push({ item, reason: `not ported: it ${verdict.refusals.join('; it ')}` });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const tool = needsTransform(files) ? getEsbuild() : null;
|
|
166
|
+
transformed = files.map((f) => transformFile(srcDir, f, tool));
|
|
167
|
+
} catch (err) {
|
|
168
|
+
report.unported.push({ item, reason: err.message });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (entries.length > 1) report.warnings.push(`${item}: one of several mods, so its MCP server is ${name}-${id}`);
|
|
172
|
+
const meta = {
|
|
173
|
+
srcDir, slug, sourceName: name, title: entries.length === 1 ? title || name : `${title || name} (${stem})`,
|
|
174
|
+
version: version ?? '0.0.0', entryRel, mcpServer: entries.length === 1 ? name : `${name}-${id}`, options,
|
|
175
|
+
};
|
|
176
|
+
if (Object.keys(options).length) report.warnings.push(`${item}: userConfig (${Object.keys(options).join(', ')}) uses its defaults; Antigravity has no settings for it`);
|
|
177
|
+
emit(pluginDir, { id, files, transformed, scan, verdict, meta, report, item });
|
|
178
|
+
report.ported.push(`${item} (${verdict.verdict}, sidecar ${id}, sidecar://${slug}/${id}/ui.html)`);
|
|
179
|
+
for (const f of verdict.findings) {
|
|
180
|
+
if (f.level === 'none') report.unported.push({ item: `${item} ${f.item}`, reason: f.reason });
|
|
181
|
+
else report.warnings.push(`${item}: ${f.item}${f.level === 'partial' ? ' is partial' : ''}: ${f.reason}`);
|
|
182
|
+
}
|
|
183
|
+
for (const w of verdict.warnings) report.warnings.push(`${item}: ${w}`);
|
|
184
|
+
});
|
|
185
|
+
if (entries.length) dropModLeftovers(pluginDir, entries, report);
|
|
186
|
+
return report;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const CODE = /\.(m?[jt]sx?|cjs|mts)$/;
|
|
190
|
+
const walk = (dir, rel = '') => readdirSync(join(dir, rel), { withFileTypes: true }).flatMap((e) => {
|
|
191
|
+
const path = rel ? `${rel}/${e.name}` : e.name;
|
|
192
|
+
if (e.isDirectory()) return ['sidecars', 'node_modules', '.git'].includes(path) ? [] : walk(dir, path);
|
|
193
|
+
return [path];
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Staged files that only run inside Claude Code: the mod's tests and helpers that
|
|
197
|
+
// import 'claude-code' (or 'claude-code/testing') or the mod, and its tsconfig.json. The ported mod lives in
|
|
198
|
+
// sidecars/ now, so these would ship raw source nothing can run.
|
|
199
|
+
function dropModLeftovers(pluginDir, entries, report) {
|
|
200
|
+
const modFiles = new Set(entries);
|
|
201
|
+
const dropped = [];
|
|
202
|
+
for (const rel of walk(pluginDir).filter((p) => CODE.test(p))) {
|
|
203
|
+
let specs;
|
|
204
|
+
try { specs = importsOf(readFileSync(join(pluginDir, rel), 'utf8')); } catch { continue; }
|
|
205
|
+
const usesMod = specs.some((s) => s === 'claude-code' || s.startsWith('claude-code/') || (/^\.{1,2}\//.test(s) && [...modFiles].some((m) => posix.normalize(posix.join(posix.dirname(rel), s)).replace(/\.[^./]+$/, '') === m.replace(/\.[^./]+$/, ''))));
|
|
206
|
+
if (usesMod) { rmSync(join(pluginDir, rel), { force: true }); dropped.push(rel); }
|
|
207
|
+
}
|
|
208
|
+
if (existsSync(join(pluginDir, 'tsconfig.json'))) { rmSync(join(pluginDir, 'tsconfig.json')); dropped.push('tsconfig.json'); }
|
|
209
|
+
if (dropped.length) report.warnings.push(`dropped files that only run inside Claude Code: ${dropped.sort().join(', ')}`);
|
|
210
|
+
}
|