gent-cli 24.0.0 → 25.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/QUICKSTART.md +6 -5
- package/README.md +7 -6
- package/package.json +3 -3
- package/src/commands/ai.js +15 -30
- package/src/commands/ask.js +4 -1
- package/src/commands/canonical.js +73 -14
- package/src/commands/changelog.js +2 -0
- package/src/commands/chat.js +80 -0
- package/src/commands/commit.js +1 -0
- package/src/commands/config.js +1 -16
- package/src/commands/docs.js +2 -0
- package/src/commands/doctor.js +12 -12
- package/src/commands/explain.js +3 -3
- package/src/commands/merge.js +25 -3
- package/src/commands/resolve.js +30 -6
- package/src/commands/review.js +4 -3
- package/src/commands/setup.js +2 -52
- package/src/commands/summary.js +5 -1
- package/src/index.js +20 -11
- package/src/utils/ai-service.js +86 -163
- package/src/utils/user-config.js +3 -33
package/src/commands/resolve.js
CHANGED
|
@@ -31,6 +31,7 @@ const { generateCommitHash } = require('../utils/helpers');
|
|
|
31
31
|
const authStorage = require('../utils/auth-storage');
|
|
32
32
|
const journal = require('../utils/journal');
|
|
33
33
|
const ai = require('../utils/ai-service');
|
|
34
|
+
const reviewCommand = require('./review');
|
|
34
35
|
|
|
35
36
|
async function resolve(options = {}) {
|
|
36
37
|
try {
|
|
@@ -64,6 +65,19 @@ async function resolve(options = {}) {
|
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
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
|
+
|
|
67
81
|
console.log(chalk.bold.cyan(`\nResolving merge of '${mergeState.sourceBranch}' — ${markerFiles.length} file(s)\n`));
|
|
68
82
|
|
|
69
83
|
// Working copy of merged tree entries (we patch hashes as files resolve).
|
|
@@ -126,23 +140,28 @@ async function resolve(options = {}) {
|
|
|
126
140
|
|
|
127
141
|
if (unresolvedFiles > 0) {
|
|
128
142
|
console.log(chalk.yellow(`\n${unresolvedFiles} file(s) still have conflicts. Re-run "gent resolve" when ready.`));
|
|
143
|
+
process.exitCode = 1;
|
|
129
144
|
return;
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
// All conflicts resolved — offer to finalize the merge commit.
|
|
133
|
-
const
|
|
148
|
+
const finalize = options.ai || (await inquirer.prompt([{
|
|
134
149
|
type: 'confirm',
|
|
135
150
|
name: 'finalize',
|
|
136
151
|
message: 'All conflicts resolved. Create the merge commit now?',
|
|
137
152
|
default: true
|
|
138
|
-
}]);
|
|
153
|
+
}])).finalize;
|
|
139
154
|
|
|
140
155
|
if (!finalize) {
|
|
141
156
|
console.log(chalk.cyan('Resolved files staged. Run "gent commit" when ready.'));
|
|
142
157
|
return;
|
|
143
158
|
}
|
|
144
159
|
|
|
145
|
-
await finalizeMerge(gentPath, staging, mergeState, entriesByName);
|
|
160
|
+
const mergeCommit = await finalizeMerge(gentPath, staging, mergeState, entriesByName);
|
|
161
|
+
if (options.ai) {
|
|
162
|
+
console.log(chalk.bold.cyan('\nAI review of the completed merge'));
|
|
163
|
+
await reviewCommand(mergeCommit.hash, { head: true });
|
|
164
|
+
}
|
|
146
165
|
} catch (error) {
|
|
147
166
|
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
148
167
|
console.error(chalk.red('Error: Not a gent repository'));
|
|
@@ -177,9 +196,9 @@ async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
|
177
196
|
choices.push({ name: 'Skip the rest of this file', value: 'skip' });
|
|
178
197
|
|
|
179
198
|
if (options.ai) {
|
|
180
|
-
const suggestion = await askAiForHunk(seg, file);
|
|
199
|
+
const suggestion = await askAiForHunk(seg, file, true);
|
|
181
200
|
if (suggestion !== null) return suggestion;
|
|
182
|
-
|
|
201
|
+
return null;
|
|
183
202
|
}
|
|
184
203
|
|
|
185
204
|
const { choice } = await inquirer.prompt([{
|
|
@@ -212,13 +231,17 @@ async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
|
212
231
|
}
|
|
213
232
|
}
|
|
214
233
|
|
|
215
|
-
async function askAiForHunk(seg, file) {
|
|
234
|
+
async function askAiForHunk(seg, file, autoAccept = false) {
|
|
216
235
|
try {
|
|
217
236
|
const suggestion = await ai.resolveConflictHunk({
|
|
218
237
|
ours: seg.ours.join('\n'),
|
|
219
238
|
theirs: seg.theirs.join('\n'),
|
|
220
239
|
fileName: file
|
|
221
240
|
});
|
|
241
|
+
if (autoAccept) {
|
|
242
|
+
console.log(chalk.green(` ✓ AI resolved ${file}`));
|
|
243
|
+
return suggestion.split('\n');
|
|
244
|
+
}
|
|
222
245
|
console.log(chalk.cyan(' AI suggestion (review before accepting):'));
|
|
223
246
|
suggestion.split('\n').forEach(line => console.log(chalk.cyan(` ${line}`)));
|
|
224
247
|
const { accept } = await inquirer.prompt([{
|
|
@@ -297,6 +320,7 @@ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
|
|
|
297
320
|
await writeJSON(path.join(gentPath, STAGING_FILE), staging);
|
|
298
321
|
|
|
299
322
|
console.log(chalk.green(`\n✓ Merge committed — ${mergeCommit.hash.substring(0, 7)}`));
|
|
323
|
+
return mergeCommit;
|
|
300
324
|
}
|
|
301
325
|
|
|
302
326
|
module.exports = resolve;
|
package/src/commands/review.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* gent review <ref> → review diff for that commit
|
|
8
8
|
*
|
|
9
9
|
* Output: prioritized bug/risk list followed by smaller polish suggestions.
|
|
10
|
-
* Without
|
|
10
|
+
* Without local AI, prints the raw diff so the command still has value.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const path = require('path');
|
|
@@ -73,6 +73,7 @@ async function review(refArg, options = {}) {
|
|
|
73
73
|
|
|
74
74
|
console.log(chalk.bold.cyan(`\n${title}\n`));
|
|
75
75
|
|
|
76
|
+
await ai.prime();
|
|
76
77
|
if (!ai.isEnabled()) {
|
|
77
78
|
console.log(trimmed);
|
|
78
79
|
console.log(chalk.gray(`\n${ai.disabledHint()}`));
|
|
@@ -82,6 +83,7 @@ async function review(refArg, options = {}) {
|
|
|
82
83
|
const spinner = ora(`Reviewing with ${ai.getModel()}...`).start();
|
|
83
84
|
try {
|
|
84
85
|
const out = await ai.complete({
|
|
86
|
+
profile: 'review',
|
|
85
87
|
system:
|
|
86
88
|
'You are a senior code reviewer. Given a unified diff, list concrete ' +
|
|
87
89
|
'issues you would block on, then smaller suggestions. Format:\n' +
|
|
@@ -90,8 +92,7 @@ async function review(refArg, options = {}) {
|
|
|
90
92
|
'🟢 Looks good\n - one-line positive note\n' +
|
|
91
93
|
'Be specific. If nothing is wrong, say so plainly.',
|
|
92
94
|
prompt: `Review this diff:\n\n${trimmed}`,
|
|
93
|
-
maxTokens:
|
|
94
|
-
thinking: true,
|
|
95
|
+
maxTokens: 800,
|
|
95
96
|
});
|
|
96
97
|
spinner.stop();
|
|
97
98
|
console.log(out + '\n');
|
package/src/commands/setup.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* gent setup
|
|
5
5
|
*
|
|
6
|
-
* Walks the user through: backend URL → login/register →
|
|
6
|
+
* Walks the user through: backend URL → login/register → identity.
|
|
7
7
|
* Each step is skippable; nothing is required.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -15,7 +15,6 @@ const axios = require('axios');
|
|
|
15
15
|
const userConfig = require('../utils/user-config');
|
|
16
16
|
const authStorage = require('../utils/auth-storage');
|
|
17
17
|
const authService = require('../services/auth-service');
|
|
18
|
-
const ai = require('../utils/ai-service');
|
|
19
18
|
|
|
20
19
|
async function setup() {
|
|
21
20
|
console.log(boxen(
|
|
@@ -26,7 +25,6 @@ async function setup() {
|
|
|
26
25
|
|
|
27
26
|
await stepBackend();
|
|
28
27
|
await stepAuth();
|
|
29
|
-
await stepAiKey();
|
|
30
28
|
await stepIdentity();
|
|
31
29
|
|
|
32
30
|
console.log(chalk.green('\n✓ Setup complete!'));
|
|
@@ -125,56 +123,8 @@ async function stepAuth() {
|
|
|
125
123
|
}
|
|
126
124
|
}
|
|
127
125
|
|
|
128
|
-
async function stepAiKey() {
|
|
129
|
-
console.log(chalk.bold('\n3. AI features (optional)'));
|
|
130
|
-
const existing = await ai.resolveKey();
|
|
131
|
-
if (existing.value) {
|
|
132
|
-
console.log(chalk.gray(` Key already configured [${existing.source}] — skipping.`));
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
console.log(chalk.gray(' Gent uses Anthropic Claude for commit-message suggestions,'));
|
|
137
|
-
console.log(chalk.gray(' diff explanations, AI conflict resolution, code review, and more.'));
|
|
138
|
-
console.log(chalk.gray(' Get a key at: https://console.anthropic.com/settings/keys'));
|
|
139
|
-
|
|
140
|
-
const { provide } = await inquirer.prompt([{
|
|
141
|
-
type: 'confirm',
|
|
142
|
-
name: 'provide',
|
|
143
|
-
message: 'Add an Anthropic API key now?',
|
|
144
|
-
default: true,
|
|
145
|
-
}]);
|
|
146
|
-
if (!provide) return;
|
|
147
|
-
|
|
148
|
-
const { key } = await inquirer.prompt([{
|
|
149
|
-
type: 'password',
|
|
150
|
-
name: 'key',
|
|
151
|
-
message: 'Anthropic API key:',
|
|
152
|
-
mask: '*',
|
|
153
|
-
validate: (v) => v.length > 0 || 'Cannot be empty',
|
|
154
|
-
}]);
|
|
155
|
-
|
|
156
|
-
await userConfig.set('ai.api_key', key);
|
|
157
|
-
|
|
158
|
-
const { testNow } = await inquirer.prompt([{
|
|
159
|
-
type: 'confirm',
|
|
160
|
-
name: 'testNow',
|
|
161
|
-
message: 'Test the key now (1 small request)?',
|
|
162
|
-
default: true,
|
|
163
|
-
}]);
|
|
164
|
-
if (testNow) {
|
|
165
|
-
const spinner = ora('Asking Claude to say hi...').start();
|
|
166
|
-
try {
|
|
167
|
-
await ai.complete({ prompt: 'Reply with the single word: ok', maxTokens: 4 });
|
|
168
|
-
spinner.succeed(chalk.green('AI key works'));
|
|
169
|
-
} catch (err) {
|
|
170
|
-
spinner.fail(chalk.red(err.message));
|
|
171
|
-
console.log(chalk.yellow(' You can fix this with `gent config set ai.api_key <key>`.'));
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
126
|
async function stepIdentity() {
|
|
177
|
-
console.log(chalk.bold('\
|
|
127
|
+
console.log(chalk.bold('\n3. Default identity'));
|
|
178
128
|
const currentName = await userConfig.getResolved('user.name');
|
|
179
129
|
const currentEmail = await userConfig.getResolved('user.email');
|
|
180
130
|
|
package/src/commands/summary.js
CHANGED
|
@@ -150,12 +150,16 @@ async function summary(options = {}) {
|
|
|
150
150
|
}));
|
|
151
151
|
|
|
152
152
|
if (options.ai) {
|
|
153
|
+
await ai.prime();
|
|
153
154
|
if (!ai.isEnabled()) {
|
|
154
155
|
console.log(chalk.gray(ai.disabledHint()));
|
|
155
156
|
} else {
|
|
156
157
|
try {
|
|
157
158
|
const facts = lines.join('\n').replace(/\[[0-9;]*m/g, ''); // strip colors
|
|
158
|
-
const narrative = await ai.explainChanges(
|
|
159
|
+
const narrative = await ai.explainChanges(
|
|
160
|
+
`Repository stats:\n${facts}\n\nGive a 2-3 sentence health assessment.`,
|
|
161
|
+
'summary',
|
|
162
|
+
);
|
|
159
163
|
console.log(chalk.cyan(narrative));
|
|
160
164
|
} catch (err) {
|
|
161
165
|
console.log(chalk.yellow(`AI summary failed: ${err.message}`));
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* History: commit, log, show, tag, explain
|
|
14
14
|
* Branching: branch, checkout, merge, resolve, stash
|
|
15
15
|
* Safety: undo, redo
|
|
16
|
-
* Insight: summary, ask, review, docs, changelog
|
|
16
|
+
* Insight: summary, ask, chat, review, docs, changelog
|
|
17
17
|
* Remote: remote, repos, members, push, pull, search, web, share
|
|
18
18
|
* Auth: register, login, logout, whoami, password
|
|
19
19
|
* AI: ai (status|test|models)
|
|
@@ -72,6 +72,7 @@ const doctorCommand = require('./commands/doctor');
|
|
|
72
72
|
const setupCommand = require('./commands/setup');
|
|
73
73
|
const aiCommand = require('./commands/ai');
|
|
74
74
|
const askCommand = route('ask', require('./commands/ask'));
|
|
75
|
+
const chatCommand = require('./commands/chat');
|
|
75
76
|
const reviewCommand = route('review', require('./commands/review'));
|
|
76
77
|
const docsCommand = route('docs', require('./commands/docs'));
|
|
77
78
|
const changelogCommand = route('changelog', require('./commands/changelog'));
|
|
@@ -188,7 +189,7 @@ program
|
|
|
188
189
|
.description('Record changes to the repository')
|
|
189
190
|
.option('-m, --message <message>', 'Commit message')
|
|
190
191
|
.option('-a, --all', 'Automatically stage all modified files')
|
|
191
|
-
.option('--ai', 'Suggest a commit message with
|
|
192
|
+
.option('--ai', 'Suggest a commit message with local Gent AI')
|
|
192
193
|
.action(commitCommand);
|
|
193
194
|
|
|
194
195
|
program
|
|
@@ -222,7 +223,7 @@ program
|
|
|
222
223
|
program
|
|
223
224
|
.command('summary')
|
|
224
225
|
.description('Show a repository health & statistics dashboard')
|
|
225
|
-
.option('--ai', 'Add an AI-written health narrative
|
|
226
|
+
.option('--ai', 'Add an AI-written health narrative')
|
|
226
227
|
.action(summaryCommand);
|
|
227
228
|
|
|
228
229
|
// ─── Branching & Merging ────────────────────────────────
|
|
@@ -258,6 +259,7 @@ program
|
|
|
258
259
|
.command('merge [branch]')
|
|
259
260
|
.description('Merge a branch into the current branch (3-way smart merge)')
|
|
260
261
|
.option('-m, --message <message>', 'Merge commit message')
|
|
262
|
+
.option('--ai', 'Resolve conflicts with AI, commit, then review the merge')
|
|
261
263
|
.option('--continue', 'Finish a resolved canonical merge')
|
|
262
264
|
.option('--abort', 'Abort a canonical merge')
|
|
263
265
|
.action(async (branch, options) => {
|
|
@@ -284,7 +286,7 @@ program
|
|
|
284
286
|
program
|
|
285
287
|
.command('resolve')
|
|
286
288
|
.description('Interactively resolve merge conflicts left by "gent merge"')
|
|
287
|
-
.option('--ai', '
|
|
289
|
+
.option('--ai', 'Resolve every text conflict with AI, commit, then review')
|
|
288
290
|
.action(resolveCommand);
|
|
289
291
|
|
|
290
292
|
program
|
|
@@ -352,18 +354,18 @@ program
|
|
|
352
354
|
|
|
353
355
|
program
|
|
354
356
|
.command('setup')
|
|
355
|
-
.description('Interactive first-run wizard (backend URL, login,
|
|
357
|
+
.description('Interactive first-run wizard (backend URL, login, identity)')
|
|
356
358
|
.action(setupCommand);
|
|
357
359
|
|
|
358
360
|
program
|
|
359
361
|
.command('config [subcommand] [args...]')
|
|
360
|
-
.description('Manage CLI settings (list|get|set|unset|path)
|
|
362
|
+
.description('Manage CLI settings (list|get|set|unset|path)')
|
|
361
363
|
.action(configCommand);
|
|
362
364
|
|
|
363
365
|
program
|
|
364
366
|
.command('doctor')
|
|
365
|
-
.description('Run a health check across node, repo, auth, backend, and AI
|
|
366
|
-
.option('--ai', 'Also live-test
|
|
367
|
+
.description('Run a health check across node, repo, auth, backend, and Gent AI')
|
|
368
|
+
.option('--ai', 'Also live-test Gent AI with a tiny request')
|
|
367
369
|
.action(doctorCommand);
|
|
368
370
|
|
|
369
371
|
program
|
|
@@ -375,7 +377,7 @@ program
|
|
|
375
377
|
|
|
376
378
|
program
|
|
377
379
|
.command('ask [question]')
|
|
378
|
-
.description('Ask
|
|
380
|
+
.description('Ask local Gent AI a question about this repo')
|
|
379
381
|
.action(async (question, options) => {
|
|
380
382
|
if (!question && interactive.isInteractive()) {
|
|
381
383
|
question = await interactive.promptAsk();
|
|
@@ -390,6 +392,11 @@ program
|
|
|
390
392
|
.option('--head', 'Force review of HEAD commit')
|
|
391
393
|
.action(reviewCommand);
|
|
392
394
|
|
|
395
|
+
program
|
|
396
|
+
.command('chat [message]')
|
|
397
|
+
.description('Chat with Gent AI about the current repository')
|
|
398
|
+
.action(chatCommand);
|
|
399
|
+
|
|
393
400
|
program
|
|
394
401
|
.command('docs')
|
|
395
402
|
.description('Generate a README.md draft for this repo using AI')
|
|
@@ -552,7 +559,7 @@ function showQuickstart() {
|
|
|
552
559
|
console.log(chalk.gray('A Git-like VCS with cloud sync + AI superpowers.\n'));
|
|
553
560
|
console.log(chalk.bold('First time? Try:'));
|
|
554
561
|
console.log(` ${chalk.cyan('gent auto')} ${chalk.gray('guided init → commit → push (interactive)')}`);
|
|
555
|
-
console.log(` ${chalk.cyan('gent setup')} ${chalk.gray('configure login +
|
|
562
|
+
console.log(` ${chalk.cyan('gent setup')} ${chalk.gray('configure login + identity + remote')}`);
|
|
556
563
|
console.log(` ${chalk.cyan('gent doctor')} ${chalk.gray('check everything is wired up')}`);
|
|
557
564
|
console.log(` ${chalk.cyan('gent template list')} ${chalk.gray('scaffold a starter project')}`);
|
|
558
565
|
console.log();
|
|
@@ -560,9 +567,11 @@ function showQuickstart() {
|
|
|
560
567
|
console.log(` ${chalk.cyan('gent init && gent add -A && gent commit -m "init"')}`);
|
|
561
568
|
console.log(` ${chalk.cyan('gent push')} / ${chalk.cyan('gent pull')} / ${chalk.cyan('gent merge <branch>')}`);
|
|
562
569
|
console.log();
|
|
563
|
-
console.log(chalk.bold('AI features
|
|
570
|
+
console.log(chalk.bold('Local AI features:'));
|
|
571
|
+
console.log(` ${chalk.cyan('gent chat')} ${chalk.gray('interactive repository chat')}`);
|
|
564
572
|
console.log(` ${chalk.cyan('gent ask "what does this repo do?"')}`);
|
|
565
573
|
console.log(` ${chalk.cyan('gent review')} ${chalk.gray('review staged changes')}`);
|
|
574
|
+
console.log(` ${chalk.cyan('gent merge dev --ai')} ${chalk.gray('resolve, commit, then review')}`);
|
|
566
575
|
console.log(` ${chalk.cyan('gent docs --write')} ${chalk.gray('generate README.md')}`);
|
|
567
576
|
console.log(` ${chalk.cyan('gent changelog')} ${chalk.gray('grouped release notes')}`);
|
|
568
577
|
console.log();
|
package/src/utils/ai-service.js
CHANGED
|
@@ -1,209 +1,131 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================================
|
|
3
|
-
* AI Service - Optional, key-gated Claude integration (hybrid layer)
|
|
4
|
-
* ============================================================================
|
|
5
|
-
*
|
|
6
|
-
* PURPOSE:
|
|
7
|
-
* Power Gent's *optional* "smart" features (commit-message suggestions, diff
|
|
8
|
-
* explanations, AI-assisted conflict resolution). Every feature has a
|
|
9
|
-
* reliable algorithmic path; this layer only activates when the user has set
|
|
10
|
-
* an API key, and degrades gracefully (never throws into a command) when it
|
|
11
|
-
* is absent or the request fails.
|
|
12
|
-
*
|
|
13
|
-
* ENABLEMENT:
|
|
14
|
-
* Either set ANTHROPIC_API_KEY in the environment, OR save it once with
|
|
15
|
-
* `gent config set ai.api_key <key>` (stored in ~/.gent/cli-config.json).
|
|
16
|
-
* Optionally pick a model with GENT_AI_MODEL or `gent config set ai.model`.
|
|
17
|
-
* Default model: claude-opus-4-7. For a cheaper / faster option try
|
|
18
|
-
* claude-haiku-4-5 or claude-sonnet-4-6.
|
|
19
|
-
*
|
|
20
|
-
* IMPLEMENTATION NOTE:
|
|
21
|
-
* Calls the Anthropic Messages API (POST /v1/messages) directly over the
|
|
22
|
-
* project's existing `axios` dependency, to honour Gent's "no new runtime
|
|
23
|
-
* dependencies" constraint. A production app would normally use the official
|
|
24
|
-
* `@anthropic-ai/sdk`; raw HTTP is a deliberate trade-off here because the AI
|
|
25
|
-
* layer is optional and self-contained.
|
|
26
|
-
*
|
|
27
|
-
* ============================================================================
|
|
28
|
-
*/
|
|
1
|
+
/** Direct, low-latency OpenAI client for Gent's local AI commands. */
|
|
29
2
|
|
|
30
3
|
const axios = require('axios');
|
|
31
|
-
const userConfig = require('./user-config');
|
|
32
4
|
|
|
33
|
-
const API_URL = 'https://api.
|
|
34
|
-
const
|
|
35
|
-
const DEFAULT_MODEL = 'claude-opus-4-7';
|
|
5
|
+
const API_URL = 'https://api.openai.com/v1/responses';
|
|
6
|
+
const DEFAULT_MODEL = 'gpt-4.1-mini';
|
|
36
7
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
8
|
+
const PROMPTS = Object.freeze({
|
|
9
|
+
chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
|
|
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 the final merged text with no markdown fence or explanation.',
|
|
12
|
+
commit: 'Write one concise conventional commit message. Return only the message.',
|
|
13
|
+
explain: 'Explain this change briefly and concretely. Return short bullets only.',
|
|
14
|
+
docs: 'Write concise, accurate repository documentation from only the supplied context.',
|
|
15
|
+
changelog: 'Create a concise user-facing changelog. Group related changes and omit filler.',
|
|
16
|
+
summary: 'Give a concise repository health assessment with the most important risk first.',
|
|
17
|
+
});
|
|
41
18
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return { value: _resolvedKey, source: _resolvedKeySource };
|
|
45
|
-
}
|
|
46
|
-
const r = await userConfig.getResolved('ai.api_key');
|
|
47
|
-
_resolvedKey = r.value || null;
|
|
48
|
-
_resolvedKeySource = r.source;
|
|
49
|
-
return { value: _resolvedKey, source: _resolvedKeySource };
|
|
19
|
+
function getApiKey() {
|
|
20
|
+
return process.env.OPENAI_API_KEY || null;
|
|
50
21
|
}
|
|
51
22
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const r = await userConfig.getResolved('ai.model');
|
|
55
|
-
_resolvedModel = r.value || DEFAULT_MODEL;
|
|
56
|
-
return _resolvedModel;
|
|
23
|
+
function getModel() {
|
|
24
|
+
return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
|
|
57
25
|
}
|
|
58
26
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* or falls back to env-only (the original behavior) on cold start.
|
|
62
|
-
*/
|
|
63
|
-
function getApiKey() {
|
|
64
|
-
if (_resolvedKey !== undefined) return _resolvedKey;
|
|
65
|
-
return process.env.ANTHROPIC_API_KEY || null;
|
|
27
|
+
function getApiUrl() {
|
|
28
|
+
return process.env.GENT_AI_API_URL || API_URL;
|
|
66
29
|
}
|
|
67
30
|
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
return
|
|
31
|
+
async function resolveKey() {
|
|
32
|
+
const value = getApiKey();
|
|
33
|
+
return { value, source: value ? 'local Gent installation' : 'unset' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resolveModel() {
|
|
37
|
+
return getModel();
|
|
71
38
|
}
|
|
72
39
|
|
|
73
|
-
/**
|
|
74
|
-
* Async pre-flight resolver — call once from a command before doing AI work
|
|
75
|
-
* so isEnabled()/getModel() see the user-config values even if env is empty.
|
|
76
|
-
*/
|
|
77
40
|
async function prime() {
|
|
78
|
-
|
|
79
|
-
await resolveModel();
|
|
41
|
+
return resolveKey();
|
|
80
42
|
}
|
|
81
43
|
|
|
82
44
|
function isEnabled() {
|
|
83
|
-
return
|
|
45
|
+
return Boolean(getApiKey());
|
|
84
46
|
}
|
|
85
47
|
|
|
86
48
|
function disabledHint() {
|
|
87
|
-
return 'AI
|
|
49
|
+
return 'Gent AI is unavailable in this CLI installation.';
|
|
88
50
|
}
|
|
89
51
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// didn't prime() first.
|
|
101
|
-
await prime();
|
|
52
|
+
function extractText(payload) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (const item of payload?.output || []) {
|
|
55
|
+
if (item.type !== 'message') continue;
|
|
56
|
+
for (const content of item.content || []) {
|
|
57
|
+
if (content.type === 'output_text' && content.text) chunks.push(content.text);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return chunks.join('').trim();
|
|
61
|
+
}
|
|
102
62
|
|
|
63
|
+
async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
|
|
103
64
|
const apiKey = getApiKey();
|
|
104
|
-
if (!apiKey) throw new Error(
|
|
105
|
-
|
|
106
|
-
const body = {
|
|
107
|
-
model: getModel(),
|
|
108
|
-
max_tokens: maxTokens,
|
|
109
|
-
messages: [{ role: 'user', content: prompt }]
|
|
110
|
-
};
|
|
111
|
-
if (system) body.system = system;
|
|
112
|
-
// Adaptive thinking — opt-in per caller. We leave display at the API
|
|
113
|
-
// default ("omitted") so reasoning never leaks into CLI output; this just
|
|
114
|
-
// lets the model think harder on complex tasks (review, conflict resolve)
|
|
115
|
-
// without changing what the user sees.
|
|
116
|
-
if (thinking) body.thinking = { type: 'adaptive' };
|
|
65
|
+
if (!apiKey) throw new Error(disabledHint());
|
|
117
66
|
|
|
67
|
+
const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
|
|
118
68
|
try {
|
|
119
|
-
const
|
|
69
|
+
const response = await axios.post(getApiUrl(), {
|
|
70
|
+
model: getModel(),
|
|
71
|
+
input: prompt,
|
|
72
|
+
instructions,
|
|
73
|
+
max_output_tokens: maxTokens,
|
|
74
|
+
store: false,
|
|
75
|
+
}, {
|
|
120
76
|
headers: {
|
|
121
|
-
|
|
122
|
-
'
|
|
123
|
-
'content-type': 'application/json'
|
|
77
|
+
Authorization: `Bearer ${apiKey}`,
|
|
78
|
+
'Content-Type': 'application/json',
|
|
124
79
|
},
|
|
125
|
-
timeout:
|
|
80
|
+
timeout: 30000,
|
|
126
81
|
});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.join('')
|
|
133
|
-
.trim();
|
|
134
|
-
} catch (err) {
|
|
135
|
-
throw enrichAiError(err);
|
|
82
|
+
const text = extractText(response.data);
|
|
83
|
+
if (!text) throw new Error('OpenAI returned an empty response');
|
|
84
|
+
return text;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw enrichAiError(error);
|
|
136
87
|
}
|
|
137
88
|
}
|
|
138
89
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
if (status === 404 || (apiMsg && /model/i.test(apiMsg))) {
|
|
149
|
-
return new Error(`Anthropic rejected the model "${getModel()}" — set a valid one with \`gent config set ai.model claude-opus-4-7\`.`);
|
|
150
|
-
}
|
|
151
|
-
if (status === 429) {
|
|
152
|
-
return new Error('Anthropic rate-limited the request (429). Retry in a moment or switch to a lighter model.');
|
|
153
|
-
}
|
|
154
|
-
if (apiMsg) return new Error(`AI request failed: ${apiMsg}`);
|
|
155
|
-
return err;
|
|
90
|
+
function enrichAiError(error) {
|
|
91
|
+
const status = error?.response?.status;
|
|
92
|
+
const apiError = error?.response?.data?.error;
|
|
93
|
+
if (status === 401 || status === 403) return new Error('Gent AI credential was rejected.');
|
|
94
|
+
if (apiError?.code === 'insufficient_quota') return new Error('Gent AI quota is exhausted.');
|
|
95
|
+
if (status === 429) return new Error('Gent AI is busy. Retry in a moment.');
|
|
96
|
+
if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
|
|
97
|
+
return error;
|
|
156
98
|
}
|
|
157
99
|
|
|
158
|
-
// ─── High-level helpers ─────────────────────────────────
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Suggest a concise commit message from a staged diff / summary.
|
|
162
|
-
* @param {String} diffSummary
|
|
163
|
-
* @returns {Promise<String>}
|
|
164
|
-
*/
|
|
165
100
|
async function suggestCommitMessage(diffSummary) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return complete({ system, prompt, maxTokens: 512 });
|
|
101
|
+
return complete({ profile: 'commit', prompt: diffSummary, maxTokens: 160 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function explainChanges(content, profile = 'explain') {
|
|
105
|
+
return complete({ profile, prompt: content, maxTokens: 500 });
|
|
172
106
|
}
|
|
173
107
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
'You are a senior engineer explaining a code change to a teammate. Summarize what ' +
|
|
182
|
-
'changed and why it matters in a few short bullet points. Be specific and concise.';
|
|
183
|
-
const prompt = `Explain these changes:\n\n${content}`;
|
|
184
|
-
return complete({ system, prompt, maxTokens: 1024 });
|
|
108
|
+
async function reviewChanges(content, context = '') {
|
|
109
|
+
return complete({
|
|
110
|
+
profile: 'review',
|
|
111
|
+
system: context,
|
|
112
|
+
prompt: content,
|
|
113
|
+
maxTokens: 800,
|
|
114
|
+
});
|
|
185
115
|
}
|
|
186
116
|
|
|
187
|
-
/**
|
|
188
|
-
* Propose a resolution for a single merge-conflict hunk.
|
|
189
|
-
* @param {Object} hunk - { base?, ours, theirs, fileName? }
|
|
190
|
-
* @returns {Promise<String>} the suggested merged text (no conflict markers)
|
|
191
|
-
*/
|
|
192
117
|
async function resolveConflictHunk({ base, ours, theirs, fileName }) {
|
|
193
|
-
const system =
|
|
194
|
-
'You resolve git merge conflicts. Combine the intent of BOTH sides into a single ' +
|
|
195
|
-
'correct version. Reply with ONLY the resolved file section — no conflict markers, ' +
|
|
196
|
-
'no explanation, no markdown fences.';
|
|
197
118
|
const prompt =
|
|
198
|
-
`File: ${fileName || 'unknown'}\n
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return complete({ system, prompt, maxTokens: 2048, thinking: true });
|
|
119
|
+
`File: ${fileName || 'unknown'}\n` +
|
|
120
|
+
`BASE:\n${base || '(none)'}\n\n` +
|
|
121
|
+
`OURS:\n${ours}\n\n` +
|
|
122
|
+
`THEIRS:\n${theirs}`;
|
|
123
|
+
return complete({ profile: 'merge', prompt, maxTokens: 1400 });
|
|
204
124
|
}
|
|
205
125
|
|
|
206
126
|
module.exports = {
|
|
127
|
+
PROMPTS,
|
|
128
|
+
DEFAULT_MODEL,
|
|
207
129
|
isEnabled,
|
|
208
130
|
getModel,
|
|
209
131
|
getApiKey,
|
|
@@ -214,6 +136,7 @@ module.exports = {
|
|
|
214
136
|
complete,
|
|
215
137
|
suggestCommitMessage,
|
|
216
138
|
explainChanges,
|
|
139
|
+
reviewChanges,
|
|
217
140
|
resolveConflictHunk,
|
|
218
|
-
|
|
141
|
+
extractText,
|
|
219
142
|
};
|