skill-probe 0.5.0 → 0.6.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/README.md CHANGED
@@ -28,6 +28,35 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
28
28
  npm i -g skill-probe # or: npx skill-probe
29
29
  ```
30
30
 
31
+ ## Start here (`skill-probe doctor`)
32
+
33
+ Run this first — it catches the setup problems (no skills dir, runtime not installed or not
34
+ authenticated, a config typo) before you spend any probes:
35
+
36
+ ```bash
37
+ skill-probe doctor --cwd . # check the project + runtime
38
+ skill-probe doctor --config probe.config.json # also sanity-check a config
39
+ ```
40
+
41
+ ```
42
+ skill-probe doctor
43
+
44
+ PASS Node 22.18.0
45
+ PASS config parsed: probe.config.json
46
+ PASS found .claude/skills/ with 4 skills
47
+ PASS expected skills all exist
48
+ PASS claude CLI found
49
+ PASS claude-code probe succeeded (CLI is authenticated)
50
+ WARN threshold 90% with k=10 cannot certify a pass — need ~k=35
51
+ FAIL skill "greeter" missing description:
52
+ ```
53
+
54
+ It checks: Node version, `.claude/skills/` exists, every skill has a `SKILL.md` with `name:` +
55
+ `description:`, names match folders, the config parses, every `expected` skill exists, the
56
+ threshold/k are statistically achievable, the runtime CLI is installed, and a harmless live probe
57
+ authenticates. Exit `0` healthy · `1` warnings only · `2` hard failures. Use `--skip-probe` to skip
58
+ the live (costing) auth check.
59
+
31
60
  ## Generate a config (`skill-probe gen`)
32
61
 
33
62
  Don't want to hand-write the cases? Draft them from your skills, then review:
package/dist/cli.js CHANGED
@@ -8,14 +8,16 @@ import { resolve } from "node:path";
8
8
  import { runAudit } from "./eval.js";
9
9
  import { runFix } from "./fix.js";
10
10
  import { runContextAudit } from "./context.js";
11
+ import { runDoctor } from "./doctor.js";
11
12
  import { listSkills, generateCases, genConfig } from "./gen.js";
12
13
  import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
13
- import { renderTable, renderMarkdown, renderJson, renderFix, renderContext } from "./report.js";
14
+ import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor } from "./report.js";
14
15
  const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
15
16
 
16
17
  USAGE
17
18
  skill-probe --config <file.json> [options] # audit
18
19
  skill-probe context --config <file.json> [options] # isolation vs co-loaded activation rates
20
+ skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
19
21
  skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
20
22
  skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
21
23
 
@@ -32,6 +34,10 @@ OPTIONS
32
34
  --markdown emit a Markdown table (paste into a PR/README)
33
35
  --no-cost hide the cost line (e.g. when on a Claude subscription)
34
36
  --quiet suppress the live progress line on stderr
37
+ doctor only:
38
+ --cwd <dir> project dir containing .claude/skills/ (default: .)
39
+ --config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
40
+ --skip-probe don't run the harmless live probe (skip the auth check; no cost)
35
41
  gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
36
42
  --cwd <dir> project dir containing .claude/skills/ (default: .)
37
43
  --per-skill <n> should-fire prompts to draft per skill (default 3)
@@ -87,6 +93,17 @@ async function audit(values) {
87
93
  console.log(text);
88
94
  return result.exitCode;
89
95
  }
96
+ async function doctor(values) {
97
+ const result = await runDoctor({
98
+ cwd: values["cwd"] ?? ".",
99
+ ...(values["config"] ? { configPath: values["config"] } : {}),
100
+ ...(values["runtime"] ? { runtime: values["runtime"] } : {}),
101
+ ...(values["model"] ? { model: values["model"] } : {}),
102
+ ...(values["skip-probe"] ? { skipProbe: true } : {}),
103
+ });
104
+ console.log(values["json"] ? renderJson(result) : renderDoctor(result));
105
+ return result.exitCode;
106
+ }
90
107
  async function context(values) {
91
108
  const cfg = loadConfig(values["config"], {
92
109
  runtime: values["runtime"], model: values["model"],
@@ -147,6 +164,7 @@ async function main() {
147
164
  cwd: { type: "string" },
148
165
  "per-skill": { type: "string" },
149
166
  decoys: { type: "string" },
167
+ "skip-probe": { type: "boolean", default: false },
150
168
  json: { type: "boolean", default: false },
151
169
  markdown: { type: "boolean", default: false },
152
170
  "no-cost": { type: "boolean", default: false },
@@ -163,6 +181,9 @@ async function main() {
163
181
  if (cmd === "gen") {
164
182
  code = await gen(values);
165
183
  }
184
+ else if (cmd === "doctor") {
185
+ code = await doctor(values); // config is optional for doctor
186
+ }
166
187
  else {
167
188
  if (!values.config) {
168
189
  console.log(HELP);
@@ -0,0 +1,23 @@
1
+ import type { ProbeResult, ProbeOpts } from "./types.ts";
2
+ export type CheckStatus = "pass" | "warn" | "fail" | "info";
3
+ export interface Check {
4
+ status: CheckStatus;
5
+ message: string;
6
+ }
7
+ export interface DoctorResult {
8
+ checks: Check[];
9
+ exitCode: 0 | 1 | 2;
10
+ }
11
+ export interface DoctorOpts {
12
+ cwd: string;
13
+ configPath?: string;
14
+ runtime?: string;
15
+ model?: string;
16
+ skipProbe?: boolean;
17
+ }
18
+ /** Injection seam so tests don't shell out to a real runtime. */
19
+ export interface DoctorDeps {
20
+ cliInstalled?: (cmd: string) => boolean;
21
+ probe?: (runtime: string, prompt: string, opts: ProbeOpts) => Promise<ProbeResult>;
22
+ }
23
+ export declare function runDoctor(opts: DoctorOpts, deps?: DoctorDeps): Promise<DoctorResult>;
package/dist/doctor.js ADDED
@@ -0,0 +1,138 @@
1
+ /** `skill-probe doctor` — preflight checks. Now that the tool is public, the first failures users
2
+ * hit are SETUP failures (no skills dir, runtime not installed, not authed, a config typo), not deep
3
+ * eval problems. doctor surfaces those in one cheap pass before they spend probes on a broken setup.
4
+ *
5
+ * Exit: 0 = healthy, 1 = warnings only, 2 = at least one hard failure. */
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { loadConfig } from "./config.js";
10
+ import { wilsonInterval } from "./stats.js";
11
+ import { kToCertify } from "./report.js";
12
+ import { getAdapter } from "./adapters/index.js";
13
+ const NAME_RE = /^name:\s*(.*)$/m;
14
+ const DESC_RE = /^description:\s*(.*)$/m;
15
+ const MIN_NODE = { major: 22, minor: 18 }; // package.json engines: ">=22.18"
16
+ function realCliInstalled(cmd) {
17
+ const r = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 15_000 });
18
+ // ENOENT = not on PATH. Any other result (even a non-zero exit) means the binary exists.
19
+ return !(r.error && r.error.code === "ENOENT");
20
+ }
21
+ export async function runDoctor(opts, deps = {}) {
22
+ const cliInstalled = deps.cliInstalled ?? realCliInstalled;
23
+ const probe = deps.probe ?? ((rt, p, o) => getAdapter(rt).probe(p, o));
24
+ const checks = [];
25
+ const add = (status, message) => { checks.push({ status, message }); };
26
+ // 1. Node version (declared engine)
27
+ const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
28
+ if (maj > MIN_NODE.major || (maj === MIN_NODE.major && min >= MIN_NODE.minor)) {
29
+ add("pass", `Node ${process.versions.node}`);
30
+ }
31
+ else {
32
+ add("warn", `Node ${process.versions.node} is below the declared engine >=22.18 ` +
33
+ `(the compiled CLI may still run, but this is untested)`);
34
+ }
35
+ // 2. Config (optional). Load first — it can redirect the skills dir (cwd resolves to the config's dir).
36
+ let cfg = null;
37
+ if (opts.configPath) {
38
+ if (!existsSync(resolve(opts.configPath))) {
39
+ add("fail", `config not found: ${opts.configPath}`);
40
+ }
41
+ else {
42
+ try {
43
+ cfg = loadConfig(opts.configPath, { ...(opts.runtime ? { runtime: opts.runtime } : {}) });
44
+ add("pass", `config parsed: ${opts.configPath}`);
45
+ }
46
+ catch (e) {
47
+ add("fail", `config ${opts.configPath}: ${e instanceof Error ? e.message : String(e)}`);
48
+ }
49
+ }
50
+ }
51
+ const skillsCwd = cfg ? cfg.cwd : resolve(opts.cwd);
52
+ const runtime = cfg?.runtime ?? opts.runtime ?? "claude-code";
53
+ // 3-6. Skills directory + each SKILL.md
54
+ const skillNames = new Set();
55
+ const skillsRoot = join(skillsCwd, ".claude", "skills");
56
+ if (!existsSync(skillsRoot)) {
57
+ add("fail", `no .claude/skills/ directory under ${skillsCwd}`);
58
+ }
59
+ else {
60
+ const dirs = readdirSync(skillsRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
61
+ if (dirs.length === 0) {
62
+ add("fail", `.claude/skills/ exists but contains no skill folders`);
63
+ }
64
+ let valid = 0;
65
+ for (const d of dirs) {
66
+ const md = join(skillsRoot, d.name, "SKILL.md");
67
+ if (!existsSync(md)) {
68
+ add("fail", `skill "${d.name}" missing SKILL.md`);
69
+ continue;
70
+ }
71
+ const text = readFileSync(md, "utf8");
72
+ const name = NAME_RE.exec(text)?.[1]?.trim() ?? "";
73
+ const desc = DESC_RE.exec(text)?.[1]?.trim() ?? "";
74
+ if (!name)
75
+ add("fail", `skill "${d.name}" missing name:`);
76
+ else if (name !== d.name)
77
+ add("warn", `skill "${d.name}" has name: "${name}" (doesn't match folder name)`);
78
+ if (!desc)
79
+ add("fail", `skill "${d.name}" missing description:`);
80
+ else if (desc === "|" || desc === ">")
81
+ add("warn", `skill "${d.name}" uses a multi-line description block — a single-line description triggers more reliably`);
82
+ skillNames.add(d.name);
83
+ valid++;
84
+ }
85
+ if (valid > 0)
86
+ add("pass", `found .claude/skills/ with ${valid} skill${valid === 1 ? "" : "s"}`);
87
+ }
88
+ // 7-9. Config-dependent checks
89
+ if (cfg) {
90
+ const missing = cfg.cases
91
+ .map((c) => c.expected).filter((s) => s !== null)
92
+ .filter((s) => !skillNames.has(s));
93
+ if (missing.length)
94
+ add("fail", `config expects skill(s) not in the library: ${[...new Set(missing)].join(", ")}`);
95
+ else
96
+ add("pass", `expected skills all exist`);
97
+ if (cfg.cases.every((c) => c.expected === null)) {
98
+ add("warn", `config has only decoy cases — no skill is being verified to fire`);
99
+ }
100
+ // a strict threshold at a small k can be mathematically impossible to certify
101
+ if (wilsonInterval(cfg.k, cfg.k, cfg.conf).lo <= cfg.threshold) {
102
+ add("warn", `threshold ${Math.round(cfg.threshold * 100)}% with k=${cfg.k} cannot certify a pass ` +
103
+ `even on a perfect run — need ~k=${kToCertify(cfg.threshold, cfg.conf)} (or lower the threshold)`);
104
+ }
105
+ }
106
+ // 10-11. Runtime + a harmless live probe (verifies the CLI is installed AND authed)
107
+ add("info", `runtime: ${runtime} model: ${cfg?.model ?? opts.model ?? "(runtime default)"}`);
108
+ const known = runtime === "claude-code" || runtime === "opencode";
109
+ const bin = runtime === "claude-code" ? "claude" : runtime === "opencode" ? "opencode" : null;
110
+ let cliOk = false;
111
+ if (!known) {
112
+ add("fail", `unknown runtime "${runtime}" (expected claude-code or opencode)`);
113
+ }
114
+ else if (bin && cliInstalled(bin)) {
115
+ add("pass", `${bin} CLI found`);
116
+ cliOk = true;
117
+ }
118
+ else {
119
+ add("fail", `${bin} CLI not found on PATH — install it and authenticate to run ${runtime}`);
120
+ }
121
+ if (opts.skipProbe) {
122
+ add("info", `probe skipped (--skip-probe)`);
123
+ }
124
+ else if (known && cliOk) {
125
+ const r = await probe(runtime, "Reply with exactly: ok", {
126
+ cwd: skillsCwd, timeoutMs: 60_000,
127
+ ...(cfg?.model ?? opts.model ? { model: (cfg?.model ?? opts.model) } : {}),
128
+ });
129
+ if (r.status === "ok")
130
+ add("pass", `${runtime} probe succeeded (CLI is authenticated)`);
131
+ else
132
+ add("fail", `${runtime} probe failed — check auth/login: ${r.error ?? "unknown error"}`);
133
+ }
134
+ const hasFail = checks.some((c) => c.status === "fail");
135
+ const hasWarn = checks.some((c) => c.status === "warn");
136
+ const exitCode = hasFail ? 2 : hasWarn ? 1 : 0;
137
+ return { checks, exitCode };
138
+ }
package/dist/report.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { AuditResult } from "./eval.ts";
2
2
  import type { FixResult } from "./fix.ts";
