gent-cli 7.0.0 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "7.0.0",
4
- "description": "A modern, Git-like version control CLI with built-in cloud authentication and global user identity management.",
3
+ "version": "8.0.0",
4
+ "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "gent": "src/index.js"
@@ -0,0 +1,82 @@
1
+ /**
2
+ * AI Command - Manage and verify AI integration.
3
+ *
4
+ * gent ai status → show key source, model, where it came from
5
+ * gent ai test → make a tiny live request to confirm it works
6
+ * gent ai models → list the model ids gent suggests
7
+ */
8
+
9
+ const chalk = require('chalk');
10
+ const ora = require('ora');
11
+ const ai = require('../utils/ai-service');
12
+ const userConfig = require('../utils/user-config');
13
+
14
+ const SUGGESTED_MODELS = [
15
+ { id: 'claude-opus-4-7', tag: 'flagship', note: 'Highest quality' },
16
+ { id: 'claude-sonnet-4-6', tag: 'balanced', note: 'Strong, faster, cheaper' },
17
+ { id: 'claude-haiku-4-5', tag: 'fastest', note: 'Fastest & cheapest' },
18
+ ];
19
+
20
+ async function aiCommand(subcommand) {
21
+ const sub = (subcommand || 'status').toLowerCase();
22
+ switch (sub) {
23
+ case 'status': return status();
24
+ case 'test': return test();
25
+ case 'models': return models();
26
+ default:
27
+ console.error(chalk.red(`Unknown subcommand '${sub}'`));
28
+ console.log(chalk.gray('Usage: gent ai <status|test|models>'));
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ async function status() {
34
+ const { value: key, source: keySource } = await ai.resolveKey();
35
+ const model = await ai.resolveModel();
36
+ const { source: modelSource } = await userConfig.getResolved('ai.model');
37
+
38
+ console.log(chalk.bold.cyan('\nGent AI status\n'));
39
+ if (key) {
40
+ console.log(` ${chalk.green('●')} API key: ${userConfig.maskSecret(key)} ${chalk.gray(`[${keySource}]`)}`);
41
+ } else {
42
+ console.log(` ${chalk.gray('○')} API key: ${chalk.gray('not set')}`);
43
+ console.log(chalk.gray(' ↳ ' + ai.disabledHint()));
44
+ }
45
+ console.log(` ${chalk.green('●')} Model: ${model} ${chalk.gray(`[${modelSource}]`)}`);
46
+ console.log(chalk.gray('\n Run `gent ai test` to verify the key actually works.'));
47
+ console.log();
48
+ }
49
+
50
+ async function test() {
51
+ const { value: key } = await ai.resolveKey();
52
+ if (!key) {
53
+ console.error(chalk.red('No AI key configured.'));
54
+ console.log(chalk.yellow('Set one with `gent config set ai.api_key <key>`.'));
55
+ process.exit(1);
56
+ }
57
+
58
+ const model = await ai.resolveModel();
59
+ const spinner = ora(`Pinging Anthropic (${model})...`).start();
60
+ try {
61
+ const reply = await ai.complete({
62
+ prompt: 'Reply with the single word: pong',
63
+ maxTokens: 8,
64
+ });
65
+ spinner.succeed(chalk.green(`✓ Reachable. Reply: "${reply}"`));
66
+ } catch (err) {
67
+ spinner.fail(chalk.red(err.message));
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ async function models() {
73
+ const current = await ai.resolveModel();
74
+ console.log(chalk.bold.cyan('\nSuggested Claude models\n'));
75
+ for (const m of SUGGESTED_MODELS) {
76
+ const active = m.id === current ? chalk.green(' (current)') : '';
77
+ console.log(` ${chalk.cyan(m.id.padEnd(22))} ${chalk.gray(m.tag.padEnd(10))} ${m.note}${active}`);
78
+ }
79
+ console.log(chalk.gray('\n Switch with: gent config set ai.model <id>\n'));
80
+ }
81
+
82
+ module.exports = aiCommand;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Ask Command - Plain-English Q&A about the current repo.
3
+ *
4
+ * gent ask "what does this project do?"
5
+ * gent ask "who has touched src/server.js recently?"
6
+ * gent ask "what's pending on the current branch?"
7
+ *
8
+ * Builds a compact repo summary (README + last N commits + tree listing) and
9
+ * sends it as context. Falls back to a useful text dump if no AI key is set.
10
+ */
11
+
12
+ const path = require('path');
13
+ const chalk = require('chalk');
14
+ const ora = require('ora');
15
+ const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
16
+ const fs = require('fs').promises;
17
+ const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
18
+ const ai = require('../utils/ai-service');
19
+
20
+ const MAX_CONTEXT_CHARS = 14000;
21
+ const MAX_COMMITS = 25;
22
+
23
+ async function ask(question, options = {}) {
24
+ try {
25
+ if (!question || !question.trim()) {
26
+ console.error(chalk.red('Usage: gent ask "<your question>"'));
27
+ process.exit(1);
28
+ }
29
+
30
+ const gentPath = await getGentPath();
31
+ const context = await buildRepoContext(gentPath);
32
+
33
+ if (!ai.isEnabled()) {
34
+ console.log(chalk.yellow(ai.disabledHint()));
35
+ console.log(chalk.gray('\nHere is the raw repo context you can pipe into another tool:\n'));
36
+ console.log(context);
37
+ return;
38
+ }
39
+
40
+ const spinner = ora(`Asking ${ai.getModel()}...`).start();
41
+ try {
42
+ const answer = await ai.complete({
43
+ system:
44
+ 'You are a senior engineer answering questions about a software repository. ' +
45
+ 'Be concrete and concise. If the answer is not in the context, say so. ' +
46
+ 'Reference filenames and short commit hashes when helpful.',
47
+ prompt: `Repository context:\n\n${context}\n\nQuestion: ${question}`,
48
+ maxTokens: 1024,
49
+ });
50
+ spinner.stop();
51
+ console.log('\n' + answer + '\n');
52
+ } catch (err) {
53
+ spinner.fail(chalk.red(err.message));
54
+ process.exit(1);
55
+ }
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
58
+ console.error(chalk.red('Error: Not a gent repository'));
59
+ console.log(chalk.yellow('Run "gent init" to initialize a repository'));
60
+ } else {
61
+ console.error(chalk.red('Error:'), error.message);
62
+ }
63
+ process.exit(1);
64
+ }
65
+ }
66
+
67
+ async function buildRepoContext(gentPath) {
68
+ const cwd = process.cwd();
69
+ const parts = [];
70
+
71
+ // Project name + description from config
72
+ try {
73
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
74
+ const repo = config.repository || {};
75
+ parts.push(`# Project\nname: ${repo.name || '(unnamed)'}\ndescription: ${repo.description || ''}`);
76
+ } catch { /* missing config — fine */ }
77
+
78
+ // README if present (any case, common extensions)
79
+ const readme = await findReadme(cwd);
80
+ if (readme) {
81
+ const text = await fs.readFile(readme.path, 'utf-8').catch(() => '');
82
+ if (text) parts.push(`# README (${readme.rel})\n${text.slice(0, 4000)}`);
83
+ }
84
+
85
+ // Recent commits
86
+ try {
87
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
88
+ const commits = (repository.commits || []).slice(-MAX_COMMITS).reverse();
89
+ const branch = repository.currentBranch || 'main';
90
+ const lines = commits.map(c =>
91
+ `- ${(c.hash || '').slice(0, 7)} (${(c.author?.name || 'unknown')}): ${(c.message || '').split('\n')[0]}`
92
+ );
93
+ parts.push(`# Recent commits on '${branch}'\n${lines.join('\n')}`);
94
+ } catch { /* no commits yet — fine */ }
95
+
96
+ // Top-level layout
97
+ try {
98
+ const entries = await fs.readdir(cwd, { withFileTypes: true });
99
+ const layout = entries
100
+ .filter(e => !e.name.startsWith('.') && e.name !== 'node_modules')
101
+ .map(e => e.isDirectory() ? `${e.name}/` : e.name)
102
+ .slice(0, 60);
103
+ parts.push(`# Top-level layout\n${layout.join('\n')}`);
104
+ } catch { /* unreadable — fine */ }
105
+
106
+ const joined = parts.join('\n\n');
107
+ return joined.length > MAX_CONTEXT_CHARS
108
+ ? joined.slice(0, MAX_CONTEXT_CHARS) + '\n... (context truncated)'
109
+ : joined;
110
+ }
111
+
112
+ async function findReadme(cwd) {
113
+ const candidates = ['README.md', 'readme.md', 'README.txt', 'README', 'README.rst'];
114
+ for (const c of candidates) {
115
+ const p = path.join(cwd, c);
116
+ if (await pathExists(p)) return { path: p, rel: c };
117
+ }
118
+ return null;
119
+ }
120
+
121
+ module.exports = ask;
@@ -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;
@@ -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
@@ -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;