@lovenyberg/ove 0.1.0 → 0.2.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.
- package/CLAUDE.md +4 -3
- package/README.md +41 -9
- package/config.example.json +3 -0
- package/docs/examples.md +65 -3
- package/docs/index.html +26 -10
- package/docs/plans/2026-02-21-codex-runner-design.md +51 -0
- package/docs/plans/2026-02-21-codex-runner-plan.md +475 -0
- package/package.json +1 -1
- package/src/config.test.ts +52 -2
- package/src/config.ts +39 -1
- package/src/index.ts +76 -12
- package/src/router.test.ts +25 -0
- package/src/router.ts +11 -0
- package/src/runner.ts +1 -0
- package/src/runners/codex.test.ts +85 -0
- package/src/runners/codex.ts +137 -0
- package/src/setup.test.ts +87 -20
- package/src/setup.ts +180 -54
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# Codex Runner Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add OpenAI Codex CLI as a second agent runner, selectable per-repo or globally.
|
|
6
|
+
|
|
7
|
+
**Architecture:** New `CodexRunner` class implements the existing `AgentRunner` interface. Config gains a `runner` field (global + per-repo). A factory function in `index.ts` resolves which runner to use per task.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Bun + TypeScript, `codex` CLI (npm: `@openai/codex`), JSONL stream parsing.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Add `RunnerConfig` type and `model` to `RunOptions`
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `src/runner.ts:1-4` (add model to RunOptions)
|
|
17
|
+
- Modify: `src/config.ts:1-36` (add RunnerConfig, add to Config and RepoConfig)
|
|
18
|
+
|
|
19
|
+
**Step 1: Add `model` to `RunOptions` in `src/runner.ts`**
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
export interface RunOptions {
|
|
23
|
+
maxTurns: number;
|
|
24
|
+
mcpConfigPath?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Step 2: Add `RunnerConfig` and wire it into config types in `src/config.ts`**
|
|
30
|
+
|
|
31
|
+
Add the type:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
export interface RunnerConfig {
|
|
35
|
+
name: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Add `runner?: RunnerConfig` to `RepoConfig`:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
export interface RepoConfig {
|
|
44
|
+
url: string;
|
|
45
|
+
defaultBranch: string;
|
|
46
|
+
runner?: RunnerConfig;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Add `runner?: RunnerConfig` to `Config`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
export interface Config {
|
|
54
|
+
repos: Record<string, RepoConfig>;
|
|
55
|
+
users: Record<string, UserConfig>;
|
|
56
|
+
claude: {
|
|
57
|
+
maxTurns: number;
|
|
58
|
+
};
|
|
59
|
+
reposDir: string;
|
|
60
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
61
|
+
cron?: CronTaskConfig[];
|
|
62
|
+
runner?: RunnerConfig;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Step 3: Parse `runner` in `loadConfig`**
|
|
67
|
+
|
|
68
|
+
In the `loadConfig` function, add to the return object:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
runner: raw.runner,
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Step 4: Preserve `runner` in `saveConfig`**
|
|
75
|
+
|
|
76
|
+
In `saveConfig`, add to the merged object:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
if (config.runner) merged.runner = config.runner;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Step 5: Run tests**
|
|
83
|
+
|
|
84
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun test`
|
|
85
|
+
Expected: All existing tests pass (no tests break from adding optional fields).
|
|
86
|
+
|
|
87
|
+
**Step 6: Commit**
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
git add src/runner.ts src/config.ts
|
|
91
|
+
git commit -m "feat: add RunnerConfig type and model to RunOptions"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### Task 2: Create `CodexRunner`
|
|
97
|
+
|
|
98
|
+
**Files:**
|
|
99
|
+
- Create: `src/runners/codex.ts`
|
|
100
|
+
- Create: `src/runners/codex.test.ts`
|
|
101
|
+
|
|
102
|
+
**Step 1: Write the failing test in `src/runners/codex.test.ts`**
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { describe, it, expect } from "bun:test";
|
|
106
|
+
import { CodexRunner, summarizeCodexItem } from "./codex";
|
|
107
|
+
|
|
108
|
+
describe("CodexRunner", () => {
|
|
109
|
+
const runner = new CodexRunner();
|
|
110
|
+
|
|
111
|
+
it("has correct name", () => {
|
|
112
|
+
expect(runner.name).toBe("codex");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("builds correct args for a prompt", () => {
|
|
116
|
+
const args = runner.buildArgs("fix the bug", "/tmp/work", {
|
|
117
|
+
maxTurns: 25,
|
|
118
|
+
});
|
|
119
|
+
expect(args).toContain("exec");
|
|
120
|
+
expect(args).toContain("--json");
|
|
121
|
+
expect(args).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
122
|
+
expect(args).toContain("--skip-git-repo-check");
|
|
123
|
+
expect(args).toContain("--ephemeral");
|
|
124
|
+
expect(args).toContain("-C");
|
|
125
|
+
expect(args).toContain("/tmp/work");
|
|
126
|
+
expect(args).toContain("fix the bug");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("includes model flag when provided", () => {
|
|
130
|
+
const args = runner.buildArgs("test", "/tmp/work", {
|
|
131
|
+
maxTurns: 25,
|
|
132
|
+
model: "o3",
|
|
133
|
+
});
|
|
134
|
+
expect(args).toContain("-m");
|
|
135
|
+
expect(args).toContain("o3");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("omits model flag when not provided", () => {
|
|
139
|
+
const args = runner.buildArgs("test", "/tmp/work", { maxTurns: 25 });
|
|
140
|
+
expect(args).not.toContain("-m");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("ignores mcpConfigPath (not supported by codex CLI)", () => {
|
|
144
|
+
const args = runner.buildArgs("test", "/tmp/work", {
|
|
145
|
+
maxTurns: 25,
|
|
146
|
+
mcpConfigPath: "/tmp/mcp.json",
|
|
147
|
+
});
|
|
148
|
+
expect(args).not.toContain("--mcp-config");
|
|
149
|
+
expect(args).not.toContain("/tmp/mcp.json");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("summarizeCodexItem", () => {
|
|
154
|
+
it("summarizes command_execution", () => {
|
|
155
|
+
expect(
|
|
156
|
+
summarizeCodexItem({ type: "command_execution", command: "bun test" })
|
|
157
|
+
).toEqual({ tool: "shell", input: "bun test" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("summarizes file_change with paths", () => {
|
|
161
|
+
const result = summarizeCodexItem({
|
|
162
|
+
type: "file_change",
|
|
163
|
+
changes: [
|
|
164
|
+
{ path: "src/a.ts", kind: "update" },
|
|
165
|
+
{ path: "src/b.ts", kind: "add" },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
expect(result).toEqual({ tool: "file_change", input: "src/a.ts, src/b.ts" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("summarizes mcp_tool_call", () => {
|
|
172
|
+
const result = summarizeCodexItem({
|
|
173
|
+
type: "mcp_tool_call",
|
|
174
|
+
tool: "search",
|
|
175
|
+
arguments: '{"q":"hello"}',
|
|
176
|
+
});
|
|
177
|
+
expect(result).toEqual({ tool: "search", input: '{"q":"hello"}' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns null for agent_message", () => {
|
|
181
|
+
expect(
|
|
182
|
+
summarizeCodexItem({ type: "agent_message", text: "done" })
|
|
183
|
+
).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns null for unknown types", () => {
|
|
187
|
+
expect(summarizeCodexItem({ type: "reasoning", text: "thinking" })).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Step 2: Run tests to verify they fail**
|
|
193
|
+
|
|
194
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun test src/runners/codex.test.ts`
|
|
195
|
+
Expected: FAIL — module not found.
|
|
196
|
+
|
|
197
|
+
**Step 3: Write `src/runners/codex.ts`**
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
import type {
|
|
201
|
+
AgentRunner,
|
|
202
|
+
RunOptions,
|
|
203
|
+
RunResult,
|
|
204
|
+
StatusCallback,
|
|
205
|
+
} from "../runner";
|
|
206
|
+
import { logger } from "../logger";
|
|
207
|
+
import { which } from "bun";
|
|
208
|
+
import { realpathSync } from "node:fs";
|
|
209
|
+
|
|
210
|
+
export function summarizeCodexItem(
|
|
211
|
+
item: any
|
|
212
|
+
): { tool: string; input: string } | null {
|
|
213
|
+
if (!item) return null;
|
|
214
|
+
switch (item.type) {
|
|
215
|
+
case "command_execution":
|
|
216
|
+
return { tool: "shell", input: item.command || "" };
|
|
217
|
+
case "file_change": {
|
|
218
|
+
const paths = (item.changes || [])
|
|
219
|
+
.map((c: any) => c.path)
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join(", ");
|
|
222
|
+
return { tool: "file_change", input: paths };
|
|
223
|
+
}
|
|
224
|
+
case "mcp_tool_call":
|
|
225
|
+
return {
|
|
226
|
+
tool: item.tool || "mcp",
|
|
227
|
+
input:
|
|
228
|
+
typeof item.arguments === "string"
|
|
229
|
+
? item.arguments
|
|
230
|
+
: JSON.stringify(item.arguments || "").slice(0, 80),
|
|
231
|
+
};
|
|
232
|
+
default:
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export class CodexRunner implements AgentRunner {
|
|
238
|
+
name = "codex";
|
|
239
|
+
private codexPath: string;
|
|
240
|
+
|
|
241
|
+
constructor() {
|
|
242
|
+
const found = which("codex");
|
|
243
|
+
this.codexPath = found ? realpathSync(found) : "codex";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
buildArgs(prompt: string, workDir: string, opts: RunOptions): string[] {
|
|
247
|
+
const args = [
|
|
248
|
+
"exec",
|
|
249
|
+
"--json",
|
|
250
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
251
|
+
"--skip-git-repo-check",
|
|
252
|
+
"--ephemeral",
|
|
253
|
+
"-C",
|
|
254
|
+
workDir,
|
|
255
|
+
];
|
|
256
|
+
if (opts.model) args.push("-m", opts.model);
|
|
257
|
+
args.push(prompt);
|
|
258
|
+
return args;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async run(
|
|
262
|
+
prompt: string,
|
|
263
|
+
workDir: string,
|
|
264
|
+
opts: RunOptions,
|
|
265
|
+
onStatus?: StatusCallback
|
|
266
|
+
): Promise<RunResult> {
|
|
267
|
+
const args = this.buildArgs(prompt, workDir, opts);
|
|
268
|
+
const startTime = Date.now();
|
|
269
|
+
logger.info("starting codex task", {
|
|
270
|
+
workDir,
|
|
271
|
+
model: opts.model,
|
|
272
|
+
codexPath: this.codexPath,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const proc = Bun.spawn([this.codexPath, ...args], {
|
|
276
|
+
cwd: workDir,
|
|
277
|
+
stdout: "pipe",
|
|
278
|
+
stderr: "pipe",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
let lastAgentMessage: string | null = null;
|
|
282
|
+
let errorMessage: string | null = null;
|
|
283
|
+
const decoder = new TextDecoder();
|
|
284
|
+
const reader = proc.stdout.getReader();
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
let buffer = "";
|
|
288
|
+
while (true) {
|
|
289
|
+
const { done, value } = await reader.read();
|
|
290
|
+
if (done) break;
|
|
291
|
+
buffer += decoder.decode(value, { stream: true });
|
|
292
|
+
const lines = buffer.split("\n");
|
|
293
|
+
buffer = lines.pop() || "";
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
if (!line.trim()) continue;
|
|
296
|
+
try {
|
|
297
|
+
const event = JSON.parse(line);
|
|
298
|
+
if (
|
|
299
|
+
event.type === "item.completed" &&
|
|
300
|
+
event.item?.type === "agent_message"
|
|
301
|
+
) {
|
|
302
|
+
lastAgentMessage = event.item.text || "";
|
|
303
|
+
if (onStatus) onStatus({ kind: "text", text: lastAgentMessage });
|
|
304
|
+
}
|
|
305
|
+
if (event.type === "item.started" && event.item) {
|
|
306
|
+
const summary = summarizeCodexItem(event.item);
|
|
307
|
+
if (summary && onStatus) {
|
|
308
|
+
onStatus({ kind: "tool", ...summary });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (event.type === "turn.failed") {
|
|
312
|
+
errorMessage = event.error?.message || "Turn failed";
|
|
313
|
+
}
|
|
314
|
+
} catch {}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
reader.releaseLock();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const exitCode = await proc.exited;
|
|
322
|
+
const durationMs = Date.now() - startTime;
|
|
323
|
+
|
|
324
|
+
if (exitCode !== 0) {
|
|
325
|
+
const stderr = await new Response(proc.stderr).text();
|
|
326
|
+
const output = errorMessage || stderr || "Codex task failed";
|
|
327
|
+
logger.error("codex task failed", { exitCode, output, durationMs });
|
|
328
|
+
return { success: false, output, durationMs };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const finalOutput =
|
|
332
|
+
lastAgentMessage || "Task completed (no output)";
|
|
333
|
+
logger.info("codex task completed", { durationMs });
|
|
334
|
+
return { success: true, output: finalOutput, durationMs };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Step 4: Run tests to verify they pass**
|
|
340
|
+
|
|
341
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun test src/runners/codex.test.ts`
|
|
342
|
+
Expected: All tests PASS.
|
|
343
|
+
|
|
344
|
+
**Step 5: Run all tests**
|
|
345
|
+
|
|
346
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun test`
|
|
347
|
+
Expected: All tests pass.
|
|
348
|
+
|
|
349
|
+
**Step 6: Commit**
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
git add src/runners/codex.ts src/runners/codex.test.ts
|
|
353
|
+
git commit -m "feat: add CodexRunner for OpenAI Codex CLI"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
### Task 3: Add runner factory and per-task runner selection in `index.ts`
|
|
359
|
+
|
|
360
|
+
**Files:**
|
|
361
|
+
- Modify: `src/index.ts:5` (add CodexRunner import)
|
|
362
|
+
- Modify: `src/index.ts:45` (replace hardcoded runner with factory)
|
|
363
|
+
- Modify: `src/index.ts:262-278` (discuss mode — resolve runner)
|
|
364
|
+
- Modify: `src/index.ts:429-487` (processTask — resolve runner per repo)
|
|
365
|
+
|
|
366
|
+
**Step 1: Add import and runner factory**
|
|
367
|
+
|
|
368
|
+
Add import at top of `src/index.ts`:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
import { CodexRunner } from "./runners/codex";
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Replace line 45 (`const runner: AgentRunner = new ClaudeRunner();`) with:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
const runners = new Map<string, AgentRunner>();
|
|
378
|
+
|
|
379
|
+
function getRunner(name: string = "claude"): AgentRunner {
|
|
380
|
+
let r = runners.get(name);
|
|
381
|
+
if (!r) {
|
|
382
|
+
switch (name) {
|
|
383
|
+
case "codex":
|
|
384
|
+
r = new CodexRunner();
|
|
385
|
+
break;
|
|
386
|
+
case "claude":
|
|
387
|
+
default:
|
|
388
|
+
r = new ClaudeRunner();
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
runners.set(name, r);
|
|
392
|
+
}
|
|
393
|
+
return r;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function getRunnerForRepo(repo: string): AgentRunner {
|
|
397
|
+
const repoRunner = config.repos[repo]?.runner;
|
|
398
|
+
const globalRunner = config.runner;
|
|
399
|
+
const name = repoRunner?.name || globalRunner?.name || "claude";
|
|
400
|
+
return getRunner(name);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function getRunnerOptsForRepo(repo: string, baseOpts: RunOptions): RunOptions {
|
|
404
|
+
const repoRunner = config.repos[repo]?.runner;
|
|
405
|
+
const globalRunner = config.runner;
|
|
406
|
+
const model = repoRunner?.model || globalRunner?.model;
|
|
407
|
+
return model ? { ...baseOpts, model } : baseOpts;
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**Step 2: Update discuss mode (around line 269)**
|
|
412
|
+
|
|
413
|
+
Change `runner.run(` to use the factory. The discuss mode doesn't have a specific repo, so use the global default:
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
const discussRunner = getRunner(config.runner?.name);
|
|
417
|
+
const result = await discussRunner.run(
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Step 3: Update `processTask` (around line 472)**
|
|
421
|
+
|
|
422
|
+
Replace the `runner.run(` call with per-repo resolution:
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
const taskRunner = getRunnerForRepo(task.repo);
|
|
426
|
+
const runOpts = getRunnerOptsForRepo(task.repo, {
|
|
427
|
+
maxTurns: config.claude.maxTurns,
|
|
428
|
+
mcpConfigPath,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const result = await taskRunner.run(
|
|
432
|
+
task.prompt,
|
|
433
|
+
workDir,
|
|
434
|
+
runOpts,
|
|
435
|
+
(event: StatusEvent) => {
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Step 4: Update the startup log (around line 559)**
|
|
439
|
+
|
|
440
|
+
Change `runner: runner.name` to show the default runner:
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Step 5: Run all tests**
|
|
447
|
+
|
|
448
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun test`
|
|
449
|
+
Expected: All tests pass.
|
|
450
|
+
|
|
451
|
+
**Step 6: Commit**
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
git add src/index.ts
|
|
455
|
+
git commit -m "feat: add runner factory with per-repo runner selection"
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
### Task 4: Verify end-to-end
|
|
461
|
+
|
|
462
|
+
**Step 1: Check TypeScript compilation**
|
|
463
|
+
|
|
464
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun build src/index.ts --no-bundle --outdir /tmp/ove-check 2>&1 | head -20`
|
|
465
|
+
Expected: No type errors.
|
|
466
|
+
|
|
467
|
+
**Step 2: Run full test suite**
|
|
468
|
+
|
|
469
|
+
Run: `cd /home/love/code/seenthis/dev-agent && bun test`
|
|
470
|
+
Expected: All tests pass.
|
|
471
|
+
|
|
472
|
+
**Step 3: Verify codex binary is available (optional)**
|
|
473
|
+
|
|
474
|
+
Run: `which codex`
|
|
475
|
+
Expected: Path to codex binary, or empty if not installed. (Runner gracefully falls back to `"codex"` string — will fail at runtime with a clear error if not installed.)
|
package/package.json
CHANGED
package/src/config.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { loadConfig, isAuthorized, getUserRepos } from "./config";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { loadConfig, isAuthorized, getUserRepos, saveConfig, addRepo, addUser } from "./config";
|
|
3
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
3
4
|
|
|
4
5
|
describe("loadConfig", () => {
|
|
5
6
|
it("returns config with repos and users", () => {
|
|
@@ -49,3 +50,52 @@ describe("getUserRepos", () => {
|
|
|
49
50
|
expect(getUserRepos(config, "slack:U123")).toEqual(["a", "b"]);
|
|
50
51
|
});
|
|
51
52
|
});
|
|
53
|
+
|
|
54
|
+
describe("saveConfig / addRepo / addUser", () => {
|
|
55
|
+
const testPath = "./test-config-tmp.json";
|
|
56
|
+
const origEnv = process.env.CONFIG_PATH;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
process.env.CONFIG_PATH = testPath;
|
|
60
|
+
writeFileSync(testPath, JSON.stringify({ runner: "claude", repos: {}, users: {} }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
process.env.CONFIG_PATH = origEnv;
|
|
65
|
+
try { unlinkSync(testPath); } catch {}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("saveConfig preserves extra fields from existing file", () => {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
saveConfig(config);
|
|
71
|
+
const written = JSON.parse(readFileSync(testPath, "utf-8"));
|
|
72
|
+
expect(written.runner).toBe("claude");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("addRepo adds a repo and persists to disk", () => {
|
|
76
|
+
const config = loadConfig();
|
|
77
|
+
addRepo(config, "my-app", "git@github.com:user/my-app.git", "develop");
|
|
78
|
+
expect(config.repos["my-app"]).toEqual({ url: "git@github.com:user/my-app.git", defaultBranch: "develop" });
|
|
79
|
+
const written = JSON.parse(readFileSync(testPath, "utf-8"));
|
|
80
|
+
expect(written.repos["my-app"].url).toBe("git@github.com:user/my-app.git");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("addRepo defaults branch to main", () => {
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
addRepo(config, "test", "https://github.com/u/test.git");
|
|
86
|
+
expect(config.repos["test"].defaultBranch).toBe("main");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("addUser creates a new user", () => {
|
|
90
|
+
const config = loadConfig();
|
|
91
|
+
addUser(config, "slack:U1", "Alice", ["my-app"]);
|
|
92
|
+
expect(config.users["slack:U1"]).toEqual({ name: "Alice", repos: ["my-app"] });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("addUser merges repos for existing user without dupes", () => {
|
|
96
|
+
const config = loadConfig();
|
|
97
|
+
addUser(config, "slack:U1", "Alice", ["a", "b"]);
|
|
98
|
+
addUser(config, "slack:U1", "Alice", ["b", "c"]);
|
|
99
|
+
expect(config.users["slack:U1"].repos).toEqual(["a", "b", "c"]);
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface RunnerConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
}
|
|
2
7
|
|
|
3
8
|
export interface RepoConfig {
|
|
4
9
|
url: string;
|
|
5
10
|
defaultBranch: string;
|
|
11
|
+
runner?: RunnerConfig;
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
export interface UserConfig {
|
|
@@ -33,6 +39,7 @@ export interface Config {
|
|
|
33
39
|
reposDir: string;
|
|
34
40
|
mcpServers?: Record<string, McpServerConfig>;
|
|
35
41
|
cron?: CronTaskConfig[];
|
|
42
|
+
runner?: RunnerConfig;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export function loadConfig(): Config {
|
|
@@ -46,6 +53,7 @@ export function loadConfig(): Config {
|
|
|
46
53
|
reposDir: process.env.REPOS_DIR || raw.reposDir || "./repos",
|
|
47
54
|
mcpServers: raw.mcpServers,
|
|
48
55
|
cron: raw.cron,
|
|
56
|
+
runner: raw.runner,
|
|
49
57
|
};
|
|
50
58
|
} catch {
|
|
51
59
|
return {
|
|
@@ -71,3 +79,33 @@ export function isAuthorized(config: Config, platformUserId: string, repo?: stri
|
|
|
71
79
|
if (!repo) return true;
|
|
72
80
|
return user.repos.includes(repo);
|
|
73
81
|
}
|
|
82
|
+
|
|
83
|
+
export function saveConfig(config: Config): void {
|
|
84
|
+
const configPath = process.env.CONFIG_PATH || "./config.json";
|
|
85
|
+
// Read existing file to preserve extra fields (e.g. "runner")
|
|
86
|
+
let existing: Record<string, any> = {};
|
|
87
|
+
try {
|
|
88
|
+
existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
89
|
+
} catch {}
|
|
90
|
+
const merged = { ...existing, repos: config.repos, users: config.users, claude: config.claude, reposDir: config.reposDir };
|
|
91
|
+
if (config.mcpServers) merged.mcpServers = config.mcpServers;
|
|
92
|
+
if (config.cron) merged.cron = config.cron;
|
|
93
|
+
if (config.runner) merged.runner = config.runner;
|
|
94
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function addRepo(config: Config, name: string, url: string, branch: string = "main"): void {
|
|
98
|
+
config.repos[name] = { url, defaultBranch: branch };
|
|
99
|
+
saveConfig(config);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function addUser(config: Config, userId: string, name: string, repos: string[]): void {
|
|
103
|
+
const existing = config.users[userId];
|
|
104
|
+
if (existing) {
|
|
105
|
+
const merged = new Set([...existing.repos, ...repos]);
|
|
106
|
+
existing.repos = [...merged];
|
|
107
|
+
} else {
|
|
108
|
+
config.users[userId] = { name, repos: [...repos] };
|
|
109
|
+
}
|
|
110
|
+
saveConfig(config);
|
|
111
|
+
}
|