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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup Command - First-run interactive wizard.
|
|
3
|
+
*
|
|
4
|
+
* gent setup
|
|
5
|
+
*
|
|
6
|
+
* Walks the user through: backend URL → login/register → AI key → identity.
|
|
7
|
+
* Each step is skippable; nothing is required.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const chalk = require('chalk');
|
|
11
|
+
const inquirer = require('inquirer');
|
|
12
|
+
const boxen = require('boxen');
|
|
13
|
+
const ora = require('ora');
|
|
14
|
+
const axios = require('axios');
|
|
15
|
+
const userConfig = require('../utils/user-config');
|
|
16
|
+
const authStorage = require('../utils/auth-storage');
|
|
17
|
+
const authService = require('../services/auth-service');
|
|
18
|
+
const ai = require('../utils/ai-service');
|
|
19
|
+
|
|
20
|
+
async function setup() {
|
|
21
|
+
console.log(boxen(
|
|
22
|
+
chalk.bold.cyan('Welcome to Gent\n') +
|
|
23
|
+
chalk.white('Let\'s get you set up. Every step is optional — press Enter to skip.'),
|
|
24
|
+
{ padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan' }
|
|
25
|
+
));
|
|
26
|
+
|
|
27
|
+
await stepBackend();
|
|
28
|
+
await stepAuth();
|
|
29
|
+
await stepAiKey();
|
|
30
|
+
await stepIdentity();
|
|
31
|
+
|
|
32
|
+
console.log(chalk.green('\n✓ Setup complete!'));
|
|
33
|
+
console.log(chalk.gray(' Run `gent doctor` any time to verify your setup.\n'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function stepBackend() {
|
|
37
|
+
console.log(chalk.bold('\n1. Backend server'));
|
|
38
|
+
const current = await userConfig.getResolved('api.base_url');
|
|
39
|
+
console.log(chalk.gray(` Current: ${current.value} [${current.source}]`));
|
|
40
|
+
|
|
41
|
+
const { change } = await inquirer.prompt([{
|
|
42
|
+
type: 'confirm',
|
|
43
|
+
name: 'change',
|
|
44
|
+
message: 'Change backend URL?',
|
|
45
|
+
default: false,
|
|
46
|
+
}]);
|
|
47
|
+
if (!change) return;
|
|
48
|
+
|
|
49
|
+
const { url } = await inquirer.prompt([{
|
|
50
|
+
type: 'input',
|
|
51
|
+
name: 'url',
|
|
52
|
+
message: 'Backend URL:',
|
|
53
|
+
default: current.value,
|
|
54
|
+
validate: (v) => /^https?:\/\//.test(v) || 'Must start with http:// or https://',
|
|
55
|
+
}]);
|
|
56
|
+
|
|
57
|
+
const spinner = ora('Probing backend...').start();
|
|
58
|
+
try {
|
|
59
|
+
await axios.get(url, { timeout: 8000, validateStatus: () => true });
|
|
60
|
+
spinner.succeed(chalk.green('Backend reachable'));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
spinner.warn(chalk.yellow(`Could not reach ${url} (${err.code || err.message}) — saving anyway`));
|
|
63
|
+
}
|
|
64
|
+
await userConfig.set('api.base_url', url);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function stepAuth() {
|
|
68
|
+
console.log(chalk.bold('\n2. Account'));
|
|
69
|
+
const isAuth = await authStorage.isAuthenticated();
|
|
70
|
+
if (isAuth) {
|
|
71
|
+
const user = await authStorage.getUser();
|
|
72
|
+
console.log(chalk.gray(` Already logged in as ${user?.email || 'unknown'} — skipping.`));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { action } = await inquirer.prompt([{
|
|
77
|
+
type: 'list',
|
|
78
|
+
name: 'action',
|
|
79
|
+
message: 'What would you like to do?',
|
|
80
|
+
choices: [
|
|
81
|
+
{ name: 'Log in to an existing account', value: 'login' },
|
|
82
|
+
{ name: 'Create a new account', value: 'register' },
|
|
83
|
+
{ name: 'Skip for now', value: 'skip' },
|
|
84
|
+
],
|
|
85
|
+
default: 'login',
|
|
86
|
+
}]);
|
|
87
|
+
if (action === 'skip') return;
|
|
88
|
+
|
|
89
|
+
const credentials = await inquirer.prompt([
|
|
90
|
+
{
|
|
91
|
+
type: 'input',
|
|
92
|
+
name: 'email',
|
|
93
|
+
message: 'Email:',
|
|
94
|
+
validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Invalid email',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: 'password',
|
|
98
|
+
name: 'password',
|
|
99
|
+
message: 'Password:',
|
|
100
|
+
mask: '*',
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const spinner = ora(action === 'login' ? 'Logging in...' : 'Creating account...').start();
|
|
105
|
+
try {
|
|
106
|
+
if (action === 'login') {
|
|
107
|
+
await authService.login(credentials.email, credentials.password);
|
|
108
|
+
} else {
|
|
109
|
+
const more = await inquirer.prompt([
|
|
110
|
+
{ type: 'password', name: 'passwordConfirm', message: 'Confirm password:', mask: '*' },
|
|
111
|
+
{ type: 'input', name: 'firstName', message: 'First name:', default: '' },
|
|
112
|
+
{ type: 'input', name: 'lastName', message: 'Last name:', default: '' },
|
|
113
|
+
]);
|
|
114
|
+
await authService.register(
|
|
115
|
+
credentials.email,
|
|
116
|
+
credentials.password,
|
|
117
|
+
more.passwordConfirm,
|
|
118
|
+
more.firstName,
|
|
119
|
+
more.lastName
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
spinner.succeed(chalk.green('Signed in'));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
spinner.fail(chalk.red(err.message));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
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
|
+
async function stepIdentity() {
|
|
177
|
+
console.log(chalk.bold('\n4. Default identity'));
|
|
178
|
+
const currentName = await userConfig.getResolved('user.name');
|
|
179
|
+
const currentEmail = await userConfig.getResolved('user.email');
|
|
180
|
+
|
|
181
|
+
if (currentName.value && currentEmail.value) {
|
|
182
|
+
console.log(chalk.gray(` ${currentName.value} <${currentEmail.value}> — skipping.`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Prefill from logged-in user if available
|
|
187
|
+
const u = await authStorage.getUser();
|
|
188
|
+
const defaultName = currentName.value ||
|
|
189
|
+
(u ? [u.first_name, u.last_name].filter(Boolean).join(' ') : '');
|
|
190
|
+
const defaultEmail = currentEmail.value || (u ? u.email : '');
|
|
191
|
+
|
|
192
|
+
const answers = await inquirer.prompt([
|
|
193
|
+
{ type: 'input', name: 'name', message: 'Default name on commits:', default: defaultName },
|
|
194
|
+
{ type: 'input', name: 'email', message: 'Default email on commits:', default: defaultEmail },
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
if (answers.name) await userConfig.set('user.name', answers.name);
|
|
198
|
+
if (answers.email) await userConfig.set('user.email', answers.email);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = setup;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share Command - Print a shareable link to current branch/commit.
|
|
3
|
+
*
|
|
4
|
+
* gent share → link to current HEAD on current branch
|
|
5
|
+
* gent share --branch <name> → link to a branch's tip
|
|
6
|
+
* gent share --commit <hash> → link to a specific commit
|
|
7
|
+
*
|
|
8
|
+
* Like `gent web --print` but always commit-scoped by default — handy for
|
|
9
|
+
* Slack/PR descriptions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const { getGentPath, readJSON } = require('../utils/fileSystem');
|
|
15
|
+
const { CONFIG_FILE, COMMITS_FILE, parseRemoteUrl } = require('../utils/constants');
|
|
16
|
+
const userConfig = require('../utils/user-config');
|
|
17
|
+
|
|
18
|
+
async function share(options = {}) {
|
|
19
|
+
try {
|
|
20
|
+
const gentPath = await getGentPath();
|
|
21
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
22
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
23
|
+
|
|
24
|
+
const remote = (config.remotes || {}).origin;
|
|
25
|
+
if (!remote) {
|
|
26
|
+
console.error(chalk.red('No origin remote configured.'));
|
|
27
|
+
console.log(chalk.yellow('Set one with `gent remote add origin <url>`.'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const info = parseRemoteUrl(remote.url);
|
|
31
|
+
if (!info) {
|
|
32
|
+
console.error(chalk.red('Origin URL is not in a recognized gent format.'));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { value: baseUrl } = await userConfig.getResolved('api.base_url');
|
|
37
|
+
const webHost = baseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
|
|
38
|
+
const repoBase = `${webHost}/${info.owner_id}/${info.repo_name}`;
|
|
39
|
+
|
|
40
|
+
if (options.commit) {
|
|
41
|
+
console.log(`${repoBase}/commit/${options.commit}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const branch = options.branch || repository.currentBranch;
|
|
45
|
+
const tip = repository.branches[branch];
|
|
46
|
+
if (tip) {
|
|
47
|
+
console.log(`${repoBase}/commit/${tip}`);
|
|
48
|
+
console.log(chalk.gray(`(${branch} @ ${tip.slice(0, 7)})`));
|
|
49
|
+
} else {
|
|
50
|
+
console.log(`${repoBase}/tree/${encodeURIComponent(branch)}`);
|
|
51
|
+
console.log(chalk.gray(`(${branch} has no commits yet)`));
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
55
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
56
|
+
} else {
|
|
57
|
+
console.error(chalk.red('Error:'), error.message);
|
|
58
|
+
}
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = share;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Summary Command - Repository health & statistics dashboard
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* A one-glance overview of the repository — commits, branches, tags,
|
|
8
|
+
* contributors, tracked files, object-store size, most-changed files, last
|
|
9
|
+
* activity, and how far ahead of the remote the current branch is. Something
|
|
10
|
+
* plain git doesn't offer in a single command.
|
|
11
|
+
*
|
|
12
|
+
* USAGE:
|
|
13
|
+
* gent summary → print the dashboard
|
|
14
|
+
* gent summary --ai → also include a short AI-written health narrative
|
|
15
|
+
*
|
|
16
|
+
* ============================================================================
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs').promises;
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
const boxen = require('boxen');
|
|
23
|
+
const { formatDistanceToNow } = require('date-fns');
|
|
24
|
+
const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
|
|
25
|
+
const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
|
|
26
|
+
const { readBlobAsString } = require('../utils/hash-engine');
|
|
27
|
+
const { formatBytes } = require('../utils/helpers');
|
|
28
|
+
const ai = require('../utils/ai-service');
|
|
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
|
+
/** Recursively sum the byte size of the object store. */
|
|
37
|
+
async function objectStoreSize(gentPath) {
|
|
38
|
+
const root = path.join(gentPath, 'objects');
|
|
39
|
+
let total = 0;
|
|
40
|
+
async function walk(dir) {
|
|
41
|
+
let entries;
|
|
42
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
const full = path.join(dir, e.name);
|
|
45
|
+
if (e.isDirectory()) await walk(full);
|
|
46
|
+
else { try { total += (await fs.stat(full)).size; } catch { /* ignore */ } }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await walk(root);
|
|
50
|
+
return total;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Count change frequency per file across history (hash-only tree diff). */
|
|
54
|
+
function mostChangedFiles(commits, commitMap, limit = 5) {
|
|
55
|
+
const counts = new Map();
|
|
56
|
+
for (const c of commits) {
|
|
57
|
+
const cur = new Map(treeEntriesOf(c).map(e => [e.name, e.hash]));
|
|
58
|
+
const parent = c.parent ? commitMap.get(c.parent) : null;
|
|
59
|
+
const prev = new Map(treeEntriesOf(parent).map(e => [e.name, e.hash]));
|
|
60
|
+
const names = new Set([...cur.keys(), ...prev.keys()]);
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (cur.get(name) !== prev.get(name)) counts.set(name, (counts.get(name) || 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Count commits reachable from `head` not yet known to the remote ref. */
|
|
69
|
+
function aheadCount(commitMap, head, remoteRef) {
|
|
70
|
+
if (!head) return 0;
|
|
71
|
+
let cur = head, n = 0;
|
|
72
|
+
const guard = new Set();
|
|
73
|
+
while (cur && cur !== remoteRef && !guard.has(cur)) {
|
|
74
|
+
guard.add(cur);
|
|
75
|
+
const c = commitMap.get(cur);
|
|
76
|
+
if (!c) break;
|
|
77
|
+
n++;
|
|
78
|
+
cur = c.parent;
|
|
79
|
+
}
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function summary(options = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const gentPath = await getGentPath();
|
|
86
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
87
|
+
const configPath = path.join(gentPath, CONFIG_FILE);
|
|
88
|
+
const config = (await pathExists(configPath)) ? await readJSON(configPath) : {};
|
|
89
|
+
|
|
90
|
+
const commits = repository.commits || [];
|
|
91
|
+
const commitMap = new Map(commits.map(c => [c.hash, c]));
|
|
92
|
+
const branches = repository.branches || {};
|
|
93
|
+
const currentBranch = repository.currentBranch || 'main';
|
|
94
|
+
const tags = repository.tags || {};
|
|
95
|
+
|
|
96
|
+
// Contributors
|
|
97
|
+
const authors = new Map();
|
|
98
|
+
for (const c of commits) {
|
|
99
|
+
const key = `${c.author?.name || 'Unknown'} <${c.author?.email || 'unknown'}>`;
|
|
100
|
+
authors.set(key, (authors.get(key) || 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
const topAuthors = [...authors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
103
|
+
|
|
104
|
+
// Tracked files + lines of code (text blobs only)
|
|
105
|
+
const headHash = branches[currentBranch];
|
|
106
|
+
const headTree = treeEntriesOf(commitMap.get(headHash));
|
|
107
|
+
let loc = 0;
|
|
108
|
+
for (const e of headTree) {
|
|
109
|
+
try { loc += (await readBlobAsString(gentPath, e.hash)).split('\n').length; } catch { /* binary */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const storeSize = await objectStoreSize(gentPath);
|
|
113
|
+
const topChanged = mostChangedFiles(commits, commitMap);
|
|
114
|
+
|
|
115
|
+
// Last activity
|
|
116
|
+
const last = commits.reduce((acc, c) =>
|
|
117
|
+
(!acc || new Date(c.timestamp) > new Date(acc.timestamp)) ? c : acc, null);
|
|
118
|
+
|
|
119
|
+
// Ahead of remote (if known)
|
|
120
|
+
const remoteRef = (config.remoteRefs || {})[`origin/${currentBranch}`];
|
|
121
|
+
const ahead = remoteRef ? aheadCount(commitMap, headHash, remoteRef) : null;
|
|
122
|
+
|
|
123
|
+
// ── Render ──
|
|
124
|
+
const lines = [];
|
|
125
|
+
lines.push(chalk.bold.cyan(config.repository?.name || path.basename(process.cwd())));
|
|
126
|
+
if (config.repository?.description) lines.push(chalk.gray(config.repository.description));
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(`${chalk.bold('Branch:')} ${chalk.green(currentBranch)} ${chalk.gray(`(${Object.keys(branches).length} total)`)}`);
|
|
129
|
+
lines.push(`${chalk.bold('Commits:')} ${commits.length}`);
|
|
130
|
+
lines.push(`${chalk.bold('Tags:')} ${Object.keys(tags).length}`);
|
|
131
|
+
lines.push(`${chalk.bold('Tracked:')} ${headTree.length} file(s), ~${loc} lines`);
|
|
132
|
+
lines.push(`${chalk.bold('Objects:')} ${formatBytes(storeSize)}`);
|
|
133
|
+
if (ahead !== null) lines.push(`${chalk.bold('Remote:')} ${ahead === 0 ? chalk.green('up to date') : chalk.yellow(`${ahead} commit(s) ahead of origin/${currentBranch}`)}`);
|
|
134
|
+
if (last) lines.push(`${chalk.bold('Last commit:')} ${formatDistanceToNow(new Date(last.timestamp), { addSuffix: true })}`);
|
|
135
|
+
|
|
136
|
+
if (topAuthors.length) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(chalk.bold('Top contributors:'));
|
|
139
|
+
for (const [name, n] of topAuthors) lines.push(` ${chalk.green(String(n).padStart(4))} ${name}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (topChanged.length) {
|
|
143
|
+
lines.push('');
|
|
144
|
+
lines.push(chalk.bold('Most-changed files:'));
|
|
145
|
+
for (const [name, n] of topChanged) lines.push(` ${chalk.yellow(String(n).padStart(4))} ${name}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(boxen(lines.join('\n'), {
|
|
149
|
+
padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan', title: 'gent summary', titleAlignment: 'center'
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
if (options.ai) {
|
|
153
|
+
if (!ai.isEnabled()) {
|
|
154
|
+
console.log(chalk.gray(ai.disabledHint()));
|
|
155
|
+
} else {
|
|
156
|
+
try {
|
|
157
|
+
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
|
+
console.log(chalk.cyan(narrative));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.log(chalk.yellow(`AI summary failed: ${err.message}`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
167
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
168
|
+
console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
|
|
169
|
+
} else {
|
|
170
|
+
console.error(chalk.red('Error:'), error.message);
|
|
171
|
+
}
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = summary;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Command - Quick-start from a baked-in starter.
|
|
3
|
+
*
|
|
4
|
+
* gent template list → show available templates
|
|
5
|
+
* gent template use <name> [directory] → scaffold a starter
|
|
6
|
+
*
|
|
7
|
+
* Templates are tiny inline definitions — no network needed. Keep them small
|
|
8
|
+
* so the CLI bundle stays light.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs').promises;
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const { pathExists, ensureDir } = require('../utils/fileSystem');
|
|
15
|
+
const initCommand = require('./init');
|
|
16
|
+
|
|
17
|
+
const TEMPLATES = {
|
|
18
|
+
node: {
|
|
19
|
+
description: 'Minimal Node.js project (package.json + index.js)',
|
|
20
|
+
files: {
|
|
21
|
+
'package.json': JSON.stringify({
|
|
22
|
+
name: '__NAME__',
|
|
23
|
+
version: '0.1.0',
|
|
24
|
+
main: 'index.js',
|
|
25
|
+
scripts: { start: 'node index.js' },
|
|
26
|
+
}, null, 2) + '\n',
|
|
27
|
+
'index.js': "console.log('hello from __NAME__');\n",
|
|
28
|
+
'.gentignore': 'node_modules/\n.env\n',
|
|
29
|
+
'README.md': '# __NAME__\n\nA Node.js project scaffolded with `gent template use node`.\n',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
python: {
|
|
33
|
+
description: 'Minimal Python project (main.py + requirements.txt)',
|
|
34
|
+
files: {
|
|
35
|
+
'main.py': "def main():\n print('hello from __NAME__')\n\nif __name__ == '__main__':\n main()\n",
|
|
36
|
+
'requirements.txt': '',
|
|
37
|
+
'.gentignore': '__pycache__/\n.venv/\n*.pyc\n.env\n',
|
|
38
|
+
'README.md': '# __NAME__\n\nA Python project scaffolded with `gent template use python`.\n',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
react: {
|
|
42
|
+
description: 'Vite + React skeleton (vite-style index.html + src/main.jsx)',
|
|
43
|
+
files: {
|
|
44
|
+
'package.json': JSON.stringify({
|
|
45
|
+
name: '__NAME__',
|
|
46
|
+
version: '0.1.0',
|
|
47
|
+
type: 'module',
|
|
48
|
+
scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
|
|
49
|
+
dependencies: { react: '^18.3.1', 'react-dom': '^18.3.1' },
|
|
50
|
+
devDependencies: { vite: '^5.4.0', '@vitejs/plugin-react': '^4.3.1' },
|
|
51
|
+
}, null, 2) + '\n',
|
|
52
|
+
'index.html': '<!doctype html>\n<html><head><title>__NAME__</title></head>\n<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>\n',
|
|
53
|
+
'src/main.jsx': "import React from 'react';\nimport { createRoot } from 'react-dom/client';\ncreateRoot(document.getElementById('root')).render(<h1>__NAME__</h1>);\n",
|
|
54
|
+
'.gentignore': 'node_modules/\ndist/\n.env\n',
|
|
55
|
+
'README.md': '# __NAME__\n\nRun `npm install && npm run dev` to start.\n',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
'django-api': {
|
|
59
|
+
description: 'Minimal Django project shell (manage.py + settings stub)',
|
|
60
|
+
files: {
|
|
61
|
+
'manage.py': "#!/usr/bin/env python\nimport os, sys\nif __name__ == '__main__':\n os.environ.setdefault('DJANGO_SETTINGS_MODULE', '__NAME__.settings')\n from django.core.management import execute_from_command_line\n execute_from_command_line(sys.argv)\n",
|
|
62
|
+
'requirements.txt': 'Django>=5.0\n',
|
|
63
|
+
'.gentignore': '__pycache__/\n*.pyc\n.venv/\ndb.sqlite3\n.env\n',
|
|
64
|
+
'README.md': '# __NAME__\n\nRun `python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`.\n',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
async function template(subcommand, args = [], options = {}) {
|
|
70
|
+
const sub = (subcommand || 'list').toLowerCase();
|
|
71
|
+
switch (sub) {
|
|
72
|
+
case 'list': return list();
|
|
73
|
+
case 'use': return use(args[0], args[1], options);
|
|
74
|
+
default:
|
|
75
|
+
console.error(chalk.red(`Unknown subcommand '${sub}'`));
|
|
76
|
+
console.log(chalk.gray('Usage: gent template <list|use>'));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function list() {
|
|
82
|
+
console.log(chalk.bold.cyan('\nAvailable templates\n'));
|
|
83
|
+
for (const [name, t] of Object.entries(TEMPLATES)) {
|
|
84
|
+
console.log(` ${chalk.cyan(name.padEnd(14))} ${chalk.gray(t.description)}`);
|
|
85
|
+
}
|
|
86
|
+
console.log(chalk.gray('\n Usage: gent template use <name> [directory]\n'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function use(name, directory) {
|
|
90
|
+
if (!name) {
|
|
91
|
+
console.error(chalk.red('Usage: gent template use <name> [directory]'));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const tpl = TEMPLATES[name];
|
|
95
|
+
if (!tpl) {
|
|
96
|
+
console.error(chalk.red(`Unknown template '${name}'`));
|
|
97
|
+
console.log(chalk.gray(`Available: ${Object.keys(TEMPLATES).join(', ')}`));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const targetDir = directory || name;
|
|
102
|
+
const targetPath = path.resolve(process.cwd(), targetDir);
|
|
103
|
+
const projectName = path.basename(targetPath).replace(/[^A-Za-z0-9_-]/g, '-');
|
|
104
|
+
|
|
105
|
+
if (await pathExists(targetPath)) {
|
|
106
|
+
const entries = await fs.readdir(targetPath);
|
|
107
|
+
if (entries.length > 0) {
|
|
108
|
+
console.error(chalk.red(`Directory '${targetDir}' is not empty.`));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
await ensureDir(targetPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [rel, content] of Object.entries(tpl.files)) {
|
|
116
|
+
const filePath = path.join(targetPath, rel);
|
|
117
|
+
await ensureDir(path.dirname(filePath));
|
|
118
|
+
const body = content.replace(/__NAME__/g, projectName);
|
|
119
|
+
await fs.writeFile(filePath, body, 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(chalk.green(`✓ Scaffolded '${name}' into ${targetDir}/`));
|
|
123
|
+
|
|
124
|
+
// Auto-init a gent repo so the next steps work
|
|
125
|
+
const originalCwd = process.cwd();
|
|
126
|
+
try {
|
|
127
|
+
process.chdir(targetPath);
|
|
128
|
+
await initCommand({});
|
|
129
|
+
} finally {
|
|
130
|
+
process.chdir(originalCwd);
|
|
131
|
+
}
|
|
132
|
+
console.log(chalk.gray(' Next: cd ' + targetDir + ' && gent add -A && gent commit -m "init"'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = template;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Undo / Redo Commands - One-command safety net over the operation journal
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Reverse (or re-apply) the last history-changing operation without having to
|
|
8
|
+
* reason about reflogs and commit hashes.
|
|
9
|
+
*
|
|
10
|
+
* USAGE:
|
|
11
|
+
* gent undo → Reverse the last operation
|
|
12
|
+
* gent undo --list → Show the operation history (most recent first)
|
|
13
|
+
* gent redo → Re-apply the last undone operation
|
|
14
|
+
*
|
|
15
|
+
* See src/utils/journal.js for the recorded state and exact undo semantics
|
|
16
|
+
* (working files are never deleted).
|
|
17
|
+
*
|
|
18
|
+
* ============================================================================
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
const { formatDistanceToNow } = require('date-fns');
|
|
23
|
+
const { getGentPath } = require('../utils/fileSystem');
|
|
24
|
+
const journal = require('../utils/journal');
|
|
25
|
+
|
|
26
|
+
function shortHash(h) {
|
|
27
|
+
return h ? h.substring(0, 7) : '(none)';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notARepo(error) {
|
|
31
|
+
return error.code === 'ENOENT' && error.message.includes('.gent');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `gent undo` — reverse the last operation, or list history with --list.
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
*/
|
|
38
|
+
async function undo(options = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const gentPath = await getGentPath();
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
|
|
43
|
+
if (options.list) {
|
|
44
|
+
await printHistory(gentPath);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await journal.applyUndo(gentPath, cwd);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
console.log(chalk.yellow('Nothing to undo'));
|
|
51
|
+
console.log(chalk.gray('History-changing operations (commit, merge, reset, checkout) can be undone.'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { entry } = result;
|
|
56
|
+
console.log(chalk.green(`✓ Undid ${chalk.bold(entry.op)}: ${entry.description}`));
|
|
57
|
+
console.log(chalk.gray(` Now on '${result.branch}' at ${shortHash(result.head)}`));
|
|
58
|
+
console.log(chalk.cyan(' Run "gent redo" to re-apply.'));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (notARepo(error)) {
|
|
61
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
62
|
+
console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
|
|
63
|
+
} else {
|
|
64
|
+
console.error(chalk.red('Error:'), error.message);
|
|
65
|
+
}
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `gent redo` — re-apply the last undone operation.
|
|
72
|
+
*/
|
|
73
|
+
async function redo() {
|
|
74
|
+
try {
|
|
75
|
+
const gentPath = await getGentPath();
|
|
76
|
+
const cwd = process.cwd();
|
|
77
|
+
|
|
78
|
+
const result = await journal.applyRedo(gentPath, cwd);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
console.log(chalk.yellow('Nothing to redo'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { entry } = result;
|
|
85
|
+
console.log(chalk.green(`✓ Redid ${chalk.bold(entry.op)}: ${entry.description}`));
|
|
86
|
+
console.log(chalk.gray(` Now on '${result.branch}' at ${shortHash(result.head)}`));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (notARepo(error)) {
|
|
89
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
90
|
+
console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
|
|
91
|
+
} else {
|
|
92
|
+
console.error(chalk.red('Error:'), error.message);
|
|
93
|
+
}
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function printHistory(gentPath) {
|
|
99
|
+
const entries = await journal.listEntries(gentPath);
|
|
100
|
+
if (entries.length === 0) {
|
|
101
|
+
console.log(chalk.yellow('No operations recorded yet'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(chalk.bold.cyan('\nOperation history (most recent first):\n'));
|
|
106
|
+
entries.forEach((e, i) => {
|
|
107
|
+
const when = chalk.gray(`(${formatDistanceToNow(new Date(e.timestamp), { addSuffix: true })})`);
|
|
108
|
+
const marker = i === 0 ? chalk.yellow('● ') : chalk.gray('○ ');
|
|
109
|
+
console.log(`${marker}${chalk.bold(e.op.padEnd(14))} ${e.description} ${when}`);
|
|
110
|
+
});
|
|
111
|
+
console.log(chalk.gray('\n"gent undo" reverses the most recent (●).'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = undo;
|
|
115
|
+
module.exports.redo = redo;
|