@wix/pathgrade 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pathgrade
2
2
 
3
- **Evaluate AI coding agents with Vitest or Jest.** Write evals as normal `.eval.ts` files, run Claude Code, Codex, or Cursor in isolated sandboxes, and score the result with deterministic checks, rubric judges, and tool-usage assertions.
3
+ **Evaluate AI coding agents with Vitest or Jest.** Write evals as normal `.eval.ts` files, run Claude Code, Codex, Cursor, or trusted-internal OpenCode fixtures in isolated workspaces, and score the result with deterministic checks, rubric judges, and tool-usage assertions.
4
4
 
5
5
  ## Why Pathgrade?
6
6
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## Quick Start
15
15
 
16
- **Prerequisites**: Node.js 20.11+, Vitest 4+ or Jest 30+, and at least one configured agent runtime. Claude uses the bundled `@anthropic-ai/claude-agent-sdk` binary by default; Codex requires the `codex` CLI; Cursor requires the `cursor-agent` CLI.
16
+ **Prerequisites**: Node.js 20.11+, Vitest 4+ or Jest 30+, and at least one configured agent runtime. Claude uses the bundled `@anthropic-ai/claude-agent-sdk` binary by default; Codex requires the `codex` CLI; Cursor requires the `cursor-agent` CLI. OpenCode requires the exact v1.18.18 macOS ARM64 or Linux ARM64 executable.
17
17
 
18
18
  ```bash
19
19
  yarn add -D @wix/pathgrade
@@ -40,6 +40,9 @@ By default, Pathgrade tries to reuse the agent CLI's native auth before falling
40
40
  - forwards `CURSOR_API_KEY` when set
41
41
  - macOS: reuses `cursor-agent login` OAuth tokens from the login Keychain
42
42
  - surfaces a clear error when neither is available (run `cursor-agent login` or set `CURSOR_API_KEY`)
43
+ - **OpenCode**
44
+ - requires an explicit `ANTHROPIC_API_KEY` in `createAgent({ env })`; ambient credentials and login state are never read
45
+ - accepts an optional explicit HTTPS `ANTHROPIC_BASE_URL` ending in `/v1`
43
46
 
44
47
  If you set `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, or `CURSOR_API_BASE_URL`, set the matching API key too.
45
48
 
