@yogiadhik/skillcheck 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 +79 -0
- package/dist/adapters/claude-code.js +59 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cache.js +48 -0
- package/dist/cli.js +77 -0
- package/dist/expect.js +47 -0
- package/dist/init.js +27 -0
- package/dist/judge.js +71 -0
- package/dist/lint/lint.js +35 -0
- package/dist/lint/rules.js +57 -0
- package/dist/reporters/github.js +6 -0
- package/dist/reporters/terminal.js +27 -0
- package/dist/runner.js +119 -0
- package/dist/skill.js +37 -0
- package/dist/transcript.js +34 -0
- package/dist/workspace.js +49 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yogesh Adhikari
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# skillcheck
|
|
2
|
+
|
|
3
|
+
Tests for your agent skills.
|
|
4
|
+
|
|
5
|
+
Skills fail silently. A bad edit to a description and the skill stops
|
|
6
|
+
triggering. A reworded instruction and the agent starts doing something
|
|
7
|
+
you didn't mean. You find out weeks later, in someone else's session.
|
|
8
|
+
|
|
9
|
+
skillcheck gives skills what code has had for fifty years: a test suite.
|
|
10
|
+
|
|
11
|
+
npx @yogiadhik/skillcheck lint .claude/skills/
|
|
12
|
+
npx @yogiadhik/skillcheck run .claude/skills/commit-helper
|
|
13
|
+
|
|
14
|
+
`lint` is free and instant: frontmatter problems, weak descriptions
|
|
15
|
+
(the description is the only thing the agent reads when deciding to
|
|
16
|
+
trigger your skill), token cost, broken relative links, dangerous
|
|
17
|
+
patterns. `run` is the real thing: it copies a fixture into a throwaway
|
|
18
|
+
workspace, mounts your skill, runs the agent headless, and tells you
|
|
19
|
+
what actually happened — which files changed, which commands ran,
|
|
20
|
+
whether the skill fired at all.
|
|
21
|
+
|
|
22
|
+
## writing a test
|
|
23
|
+
|
|
24
|
+
One YAML file next to your skill. Deterministic checks cost nothing
|
|
25
|
+
extra; a judge rubric is there for the fuzzy parts.
|
|
26
|
+
|
|
27
|
+
# commit-helper.test.yaml
|
|
28
|
+
skill: commit-helper
|
|
29
|
+
cases:
|
|
30
|
+
- name: writes conventional commit
|
|
31
|
+
prompt: "commit my staged changes"
|
|
32
|
+
setup: fixtures/dirty-repo
|
|
33
|
+
expect:
|
|
34
|
+
files_changed: []
|
|
35
|
+
command_ran: "git commit"
|
|
36
|
+
transcript_matches: "(feat|fix|chore):"
|
|
37
|
+
judge: |
|
|
38
|
+
Did the commit message accurately describe the staged diff?
|
|
39
|
+
Score 0-10.
|
|
40
|
+
|
|
41
|
+
`skill:` is the path from the test file to the skill directory.
|
|
42
|
+
`expect:` supports files_changed, command_ran, transcript_matches and
|
|
43
|
+
skill_invoked. `judge:` sends the transcript and your rubric to a cheap
|
|
44
|
+
model; pass is 7/10 by default (set judge_pass to change it).
|
|
45
|
+
|
|
46
|
+
Run `npx @yogiadhik/skillcheck init <skill-dir>` to scaffold a starter file.
|
|
47
|
+
|
|
48
|
+
## cost
|
|
49
|
+
|
|
50
|
+
Behavioral runs call the real agent, and that costs real money.
|
|
51
|
+
skillcheck caches results by content hash — unchanged skill, unchanged
|
|
52
|
+
case, no re-run — and `--budget 2.50` hard-stops a run at the cap.
|
|
53
|
+
Lint never spends anything.
|
|
54
|
+
|
|
55
|
+
## CI
|
|
56
|
+
|
|
57
|
+
- uses: YogiAdhik/skillcheck/action@v0.1.0
|
|
58
|
+
with:
|
|
59
|
+
path: .claude/skills
|
|
60
|
+
|
|
61
|
+
That runs lint with GitHub annotations on every pull request. For
|
|
62
|
+
behavioral runs in CI, call `npx @yogiadhik/skillcheck run` yourself in a job that
|
|
63
|
+
has your agent authenticated; keep a budget flag on it.
|
|
64
|
+
|
|
65
|
+
## a note on safety
|
|
66
|
+
|
|
67
|
+
`run` executes the agent with permission prompts disabled, inside a
|
|
68
|
+
throwaway temp directory. That's the point — but it means fixtures
|
|
69
|
+
should be copies, never live data, and skills you don't trust should be
|
|
70
|
+
linted, not run.
|
|
71
|
+
|
|
72
|
+
## adapters
|
|
73
|
+
|
|
74
|
+
Claude Code today. An adapter is a name and one function that takes a
|
|
75
|
+
prompt plus a working directory and returns a transcript; if you want
|
|
76
|
+
Codex or Gemini support, `src/adapters/` is where it goes and I'll take
|
|
77
|
+
the pull request.
|
|
78
|
+
|
|
79
|
+
MIT.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
import { parseStreamJson } from '../transcript.js';
|
|
3
|
+
export function buildArgs(opts) {
|
|
4
|
+
const args = [
|
|
5
|
+
'-p',
|
|
6
|
+
opts.prompt,
|
|
7
|
+
'--output-format',
|
|
8
|
+
'stream-json',
|
|
9
|
+
'--verbose',
|
|
10
|
+
'--max-turns',
|
|
11
|
+
String(opts.maxTurns ?? 30),
|
|
12
|
+
];
|
|
13
|
+
if (!opts.bare)
|
|
14
|
+
args.push('--dangerously-skip-permissions');
|
|
15
|
+
if (opts.model)
|
|
16
|
+
args.push('--model', opts.model);
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
export const claudeCode = {
|
|
20
|
+
name: 'claude-code',
|
|
21
|
+
run(opts) {
|
|
22
|
+
return new Promise((done, fail) => {
|
|
23
|
+
const child = spawn('claude', buildArgs(opts), {
|
|
24
|
+
cwd: opts.cwd,
|
|
25
|
+
env: process.env,
|
|
26
|
+
detached: process.platform !== 'win32',
|
|
27
|
+
});
|
|
28
|
+
let out = '';
|
|
29
|
+
let err = '';
|
|
30
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
child.kill('SIGKILL');
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
child.kill('SIGKILL');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
fail(new Error(`agent run timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
child.stdout?.on('data', (d) => (out += d));
|
|
46
|
+
child.stderr?.on('data', (d) => (err += d));
|
|
47
|
+
child.on('error', (e) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
fail(e);
|
|
50
|
+
});
|
|
51
|
+
child.on('close', (code) => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (code !== 0 && !out.trim())
|
|
54
|
+
return fail(new Error(`claude exited ${code}: ${err.slice(0, 500)}`));
|
|
55
|
+
done(parseStreamJson(out));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export function hashInputs(dirs, caseYaml) {
|
|
5
|
+
const h = createHash('sha1').update(caseYaml).update('\0');
|
|
6
|
+
const walk = (dir, prefix) => {
|
|
7
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
const rel = prefix + entry.name;
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
h.update('d\0' + rel + '\0');
|
|
12
|
+
walk(join(dir, entry.name), rel + '/');
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
h.update('f\0' + rel + '\0').update(readFileSync(join(dir, entry.name))).update('\0');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const dirList = Array.isArray(dirs) ? dirs : [dirs];
|
|
20
|
+
dirList.forEach((dir, i) => {
|
|
21
|
+
h.update(`dir${i}\0`);
|
|
22
|
+
walk(dir, '');
|
|
23
|
+
});
|
|
24
|
+
return h.digest('hex');
|
|
25
|
+
}
|
|
26
|
+
export class ResultCache {
|
|
27
|
+
path;
|
|
28
|
+
data = {};
|
|
29
|
+
constructor(path) {
|
|
30
|
+
this.path = path;
|
|
31
|
+
if (existsSync(path)) {
|
|
32
|
+
try {
|
|
33
|
+
this.data = JSON.parse(readFileSync(path, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
this.data = {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
get(key) {
|
|
41
|
+
return this.data[key];
|
|
42
|
+
}
|
|
43
|
+
set(key, value) {
|
|
44
|
+
this.data[key] = value;
|
|
45
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
46
|
+
writeFileSync(this.path, JSON.stringify(this.data, null, 2));
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { initTestFile } from './init.js';
|
|
4
|
+
import { lintPath } from './lint/lint.js';
|
|
5
|
+
import { printGithubAnnotations } from './reporters/github.js';
|
|
6
|
+
import { printFindings, printReports } from './reporters/terminal.js';
|
|
7
|
+
import { discoverTestFiles, reportsPass, runTests } from './runner.js';
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name('skillcheck')
|
|
11
|
+
.description('lint and behavioral tests for agent skills')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
program
|
|
14
|
+
.command('lint')
|
|
15
|
+
.argument('[path]', 'skill file or directory', '.')
|
|
16
|
+
.option('--json', 'machine-readable output')
|
|
17
|
+
.option('--github', 'github actions annotations')
|
|
18
|
+
.action((path, opts) => {
|
|
19
|
+
try {
|
|
20
|
+
const findings = lintPath(path);
|
|
21
|
+
if (opts.json)
|
|
22
|
+
console.log(JSON.stringify(findings, null, 2));
|
|
23
|
+
else if (opts.github)
|
|
24
|
+
printGithubAnnotations(findings);
|
|
25
|
+
else
|
|
26
|
+
printFindings(findings);
|
|
27
|
+
process.exitCode = findings.some((f) => f.severity === 'error') ? 1 : 0;
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
console.error(e.message);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
program
|
|
36
|
+
.command('run')
|
|
37
|
+
.argument('[path]', 'directory with *.test.yaml files', '.')
|
|
38
|
+
.option('--model <model>', 'model passed through to the agent')
|
|
39
|
+
.option('--budget <usd>', 'stop once this much has been spent')
|
|
40
|
+
.option('--timeout <seconds>', 'per-case timeout', '300')
|
|
41
|
+
.option('--no-cache', 'ignore and do not write cached results')
|
|
42
|
+
.option('--json', 'machine-readable output')
|
|
43
|
+
.action(async (path, opts) => {
|
|
44
|
+
try {
|
|
45
|
+
const files = discoverTestFiles(path);
|
|
46
|
+
if (!opts.json)
|
|
47
|
+
console.log(`${files.length} test file(s); live agent runs cost money — cap with --budget`);
|
|
48
|
+
const reports = await runTests(path, {
|
|
49
|
+
model: opts.model,
|
|
50
|
+
budgetUsd: opts.budget ? Number(opts.budget) : undefined,
|
|
51
|
+
timeoutMs: Number(opts.timeout) * 1000,
|
|
52
|
+
cache: opts.cache,
|
|
53
|
+
});
|
|
54
|
+
if (opts.json)
|
|
55
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
56
|
+
else
|
|
57
|
+
printReports(reports);
|
|
58
|
+
process.exitCode = reportsPass(reports) ? 0 : 1;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
console.error(e.message);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
program
|
|
66
|
+
.command('init')
|
|
67
|
+
.argument('<skillDir>', 'skill directory to scaffold tests for')
|
|
68
|
+
.action((skillDir) => {
|
|
69
|
+
try {
|
|
70
|
+
console.log(`wrote ${initTestFile(skillDir)}`);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
console.error(e.message);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
program.parseAsync();
|
package/dist/expect.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function checkExpectations(exp, transcript, diff) {
|
|
2
|
+
const out = [];
|
|
3
|
+
if (exp.files_changed !== undefined) {
|
|
4
|
+
const actual = [...diff.changed, ...diff.created, ...diff.deleted].sort();
|
|
5
|
+
const wanted = [...exp.files_changed].sort();
|
|
6
|
+
const pass = actual.length === wanted.length && actual.every((v, i) => v === wanted[i]);
|
|
7
|
+
out.push({
|
|
8
|
+
name: 'files_changed',
|
|
9
|
+
pass,
|
|
10
|
+
detail: pass ? 'ok' : `expected [${wanted.join(', ')}] got [${actual.join(', ')}]`,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
if (exp.command_ran !== undefined) {
|
|
14
|
+
const pass = transcript.commands.some((c) => c.includes(exp.command_ran));
|
|
15
|
+
out.push({
|
|
16
|
+
name: 'command_ran',
|
|
17
|
+
pass,
|
|
18
|
+
detail: pass
|
|
19
|
+
? 'ok'
|
|
20
|
+
: `no command containing "${exp.command_ran}" (ran: ${transcript.commands.join(' ; ') || 'none'})`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
if (exp.transcript_matches !== undefined) {
|
|
24
|
+
let pass = false;
|
|
25
|
+
let detail;
|
|
26
|
+
try {
|
|
27
|
+
const re = new RegExp(exp.transcript_matches, 'm');
|
|
28
|
+
const haystack = [...transcript.messages, ...transcript.commands].join('\n');
|
|
29
|
+
pass = re.test(haystack);
|
|
30
|
+
detail = pass ? 'ok' : `nothing in the transcript matches /${exp.transcript_matches}/`;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
detail = `invalid regex: ${exp.transcript_matches}`;
|
|
34
|
+
}
|
|
35
|
+
out.push({ name: 'transcript_matches', pass, detail });
|
|
36
|
+
}
|
|
37
|
+
if (exp.skill_invoked !== undefined) {
|
|
38
|
+
const invoked = transcript.toolCalls.some((c) => c.name === 'Skill');
|
|
39
|
+
const pass = invoked === exp.skill_invoked;
|
|
40
|
+
out.push({
|
|
41
|
+
name: 'skill_invoked',
|
|
42
|
+
pass,
|
|
43
|
+
detail: pass ? 'ok' : invoked ? 'skill fired but was expected not to' : 'skill never fired',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { isParseError, loadSkill } from './skill.js';
|
|
4
|
+
export function initTestFile(skillDir) {
|
|
5
|
+
const dir = resolve(skillDir);
|
|
6
|
+
const skill = loadSkill(join(dir, 'SKILL.md'));
|
|
7
|
+
if (isParseError(skill))
|
|
8
|
+
throw new Error(`${skill.path}: ${skill.error}`);
|
|
9
|
+
const name = basename(dir);
|
|
10
|
+
const out = join(dir, 'tests', `${name}.test.yaml`);
|
|
11
|
+
if (existsSync(out))
|
|
12
|
+
throw new Error(`${out} already exists`);
|
|
13
|
+
mkdirSync(join(dir, 'tests'), { recursive: true });
|
|
14
|
+
const firstLine = skill.description.split('\n')[0];
|
|
15
|
+
writeFileSync(out, [
|
|
16
|
+
`# behavioral tests for ${name}`,
|
|
17
|
+
`# description: ${firstLine}`,
|
|
18
|
+
'skill: ..',
|
|
19
|
+
'cases:',
|
|
20
|
+
' - name: does its job',
|
|
21
|
+
' prompt: "REPLACE: a prompt that should trigger this skill"',
|
|
22
|
+
' expect:',
|
|
23
|
+
' skill_invoked: true',
|
|
24
|
+
'',
|
|
25
|
+
].join('\n'));
|
|
26
|
+
return out;
|
|
27
|
+
}
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os';
|
|
2
|
+
import { claudeCode } from './adapters/claude-code.js';
|
|
3
|
+
export function summarize(t) {
|
|
4
|
+
const parts = [];
|
|
5
|
+
if (t.messages.length)
|
|
6
|
+
parts.push('assistant said:\n' + t.messages.join('\n---\n'));
|
|
7
|
+
if (t.commands.length)
|
|
8
|
+
parts.push('commands run:\n' + t.commands.join('\n'));
|
|
9
|
+
const tools = t.toolCalls.map((c) => c.name).join(', ');
|
|
10
|
+
if (tools)
|
|
11
|
+
parts.push('tools used: ' + tools);
|
|
12
|
+
return parts.join('\n\n') || '(empty transcript)';
|
|
13
|
+
}
|
|
14
|
+
export function parseVerdict(text, passScore) {
|
|
15
|
+
const candidates = [...text.matchAll(/\{[\s\S]*?\}/g), ...(text.match(/\{[\s\S]*\}/) ? [[text.match(/\{[\s\S]*\}/)[0]]] : [])];
|
|
16
|
+
for (const m of candidates) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(m[0]);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const score = parsed.score;
|
|
25
|
+
if (typeof score !== 'number' || !Number.isFinite(score))
|
|
26
|
+
continue;
|
|
27
|
+
const clamped = Math.min(10, Math.max(0, score));
|
|
28
|
+
const reasoning = parsed.reasoning;
|
|
29
|
+
return {
|
|
30
|
+
score: clamped,
|
|
31
|
+
pass: clamped >= passScore,
|
|
32
|
+
reasoning: typeof reasoning === 'string' ? reasoning : '',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
throw new Error('judge returned no JSON verdict');
|
|
36
|
+
}
|
|
37
|
+
export async function judgeTranscript(opts) {
|
|
38
|
+
const prompt = [
|
|
39
|
+
'You are grading an AI agent transcript against a rubric.',
|
|
40
|
+
'Rubric:',
|
|
41
|
+
opts.rubric,
|
|
42
|
+
'',
|
|
43
|
+
'Transcript:',
|
|
44
|
+
summarize(opts.transcript),
|
|
45
|
+
'',
|
|
46
|
+
'Reply with only a JSON object: {"score": <0-10>, "reasoning": "<one sentence>"}',
|
|
47
|
+
].join('\n');
|
|
48
|
+
const passScore = opts.passScore ?? 7;
|
|
49
|
+
const adapter = opts.adapter ?? claudeCode;
|
|
50
|
+
let costUsd = 0;
|
|
51
|
+
const attempt = async () => {
|
|
52
|
+
const t = await adapter.run({
|
|
53
|
+
prompt,
|
|
54
|
+
cwd: tmpdir(),
|
|
55
|
+
bare: true,
|
|
56
|
+
model: opts.model ?? 'haiku',
|
|
57
|
+
maxTurns: 1,
|
|
58
|
+
timeoutMs: 120_000,
|
|
59
|
+
});
|
|
60
|
+
costUsd += t.result?.costUsd ?? 0;
|
|
61
|
+
return parseVerdict(t.messages.join('\n'), passScore);
|
|
62
|
+
};
|
|
63
|
+
let verdict;
|
|
64
|
+
try {
|
|
65
|
+
verdict = await attempt();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
verdict = await attempt();
|
|
69
|
+
}
|
|
70
|
+
return { ...verdict, costUsd };
|
|
71
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isParseError, loadSkill } from '../skill.js';
|
|
4
|
+
import { allRules } from './rules.js';
|
|
5
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']);
|
|
6
|
+
export function discoverSkillFiles(path) {
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
throw new Error(`${path}: no such file or directory`);
|
|
9
|
+
if (statSync(path).isFile())
|
|
10
|
+
return [path];
|
|
11
|
+
const found = [];
|
|
12
|
+
const walk = (dir) => {
|
|
13
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name))
|
|
15
|
+
walk(join(dir, entry.name));
|
|
16
|
+
else if (entry.isFile() && entry.name === 'SKILL.md')
|
|
17
|
+
found.push(join(dir, entry.name));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
walk(path);
|
|
21
|
+
return found.sort();
|
|
22
|
+
}
|
|
23
|
+
export function lintPath(path) {
|
|
24
|
+
const findings = [];
|
|
25
|
+
for (const file of discoverSkillFiles(path)) {
|
|
26
|
+
const skill = loadSkill(file);
|
|
27
|
+
if (isParseError(skill)) {
|
|
28
|
+
findings.push({ rule: 'parse', severity: 'error', message: skill.error, file });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const rule of allRules)
|
|
32
|
+
findings.push(...rule(skill));
|
|
33
|
+
}
|
|
34
|
+
return findings;
|
|
35
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
function finding(s, rule, severity, message) {
|
|
4
|
+
return { rule, severity, message, file: s.path };
|
|
5
|
+
}
|
|
6
|
+
export const frontmatterFields = (s) => {
|
|
7
|
+
const out = [];
|
|
8
|
+
if (typeof s.frontmatter.name !== 'string' || !s.frontmatter.name.trim())
|
|
9
|
+
out.push(finding(s, 'frontmatter-fields', 'error', 'frontmatter is missing a name'));
|
|
10
|
+
if (!s.description.trim())
|
|
11
|
+
out.push(finding(s, 'frontmatter-fields', 'error', 'frontmatter is missing a description'));
|
|
12
|
+
return out;
|
|
13
|
+
};
|
|
14
|
+
export const descriptionQuality = (s) => {
|
|
15
|
+
const d = s.description.trim();
|
|
16
|
+
if (!d)
|
|
17
|
+
return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
if (d.length < 20)
|
|
20
|
+
out.push(finding(s, 'description-quality', 'warning', 'description under 20 chars; unlikely to trigger reliably'));
|
|
21
|
+
if (d.length > 1000)
|
|
22
|
+
out.push(finding(s, 'description-quality', 'warning', 'description over 1000 chars; gets truncated in listings'));
|
|
23
|
+
if (!/\b(use|when|for|trigger)\b/i.test(d))
|
|
24
|
+
out.push(finding(s, 'description-quality', 'warning', 'description has no trigger language ("use when ...") — the agent decides from this text alone'));
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
export const tokenBudget = (s) => {
|
|
28
|
+
const tokens = Math.round(s.raw.length / 4);
|
|
29
|
+
if (tokens > 10_000)
|
|
30
|
+
return [finding(s, 'token-budget', 'warning', `skill is roughly ${tokens} tokens; every invocation pays this cost`)];
|
|
31
|
+
return [];
|
|
32
|
+
};
|
|
33
|
+
export const relativeLinks = (s) => {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const m of s.body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
|
|
36
|
+
const target = m[1];
|
|
37
|
+
if (/^(https?:|mailto:|#)/.test(target))
|
|
38
|
+
continue;
|
|
39
|
+
if (!existsSync(resolve(s.dir, target.split('#')[0])))
|
|
40
|
+
out.push(finding(s, 'relative-links', 'error', `broken relative link: ${target}`));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
};
|
|
44
|
+
const DANGEROUS = [
|
|
45
|
+
{ re: /rm\s+-rf\s+[~/]/, why: 'destructive rm -rf on a home or root path' },
|
|
46
|
+
{ re: /curl[^\n|]*\|\s*(ba)?sh/, why: 'pipes curl to a shell' },
|
|
47
|
+
{ re: /--dangerously-skip-permissions/, why: 'tells the agent to skip permission prompts' },
|
|
48
|
+
{ re: /\bsudo\b/, why: 'asks for sudo' },
|
|
49
|
+
];
|
|
50
|
+
export const dangerousPatterns = (s) => {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const { re, why } of DANGEROUS)
|
|
53
|
+
if (re.test(s.body))
|
|
54
|
+
out.push(finding(s, 'dangerous-patterns', 'warning', why));
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
export const allRules = [frontmatterFields, descriptionQuality, tokenBudget, relativeLinks, dangerousPatterns];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
export function printFindings(findings) {
|
|
3
|
+
for (const f of findings) {
|
|
4
|
+
const tag = f.severity === 'error' ? pc.red('error') : pc.yellow('warn ');
|
|
5
|
+
console.log(`${tag} ${f.file} [${f.rule}] ${f.message}`);
|
|
6
|
+
}
|
|
7
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
8
|
+
console.log(`\n${findings.length} finding(s), ${errors} error(s)`);
|
|
9
|
+
}
|
|
10
|
+
export function printReports(reports) {
|
|
11
|
+
let cost = 0;
|
|
12
|
+
for (const r of reports) {
|
|
13
|
+
cost += r.costUsd;
|
|
14
|
+
const failed = r.checks.filter((c) => !c.pass);
|
|
15
|
+
const ok = !r.error && failed.length === 0 && (r.verdict?.pass ?? true);
|
|
16
|
+
const mark = ok ? pc.green('pass') : pc.red('fail');
|
|
17
|
+
const cached = r.cached ? pc.dim(' (cached)') : '';
|
|
18
|
+
console.log(`${mark} ${r.case}${cached}`);
|
|
19
|
+
if (r.error)
|
|
20
|
+
console.log(pc.red(` ${r.error}`));
|
|
21
|
+
for (const c of failed)
|
|
22
|
+
console.log(pc.red(` ${c.name}: ${c.detail}`));
|
|
23
|
+
if (r.verdict && !r.verdict.pass)
|
|
24
|
+
console.log(pc.red(` judge: ${r.verdict.score}/10 — ${r.verdict.reasoning}`));
|
|
25
|
+
}
|
|
26
|
+
console.log(`\n${reports.length} case(s), $${cost.toFixed(4)} spent`);
|
|
27
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { claudeCode } from './adapters/claude-code.js';
|
|
6
|
+
import { hashInputs, ResultCache } from './cache.js';
|
|
7
|
+
import { checkExpectations } from './expect.js';
|
|
8
|
+
import { judgeTranscript } from './judge.js';
|
|
9
|
+
import { diffWorkspace, makeWorkspace, snapshot } from './workspace.js';
|
|
10
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.skillcheck']);
|
|
11
|
+
export function discoverTestFiles(path) {
|
|
12
|
+
if (statSync(path).isFile())
|
|
13
|
+
return [path];
|
|
14
|
+
const found = [];
|
|
15
|
+
const walk = (dir) => {
|
|
16
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name))
|
|
18
|
+
walk(join(dir, entry.name));
|
|
19
|
+
else if (entry.isFile() && entry.name.endsWith('.test.yaml'))
|
|
20
|
+
found.push(join(dir, entry.name));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(path);
|
|
24
|
+
return found.sort();
|
|
25
|
+
}
|
|
26
|
+
export function loadTestFile(path) {
|
|
27
|
+
const doc = YAML.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
if (!doc || typeof doc.skill !== 'string' || !Array.isArray(doc.cases))
|
|
29
|
+
throw new Error(`${path}: needs a "skill" path and a "cases" list`);
|
|
30
|
+
return { path, skill: doc.skill, cases: doc.cases };
|
|
31
|
+
}
|
|
32
|
+
export async function runTests(root, opts = {}) {
|
|
33
|
+
const adapter = opts.adapter ?? claudeCode;
|
|
34
|
+
const cache = new ResultCache(join(process.cwd(), '.skillcheck', 'cache.json'));
|
|
35
|
+
const reports = [];
|
|
36
|
+
let spent = 0;
|
|
37
|
+
for (const file of discoverTestFiles(root)) {
|
|
38
|
+
const testFile = loadTestFile(file);
|
|
39
|
+
const skillDir = resolve(dirname(file), testFile.skill);
|
|
40
|
+
for (const c of testFile.cases) {
|
|
41
|
+
if (opts.budgetUsd !== undefined && spent >= opts.budgetUsd) {
|
|
42
|
+
reports.push({
|
|
43
|
+
file,
|
|
44
|
+
case: c.name,
|
|
45
|
+
checks: [],
|
|
46
|
+
costUsd: 0,
|
|
47
|
+
cached: false,
|
|
48
|
+
error: `budget of $${opts.budgetUsd} reached before this case`,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = hashInputs(c.setup ? [skillDir, resolve(dirname(file), c.setup)] : skillDir, YAML.stringify(c) + '\0' + (opts.model ?? ''));
|
|
53
|
+
if (opts.cache !== false) {
|
|
54
|
+
const hit = cache.get(key);
|
|
55
|
+
if (hit) {
|
|
56
|
+
reports.push({ ...hit, cached: true });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let ws;
|
|
61
|
+
try {
|
|
62
|
+
ws = await makeWorkspace({
|
|
63
|
+
fixture: c.setup ? resolve(dirname(file), c.setup) : undefined,
|
|
64
|
+
skillDir,
|
|
65
|
+
skillName: basename(skillDir),
|
|
66
|
+
});
|
|
67
|
+
const before = await snapshot(ws);
|
|
68
|
+
const transcript = await adapter.run({
|
|
69
|
+
prompt: c.prompt,
|
|
70
|
+
cwd: ws,
|
|
71
|
+
model: opts.model,
|
|
72
|
+
timeoutMs: opts.timeoutMs,
|
|
73
|
+
});
|
|
74
|
+
const diff = await diffWorkspace(ws, before);
|
|
75
|
+
const checks = c.expect ? checkExpectations(c.expect, transcript, diff) : [];
|
|
76
|
+
let verdict;
|
|
77
|
+
if (c.judge)
|
|
78
|
+
verdict = await judgeTranscript({
|
|
79
|
+
rubric: c.judge,
|
|
80
|
+
transcript,
|
|
81
|
+
model: opts.model,
|
|
82
|
+
passScore: c.judge_pass,
|
|
83
|
+
adapter,
|
|
84
|
+
});
|
|
85
|
+
const judgeCost = verdict?.costUsd ?? 0;
|
|
86
|
+
const costUsd = (transcript.result?.costUsd ?? 0) + judgeCost;
|
|
87
|
+
spent += costUsd;
|
|
88
|
+
const report = { file, case: c.name, checks, verdict, costUsd, cached: false };
|
|
89
|
+
reports.push(report);
|
|
90
|
+
if (opts.cache !== false) {
|
|
91
|
+
try {
|
|
92
|
+
cache.set(key, report);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// a failed cache write must not fail the case
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
reports.push({
|
|
101
|
+
file,
|
|
102
|
+
case: c.name,
|
|
103
|
+
checks: [],
|
|
104
|
+
costUsd: 0,
|
|
105
|
+
cached: false,
|
|
106
|
+
error: e.message,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (ws)
|
|
111
|
+
await rm(ws, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return reports;
|
|
116
|
+
}
|
|
117
|
+
export function reportsPass(reports) {
|
|
118
|
+
return reports.every((r) => !r.error && r.checks.every((c) => c.pass) && (r.verdict?.pass ?? true));
|
|
119
|
+
}
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
export function loadSkill(path) {
|
|
5
|
+
let raw;
|
|
6
|
+
try {
|
|
7
|
+
raw = readFileSync(path, 'utf8');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return { path, error: 'cannot read file' };
|
|
11
|
+
}
|
|
12
|
+
if (!raw.startsWith('---'))
|
|
13
|
+
return { path, error: 'missing frontmatter' };
|
|
14
|
+
const end = raw.indexOf('\n---', 3);
|
|
15
|
+
if (end === -1)
|
|
16
|
+
return { path, error: 'unterminated frontmatter' };
|
|
17
|
+
let fm;
|
|
18
|
+
try {
|
|
19
|
+
fm = YAML.parse(raw.slice(3, end)) ?? {};
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
return { path, error: `bad yaml in frontmatter: ${e.message}` };
|
|
23
|
+
}
|
|
24
|
+
const body = raw.slice(raw.indexOf('\n', end + 1) + 1);
|
|
25
|
+
return {
|
|
26
|
+
path,
|
|
27
|
+
dir: dirname(path),
|
|
28
|
+
name: typeof fm.name === 'string' ? fm.name : basename(dirname(path)),
|
|
29
|
+
description: typeof fm.description === 'string' ? fm.description : '',
|
|
30
|
+
frontmatter: fm,
|
|
31
|
+
body,
|
|
32
|
+
raw,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function isParseError(s) {
|
|
36
|
+
return 'error' in s;
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function parseStreamJson(output) {
|
|
2
|
+
const t = { messages: [], toolCalls: [], commands: [] };
|
|
3
|
+
for (const line of output.split('\n')) {
|
|
4
|
+
if (!line.trim().startsWith('{'))
|
|
5
|
+
continue;
|
|
6
|
+
let event;
|
|
7
|
+
try {
|
|
8
|
+
event = JSON.parse(line);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (event.type === 'assistant') {
|
|
14
|
+
for (const block of event.message?.content ?? []) {
|
|
15
|
+
if (block.type === 'text' && block.text)
|
|
16
|
+
t.messages.push(block.text);
|
|
17
|
+
if (block.type === 'tool_use' && typeof block.name === 'string') {
|
|
18
|
+
const call = { name: block.name, input: block.input ?? {} };
|
|
19
|
+
t.toolCalls.push(call);
|
|
20
|
+
if (call.name === 'Bash' && typeof call.input.command === 'string')
|
|
21
|
+
t.commands.push(call.input.command);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (event.type === 'result') {
|
|
26
|
+
t.result = {
|
|
27
|
+
costUsd: event.total_cost_usd ?? 0,
|
|
28
|
+
numTurns: event.num_turns ?? 0,
|
|
29
|
+
isError: !!event.is_error,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cp, mkdtemp, readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
const SKIP = new Set(['.git', '.claude', 'node_modules']);
|
|
6
|
+
async function walkFiles(root, dir = root) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
9
|
+
if (SKIP.has(entry.name))
|
|
10
|
+
continue;
|
|
11
|
+
const p = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
out.push(...(await walkFiles(root, p)));
|
|
14
|
+
else
|
|
15
|
+
out.push(p);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
export async function snapshot(dir) {
|
|
20
|
+
const map = new Map();
|
|
21
|
+
for (const file of await walkFiles(dir)) {
|
|
22
|
+
const hash = createHash('sha1').update(await readFile(file)).digest('hex');
|
|
23
|
+
map.set(relative(dir, file).split(sep).join('/'), hash);
|
|
24
|
+
}
|
|
25
|
+
return map;
|
|
26
|
+
}
|
|
27
|
+
export async function makeWorkspace(opts) {
|
|
28
|
+
const dir = await mkdtemp(join(tmpdir(), 'skillcheck-'));
|
|
29
|
+
if (opts.fixture)
|
|
30
|
+
await cp(opts.fixture, dir, { recursive: true });
|
|
31
|
+
await cp(opts.skillDir, join(dir, '.claude', 'skills', opts.skillName), { recursive: true });
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
export async function diffWorkspace(dir, before) {
|
|
35
|
+
const after = await snapshot(dir);
|
|
36
|
+
const changed = [];
|
|
37
|
+
const created = [];
|
|
38
|
+
const deleted = [];
|
|
39
|
+
for (const [path, hash] of after) {
|
|
40
|
+
if (!before.has(path))
|
|
41
|
+
created.push(path);
|
|
42
|
+
else if (before.get(path) !== hash)
|
|
43
|
+
changed.push(path);
|
|
44
|
+
}
|
|
45
|
+
for (const path of before.keys())
|
|
46
|
+
if (!after.has(path))
|
|
47
|
+
deleted.push(path);
|
|
48
|
+
return { changed: changed.sort(), created: created.sort(), deleted: deleted.sort() };
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yogiadhik/skillcheck",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "lint and behavioral tests for agent skills",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Yogesh Adhikari",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"skillcheck": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"commander": "^15.0.0",
|
|
24
|
+
"cross-spawn": "^7.0.6",
|
|
25
|
+
"picocolors": "^1.1.1",
|
|
26
|
+
"yaml": "^2.9.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/cross-spawn": "^6.0.6",
|
|
30
|
+
"@types/node": "^26.1.1",
|
|
31
|
+
"typescript": "^7.0.2",
|
|
32
|
+
"vitest": "^4.1.10"
|
|
33
|
+
}
|
|
34
|
+
}
|