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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Doctor Command - Health check for the gent CLI.
3
+ *
4
+ * gent doctor → run all checks
5
+ * gent doctor --ai → also ping Anthropic with a 1-token request
6
+ *
7
+ * Each row prints PASS / WARN / FAIL plus a hint on how to fix the issue.
8
+ */
9
+
10
+ const path = require('path');
11
+ const fs = require('fs').promises;
12
+ const chalk = require('chalk');
13
+ const axios = require('axios');
14
+ const packageJson = require('../../package.json');
15
+ const { GENT_DIR } = require('../utils/constants');
16
+ const userConfig = require('../utils/user-config');
17
+ const apiClient = require('../utils/api-client');
18
+ const authStorage = require('../utils/auth-storage');
19
+ const ai = require('../utils/ai-service');
20
+
21
+ const MIN_NODE_MAJOR = 18;
22
+
23
+ async function doctor(options = {}) {
24
+ console.log(chalk.bold.cyan('\nGent CLI health check\n'));
25
+
26
+ const checks = [];
27
+
28
+ checks.push(await checkNodeVersion());
29
+ checks.push(await checkCliVersion());
30
+ checks.push(await checkRepo());
31
+ checks.push(await checkAuth());
32
+ checks.push(await checkApi());
33
+ checks.push(await checkAiKey(!!options.ai));
34
+
35
+ for (const c of checks) {
36
+ printCheck(c);
37
+ }
38
+
39
+ const failed = checks.filter(c => c.status === 'fail').length;
40
+ const warned = checks.filter(c => c.status === 'warn').length;
41
+ const passed = checks.filter(c => c.status === 'pass').length;
42
+
43
+ console.log();
44
+ console.log(
45
+ chalk.green(` ${passed} pass`) + ' ' +
46
+ chalk.yellow(`${warned} warn`) + ' ' +
47
+ chalk.red(`${failed} fail`)
48
+ );
49
+
50
+ if (failed > 0) process.exit(1);
51
+ }
52
+
53
+ function printCheck(c) {
54
+ const badge = c.status === 'pass' ? chalk.green('✓ PASS')
55
+ : c.status === 'warn' ? chalk.yellow('! WARN')
56
+ : chalk.red('✗ FAIL');
57
+ console.log(` ${badge} ${chalk.bold(c.name)} ${chalk.gray(`— ${c.detail}`)}`);
58
+ if (c.hint) console.log(chalk.gray(` hint: ${c.hint}`));
59
+ }
60
+
61
+ async function checkNodeVersion() {
62
+ const v = process.versions.node;
63
+ const major = parseInt(v.split('.')[0], 10);
64
+ if (major >= MIN_NODE_MAJOR) {
65
+ return { name: 'Node version', status: 'pass', detail: `v${v}` };
66
+ }
67
+ return {
68
+ name: 'Node version',
69
+ status: 'fail',
70
+ detail: `v${v} (need ≥${MIN_NODE_MAJOR})`,
71
+ hint: `Install Node ${MIN_NODE_MAJOR}+ — e.g. via nvm.`,
72
+ };
73
+ }
74
+
75
+ async function checkCliVersion() {
76
+ return {
77
+ name: 'Gent CLI',
78
+ status: 'pass',
79
+ detail: `v${packageJson.version}`,
80
+ };
81
+ }
82
+
83
+ async function checkRepo() {
84
+ const gentPath = path.join(process.cwd(), GENT_DIR);
85
+ try {
86
+ const stat = await fs.stat(gentPath);
87
+ if (!stat.isDirectory()) throw new Error('not a directory');
88
+ return { name: 'Repository (.gent)', status: 'pass', detail: gentPath };
89
+ } catch {
90
+ return {
91
+ name: 'Repository (.gent)',
92
+ status: 'warn',
93
+ detail: 'not a gent repo (this directory)',
94
+ hint: 'Run `gent init` to start a repo here, or cd into one.',
95
+ };
96
+ }
97
+ }
98
+
99
+ async function checkAuth() {
100
+ const isAuth = await authStorage.isAuthenticated();
101
+ if (!isAuth) {
102
+ return {
103
+ name: 'Authentication',
104
+ status: 'warn',
105
+ detail: 'not logged in',
106
+ hint: 'Run `gent login` or `gent register`.',
107
+ };
108
+ }
109
+ const user = await authStorage.getUser();
110
+ const who = user ? `${user.email}` : 'unknown user';
111
+ return { name: 'Authentication', status: 'pass', detail: who };
112
+ }
113
+
114
+ async function checkApi() {
115
+ const { value: baseUrl, source } = await userConfig.getResolved('api.base_url');
116
+ try {
117
+ await axios.get(baseUrl, { timeout: 8000, validateStatus: () => true });
118
+ return {
119
+ name: 'Backend reachable',
120
+ status: 'pass',
121
+ detail: `${baseUrl} [${source}]`,
122
+ };
123
+ } catch (err) {
124
+ return {
125
+ name: 'Backend reachable',
126
+ status: 'fail',
127
+ detail: `${baseUrl} → ${err.code || err.message}`,
128
+ hint: 'If running a local server: `gent config set api.base_url http://localhost:8000`.',
129
+ };
130
+ }
131
+ }
132
+
133
+ async function checkAiKey(probe) {
134
+ const { value: key, source } = await ai.resolveKey();
135
+ if (!key) {
136
+ return {
137
+ name: 'AI key',
138
+ status: 'warn',
139
+ detail: 'not configured (AI features will be skipped, not failed)',
140
+ hint: 'Run `gent config set ai.api_key <key>` or set ANTHROPIC_API_KEY.',
141
+ };
142
+ }
143
+
144
+ if (!probe) {
145
+ return {
146
+ name: 'AI key',
147
+ status: 'pass',
148
+ detail: `present [${source}], model: ${await ai.resolveModel()} (use --ai to live-test)`,
149
+ };
150
+ }
151
+
152
+ try {
153
+ await ai.complete({ prompt: 'ping', maxTokens: 4 });
154
+ return {
155
+ name: 'AI key',
156
+ status: 'pass',
157
+ detail: `verified — model ${await ai.resolveModel()} responded`,
158
+ };
159
+ } catch (err) {
160
+ return {
161
+ name: 'AI key',
162
+ status: 'fail',
163
+ detail: err.message,
164
+ hint: 'Re-check the key (`gent config set ai.api_key`) or model (`gent config set ai.model`).',
165
+ };
166
+ }
167
+ }
168
+
169
+ module.exports = doctor;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * ============================================================================
3
+ * Explain Command - Plain-language summary of a commit or pending changes
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Turn a diff into a human explanation. With an API key set this uses Claude;
8
+ * without one it still prints the unified diff plus a hint, so the command is
9
+ * useful either way.
10
+ *
11
+ * USAGE:
12
+ * gent explain → explain the latest commit (HEAD)
13
+ * gent explain <ref> → explain a specific commit (full or short hash)
14
+ * gent explain --staged → explain the currently staged changes
15
+ *
16
+ * ============================================================================
17
+ */
18
+
19
+ const path = require('path');
20
+ const chalk = require('chalk');
21
+ const ora = require('ora');
22
+ const { getGentPath, readJSON } = require('../utils/fileSystem');
23
+ const { COMMITS_FILE, STAGING_FILE } = require('../utils/constants');
24
+ const { readBlobAsString, treeToMap } = require('../utils/hash-engine');
25
+ const { formatUnifiedDiff } = require('../utils/diff-engine');
26
+ const ai = require('../utils/ai-service');
27
+
28
+ const MAX_DIFF_CHARS = 12000;
29
+
30
+ function treeEntriesOf(commit) {
31
+ if (!commit) return [];
32
+ if (Array.isArray(commit.tree)) return commit.tree;
33
+ return (commit.files || []).map(f => ({ name: f.path || f.name, hash: f.hash }));
34
+ }
35
+
36
+ /**
37
+ * Build a unified-diff text between two trees (old → new).
38
+ */
39
+ async function diffTrees(gentPath, oldEntries, newEntries) {
40
+ const oldMap = treeToMap(oldEntries);
41
+ const newMap = treeToMap(newEntries);
42
+ const files = new Set([...oldMap.keys(), ...newMap.keys()]);
43
+
44
+ const parts = [];
45
+ for (const file of files) {
46
+ const oh = oldMap.get(file);
47
+ const nh = newMap.get(file);
48
+ if (oh === nh) continue;
49
+ let oldText = '', newText = '';
50
+ try { if (oh) oldText = await readBlobAsString(gentPath, oh); } catch { /* binary/legacy */ }
51
+ try { if (nh) newText = await readBlobAsString(gentPath, nh); } catch { /* binary/legacy */ }
52
+ const d = formatUnifiedDiff(file, oldText, newText);
53
+ if (d) parts.push(d);
54
+ }
55
+ return parts.join('\n\n');
56
+ }
57
+
58
+ async function explain(ref, options = {}) {
59
+ try {
60
+ const gentPath = await getGentPath();
61
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
62
+ const commits = repository.commits || [];
63
+ const commitMap = new Map(commits.map(c => [c.hash, c]));
64
+
65
+ let title;
66
+ let diffText;
67
+
68
+ if (options.staged) {
69
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
70
+ const entries = staging.entries || [];
71
+ if (entries.length === 0) {
72
+ console.log(chalk.yellow('No staged changes to explain'));
73
+ return;
74
+ }
75
+ const headHash = repository.branches[repository.currentBranch];
76
+ const headTree = treeEntriesOf(commitMap.get(headHash));
77
+ const stagedTree = entries
78
+ .filter(e => e.status !== 'deleted')
79
+ .map(e => ({ name: e.path, hash: e.hash }));
80
+ // Overlay staged entries on the HEAD tree
81
+ const overlay = new Map(headTree.map(e => [e.name, e.hash]));
82
+ for (const e of entries) {
83
+ if (e.status === 'deleted') overlay.delete(e.path);
84
+ else overlay.set(e.path, e.hash);
85
+ }
86
+ title = 'Staged changes';
87
+ diffText = await diffTrees(
88
+ gentPath,
89
+ headTree,
90
+ [...overlay].map(([name, hash]) => ({ name, hash }))
91
+ );
92
+ void stagedTree;
93
+ } else {
94
+ const targetHash = ref
95
+ ? (commits.find(c => c.hash === ref || c.hash.startsWith(ref)) || {}).hash
96
+ : repository.branches[repository.currentBranch];
97
+ const commit = targetHash ? commitMap.get(targetHash) : null;
98
+ if (!commit) {
99
+ console.log(chalk.yellow(ref ? `Commit '${ref}' not found` : 'No commits yet'));
100
+ return;
101
+ }
102
+ const parent = commit.parent ? commitMap.get(commit.parent) : null;
103
+ title = `Commit ${commit.hash.substring(0, 7)} — ${commit.message}`;
104
+ diffText = await diffTrees(gentPath, treeEntriesOf(parent), treeEntriesOf(commit));
105
+ }
106
+
107
+ if (!diffText) {
108
+ console.log(chalk.gray('No textual changes to explain.'));
109
+ return;
110
+ }
111
+
112
+ const trimmed = diffText.length > MAX_DIFF_CHARS
113
+ ? diffText.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
114
+ : diffText;
115
+
116
+ console.log(chalk.bold.cyan(`\n${title}\n`));
117
+
118
+ if (!ai.isEnabled()) {
119
+ console.log(trimmed);
120
+ console.log(chalk.gray(`\n${ai.disabledHint()}`));
121
+ return;
122
+ }
123
+
124
+ const spinner = ora(`Asking ${ai.getModel()} to explain...`).start();
125
+ try {
126
+ const explanation = await ai.explainChanges(trimmed);
127
+ spinner.stop();
128
+ console.log(explanation);
129
+ } catch (err) {
130
+ spinner.fail(chalk.yellow('AI request failed — showing the raw diff instead'));
131
+ console.log(chalk.gray(`(${err.message})\n`));
132
+ console.log(trimmed);
133
+ }
134
+ } catch (error) {
135
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
136
+ console.error(chalk.red('Error: Not a gent repository'));
137
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
138
+ } else {
139
+ console.error(chalk.red('Error:'), error.message);
140
+ }
141
+ process.exit(1);
142
+ }
143
+ }
144
+
145
+ module.exports = explain;
@@ -60,7 +60,9 @@ async function log(options) {
60
60
  cur = c.parent;
61
61
  }
