@vexdo/cli 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.
Files changed (51) hide show
  1. package/.eslintrc.json +23 -0
  2. package/.github/workflows/ci.yml +84 -0
  3. package/.idea/copilot.data.migration.ask2agent.xml +6 -0
  4. package/.idea/go.imports.xml +11 -0
  5. package/.idea/misc.xml +6 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/vcs.xml +7 -0
  8. package/.idea/vexdo-cli.iml +9 -0
  9. package/.prettierrc +5 -0
  10. package/CLAUDE.md +93 -0
  11. package/CONTRIBUTING.md +62 -0
  12. package/LICENSE +21 -0
  13. package/README.md +206 -0
  14. package/bin/vexdo.js +2 -0
  15. package/package.json +35 -0
  16. package/src/commands/abort.ts +66 -0
  17. package/src/commands/fix.ts +106 -0
  18. package/src/commands/init.ts +142 -0
  19. package/src/commands/logs.ts +74 -0
  20. package/src/commands/review.ts +107 -0
  21. package/src/commands/start.ts +197 -0
  22. package/src/commands/status.ts +52 -0
  23. package/src/commands/submit.ts +38 -0
  24. package/src/index.ts +42 -0
  25. package/src/lib/claude.ts +259 -0
  26. package/src/lib/codex.ts +96 -0
  27. package/src/lib/config.ts +157 -0
  28. package/src/lib/gh.ts +78 -0
  29. package/src/lib/git.ts +119 -0
  30. package/src/lib/logger.ts +147 -0
  31. package/src/lib/requirements.ts +18 -0
  32. package/src/lib/review-loop.ts +154 -0
  33. package/src/lib/state.ts +121 -0
  34. package/src/lib/submit-task.ts +43 -0
  35. package/src/lib/tasks.ts +94 -0
  36. package/src/prompts/arbiter.ts +21 -0
  37. package/src/prompts/reviewer.ts +20 -0
  38. package/src/types/index.ts +96 -0
  39. package/test/config.test.ts +124 -0
  40. package/test/state.test.ts +147 -0
  41. package/test/unit/claude.test.ts +117 -0
  42. package/test/unit/codex.test.ts +67 -0
  43. package/test/unit/gh.test.ts +49 -0
  44. package/test/unit/git.test.ts +120 -0
  45. package/test/unit/review-loop.test.ts +198 -0
  46. package/tests/integration/review.test.ts +137 -0
  47. package/tests/integration/start.test.ts +220 -0
  48. package/tests/unit/init.test.ts +91 -0
  49. package/tsconfig.json +15 -0
  50. package/tsup.config.ts +8 -0
  51. package/vitest.config.ts +7 -0
