efaimo 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/CHANGELOG.md +47 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +281 -0
- package/SKILL.md +57 -0
- package/dist/check/check.d.ts +31 -0
- package/dist/check/check.js +77 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +372 -0
- package/dist/clients/introspect.d.ts +8 -0
- package/dist/clients/introspect.js +198 -0
- package/dist/clients/rawprobe.d.ts +69 -0
- package/dist/clients/rawprobe.js +597 -0
- package/dist/clients/stateless.d.ts +7 -0
- package/dist/clients/stateless.js +121 -0
- package/dist/core/engine.d.ts +6 -0
- package/dist/core/engine.js +17 -0
- package/dist/core/grade.d.ts +8 -0
- package/dist/core/grade.js +19 -0
- package/dist/core/types.d.ts +243 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +26 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +88 -0
- package/dist/reporters/badge.d.ts +12 -0
- package/dist/reporters/badge.js +37 -0
- package/dist/reporters/json.d.ts +1 -0
- package/dist/reporters/json.js +10 -0
- package/dist/reporters/markdown.d.ts +7 -0
- package/dist/reporters/markdown.js +121 -0
- package/dist/reporters/pretty.d.ts +15 -0
- package/dist/reporters/pretty.js +221 -0
- package/dist/rules/injection.d.ts +20 -0
- package/dist/rules/injection.js +106 -0
- package/dist/rules/mcp/index.d.ts +4 -0
- package/dist/rules/mcp/index.js +643 -0
- package/dist/rules/skill/index.d.ts +2 -0
- package/dist/rules/skill/index.js +279 -0
- package/dist/skills/parse.d.ts +4 -0
- package/dist/skills/parse.js +113 -0
- package/dist/targets/clientConfigs.d.ts +17 -0
- package/dist/targets/clientConfigs.js +110 -0
- package/dist/targets/repoScan.d.ts +2 -0
- package/dist/targets/repoScan.js +0 -0
- package/dist/targets/resolve.d.ts +28 -0
- package/dist/targets/resolve.js +48 -0
- package/dist/testing/anthropicRunner.d.ts +6 -0
- package/dist/testing/anthropicRunner.js +33 -0
- package/dist/testing/harness.d.ts +45 -0
- package/dist/testing/harness.js +96 -0
- package/dist/testing/openaiRunner.d.ts +12 -0
- package/dist/testing/openaiRunner.js +40 -0
- package/dist/util/childEnv.d.ts +1 -0
- package/dist/util/childEnv.js +43 -0
- package/dist/util/dotenv.d.ts +12 -0
- package/dist/util/dotenv.js +53 -0
- package/dist/util/misc.d.ts +15 -0
- package/dist/util/misc.js +121 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/weigh/anthropicExact.d.ts +11 -0
- package/dist/weigh/anthropicExact.js +46 -0
- package/dist/weigh/diff.d.ts +14 -0
- package/dist/weigh/diff.js +19 -0
- package/dist/weigh/serializers.d.ts +21 -0
- package/dist/weigh/serializers.js +44 -0
- package/dist/weigh/tokens.d.ts +12 -0
- package/dist/weigh/tokens.js +23 -0
- package/dist/weigh/weigh.d.ts +6 -0
- package/dist/weigh/weigh.js +104 -0
- package/docs/INTEGRATIONS.md +119 -0
- package/docs/METHODOLOGY.md +83 -0
- package/docs/RULES.md +76 -0
- package/package.json +77 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { scanTextForInjection } from "../injection.js";
|
|
3
|
+
import { formatTokens } from "../../util/misc.js";
|
|
4
|
+
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
5
|
+
const STANDARD_FIELDS = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools"]);
|
|
6
|
+
function skillLabel(s) {
|
|
7
|
+
return s.name ?? path.basename(path.dirname(s.file));
|
|
8
|
+
}
|
|
9
|
+
const s101 = {
|
|
10
|
+
id: "S101",
|
|
11
|
+
title: "frontmatter does not match the spec",
|
|
12
|
+
surface: "skill",
|
|
13
|
+
check({ skill }) {
|
|
14
|
+
const findings = [];
|
|
15
|
+
const label = skillLabel(skill);
|
|
16
|
+
const push = (severity, message, fixHint) => findings.push({ ruleId: "S101", severity, title: "frontmatter does not match the spec", message, target: label, fixHint });
|
|
17
|
+
if (skill.parseError) {
|
|
18
|
+
push("error", `${skill.file}: ${skill.parseError}`, "SKILL.md must start with a --- YAML frontmatter block");
|
|
19
|
+
if (!skill.frontmatterRaw)
|
|
20
|
+
return findings;
|
|
21
|
+
}
|
|
22
|
+
const name = skill.frontmatter.name;
|
|
23
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
24
|
+
push("error", "required field `name` is missing", "add `name:` matching the skill directory name");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
if (!NAME_RE.test(name)) {
|
|
28
|
+
push("error", `name "${name}" is invalid (spec: lowercase a-z, 0-9, hyphens; no leading/trailing/double hyphen)`);
|
|
29
|
+
}
|
|
30
|
+
if (name.length > 64)
|
|
31
|
+
push("error", `name is ${name.length} chars (spec max 64)`);
|
|
32
|
+
const dirName = path.basename(skill.dir);
|
|
33
|
+
if (name !== dirName) {
|
|
34
|
+
// A release tarball or default clone lands in "<name>-0.1.0" or
|
|
35
|
+
// "<name>-main"; that is packaging, not the author's naming, so it
|
|
36
|
+
// must not fail the skill (or --strict) with a spurious error.
|
|
37
|
+
const suffix = dirName.startsWith(`${name}-`) ? dirName.slice(name.length + 1) : undefined;
|
|
38
|
+
const archiveLike = suffix !== undefined &&
|
|
39
|
+
/^(v?\d+(\.\d+)*([-.][0-9A-Za-z.]+)?|main|master|trunk|[0-9a-f]{7,40})$/.test(suffix);
|
|
40
|
+
push(archiveLike ? "info" : "warn", `name "${name}" does not match its directory name "${dirName}" (agentskills.io spec expects them to match)`, archiveLike
|
|
41
|
+
? "looks like an unpacked archive or clone directory; install the skill under a directory named after it"
|
|
42
|
+
: "rename the directory or the skill; hosts key skills by directory");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const desc = skill.frontmatter.description;
|
|
46
|
+
if (typeof desc !== "string" || !desc.trim()) {
|
|
47
|
+
push("error", "required field `description` is missing", "hosts select skills by description; without one the skill never triggers");
|
|
48
|
+
}
|
|
49
|
+
else if (desc.length > 1024) {
|
|
50
|
+
push("error", `description is ${desc.length} chars (spec max 1024)`);
|
|
51
|
+
}
|
|
52
|
+
const compat = skill.frontmatter.compatibility;
|
|
53
|
+
if (typeof compat === "string" && compat.length > 500) {
|
|
54
|
+
push("warn", `compatibility is ${compat.length} chars (spec max 500)`);
|
|
55
|
+
}
|
|
56
|
+
const allowedTools = skill.frontmatter["allowed-tools"];
|
|
57
|
+
if (allowedTools !== undefined && typeof allowedTools !== "string") {
|
|
58
|
+
push("warn", "`allowed-tools` must be a space-separated string per the spec (found a non-string value)");
|
|
59
|
+
}
|
|
60
|
+
const metadata = skill.frontmatter.metadata;
|
|
61
|
+
if (metadata !== undefined) {
|
|
62
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
63
|
+
push("warn", "`metadata` must be a map of string keys to string values");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const nonString = Object.entries(metadata).filter(([, v]) => typeof v !== "string");
|
|
67
|
+
if (nonString.length)
|
|
68
|
+
push("info", `metadata values should be strings (${nonString.map(([k]) => k).join(", ")})`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const unknown = Object.keys(skill.frontmatter).filter((k) => !STANDARD_FIELDS.has(k));
|
|
72
|
+
for (const k of unknown.slice(0, 4)) {
|
|
73
|
+
push("info", `non-standard frontmatter field \`${k}\` (portable fields: ${[...STANDARD_FIELDS].join(", ")})`);
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const s102 = {
|
|
79
|
+
id: "S102",
|
|
80
|
+
title: "weak trigger description",
|
|
81
|
+
surface: "skill",
|
|
82
|
+
check({ skill }) {
|
|
83
|
+
const desc = skill.description?.trim();
|
|
84
|
+
if (!desc)
|
|
85
|
+
return []; // S101 already errors
|
|
86
|
+
const findings = [];
|
|
87
|
+
const label = skillLabel(skill);
|
|
88
|
+
if (desc.length < 20) {
|
|
89
|
+
findings.push({
|
|
90
|
+
ruleId: "S102",
|
|
91
|
+
severity: "warn",
|
|
92
|
+
title: "weak trigger description",
|
|
93
|
+
message: `description is only ${desc.length} chars; too thin for hosts to match against tasks`,
|
|
94
|
+
target: label,
|
|
95
|
+
fixHint: "state what the skill does AND when to use it, with concrete trigger words",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (!/\buse\b|\bwhen\b|\bfor\b|\bhelps?\b/i.test(desc)) {
|
|
99
|
+
findings.push({
|
|
100
|
+
ruleId: "S102",
|
|
101
|
+
severity: "info",
|
|
102
|
+
title: "weak trigger description",
|
|
103
|
+
message: "description never says when to use the skill; hosts select skills by matching descriptions to tasks",
|
|
104
|
+
target: label,
|
|
105
|
+
fixHint: 'add a "use when ..." clause with the words users actually type',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return findings;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const s103 = {
|
|
112
|
+
id: "S103",
|
|
113
|
+
title: "trigger collision in skill set",
|
|
114
|
+
surface: "skill",
|
|
115
|
+
check({ skill, set }) {
|
|
116
|
+
// Only emit from the first skill to avoid duplicate pair reports.
|
|
117
|
+
if (set.skills.length < 2 || set.skills[0] !== skill)
|
|
118
|
+
return [];
|
|
119
|
+
const findings = [];
|
|
120
|
+
const entries = set.skills
|
|
121
|
+
.filter((s) => s.description)
|
|
122
|
+
.map((s) => ({ label: skillLabel(s), tokens: new Set(normalize(s.description)) }));
|
|
123
|
+
for (let i = 0; i < entries.length && findings.length < 5; i++) {
|
|
124
|
+
for (let j = i + 1; j < entries.length && findings.length < 5; j++) {
|
|
125
|
+
const a = entries[i];
|
|
126
|
+
const b = entries[j];
|
|
127
|
+
const inter = [...a.tokens].filter((t) => b.tokens.has(t)).length;
|
|
128
|
+
const union = new Set([...a.tokens, ...b.tokens]).size;
|
|
129
|
+
if (union > 0 && inter / union > 0.55) {
|
|
130
|
+
findings.push({
|
|
131
|
+
ruleId: "S103",
|
|
132
|
+
severity: "warn",
|
|
133
|
+
title: "trigger collision in skill set",
|
|
134
|
+
message: `skills "${a.label}" and "${b.label}" have ${Math.round((inter / union) * 100)}% overlapping descriptions; hosts may trigger the wrong one`,
|
|
135
|
+
fixHint: "differentiate the descriptions by task and trigger words",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const names = set.skills.map((s) => s.name).filter(Boolean);
|
|
141
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
142
|
+
for (const d of [...new Set(dupes)]) {
|
|
143
|
+
findings.push({
|
|
144
|
+
ruleId: "S103",
|
|
145
|
+
severity: "error",
|
|
146
|
+
title: "trigger collision in skill set",
|
|
147
|
+
message: `duplicate skill name "${d}" in the set`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return findings;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
function normalize(text) {
|
|
154
|
+
return text
|
|
155
|
+
.toLowerCase()
|
|
156
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
157
|
+
.split(/\s+/)
|
|
158
|
+
.filter((w) => w.length > 3);
|
|
159
|
+
}
|
|
160
|
+
const s104 = {
|
|
161
|
+
id: "S104",
|
|
162
|
+
title: "context budget",
|
|
163
|
+
surface: "skill",
|
|
164
|
+
check({ skill, weigh }) {
|
|
165
|
+
if (!weigh)
|
|
166
|
+
return [];
|
|
167
|
+
const entry = weigh.perSkill.find((e) => e.dir === skill.dir);
|
|
168
|
+
if (!entry)
|
|
169
|
+
return [];
|
|
170
|
+
const label = skillLabel(skill);
|
|
171
|
+
const findings = [];
|
|
172
|
+
// The spec targets ~100 tokens but allows a 1024-char description (~256
|
|
173
|
+
// tokens), so only flag metadata that is genuinely heavy, and as info.
|
|
174
|
+
if (entry.metadataTokens > 260) {
|
|
175
|
+
findings.push({
|
|
176
|
+
ruleId: "S104",
|
|
177
|
+
severity: "info",
|
|
178
|
+
title: "context budget",
|
|
179
|
+
message: `metadata level is ~${formatTokens(entry.metadataTokens)} tokens; the spec targets ~100 and this loads at startup for every installed skill`,
|
|
180
|
+
target: label,
|
|
181
|
+
fixHint: "tighten name + description",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (entry.bodyTokens > 5000) {
|
|
185
|
+
findings.push({
|
|
186
|
+
ruleId: "S104",
|
|
187
|
+
severity: "warn",
|
|
188
|
+
title: "context budget",
|
|
189
|
+
message: `instructions are ~${formatTokens(entry.bodyTokens)} tokens (spec recommends staying under 5k)`,
|
|
190
|
+
target: label,
|
|
191
|
+
fixHint: "move detail into references/ files loaded on demand (progressive disclosure)",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (entry.bodyLines > 500) {
|
|
195
|
+
findings.push({
|
|
196
|
+
ruleId: "S104",
|
|
197
|
+
severity: "warn",
|
|
198
|
+
title: "context budget",
|
|
199
|
+
message: `SKILL.md is ${entry.bodyLines} lines (spec recommends under 500)`,
|
|
200
|
+
target: label,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (entry.bodyTokens > 2500 && entry.refFileCount === 0) {
|
|
204
|
+
findings.push({
|
|
205
|
+
ruleId: "S104",
|
|
206
|
+
severity: "info",
|
|
207
|
+
title: "context budget",
|
|
208
|
+
message: "long instructions with no referenced files; consider progressive disclosure (scripts/, references/, assets/)",
|
|
209
|
+
target: label,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return findings;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const s105 = {
|
|
216
|
+
id: "S105",
|
|
217
|
+
title: "possible instruction injection",
|
|
218
|
+
surface: "skill",
|
|
219
|
+
check({ skill }) {
|
|
220
|
+
const label = skillLabel(skill);
|
|
221
|
+
const findings = scanTextForInjection(skill.body, { ruleId: "S105", where: `skill "${label}" body`, cap: 8 });
|
|
222
|
+
if (skill.description) {
|
|
223
|
+
findings.push(...scanTextForInjection(skill.description, {
|
|
224
|
+
ruleId: "S105",
|
|
225
|
+
where: `skill "${label}" description`,
|
|
226
|
+
cap: Math.max(0, 8 - findings.length),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
return findings;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const s106 = {
|
|
233
|
+
id: "S106",
|
|
234
|
+
title: "broken or escaping file references",
|
|
235
|
+
surface: "skill",
|
|
236
|
+
check({ skill }) {
|
|
237
|
+
const findings = [];
|
|
238
|
+
const label = skillLabel(skill);
|
|
239
|
+
for (const ref of skill.referencedPaths) {
|
|
240
|
+
if (findings.length >= 10)
|
|
241
|
+
break;
|
|
242
|
+
if (!ref.exists) {
|
|
243
|
+
// Only a markdown link to a missing file is unambiguously a broken
|
|
244
|
+
// reference. A backticked path is too often an example in prose, so it
|
|
245
|
+
// is not flagged (avoids false positives on well-written skills).
|
|
246
|
+
if (ref.source !== "link")
|
|
247
|
+
continue;
|
|
248
|
+
findings.push({
|
|
249
|
+
ruleId: "S106",
|
|
250
|
+
severity: "error",
|
|
251
|
+
title: "broken or escaping file references",
|
|
252
|
+
message: `references missing file "${ref.raw}"`,
|
|
253
|
+
target: label,
|
|
254
|
+
fixHint: "agents will fail mid-task when they try to open it",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else if (ref.raw.includes("..")) {
|
|
258
|
+
findings.push({
|
|
259
|
+
ruleId: "S106",
|
|
260
|
+
severity: "warn",
|
|
261
|
+
title: "broken or escaping file references",
|
|
262
|
+
message: `reference "${ref.raw}" escapes the skill directory; skills should be self-contained`,
|
|
263
|
+
target: label,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
else if (ref.raw.split("/").length > 2) {
|
|
267
|
+
findings.push({
|
|
268
|
+
ruleId: "S106",
|
|
269
|
+
severity: "info",
|
|
270
|
+
title: "broken or escaping file references",
|
|
271
|
+
message: `reference "${ref.raw}" is nested ${ref.raw.split("/").length - 1} levels deep (spec suggests one level from the skill root)`,
|
|
272
|
+
target: label,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return findings;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
export const SKILL_RULES = [s101, s102, s103, s104, s105, s106];
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SkillInfo, SkillSet } from "../core/types.js";
|
|
2
|
+
export declare function parseSkillFile(file: string): SkillInfo;
|
|
3
|
+
/** Resolve a path (SKILL.md file, skill dir, or a directory of skills) into a SkillSet. */
|
|
4
|
+
export declare function findSkills(input: string): SkillSet;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { walkFiles } from "../util/misc.js";
|
|
5
|
+
const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
6
|
+
// Only backticked strings that look like a real intra-skill path: a directory
|
|
7
|
+
// separator, no glob star. A bare filename in prose (`package.json`, `*.py`) is
|
|
8
|
+
// an instruction, not a file the skill loads.
|
|
9
|
+
const BACKTICK_PATH_RE = /`([^`\s*]*\/[^`\s*]*\.(?:md|txt|py|js|ts|mjs|json|yaml|yml|sh|csv|html))`/g;
|
|
10
|
+
export function parseSkillFile(file) {
|
|
11
|
+
const dir = path.dirname(file);
|
|
12
|
+
const base = {
|
|
13
|
+
dir,
|
|
14
|
+
file,
|
|
15
|
+
frontmatter: {},
|
|
16
|
+
frontmatterRaw: "",
|
|
17
|
+
body: "",
|
|
18
|
+
bodyLines: 0,
|
|
19
|
+
referencedPaths: [],
|
|
20
|
+
files: [],
|
|
21
|
+
};
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = fs.readFileSync(file, "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return { ...base, parseError: `cannot read file: ${e.message}` };
|
|
28
|
+
}
|
|
29
|
+
let body = raw;
|
|
30
|
+
if (/^---\r?\n/.test(raw)) {
|
|
31
|
+
const end = raw.slice(4).search(/^---\s*$/m);
|
|
32
|
+
if (end !== -1) {
|
|
33
|
+
base.frontmatterRaw = raw.slice(4, 4 + end);
|
|
34
|
+
body = raw.slice(4 + end).replace(/^---\s*\r?\n?/, "");
|
|
35
|
+
try {
|
|
36
|
+
const parsed = YAML.parse(base.frontmatterRaw);
|
|
37
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
38
|
+
base.frontmatter = parsed;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
base.parseError = "frontmatter is not a YAML mapping";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
base.parseError = `frontmatter YAML parse error: ${e.message}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
base.parseError = "frontmatter opened with --- but never closed";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
base.parseError = "missing YAML frontmatter (file must start with ---)";
|
|
54
|
+
}
|
|
55
|
+
base.body = body;
|
|
56
|
+
base.bodyLines = body.split(/\r?\n/).length;
|
|
57
|
+
if (typeof base.frontmatter.name === "string")
|
|
58
|
+
base.name = base.frontmatter.name;
|
|
59
|
+
if (typeof base.frontmatter.description === "string")
|
|
60
|
+
base.description = base.frontmatter.description;
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const [re, source] of [
|
|
63
|
+
[MD_LINK_RE, "link"],
|
|
64
|
+
[BACKTICK_PATH_RE, "code"],
|
|
65
|
+
]) {
|
|
66
|
+
re.lastIndex = 0;
|
|
67
|
+
let m;
|
|
68
|
+
while ((m = re.exec(body))) {
|
|
69
|
+
const rawPath = m[1];
|
|
70
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) || // http:, mailto:, etc.
|
|
71
|
+
rawPath.startsWith("#") ||
|
|
72
|
+
rawPath.startsWith("/") ||
|
|
73
|
+
rawPath.includes("*") || // a glob, not a file
|
|
74
|
+
/^[A-Za-z]:[\\/]/.test(rawPath)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const cleaned = rawPath.split("#")[0];
|
|
78
|
+
if (!cleaned || seen.has(cleaned))
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(cleaned);
|
|
81
|
+
const resolved = path.resolve(dir, cleaned);
|
|
82
|
+
base.referencedPaths.push({ raw: cleaned, resolved, exists: fs.existsSync(resolved), source });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const f of walkFiles(dir, { maxDepth: 4 })) {
|
|
86
|
+
try {
|
|
87
|
+
base.files.push({ path: f, bytes: fs.statSync(f).size });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return base;
|
|
94
|
+
}
|
|
95
|
+
/** Resolve a path (SKILL.md file, skill dir, or a directory of skills) into a SkillSet. */
|
|
96
|
+
export function findSkills(input) {
|
|
97
|
+
const abs = path.resolve(input);
|
|
98
|
+
const stat = fs.statSync(abs);
|
|
99
|
+
if (stat.isFile()) {
|
|
100
|
+
return { root: path.dirname(abs), skills: [parseSkillFile(abs)] };
|
|
101
|
+
}
|
|
102
|
+
const direct = path.join(abs, "SKILL.md");
|
|
103
|
+
if (fs.existsSync(direct)) {
|
|
104
|
+
return { root: abs, skills: [parseSkillFile(direct)] };
|
|
105
|
+
}
|
|
106
|
+
const skills = [];
|
|
107
|
+
for (const f of walkFiles(abs, { maxDepth: 3 })) {
|
|
108
|
+
if (path.basename(f) === "SKILL.md")
|
|
109
|
+
skills.push(parseSkillFile(f));
|
|
110
|
+
}
|
|
111
|
+
skills.sort((a, b) => a.file.localeCompare(b.file));
|
|
112
|
+
return { root: abs, skills };
|
|
113
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResolvedTarget } from "./resolve.js";
|
|
2
|
+
export interface ClientServerEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
target: Extract<ResolvedTarget, {
|
|
5
|
+
kind: "stdio" | "http";
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
export interface ClientConfigResult {
|
|
9
|
+
entries: ClientServerEntry[];
|
|
10
|
+
/** Config files that were found and parsed. */
|
|
11
|
+
sources: string[];
|
|
12
|
+
/** Paths that were checked but absent. */
|
|
13
|
+
missing: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare const SUPPORTED_CLIENTS: readonly ["claude-code", "claude-desktop", "cursor", "vscode"];
|
|
16
|
+
export type SupportedClient = (typeof SUPPORTED_CLIENTS)[number];
|
|
17
|
+
export declare function loadClientServers(client: string, cwd?: string): ClientConfigResult;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const SUPPORTED_CLIENTS = ["claude-code", "claude-desktop", "cursor", "vscode"];
|
|
5
|
+
export function loadClientServers(client, cwd = process.cwd()) {
|
|
6
|
+
const home = os.homedir();
|
|
7
|
+
const candidates = [];
|
|
8
|
+
switch (client) {
|
|
9
|
+
case "claude-code":
|
|
10
|
+
candidates.push({ file: path.join(cwd, ".mcp.json"), pick: (j) => topLevel(j, "mcpServers") }, { file: path.join(home, ".claude.json"), pick: (j) => claudeUserConfig(j, cwd) });
|
|
11
|
+
break;
|
|
12
|
+
case "claude-desktop": {
|
|
13
|
+
const file = process.platform === "win32"
|
|
14
|
+
? path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json")
|
|
15
|
+
: process.platform === "darwin"
|
|
16
|
+
? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
17
|
+
: path.join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
18
|
+
candidates.push({ file, pick: (j) => topLevel(j, "mcpServers") });
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
case "cursor":
|
|
22
|
+
candidates.push({ file: path.join(cwd, ".cursor", "mcp.json"), pick: (j) => topLevel(j, "mcpServers") }, { file: path.join(home, ".cursor", "mcp.json"), pick: (j) => topLevel(j, "mcpServers") });
|
|
23
|
+
break;
|
|
24
|
+
case "vscode":
|
|
25
|
+
candidates.push({ file: path.join(cwd, ".vscode", "mcp.json"), pick: (j) => topLevel(j, "servers") });
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
throw new Error(`unknown client "${client}" (supported: ${SUPPORTED_CLIENTS.join(", ")})`);
|
|
29
|
+
}
|
|
30
|
+
const result = { entries: [], sources: [], missing: [] };
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
for (const { file, pick } of candidates) {
|
|
33
|
+
if (!fs.existsSync(file)) {
|
|
34
|
+
result.missing.push(file);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
let json;
|
|
38
|
+
try {
|
|
39
|
+
json = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
result.missing.push(`${file} (unparseable JSON)`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const servers = pick(json);
|
|
46
|
+
if (!servers) {
|
|
47
|
+
result.missing.push(`${file} (no server map found)`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
result.sources.push(file);
|
|
51
|
+
for (const [name, valueRaw] of Object.entries(servers)) {
|
|
52
|
+
if (seen.has(name))
|
|
53
|
+
continue;
|
|
54
|
+
const target = entryToTarget(name, valueRaw, client);
|
|
55
|
+
if (target) {
|
|
56
|
+
seen.add(name);
|
|
57
|
+
result.entries.push({ name, target });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function topLevel(j, key) {
|
|
64
|
+
if (j && typeof j === "object" && key in j) {
|
|
65
|
+
const v = j[key];
|
|
66
|
+
if (v && typeof v === "object")
|
|
67
|
+
return v;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
function samePath(a, b) {
|
|
72
|
+
const ra = path.resolve(a);
|
|
73
|
+
const rb = path.resolve(b);
|
|
74
|
+
// Windows paths are case-insensitive and the drive letter case varies between
|
|
75
|
+
// process.cwd() (uppercase) and stored config keys (often lowercase).
|
|
76
|
+
return process.platform === "win32" ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
|
|
77
|
+
}
|
|
78
|
+
function claudeUserConfig(j, cwd) {
|
|
79
|
+
const merged = {};
|
|
80
|
+
const top = topLevel(j, "mcpServers");
|
|
81
|
+
if (top)
|
|
82
|
+
Object.assign(merged, top);
|
|
83
|
+
const projects = topLevel(j, "projects");
|
|
84
|
+
if (projects) {
|
|
85
|
+
for (const [proj, conf] of Object.entries(projects)) {
|
|
86
|
+
if (samePath(proj, cwd)) {
|
|
87
|
+
const projServers = topLevel(conf, "mcpServers");
|
|
88
|
+
if (projServers)
|
|
89
|
+
Object.assign(merged, projServers);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(merged).length ? merged : undefined;
|
|
94
|
+
}
|
|
95
|
+
function entryToTarget(name, raw, client) {
|
|
96
|
+
if (!raw || typeof raw !== "object")
|
|
97
|
+
return undefined;
|
|
98
|
+
const v = raw;
|
|
99
|
+
const label = `${client}:${name}`;
|
|
100
|
+
if (typeof v.url === "string") {
|
|
101
|
+
const headers = v.headers && typeof v.headers === "object" ? v.headers : undefined;
|
|
102
|
+
return { kind: "http", url: v.url, label, headers };
|
|
103
|
+
}
|
|
104
|
+
if (typeof v.command === "string") {
|
|
105
|
+
const args = Array.isArray(v.args) ? v.args.map(String) : [];
|
|
106
|
+
const env = v.env && typeof v.env === "object" ? v.env : undefined;
|
|
107
|
+
return { kind: "stdio", command: v.command, args, label, env };
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type ResolvedTarget = {
|
|
2
|
+
kind: "stdio";
|
|
3
|
+
command: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
label: string;
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "http";
|
|
9
|
+
url: string;
|
|
10
|
+
label: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "skillset";
|
|
14
|
+
path: string;
|
|
15
|
+
label: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "repo";
|
|
18
|
+
path: string;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
export interface ResolveOptions {
|
|
22
|
+
forceStdio?: boolean;
|
|
23
|
+
forceSkill?: boolean;
|
|
24
|
+
forceRepo?: boolean;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export declare function resolveTarget(input: string, opts?: ResolveOptions): ResolvedTarget;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseCommandString, walkFiles } from "../util/misc.js";
|
|
4
|
+
export function resolveTarget(input, opts = {}) {
|
|
5
|
+
if (opts.forceStdio) {
|
|
6
|
+
const { command, args } = parseCommandString(input);
|
|
7
|
+
if (!command)
|
|
8
|
+
throw new Error(`empty stdio command: "${input}"`);
|
|
9
|
+
return { kind: "stdio", command, args, label: input, env: opts.env };
|
|
10
|
+
}
|
|
11
|
+
if (/^https?:\/\//i.test(input)) {
|
|
12
|
+
return { kind: "http", url: input, label: input, headers: opts.headers };
|
|
13
|
+
}
|
|
14
|
+
const abs = path.resolve(input);
|
|
15
|
+
if (fs.existsSync(abs)) {
|
|
16
|
+
const stat = fs.statSync(abs);
|
|
17
|
+
if (opts.forceSkill)
|
|
18
|
+
return { kind: "skillset", path: abs, label: input };
|
|
19
|
+
if (opts.forceRepo)
|
|
20
|
+
return { kind: "repo", path: abs, label: input };
|
|
21
|
+
if (stat.isFile()) {
|
|
22
|
+
if (abs.toLowerCase().endsWith(".md"))
|
|
23
|
+
return { kind: "skillset", path: abs, label: input };
|
|
24
|
+
throw new Error(`"${input}" is a file but not a SKILL.md; pass a skill file, a directory, a URL, or a stdio command (use --stdio to force command mode)`);
|
|
25
|
+
}
|
|
26
|
+
if (hasSkillFile(abs))
|
|
27
|
+
return { kind: "skillset", path: abs, label: input };
|
|
28
|
+
return { kind: "repo", path: abs, label: input };
|
|
29
|
+
}
|
|
30
|
+
// --skill / --repo assert a filesystem path. If it does not exist, that is an
|
|
31
|
+
// error, never a stdio command to spawn: a read-only flag must not exec.
|
|
32
|
+
if (opts.forceSkill || opts.forceRepo) {
|
|
33
|
+
throw new Error(`"${input}" does not exist (pass an existing ${opts.forceSkill ? "skill" : "repo"} path)`);
|
|
34
|
+
}
|
|
35
|
+
const { command, args } = parseCommandString(input);
|
|
36
|
+
if (!command)
|
|
37
|
+
throw new Error(`cannot resolve target: "${input}"`);
|
|
38
|
+
return { kind: "stdio", command, args, label: input, env: opts.env };
|
|
39
|
+
}
|
|
40
|
+
function hasSkillFile(dir) {
|
|
41
|
+
if (fs.existsSync(path.join(dir, "SKILL.md")))
|
|
42
|
+
return true;
|
|
43
|
+
for (const f of walkFiles(dir, { maxDepth: 3 })) {
|
|
44
|
+
if (path.basename(f) === "SKILL.md")
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live runner. Used only by `efaimo test --live`, because every call spends
|
|
3
|
+
* tokens on the caller's ANTHROPIC_API_KEY.
|
|
4
|
+
*/
|
|
5
|
+
export function anthropicRunner(apiKey) {
|
|
6
|
+
return async (req) => {
|
|
7
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: {
|
|
10
|
+
"content-type": "application/json",
|
|
11
|
+
"x-api-key": apiKey,
|
|
12
|
+
"anthropic-version": "2023-06-01",
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
model: req.model,
|
|
16
|
+
max_tokens: 1024,
|
|
17
|
+
...(req.system ? { system: req.system } : {}),
|
|
18
|
+
messages: [{ role: "user", content: req.user }],
|
|
19
|
+
}),
|
|
20
|
+
redirect: "error",
|
|
21
|
+
signal: AbortSignal.timeout(60000),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const body = await res.text().catch(() => "");
|
|
25
|
+
throw new Error(`anthropic API ${res.status}: ${body.slice(0, 160)}`);
|
|
26
|
+
}
|
|
27
|
+
const json = (await res.json());
|
|
28
|
+
return (json.content ?? [])
|
|
29
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
30
|
+
.map((b) => b.text)
|
|
31
|
+
.join("");
|
|
32
|
+
};
|
|
33
|
+
}
|