gent-cli 6.0.1 → 8.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 +116 -85
- package/README.md +60 -3
- package/package.json +6 -4
- package/src/commands/ai.js +82 -0
- package/src/commands/ask.js +121 -0
- package/src/commands/branch.js +3 -0
- package/src/commands/changelog.js +121 -0
- package/src/commands/checkout.js +5 -0
- package/src/commands/clone.js +6 -3
- package/src/commands/commit.js +65 -2
- package/src/commands/config.js +147 -0
- package/src/commands/docs.js +141 -0
- package/src/commands/doctor.js +169 -0
- package/src/commands/explain.js +145 -0
- package/src/commands/log.js +51 -1
- package/src/commands/merge.js +4 -0
- package/src/commands/pull.js +4 -3
- package/src/commands/reset.js +10 -0
- package/src/commands/resolve.js +280 -0
- package/src/commands/review.js +157 -0
- package/src/commands/search.js +77 -0
- package/src/commands/setup.js +201 -0
- package/src/commands/share.js +63 -0
- package/src/commands/summary.js +176 -0
- package/src/commands/template.js +135 -0
- package/src/commands/undo.js +115 -0
- package/src/commands/web.js +72 -0
- package/src/index.js +189 -12
- package/src/utils/ai-service.js +219 -0
- package/src/utils/api-client.js +28 -6
- package/src/utils/constants.js +3 -1
- package/src/utils/diff-engine.js +68 -2
- package/src/utils/env-loader.js +61 -0
- package/src/utils/journal.js +215 -0
- package/src/utils/merge-engine.js +340 -140
- package/src/utils/user-config.js +225 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Command - Open the current repo (or a specific commit/branch) in browser.
|
|
3
|
+
*
|
|
4
|
+
* gent web → open repo page
|
|
5
|
+
* gent web --branch <name> → open a specific branch
|
|
6
|
+
* gent web --commit <hash> → open a specific commit
|
|
7
|
+
* gent web --print → don't launch, just print the URL
|
|
8
|
+
*
|
|
9
|
+
* Builds the URL from the configured api.base_url and the remote's owner_id/repo_name.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { exec } = require('child_process');
|
|
14
|
+
const chalk = require('chalk');
|
|
15
|
+
const { getGentPath, readJSON } = require('../utils/fileSystem');
|
|
16
|
+
const { CONFIG_FILE, parseRemoteUrl } = require('../utils/constants');
|
|
17
|
+
const userConfig = require('../utils/user-config');
|
|
18
|
+
|
|
19
|
+
async function web(options = {}) {
|
|
20
|
+
try {
|
|
21
|
+
const gentPath = await getGentPath();
|
|
22
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
23
|
+
const remote = (config.remotes || {}).origin;
|
|
24
|
+
if (!remote) {
|
|
25
|
+
console.error(chalk.red('No origin remote configured.'));
|
|
26
|
+
console.log(chalk.yellow('Run `gent remote add origin <url>` or `gent init --remote`.'));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const info = parseRemoteUrl(remote.url);
|
|
30
|
+
if (!info) {
|
|
31
|
+
console.error(chalk.red(`Origin URL '${remote.url}' isn't a recognized gent URL.`));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { value: baseUrl } = await userConfig.getResolved('api.base_url');
|
|
36
|
+
// Strip /api suffix if present so we get the web host
|
|
37
|
+
const webHost = baseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
|
|
38
|
+
|
|
39
|
+
let url = `${webHost}/${info.owner_id}/${info.repo_name}`;
|
|
40
|
+
if (options.branch) url += `/tree/${encodeURIComponent(options.branch)}`;
|
|
41
|
+
if (options.commit) url += `/commit/${encodeURIComponent(options.commit)}`;
|
|
42
|
+
|
|
43
|
+
if (options.print) {
|
|
44
|
+
console.log(url);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(chalk.gray(`Opening ${url}`));
|
|
49
|
+
openInBrowser(url);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
52
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
53
|
+
} else {
|
|
54
|
+
console.error(chalk.red('Error:'), error.message);
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function openInBrowser(url) {
|
|
61
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
62
|
+
: process.platform === 'win32' ? 'start ""'
|
|
63
|
+
: 'xdg-open';
|
|
64
|
+
exec(`${cmd} "${url}"`, (err) => {
|
|
65
|
+
if (err) {
|
|
66
|
+
console.error(chalk.yellow('Could not auto-open. URL:'));
|
|
67
|
+
console.log(url);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = web;
|
package/src/index.js
CHANGED
|
@@ -7,17 +7,25 @@
|
|
|
7
7
|
* ============================================================================
|
|
8
8
|
*
|
|
9
9
|
* COMMANDS:
|
|
10
|
+
* Setup: setup, config, doctor
|
|
10
11
|
* Repository: init, clone
|
|
11
12
|
* Staging: add, rm, reset, status, diff
|
|
12
|
-
* History: commit, log, show, tag
|
|
13
|
-
* Branching: branch, checkout, merge, stash
|
|
14
|
-
*
|
|
13
|
+
* History: commit, log, show, tag, explain
|
|
14
|
+
* Branching: branch, checkout, merge, resolve, stash
|
|
15
|
+
* Safety: undo, redo
|
|
16
|
+
* Insight: summary, ask, review, docs, changelog
|
|
17
|
+
* Remote: remote, repos, push, pull, search, web, share
|
|
15
18
|
* Auth: register, login, logout, whoami
|
|
19
|
+
* AI: ai (status|test|models)
|
|
20
|
+
* Templates: template (list|use)
|
|
16
21
|
*
|
|
17
22
|
* @author Abdalrahman Kanawati
|
|
18
|
-
* @version
|
|
23
|
+
* @version 7.0.0
|
|
19
24
|
*/
|
|
20
25
|
|
|
26
|
+
// Boot: load env files BEFORE anything else reads process.env.
|
|
27
|
+
require('./utils/env-loader').load();
|
|
28
|
+
|
|
21
29
|
const { program } = require('commander');
|
|
22
30
|
const chalk = require('chalk');
|
|
23
31
|
const packageJson = require('../package.json');
|
|
@@ -42,6 +50,10 @@ const remoteCommand = require('./commands/remote');
|
|
|
42
50
|
const pushCommand = require('./commands/push');
|
|
43
51
|
const pullCommand = require('./commands/pull');
|
|
44
52
|
const reposCommand = require('./commands/repos');
|
|
53
|
+
const undoCommand = require('./commands/undo');
|
|
54
|
+
const resolveCommand = require('./commands/resolve');
|
|
55
|
+
const summaryCommand = require('./commands/summary');
|
|
56
|
+
const explainCommand = require('./commands/explain');
|
|
45
57
|
|
|
46
58
|
// Import auth commands
|
|
47
59
|
const registerCommand = require('./commands/register');
|
|
@@ -49,6 +61,20 @@ const loginCommand = require('./commands/login');
|
|
|
49
61
|
const logoutCommand = require('./commands/logout');
|
|
50
62
|
const whoamiCommand = require('./commands/whoami');
|
|
51
63
|
|
|
64
|
+
// Import new gent-platform commands
|
|
65
|
+
const configCommand = require('./commands/config');
|
|
66
|
+
const doctorCommand = require('./commands/doctor');
|
|
67
|
+
const setupCommand = require('./commands/setup');
|
|
68
|
+
const aiCommand = require('./commands/ai');
|
|
69
|
+
const askCommand = require('./commands/ask');
|
|
70
|
+
const reviewCommand = require('./commands/review');
|
|
71
|
+
const docsCommand = require('./commands/docs');
|
|
72
|
+
const changelogCommand = require('./commands/changelog');
|
|
73
|
+
const webCommand = require('./commands/web');
|
|
74
|
+
const shareCommand = require('./commands/share');
|
|
75
|
+
const searchCommand = require('./commands/search');
|
|
76
|
+
const templateCommand = require('./commands/template');
|
|
77
|
+
|
|
52
78
|
// Configure CLI
|
|
53
79
|
program
|
|
54
80
|
.name('gent')
|
|
@@ -78,10 +104,16 @@ program
|
|
|
78
104
|
.action(statusCommand);
|
|
79
105
|
|
|
80
106
|
program
|
|
81
|
-
.command('add
|
|
82
|
-
.description('Add file contents to the staging area')
|
|
107
|
+
.command('add [files...]')
|
|
108
|
+
.description('Add file contents to the staging area (use -A/--all to add everything)')
|
|
83
109
|
.option('-A, --all', 'Add all files')
|
|
84
|
-
.action(
|
|
110
|
+
.action((files, options) => {
|
|
111
|
+
if ((!files || files.length === 0) && !options.all) {
|
|
112
|
+
console.error('error: specify files to add, or use -A to add all');
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
return addCommand(files || [], options);
|
|
116
|
+
});
|
|
85
117
|
|
|
86
118
|
program
|
|
87
119
|
.command('rm <files...>')
|
|
@@ -110,6 +142,7 @@ program
|
|
|
110
142
|
.description('Record changes to the repository')
|
|
111
143
|
.option('-m, --message <message>', 'Commit message')
|
|
112
144
|
.option('-a, --all', 'Automatically stage all modified files')
|
|
145
|
+
.option('--ai', 'Suggest a commit message with AI (needs ANTHROPIC_API_KEY)')
|
|
113
146
|
.action(commitCommand);
|
|
114
147
|
|
|
115
148
|
program
|
|
@@ -117,6 +150,7 @@ program
|
|
|
117
150
|
.description('Show commit logs')
|
|
118
151
|
.option('-n, --number <count>', 'Limit the number of commits to show', '10')
|
|
119
152
|
.option('--oneline', 'Show each commit on a single line')
|
|
153
|
+
.option('--graph', 'Show an ASCII commit graph with branches and merges')
|
|
120
154
|
.option('--stat', 'Show file change statistics')
|
|
121
155
|
.action(logCommand);
|
|
122
156
|
|
|
@@ -133,6 +167,18 @@ program
|
|
|
133
167
|
.option('-d, --delete <name>', 'Delete a tag')
|
|
134
168
|
.action(tagCommand);
|
|
135
169
|
|
|
170
|
+
program
|
|
171
|
+
.command('explain [ref]')
|
|
172
|
+
.description('Explain a commit or staged changes in plain language')
|
|
173
|
+
.option('--staged', 'Explain staged changes instead of a commit')
|
|
174
|
+
.action(explainCommand);
|
|
175
|
+
|
|
176
|
+
program
|
|
177
|
+
.command('summary')
|
|
178
|
+
.description('Show a repository health & statistics dashboard')
|
|
179
|
+
.option('--ai', 'Add an AI-written health narrative (needs ANTHROPIC_API_KEY)')
|
|
180
|
+
.action(summaryCommand);
|
|
181
|
+
|
|
136
182
|
// ─── Branching & Merging ────────────────────────────────
|
|
137
183
|
|
|
138
184
|
program
|
|
@@ -155,6 +201,11 @@ program
|
|
|
155
201
|
.option('-m, --message <message>', 'Merge commit message')
|
|
156
202
|
.action(mergeCommand);
|
|
157
203
|
|
|
204
|
+
program
|
|
205
|
+
.command('resolve')
|
|
206
|
+
.description('Interactively resolve merge conflicts left by "gent merge"')
|
|
207
|
+
.action(resolveCommand);
|
|
208
|
+
|
|
158
209
|
program
|
|
159
210
|
.command('stash [subcommand]')
|
|
160
211
|
.description('Stash working tree changes (pop|list|drop|apply)')
|
|
@@ -162,6 +213,17 @@ program
|
|
|
162
213
|
.option('-i, --index <index>', 'Stash index for pop/apply/drop')
|
|
163
214
|
.action(stashCommand);
|
|
164
215
|
|
|
216
|
+
program
|
|
217
|
+
.command('undo')
|
|
218
|
+
.description('Reverse the last history-changing operation (safety net)')
|
|
219
|
+
.option('-l, --list', 'Show the operation history')
|
|
220
|
+
.action(undoCommand);
|
|
221
|
+
|
|
222
|
+
program
|
|
223
|
+
.command('redo')
|
|
224
|
+
.description('Re-apply the last undone operation')
|
|
225
|
+
.action(undoCommand.redo);
|
|
226
|
+
|
|
165
227
|
// ─── Remote & Sync ──────────────────────────────────────
|
|
166
228
|
|
|
167
229
|
program
|
|
@@ -190,6 +252,83 @@ program
|
|
|
190
252
|
.description('Pull and merge remote commits')
|
|
191
253
|
.action(pullCommand);
|
|
192
254
|
|
|
255
|
+
// ─── Setup, Config & Diagnostics ────────────────────────
|
|
256
|
+
|
|
257
|
+
program
|
|
258
|
+
.command('setup')
|
|
259
|
+
.description('Interactive first-run wizard (backend URL, login, AI key, identity)')
|
|
260
|
+
.action(setupCommand);
|
|
261
|
+
|
|
262
|
+
program
|
|
263
|
+
.command('config [subcommand] [args...]')
|
|
264
|
+
.description('Manage CLI settings (list|get|set|unset|path) — e.g. gent config set ai.api_key <key>')
|
|
265
|
+
.action(configCommand);
|
|
266
|
+
|
|
267
|
+
program
|
|
268
|
+
.command('doctor')
|
|
269
|
+
.description('Run a health check across node, repo, auth, backend, and AI key')
|
|
270
|
+
.option('--ai', 'Also live-test the AI key with a tiny request')
|
|
271
|
+
.action(doctorCommand);
|
|
272
|
+
|
|
273
|
+
program
|
|
274
|
+
.command('ai [subcommand]')
|
|
275
|
+
.description('Inspect AI integration (status|test|models)')
|
|
276
|
+
.action(aiCommand);
|
|
277
|
+
|
|
278
|
+
// ─── Platform-special (AI-powered) ──────────────────────
|
|
279
|
+
|
|
280
|
+
program
|
|
281
|
+
.command('ask <question>')
|
|
282
|
+
.description('Ask Claude a question about this repo (needs AI key)')
|
|
283
|
+
.action(askCommand);
|
|
284
|
+
|
|
285
|
+
program
|
|
286
|
+
.command('review [ref]')
|
|
287
|
+
.description('AI code review on staged changes (default), HEAD, or a specific commit')
|
|
288
|
+
.option('--staged', 'Force review of staged changes')
|
|
289
|
+
.option('--head', 'Force review of HEAD commit')
|
|
290
|
+
.action(reviewCommand);
|
|
291
|
+
|
|
292
|
+
program
|
|
293
|
+
.command('docs')
|
|
294
|
+
.description('Generate a README.md draft for this repo using AI')
|
|
295
|
+
.option('--write', 'Write the draft to README.md instead of stdout')
|
|
296
|
+
.option('--section <name>', 'Only generate a single named section')
|
|
297
|
+
.action(docsCommand);
|
|
298
|
+
|
|
299
|
+
program
|
|
300
|
+
.command('changelog [range]')
|
|
301
|
+
.description('Print a changelog. range = <from>..<to> or <from> (default: since last tag)')
|
|
302
|
+
.option('--plain', 'Skip AI grouping — flat commit list')
|
|
303
|
+
.action(changelogCommand);
|
|
304
|
+
|
|
305
|
+
program
|
|
306
|
+
.command('web')
|
|
307
|
+
.description('Open the current repo (or a branch/commit) on the gent web app')
|
|
308
|
+
.option('--branch <name>', 'Open a specific branch')
|
|
309
|
+
.option('--commit <hash>', 'Open a specific commit')
|
|
310
|
+
.option('--print', 'Print the URL instead of launching a browser')
|
|
311
|
+
.action(webCommand);
|
|
312
|
+
|
|
313
|
+
program
|
|
314
|
+
.command('share')
|
|
315
|
+
.description('Print a shareable link to current branch tip (or --branch/--commit)')
|
|
316
|
+
.option('--branch <name>', 'Link to a specific branch')
|
|
317
|
+
.option('--commit <hash>', 'Link to a specific commit')
|
|
318
|
+
.action(shareCommand);
|
|
319
|
+
|
|
320
|
+
program
|
|
321
|
+
.command('search [query]')
|
|
322
|
+
.description('Search your repositories on the gent backend')
|
|
323
|
+
.option('--mine', 'Only repos you own')
|
|
324
|
+
.option('--json', 'Output as JSON')
|
|
325
|
+
.action(searchCommand);
|
|
326
|
+
|
|
327
|
+
program
|
|
328
|
+
.command('template [subcommand] [args...]')
|
|
329
|
+
.description('Quick-start from a baked-in template (list|use <name> [directory])')
|
|
330
|
+
.action(templateCommand);
|
|
331
|
+
|
|
193
332
|
// ─── Authentication ─────────────────────────────────────
|
|
194
333
|
|
|
195
334
|
program
|
|
@@ -231,19 +370,57 @@ program
|
|
|
231
370
|
}
|
|
232
371
|
});
|
|
233
372
|
|
|
373
|
+
// Friendlier global error mapping. Per-command handlers still own their own
|
|
374
|
+
// errors; this catches anything that bubbles up (e.g. unknown command).
|
|
375
|
+
function explainError(err) {
|
|
376
|
+
if (!err) return '';
|
|
377
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
|
|
378
|
+
return `Cannot reach the gent backend. Check the URL with \`gent config get api.base_url\` and try \`gent doctor\`.`;
|
|
379
|
+
}
|
|
380
|
+
if (err.code === 'commander.unknownCommand') {
|
|
381
|
+
return `${err.message}\n\nRun \`gent\` (no args) to see the command list, or \`gent help <command>\` for details.`;
|
|
382
|
+
}
|
|
383
|
+
return err.message;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function showQuickstart() {
|
|
387
|
+
console.log();
|
|
388
|
+
console.log(chalk.bold.cyan('Gent CLI ') + chalk.gray(`v${packageJson.version}`));
|
|
389
|
+
console.log(chalk.gray('A Git-like VCS with cloud sync + AI superpowers.\n'));
|
|
390
|
+
console.log(chalk.bold('First time? Try:'));
|
|
391
|
+
console.log(` ${chalk.cyan('gent setup')} ${chalk.gray('interactive walkthrough (login + AI key + remote)')}`);
|
|
392
|
+
console.log(` ${chalk.cyan('gent doctor')} ${chalk.gray('check everything is wired up')}`);
|
|
393
|
+
console.log(` ${chalk.cyan('gent template list')} ${chalk.gray('scaffold a starter project')}`);
|
|
394
|
+
console.log();
|
|
395
|
+
console.log(chalk.bold('Everyday flow:'));
|
|
396
|
+
console.log(` ${chalk.cyan('gent init && gent add -A && gent commit -m "init"')}`);
|
|
397
|
+
console.log(` ${chalk.cyan('gent push')} / ${chalk.cyan('gent pull')} / ${chalk.cyan('gent merge <branch>')}`);
|
|
398
|
+
console.log();
|
|
399
|
+
console.log(chalk.bold('AI features (need an Anthropic key):'));
|
|
400
|
+
console.log(` ${chalk.cyan('gent ask "what does this repo do?"')}`);
|
|
401
|
+
console.log(` ${chalk.cyan('gent review')} ${chalk.gray('review staged changes')}`);
|
|
402
|
+
console.log(` ${chalk.cyan('gent docs --write')} ${chalk.gray('generate README.md')}`);
|
|
403
|
+
console.log(` ${chalk.cyan('gent changelog')} ${chalk.gray('grouped release notes')}`);
|
|
404
|
+
console.log();
|
|
405
|
+
console.log(chalk.gray('Full command list: ') + chalk.cyan('gent --help'));
|
|
406
|
+
console.log();
|
|
407
|
+
}
|
|
408
|
+
|
|
234
409
|
// Error handling
|
|
235
410
|
program.exitOverride();
|
|
236
411
|
|
|
237
412
|
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// Show help if no command provided
|
|
413
|
+
// Show quickstart if no command provided (instead of raw help).
|
|
241
414
|
if (!process.argv.slice(2).length) {
|
|
242
|
-
|
|
415
|
+
showQuickstart();
|
|
416
|
+
process.exit(0);
|
|
243
417
|
}
|
|
418
|
+
|
|
419
|
+
program.parse(process.argv);
|
|
420
|
+
|
|
244
421
|
} catch (err) {
|
|
245
422
|
if (err.code !== 'commander.help' && err.code !== 'commander.helpDisplayed' && err.code !== 'commander.version') {
|
|
246
|
-
console.error(chalk.red('Error:'), err
|
|
423
|
+
console.error(chalk.red('Error:'), explainError(err));
|
|
247
424
|
process.exit(1);
|
|
248
425
|
}
|
|
249
426
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
*/
|
|
29
|
+
|
|
30
|
+
const axios = require('axios');
|
|
31
|
+
const userConfig = require('./user-config');
|
|
32
|
+
|
|
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';
|
|
36
|
+
|
|
37
|
+
// Per-process cache so repeated AI calls don't keep hitting disk.
|
|
38
|
+
let _resolvedKey;
|
|
39
|
+
let _resolvedKeySource;
|
|
40
|
+
let _resolvedModel;
|
|
41
|
+
|
|
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 };
|
|
50
|
+
}
|
|
51
|
+
|
|
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;
|
|
57
|
+
}
|
|
58
|
+
|
|
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;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getModel() {
|
|
69
|
+
if (_resolvedModel) return _resolvedModel;
|
|
70
|
+
return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
async function prime() {
|
|
78
|
+
await resolveKey();
|
|
79
|
+
await resolveModel();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isEnabled() {
|
|
83
|
+
return !!getApiKey();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
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.';
|
|
88
|
+
}
|
|
89
|
+
|
|
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();
|
|
102
|
+
|
|
103
|
+
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' };
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const res = await axios.post(API_URL, body, {
|
|
120
|
+
headers: {
|
|
121
|
+
'x-api-key': apiKey,
|
|
122
|
+
'anthropic-version': API_VERSION,
|
|
123
|
+
'content-type': 'application/json'
|
|
124
|
+
},
|
|
125
|
+
timeout: 60000
|
|
126
|
+
});
|
|
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);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
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;
|
|
156
|
+
}
|
|
157
|
+
|
|
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
|
+
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 });
|
|
172
|
+
}
|
|
173
|
+
|
|
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 });
|
|
185
|
+
}
|
|
186
|
+
|
|
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
|
+
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
|
+
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 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
isEnabled,
|
|
208
|
+
getModel,
|
|
209
|
+
getApiKey,
|
|
210
|
+
disabledHint,
|
|
211
|
+
prime,
|
|
212
|
+
resolveKey,
|
|
213
|
+
resolveModel,
|
|
214
|
+
complete,
|
|
215
|
+
suggestCommitMessage,
|
|
216
|
+
explainChanges,
|
|
217
|
+
resolveConflictHunk,
|
|
218
|
+
DEFAULT_MODEL,
|
|
219
|
+
};
|
package/src/utils/api-client.js
CHANGED
|
@@ -5,11 +5,26 @@
|
|
|
5
5
|
|
|
6
6
|
const axios = require('axios');
|
|
7
7
|
const { API_BASE_URL } = require('./constants');
|
|
8
|
+
const userConfig = require('./user-config');
|
|
8
9
|
const authStorage = require('./auth-storage');
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
+
// Resolved once per process so commands see a stable URL. CLI runs are short,
|
|
12
|
+
// so we don't bother with cache invalidation — the next invocation re-reads.
|
|
13
|
+
let _resolvedBaseUrl = null;
|
|
14
|
+
async function resolveBaseUrl() {
|
|
15
|
+
if (_resolvedBaseUrl) return _resolvedBaseUrl;
|
|
16
|
+
try {
|
|
17
|
+
const { value } = await userConfig.getResolved('api.base_url');
|
|
18
|
+
_resolvedBaseUrl = value || API_BASE_URL;
|
|
19
|
+
} catch {
|
|
20
|
+
_resolvedBaseUrl = API_BASE_URL;
|
|
21
|
+
}
|
|
22
|
+
return _resolvedBaseUrl;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create axios instance with base configuration. baseURL is set per-request
|
|
26
|
+
// by the interceptor below so config/env changes take effect immediately.
|
|
11
27
|
const apiClient = axios.create({
|
|
12
|
-
baseURL: API_BASE_URL,
|
|
13
28
|
headers: {
|
|
14
29
|
'Content-Type': 'application/json'
|
|
15
30
|
},
|
|
@@ -37,9 +52,13 @@ function processQueue(error, token = null) {
|
|
|
37
52
|
failedRequestsQueue = [];
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
// Request interceptor -
|
|
55
|
+
// Request interceptor - Resolve base URL + add JWT token to headers
|
|
41
56
|
apiClient.interceptors.request.use(
|
|
42
57
|
async (config) => {
|
|
58
|
+
if (!config.baseURL) {
|
|
59
|
+
config.baseURL = await resolveBaseUrl();
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
const token = await authStorage.getAccessToken();
|
|
44
63
|
|
|
45
64
|
if (token) {
|
|
@@ -89,9 +108,11 @@ apiClient.interceptors.response.use(
|
|
|
89
108
|
throw new Error('Session expired. Please login again.');
|
|
90
109
|
}
|
|
91
110
|
|
|
92
|
-
// Call refresh endpoint
|
|
111
|
+
// Call refresh endpoint (raw axios — bypasses our interceptor
|
|
112
|
+
// intentionally so a 401 here doesn't loop back into refresh).
|
|
113
|
+
const baseUrl = await resolveBaseUrl();
|
|
93
114
|
const response = await axios.post(
|
|
94
|
-
`${
|
|
115
|
+
`${baseUrl}/api/auth/token/refresh/`,
|
|
95
116
|
{ refresh: refreshToken }
|
|
96
117
|
);
|
|
97
118
|
|
|
@@ -188,5 +209,6 @@ module.exports = {
|
|
|
188
209
|
put,
|
|
189
210
|
delete: del,
|
|
190
211
|
patch,
|
|
191
|
-
apiClient // Export raw client if needed
|
|
212
|
+
apiClient, // Export raw client if needed
|
|
213
|
+
resolveBaseUrl,
|
|
192
214
|
};
|
package/src/utils/constants.js
CHANGED
|
@@ -12,7 +12,9 @@ module.exports = {
|
|
|
12
12
|
HEAD_FILE: 'HEAD',
|
|
13
13
|
AUTH_FILE: 'auth.json',
|
|
14
14
|
|
|
15
|
-
// API Configuration
|
|
15
|
+
// API Configuration — default used when no env/config override.
|
|
16
|
+
// Use getResolvedApiBaseUrl() in code that runs after process boot
|
|
17
|
+
// to respect GENT_API_URL env or user config (~/.gent/cli-config.json).
|
|
16
18
|
API_BASE_URL: 'https://gent-api.onrender.com',
|
|
17
19
|
API_ENDPOINTS: {
|
|
18
20
|
// Auth
|