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.
@@ -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 { finalize } = await inquirer.prompt([{
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
- console.log(chalk.yellow(' Choose a manual resolution instead.'));
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;
@@ -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 an AI key, prints the raw diff so the command still has value.
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: 1500,
94
- thinking: true,
95
+ maxTokens: 800,
95
96
  });
96
97
  spinner.stop();
97
98
  console.log(out + '\n');
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * gent setup
5
5
  *
6
- * Walks the user through: backend URL → login/register → AI key → identity.
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('\n4. Default identity'));
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
 
@@ -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(`Repository stats:\n${facts}\n\nGive a 2-3 sentence health assessment.`);
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 AI (needs ANTHROPIC_API_KEY)')
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 (needs ANTHROPIC_API_KEY)')
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', 'Ask AI for each conflict resolution and review it before applying')
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, AI key, identity)')
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) — e.g. gent config set ai.api_key <key>')
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 key')
366
- .option('--ai', 'Also live-test the AI key with a tiny request')
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 Claude a question about this repo (needs AI key)')
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 + AI key + remote')}`);
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 (need an Anthropic key):'));
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();
@@ -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.anthropic.com/v1/messages';
34
- const API_VERSION = '2023-06-01';
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
- // Per-process cache so repeated AI calls don't keep hitting disk.
38
- let _resolvedKey;
39
- let _resolvedKeySource;
40
- let _resolvedModel;
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
- async function resolveKey() {
43
- if (_resolvedKey !== undefined) {
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
- async function resolveModel() {
53
- if (_resolvedModel) return _resolvedModel;
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
- * Synchronous getter used in hot paths. Returns whatever was last resolved,
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 getModel() {
69
- if (_resolvedModel) return _resolvedModel;
70
- return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
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
- await resolveKey();
79
- await resolveModel();
41
+ return resolveKey();
80
42
  }
81
43
 
82
44
  function isEnabled() {
83
- return !!getApiKey();
45
+ return Boolean(getApiKey());
84
46
  }
85
47
 
86
48
  function disabledHint() {
87
- return 'AI features are off save a key with `gent config set ai.api_key <key>` or set ANTHROPIC_API_KEY in your env.';
49
+ return 'Gent AI is unavailable in this CLI installation.';
88
50
  }
89
51
 
90
- /**
91
- * Low-level single-shot completion. Returns the assistant's text.
92
- * @param {Object} opts
93
- * @param {String} opts.prompt - user content
94
- * @param {String} [opts.system] - system prompt
95
- * @param {Number} [opts.maxTokens]
96
- * @returns {Promise<String>}
97
- */
98
- async function complete({ prompt, system, maxTokens = 1024, thinking = false }) {
99
- // Make sure env/config-stored values are resolved even if the caller
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('AI not enabled');
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 res = await axios.post(API_URL, body, {
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
- 'x-api-key': apiKey,
122
- 'anthropic-version': API_VERSION,
123
- 'content-type': 'application/json'
77
+ Authorization: `Bearer ${apiKey}`,
78
+ 'Content-Type': 'application/json',
124
79
  },
125
- timeout: 60000
80
+ timeout: 30000,
126
81
  });
127
-
128
- const blocks = (res.data && res.data.content) || [];
129
- return blocks
130
- .filter(b => b.type === 'text')
131
- .map(b => b.text)
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
- * Wrap raw Anthropic errors with hints that actually help the user.
141
- */
142
- function enrichAiError(err) {
143
- const status = err?.response?.status;
144
- const apiMsg = err?.response?.data?.error?.message || err?.response?.data?.message;
145
- if (status === 401) {
146
- return new Error('Anthropic rejected the API key (401). Check `gent config get ai.api_key` and try `gent ai test`.');
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
- const system =
167
- 'You write clear, conventional git commit messages. Reply with ONLY the commit ' +
168
- 'message: a concise imperative subject line (<=72 chars), optionally followed by ' +
169
- 'a blank line and short body. No quotes, no preamble, no markdown fences.';
170
- const prompt = `Write a commit message for these staged changes:\n\n${diffSummary}`;
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
- * Explain a commit or diff in plain language.
176
- * @param {String} content - diff or commit details
177
- * @returns {Promise<String>}
178
- */
179
- async function explainChanges(content) {
180
- const system =
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\n` +
199
- `<<<<<<< BASE (common ancestor)\n${base || '(none)'}\n` +
200
- `======= OURS\n${ours}\n` +
201
- `======= THEIRS\n${theirs}\n>>>>>>>\n\n` +
202
- 'Return the merged result for this section.';
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
- DEFAULT_MODEL,
141
+ extractText,
219
142
  };