3
3
  import type { ContextAuditResult } from "./context.ts";
4
+ import type { DoctorResult } from "./doctor.ts";
5
+ /** Smallest k at which an all-success run could certify reliability >= threshold. */
6
+ export declare function kToCertify(threshold: number, conf: number): number;
4
7
  export interface RenderOpts {
5
8
  showCost?: boolean;
6
9
  }
@@ -8,6 +11,7 @@ export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
8
11
  /** Markdown table — paste into a PR or README. */
9
12
  export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
10
13
  export declare function renderJson(a: AuditResult | unknown): string;
14
+ export declare function renderDoctor(d: DoctorResult): string;
11
15
  /** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
12
16
  export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
13
17
  export declare function renderFix(f: FixResult): string;
package/dist/report.js CHANGED
@@ -8,7 +8,7 @@ const LABEL = {
8
8
  inconclusive: "INCONCLUSIVE", error: "ERROR ",
9
9
  };
10
10
  /** Smallest k at which an all-success run could certify reliability >= threshold. */
11
- function kToCertify(threshold, conf) {
11
+ export function kToCertify(threshold, conf) {
12
12
  for (let k = 1; k <= 500; k++) {
13
13
  if (wilsonInterval(k, k, conf).lo > threshold)
14
14
  return k;
@@ -81,6 +81,18 @@ export function renderMarkdown(a, opts = {}) {
81
81
  export function renderJson(a) {
82
82
  return JSON.stringify(a, null, 2);
83
83
  }
84
+ export function renderDoctor(d) {
85
+ const out = ["skill-probe doctor", ""];
86
+ for (const c of d.checks)
87
+ out.push(`${c.status.toUpperCase().padEnd(4)} ${c.message}`);
88
+ out.push("");
89
+ const f = d.checks.filter((c) => c.status === "fail").length;
90
+ const w = d.checks.filter((c) => c.status === "warn").length;
91
+ out.push(f ? `${f} failure(s)` + (w ? `, ${w} warning(s)` : "") + ` | exit ${d.exitCode}`
92
+ : w ? `${w} warning(s) | exit ${d.exitCode}`
93
+ : `all healthy | exit 0`);
94
+ return out.join("\n");
95
+ }
84
96
  /** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
85
97
  export function renderContext(a, opts = {}) {
86
98
  const showCost = opts.showCost ?? true;
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "skill-probe",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Runtime-agnostic, co-loaded-aware auditor for AI agent skill libraries: measures which skill actually fires, with statistical confidence.",
5
5
  "type": "module",
6
- "bin": { "skill-probe": "dist/cli.js" },
6
+ "bin": {
7
+ "skill-probe": "dist/cli.js"
8
+ },
7
9
  "scripts": {
8
10
  "dev": "node src/cli.ts",
9
11
  "test": "node --test test/*.test.ts",
@@ -11,13 +13,36 @@
11
13
  "build": "tsc",
12
14
  "prepublishOnly": "npm run build && npm test"
13
15
  },
14
- "keywords": ["agent-skills", "skill", "eval", "claude-code", "mcp", "SKILL.md", "trigger"],
16
+ "keywords": [
17
+ "agent-skills",
18
+ "skill",
19
+ "eval",
20
+ "claude-code",
21
+ "mcp",
22
+ "SKILL.md",
23
+ "trigger"
24
+ ],
15
25
  "author": "HystonKayange",
16
26
  "license": "MIT",
17
- "repository": { "type": "git", "url": "git+https://github.com/HystonKayange/skill-probe.git" },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/HystonKayange/skill-probe.git"
30
+ },
18
31
  "homepage": "https://github.com/HystonKayange/skill-probe#readme",
19
- "bugs": { "url": "https://github.com/HystonKayange/skill-probe/issues" },
20
- "engines": { "node": ">=22.18" },
21
- "devDependencies": { "typescript": "^5.5.0", "@types/node": "^22.0.0" },
22
- "files": ["dist", "examples", "README.md", "LICENSE"]
32
+ "bugs": {
33
+ "url": "https://github.com/HystonKayange/skill-probe/issues"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.18"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^5.5.0",
40
+ "@types/node": "^22.0.0"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "examples",
45
+ "README.md",
46
+ "LICENSE"
47
+ ]
23
48
  }