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,205 @@
|
|
|
1
|
+
// Builds rules/ for agy:
|
|
2
|
+
// - CLAUDE.md, GEMINI.md (or gemini-extension.json `contextFileName`) and
|
|
3
|
+
// Codex's AGENTS.md -> rules/AGENTS.md, plain Markdown and always on
|
|
4
|
+
// - Cursor .mdc: alwaysApply -> an always_on rule; description only -> a
|
|
5
|
+
// model_decision rule (agy lists it with its description, live-tested);
|
|
6
|
+
// globs or nothing -> a skill, because agy 1.4.5 never applies a plugin's
|
|
7
|
+
// glob or manual rules (live-tested on view and edit, though `agy plugin
|
|
8
|
+
// validate` accepts them)
|
|
9
|
+
// - .cursorrules and Roo/Cline/Kilo workspace rules -> always_on rules
|
|
10
|
+
// A rule over agy's 24 KB per-file limit is reported unported instead of being
|
|
11
|
+
// silently truncated.
|
|
12
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { basename, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { readJson } from './detector.mjs';
|
|
16
|
+
import { slugifyName } from './skill-compiler.mjs';
|
|
17
|
+
import { formatFrontmatter, splitFrontmatter } from './yaml-lite.mjs';
|
|
18
|
+
|
|
19
|
+
export const RULE_BYTES = 24_000;
|
|
20
|
+
export const RULES_BUDGET_TOKENS = 20_000;
|
|
21
|
+
|
|
22
|
+
const read = (file) => readFileSync(file, 'utf8').replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
23
|
+
const isFile = (file) => existsSync(file) && !lstatSync(file).isSymbolicLink() && statSync(file).isFile();
|
|
24
|
+
const mdFiles = (dir) => {
|
|
25
|
+
if (!existsSync(dir) || lstatSync(dir).isSymbolicLink() || !statSync(dir).isDirectory()) return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
28
|
+
const full = join(dir, entry);
|
|
29
|
+
if (lstatSync(full).isSymbolicLink()) continue;
|
|
30
|
+
const st = statSync(full);
|
|
31
|
+
if (st.isDirectory()) out.push(...mdFiles(full));
|
|
32
|
+
else if (/\.(md|mdc|txt)$/i.test(entry)) out.push(full);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function geminiContextFiles(srcDir) {
|
|
38
|
+
let names = ['GEMINI.md'];
|
|
39
|
+
try {
|
|
40
|
+
const declared = readJson(join(srcDir, 'gemini-extension.json')).contextFileName;
|
|
41
|
+
if (typeof declared === 'string') names = [declared];
|
|
42
|
+
else if (Array.isArray(declared)) names = declared.filter((n) => typeof n === 'string');
|
|
43
|
+
} catch {}
|
|
44
|
+
return names;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function cursorTrigger(data) {
|
|
48
|
+
const globs = Array.isArray(data.globs) ? data.globs.filter(Boolean).join(',') : (typeof data.globs === 'string' ? data.globs.trim() : '');
|
|
49
|
+
const description = typeof data.description === 'string' ? data.description.trim() : '';
|
|
50
|
+
if (data.alwaysApply === true) return { trigger: 'always_on' };
|
|
51
|
+
if (globs) return { trigger: 'glob', globs, ...(description ? { description } : {}) };
|
|
52
|
+
if (description) return { trigger: 'model_decision', description };
|
|
53
|
+
return { trigger: 'manual' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RULE_VAR_RE = /(?:\$\{(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\}|\$(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\b)((?:[\\/]|\$\{\/\})+)([^\s"'`<>|*?()[\]{}]*)/g;
|
|
57
|
+
const RULE_VAR_BARE_RE = /\$\{(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\}|\$(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|extensionPath|GEMINI_PLUGIN_PATH)\b/g;
|
|
58
|
+
|
|
59
|
+
export function rewriteRuleVars(text, label, report) {
|
|
60
|
+
if (typeof text !== 'string') return text;
|
|
61
|
+
let count = 0;
|
|
62
|
+
let out = text.replace(RULE_VAR_RE, (_, _sep, tail) => {
|
|
63
|
+
count++;
|
|
64
|
+
const normalizedTail = (tail ?? '').replace(/(?:\$\{\/\}|[\\/])+/g, '/');
|
|
65
|
+
return `./${normalizedTail}`;
|
|
66
|
+
});
|
|
67
|
+
out = out.replace(RULE_VAR_BARE_RE, () => {
|
|
68
|
+
count++;
|
|
69
|
+
return '.';
|
|
70
|
+
});
|
|
71
|
+
if (count > 0 && report) {
|
|
72
|
+
const msg = `${label}: rewrote plugin-root variable(s) to relative path(s)`;
|
|
73
|
+
if (!report.warnings.includes(msg)) report.warnings.push(msg);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A glob-scoped or manual Cursor rule becomes a skill the agent can pull in.
|
|
79
|
+
function mdcToSkill(pluginDir, base, trigger, body, item, report) {
|
|
80
|
+
let name = slugifyName(base);
|
|
81
|
+
for (let n = 2; existsSync(join(pluginDir, 'skills', name)); n++) name = `${slugifyName(base)}-${n}`;
|
|
82
|
+
const rawDescription = trigger.trigger === 'glob'
|
|
83
|
+
? `Use when working on files matching ${trigger.globs}.${trigger.description ? ` ${trigger.description}` : ''}`
|
|
84
|
+
: `Use only when the user asks for the "${base}" rule.`;
|
|
85
|
+
const description = rewriteRuleVars(rawDescription, item, report);
|
|
86
|
+
const cleanBody = rewriteRuleVars(body, item, report);
|
|
87
|
+
mkdirSync(join(pluginDir, 'skills', name), { recursive: true });
|
|
88
|
+
writeFileSync(join(pluginDir, 'skills', name, 'SKILL.md'), formatFrontmatter({ name, description }, `\n${cleanBody.trim()}\n`));
|
|
89
|
+
report.ported.push(`${item} → skills/${name}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
93
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
94
|
+
const rulesDir = join(pluginDir, 'rules');
|
|
95
|
+
const outputs = [];
|
|
96
|
+
const used = new Set(existsSync(rulesDir) ? readdirSync(rulesDir).map((f) => f.toLowerCase()) : []);
|
|
97
|
+
const add = (file, rawContent, item, alwaysOn) => {
|
|
98
|
+
const content = rewriteRuleVars(rawContent, item, report);
|
|
99
|
+
if (Buffer.byteLength(content) > RULE_BYTES) {
|
|
100
|
+
report.unported.push({ item, reason: `over agy's 24 KB per-rule limit (${Buffer.byteLength(content)} bytes); agy would truncate it` });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let name = file;
|
|
104
|
+
for (let n = 2; used.has(name.toLowerCase()); n++) name = file.replace(/\.md$/, `-${n}.md`);
|
|
105
|
+
used.add(name.toLowerCase());
|
|
106
|
+
outputs.push({ name, content, alwaysOn });
|
|
107
|
+
report.ported.push(`${item} → rules/${name}`);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const preExisting = existsSync(rulesDir)
|
|
111
|
+
? readdirSync(rulesDir).filter((f) => /\.md$/i.test(f) && isFile(join(rulesDir, f)))
|
|
112
|
+
: [];
|
|
113
|
+
|
|
114
|
+
// Always-on context files become one rules/AGENTS.md.
|
|
115
|
+
const context = [];
|
|
116
|
+
if (ecosystems.includes('claude')) context.push('CLAUDE.md');
|
|
117
|
+
if (ecosystems.includes('gemini')) context.push(...geminiContextFiles(srcDir));
|
|
118
|
+
if (ecosystems.includes('codex')) context.push('AGENTS.md');
|
|
119
|
+
const sections = [];
|
|
120
|
+
const sources = [];
|
|
121
|
+
for (const name of [...new Set(context)]) {
|
|
122
|
+
const file = join(srcDir, name);
|
|
123
|
+
if (!isFile(file)) continue;
|
|
124
|
+
const text = read(file).trim();
|
|
125
|
+
if (!text) continue;
|
|
126
|
+
if (/^@(?!\[)\S+/m.test(text)) report.warnings.push(`${name}: "@path" imports aren't expanded by agy (it uses @[label](path))`);
|
|
127
|
+
sections.push(text);
|
|
128
|
+
sources.push(name);
|
|
129
|
+
}
|
|
130
|
+
if (sections.length) {
|
|
131
|
+
// Merge with a rules/AGENTS.md the source already shipped.
|
|
132
|
+
if (isFile(join(rulesDir, 'AGENTS.md'))) sections.unshift(read(join(rulesDir, 'AGENTS.md')).trim());
|
|
133
|
+
used.delete('agents.md');
|
|
134
|
+
add('AGENTS.md', `${sections.join('\n\n')}\n`, sources.join(' + '), true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const f of preExisting) {
|
|
138
|
+
if (sections.length && f.toLowerCase() === 'agents.md') continue;
|
|
139
|
+
const full = join(rulesDir, f);
|
|
140
|
+
const raw = read(full);
|
|
141
|
+
const item = `rules/${f}`;
|
|
142
|
+
const content = rewriteRuleVars(raw, item, report);
|
|
143
|
+
if (Buffer.byteLength(content) > RULE_BYTES) {
|
|
144
|
+
rmSync(full, { force: true });
|
|
145
|
+
used.delete(f.toLowerCase());
|
|
146
|
+
report.unported.push({ item, reason: `over agy's 24 KB per-rule limit (${Buffer.byteLength(content)} bytes); agy would truncate it` });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (content !== raw) writeFileSync(full, content);
|
|
150
|
+
let alwaysOn = true;
|
|
151
|
+
try {
|
|
152
|
+
const fm = splitFrontmatter(content);
|
|
153
|
+
if (fm.hasFrontmatter && fm.data.trigger && fm.data.trigger !== 'always_on') alwaysOn = false;
|
|
154
|
+
} catch {}
|
|
155
|
+
outputs.push({ name: f, content, alwaysOn, existing: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (ecosystems.includes('cursor')) {
|
|
159
|
+
for (const file of mdFiles(join(srcDir, '.cursor/rules')).filter((f) => f.endsWith('.mdc'))) {
|
|
160
|
+
let parsed;
|
|
161
|
+
try { parsed = splitFrontmatter(read(file)); } catch (err) {
|
|
162
|
+
report.unported.push({ item: `.cursor/rules/${basename(file)}`, reason: `frontmatter isn't parseable: ${err.message}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const trigger = cursorTrigger(parsed.data);
|
|
166
|
+
const item = `.cursor/rules/${basename(file)} (${trigger.trigger})`;
|
|
167
|
+
if (/^@\S+/m.test(parsed.body)) report.warnings.push(`.cursor/rules/${basename(file)}: "@file" references aren't expanded by agy`);
|
|
168
|
+
if (trigger.trigger === 'glob' || trigger.trigger === 'manual') {
|
|
169
|
+
mdcToSkill(pluginDir, basename(file, '.mdc'), trigger, parsed.body, item, report);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (trigger.description) trigger.description = rewriteRuleVars(trigger.description, item, report);
|
|
173
|
+
add(`${slugifyName(basename(file, '.mdc'))}.md`, formatFrontmatter(trigger, parsed.body.trim() + '\n'), item, trigger.trigger === 'always_on');
|
|
174
|
+
}
|
|
175
|
+
if (isFile(join(srcDir, '.cursorrules'))) {
|
|
176
|
+
add('cursorrules.md', formatFrontmatter({ trigger: 'always_on' }, `${read(join(srcDir, '.cursorrules')).trim()}\n`), '.cursorrules', true);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ecosystems.includes('roo')) {
|
|
181
|
+
const workspace = [
|
|
182
|
+
...mdFiles(join(srcDir, '.roo/rules')), ...mdFiles(join(srcDir, '.kilocode/rules')),
|
|
183
|
+
...mdFiles(join(srcDir, '.clinerules')),
|
|
184
|
+
...['.roorules', '.kilocoderules', '.clinerules'].map((f) => join(srcDir, f)).filter(isFile),
|
|
185
|
+
];
|
|
186
|
+
for (const file of workspace) {
|
|
187
|
+
const rel = file.slice(srcDir.length + 1).replace(/\\/g, '/');
|
|
188
|
+
add(`${slugifyName(rel.replace(/\.(md|txt)$/i, ''))}.md`, formatFrontmatter({ trigger: 'always_on' }, `${read(file).trim()}\n`), rel, true);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Root context files agy staged are never loaded from there.
|
|
193
|
+
for (const name of ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md', ...geminiContextFiles(srcDir)]) {
|
|
194
|
+
rmSync(join(pluginDir, name), { force: true });
|
|
195
|
+
}
|
|
196
|
+
const newOutputs = outputs.filter((o) => !o.existing);
|
|
197
|
+
if (newOutputs.length) mkdirSync(rulesDir, { recursive: true });
|
|
198
|
+
for (const { name, content } of newOutputs) writeFileSync(join(rulesDir, name), content);
|
|
199
|
+
|
|
200
|
+
const alwaysOnTokens = Math.ceil(outputs.filter((o) => o.alwaysOn).reduce((n, o) => n + Buffer.byteLength(o.content), 0) / 4);
|
|
201
|
+
if (alwaysOnTokens > RULES_BUDGET_TOKENS) {
|
|
202
|
+
report.warnings.push(`rules: about ${alwaysOnTokens} tokens of always-on rules, over agy's ${RULES_BUDGET_TOKENS}-token budget; agy shows the excess as file pointers`);
|
|
203
|
+
}
|
|
204
|
+
return report;
|
|
205
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Normalizes skills in a staged plugin dir so agy loads them under the right
|
|
2
|
+
// name: the skills `agy plugin install` made from Claude/Gemini commands
|
|
3
|
+
// (no `name`, Claude-only keys, argument placeholders) and native skills. It
|
|
4
|
+
// also brings in Codex's `.agents/skills`. `commands/` is removed afterwards:
|
|
5
|
+
// plugins don't load it, and agy has already converted it.
|
|
6
|
+
import { cpSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { rewriteRuleVars } from './rules-compiler.mjs';
|
|
10
|
+
import { formatFrontmatter, splitFrontmatter } from './yaml-lite.mjs';
|
|
11
|
+
|
|
12
|
+
export const SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
|
+
const CLAUDE_ONLY_KEYS = ['argument-hint', 'allowed-tools', 'model', 'disable-model-invocation'];
|
|
14
|
+
const ARGS = /\$ARGUMENTS\b|\{\{\s*args\s*\}\}|\$[1-9]\b/g;
|
|
15
|
+
const SKILL_BYTES = 24_000;
|
|
16
|
+
|
|
17
|
+
const dirs = (dir) => (existsSync(dir) ? readdirSync(dir).filter((d) => statSync(join(dir, d)).isDirectory()) : []);
|
|
18
|
+
|
|
19
|
+
// Linear on any input: no regex that can backtrack over runs of "-".
|
|
20
|
+
export function slugifyName(text) {
|
|
21
|
+
const slug = String(text).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join('-').slice(0, 64);
|
|
22
|
+
return (slug.endsWith('-') ? slug.slice(0, -1) : slug) || 'skill';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function firstLine(body) {
|
|
26
|
+
const line = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find((l) => l);
|
|
27
|
+
return line ? line.slice(0, 200) : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Rewrites one SKILL.md in place. Returns the skill's final folder name.
|
|
31
|
+
export function normalizeSkill(skillsDir, folder, report, { fromCommand = false } = {}) {
|
|
32
|
+
const file = join(skillsDir, folder, 'SKILL.md');
|
|
33
|
+
const text = readFileSync(file, 'utf8');
|
|
34
|
+
let { data, body, hasFrontmatter } = (() => {
|
|
35
|
+
try { return splitFrontmatter(text); } catch (err) {
|
|
36
|
+
report.warnings.push(`skill:${folder}: frontmatter isn't parseable (${err.message}); rebuilt from the folder name`);
|
|
37
|
+
return { data: {}, body: text.replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n?/, ''), hasFrontmatter: false };
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
const notes = [];
|
|
41
|
+
let name = typeof data.name === 'string' && SKILL_NAME.test(data.name) ? data.name : null;
|
|
42
|
+
let finalFolder = folder;
|
|
43
|
+
if (name && name !== folder) {
|
|
44
|
+
if (!existsSync(join(skillsDir, name))) {
|
|
45
|
+
renameSync(join(skillsDir, folder), join(skillsDir, name));
|
|
46
|
+
finalFolder = name;
|
|
47
|
+
} else {
|
|
48
|
+
notes.push(`renamed from "${name}" to match its folder`);
|
|
49
|
+
name = folder;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!name) name = SKILL_NAME.test(folder) ? folder : slugifyName(folder);
|
|
53
|
+
if (name !== finalFolder && !existsSync(join(skillsDir, name))) {
|
|
54
|
+
// agy names a skill after its frontmatter and wants the folder to match.
|
|
55
|
+
renameSync(join(skillsDir, finalFolder), join(skillsDir, name));
|
|
56
|
+
finalFolder = name;
|
|
57
|
+
} else if (name !== finalFolder) {
|
|
58
|
+
name = finalFolder;
|
|
59
|
+
}
|
|
60
|
+
const out = { name, ...data };
|
|
61
|
+
out.name = name;
|
|
62
|
+
const dropped = CLAUDE_ONLY_KEYS.filter((k) => k in out);
|
|
63
|
+
dropped.forEach((k) => delete out[k]);
|
|
64
|
+
if (dropped.length) notes.push(`dropped ${dropped.join(', ')}`);
|
|
65
|
+
if (typeof out.description !== 'string' || !out.description.trim()) {
|
|
66
|
+
out.description = firstLine(body) || `Ported skill ${name}.`;
|
|
67
|
+
notes.push('description taken from the body');
|
|
68
|
+
}
|
|
69
|
+
const replaced = body.match(ARGS);
|
|
70
|
+
if (replaced) {
|
|
71
|
+
body = body.replace(ARGS, "the user's request");
|
|
72
|
+
notes.push(`replaced ${[...new Set(replaced)].join(', ')} with "the user's request" (skills take no arguments)`);
|
|
73
|
+
}
|
|
74
|
+
if (/^!`|!\{[^}]*\}/m.test(body)) {
|
|
75
|
+
report.warnings.push(`skill:${name}: inline shell execution (!\`…\` / !{…}) isn't supported by agy and is left as text`);
|
|
76
|
+
}
|
|
77
|
+
out.description = rewriteRuleVars(out.description, `skill:${name}`, report);
|
|
78
|
+
body = rewriteRuleVars(body, `skill:${name}`, report);
|
|
79
|
+
const rewritten = formatFrontmatter(out, body);
|
|
80
|
+
const changed = !hasFrontmatter || rewritten !== text.replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
81
|
+
if (changed) writeFileSync(join(skillsDir, finalFolder, 'SKILL.md'), rewritten);
|
|
82
|
+
if (Buffer.byteLength(body) > SKILL_BYTES) report.warnings.push(`skill:${name}: body is over agy's 24 KB skill limit`);
|
|
83
|
+
if (notes.length) report.warnings.push(`skill:${name}: ${notes.join('; ')}`);
|
|
84
|
+
report.ported.push(fromCommand ? `command → skill:${name}` : `skill:${name}`);
|
|
85
|
+
return finalFolder;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Command skills agy made: Claude's are `<plugin>-cmd-<x>`, Gemini's are named
|
|
89
|
+
// after the .toml file. Anything that came from commands/ is "fromCommand".
|
|
90
|
+
function commandNames(srcDir) {
|
|
91
|
+
const names = new Set();
|
|
92
|
+
const walk = (dir) => {
|
|
93
|
+
for (const entry of existsSync(dir) ? readdirSync(dir) : []) {
|
|
94
|
+
const full = join(dir, entry);
|
|
95
|
+
if (statSync(full).isDirectory()) walk(full);
|
|
96
|
+
else names.add(entry.replace(/\.(md|toml)$/i, ''));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(join(srcDir, 'commands'));
|
|
100
|
+
return names;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function compileSkills(pluginDir, { srcDir, ecosystems }) {
|
|
104
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
105
|
+
const skillsDir = join(pluginDir, 'skills');
|
|
106
|
+
const commands = commandNames(srcDir);
|
|
107
|
+
|
|
108
|
+
if (ecosystems.includes('codex')) {
|
|
109
|
+
for (const folder of dirs(join(srcDir, '.agents/skills'))) {
|
|
110
|
+
if (!existsSync(join(srcDir, '.agents/skills', folder, 'SKILL.md'))) continue;
|
|
111
|
+
if (existsSync(join(skillsDir, folder))) {
|
|
112
|
+
report.unported.push({ item: `skill:${folder}`, reason: 'a skill with this name already exists in the plugin' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
cpSync(join(srcDir, '.agents/skills', folder), join(skillsDir, folder), { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const folder of dirs(skillsDir)) {
|
|
120
|
+
if (!existsSync(join(skillsDir, folder, 'SKILL.md'))) {
|
|
121
|
+
report.warnings.push(`skills/${folder}: no SKILL.md, so agy ignores it`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const fromCommand = commands.has(folder) || [...commands].some((c) => folder.endsWith(`-cmd-${c}`));
|
|
125
|
+
normalizeSkill(skillsDir, folder, report, { fromCommand });
|
|
126
|
+
}
|
|
127
|
+
if (existsSync(join(pluginDir, 'commands'))) {
|
|
128
|
+
const converted = report.ported.filter((p) => p.startsWith('command')).length;
|
|
129
|
+
if (converted < commands.size) {
|
|
130
|
+
report.unported.push({ item: 'commands/', reason: `${commands.size - converted} command(s) weren't converted to skills by agy` });
|
|
131
|
+
}
|
|
132
|
+
rmSync(join(pluginDir, 'commands'), { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
return report;
|
|
135
|
+
}
|
package/lib/source.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Resolves a --source: a local path, or `owner/repo[@ref]` fetched from GitHub
|
|
2
|
+
// at that ref (tag, branch or commit SHA) into a temp dir.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
// Owner, repo and ref can't start with "-", so none of them can reach git as an
|
|
9
|
+
// option (e.g. a ref of `--upload-pack=<cmd>`). Refs are limited to the
|
|
10
|
+
// characters tag, branch and SHA names use.
|
|
11
|
+
const GITHUB = /^([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.][A-Za-z0-9_.-]*?)(?:\.git)?(?:@([A-Za-z0-9_][A-Za-z0-9_./-]*))?$/;
|
|
12
|
+
|
|
13
|
+
function git(args, cwd) {
|
|
14
|
+
const run = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 300_000 });
|
|
15
|
+
if (run.error) throw new Error(`can't run git: ${run.error.message}`);
|
|
16
|
+
if (run.status !== 0) throw new Error(`git ${args[0]} failed: ${run.stderr.trim()}`);
|
|
17
|
+
return run.stdout.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeWritable(dir) {
|
|
21
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
22
|
+
const p = join(dir, e.name);
|
|
23
|
+
if (e.isDirectory()) makeWritable(p);
|
|
24
|
+
else if (e.isFile()) chmodSync(p, 0o666);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// rmSync -r -f, except that Node 24's rmSync (agy-node's) can't delete read-only
|
|
29
|
+
// files on Windows, which a git clone's pack files are: on EPERM, make every file
|
|
30
|
+
// writable and try once more.
|
|
31
|
+
export function removeTree(dir) {
|
|
32
|
+
try {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err.code !== 'EPERM' || !existsSync(dir)) throw err;
|
|
36
|
+
makeWritable(dir);
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseSourceSpec(spec) {
|
|
42
|
+
if (existsSync(spec)) return { kind: 'path', path: resolve(spec) };
|
|
43
|
+
const m = GITHUB.exec(spec);
|
|
44
|
+
if (!m || m[3]?.includes('..')) throw new Error(`source "${spec}" is neither an existing path nor owner/repo[@ref]`);
|
|
45
|
+
return { kind: 'github', owner: m[1], repo: m[2], ref: m[3] ?? null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Returns {dir, commit, cleanup}. `subPath` picks a folder inside the source.
|
|
49
|
+
export function resolveSource(spec, { subPath } = {}) {
|
|
50
|
+
const parsed = parseSourceSpec(spec);
|
|
51
|
+
let root;
|
|
52
|
+
let commit = null;
|
|
53
|
+
let cleanup = () => {};
|
|
54
|
+
if (parsed.kind === 'path') {
|
|
55
|
+
root = parsed.path;
|
|
56
|
+
} else {
|
|
57
|
+
root = mkdtempSync(join(tmpdir(), 'agy-porter-src-'));
|
|
58
|
+
cleanup = () => removeTree(root);
|
|
59
|
+
try {
|
|
60
|
+
const ref = parsed.ref ?? 'HEAD';
|
|
61
|
+
if (ref.startsWith('-')) throw new Error(`invalid ref "${ref}"`);
|
|
62
|
+
git(['init', '-q'], root);
|
|
63
|
+
// Keep the upstream's line endings: on Windows, autocrlf would rewrite every file.
|
|
64
|
+
git(['config', 'core.autocrlf', 'false'], root);
|
|
65
|
+
// "--": git reads everything after it as a repository or refspec, never an option.
|
|
66
|
+
git(['fetch', '-q', '--depth', '1', '--', `https://github.com/${parsed.owner}/${parsed.repo}.git`, ref], root);
|
|
67
|
+
git(['checkout', '-q', 'FETCH_HEAD'], root);
|
|
68
|
+
commit = git(['rev-parse', 'HEAD'], root);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
cleanup();
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const dir = subPath ? resolve(root, subPath) : root;
|
|
75
|
+
const rel = relative(root, dir);
|
|
76
|
+
if (rel.startsWith('..') || isAbsolute(rel) || !existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
77
|
+
cleanup();
|
|
78
|
+
throw new Error(`no folder "${subPath}" in ${spec}`);
|
|
79
|
+
}
|
|
80
|
+
return { dir, commit, unpinned: parsed.kind === 'github' && !parsed.ref, cleanup };
|
|
81
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Scans a ported plugin dir before it's installed. It strips Unicode tag
|
|
2
|
+
// characters (U+E0000–E007F, invisible prompt-injection carriers), flags risky
|
|
3
|
+
// patterns, checks agy's rule and skill size limits, and infers the capability
|
|
4
|
+
// tier with the same names and meaning as Hangar's TIER_*.
|
|
5
|
+
import { closeSync, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readdirSync, readFileSync, writeSync } from 'node:fs';
|
|
6
|
+
import { extname, join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { extractServerMap } from './manifest-sanitizer.mjs';
|
|
9
|
+
import { RULE_BYTES, RULES_BUDGET_TOKENS } from './rules-compiler.mjs';
|
|
10
|
+
import { splitFrontmatter } from './yaml-lite.mjs';
|
|
11
|
+
|
|
12
|
+
export const TIERS = ['TIER_1_TELEMETRY', 'TIER_2_LOCAL_TOOLING', 'TIER_3_AGENT_CONTROL'];
|
|
13
|
+
const MAX_SCAN_BYTES = 1_000_000;
|
|
14
|
+
const BINARY_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.gz', '.wasm', '.woff', '.woff2', '.ttf', '.exe', '.dll', '.so', '.dylib']);
|
|
15
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
16
|
+
|
|
17
|
+
const TAGS = /[\u{E0000}-\u{E007F}]/gu;
|
|
18
|
+
const BIDI = /[\u202A-\u202E\u2066-\u2069]/;
|
|
19
|
+
const PATTERNS = [
|
|
20
|
+
['pipe-to-shell', 'high', /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/i],
|
|
21
|
+
['pipe-to-shell', 'high', /\b(iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^\n|]*\|\s*(iex|Invoke-Expression)\b/i],
|
|
22
|
+
['decode-and-exec', 'high', /base64\s+(-d|--decode)\b[^\n]*\|\s*(ba|z)?sh\b/i],
|
|
23
|
+
['decode-and-exec', 'high', /\b(eval|exec|Function)\s*\(\s*(atob|Buffer\.from|base64\.b64decode)\s*\(/],
|
|
24
|
+
['decode-and-exec', 'high', /FromBase64String\([^\n]*\)[^\n]*(iex|Invoke-Expression)/i],
|
|
25
|
+
['encoded-powershell', 'high', /\bpowershell(\.exe)?\b[^\n]*\s-(e|ec|enc|encodedcommand)\s+[A-Za-z0-9+/=]{20,}/i],
|
|
26
|
+
];
|
|
27
|
+
const TOKEN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
28
|
+
|
|
29
|
+
export function shannon(s) {
|
|
30
|
+
const counts = {};
|
|
31
|
+
for (const c of s) counts[c] = (counts[c] ?? 0) + 1;
|
|
32
|
+
return Object.values(counts).reduce((h, n) => h - (n / s.length) * Math.log2(n / s.length), 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A long token that reads like a key or an encoded payload, not like an
|
|
36
|
+
// alphabet table, a URL path or a subresource-integrity hash.
|
|
37
|
+
export function looksRandom(token, before = '') {
|
|
38
|
+
if (shannon(token) <= 4.5) return false;
|
|
39
|
+
if (!/[A-Z]/.test(token) || !/[a-z]/.test(token) || !/\d/.test(token)) return false;
|
|
40
|
+
if (/sha(256|384|512)-$/.test(before) || /^sha(256|384|512)-/.test(token)) return false;
|
|
41
|
+
if ((token.match(/\//g) ?? []).length >= 2 && !/[+=]/.test(token)) return false;
|
|
42
|
+
for (let i = 0, run = 1; i < token.length - 1; i++) {
|
|
43
|
+
run = token.charCodeAt(i + 1) === token.charCodeAt(i) + 1 ? run + 1 : 1;
|
|
44
|
+
if (run >= 6) return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function* walk(dir, rel = '') {
|
|
50
|
+
for (const entry of readdirSync(dir)) {
|
|
51
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
52
|
+
const full = join(dir, entry);
|
|
53
|
+
let lst;
|
|
54
|
+
try { lst = lstatSync(full); } catch { continue; }
|
|
55
|
+
if (lst.isSymbolicLink()) continue;
|
|
56
|
+
const path = rel ? `${rel}/${entry}` : entry;
|
|
57
|
+
if (lst.isDirectory()) yield* walk(full, path);
|
|
58
|
+
else if (lst.isFile()) yield { full, path };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
63
|
+
|
|
64
|
+
export function auditText(path, text) {
|
|
65
|
+
const flags = [];
|
|
66
|
+
for (const [kind, severity, re] of PATTERNS) {
|
|
67
|
+
const m = re.exec(text);
|
|
68
|
+
if (m) flags.push({ kind, severity, file: path, line: lineOf(text, m.index), excerpt: m[0].slice(0, 120) });
|
|
69
|
+
}
|
|
70
|
+
if (BIDI.test(text)) flags.push({ kind: 'bidi-control', severity: 'medium', file: path, line: lineOf(text, text.search(BIDI)) });
|
|
71
|
+
// Lockfiles and source maps are full of hashes; skip them for entropy.
|
|
72
|
+
if (!/(\.lock|lock\.json|\.map)$/i.test(path)) {
|
|
73
|
+
for (const m of text.matchAll(TOKEN)) {
|
|
74
|
+
if (looksRandom(m[0], text.slice(Math.max(0, m.index - 7), m.index))) {
|
|
75
|
+
flags.push({ kind: 'high-entropy', severity: 'medium', file: path, line: lineOf(text, m.index), excerpt: `${m[0].slice(0, 12)}…` });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return flags;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readJsonIf(file) {
|
|
84
|
+
try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectHookEvents(obj, events) {
|
|
88
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
89
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
90
|
+
if (k === 'enabled' || k === 'description' || k === 'modules') continue;
|
|
91
|
+
if (Array.isArray(v)) events.add(k);
|
|
92
|
+
else if (v && typeof v === 'object') collectHookEvents(v, events);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// TIER_2: anything that runs a local command (hooks, stdio MCP servers, sidecars).
|
|
97
|
+
// TIER_3: subagents, or hooks that steer the agent loop (Stop, PostInvocation).
|
|
98
|
+
export function inferTier(pluginDir) {
|
|
99
|
+
const reasons = [];
|
|
100
|
+
let rank = 0;
|
|
101
|
+
const events = new Set();
|
|
102
|
+
for (const rel of ['hooks.json', 'hooks/hooks.json', '.claude-plugin/hooks.json']) {
|
|
103
|
+
collectHookEvents(readJsonIf(join(pluginDir, rel)), events);
|
|
104
|
+
}
|
|
105
|
+
for (const rel of ['plugin.json', '.claude-plugin/plugin.json']) {
|
|
106
|
+
const manifest = readJsonIf(join(pluginDir, rel));
|
|
107
|
+
if (manifest?.hooks && typeof manifest.hooks === 'object') collectHookEvents(manifest.hooks, events);
|
|
108
|
+
}
|
|
109
|
+
if (events.size) { rank = Math.max(rank, 1); reasons.push(`hooks: ${[...events].join(', ')}`); }
|
|
110
|
+
if (events.has('Stop') || events.has('PostInvocation')) { rank = 2; reasons.push('a hook can keep the agent running'); }
|
|
111
|
+
const mcpMap = {};
|
|
112
|
+
for (const rel of ['mcp_config.json', '.mcp.json', 'mcp.json']) {
|
|
113
|
+
Object.assign(mcpMap, extractServerMap(readJsonIf(join(pluginDir, rel))));
|
|
114
|
+
}
|
|
115
|
+
for (const rel of ['plugin.json', '.claude-plugin/plugin.json', 'gemini-extension.json']) {
|
|
116
|
+
const manifest = readJsonIf(join(pluginDir, rel));
|
|
117
|
+
if (manifest?.mcpServers && typeof manifest.mcpServers === 'object' && !Array.isArray(manifest.mcpServers)) {
|
|
118
|
+
Object.assign(mcpMap, extractServerMap({ mcpServers: manifest.mcpServers }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const servers = Object.entries(mcpMap);
|
|
122
|
+
if (servers.length) {
|
|
123
|
+
rank = Math.max(rank, 1);
|
|
124
|
+
reasons.push(`MCP servers: ${servers.map(([n]) => n).join(', ')}`);
|
|
125
|
+
}
|
|
126
|
+
const agentsDir = join(pluginDir, 'agents');
|
|
127
|
+
const agents = existsSync(agentsDir) ? readdirSync(agentsDir).filter((f) => f.endsWith('.md')) : [];
|
|
128
|
+
if (agents.length) { rank = 2; reasons.push(`agents: ${agents.map((f) => f.replace(/\.md$/, '')).join(', ')}`); }
|
|
129
|
+
// Sidecars, including ported UI mods, whose mod.json lists their scan's Tier 3 reasons.
|
|
130
|
+
const sidecarsDir = join(pluginDir, 'sidecars');
|
|
131
|
+
const sidecars = existsSync(sidecarsDir) ? readdirSync(sidecarsDir).filter((d) => existsSync(join(sidecarsDir, d, 'sidecar.json'))) : [];
|
|
132
|
+
if (sidecars.length) { rank = Math.max(rank, 1); reasons.push(`sidecars: ${sidecars.join(', ')} (a full Node process outside agy's permission prompts)`); }
|
|
133
|
+
for (const id of sidecars) {
|
|
134
|
+
const mod = readJsonIf(join(sidecarsDir, id, 'mod.json'));
|
|
135
|
+
if (mod?.tier !== TIERS[2]) continue;
|
|
136
|
+
rank = 2;
|
|
137
|
+
reasons.push(`UI mod ${id}: ${(mod.tierReasons ?? []).slice(1).join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
return { tier: TIERS[rank], reasons };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function auditPlugin(pluginDir) {
|
|
143
|
+
const flags = [];
|
|
144
|
+
const warnings = [];
|
|
145
|
+
let alwaysOnBytes = 0;
|
|
146
|
+
for (const { full, path } of walk(pluginDir)) {
|
|
147
|
+
if (BINARY_EXT.has(extname(path).toLowerCase())) continue;
|
|
148
|
+
// One descriptor for the size check, the read and the rewrite, so the file
|
|
149
|
+
// can't be swapped between them.
|
|
150
|
+
const fd = openSync(full, 'r+');
|
|
151
|
+
let text;
|
|
152
|
+
try {
|
|
153
|
+
if (fstatSync(fd).size > MAX_SCAN_BYTES) { warnings.push(`audit: ${path} is over 1 MB and wasn't scanned`); continue; }
|
|
154
|
+
const buffer = readFileSync(fd);
|
|
155
|
+
if (buffer.includes(0)) continue;
|
|
156
|
+
text = buffer.toString('utf8');
|
|
157
|
+
const tags = text.match(TAGS);
|
|
158
|
+
if (tags) {
|
|
159
|
+
text = text.replace(TAGS, '');
|
|
160
|
+
const stripped = Buffer.from(text, 'utf8');
|
|
161
|
+
ftruncateSync(fd, 0);
|
|
162
|
+
writeSync(fd, stripped, 0, stripped.length, 0);
|
|
163
|
+
flags.push({ kind: 'unicode-tags-stripped', severity: 'high', file: path, count: tags.length });
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
flags.push(...auditText(path, text));
|
|
169
|
+
if (/^rules\/[^/]+\.md$/i.test(path)) {
|
|
170
|
+
if (Buffer.byteLength(text) > RULE_BYTES) flags.push({ kind: 'rule-over-limit', severity: 'medium', file: path, bytes: Buffer.byteLength(text) });
|
|
171
|
+
let alwaysOn = /^rules\/agents\.md$/i.test(path);
|
|
172
|
+
try { alwaysOn ||= splitFrontmatter(text).data.trigger === 'always_on'; } catch {}
|
|
173
|
+
if (alwaysOn) alwaysOnBytes += Buffer.byteLength(text);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const tokens = Math.ceil(alwaysOnBytes / 4);
|
|
177
|
+
if (tokens > RULES_BUDGET_TOKENS) flags.push({ kind: 'rules-over-budget', severity: 'medium', tokens });
|
|
178
|
+
return { flags, warnings, ...inferTier(pluginDir) };
|
|
179
|
+
}
|
package/lib/tool-map.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Source tool names -> agy tool names. Claude's comes from `agy plugin validate`
|
|
2
|
+
// 1.4.5 (docs/agy-baseline.md, "Tool names"); where the CLI and the IDE name a
|
|
3
|
+
// tool differently, both are listed. scripts/hook-shim.mjs keeps its own reverse
|
|
4
|
+
// copy because it ships alone inside each ported plugin.
|
|
5
|
+
export const TOOLS = {
|
|
6
|
+
claude: {
|
|
7
|
+
Bash: ['run_command'],
|
|
8
|
+
Read: ['view_file', 'read_file'],
|
|
9
|
+
Write: ['write_to_file', 'write_file'],
|
|
10
|
+
Edit: ['replace_file_content', 'edit_file'],
|
|
11
|
+
MultiEdit: ['multi_replace_file_content', 'edit_file'],
|
|
12
|
+
Grep: ['grep_search'],
|
|
13
|
+
Glob: ['find_by_name'],
|
|
14
|
+
LS: ['list_dir'],
|
|
15
|
+
WebFetch: ['read_url_content'],
|
|
16
|
+
WebSearch: ['search_web'],
|
|
17
|
+
NotebookEdit: ['notebook_edit'],
|
|
18
|
+
Task: ['invoke_subagent'],
|
|
19
|
+
Agent: ['invoke_subagent'],
|
|
20
|
+
},
|
|
21
|
+
gemini: {
|
|
22
|
+
run_shell_command: ['run_command'],
|
|
23
|
+
read_file: ['view_file', 'read_file'],
|
|
24
|
+
write_file: ['write_to_file', 'write_file'],
|
|
25
|
+
replace: ['replace_file_content', 'edit_file'],
|
|
26
|
+
glob: ['find_by_name'],
|
|
27
|
+
search_file_content: ['grep_search'],
|
|
28
|
+
list_directory: ['list_dir'],
|
|
29
|
+
web_fetch: ['read_url_content'],
|
|
30
|
+
google_web_search: ['search_web'],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const AGY_TOOLS = new Set(Object.values(TOOLS).flatMap((m) => Object.values(m).flat()));
|
|
35
|
+
|
|
36
|
+
// Roo/Kilo/Cline mode tool groups.
|
|
37
|
+
export const ROO_GROUPS = {
|
|
38
|
+
read: ['view_file', 'read_file', 'list_dir', 'grep_search', 'find_by_name'],
|
|
39
|
+
edit: ['write_to_file', 'write_file', 'replace_file_content', 'edit_file', 'multi_replace_file_content'],
|
|
40
|
+
command: ['run_command'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function unmappedToolReason(name) {
|
|
44
|
+
if (name === 'TodoWrite' || /^Task(Create|Get|List|Update|Output|Stop)$/.test(name)) return 'agy has no built-in tool that does the same job';
|
|
45
|
+
return 'no agy tool with this name';
|
|
46
|
+
}
|