banana-code 1.3.0 → 1.4.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.
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Claude Code CLI Provider for Banana Code
3
+ *
4
+ * Uses the Claude Code CLI binary as a model provider via `claude -p` (print mode).
5
+ * This spawns the real `claude` binary with the user's own subscription auth.
6
+ * No OAuth tokens are extracted or proxied. TOS-compliant.
7
+ *
8
+ * The prompt is piped via stdin (not CLI args) to avoid OS command-line length limits.
9
+ * --system-prompt is used for Banana's system prompt so it doesn't collide with CLAUDE.md.
10
+ * --tools "" disables all built-in tools so Claude acts as a pure model provider.
11
+ *
12
+ * Interface matches OpenAICompatibleClient: chat(), chatStream(), isConnected(), listModels()
13
+ */
14
+
15
+ const { spawn } = require('child_process');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+
20
+ // Where the claude binary is typically installed
21
+ const CLAUDE_PATHS_WIN = [
22
+ path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
23
+ path.join(os.homedir(), '.local', 'bin', 'claude')
24
+ ];
25
+ const CLAUDE_PATHS_UNIX = [
26
+ path.join(os.homedir(), '.local', 'bin', 'claude'),
27
+ '/usr/local/bin/claude',
28
+ '/opt/homebrew/bin/claude'
29
+ ];
30
+
31
+ const CLAUDE_MODELS = {
32
+ 'opus': { id: 'opus', name: 'Claude Opus', contextLimit: 200000 },
33
+ 'sonnet': { id: 'sonnet', name: 'Claude Sonnet', contextLimit: 200000 },
34
+ 'haiku': { id: 'haiku', name: 'Claude Haiku', contextLimit: 200000 }
35
+ };
36
+
37
+ const DEFAULT_MODEL = 'sonnet';
38
+
39
+ // Env vars safe to pass to the claude subprocess (no API keys or secrets)
40
+ const SAFE_ENV_KEYS = new Set([
41
+ 'PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
42
+ 'SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'SHELL', 'TERM',
43
+ 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'TEMP', 'TMP',
44
+ 'USER', 'USERNAME', 'LOGNAME', 'HOSTNAME',
45
+ 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME',
46
+ 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE',
47
+ 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'COMMONPROGRAMFILES',
48
+ 'WINDIR', 'OS', 'PROCESSOR_ARCHITECTURE',
49
+ 'NUMBER_OF_PROCESSORS', 'PATHEXT'
50
+ ]);
51
+
52
+ function buildSafeEnv() {
53
+ const env = {};
54
+ for (const [key, value] of Object.entries(process.env)) {
55
+ if (SAFE_ENV_KEYS.has(key) || SAFE_ENV_KEYS.has(key.toUpperCase())) {
56
+ env[key] = value;
57
+ }
58
+ }
59
+ return env;
60
+ }
61
+
62
+ /**
63
+ * Find the claude binary path.
64
+ * Checks known install locations first, then falls back to PATH.
65
+ */
66
+ function findClaudeBinary() {
67
+ const candidates = process.platform === 'win32' ? CLAUDE_PATHS_WIN : CLAUDE_PATHS_UNIX;
68
+
69
+ for (const candidate of candidates) {
70
+ try {
71
+ if (fs.existsSync(candidate)) return candidate;
72
+ } catch {
73
+ // continue
74
+ }
75
+ }
76
+
77
+ // Fall back to just 'claude' on PATH
78
+ return 'claude';
79
+ }
80
+
81
+ /**
82
+ * Convert OpenAI-style messages array into { systemPrompt, userPrompt }.
83
+ * System messages become the --system-prompt flag.
84
+ * Everything else is flattened into a conversation string piped via stdin.
85
+ */
86
+ function splitMessages(messages) {
87
+ const systemParts = [];
88
+ const conversationParts = [];
89
+
90
+ for (const msg of messages) {
91
+ if (!msg || !msg.role) continue;
92
+
93
+ if (msg.role === 'system') {
94
+ const text = typeof msg.content === 'string' ? msg.content : '';
95
+ if (text) systemParts.push(text);
96
+ continue;
97
+ }
98
+
99
+ if (msg.role === 'user') {
100
+ const text = typeof msg.content === 'string'
101
+ ? msg.content
102
+ : (Array.isArray(msg.content)
103
+ ? msg.content
104
+ .filter(c => c && (typeof c === 'string' || c.type === 'text'))
105
+ .map(c => typeof c === 'string' ? c : c.text)
106
+ .join('\n')
107
+ : '');
108
+ if (text) conversationParts.push(`[User]\n${text}`);
109
+ continue;
110
+ }
111
+
112
+ if (msg.role === 'assistant') {
113
+ const text = typeof msg.content === 'string'
114
+ ? msg.content
115
+ : (Array.isArray(msg.content)
116
+ ? msg.content
117
+ .filter(c => c && (typeof c === 'string' || c.type === 'text'))
118
+ .map(c => typeof c === 'string' ? c : c.text)
119
+ .join('\n')
120
+ : '');
121
+ if (text) conversationParts.push(`[Assistant]\n${text}`);
122
+ continue;
123
+ }
124
+
125
+ if (msg.role === 'tool') {
126
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || {});
127
+ conversationParts.push(`[Tool Result]\n${text}`);
128
+ }
129
+ }
130
+
131
+ return {
132
+ systemPrompt: systemParts.join('\n\n').trim(),
133
+ userPrompt: conversationParts.join('\n\n').trim()
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Map a Banana Code model alias to a claude CLI --model flag value.
139
+ */
140
+ function resolveClaudeModel(modelOption) {
141
+ if (!modelOption || typeof modelOption !== 'string') return DEFAULT_MODEL;
142
+ const lower = modelOption.toLowerCase().replace(/^claude-code[:/]/, '');
143
+ if (lower.includes('opus')) return 'opus';
144
+ if (lower.includes('sonnet')) return 'sonnet';
145
+ if (lower.includes('haiku')) return 'haiku';
146
+ return DEFAULT_MODEL;
147
+ }
148
+
149
+ class ClaudeCodeClient {
150
+ constructor(options = {}) {
151
+ this.claudeBinary = options.claudeBinary || findClaudeBinary();
152
+ this.label = 'Claude Code';
153
+ this.defaultModel = options.model || DEFAULT_MODEL;
154
+ }
155
+
156
+ /**
157
+ * Build the base args for claude -p. Prompt comes from stdin.
158
+ */
159
+ _buildArgs(model, systemPrompt, format) {
160
+ const args = ['-p', '--output-format', format, '--model', model];
161
+ args.push('--tools', '');
162
+ args.push('--no-session-persistence');
163
+ if (systemPrompt) {
164
+ args.push('--system-prompt', systemPrompt);
165
+ }
166
+ if (format === 'stream-json') {
167
+ args.push('--verbose', '--include-partial-messages');
168
+ }
169
+ return args;
170
+ }
171
+
172
+ /**
173
+ * Non-streaming chat: spawns `claude -p` with --output-format json.
174
+ * Prompt is piped via stdin to avoid OS command-line length limits.
175
+ * Returns OpenAI-compatible response format.
176
+ */
177
+ async chat(messages, options = {}) {
178
+ const { systemPrompt, userPrompt } = splitMessages(messages);
179
+ const model = resolveClaudeModel(options.model);
180
+ const args = this._buildArgs(model, systemPrompt, 'json');
181
+ const timeout = options.timeout || 300000;
182
+
183
+ return new Promise((resolve, reject) => {
184
+ let settled = false;
185
+ const settle = (fn, value) => {
186
+ if (settled) return;
187
+ settled = true;
188
+ fn(value);
189
+ };
190
+
191
+ const child = spawn(this.claudeBinary, args, {
192
+ env: buildSafeEnv(),
193
+ stdio: ['pipe', 'pipe', 'pipe'],
194
+ windowsHide: true
195
+ });
196
+
197
+ let stdout = '';
198
+ let stderr = '';
199
+
200
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
201
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
202
+
203
+ // Timeout watchdog
204
+ const timer = setTimeout(() => {
205
+ child.kill('SIGTERM');
206
+ settle(reject, new Error('Claude Code request timed out'));
207
+ }, timeout);
208
+
209
+ child.on('error', (err) => {
210
+ clearTimeout(timer);
211
+ settle(reject, new Error(`Claude Code process error: ${err.message}`));
212
+ });
213
+
214
+ child.on('close', (code) => {
215
+ clearTimeout(timer);
216
+
217
+ if (code !== 0 && !stdout.trim()) {
218
+ settle(reject, new Error(`Claude Code exited with code ${code}${stderr ? ': ' + stderr.slice(0, 500) : ''}`));
219
+ return;
220
+ }
221
+
222
+ try {
223
+ const result = JSON.parse(stdout.trim());
224
+
225
+ if (result.is_error) {
226
+ settle(reject, new Error(`Claude Code error: ${result.result || 'Unknown error'}`));
227
+ return;
228
+ }
229
+
230
+ const text = result.result || '';
231
+ const usage = result.usage || {};
232
+ const modelUsage = result.modelUsage || {};
233
+ const modelKey = Object.keys(modelUsage)[0];
234
+ const modelStats = modelKey ? modelUsage[modelKey] : {};
235
+
236
+ settle(resolve, {
237
+ id: result.session_id || null,
238
+ object: 'chat.completion',
239
+ choices: [{
240
+ index: 0,
241
+ finish_reason: result.stop_reason === 'end_turn' ? 'stop' : (result.stop_reason || 'stop'),
242
+ message: {
243
+ role: 'assistant',
244
+ content: text
245
+ }
246
+ }],
247
+ usage: {
248
+ prompt_tokens: modelStats.inputTokens || usage.input_tokens || 0,
249
+ completion_tokens: modelStats.outputTokens || usage.output_tokens || 0,
250
+ total_tokens: (modelStats.inputTokens || usage.input_tokens || 0) + (modelStats.outputTokens || usage.output_tokens || 0),
251
+ cache_read_input_tokens: modelStats.cacheReadInputTokens || usage.cache_read_input_tokens || 0,
252
+ cache_creation_input_tokens: modelStats.cacheCreationInputTokens || usage.cache_creation_input_tokens || 0
253
+ },
254
+ _claude_code: {
255
+ cost_usd: result.total_cost_usd || modelStats.costUSD || 0,
256
+ duration_ms: result.duration_ms || 0,
257
+ model: modelKey || model,
258
+ session_id: result.session_id
259
+ }
260
+ });
261
+ } catch {
262
+ settle(reject, new Error(`Claude Code returned invalid JSON: ${stdout.slice(0, 200)}`));
263
+ }
264
+ });
265
+
266
+ // Handle abort signal
267
+ if (options.signal) {
268
+ if (options.signal.aborted) {
269
+ child.kill('SIGTERM');
270
+ settle(reject, new Error('Claude Code request was cancelled'));
271
+ return;
272
+ }
273
+ options.signal.addEventListener('abort', () => {
274
+ child.kill('SIGTERM');
275
+ settle(reject, new Error('Claude Code request was cancelled'));
276
+ }, { once: true });
277
+ }
278
+
279
+ // Pipe prompt via stdin and close it
280
+ try {
281
+ child.stdin.write(userPrompt || '\n');
282
+ child.stdin.end();
283
+ } catch (e) {
284
+ settle(reject, new Error(`Claude Code stdin error: ${e.message}`));
285
+ }
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Streaming chat: spawns `claude -p` with --output-format stream-json --verbose.
291
+ * Returns a Response object with an SSE body stream (matching OpenAI format)
292
+ * that Banana Code's StreamHandler can consume.
293
+ *
294
+ * Parses Claude's stream-json events:
295
+ * - stream_event with content_block_delta -> incremental text
296
+ * - assistant (full message) -> fallback if no deltas received
297
+ * - result -> final metadata, sends [DONE]
298
+ */
299
+ async chatStream(messages, options = {}) {
300
+ const { systemPrompt, userPrompt } = splitMessages(messages);
301
+ const model = resolveClaudeModel(options.model);
302
+ const args = this._buildArgs(model, systemPrompt, 'stream-json');
303
+ const IDLE_TIMEOUT = 60000;
304
+
305
+ const child = spawn(this.claudeBinary, args, {
306
+ env: buildSafeEnv(),
307
+ stdio: ['pipe', 'pipe', 'pipe'],
308
+ windowsHide: true
309
+ });
310
+
311
+ // Handle abort signal
312
+ if (options.signal) {
313
+ if (options.signal.aborted) {
314
+ child.kill('SIGTERM');
315
+ throw new Error('Claude Code request was cancelled');
316
+ }
317
+ options.signal.addEventListener('abort', () => {
318
+ child.kill('SIGTERM');
319
+ }, { once: true });
320
+ }
321
+
322
+ // Pipe prompt via stdin and close it
323
+ try {
324
+ child.stdin.write(userPrompt || '\n');
325
+ child.stdin.end();
326
+ } catch {
327
+ child.kill('SIGTERM');
328
+ throw new Error('Claude Code: failed to write prompt to stdin');
329
+ }
330
+
331
+ const encoder = new TextEncoder();
332
+ let stderrData = '';
333
+ child.stderr.on('data', (chunk) => {
334
+ stderrData += chunk.toString();
335
+ if (stderrData.length > 2000) stderrData = stderrData.slice(-2000);
336
+ });
337
+
338
+ const readable = new ReadableStream({
339
+ start(controller) {
340
+ let buffer = '';
341
+ let closed = false;
342
+ let sentAnyContent = false;
343
+ let idleTimer = null;
344
+
345
+ const resetIdleTimer = () => {
346
+ if (idleTimer) clearTimeout(idleTimer);
347
+ idleTimer = setTimeout(() => {
348
+ if (!closed) {
349
+ child.kill('SIGTERM');
350
+ closeStream();
351
+ }
352
+ }, IDLE_TIMEOUT);
353
+ };
354
+
355
+ const closeStream = () => {
356
+ if (closed) return;
357
+ closed = true;
358
+ if (idleTimer) clearTimeout(idleTimer);
359
+ try {
360
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
361
+ controller.close();
362
+ } catch {
363
+ // Already closed
364
+ }
365
+ };
366
+
367
+ const emitText = (text) => {
368
+ if (!text || closed) return;
369
+ sentAnyContent = true;
370
+ resetIdleTimer();
371
+ const sseData = JSON.stringify({
372
+ choices: [{ delta: { content: text } }]
373
+ });
374
+ try {
375
+ controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
376
+ } catch {
377
+ // Stream closed
378
+ }
379
+ };
380
+
381
+ resetIdleTimer();
382
+
383
+ child.stdout.on('data', (chunk) => {
384
+ buffer += chunk.toString();
385
+ const lines = buffer.split('\n');
386
+ buffer = lines.pop() || '';
387
+
388
+ for (const line of lines) {
389
+ const trimmed = line.trim();
390
+ if (!trimmed) continue;
391
+
392
+ let parsed;
393
+ try {
394
+ parsed = JSON.parse(trimmed);
395
+ } catch {
396
+ continue;
397
+ }
398
+
399
+ // Incremental text deltas (the real streaming path)
400
+ if (parsed.type === 'stream_event' && parsed.event?.type === 'content_block_delta') {
401
+ const delta = parsed.event.delta;
402
+ if (delta?.type === 'text_delta' && delta.text) {
403
+ emitText(delta.text);
404
+ }
405
+ continue;
406
+ }
407
+
408
+ // Full assistant message (fallback if no deltas were received)
409
+ if (parsed.type === 'assistant' && parsed.message?.content && !sentAnyContent) {
410
+ for (const block of parsed.message.content) {
411
+ if (block.type === 'text' && block.text) {
412
+ emitText(block.text);
413
+ }
414
+ }
415
+ continue;
416
+ }
417
+
418
+ // Final result - stream is done
419
+ if (parsed.type === 'result') {
420
+ // If nothing was streamed yet, send the result text as fallback
421
+ if (!sentAnyContent && parsed.result && typeof parsed.result === 'string') {
422
+ emitText(parsed.result);
423
+ }
424
+ closeStream();
425
+ return;
426
+ }
427
+ }
428
+ });
429
+
430
+ child.stdout.on('end', () => {
431
+ // Process remaining buffer
432
+ if (buffer.trim()) {
433
+ try {
434
+ const parsed = JSON.parse(buffer.trim());
435
+ if (parsed.type === 'result') {
436
+ if (!sentAnyContent && parsed.result && typeof parsed.result === 'string') {
437
+ emitText(parsed.result);
438
+ }
439
+ }
440
+ } catch {
441
+ // ignore
442
+ }
443
+ }
444
+ closeStream();
445
+ });
446
+
447
+ child.on('error', (err) => {
448
+ if (idleTimer) clearTimeout(idleTimer);
449
+ if (!closed) {
450
+ closed = true;
451
+ try {
452
+ controller.error(new Error(`Claude Code process error: ${err.message}`));
453
+ } catch {
454
+ // Already closed
455
+ }
456
+ }
457
+ });
458
+
459
+ child.on('exit', (code) => {
460
+ if (code !== 0 && !closed) {
461
+ if (idleTimer) clearTimeout(idleTimer);
462
+ closed = true;
463
+ try {
464
+ controller.error(new Error(`Claude Code exited with code ${code}${stderrData ? ': ' + stderrData.slice(0, 500) : ''}`));
465
+ } catch {
466
+ // Already closed
467
+ }
468
+ }
469
+ });
470
+ },
471
+
472
+ cancel() {
473
+ child.kill('SIGTERM');
474
+ }
475
+ });
476
+
477
+ return new Response(readable, {
478
+ status: 200,
479
+ headers: { 'Content-Type': 'text/event-stream' }
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Check if Claude Code CLI is installed.
485
+ * Verifies the binary exists and responds to --version.
486
+ */
487
+ async isConnected(options = {}) {
488
+ const throwOnError = options.throwOnError === true;
489
+ return new Promise((resolve, reject) => {
490
+ const child = spawn(this.claudeBinary, ['--version'], {
491
+ stdio: ['ignore', 'pipe', 'pipe'],
492
+ timeout: 5000,
493
+ windowsHide: true
494
+ });
495
+
496
+ let stdout = '';
497
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
498
+
499
+ child.on('error', (err) => {
500
+ if (throwOnError) {
501
+ reject(new Error(`Claude Code CLI not found or not working: ${err.message}`));
502
+ } else {
503
+ resolve(false);
504
+ }
505
+ });
506
+
507
+ child.on('close', (code) => {
508
+ const version = stdout.trim();
509
+ // Accept any successful exit with version-like output
510
+ if (code === 0 && version) {
511
+ resolve(true);
512
+ } else if (throwOnError) {
513
+ reject(new Error(`Claude Code CLI check failed (exit ${code}): ${version || 'no output'}`));
514
+ } else {
515
+ resolve(false);
516
+ }
517
+ });
518
+ });
519
+ }
520
+
521
+ /**
522
+ * List available Claude models.
523
+ * Claude Code CLI doesn't have a models endpoint, so we return hardcoded options.
524
+ */
525
+ async listModels() {
526
+ return Object.entries(CLAUDE_MODELS).map(([key, model]) => ({
527
+ id: model.id,
528
+ object: 'model',
529
+ owned_by: 'anthropic',
530
+ ...model
531
+ }));
532
+ }
533
+ }
534
+
535
+ module.exports = {
536
+ ClaudeCodeClient,
537
+ findClaudeBinary,
538
+ CLAUDE_MODELS,
539
+ DEFAULT_MODEL
540
+ };
package/lib/config.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const { ensureDirSync, atomicWriteFileSync, atomicWriteJsonSync } = require('./fsUtils');
7
8
 
8
9
  const DEFAULT_CONFIG = {
9
10
  lmStudioUrl: 'http://localhost:1234',
@@ -38,17 +39,14 @@ class Config {
38
39
  this.configPath = path.join(this.bananaDir, 'config.json');
39
40
  this.instructionsPath = path.join(this.bananaDir, 'instructions.md');
40
41
  this.historyDir = path.join(this.bananaDir, 'history');
42
+ this.runSnapshotPath = path.join(this.bananaDir, 'last-run.json');
41
43
  this.config = { ...DEFAULT_CONFIG };
42
44
  this.load();
43
45
  }
44
46
 
45
47
  ensureDir() {
46
- if (!fs.existsSync(this.bananaDir)) {
47
- fs.mkdirSync(this.bananaDir, { recursive: true });
48
- }
49
- if (!fs.existsSync(this.historyDir)) {
50
- fs.mkdirSync(this.historyDir, { recursive: true });
51
- }
48
+ ensureDirSync(this.bananaDir);
49
+ ensureDirSync(this.historyDir);
52
50
  }
53
51
 
54
52
  load() {
@@ -77,7 +75,7 @@ class Config {
77
75
 
78
76
  save() {
79
77
  this.ensureDir();
80
- fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
78
+ atomicWriteJsonSync(this.configPath, this.config);
81
79
  }
82
80
 
83
81
  get(key) {
@@ -151,7 +149,7 @@ It is automatically loaded into the AI context at the start of every conversatio
151
149
  - Don't modify package-lock.json or pnpm-lock.yaml
152
150
  - Don't change the build configuration without asking
153
151
  `;
154
- fs.writeFileSync(bananaMdPath, template);
152
+ atomicWriteFileSync(bananaMdPath, template);
155
153
  return template;
156
154
  }
157
155
 
@@ -160,14 +158,52 @@ It is automatically loaded into the AI context at the start of every conversatio
160
158
  this.ensureDir();
161
159
  const filename = `${name.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}.json`;
162
160
  const filepath = path.join(this.historyDir, filename);
163
- fs.writeFileSync(filepath, JSON.stringify({
161
+ atomicWriteJsonSync(filepath, {
164
162
  name,
165
163
  savedAt: new Date().toISOString(),
166
164
  history
167
- }, null, 2));
165
+ });
168
166
  return filename;
169
167
  }
170
168
 
169
+ saveRunSnapshot(snapshot) {
170
+ this.ensureDir();
171
+ atomicWriteJsonSync(this.runSnapshotPath, {
172
+ savedAt: new Date().toISOString(),
173
+ completed: false,
174
+ ...snapshot
175
+ });
176
+ }
177
+
178
+ completeRunSnapshot(extra = {}) {
179
+ this.ensureDir();
180
+ if (!fs.existsSync(this.runSnapshotPath)) return;
181
+ try {
182
+ const existing = JSON.parse(fs.readFileSync(this.runSnapshotPath, 'utf-8'));
183
+ atomicWriteJsonSync(this.runSnapshotPath, {
184
+ ...existing,
185
+ ...extra,
186
+ completed: true,
187
+ completedAt: new Date().toISOString()
188
+ });
189
+ } catch {
190
+ atomicWriteJsonSync(this.runSnapshotPath, {
191
+ completed: true,
192
+ completedAt: new Date().toISOString(),
193
+ ...extra
194
+ });
195
+ }
196
+ }
197
+
198
+ getRunSnapshot() {
199
+ try {
200
+ if (!fs.existsSync(this.runSnapshotPath)) return null;
201
+ return JSON.parse(fs.readFileSync(this.runSnapshotPath, 'utf-8'));
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
171
207
  listConversations() {
172
208
  this.ensureDir();
173
209
  try {
@@ -233,7 +269,7 @@ It is automatically loaded into the AI context at the start of every conversatio
233
269
 
234
270
  saveHooks(hookConfig) {
235
271
  this.ensureDir();
236
- fs.writeFileSync(this.getHooksPath(), JSON.stringify(hookConfig, null, 2));
272
+ atomicWriteJsonSync(this.getHooksPath(), hookConfig);
237
273
  }
238
274
  }
239
275
 
@@ -253,9 +289,7 @@ class GlobalConfig {
253
289
 
254
290
  ensureDir() {
255
291
  for (const dir of [this.bananaDir, this.commandsDir, path.join(this.bananaDir, 'logs')]) {
256
- if (!fs.existsSync(dir)) {
257
- fs.mkdirSync(dir, { recursive: true });
258
- }
292
+ ensureDirSync(dir);
259
293
  }
260
294
  }
261
295
 
@@ -271,7 +305,7 @@ class GlobalConfig {
271
305
 
272
306
  save() {
273
307
  this.ensureDir();
274
- fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
308
+ atomicWriteJsonSync(this.configPath, this.config);
275
309
  }
276
310
 
277
311
  get(key) {