@@ -231,7 +234,24 @@ toolUsage('expected-workflow', [
231
234
 
232
235
  ## Conversations
233
236
 
234
- Pathgrade currently supports three agent backends: `claude`, `codex`, and `cursor`. Set the backend per test via `createAgent({ agent: 'claude' })`, or omit `agent` and use `PATHGRADE_AGENT` as the process-wide fallback.
237
+ Pathgrade supports `claude`, `codex`, `cursor`, and `opencode`. Set the backend per test via `createAgent({ agent: 'claude' })`, or omit `agent` and use `PATHGRADE_AGENT` as the process-wide fallback.
238
+
239
+ ### OpenCode trusted-internal backend
240
+
241
+ OpenCode v1 is only for fixtures, prompts, skills, and generated MCP mocks controlled by trusted Pathgrade maintainers. It is not a security sandbox: OpenCode and Bash children run with the host user's filesystem, process, and network authority and receive the provider credentials. Do not use it for external repositories, user-supplied prompts, or adversarial fixtures.
242
+
243
+ ```typescript
244
+ const agent = await createAgent({
245
+ agent: 'opencode',
246
+ opencodeExecutable: '/absolute/path/to/opencode-v1.18.18',
247
+ env: {
248
+ ANTHROPIC_API_KEY: process.env.APP_ANTHROPIC_API_KEY!,
249
+ ANTHROPIC_BASE_URL: 'https://api.example.com/v1',
250
+ },
251
+ });
252
+ ```
253
+
254
+ The backend supports only `anthropic/claude-sonnet-5`, generated stdio `mcpMock` servers, and clean hosts without system-managed OpenCode configuration. `PATHGRADE_AGENT=opencode` selects the backend but does not supply the required executable or credentials.
235
255
 
236
256
  ### `agent.prompt()` - One shot
237
257
 
@@ -292,7 +312,8 @@ const result = await agent.runConversation({
292
312
  Pathgrade exposes a few useful features that are easy to miss from the basic examples:
293
313
 
294
314
  - `createAgent({ skillDir, workspace })` stages a real skill and a fixture workspace into the sandbox, which is how Pathgrade's skill examples are evaluated.
295
- - `createAgent({ debug: true })` preserves the final workspace under `pathgrade-debug/<test-name>/`; when you use `runConversation()`, it also writes `run-snapshot.json`.
315
+ - `createAgent({ debug: true })` preserves the final workspace under the backward-compatible `pathgrade-debug/<test-name>/` path; when you use `runConversation()`, it also writes `run-snapshot.json`.
316
+ - `createAgent({ debug: { retainRuns: 5 } })` opts into managed run retention under `pathgrade-debug/runs/<run-id>/<test-name>/`. Use `pathgrade clean --debug`, `--keep=N`, and `--dry-run` to clean marked, inactive debug runs safely.
296
317
  - `evaluate.fromSnapshot(snapshotPath, scorers)` re-runs grading against a saved snapshot without re-running the agent.
297
318
  - `previewReactions(messages, reactions)` lets you inspect which scripted reactions would fire offline.
298
319
  - `conversationWindow` on agents and personas keeps long transcripts bounded with summarization instead of sending the full conversation every turn.
@@ -409,13 +430,13 @@ Notes:
409
430
 
410
431
  | Variable | Purpose |
411
432
  |----------|---------|
412
- | `ANTHROPIC_API_KEY` | Claude auth and the required key when using `ANTHROPIC_BASE_URL` |
433
+ | `ANTHROPIC_API_KEY` | Claude auth; also required explicitly in `AgentOptions.env` for OpenCode |
413
434
  | `OPENAI_API_KEY` | Codex auth and the required key when using `OPENAI_BASE_URL` |
414
435
  | `CURSOR_API_KEY` | Cursor auth and the required key when using `CURSOR_API_BASE_URL` |
415
436
  | `ANTHROPIC_BASE_URL` | Custom Anthropic-compatible endpoint |
416
437
  | `OPENAI_BASE_URL` | Custom OpenAI-compatible endpoint |
417
438
  | `CURSOR_API_BASE_URL` | Custom Cursor-compatible endpoint |
418
- | `PATHGRADE_AGENT` | Fallback agent for all tests (`claude`, `codex`, or `cursor`). `createAgent({ agent })` wins over this. |
439
+ | `PATHGRADE_AGENT` | Fallback agent for all tests (`claude`, `codex`, `cursor`, or `opencode`). `createAgent({ agent })` wins over this. |
419
440
  | `PATHGRADE_CODEX_TRANSPORT` | Fallback Codex transport (`exec` or `app-server`). `createAgent({ transport })` wins over this. |
420
441
  | `PATHGRADE_VERBOSE` | `1` enables live per-turn streaming to stderr |
421
442
  | `PATHGRADE_DIAGNOSTICS` | `1` prints full diagnostics for passing evals too |
@@ -0,0 +1,24 @@
1
+ import type { MockMcpServerDescriptor } from '../core/mcp-mock.types.js';
2
+ import type { AgentName, AgentOptions } from '../sdk/types.js';
3
+ export declare const OPENCODE_MODEL = "anthropic/claude-sonnet-5";
4
+ export declare const OPENCODE_VERSION = "1.18.18";
5
+ export interface OpenCodeRuntimeLockEntry {
6
+ version: typeof OPENCODE_VERSION;
7
+ executableSha256: string;
8
+ }
9
+ export declare const OPENCODE_RUNTIME_LOCK: {
10
+ readonly 'darwin-arm64': {
11
+ readonly version: "1.18.18";
12
+ readonly executableSha256: "4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422";
13
+ };
14
+ readonly 'linux-arm64': {
15
+ readonly version: "1.18.18";
16
+ readonly executableSha256: "a63ef0c7271383e48ffe36c156b4146087bbbc97929dec40e8be95ed4f4d76ae";
17
+ };
18
+ };
19
+ export type OpenCodePlatformKey = keyof typeof OPENCODE_RUNTIME_LOCK;
20
+ export declare function getOpenCodePlatformKey(platform: NodeJS.Platform, arch: string): OpenCodePlatformKey | undefined;
21
+ export declare function currentOpenCodePlatformKey(): OpenCodePlatformKey | undefined;
22
+ export declare function validateOpenCodeDeclaration(agent: AgentName, opts: AgentOptions): void;
23
+ export declare function sanitizeOpenCodeToolName(value: string): string;
24
+ export declare function collectOpenCodeMcpToolNames(declaration: MockMcpServerDescriptor | MockMcpServerDescriptor[] | undefined): string[];
@@ -0,0 +1,84 @@
1
+ import * as path from 'node:path';
2
+ export const OPENCODE_MODEL = 'anthropic/claude-sonnet-5';
3
+ export const OPENCODE_VERSION = '1.18.18';
4
+ export const OPENCODE_RUNTIME_LOCK = {
5
+ 'darwin-arm64': {
6
+ version: OPENCODE_VERSION,
7
+ executableSha256: '4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422',
8
+ },
9
+ 'linux-arm64': {
10
+ version: OPENCODE_VERSION,
11
+ executableSha256: 'a63ef0c7271383e48ffe36c156b4146087bbbc97929dec40e8be95ed4f4d76ae',
12
+ },
13
+ };
14
+ export function getOpenCodePlatformKey(platform, arch) {
15
+ const key = `${platform}-${arch}`;
16
+ return key in OPENCODE_RUNTIME_LOCK ? key : undefined;
17
+ }
18
+ export function currentOpenCodePlatformKey() {
19
+ return getOpenCodePlatformKey(process.platform, process.arch);
20
+ }
21
+ function hasOwn(record, key) {
22
+ return Object.prototype.hasOwnProperty.call(record, key);
23
+ }
24
+ function assertOpenCodeBaseUrl(value) {
25
+ let url;
26
+ try {
27
+ url = new URL(value);
28
+ }
29
+ catch {
30
+ throw new Error('OpenCode ANTHROPIC_BASE_URL must be an absolute HTTPS API root ending in /v1');
31
+ }
32
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !url.pathname.endsWith('/v1')) {
33
+ throw new Error('OpenCode ANTHROPIC_BASE_URL must be an absolute HTTPS API root ending in /v1');
34
+ }
35
+ }
36
+ export function validateOpenCodeDeclaration(agent, opts) {
37
+ if (agent !== 'opencode') {
38
+ if (opts.opencodeExecutable !== undefined) {
39
+ throw new Error('opencodeExecutable is valid only for agent: "opencode"');
40
+ }
41
+ return;
42
+ }
43
+ if (!currentOpenCodePlatformKey()) {
44
+ throw new Error(`OpenCode is not supported on ${process.platform}-${process.arch}`);
45
+ }
46
+ if (!opts.opencodeExecutable || !path.isAbsolute(opts.opencodeExecutable)) {
47
+ throw new Error('OpenCode requires an absolute opencodeExecutable path');
48
+ }
49
+ if (opts.model !== undefined && opts.model !== OPENCODE_MODEL) {
50
+ throw new Error(`OpenCode v1 supports only model ${OPENCODE_MODEL}`);
51
+ }
52
+ const env = opts.env ?? {};
53
+ if (!hasOwn(env, 'ANTHROPIC_API_KEY') || !env.ANTHROPIC_API_KEY?.trim()) {
54
+ throw new Error('OpenCode requires a nonempty own env.ANTHROPIC_API_KEY');
55
+ }
56
+ if (hasOwn(env, 'ANTHROPIC_BASE_URL')) {
57
+ if (!env.ANTHROPIC_BASE_URL) {
58
+ throw new Error('OpenCode ANTHROPIC_BASE_URL must be a nonempty absolute HTTPS API root ending in /v1');
59
+ }
60
+ assertOpenCodeBaseUrl(env.ANTHROPIC_BASE_URL);
61
+ }
62
+ if (opts.copyFromHome !== undefined)
63
+ throw new Error('OpenCode does not support copyFromHome');
64
+ if (opts.mcpConfigFile !== undefined)
65
+ throw new Error('OpenCode v1 supports generated mcpMock only, not mcpConfigFile');
66
+ if (opts.mcpSafety !== undefined)
67
+ throw new Error('OpenCode v1 does not support mcpSafety');
68
+ if (opts.transport !== undefined)
69
+ throw new Error('OpenCode does not support transport overrides');
70
+ for (const key of Object.keys(env)) {
71
+ if (key === 'HOME' || key.startsWith('XDG_') || key.startsWith('OPENCODE_')) {
72
+ throw new Error(`OpenCode reserves env.${key}`);
73
+ }
74
+ }
75
+ }
76
+ export function sanitizeOpenCodeToolName(value) {
77
+ return value.replace(/[^A-Za-z0-9_-]/g, '_');
78
+ }
79
+ export function collectOpenCodeMcpToolNames(declaration) {
80
+ if (!declaration)
81
+ return [];
82
+ const descriptors = Array.isArray(declaration) ? declaration : [declaration];
83
+ return descriptors.flatMap((descriptor) => descriptor.config.tools.map((tool) => `${sanitizeOpenCodeToolName(descriptor.config.name)}_${sanitizeOpenCodeToolName(tool.name)}`));
84
+ }
@@ -0,0 +1,27 @@
1
+ import { BaseAgent, type AgentCommandRunner, type AgentSession, type AgentSessionOptions, type AgentTurnResult, type EnvironmentHandle } from '../types.js';
2
+ interface SpawnResult {
3
+ stdout: string;
4
+ exitCode: number | null;
5
+ signal: NodeJS.Signals | null;
6
+ overflow: boolean;
7
+ aborted: boolean;
8
+ }
9
+ interface SpawnOpenCodeOptions {
10
+ cwd: string;
11
+ env: Record<string, string>;
12
+ stdin: string;
13
+ signal?: AbortSignal;
14
+ outputCapBytes?: number;
15
+ }
16
+ export declare function spawnOpenCode(executable: string, args: string[], options: SpawnOpenCodeOptions): Promise<SpawnResult>;
17
+ interface ParsedOpenCodeTurn {
18
+ result: AgentTurnResult;
19
+ sessionId: string;
20
+ }
21
+ export declare function parseOpenCodeOutput(stdout: string, processResult: Pick<SpawnResult, 'exitCode' | 'overflow' | 'aborted'>, mcpToolNames: ReadonlySet<string>): ParsedOpenCodeTurn;
22
+ export declare function managedOpenCodeConfigPaths(platform?: NodeJS.Platform, username?: string): string[];
23
+ export declare function assertCleanManagedOpenCodeHost(candidates?: string[]): Promise<void>;
24
+ export declare class OpenCodeAgent extends BaseAgent {
25
+ createSession(runtime: EnvironmentHandle, _runCommand: AgentCommandRunner, options?: AgentSessionOptions): Promise<AgentSession>;
26
+ }
27
+ export {};