Welcome to agent-catalog-eval, the CLI that grades your coding agents so you don't have to! Think of it as a rigorous (but fair) professor for your AI assistants. We evaluate coding-agent skills against a catalog of test cases to see if they're actually learning or just hallucinating their way through the semester.
You provide the homework (a directory of cases with a prompt.md, before/ and after/ snapshots, an eval.yaml, and a judge rubric), and we do the grading! This CLI unleashes your chosen agent (Cursor, OpenCode, or Claude Code) on every case, compares the resulting workspace against your after/ snapshot using an LLM judge, and hands out the pass/fail grades.
We extracted this runner from an internal skills repository so you can run the same harness against your own skill catalogs without the dreaded copy-paste. DRY, baby! ☂️
Ready to test some bots? Let's get this installed!
# For the commitment-phobes (one-off)
npx agoda-agent-catalog-eval --help
# For the long haul (project install)
pnpm add -D agoda-agent-catalog-evalThe published binary is agent-catalog-eval. Easy peasy! 🍋
agent-catalog-eval # Run all cases in your current directory
agent-catalog-eval tests/e2e # Run cases hiding in ./tests/e2e
agent-catalog-eval ./skills --filter ioc # Only run cases with "ioc" in the name (for when you're feeling specific)Routing eval checks which skill the agent picked for a given prompt, deterministically. Two modes:
agent-catalog-eval route ./routing-cases --upstream-mcp https://skills.example/mcpPer case, the harness:
- Spawns OpenCode in a fresh per-case workdir under
<output-dir>/<caseId>/. - Writes an
opencode.jsonthat registers a stdio MCP server pointing at the bundled proxy with--case-idbaked in. - The proxy connects to
--upstream-mcp(Streamable HTTP, SSE fallback) and forwards everytools/listandtools/callfrom OpenCode to it, while teeing each request to the per-caseobserved.jsonl(tools/listrows include each visible tool's name and description so the failure report can diff them). - After the agent exits or hits
--timeout, the scorer reads the JSONL and the diagnostic writer rendersreport.mdwith the prompt, the visible tools, the tool(s) the agent actually called, and — when the agent picked the wrong skill — a word-level diff between the expected and the called skill's description. - A cumulative
<output-dir>/summary.jsonlists every case for CI consumption.
Currently OpenCode is the only wired agent. Cursor and Claude Code adapters will land in follow-up PRs.
agent-catalog-eval route ./routing-cases ./observed.jsonlSkips the agent invocation and grades a pre-existing observation log. Useful when you've captured observations from a different harness and just want the deterministic verdict + reports.
--filter <substring>— only score cases whose ID contains the substring--dry-run— list discovered cases (with their expectation) and exit--output-dir <path>— where per-case workdirs and reports are written (default:<casesDir>/.route-eval)--timeout <seconds>— per-case agent execution timeout (e2e mode only)--worker-model <name>— model passed to OpenCode (e2e mode only)
cases-dir: directory tree ofeval.yamlfiles withmode: routing. Eacheval.yaml's parent directory is a case. The case ID is the POSIX path of that directory relative tocases-dir(no leading./, no trailing/).observed.jsonl(score-only mode, or as produced by the proxy): newline-delimited JSON with one observation per line:caseId(string, required) — must match the case ID derived from the eval.yaml pathtool(string, optional) — the invoked skill/tool namevisibleTools(optional) —string[]orArray<{ name: string, description?: string }>(the proxy writes the latter so the diagnostic report can diff descriptions)rationale(string, optional) — the agent's reasoning, surfaced in failure reportstype(string, optional) —"tools/list"or"tools/call"(proxy-emitted; ignored by the scorer, which keys offtoolandvisibleTools)
If any line fails to parse or violates the schema, the eval exits 2 with the offending line numbers — no records are silently dropped.
Exactly one of expected_skill, expected_any_of, or expected_none is required.
mode: routing
prompt: |
Refactor this controller to use constructor injection.
expected_skill: csharp-ioc-refactor
# expected_any_of: [csharp-ioc-refactor, di-cleanup]
# expected_none: true
forbidden_skills: [vite-migration]
notes: optional human note shown in the reportforbidden_skills (if present) takes precedence: a case fails if any forbidden
skill fires, even when an expected skill also fires. Failure reasons always include
the full observed list so you can fix the skill descriptions.
--filter <substring>: only score cases whose ID contains the substring--dry-run: list discovered cases (with their expectation) and exit; no observation file is read
End-to-end mode (under <output-dir>, default <casesDir>/.route-eval/):
summary.json—{ generatedAt, upstreamMcp, total, passed, failed, cases: [{ caseId, passed, reason, expected, observed, durationMs, reportPath }] }<caseId>/observed.jsonl— raw proxy log<caseId>/report.md— per-case diagnostic markdown (prompt, visible tools, called tool, description diff)<caseId>/agent.log— agent stdout / stderr / exit info<caseId>/opencode.json— the per-case OpenCode config that registered the proxy
Score-only mode (under <casesDir>/.route-eval/):
results.json—{ generatedAt, total, passed, failed, results: [{ caseId, passed, reason, observed, visibleTools, rationale, expected }] }report.md— human-readable summary table plus a Failure Diagnostics section
0— all cases passed1— at least one case failed2— usage error / unparseable cases / unparseable observation log
cases-dir is a positional argument, much like vitest path/to/tests or jest src. It defaults to your current working directory (process.cwd()). Any folder inside cases-dir that has an eval.yaml is officially a test case. (Don't worry, we automatically ignore the boring stuff like node_modules, src, dist, .git, and output).
Here's how you structure your agent's pop quiz:
my-skill-eval/
├── eval.yaml # The syllabus: skill_path, threshold, judge_rubric
├── prompt.md # The exam question: what you tell the agent
├── before/ # The blank canvas: initial workspace state
└── after/ # The answer key: ground-truth desired state
Your eval.yaml should look a little something like this:
skill_path: skills/my-skill/SKILL.md # Where the skill lives (resolved against --repo-root)
threshold: 70 # The passing grade (0–100). No participation trophies here! 🏆
judge_rubric: |
Score 100 if X. Penalize for Y.
...Because we know you love to customize:
| Option | What it does |
|---|---|
[cases-dir] |
Where the tests live. Default: cwd. |
--agent <name> |
Who's taking the test? cursor, opencode, or claude-code. Default: opencode (because CI loves it). |
--dry-run |
Just looking! List discovered cases but don't actually run anything. 👀 |
--filter <pattern> |
Substring match on test name. |
--worker-model <name> |
The brains of the operation. Default: claude-opus-4-7. |
--judge-model <name> |
The strict grader. Default: gemini-3.1-flash. |
--timeout <seconds> |
Pencils down! Hard timeout per agent. Default: 420 seconds. ⏱️ |
--collect |
Send us a postcard (POST telemetry summary) after the run. |
--metrics-url <url> |
Where to send the postcard. Default: $METRICS_URL or our built-in fallback. |
--header KEY=VALUE |
Extra headers for OpenAI calls and the metrics POST. Repeatable. BYOH (Bring Your Own Headers). |
--project <name> |
Override CI project name (we auto-detect by default). |
--repo-root <path> |
Where the repo starts (for resolving skill_path). Default: nearest .git ancestor. |
--output-dir <path> |
Where the magic (and mess) happens. Default: <cases-dir>/output. |
--base-url <url> |
OpenAI-compatible base URL. Default: $OPENAI_BASE_URL or https://api.openai.com/v1. |
--otel-endpoint <url> |
Send OpenTelemetry traces from each opencode run to this OTLP endpoint. Off when omitted. 🛰️ |
--otel-protocol <proto> |
OTLP protocol: grpc or http/protobuf. Default: grpc. |
--otel-service-name <name> |
service.name attribute on emitted spans. Default: agoda-agent-catalog-eval. |
--help, -h |
When all else fails, ask for help! 🆘 |
We default to opencode instead of cursor. Why? Because opencode is headless, OpenAI-compatible, and plays incredibly well with CI pipelines. cursor, on the other hand, needs a local install and is strictly for dev environments.
Want to switch it up? Just pass --agent cursor or --agent claude-code and you're good to go!
| Variable | What it's for |
|---|---|
OPENAI_API_KEY |
Your golden ticket to the OpenAI-compatible gateway. Required (unless you're just doing a --dry-run). 🎫 |
OPENAI_BASE_URL |
Override the default base URL. |
METRICS_URL |
Override the default telemetry URL. |
OTEL_EXPORTER_OTLP_ENDPOINT |
Standard OTEL var. Used when --otel-endpoint is not passed. |
OTEL_EXPORTER_OTLP_PROTOCOL |
Standard OTEL var. Used when --otel-protocol is not passed. |
OTEL_SERVICE_NAME |
Standard OTEL var. Used when --otel-service-name is not passed. |
We're pretty smart about figuring out where we're running. CI context (project / pipeline / commit / branch) is auto-detected from the first matching environment variable:
| Provider | Project | Pipeline | Commit | Branch |
|---|---|---|---|---|
| GitLab 🦊 | CI_PROJECT_PATH |
CI_PIPELINE_ID |
CI_COMMIT_SHA |
CI_COMMIT_BRANCH |
| GitHub Actions 🐙 | GITHUB_REPOSITORY |
GITHUB_RUN_ID |
GITHUB_SHA |
GITHUB_REF_NAME |
| TeamCity 🏙️ | TEAMCITY_BUILDCONF_NAME |
BUILD_NUMBER |
BUILD_VCS_NUMBER |
TEAMCITY_BUILD_BRANCH |
| AppVeyor ☁️ | APPVEYOR_PROJECT_SLUG |
APPVEYOR_BUILD_ID |
APPVEYOR_REPO_COMMIT |
APPVEYOR_REPO_BRANCH |
| (none) 🤷♂️ | unknown |
local |
unknown |
unknown |
Want to be the boss? Override any field with --project (more overrides coming soon!).
| Code | What it means |
|---|---|
0 |
🟢 Success! All cases passed (or you ran --dry-run, or we found absolutely nothing to do). |
1 |
🔴 Uh oh. At least one case failed, or you typed something wrong. Better luck next time! |
If you pass the --collect flag, we'll POST a lovely application/json summary to your --metrics-url.
Want to see what your tests are actually doing — both the agent runs and the judge LLM calls? Wire up an OTLP endpoint and we'll ship traces from both:
agent-catalog-eval tests/e2e \
--agent opencode \
--otel-endpoint http://localhost:4317 \
--otel-protocol grpcWhen --otel-endpoint is set, the runner emits two flavours of spans:
1. Judge (LLM) spans — emitted from this CLI
The judge call to OpenAI is auto-instrumented with @arizeai/openinference-instrumentation-openai using the OpenInference semantic conventions. That means Arize (and any other OpenInference-aware backend) renders these as proper LLM spans — input/output messages, model, token counts, cost — without any extra tagging from you.
Each test is wrapped in an eval.test parent span with these attributes, so all the LLM activity for one test is grouped together in one trace:
| Attribute | Value |
|---|---|
agoda.eval.test_name |
The test case name (e.g. csharp-ioc/refactor-manual-di) |
agoda.eval.skill_path |
Path to the SKILL.md being evaluated |
agoda.eval.threshold |
Pass/fail score threshold for the test |
agoda.eval.agent |
opencode / cursor / claude-code |
agoda.eval.worker_model |
The worker model name |
agoda.eval.judge_model |
The judge model name |
agoda.eval.category |
The eval category, when set |
agoda.eval.score |
Final score from the judge (set when the test finishes) |
agoda.eval.passed |
Whether the score met the threshold (set when the test finishes) |
2. OpenCode subprocess spans — emitted from opencode
For opencode runs, the runner also:
- Adds the
@devtheops/opencode-plugin-otelplugin to the per-testopencode.json. (You'll need it on the box where opencode runs — the plugin is loaded from npm.) - Sets
OPENCODE_ENABLE_TELEMETRY=1and theOPENCODE_OTLP_*env vars on the spawned process, plus the standardOTEL_EXPORTER_OTLP_*andOTEL_SERVICE_NAMEvars so any other OTEL-aware tool also picks them up. - Packs the same per-test attributes into
OTEL_RESOURCE_ATTRIBUTESso each opencode span knows which test, skill, agent, project, pipeline, commit, and branch it came from. - Injects the W3C
TRACEPARENTenv var so plugins that honour it can stitch their spans under the parenteval.testspan.
The judge spans are emitted regardless of
--agent. The opencode-specific bits only fire for--agent opencode—cursorandclaude-codehave their own telemetry stories.
For a throwaway local collector that just prints whatever it receives:
# otel-collector.yaml
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
batch:
exporters:
debug:
verbosity: detailed
service:
pipelines:
traces: { receivers: [otlp], processors: [batch], exporters: [debug] }
metrics: { receivers: [otlp], processors: [batch], exporters: [debug] }
logs: { receivers: [otlp], processors: [batch], exporters: [debug] }Run the collector, then:
agent-catalog-eval tests/e2e --otel-endpoint http://localhost:4317An internal skills repository is our reference consumer. Once this package hits the shelves, it'll run something like this:
npx agoda-agent-catalog-eval tests/e2e \
--agent opencode \
--collect \
--header x-custom-auth=my-tokenWhen your brilliant code gets merged to main, our changeset.yml workflow will automatically open/merge a release PR and publish it to npm with access: public and provenance enabled. Magic! ✨
Remember, in the world of AI coding agents, there are two types of people: those who test their agents, and those who trust them blindly. With agent-catalog-eval, you can trust and verify!
Happy evaluating, and may your agents always score 100! 🚀