@wix/pathgrade 1.0.8 → 1.0.10

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/bin/pathgrade.js CHANGED
@@ -1,2 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import '../dist/pathgrade.js';
2
+ import { runPathgradeCli } from '../dist/pathgrade.js';
3
+
4
+ runPathgradeCli().catch(error => {
5
+ console.error(error);
6
+ process.exit(1);
7
+ });
@@ -1,5 +1,5 @@
1
- import type { MockMcpServerDescriptor } from '../core/mcp-mock.types.js';
2
- import type { AgentName, AgentOptions } from '../sdk/types.js';
1
+ import type { MockMcpServerDescriptor } from '../../core/mcp-mock.types.js';
2
+ import type { AgentName, AgentOptions } from '../../sdk/types.js';
3
3
  export declare const OPENCODE_MODEL = "anthropic/claude-sonnet-5";
4
4
  export declare const OPENCODE_VERSION = "1.18.18";
5
5
  export interface OpenCodeRuntimeLockEntry {
@@ -0,0 +1,3 @@
1
+ export declare function killOpenCodeProcessGroup(pid: number, signal: NodeJS.Signals): void;
2
+ export declare function registerOpenCodeProcessGroup(pid: number): void;
3
+ export declare function unregisterOpenCodeProcessGroup(pid: number): void;
@@ -0,0 +1,47 @@
1
+ /** Process-wide ownership of detached OpenCode child process groups. */
2
+ const activeProcessGroups = new Set();
3
+ let processCleanupInstalled = false;
4
+ export function killOpenCodeProcessGroup(pid, signal) {
5
+ try {
6
+ process.kill(-pid, signal);
7
+ }
8
+ catch {
9
+ // The process group may already have exited.
10
+ }
11
+ }
12
+ function killAllProcessGroups() {
13
+ for (const pid of activeProcessGroups) {
14
+ killOpenCodeProcessGroup(pid, 'SIGKILL');
15
+ }
16
+ activeProcessGroups.clear();
17
+ }
18
+ function uninstallProcessCleanup() {
19
+ if (!processCleanupInstalled)
20
+ return;
21
+ processCleanupInstalled = false;
22
+ process.removeListener('exit', onProcessExit);
23
+ process.removeListener('SIGINT', onProcessSignal);
24
+ process.removeListener('SIGTERM', onProcessSignal);
25
+ }
26
+ function onProcessExit() {
27
+ killAllProcessGroups();
28
+ }
29
+ function onProcessSignal(signal) {
30
+ killAllProcessGroups();
31
+ uninstallProcessCleanup();
32
+ process.kill(process.pid, signal);
33
+ }
34
+ export function registerOpenCodeProcessGroup(pid) {
35
+ activeProcessGroups.add(pid);
36
+ if (processCleanupInstalled)
37
+ return;
38
+ processCleanupInstalled = true;
39
+ process.once('exit', onProcessExit);
40
+ process.once('SIGINT', onProcessSignal);
41
+ process.once('SIGTERM', onProcessSignal);
42
+ }
43
+ export function unregisterOpenCodeProcessGroup(pid) {
44
+ activeProcessGroups.delete(pid);
45
+ if (activeProcessGroups.size === 0)
46
+ uninstallProcessCleanup();
47
+ }
@@ -7,7 +7,8 @@ import fs from 'fs-extra';
7
7
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
8
8
  import { buildSummary, enrichSkillEvents } from '../tool-events.js';
9
9
  import { readStagedMcpServers } from '../providers/mcp-config.js';
10
- import { currentOpenCodePlatformKey, OPENCODE_MODEL, OPENCODE_RUNTIME_LOCK, } from './opencode-contract.js';
10
+ import { currentOpenCodePlatformKey, OPENCODE_MODEL, OPENCODE_RUNTIME_LOCK, } from './opencode/contract.js';
11
+ import { killOpenCodeProcessGroup, registerOpenCodeProcessGroup, unregisterOpenCodeProcessGroup, } from './opencode/process-groups.js';
11
12
  const OUTPUT_CAP_BYTES = 16 * 1024 * 1024;
12
13
  const FIXED_OPENCODE_ENV = {
13
14
  OPENCODE_CLIENT: 'pathgrade',
@@ -68,6 +69,7 @@ export function spawnOpenCode(executable, args, options) {
68
69
  let aborted = false;
69
70
  let killTimer;
70
71
  let settled = false;
72
+ let terminating = false;
71
73
  const child = spawn(executable, args, {
72
74
  cwd: options.cwd,
73
75
  env: options.env,
@@ -75,22 +77,22 @@ export function spawnOpenCode(executable, args, options) {
75
77
  detached: true,
76
78
  stdio: ['pipe', 'pipe', 'pipe'],
77
79
  });
80
+ if (child.pid)
81
+ registerOpenCodeProcessGroup(child.pid);
78
82
  const killGroup = (signal) => {
79
83
  if (!child.pid)
80
84
  return;
81
- try {
82
- process.kill(-child.pid, signal);
83
- }
84
- catch {
85
- // The process may have exited between the state check and kill.
86
- }
85
+ killOpenCodeProcessGroup(child.pid, signal);
87
86
  };
88
87
  const terminate = () => {
88
+ terminating = true;
89
89
  killGroup('SIGTERM');
90
90
  killTimer ??= setTimeout(() => killGroup('SIGKILL'), 250);
91
91
  killTimer.unref();
92
92
  };
93
93
  const onAbort = () => {
94
+ if (aborted)
95
+ return;
94
96
  aborted = true;
95
97
  terminate();
96
98
  };
@@ -98,9 +100,6 @@ export function spawnOpenCode(executable, args, options) {
98
100
  overflow = true;
99
101
  terminate();
100
102
  };
101
- options.signal?.addEventListener('abort', onAbort, { once: true });
102
- if (options.signal?.aborted)
103
- onAbort();
104
103
  child.stdout.on('data', (chunk) => {
105
104
  stdoutBytes += chunk.length;
106
105
  if (stdoutBytes <= cap)
@@ -114,16 +113,23 @@ export function spawnOpenCode(executable, args, options) {
114
113
  onOverflow();
115
114
  });
116
115
  child.stdin.on('error', () => undefined);
116
+ child.once('error', (error) => finish(undefined, undefined, error));
117
+ child.once('close', (code, signal) => finish(code, signal));
118
+ options.signal?.addEventListener('abort', onAbort, { once: true });
119
+ if (options.signal?.aborted)
120
+ onAbort();
117
121
  if (!aborted)
118
122
  child.stdin.end(options.stdin);
119
123
  else
120
124
  child.stdin.destroy();
121
- child.once('error', (error) => finish(undefined, undefined, error));
122
- child.once('close', (code, signal) => finish(code, signal));
123
125
  function finish(code, signal, error) {
124
126
  if (settled)
125
127
  return;
126
128
  settled = true;
129
+ if (child.pid && terminating)
130
+ killOpenCodeProcessGroup(child.pid, 'SIGKILL');
131
+ if (child.pid)
132
+ unregisterOpenCodeProcessGroup(child.pid);
127
133
  options.signal?.removeEventListener('abort', onAbort);
128
134
  if (killTimer)
129
135
  clearTimeout(killTimer);
@@ -377,6 +383,7 @@ class OpenCodeSession {
377
383
  disposed = false;
378
384
  sessionId;
379
385
  inFlight;
386
+ disposeController = new AbortController();
380
387
  constructor(runtime, options) {
381
388
  this.workspacePath = getWorkspacePath(runtime);
382
389
  this.runtimeEnv = getRuntimeEnv(runtime);
@@ -432,11 +439,15 @@ class OpenCodeSession {
432
439
  '--model', OPENCODE_MODEL, '--agent', 'build',
433
440
  ...(this.sessionId ? ['--session', this.sessionId] : []),
434
441
  ];
442
+ const turnSignal = this.getAbortSignal();
443
+ const signal = turnSignal
444
+ ? AbortSignal.any([turnSignal, this.disposeController.signal])
445
+ : this.disposeController.signal;
435
446
  const processResult = await spawnOpenCode(this.resolvedExecutable, args, {
436
447
  cwd: this.workspacePath,
437
448
  env,
438
449
  stdin: message,
439
- signal: this.getAbortSignal(),
450
+ signal,
440
451
  });
441
452
  const parsed = parseOpenCodeOutput(processResult.stdout, processResult, this.mcpToolNames);
442
453
  if (this.sessionId && parsed.sessionId !== this.sessionId) {
@@ -494,6 +505,7 @@ class OpenCodeSession {
494
505
  if (this.disposed)
495
506
  return;
496
507
  this.disposed = true;
508
+ this.disposeController.abort();
497
509
  await this.inFlight?.catch(() => undefined);
498
510
  await this.cleanupState();
499
511
  }
@@ -1,3 +1,5 @@
1
- export declare function runInit(dir: string, opts?: {
1
+ export interface InitOptions {
2
2
  force?: boolean;
3
- }): Promise<void>;
3
+ commandName?: string;
4
+ }
5
+ export declare function runInit(dir: string, opts?: InitOptions): Promise<void>;
@@ -22,6 +22,11 @@ async function findExistingEvalFile(dir) {
22
22
  return null;
23
23
  }
24
24
  export async function runInit(dir, opts = {}) {
25
+ const commandName = opts.commandName?.trim() || 'pathgrade';
26
+ const skills = await detectSkills(dir);
27
+ const dirName = path.basename(dir);
28
+ const evalName = skills.length === 1 ? skills[0].name : dirName;
29
+ const evalPath = resolveEvalPath(dir, evalName);
25
30
  const existing = await findExistingEvalFile(dir);
26
31
  if (existing) {
27
32
  if (opts.force) {
@@ -33,16 +38,11 @@ export async function runInit(dir, opts = {}) {
33
38
  throw new Error(`${name} already exists`);
34
39
  }
35
40
  }
36
- console.log('\npathgrade init\n');
37
- // Detect skills
38
- const skills = await detectSkills(dir);
39
- // Derive eval filename: <skill-name>.eval.ts or <dirname>.eval.ts
40
- const dirName = path.basename(dir);
41
+ console.log(`\n${commandName} init\n`);
41
42
  if (skills.length === 0) {
42
43
  console.log(' No SKILL.md found. Creating a generic template.');
43
44
  console.log(' Place a SKILL.md in this directory for better scaffolding.\n');
44
- const evalPath = path.join(dir, `${dirName}.eval.ts`);
45
- await writeTemplate(evalPath, 'my-skill', 'Describe what the agent should do with this skill.');
45
+ await writeTemplate(evalPath, 'my-skill', 'Describe what the agent should do with this skill.', commandName);
46
46
  return;
47
47
  }
48
48
  console.log(` Found ${skills.length} skill(s): ${skills.map(s => s.name).join(', ')}\n`);
@@ -61,17 +61,15 @@ export async function runInit(dir, opts = {}) {
61
61
  const openaiKey = process.env.OPENAI_API_KEY;
62
62
  const hasApiKey = !!(anthropicKey || openaiKey);
63
63
  const cliAvailable = await isClaudeCliAvailable();
64
- const evalName = skills.length === 1 ? skills[0].name : dirName;
65
- const evalPath = path.join(dir, `${evalName}.eval.ts`);
66
64
  if (hasApiKey || cliAvailable) {
67
65
  const { Spinner, fmt } = await import('../utils/cli.js');
68
66
  const label = 'generating eval with available LLM backend';
69
67
  const spinner = new Spinner('init', label);
70
68
  try {
71
69
  const config = await generateWithLLM(skills);
72
- await fs.writeFile(evalPath, config, 'utf-8');
70
+ await writeEvalFile(evalPath, config);
73
71
  spinner.stop(fmt.green(`created ${path.basename(evalPath)}`));
74
- console.log(` Review and edit the file, then run: pathgrade\n`);
72
+ console.log(` Review and edit the file, then run: ${commandName}\n`);
75
73
  return;
76
74
  }
77
75
  catch (err) {
@@ -86,9 +84,9 @@ export async function runInit(dir, opts = {}) {
86
84
  const skill = skills[0];
87
85
  const taskName = `test-${skill.name}`;
88
86
  const instruction = extractInstructionHint(skill.skillMd);
89
- await writeTemplate(evalPath, taskName, instruction);
87
+ await writeTemplate(evalPath, taskName, instruction, commandName);
90
88
  }
91
- async function writeTemplate(evalPath, taskName, instruction) {
89
+ async function writeTemplate(evalPath, taskName, instruction, commandName) {
92
90
  const templatePath = path.join(import.meta.dirname, '..', '..', 'templates', 'eval.ts.template');
93
91
  let template;
94
92
  if (await fs.pathExists(templatePath)) {
@@ -101,9 +99,38 @@ async function writeTemplate(evalPath, taskName, instruction) {
101
99
  const result = template
102
100
  .replace(/\{\{TASK_NAME\}\}/g, taskName)
103
101
  .replace(/\{\{INSTRUCTION\}\}/g, instruction);
104
- await fs.writeFile(evalPath, result, 'utf-8');
102
+ await writeEvalFile(evalPath, result);
105
103
  console.log(` Created ${path.basename(evalPath)}.`);
106
- console.log(` Edit the file to define your eval tasks, then run: pathgrade\n`);
104
+ console.log(` Edit the file to define your eval tasks, then run: ${commandName}\n`);
105
+ }
106
+ function resolveEvalPath(dir, evalName) {
107
+ const baseDir = path.resolve(dir);
108
+ if (!evalName
109
+ || evalName === '.'
110
+ || evalName === '..'
111
+ || path.basename(evalName) !== evalName
112
+ || evalName.includes('/')
113
+ || evalName.includes('\\')) {
114
+ throw new Error(`Invalid skill name for eval filename: ${JSON.stringify(evalName)}`);
115
+ }
116
+ const evalPath = path.resolve(baseDir, `${evalName}.eval.ts`);
117
+ if (path.dirname(evalPath) !== baseDir) {
118
+ throw new Error(`Eval filename escapes the target directory: ${JSON.stringify(evalName)}`);
119
+ }
120
+ return evalPath;
121
+ }
122
+ async function writeEvalFile(evalPath, content) {
123
+ try {
124
+ const stat = await fs.lstat(evalPath);
125
+ if (stat.isSymbolicLink()) {
126
+ throw new Error(`Refusing to write eval through symbolic link: ${path.basename(evalPath)}`);
127
+ }
128
+ }
129
+ catch (error) {
130
+ if (error.code !== 'ENOENT')
131
+ throw error;
132
+ }
133
+ await fs.writeFile(evalPath, content, 'utf-8');
107
134
  }
108
135
  /**
109
136
  * Extract a reasonable instruction hint from SKILL.md content.
@@ -15,6 +15,8 @@ import fs from 'fs-extra';
15
15
  import { findSkillRoot } from '../affected/anchor.js';
16
16
  import { parsePathgradeMeta } from '../affected/meta.js';
17
17
  import { discoverEvalFiles } from './affected.js';
18
+ import * as ts from 'typescript';
19
+ import { hasEvalSdkImport } from '../evals/sdk-import.js';
18
20
  /**
19
21
  * Run the validate command. Returns exit code (0 = valid, 1 = errors).
20
22
  */
@@ -40,8 +42,12 @@ export async function runValidate(filePath, opts = {}) {
40
42
  }
41
43
  }
42
44
  // Check 3: Imports
43
- if (!content.includes("from '@wix/pathgrade'") && !content.includes('from "@wix/pathgrade"')) {
44
- errors.push({ check: 'imports-pathgrade', message: "No import from '@wix/pathgrade' found." });
45
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
46
+ if (!hasEvalSdkImport(sourceFile)) {
47
+ errors.push({
48
+ check: 'imports-pathgrade',
49
+ message: 'No recognizable eval SDK import found.',
50
+ });
45
51
  }
46
52
  // Check 4: describe/it blocks
47
53
  if (!/describe\s*\(/.test(content) || !/it\s*\(/.test(content)) {
@@ -2,8 +2,8 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import picomatch from 'picomatch';
4
4
  import * as ts from 'typescript';
5
+ import { hasEvalSdkImport } from './sdk-import.js';
5
6
  const EVAL_SUFFIX = '.eval.ts';
6
- const PATHGRADE_PACKAGE = '@wix/pathgrade';
7
7
  export function discoverPathgradeEvalFiles(opts) {
8
8
  const { cwd, include, exclude } = opts;
9
9
  const root = path.resolve(cwd);
@@ -48,16 +48,8 @@ export function isPathgradeEval(absPath) {
48
48
  const source = fs.readFileSync(absPath, 'utf8');
49
49
  const sourceFile = ts.createSourceFile(absPath, source, ts.ScriptTarget.Latest,
50
50
  /* setParentNodes */ false, ts.ScriptKind.TS);
51
- for (const stmt of sourceFile.statements) {
52
- if (isPathgradeImport(stmt) || isPathgradeMetaExport(stmt))
53
- return true;
54
- }
55
- return false;
56
- }
57
- function isPathgradeImport(stmt) {
58
- if (!ts.isImportDeclaration(stmt))
59
- return false;
60
- return ts.isStringLiteral(stmt.moduleSpecifier) && stmt.moduleSpecifier.text === PATHGRADE_PACKAGE;
51
+ return hasEvalSdkImport(sourceFile)
52
+ || sourceFile.statements.some(isPathgradeMetaExport);
61
53
  }
62
54
  function isPathgradeMetaExport(stmt) {
63
55
  if (!ts.isVariableStatement(stmt))
@@ -0,0 +1,3 @@
1
+ import * as ts from 'typescript';
2
+ /** Detects the Pathgrade SDK shape without depending on a module specifier. */
3
+ export declare function hasEvalSdkImport(sourceFile: ts.SourceFile): boolean;
@@ -0,0 +1,76 @@
1
+ import * as ts from 'typescript';
2
+ const PATHGRADE_PACKAGE = '@wix/pathgrade';
3
+ const SUPPORTING_SDK_IMPORTS = new Set([
4
+ 'Agent',
5
+ 'check',
6
+ 'judge',
7
+ 'score',
8
+ 'toolUsage',
9
+ ]);
10
+ /** Detects the Pathgrade SDK shape without depending on a module specifier. */
11
+ export function hasEvalSdkImport(sourceFile) {
12
+ const sdkSignalsBySource = new Map();
13
+ const namespaceSources = new Map();
14
+ for (const stmt of sourceFile.statements) {
15
+ if (!ts.isImportDeclaration(stmt))
16
+ continue;
17
+ if (ts.isStringLiteral(stmt.moduleSpecifier)
18
+ && stmt.moduleSpecifier.text === PATHGRADE_PACKAGE)
19
+ return true;
20
+ const bindings = stmt.importClause?.namedBindings;
21
+ const importSource = ts.isStringLiteral(stmt.moduleSpecifier)
22
+ ? stmt.moduleSpecifier.text
23
+ : stmt.moduleSpecifier.getText(sourceFile);
24
+ if (bindings && ts.isNamespaceImport(bindings)) {
25
+ namespaceSources.set(bindings.name.text, importSource);
26
+ continue;
27
+ }
28
+ if (bindings && ts.isNamedImports(bindings)) {
29
+ const importedNames = sdkSignalsBySource.get(importSource) ?? new Set();
30
+ for (const element of bindings.elements) {
31
+ importedNames.add(element.propertyName?.text ?? element.name.text);
32
+ }
33
+ sdkSignalsBySource.set(importSource, importedNames);
34
+ }
35
+ }
36
+ function addNamespaceMember(namespace, member) {
37
+ const importSource = namespaceSources.get(namespace);
38
+ if (!importSource)
39
+ return;
40
+ const members = sdkSignalsBySource.get(importSource) ?? new Set();
41
+ members.add(member);
42
+ sdkSignalsBySource.set(importSource, members);
43
+ }
44
+ function visit(node) {
45
+ if (ts.isPropertyAccessExpression(node)
46
+ && ts.isIdentifier(node.expression)
47
+ && namespaceSources.has(node.expression.text)) {
48
+ addNamespaceMember(node.expression.text, node.name.text);
49
+ }
50
+ if (ts.isQualifiedName(node)
51
+ && ts.isIdentifier(node.left)
52
+ && namespaceSources.has(node.left.text)) {
53
+ addNamespaceMember(node.left.text, node.right.text);
54
+ }
55
+ if (ts.isVariableDeclaration(node)
56
+ && ts.isObjectBindingPattern(node.name)
57
+ && node.initializer
58
+ && ts.isIdentifier(node.initializer)
59
+ && namespaceSources.has(node.initializer.text)) {
60
+ for (const element of node.name.elements) {
61
+ const importedName = element.propertyName ?? element.name;
62
+ if (ts.isIdentifier(importedName)) {
63
+ addNamespaceMember(node.initializer.text, importedName.text);
64
+ }
65
+ }
66
+ }
67
+ ts.forEachChild(node, visit);
68
+ }
69
+ visit(sourceFile);
70
+ return [...sdkSignalsBySource.values()].some(matchesSdkShape);
71
+ }
72
+ function matchesSdkShape(names) {
73
+ return names.has('evaluate')
74
+ && (names.has('createAgent')
75
+ || [...SUPPORTING_SDK_IMPORTS].some(name => names.has(name)));
76
+ }
@@ -7,4 +7,8 @@
7
7
  * pathgrade init [--force] Generate eval scaffolding
8
8
  * pathgrade preview [browser] View results (CLI default, or browser)
9
9
  */
10
- export {};
10
+ export interface PathgradeCliOptions {
11
+ name?: string;
12
+ version?: string;
13
+ }
14
+ export declare function runPathgradeCli(options?: PathgradeCliOptions): Promise<void>;
package/dist/pathgrade.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
+ import { fileURLToPath } from 'node:url';
12
13
  import { runInit } from './commands/init.js';
13
14
  import { runAnalyze } from './commands/analyze.js';
14
15
  import { runValidate, runValidateAffected } from './commands/validate.js';
@@ -58,15 +59,20 @@ function validateApiKeys() {
58
59
  ` ${fmt.dim(' Claude CLI auth (keychain) and Codex exec cached login (~/.codex/auth.json) may still work if installed.')}\n`);
59
60
  }
60
61
  }
61
- async function main() {
62
+ export async function runPathgradeCli(options = {}) {
63
+ const cliName = options.name?.trim() || 'pathgrade';
62
64
  shutdown.install();
63
65
  const args = process.argv.slice(2);
64
66
  const command = args[0];
65
67
  if (command === '--help' || command === '-h') {
66
- printHelp();
68
+ printHelp(cliName);
67
69
  return;
68
70
  }
69
71
  if (command === '--version' || command === '-v') {
72
+ if (options.version) {
73
+ console.log(options.version);
74
+ return;
75
+ }
70
76
  const pkg = JSON.parse(await import('fs').then(fs => fs.promises.readFile(new URL('../package.json', import.meta.url), 'utf-8')));
71
77
  console.log(pkg.version);
72
78
  return;
@@ -89,7 +95,7 @@ async function main() {
89
95
  }
90
96
  const filePath = validateArgs[0];
91
97
  if (!filePath) {
92
- console.error('Usage: pathgrade validate <file.eval.ts> | pathgrade validate --affected');
98
+ console.error(`Usage: ${cliName} validate <file.eval.ts> | ${cliName} validate --affected`);
93
99
  process.exitCode = 1;
94
100
  return;
95
101
  }
@@ -99,13 +105,16 @@ async function main() {
99
105
  }
100
106
  if (command === 'init') {
101
107
  const hasForce = args.includes('--force');
102
- await runInit(process.cwd(), { force: hasForce });
108
+ await runInit(process.cwd(), {
109
+ force: hasForce,
110
+ commandName: cliName,
111
+ });
103
112
  return;
104
113
  }
105
114
  if (command === 'clean') {
106
115
  const result = await runClean(process.cwd(), parseCleanArgs(args.slice(1)));
107
116
  const action = result.dryRun ? 'would remove' : 'removed';
108
- console.log(`pathgrade: ${action} ${result.removed} debug run(s); ` +
117
+ console.log(`${cliName}: ${action} ${result.removed} debug run(s); ` +
109
118
  `retained ${result.retained}; active ${result.active}`);
110
119
  return;
111
120
  }
@@ -159,7 +168,7 @@ async function main() {
159
168
  validateApiKeys();
160
169
  const parsed = parsePathgradeRunArgs(command === 'run' ? args.slice(1) : args);
161
170
  for (const warning of parsed.warnings ?? []) {
162
- console.error(`pathgrade: ${warning}`);
171
+ console.error(`${cliName}: ${warning}`);
163
172
  }
164
173
  if (parsed.changed) {
165
174
  const exitCode = await runChanged({
@@ -194,15 +203,15 @@ async function main() {
194
203
  return;
195
204
  }
196
205
  console.error(`Unknown command: ${command}`);
197
- console.error('Run "pathgrade --help" for usage.');
206
+ console.error(`Run "${cliName} --help" for usage.`);
198
207
  process.exitCode = 1;
199
208
  }
200
- function printHelp() {
209
+ function printHelp(cliName) {
201
210
  console.log(`
202
- pathgrade - Evaluate AI agent skills with a runner adapter
211
+ ${cliName} - Evaluate AI agent skills with a runner adapter
203
212
 
204
213
  Usage:
205
- pathgrade run [-- runner-args] Run evals (loads .env, delegates to the selected adapter)
214
+ ${cliName} run [-- runner-args] Run evals (loads .env, delegates to the selected adapter)
206
215
  [--changed] Run only evals affected by the current PR/change-set
207
216
  [--since=<ref>] Override base ref (implies git mode)
208
217
  [--changed-files=<path>] Use an explicit newline-delimited file list
@@ -210,22 +219,22 @@ function printHelp() {
210
219
  [--diagnostics] Print full diagnostics for passing evals too
211
220
  [--quiet] Suppress the run-start summary
212
221
  [--verbose|-v] Stream live per-turn events to stderr during the run
213
- pathgrade init [--force] Generate eval scaffolding
214
- pathgrade analyze [--skill=X] Analyze skills and output JSON
215
- pathgrade validate <file> Validate an .eval.ts file
216
- pathgrade validate --affected Strict: every eval must be anchored or have valid __pathgradeMeta
217
- pathgrade clean --debug Remove completed debug runs
222
+ ${cliName} init [--force] Generate eval scaffolding
223
+ ${cliName} analyze [--skill=X] Analyze skills and output JSON
224
+ ${cliName} validate <file> Validate an .eval.ts file
225
+ ${cliName} validate --affected Strict: every eval must be anchored or have valid __pathgradeMeta
226
+ ${cliName} clean --debug Remove completed debug runs
218
227
  [--keep=N] Keep the N newest completed debug runs
219
228
  [--dry-run] Report removals without changing files
220
- pathgrade preview [browser] View results (CLI default, or browser)
229
+ ${cliName} preview [browser] View results (CLI default, or browser)
221
230
  [--last=N] Show only the N most recent reports
222
231
  [--filter=X] Filter reports by test name (substring)
223
- pathgrade preview-reactions Preview reactions against a snapshot
224
- pathgrade report Format .pathgrade/results.json as a markdown PR comment
232
+ ${cliName} preview-reactions Preview reactions against a snapshot
233
+ ${cliName} report Format .pathgrade/results.json as a markdown PR comment
225
234
  [--results-path=<path>] Override results.json location
226
235
  [--no-comment] Print markdown to stdout; do not post
227
236
  [--comment-id=<id>] Override comment marker (default: $GITHUB_WORKFLOW:$GITHUB_JOB)
228
- pathgrade affected Print eval files affected by a change-set (one per line)
237
+ ${cliName} affected Print eval files affected by a change-set (one per line)
229
238
  [--since=<ref>] Diff <ref>...HEAD (overrides git auto-detection)
230
239
  [--changed-files=<path>] Newline-delimited repo-relative file list
231
240
  [--explain] Print human-readable per-eval decision to stderr
@@ -237,16 +246,19 @@ function printHelp() {
237
246
  OPENAI_API_KEY API key for Codex and OpenAI-backed judges
238
247
 
239
248
  Examples:
240
- pathgrade run # run all *.eval.ts files
241
- pathgrade run --diagnostics # print full diagnostics for passing evals too
242
- pathgrade run --verbose # stream per-turn events live to stderr while evals run
243
- pathgrade run -- --grep superlint # filter by test name
244
- pathgrade init # scaffold eval files
245
- pathgrade preview browser # open web UI
246
- pathgrade preview-reactions --snapshot ./pathgrade-debug/run-snapshot.json --reactions ./reactions.ts
249
+ ${cliName} run # run all *.eval.ts files
250
+ ${cliName} run --diagnostics # print full diagnostics for passing evals too
251
+ ${cliName} run --verbose # stream per-turn events live to stderr while evals run
252
+ ${cliName} run -- --grep superlint # filter by test name
253
+ ${cliName} init # scaffold eval files
254
+ ${cliName} preview browser # open web UI
255
+ ${cliName} preview-reactions --snapshot ./pathgrade-debug/run-snapshot.json --reactions ./reactions.ts
247
256
  `);
248
257
  }
249
- main().catch(err => {
250
- console.error(err);
251
- process.exit(1);
252
- });
258
+ if (process.argv[1]
259
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
260
+ runPathgradeCli().catch(err => {
261
+ console.error(err);
262
+ process.exit(1);
263
+ });
264
+ }
package/dist/sdk/agent.js CHANGED
@@ -15,7 +15,7 @@ import { createVerboseEmitter } from '../reporters/verbose-emitter.js';
15
15
  import fs from 'fs-extra';
16
16
  import * as path from 'path';
17
17
  import { cleanDebugRuns, DEFAULT_DEBUG_RETAIN_RUNS, prepareManagedDebugRun, } from '../providers/debug-runs.js';
18
- import { collectOpenCodeMcpToolNames, validateOpenCodeDeclaration } from '../agents/opencode-contract.js';
18
+ import { collectOpenCodeMcpToolNames, validateOpenCodeDeclaration } from '../agents/opencode/contract.js';
19
19
  /**
20
20
  * Test-only injection point: override the sink used by the next emitter
21
21
  * built inside `createAgent`. Pass `null` to restore the default (stderr).
@@ -35,13 +35,14 @@ export async function runJudgeSession(input, options = {}) {
35
35
  { role: 'user', content: user },
36
36
  ];
37
37
  let rounds = 0;
38
+ let scoreRepairAttempted = false;
38
39
  while (rounds < maxRounds) {
39
40
  rounds++;
40
41
  let response;
41
42
  try {
42
43
  response = await llm.callWithTools(messages, {
43
44
  system,
44
- tools: toolSchemas,
45
+ tools: scoreRepairAttempted ? [] : toolSchemas,
45
46
  model: scorer.model,
46
47
  cacheControl: scorer.cacheControl,
47
48
  });
@@ -63,6 +64,22 @@ export async function runJudgeSession(input, options = {}) {
63
64
  }
64
65
  const parsed = parseFinalScore(response.text);
65
66
  if (!parsed.ok) {
67
+ if (parsed.message.startsWith('JSON parse failed:')
68
+ && !scoreRepairAttempted
69
+ && rounds < maxRounds) {
70
+ scoreRepairAttempted = true;
71
+ messages.push({ role: 'assistant', content: response.text });
72
+ messages.push({
73
+ role: 'user',
74
+ content: [
75
+ `Your final answer was not valid JSON: ${parsed.message}.`,
76
+ 'Return ONLY a valid JSON object with double-quoted keys in this exact shape:',
77
+ '{"score": <number 0..1>, "details": "<brief explanation>"}',
78
+ 'Do not call more tools or include Markdown fences.',
79
+ ].join('\n'),
80
+ });
81
+ continue;
82
+ }
66
83
  return makeOutcome(tokenUsage, toolCalls, logEntries, rounds, {
67
84
  code: 'invalid_score',
68
85
  details: parsed.message,
@@ -11,6 +11,11 @@ function resolveBaseUrl(env) {
11
11
  || process.env.APP_ANTHROPIC_BASE_URL
12
12
  || 'https://api.anthropic.com';
13
13
  }
14
+ function resolveMessagesUrl(env) {
15
+ const baseUrl = resolveBaseUrl(env).replace(/\/+$/, '');
16
+ const apiRoot = baseUrl.endsWith('/v1') ? baseUrl : `${baseUrl}/v1`;
17
+ return `${apiRoot}/messages`;
18
+ }
14
19
  function buildHeaders(apiKey, useCache) {
15
20
  const headers = {
16
21
  'Content-Type': 'application/json',
@@ -57,7 +62,7 @@ function resolveTemperature(model, config, temperature) {
57
62
  return temperature ?? 0;
58
63
  }
59
64
  async function postAnthropic(apiKey, useCache, body, env) {
60
- const response = await fetch(`${resolveBaseUrl(env)}/v1/messages`, {
65
+ const response = await fetch(resolveMessagesUrl(env), {
61
66
  method: 'POST',
62
67
  headers: buildHeaders(apiKey, useCache),
63
68
  body: JSON.stringify(body),
package/dist/utils/llm.js CHANGED
@@ -199,6 +199,7 @@ export function createAgentLLM(agentName, agentEnv) {
199
199
  const adapters = agentEnv && Object.keys(agentEnv).length > 0
200
200
  ? baseAdapters.map((a) => ({
201
201
  ...a,
202
+ isAvailable: (env) => a.isAvailable({ ...agentEnv, ...env }),
202
203
  call: (prompt, opts) => a.call(prompt, { ...opts, env: { ...agentEnv, ...opts.env } }),
203
204
  ...(a.callWithTools
204
205
  ? { callWithTools: (messages, opts) => a.callWithTools(messages, { ...opts, env: { ...agentEnv, ...opts.env } }) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/pathgrade",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "packageManager": "yarn@4.12.0",
5
5
  "description": "Evaluate whether AI agents discover and use your skills correctly",
6
6
  "exports": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/core/mcp-mock.d.ts",
45
45
  "default": "./dist/core/mcp-mock.js"
46
46
  },
47
+ "./cli": {
48
+ "types": "./dist/pathgrade.d.ts",
49
+ "default": "./dist/pathgrade.js"
50
+ },
47
51
  "./package.json": "./package.json"
48
52
  },
49
53
  "bin": "bin/pathgrade.js",
@@ -128,5 +132,5 @@
128
132
  "typescript": "^5.9.3",
129
133
  "zod": "4.3.6"
130
134
  },
131
- "falconPackageHash": "3a6990dfd4a93a86e7bd1cf9acc42873b8b0b580f5a9b0d7c9692ed5"
135
+ "falconPackageHash": "be877aa61dfb2cd1b58c716b704cffee9592428675a660382e86e358"
132
136
  }