skilldiff 0.1.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.
@@ -0,0 +1,52 @@
1
+ // skilldiff v0.1 — behavior diff report formatting.
2
+ // Same text is used for CLI output and (later) the GitHub PR comment body.
3
+ import { summarize } from "./assertions.js";
4
+ function sideLines(side) {
5
+ const lines = [];
6
+ lines.push(`${side.label}: ${side.trace.toolCalls.length} tool calls, ${side.trace.filesChanged.length} file(s) changed, ${side.trace.commandsRun.length} command(s) run`);
7
+ if (side.trace.filesChanged.length > 0) {
8
+ lines.push(` files: ${side.trace.filesChanged.join(", ")}`);
9
+ }
10
+ if (side.trace.commandsRun.length > 0) {
11
+ lines.push(` commands: ${side.trace.commandsRun.join(" | ")}`);
12
+ }
13
+ if (side.trace.warnings.length > 0) {
14
+ lines.push(` warnings: ${side.trace.warnings.join("; ")}`);
15
+ }
16
+ return lines;
17
+ }
18
+ export function formatReport(scenarioName, sides) {
19
+ const out = [];
20
+ out.push(`skilldiff behavior report — ${scenarioName}`);
21
+ out.push("");
22
+ for (const side of sides) {
23
+ out.push(...sideLines(side));
24
+ }
25
+ // Assertion results are expected to be identical for old and new (the scenario
26
+ // describes desired behavior); the interesting signal is WHICH side fails.
27
+ out.push("");
28
+ out.push("Assertions:");
29
+ const newSide = sides[sides.length - 1];
30
+ for (const r of newSide.assertions) {
31
+ const mark = r.pass ? "✓" : "✗";
32
+ out.push(` ${mark} [${r.kind}] ${r.expected}`);
33
+ if (!r.pass) {
34
+ out.push(` actual (new): ${r.actual}`);
35
+ const oldSide = sides[0];
36
+ if (oldSide && oldSide !== newSide) {
37
+ const oldMatching = oldSide.assertions.find((o) => o.kind === r.kind && o.expected === r.expected);
38
+ if (oldMatching && !oldMatching.pass) {
39
+ out.push(` note: old skill also failed this assertion`);
40
+ }
41
+ else if (oldMatching?.pass) {
42
+ out.push(` actual (old): ${oldMatching.actual}`);
43
+ out.push(` note: this is a REGRESSION — old skill passed, new skill fails`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const { passed, failed } = summarize(newSide.assertions);
49
+ out.push("");
50
+ out.push(`Result (new skill): ${passed} passed, ${failed} failed`);
51
+ return out.join("\n");
52
+ }
@@ -0,0 +1,29 @@
1
+ import type { Scenario } from "./scenario.js";
2
+ import type { SideResult } from "./report.js";
3
+ /** A recorded trace fixture — captured once from a live run, replayed deterministically. */
4
+ export interface RecordedTrace {
5
+ toolCalls: Array<{
6
+ tool: string;
7
+ input: Record<string, unknown>;
8
+ }>;
9
+ output?: string;
10
+ filesChanged?: string[];
11
+ warnings?: string[];
12
+ }
13
+ export declare function loadRecordedTrace(path: string): Promise<RecordedTrace>;
14
+ export interface RunOptions {
15
+ /** Recorded trace for the old skill (required in recorded mode). */
16
+ oldTrace?: string;
17
+ /** Recorded trace for the new skill (optional; defaults to old-only mode). */
18
+ newTrace?: string;
19
+ /** Force live mode even if recorded traces are given. */
20
+ live?: boolean;
21
+ /** Git ref to fetch the OLD skill version from (e.g. base branch). Enables the full pipeline. */
22
+ base?: string;
23
+ }
24
+ export interface RunResult {
25
+ report: string;
26
+ passed: boolean;
27
+ sides: SideResult[];
28
+ }
29
+ export declare function runScenario(scenario: Scenario, opts: RunOptions): Promise<RunResult>;
@@ -0,0 +1,118 @@
1
+ // skilldiff v0.1 — scenario runner skeleton.
2
+ // Two modes:
3
+ // recorded: replays a captured trace JSON (deterministic, for CI without quota)
4
+ // freebuff: live run via the Freebuff/Codebuff harness (scripts/freebuff-adapter.ts)
5
+ import { readFile, cp, rm } from "node:fs/promises";
6
+ import { resolve, join } from "node:path";
7
+ import { mkdtemp } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { fetchSkillVersion, cleanupSkillVersion } from "./baseline.js";
10
+ import { buildTrace } from "./trace-utils.js";
11
+ import { evaluateAssertions } from "./assertions.js";
12
+ import { formatReport } from "./report.js";
13
+ export async function loadRecordedTrace(path) {
14
+ const raw = JSON.parse(await readFile(path, "utf8"));
15
+ if (!Array.isArray(raw.toolCalls)) {
16
+ throw new Error(`${path}: recorded trace must have a toolCalls array`);
17
+ }
18
+ return raw;
19
+ }
20
+ function runRecordedSide(label, trace, scenario, fixtureRoot) {
21
+ const runTrace = buildTrace({
22
+ toolCalls: trace.toolCalls,
23
+ output: trace.output ?? "",
24
+ fixtureRoot,
25
+ filesChanged: trace.filesChanged,
26
+ warnings: trace.warnings,
27
+ });
28
+ return { label, trace: runTrace, assertions: evaluateAssertions(runTrace, scenario.expect) };
29
+ }
30
+ async function runFreebuffSide(label, scenario, fixtureRoot, skillVersionDir) {
31
+ // Dynamic import: the SDK is only needed for live runs.
32
+ const { runFreebuffHarness } = await import("../scripts/freebuff-adapter.js");
33
+ // If a skill version dir is given, copy those files INTO the fixture so the
34
+ // agent reads the version under test from the usual .claude/skills location.
35
+ if (skillVersionDir) {
36
+ for (const skillPath of scenario.skillPaths) {
37
+ await cp(join(skillVersionDir, skillPath), join(fixtureRoot, skillPath), { force: true });
38
+ }
39
+ }
40
+ // Also pass them inline for harnesses that support direct skill injection.
41
+ const skillFiles = skillVersionDir
42
+ ? await Promise.all(scenario.skillPaths.map(async (p) => ({
43
+ name: p.split("/").slice(-2, -1)[0] ?? "skill",
44
+ path: join(skillVersionDir, p),
45
+ })))
46
+ : undefined;
47
+ const { traces, error } = await runFreebuffHarness({
48
+ cwd: fixtureRoot,
49
+ prompt: scenario.prompt,
50
+ maxTurns: scenario.maxTurns,
51
+ skillFiles,
52
+ });
53
+ const warnings = [];
54
+ if (error)
55
+ warnings.push(`harness error: ${error}`);
56
+ const output = traces
57
+ .filter((t) => t.tool === "message" || t.tool === "text")
58
+ .map((t) => String(t.input?.text ?? ""))
59
+ .join("\n");
60
+ const runTrace = buildTrace({
61
+ toolCalls: traces,
62
+ output,
63
+ fixtureRoot,
64
+ warnings,
65
+ });
66
+ return { label, trace: runTrace, assertions: evaluateAssertions(runTrace, scenario.expect) };
67
+ }
68
+ export async function runScenario(scenario, opts) {
69
+ const fixtureRoot = resolve(scenario.fixture);
70
+ if (!opts.live && opts.oldTrace) {
71
+ // Deterministic recorded mode
72
+ const old = await loadRecordedTrace(opts.oldTrace);
73
+ const sides = [runRecordedSide("old skill", old, scenario, fixtureRoot)];
74
+ if (opts.newTrace) {
75
+ const neu = await loadRecordedTrace(opts.newTrace);
76
+ sides.push(runRecordedSide("new skill", neu, scenario, fixtureRoot));
77
+ }
78
+ const report = formatReport(scenario.name, sides);
79
+ const lastAssertions = sides[sides.length - 1].assertions;
80
+ return { report, passed: lastAssertions.every((a) => a.pass), sides };
81
+ }
82
+ // Live mode — currently Freebuff only; other harnesses come online in later v0.1 cuts.
83
+ const harness = scenario.harness ?? "freebuff";
84
+ if (harness !== "freebuff") {
85
+ throw new Error(`live harness '${harness}' not wired yet — use recorded mode or harness: freebuff`);
86
+ }
87
+ if (opts.base) {
88
+ // Full pipeline: fetch old skill from base ref -> snapshot the new skill's
89
+ // current fixture files -> run old, restore, run new, diff.
90
+ const oldVersion = await fetchSkillVersion(fixtureRoot, opts.base, scenario.skillPaths);
91
+ // Snapshot current (new) skill files so we can restore after the old-skill run.
92
+ const snapshotDir = await mkdtemp(join(tmpdir(), "skilldiff-current-"));
93
+ for (const skillPath of scenario.skillPaths) {
94
+ await cp(join(fixtureRoot, skillPath), join(snapshotDir, skillPath), { force: true });
95
+ }
96
+ try {
97
+ const oldSide = await runFreebuffSide("old skill", scenario, fixtureRoot, oldVersion.dir);
98
+ // Restore new skill files before the new run.
99
+ for (const skillPath of scenario.skillPaths) {
100
+ await cp(join(snapshotDir, skillPath), join(fixtureRoot, skillPath), { force: true });
101
+ }
102
+ const newSide = await runFreebuffSide("new skill", scenario, fixtureRoot);
103
+ const report = formatReport(scenario.name, [oldSide, newSide]);
104
+ return { report, passed: newSide.assertions.every((a) => a.pass), sides: [oldSide, newSide] };
105
+ }
106
+ finally {
107
+ // Restore new skill files even on failure so the fixture isn't left with old versions.
108
+ for (const skillPath of scenario.skillPaths) {
109
+ await cp(join(snapshotDir, skillPath), join(fixtureRoot, skillPath), { force: true }).catch(() => { });
110
+ }
111
+ await cleanupSkillVersion(oldVersion);
112
+ await rm(snapshotDir, { recursive: true, force: true }).catch(() => { });
113
+ }
114
+ }
115
+ const newSide = await runFreebuffSide("new skill", scenario, fixtureRoot);
116
+ const report = formatReport(scenario.name, [newSide]);
117
+ return { report, passed: newSide.assertions.every((a) => a.pass), sides: [newSide] };
118
+ }
@@ -0,0 +1,36 @@
1
+ /** The 5 assertion kinds, per design-decisions.md. */
2
+ export type AssertionKind = "files_changed" | "commands_run" | "tool_calls" | "must_not" | "output_contains";
3
+ export interface Expectations {
4
+ /** Paths that must have been changed/created relative to fixture root. Partial match. */
5
+ files_changed?: string[];
6
+ /** Commands that must have been run. Partial match against full command strings. */
7
+ commands_run?: string[];
8
+ /** Tool names that must have been invoked. Partial match against normalized tool names. */
9
+ tool_calls?: string[];
10
+ /** Things that must NOT have happened. Same shape as the positive assertions. */
11
+ must_not?: {
12
+ files_changed?: string[];
13
+ commands_run?: string[];
14
+ tool_calls?: string[];
15
+ };
16
+ /** Substring(s) that must appear in the agent's final text output. */
17
+ output_contains?: string[];
18
+ }
19
+ export interface Scenario {
20
+ name: string;
21
+ /** Skill paths (repo-relative) whose behavior is under test. */
22
+ skillPaths: string[];
23
+ /** Path to the fixture repo (absolute or relative to the scenario file). */
24
+ fixture: string;
25
+ /** Prompt given to the agent. */
26
+ prompt: string;
27
+ /** Harness to use: freebuff | cursor-agent | codex | claude-code. Default: auto-detect. */
28
+ harness?: string;
29
+ /** Per-scenario turn cap. */
30
+ maxTurns?: number;
31
+ expect: Expectations;
32
+ }
33
+ /** Parse and validate a scenario YAML string. */
34
+ export declare function parseScenarioYaml(yamlText: string, sourceName?: string): Scenario;
35
+ /** Load a scenario from a YAML file path. */
36
+ export declare function loadScenario(path: string): Promise<Scenario>;
@@ -0,0 +1,71 @@
1
+ // skilldiff v0.1 — scenario types + YAML loader.
2
+ // Scenario YAML describes: which skill files, which fixture repo, the prompt to run,
3
+ // and the 5 assertion kinds (files_changed, commands_run, tool_calls, must_not, output_contains).
4
+ import { parse } from "yaml";
5
+ import { readFile } from "node:fs/promises";
6
+ function expectStringArray(value, field, scenarioName) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
10
+ throw new Error(`${scenarioName}: ${field} must be a list of strings`);
11
+ }
12
+ return value;
13
+ }
14
+ /** Parse and validate a scenario YAML string. */
15
+ export function parseScenarioYaml(yamlText, sourceName = "(inline)") {
16
+ let raw;
17
+ try {
18
+ raw = parse(yamlText);
19
+ }
20
+ catch (err) {
21
+ throw new Error(`${sourceName}: invalid YAML — ${err.message}`);
22
+ }
23
+ if (!raw || typeof raw !== "object") {
24
+ throw new Error(`${sourceName}: scenario must be a YAML mapping`);
25
+ }
26
+ const name = typeof raw.name === "string" ? raw.name : "";
27
+ if (!name)
28
+ throw new Error(`${sourceName}: missing required field 'name'`);
29
+ const prompt = typeof raw.prompt === "string" ? raw.prompt : "";
30
+ if (!prompt)
31
+ throw new Error(`${sourceName}: missing required field 'prompt'`);
32
+ const fixture = typeof raw.fixture === "string" ? raw.fixture : "";
33
+ if (!fixture)
34
+ throw new Error(`${sourceName}: missing required field 'fixture'`);
35
+ const skillPaths = expectStringArray(raw.skillPaths, "skillPaths", name) ?? [];
36
+ if (skillPaths.length === 0) {
37
+ throw new Error(`${sourceName}: 'skillPaths' must list at least one skill file under test`);
38
+ }
39
+ const harness = typeof raw.harness === "string" ? raw.harness : undefined;
40
+ if (harness && !["freebuff", "cursor-agent", "codex", "claude-code"].includes(harness)) {
41
+ throw new Error(`${sourceName}: unknown harness '${harness}' (expected freebuff | cursor-agent | codex | claude-code)`);
42
+ }
43
+ const maxTurns = typeof raw.maxTurns === "number" && Number.isInteger(raw.maxTurns) && raw.maxTurns > 0
44
+ ? raw.maxTurns
45
+ : undefined;
46
+ const expectRaw = (raw.expect ?? {});
47
+ if (typeof raw.expect !== "object" || raw.expect === null) {
48
+ throw new Error(`${sourceName}: 'expect' must be a mapping`);
49
+ }
50
+ const mustNotRaw = (expectRaw.must_not ?? {});
51
+ if (typeof expectRaw.must_not !== "undefined" && (typeof expectRaw.must_not !== "object" || expectRaw.must_not === null)) {
52
+ throw new Error(`${name}: expect.must_not must be a mapping`);
53
+ }
54
+ const expect = {
55
+ files_changed: expectStringArray(expectRaw.files_changed, "expect.files_changed", name),
56
+ commands_run: expectStringArray(expectRaw.commands_run, "expect.commands_run", name),
57
+ tool_calls: expectStringArray(expectRaw.tool_calls, "expect.tool_calls", name),
58
+ must_not: {
59
+ files_changed: expectStringArray(mustNotRaw.files_changed, "expect.must_not.files_changed", name),
60
+ commands_run: expectStringArray(mustNotRaw.commands_run, "expect.must_not.commands_run", name),
61
+ tool_calls: expectStringArray(mustNotRaw.tool_calls, "expect.must_not.tool_calls", name),
62
+ },
63
+ output_contains: expectStringArray(expectRaw.output_contains, "expect.output_contains", name),
64
+ };
65
+ return { name, skillPaths, fixture, prompt, harness, maxTurns, expect };
66
+ }
67
+ /** Load a scenario from a YAML file path. */
68
+ export async function loadScenario(path) {
69
+ const text = await readFile(path, "utf8");
70
+ return parseScenarioYaml(text, path);
71
+ }
@@ -0,0 +1,27 @@
1
+ import { type RunTrace } from "./assertions.js";
2
+ interface ToolUse {
3
+ tool: string;
4
+ input: Record<string, unknown>;
5
+ }
6
+ /**
7
+ * Derive commandsRun from bash-like tool calls.
8
+ * Handles both `command: "..."` (string) and `command: ["..."]` (argv array) shapes.
9
+ */
10
+ export declare function extractCommands(toolCalls: ToolUse[]): string[];
11
+ /**
12
+ * Diff the fixture working tree against the harness's starting point using git.
13
+ * Falls back to an empty list if the fixture is not a git repo.
14
+ */
15
+ export declare function detectFilesChanged(fixtureRoot: string): string[];
16
+ /**
17
+ * Build a complete RunTrace from raw tool calls + final output.
18
+ * Optionally takes filesChanged if the caller tracked it differently (e.g. pre/post snapshot).
19
+ */
20
+ export declare function buildTrace(opts: {
21
+ toolCalls: ToolUse[];
22
+ output: string;
23
+ fixtureRoot?: string;
24
+ filesChanged?: string[];
25
+ warnings?: string[];
26
+ }): RunTrace;
27
+ export {};
@@ -0,0 +1,54 @@
1
+ // skilldiff v0.1 — helpers to build a RunTrace from harness output.
2
+ import { execFileSync } from "node:child_process";
3
+ import { normalizeToolName } from "./assertions.js";
4
+ /**
5
+ * Derive commandsRun from bash-like tool calls.
6
+ * Handles both `command: "..."` (string) and `command: ["..."]` (argv array) shapes.
7
+ */
8
+ export function extractCommands(toolCalls) {
9
+ const commands = [];
10
+ for (const tc of toolCalls) {
11
+ if (normalizeToolName(tc.tool) !== "bash")
12
+ continue;
13
+ const cmd = tc.input.command ?? tc.input.cmd ?? tc.input.script;
14
+ if (typeof cmd === "string") {
15
+ commands.push(cmd);
16
+ }
17
+ else if (Array.isArray(cmd)) {
18
+ commands.push(cmd.join(" "));
19
+ }
20
+ }
21
+ return commands;
22
+ }
23
+ /**
24
+ * Diff the fixture working tree against the harness's starting point using git.
25
+ * Falls back to an empty list if the fixture is not a git repo.
26
+ */
27
+ export function detectFilesChanged(fixtureRoot) {
28
+ try {
29
+ const out = execFileSync("git", ["status", "--porcelain"], { cwd: fixtureRoot, encoding: "utf8" });
30
+ return out
31
+ .split("\n")
32
+ .map((line) => line.trim())
33
+ .filter((line) => line.length > 0)
34
+ .map((line) => line.replace(/^\S+\s+/, "")) // strip status code, keep path
35
+ .map((line) => line.replace(/^"(.*)"$/, "$1")); // unquote paths with spaces
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ /**
42
+ * Build a complete RunTrace from raw tool calls + final output.
43
+ * Optionally takes filesChanged if the caller tracked it differently (e.g. pre/post snapshot).
44
+ */
45
+ export function buildTrace(opts) {
46
+ const filesChanged = opts.filesChanged ?? (opts.fixtureRoot ? detectFilesChanged(opts.fixtureRoot) : []);
47
+ return {
48
+ toolCalls: opts.toolCalls,
49
+ filesChanged,
50
+ commandsRun: extractCommands(opts.toolCalls),
51
+ output: opts.output,
52
+ warnings: opts.warnings ?? [],
53
+ };
54
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "skilldiff",
3
+ "version": "0.1.0",
4
+ "description": "Behavioral regression testing for agent skills. Runs your skill in a real agent harness and diffs the behavior on every PR.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/scs0209/skilldiff.git"
10
+ },
11
+ "homepage": "https://github.com/scs0209/skilldiff#readme",
12
+ "bugs": "https://github.com/scs0209/skilldiff/issues",
13
+ "keywords": [
14
+ "agent",
15
+ "skills",
16
+ "testing",
17
+ "regression",
18
+ "claude",
19
+ "cursor",
20
+ "codex",
21
+ "codebuff",
22
+ "ci"
23
+ ],
24
+ "files": [
25
+ "dist/",
26
+ "scripts/",
27
+ "README.md",
28
+ "LICENSE",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "bin": {
32
+ "skilldiff": "./dist/src/cli.js"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "prepublishOnly": "npm run build && npm test",
37
+ "spike": "tsx scripts/spike.ts",
38
+ "spike:recorded": "tsx scripts/recorded-spike.ts",
39
+ "spike:freebuff": "tsx scripts/freebuff-spike.ts",
40
+ "test": "vitest run"
41
+ },
42
+ "dependencies": {
43
+ "@anthropic-ai/claude-agent-sdk": "^0.1.0",
44
+ "@codebuff/sdk": "^0.10.7",
45
+ "yaml": "^2.9.1"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.0.0",
49
+ "tsx": "^4.19.0",
50
+ "typescript": "^5.6.0",
51
+ "vitest": "^3.0.0"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ }
56
+ }
@@ -0,0 +1,77 @@
1
+ // Harness CLI adapters — normalize each harness's JSON event stream into tool traces.
2
+ // Shared between the live spike (scripts/spike.ts) and the recorded spike (scripts/recorded-spike.ts).
3
+
4
+ export interface ToolUse {
5
+ tool: string;
6
+ input: Record<string, unknown>;
7
+ }
8
+
9
+ export interface HarnessAdapter {
10
+ name: string;
11
+ cmd: string;
12
+ baseArgs: string[]; // headless/print flags, prompt appended last
13
+ extractToolUses: (line: unknown) => ToolUse[];
14
+ }
15
+
16
+ // ---- Claude Code: stream-json assistant events -------------------------
17
+ export const claudeAdapter: HarnessAdapter = {
18
+ name: "claude-code",
19
+ cmd: "claude",
20
+ baseArgs: ["-p", "--output-format", "stream-json", "--verbose", "--max-turns", "5"],
21
+ extractToolUses: (line) => {
22
+ const msg = line as { type?: string; message?: { content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }> } };
23
+ if (msg.type !== "assistant" || !msg.message?.content) return [];
24
+ return msg.message.content
25
+ .filter((b) => b.type === "tool_use" && b.name)
26
+ .map((b) => ({ tool: b.name!, input: b.input ?? {} }));
27
+ },
28
+ };
29
+
30
+ // ---- Cursor agent: stream-json events ----------------------------------
31
+ export const cursorAdapter: HarnessAdapter = {
32
+ name: "cursor-agent",
33
+ cmd: "cursor-agent",
34
+ baseArgs: ["-p", "--output-format", "stream-json", "--force"],
35
+ extractToolUses: (line) => {
36
+ const msg = line as { type?: string; tool_call?: { name?: string; args?: Record<string, unknown> }; result?: { tool_calls?: Array<{ type?: string; name?: string; input?: Record<string, unknown>; arguments?: unknown }> } };
37
+ if (msg.type === "tool_call" && msg.tool_call?.name) {
38
+ return [{ tool: msg.tool_call.name, input: msg.tool_call.args ?? {} }];
39
+ }
40
+ if (msg.type === "result" && msg.result?.tool_calls) {
41
+ return msg.result.tool_calls
42
+ .filter((tc) => tc.name)
43
+ .map((tc) => ({ tool: tc.name!, input: tc.input ?? {} }));
44
+ }
45
+ return [];
46
+ },
47
+ };
48
+
49
+ // ---- Codex: exec --json headless events --------------------------------
50
+ // Handles both codex exec --json output (item.started/item.completed)
51
+ // and rollout transcript shape (response_item.function_call with exec_command).
52
+ export const codexAdapter: HarnessAdapter = {
53
+ name: "codex",
54
+ cmd: "codex",
55
+ baseArgs: ["exec", "--json", "--skip-git-repo-check", "--full-auto"],
56
+ extractToolUses: (line) => {
57
+ const msg = line as { type?: string; item?: { type?: string; name?: string; command?: string[] } };
58
+ if ((msg.type === "item.started" || msg.type === "item.completed") && msg.item?.type === "command_execution") {
59
+ return [{ tool: "Bash", input: { command: (msg.item.command ?? []).join(" ") } }];
60
+ }
61
+ const item = line as { type?: string; payload?: { type?: string; name?: string; arguments?: string } };
62
+ if (item.type === "response_item" && item.payload?.type === "function_call" && item.payload.name) {
63
+ let args: Record<string, unknown> = {};
64
+ try {
65
+ args = JSON.parse(item.payload.arguments ?? "{}") as Record<string, unknown>;
66
+ } catch {
67
+ // keep empty args if arguments is not valid JSON
68
+ }
69
+ const name = item.payload.name === "exec_command" ? "Bash" : item.payload.name;
70
+ return [{ tool: name, input: args }];
71
+ }
72
+ return [];
73
+ },
74
+ };
75
+
76
+ // Prefer cursor/codex; claude CLI often runs on a console account with no credits.
77
+ export const ADAPTERS = [cursorAdapter, codexAdapter, claudeAdapter];
@@ -0,0 +1,109 @@
1
+ // Freebuff/Codebuff adapter — runs skills headless via @codebuff/sdk using the
2
+ // Freebuff desktop auth token (they share the same codebuff.com account system).
3
+ //
4
+ // Token resolution order:
5
+ // 1. CODEBUFF_API_KEY env var
6
+ // 2. ~/.config/freebuff-desktop/state.json -> authSessions["https://www.codebuff.com"].token
7
+
8
+ import { CodebuffClient } from "@codebuff/sdk";
9
+ import { readFile } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ export interface FreebuffToolUse {
14
+ tool: string;
15
+ input: Record<string, unknown>;
16
+ }
17
+
18
+ export async function resolveFreebuffToken(explicit?: string): Promise<string | null> {
19
+ if (process.env.CODEBUFF_API_KEY) return process.env.CODEBUFF_API_KEY;
20
+ if (explicit) return explicit;
21
+ try {
22
+ const statePath = join(homedir(), ".config", "freebuff-desktop", "state.json");
23
+ const state = JSON.parse(await readFile(statePath, "utf8"));
24
+ const token = state?.authSessions?.["https://www.codebuff.com"]?.token;
25
+ return typeof token === "string" && token.length > 0 ? token : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export async function runFreebuffHarness(opts: {
32
+ cwd: string;
33
+ prompt: string;
34
+ maxTurns?: number;
35
+ token?: string;
36
+ /** Skill versions to inject into the prompt (old or new skill under test). */
37
+ skillFiles?: Array<{ name: string; path: string }>;
38
+ }): Promise<{ traces: FreebuffToolUse[]; error?: string }> {
39
+ const token = await resolveFreebuffToken(opts.token);
40
+ if (!token) {
41
+ return { traces: [], error: "No Codebuff/Freebuff token. Login to Freebuff desktop or set CODEBUFF_API_KEY." };
42
+ }
43
+
44
+ const client = new CodebuffClient({ apiKey: token, cwd: opts.cwd });
45
+ const traces: FreebuffToolUse[] = [];
46
+ let error: string | undefined;
47
+
48
+ // Skill injection: inline the skill file contents into the prompt so the agent
49
+ // is tested ON this skill version (old vs new), not on whatever is in .claude/.
50
+ let prompt = opts.prompt;
51
+ if (opts.skillFiles && opts.skillFiles.length > 0) {
52
+ const skillsBlock = await Promise.all(
53
+ opts.skillFiles.map(async (sf) => {
54
+ const content = await readFile(sf.path, "utf8").catch(() => `<<unreadable: ${sf.path}>>`);
55
+ return `<skill name="${sf.name}">\n${content}\n</skill>`;
56
+ }),
57
+ );
58
+ prompt = `${prompt}\n\nYou have these skills loaded. Follow the matching skill's instructions exactly:\n\n${skillsBlock.join("\n\n")}`;
59
+ }
60
+
61
+ const runState = await client.run({
62
+ agent: "codebuff/base@latest",
63
+ prompt,
64
+ handleEvent: (event: { type: string; [k: string]: unknown }) => {
65
+ // Tool call events carry the observable behavior we assert on.
66
+ if (event.type === "tool_call" || event.type === "toolCall") {
67
+ const e = event as { type: string; toolName?: string; tool?: string; input?: Record<string, unknown>; args?: Record<string, unknown> };
68
+ traces.push({ tool: e.toolName ?? e.tool ?? "unknown", input: e.input ?? e.args ?? {} });
69
+ } else if (event.type === "error") {
70
+ const e = event as { message?: string };
71
+ error = e.message ?? "unknown error";
72
+ }
73
+ },
74
+ });
75
+
76
+ // Fall back to harvesting tool calls from the message history if events were sparse
77
+ if (traces.length === 0 && runState) {
78
+ try {
79
+ const steps = (runState as unknown as { steps?: Array<{ toolCall?: { toolName?: string; input?: Record<string, unknown> } }> }).steps ?? [];
80
+ for (const step of steps) {
81
+ if (step.toolCall?.toolName) {
82
+ traces.push({ tool: step.toolCall.toolName, input: step.toolCall.input ?? {} });
83
+ }
84
+ }
85
+ } catch {
86
+ // best-effort fallback only
87
+ }
88
+ }
89
+
90
+ void opts.maxTurns;
91
+ return { traces, error };
92
+ }
93
+
94
+ // convenience used by the spike to verify the token works at all
95
+ export async function checkFreebuffAuth(token?: string): Promise<{ ok: boolean; user?: string; error?: string }> {
96
+ const t = await resolveFreebuffToken(token);
97
+ if (!t) return { ok: false, error: "no token" };
98
+ try {
99
+ const res = await fetch("https://www.codebuff.com/api/v1/me?fields=id,email", {
100
+ headers: { Authorization: `Bearer ${t}` },
101
+ signal: AbortSignal.timeout(10000),
102
+ });
103
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
104
+ const body = (await res.json()) as { id?: string; email?: string };
105
+ return { ok: true, user: body.email ?? body.id };
106
+ } catch (err) {
107
+ return { ok: false, error: (err as Error).message };
108
+ }
109
+ }