From 0613281d394671395dd1fef11b930e7e6a5a6d9a Mon Sep 17 00:00:00 2001 From: icntswm <82268105+icntswm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:19:33 +0300 Subject: [PATCH] Add --plugin-dir and show what the cache saved --- README.md | 3 ++ action.yml | 29 +++++++++-- docs/ci.md | 13 ++++- docs/commands.md | 1 + src/agents/claude.ts | 12 +++-- src/agents/types.ts | 4 +- src/cache.ts | 2 + src/cases.ts | 9 ++-- src/cli.ts | 38 ++++++++++----- src/describe.ts | 110 +++++++++++++++++++++++++++++++++--------- src/markdown.ts | 8 ++- src/report.ts | 3 +- src/results.ts | 6 ++- test/adapter.test.ts | 37 ++++++++++++++ test/cases.test.ts | 12 +++++ test/cli.test.ts | 83 ++++++++++++++++++++++++++++++- test/describe.test.ts | 74 +++++++++++++++++++++++++++- test/markdown.test.ts | 11 +++++ test/report.test.ts | 9 ++++ test/results.test.ts | 25 ++++++++++ 20 files changed, 434 insertions(+), 55 deletions(-) create mode 100644 test/results.test.ts diff --git a/README.md b/README.md index 331c942..e61184e 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ skillcheck run --only 2,5 # confirm what the batch flagged with real runs Every option, the default file lookup and exit codes are in [Commands](docs/commands.md). +To test a plugin from its repository, use `--config-dir` with an empty +directory and `--plugin-dir .`; cases name its skills as `plugin:skill`. + No cases yet? `skillcheck gen -o skillcheck.yaml` lets the model draft them from your skill descriptions, for you to review. Coming from Anthropic's skill-creator? `skillcheck import eval_set.json --skill ` converts its diff --git a/action.yml b/action.yml index 24b73a3..c6c0932 100644 --- a/action.yml +++ b/action.yml @@ -15,6 +15,9 @@ inputs: Claude Code config dir, so the run sees only them. Empty uses the runner's own config. default: skills + plugin-dir: + description: Directory of a Claude Code plugin to test from source; empty auto-detects .claude-plugin/plugin.json at the repository root. + default: "" lint: description: Run the free lint step first. default: "true" @@ -94,15 +97,27 @@ runs: run: npm install -g "@anthropic-ai/claude-code@$CC_VERSION" "@icntswm/skillcheck@$SC_VERSION" - name: config dir - if: inputs.skills-dir != '' shell: bash env: SKILLS_DIR: ${{ inputs.skills-dir }} + PLUGIN_DIR: ${{ inputs.plugin-dir }} run: | dir="$RUNNER_TEMP/skillcheck-config" - mkdir -p "$dir/skills" - cp -R "$SKILLS_DIR/." "$dir/skills/" - echo "SKILLCHECK_CONFIG_DIR=$dir" >> "$GITHUB_ENV" + if [ -n "$PLUGIN_DIR" ] || [ -f ".claude-plugin/plugin.json" ]; then + rm -rf "$dir" + mkdir -p "$dir" + echo "SKILLCHECK_CONFIG_DIR=$dir" >> "$GITHUB_ENV" + echo "SKILLCHECK_PLUGIN_DIR=${PLUGIN_DIR:-.}" >> "$GITHUB_ENV" + elif [ -n "$SKILLS_DIR" ]; then + mkdir -p "$dir/skills" + cp -R "$SKILLS_DIR/." "$dir/skills/" + echo "SKILLCHECK_CONFIG_DIR=$dir" >> "$GITHUB_ENV" + echo "SKILLCHECK_PLUGIN_DIR=" >> "$GITHUB_ENV" + else + # GITHUB_ENV lasts the whole job: clear what an earlier call of this action set + echo "SKILLCHECK_CONFIG_DIR=" >> "$GITHUB_ENV" + echo "SKILLCHECK_PLUGIN_DIR=" >> "$GITHUB_ENV" + fi - name: lint if: inputs.lint == 'true' @@ -112,6 +127,9 @@ runs: run: | args=(lint "$FILE") [ -n "$SKILLCHECK_CONFIG_DIR" ] && args+=(--config-dir "$SKILLCHECK_CONFIG_DIR") + if [ -n "$SKILLCHECK_PLUGIN_DIR" ]; then + if skillcheck --help | grep -- --plugin-dir > /dev/null; then args+=(--plugin-dir "$SKILLCHECK_PLUGIN_DIR"); else echo "::warning::this skillcheck version has no --plugin-dir"; fi + fi skillcheck "${args[@]}" - name: run @@ -131,6 +149,9 @@ runs: run: | args=(run "$FILE") [ -n "$SKILLCHECK_CONFIG_DIR" ] && args+=(--config-dir "$SKILLCHECK_CONFIG_DIR") + if [ -n "$SKILLCHECK_PLUGIN_DIR" ]; then + if skillcheck --help | grep -- --plugin-dir > /dev/null; then args+=(--plugin-dir "$SKILLCHECK_PLUGIN_DIR"); else echo "::warning::this skillcheck version has no --plugin-dir"; fi + fi [ "$BATCH" = "true" ] && args+=(--batch) [ -n "$MODEL" ] && args+=(--model "$MODEL") [ -n "$REPEAT" ] && args+=(--repeat "$REPEAT") diff --git a/docs/ci.md b/docs/ci.md index d0dec2d..ef4f03a 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -151,7 +151,7 @@ does not block unrelated pull requests, but it still shows in the comment. when nothing that affects its routing changed: the case itself, the frontmatter of any skill or command, `CLAUDE.md` files, the model, the timeout, the agent and its version. It does not work with `batch: true`. Failed and skipped cases -always run again. Reused cases are marked cached and cost nothing. +always run again. Reused cases are marked cached and cost nothing; the report shows what the cache saved. Editing a skill's body (everything after the frontmatter) does not invalidate the cache, since only descriptions and other frontmatter affect routing. @@ -189,6 +189,17 @@ A failing case fails the step, so a step that reads the outputs needs `if: always()`. `skillcheck.xml` still works with `actions/upload-artifact` or a JUnit reporter action. +### Testing a plugin + +Set the action's `plugin-dir` input to a plugin source directory. When it is +empty, the action auto-detects `.claude-plugin/plugin.json` at the repository +root. Plugin mode uses an empty isolated config directory, and cases refer to +skills as `plugin:skill`. The `skills-dir` input is ignored in plugin mode. + +```yaml +- uses: icntswm/skillcheck@v1 +``` + ### Only the skills a pull request touched `--skill` keeps the cases that mention the given skills, so a pull request diff --git a/docs/commands.md b/docs/commands.md index 9979d5f..23fb228 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -153,6 +153,7 @@ purpose: | `--no-early-stop` | | let the agent finish its turn instead of stopping it once a skill is picked; costs more; not with `--batch` | | `--budget ` | | stop starting new runs once the spend estimate reaches this; the rest is reported as skipped | | `--config-dir ` | | see [Where skills come from](#where-skills-come-from) | +| `--plugin-dir ` | | load a plugin from source for this session; repeatable | **Reports** diff --git a/src/agents/claude.ts b/src/agents/claude.ts index da17a0e..cede035 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -213,6 +213,7 @@ function claudeArgs(opts: RunOptions): string[] { "--disallowedTools", ...DISALLOWED_TOOLS, ]; if (opts.model) args.push("--model", opts.model); + for (const dir of opts.pluginDirs ?? []) args.push("--plugin-dir", dir); return args; } @@ -230,13 +231,14 @@ function batchArgs(opts: BatchOptions): string[] { "--disallowedTools", ...DISALLOWED_TOOLS, ]; if (opts.model) args.push("--model", opts.model); + for (const dir of opts.pluginDirs ?? []) args.push("--plugin-dir", dir); return args; } -function listArgs(): string[] { +function listArgs(opts: { pluginDirs?: string[] }): string[] { // The init event arrives before any model call, so the process is killed // before anything is billed; the disallow list is a guard for that race. - return [ + const args = [ "-p", "none", "--output-format", "stream-json", "--verbose", @@ -244,6 +246,8 @@ function listArgs(): string[] { "--permission-mode", "plan", "--disallowedTools", ...DISALLOWED_TOOLS, ]; + for (const dir of opts.pluginDirs ?? []) args.push("--plugin-dir", dir); + return args; } /** Seconds as written in the timeout message: 180000 -> "180", 400 -> "0.4". */ @@ -385,11 +389,11 @@ export class ClaudeAdapter implements AgentAdapter { } /** Ask the agent for its skill lists; killed right after init, so no model call. */ - async listSkills(opts: { configDir?: string; timeoutMs: number }): Promise { + async listSkills(opts: { configDir?: string; pluginDirs?: string[]; timeoutMs: number }): Promise { const bin = process.env.SKILLCHECK_CLAUDE_BIN || "claude"; const workdir = await mkdtemp(path.join(os.tmpdir(), "skillcheck-")); try { - const out = await this.streamRun(bin, listArgs(), workdir, opts.timeoutMs, { + const out = await this.streamRun(bin, listArgs(opts), workdir, opts.timeoutMs, { earlyStop: false, configDir: opts.configDir, stop: (stream) => stream.sawInit, diff --git a/src/agents/types.ts b/src/agents/types.ts index ddbcb77..68b2ab4 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -6,6 +6,7 @@ export interface RunOptions { earlyStop: boolean; /** claude config dir, passed to the child as CLAUDE_CONFIG_DIR */ configDir?: string; + pluginDirs?: string[]; } /** Billed tokens of one run, as the API reports them. */ @@ -42,6 +43,7 @@ export interface BatchOptions { timeoutMs: number; /** claude config dir, passed to the child as CLAUDE_CONFIG_DIR */ configDir?: string; + pluginDirs?: string[]; } export interface BatchResult { @@ -69,5 +71,5 @@ export interface AgentAdapter { /** one call answers many cases; agents without batch mode omit it */ runBatch?(opts: BatchOptions): Promise; /** ask the agent what it can load; agents without this omit it */ - listSkills?(opts: { configDir?: string; timeoutMs: number }): Promise; + listSkills?(opts: { configDir?: string; pluginDirs?: string[]; timeoutMs: number }): Promise; } diff --git a/src/cache.ts b/src/cache.ts index 1173762..6fd6bb4 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -88,6 +88,8 @@ export function routingContext( kind: doc.kind, name: doc.name, plugin: doc.plugin, + // inline manifest commands have no frontmatter of their own + description: doc.description, frontmatter: rawFrontmatter(readFile, doc.file), })); for (const root of roots) { diff --git a/src/cases.ts b/src/cases.ts index 35f5625..4944ae1 100644 --- a/src/cases.ts +++ b/src/cases.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import * as fs from "node:fs"; import * as path from "node:path"; import { parse as parseYaml } from "yaml"; -import { commandNames, skillDirNames, skillRoots } from "./describe.js"; +import { commandNames, loadSkillDocs, skillDirNames, skillRoots } from "./describe.js"; export interface Case { index: number; // 1-based position in file @@ -217,24 +217,25 @@ export function findDefaultCasesFile(cwd: string): string | undefined { } /** Names of skills/commands discoverable on disk, plus built-in slash skills. */ -export function knownSkillNames(opts?: { home?: string; cwd?: string; configDir?: string }): Set { +export function knownSkillNames(opts?: { home?: string; cwd?: string; configDir?: string; pluginDirs?: string[] }): Set { const out = new Set(BUILTIN_SKILLS); for (const root of skillRoots(opts)) { for (const name of skillDirNames(path.join(root, "skills"))) out.add(name); for (const name of commandNames(path.join(root, "commands"))) out.add(name); } + for (const doc of loadSkillDocs(opts)) if (doc.plugin !== null) out.add(doc.name); return out; } const NAME_FIELDS = ["expect", "expect_any", "forbid", "first"] as const; -export function unknownNames(suite: Suite, known: Set): string[] { +export function unknownNames(suite: Suite, known: Set, sourcePlugins: Set = new Set()): string[] { const errors: string[] = []; for (const c of suite.cases) { for (const field of NAME_FIELDS) { const names = field === "first" ? (c.first ? [c.first] : []) : c[field]; for (const name of names) { - if (name.includes(":")) continue; // plugin skills are not checked + if (name.includes(":") && !sourcePlugins.has(name.slice(0, name.indexOf(":")))) continue; // installed plugin skills are not checked if (!known.has(name)) errors.push(`#${c.index}: unknown skill "${name}" in ${field}`); } } diff --git a/src/cli.ts b/src/cli.ts index d564b3f..01b8832 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,7 @@ import { type Suite, } from "./cases.js"; import { confusion } from "./confusion.js"; -import { loadSkillDocs, pluginInstallPaths, skillRoots, type SkillDoc } from "./describe.js"; +import { loadSkillDocs, pluginInstallPaths, skillRoots, sourcePlugins, type SkillDoc } from "./describe.js"; import { GEN_SCHEMA, buildGenPrompt, caseEntry, genSuite, orderCases, parseGenAnswer, type GenCase } from "./gen.js"; import { aggregate, judge, type CaseResult, type RunVerdict } from "./judge.js"; import { evalSetToSuite, type ImportedSuite } from "./import.js"; @@ -80,6 +80,8 @@ list/init options: --timeout per-call timeout (same default as run) --config-dir claude config dir: run/check/lint/list/init use it instead of CLAUDE_CONFIG_DIR; run, list and init also set it for the agent + --plugin-dir plugin source directory (repeatable); run/check/lint/list/init/gen + load plugins from it for this session init options: --force overwrite an existing file import options: @@ -134,6 +136,7 @@ const OPTIONS = { overlap: { type: "string" }, strict: { type: "boolean", default: false }, "config-dir": { type: "string" }, + "plugin-dir": { type: "string", multiple: true }, force: { type: "boolean", default: false }, out: { type: "string", short: "o" }, "per-skill": { type: "string" }, @@ -141,7 +144,7 @@ const OPTIONS = { append: { type: "string" }, } satisfies ParseArgsOptionsConfig; -type Values = { [K in keyof typeof OPTIONS]: (typeof OPTIONS)[K]["type"] extends "boolean" ? boolean : string | undefined }; +type Values = { [K in keyof typeof OPTIONS]: (typeof OPTIONS)[K]["type"] extends "boolean" ? boolean : string | undefined } & { "plugin-dir": string[] | undefined }; export interface Io { stdout: ReportStream; @@ -181,6 +184,7 @@ interface Flags { overlap: number; strict: boolean; configDir?: string; + pluginDirs: string[]; force: boolean; out?: string; perSkill: number; @@ -228,12 +232,21 @@ function parseFlags(values: Values, cwd: string): Flags { overlap: ratioFlag(values.overlap) ?? 0.3, strict: values.strict, configDir: values["config-dir"] !== undefined ? resolveConfigDir(values["config-dir"], cwd) : undefined, + pluginDirs: (values["plugin-dir"] ?? []).map((dir) => resolvePluginDir(dir, cwd)), force: values.force, out: values.out, perSkill: intFlag(values["per-skill"], "per-skill", 1) ?? 4, }; } +function resolvePluginDir(raw: string, cwd: string): string { + const abs = path.resolve(cwd, raw); + let isDir = false; + try { isDir = fs.statSync(abs).isDirectory(); } catch { /* missing */ } + if (!isDir) throw new UsageError(`--plugin-dir is not a directory: ${abs}`); + return abs; +} + /** --config-dir: absolute, must be an existing directory. */ function resolveConfigDir(raw: string, cwd: string): string { const abs = path.resolve(cwd, raw); @@ -366,7 +379,7 @@ async function checkCommand(file: string, flags: Flags, io: Io): Promise if (!suite) return 2; const cases = applySkillFilter(suite.cases, flags.skill); if (flags.nameCheck) { - const errors = unknownNames({ ...suite, cases }, knownSkillNames({ cwd: io.cwd, configDir: flags.configDir })); + const errors = unknownNames({ ...suite, cases }, knownSkillNames({ cwd: io.cwd, configDir: flags.configDir, pluginDirs: flags.pluginDirs }), new Set(sourcePlugins(flags.pluginDirs).map((p) => p.plugin))); if (errors.length > 0) { printConfigError(io.stderr, file, new ConfigError(errors)); return 2; @@ -385,7 +398,7 @@ async function lintCommand(positional: string | undefined, flags: Flags, io: Io) } else { suite = null; } - const docs = loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir }); + const docs = loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir, pluginDirs: flags.pluginDirs }); const report = lint(docs, suite, { top: flags.top, overlap: flags.overlap, minLength: LINT_DEFAULTS.minLength }); printLint(io.stdout, report, docs, suite); if (flags.strict && (report.short.length || report.similar.length || report.far.length)) return 1; @@ -399,7 +412,7 @@ async function listCommand(flags: Flags, io: Io, deps: { adapter?: AgentAdapter io.stderr.write(`skillcheck: agent ${adapter.name} has no list mode\n`); return 2; } - const list = await adapter.listSkills({ configDir: flags.configDir, timeoutMs: flags.timeoutSec * 1000 }); + const list = await adapter.listSkills({ configDir: flags.configDir, pluginDirs: flags.pluginDirs, timeoutMs: flags.timeoutSec * 1000 }); if (list.error) { io.stderr.write(`skillcheck: ${list.error}\n`); return 2; @@ -464,7 +477,7 @@ async function initCommand( let skills: string[] = []; let slashCommands: string[] = []; const list: SkillList | null = adapter.listSkills - ? await adapter.listSkills({ configDir: flags.configDir, timeoutMs: flags.timeoutSec * 1000 }) + ? await adapter.listSkills({ configDir: flags.configDir, pluginDirs: flags.pluginDirs, timeoutMs: flags.timeoutSec * 1000 }) : null; if (list && !list.error) { skills = list.skills; @@ -473,7 +486,7 @@ async function initCommand( // a missing or failing agent must not block writing the starter file const why = list?.error ?? `agent ${adapter.name} has no list mode`; io.stderr.write(`note: could not ask the agent (${why}); listed skills found on disk\n`); - skills = [...knownSkillNames({ cwd: io.cwd, configDir: flags.configDir })].sort(); + skills = [...knownSkillNames({ cwd: io.cwd, configDir: flags.configDir, pluginDirs: flags.pluginDirs })].sort(); } try { fs.writeFileSync(file, initTemplate(flags.agent ?? "claude", skills, slashCommands)); @@ -523,7 +536,7 @@ function importCommand(positional: string | undefined, flags: Flags, io: Io): nu } async function genCommand(flags: Flags, io: Io, deps: { adapter?: AgentAdapter }): Promise { - const docs = loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir }); + const docs = loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir, pluginDirs: flags.pluginDirs }); const byName = new Map(docs.map((doc) => [doc.name, doc])); const append = flags.append === undefined ? null : path.resolve(io.cwd, flags.append); const existing = append === null ? null : await loadCases(append, io); @@ -571,6 +584,7 @@ async function genCommand(flags: Flags, io: Io, deps: { adapter?: AgentAdapter } model: flags.model, timeoutMs: flags.timeoutSec * 1000, configDir: flags.configDir, + pluginDirs: flags.pluginDirs, }); if (result.error) { io.stderr.write(`skillcheck: gen failed for ${group.map((doc) => doc.name).join(", ")}: ${result.error}\n`); @@ -793,10 +807,10 @@ async function runCommand(file: string, flags: Flags, io: Io, deps: { adapter?: } const selected = applySkillFilter(byOnly, flags.skill); - const docs = flags.cacheFile === undefined ? [] : loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir }); + const docs = flags.cacheFile === undefined ? [] : loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir, pluginDirs: flags.pluginDirs }); let context = ""; if (flags.cacheFile !== undefined) { - const where = { cwd: io.cwd, configDir: flags.configDir }; + const where = { cwd: io.cwd, configDir: flags.configDir, pluginDirs: flags.pluginDirs }; const [userRoot, projectRoot] = skillRoots(where) as [string, string]; // nested commands/ dirs, which loadSkillDocs does not list const roots = [ @@ -890,6 +904,7 @@ async function runCommand(file: string, flags: Flags, io: Io, deps: { adapter?: fixed: report.cases.filter((c) => c.change === "fixed").map((c) => `#${c.id ?? c.index}`), } : undefined, cached: report.summary.cached, + savedUsd: report.summary.savedUsd, }); if (flags.batch) { // cases that only errored have no answer to confirm @@ -976,6 +991,7 @@ async function runIndividually( timeoutMs: flags.timeoutSec * 1000, earlyStop: flags.earlyStop, configDir: flags.configDir, + pluginDirs: flags.pluginDirs, }); fatal.note(r.error); if (r.availableSkills) { @@ -1026,7 +1042,7 @@ async function runBatched( const active = job.chunk.filter((item) => job.round < item.runs.length); if (active.length === 0) return; const prompt = buildBatchPrompt(active.map((item, i) => ({ n: i + 1, query: item.c.query }))); - const br = await runBatch({ prompt, schema: BATCH_SCHEMA, model, timeoutMs: flags.timeoutSec * 1000, configDir: flags.configDir }); + const br = await runBatch({ prompt, schema: BATCH_SCHEMA, model, timeoutMs: flags.timeoutSec * 1000, configDir: flags.configDir, pluginDirs: flags.pluginDirs }); const parsed = parseBatchAnswer(br.structured, br.text); const chunkError = br.error ?? parsed.error; // a chunk error is every case's error fatal.note(br.error); diff --git a/src/describe.ts b/src/describe.ts index eb7dae5..d247301 100644 --- a/src/describe.ts +++ b/src/describe.ts @@ -64,7 +64,7 @@ export function commandNames(dir: string): string[] { return out; } -export function loadSkillDocs(opts?: { home?: string; cwd?: string; configDir?: string }): SkillDoc[] { +export function loadSkillDocs(opts?: { home?: string; cwd?: string; configDir?: string; pluginDirs?: string[] }): SkillDoc[] { const cwd = opts?.cwd ?? process.cwd(); const byName = new Map(); const roots = skillRoots(opts); @@ -83,7 +83,12 @@ export function loadSkillDocs(opts?: { home?: string; cwd?: string; configDir?: for (const [name, doc] of inRoot) byName.set(name, doc); } // plugin docs carry a `plugin:` prefix, so they never collide with the above - for (const doc of pluginDocs(roots[0] as string, cwd)) { + const sources = sourcePlugins(opts?.pluginDirs ?? []); + const sourceNames = new Set(sources.map((p) => p.plugin)); + for (const doc of pluginDocs(pluginInstallPaths(opts).filter((p) => !sourceNames.has(p.plugin)))) { + if (!byName.has(doc.name)) byName.set(doc.name, doc); + } + for (const doc of pluginDocs(sources)) { if (!byName.has(doc.name)) byName.set(doc.name, doc); } return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); @@ -177,16 +182,41 @@ function unquote(value: string): string { } /** Plugin skills and commands registered under the user config root. */ -function pluginDocs(configRoot: string, cwd: string): SkillDoc[] { - return enabledPlugins(configRoot, cwd).flatMap(({ plugin, installPath }) => { +function pluginDocs(plugins: { plugin: string; installPath: string }[]): SkillDoc[] { + return plugins.flatMap(({ plugin, installPath }) => { const manifest = pluginManifest(installPath); return [...pluginSkillDocs(plugin, installPath, manifest), ...pluginCommandDocs(plugin, installPath, manifest)]; }); } /** The plugins Claude Code loads for cwd, with their install dirs. */ -export function pluginInstallPaths(opts?: { home?: string; cwd?: string; configDir?: string }): { plugin: string; installPath: string }[] { - return enabledPlugins(skillRoots(opts)[0] as string, opts?.cwd ?? process.cwd()); +export function pluginInstallPaths(opts?: { home?: string; cwd?: string; configDir?: string; pluginDirs?: string[] }): { plugin: string; installPath: string }[] { + const installed = enabledPlugins(skillRoots(opts)[0] as string, opts?.cwd ?? process.cwd()); + const sources = sourcePlugins(opts?.pluginDirs ?? []); + const sourceNames = new Set(sources.map((p) => p.plugin)); + return [...installed.filter((p) => !sourceNames.has(p.plugin)), ...sources]; +} + +export function sourcePlugins(dirs: string[]): { plugin: string; installPath: string }[] { + const out: { plugin: string; installPath: string }[] = []; + for (const dir of dirs) { + if (isFile(path.join(dir, ".claude-plugin", "plugin.json"))) out.push(sourcePlugin(dir)); + else { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + // statSync follows directory symlinks, so a linked child plugin counts too. + const child = path.join(dir, entry.name); + try { + if (fs.statSync(child).isDirectory() && isFile(path.join(child, ".claude-plugin", "plugin.json"))) out.push(sourcePlugin(child)); + } catch { /* not a readable plugin directory */ } + } + } + } + return out; +} + +function sourcePlugin(installPath: string): { plugin: string; installPath: string } { + const name = pluginManifest(installPath)?.name; + return { plugin: typeof name === "string" && name.trim() !== "" ? name.trim() : path.basename(installPath), installPath }; } function enabledPlugins(configRoot: string, cwd: string): { plugin: string; installPath: string }[] { @@ -224,15 +254,29 @@ function disabledPlugins(configRoot: string): Set { type Manifest = Record | null; +/** Manifest component paths: one string or an array of them, relative to the plugin root. */ +function manifestPaths(installPath: string, declared: unknown): string[] { + const list = typeof declared === "string" ? [declared] : Array.isArray(declared) ? declared : []; + return list.filter((s): s is string => typeof s === "string").map((rel) => path.resolve(installPath, rel)); +} + function pluginSkillDocs(plugin: string, installPath: string, manifest: Manifest): SkillDoc[] { - const declared = manifest?.skills; - const dirs = Array.isArray(declared) - ? declared.filter((s): s is string => typeof s === "string").map((rel) => path.resolve(installPath, rel)) - : skillDirNames(path.join(installPath, "skills")).map((name) => path.join(installPath, "skills", name)); + // `skills` adds to the default skills/ scan; each entry holds /SKILL.md dirs or SKILL.md itself + const defaultDir = path.join(installPath, "skills"); + const declared = manifestPaths(installPath, manifest?.skills); + const dirs: string[] = []; + for (const root of [defaultDir, ...declared]) { + if (isFile(path.join(root, "SKILL.md"))) dirs.push(root); + else dirs.push(...skillDirNames(root).map((name) => path.join(root, name))); + } + // a bare SKILL.md at the root is a single-skill plugin + if (dirs.length === 0 && declared.length === 0 && !isDir(defaultDir) && isFile(path.join(installPath, "SKILL.md"))) dirs.push(installPath); const out: SkillDoc[] = []; + const seen = new Set(); for (const dir of dirs) { const file = path.join(dir, "SKILL.md"); - if (!isFile(file)) continue; + if (!isFile(file) || seen.has(file)) continue; + seen.add(file); const fm = readFrontmatter(file); const name = fm.name !== undefined && fm.name.trim() !== "" ? fm.name.trim() : path.basename(dir); out.push({ name: `${plugin}:${name}`, kind: "skill", file, description: describeFrom(fm), plugin }); @@ -242,20 +286,30 @@ function pluginSkillDocs(plugin: string, installPath: string, manifest: Manifest function pluginCommandDocs(plugin: string, installPath: string, manifest: Manifest): SkillDoc[] { const declared = manifest?.commands; - let files: string[]; - if (Array.isArray(declared)) { - files = []; - for (const entry of declared) { - if (typeof entry !== "string") continue; - const p = path.resolve(installPath, entry); - if (isFile(p)) { - if (p.endsWith(".md")) files.push(p); - } else { - files.push(...commandFiles(p)); - } + // an object map names each command: { name: { source | content, description? } } + if (typeof declared === "object" && declared !== null && !Array.isArray(declared)) { + const manifestFile = path.join(installPath, ".claude-plugin", "plugin.json"); + const out: SkillDoc[] = []; + for (const [name, raw] of Object.entries(declared)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Record; + const source = typeof entry.source === "string" ? path.resolve(installPath, entry.source) : null; + if (source === null && typeof entry.content !== "string") continue; + const file = source !== null && isFile(source) ? source : manifestFile; + const description = typeof entry.description === "string" ? entry.description : file === source ? readDescription(file) : ""; + out.push({ name: `${plugin}:${name}`, kind: "command", file, description, plugin }); + } + return out; + } + // a path or an array replaces the default commands/ scan + const files: string[] = []; + const paths = declared === undefined ? [path.join(installPath, "commands")] : manifestPaths(installPath, declared); + for (const p of paths) { + if (isFile(p)) { + if (p.endsWith(".md")) files.push(p); + } else { + files.push(...commandFiles(p)); } - } else { - files = commandFiles(path.join(installPath, "commands")); } return files.map((file) => ({ name: `${plugin}:${path.basename(file).replace(/\.md$/, "")}`, @@ -291,3 +345,11 @@ function isFile(p: string): boolean { return false; } } + +function isDir(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} diff --git a/src/markdown.ts b/src/markdown.ts index 7a76c2f..d4ffb84 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -58,12 +58,16 @@ export function toMarkdown(report: SuiteReport): string { function header(report: SuiteReport): string { const s = report.summary; if (s.failed === 0 && s.skipped === 0) { - return `### ✅ skillcheck: ${s.cases} passed${(s.cached ?? 0) > 0 ? `, ${s.cached} cached` : ""}`; + return `### ✅ skillcheck: ${s.cases} passed${cachedText(s)}`; } const failedPart = s.failed > 0 ? `${s.failed} failed` : null; const skippedPart = s.skipped > 0 ? `${s.skipped} skipped` : null; const parts = [failedPart, skippedPart].filter((p): p is string => p !== null).join(", "); - return `### ❌ skillcheck: ${parts} of ${s.cases}${(s.cached ?? 0) > 0 ? `, ${s.cached} cached` : ""}`; + return `### ❌ skillcheck: ${parts} of ${s.cases}${cachedText(s)}`; +} + +function cachedText(s: SuiteReport["summary"]): string { + return (s.cached ?? 0) > 0 ? `, ${s.cached} cached${(s.savedUsd ?? 0) > 0 ? ` (saved $${s.savedUsd?.toFixed(2)})` : ""}` : ""; } /** Model, batch flag, runs, cost and duration, " · " separated. */ diff --git a/src/report.ts b/src/report.ts index 44f582a..390168d 100644 --- a/src/report.ts +++ b/src/report.ts @@ -79,6 +79,7 @@ export class Reporter { skippedRunCosts?: (number | null)[]; baseline?: { summary: BaselineSummary; regressed: string[]; fixed: string[] }; cached?: number; + savedUsd?: number; }, ): void { const verdicts = results.flatMap((r) => r.runs); @@ -105,7 +106,7 @@ export class Reporter { const failed = results.filter((r) => !r.ok).length; const skipped = extra?.skipped ?? 0; const skippedPart = skipped > 0 ? `, ${skipped} skipped` : ""; - const cachedPart = (extra?.cached ?? 0) > 0 ? `, ${extra?.cached} cached` : ""; + const cachedPart = (extra?.cached ?? 0) > 0 ? `, ${extra?.cached} cached${(extra?.savedUsd ?? 0) > 0 ? ` (saved $${extra?.savedUsd?.toFixed(2)})` : ""}` : ""; const costs = [...verdicts.map((v) => v.costUsd), ...(extra?.skippedRunCosts ?? [])]; const cost = costs.length === 0 && (extra?.cached ?? 0) > 0 ? "cost $0.00" : costLine(costs, extra?.estimatedUsd); this.write(`${failed} failed${skippedPart} of ${results.length + skipped + (extra?.cached ?? 0)} · runs ${costs.length} · ${cost}${cachedPart}\n`); diff --git a/src/results.ts b/src/results.ts index fd34ccb..0f30c6f 100644 --- a/src/results.ts +++ b/src/results.ts @@ -29,6 +29,7 @@ export interface SuiteReport { budgetUsd: number | null; budgetReached: boolean; cached?: number; + savedUsd?: number; }; unavailable: string[]; confusion: ConfusionPair[]; @@ -94,6 +95,8 @@ export function buildReport(input: ReportInput): SuiteReport { const verdicts = input.cases.flatMap((e, i) => input.cached?.[i] ? [] : (e.result?.runs ?? [])); const costs = [...verdicts.map((v) => v.costUsd), ...(input.skippedRunCosts ?? [])]; const known = costs.filter((c): c is number => c !== null); + const cachedCount = input.cached?.filter(Boolean).length ?? 0; + const savedUsd = input.cases.reduce((sum, e, i) => input.cached?.[i] ? sum + (e.result?.runs ?? []).reduce((n, r) => n + (r.costUsd ?? 0), 0) : sum, 0); const cases = input.cases.map((e, i) => toCaseReport(e, input.cached?.[i] ?? false)); const comparison = input.baseline ? compare(cases, input.baseline, input.baselineFile ?? input.baseline.file, input.suiteCases ?? cases) : null; return { @@ -116,7 +119,8 @@ export function buildReport(input: ReportInput): SuiteReport { diagnoses: verdicts.filter((v) => v.diagnosis).length, budgetUsd: input.budgetUsd, budgetReached: input.budgetReached, - cached: input.cached?.filter(Boolean).length ?? 0, + cached: cachedCount, + ...(cachedCount > 0 ? { savedUsd } : {}), }, unavailable: input.unavailable, confusion: input.confusion, diff --git a/test/adapter.test.ts b/test/adapter.test.ts index ac8974e..9a530c8 100644 --- a/test/adapter.test.ts +++ b/test/adapter.test.ts @@ -52,6 +52,43 @@ describe("ClaudeAdapter", () => { expect(r.durationMs).toBeGreaterThanOrEqual(0); }); + it("passes each plugin directory once to run, batch, and list", async () => { + const argsOut = path.join(tmp, "args.json"); + const dirs = [path.join(tmp, "one"), path.join(tmp, "two")]; + process.env.FAKE_ARGS_OUT = argsOut; + process.env.FAKE_FIXTURE = path.join(fixtures, "synthetic-garbage.jsonl"); + await new ClaudeAdapter().run(opts({ pluginDirs: dirs })); + let seen = JSON.parse(readFileSync(argsOut, "utf8")) as { args: string[] }; + expect(seen.args.filter((arg) => arg === "--plugin-dir")).toHaveLength(2); + expect(seen.args).toContain("--plugin-dir"); + expect(seen.args).toContain(dirs[0]); + expect(seen.args).toContain(dirs[1]); + + process.env.FAKE_FIXTURE = path.join(fixtures, "synthetic-batch.jsonl"); + await new ClaudeAdapter().runBatch({ prompt: "p", schema: BATCH_SCHEMA, timeoutMs: 30_000, pluginDirs: dirs }); + seen = JSON.parse(readFileSync(argsOut, "utf8")) as { args: string[] }; + expect(seen.args.filter((arg) => arg === "--plugin-dir")).toHaveLength(2); + + process.env.FAKE_FIXTURE = path.join(fixtures, "synthetic-init.jsonl"); + await new ClaudeAdapter().listSkills({ timeoutMs: 30_000, pluginDirs: dirs }); + seen = JSON.parse(readFileSync(argsOut, "utf8")) as { args: string[] }; + expect(seen.args.filter((arg) => arg === "--plugin-dir")).toHaveLength(2); + + process.env.FAKE_ARGS_OUT = path.join(tmp, "plain-args.json"); + await new ClaudeAdapter().run(opts()); + seen = JSON.parse(readFileSync(process.env.FAKE_ARGS_OUT, "utf8")) as { args: string[] }; + expect(seen.args).not.toContain("--plugin-dir"); + + process.env.FAKE_FIXTURE = path.join(fixtures, "synthetic-batch.jsonl"); + await new ClaudeAdapter().runBatch({ prompt: "p", schema: BATCH_SCHEMA, timeoutMs: 30_000 }); + seen = JSON.parse(readFileSync(process.env.FAKE_ARGS_OUT, "utf8")) as { args: string[] }; + expect(seen.args).not.toContain("--plugin-dir"); + process.env.FAKE_FIXTURE = path.join(fixtures, "synthetic-init.jsonl"); + await new ClaudeAdapter().listSkills({ timeoutMs: 30_000 }); + seen = JSON.parse(readFileSync(process.env.FAKE_ARGS_OUT, "utf8")) as { args: string[] }; + expect(seen.args).not.toContain("--plugin-dir"); + }); + it("stops early and kills the process group when the fake hangs", async () => { process.env.FAKE_FIXTURE = path.join(fixtures, "synthetic-two-skills.jsonl"); process.env.FAKE_HANG = "1"; diff --git a/test/cases.test.ts b/test/cases.test.ts index bd2ce09..a52e1ee 100644 --- a/test/cases.test.ts +++ b/test/cases.test.ts @@ -264,4 +264,16 @@ describe("unknownNames", () => { '#2: unknown skill "gone" in first', ]); }); + + it("checks names for source plugins but preserves the installed-plugin skip", () => { + const suite = parseSuite({ cases: [ + { query: "typo", expect: ["p:typo"] }, + { query: "known", expect: ["p:skill"] }, + { query: "installed", expect: ["other:x"] }, + ] }); + expect(unknownNames(suite, new Set(["p:skill"]), new Set(["p"]))).toEqual([ + '#1: unknown skill "p:typo" in expect', + ]); + expect(unknownNames(suite, new Set(["p:skill"]))).toEqual([]); + }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 5d89d50..41010bb 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { Writable } from "node:stream"; import * as os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { main } from "../src/cli.js"; import type { AgentAdapter, BatchOptions, BatchResult, RunOptions, RunResult, SkillList } from "../src/agents/types.js"; @@ -60,6 +61,13 @@ const SUITE = { }; describe("cli check", () => { + it("rejects a missing plugin directory as a usage error", async () => { + const out = new Sink(); + const code = await main(["check", "missing.json", "--plugin-dir", "no-such-plugin"], { stdout: out, stderr: out, cwd: tmp }); + expect(code).toBe(2); + expect(out.text).toContain("--plugin-dir is not a directory"); + }); + it("accepts a valid file without the name check", async () => { const file = writeCases("cases.json", SUITE); const out = new Sink(); @@ -95,9 +103,49 @@ describe("cli check", () => { else process.env.CLAUDE_CONFIG_DIR = prev; } }); + + it("checks source plugin names while leaving installed-plugin names unchecked", async () => { + const plugin = path.join(tmp, "plugin"); + mkdirSync(path.join(plugin, ".claude-plugin"), { recursive: true }); + mkdirSync(path.join(plugin, "skills", "skill"), { recursive: true }); + writeFileSync(path.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "p" })); + writeFileSync(path.join(plugin, "skills", "skill", "SKILL.md"), "---\ndescription: source skill\n---\nbody\n"); + const cfg = path.join(tmp, "empty-config"); + mkdirSync(cfg); + const bad = writeCases("bad-plugin.json", { cases: [{ query: "q", expect: ["p:typo"] }] }); + const good = writeCases("good-plugin.json", { cases: [ + { query: "q", expect: ["p:skill"] }, + { query: "q2", expect: ["other:x"] }, + ] }); + const badOut = new Sink(); + expect(await main(["check", bad, "--config-dir", cfg, "--plugin-dir", plugin], { stdout: badOut, stderr: badOut, cwd: tmp })).toBe(2); + expect(badOut.text).toContain('unknown skill "p:typo"'); + expect(await main(["check", good, "--config-dir", cfg, "--plugin-dir", plugin], { stdout: new Sink(), stderr: new Sink(), cwd: tmp })).toBe(0); + }); }); describe("cli run", () => { + it("passes a relative plugin directory resolved from io.cwd to claude", async () => { + const plugin = path.join(tmp, "plugin"); + mkdirSync(path.join(plugin, ".claude-plugin"), { recursive: true }); + writeFileSync(path.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "p" })); + const file = writeCases("plugin-run.json", { cases: [{ query: "why does it fail", expect: ["test-guard"] }] }); + const argsOut = path.join(tmp, "claude-args.json"); + const previous = { bin: process.env.SKILLCHECK_CLAUDE_BIN, fixture: process.env.FAKE_FIXTURE, args: process.env.FAKE_ARGS_OUT }; + process.env.SKILLCHECK_CLAUDE_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "bin", "fake-claude.mjs"); + process.env.FAKE_FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "synthetic-garbage.jsonl"); + process.env.FAKE_ARGS_OUT = argsOut; + try { + expect(await main(["run", file, "--plugin-dir", "plugin"], { stdout: new Sink(), stderr: new Sink(), cwd: tmp })).toBe(0); + const seen = JSON.parse(readFileSync(argsOut, "utf8")) as { args: string[] }; + expect(seen.args).toContain("--plugin-dir"); + expect(seen.args[seen.args.indexOf("--plugin-dir") + 1]).toBe(plugin); + } finally { + if (previous.bin === undefined) delete process.env.SKILLCHECK_CLAUDE_BIN; else process.env.SKILLCHECK_CLAUDE_BIN = previous.bin; + if (previous.fixture === undefined) delete process.env.FAKE_FIXTURE; else process.env.FAKE_FIXTURE = previous.fixture; + if (previous.args === undefined) delete process.env.FAKE_ARGS_OUT; else process.env.FAKE_ARGS_OUT = previous.args; + } + }); it("passes all cases, prints header, lines and summary, exits 0", async () => { const file = writeCases("cases.json", SUITE); const out = new Sink(); @@ -436,6 +484,39 @@ describe("cli run", () => { expect(calls).toHaveLength(4); }); + it("invalidates a source-plugin cache entry on description changes, not body changes", async () => { + const plugin = path.join(tmp, "plugin"); + const skillFile = path.join(plugin, "skills", "skill", "SKILL.md"); + mkdirSync(path.join(plugin, ".claude-plugin"), { recursive: true }); + mkdirSync(path.dirname(skillFile), { recursive: true }); + mkdirSync(path.join(tmp, "empty-config")); + writeFileSync(path.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "p" })); + writeFileSync(skillFile, "---\ndescription: route this\n---\nbody\n"); + const file = writeCases("plugin-cache.json", { cases: [{ query: "q", expect: ["p:skill"] }] }); + const cache = path.join(tmp, "plugin-cache-file.json"); + const calls: RunOptions[] = []; + const adapter = fakeAdapter({ q: { loaded: ["p:skill"] } }, calls); + const run = () => main(["run", file, "--cache", cache, "--config-dir", "empty-config", "--plugin-dir", "plugin"], { stdout: new Sink(), stderr: new Sink(), cwd: tmp }, { adapter }); + expect(await run()).toBe(0); + writeFileSync(skillFile, "---\ndescription: route this\n---\nchanged body\n"); + expect(await run()).toBe(0); + writeFileSync(skillFile, "---\ndescription: route differently\n---\nchanged body\n"); + expect(await run()).toBe(0); + expect(calls).toHaveLength(2); + }); + + it("reports the cost saved by cached cases", async () => { + const file = writeCases("saved.json", { cases: [{ query: "q", expect: ["find-bug"] }] }); + const cache = path.join(tmp, "saved-cache.json"); + const adapter = fakeAdapter({ q: { loaded: ["find-bug"], costUsd: 1.8 } }); + expect(await main(["run", file, "--cache", cache], { stdout: new Sink(), stderr: new Sink(), cwd: tmp }, { adapter })).toBe(0); + const json = path.join(tmp, "saved-report.json"); + const out = new Sink(); + expect(await main(["run", file, "--cache", cache, "--json", json], { stdout: out, stderr: out, cwd: tmp }, { adapter })).toBe(0); + expect(JSON.parse(readFileSync(json, "utf8")).summary.savedUsd).toBe(1.8); + expect(out.text).toContain("cached (saved $1.80)"); + }); + it("counts only live cases in the header and gives cached JUnit cases no time", async () => { const file = writeCases("cases.json", { cases: [{ query: "q1", expect: ["find-bug"] }, { query: "q2", expect: ["find-bug"] }] }); const cache = path.join(tmp, "cache.json"); @@ -1097,7 +1178,7 @@ describe("cli list", () => { { adapter: listAdapter({ skills: ["b", "a"], slashCommands: ["x"], error: null }, seen) }); expect(code).toBe(0); expect(out.text).toBe("skills (2):\n b\n a\nother slash commands (1):\n x\n"); - expect(seen).toEqual([{ configDir: tmp, timeoutMs: 180_000 }]); + expect(seen).toEqual([{ configDir: tmp, pluginDirs: [], timeoutMs: 180_000 }]); }); it("exits 2 when the agent reports an error", async () => { diff --git a/test/describe.test.ts b/test/describe.test.ts index 9b68e1f..565c3f2 100644 --- a/test/describe.test.ts +++ b/test/describe.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { loadSkillDocs, pluginInstallPaths, type SkillDoc } from "../src/describe.js"; +import { loadSkillDocs, pluginInstallPaths, sourcePlugins, type SkillDoc } from "../src/describe.js"; let tmp: string; @@ -170,6 +170,35 @@ describe("loadSkillDocs plugins", () => { expect(byName(docs, "demo:hello")?.file).toBe(path.join(demo, "custom", "renamed", "SKILL.md")); }); + it("scans manifest skill roots next to skills/ and reads a command map", () => { + base = fs.mkdtempSync(path.join(tmp, "plug-roots-")); + const cfg = path.join(base, "cfg"); + const roots = plugin("roots", { + ".claude-plugin/plugin.json": JSON.stringify({ + name: "roots", + skills: "./custom", + commands: { about: { content: "About.", description: "Explain what this plugin does" }, run: { source: "./run.md" } }, + }), + "skills/base/SKILL.md": "---\ndescription: Default folder skill still loads\n---\n", + "custom/foo/SKILL.md": "---\ndescription: Skill found under a custom root\n---\n", + "commands/ignored.md": "---\ndescription: Replaced by the manifest map\n---\n", + "run.md": "---\ndescription: Run the thing from a source file\n---\n", + }); + registry(cfg, { "roots@mp": [{ scope: "user", installPath: roots }] }); + const docs = load(cfg, base); + expect(names(docs)).toEqual(["roots:about", "roots:base", "roots:foo", "roots:run"]); + expect(byName(docs, "roots:about")?.description).toBe("Explain what this plugin does"); + expect(byName(docs, "roots:run")?.description).toBe("Run the thing from a source file"); + }); + + it("loads a plugin with only a root SKILL.md as one skill", () => { + base = fs.mkdtempSync(path.join(tmp, "plug-single-")); + const cfg = path.join(base, "cfg"); + const single = plugin("single", { "SKILL.md": "---\nname: solo\ndescription: The only skill of this plugin\n---\n" }); + registry(cfg, { "single@mp": [{ scope: "user", installPath: single }] }); + expect(names(load(cfg, base))).toEqual(["single:solo"]); + }); + it("falls back to the default skills/ and commands/ layout without a manifest", () => { base = fs.mkdtempSync(path.join(tmp, "plug-default-")); const cfg = path.join(base, "cfg"); @@ -191,6 +220,49 @@ describe("loadSkillDocs plugins", () => { expect(pluginInstallPaths({ cwd: base, configDir: cfg })).toEqual([{ plugin: "on", installPath: on }]); }); + it("discovers source plugins by manifest name, basename fallback, collections, and symlink", () => { + base = fs.mkdtempSync(path.join(tmp, "plug-source-")); + const direct = plugin("direct", { ".claude-plugin/plugin.json": JSON.stringify({ name: "p" }) }); + const collection = path.join(base, "collection"); + const second = plugin("second", { ".claude-plugin/plugin.json": JSON.stringify({ name: "second-name" }) }); + const unnamed = plugin("unnamed", { ".claude-plugin/plugin.json": JSON.stringify({}) }); + fs.mkdirSync(collection, { recursive: true }); + fs.symlinkSync(second, path.join(collection, "linked"), "dir"); + fs.cpSync(unnamed, path.join(collection, "unnamed"), { recursive: true }); + expect(sourcePlugins([direct, collection])).toEqual([ + { plugin: "p", installPath: direct }, + { plugin: "second-name", installPath: path.join(collection, "linked") }, + { plugin: "unnamed", installPath: path.join(collection, "unnamed") }, + ]); + }); + + it("loads source plugin skills and commands with descriptions", () => { + base = fs.mkdtempSync(path.join(tmp, "plug-source-docs-")); + const source = plugin("source", { + ".claude-plugin/plugin.json": JSON.stringify({ name: "p" }), + "skills/skill/SKILL.md": "---\ndescription: skill description\n---\nbody\n", + "commands/cmd.md": "---\ndescription: command description\n---\nbody\n", + }); + const docs = loadSkillDocs({ cwd: base, configDir: path.join(base, "empty"), pluginDirs: [source] }); + expect(byName(docs, "p:skill")).toMatchObject({ plugin: "p", description: "skill description" }); + expect(byName(docs, "p:cmd")).toMatchObject({ plugin: "p", description: "command description" }); + expect(pluginInstallPaths({ cwd: base, configDir: path.join(base, "empty"), pluginDirs: [source] })).toContainEqual({ plugin: "p", installPath: source }); + }); + + it("source plugin of the same name replaces its installed docs", () => { + base = fs.mkdtempSync(path.join(tmp, "plug-source-replace-")); + const cfg = path.join(base, "cfg"); + const installed = plugin("installed", { "skills/old/SKILL.md": "---\ndescription: installed\n---\n" }); + const source = plugin("source", { + ".claude-plugin/plugin.json": JSON.stringify({ name: "p" }), + "skills/new/SKILL.md": "---\ndescription: source\n---\n", + }); + registry(cfg, { "p@market": [{ scope: "user", installPath: installed }] }); + const docs = withConfig(cfg, () => loadSkillDocs({ cwd: base, pluginDirs: [source] })); + expect(byName(docs, "p:new")?.description).toBe("source"); + expect(byName(docs, "p:old")).toBeUndefined(); + }); + it("skips disabled plugins and project/local scopes for other paths", () => { base = fs.mkdtempSync(path.join(tmp, "plug-scope-")); const cfg = path.join(base, "cfg"); diff --git a/test/markdown.test.ts b/test/markdown.test.ts index 2e3ea7b..7c952fa 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -88,6 +88,17 @@ describe("toMarkdown", () => { expect(md).toContain("### ✅ skillcheck: 1 passed, 1 cached"); }); + it("shows saved cache cost only when positive", () => { + const report = buildReport({ + file: "skillcheck.yaml", agent: "claude", model: "sonnet", startedAtMs: 0, durationMs: 0, + cases: [done(c1, [verdict(c1, { costUsd: 1.8 })])], unavailable: [], confusion: [], estimatedCostUsd: 0, + budgetUsd: null, budgetReached: false, cached: [true], + }); + expect(toMarkdown(report)).toContain("1 cached (saved $1.80)"); + const noCost = { ...report, summary: { ...report.summary, savedUsd: 0 } }; + expect(toMarkdown(noCost)).not.toContain("(saved"); + }); + it("renders baseline counts and change labels", () => { const c2 = kase({ index: 2, query: "fixed case" }); const c3 = kase({ index: 3, query: "new case" }); diff --git a/test/report.test.ts b/test/report.test.ts index 6efb517..4f36eaa 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -122,6 +122,15 @@ describe("Reporter", () => { expect(text()).toBe("\n1 failed of 2 · runs 2 · cost $0.14\n"); }); + it("shows the cost saved by cached cases only when it is positive", () => { + const { text, stream } = makeSink(); + new Reporter(stream).summary([result({ runs: [verdict({ costUsd: 0.1 })] })], { cached: 2, savedUsd: 1.8 }); + expect(text()).toContain(", 2 cached (saved $1.80)"); + const zero = makeSink(); + new Reporter(zero.stream).summary([result()], { cached: 1, savedUsd: 0 }); + expect(zero.text()).not.toContain("(saved"); + }); + it("marks unknown costs", () => { const { text, stream } = makeSink(); new Reporter(stream).summary([ diff --git a/test/results.test.ts b/test/results.test.ts new file mode 100644 index 0000000..c8ef709 --- /dev/null +++ b/test/results.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { aggregate, judge } from "../src/judge.js"; +import { buildReport, type ReportCase } from "../src/results.js"; +import type { Case } from "../src/cases.js"; +import type { RunResult } from "../src/agents/types.js"; + +const c: Case = { index: 1, query: "q", expect: ["skill"], expect_any: [], forbid: [], none: false }; + +function reportCase(costUsd: number | null): ReportCase { + const run: RunResult = { loaded: ["skill"], text: "", costUsd, availableSkills: null, error: null, stoppedEarly: false, durationMs: 1 }; + return { c, threshold: 1, result: aggregate(c, [judge(c, run)], 1) }; +} + +function input(cases: ReportCase[], cached?: boolean[]) { + return { file: "x", agent: "claude", model: null, startedAtMs: 0, durationMs: 0, cases, unavailable: [], confusion: [], estimatedCostUsd: 0, budgetUsd: null, budgetReached: false, cached }; +} + +describe("buildReport cache savings", () => { + it("sums only non-null costs from cached cases and omits savings when none are cached", () => { + const report = buildReport(input([reportCase(1.2), reportCase(null)], [true, true])); + expect(report.summary.savedUsd).toBe(1.2); + const fresh = buildReport(input([reportCase(1.2)])); + expect(fresh.summary).not.toHaveProperty("savedUsd"); + }); +});