@vexdo/cli 0.1.0 → 0.1.1
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 +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1540 -0
- package/package.json +9 -1
- package/.eslintrc.json +0 -23
- package/.github/workflows/ci.yml +0 -84
- package/.idea/copilot.data.migration.ask2agent.xml +0 -6
- package/.idea/go.imports.xml +0 -11
- package/.idea/misc.xml +0 -6
- package/.idea/modules.xml +0 -8
- package/.idea/vcs.xml +0 -7
- package/.idea/vexdo-cli.iml +0 -9
- package/.prettierrc +0 -5
- package/CLAUDE.md +0 -93
- package/CONTRIBUTING.md +0 -62
- package/src/commands/abort.ts +0 -66
- package/src/commands/fix.ts +0 -106
- package/src/commands/init.ts +0 -142
- package/src/commands/logs.ts +0 -74
- package/src/commands/review.ts +0 -107
- package/src/commands/start.ts +0 -197
- package/src/commands/status.ts +0 -52
- package/src/commands/submit.ts +0 -38
- package/src/index.ts +0 -42
- package/src/lib/claude.ts +0 -259
- package/src/lib/codex.ts +0 -96
- package/src/lib/config.ts +0 -157
- package/src/lib/gh.ts +0 -78
- package/src/lib/git.ts +0 -119
- package/src/lib/logger.ts +0 -147
- package/src/lib/requirements.ts +0 -18
- package/src/lib/review-loop.ts +0 -154
- package/src/lib/state.ts +0 -121
- package/src/lib/submit-task.ts +0 -43
- package/src/lib/tasks.ts +0 -94
- package/src/prompts/arbiter.ts +0 -21
- package/src/prompts/reviewer.ts +0 -20
- package/src/types/index.ts +0 -96
- package/test/config.test.ts +0 -124
- package/test/state.test.ts +0 -147
- package/test/unit/claude.test.ts +0 -117
- package/test/unit/codex.test.ts +0 -67
- package/test/unit/gh.test.ts +0 -49
- package/test/unit/git.test.ts +0 -120
- package/test/unit/review-loop.test.ts +0 -198
- package/tests/integration/review.test.ts +0 -137
- package/tests/integration/start.test.ts +0 -220
- package/tests/unit/init.test.ts +0 -91
- package/tsconfig.json +0 -15
- package/tsup.config.ts +0 -8
- package/vitest.config.ts +0 -7
package/src/lib/claude.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
|
|
3
|
-
import { ARBITER_SYSTEM_PROMPT } from '../prompts/arbiter.js';
|
|
4
|
-
import { REVIEWER_SYSTEM_PROMPT } from '../prompts/reviewer.js';
|
|
5
|
-
import type { ArbiterResult, ReviewComment, ReviewResult } from '../types/index.js';
|
|
6
|
-
|
|
7
|
-
const REVIEWER_MAX_TOKENS_DEFAULT = 4096;
|
|
8
|
-
const ARBITER_MAX_TOKENS_DEFAULT = 2048;
|
|
9
|
-
const MAX_ATTEMPTS = 3;
|
|
10
|
-
|
|
11
|
-
export interface ReviewerOptions {
|
|
12
|
-
spec: string;
|
|
13
|
-
diff: string;
|
|
14
|
-
model: string;
|
|
15
|
-
maxTokens?: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ArbiterOptions {
|
|
19
|
-
spec: string;
|
|
20
|
-
diff: string;
|
|
21
|
-
reviewComments: ReviewComment[];
|
|
22
|
-
model: string;
|
|
23
|
-
maxTokens?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class ClaudeError extends Error {
|
|
27
|
-
attempt: number;
|
|
28
|
-
cause: unknown;
|
|
29
|
-
|
|
30
|
-
constructor(attempt: number, cause: unknown) {
|
|
31
|
-
const message = cause instanceof Error ? cause.message : String(cause);
|
|
32
|
-
super(`Claude API failed after ${String(attempt)} attempts: ${message}`);
|
|
33
|
-
this.name = 'ClaudeError';
|
|
34
|
-
this.attempt = attempt;
|
|
35
|
-
this.cause = cause;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function sleep(ms: number): Promise<void> {
|
|
40
|
-
await new Promise((resolve) => {
|
|
41
|
-
setTimeout(resolve, ms);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class ClaudeClient {
|
|
46
|
-
private client: Anthropic;
|
|
47
|
-
|
|
48
|
-
constructor(apiKey: string) {
|
|
49
|
-
this.client = new Anthropic({ apiKey });
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async runReviewer(opts: ReviewerOptions): Promise<ReviewResult> {
|
|
53
|
-
return this.runWithRetry(async () => {
|
|
54
|
-
const response = await this.client.messages.create({
|
|
55
|
-
model: opts.model,
|
|
56
|
-
max_tokens: opts.maxTokens ?? REVIEWER_MAX_TOKENS_DEFAULT,
|
|
57
|
-
system: REVIEWER_SYSTEM_PROMPT,
|
|
58
|
-
messages: [
|
|
59
|
-
{
|
|
60
|
-
role: 'user',
|
|
61
|
-
content: `SPEC:\n${opts.spec}\n\nDIFF:\n${opts.diff}`,
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
return parseReviewerResult(extractTextFromResponse(response));
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async runArbiter(opts: ArbiterOptions): Promise<ArbiterResult> {
|
|
71
|
-
return this.runWithRetry(async () => {
|
|
72
|
-
const response = await this.client.messages.create({
|
|
73
|
-
model: opts.model,
|
|
74
|
-
max_tokens: opts.maxTokens ?? ARBITER_MAX_TOKENS_DEFAULT,
|
|
75
|
-
system: ARBITER_SYSTEM_PROMPT,
|
|
76
|
-
messages: [
|
|
77
|
-
{
|
|
78
|
-
role: 'user',
|
|
79
|
-
content: `SPEC:\n${opts.spec}\n\nDIFF:\n${opts.diff}\n\nREVIEWER COMMENTS:\n${JSON.stringify(opts.reviewComments)}`,
|
|
80
|
-
},
|
|
81
|
-
],
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
return parseArbiterResult(extractTextFromResponse(response));
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
private async runWithRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
89
|
-
let lastError: unknown = new Error('Unknown Claude failure');
|
|
90
|
-
|
|
91
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
92
|
-
try {
|
|
93
|
-
return await fn();
|
|
94
|
-
} catch (error: unknown) {
|
|
95
|
-
lastError = error;
|
|
96
|
-
|
|
97
|
-
if (!isRetryableError(error) || attempt === MAX_ATTEMPTS) {
|
|
98
|
-
throw new ClaudeError(attempt, error);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
await sleep(1000 * 2 ** (attempt - 1));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
throw new ClaudeError(MAX_ATTEMPTS, lastError);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function extractTextFromResponse(response: unknown): string {
|
|
110
|
-
const content =
|
|
111
|
-
typeof response === 'object' && response !== null && 'content' in response
|
|
112
|
-
? (response as { content?: unknown }).content
|
|
113
|
-
: null;
|
|
114
|
-
|
|
115
|
-
if (!Array.isArray(content)) {
|
|
116
|
-
throw new Error('Claude response missing content array');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const text = content
|
|
120
|
-
.filter((block): block is { type: string; text?: string } => typeof block === 'object' && block !== null && 'type' in block)
|
|
121
|
-
.filter((block) => block.type === 'text')
|
|
122
|
-
.map((block) => (typeof block.text === 'string' ? block.text : ''))
|
|
123
|
-
.join('\n')
|
|
124
|
-
.trim();
|
|
125
|
-
|
|
126
|
-
if (!text) {
|
|
127
|
-
throw new Error('Claude response had no text content');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return text;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function extractJson(text: string): string {
|
|
134
|
-
const trimmed = text.trim();
|
|
135
|
-
|
|
136
|
-
if (!trimmed.startsWith('```')) {
|
|
137
|
-
return trimmed;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
|
|
141
|
-
if (fenced && fenced[1]) {
|
|
142
|
-
return fenced[1].trim();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return trimmed;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function parseReviewerResult(raw: string): ReviewResult {
|
|
149
|
-
let parsed: unknown;
|
|
150
|
-
try {
|
|
151
|
-
parsed = JSON.parse(extractJson(raw));
|
|
152
|
-
} catch (error: unknown) {
|
|
153
|
-
throw new Error(`Failed to parse reviewer JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (!isReviewResult(parsed)) {
|
|
157
|
-
throw new Error('Reviewer JSON does not match schema');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return parsed;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function parseArbiterResult(raw: string): ArbiterResult {
|
|
164
|
-
let parsed: unknown;
|
|
165
|
-
try {
|
|
166
|
-
parsed = JSON.parse(extractJson(raw));
|
|
167
|
-
} catch (error: unknown) {
|
|
168
|
-
throw new Error(`Failed to parse arbiter JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (!isArbiterResult(parsed)) {
|
|
172
|
-
throw new Error('Arbiter JSON does not match schema');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return parsed;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function isReviewComment(value: unknown): value is ReviewComment {
|
|
179
|
-
if (typeof value !== 'object' || value === null) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const candidate = value as Record<string, unknown>;
|
|
184
|
-
if (!['critical', 'important', 'minor', 'noise'].includes(String(candidate.severity))) {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
if (typeof candidate.comment !== 'string') {
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
if (candidate.file !== undefined && typeof candidate.file !== 'string') {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
if (candidate.line !== undefined && typeof candidate.line !== 'number') {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
if (candidate.suggestion !== undefined && typeof candidate.suggestion !== 'string') {
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return true;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function isReviewResult(value: unknown): value is ReviewResult {
|
|
204
|
-
if (typeof value !== 'object' || value === null) {
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const comments = (value as { comments?: unknown }).comments;
|
|
209
|
-
return Array.isArray(comments) && comments.every((comment) => isReviewComment(comment));
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function isArbiterResult(value: unknown): value is ArbiterResult {
|
|
213
|
-
if (typeof value !== 'object' || value === null) {
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const candidate = value as Record<string, unknown>;
|
|
218
|
-
|
|
219
|
-
if (!['fix', 'submit', 'escalate'].includes(String(candidate.decision))) {
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
if (typeof candidate.reasoning !== 'string' || typeof candidate.summary !== 'string') {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (candidate.decision === 'fix') {
|
|
227
|
-
return typeof candidate.feedback_for_codex === 'string' && candidate.feedback_for_codex.length > 0;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return candidate.feedback_for_codex === undefined;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function getStatusCode(error: unknown): number | undefined {
|
|
234
|
-
if (typeof error !== 'object' || error === null) {
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const candidate = error as { status?: unknown; statusCode?: unknown };
|
|
239
|
-
if (typeof candidate.status === 'number') {
|
|
240
|
-
return candidate.status;
|
|
241
|
-
}
|
|
242
|
-
if (typeof candidate.statusCode === 'number') {
|
|
243
|
-
return candidate.statusCode;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function isRetryableError(error: unknown): boolean {
|
|
250
|
-
const status = getStatusCode(error);
|
|
251
|
-
if (status === 400 || status === 401 || status === 403) {
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
if (status === 429 || (status !== undefined && status >= 500 && status <= 599)) {
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return true;
|
|
259
|
-
}
|
package/src/lib/codex.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import * as logger from './logger.js';
|
|
4
|
-
|
|
5
|
-
const CODEX_TIMEOUT_MS = 600_000;
|
|
6
|
-
|
|
7
|
-
export interface CodexExecOptions {
|
|
8
|
-
spec: string;
|
|
9
|
-
model: string;
|
|
10
|
-
cwd: string;
|
|
11
|
-
verbose?: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface CodexResult {
|
|
15
|
-
stdout: string;
|
|
16
|
-
stderr: string;
|
|
17
|
-
exitCode: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class CodexError extends Error {
|
|
21
|
-
stdout: string;
|
|
22
|
-
stderr: string;
|
|
23
|
-
exitCode: number;
|
|
24
|
-
|
|
25
|
-
constructor(stdout: string, stderr: string, exitCode: number) {
|
|
26
|
-
super(`codex exec failed (exit ${String(exitCode)})`);
|
|
27
|
-
this.name = 'CodexError';
|
|
28
|
-
this.stdout = stdout;
|
|
29
|
-
this.stderr = stderr;
|
|
30
|
-
this.exitCode = exitCode;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class CodexNotFoundError extends Error {
|
|
35
|
-
constructor() {
|
|
36
|
-
super('codex CLI not found. Install it: npm install -g @openai/codex');
|
|
37
|
-
this.name = 'CodexNotFoundError';
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Ensure codex CLI is installed and executable.
|
|
43
|
-
*/
|
|
44
|
-
export async function checkCodexAvailable(): Promise<void> {
|
|
45
|
-
await new Promise<void>((resolve, reject) => {
|
|
46
|
-
execFileCb('codex', ['--version'], { timeout: CODEX_TIMEOUT_MS, encoding: 'utf8' }, (error) => {
|
|
47
|
-
if (error) {
|
|
48
|
-
reject(new CodexNotFoundError());
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
resolve();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Execute codex with a task spec and model.
|
|
58
|
-
*/
|
|
59
|
-
export async function exec(opts: CodexExecOptions): Promise<CodexResult> {
|
|
60
|
-
const args = ['exec', '--model', opts.model, '--full-auto', '--', opts.spec];
|
|
61
|
-
|
|
62
|
-
const result = await new Promise<CodexResult>((resolve, reject) => {
|
|
63
|
-
execFileCb(
|
|
64
|
-
'codex',
|
|
65
|
-
args,
|
|
66
|
-
{ cwd: opts.cwd, timeout: CODEX_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 },
|
|
67
|
-
(error, stdout, stderr) => {
|
|
68
|
-
const normalizedStdout = stdout.trimEnd();
|
|
69
|
-
const normalizedStderr = stderr.trimEnd();
|
|
70
|
-
|
|
71
|
-
if (opts.verbose) {
|
|
72
|
-
if (normalizedStdout) {
|
|
73
|
-
logger.debug(normalizedStdout);
|
|
74
|
-
}
|
|
75
|
-
if (normalizedStderr) {
|
|
76
|
-
logger.debug(normalizedStderr);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (error) {
|
|
81
|
-
const exitCode = typeof error.code === 'number' ? error.code : 1;
|
|
82
|
-
reject(new CodexError(normalizedStdout, normalizedStderr || error.message, exitCode));
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
resolve({
|
|
87
|
-
stdout: normalizedStdout,
|
|
88
|
-
stderr: normalizedStderr,
|
|
89
|
-
exitCode: 0,
|
|
90
|
-
});
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
return result;
|
|
96
|
-
}
|
package/src/lib/config.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { parse } from 'yaml';
|
|
4
|
-
|
|
5
|
-
import type { CodexConfig, ReviewConfig, ServiceConfig, VexdoConfig } from '../types/index.js';
|
|
6
|
-
|
|
7
|
-
const DEFAULT_REVIEW_MODEL = 'claude-haiku-4-5-20251001';
|
|
8
|
-
const DEFAULT_MAX_ITERATIONS = 3;
|
|
9
|
-
const DEFAULT_AUTO_SUBMIT = false;
|
|
10
|
-
const DEFAULT_CODEX_MODEL = 'gpt-4o';
|
|
11
|
-
|
|
12
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
-
return typeof value === 'object' && value !== null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function readObjectField(obj: Record<string, unknown>, fieldPath: string): unknown {
|
|
17
|
-
return obj[fieldPath];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function requireString(value: unknown, fieldPath: string): string {
|
|
21
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
22
|
-
throw new Error(`${fieldPath} must be a non-empty string`);
|
|
23
|
-
}
|
|
24
|
-
return value;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function parseServices(value: unknown): ServiceConfig[] {
|
|
28
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
29
|
-
throw new Error('services must be a non-empty array');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return value.map((service, index) => {
|
|
33
|
-
if (!isRecord(service)) {
|
|
34
|
-
throw new Error(`services[${String(index)}] must be an object`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const name = requireString(readObjectField(service, 'name'), `services[${String(index)}].name`);
|
|
38
|
-
const servicePath = requireString(readObjectField(service, 'path'), `services[${String(index)}].path`);
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
name,
|
|
42
|
-
path: servicePath,
|
|
43
|
-
};
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseReview(value: unknown): ReviewConfig {
|
|
48
|
-
if (value === undefined) {
|
|
49
|
-
return {
|
|
50
|
-
model: DEFAULT_REVIEW_MODEL,
|
|
51
|
-
max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
52
|
-
auto_submit: DEFAULT_AUTO_SUBMIT,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!isRecord(value)) {
|
|
57
|
-
throw new Error('review must be an object');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const modelRaw = readObjectField(value, 'model');
|
|
61
|
-
const iterationsRaw = readObjectField(value, 'max_iterations');
|
|
62
|
-
const autoSubmitRaw = readObjectField(value, 'auto_submit');
|
|
63
|
-
|
|
64
|
-
const model = modelRaw === undefined ? DEFAULT_REVIEW_MODEL : requireString(modelRaw, 'review.model');
|
|
65
|
-
|
|
66
|
-
let max_iterations = DEFAULT_MAX_ITERATIONS;
|
|
67
|
-
if (iterationsRaw !== undefined) {
|
|
68
|
-
if (typeof iterationsRaw !== 'number' || !Number.isInteger(iterationsRaw) || iterationsRaw <= 0) {
|
|
69
|
-
throw new Error('review.max_iterations must be a positive integer');
|
|
70
|
-
}
|
|
71
|
-
max_iterations = iterationsRaw;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let auto_submit = DEFAULT_AUTO_SUBMIT;
|
|
75
|
-
if (autoSubmitRaw !== undefined) {
|
|
76
|
-
if (typeof autoSubmitRaw !== 'boolean') {
|
|
77
|
-
throw new Error('review.auto_submit must be a boolean');
|
|
78
|
-
}
|
|
79
|
-
auto_submit = autoSubmitRaw;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
model,
|
|
84
|
-
max_iterations,
|
|
85
|
-
auto_submit,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function parseCodex(value: unknown): CodexConfig {
|
|
90
|
-
if (value === undefined) {
|
|
91
|
-
return { model: DEFAULT_CODEX_MODEL };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!isRecord(value)) {
|
|
95
|
-
throw new Error('codex must be an object');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const modelRaw = readObjectField(value, 'model');
|
|
99
|
-
const model = modelRaw === undefined ? DEFAULT_CODEX_MODEL : requireString(modelRaw, 'codex.model');
|
|
100
|
-
return { model };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function findProjectRoot(startDir: string = process.cwd()): string | null {
|
|
104
|
-
let current = path.resolve(startDir);
|
|
105
|
-
let reachedRoot = false;
|
|
106
|
-
|
|
107
|
-
while (!reachedRoot) {
|
|
108
|
-
const candidate = path.join(current, '.vexdo.yml');
|
|
109
|
-
if (fs.existsSync(candidate)) {
|
|
110
|
-
return current;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const parent = path.dirname(current);
|
|
114
|
-
reachedRoot = parent === current;
|
|
115
|
-
current = parent;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function loadConfig(projectRoot: string): VexdoConfig {
|
|
122
|
-
const configPath = path.join(projectRoot, '.vexdo.yml');
|
|
123
|
-
|
|
124
|
-
if (!fs.existsSync(configPath)) {
|
|
125
|
-
throw new Error(`Configuration file not found: ${configPath}`);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const configRaw = fs.readFileSync(configPath, 'utf8');
|
|
129
|
-
let parsed: unknown;
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
parsed = parse(configRaw);
|
|
133
|
-
} catch (error: unknown) {
|
|
134
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
-
throw new Error(`Invalid YAML in .vexdo.yml: ${message}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!isRecord(parsed)) {
|
|
139
|
-
throw new Error('config must be an object');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const versionRaw = readObjectField(parsed, 'version');
|
|
143
|
-
if (versionRaw !== 1) {
|
|
144
|
-
throw new Error('version must be 1');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const services = parseServices(readObjectField(parsed, 'services'));
|
|
148
|
-
const review = parseReview(readObjectField(parsed, 'review'));
|
|
149
|
-
const codex = parseCodex(readObjectField(parsed, 'codex'));
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
version: 1,
|
|
153
|
-
services,
|
|
154
|
-
review,
|
|
155
|
-
codex,
|
|
156
|
-
};
|
|
157
|
-
}
|
package/src/lib/gh.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
const GH_TIMEOUT_MS = 30_000;
|
|
4
|
-
|
|
5
|
-
export interface CreatePrOptions {
|
|
6
|
-
title: string;
|
|
7
|
-
body: string;
|
|
8
|
-
base?: string;
|
|
9
|
-
cwd: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export class GhNotFoundError extends Error {
|
|
13
|
-
constructor() {
|
|
14
|
-
super('gh CLI not found. Install it: https://cli.github.com');
|
|
15
|
-
this.name = 'GhNotFoundError';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Ensure GitHub CLI is installed and executable.
|
|
21
|
-
*/
|
|
22
|
-
export async function checkGhAvailable(): Promise<void> {
|
|
23
|
-
await new Promise<void>((resolve, reject) => {
|
|
24
|
-
execFileCb('gh', ['--version'], { timeout: GH_TIMEOUT_MS, encoding: 'utf8' }, (error) => {
|
|
25
|
-
if (error) {
|
|
26
|
-
reject(new GhNotFoundError());
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
resolve();
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Create a pull request and return the new PR URL.
|
|
36
|
-
*/
|
|
37
|
-
export async function createPr(opts: CreatePrOptions): Promise<string> {
|
|
38
|
-
const base = opts.base ?? 'main';
|
|
39
|
-
const output = await new Promise<string>((resolve, reject) => {
|
|
40
|
-
execFileCb(
|
|
41
|
-
'gh',
|
|
42
|
-
['pr', 'create', '--title', opts.title, '--body', opts.body, '--base', base],
|
|
43
|
-
{ cwd: opts.cwd, timeout: GH_TIMEOUT_MS, encoding: 'utf8' },
|
|
44
|
-
(error, stdout, stderr) => {
|
|
45
|
-
if (error) {
|
|
46
|
-
reject(new Error((stderr || error.message).trim()));
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
resolve(stdout.trim());
|
|
50
|
-
},
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
return output;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Return an existing PR URL for a branch, or null when no PR exists.
|
|
59
|
-
*/
|
|
60
|
-
export async function getPrUrl(branch: string, cwd: string): Promise<string | null> {
|
|
61
|
-
const output = await new Promise<string | null>((resolve) => {
|
|
62
|
-
execFileCb(
|
|
63
|
-
'gh',
|
|
64
|
-
['pr', 'view', branch, '--json', 'url', '--jq', '.url'],
|
|
65
|
-
{ cwd, timeout: GH_TIMEOUT_MS, encoding: 'utf8' },
|
|
66
|
-
(error, stdout) => {
|
|
67
|
-
if (error) {
|
|
68
|
-
resolve(null);
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
const url = stdout.trim();
|
|
72
|
-
resolve(url || null);
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
return output;
|
|
78
|
-
}
|
package/src/lib/git.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
const GIT_TIMEOUT_MS = 30_000;
|
|
4
|
-
|
|
5
|
-
/** Error thrown when a git command fails. */
|
|
6
|
-
export class GitError extends Error {
|
|
7
|
-
command: string;
|
|
8
|
-
exitCode: number;
|
|
9
|
-
stderr: string;
|
|
10
|
-
|
|
11
|
-
constructor(args: string[], exitCode: number, stderr: string) {
|
|
12
|
-
super(`git ${args.join(' ')} failed (exit ${String(exitCode)}): ${stderr}`);
|
|
13
|
-
this.name = 'GitError';
|
|
14
|
-
this.command = `git ${args.join(' ')}`;
|
|
15
|
-
this.exitCode = exitCode;
|
|
16
|
-
this.stderr = stderr;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Run a git command in a specific working directory.
|
|
22
|
-
*/
|
|
23
|
-
export async function exec(args: string[], cwd: string): Promise<string> {
|
|
24
|
-
return new Promise((resolve, reject) => {
|
|
25
|
-
execFileCb('git', args, { cwd, timeout: GIT_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
26
|
-
if (error) {
|
|
27
|
-
const exitCode = typeof error.code === 'number' ? error.code : -1;
|
|
28
|
-
reject(new GitError(args, exitCode, (stderr || error.message).trim()));
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
resolve(stdout.trimEnd());
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Get the current branch name.
|
|
38
|
-
*/
|
|
39
|
-
export async function getCurrentBranch(cwd: string): Promise<string> {
|
|
40
|
-
return exec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Check whether a local branch exists.
|
|
45
|
-
*/
|
|
46
|
-
export async function branchExists(name: string, cwd: string): Promise<boolean> {
|
|
47
|
-
try {
|
|
48
|
-
await exec(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], cwd);
|
|
49
|
-
return true;
|
|
50
|
-
} catch (error) {
|
|
51
|
-
if (error instanceof GitError && error.exitCode === 1) {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Create and checkout a new branch.
|
|
60
|
-
*/
|
|
61
|
-
export async function createBranch(name: string, cwd: string): Promise<void> {
|
|
62
|
-
if (await branchExists(name, cwd)) {
|
|
63
|
-
throw new GitError(['checkout', '-b', name], 128, `branch '${name}' already exists`);
|
|
64
|
-
}
|
|
65
|
-
await exec(['checkout', '-b', name], cwd);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Checkout an existing branch.
|
|
70
|
-
*/
|
|
71
|
-
export async function checkoutBranch(name: string, cwd: string): Promise<void> {
|
|
72
|
-
await exec(['checkout', name], cwd);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Get the git diff for the working directory.
|
|
77
|
-
*/
|
|
78
|
-
export async function getDiff(cwd: string, base?: string): Promise<string> {
|
|
79
|
-
if (base) {
|
|
80
|
-
return exec(['diff', `${base}..HEAD`], cwd);
|
|
81
|
-
}
|
|
82
|
-
return exec(['diff', 'HEAD'], cwd);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Get porcelain status output.
|
|
87
|
-
*/
|
|
88
|
-
export async function getStatus(cwd: string): Promise<string> {
|
|
89
|
-
return exec(['status', '--porcelain'], cwd);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Return whether the repository has uncommitted changes.
|
|
94
|
-
*/
|
|
95
|
-
export async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
|
96
|
-
const status = await getStatus(cwd);
|
|
97
|
-
return status.length > 0;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Stage all changes.
|
|
102
|
-
*/
|
|
103
|
-
export async function stageAll(cwd: string): Promise<void> {
|
|
104
|
-
await exec(['add', '-A'], cwd);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Commit staged changes.
|
|
109
|
-
*/
|
|
110
|
-
export async function commit(message: string, cwd: string): Promise<void> {
|
|
111
|
-
await exec(['commit', '-m', message], cwd);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Build the task branch name for a service.
|
|
116
|
-
*/
|
|
117
|
-
export function getBranchName(taskId: string, service: string): string {
|
|
118
|
-
return `vexdo/${taskId}/${service}`;
|
|
119
|
-
}
|