gent-cli 27.0.0 → 28.0.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 CHANGED
@@ -24,7 +24,7 @@ Beyond a faithful git-like workflow, Gent adds:
24
24
  - **`gent undo` / `gent redo`** — a one-command safety net over an operation journal (friendlier than `git reflog`).
25
25
  - **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
26
26
  - **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
27
- - **Managed AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency AI through the signed-in Gent account, with task-specific prompts and no user API-key setup.
27
+ - **Direct local AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency OpenAI calls from the CLI with task-specific prompts. Configure once with `gent ai configure`.
28
28
  - **Genti, your terminal mascot** — a mint one-eyed sky-jelly that *acts out* your workflow: it floats a file crate to the cloud on `gent push`, carries one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
29
29
 
30
30
  See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
@@ -500,14 +500,16 @@ gent explain --staged # explain currently staged changes
500
500
 
501
501
  ### Optional AI features
502
502
 
503
- AI calls run directly from the CLI using the credential provisioned for the
504
- installation. Commands never prompt users for a provider key. Fast task-specific
505
- prompts keep review, merge resolution, chat, and summaries concise.
503
+ AI calls run directly from the CLI. Run `gent ai configure` once to enter the
504
+ OpenAI key through a masked prompt; Gent stores it locally with owner-only
505
+ permissions. Fast task-specific prompts keep review, merge resolution, chat,
506
+ and summaries concise.
506
507
 
507
508
  ```bash
509
+ gent ai configure # configure OpenAI once on this computer
508
510
  gent commit --ai # suggest a commit message from the staged diff
509
511
  gent explain # narrate a diff instead of just printing it
510
- gent resolve # adds an "Ask AI" choice per conflict hunk
512
+ gent resolve # ours/theirs/both/AI/edit/skip per conflict hunk
511
513
  ```
512
514
 
513
515
  ### Tags
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "27.0.0",
3
+ "version": "28.0.0",
4
4
  "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/resolve-options.test.js && node tests/offline-e2e.js && node tests/ai-merge.e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
12
- "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/resolve-options.test.js",
11
+ "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/local-ai-config.test.js tests/resolve-options.test.js && node tests/offline-e2e.js && node tests/ai-merge.e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
12
+ "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/local-ai-config.test.js tests/resolve-options.test.js",
13
13
  "test:e2e": "node tests/offline-e2e.js",
14
14
  "test:remote:e2e": "node tests/remote-e2e.js",
15
15
  "demo": "bash demo.sh",
@@ -1,28 +1,50 @@
1
1
  /**
2
2
  * AI Command - Manage and verify AI integration.
3
3
  *
4
- * gent ai status show the managed service status
4
+ * gent ai configure securely save a key for this computer
5
+ * gent ai status → show local AI status
5
6
  * gent ai test → make a tiny live request to confirm it works
6
7
  * gent ai models → show provider management information
7
8
  */
8
9
 
9
10
  const chalk = require('chalk');
11
+ const inquirer = require('inquirer');
10
12
  const ora = require('ora');
11
13
  const ai = require('../utils/ai-service');
14
+ const localAiConfig = require('../utils/local-ai-config');
12
15
 
13
16
  async function aiCommand(subcommand) {
14
17
  const sub = (subcommand || 'status').toLowerCase();
15
18
  switch (sub) {
19
+ case 'configure': return configure();
16
20
  case 'status': return status();
17
21
  case 'test': return test();
18
22
  case 'models': return models();
19
23
  default:
20
24
  console.error(chalk.red(`Unknown subcommand '${sub}'`));
21
- console.log(chalk.gray('Usage: gent ai <status|test|models>'));
25
+ console.log(chalk.gray('Usage: gent ai <configure|status|test|models>'));
22
26
  process.exit(1);
23
27
  }
24
28
  }
25
29
 
