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,40 @@
|
|
|
1
|
+
import { expect, test, describe, mock } from "bun:test";
|
|
2
|
+
import { GeminiProvider } from "./gemini.js";
|
|
3
|
+
|
|
4
|
+
// Global mock for fs/promises
|
|
5
|
+
mock.module("node:fs/promises", () => ({
|
|
6
|
+
readFile: async (path: string) => {
|
|
7
|
+
if (path.includes("/existent/.gemini/settings.json")) {
|
|
8
|
+
return JSON.stringify({
|
|
9
|
+
model: {
|
|
10
|
+
name: "gemini-3-flash-preview"
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
throw new Error("File not found");
|
|
15
|
+
},
|
|
16
|
+
writeFile: async () => {},
|
|
17
|
+
unlink: async () => {}
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mocking os
|
|
21
|
+
let mockHome = "/existent";
|
|
22
|
+
mock.module("node:os", () => ({
|
|
23
|
+
homedir: () => mockHome
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("GeminiProvider", () => {
|
|
27
|
+
test("getModelName should return model name from settings", async () => {
|
|
28
|
+
mockHome = "/existent";
|
|
29
|
+
const provider = new GeminiProvider();
|
|
30
|
+
const modelName = await provider.getModelName();
|
|
31
|
+
expect(modelName).toBe("gemini-3-flash-preview");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("getModelName should return undefined if file reading fails", async () => {
|
|
35
|
+
mockHome = "/non-existent";
|
|
36
|
+
const provider = new GeminiProvider();
|
|
37
|
+
const modelName = await provider.getModelName();
|
|
38
|
+
expect(modelName).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseProvider,
|
|
3
|
+
checkForErrors,
|
|
4
|
+
execCommand,
|
|
5
|
+
execCommandStreaming,
|
|
6
|
+
parseStreamJsonResult,
|
|
7
|
+
detectStepFromOutput,
|
|
8
|
+
} from "./base.js";
|
|
9
|
+
import type { AIResult, ProviderOptions } from "./types.js";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { writeFile, unlink, readFile } from "node:fs/promises";
|
|
13
|
+
|
|
14
|
+
export class GeminiProvider extends BaseProvider {
|
|
15
|
+
name = "Gemini CLI";
|
|
16
|
+
cliCommand = "gemini";
|
|
17
|
+
|
|
18
|
+
async getModelName(): Promise<string | undefined> {
|
|
19
|
+
try {
|
|
20
|
+
const settingsPath = join(homedir(), ".gemini/settings.json");
|
|
21
|
+
const content = await readFile(settingsPath, "utf-8");
|
|
22
|
+
const settings = JSON.parse(content);
|
|
23
|
+
return settings.model?.name;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async execute(
|
|
30
|
+
prompt: string,
|
|
31
|
+
workDir: string,
|
|
32
|
+
options?: ProviderOptions,
|
|
33
|
+
): Promise<AIResult> {
|
|
34
|
+
return this.executeStreaming(prompt, workDir, () => {}, options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async executeStreaming(
|
|
38
|
+
prompt: string,
|
|
39
|
+
workDir: string,
|
|
40
|
+
onProgress: (step: string, content?: string) => void,
|
|
41
|
+
options?: ProviderOptions,
|
|
42
|
+
): Promise<AIResult> {
|
|
43
|
+
// Disable the pickle-rick extension to prevent recursion or interference
|
|
44
|
+
try {
|
|
45
|
+
await execCommand(this.cliCommand, ["extensions", "disable", "pickle-rick"], workDir);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Silently ignore if it fails (might not be installed)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const extensionPath = join(homedir(), ".gemini/extensions/pickle-rick");
|
|
51
|
+
const promptFile = join(workDir, ".gemini-prompt.txt");
|
|
52
|
+
await writeFile(promptFile, prompt, "utf-8");
|
|
53
|
+
|
|
54
|
+
const geminiArgs = [
|
|
55
|
+
"-s",
|
|
56
|
+
"-y",
|
|
57
|
+
"-o",
|
|
58
|
+
"stream-json",
|
|
59
|
+
"--include-directories",
|
|
60
|
+
extensionPath,
|
|
61
|
+
"--include-directories",
|
|
62
|
+
join(extensionPath, "sessions"),
|
|
63
|
+
"--include-directories",
|
|
64
|
+
join(extensionPath, "jar"),
|
|
65
|
+
"--include-directories",
|
|
66
|
+
join(extensionPath, "worktrees"),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
if (options?.resumeSessionId) {
|
|
70
|
+
geminiArgs.push("-r", options.resumeSessionId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (options?.providerArgs) {
|
|
74
|
+
geminiArgs.push(...options.providerArgs);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (options?.extraIncludes) {
|
|
78
|
+
for (const include of options.extraIncludes) {
|
|
79
|
+
geminiArgs.push("--include-directories", include);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const command = "sh";
|
|
84
|
+
const shellArgs = [
|
|
85
|
+
"-c",
|
|
86
|
+
`cat "${promptFile}" | ${this.cliCommand} ${geminiArgs.join(" ")}`,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const outputLines: string[] = [];
|
|
90
|
+
let sessionId: string | undefined;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const { exitCode } = await execCommandStreaming(
|
|
94
|
+
command,
|
|
95
|
+
shellArgs,
|
|
96
|
+
workDir,
|
|
97
|
+
(line) => {
|
|
98
|
+
outputLines.push(line);
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(line);
|
|
101
|
+
|
|
102
|
+
if (parsed.type === "init" && parsed.session_id) {
|
|
103
|
+
sessionId = parsed.session_id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
parsed.type === "message" &&
|
|
108
|
+
parsed.role === "assistant" &&
|
|
109
|
+
parsed.content
|
|
110
|
+
) {
|
|
111
|
+
onProgress("thinking", parsed.content);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const step = detectStepFromOutput(line);
|
|
115
|
+
if (step) {
|
|
116
|
+
onProgress(step);
|
|
117
|
+
}
|
|
118
|
+
} catch {} // Ignore JSON parsing errors
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const fullOutput = outputLines.join("\n");
|
|
123
|
+
|
|
124
|
+
let error = checkForErrors(fullOutput);
|
|
125
|
+
|
|
126
|
+
// If exit code is bad but no JSON error found, use the raw output (likely stderr)
|
|
127
|
+
if (exitCode !== 0 && !error) {
|
|
128
|
+
// Filter out likely non-error lines (json) to find the error message
|
|
129
|
+
const rawLines = outputLines.filter((l) => !l.trim().startsWith("{"));
|
|
130
|
+
error =
|
|
131
|
+
rawLines.join("\n") ||
|
|
132
|
+
"Unknown execution error (exit code " + exitCode + ")";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (error) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
response: "",
|
|
139
|
+
inputTokens: 0,
|
|
140
|
+
outputTokens: 0,
|
|
141
|
+
error,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { response, inputTokens, outputTokens } =
|
|
146
|
+
parseStreamJsonResult(fullOutput);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
success: exitCode === 0 && !error,
|
|
150
|
+
response,
|
|
151
|
+
inputTokens,
|
|
152
|
+
outputTokens,
|
|
153
|
+
error:
|
|
154
|
+
error ||
|
|
155
|
+
(exitCode !== 0 ? "Process exited with code " + exitCode : undefined),
|
|
156
|
+
sessionId,
|
|
157
|
+
};
|
|
158
|
+
} finally {
|
|
159
|
+
try {
|
|
160
|
+
await unlink(promptFile);
|
|
161
|
+
} catch (e) {} // Ignore errors during cleanup
|
|
162
|
+
|
|
163
|
+
// Re-enable the extension
|
|
164
|
+
try {
|
|
165
|
+
await execCommand(this.cliCommand, ["extensions", "enable", "pickle-rick"], workDir);
|
|
166
|
+
} catch (e) {} // Ignore errors during cleanup
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test, describe, mock } from "bun:test";
|
|
2
|
+
import { createProvider, getConfiguredModel } from "./index.js";
|
|
3
|
+
import { GeminiProvider } from "./gemini.js";
|
|
4
|
+
import { CodexProvider } from "./codex.js";
|
|
5
|
+
import { OpencodeProvider } from "./opencode.js";
|
|
6
|
+
|
|
7
|
+
mock.module("../config/settings.js", () => ({
|
|
8
|
+
getConfiguredProvider: async () => "gemini",
|
|
9
|
+
loadSettings: async () => ({
|
|
10
|
+
model: {
|
|
11
|
+
provider: "gemini",
|
|
12
|
+
model: "gemini-3-flash"
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe("Providers Index", () => {
|
|
18
|
+
test("createProvider should return GeminiProvider for 'gemini' setting", async () => {
|
|
19
|
+
const provider = await createProvider();
|
|
20
|
+
expect(provider).toBeInstanceOf(GeminiProvider);
|
|
21
|
+
expect(provider.name).toBe("Gemini CLI");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("getConfiguredModel should return model from settings", async () => {
|
|
25
|
+
const model = await getConfiguredModel();
|
|
26
|
+
expect(model).toBe("gemini-3-flash");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./base.js";
|
|
3
|
+
export * from "./codex.js";
|
|
4
|
+
export * from "./gemini.js";
|
|
5
|
+
export * from "./opencode.js";
|
|
6
|
+
|
|
7
|
+
import { CodexProvider } from "./codex.js";
|
|
8
|
+
import { GeminiProvider } from "./gemini.js";
|
|
9
|
+
import { OpencodeProvider } from "./opencode.js";
|
|
10
|
+
import type { AIProvider, AIProviderName } from "./types.js";
|
|
11
|
+
import { loadSettings, getConfiguredProvider } from "../config/settings.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a provider instance based on settings or default to Gemini
|
|
15
|
+
*/
|
|
16
|
+
export async function createProvider(): Promise<AIProvider> {
|
|
17
|
+
const configuredProvider = await getConfiguredProvider();
|
|
18
|
+
|
|
19
|
+
switch (configuredProvider) {
|
|
20
|
+
case "codex":
|
|
21
|
+
return new CodexProvider();
|
|
22
|
+
case "opencode":
|
|
23
|
+
return new OpencodeProvider();
|
|
24
|
+
case "gemini":
|
|
25
|
+
default:
|
|
26
|
+
return new GeminiProvider();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get the currently configured provider name
|
|
32
|
+
*/
|
|
33
|
+
export { getConfiguredProvider };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the currently configured model name from settings
|
|
37
|
+
*/
|
|
38
|
+
export async function getConfiguredModel(): Promise<string | undefined> {
|
|
39
|
+
const settings = await loadSettings();
|
|
40
|
+
return settings.model?.model;
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, test, describe, mock } from "bun:test";
|
|
2
|
+
import { OpencodeProvider } from "./opencode.js";
|
|
3
|
+
|
|
4
|
+
// Track which paths should succeed
|
|
5
|
+
let shouldJsonSucceed = true;
|
|
6
|
+
let shouldYamlSucceed = false;
|
|
7
|
+
|
|
8
|
+
// Global mock for fs/promises
|
|
9
|
+
mock.module("node:fs/promises", () => ({
|
|
10
|
+
readFile: async (path: string) => {
|
|
11
|
+
if (path.includes("/.config/opencode/config.json") && shouldJsonSucceed) {
|
|
12
|
+
return JSON.stringify({
|
|
13
|
+
model: "anthropic/claude-sonnet-4-5"
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
if (path.includes("/.opencode/config.yaml") && shouldYamlSucceed) {
|
|
17
|
+
return `model: openai/gpt-4\nother: value`;
|
|
18
|
+
}
|
|
19
|
+
throw new Error("File not found");
|
|
20
|
+
},
|
|
21
|
+
writeFile: async () => {},
|
|
22
|
+
unlink: async () => {}
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
mock.module("node:os", () => ({
|
|
26
|
+
homedir: () => "/home/testuser"
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe("OpencodeProvider", () => {
|
|
30
|
+
test("should have correct name and cliCommand", () => {
|
|
31
|
+
const provider = new OpencodeProvider();
|
|
32
|
+
expect(provider.name).toBe("OpenCode");
|
|
33
|
+
expect(provider.cliCommand).toBe("opencode");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("getModelName should return model from JSON config", async () => {
|
|
37
|
+
shouldJsonSucceed = true;
|
|
38
|
+
shouldYamlSucceed = false;
|
|
39
|
+
const provider = new OpencodeProvider();
|
|
40
|
+
const modelName = await provider.getModelName();
|
|
41
|
+
expect(modelName).toBe("anthropic/claude-sonnet-4-5");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("getModelName should return model from YAML config if JSON not found", async () => {
|
|
45
|
+
shouldJsonSucceed = false;
|
|
46
|
+
shouldYamlSucceed = true;
|
|
47
|
+
const provider = new OpencodeProvider();
|
|
48
|
+
const modelName = await provider.getModelName();
|
|
49
|
+
expect(modelName).toBe("openai/gpt-4");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("getModelName should return undefined if no config found", async () => {
|
|
53
|
+
shouldJsonSucceed = false;
|
|
54
|
+
shouldYamlSucceed = false;
|
|
55
|
+
const provider = new OpencodeProvider();
|
|
56
|
+
const modelName = await provider.getModelName();
|
|
57
|
+
expect(modelName).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("isAvailable should check if opencode command exists", async () => {
|
|
61
|
+
const provider = new OpencodeProvider();
|
|
62
|
+
expect(typeof provider.isAvailable).toBe("function");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseProvider,
|
|
3
|
+
checkForErrors,
|
|
4
|
+
detectStepFromOutput,
|
|
5
|
+
execCommand,
|
|
6
|
+
execCommandStreaming,
|
|
7
|
+
} from "./base.js";
|
|
8
|
+
import type { AIResult, ProviderOptions } from "./types.js";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { writeFile, unlink, readFile } from "node:fs/promises";
|
|
12
|
+
|
|
13
|
+
interface OpencodeEvent {
|
|
14
|
+
type: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
sessionID?: string;
|
|
17
|
+
part?: {
|
|
18
|
+
id?: string;
|
|
19
|
+
sessionID?: string;
|
|
20
|
+
messageID?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
tool?: string;
|
|
24
|
+
callID?: string;
|
|
25
|
+
state?: {
|
|
26
|
+
status?: string;
|
|
27
|
+
input?: Record<string, unknown>;
|
|
28
|
+
output?: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
31
|
+
time?: {
|
|
32
|
+
start?: number;
|
|
33
|
+
end?: number;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
reason?: string;
|
|
37
|
+
snapshot?: string;
|
|
38
|
+
cost?: number;
|
|
39
|
+
tokens?: {
|
|
40
|
+
input?: number;
|
|
41
|
+
output?: number;
|
|
42
|
+
reasoning?: number;
|
|
43
|
+
cache?: {
|
|
44
|
+
read?: number;
|
|
45
|
+
write?: number;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
error?: {
|
|
50
|
+
name?: string;
|
|
51
|
+
data?: {
|
|
52
|
+
message?: string;
|
|
53
|
+
statusCode?: number;
|
|
54
|
+
isRetryable?: boolean;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export class OpencodeProvider extends BaseProvider {
|
|
62
|
+
name = "OpenCode";
|
|
63
|
+
cliCommand = "opencode";
|
|
64
|
+
|
|
65
|
+
async getModelName(): Promise<string | undefined> {
|
|
66
|
+
// Try JSON config first
|
|
67
|
+
try {
|
|
68
|
+
const configPath = join(homedir(), ".config/opencode/config.json");
|
|
69
|
+
const content = await readFile(configPath, "utf-8");
|
|
70
|
+
const config = JSON.parse(content);
|
|
71
|
+
if (config.model) {
|
|
72
|
+
return config.model;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall through to try YAML config
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Try YAML config
|
|
79
|
+
try {
|
|
80
|
+
const yamlConfigPath = join(homedir(), ".opencode/config.yaml");
|
|
81
|
+
const content = await readFile(yamlConfigPath, "utf-8");
|
|
82
|
+
// Simple YAML parsing - look for model: line
|
|
83
|
+
const match = content.match(/^model:\s*(.+)$/m);
|
|
84
|
+
if (match) {
|
|
85
|
+
return match[1].trim();
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Config not found or unreadable
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async execute(
|
|
95
|
+
prompt: string,
|
|
96
|
+
workDir: string,
|
|
97
|
+
options?: ProviderOptions,
|
|
98
|
+
): Promise<AIResult> {
|
|
99
|
+
return this.executeStreaming(prompt, workDir, () => {}, options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async executeStreaming(
|
|
103
|
+
prompt: string,
|
|
104
|
+
workDir: string,
|
|
105
|
+
onProgress: (step: string, content?: string) => void,
|
|
106
|
+
options?: ProviderOptions,
|
|
107
|
+
): Promise<AIResult> {
|
|
108
|
+
const promptFile = join(workDir, ".opencode-prompt.txt");
|
|
109
|
+
await writeFile(promptFile, prompt, "utf-8");
|
|
110
|
+
|
|
111
|
+
const opencodeArgs = ["run", "--format", "json"];
|
|
112
|
+
|
|
113
|
+
if (options?.resumeSessionId) {
|
|
114
|
+
opencodeArgs.push("-s", options.resumeSessionId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options?.providerArgs) {
|
|
118
|
+
opencodeArgs.push(...options.providerArgs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Read prompt from stdin
|
|
122
|
+
const command = "sh";
|
|
123
|
+
const shellArgs = [
|
|
124
|
+
"-c",
|
|
125
|
+
`cat "${promptFile}" | ${this.cliCommand} ${opencodeArgs.join(" ")}`,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const outputLines: string[] = [];
|
|
129
|
+
let sessionId: string | undefined;
|
|
130
|
+
let accumulatedResponse = "";
|
|
131
|
+
let inputTokens = 0;
|
|
132
|
+
let outputTokens = 0;
|
|
133
|
+
let error: string | undefined;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const { exitCode } = await execCommandStreaming(
|
|
137
|
+
command,
|
|
138
|
+
shellArgs,
|
|
139
|
+
workDir,
|
|
140
|
+
(line) => {
|
|
141
|
+
outputLines.push(line);
|
|
142
|
+
try {
|
|
143
|
+
const event: OpencodeEvent = JSON.parse(line);
|
|
144
|
+
|
|
145
|
+
// Extract session ID from step_start
|
|
146
|
+
if (event.type === "step_start" && event.sessionID) {
|
|
147
|
+
sessionId = event.sessionID;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Accumulate text response
|
|
151
|
+
if (event.type === "text" && event.part?.text) {
|
|
152
|
+
accumulatedResponse += event.part.text;
|
|
153
|
+
onProgress("thinking", event.part.text);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Detect step from tool_use
|
|
157
|
+
const step = detectStepFromOutput(line);
|
|
158
|
+
if (step) {
|
|
159
|
+
onProgress(step);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Extract tokens and cost from step_finish
|
|
163
|
+
if (event.type === "step_finish" && event.part) {
|
|
164
|
+
if (event.part.tokens?.input) {
|
|
165
|
+
inputTokens = event.part.tokens.input;
|
|
166
|
+
}
|
|
167
|
+
if (event.part.tokens?.output) {
|
|
168
|
+
outputTokens = event.part.tokens.output;
|
|
169
|
+
}
|
|
170
|
+
// Check for final completion
|
|
171
|
+
if (event.part.reason === "stop") {
|
|
172
|
+
// Final step
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check for errors
|
|
177
|
+
if (event.type === "error" && event.error?.data?.message) {
|
|
178
|
+
error = event.error.data.message;
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore JSON parsing errors
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Check for errors in raw output
|
|
187
|
+
const fullOutput = outputLines.join("\n");
|
|
188
|
+
const parsedError = checkForErrors(fullOutput);
|
|
189
|
+
if (parsedError) {
|
|
190
|
+
error = parsedError;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If exit code is bad but no error found, use raw output
|
|
194
|
+
if (exitCode !== 0 && !error) {
|
|
195
|
+
const rawLines = outputLines.filter((l) => !l.trim().startsWith("{"));
|
|
196
|
+
error =
|
|
197
|
+
rawLines.join("\n") ||
|
|
198
|
+
`Unknown execution error (exit code ${exitCode})`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (error) {
|
|
202
|
+
return {
|
|
203
|
+
success: false,
|
|
204
|
+
response: accumulatedResponse,
|
|
205
|
+
inputTokens,
|
|
206
|
+
outputTokens,
|
|
207
|
+
error,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: exitCode === 0 && !error,
|
|
213
|
+
response: accumulatedResponse || "Task completed",
|
|
214
|
+
inputTokens,
|
|
215
|
+
outputTokens,
|
|
216
|
+
error: exitCode !== 0 ? `Process exited with code ${exitCode}` : undefined,
|
|
217
|
+
sessionId,
|
|
218
|
+
};
|
|
219
|
+
} finally {
|
|
220
|
+
// Cleanup
|
|
221
|
+
try {
|
|
222
|
+
await unlink(promptFile);
|
|
223
|
+
} catch {
|
|
224
|
+
// Ignore cleanup errors
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ... existing imports
|
|
2
|
+
export interface AIResult {
|
|
3
|
+
success: boolean;
|
|
4
|
+
response: string;
|
|
5
|
+
inputTokens: number;
|
|
6
|
+
outputTokens: number;
|
|
7
|
+
sessionId?: string; // Added field
|
|
8
|
+
cost?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProviderOptions {
|
|
13
|
+
modelOverride?: string;
|
|
14
|
+
resumeSessionId?: string; // Added field
|
|
15
|
+
providerArgs?: string[];
|
|
16
|
+
extraIncludes?: string[]; // Added: Force include directories (e.g. for Worktrees)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ... rest of file
|
|
20
|
+
export type ProgressCallback = (step: string, content?: string) => void;
|
|
21
|
+
|
|
22
|
+
export interface AIProvider {
|
|
23
|
+
name: string;
|
|
24
|
+
cliCommand: string;
|
|
25
|
+
isAvailable(): Promise<boolean>;
|
|
26
|
+
getModelName?(): Promise<string | undefined>;
|
|
27
|
+
execute(prompt: string, workDir: string, options?: ProviderOptions): Promise<AIResult>;
|
|
28
|
+
executeStreaming?(
|
|
29
|
+
prompt: string,
|
|
30
|
+
workDir: string,
|
|
31
|
+
onProgress: ProgressCallback,
|
|
32
|
+
options?: ProviderOptions,
|
|
33
|
+
): Promise<AIResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AIProviderName =
|
|
37
|
+
| "claude"
|
|
38
|
+
| "opencode"
|
|
39
|
+
| "cursor"
|
|
40
|
+
| "codex"
|
|
41
|
+
| "qwen"
|
|
42
|
+
| "droid"
|
|
43
|
+
| "copilot"
|
|
44
|
+
| "gemini";
|