@@ -0,0 +1,21 @@
1
+ export const ARBITER_SYSTEM_PROMPT = `You are a technical arbiter receiving: spec, diff, and reviewer comments.
2
+
3
+ Rules:
4
+ - Treat the spec as the single source of truth, not reviewer opinion.
5
+ - Explicitly check each reviewer comment against the spec:
6
+ - If a comment correctly identifies a spec violation, include it in the decision.
7
+ - If a comment conflicts with the spec or invents requirements, flag it and escalate.
8
+ - If comments are ambiguous, escalate.
9
+ - Never resolve architectural decisions autonomously; escalate instead.
10
+ - When decision is fix, feedback_for_codex must be concrete and actionable: what to change, where, and how.
11
+
12
+ Decision rules:
13
+ - submit: no critical/important comments that reflect real spec violations.
14
+ - fix: clear spec violations with actionable fixes; include feedback_for_codex.
15
+ - escalate: any reviewer/spec conflict, architectural ambiguity, or if max-iteration-like uncertainty would require escalation.
16
+
17
+ Output requirements:
18
+ - Output ONLY valid JSON with schema:
19
+ { "decision", "reasoning", "feedback_for_codex", "summary" }
20
+ - feedback_for_codex is required when decision="fix" and must be omitted otherwise.
21
+ - Never output prose outside the JSON.`;
@@ -0,0 +1,20 @@
1
+ export const REVIEWER_SYSTEM_PROMPT = `You are a strict code reviewer evaluating a git diff against a provided spec.
2
+
3
+ Rules:
4
+ - Treat the spec (acceptance criteria + architectural constraints) as the ONLY source of truth.
5
+ - Never invent requirements that are not explicitly present in the spec.
6
+ - Evaluate whether the diff violates the spec; distinguish real violations from stylistic preferences.
7
+ - For each issue, provide exact file and line number when visible in the diff.
8
+ - Severity definitions (strict):
9
+ - critical: breaks an acceptance criterion or architectural constraint.
10
+ - important: likely to cause bugs or maintenance issues directly related to the spec.
11
+ - minor: code quality issue not blocking the spec.
12
+ - noise: style/preference, spec-neutral.
13
+ - If the diff fully satisfies the spec, return no comments.
14
+
15
+ Output requirements:
16
+ - Output ONLY valid JSON.
17
+ - JSON schema:
18
+ { "comments": [ { "severity", "file", "line", "comment", "suggestion" } ] }
19
+ - If no issues: { "comments": [] }
20
+ - Never output prose outside the JSON.`;
@@ -0,0 +1,96 @@
1
+ export type CommentSeverity = 'critical' | 'important' | 'minor' | 'noise';
2
+
3
+ export type ArbiterDecision = 'fix' | 'submit' | 'escalate';
4
+
5
+ export type StepStatus = 'pending' | 'in_progress' | 'done' | 'failed' | 'escalated';
6
+
7
+ export type TaskStatus = 'in_progress' | 'review' | 'done' | 'blocked' | 'escalated';
8
+
9
+ export interface ServiceConfig {
10
+ name: string;
11
+ path: string;
12
+ }
13
+
14
+ export interface ReviewConfig {
15
+ model: string;
16
+ max_iterations: number;
17
+ auto_submit: boolean;
18
+ }
19
+
20
+ export interface CodexConfig {
21
+ model: string;
22
+ }
23
+
24
+ export interface VexdoConfig {
25
+ version: 1;
26
+ services: ServiceConfig[];
27
+ review: ReviewConfig;
28
+ codex: CodexConfig;
29
+ }
30
+
31
+ export interface TaskStep {
32
+ service: string;
33
+ spec: string;
34
+ depends_on?: string[];
35
+ }
36
+
37
+ export interface Task {
38
+ id: string;
39
+ title: string;
40
+ steps: TaskStep[];
41
+ depends_on?: string[];
42
+ }
43
+
44
+ export interface ReviewComment {
45
+ severity: CommentSeverity;
46
+ file?: string;
47
+ line?: number;
48
+ comment: string;
49
+ suggestion?: string;
50
+ }
51
+
52
+ export interface ReviewResult {
53
+ comments: ReviewComment[];
54
+ }
55
+
56
+ export interface ArbiterResult {
57
+ decision: ArbiterDecision;
58
+ reasoning: string;
59
+ feedback_for_codex?: string;
60
+ summary: string;
61
+ }
62
+
63
+ export interface StepState {
64
+ service: string;
65
+ status: StepStatus;
66
+ iteration: number;
67
+ branch?: string;
68
+ lastReviewComments?: ReviewComment[];
69
+ lastArbiterResult?: ArbiterResult;
70
+ }
71
+
72
+ export interface VexdoState {
73
+ taskId: string;
74
+ taskTitle: string;
75
+ taskPath: string;
76
+ status: TaskStatus;
77
+ steps: StepState[];
78
+ startedAt: string;
79
+ updatedAt: string;
80
+ }
81
+
82
+ export interface StartOptions {
83
+ dryRun?: boolean;
84
+ verbose?: boolean;
85
+ resume?: boolean;
86
+ }
87
+
88
+ export interface IterationLog {
89
+ taskId: string;
90
+ service: string;
91
+ iteration: number;
92
+ diff: string;
93
+ review: ReviewResult;
94
+ arbiter: ArbiterResult;
95
+ timestamp: string;
96
+ }
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { findProjectRoot, loadConfig } from '../src/lib/config.js';
8
+
9
+ const tempDirs: string[] = [];
10
+
11
+ function makeTempDir(): string {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vexdo-config-'));
13
+ tempDirs.push(dir);
14
+ return dir;
15
+ }
16
+
17
+ function writeConfig(dir: string, content: string): void {
18
+ fs.writeFileSync(path.join(dir, '.vexdo.yml'), content, 'utf8');
19
+ }
20
+
21
+ afterEach(() => {
22
+ for (const dir of tempDirs.splice(0)) {
23
+ fs.rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ describe('findProjectRoot', () => {
28
+ it('finds a parent directory containing .vexdo.yml', () => {
29
+ const root = makeTempDir();
30
+ const nested = path.join(root, 'a', 'b', 'c');
31
+ fs.mkdirSync(nested, { recursive: true });
32
+ writeConfig(root, 'version: 1\nservices:\n - name: api\n path: ./api\n');
33
+
34
+ expect(findProjectRoot(nested)).toBe(root);
35
+ });
36
+
37
+ it('returns null when no config exists', () => {
38
+ const dir = makeTempDir();
39
+ expect(findProjectRoot(dir)).toBeNull();
40
+ });
41
+ });
42
+
43
+ describe('loadConfig', () => {
44
+ it('loads valid config', () => {
45
+ const root = makeTempDir();
46
+ writeConfig(
47
+ root,
48
+ `version: 1
49
+ services:
50
+ - name: api
51
+ path: ./services/api
52
+ review:
53
+ model: custom-review
54
+ max_iterations: 7
55
+ auto_submit: true
56
+ codex:
57
+ model: custom-codex
58
+ `,
59
+ );
60
+
61
+ const result = loadConfig(root);
62
+
63
+ expect(result).toEqual({
64
+ version: 1,
65
+ services: [{ name: 'api', path: './services/api' }],
66
+ review: { model: 'custom-review', max_iterations: 7, auto_submit: true },
67
+ codex: { model: 'custom-codex' },
68
+ });
69
+ });
70
+
71
+ it('applies defaults for review and codex', () => {
72
+ const root = makeTempDir();
73
+ writeConfig(
74
+ root,
75
+ `version: 1
76
+ services:
77
+ - name: api
78
+ path: ./services/api
79
+ `,
80
+ );
81
+
82
+ const result = loadConfig(root);
83
+
84
+ expect(result.review).toEqual({
85
+ model: 'claude-haiku-4-5-20251001',
86
+ max_iterations: 3,
87
+ auto_submit: false,
88
+ });
89
+ expect(result.codex).toEqual({ model: 'gpt-4o' });
90
+ });
91
+
92
+ it('throws when config file is missing', () => {
93
+ const root = makeTempDir();
94
+ expect(() => loadConfig(root)).toThrowError(/Configuration file not found/);
95
+ });
96
+
97
+ it('throws for wrong version', () => {
98
+ const root = makeTempDir();
99
+ writeConfig(root, 'version: 2\nservices:\n - name: api\n path: ./api\n');
100
+
101
+ expect(() => loadConfig(root)).toThrowError('version must be 1');
102
+ });
103
+
104
+ it('throws for empty services', () => {
105
+ const root = makeTempDir();
106
+ writeConfig(root, 'version: 1\nservices: []\n');
107
+
108
+ expect(() => loadConfig(root)).toThrowError('services must be a non-empty array');
109
+ });
110
+
111
+ it('throws for missing service name', () => {
112
+ const root = makeTempDir();
113
+ writeConfig(root, 'version: 1\nservices:\n - path: ./api\n');
114
+
115
+ expect(() => loadConfig(root)).toThrowError('services[0].name must be a non-empty string');
116
+ });
117
+
118
+ it('throws for invalid yaml', () => {
119
+ const root = makeTempDir();
120
+ writeConfig(root, 'version: 1\nservices:\n - name: api\n path: [\n');
121
+
122
+ expect(() => loadConfig(root)).toThrowError(/Invalid YAML/);
123
+ });
124
+ });
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import {
8
+ clearState,
9
+ createState,
10
+ getStatePath,
11
+ hasActiveTask,
12
+ loadState,
13
+ saveIterationLog,
14
+ saveState,
15
+ updateStep,
16
+ } from '../src/lib/state.js';
17
+ import type { TaskStatus, VexdoState } from '../src/types/index.js';
18
+
19
+ const tempDirs: string[] = [];
20
+
21
+ function makeTempDir(): string {
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vexdo-state-'));
23
+ tempDirs.push(dir);
24
+ return dir;
25
+ }
26
+
27
+ afterEach(() => {
28
+ for (const dir of tempDirs.splice(0)) {
29
+ fs.rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ describe('state', () => {
34
+ it('returns null when absent', () => {
35
+ const root = makeTempDir();
36
+ expect(loadState(root)).toBeNull();
37
+ });
38
+
39
+ it('save/load roundtrip works', () => {
40
+ const root = makeTempDir();
41
+ const created = createState('task-1', 'Task 1', '/tmp/task.yml', [
42
+ { service: 'api', status: 'pending', iteration: 0 },
43
+ ]);
44
+
45
+ saveState(root, created);
46
+ const loaded = loadState(root);
47
+
48
+ expect(loaded).not.toBeNull();
49
+ expect(loaded?.taskId).toBe('task-1');
50
+ expect(loaded?.taskTitle).toBe('Task 1');
51
+ expect(loaded?.steps[0]?.service).toBe('api');
52
+ });
53
+
54
+ it('throws on corrupt JSON', () => {
55
+ const root = makeTempDir();
56
+ const statePath = getStatePath(root);
57
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
58
+ fs.writeFileSync(statePath, '{ not-json', 'utf8');
59
+
60
+ expect(() => loadState(root)).toThrowError(/Corrupt state file/);
61
+ });
62
+
63
+ it('clearState removes file and is idempotent', () => {
64
+ const root = makeTempDir();
65
+ const state = createState('task-1', 'Task 1', '/tmp/task.yml', []);
66
+ saveState(root, state);
67
+
68
+ clearState(root);
69
+ clearState(root);
70
+
71
+ expect(loadState(root)).toBeNull();
72
+ });
73
+
74
+ it('hasActiveTask handles all statuses', () => {
75
+ const root = makeTempDir();
76
+ const statuses: TaskStatus[] = ['in_progress', 'review', 'done', 'blocked', 'escalated'];
77
+
78
+ for (const status of statuses) {
79
+ const base: VexdoState = {
80
+ taskId: 'id',
81
+ taskTitle: 'title',
82
+ taskPath: '/tmp/task.yml',
83
+ status,
84
+ steps: [],
85
+ startedAt: new Date().toISOString(),
86
+ updatedAt: new Date().toISOString(),
87
+ };
88
+ saveState(root, base);
89
+ const expected = status === 'in_progress' || status === 'review';
90
+ expect(hasActiveTask(root)).toBe(expected);
91
+ }
92
+ });
93
+
94
+ it('createState sets expected fields', () => {
95
+ const state = createState('task-1', 'Task 1', '/tmp/task.yml', [
96
+ { service: 'api', status: 'pending', iteration: 0 },
97
+ ]);
98
+
99
+ expect(state.taskId).toBe('task-1');
100
+ expect(state.taskTitle).toBe('Task 1');
101
+ expect(state.taskPath).toBe('/tmp/task.yml');
102
+ expect(state.status).toBe('in_progress');
103
+ expect(state.startedAt).toBeTruthy();
104
+ expect(state.updatedAt).toBe(state.startedAt);
105
+ });
106
+
107
+ it('updateStep is immutable', () => {
108
+ const original = createState('task-1', 'Task 1', '/tmp/task.yml', [
109
+ { service: 'api', status: 'pending', iteration: 0 },
110
+ { service: 'web', status: 'pending', iteration: 0 },
111
+ ]);
112
+
113
+ const updated = updateStep(original, 'api', {
114
+ status: 'in_progress',
115
+ iteration: 1,
116
+ });
117
+
118
+ expect(updated).not.toBe(original);
119
+ expect(updated.steps).not.toBe(original.steps);
120
+ expect(updated.steps[0]).not.toBe(original.steps[0]);
121
+ expect(updated.steps[1]).toBe(original.steps[1]);
122
+ expect(original.steps[0]?.status).toBe('pending');
123
+ expect(updated.steps[0]?.status).toBe('in_progress');
124
+ expect(updated.steps[0]?.iteration).toBe(1);
125
+ });
126
+
127
+ it('saveIterationLog creates diff, review, and arbiter files', () => {
128
+ const root = makeTempDir();
129
+
130
+ saveIterationLog(root, 'task-1', 'api', 2, {
131
+ diff: 'diff content',
132
+ review: {
133
+ comments: [{ severity: 'important', comment: 'Needs work' }],
134
+ },
135
+ arbiter: {
136
+ decision: 'fix',
137
+ reasoning: 'Please address comments',
138
+ summary: 'One important issue',
139
+ },
140
+ });
141
+
142
+ const logsDir = path.join(root, '.vexdo', 'logs', 'task-1');
143
+ expect(fs.existsSync(path.join(logsDir, 'api-iteration-2-diff.txt'))).toBe(true);
144
+ expect(fs.existsSync(path.join(logsDir, 'api-iteration-2-review.json'))).toBe(true);
145
+ expect(fs.existsSync(path.join(logsDir, 'api-iteration-2-arbiter.json'))).toBe(true);
146
+ });
147
+ });
@@ -0,0 +1,117 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { messagesCreateMock, anthropicCtorMock } = vi.hoisted(() => {
4
+ const create = vi.fn();
5
+ const ctor = vi.fn(() => ({
6
+ messages: {
7
+ create,
8
+ },
9
+ }));
10
+
11
+ return {
12
+ messagesCreateMock: create,
13
+ anthropicCtorMock: ctor,
14
+ };
15
+ });
16
+
17
+ vi.mock('@anthropic-ai/sdk', () => ({
18
+ default: anthropicCtorMock,
19
+ }));
20
+
21
+ import { ClaudeClient, ClaudeError } from '../../src/lib/claude.js';
22
+
23
+ describe('ClaudeClient', () => {
24
+ beforeEach(() => {
25
+ messagesCreateMock.mockReset();
26
+ anthropicCtorMock.mockClear();
27
+ });
28
+
29
+ it('runReviewer parses valid JSON response correctly', async () => {
30
+ messagesCreateMock.mockResolvedValue({
31
+ content: [{ type: 'text', text: '{"comments":[{"severity":"important","comment":"bad","file":"a.ts","line":4}]}' }],
32
+ });
33
+
34
+ const client = new ClaudeClient('test-key');
35
+ const result = await client.runReviewer({ spec: 'spec', diff: 'diff', model: 'claude' });
36
+
37
+ expect(result).toEqual({
38
+ comments: [{ severity: 'important', comment: 'bad', file: 'a.ts', line: 4 }],
39
+ });
40
+ });
41
+
42
+ it('runReviewer strips markdown code fences before parsing', async () => {
43
+ messagesCreateMock.mockResolvedValue({
44
+ content: [
45
+ {
46
+ type: 'text',
47
+ text: '```json\n{"comments":[{"severity":"minor","comment":"nit"}]}\n```',
48
+ },
49
+ ],
50
+ });
51
+
52
+ const client = new ClaudeClient('test-key');
53
+ const result = await client.runReviewer({ spec: 'spec', diff: 'diff', model: 'claude' });
54
+
55
+ expect(result.comments).toHaveLength(1);
56
+ expect(result.comments[0]).toMatchObject({ severity: 'minor', comment: 'nit' });
57
+ });
58
+
59
+ it('runReviewer retries on network error and succeeds within 3 attempts', async () => {
60
+ messagesCreateMock
61
+ .mockRejectedValueOnce(new Error('network down'))
62
+ .mockRejectedValueOnce(new Error('timeout'))
63
+ .mockResolvedValueOnce({ content: [{ type: 'text', text: '{"comments":[]}' }] });
64
+
65
+ const client = new ClaudeClient('test-key');
66
+ const resultPromise = client.runReviewer({ spec: 'spec', diff: 'diff', model: 'claude' });
67
+
68
+ await expect(resultPromise).resolves.toEqual({ comments: [] });
69
+ expect(messagesCreateMock).toHaveBeenCalledTimes(3);
70
+ });
71
+
72
+ it('runReviewer throws ClaudeError after 3 failures', async () => {
73
+ messagesCreateMock.mockRejectedValue(new Error('always down'));
74
+
75
+ const client = new ClaudeClient('test-key');
76
+ await expect(client.runReviewer({ spec: 'spec', diff: 'diff', model: 'claude' })).rejects.toMatchObject({
77
+ name: 'ClaudeError',
78
+ attempt: 3,
79
+ message: expect.stringContaining('Claude API failed after 3 attempts'),
80
+ });
81
+ });
82
+
83
+ it('runReviewer does NOT retry on 401', async () => {
84
+ const unauthorized = Object.assign(new Error('unauthorized'), { status: 401 });
85
+ messagesCreateMock.mockRejectedValue(unauthorized);
86
+
87
+ const client = new ClaudeClient('test-key');
88
+ await expect(client.runReviewer({ spec: 'spec', diff: 'diff', model: 'claude' })).rejects.toBeInstanceOf(ClaudeError);
89
+ expect(messagesCreateMock).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ it('runArbiter parses valid JSON response correctly', async () => {
93
+ messagesCreateMock.mockResolvedValue({
94
+ content: [
95
+ {
96
+ type: 'text',
97
+ text: '{"decision":"fix","reasoning":"spec violation","feedback_for_codex":"change src/a.ts","summary":"Need fix"}',
98
+ },
99
+ ],
100
+ });
101
+
102
+ const client = new ClaudeClient('test-key');
103
+ const result = await client.runArbiter({
104
+ spec: 'spec',
105
+ diff: 'diff',
106
+ model: 'claude',
107
+ reviewComments: [{ severity: 'critical', comment: 'broken' }],
108
+ });
109
+
110
+ expect(result).toEqual({
111
+ decision: 'fix',
112
+ reasoning: 'spec violation',
113
+ feedback_for_codex: 'change src/a.ts',
114
+ summary: 'Need fix',
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { execFileMock } = vi.hoisted(() => ({
4
+ execFileMock: vi.fn(),
5
+ }));
6
+ const { debugMock } = vi.hoisted(() => ({
7
+ debugMock: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('node:child_process', () => ({
11
+ execFile: execFileMock,
12
+ }));
13
+
14
+ vi.mock('../../src/lib/logger.js', () => ({
15
+ debug: debugMock,
16
+ }));
17
+
18
+ import { CodexError, CodexNotFoundError, checkCodexAvailable, exec } from '../../src/lib/codex.js';
19
+
20
+ beforeEach(() => {
21
+ execFileMock.mockReset();
22
+ debugMock.mockReset();
23
+ });
24
+
25
+ describe('checkCodexAvailable', () => {
26
+ it('resolves when codex --version exits 0', async () => {
27
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, '1.0.0', ''));
28
+
29
+ await expect(checkCodexAvailable()).resolves.toBeUndefined();
30
+ expect(execFileMock).toHaveBeenCalledWith('codex', ['--version'], expect.any(Object), expect.any(Function));
31
+ });
32
+
33
+ it('throws CodexNotFoundError when codex not found', async () => {
34
+ const error = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
35
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(error, '', ''));
36
+
37
+ await expect(checkCodexAvailable()).rejects.toBeInstanceOf(CodexNotFoundError);
38
+ });
39
+ });
40
+
41
+ describe('codex.exec', () => {
42
+ it('resolves CodexResult on success', async () => {
43
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, 'done\n', 'warn\n'));
44
+
45
+ await expect(exec({ spec: 'do it', model: 'gpt-4o', cwd: '/repo' })).resolves.toEqual({
46
+ stdout: 'done',
47
+ stderr: 'warn',
48
+ exitCode: 0,
49
+ });
50
+ });
51
+
52
+ it('throws CodexError on non-zero exit', async () => {
53
+ const error = Object.assign(new Error('bad'), { code: 9 });
54
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(error, 'partial', 'failed'));
55
+
56
+ await expect(exec({ spec: 'do it', model: 'gpt-4o', cwd: '/repo' })).rejects.toBeInstanceOf(CodexError);
57
+ });
58
+
59
+ it('verbose mode passes output to logger.debug', async () => {
60
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, 'out', 'err'));
61
+
62
+ await exec({ spec: 'do it', model: 'gpt-4o', cwd: '/repo', verbose: true });
63
+
64
+ expect(debugMock).toHaveBeenCalledWith('out');
65
+ expect(debugMock).toHaveBeenCalledWith('err');
66
+ });
67
+ });
@@ -0,0 +1,49 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { execFileMock } = vi.hoisted(() => ({
4
+ execFileMock: vi.fn(),
5
+ }));
6
+
7
+ vi.mock('node:child_process', () => ({
8
+ execFile: execFileMock,
9
+ }));
10
+
11
+ import { GhNotFoundError, checkGhAvailable, createPr, getPrUrl } from '../../src/lib/gh.js';
12
+
13
+ beforeEach(() => {
14
+ execFileMock.mockReset();
15
+ });
16
+
17
+ describe('checkGhAvailable', () => {
18
+ it('resolves when gh is present', async () => {
19
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, 'gh version', ''));
20
+
21
+ await expect(checkGhAvailable()).resolves.toBeUndefined();
22
+ });
23
+
24
+ it('throws GhNotFoundError when gh not found', async () => {
25
+ const error = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
26
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(error, '', ''));
27
+
28
+ await expect(checkGhAvailable()).rejects.toBeInstanceOf(GhNotFoundError);
29
+ });
30
+ });
31
+
32
+ describe('gh helpers', () => {
33
+ it('createPr returns URL from stdout', async () => {
34
+ execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, 'https://github.com/org/repo/pull/1\n', ''));
35
+
36
+ await expect(createPr({ title: 't', body: 'b', cwd: '/repo' })).resolves.toBe(
37
+ 'https://github.com/org/repo/pull/1',
38
+ );
39
+ });
40
+
41
+ it('getPrUrl returns URL or null', async () => {
42
+ execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, 'https://github.com/org/repo/pull/2\n', ''));
43
+ await expect(getPrUrl('branch', '/repo')).resolves.toBe('https://github.com/org/repo/pull/2');
44
+
45
+ const error = Object.assign(new Error('none'), { code: 1 });
46
+ execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(error, '', ''));
47
+ await expect(getPrUrl('branch', '/repo')).resolves.toBeNull();
48
+ });
49
+ });