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.
Files changed (128) hide show
  1. package/README.md +242 -0
  2. package/bin.js +3 -0
  3. package/dist/pickle +0 -0
  4. package/dist/worker-executor.js +207 -0
  5. package/package.json +53 -0
  6. package/src/games/GameSidebarManager.test.ts +64 -0
  7. package/src/games/GameSidebarManager.ts +78 -0
  8. package/src/games/gameboy/GameboyView.test.ts +25 -0
  9. package/src/games/gameboy/GameboyView.ts +100 -0
  10. package/src/games/gameboy/gameboy-polyfills.ts +313 -0
  11. package/src/games/index.test.ts +9 -0
  12. package/src/games/index.ts +4 -0
  13. package/src/games/snake/SnakeGame.test.ts +35 -0
  14. package/src/games/snake/SnakeGame.ts +145 -0
  15. package/src/games/snake/SnakeView.test.ts +25 -0
  16. package/src/games/snake/SnakeView.ts +290 -0
  17. package/src/index.test.ts +24 -0
  18. package/src/index.ts +141 -0
  19. package/src/services/commands/worker.test.ts +14 -0
  20. package/src/services/commands/worker.ts +262 -0
  21. package/src/services/config/index.ts +2 -0
  22. package/src/services/config/settings.test.ts +42 -0
  23. package/src/services/config/settings.ts +220 -0
  24. package/src/services/config/state.test.ts +88 -0
  25. package/src/services/config/state.ts +130 -0
  26. package/src/services/config/types.ts +39 -0
  27. package/src/services/execution/index.ts +1 -0
  28. package/src/services/execution/pickle-source.test.ts +88 -0
  29. package/src/services/execution/pickle-source.ts +264 -0
  30. package/src/services/execution/prompt.test.ts +93 -0
  31. package/src/services/execution/prompt.ts +322 -0
  32. package/src/services/execution/sequential.test.ts +91 -0
  33. package/src/services/execution/sequential.ts +422 -0
  34. package/src/services/execution/worker-client.ts +94 -0
  35. package/src/services/execution/worker-executor.ts +41 -0
  36. package/src/services/execution/worker.test.ts +73 -0
  37. package/src/services/git/branch.test.ts +147 -0
  38. package/src/services/git/branch.ts +128 -0
  39. package/src/services/git/diff.test.ts +113 -0
  40. package/src/services/git/diff.ts +323 -0
  41. package/src/services/git/index.ts +4 -0
  42. package/src/services/git/pr.test.ts +104 -0
  43. package/src/services/git/pr.ts +192 -0
  44. package/src/services/git/worktree.test.ts +99 -0
  45. package/src/services/git/worktree.ts +141 -0
  46. package/src/services/providers/base.test.ts +86 -0
  47. package/src/services/providers/base.ts +438 -0
  48. package/src/services/providers/codex.test.ts +39 -0
  49. package/src/services/providers/codex.ts +208 -0
  50. package/src/services/providers/gemini.test.ts +40 -0
  51. package/src/services/providers/gemini.ts +169 -0
  52. package/src/services/providers/index.test.ts +28 -0
  53. package/src/services/providers/index.ts +41 -0
  54. package/src/services/providers/opencode.test.ts +64 -0
  55. package/src/services/providers/opencode.ts +228 -0
  56. package/src/services/providers/types.ts +44 -0
  57. package/src/skills/code-implementer.md +105 -0
  58. package/src/skills/code-researcher.md +78 -0
  59. package/src/skills/implementation-planner.md +105 -0
  60. package/src/skills/plan-reviewer.md +100 -0
  61. package/src/skills/prd-drafter.md +123 -0
  62. package/src/skills/research-reviewer.md +79 -0
  63. package/src/skills/ruthless-refactorer.md +52 -0
  64. package/src/skills/ticket-manager.md +135 -0
  65. package/src/types/index.ts +2 -0
  66. package/src/types/rpc.ts +14 -0
  67. package/src/types/tasks.ts +50 -0
  68. package/src/types.d.ts +9 -0
  69. package/src/ui/common.ts +28 -0
  70. package/src/ui/components/FilePickerView.test.ts +79 -0
  71. package/src/ui/components/FilePickerView.ts +161 -0
  72. package/src/ui/components/MultiLineInput.test.ts +27 -0
  73. package/src/ui/components/MultiLineInput.ts +233 -0
  74. package/src/ui/components/SessionChip.test.ts +69 -0
  75. package/src/ui/components/SessionChip.ts +481 -0
  76. package/src/ui/components/ToyboxSidebar.test.ts +36 -0
  77. package/src/ui/components/ToyboxSidebar.ts +329 -0
  78. package/src/ui/components/refactor_plan.md +35 -0
  79. package/src/ui/controllers/DashboardController.integration.test.ts +43 -0
  80. package/src/ui/controllers/DashboardController.ts +650 -0
  81. package/src/ui/dashboard.test.ts +43 -0
  82. package/src/ui/dashboard.ts +309 -0
  83. package/src/ui/dialogs/DashboardDialog.test.ts +146 -0
  84. package/src/ui/dialogs/DashboardDialog.ts +399 -0
  85. package/src/ui/dialogs/Dialog.test.ts +50 -0
  86. package/src/ui/dialogs/Dialog.ts +241 -0
  87. package/src/ui/dialogs/DialogSidebar.test.ts +60 -0
  88. package/src/ui/dialogs/DialogSidebar.ts +71 -0
  89. package/src/ui/dialogs/DiffViewDialog.test.ts +57 -0
  90. package/src/ui/dialogs/DiffViewDialog.ts +510 -0
  91. package/src/ui/dialogs/PRPreviewDialog.test.ts +50 -0
  92. package/src/ui/dialogs/PRPreviewDialog.ts +346 -0
  93. package/src/ui/dialogs/test-utils.ts +232 -0
  94. package/src/ui/file-picker-utils.test.ts +71 -0
  95. package/src/ui/file-picker-utils.ts +200 -0
  96. package/src/ui/input-chrome.test.ts +62 -0
  97. package/src/ui/input-chrome.ts +172 -0
  98. package/src/ui/logger.test.ts +68 -0
  99. package/src/ui/logger.ts +45 -0
  100. package/src/ui/mock-factory.ts +6 -0
  101. package/src/ui/spinner.test.ts +65 -0
  102. package/src/ui/spinner.ts +41 -0
  103. package/src/ui/test-setup.ts +300 -0
  104. package/src/ui/theme.test.ts +23 -0
  105. package/src/ui/theme.ts +16 -0
  106. package/src/ui/views/LandingView.integration.test.ts +21 -0
  107. package/src/ui/views/LandingView.test.ts +24 -0
  108. package/src/ui/views/LandingView.ts +221 -0
  109. package/src/ui/views/LogView.test.ts +24 -0
  110. package/src/ui/views/LogView.ts +277 -0
  111. package/src/ui/views/ToyboxView.test.ts +46 -0
  112. package/src/ui/views/ToyboxView.ts +323 -0
  113. package/src/utils/clipboard.test.ts +86 -0
  114. package/src/utils/clipboard.ts +100 -0
  115. package/src/utils/index.test.ts +68 -0
  116. package/src/utils/index.ts +95 -0
  117. package/src/utils/persona.test.ts +12 -0
  118. package/src/utils/persona.ts +8 -0
  119. package/src/utils/project-root.test.ts +38 -0
  120. package/src/utils/project-root.ts +22 -0
  121. package/src/utils/resources.test.ts +64 -0
  122. package/src/utils/resources.ts +92 -0
  123. package/src/utils/search.test.ts +48 -0
  124. package/src/utils/search.ts +103 -0
  125. package/src/utils/session-tracker.test.ts +46 -0
  126. package/src/utils/session-tracker.ts +67 -0
  127. package/src/utils/spinner.test.ts +54 -0
  128. 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,2 @@
1
+ export * from "./types.js";
2
+ export * from "./state.js";
@@ -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
+ });