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,262 @@
|
|
|
1
|
+
import { join, dirname } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { readFile, mkdir, appendFile } from "node:fs/promises";
|
|
5
|
+
import { getExtensionRoot, resolveResource, resolveSkillPath } from "../../utils/resources.js";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
export async function runWorker(
|
|
10
|
+
task: string,
|
|
11
|
+
ticketId: string,
|
|
12
|
+
ticketPath: string,
|
|
13
|
+
timeout: string,
|
|
14
|
+
skillName?: string
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const startEpoch = Date.now();
|
|
17
|
+
let timeoutMs = parseInt(timeout) * 1000;
|
|
18
|
+
|
|
19
|
+
// 1. Setup paths
|
|
20
|
+
// Normalize ticketPath to ensure we have the directory
|
|
21
|
+
let targetDir = ticketPath;
|
|
22
|
+
if (ticketPath.endsWith(".md")) {
|
|
23
|
+
targetDir = dirname(ticketPath);
|
|
24
|
+
} else if (existsSync(ticketPath) && existsSync(join(ticketPath, "..", "state.json"))) {
|
|
25
|
+
// If ticketPath is a dir but the state is in parent, it might be a sub-ticket.
|
|
26
|
+
// But usually ticketPath IS the ticket directory.
|
|
27
|
+
// Let's stick to the logic: targetDir is where we run.
|
|
28
|
+
targetDir = ticketPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Ensure absolute path if not already
|
|
32
|
+
if (!targetDir.startsWith("/")) {
|
|
33
|
+
targetDir = join(process.cwd(), targetDir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await mkdir(targetDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const sessionLog = join(targetDir, `worker_session_${process.pid}.log`);
|
|
39
|
+
const extensionRoot = getExtensionRoot();
|
|
40
|
+
|
|
41
|
+
// --- Timeout Clamping Logic (Ported from spawn_morty.py) ---
|
|
42
|
+
// Check parent dir (Manager state) first, then current dir (Worker state resume)
|
|
43
|
+
let timeoutStatePath: string | null = null;
|
|
44
|
+
const parentState = join(targetDir, "..", "state.json");
|
|
45
|
+
const workerState = join(targetDir, "state.json");
|
|
46
|
+
|
|
47
|
+
if (existsSync(parentState)) {
|
|
48
|
+
timeoutStatePath = parentState;
|
|
49
|
+
} else if (existsSync(workerState)) {
|
|
50
|
+
timeoutStatePath = workerState;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (timeoutStatePath) {
|
|
54
|
+
try {
|
|
55
|
+
const stateContent = await readFile(timeoutStatePath, "utf-8");
|
|
56
|
+
const state = JSON.parse(stateContent);
|
|
57
|
+
const maxMins = state.max_time_minutes || 0;
|
|
58
|
+
const startTime = state.start_time_epoch || 0;
|
|
59
|
+
|
|
60
|
+
if (maxMins > 0 && startTime > 0) {
|
|
61
|
+
// startTime is in seconds in python script usually, let's check input.
|
|
62
|
+
// In JS Date.now() is ms. Python time.time() is seconds.
|
|
63
|
+
// The state file usually stores seconds (from python).
|
|
64
|
+
// Let's assume seconds if it's small, ms if it's huge.
|
|
65
|
+
// Actually, let's look at `state.json` convention.
|
|
66
|
+
// Usually Python `time.time()` -> float seconds.
|
|
67
|
+
// JS `Date.now()` -> int ms.
|
|
68
|
+
|
|
69
|
+
// If it's < 10^11, it's seconds.
|
|
70
|
+
const nowSeconds = Date.now() / 1000;
|
|
71
|
+
let startSeconds = startTime;
|
|
72
|
+
if (startSeconds > 100000000000) { // It's MS
|
|
73
|
+
startSeconds = startSeconds / 1000;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const remaining = (maxMins * 60) - (nowSeconds - startSeconds);
|
|
77
|
+
if (remaining * 1000 < timeoutMs) {
|
|
78
|
+
const clamped = Math.max(10, Math.floor(remaining));
|
|
79
|
+
timeoutMs = clamped * 1000;
|
|
80
|
+
console.log(pc.yellow(`⚠️ Worker timeout clamped: ${clamped}s (Global Session Limit)`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Ignore state read errors
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Build Prompt
|
|
89
|
+
const tomlPath = resolveResource("commands/send-to-morty.toml");
|
|
90
|
+
let basePrompt = '# **TASK REQUEST**\n$ARGUMENTS\n\nYou are a Morty Worker. Implement the request above.';
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (existsSync(tomlPath)) {
|
|
94
|
+
const content = await readFile(tomlPath, "utf-8");
|
|
95
|
+
const match = content.match(/prompt = \"""([\s\S]*?)\"""/);
|
|
96
|
+
if (match) {
|
|
97
|
+
basePrompt = match[1].trim();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.warn(pc.yellow("⚠️ Failed to load prompt TOML, using fallback."));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let workerPrompt = basePrompt.replace("${extensionPath}", extensionRoot);
|
|
105
|
+
workerPrompt = workerPrompt.replace("$ARGUMENTS", task);
|
|
106
|
+
|
|
107
|
+
// Inject Skill if provided
|
|
108
|
+
if (skillName) {
|
|
109
|
+
const skillPath = resolveSkillPath(skillName);
|
|
110
|
+
console.log(pc.dim(`🔍 Resolving skill '${skillName}' -> '${skillPath}'`));
|
|
111
|
+
|
|
112
|
+
if (skillPath && existsSync(skillPath)) {
|
|
113
|
+
const skillContent = await readFile(skillPath, "utf-8");
|
|
114
|
+
console.log(pc.dim(`📄 Skill content loaded (${skillContent.length} chars)`));
|
|
115
|
+
|
|
116
|
+
if (skillContent.length > 0) {
|
|
117
|
+
workerPrompt += `\n\n<skill_injection>\n${skillContent}\n</skill_injection>`;
|
|
118
|
+
} else {
|
|
119
|
+
console.warn(pc.yellow(`⚠️ Skill file is empty: ${skillPath}`));
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
console.warn(pc.yellow(`⚠️ Skill '${skillName}' not found at ${skillPath}`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fallback prompt enforcement
|
|
127
|
+
if (workerPrompt.length < 200) {
|
|
128
|
+
workerPrompt += `\n\nTask: "${task}"\n1. Activate persona: activate_skill("load-pickle-persona").\n2. Output: <promise>I AM DONE</promise>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. Build Command
|
|
132
|
+
const includes = [
|
|
133
|
+
process.cwd(), // Current workspace
|
|
134
|
+
targetDir,
|
|
135
|
+
join(targetDir, ".."),
|
|
136
|
+
extensionRoot,
|
|
137
|
+
join(extensionRoot, "scripts"),
|
|
138
|
+
join(extensionRoot, "skills"),
|
|
139
|
+
join(extensionRoot, "sessions"),
|
|
140
|
+
join(extensionRoot, "jar"),
|
|
141
|
+
join(extensionRoot, "worktrees")
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// Filter unique and existing paths
|
|
145
|
+
const uniqueIncludes = [...new Set(includes)].filter(p => existsSync(p));
|
|
146
|
+
|
|
147
|
+
let args = ["-s", "-y", "-o", "text"];
|
|
148
|
+
for (const p of uniqueIncludes) {
|
|
149
|
+
args.push("--include-directories", p);
|
|
150
|
+
}
|
|
151
|
+
args.push("-p", workerPrompt);
|
|
152
|
+
|
|
153
|
+
let command = "gemini";
|
|
154
|
+
let cmdArgs = args;
|
|
155
|
+
|
|
156
|
+
// Check for Command Override (for testing or specialized environments)
|
|
157
|
+
if (process.env.PICKLE_WORKER_CMD_OVERRIDE) {
|
|
158
|
+
const parts = process.env.PICKLE_WORKER_CMD_OVERRIDE.split(" ");
|
|
159
|
+
command = parts[0];
|
|
160
|
+
// We assume the override includes necessary base args, but we might need to append prompt/includes
|
|
161
|
+
// Actually, spawn_morty.py REPLACES the cmd with the override + prompt.
|
|
162
|
+
// Let's assume the override replaces the 'gemini' part but keeps the args we built,
|
|
163
|
+
// OR replaces the whole thing.
|
|
164
|
+
// spawn_morty.py: cmd = shlex.split(os.environ["PICKLE_WORKER_CMD_OVERRIDE"])
|
|
165
|
+
// It ignores the built args if override is present?
|
|
166
|
+
// Wait, spawn_morty.py lines:
|
|
167
|
+
// cmd = ["gemini", ...]
|
|
168
|
+
// ... build cmd ...
|
|
169
|
+
// if "PICKLE_WORKER_CMD_OVERRIDE": cmd = shlex.split(...)
|
|
170
|
+
// So it COMPLETELY replaces it. That seems wrong if we want to pass the prompt.
|
|
171
|
+
// BUT, if the override is just the binary, we should prepend it.
|
|
172
|
+
// Let's assume the user knows what they are doing if they use the override.
|
|
173
|
+
// FOR NOW: Let's stick to standard behavior unless forced.
|
|
174
|
+
// If I want to match spawn_morty exactly:
|
|
175
|
+
if (process.env.PICKLE_WORKER_CMD_OVERRIDE) {
|
|
176
|
+
const override = process.env.PICKLE_WORKER_CMD_OVERRIDE.split(" ");
|
|
177
|
+
command = override[0];
|
|
178
|
+
cmdArgs = [...override.slice(1), ...args]; // Prepend flags from override, append ours?
|
|
179
|
+
// Actually, usually override is like "gemini-beta".
|
|
180
|
+
cmdArgs = [...override.slice(1), ...args];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(pc.cyan(`🥒 Spawning Morty Worker [Ticket: ${ticketId}]`));
|
|
185
|
+
console.log(pc.dim(` Log: ${sessionLog}`));
|
|
186
|
+
console.log(pc.dim(` Timeout: ${timeoutMs/1000}s`));
|
|
187
|
+
|
|
188
|
+
// 4. Spawn Process
|
|
189
|
+
const logStream = {
|
|
190
|
+
write: async (msg: string) => {
|
|
191
|
+
try {
|
|
192
|
+
await appendFile(sessionLog, msg);
|
|
193
|
+
} catch (e) {}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Header
|
|
198
|
+
await logStream.write(`CWD: ${process.cwd()}\nCommand: ${command} ${cmdArgs.join(" ")}\n${"-".repeat(80)}\n\n`);
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const child = spawn(command, cmdArgs, {
|
|
202
|
+
cwd: process.cwd(),
|
|
203
|
+
env: {
|
|
204
|
+
...process.env,
|
|
205
|
+
PYTHONUNBUFFERED: "1",
|
|
206
|
+
PICKLE_STATE_FILE: workerState // Pass state file location to worker
|
|
207
|
+
},
|
|
208
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
let outputBuffer = "";
|
|
212
|
+
|
|
213
|
+
child.stdout.on("data", async (data) => {
|
|
214
|
+
const str = data.toString();
|
|
215
|
+
outputBuffer += str;
|
|
216
|
+
await logStream.write(str);
|
|
217
|
+
process.stdout.write("."); // Spinner tick
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
child.stderr.on("data", async (data) => {
|
|
221
|
+
const str = data.toString();
|
|
222
|
+
outputBuffer += str;
|
|
223
|
+
await logStream.write(str);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Timeout Logic
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
child.kill();
|
|
229
|
+
const msg = `\n\n[TIMEOUT] Worker exceeded ${timeoutMs/1000}s limit.\n`;
|
|
230
|
+
logStream.write(msg).catch(() => {});
|
|
231
|
+
console.error(pc.red(msg));
|
|
232
|
+
reject(new Error("Timeout"));
|
|
233
|
+
}, timeoutMs);
|
|
234
|
+
|
|
235
|
+
child.on("close", (code) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
|
|
238
|
+
// Check for explicit success promise
|
|
239
|
+
const isSuccess = outputBuffer.includes("<promise>I AM DONE</promise>") || outputBuffer.includes("I AM DONE");
|
|
240
|
+
|
|
241
|
+
if (isSuccess) {
|
|
242
|
+
console.log(pc.green(`\n✅ Worker Succeeded (Exit: ${code})`));
|
|
243
|
+
resolve();
|
|
244
|
+
} else {
|
|
245
|
+
console.log(pc.red(`\n❌ Worker Failed (Exit: ${code}) - Check logs at ${sessionLog}`));
|
|
246
|
+
// We resolve, but set exitCode to 1 so the CLI fails gracefully after cleanup if needed
|
|
247
|
+
if (code !== 0) {
|
|
248
|
+
process.exitCode = code || 1;
|
|
249
|
+
} else {
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
}
|
|
252
|
+
resolve();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
child.on("error", (err) => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
logStream.write(`\n\n[ERROR] Spawn failed: ${err.message}\n`).catch(() => {});
|
|
259
|
+
reject(err);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test, describe, mock } from "bun:test";
|
|
2
|
+
import { loadSettings, saveSettings, getConfiguredProvider, getConfiguredModel, updateModelSettings } from "./settings.js";
|
|
3
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
// Mock fs/promises
|
|
6
|
+
mock.module("node:fs/promises", () => ({
|
|
7
|
+
readFile: async (path: string) => {
|
|
8
|
+
if (path.includes("/.pickle/settings.json")) {
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
model: {
|
|
11
|
+
provider: "gemini",
|
|
12
|
+
model: "gemini-3-flash"
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
throw new Error("File not found");
|
|
17
|
+
},
|
|
18
|
+
writeFile: async () => {},
|
|
19
|
+
mkdir: async () => {}
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module("node:os", () => ({
|
|
23
|
+
homedir: () => "/home/testuser"
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("Settings", () => {
|
|
27
|
+
test("loadSettings should parse settings.json", async () => {
|
|
28
|
+
const settings = await loadSettings();
|
|
29
|
+
expect(settings.model?.provider).toBe("gemini");
|
|
30
|
+
expect(settings.model?.model).toBe("gemini-3-flash");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("getConfiguredProvider should return provider from settings", async () => {
|
|
34
|
+
const provider = await getConfiguredProvider();
|
|
35
|
+
expect(provider).toBe("gemini");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("getConfiguredModel should return model from settings", async () => {
|
|
39
|
+
const model = await getConfiguredModel();
|
|
40
|
+
expect(model).toBe("gemini-3-flash");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { PickleSettings, PickleSettingsSchema } from "./types.js";
|
|
5
|
+
import type { AIProviderName } from "../providers/types.js";
|
|
6
|
+
|
|
7
|
+
const SETTINGS_DIR = join(homedir(), ".pickle");
|
|
8
|
+
const SETTINGS_PATH = join(SETTINGS_DIR, "settings.json");
|
|
9
|
+
|
|
10
|
+
// Valid provider names
|
|
11
|
+
const VALID_PROVIDERS = [
|
|
12
|
+
"gemini", "opencode", "claude", "cursor", "codex",
|
|
13
|
+
"qwen", "droid", "copilot"
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export interface ValidationResult {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
errors: string[];
|
|
19
|
+
warnings: string[];
|
|
20
|
+
fixed?: string; // Fixed JSON string if applicable
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Attempt to fix common JSON syntax errors
|
|
25
|
+
*/
|
|
26
|
+
function fixJsonSyntax(jsonString: string): string | null {
|
|
27
|
+
// Remove trailing commas before } or ]
|
|
28
|
+
let fixed = jsonString.replace(/,(\s*[}\]])/g, '$1');
|
|
29
|
+
|
|
30
|
+
// Try to parse the fixed JSON
|
|
31
|
+
try {
|
|
32
|
+
JSON.parse(fixed);
|
|
33
|
+
return fixed;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate settings file content
|
|
41
|
+
*/
|
|
42
|
+
export function validateSettings(content: string): ValidationResult {
|
|
43
|
+
const errors: string[] = [];
|
|
44
|
+
const warnings: string[] = [];
|
|
45
|
+
|
|
46
|
+
// Check if content is empty
|
|
47
|
+
if (!content || content.trim() === "") {
|
|
48
|
+
return { valid: false, errors: ["Settings file is empty"], warnings };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Try to parse JSON
|
|
52
|
+
let parsed: unknown;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(content);
|
|
55
|
+
} catch (parseError) {
|
|
56
|
+
// Try to fix common syntax errors
|
|
57
|
+
const fixed = fixJsonSyntax(content);
|
|
58
|
+
if (fixed) {
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(fixed);
|
|
61
|
+
warnings.push("Fixed trailing comma in JSON");
|
|
62
|
+
} catch {
|
|
63
|
+
errors.push(`Invalid JSON syntax: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
64
|
+
return { valid: false, errors, warnings, fixed: undefined };
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
errors.push(`Invalid JSON syntax: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
68
|
+
return { valid: false, errors, warnings, fixed: undefined };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Keep track if we fixed the JSON
|
|
73
|
+
const wasFixed = parsed !== undefined && content !== JSON.stringify(parsed);
|
|
74
|
+
|
|
75
|
+
// Validate against schema
|
|
76
|
+
const schemaResult = PickleSettingsSchema.safeParse(parsed);
|
|
77
|
+
if (!schemaResult.success) {
|
|
78
|
+
schemaResult.error.errors.forEach((err) => {
|
|
79
|
+
errors.push(`Schema error at ${err.path.join('.')}: ${err.message}`);
|
|
80
|
+
});
|
|
81
|
+
return { valid: false, errors, warnings, fixed: wasFixed ? JSON.stringify(parsed) : undefined };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const settings = schemaResult.data;
|
|
85
|
+
|
|
86
|
+
// Validate provider if specified
|
|
87
|
+
if (settings.model?.provider) {
|
|
88
|
+
if (!VALID_PROVIDERS.includes(settings.model.provider as typeof VALID_PROVIDERS[number])) {
|
|
89
|
+
errors.push(
|
|
90
|
+
`Invalid provider "${settings.model.provider}". ` +
|
|
91
|
+
`Must be one of: ${VALID_PROVIDERS.join(", ")}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate model string if specified
|
|
97
|
+
if (settings.model?.model !== undefined) {
|
|
98
|
+
if (typeof settings.model.model !== "string") {
|
|
99
|
+
errors.push("Model must be a string");
|
|
100
|
+
} else if (settings.model.model.trim() === "") {
|
|
101
|
+
warnings.push("Model name is empty - provider default will be used");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Warn if no provider configured
|
|
106
|
+
if (!settings.model?.provider) {
|
|
107
|
+
warnings.push("No provider configured - will use default (Gemini)");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
valid: errors.length === 0,
|
|
112
|
+
errors,
|
|
113
|
+
warnings,
|
|
114
|
+
fixed: wasFixed ? JSON.stringify(parsed, null, 2) : undefined
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate and load settings with detailed error reporting
|
|
120
|
+
*/
|
|
121
|
+
export async function loadSettingsWithValidation(): Promise<{ settings: PickleSettings; validation: ValidationResult }> {
|
|
122
|
+
try {
|
|
123
|
+
const content = await readFile(SETTINGS_PATH, "utf-8");
|
|
124
|
+
const validation = validateSettings(content);
|
|
125
|
+
|
|
126
|
+
// If we have a fixed version, use it
|
|
127
|
+
const parsed = validation.fixed
|
|
128
|
+
? JSON.parse(validation.fixed)
|
|
129
|
+
: JSON.parse(content);
|
|
130
|
+
|
|
131
|
+
const settings = PickleSettingsSchema.parse(parsed);
|
|
132
|
+
return { settings, validation };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// File doesn't exist or is completely unreadable
|
|
135
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
|
|
136
|
+
return {
|
|
137
|
+
settings: {},
|
|
138
|
+
validation: {
|
|
139
|
+
valid: true,
|
|
140
|
+
errors: [],
|
|
141
|
+
warnings: ["Settings file does not exist - using defaults"]
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
settings: {},
|
|
148
|
+
validation: {
|
|
149
|
+
valid: false,
|
|
150
|
+
errors: [`Failed to load settings: ${e instanceof Error ? e.message : String(e)}`],
|
|
151
|
+
warnings: []
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Load settings from ~/.pickle/settings.json
|
|
159
|
+
* Returns default settings if file doesn't exist or is invalid
|
|
160
|
+
*/
|
|
161
|
+
export async function loadSettings(): Promise<PickleSettings> {
|
|
162
|
+
try {
|
|
163
|
+
const content = await readFile(SETTINGS_PATH, "utf-8");
|
|
164
|
+
const json = JSON.parse(content);
|
|
165
|
+
return PickleSettingsSchema.parse(json);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// Return empty/default settings if file doesn't exist or is invalid
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Save settings to ~/.pickle/settings.json
|
|
174
|
+
* Creates the directory if it doesn't exist
|
|
175
|
+
*/
|
|
176
|
+
export async function saveSettings(settings: PickleSettings): Promise<void> {
|
|
177
|
+
try {
|
|
178
|
+
await mkdir(SETTINGS_DIR, { recursive: true });
|
|
179
|
+
const validated = PickleSettingsSchema.parse(settings);
|
|
180
|
+
await writeFile(SETTINGS_PATH, JSON.stringify(validated, null, 2), "utf-8");
|
|
181
|
+
} catch (e) {
|
|
182
|
+
throw new Error(`Failed to save settings: ${e}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the configured provider name from settings
|
|
188
|
+
* Returns undefined if not configured
|
|
189
|
+
*/
|
|
190
|
+
export async function getConfiguredProvider(): Promise<string | undefined> {
|
|
191
|
+
const settings = await loadSettings();
|
|
192
|
+
return settings.model?.provider;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the configured model name from settings
|
|
197
|
+
* Returns undefined if not configured
|
|
198
|
+
*/
|
|
199
|
+
export async function getConfiguredModel(): Promise<string | undefined> {
|
|
200
|
+
const settings = await loadSettings();
|
|
201
|
+
return settings.model?.model;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Update specific model settings
|
|
206
|
+
*/
|
|
207
|
+
export async function updateModelSettings(
|
|
208
|
+
provider?: AIProviderName,
|
|
209
|
+
model?: string
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const settings = await loadSettings();
|
|
212
|
+
const newSettings: PickleSettings = {
|
|
213
|
+
...settings,
|
|
214
|
+
model: {
|
|
215
|
+
provider,
|
|
216
|
+
model,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
await saveSettings(newSettings);
|
|
220
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mock, expect, test, describe, beforeEach } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const mockFiles = new Map<string, string>();
|
|
5
|
+
|
|
6
|
+
mock.module("node:fs", () => ({
|
|
7
|
+
existsSync: (path: string) => {
|
|
8
|
+
return mockFiles.has(path) || (path.includes(".pickle/sessions") && !path.includes("definitely-not-there"));
|
|
9
|
+
}
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
mock.module("node:fs/promises", () => ({
|
|
13
|
+
readFile: async (path: string) => {
|
|
14
|
+
if (mockFiles.has(path)) return mockFiles.get(path);
|
|
15
|
+
throw new Error(`File not found: ${path}`);
|
|
16
|
+
},
|
|
17
|
+
writeFile: async (path: string, content: string) => {
|
|
18
|
+
mockFiles.set(path, content);
|
|
19
|
+
},
|
|
20
|
+
mkdir: async () => {},
|
|
21
|
+
readdir: async (path: string) => {
|
|
22
|
+
if (path.includes("sessions")) {
|
|
23
|
+
return [{ name: "session-1", isDirectory: () => true }];
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module("node:os", () => ({
|
|
30
|
+
homedir: () => "/home/testuser"
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
mock.module("./settings.js", () => ({
|
|
34
|
+
loadSettings: async () => ({ max_iterations: 15 })
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module("../../utils/project-root.js", () => ({
|
|
38
|
+
findProjectRoot: () => "/project"
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Import AFTER mocks
|
|
42
|
+
const { getSessionPath, loadState, saveState, createSession } = await import("./state.js");
|
|
43
|
+
|
|
44
|
+
describe("Config State", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
mockFiles.clear();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("getSessionPath should return correct path", () => {
|
|
50
|
+
expect(getSessionPath("/app", "sid")).toBe(join("/app", ".pickle", "sessions", "sid"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("saveState and loadState should work together", async () => {
|
|
54
|
+
const sessionDir = "/project/.pickle/sessions/test-session";
|
|
55
|
+
const state: any = {
|
|
56
|
+
active: true,
|
|
57
|
+
working_dir: "/project",
|
|
58
|
+
step: "prd",
|
|
59
|
+
iteration: 1,
|
|
60
|
+
max_iterations: 10,
|
|
61
|
+
max_time_minutes: 60,
|
|
62
|
+
worker_timeout_seconds: 1200,
|
|
63
|
+
start_time_epoch: Date.now(),
|
|
64
|
+
completion_promise: "DONE",
|
|
65
|
+
original_prompt: "test prompt",
|
|
66
|
+
current_ticket: "t1",
|
|
67
|
+
history: [],
|
|
68
|
+
started_at: new Date().toISOString(),
|
|
69
|
+
session_dir: sessionDir
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
await saveState(sessionDir, state);
|
|
73
|
+
const loaded = await loadState(sessionDir);
|
|
74
|
+
expect(loaded).not.toBeNull();
|
|
75
|
+
expect(loaded?.original_prompt).toBe("test prompt");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("loadState should return null if file does not exist", async () => {
|
|
79
|
+
const loaded = await loadState("/definitely-not-there");
|
|
80
|
+
expect(loaded).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("createSession should initialize a valid session", async () => {
|
|
84
|
+
const state = await createSession("/project", "new session prompt");
|
|
85
|
+
expect(state.original_prompt).toBe("new session prompt");
|
|
86
|
+
expect(state.working_dir).toBe("/project");
|
|
87
|
+
});
|
|
88
|
+
});
|