62
62
 
63
- if (options.oneline) {
63
+ if (options.graph) {
64
+ displayGraphLog(commits, headHash, repository.branches || {}, limit);
65
+ } else if (options.oneline) {
64
66
  displayOnelineLog(ordered, headHash);
65
67
  } else {
66
68
  displayDetailedLog(ordered, headHash, currentBranch, options);
@@ -118,6 +120,54 @@ function displayDetailedLog(commits, currentCommitHash, currentBranch, options)
118
120
  });
119
121
  }
120
122
 
123
+ /**
124
+ * Display a commit graph reachable from HEAD (parent + mergeParent edges),
125
+ * ordered newest-first, with branch/HEAD decorations and merge annotations.
126
+ * A simplified single-rail graph: merges are annotated rather than drawn as
127
+ * separate lanes, which keeps the output readable in a terminal.
128
+ */
129
+ function displayGraphLog(allCommits, headHash, branches, limit) {
130
+ const map = new Map(allCommits.map(c => [c.hash, c]));
131
+
132
+ // Reachable set from HEAD via both edges.
133
+ const reachable = [];
134
+ const seen = new Set();
135
+ const stack = [headHash];
136
+ while (stack.length) {
137
+ const h = stack.pop();
138
+ if (!h || seen.has(h)) continue;
139
+ const c = map.get(h);
140
+ if (!c) continue;
141
+ seen.add(h);
142
+ reachable.push(c);
143
+ if (c.parent) stack.push(c.parent);
144
+ if (c.mergeParent) stack.push(c.mergeParent);
145
+ }
146
+ reachable.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
147
+ const limited = reachable.slice(0, limit);
148
+
149
+ // Branch labels by commit hash.
150
+ const refs = new Map();
151
+ for (const [name, hash] of Object.entries(branches)) {
152
+ if (!hash) continue;
153
+ if (!refs.has(hash)) refs.set(hash, []);
154
+ refs.get(hash).push(name);
155
+ }
156
+
157
+ console.log(chalk.bold.cyan('\nCommit graph:\n'));
158
+ limited.forEach((c, i) => {
159
+ const headLabel = c.hash === headHash ? chalk.yellow.bold(' (HEAD)') : '';
160
+ const refLabel = refs.has(c.hash) ? chalk.green(` (${refs.get(c.hash).join(', ')})`) : '';
161
+ const time = chalk.gray(`(${formatDistanceToNow(new Date(c.timestamp), { addSuffix: true })})`);
162
+ console.log(`${chalk.yellow('*')} ${chalk.yellow(c.hash.substring(0, 7))}${headLabel}${refLabel} ${c.message} ${time}`);
163
+ if (c.mergeParent) {
164
+ console.log(chalk.gray(`|\\ merge: ${(c.parent || '').substring(0, 7)} + ${c.mergeParent.substring(0, 7)}`));
165
+ }
166
+ if (i < limited.length - 1) console.log(chalk.gray('|'));
167
+ });
168
+ console.log();
169
+ }
170
+
121
171
  /**
122
172
  * Display oneline commit log
123
173
  */
