Skip to content

Commit 912fe42

Browse files
author
Sean Roberts
committed
feat: init support
1 parent ac782da commit 912fe42

6 files changed

Lines changed: 190 additions & 43 deletions

File tree

src/adapters/registry.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export function getAdapter(adapterName: string): AgentAdapter {
2424
const factory = BUILTIN_FACTORIES[adapterName];
2525
if (!factory) {
2626
throw new Error(
27-
`Unknown adapter: "${adapterName}". Built-in: claude-sdk, claude-code, codex, gemini, gemini-acp, goose. ` +
28-
`Register custom adapters via the "adapters" config field or registerAdapter().`,
27+
`Unknown agent: "${adapterName}". Built-in: ${getBuiltinAdapterNames().join(", ")}. ` +
28+
`Register custom agents via the "adapters" config field or registerAdapter().`,
2929
);
3030
}
3131

@@ -34,6 +34,11 @@ export function getAdapter(adapterName: string): AgentAdapter {
3434
return instance;
3535
}
3636

37+
/** Names of all built-in agent adapters. */
38+
export function getBuiltinAdapterNames(): string[] {
39+
return Object.keys(BUILTIN_FACTORIES);
40+
}
41+
3742
/** Register a custom adapter by name. */
3843
export function registerAdapter(name: string, adapter: AgentAdapter): void {
3944
instanceCache.set(name, adapter);

src/cli.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import * as fs from "node:fs";
44
import * as path from "node:path";
5+
import * as readline from "node:readline";
56
import { fileURLToPath } from "node:url";
67
import { Command } from "commander";
78
import { run } from "./runner/runner.js";
@@ -11,6 +12,7 @@ import { initReport, finalizeReport } from "./reports/writer.js";
1112
import { listReports, readReport, readScenarioResults } from "./reports/reader.js";
1213
import { setBaseline, readBaseline, listBaselines, deleteBaseline, DEFAULT_BASELINE_NAME } from "./baselines/store.js";
1314
import { compareBaseline } from "./baselines/compare.js";
15+
import { getBuiltinAdapterNames } from "./adapters/registry.js";
1416
import {
1517
renderReportList,
1618
renderReportDetail,
@@ -52,6 +54,105 @@ function handleSignal(signal: NodeJS.Signals): void {
5254
process.on("SIGINT", () => handleSignal("SIGINT"));
5355
process.on("SIGTERM", () => handleSignal("SIGTERM"));
5456

57+
// --- axis init command ---
58+
59+
const BUILT_IN_AGENTS = ["claude-code", "codex", "gemini"];
60+
61+
function prompt(rl: readline.Interface, question: string, defaultValue: string): Promise<string> {
62+
return new Promise((resolve) => {
63+
rl.question(question, (answer) => {
64+
resolve(answer.trim() || defaultValue);
65+
});
66+
});
67+
}
68+
69+
program
70+
.command("init")
71+
.description("Initialize a new AXIS configuration and sample scenario")
72+
.option("-s, --scenarios <path>", "path to scenarios directory", "./scenarios")
73+
.option("-a, --agent <names>", "agent(s) to include (comma-separated, e.g. claude-code,codex)")
74+
.option("-f, --force", "overwrite existing files")
75+
.action(async (opts) => {
76+
let scenariosPath: string = opts.scenarios;
77+
let agents: string[] = opts.agent
78+
? opts.agent
79+
.split(/[\s,]+/)
80+
.filter(Boolean)
81+
.map((a: string) => a.toLowerCase())
82+
: [];
83+
84+
const hasExplicitFlags = opts.agent || opts.scenarios !== "./scenarios";
85+
const interactive = process.stdin.isTTY && !hasExplicitFlags;
86+
87+
if (interactive) {
88+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
89+
90+
scenariosPath = await prompt(rl, ` Scenarios directory (${scenariosPath}): `, scenariosPath);
91+
const agentAnswer = await prompt(
92+
rl,
93+
` Agents [${BUILT_IN_AGENTS.join(", ")}] (claude-code): `,
94+
"claude-code",
95+
);
96+
agents = agentAnswer
97+
.split(/[\s,]+/)
98+
.filter(Boolean)
99+
.map((a) => a.toLowerCase());
100+
rl.close();
101+
}
102+
103+
// Filter out unknown agents (warn the user about which were ignored)
104+
const knownAgents = new Set(getBuiltinAdapterNames());
105+
const ignored = agents.filter((a) => !knownAgents.has(a));
106+
agents = agents.filter((a) => knownAgents.has(a));
107+
108+
if (ignored.length > 0) {
109+
process.stderr.write(
110+
`\n Warning: ignoring unknown agent${ignored.length > 1 ? "s" : ""}: ${ignored.join(", ")}\n` +
111+
` Built-in agents: ${getBuiltinAdapterNames().join(", ")}\n`,
112+
);
113+
}
114+
115+
if (agents.length === 0) agents = ["claude-code"];
116+
117+
const configPath = path.resolve("axis.config.json");
118+
const scenariosDir = path.resolve(scenariosPath);
119+
const scenarioFile = path.join(scenariosDir, "hello-world.json");
120+
121+
// Check for existing files
122+
if (!opts.force) {
123+
if (fs.existsSync(configPath)) {
124+
process.stderr.write("\n axis.config.json already exists. Use --force to overwrite.\n\n");
125+
process.exit(1);
126+
}
127+
if (fs.existsSync(scenarioFile)) {
128+
process.stderr.write(`\n ${path.relative(".", scenarioFile)} already exists. Use --force to overwrite.\n\n`);
129+
process.exit(1);
130+
}
131+
}
132+
133+
const config = {
134+
scenarios: scenariosPath,
135+
agents,
136+
};
137+
138+
const scenario = {
139+
name: "Hello World",
140+
prompt: "Create a file called hello.txt with the content 'Hello, World!'",
141+
rubric: [
142+
{ check: "A file named hello.txt was created" },
143+
{ check: "The file contains the text 'Hello, World!'" },
144+
],
145+
};
146+
147+
fs.mkdirSync(scenariosDir, { recursive: true });
148+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
149+
fs.writeFileSync(scenarioFile, JSON.stringify(scenario, null, 2) + "\n");
150+
151+
process.stdout.write(`\n Created axis.config.json\n`);
152+
process.stdout.write(` Created ${path.relative(".", scenarioFile)}\n\n`);
153+
process.stdout.write(` Run \`axis run\` to execute your first scenario.\n\n`);
154+
});
155+
55156
// --- Shared run pipeline ---
56157

57158
interface RunPipelineOptions {
@@ -166,7 +267,7 @@ program
166267
const pipelineOpts: RunPipelineOptions = {
167268
configPath: opts.config,
168269
scenario: opts.scenario,
169-
agent: opts.agent,
270+
agent: opts.agent ? opts.agent.toLowerCase() : undefined,
170271
concurrency: opts.concurrency,
171272
score: opts.score,
172273
verbose: opts.verbose,
@@ -331,7 +432,7 @@ program
331432

332433
// View a specific scenario result
333434
if (scenarioKey) {
334-
const agentFilter: string[] | undefined = opts.agent;
435+
const agentFilter: string[] | undefined = opts.agent?.map((a: string) => a.toLowerCase());
335436

336437
// Read all agents, then filter if --agent was specified
337438
let results = readScenarioResults(configDir, reportId, scenarioKey);

src/config/loader.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,26 @@ export async function loadConfig(configPath?: string): Promise<{ config: AxisCon
2323
}
2424

2525
validateConfig(parsed, resolvedPath);
26+
normalizeConfigAgents(parsed);
2627

2728
return {
2829
config: parsed,
2930
configDir: path.dirname(resolvedPath),
3031
};
3132
}
3233

34+
/** Lowercase all agent names in a validated config (mutates in place). */
35+
function normalizeConfigAgents(config: AxisConfig): void {
36+
for (let i = 0; i < config.agents.length; i++) {
37+
const entry = config.agents[i];
38+
if (typeof entry === "string") {
39+
config.agents[i] = entry.toLowerCase();
40+
} else {
41+
entry.adapter = entry.adapter.toLowerCase();
42+
}
43+
}
44+
}
45+
3346
export async function discoverScenarios(
3447
configDir: string,
3548
scenariosPath: string,
@@ -97,6 +110,7 @@ async function loadScenarioFile(filePath: string, rootDir: string): Promise<Scen
97110
}
98111

99112
validateScenario(parsed, filePath);
113+
normalizeScenarioAgents(parsed);
100114

101115
// Derive key from relative path: scenarios/cms/create-post.json → "cms/create-post"
102116
const relativePath = path.relative(rootDir, filePath);
@@ -116,6 +130,18 @@ async function loadScenarioFile(filePath: string, rootDir: string): Promise<Scen
116130
return scenario.variants.map((variant) => expandVariant(scenario, variant, baseKey));
117131
}
118132

133+
/** Lowercase agent-name entries in a scenario and any variants (mutates in place). */
134+
function normalizeScenarioAgents(scenario: Scenario & { variants?: ScenarioVariant[] }): void {
135+
if (scenario.agents) {
136+
scenario.agents = scenario.agents.map((a) => a.toLowerCase());
137+
}
138+
if (scenario.variants) {
139+
for (const v of scenario.variants) {
140+
if (v.agents) v.agents = v.agents.map((a) => a.toLowerCase());
141+
}
142+
}
143+
}
144+
119145
function expandVariant(parent: Scenario, variant: ScenarioVariant, baseKey: string): Scenario {
120146
const expanded: Scenario = {
121147
key: `${baseKey}@${variant.name}`,

src/docs-site/src/pages/cli.astro

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@ import DocsLayout from "../layouts/DocsLayout.astro";
99
directly or via <code>npx @netlify/axis</code>.
1010
</p>
1111

12+
<h2><code>axis init</code></h2>
13+
<div class="cli-block">
14+
<div class="cli-command">axis init [options]</div>
15+
<div class="cli-desc">
16+
Scaffold a new <code>axis.config.json</code> and a sample scenario in <code>./scenarios</code>. When run in
17+
a TTY without flags, prompts interactively for the scenarios directory and agents.
18+
</div>
19+
<div class="cli-flags">
20+
<div class="cli-flag">
21+
<span class="cli-flag-name"><code>-s, --scenarios &lt;path&gt;</code></span>
22+
<span class="cli-flag-desc">Path to scenarios directory (default: <code>./scenarios</code>).</span>
23+
</div>
24+
<div class="cli-flag">
25+
<span class="cli-flag-name"><code>-a, --agent &lt;names&gt;</code></span>
26+
<span class="cli-flag-desc">
27+
Agent(s) to include — comma-separated (e.g. <code>claude-code,codex,gemini</code>). Default:
28+
<code>claude-code</code>.
29+
</span>
30+
</div>
31+
<div class="cli-flag">
32+
<span class="cli-flag-name"><code>-f, --force</code></span>
33+
<span class="cli-flag-desc">Overwrite existing files.</span>
34+
</div>
35+
</div>
36+
</div>
37+
1238
<h2><code>axis run</code></h2>
1339
<div class="cli-block">
1440
<div class="cli-command">axis run [options]</div>

src/docs-site/src/pages/quickstart.astro

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import DocsLayout from "../layouts/DocsLayout.astro";
55
<DocsLayout title="Quick Start" active="quickstart">
66
<h1>Quick Start</h1>
77
<p class="lead">
8-
Get AXIS running in your project. This guide walks through creating a config,
9-
writing your first scenario, running it, and understanding the results.
8+
Get AXIS running in your project. Install the CLI, initialize a config, then iterate: run,
9+
review, and baseline.
1010
</p>
1111

1212
<h2>Prerequisites</h2>
@@ -15,52 +15,41 @@ import DocsLayout from "../layouts/DocsLayout.astro";
1515
<li>An API key for at least one supported agent (for example, <code>ANTHROPIC_API_KEY</code> for Claude Code).</li>
1616
</ul>
1717

18-
<h2>1. Create a Config File</h2>
18+
<h2>1. Install the CLI</h2>
1919
<p>
20-
Add an <code>axis.config.json</code> to your project root. At minimum, specify where your
21-
scenarios live and which agents to run.
20+
Install AXIS globally so the <code>axis</code> binary is available on your <code>PATH</code>:
2221
</p>
23-
<pre><code>{`{
24-
"scenarios": "./scenarios",
25-
"agents": ["claude-code"]
26-
}`}</code></pre>
22+
<pre><code>npm install -g @netlify/axis</code></pre>
2723
<p>
28-
See <a href="/configuration">Configuration Reference</a> for the full set of options, including
29-
scoring weights, MCP servers, and custom agents.
24+
Or, to skip the install and run directly with <code>npx</code>, prefix any command with
25+
<code>npx @netlify/axis</code> (for example, <code>npx @netlify/axis init</code>).
3026
</p>
3127

32-
<h2>2. Write a Scenario</h2>
28+
<h2>2. Initialize Your Project</h2>
3329
<p>
34-
Create a <code>scenarios/</code> directory and add your first scenario as a JSON file. Each
35-
scenario needs three things: a <strong>name</strong>, a <strong>prompt</strong> (the task for
36-
the agent), and a <strong>rubric</strong> (how to judge whether it succeeded).
37-
</p>
38-
<pre><code>{`{
39-
"name": "Create a greeting file",
40-
"prompt": "Create a file called hello.txt with the content 'Hello from AXIS'.",
41-
"rubric": [
42-
{ "check": "File hello.txt exists in the workspace", "weight": 0.5 },
43-
{ "check": "File contains exactly 'Hello from AXIS'", "weight": 0.5 }
44-
]
45-
}`}</code></pre>
46-
<p>
47-
Save this as <code>scenarios/hello-world.json</code>. The filename (without <code>.json</code>)
48-
becomes the scenario key used in reports and CLI commands.
30+
From your project root, run:
4931
</p>
32+
<pre><code>axis init</code></pre>
5033
<p>
51-
A few things that make scenarios work well:
34+
In an interactive terminal, this prompts you for the scenarios directory and which agents to
35+
include (comma-separated, e.g. <code>claude-code,codex,gemini</code>). It then creates two files:
5236
</p>
5337
<ul>
54-
<li><strong>Specific prompts</strong> -tell the agent exactly what to do. Vague prompts lead to inconsistent results.</li>
55-
<li><strong>Observable rubric checks</strong> -each check should describe something a judge can verify from the transcript and workspace state.</li>
56-
<li><strong>Weighted checks</strong> -distribute weight based on importance. If the file existing matters more than its content, weight it higher.</li>
38+
<li><code>axis.config.json</code> -minimal config with your chosen scenarios path and agents.</li>
39+
<li><code>scenarios/hello-world.json</code> -a sample scenario that asks the agent to create a file with specific content.</li>
5740
</ul>
5841
<p>
59-
See <a href="/scenarios">Writing Scenarios</a> for a deeper guide on prompts, rubrics, setup/teardown, and examples.
42+
To skip the prompts, pass flags directly:
43+
</p>
44+
<pre><code>axis init --agent claude-code,codex --scenarios ./scenarios</code></pre>
45+
<p>
46+
See the <a href="/configuration">Configuration Reference</a> for additional options like scoring
47+
weights, MCP servers, and custom agents, and <a href="/scenarios">Writing Scenarios</a> for guidance
48+
on writing effective prompts and rubrics.
6049
</p>
6150

6251
<h2>3. Run It</h2>
63-
<pre><code>npx @netlify/axis run</code></pre>
52+
<pre><code>axis run</code></pre>
6453
<p>
6554
AXIS spawns the agent in an isolated workspace, captures the full interaction transcript, scores
6655
the result against your rubric, and displays a summary in your terminal.
@@ -86,13 +75,13 @@ import DocsLayout from "../layouts/DocsLayout.astro";
8675
Every run saves a report to <code>.axis/reports/</code>. You can revisit it at any time.
8776
</p>
8877
<pre><code>{`# View the latest report summary
89-
npx @netlify/axis reports latest
78+
axis reports latest
9079
9180
# Open the HTML report in your browser
92-
npx @netlify/axis reports latest --html
81+
axis reports latest --html
9382
9483
# Get JSON output for scripting
95-
npx @netlify/axis reports latest --json`}</code></pre>
84+
axis reports latest --json`}</code></pre>
9685
<p>
9786
The HTML report includes the full scoring breakdown, interaction transcript, and judge
9887
evaluations. See <a href="/reports">Reports & Baselines</a> for details on report contents
@@ -142,10 +131,10 @@ npx @netlify/axis reports latest --json`}</code></pre>
142131
it to detect regressions.
143132
</p>
144133
<pre><code>{`# Save the latest report as a baseline
145-
npx @netlify/axis baseline set
134+
axis baseline set
146135
147136
# Compare future runs automatically
148-
npx @netlify/axis run --compare-baseline`}</code></pre>
137+
axis run --compare-baseline`}</code></pre>
149138
<p>
150139
The comparison exits with code 1 if any regressions are detected, making it suitable for CI
151140
gating. See <a href="/reports">Reports & Baselines</a> for baseline workflows.

src/runner/runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export async function run(options: RunOptions = {}): Promise<RunOutput> {
203203
const missing = required.filter((key) => !jobEnv[key]);
204204
if (missing.length > 0) {
205205
throw new Error(
206-
`The "${job.agentConfig.adapter}" adapter requires environment variable${missing.length > 1 ? "s" : ""} ${missing.join(", ")} ` +
206+
`The "${job.agentConfig.adapter}" agent requires environment variable${missing.length > 1 ? "s" : ""} ${missing.join(", ")} ` +
207207
`but ${missing.length > 1 ? "they are" : "it is"} not set. ` +
208208
`Add ${missing.length > 1 ? "them" : "it"} to your shell environment or to the "env" array in axis.config.json.`,
209209
);

0 commit comments

Comments
 (0)