@icntswm/skillcheck 0.1.0 → 1.0.1
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/README.md +14 -19
- package/dist/agents/claude.js +37 -7
- package/dist/cli.js +8 -1
- package/dist/describe.js +6 -5
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://www.npmjs.com/package/@icntswm/skillcheck)
|
|
5
5
|

|
|
6
|
-

|
|
7
6
|
|
|
8
7
|
**Regression tests for Claude Code skills.**
|
|
9
8
|
|
|
@@ -12,22 +11,12 @@ some of your requests load the wrong skill. Nothing warns you: the agent still
|
|
|
12
11
|
answers, just with the wrong instructions. skillcheck catches this before your
|
|
13
12
|
users do.
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
$ skillcheck run examples/demo/skillcheck.yaml --skill test-guard,find-bug
|
|
17
|
-
5 cases × 1 repeat × 1 agent = 5 runs
|
|
18
|
-
ok #bug-500 POST /orders returns 500 with 'nil pointer deref… → find-bug
|
|
19
|
-
FAIL #flaky-ci-only this test is red in CI roughly once a day but I … → find-bug · not loaded test-guard
|
|
20
|
-
FAIL #flaky-retry TestCartMerge fails sometimes and goes green w… → find-bug · not loaded test-guard; forbidden find-bug
|
|
21
|
-
ok #bug-consistent TestCheckoutTotal fails on every run since this … → find-bug
|
|
22
|
-
ok #perf-latency the /search endpoint went from 80ms to 900ms aft… → perf-profile
|
|
23
|
-
|
|
24
|
-
confusion:
|
|
25
|
-
expected test-guard → got find-bug (2)
|
|
26
|
-
2 failed of 5 · runs 5 · cost $0.25
|
|
27
|
-
```
|
|
14
|
+

|
|
28
15
|
|
|
29
|
-
|
|
30
|
-
|
|
16
|
+
A real run on the [demo](examples/demo): one skill's description got wider,
|
|
17
|
+
and it started taking requests that belong to its neighbour. The free `lint`
|
|
18
|
+
flags a suspicious description, and one batch call finds the two misrouted
|
|
19
|
+
requests.
|
|
31
20
|
|
|
32
21
|
## What you get
|
|
33
22
|
|
|
@@ -46,8 +35,9 @@ wider, and it started taking requests that belong to its neighbour.
|
|
|
46
35
|
from a broken one instead of letting you guess.
|
|
47
36
|
- 🏷️ **Catches renames and typos.** Case names are checked against the skills
|
|
48
37
|
the agent really has, so a renamed skill can't pass silently.
|
|
49
|
-
- ⚙️ **CI-ready.**
|
|
50
|
-
|
|
38
|
+
- ⚙️ **CI-ready.** A GitHub Action (`uses: icntswm/skillcheck@v1`), JUnit
|
|
39
|
+
and JSON reports, clear exit codes, a spending cap (`--budget`), and
|
|
40
|
+
`--config-dir` to test only the skills in your repository.
|
|
51
41
|
- 📄 **Plain YAML, one dependency.** Cases are readable by anyone on the team
|
|
52
42
|
and live next to the skills they test.
|
|
53
43
|
|
|
@@ -86,7 +76,7 @@ Start at the cheapest level and go up only when it finds nothing.
|
|
|
86
76
|
| `skillcheck run --batch` | one per 25 cases | which skill the model *says* it would load |
|
|
87
77
|
| `skillcheck run` | one per case | which skill the model *actually* loads |
|
|
88
78
|
|
|
89
|
-
On the demo suite one batch call covered all 12 cases for $0.05 and agreed
|
|
79
|
+
On the demo suite one batch call covered all 12 cases for $0.05–0.10 and agreed
|
|
90
80
|
with the normal run in 34 checks out of 34. More in [docs/cost.md](docs/cost.md).
|
|
91
81
|
|
|
92
82
|
## Does it really catch regressions?
|
|
@@ -144,6 +134,11 @@ stops Claude Code before the first model call, so it costs nothing.
|
|
|
144
134
|
Works with Claude Code. Agents sit behind a small adapter interface, so others
|
|
145
135
|
that support skills can be added.
|
|
146
136
|
|
|
137
|
+
Requests can be in any language: `run` asks the model itself. `lint` compares
|
|
138
|
+
words, so it is tuned for English and Russian, works roughly for other
|
|
139
|
+
languages that put spaces between words, and is of little use for Chinese,
|
|
140
|
+
Japanese or Korean.
|
|
141
|
+
|
|
147
142
|
## License
|
|
148
143
|
|
|
149
144
|
[MIT](LICENSE)
|
package/dist/agents/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
2
3
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
@@ -41,6 +42,10 @@ export class ClaudeStream {
|
|
|
41
42
|
get sawInit() {
|
|
42
43
|
return this.sawInitEvent;
|
|
43
44
|
}
|
|
45
|
+
/** True after the result event: the run is over, nothing more will come. */
|
|
46
|
+
get finished() {
|
|
47
|
+
return this.sawResult;
|
|
48
|
+
}
|
|
44
49
|
/** Feed one stdout line; true means the run can be stopped early. */
|
|
45
50
|
push(line) {
|
|
46
51
|
if (!line.startsWith("{"))
|
|
@@ -195,6 +200,25 @@ function spawnErrorMessage(e, bin) {
|
|
|
195
200
|
return `${bin} not found`;
|
|
196
201
|
return `spawn failed: ${err.message}`;
|
|
197
202
|
}
|
|
203
|
+
/** Live runs by process group id, with their tmp dirs; see abortActiveRuns. */
|
|
204
|
+
const active = new Map();
|
|
205
|
+
/**
|
|
206
|
+
* Kill every running claude group and remove its tmp dir. Runs are detached
|
|
207
|
+
* process groups, so Ctrl+C on skillcheck does not reach them: without this
|
|
208
|
+
* they keep running (and billing) after skillcheck exits.
|
|
209
|
+
*/
|
|
210
|
+
export function abortActiveRuns() {
|
|
211
|
+
for (const [pid, workdir] of active) {
|
|
212
|
+
try {
|
|
213
|
+
process.kill(-pid, "SIGTERM");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// already exited
|
|
217
|
+
}
|
|
218
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
active.clear();
|
|
221
|
+
}
|
|
198
222
|
const LOGIN_HINT = " — with --config-dir, auth comes from ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (claude setup-token)";
|
|
199
223
|
/** A fresh config dir has no credentials; point the user at the env vars. */
|
|
200
224
|
function withLoginHint(error, configDir) {
|
|
@@ -286,11 +310,13 @@ export class ClaudeAdapter {
|
|
|
286
310
|
let stderrText = "";
|
|
287
311
|
let error = null;
|
|
288
312
|
let stoppedEarly = false;
|
|
313
|
+
let stopping = false;
|
|
289
314
|
let settled = false;
|
|
290
315
|
let killTimer;
|
|
291
316
|
let timeoutTimer;
|
|
292
317
|
// detached => child.pid is the process group id; ESRCH means it is gone
|
|
293
318
|
const killGroup = (pid) => {
|
|
319
|
+
clearTimeout(timeoutTimer); // the run is ending anyway, a late timeout must not overwrite its outcome
|
|
294
320
|
try {
|
|
295
321
|
process.kill(-pid, "SIGTERM");
|
|
296
322
|
}
|
|
@@ -311,6 +337,8 @@ export class ClaudeAdapter {
|
|
|
311
337
|
if (settled)
|
|
312
338
|
return;
|
|
313
339
|
settled = true;
|
|
340
|
+
if (child?.pid)
|
|
341
|
+
active.delete(child.pid);
|
|
314
342
|
if (killTimer)
|
|
315
343
|
clearTimeout(killTimer);
|
|
316
344
|
if (timeoutTimer)
|
|
@@ -334,6 +362,8 @@ export class ClaudeAdapter {
|
|
|
334
362
|
return;
|
|
335
363
|
}
|
|
336
364
|
const proc = child;
|
|
365
|
+
if (proc.pid)
|
|
366
|
+
active.set(proc.pid, workdir);
|
|
337
367
|
proc.on("error", (e) => {
|
|
338
368
|
if (!error)
|
|
339
369
|
error = spawnErrorMessage(e, bin);
|
|
@@ -342,11 +372,9 @@ export class ClaudeAdapter {
|
|
|
342
372
|
proc.stdout?.on("data", (chunk) => {
|
|
343
373
|
if (!stdoutSeen && chunk.trim() !== "")
|
|
344
374
|
stdoutSeen = true;
|
|
345
|
-
tail
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const line = tail.slice(0, nl);
|
|
349
|
-
tail = tail.slice(nl + 1);
|
|
375
|
+
const lines = (tail + chunk).split("\n");
|
|
376
|
+
tail = lines.pop(); // the unfinished last line waits for the next chunk
|
|
377
|
+
for (const line of lines) {
|
|
350
378
|
const stop = stream.push(line); // always parse: stop and abandon only gate the kill
|
|
351
379
|
if (error)
|
|
352
380
|
continue; // first failure wins, the group is already being killed
|
|
@@ -356,8 +384,10 @@ export class ClaudeAdapter {
|
|
|
356
384
|
if (proc.pid)
|
|
357
385
|
killGroup(proc.pid);
|
|
358
386
|
}
|
|
359
|
-
else if (!
|
|
360
|
-
|
|
387
|
+
else if (!stopping && ((control.earlyStop && stop) || control.stop?.(stream) === true)) {
|
|
388
|
+
stopping = true;
|
|
389
|
+
// after the result event the run is complete: the kill only reaps the process
|
|
390
|
+
stoppedEarly = !stream.finished;
|
|
361
391
|
if (proc.pid)
|
|
362
392
|
killGroup(proc.pid);
|
|
363
393
|
}
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
|
-
import { ClaudeAdapter, DEFAULT_DIRECTIVE } from "./agents/claude.js";
|
|
7
|
+
import { abortActiveRuns, ClaudeAdapter, DEFAULT_DIRECTIVE } from "./agents/claude.js";
|
|
8
8
|
import { BATCH_SCHEMA, buildBatchPrompt, parseBatchAnswer } from "./batch.js";
|
|
9
9
|
import { Budget } from "./budget.js";
|
|
10
10
|
import { BUILTIN_SKILLS, ConfigError, findDefaultCasesFile, knownSkillNames, loadSuite, unknownNames, } from "./cases.js";
|
|
@@ -667,6 +667,13 @@ function invokedDirectly() {
|
|
|
667
667
|
}
|
|
668
668
|
}
|
|
669
669
|
if (invokedDirectly()) {
|
|
670
|
+
// agent runs are detached process groups that Ctrl+C does not reach
|
|
671
|
+
for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
|
|
672
|
+
process.once(signal, () => {
|
|
673
|
+
abortActiveRuns();
|
|
674
|
+
process.exit(code);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
670
677
|
main(process.argv.slice(2)).then((code) => {
|
|
671
678
|
process.exitCode = code;
|
|
672
679
|
});
|
package/dist/describe.js
CHANGED
|
@@ -194,7 +194,8 @@ function pluginDocs(configRoot, cwd) {
|
|
|
194
194
|
const installPath = entry.installPath;
|
|
195
195
|
if (typeof installPath !== "string" || installPath === "")
|
|
196
196
|
continue;
|
|
197
|
-
|
|
197
|
+
const manifest = pluginManifest(installPath);
|
|
198
|
+
out.push(...pluginSkillDocs(plugin, installPath, manifest), ...pluginCommandDocs(plugin, installPath, manifest));
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
return out;
|
|
@@ -210,8 +211,8 @@ function disabledPlugins(configRoot) {
|
|
|
210
211
|
}
|
|
211
212
|
return out;
|
|
212
213
|
}
|
|
213
|
-
function pluginSkillDocs(plugin, installPath) {
|
|
214
|
-
const declared =
|
|
214
|
+
function pluginSkillDocs(plugin, installPath, manifest) {
|
|
215
|
+
const declared = manifest?.skills;
|
|
215
216
|
const dirs = Array.isArray(declared)
|
|
216
217
|
? declared.filter((s) => typeof s === "string").map((rel) => path.resolve(installPath, rel))
|
|
217
218
|
: skillDirNames(path.join(installPath, "skills")).map((name) => path.join(installPath, "skills", name));
|
|
@@ -226,8 +227,8 @@ function pluginSkillDocs(plugin, installPath) {
|
|
|
226
227
|
}
|
|
227
228
|
return out;
|
|
228
229
|
}
|
|
229
|
-
function pluginCommandDocs(plugin, installPath) {
|
|
230
|
-
const declared =
|
|
230
|
+
function pluginCommandDocs(plugin, installPath, manifest) {
|
|
231
|
+
const declared = manifest?.commands;
|
|
231
232
|
let files;
|
|
232
233
|
if (Array.isArray(declared)) {
|
|
233
234
|
files = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@icntswm/skillcheck",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Regression tests for Claude Code skills: check that every request still loads the right skill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^26.6.3",
|
|
35
|
-
"tsx": "^4.23.15",
|
|
36
35
|
"typescript": "^7.0.2",
|
|
37
36
|
"vitest": "^5.0.2"
|
|
38
37
|
},
|