30
+ async function configure() {
31
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
32
+ console.error(chalk.red('Run `gent ai configure` in an interactive terminal.'));
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ const { apiKey } = await inquirer.prompt([{
37
+ type: 'password',
38
+ name: 'apiKey',
39
+ message: 'OpenAI API key:',
40
+ mask: '*',
41
+ validate: value => localAiConfig.validateApiKey(value) || 'Enter a valid key beginning with sk-.',
42
+ }]);
43
+ const savedPath = await localAiConfig.saveApiKey(apiKey);
44
+ console.log(chalk.green('✓ Gent AI configured for this computer.'));
45
+ console.log(chalk.gray(` Stored locally in ${savedPath} with owner-only permissions.`));
46
+ }
47
+
26
48
  async function status() {
27
49
  const { value: available, source } = await ai.resolveKey();
28
50
 
@@ -216,7 +216,7 @@ const handlers = {
216
216
  continue;
217
217
  }
218
218
  try {
219
- const suggestion = await ai.resolveConflictHunk({
219
+ const resolution = await ai.resolveConflictHunk({
220
220
  base: sides.base?.toString('utf8') || '',
221
221
  ours: sides.ours?.toString('utf8') || '',
222
222
  theirs: sides.theirs?.toString('utf8') || '',
@@ -226,10 +226,11 @@ const handlers = {
226
226
  await worktree.assertNoSymlinkParent(repo, name);
227
227
  const absolute = path.join(repo.worktree, name);
228
228
  await fs.mkdir(path.dirname(absolute), { recursive: true });
229
- await fs.writeFile(absolute, suggestion, 'utf8');
229
+ await fs.writeFile(absolute, resolution.merged, 'utf8');
230
230
  await merge.markResolved(repo, name);
231
231
  resolved++;
232
232
  console.log(`Resolved and staged ${name}`);
233
+ console.log(` AI: ${resolution.summary}`);
233
234
  } catch (error) {
234
235
  console.log(`AI did not resolve ${name}: ${error.message}`);
235
236
  }
@@ -137,7 +137,7 @@ async function checkAiKey(probe) {
137
137
  name: 'AI service',
138
138
  status: 'warn',
139
139
  detail: 'unavailable in this CLI installation',
140
- hint: 'Ask the Gent distributor to provision the local AI credential.',
140
+ hint: 'Run `gent ai configure` once on this computer.',
141
141
  };
142
142
  }
143
143
 
@@ -161,7 +161,7 @@ async function checkAiKey(probe) {
161
161
  name: 'AI service',
162
162
  status: 'fail',
163
163
  detail: err.message,
164
- hint: 'Retry later or ask the Gent distributor to check AI quota and credentials.',
164
+ hint: 'Run `gent ai configure` to replace the key, or check the OpenAI project quota.',
165
165
  };
166
166
  }
167
167
  }
@@ -33,13 +33,6 @@ const journal = require('../utils/journal');
33
33
  const ai = require('../utils/ai-service');
34
34
  const reviewCommand = require('./review');
35
35
 
36
- function resolutionModes() {
37
- return [
38
- { name: 'Resolve with AI (fast) — resolve, commit, then review', value: 'ai' },
39
- { name: 'Resolve manually — choose each conflict', value: 'manual' },
40
- ];
41
- }
42
-
43
36
  async function resolve(options = {}) {
44
37
  try {
45
38
  await ai.prime();
@@ -72,16 +65,6 @@ async function resolve(options = {}) {
72
65
  return;
73
66
  }
74
67
 
75
- if (!options.ai && process.stdin.isTTY && process.stdout.isTTY) {
76
- const { mode } = await inquirer.prompt([{
77
- type: 'list',
78
- name: 'mode',
79
- message: 'How should Gent resolve this merge?',
80
- choices: resolutionModes(),
81
- }]);
82
- options.ai = mode === 'ai';
83
- }
84
-
85
68
  console.log(chalk.bold.cyan(`\nResolving merge of '${mergeState.sourceBranch}' — ${markerFiles.length} file(s)\n`));
86
69
 
87
70
  // Working copy of merged tree entries (we patch hashes as files resolve).
@@ -110,6 +93,7 @@ async function resolve(options = {}) {
110
93
  let idx = 0;
111
94
  let aborted = false;
112
95
  const out = [];
96
+ const aiSummaries = [];
113
97
 
114
98
  for (const seg of segments) {
115
99
  if (seg.type === 'text') {
@@ -117,7 +101,7 @@ async function resolve(options = {}) {
117
101
  continue;
118
102
  }
119
103
  idx++;
120
- const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options);
104
+ const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options, aiSummaries);
121
105
  if (resolvedLines === null) { aborted = true; break; }
122
106
  out.push(...resolvedLines);
123
107
  }
@@ -137,6 +121,9 @@ async function resolve(options = {}) {
137
121
  } else {
138
122
  await stageResolved(gentPath, staging, entriesByName, file, resolvedContent);
139
123
  console.log(chalk.green(` ✓ resolved ${file}`));
124
+ if (aiSummaries.length) {
125
+ console.log(chalk.gray(` AI: ${summarizeFileChanges(aiSummaries)}`));
126
+ }
140
127
  }
141
128
  }
142
129
 
@@ -181,24 +168,17 @@ async function resolve(options = {}) {
181
168
  * Prompt for one conflict hunk. Returns the chosen lines, or null to abort
182
169
  * (leave the rest of the file as-is with markers).
183
170
  */
184
- async function resolveHunk(seg, file, idx, total, options = {}) {
171
+ async function resolveHunk(seg, file, idx, total, options = {}, aiSummaries = []) {
185
172
  console.log(chalk.gray(` Conflict ${idx}/${total}:`));
186
173
  console.log(chalk.green(' <<< ours'));
187
174
  seg.ours.forEach(l => console.log(chalk.green(` ${l}`)));
188
175
  console.log(chalk.red(' >>> theirs'));
189
176
  seg.theirs.forEach(l => console.log(chalk.red(` ${l}`)));
190
177
 
191
- const choices = [
192
- { name: 'Keep ours', value: 'ours' },
193
- { name: 'Keep theirs', value: 'theirs' },
194
- { name: 'Keep both (ours then theirs)', value: 'both' },
195
- { name: 'Edit manually', value: 'edit' }
196
- ];
197
- choices.splice(3, 0, { name: `Resolve with AI (${ai.getModel()})`, value: 'ai' });
198
- choices.push({ name: 'Skip the rest of this file', value: 'skip' });
178
+ const choices = resolutionChoices();
199
179
 
200
180
  if (options.ai) {
201
- const suggestion = await askAiForHunk(seg, file, true);
181
+ const suggestion = await askAiForHunk(seg, file, true, aiSummaries);
202
182
  if (suggestion !== null) return suggestion;
203
183
  return null;
204
184
  }
@@ -225,40 +205,64 @@ async function resolveHunk(seg, file, idx, total, options = {}) {
225
205
  return text.replace(/\n$/, '').split('\n');
226
206
  }
227
207
  case 'ai': {
228
- const suggestion = await askAiForHunk(seg, file);
208
+ const suggestion = await askAiForHunk(seg, file, false, aiSummaries);
229
209
  if (suggestion !== null) return suggestion;
230
- return resolveHunk(seg, file, idx, total, { ai: false });
210
+ return resolveHunk(seg, file, idx, total, { ai: false }, aiSummaries);
231
211
  }
232
212
  default: return seg.ours;
233
213
  }
234
214
  }
235
215
 
236
- async function askAiForHunk(seg, file, autoAccept = false) {
216
+ function resolutionChoices() {
217
+ return [
218
+ { name: 'Keep ours', value: 'ours' },
219
+ { name: 'Keep theirs', value: 'theirs' },
220
+ { name: 'Keep both (ours then theirs)', value: 'both' },
221
+ { name: `Resolve with AI (${ai.getModel()})`, value: 'ai' },
222
+ { name: 'Edit manually', value: 'edit' },
223
+ { name: 'Skip the rest of this file', value: 'skip' },
224
+ ];
225
+ }
226
+
227
+ async function askAiForHunk(seg, file, autoAccept = false, aiSummaries = []) {
237
228
  try {
238
- const suggestion = await ai.resolveConflictHunk({
229
+ const resolution = await ai.resolveConflictHunk({
239
230
  ours: seg.ours.join('\n'),
240
231
  theirs: seg.theirs.join('\n'),
241
232
  fileName: file
242
233
  });
243
234
  if (autoAccept) {
244
- console.log(chalk.green(` ✓ AI resolved ${file}`));
245
- return suggestion.split('\n');
235
+ aiSummaries.push(resolution.summary);
236
+ return resolution.merged.split('\n');
246
237
  }
247
238
  console.log(chalk.cyan(' AI suggestion (review before accepting):'));
248
- suggestion.split('\n').forEach(line => console.log(chalk.cyan(` ${line}`)));
239
+ resolution.merged.split('\n').forEach(line => console.log(chalk.cyan(` ${line}`)));
249
240
  const { accept } = await inquirer.prompt([{
250
241
  type: 'confirm',
251
242
  name: 'accept',
252
243
  message: 'Use this AI suggestion?',
253
244
  default: false,
254
245
  }]);
255
- return accept ? suggestion.split('\n') : null;
246
+ if (!accept) return null;
247
+ aiSummaries.push(resolution.summary);
248
+ return resolution.merged.split('\n');
256
249
  } catch (error) {
257
250
  console.log(chalk.yellow(` AI failed (${error.message}); no file was changed.`));
258
251
  return null;
259
252
  }
260
253
  }
261
254
 
255
+ function summarizeFileChanges(summaries) {
256
+ const summary = [...new Set(summaries)].join('; ');
257
+ const words = summary.split(/\s+/);
258
+ const wordLimited = words.length <= 32
259
+ ? summary
260
+ : `${words.slice(0, 32).join(' ')}...`;
261
+ return wordLimited.length <= 220
262
+ ? wordLimited
263
+ : `${wordLimited.slice(0, 217).trimEnd()}...`;
264
+ }
265
+
262
266
  /** Store the resolved file as a blob, patch the tree entry, and stage it. */
263
267
  async function stageResolved(gentPath, staging, entriesByName, file, content) {
264
268
  const hash = await storeBlob(gentPath, content);
@@ -326,4 +330,4 @@ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
326
330
  }
327
331
 
328
332
  module.exports = resolve;
329
- module.exports.resolutionModes = resolutionModes;
333
+ module.exports.resolutionChoices = resolutionChoices;
package/src/index.js CHANGED
@@ -370,7 +370,7 @@ program
370
370
 
371
371
  program
372
372
  .command('ai [subcommand]')
373
- .description('Inspect AI integration (status|test|models)')
373
+ .description('Configure or inspect local AI (configure|status|test|models)')
374
374
  .action(aiCommand);
375
375
 
376
376
  // ─── Platform-special (AI-powered) ──────────────────────
@@ -1,17 +1,14 @@
1
- /** Managed AI client for Gent commands, with a direct local-development override. */
1
+ /** Direct, low-latency OpenAI client for Gent's local AI commands. */
2
2
 
3
3
  const axios = require('axios');
4
- const apiClient = require('./api-client');
5
- const authStorage = require('./auth-storage');
6
4
 
7
5
  const API_URL = 'https://api.openai.com/v1/responses';
8
6
  const DEFAULT_MODEL = 'gpt-4.1-mini';
9
- let managedEnabled = false;
10
7
 
11
8
  const PROMPTS = Object.freeze({
12
9
  chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
13
10
  review: 'Review fast. Return only concrete correctness, security, or regression risks, then brief fixes. If none, say "No blocking issues."',
14
- merge: 'Resolve this merge conflict fast. Preserve both intended behaviors. Return only the final merged text with no markdown fence or explanation.',
11
+ merge: 'Resolve this merge conflict fast. Preserve both intended behaviors. Return only valid JSON with this shape: {"merged":"final merged text","summary":"one sentence, at most 18 words, saying what was kept or combined"}. Do not use markdown fences.',
15
12
  commit: 'Write one concise conventional commit message. Return only the message.',
16
13
  explain: 'Explain this change briefly and concretely. Return short bullets only.',
17
14
  docs: 'Write concise, accurate repository documentation from only the supplied context.',
@@ -33,9 +30,7 @@ function getApiUrl() {
33
30
 
34
31
  async function resolveKey() {
35
32
  const value = getApiKey();
36
- if (value) return { value, source: 'local Gent installation' };
37
- managedEnabled = Boolean(await authStorage.getAccessToken());
38
- return { value: managedEnabled ? 'managed' : null, source: managedEnabled ? 'managed Gent service' : 'unset' };
33
+ return { value, source: value ? 'local CLI configuration' : 'unset' };
39
34
  }
40
35
 
41
36
  async function resolveModel() {
@@ -47,11 +42,11 @@ async function prime() {
47
42
  }
48
43
 
49
44
  function isEnabled() {
50
- return Boolean(getApiKey()) || managedEnabled;
45
+ return Boolean(getApiKey());
51
46
  }
52
47
 
53
48
  function disabledHint() {
54
- return 'Gent AI is unavailable. Sign in with `gent login` and try again.';
49
+ return 'Gent AI is unavailable. Run `gent ai configure` once on this computer.';
55
50
  }
56
51
 
57
52
  function extractText(payload) {
@@ -67,21 +62,9 @@ function extractText(payload) {
67
62
 
68
63
  async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
69
64
  const apiKey = getApiKey();
70
- if (!apiKey && !managedEnabled) await resolveKey();
71
- if (!apiKey && !managedEnabled) throw new Error(disabledHint());
65
+ if (!apiKey) throw new Error(disabledHint());
72
66
  const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
73
67
  try {
74
- if (!apiKey) {
75
- const response = await apiClient.post('/api/ai/complete/', {
76
- profile,
77
- prompt,
78
- system,
79
- max_tokens: maxTokens,
80
- });
81
- const text = response?.output_text?.trim();
82
- if (!text) throw new Error('Gent AI returned an empty response');
83
- return text;
84
- }
85
68
  const response = await axios.post(getApiUrl(), {
86
69
  model: getModel(),
87
70
  input: prompt,
@@ -107,10 +90,8 @@ function enrichAiError(error) {
107
90
  const status = error?.response?.status;
108
91
  const apiError = error?.response?.data?.error;
109
92
  const apiCode = typeof apiError === 'object' ? apiError?.code : null;
110
- if (status === 401) return new Error('Sign in with `gent login` to use Gent AI.');
111
- if (status === 404) return new Error('Gent AI is not available on this Gent server yet.');
112
- if (status === 403) return new Error('Gent AI access was rejected.');
113
- if (status === 503) return new Error('Gent AI is temporarily unavailable.');
93
+ if (status === 401 || status === 403) return new Error('OpenAI rejected the configured CLI credential.');
94
+ if (status === 404) return new Error(`OpenAI rejected model "${getModel()}".`);
114
95
  if (apiCode === 'insufficient_quota') return new Error('Gent AI quota is exhausted.');
115
96
  if (status === 429) return new Error('Gent AI is busy. Retry in a moment.');
116
97
  if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
@@ -141,7 +122,42 @@ async function resolveConflictHunk({ base, ours, theirs, fileName }) {
141
122
  `BASE:\n${base || '(none)'}\n\n` +
142
123
  `OURS:\n${ours}\n\n` +
143
124
  `THEIRS:\n${theirs}`;
144
- return complete({ profile: 'merge', prompt, maxTokens: 1400 });
125
+ const response = await complete({ profile: 'merge', prompt, maxTokens: 1400 });
126
+ return parseMergeResolution(response);
127
+ }
128
+
129
+ function parseMergeResolution(response) {
130
+ const cleaned = response
131
+ .replace(/^```(?:json)?\s*/i, '')
132
+ .replace(/\s*```$/, '')
133
+ .trim();
134
+ try {
135
+ const parsed = JSON.parse(cleaned);
136
+ if (typeof parsed.merged !== 'string') throw new Error('missing merged text');
137
+ return {
138
+ merged: parsed.merged,
139
+ summary: briefSummary(parsed.summary),
140
+ };
141
+ } catch {
142
+ return {
143
+ merged: response,
144
+ summary: 'Combined the conflicting changes.',
145
+ };
146
+ }
147
+ }
148
+
149
+ function briefSummary(value) {
150
+ const summary = typeof value === 'string'
151
+ ? value.replace(/\s+/g, ' ').trim()
152
+ : '';
153
+ if (!summary) return 'Combined the conflicting changes.';
154
+ const words = summary.split(' ');
155
+ const wordLimited = words.length <= 18
156
+ ? summary
157
+ : `${words.slice(0, 18).join(' ')}...`;
158
+ return wordLimited.length <= 160
159
+ ? wordLimited
160
+ : `${wordLimited.slice(0, 157).trimEnd()}...`;
145
161
  }
146
162
 
147
163
  module.exports = {
@@ -159,5 +175,6 @@ module.exports = {
159
175
  explainChanges,
160
176
  reviewChanges,
161
177
  resolveConflictHunk,
178
+ parseMergeResolution,
162
179
  extractText,
163
180
  };
@@ -0,0 +1,39 @@
1
+ const fs = require('fs').promises;
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { GENT_DIR } = require('./constants');
5
+
6
+ function getEnvPath() {
7
+ return path.join(os.homedir(), GENT_DIR, '.env');
8
+ }
9
+
10
+ function validateApiKey(value) {
11
+ return typeof value === 'string' && /^sk-[A-Za-z0-9_-]{20,}$/.test(value.trim());
12
+ }
13
+
14
+ async function saveApiKey(value) {
15
+ const apiKey = value.trim();
16
+ if (!validateApiKey(apiKey)) throw new Error('Enter a valid OpenAI API key beginning with sk-.');
17
+
18
+ const envPath = getEnvPath();
19
+ const directory = path.dirname(envPath);
20
+ const existing = await fs.readFile(envPath, 'utf8').catch(error => {
21
+ if (error.code === 'ENOENT') return '';
22
+ throw error;
23
+ });
24
+ const lines = existing
25
+ .split(/\r?\n/)
26
+ .filter(line => !/^\s*OPENAI_API_KEY\s*=/.test(line));
27
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
28
+ lines.push(`OPENAI_API_KEY=${apiKey}`);
29
+
30
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
31
+ const temporary = `${envPath}.tmp-${process.pid}`;
32
+ await fs.writeFile(temporary, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
33
+ await fs.rename(temporary, envPath);
34
+ await fs.chmod(envPath, 0o600);
35
+ process.env.OPENAI_API_KEY = apiKey;
36
+ return envPath;
37
+ }
38
+
39
+ module.exports = { getEnvPath, validateApiKey, saveApiKey };