@icntswm/skillcheck 1.0.0 → 1.0.2

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 CHANGED
@@ -3,7 +3,6 @@
3
3
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
4
  [![npm](https://img.shields.io/npm/v/@icntswm/skillcheck.svg)](https://www.npmjs.com/package/@icntswm/skillcheck)
5
5
  ![Node 20+](https://img.shields.io/badge/node-20%2B-339933.svg)
6
- ![Claude Code](https://img.shields.io/badge/works%20with-Claude%20Code-d97757.svg)
7
6
 
8
7
  **Regression tests for Claude Code skills.**
9
8
 
@@ -135,6 +134,11 @@ stops Claude Code before the first model call, so it costs nothing.
135
134
  Works with Claude Code. Agents sit behind a small adapter interface, so others
136
135
  that support skills can be added.
137
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
+
138
142
  ## License
139
143
 
140
144
  [MIT](LICENSE)
@@ -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,48 @@ 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
+ function groupAlive(pid) {
206
+ try {
207
+ process.kill(-pid, 0);
208
+ return true;
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ /**
215
+ * Kill every running claude group and remove its tmp dir. Runs are detached
216
+ * process groups, so Ctrl+C on skillcheck does not reach them: without this
217
+ * they keep running (and billing) after skillcheck exits. Groups still alive
218
+ * after graceMs get SIGKILL, so a process that ignores SIGTERM dies too.
219
+ */
220
+ export async function abortActiveRuns(graceMs = KILL_GRACE_MS) {
221
+ const runs = [...active];
222
+ active.clear();
223
+ for (const [pid] of runs) {
224
+ try {
225
+ process.kill(-pid, "SIGTERM");
226
+ }
227
+ catch {
228
+ // already exited
229
+ }
230
+ }
231
+ const deadline = Date.now() + graceMs;
232
+ while (runs.some(([pid]) => groupAlive(pid)) && Date.now() < deadline) {
233
+ await new Promise((r) => setTimeout(r, 50));
234
+ }
235
+ for (const [pid, workdir] of runs) {
236
+ try {
237
+ process.kill(-pid, "SIGKILL");
238
+ }
239
+ catch {
240
+ // already exited
241
+ }
242
+ rmSync(workdir, { recursive: true, force: true });
243
+ }
244
+ }
198
245
  const LOGIN_HINT = " — with --config-dir, auth comes from ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (claude setup-token)";
199
246
  /** A fresh config dir has no credentials; point the user at the env vars. */
200
247
  function withLoginHint(error, configDir) {
@@ -286,11 +333,13 @@ export class ClaudeAdapter {
286
333
  let stderrText = "";
287
334
  let error = null;
288
335
  let stoppedEarly = false;
336
+ let stopping = false;
289
337
  let settled = false;
290
338
  let killTimer;
291
339
  let timeoutTimer;
292
340
  // detached => child.pid is the process group id; ESRCH means it is gone
293
341
  const killGroup = (pid) => {
342
+ clearTimeout(timeoutTimer); // the run is ending anyway, a late timeout must not overwrite its outcome
294
343
  try {
295
344
  process.kill(-pid, "SIGTERM");
296
345
  }
@@ -311,6 +360,8 @@ export class ClaudeAdapter {
311
360
  if (settled)
312
361
  return;
313
362
  settled = true;
363
+ if (child?.pid)
364
+ active.delete(child.pid);
314
365
  if (killTimer)
315
366
  clearTimeout(killTimer);
316
367
  if (timeoutTimer)
@@ -334,6 +385,8 @@ export class ClaudeAdapter {
334
385
  return;
335
386
  }
336
387
  const proc = child;
388
+ if (proc.pid)
389
+ active.set(proc.pid, workdir);
337
390
  proc.on("error", (e) => {
338
391
  if (!error)
339
392
  error = spawnErrorMessage(e, bin);
@@ -342,11 +395,9 @@ export class ClaudeAdapter {
342
395
  proc.stdout?.on("data", (chunk) => {
343
396
  if (!stdoutSeen && chunk.trim() !== "")
344
397
  stdoutSeen = true;
345
- tail += chunk;
346
- let nl;
347
- while ((nl = tail.indexOf("\n")) >= 0) {
348
- const line = tail.slice(0, nl);
349
- tail = tail.slice(nl + 1);
398
+ const lines = (tail + chunk).split("\n");
399
+ tail = lines.pop(); // the unfinished last line waits for the next chunk
400
+ for (const line of lines) {
350
401
  const stop = stream.push(line); // always parse: stop and abandon only gate the kill
351
402
  if (error)
352
403
  continue; // first failure wins, the group is already being killed
@@ -356,8 +407,10 @@ export class ClaudeAdapter {
356
407
  if (proc.pid)
357
408
  killGroup(proc.pid);
358
409
  }
359
- else if (!stoppedEarly && ((control.earlyStop && stop) || control.stop?.(stream) === true)) {
360
- stoppedEarly = true;
410
+ else if (!stopping && ((control.earlyStop && stop) || control.stop?.(stream) === true)) {
411
+ stopping = true;
412
+ // after the result event the run is complete: the kill only reaps the process
413
+ stoppedEarly = !stream.finished;
361
414
  if (proc.pid)
362
415
  killGroup(proc.pid);
363
416
  }
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,14 @@ 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
+ // a second Ctrl+C during the grace period exits at once
674
+ process.once(signal, () => process.exit(code));
675
+ void abortActiveRuns().finally(() => process.exit(code));
676
+ });
677
+ }
670
678
  main(process.argv.slice(2)).then((code) => {
671
679
  process.exitCode = code;
672
680
  });
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
- out.push(...pluginSkillDocs(plugin, installPath), ...pluginCommandDocs(plugin, installPath));
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 = pluginManifest(installPath)?.skills;
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 = pluginManifest(installPath)?.commands;
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": "1.0.0",
3
+ "version": "1.0.2",
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
  },