@@ -13,6 +13,7 @@ const { generateCommitHash } = require('../utils/helpers');
13
13
  const authStorage = require('../utils/auth-storage');
14
14
  const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-engine');
15
15
  const { storeTree, readBlobAsString, storeBlob } = require('../utils/hash-engine');
16
+ const journal = require('../utils/journal');
16
17
 
17
18
  /**
18
19
  * Merge a branch into the current branch
@@ -66,6 +67,7 @@ async function merge(sourceBranch, options) {
66
67
  // Fast-forward: current branch is merge base → just move pointer
67
68
  if (baseHash === oursHash) {
68
69
  spinner.text = 'Fast-forward merge...';
70
+ await journal.recordOp(gentPath, 'merge', `fast-forward '${sourceBranch}' into ${currentBranch}`, { restoreTree: true });
69
71
  repository.branches[currentBranch] = theirsHash;
70
72
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
71
73
 
@@ -165,6 +167,8 @@ async function merge(sourceBranch, options) {
165
167
  }
166
168
  };
167
169
 
170
+ await journal.recordOp(gentPath, 'merge', `merge '${sourceBranch}' into ${currentBranch}`);
171
+
168
172
  repository.commits.push(mergeCommit);
169
173
  repository.branches[currentBranch] = mergeCommit.hash;
170
174
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
@@ -30,7 +30,7 @@ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
30
30
  const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
31
31
  const apiClient = require('../utils/api-client');
32
32
  const authStorage = require('../utils/auth-storage');
33
- const { storeBlob, objectExists, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
33
+ const { storeBlob, objectExists, readBlob, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
34
34
  const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
35
35
  const { generateCommitHash } = require('../utils/helpers');
36
36
 
@@ -311,10 +311,11 @@ async function checkoutTree(gentPath, cwd, previousTree, nextTree) {
311
311
  const relPath = entry.name || entry.path;
312
312
  if (!relPath || !entry.hash) continue;
313
313
 
314
- const content = await readBlobAsString(gentPath, entry.hash);
314
+ // Write the raw Buffer so binary blobs round-trip byte-exact.
315
+ const buf = await readBlob(gentPath, entry.hash);
315
316
  const fullPath = path.join(cwd, relPath);
316
317
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
317
- await fs.writeFile(fullPath, content, 'utf-8');
318
+ await fs.writeFile(fullPath, buf);
318
319
  }
319
320
  }
320
321
 
@@ -30,6 +30,7 @@ const ora = require('ora');
30
30
  const { getGentPath, readJSON, writeJSON, pathExists } = require('../utils/fileSystem');
31
31
  const { STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
32
32
  const { readBlobAsString } = require('../utils/hash-engine');
33
+ const journal = require('../utils/journal');
33
34
 
34
35
  /**
35
36
  * Reset staging or HEAD
@@ -112,6 +113,15 @@ async function resetHead(gentPath, cwd, args, options) {
112
113
 
113
114
  const spinner = ora(`Resetting to ${target.hash.substring(0, 7)}...`).start();
114
115
 
116
+ // Journal pre-state. A hard reset discards working-tree content, so flag it
117
+ // for working-tree restore on undo.
118
+ await journal.recordOp(
119
+ gentPath,
120
+ 'reset',
121
+ `${options.hard ? 'hard' : 'soft'} reset to ${target.hash.substring(0, 7)}`,
122
+ { restoreTree: !!options.hard }
123
+ );
124
+
115
125
  // Move branch pointer
116
126
  repository.branches[currentBranch] = target.hash;
117
127
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);