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,121 @@
1
+ /**
2
+ * Changelog Command - AI-grouped changelog between two refs.
3
+ *
4
+ * gent changelog → since the most recent tag (or last 50 commits)
5
+ * gent changelog <from>..<to> → commits between two refs
6
+ * gent changelog <from> → from <from> to HEAD
7
+ * gent changelog --plain → flat list, no AI grouping
8
+ */
9
+
10
+ const path = require('path');
11
+ const chalk = require('chalk');
12
+ const ora = require('ora');
13
+ const { getGentPath, readJSON } = require('../utils/fileSystem');
14
+ const { COMMITS_FILE } = require('../utils/constants');
15
+ const ai = require('../utils/ai-service');
16
+
17
+ const MAX_COMMITS = 200;
18
+
19
+ async function changelog(range, options = {}) {
20
+ try {
21
+ const gentPath = await getGentPath();
22
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
23
+ const commits = repository.commits || [];
24
+ if (commits.length === 0) {
25
+ console.log(chalk.yellow('No commits yet.'));
26
+ return;
27
+ }
28
+
29
+ const { from, to } = resolveRange(range, repository);
30
+ const selected = commitsBetween(commits, from, to);
31
+
32
+ if (selected.length === 0) {
33
+ console.log(chalk.yellow('No commits in the requested range.'));
34
+ return;
35
+ }
36
+
37
+ const header = `${chalk.bold.cyan('Changelog')} ${chalk.gray(
38
+ `${(from || 'root').slice(0, 7)}..${(to || 'HEAD').slice(0, 7)} ` +
39
+ `(${selected.length} commits)`
40
+ )}`;
41
+ console.log('\n' + header + '\n');
42
+
43
+ if (options.plain || !ai.isEnabled()) {
44
+ for (const c of selected) {
45
+ const short = (c.hash || '').slice(0, 7);
46
+ const subject = (c.message || '').split('\n')[0];
47
+ console.log(` ${chalk.gray(short)} ${subject}`);
48
+ }
49
+ if (!ai.isEnabled()) {
50
+ console.log(chalk.gray(`\n${ai.disabledHint()}`));
51
+ }
52
+ return;
53
+ }
54
+
55
+ const summary = selected.map(c =>
56
+ `- ${(c.hash || '').slice(0, 7)}: ${(c.message || '').split('\n')[0]}`
57
+ ).join('\n');
58
+
59
+ const spinner = ora(`Grouping with ${ai.getModel()}...`).start();
60
+ try {
61
+ const out = await ai.complete({
62
+ system:
63
+ 'You write release-note-style changelogs. Group commits into ' +
64
+ 'Features / Fixes / Improvements / Other. Keep each bullet to one line ' +
65
+ 'and start with a verb. Drop merge commits and noise. Reply with Markdown.',
66
+ prompt: `Commits (newest first):\n\n${summary}`,
67
+ maxTokens: 1500,
68
+ });
69
+ spinner.stop();
70
+ console.log(out + '\n');
71
+ } catch (err) {
72
+ spinner.fail(chalk.yellow('AI grouping failed — falling back to plain list'));
73
+ console.log(chalk.gray(`(${err.message})\n`));
74
+ for (const c of selected) {
75
+ console.log(` ${chalk.gray((c.hash || '').slice(0, 7))} ${(c.message || '').split('\n')[0]}`);
76
+ }
77
+ }
78
+ } catch (error) {
79
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
80
+ console.error(chalk.red('Error: Not a gent repository'));
81
+ } else {
82
+ console.error(chalk.red('Error:'), error.message);
83
+ }
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ function resolveRange(range, repository) {
89
+ const head = repository.branches[repository.currentBranch] || null;
90
+ if (!range) {
91
+ // Use most recent tag if any, else null (means "all up to MAX_COMMITS")
92
+ const tags = repository.tags || {};
93
+ const tagHashes = Object.values(tags).map(t => t.hash || t.commit_sha).filter(Boolean);
94
+ return { from: tagHashes[tagHashes.length - 1] || null, to: head };
95
+ }
96
+ if (range.includes('..')) {
97
+ const [from, to] = range.split('..');
98
+ return { from: from || null, to: to || head };
99
+ }
100
+ return { from: range, to: head };
101
+ }
102
+
103
+ function commitsBetween(commits, from, to) {
104
+ const byHash = new Map(commits.map(c => [c.hash, c]));
105
+ const startHash = to || (commits[commits.length - 1] || {}).hash;
106
+ if (!startHash) return [];
107
+
108
+ const result = [];
109
+ let cur = startHash;
110
+ const seen = new Set();
111
+ while (cur && cur !== from && !seen.has(cur) && result.length < MAX_COMMITS) {
112
+ const c = byHash.get(cur);
113
+ if (!c) break;
114
+ seen.add(cur);
115
+ result.push(c);
116
+ cur = c.parent;
117
+ }
118
+ return result; // newest first
119
+ }
120
+
121
+ module.exports = changelog;
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const chalk = require('chalk');
8
8
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
9
9
  const { COMMITS_FILE } = require('../utils/constants');
10
+ const journal = require('../utils/journal');
10
11
 
11
12
  /**
12
13
  * Switch to a different branch
@@ -28,6 +29,8 @@ async function checkout(branch, options) {
28
29
  }
29
30
 
30
31
  // Create and switch to new branch
32
+ await journal.recordOp(gentPath, 'checkout', `create branch '${branch}'`);
33
+
31
34
  const currentCommit = branches[repository.currentBranch] || null;
32
35
  branches[branch] = currentCommit;
33
36
  repository.branches = branches;
@@ -52,6 +55,8 @@ async function checkout(branch, options) {
52
55
  return;
53
56
  }
54
57
 
58
+ await journal.recordOp(gentPath, 'checkout', `switch to branch '${branch}'`);
59
+
55
60
  repository.currentBranch = branch;
56
61
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
57
62
 
@@ -32,7 +32,7 @@ const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
32
32
  const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
33
33
  const apiClient = require('../utils/api-client');
34
34
  const authStorage = require('../utils/auth-storage');
35
- const { storeBlob, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
35
+ const { storeBlob, readBlob, decodeRemoteBlobContent } = require('../utils/hash-engine');
36
36
 
37
37
  /**
38
38
  * Clone remote repository
@@ -257,10 +257,13 @@ async function clone(url, directory, options) {
257
257
  let fileCount = 0;
258
258
  for (const entry of tree) {
259
259
  try {
260
- const content = await readBlobAsString(gentPath, entry.hash);
260
+ // Write the raw Buffer — not a UTF-8 string. Decoding a
261
+ // binary blob (PNG, PDF, etc.) as UTF-8 would replace
262
+ // non-utf-8 bytes with U+FFFD, silently corrupting it.
263
+ const buf = await readBlob(gentPath, entry.hash);
261
264
  const fullPath = path.join(targetPath, entry.name || entry.path);
262
265
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
263
- await fs.writeFile(fullPath, content, 'utf-8');
266
+ await fs.writeFile(fullPath, buf);
264
267
  fileCount++;
265
268
  } catch {
266
269
  // Blob missing
@@ -11,7 +11,10 @@ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
11
11
  const authStorage = require('../utils/auth-storage');
12
12
  const { STAGING_FILE, COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
13
13
  const { generateCommitHash } = require('../utils/helpers');
14
- const { storeTree, snapshotFile } = require('../utils/hash-engine');
14
+ const { storeTree, snapshotFile, readBlobAsString } = require('../utils/hash-engine');
15
+ const { formatUnifiedDiff } = require('../utils/diff-engine');
16
+ const journal = require('../utils/journal');
17
+ const ai = require('../utils/ai-service');
15
18
 
16
19
  /**
17
20
  * Create a new commit
@@ -36,6 +39,25 @@ async function commit(options) {
36
39
  // Get commit message
37
40
  let message = options.message;
38
41
 
42
+ // Optional: AI-suggested commit message (`gent commit --ai`)
43
+ if (!message && options.ai) {
44
+ if (!ai.isEnabled()) {
45
+ console.log(chalk.yellow(ai.disabledHint()));
46
+ } else {
47
+ const suggested = await suggestMessage(gentPath, stagedEntries);
48
+ if (suggested) {
49
+ const answer = await inquirer.prompt([{
50
+ type: 'input',
51
+ name: 'message',
52
+ message: 'Commit message (AI-suggested, edit as needed):',
53
+ default: suggested.split('\n')[0],
54
+ validate: (input) => input.length > 0 || 'Commit message cannot be empty'
55
+ }]);
56
+ message = answer.message;
57
+ }
58
+ }
59
+ }
60
+
39
61
  if (!message) {
40
62
  const answer = await inquirer.prompt([
41
63
  {
@@ -148,7 +170,9 @@ async function commit(options) {
148
170
  }
149
171
  };
150
172
 
151
- // Save commit
173
+ // Save commit (journal pre-state first so "gent undo" can reverse it)
174
+ await journal.recordOp(gentPath, 'commit', `${message} [${repository.currentBranch}]`);
175
+
152
176
  repository.commits = repository.commits || [];
153
177
  repository.commits.push(commitObj);
154
178
  repository.branches[repository.currentBranch] = commitObj.hash;
@@ -181,4 +205,43 @@ async function commit(options) {
181
205
  }
182
206
  }
183
207
 
208
+ /**
209
+ * Build a compact staged-diff summary and ask the AI for a commit message.
210
+ * Returns null on any failure (caller falls back to a manual prompt).
211
+ * @param {String} gentPath
212
+ * @param {Array} stagedEntries
213
+ * @returns {Promise<String|null>}
214
+ */
215
+ async function suggestMessage(gentPath, stagedEntries) {
216
+ try {
217
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
218
+ const headHash = repository.branches[repository.currentBranch];
219
+ const headCommit = headHash ? (repository.commits || []).find(c => c.hash === headHash) : null;
220
+ const headMap = new Map(
221
+ ((headCommit && headCommit.tree) ? headCommit.tree : (headCommit ? headCommit.files : []) || [])
222
+ .map(f => [f.path || f.name, f.hash])
223
+ );
224
+
225
+ const parts = [];
226
+ for (const e of stagedEntries) {
227
+ if (e.status === 'deleted') { parts.push(`deleted: ${e.path}`); continue; }
228
+ let oldText = '';
229
+ const prev = headMap.get(e.path);
230
+ try { if (prev) oldText = await readBlobAsString(gentPath, prev); } catch { /* binary */ }
231
+ let newText = '';
232
+ try { newText = await readBlobAsString(gentPath, e.hash); } catch { /* binary */ }
233
+ const d = formatUnifiedDiff(e.path, oldText, newText);
234
+ parts.push(d || `${e.status}: ${e.path}`);
235
+ }
236
+
237
+ const summary = parts.join('\n\n').slice(0, 12000);
238
+ const spinner = ora(`Asking ${ai.getModel()} for a commit message...`).start();
239
+ const msg = await ai.suggestCommitMessage(summary);
240
+ spinner.stop();
241
+ return msg || null;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
184
247
  module.exports = commit;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Config Command - Manage CLI-wide settings (~/.gent/cli-config.json)
3
+ *
4
+ * gent config list → show all settings + source
5
+ * gent config get <key> → print one setting
6
+ * gent config set <key> <value> → save a setting
7
+ * gent config unset <key> → remove a setting
8
+ * gent config path → print config file location
9
+ *
10
+ * gent config set ai.api_key <key> ← stored obfuscated
11
+ * gent config set api.base_url http://localhost:8000
12
+ */
13
+
14
+ const chalk = require('chalk');
15
+ const inquirer = require('inquirer');
16
+ const userConfig = require('../utils/user-config');
17
+
18
+ async function config(subcommand, args, options) {
19
+ try {
20
+ const sub = (subcommand || 'list').toLowerCase();
21
+ const positional = args || [];
22
+
23
+ switch (sub) {
24
+ case 'list':
25
+ case 'ls':
26
+ return list();
27
+ case 'get':
28
+ return get(positional[0]);
29
+ case 'set':
30
+ return set(positional[0], positional[1], options);
31
+ case 'unset':
32
+ case 'remove':
33
+ case 'rm':
34
+ return unset(positional[0]);
35
+ case 'path':
36
+ console.log(userConfig.getConfigPath());
37
+ return;
38
+ default:
39
+ console.error(chalk.red(`Unknown subcommand '${sub}'`));
40
+ console.log(chalk.gray('Usage: gent config <list|get|set|unset|path> [args]'));
41
+ process.exit(1);
42
+ }
43
+ } catch (error) {
44
+ console.error(chalk.red('Error:'), error.message);
45
+ process.exit(1);
46
+ }
47
+ }
48
+
49
+ async function list() {
50
+ const rows = await userConfig.listAll();
51
+ console.log(chalk.bold.cyan('\nGent CLI configuration\n'));
52
+
53
+ const sourceColor = {
54
+ env: chalk.magenta,
55
+ config: chalk.green,
56
+ default: chalk.gray,
57
+ unset: chalk.gray,
58
+ };
59
+
60
+ for (const row of rows) {
61
+ const value = row.value === undefined
62
+ ? chalk.gray('(not set)')
63
+ : chalk.white(row.value);
64
+ const sourceLabel = row.source === 'env'
65
+ ? `env: ${row.envName}`
66
+ : row.source;
67
+ console.log(
68
+ ` ${chalk.cyan(row.key.padEnd(16))} ${value} ` +
69
+ sourceColor[row.source](`[${sourceLabel}]`)
70
+ );
71
+ }
72
+ console.log(chalk.gray(`\nFile: ${userConfig.getConfigPath()}`));
73
+ console.log(chalk.gray('Set a value: gent config set <key> <value>\n'));
74
+ }
75
+
76
+ async function get(key) {
77
+ if (!key) {
78
+ console.error(chalk.red('Usage: gent config get <key>'));
79
+ process.exit(1);
80
+ }
81
+ if (!userConfig.isAllowedKey(key)) {
82
+ console.error(chalk.red(`Unknown key '${key}'`));
83
+ console.log(chalk.gray(`Allowed: ${userConfig.listAllowedKeys().join(', ')}`));
84
+ process.exit(1);
85
+ }
86
+ const resolved = await userConfig.getResolved(key);
87
+ if (resolved.value === undefined) {
88
+ console.log(chalk.gray('(not set)'));
89
+ return;
90
+ }
91
+ console.log(resolved.value);
92
+ }
93
+
94
+ async function set(key, value, options) {
95
+ if (!key) {
96
+ console.error(chalk.red('Usage: gent config set <key> <value>'));
97
+ process.exit(1);
98
+ }
99
+ if (!userConfig.isAllowedKey(key)) {
100
+ console.error(chalk.red(`Unknown key '${key}'`));
101
+ console.log(chalk.gray(`Allowed: ${userConfig.listAllowedKeys().join(', ')}`));
102
+ process.exit(1);
103
+ }
104
+
105
+ // Secret prompt: if no value given for ai.api_key, prompt with masking.
106
+ if ((value === undefined || value === '') && key === 'ai.api_key') {
107
+ const answers = await inquirer.prompt([{
108
+ type: 'password',
109
+ name: 'value',
110
+ message: 'Anthropic API key:',
111
+ mask: '*',
112
+ validate: (input) => input.length > 0 || 'Key cannot be empty',
113
+ }]);
114
+ value = answers.value;
115
+ }
116
+
117
+ if (value === undefined) {
118
+ console.error(chalk.red('Usage: gent config set <key> <value>'));
119
+ process.exit(1);
120
+ }
121
+
122
+ await userConfig.set(key, value);
123
+ const display = key === 'ai.api_key' ? userConfig.maskSecret(value) : value;
124
+ console.log(chalk.green(`✓ ${key} = ${display}`));
125
+
126
+ const envName = userConfig.ENV_OVERRIDES[key];
127
+ if (envName && process.env[envName]) {
128
+ console.log(chalk.yellow(
129
+ `Note: ${envName} is currently set in your environment and will override this value.`
130
+ ));
131
+ }
132
+ }
133
+
134
+ async function unset(key) {
135
+ if (!key) {
136
+ console.error(chalk.red('Usage: gent config unset <key>'));
137
+ process.exit(1);
138
+ }
139
+ if (!userConfig.isAllowedKey(key)) {
140
+ console.error(chalk.red(`Unknown key '${key}'`));
141
+ process.exit(1);
142
+ }
143
+ await userConfig.unset(key);
144
+ console.log(chalk.green(`✓ Removed ${key}`));
145
+ }
146
+
147
+ module.exports = config;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Docs Command - AI-generate a README for the current repo.
3
+ *
4
+ * gent docs → print suggested README to stdout
5
+ * gent docs --write → write to README.md (prompt before overwrite)
6
+ * gent docs --section <name> → only regenerate a section (Usage, Install, ...)
7
+ *
8
+ * Builds context from: repository name/desc, file tree, key files (package.json,
9
+ * pyproject.toml, etc.), and a sample of source files.
10
+ */
11
+
12
+ const path = require('path');
13
+ const fs = require('fs').promises;
14
+ const chalk = require('chalk');
15
+ const ora = require('ora');
16
+ const inquirer = require('inquirer');
17
+ const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
18
+ const { CONFIG_FILE } = require('../utils/constants');
19
+ const ai = require('../utils/ai-service');
20
+
21
+ const KEY_FILES = [
22
+ 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod',
23
+ 'requirements.txt', 'Gemfile', 'composer.json', 'pom.xml',
24
+ ];
25
+
26
+ const MAX_CONTEXT_CHARS = 16000;
27
+
28
+ async function docs(options = {}) {
29
+ try {
30
+ const cwd = process.cwd();
31
+ const gentPath = await getGentPath();
32
+
33
+ if (!ai.isEnabled()) {
34
+ console.error(chalk.red('AI is required for `gent docs`.'));
35
+ console.log(chalk.yellow(ai.disabledHint()));
36
+ process.exit(1);
37
+ }
38
+
39
+ const context = await buildDocsContext(gentPath, cwd);
40
+ const target = options.section
41
+ ? `Generate ONLY the "${options.section}" section of a README.md.`
42
+ : 'Generate a complete, polished README.md.';
43
+
44
+ const spinner = ora(`Drafting README with ${ai.getModel()}...`).start();
45
+ let draft;
46
+ try {
47
+ draft = await ai.complete({
48
+ system:
49
+ 'You write clear, accurate, well-formatted README.md files. ' +
50
+ 'Use plain GitHub-flavored Markdown. Do not invent features that are not ' +
51
+ 'in the provided context. Prefer short paragraphs and concrete examples.',
52
+ prompt:
53
+ `${target}\n\nProject context:\n\n${context}\n\n` +
54
+ 'Output: ONLY the Markdown. No preamble, no code fences around the whole document.',
55
+ maxTokens: 2500,
56
+ });
57
+ } catch (err) {
58
+ spinner.fail(chalk.red(err.message));
59
+ process.exit(1);
60
+ }
61
+ spinner.stop();
62
+
63
+ if (!options.write) {
64
+ console.log(draft);
65
+ console.log(chalk.gray('\n(use --write to save to README.md)\n'));
66
+ return;
67
+ }
68
+
69
+ const target_path = path.join(cwd, 'README.md');
70
+ if (await pathExists(target_path)) {
71
+ const { ok } = await inquirer.prompt([{
72
+ type: 'confirm',
73
+ name: 'ok',
74
+ message: `Overwrite existing README.md?`,
75
+ default: false,
76
+ }]);
77
+ if (!ok) {
78
+ console.log(chalk.yellow('Aborted — nothing written.'));
79
+ return;
80
+ }
81
+ }
82
+ await fs.writeFile(target_path, draft + '\n', 'utf-8');
83
+ console.log(chalk.green(`✓ Wrote ${path.relative(cwd, target_path)}`));
84
+ } catch (error) {
85
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
86
+ console.error(chalk.red('Error: Not a gent repository'));
87
+ } else {
88
+ console.error(chalk.red('Error:'), error.message);
89
+ }
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ async function buildDocsContext(gentPath, cwd) {
95
+ const parts = [];
96
+
97
+ try {
98
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
99
+ const r = config.repository || {};
100
+ parts.push(`# Project metadata\nname: ${r.name || ''}\ndescription: ${r.description || ''}`);
101
+ } catch { /* fine */ }
102
+
103
+ // Top-level layout
104
+ try {
105
+ const entries = await fs.readdir(cwd, { withFileTypes: true });
106
+ const layout = entries
107
+ .filter(e => !e.name.startsWith('.') && e.name !== 'node_modules')
108
+ .map(e => e.isDirectory() ? `${e.name}/` : e.name);
109
+ parts.push(`# Top-level layout\n${layout.join('\n')}`);
110
+ } catch { /* fine */ }
111
+
112
+ // Key manifest files
113
+ for (const f of KEY_FILES) {
114
+ const p = path.join(cwd, f);
115
+ if (await pathExists(p)) {
116
+ try {
117
+ const text = await fs.readFile(p, 'utf-8');
118
+ parts.push(`# ${f}\n${text.slice(0, 2000)}`);
119
+ } catch { /* fine */ }
120
+ }
121
+ }
122
+
123
+ // Existing README (so the model can preserve voice if user wants to refresh)
124
+ for (const c of ['README.md', 'readme.md']) {
125
+ const p = path.join(cwd, c);
126
+ if (await pathExists(p)) {
127
+ try {
128
+ const text = await fs.readFile(p, 'utf-8');
129
+ parts.push(`# Existing ${c}\n${text.slice(0, 3000)}`);
130
+ break;
131
+ } catch { /* fine */ }
132
+ }
133
+ }
134
+
135
+ const joined = parts.join('\n\n');
136
+ return joined.length > MAX_CONTEXT_CHARS
137
+ ? joined.slice(0, MAX_CONTEXT_CHARS) + '\n... (context truncated)'
138
+ : joined;
139
+ }
140
+
141
+ module.exports = docs;