gent-cli 26.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 +7 -5
- package/package.json +3 -3
- package/src/commands/ai.js +25 -3
- package/src/commands/canonical.js +3 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/resolve.js +41 -34
- package/src/index.js +1 -1
- package/src/utils/ai-service.js +45 -7
- package/src/utils/local-ai-config.js +39 -0
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
|
-
- **Direct local AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency OpenAI calls from the CLI with task-specific prompts
|
|
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
|
|
504
|
-
|
|
505
|
-
prompts keep review, merge resolution, chat,
|
|
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 #
|
|
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": "
|
|
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 && 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",
|
|
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",
|
package/src/commands/ai.js
CHANGED
|
@@ -1,34 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI Command - Manage and verify AI integration.
|
|
3
3
|
*
|
|
4
|
-
* gent ai
|
|
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
|
|
|
29
51
|
console.log(chalk.bold.cyan('\nGent AI status\n'));
|
|
30
52
|
if (available) {
|
|
31
|
-
console.log(` ${chalk.green('●')} Service: ${chalk.white(
|
|
53
|
+
console.log(` ${chalk.green('●')} Service: ${chalk.white(source)}`);
|
|
32
54
|
} else {
|
|
33
55
|
console.log(` ${chalk.gray('○')} Service: ${chalk.gray('unavailable')}`);
|
|
34
56
|
console.log(chalk.gray(' ↳ ' + ai.disabledHint()));
|
|
@@ -216,7 +216,7 @@ const handlers = {
|
|
|
216
216
|
continue;
|
|
217
217
|
}
|
|
218
218
|
try {
|
|
219
|
-
const
|
|
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,
|
|
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
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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: '
|
|
140
|
+
hint: 'Run `gent ai configure` once on this computer.',
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -145,7 +145,7 @@ async function checkAiKey(probe) {
|
|
|
145
145
|
return {
|
|
146
146
|
name: 'AI service',
|
|
147
147
|
status: 'pass',
|
|
148
|
-
detail:
|
|
148
|
+
detail: `${source}, model: ${await ai.resolveModel()} (use --ai to live-test)`,
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
|
@@ -161,7 +161,7 @@ async function checkAiKey(probe) {
|
|
|
161
161
|
name: 'AI service',
|
|
162
162
|
status: 'fail',
|
|
163
163
|
detail: err.message,
|
|
164
|
-
hint: '
|
|
164
|
+
hint: 'Run `gent ai configure` to replace the key, or check the OpenAI project quota.',
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
}
|
package/src/commands/resolve.js
CHANGED
|
@@ -65,19 +65,6 @@ async function resolve(options = {}) {
|
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
if (!options.ai && ai.isEnabled() && process.stdin.isTTY && process.stdout.isTTY) {
|
|
69
|
-
const { mode } = await inquirer.prompt([{
|
|
70
|
-
type: 'list',
|
|
71
|
-
name: 'mode',
|
|
72
|
-
message: 'How should Gent resolve this merge?',
|
|
73
|
-
choices: [
|
|
74
|
-
{ name: 'Merge with AI (fast) — resolve, commit, then review', value: 'ai' },
|
|
75
|
-
{ name: 'Resolve manually — choose each conflict', value: 'manual' },
|
|
76
|
-
],
|
|
77
|
-
}]);
|
|
78
|
-
options.ai = mode === 'ai';
|
|
79
|
-
}
|
|
80
|
-
|
|
81
68
|
console.log(chalk.bold.cyan(`\nResolving merge of '${mergeState.sourceBranch}' — ${markerFiles.length} file(s)\n`));
|
|
82
69
|
|
|
83
70
|
// Working copy of merged tree entries (we patch hashes as files resolve).
|
|
@@ -106,6 +93,7 @@ async function resolve(options = {}) {
|
|
|
106
93
|
let idx = 0;
|
|
107
94
|
let aborted = false;
|
|
108
95
|
const out = [];
|
|
96
|
+
const aiSummaries = [];
|
|
109
97
|
|
|
110
98
|
for (const seg of segments) {
|
|
111
99
|
if (seg.type === 'text') {
|
|
@@ -113,7 +101,7 @@ async function resolve(options = {}) {
|
|
|
113
101
|
continue;
|
|
114
102
|
}
|
|
115
103
|
idx++;
|
|
116
|
-
const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options);
|
|
104
|
+
const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options, aiSummaries);
|
|
117
105
|
if (resolvedLines === null) { aborted = true; break; }
|
|
118
106
|
out.push(...resolvedLines);
|
|
119
107
|
}
|
|
@@ -133,6 +121,9 @@ async function resolve(options = {}) {
|
|
|
133
121
|
} else {
|
|
134
122
|
await stageResolved(gentPath, staging, entriesByName, file, resolvedContent);
|
|
135
123
|
console.log(chalk.green(` ✓ resolved ${file}`));
|
|
124
|
+
if (aiSummaries.length) {
|
|
125
|
+
console.log(chalk.gray(` AI: ${summarizeFileChanges(aiSummaries)}`));
|
|
126
|
+
}
|
|
136
127
|
}
|
|
137
128
|
}
|
|
138
129
|
|
|
@@ -177,26 +168,17 @@ async function resolve(options = {}) {
|
|
|
177
168
|
* Prompt for one conflict hunk. Returns the chosen lines, or null to abort
|
|
178
169
|
* (leave the rest of the file as-is with markers).
|
|
179
170
|
*/
|
|
180
|
-
async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
171
|
+
async function resolveHunk(seg, file, idx, total, options = {}, aiSummaries = []) {
|
|
181
172
|
console.log(chalk.gray(` Conflict ${idx}/${total}:`));
|
|
182
173
|
console.log(chalk.green(' <<< ours'));
|
|
183
174
|
seg.ours.forEach(l => console.log(chalk.green(` ${l}`)));
|
|
184
175
|
console.log(chalk.red(' >>> theirs'));
|
|
185
176
|
seg.theirs.forEach(l => console.log(chalk.red(` ${l}`)));
|
|
186
177
|
|
|
187
|
-
const choices =
|
|
188
|
-
{ name: 'Keep ours', value: 'ours' },
|
|
189
|
-
{ name: 'Keep theirs', value: 'theirs' },
|
|
190
|
-
{ name: 'Keep both (ours then theirs)', value: 'both' },
|
|
191
|
-
{ name: 'Edit manually', value: 'edit' }
|
|
192
|
-
];
|
|
193
|
-
if (ai.isEnabled()) {
|
|
194
|
-
choices.splice(3, 0, { name: `Ask AI (${ai.getModel()})`, value: 'ai' });
|
|
195
|
-
}
|
|
196
|
-
choices.push({ name: 'Skip the rest of this file', value: 'skip' });
|
|
178
|
+
const choices = resolutionChoices();
|
|
197
179
|
|
|
198
180
|
if (options.ai) {
|
|
199
|
-
const suggestion = await askAiForHunk(seg, file, true);
|
|
181
|
+
const suggestion = await askAiForHunk(seg, file, true, aiSummaries);
|
|
200
182
|
if (suggestion !== null) return suggestion;
|
|
201
183
|
return null;
|
|
202
184
|
}
|
|
@@ -223,40 +205,64 @@ async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
|
223
205
|
return text.replace(/\n$/, '').split('\n');
|
|
224
206
|
}
|
|
225
207
|
case 'ai': {
|
|
226
|
-
const suggestion = await askAiForHunk(seg, file);
|
|
208
|
+
const suggestion = await askAiForHunk(seg, file, false, aiSummaries);
|
|
227
209
|
if (suggestion !== null) return suggestion;
|
|
228
|
-
return resolveHunk(seg, file, idx, total, { ai: false });
|
|
210
|
+
return resolveHunk(seg, file, idx, total, { ai: false }, aiSummaries);
|
|
229
211
|
}
|
|
230
212
|
default: return seg.ours;
|
|
231
213
|
}
|
|
232
214
|
}
|
|
233
215
|
|
|
234
|
-
|
|
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 = []) {
|
|
235
228
|
try {
|
|
236
|
-
const
|
|
229
|
+
const resolution = await ai.resolveConflictHunk({
|
|
237
230
|
ours: seg.ours.join('\n'),
|
|
238
231
|
theirs: seg.theirs.join('\n'),
|
|
239
232
|
fileName: file
|
|
240
233
|
});
|
|
241
234
|
if (autoAccept) {
|
|
242
|
-
|
|
243
|
-
return
|
|
235
|
+
aiSummaries.push(resolution.summary);
|
|
236
|
+
return resolution.merged.split('\n');
|
|
244
237
|
}
|
|
245
238
|
console.log(chalk.cyan(' AI suggestion (review before accepting):'));
|
|
246
|
-
|
|
239
|
+
resolution.merged.split('\n').forEach(line => console.log(chalk.cyan(` ${line}`)));
|
|
247
240
|
const { accept } = await inquirer.prompt([{
|
|
248
241
|
type: 'confirm',
|
|
249
242
|
name: 'accept',
|
|
250
243
|
message: 'Use this AI suggestion?',
|
|
251
244
|
default: false,
|
|
252
245
|
}]);
|
|
253
|
-
|
|
246
|
+
if (!accept) return null;
|
|
247
|
+
aiSummaries.push(resolution.summary);
|
|
248
|
+
return resolution.merged.split('\n');
|
|
254
249
|
} catch (error) {
|
|
255
250
|
console.log(chalk.yellow(` AI failed (${error.message}); no file was changed.`));
|
|
256
251
|
return null;
|
|
257
252
|
}
|
|
258
253
|
}
|
|
259
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
|
+
|
|
260
266
|
/** Store the resolved file as a blob, patch the tree entry, and stage it. */
|
|
261
267
|
async function stageResolved(gentPath, staging, entriesByName, file, content) {
|
|
262
268
|
const hash = await storeBlob(gentPath, content);
|
|
@@ -324,3 +330,4 @@ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
|
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
module.exports = resolve;
|
|
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('
|
|
373
|
+
.description('Configure or inspect local AI (configure|status|test|models)')
|
|
374
374
|
.action(aiCommand);
|
|
375
375
|
|
|
376
376
|
// ─── Platform-special (AI-powered) ──────────────────────
|
package/src/utils/ai-service.js
CHANGED
|
@@ -8,7 +8,7 @@ const DEFAULT_MODEL = 'gpt-4.1-mini';
|
|
|
8
8
|
const PROMPTS = Object.freeze({
|
|
9
9
|
chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
|
|
10
10
|
review: 'Review fast. Return only concrete correctness, security, or regression risks, then brief fixes. If none, say "No blocking issues."',
|
|
11
|
-
merge: 'Resolve this merge conflict fast. Preserve both intended behaviors. Return only
|
|
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.',
|
|
12
12
|
commit: 'Write one concise conventional commit message. Return only the message.',
|
|
13
13
|
explain: 'Explain this change briefly and concretely. Return short bullets only.',
|
|
14
14
|
docs: 'Write concise, accurate repository documentation from only the supplied context.',
|
|
@@ -30,7 +30,7 @@ function getApiUrl() {
|
|
|
30
30
|
|
|
31
31
|
async function resolveKey() {
|
|
32
32
|
const value = getApiKey();
|
|
33
|
-
return { value, source: value ? 'local
|
|
33
|
+
return { value, source: value ? 'local CLI configuration' : 'unset' };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
async function resolveModel() {
|
|
@@ -46,7 +46,7 @@ function isEnabled() {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function disabledHint() {
|
|
49
|
-
return 'Gent AI is unavailable
|
|
49
|
+
return 'Gent AI is unavailable. Run `gent ai configure` once on this computer.';
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function extractText(payload) {
|
|
@@ -63,7 +63,6 @@ function extractText(payload) {
|
|
|
63
63
|
async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
|
|
64
64
|
const apiKey = getApiKey();
|
|
65
65
|
if (!apiKey) throw new Error(disabledHint());
|
|
66
|
-
|
|
67
66
|
const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
|
|
68
67
|
try {
|
|
69
68
|
const response = await axios.post(getApiUrl(), {
|
|
@@ -90,10 +89,13 @@ async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 })
|
|
|
90
89
|
function enrichAiError(error) {
|
|
91
90
|
const status = error?.response?.status;
|
|
92
91
|
const apiError = error?.response?.data?.error;
|
|
93
|
-
|
|
94
|
-
if (
|
|
92
|
+
const apiCode = typeof apiError === 'object' ? apiError?.code : null;
|
|
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()}".`);
|
|
95
|
+
if (apiCode === 'insufficient_quota') return new Error('Gent AI quota is exhausted.');
|
|
95
96
|
if (status === 429) return new Error('Gent AI is busy. Retry in a moment.');
|
|
96
97
|
if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
|
|
98
|
+
if (typeof apiError === 'string') return new Error(apiError);
|
|
97
99
|
return error;
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -120,7 +122,42 @@ async function resolveConflictHunk({ base, ours, theirs, fileName }) {
|
|
|
120
122
|
`BASE:\n${base || '(none)'}\n\n` +
|
|
121
123
|
`OURS:\n${ours}\n\n` +
|
|
122
124
|
`THEIRS:\n${theirs}`;
|
|
123
|
-
|
|
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()}...`;
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
module.exports = {
|
|
@@ -138,5 +175,6 @@ module.exports = {
|
|
|
138
175
|
explainChanges,
|
|
139
176
|
reviewChanges,
|
|
140
177
|
resolveConflictHunk,
|
|
178
|
+
parseMergeResolution,
|
|
141
179
|
extractText,
|
|
142
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 };
|