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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Turns Claude Code agents and Roo/Kilo modes into agy agents (agents/<name>.md).
|
|
2
|
+
// agy 1.4.x skips a Claude agent outright: `tools: Read, Grep` is a string
|
|
3
|
+
// where agy wants a list, and `model: sonnet` isn't an agy model tier. So
|
|
4
|
+
// tools are mapped to agy tool names, and models to agy's tiers (pro, flash,
|
|
5
|
+
// flash_lite, inherit). Anything that can't be mapped is reported.
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { slugifyName } from './skill-compiler.mjs';
|
|
10
|
+
import { ROO_GROUPS, TOOLS, unmappedToolReason } from './tool-map.mjs';
|
|
11
|
+
import { formatFrontmatter, parseYaml, splitFrontmatter } from './yaml-lite.mjs';
|
|
12
|
+
|
|
13
|
+
// Names agy reserves for built-in agents (seen in agy 1.4.5's subagent list).
|
|
14
|
+
const RESERVED = new Set(['self', 'research', 'image-generator']);
|
|
15
|
+
const CLAUDE_AGENT_KEYS = new Set(['name', 'description', 'tools', 'model']);
|
|
16
|
+
|
|
17
|
+
export function mapClaudeModel(model) {
|
|
18
|
+
if (model === undefined || model === null || model === '') return { tier: undefined };
|
|
19
|
+
const m = String(model).toLowerCase();
|
|
20
|
+
if (m === 'inherit') return { tier: 'inherit' };
|
|
21
|
+
if (m.includes('haiku')) return { tier: 'flash' };
|
|
22
|
+
if (m.includes('opus') || m.includes('sonnet')) return { tier: 'pro' };
|
|
23
|
+
return { tier: undefined, dropped: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Splits `Read, Bash(git diff:*), Agent(a, b)` on top-level commas only.
|
|
27
|
+
export function splitToolList(text) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
let depth = 0;
|
|
30
|
+
let start = 0;
|
|
31
|
+
for (let i = 0; i < text.length; i++) {
|
|
32
|
+
if (text[i] === '(') depth++;
|
|
33
|
+
else if (text[i] === ')') depth = Math.max(0, depth - 1);
|
|
34
|
+
else if (text[i] === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
|
|
35
|
+
}
|
|
36
|
+
parts.push(text.slice(start));
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function mapClaudeTools(tools) {
|
|
41
|
+
const list = Array.isArray(tools) ? tools : splitToolList(String(tools));
|
|
42
|
+
const mapped = new Set();
|
|
43
|
+
const unmapped = [];
|
|
44
|
+
let mcp = false;
|
|
45
|
+
for (const raw of list.map((t) => String(t).trim()).filter(Boolean)) {
|
|
46
|
+
// `Bash(git diff:*)` -> `Bash`, without a regex that backtracks over "(".
|
|
47
|
+
const paren = raw.indexOf('(');
|
|
48
|
+
const name = (paren > 0 && raw.endsWith(')') ? raw.slice(0, paren) : raw).trim();
|
|
49
|
+
if (name.startsWith('mcp__')) mcp = true;
|
|
50
|
+
else if (TOOLS.claude[name]) TOOLS.claude[name].forEach((t) => mapped.add(t));
|
|
51
|
+
else unmapped.push(name);
|
|
52
|
+
}
|
|
53
|
+
return { tools: [...mapped].sort(), unmapped, mcp };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function agentName(raw, slug, report, item) {
|
|
57
|
+
let name = slugifyName(raw);
|
|
58
|
+
if (RESERVED.has(name)) {
|
|
59
|
+
report.warnings.push(`${item}: "${name}" is a built-in agy agent name; renamed to "${slug}-${name}"`);
|
|
60
|
+
name = `${slug}-${name}`;
|
|
61
|
+
}
|
|
62
|
+
return name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compileClaudeAgents(pluginDir, { slug, report, taken }) {
|
|
66
|
+
const dir = join(pluginDir, 'agents');
|
|
67
|
+
if (!existsSync(dir)) return;
|
|
68
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
|
|
69
|
+
const item = `agent:${basename(file, '.md')}`;
|
|
70
|
+
let parsed;
|
|
71
|
+
try { parsed = splitFrontmatter(readFileSync(join(dir, file), 'utf8')); } catch (err) {
|
|
72
|
+
report.unported.push({ item, reason: `frontmatter isn't parseable: ${err.message}` });
|
|
73
|
+
rmSync(join(dir, file));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const { data, body } = parsed;
|
|
77
|
+
const name = agentName(data.name ?? basename(file, '.md'), slug, report, item);
|
|
78
|
+
const out = { name, description: typeof data.description === 'string' && data.description.trim() ? data.description : `Ported agent ${name}.` };
|
|
79
|
+
if (data.tools !== undefined && data.tools !== null && data.tools !== '*') {
|
|
80
|
+
const mapped = mapClaudeTools(data.tools);
|
|
81
|
+
out.tools = mapped.tools;
|
|
82
|
+
for (const t of mapped.unmapped) report.unported.push({ item: `${item} tool ${t}`, reason: unmappedToolReason(t) });
|
|
83
|
+
if (mapped.mcp) {
|
|
84
|
+
out.inheritMcp = true;
|
|
85
|
+
report.warnings.push(`${item}: MCP tools in "tools" became inheritMcp: true (all MCP tools, not just the listed ones)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const model = mapClaudeModel(data.model);
|
|
89
|
+
if (model.tier) out.model = model.tier;
|
|
90
|
+
if (model.dropped) report.warnings.push(`${item}: model "${data.model}" has no agy tier; the agent inherits the session's model`);
|
|
91
|
+
out.subagent = true;
|
|
92
|
+
const extra = Object.keys(data).filter((k) => !CLAUDE_AGENT_KEYS.has(k));
|
|
93
|
+
if (extra.length) report.warnings.push(`${item}: dropped ${extra.join(', ')}`);
|
|
94
|
+
rmSync(join(dir, file));
|
|
95
|
+
writeFileSync(join(dir, `${name}.md`), formatFrontmatter(out, body));
|
|
96
|
+
taken.add(name);
|
|
97
|
+
report.ported.push(`${item} → agents/${name}.md`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readModes(srcDir, report) {
|
|
102
|
+
const modes = [];
|
|
103
|
+
for (const file of ['.roomodes', '.kilocodemodes']) {
|
|
104
|
+
const path = join(srcDir, file);
|
|
105
|
+
if (!existsSync(path)) continue;
|
|
106
|
+
const text = readFileSync(path, 'utf8').replace(/^/, '');
|
|
107
|
+
let parsed;
|
|
108
|
+
try { parsed = text.trim().startsWith('{') ? JSON.parse(text) : parseYaml(text); } catch (err) {
|
|
109
|
+
report.unported.push({ item: file, reason: `can't parse: ${err.message}` });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (const mode of parsed?.customModes ?? []) modes.push({ ...mode, file });
|
|
113
|
+
}
|
|
114
|
+
return modes;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function modeRules(srcDir, slug) {
|
|
118
|
+
const texts = [];
|
|
119
|
+
for (const dir of [`.roo/rules-${slug}`, `.kilocode/rules-${slug}`]) {
|
|
120
|
+
const full = join(srcDir, dir);
|
|
121
|
+
if (!existsSync(full)) continue;
|
|
122
|
+
for (const f of readdirSync(full).filter((x) => /\.(md|txt)$/i.test(x)).sort()) texts.push(readFileSync(join(full, f), 'utf8').trim());
|
|
123
|
+
}
|
|
124
|
+
for (const f of [`.roorules-${slug}`, `.clinerules-${slug}`]) {
|
|
125
|
+
if (existsSync(join(srcDir, f))) texts.push(readFileSync(join(srcDir, f), 'utf8').trim());
|
|
126
|
+
}
|
|
127
|
+
return texts.filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Restricted edit groups (`fileRegex`) aren't granted: agy agents can't limit
|
|
131
|
+
// edits to a pattern, and widening a mode's permissions silently is worse than
|
|
132
|
+
// reporting the gap.
|
|
133
|
+
export function mapRooGroups(groups, item, report) {
|
|
134
|
+
const tools = new Set();
|
|
135
|
+
let mcp = false;
|
|
136
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
137
|
+
const [kind, options] = Array.isArray(group) ? group : [group, null];
|
|
138
|
+
if (kind === 'mcp') { mcp = true; continue; }
|
|
139
|
+
if (!ROO_GROUPS[kind]) {
|
|
140
|
+
report.unported.push({ item: `${item} group ${kind}`, reason: kind === 'browser' ? "agy's browser tool names aren't mapped yet" : 'unknown tool group' });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (options?.fileRegex) {
|
|
144
|
+
report.unported.push({ item: `${item} group ${kind} (${options.fileRegex})`, reason: "agy agents can't restrict edits to a file pattern, so the group isn't granted" });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
ROO_GROUPS[kind].forEach((t) => tools.add(t));
|
|
148
|
+
}
|
|
149
|
+
return { tools: [...tools].sort(), mcp };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compileRooModes(pluginDir, { srcDir, slug, report, taken }) {
|
|
153
|
+
const modes = readModes(srcDir, report);
|
|
154
|
+
if (!modes.length) return;
|
|
155
|
+
const dir = join(pluginDir, 'agents');
|
|
156
|
+
mkdirSync(dir, { recursive: true });
|
|
157
|
+
for (const mode of modes) {
|
|
158
|
+
const item = `mode:${mode.slug ?? '?'}`;
|
|
159
|
+
if (typeof mode.slug !== 'string' || typeof mode.roleDefinition !== 'string') {
|
|
160
|
+
report.unported.push({ item, reason: 'a mode needs slug and roleDefinition' });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const name = agentName(mode.slug, slug, report, item);
|
|
164
|
+
if (taken.has(name)) {
|
|
165
|
+
report.unported.push({ item, reason: `an agent named ${name} already exists in the plugin` });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const { tools, mcp } = mapRooGroups(mode.groups, item, report);
|
|
169
|
+
const description = mode.whenToUse || mode.description || mode.roleDefinition.split(/(?<=\.)\s/)[0];
|
|
170
|
+
const out = { name, description, tools, ...(mcp ? { inheritMcp: true } : {}), mainAgent: true, subagent: true };
|
|
171
|
+
const body = [`# ${mode.name ?? mode.slug}`, mode.roleDefinition.trim(), mode.customInstructions?.trim(), ...modeRules(srcDir, mode.slug)]
|
|
172
|
+
.filter(Boolean).join('\n\n');
|
|
173
|
+
writeFileSync(join(dir, `${name}.md`), formatFrontmatter(out, `\n${body}\n`));
|
|
174
|
+
taken.add(name);
|
|
175
|
+
report.ported.push(`${item} → agents/${name}.md`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function compileModes(pluginDir, { srcDir, ecosystems, slug }) {
|
|
180
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
181
|
+
const taken = new Set();
|
|
182
|
+
if (ecosystems.includes('claude')) compileClaudeAgents(pluginDir, { slug, report, taken });
|
|
183
|
+
if (ecosystems.includes('roo')) compileRooModes(pluginDir, { srcDir, slug, report, taken });
|
|
184
|
+
return report;
|
|
185
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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, 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) && statSync(file).isFile();
|
|
24
|
+
const mdFiles = (dir) => (existsSync(dir) && statSync(dir).isDirectory()
|
|
25
|
+
? readdirSync(dir).filter((f) => /\.(md|mdc|txt)$/i.test(f)).sort().map((f) => join(dir, f))
|
|
26
|
+
: []);
|
|
27
|
+
|
|
28
|
+
function geminiContextFiles(srcDir) {
|
|
29
|
+
let names = ['GEMINI.md'];
|
|
30
|
+
try {
|
|
31
|
+
const declared = readJson(join(srcDir, 'gemini-extension.json')).contextFileName;
|
|
32
|
+
if (typeof declared === 'string') names = [declared];
|
|
33
|
+
else if (Array.isArray(declared)) names = declared.filter((n) => typeof n === 'string');
|
|
34
|
+
} catch {}
|
|
35
|
+
return names;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cursorTrigger(data) {
|
|
39
|
+
const globs = Array.isArray(data.globs) ? data.globs.filter(Boolean).join(',') : (typeof data.globs === 'string' ? data.globs.trim() : '');
|
|
40
|
+
const description = typeof data.description === 'string' ? data.description.trim() : '';
|
|
41
|
+
if (data.alwaysApply === true) return { trigger: 'always_on' };
|
|
42
|
+
if (globs) return { trigger: 'glob', globs, ...(description ? { description } : {}) };
|
|
43
|
+
if (description) return { trigger: 'model_decision', description };
|
|
44
|
+
return { trigger: 'manual' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A glob-scoped or manual Cursor rule becomes a skill the agent can pull in.
|
|
48
|
+
function mdcToSkill(pluginDir, base, trigger, body, item, report) {
|
|
49
|
+
let name = slugifyName(base);
|
|
50
|
+
for (let n = 2; existsSync(join(pluginDir, 'skills', name)); n++) name = `${slugifyName(base)}-${n}`;
|
|
51
|
+
const description = trigger.trigger === 'glob'
|
|
52
|
+
? `Use when working on files matching ${trigger.globs}.${trigger.description ? ` ${trigger.description}` : ''}`
|
|
53
|
+
: `Use only when the user asks for the "${base}" rule.`;
|
|
54
|
+
mkdirSync(join(pluginDir, 'skills', name), { recursive: true });
|
|
55
|
+
writeFileSync(join(pluginDir, 'skills', name, 'SKILL.md'), formatFrontmatter({ name, description }, `\n${body.trim()}\n`));
|
|
56
|
+
report.ported.push(`${item} → skills/${name}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function compileRules(pluginDir, { srcDir, ecosystems }) {
|
|
60
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
61
|
+
const rulesDir = join(pluginDir, 'rules');
|
|
62
|
+
const outputs = [];
|
|
63
|
+
const used = new Set(existsSync(rulesDir) ? readdirSync(rulesDir).map((f) => f.toLowerCase()) : []);
|
|
64
|
+
const add = (file, content, item, alwaysOn) => {
|
|
65
|
+
if (Buffer.byteLength(content) > RULE_BYTES) {
|
|
66
|
+
report.unported.push({ item, reason: `over agy's 24 KB per-rule limit (${Buffer.byteLength(content)} bytes); agy would truncate it` });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let name = file;
|
|
70
|
+
for (let n = 2; used.has(name.toLowerCase()); n++) name = file.replace(/\.md$/, `-${n}.md`);
|
|
71
|
+
used.add(name.toLowerCase());
|
|
72
|
+
outputs.push({ name, content, alwaysOn });
|
|
73
|
+
report.ported.push(`${item} → rules/${name}`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Always-on context files become one rules/AGENTS.md.
|
|
77
|
+
const context = [];
|
|
78
|
+
if (ecosystems.includes('claude')) context.push('CLAUDE.md');
|
|
79
|
+
if (ecosystems.includes('gemini')) context.push(...geminiContextFiles(srcDir));
|
|
80
|
+
if (ecosystems.includes('codex')) context.push('AGENTS.md');
|
|
81
|
+
const sections = [];
|
|
82
|
+
const sources = [];
|
|
83
|
+
for (const name of [...new Set(context)]) {
|
|
84
|
+
const file = join(srcDir, name);
|
|
85
|
+
if (!isFile(file)) continue;
|
|
86
|
+
const text = read(file).trim();
|
|
87
|
+
if (!text) continue;
|
|
88
|
+
if (/^@(?!\[)\S+/m.test(text)) report.warnings.push(`${name}: "@path" imports aren't expanded by agy (it uses @[label](path))`);
|
|
89
|
+
sections.push(text);
|
|
90
|
+
sources.push(name);
|
|
91
|
+
}
|
|
92
|
+
if (sections.length) {
|
|
93
|
+
// Merge with a rules/AGENTS.md the source already shipped.
|
|
94
|
+
if (isFile(join(rulesDir, 'AGENTS.md'))) sections.unshift(read(join(rulesDir, 'AGENTS.md')).trim());
|
|
95
|
+
used.delete('agents.md');
|
|
96
|
+
add('AGENTS.md', `${sections.join('\n\n')}\n`, sources.join(' + '), true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (ecosystems.includes('cursor')) {
|
|
100
|
+
for (const file of mdFiles(join(srcDir, '.cursor/rules')).filter((f) => f.endsWith('.mdc'))) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try { parsed = splitFrontmatter(read(file)); } catch (err) {
|
|
103
|
+
report.unported.push({ item: `.cursor/rules/${basename(file)}`, reason: `frontmatter isn't parseable: ${err.message}` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const trigger = cursorTrigger(parsed.data);
|
|
107
|
+
const item = `.cursor/rules/${basename(file)} (${trigger.trigger})`;
|
|
108
|
+
if (/^@\S+/m.test(parsed.body)) report.warnings.push(`.cursor/rules/${basename(file)}: "@file" references aren't expanded by agy`);
|
|
109
|
+
if (trigger.trigger === 'glob' || trigger.trigger === 'manual') {
|
|
110
|
+
mdcToSkill(pluginDir, basename(file, '.mdc'), trigger, parsed.body, item, report);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
add(`${slugifyName(basename(file, '.mdc'))}.md`, formatFrontmatter(trigger, parsed.body.trim() + '\n'), item, trigger.trigger === 'always_on');
|
|
114
|
+
}
|
|
115
|
+
if (isFile(join(srcDir, '.cursorrules'))) {
|
|
116
|
+
add('cursorrules.md', formatFrontmatter({ trigger: 'always_on' }, `${read(join(srcDir, '.cursorrules')).trim()}\n`), '.cursorrules', true);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (ecosystems.includes('roo')) {
|
|
121
|
+
const workspace = [
|
|
122
|
+
...mdFiles(join(srcDir, '.roo/rules')), ...mdFiles(join(srcDir, '.kilocode/rules')),
|
|
123
|
+
...mdFiles(join(srcDir, '.clinerules')),
|
|
124
|
+
...['.roorules', '.kilocoderules', '.clinerules'].map((f) => join(srcDir, f)).filter(isFile),
|
|
125
|
+
];
|
|
126
|
+
for (const file of workspace) {
|
|
127
|
+
const rel = file.slice(srcDir.length + 1).replace(/\\/g, '/');
|
|
128
|
+
add(`${slugifyName(rel.replace(/\.(md|txt)$/i, ''))}.md`, formatFrontmatter({ trigger: 'always_on' }, `${read(file).trim()}\n`), rel, true);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Root context files agy staged are never loaded from there.
|
|
133
|
+
for (const name of ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md', ...geminiContextFiles(srcDir)]) {
|
|
134
|
+
rmSync(join(pluginDir, name), { force: true });
|
|
135
|
+
}
|
|
136
|
+
if (outputs.length) mkdirSync(rulesDir, { recursive: true });
|
|
137
|
+
for (const { name, content } of outputs) writeFileSync(join(rulesDir, name), content);
|
|
138
|
+
|
|
139
|
+
const alwaysOnTokens = Math.ceil(outputs.filter((o) => o.alwaysOn).reduce((n, o) => n + Buffer.byteLength(o.content), 0) / 4);
|
|
140
|
+
if (alwaysOnTokens > RULES_BUDGET_TOKENS) {
|
|
141
|
+
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`);
|
|
142
|
+
}
|
|
143
|
+
return report;
|
|
144
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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 { formatFrontmatter, splitFrontmatter } from './yaml-lite.mjs';
|
|
10
|
+
|
|
11
|
+
export const SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
12
|
+
const CLAUDE_ONLY_KEYS = ['argument-hint', 'allowed-tools', 'model', 'disable-model-invocation'];
|
|
13
|
+
const ARGS = /\$ARGUMENTS\b|\{\{\s*args\s*\}\}|\$[1-9]\b/g;
|
|
14
|
+
const SKILL_BYTES = 24_000;
|
|
15
|
+
|
|
16
|
+
const dirs = (dir) => (existsSync(dir) ? readdirSync(dir).filter((d) => statSync(join(dir, d)).isDirectory()) : []);
|
|
17
|
+
|
|
18
|
+
// Linear on any input: no regex that can backtrack over runs of "-".
|
|
19
|
+
export function slugifyName(text) {
|
|
20
|
+
const slug = String(text).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join('-').slice(0, 64);
|
|
21
|
+
return (slug.endsWith('-') ? slug.slice(0, -1) : slug) || 'skill';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function firstLine(body) {
|
|
25
|
+
const line = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find((l) => l);
|
|
26
|
+
return line ? line.slice(0, 200) : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Rewrites one SKILL.md in place. Returns the skill's final folder name.
|
|
30
|
+
export function normalizeSkill(skillsDir, folder, report, { fromCommand = false } = {}) {
|
|
31
|
+
const file = join(skillsDir, folder, 'SKILL.md');
|
|
32
|
+
const text = readFileSync(file, 'utf8');
|
|
33
|
+
let { data, body, hasFrontmatter } = (() => {
|
|
34
|
+
try { return splitFrontmatter(text); } catch (err) {
|
|
35
|
+
report.warnings.push(`skill:${folder}: frontmatter isn't parseable (${err.message}); rebuilt from the folder name`);
|
|
36
|
+
return { data: {}, body: text.replace(/^---\n[\s\S]*?\n---\n?/, ''), hasFrontmatter: false };
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
const notes = [];
|
|
40
|
+
let name = typeof data.name === 'string' && SKILL_NAME.test(data.name) ? data.name : null;
|
|
41
|
+
let finalFolder = folder;
|
|
42
|
+
if (name && name !== folder) {
|
|
43
|
+
if (!existsSync(join(skillsDir, name))) {
|
|
44
|
+
renameSync(join(skillsDir, folder), join(skillsDir, name));
|
|
45
|
+
finalFolder = name;
|
|
46
|
+
} else {
|
|
47
|
+
notes.push(`renamed from "${name}" to match its folder`);
|
|
48
|
+
name = folder;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!name) name = SKILL_NAME.test(folder) ? folder : slugifyName(folder);
|
|
52
|
+
if (name !== finalFolder && !existsSync(join(skillsDir, name))) {
|
|
53
|
+
// agy names a skill after its frontmatter and wants the folder to match.
|
|
54
|
+
renameSync(join(skillsDir, finalFolder), join(skillsDir, name));
|
|
55
|
+
finalFolder = name;
|
|
56
|
+
} else if (name !== finalFolder) {
|
|
57
|
+
name = finalFolder;
|
|
58
|
+
}
|
|
59
|
+
const out = { name, ...data };
|
|
60
|
+
out.name = name;
|
|
61
|
+
const dropped = CLAUDE_ONLY_KEYS.filter((k) => k in out);
|
|
62
|
+
dropped.forEach((k) => delete out[k]);
|
|
63
|
+
if (dropped.length) notes.push(`dropped ${dropped.join(', ')}`);
|
|
64
|
+
if (typeof out.description !== 'string' || !out.description.trim()) {
|
|
65
|
+
out.description = firstLine(body) || `Ported skill ${name}.`;
|
|
66
|
+
notes.push('description taken from the body');
|
|
67
|
+
}
|
|
68
|
+
const replaced = body.match(ARGS);
|
|
69
|
+
if (replaced) {
|
|
70
|
+
body = body.replace(ARGS, "the user's request");
|
|
71
|
+
notes.push(`replaced ${[...new Set(replaced)].join(', ')} with "the user's request" (skills take no arguments)`);
|
|
72
|
+
}
|
|
73
|
+
if (/^!`|!\{[^}]*\}/m.test(body)) {
|
|
74
|
+
report.warnings.push(`skill:${name}: inline shell execution (!\`…\` / !{…}) isn't supported by agy and is left as text`);
|
|
75
|
+
}
|
|
76
|
+
const rewritten = formatFrontmatter(out, body);
|
|
77
|
+
const changed = !hasFrontmatter || rewritten !== text.replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
78
|
+
if (changed) writeFileSync(join(skillsDir, finalFolder, 'SKILL.md'), rewritten);
|
|
79
|
+
if (Buffer.byteLength(body) > SKILL_BYTES) report.warnings.push(`skill:${name}: body is over agy's 24 KB skill limit`);
|
|
80
|
+
if (notes.length) report.warnings.push(`skill:${name}: ${notes.join('; ')}`);
|
|
81
|
+
report.ported.push(fromCommand ? `command → skill:${name}` : `skill:${name}`);
|
|
82
|
+
return finalFolder;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Command skills agy made: Claude's are `<plugin>-cmd-<x>`, Gemini's are named
|
|
86
|
+
// after the .toml file. Anything that came from commands/ is "fromCommand".
|
|
87
|
+
function commandNames(srcDir) {
|
|
88
|
+
const names = new Set();
|
|
89
|
+
const walk = (dir) => {
|
|
90
|
+
for (const entry of existsSync(dir) ? readdirSync(dir) : []) {
|
|
91
|
+
const full = join(dir, entry);
|
|
92
|
+
if (statSync(full).isDirectory()) walk(full);
|
|
93
|
+
else names.add(entry.replace(/\.(md|toml)$/i, ''));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(join(srcDir, 'commands'));
|
|
97
|
+
return names;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function compileSkills(pluginDir, { srcDir, ecosystems }) {
|
|
101
|
+
const report = { ported: [], unported: [], warnings: [] };
|
|
102
|
+
const skillsDir = join(pluginDir, 'skills');
|
|
103
|
+
const commands = commandNames(srcDir);
|
|
104
|
+
|
|
105
|
+
if (ecosystems.includes('codex')) {
|
|
106
|
+
for (const folder of dirs(join(srcDir, '.agents/skills'))) {
|
|
107
|
+
if (!existsSync(join(srcDir, '.agents/skills', folder, 'SKILL.md'))) continue;
|
|
108
|
+
if (existsSync(join(skillsDir, folder))) {
|
|
109
|
+
report.unported.push({ item: `skill:${folder}`, reason: 'a skill with this name already exists in the plugin' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
cpSync(join(srcDir, '.agents/skills', folder), join(skillsDir, folder), { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const folder of dirs(skillsDir)) {
|
|
117
|
+
if (!existsSync(join(skillsDir, folder, 'SKILL.md'))) {
|
|
118
|
+
report.warnings.push(`skills/${folder}: no SKILL.md, so agy ignores it`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const fromCommand = commands.has(folder) || [...commands].some((c) => folder.endsWith(`-cmd-${c}`));
|
|
122
|
+
normalizeSkill(skillsDir, folder, report, { fromCommand });
|
|
123
|
+
}
|
|
124
|
+
if (existsSync(join(pluginDir, 'commands'))) {
|
|
125
|
+
const converted = report.ported.filter((p) => p.startsWith('command')).length;
|
|
126
|
+
if (converted < commands.size) {
|
|
127
|
+
report.unported.push({ item: 'commands/', reason: `${commands.size - converted} command(s) weren't converted to skills by agy` });
|
|
128
|
+
}
|
|
129
|
+
rmSync(join(pluginDir, 'commands'), { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
return report;
|
|
132
|
+
}
|
package/lib/source.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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 { existsSync, mkdtempSync, 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
|
+
export function parseSourceSpec(spec) {
|
|
21
|
+
if (existsSync(spec)) return { kind: 'path', path: resolve(spec) };
|
|
22
|
+
const m = GITHUB.exec(spec);
|
|
23
|
+
if (!m || m[3]?.includes('..')) throw new Error(`source "${spec}" is neither an existing path nor owner/repo[@ref]`);
|
|
24
|
+
return { kind: 'github', owner: m[1], repo: m[2], ref: m[3] ?? null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns {dir, commit, cleanup}. `subPath` picks a folder inside the source.
|
|
28
|
+
export function resolveSource(spec, { subPath } = {}) {
|
|
29
|
+
const parsed = parseSourceSpec(spec);
|
|
30
|
+
let root;
|
|
31
|
+
let commit = null;
|
|
32
|
+
let cleanup = () => {};
|
|
33
|
+
if (parsed.kind === 'path') {
|
|
34
|
+
root = parsed.path;
|
|
35
|
+
} else {
|
|
36
|
+
root = mkdtempSync(join(tmpdir(), 'agy-porter-src-'));
|
|
37
|
+
cleanup = () => rmSync(root, { recursive: true, force: true });
|
|
38
|
+
try {
|
|
39
|
+
const ref = parsed.ref ?? 'HEAD';
|
|
40
|
+
if (ref.startsWith('-')) throw new Error(`invalid ref "${ref}"`);
|
|
41
|
+
git(['init', '-q'], root);
|
|
42
|
+
// "--": git reads everything after it as a repository or refspec, never an option.
|
|
43
|
+
git(['fetch', '-q', '--depth', '1', '--', `https://github.com/${parsed.owner}/${parsed.repo}.git`, ref], root);
|
|
44
|
+
git(['checkout', '-q', 'FETCH_HEAD'], root);
|
|
45
|
+
commit = git(['rev-parse', 'HEAD'], root);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
cleanup();
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const dir = subPath ? resolve(root, subPath) : root;
|
|
52
|
+
const rel = relative(root, dir);
|
|
53
|
+
if (rel.startsWith('..') || isAbsolute(rel) || !existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
54
|
+
cleanup();
|
|
55
|
+
throw new Error(`no folder "${subPath}" in ${spec}`);
|
|
56
|
+
}
|
|
57
|
+
return { dir, commit, unpinned: parsed.kind === 'github' && !parsed.ref, cleanup };
|
|
58
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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, openSync, readdirSync, readFileSync, statSync, writeSync } from 'node:fs';
|
|
6
|
+
import { extname, join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { RULE_BYTES, RULES_BUDGET_TOKENS } from './rules-compiler.mjs';
|
|
9
|
+
import { splitFrontmatter } from './yaml-lite.mjs';
|
|
10
|
+
|
|
11
|
+
export const TIERS = ['TIER_1_TELEMETRY', 'TIER_2_LOCAL_TOOLING', 'TIER_3_AGENT_CONTROL'];
|
|
12
|
+
const MAX_SCAN_BYTES = 1_000_000;
|
|
13
|
+
const BINARY_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.gz', '.wasm', '.woff', '.woff2', '.ttf', '.exe', '.dll', '.so', '.dylib']);
|
|
14
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
15
|
+
|
|
16
|
+
const TAGS = /[\u{E0000}-\u{E007F}]/gu;
|
|
17
|
+
const BIDI = /[--]/;
|
|
18
|
+
const PATTERNS = [
|
|
19
|
+
['pipe-to-shell', 'high', /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/i],
|
|
20
|
+
['pipe-to-shell', 'high', /\b(iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^\n|]*\|\s*(iex|Invoke-Expression)\b/i],
|
|
21
|
+
['decode-and-exec', 'high', /base64\s+(-d|--decode)\b[^\n]*\|\s*(ba|z)?sh\b/i],
|
|
22
|
+
['decode-and-exec', 'high', /\b(eval|exec|Function)\s*\(\s*(atob|Buffer\.from|base64\.b64decode)\s*\(/],
|
|
23
|
+
['decode-and-exec', 'high', /FromBase64String\([^\n]*\)[^\n]*(iex|Invoke-Expression)/i],
|
|
24
|
+
['encoded-powershell', 'high', /\bpowershell(\.exe)?\b[^\n]*\s-(e|ec|enc|encodedcommand)\s+[A-Za-z0-9+/=]{20,}/i],
|
|
25
|
+
];
|
|
26
|
+
const TOKEN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
27
|
+
|
|
28
|
+
export function shannon(s) {
|
|
29
|
+
const counts = {};
|
|
30
|
+
for (const c of s) counts[c] = (counts[c] ?? 0) + 1;
|
|
31
|
+
return Object.values(counts).reduce((h, n) => h - (n / s.length) * Math.log2(n / s.length), 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A long token that reads like a key or an encoded payload, not like an
|
|
35
|
+
// alphabet table, a URL path or a subresource-integrity hash.
|
|
36
|
+
export function looksRandom(token, before = '') {
|
|
37
|
+
if (shannon(token) <= 4.5) return false;
|
|
38
|
+
if (!/[A-Z]/.test(token) || !/[a-z]/.test(token) || !/\d/.test(token)) return false;
|
|
39
|
+
if (/sha(256|384|512)-$/.test(before) || /^sha(256|384|512)-/.test(token)) return false;
|
|
40
|
+
if ((token.match(/\//g) ?? []).length >= 2 && !/[+=]/.test(token)) return false;
|
|
41
|
+
for (let i = 0, run = 1; i < token.length - 1; i++) {
|
|
42
|
+
run = token.charCodeAt(i + 1) === token.charCodeAt(i) + 1 ? run + 1 : 1;
|
|
43
|
+
if (run >= 6) return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function* walk(dir, rel = '') {
|
|
49
|
+
for (const entry of readdirSync(dir)) {
|
|
50
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
51
|
+
const full = join(dir, entry);
|
|
52
|
+
const path = rel ? `${rel}/${entry}` : entry;
|
|
53
|
+
if (statSync(full).isDirectory()) yield* walk(full, path);
|
|
54
|
+
else yield { full, path };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
59
|
+
|
|
60
|
+
export function auditText(path, text) {
|
|
61
|
+
const flags = [];
|
|
62
|
+
for (const [kind, severity, re] of PATTERNS) {
|
|
63
|
+
const m = re.exec(text);
|
|
64
|
+
if (m) flags.push({ kind, severity, file: path, line: lineOf(text, m.index), excerpt: m[0].slice(0, 120) });
|
|
65
|
+
}
|
|
66
|
+
if (BIDI.test(text)) flags.push({ kind: 'bidi-control', severity: 'medium', file: path, line: lineOf(text, text.search(BIDI)) });
|
|
67
|
+
// Lockfiles and source maps are full of hashes; skip them for entropy.
|
|
68
|
+
if (!/(\.lock|lock\.json|\.map)$/i.test(path)) {
|
|
69
|
+
for (const m of text.matchAll(TOKEN)) {
|
|
70
|
+
if (looksRandom(m[0], text.slice(Math.max(0, m.index - 7), m.index))) {
|
|
71
|
+
flags.push({ kind: 'high-entropy', severity: 'medium', file: path, line: lineOf(text, m.index), excerpt: `${m[0].slice(0, 12)}…` });
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return flags;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readJsonIf(file) {
|
|
80
|
+
try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// TIER_2: anything that runs a local command (hooks, stdio MCP servers).
|
|
84
|
+
// TIER_3: subagents, or hooks that steer the agent loop (Stop, PostInvocation).
|
|
85
|
+
export function inferTier(pluginDir) {
|
|
86
|
+
const reasons = [];
|
|
87
|
+
let rank = 0;
|
|
88
|
+
const hooks = readJsonIf(join(pluginDir, 'hooks.json')) ?? {};
|
|
89
|
+
const events = new Set(Object.values(hooks).flatMap((h) => Object.keys(h ?? {})).filter((e) => e !== 'enabled'));
|
|
90
|
+
if (events.size) { rank = Math.max(rank, 1); reasons.push(`hooks: ${[...events].join(', ')}`); }
|
|
91
|
+
if (events.has('Stop') || events.has('PostInvocation')) { rank = 2; reasons.push('a hook can keep the agent running'); }
|
|
92
|
+
const servers = Object.entries(readJsonIf(join(pluginDir, 'mcp_config.json'))?.mcpServers ?? {});
|
|
93
|
+
if (servers.length) {
|
|
94
|
+
rank = Math.max(rank, 1);
|
|
95
|
+
reasons.push(`MCP servers: ${servers.map(([n]) => n).join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
const agentsDir = join(pluginDir, 'agents');
|
|
98
|
+
const agents = existsSync(agentsDir) ? readdirSync(agentsDir).filter((f) => f.endsWith('.md')) : [];
|
|
99
|
+
if (agents.length) { rank = 2; reasons.push(`agents: ${agents.map((f) => f.replace(/\.md$/, '')).join(', ')}`); }
|
|
100
|
+
return { tier: TIERS[rank], reasons };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function auditPlugin(pluginDir) {
|
|
104
|
+
const flags = [];
|
|
105
|
+
const warnings = [];
|
|
106
|
+
let alwaysOnBytes = 0;
|
|
107
|
+
for (const { full, path } of walk(pluginDir)) {
|
|
108
|
+
if (BINARY_EXT.has(extname(path).toLowerCase())) continue;
|
|
109
|
+
// One descriptor for the size check, the read and the rewrite, so the file
|
|
110
|
+
// can't be swapped between them.
|
|
111
|
+
const fd = openSync(full, 'r+');
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
if (fstatSync(fd).size > MAX_SCAN_BYTES) { warnings.push(`audit: ${path} is over 1 MB and wasn't scanned`); continue; }
|
|
115
|
+
const buffer = readFileSync(fd);
|
|
116
|
+
if (buffer.includes(0)) continue;
|
|
117
|
+
text = buffer.toString('utf8');
|
|
118
|
+
const tags = text.match(TAGS);
|
|
119
|
+
if (tags) {
|
|
120
|
+
text = text.replace(TAGS, '');
|
|
121
|
+
const stripped = Buffer.from(text, 'utf8');
|
|
122
|
+
ftruncateSync(fd, 0);
|
|
123
|
+
writeSync(fd, stripped, 0, stripped.length, 0);
|
|
124
|
+
flags.push({ kind: 'unicode-tags-stripped', severity: 'high', file: path, count: tags.length });
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
closeSync(fd);
|
|
128
|
+
}
|
|
129
|
+
flags.push(...auditText(path, text));
|
|
130
|
+
if (/^rules\/[^/]+\.md$/i.test(path)) {
|
|
131
|
+
if (Buffer.byteLength(text) > RULE_BYTES) flags.push({ kind: 'rule-over-limit', severity: 'medium', file: path, bytes: Buffer.byteLength(text) });
|
|
132
|
+
let alwaysOn = /^rules\/agents\.md$/i.test(path);
|
|
133
|
+
try { alwaysOn ||= splitFrontmatter(text).data.trigger === 'always_on'; } catch {}
|
|
134
|
+
if (alwaysOn) alwaysOnBytes += Buffer.byteLength(text);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const tokens = Math.ceil(alwaysOnBytes / 4);
|
|
138
|
+
if (tokens > RULES_BUDGET_TOKENS) flags.push({ kind: 'rules-over-budget', severity: 'medium', tokens });
|
|
139
|
+
return { flags, warnings, ...inferTier(pluginDir) };
|
|
140
|
+
}
|