im-pickle-rick 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.
- package/README.md +242 -0
- package/bin.js +3 -0
- package/dist/pickle +0 -0
- package/dist/worker-executor.js +207 -0
- package/package.json +53 -0
- package/src/games/GameSidebarManager.test.ts +64 -0
- package/src/games/GameSidebarManager.ts +78 -0
- package/src/games/gameboy/GameboyView.test.ts +25 -0
- package/src/games/gameboy/GameboyView.ts +100 -0
- package/src/games/gameboy/gameboy-polyfills.ts +313 -0
- package/src/games/index.test.ts +9 -0
- package/src/games/index.ts +4 -0
- package/src/games/snake/SnakeGame.test.ts +35 -0
- package/src/games/snake/SnakeGame.ts +145 -0
- package/src/games/snake/SnakeView.test.ts +25 -0
- package/src/games/snake/SnakeView.ts +290 -0
- package/src/index.test.ts +24 -0
- package/src/index.ts +141 -0
- package/src/services/commands/worker.test.ts +14 -0
- package/src/services/commands/worker.ts +262 -0
- package/src/services/config/index.ts +2 -0
- package/src/services/config/settings.test.ts +42 -0
- package/src/services/config/settings.ts +220 -0
- package/src/services/config/state.test.ts +88 -0
- package/src/services/config/state.ts +130 -0
- package/src/services/config/types.ts +39 -0
- package/src/services/execution/index.ts +1 -0
- package/src/services/execution/pickle-source.test.ts +88 -0
- package/src/services/execution/pickle-source.ts +264 -0
- package/src/services/execution/prompt.test.ts +93 -0
- package/src/services/execution/prompt.ts +322 -0
- package/src/services/execution/sequential.test.ts +91 -0
- package/src/services/execution/sequential.ts +422 -0
- package/src/services/execution/worker-client.ts +94 -0
- package/src/services/execution/worker-executor.ts +41 -0
- package/src/services/execution/worker.test.ts +73 -0
- package/src/services/git/branch.test.ts +147 -0
- package/src/services/git/branch.ts +128 -0
- package/src/services/git/diff.test.ts +113 -0
- package/src/services/git/diff.ts +323 -0
- package/src/services/git/index.ts +4 -0
- package/src/services/git/pr.test.ts +104 -0
- package/src/services/git/pr.ts +192 -0
- package/src/services/git/worktree.test.ts +99 -0
- package/src/services/git/worktree.ts +141 -0
- package/src/services/providers/base.test.ts +86 -0
- package/src/services/providers/base.ts +438 -0
- package/src/services/providers/codex.test.ts +39 -0
- package/src/services/providers/codex.ts +208 -0
- package/src/services/providers/gemini.test.ts +40 -0
- package/src/services/providers/gemini.ts +169 -0
- package/src/services/providers/index.test.ts +28 -0
- package/src/services/providers/index.ts +41 -0
- package/src/services/providers/opencode.test.ts +64 -0
- package/src/services/providers/opencode.ts +228 -0
- package/src/services/providers/types.ts +44 -0
- package/src/skills/code-implementer.md +105 -0
- package/src/skills/code-researcher.md +78 -0
- package/src/skills/implementation-planner.md +105 -0
- package/src/skills/plan-reviewer.md +100 -0
- package/src/skills/prd-drafter.md +123 -0
- package/src/skills/research-reviewer.md +79 -0
- package/src/skills/ruthless-refactorer.md +52 -0
- package/src/skills/ticket-manager.md +135 -0
- package/src/types/index.ts +2 -0
- package/src/types/rpc.ts +14 -0
- package/src/types/tasks.ts +50 -0
- package/src/types.d.ts +9 -0
- package/src/ui/common.ts +28 -0
- package/src/ui/components/FilePickerView.test.ts +79 -0
- package/src/ui/components/FilePickerView.ts +161 -0
- package/src/ui/components/MultiLineInput.test.ts +27 -0
- package/src/ui/components/MultiLineInput.ts +233 -0
- package/src/ui/components/SessionChip.test.ts +69 -0
- package/src/ui/components/SessionChip.ts +481 -0
- package/src/ui/components/ToyboxSidebar.test.ts +36 -0
- package/src/ui/components/ToyboxSidebar.ts +329 -0
- package/src/ui/components/refactor_plan.md +35 -0
- package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
- package/src/ui/controllers/DashboardController.ts +650 -0
- package/src/ui/dashboard.test.ts +43 -0
- package/src/ui/dashboard.ts +309 -0
- package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
- package/src/ui/dialogs/DashboardDialog.ts +399 -0
- package/src/ui/dialogs/Dialog.test.ts +50 -0
- package/src/ui/dialogs/Dialog.ts +241 -0
- package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
- package/src/ui/dialogs/DialogSidebar.ts +71 -0
- package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
- package/src/ui/dialogs/DiffViewDialog.ts +510 -0
- package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
- package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
- package/src/ui/dialogs/test-utils.ts +232 -0
- package/src/ui/file-picker-utils.test.ts +71 -0
- package/src/ui/file-picker-utils.ts +200 -0
- package/src/ui/input-chrome.test.ts +62 -0
- package/src/ui/input-chrome.ts +172 -0
- package/src/ui/logger.test.ts +68 -0
- package/src/ui/logger.ts +45 -0
- package/src/ui/mock-factory.ts +6 -0
- package/src/ui/spinner.test.ts +65 -0
- package/src/ui/spinner.ts +41 -0
- package/src/ui/test-setup.ts +300 -0
- package/src/ui/theme.test.ts +23 -0
- package/src/ui/theme.ts +16 -0
- package/src/ui/views/LandingView.integration.test.ts +21 -0
- package/src/ui/views/LandingView.test.ts +24 -0
- package/src/ui/views/LandingView.ts +221 -0
- package/src/ui/views/LogView.test.ts +24 -0
- package/src/ui/views/LogView.ts +277 -0
- package/src/ui/views/ToyboxView.test.ts +46 -0
- package/src/ui/views/ToyboxView.ts +323 -0
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +100 -0
- package/src/utils/index.test.ts +68 -0
- package/src/utils/index.ts +95 -0
- package/src/utils/persona.test.ts +12 -0
- package/src/utils/persona.ts +8 -0
- package/src/utils/project-root.test.ts +38 -0
- package/src/utils/project-root.ts +22 -0
- package/src/utils/resources.test.ts +64 -0
- package/src/utils/resources.ts +92 -0
- package/src/utils/search.test.ts +48 -0
- package/src/utils/search.ts +103 -0
- package/src/utils/session-tracker.test.ts +46 -0
- package/src/utils/session-tracker.ts +67 -0
- package/src/utils/spinner.test.ts +54 -0
- package/src/utils/spinner.ts +87 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { AIProvider, AIResult, ProviderOptions, ProgressCallback } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// Check if running in Bun
|
|
5
|
+
const isBun = typeof Bun !== "undefined";
|
|
6
|
+
const isWindows = process.platform === "win32";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a command is available in PATH
|
|
10
|
+
*/
|
|
11
|
+
export async function commandExists(command: string): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
const checkCommand = isWindows ? "where" : "which";
|
|
14
|
+
if (isBun) {
|
|
15
|
+
const proc = Bun.spawn([checkCommand, command], {
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
const exitCode = await proc.exited;
|
|
20
|
+
return exitCode === 0;
|
|
21
|
+
}
|
|
22
|
+
// Node.js fallback - where/which don't need shell
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const proc = spawn(checkCommand, [command], { stdio: "pipe" });
|
|
25
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
26
|
+
proc.on("error", () => resolve(false));
|
|
27
|
+
});
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Execute a command and return stdout
|
|
35
|
+
* @param stdinContent - Optional content to pass via stdin (useful for multi-line prompts on Windows)
|
|
36
|
+
*/
|
|
37
|
+
export async function execCommand(
|
|
38
|
+
command: string,
|
|
39
|
+
args: string[],
|
|
40
|
+
workDir: string,
|
|
41
|
+
env?: Record<string, string>,
|
|
42
|
+
stdinContent?: string,
|
|
43
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
44
|
+
if (isBun) {
|
|
45
|
+
// On Windows, run through cmd.exe to handle .cmd wrappers (npm global packages)
|
|
46
|
+
const spawnArgs = isWindows ? ["cmd.exe", "/c", command, ...args] : [command, ...args];
|
|
47
|
+
const proc = Bun.spawn(spawnArgs, {
|
|
48
|
+
cwd: workDir,
|
|
49
|
+
stdin: stdinContent ? "pipe" : "ignore",
|
|
50
|
+
stdout: "pipe",
|
|
51
|
+
stderr: "pipe",
|
|
52
|
+
env: { ...process.env, ...env },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Write stdin content if provided
|
|
56
|
+
if (stdinContent && proc.stdin) {
|
|
57
|
+
proc.stdin.write(stdinContent);
|
|
58
|
+
proc.stdin.end();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
62
|
+
new Response(proc.stdout).text(),
|
|
63
|
+
new Response(proc.stderr).text(),
|
|
64
|
+
proc.exited,
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
return { stdout, stderr, exitCode };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Node.js fallback - use shell on Windows to execute .cmd wrappers
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const proc = spawn(command, args, {
|
|
73
|
+
cwd: workDir,
|
|
74
|
+
env: { ...process.env, ...env },
|
|
75
|
+
stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"],
|
|
76
|
+
shell: isWindows, // Required on Windows for npm global commands (.cmd wrappers)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Write stdin content if provided
|
|
80
|
+
if (stdinContent && proc.stdin) {
|
|
81
|
+
proc.stdin.write(stdinContent);
|
|
82
|
+
proc.stdin.end();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let stdout = "";
|
|
86
|
+
let stderr = "";
|
|
87
|
+
|
|
88
|
+
proc.stdout?.on("data", (data) => {
|
|
89
|
+
stdout += data.toString();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
proc.stderr?.on("data", (data) => {
|
|
93
|
+
stderr += data.toString();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
proc.on("close", (exitCode) => {
|
|
97
|
+
resolve({ stdout, stderr, exitCode: exitCode ?? 1 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
proc.on("error", (err) => {
|
|
101
|
+
// Maintain backward compatibility - don't reject, include error in stderr
|
|
102
|
+
stderr += `\nSpawn error: ${err.message}`;
|
|
103
|
+
resolve({ stdout, stderr, exitCode: 1 });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse token counts from stream-json output (Claude/Qwen/Gemini format)
|
|
110
|
+
*/
|
|
111
|
+
export function parseStreamJsonResult(output: string): {
|
|
112
|
+
response: string;
|
|
113
|
+
inputTokens: number;
|
|
114
|
+
outputTokens: number;
|
|
115
|
+
} {
|
|
116
|
+
const lines = output.split("\n").filter(Boolean);
|
|
117
|
+
let response = "";
|
|
118
|
+
let inputTokens = 0;
|
|
119
|
+
let outputTokens = 0;
|
|
120
|
+
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(line);
|
|
124
|
+
if (parsed.type === "message" && parsed.role === "assistant") {
|
|
125
|
+
response += parsed.content || "";
|
|
126
|
+
} else if (parsed.type === "result") {
|
|
127
|
+
inputTokens = parsed.usage?.input_tokens || parsed.stats?.input_tokens || 0;
|
|
128
|
+
outputTokens = parsed.usage?.output_tokens || parsed.stats?.output_tokens || 0;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Ignore non-JSON lines
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { response: response || "Task completed", inputTokens, outputTokens };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check for errors in stream-json output
|
|
140
|
+
*/
|
|
141
|
+
export function checkForErrors(output: string): string | null {
|
|
142
|
+
const lines = output.split("\n").filter(Boolean);
|
|
143
|
+
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(line);
|
|
147
|
+
if (parsed.type === "error") {
|
|
148
|
+
return parsed.error?.message || parsed.message || "Unknown error";
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore non-JSON lines
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read a stream line by line, calling onLine for each non-empty line
|
|
160
|
+
*/
|
|
161
|
+
async function readStream(
|
|
162
|
+
stream: ReadableStream<Uint8Array>,
|
|
163
|
+
onLine: (line: string) => void,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const reader = stream.getReader();
|
|
166
|
+
const decoder = new TextDecoder();
|
|
167
|
+
let buffer = "";
|
|
168
|
+
try {
|
|
169
|
+
while (true) {
|
|
170
|
+
const { done, value } = await reader.read();
|
|
171
|
+
if (done) break;
|
|
172
|
+
buffer += decoder.decode(value, { stream: true });
|
|
173
|
+
const lines = buffer.split("\n");
|
|
174
|
+
buffer = lines.pop() || "";
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
if (line.trim()) onLine(line);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (buffer.trim()) onLine(buffer);
|
|
180
|
+
} finally {
|
|
181
|
+
reader.releaseLock();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Execute a command with streaming output, calling onLine for each line
|
|
187
|
+
* @param stdinContent - Optional content to pass via stdin (useful for multi-line prompts on Windows)
|
|
188
|
+
*/
|
|
189
|
+
export async function execCommandStreaming(
|
|
190
|
+
command: string,
|
|
191
|
+
args: string[],
|
|
192
|
+
workDir: string,
|
|
193
|
+
onLine: (line: string) => void,
|
|
194
|
+
env?: Record<string, string>,
|
|
195
|
+
stdinContent?: string,
|
|
196
|
+
): Promise<{ exitCode: number }> {
|
|
197
|
+
if (isBun) {
|
|
198
|
+
// On Windows, run through cmd.exe to handle .cmd wrappers (npm global packages)
|
|
199
|
+
const spawnArgs = isWindows ? ["cmd.exe", "/c", command, ...args] : [command, ...args];
|
|
200
|
+
const proc = Bun.spawn(spawnArgs, {
|
|
201
|
+
cwd: workDir,
|
|
202
|
+
stdin: stdinContent ? "pipe" : "ignore",
|
|
203
|
+
stdout: "pipe",
|
|
204
|
+
stderr: "pipe",
|
|
205
|
+
env: { ...process.env, ...env },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Write stdin content if provided
|
|
209
|
+
if (stdinContent && proc.stdin) {
|
|
210
|
+
proc.stdin.write(stdinContent);
|
|
211
|
+
proc.stdin.end();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Process both stdout and stderr in parallel
|
|
215
|
+
await Promise.all([readStream(proc.stdout, onLine), readStream(proc.stderr, onLine)]);
|
|
216
|
+
|
|
217
|
+
const exitCode = await proc.exited;
|
|
218
|
+
return { exitCode };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Node.js fallback - use shell on Windows to execute .cmd wrappers
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
const proc = spawn(command, args, {
|
|
224
|
+
cwd: workDir,
|
|
225
|
+
env: { ...process.env, ...env },
|
|
226
|
+
stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"],
|
|
227
|
+
shell: isWindows, // Required on Windows for npm global commands (.cmd wrappers)
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Write stdin content if provided
|
|
231
|
+
if (stdinContent && proc.stdin) {
|
|
232
|
+
proc.stdin.write(stdinContent);
|
|
233
|
+
proc.stdin.end();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let stdoutBuffer = "";
|
|
237
|
+
let stderrBuffer = "";
|
|
238
|
+
|
|
239
|
+
const processBuffer = (buffer: string, isStderr = false) => {
|
|
240
|
+
const lines = buffer.split("\n");
|
|
241
|
+
const remaining = lines.pop() || "";
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
if (line.trim()) onLine(line);
|
|
244
|
+
}
|
|
245
|
+
return remaining;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
proc.stdout?.on("data", (data) => {
|
|
249
|
+
stdoutBuffer += data.toString();
|
|
250
|
+
stdoutBuffer = processBuffer(stdoutBuffer);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
proc.stderr?.on("data", (data) => {
|
|
254
|
+
stderrBuffer += data.toString();
|
|
255
|
+
stderrBuffer = processBuffer(stderrBuffer, true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
proc.on("close", (exitCode) => {
|
|
259
|
+
// Process any remaining data
|
|
260
|
+
if (stdoutBuffer.trim()) onLine(stdoutBuffer);
|
|
261
|
+
if (stderrBuffer.trim()) onLine(stderrBuffer);
|
|
262
|
+
resolve({ exitCode: exitCode ?? 1 });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
proc.on("error", (err) => {
|
|
266
|
+
// Maintain backward compatibility - don't reject, report error via onLine
|
|
267
|
+
onLine(`Spawn error: ${err.message}`);
|
|
268
|
+
resolve({ exitCode: 1 });
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if a file path looks like a test file
|
|
275
|
+
*/
|
|
276
|
+
function isTestFile(filePath: string): boolean {
|
|
277
|
+
const lower = filePath.toLowerCase();
|
|
278
|
+
return (
|
|
279
|
+
lower.includes(".test.") ||
|
|
280
|
+
lower.includes(".spec.") ||
|
|
281
|
+
lower.includes("__tests__") ||
|
|
282
|
+
lower.includes("_test.go")
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Detect the current step from a JSON output line
|
|
288
|
+
* Returns step name like "Reading code", "Implementing", etc.
|
|
289
|
+
*/
|
|
290
|
+
export function detectStepFromOutput(line: string): string | null {
|
|
291
|
+
// Fast path: skip non-JSON lines
|
|
292
|
+
const trimmed = line.trim();
|
|
293
|
+
if (!trimmed.startsWith("{")) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(trimmed);
|
|
299
|
+
|
|
300
|
+
// Normalize the object structure to a common shape
|
|
301
|
+
const root = parsed.part || parsed.item || parsed;
|
|
302
|
+
|
|
303
|
+
const toolName =
|
|
304
|
+
root.tool?.toLowerCase() ||
|
|
305
|
+
root.name?.toLowerCase() ||
|
|
306
|
+
root.tool_name?.toLowerCase() ||
|
|
307
|
+
parsed.tool?.toLowerCase() || // Fallback to root
|
|
308
|
+
"";
|
|
309
|
+
|
|
310
|
+
const command = (
|
|
311
|
+
root.command ||
|
|
312
|
+
root.input?.command ||
|
|
313
|
+
root.state?.input?.command ||
|
|
314
|
+
parsed.command
|
|
315
|
+
)?.toLowerCase() || "";
|
|
316
|
+
|
|
317
|
+
const filePath = (
|
|
318
|
+
root.file_path ||
|
|
319
|
+
root.filePath ||
|
|
320
|
+
root.path ||
|
|
321
|
+
root.files?.[0] || // Handle array
|
|
322
|
+
root.paths?.[0] || // Handle array
|
|
323
|
+
parsed.file_path ||
|
|
324
|
+
""
|
|
325
|
+
).toLowerCase();
|
|
326
|
+
|
|
327
|
+
const description = (
|
|
328
|
+
root.description ||
|
|
329
|
+
root.metadata?.description ||
|
|
330
|
+
root.state?.title ||
|
|
331
|
+
parsed.description ||
|
|
332
|
+
""
|
|
333
|
+
).toLowerCase();
|
|
334
|
+
|
|
335
|
+
const title = (root.title || root.metadata?.title || "").toLowerCase();
|
|
336
|
+
const combined = `${title} ${description} ${command}`.toLowerCase();
|
|
337
|
+
|
|
338
|
+
// Check tool name first to determine operation type
|
|
339
|
+
const isReadOperation =
|
|
340
|
+
toolName.includes("read") ||
|
|
341
|
+
toolName.includes("glob") ||
|
|
342
|
+
toolName.includes("grep") ||
|
|
343
|
+
toolName.includes("search") ||
|
|
344
|
+
toolName.includes("list");
|
|
345
|
+
|
|
346
|
+
const isWriteOperation =
|
|
347
|
+
toolName.includes("write") ||
|
|
348
|
+
toolName.includes("edit") ||
|
|
349
|
+
toolName.includes("patch") ||
|
|
350
|
+
toolName.includes("file");
|
|
351
|
+
|
|
352
|
+
// Reading code
|
|
353
|
+
if (isReadOperation) {
|
|
354
|
+
return "Reading code";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Git commit
|
|
358
|
+
if (combined.includes("git commit")) {
|
|
359
|
+
return "Committing";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Git add/staging
|
|
363
|
+
if (combined.includes("git add")) {
|
|
364
|
+
return "Staging";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Linting
|
|
368
|
+
if (
|
|
369
|
+
combined.includes("lint") ||
|
|
370
|
+
combined.includes("eslint") ||
|
|
371
|
+
combined.includes("biome") ||
|
|
372
|
+
combined.includes("prettier")
|
|
373
|
+
) {
|
|
374
|
+
return "Linting";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Testing
|
|
378
|
+
if (
|
|
379
|
+
combined.includes("vitest") ||
|
|
380
|
+
combined.includes("jest") ||
|
|
381
|
+
combined.includes("bun test") ||
|
|
382
|
+
combined.includes("npm test") ||
|
|
383
|
+
combined.includes("pytest") ||
|
|
384
|
+
combined.includes("go test")
|
|
385
|
+
) {
|
|
386
|
+
return "Testing";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Writing tests
|
|
390
|
+
if (isWriteOperation && isTestFile(filePath)) {
|
|
391
|
+
return "Writing tests";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Writing/Editing code
|
|
395
|
+
if (isWriteOperation) {
|
|
396
|
+
return "Implementing";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Generic command fallback
|
|
400
|
+
if (toolName === "bash" || toolName === "exec" || toolName.includes("command")) {
|
|
401
|
+
if (READ_COMMAND_HINTS.some(h => command.startsWith(h) || command.includes(" " + h))) {
|
|
402
|
+
return "Reading code";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return null;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const READ_COMMAND_HINTS = [
|
|
413
|
+
"rg", "ripgrep", "grep", "sed", "cat", "ls", "find", "fd", "tree", "head", "tail"
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Base implementation for AI providers
|
|
418
|
+
*/
|
|
419
|
+
export abstract class BaseProvider implements AIProvider {
|
|
420
|
+
abstract name: string;
|
|
421
|
+
abstract cliCommand: string;
|
|
422
|
+
|
|
423
|
+
async isAvailable(): Promise<boolean> {
|
|
424
|
+
return commandExists(this.cliCommand);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
abstract execute(prompt: string, workDir: string, options?: ProviderOptions): Promise<AIResult>;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Execute with streaming progress updates (optional implementation)
|
|
431
|
+
*/
|
|
432
|
+
executeStreaming?(
|
|
433
|
+
prompt: string,
|
|
434
|
+
workDir: string,
|
|
435
|
+
onProgress: (step: string, content?: string) => void,
|
|
436
|
+
options?: ProviderOptions,
|
|
437
|
+
): Promise<AIResult>;
|
|
438
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test, describe, mock } from "bun:test";
|
|
2
|
+
import { CodexProvider } from "./codex.js";
|
|
3
|
+
|
|
4
|
+
let shouldConfigSucceed = true;
|
|
5
|
+
|
|
6
|
+
mock.module("node:fs/promises", () => ({
|
|
7
|
+
readFile: async (path: string) => {
|
|
8
|
+
if (path.includes("/.codex/config.toml") && shouldConfigSucceed) {
|
|
9
|
+
return 'model = "gpt-5.2"';
|
|
10
|
+
}
|
|
11
|
+
throw new Error("File not found");
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
mock.module("node:os", () => ({
|
|
16
|
+
homedir: () => "/home/testuser",
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe("CodexProvider", () => {
|
|
20
|
+
test("should have correct name and cliCommand", () => {
|
|
21
|
+
const provider = new CodexProvider();
|
|
22
|
+
expect(provider.name).toBe("Codex");
|
|
23
|
+
expect(provider.cliCommand).toBe("codex");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("getModelName should return model from config", async () => {
|
|
27
|
+
shouldConfigSucceed = true;
|
|
28
|
+
const provider = new CodexProvider();
|
|
29
|
+
const modelName = await provider.getModelName();
|
|
30
|
+
expect(modelName).toBe("gpt-5.2");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("getModelName should return undefined if config missing", async () => {
|
|
34
|
+
shouldConfigSucceed = false;
|
|
35
|
+
const provider = new CodexProvider();
|
|
36
|
+
const modelName = await provider.getModelName();
|
|
37
|
+
expect(modelName).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseProvider,
|
|
3
|
+
checkForErrors,
|
|
4
|
+
detectStepFromOutput,
|
|
5
|
+
execCommandStreaming,
|
|
6
|
+
} from "./base.js";
|
|
7
|
+
import type { AIResult, ProviderOptions } from "./types.js";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
|
|
12
|
+
interface CodexUsage {
|
|
13
|
+
input_tokens?: number;
|
|
14
|
+
output_tokens?: number;
|
|
15
|
+
cached_input_tokens?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CodexItem {
|
|
19
|
+
id?: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
status?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
command?: string;
|
|
24
|
+
path?: string;
|
|
25
|
+
paths?: string[];
|
|
26
|
+
files?: string[];
|
|
27
|
+
changes?: Array<{
|
|
28
|
+
path?: string;
|
|
29
|
+
}>;
|
|
30
|
+
tool_name?: string;
|
|
31
|
+
input?: {
|
|
32
|
+
command?: string;
|
|
33
|
+
};
|
|
34
|
+
metadata?: {
|
|
35
|
+
description?: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CodexEvent {
|
|
41
|
+
type: string;
|
|
42
|
+
thread_id?: string;
|
|
43
|
+
usage?: CodexUsage;
|
|
44
|
+
item?: CodexItem;
|
|
45
|
+
error?: {
|
|
46
|
+
message?: string;
|
|
47
|
+
data?: {
|
|
48
|
+
message?: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
message?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
function extractErrorMessage(event: CodexEvent): string | undefined {
|
|
57
|
+
return (
|
|
58
|
+
event.error?.message ||
|
|
59
|
+
event.error?.data?.message ||
|
|
60
|
+
event.message ||
|
|
61
|
+
undefined
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class CodexProvider extends BaseProvider {
|
|
66
|
+
name = "Codex";
|
|
67
|
+
cliCommand = "codex";
|
|
68
|
+
|
|
69
|
+
async getModelName(): Promise<string | undefined> {
|
|
70
|
+
try {
|
|
71
|
+
const configPath = join(homedir(), ".codex/config.toml");
|
|
72
|
+
const content = await readFile(configPath, "utf-8");
|
|
73
|
+
const match = content.match(/^model\s*=\s*["']?([^"'\n]+)["']?\s*$/m);
|
|
74
|
+
if (match) {
|
|
75
|
+
return match[1].trim();
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Config not found or unreadable
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async execute(
|
|
85
|
+
prompt: string,
|
|
86
|
+
workDir: string,
|
|
87
|
+
options?: ProviderOptions,
|
|
88
|
+
): Promise<AIResult> {
|
|
89
|
+
return this.executeStreaming(prompt, workDir, () => {}, options);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async executeStreaming(
|
|
93
|
+
prompt: string,
|
|
94
|
+
workDir: string,
|
|
95
|
+
onProgress: (step: string, content?: string) => void,
|
|
96
|
+
options?: ProviderOptions,
|
|
97
|
+
): Promise<AIResult> {
|
|
98
|
+
const codexArgs: string[] = ["exec"];
|
|
99
|
+
|
|
100
|
+
if (options?.resumeSessionId) {
|
|
101
|
+
codexArgs.push("resume", options.resumeSessionId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
codexArgs.push("--json");
|
|
105
|
+
|
|
106
|
+
if (options?.modelOverride) {
|
|
107
|
+
codexArgs.push("--model", options.modelOverride);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (options?.providerArgs) {
|
|
111
|
+
codexArgs.push(...options.providerArgs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Codex CLI currently rejects --add-dir; ignore extraIncludes for this provider to avoid errors
|
|
115
|
+
|
|
116
|
+
codexArgs.push("-");
|
|
117
|
+
|
|
118
|
+
const outputLines: string[] = [];
|
|
119
|
+
let sessionId: string | undefined;
|
|
120
|
+
let accumulatedResponse = "";
|
|
121
|
+
let inputTokens = 0;
|
|
122
|
+
let outputTokens = 0;
|
|
123
|
+
let error: string | undefined;
|
|
124
|
+
|
|
125
|
+
const { exitCode } = await execCommandStreaming(
|
|
126
|
+
this.cliCommand,
|
|
127
|
+
codexArgs,
|
|
128
|
+
workDir,
|
|
129
|
+
(line) => {
|
|
130
|
+
outputLines.push(line);
|
|
131
|
+
try {
|
|
132
|
+
const event: CodexEvent = JSON.parse(line);
|
|
133
|
+
|
|
134
|
+
if (event.type === "thread.started" && event.thread_id) {
|
|
135
|
+
sessionId = event.thread_id;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (event.item) {
|
|
139
|
+
const itemType = event.item.type?.toLowerCase() || "";
|
|
140
|
+
const normalizedItemType = itemType.replace(/[^a-z0-9]/g, "");
|
|
141
|
+
if (
|
|
142
|
+
itemType === "agent_message" ||
|
|
143
|
+
normalizedItemType === "agentmessage"
|
|
144
|
+
) {
|
|
145
|
+
if (event.item.text) {
|
|
146
|
+
accumulatedResponse += event.item.text;
|
|
147
|
+
onProgress("thinking", event.item.text);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const step = detectStepFromOutput(line);
|
|
152
|
+
if (step) {
|
|
153
|
+
onProgress(step);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.usage) {
|
|
158
|
+
if (event.usage.input_tokens !== undefined) {
|
|
159
|
+
inputTokens = event.usage.input_tokens;
|
|
160
|
+
}
|
|
161
|
+
if (event.usage.output_tokens !== undefined) {
|
|
162
|
+
outputTokens = event.usage.output_tokens;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (event.type.endsWith(".failed") || event.type === "error") {
|
|
167
|
+
error = error || extractErrorMessage(event) || "Unknown error";
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Ignore JSON parsing errors
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
undefined,
|
|
174
|
+
prompt,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const fullOutput = outputLines.join("\n");
|
|
178
|
+
const parsedError = checkForErrors(fullOutput);
|
|
179
|
+
if (parsedError) {
|
|
180
|
+
error = parsedError;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (exitCode !== 0 && !error) {
|
|
184
|
+
const rawLines = outputLines.filter((l) => !l.trim().startsWith("{"));
|
|
185
|
+
error = rawLines.join("\n") || `Unknown execution error (exit code ${exitCode})`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (error) {
|
|
189
|
+
const compactError = error.replace(/\s+/g, " ").trim();
|
|
190
|
+
return {
|
|
191
|
+
success: false,
|
|
192
|
+
response: accumulatedResponse,
|
|
193
|
+
inputTokens,
|
|
194
|
+
outputTokens,
|
|
195
|
+
error: compactError,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
success: exitCode === 0 && !error,
|
|
201
|
+
response: accumulatedResponse || "Task completed",
|
|
202
|
+
inputTokens,
|
|
203
|
+
outputTokens,
|
|
204
|
+
error: exitCode !== 0 ? `Process exited with code ${exitCode}` : undefined,
|
|
205
|
+
sessionId,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|