gent-cli 24.0.0 → 26.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.
@@ -15,6 +15,8 @@ const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-e
15
15
  const { storeTree, readBlob, hashBlob } = require('../utils/hash-engine');
16
16
  const pet = require('./pet');
17
17
  const journal = require('../utils/journal');
18
+ const ai = require('../utils/ai-service');
19
+ const reviewCommand = require('./review');
18
20
 
19
21
  /**
20
22
  * Merge a branch into the current branch
@@ -22,7 +24,8 @@ const journal = require('../utils/journal');
22
24
  * @param {Object} options - Command options
23
25
  */
24
26
  async function merge(sourceBranch, options) {
25
- const spinner = ora(`Merging '${sourceBranch}'...`).start();
27
+ options = options || {};
28
+ const spinner = ora(options.abort ? 'Aborting merge...' : `Merging '${sourceBranch}'...`).start();
26
29
 
27
30
  try {
28
31
  const gentPath = await getGentPath();
@@ -32,6 +35,18 @@ async function merge(sourceBranch, options) {
32
35
  const branches = repository.branches || {};
33
36
  const currentBranch = repository.currentBranch;
34
37
 
38
+ if (options.abort) {
39
+ await abortMerge(gentPath, cwd, repository, spinner);
40
+ return;
41
+ }
42
+ if (options.continue) {
43
+ throw new Error('Legacy merge continuation uses "gent resolve"');
44
+ }
45
+ if (options.ai) {
46
+ await ai.prime();
47
+ if (!ai.isEnabled()) throw new Error(ai.disabledHint());
48
+ }
49
+
35
50
  // Validate branches
36
51
  if (!branches.hasOwnProperty(sourceBranch)) {
37
52
  spinner.fail(chalk.red(`Branch '${sourceBranch}' not found`));
@@ -95,6 +110,10 @@ async function merge(sourceBranch, options) {
95
110
 
96
111
  spinner.succeed(chalk.green(`Fast-forward merge: ${currentBranch} → ${theirsHash.substring(0, 7)}`));
97
112
  await pet.celebrate('merge');
113
+ if (options.ai) {
114
+ console.log(chalk.bold.cyan('\nAI review of the completed merge'));
115
+ await reviewCommand(theirsHash, { head: true });
116
+ }
98
117
  return;
99
118
  }
100
119
 
@@ -134,8 +153,10 @@ async function merge(sourceBranch, options) {
134
153
  }
135
154
  }
136
155
 
137
- console.log(chalk.yellow(`\nConflict markers: <<<<<<< HEAD / ======= / >>>>>>> ${sourceBranch}`));
138
- console.log(chalk.cyan('Resolve conflicts, then run "gent add" and "gent commit"'));
156
+ if (!options.ai) {
157
+ console.log(chalk.yellow(`\nConflict markers: <<<<<<< HEAD / ======= / >>>>>>> ${sourceBranch}`));
158
+ console.log(chalk.cyan('Resolve conflicts, then run "gent resolve"'));
159
+ }
139
160
  }
140
161
 
141
162
  // Store merged tree
@@ -199,6 +220,10 @@ async function merge(sourceBranch, options) {
199
220
  console.log(chalk.gray(` Ours: ${oursHash.substring(0, 7)} Theirs: ${theirsHash.substring(0, 7)}`));
200
221
  console.log(chalk.green(` ${autoResolved} file(s) merged automatically`));
201
222
  await pet.celebrate('merge');
223
+ if (options.ai) {
224
+ console.log(chalk.bold.cyan('\nAI review of the completed merge'));
225
+ await reviewCommand(mergeCommit.hash, { head: true });
226
+ }
202
227
  } else {
203
228
  // Stage the merge state for manual resolution
204
229
  const staging = await readJSON(path.join(gentPath, STAGING_FILE));
@@ -212,7 +237,12 @@ async function merge(sourceBranch, options) {
212
237
  conflicts: mergeResult.conflicts
213
238
  };
214
239
  await writeJSON(path.join(gentPath, STAGING_FILE), staging);
215
- process.exitCode = 1;
240
+ if (options.ai) {
241
+ process.exitCode = 0;
242
+ await require('./resolve')({ ai: true });
243
+ } else {
244
+ process.exitCode = 1;
245
+ }
216
246
  }
217
247
 
218
248
  } catch (error) {
@@ -254,8 +284,11 @@ function safePath(cwd, relativePath) {
254
284
 
255
285
  async function assertCleanWorkingTree(gentPath, cwd, commit) {
256
286
  const staging = await readJSON(path.join(gentPath, STAGING_FILE));
257
- if ((staging.entries || []).length || (staging.files || []).length || staging.mergeState) {
258
- throw new Error('Commit or stash staged changes before merging');
287
+ if (staging.mergeState) {
288
+ throw new Error('A merge is already in progress; run "gent resolve" or "gent merge --abort"');
289
+ }
290
+ if ((staging.entries || []).length || (staging.files || []).length) {
291
+ throw new Error('Commit, stash, or unstage current changes before merging');
259
292
  }
260
293
  for (const entry of treeOf(commit)) {
261
294
  const fullPath = safePath(cwd, entry.name || entry.path);
@@ -266,6 +299,44 @@ async function assertCleanWorkingTree(gentPath, cwd, commit) {
266
299
  }
267
300
  }
268
301
 
302
+ async function abortMerge(gentPath, cwd, repository, spinner) {
303
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
304
+ const state = staging.mergeState;
305
+ if (!state) {
306
+ spinner.info(chalk.yellow('No merge in progress'));
307
+ return;
308
+ }
309
+
310
+ const oursCommit = (repository.commits || []).find(commit => commit.hash === state.oursHash);
311
+ if (!oursCommit) throw new Error('Cannot abort merge: original commit is missing');
312
+
313
+ const oursTree = treeOf(oursCommit);
314
+ const mergeTree = state.mergedEntries || [];
315
+ const oursNames = new Set(oursTree.map(entry => entry.name || entry.path));
316
+ const restored = new Map();
317
+ for (const entry of oursTree) {
318
+ restored.set(entry.name || entry.path, await readBlob(gentPath, entry.hash));
319
+ }
320
+ for (const entry of mergeTree) {
321
+ const name = entry.name || entry.path;
322
+ if (!oursNames.has(name)) {
323
+ await fs.unlink(safePath(cwd, name)).catch(error => {
324
+ if (error.code !== 'ENOENT') throw error;
325
+ });
326
+ }
327
+ }
328
+ for (const [name, bytes] of restored) {
329
+ const fullPath = safePath(cwd, name);
330
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
331
+ await fs.writeFile(fullPath, bytes);
332
+ }
333
+ staging.entries = [];
334
+ staging.files = [];
335
+ staging.mergeState = null;
336
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
337
+ spinner.succeed(chalk.green('Merge aborted; working tree restored'));
338
+ }
339
+
269
340
  async function checkoutTree(gentPath, cwd, previousEntries, nextEntries) {
270
341
  const previous = new Map(previousEntries.map(entry => [entry.name || entry.path, entry]));
271
342
  const next = new Map(nextEntries.map(entry => [entry.name || entry.path, entry]));
@@ -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();