gent-cli 9.1.0 → 10.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 +1 -1
- package/src/commands/add.js +2 -2
- package/src/commands/auto.js +270 -0
- package/src/index.js +120 -11
- package/src/utils/interactive.js +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
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": {
|
package/src/commands/add.js
CHANGED
|
@@ -76,8 +76,8 @@ async function add(files, options) {
|
|
|
76
76
|
|
|
77
77
|
// Check if changed vs last commit
|
|
78
78
|
const prevHash = lastTreeMap.get(relPath);
|
|
79
|
-
if (prevHash === blobHash
|
|
80
|
-
continue; //
|
|
79
|
+
if (prevHash === blobHash) {
|
|
80
|
+
continue; // identical to the last commit — nothing to stage
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// Determine change status
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto Command - One-shot interactive flow.
|
|
3
|
+
*
|
|
4
|
+
* gent auto
|
|
5
|
+
*
|
|
6
|
+
* Walks a fresh user from nothing to a pushed commit:
|
|
7
|
+
* sign in (or register) → init → link a remote → stage → commit → push.
|
|
8
|
+
* Each step no-ops when it's already done, so `gent auto` is safe to re-run.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const chalk = require('chalk');
|
|
13
|
+
const ora = require('ora');
|
|
14
|
+
const boxen = require('boxen');
|
|
15
|
+
const inquirer = require('inquirer');
|
|
16
|
+
|
|
17
|
+
const { pathExists, readJSON, writeJSON } = require('../utils/fileSystem');
|
|
18
|
+
const { GENT_DIR, CONFIG_FILE, API_ENDPOINTS, parseRemoteUrl } = require('../utils/constants');
|
|
19
|
+
const authStorage = require('../utils/auth-storage');
|
|
20
|
+
const authService = require('../services/auth-service');
|
|
21
|
+
const apiClient = require('../utils/api-client');
|
|
22
|
+
const { isInteractive } = require('../utils/interactive');
|
|
23
|
+
|
|
24
|
+
const initCommand = require('./init');
|
|
25
|
+
const addCommand = require('./add');
|
|
26
|
+
const commitCommand = require('./commit');
|
|
27
|
+
const pushCommand = require('./push');
|
|
28
|
+
|
|
29
|
+
async function auto() {
|
|
30
|
+
if (!isInteractive()) {
|
|
31
|
+
console.error(chalk.red('gent auto needs an interactive terminal.'));
|
|
32
|
+
console.log(chalk.yellow('In scripts/CI, use the individual commands: gent init / add / commit / push.'));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(boxen(
|
|
37
|
+
chalk.bold.cyan('gent auto') + chalk.gray(' — guided flow\n') +
|
|
38
|
+
chalk.white('Sign in → init → link remote → add → commit → push.'),
|
|
39
|
+
{ padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan' }
|
|
40
|
+
));
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await ensureAuth();
|
|
44
|
+
await ensureRepo();
|
|
45
|
+
const linked = await ensureRemote();
|
|
46
|
+
await stageChanges();
|
|
47
|
+
await commitChanges();
|
|
48
|
+
|
|
49
|
+
if (linked) {
|
|
50
|
+
await pushChanges();
|
|
51
|
+
} else {
|
|
52
|
+
console.log(chalk.gray('\nSkipped push — no remote linked. Run `gent push` once you link one.'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(chalk.green('\n✓ All done.'));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Sub-steps handle their own expected errors; this catches anything
|
|
58
|
+
// unexpected (e.g. a corrupt .gent/config.json) with a clean message.
|
|
59
|
+
console.error(chalk.red('\ngent auto stopped:'), err.message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── 1. Account ─────────────────────────────────────────
|
|
65
|
+
async function ensureAuth() {
|
|
66
|
+
console.log(chalk.bold('\n1) Account'));
|
|
67
|
+
|
|
68
|
+
if (await authStorage.isAuthenticated()) {
|
|
69
|
+
const user = await authStorage.getUser();
|
|
70
|
+
console.log(chalk.green(` ✓ Signed in as ${user?.email || 'unknown'}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { action } = await inquirer.prompt([{
|
|
75
|
+
type: 'list',
|
|
76
|
+
name: 'action',
|
|
77
|
+
message: 'You are not signed in. What would you like to do?',
|
|
78
|
+
choices: [
|
|
79
|
+
{ name: 'Log in to an existing account', value: 'login' },
|
|
80
|
+
{ name: 'Create a new account', value: 'register' },
|
|
81
|
+
],
|
|
82
|
+
}]);
|
|
83
|
+
|
|
84
|
+
const creds = await inquirer.prompt([
|
|
85
|
+
{
|
|
86
|
+
type: 'input',
|
|
87
|
+
name: 'email',
|
|
88
|
+
message: 'Email:',
|
|
89
|
+
validate: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Please enter a valid email',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: 'password',
|
|
93
|
+
name: 'password',
|
|
94
|
+
message: 'Password:',
|
|
95
|
+
mask: '*',
|
|
96
|
+
validate: v => v.length > 0 || 'Password is required',
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
let extra = {};
|
|
101
|
+
if (action === 'register') {
|
|
102
|
+
extra = await inquirer.prompt([
|
|
103
|
+
{
|
|
104
|
+
type: 'password',
|
|
105
|
+
name: 'passwordConfirm',
|
|
106
|
+
message: 'Confirm password:',
|
|
107
|
+
mask: '*',
|
|
108
|
+
validate: v => v === creds.password || 'Passwords do not match',
|
|
109
|
+
},
|
|
110
|
+
{ type: 'input', name: 'firstName', message: 'First name:', default: '' },
|
|
111
|
+
{ type: 'input', name: 'lastName', message: 'Last name:', default: '' },
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const spinner = ora(action === 'login' ? 'Logging in…' : 'Creating account…').start();
|
|
116
|
+
try {
|
|
117
|
+
if (action === 'login') {
|
|
118
|
+
await authService.login(creds.email, creds.password);
|
|
119
|
+
} else {
|
|
120
|
+
await authService.register(
|
|
121
|
+
creds.email, creds.password, extra.passwordConfirm, extra.firstName, extra.lastName
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
spinner.succeed(chalk.green(`Signed in as ${creds.email}`));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
spinner.fail(chalk.red(err.message));
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── 2. Repository ──────────────────────────────────────
|
|
132
|
+
async function ensureRepo() {
|
|
133
|
+
console.log(chalk.bold('\n2) Repository'));
|
|
134
|
+
if (await pathExists(path.join(process.cwd(), GENT_DIR))) {
|
|
135
|
+
console.log(chalk.green(' ✓ Already a gent repository'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await initCommand({}); // prints its own "Initialized …" line
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── 3. Remote ──────────────────────────────────────────
|
|
142
|
+
// Returns true when origin is linked (so we know whether to push at the end).
|
|
143
|
+
async function ensureRemote() {
|
|
144
|
+
console.log(chalk.bold('\n3) Remote'));
|
|
145
|
+
|
|
146
|
+
const configPath = path.join(process.cwd(), GENT_DIR, CONFIG_FILE);
|
|
147
|
+
const config = await readJSON(configPath);
|
|
148
|
+
config.remotes = config.remotes || {};
|
|
149
|
+
|
|
150
|
+
if (config.remotes.origin) {
|
|
151
|
+
console.log(chalk.green(` ✓ Linked: origin → ${config.remotes.origin.url}`));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { how } = await inquirer.prompt([{
|
|
156
|
+
type: 'list',
|
|
157
|
+
name: 'how',
|
|
158
|
+
message: 'No remote linked yet. Link one?',
|
|
159
|
+
choices: [
|
|
160
|
+
{ name: 'Create a new repository on gent', value: 'create' },
|
|
161
|
+
{ name: 'Link an existing remote URL', value: 'existing' },
|
|
162
|
+
{ name: 'Skip for now', value: 'skip' },
|
|
163
|
+
],
|
|
164
|
+
}]);
|
|
165
|
+
|
|
166
|
+
if (how === 'skip') {
|
|
167
|
+
console.log(chalk.gray(' Skipped.'));
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (how === 'existing') {
|
|
172
|
+
const { url } = await inquirer.prompt([{
|
|
173
|
+
type: 'input',
|
|
174
|
+
name: 'url',
|
|
175
|
+
message: 'Remote URL (/api/repos/{owner_id}/{repo}):',
|
|
176
|
+
validate: v => !!parseRemoteUrl(v) || 'Expected /api/repos/{owner_id}/{repo_name}',
|
|
177
|
+
}]);
|
|
178
|
+
config.remotes.origin = { url };
|
|
179
|
+
await writeJSON(configPath, config);
|
|
180
|
+
console.log(chalk.green(` ✓ Linked origin → ${url}`));
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// create
|
|
185
|
+
if (!(await authStorage.isAuthenticated())) {
|
|
186
|
+
console.log(chalk.yellow(' Sign in first to create a remote — skipping.'));
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { name, isPrivate } = await inquirer.prompt([
|
|
191
|
+
{
|
|
192
|
+
type: 'input',
|
|
193
|
+
name: 'name',
|
|
194
|
+
message: 'Repository name:',
|
|
195
|
+
default: path.basename(process.cwd()),
|
|
196
|
+
validate: v => v.trim().length > 0 || 'Please enter a name',
|
|
197
|
+
},
|
|
198
|
+
{ type: 'confirm', name: 'isPrivate', message: 'Private repository?', default: false },
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
const spinner = ora(`Creating '${name}' on gent…`).start();
|
|
202
|
+
try {
|
|
203
|
+
const data = await apiClient.post(API_ENDPOINTS.REPOS_CREATE, {
|
|
204
|
+
name: name.trim(),
|
|
205
|
+
description: '',
|
|
206
|
+
is_private: isPrivate,
|
|
207
|
+
});
|
|
208
|
+
const repo = data.repository || data;
|
|
209
|
+
const url = `/api/repos/${repo.owner_id}/${repo.name}`;
|
|
210
|
+
config.remotes.origin = { url };
|
|
211
|
+
await writeJSON(configPath, config);
|
|
212
|
+
spinner.succeed(chalk.green(`Linked origin → ${url}`));
|
|
213
|
+
return true;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err.response?.status === 400) {
|
|
216
|
+
spinner.warn(chalk.yellow('Could not create — the name may already be taken.'));
|
|
217
|
+
console.log(chalk.gray(' Tip: `gent repos` to list, then `gent remote add origin <url>`.'));
|
|
218
|
+
} else {
|
|
219
|
+
spinner.fail(chalk.red(err.message));
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── 4. Stage ───────────────────────────────────────────
|
|
226
|
+
async function stageChanges() {
|
|
227
|
+
console.log(chalk.bold('\n4) Stage changes'));
|
|
228
|
+
|
|
229
|
+
const { how } = await inquirer.prompt([{
|
|
230
|
+
type: 'list',
|
|
231
|
+
name: 'how',
|
|
232
|
+
message: 'What should we stage?',
|
|
233
|
+
choices: [
|
|
234
|
+
{ name: 'All changes', value: 'all' },
|
|
235
|
+
{ name: 'Specific paths', value: 'pick' },
|
|
236
|
+
{ name: 'Nothing (skip)', value: 'skip' },
|
|
237
|
+
],
|
|
238
|
+
default: 'all',
|
|
239
|
+
}]);
|
|
240
|
+
|
|
241
|
+
if (how === 'skip') return;
|
|
242
|
+
|
|
243
|
+
if (how === 'all') {
|
|
244
|
+
await addCommand([], { all: true });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const { paths } = await inquirer.prompt([{
|
|
249
|
+
type: 'input',
|
|
250
|
+
name: 'paths',
|
|
251
|
+
message: 'Paths to add (space-separated):',
|
|
252
|
+
validate: v => v.trim().length > 0 || 'Enter at least one path',
|
|
253
|
+
}]);
|
|
254
|
+
await addCommand(paths.trim().split(/\s+/), {});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── 5. Commit ──────────────────────────────────────────
|
|
258
|
+
async function commitChanges() {
|
|
259
|
+
console.log(chalk.bold('\n5) Commit'));
|
|
260
|
+
// commit() prompts for the message itself and no-ops if nothing is staged.
|
|
261
|
+
await commitCommand({});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── 6. Push ────────────────────────────────────────────
|
|
265
|
+
async function pushChanges() {
|
|
266
|
+
console.log(chalk.bold('\n6) Push'));
|
|
267
|
+
await pushCommand('origin', undefined, {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = auto;
|
package/src/index.js
CHANGED
|
@@ -29,8 +29,10 @@ require('./utils/env-loader').load();
|
|
|
29
29
|
const { program } = require('commander');
|
|
30
30
|
const chalk = require('chalk');
|
|
31
31
|
const packageJson = require('../package.json');
|
|
32
|
+
const interactive = require('./utils/interactive');
|
|
32
33
|
|
|
33
34
|
// Import core commands
|
|
35
|
+
const autoCommand = require('./commands/auto');
|
|
34
36
|
const initCommand = require('./commands/init');
|
|
35
37
|
const cloneCommand = require('./commands/clone');
|
|
36
38
|
const statusCommand = require('./commands/status');
|
|
@@ -85,6 +87,11 @@ program
|
|
|
85
87
|
|
|
86
88
|
// ─── Repository Setup ───────────────────────────────────
|
|
87
89
|
|
|
90
|
+
program
|
|
91
|
+
.command('auto')
|
|
92
|
+
.description('Guided flow: sign in → init → link remote → add → commit → push')
|
|
93
|
+
.action(autoCommand);
|
|
94
|
+
|
|
88
95
|
program
|
|
89
96
|
.command('init')
|
|
90
97
|
.description('Initialize a new gent repository')
|
|
@@ -93,9 +100,18 @@ program
|
|
|
93
100
|
.action(initCommand);
|
|
94
101
|
|
|
95
102
|
program
|
|
96
|
-
.command('clone
|
|
103
|
+
.command('clone [url] [directory]')
|
|
97
104
|
.description('Clone a remote repository')
|
|
98
|
-
.action(
|
|
105
|
+
.action(async (url, directory, options) => {
|
|
106
|
+
if (!url && interactive.isInteractive()) {
|
|
107
|
+
({ url, directory } = await interactive.promptClone());
|
|
108
|
+
}
|
|
109
|
+
if (!url) {
|
|
110
|
+
console.error(chalk.red('error: missing url — usage: gent clone <url> [directory]'));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
return cloneCommand(url, directory, options);
|
|
114
|
+
});
|
|
99
115
|
|
|
100
116
|
// ─── Staging & Working Tree ─────────────────────────────
|
|
101
117
|
|
|
@@ -118,10 +134,19 @@ program
|
|
|
118
134
|
});
|
|
119
135
|
|
|
120
136
|
program
|
|
121
|
-
.command('rm
|
|
137
|
+
.command('rm [files...]')
|
|
122
138
|
.description('Remove files from working tree and staging')
|
|
123
139
|
.option('--cached', 'Only remove from staging, keep file on disk')
|
|
124
|
-
.action(
|
|
140
|
+
.action(async (files, options) => {
|
|
141
|
+
if ((!files || files.length === 0) && interactive.isInteractive()) {
|
|
142
|
+
files = await interactive.promptRm();
|
|
143
|
+
}
|
|
144
|
+
if (!files || files.length === 0) {
|
|
145
|
+
console.error('error: specify files to remove');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
return rmCommand(files, options);
|
|
149
|
+
});
|
|
125
150
|
|
|
126
151
|
program
|
|
127
152
|
.command('reset [files...]')
|
|
@@ -192,16 +217,46 @@ program
|
|
|
192
217
|
.action(branchCommand);
|
|
193
218
|
|
|
194
219
|
program
|
|
195
|
-
.command('checkout
|
|
220
|
+
.command('checkout [branch]')
|
|
196
221
|
.description('Switch branches or restore working tree files')
|
|
197
222
|
.option('-b, --create', 'Create a new branch')
|
|
198
|
-
.action(
|
|
223
|
+
.action(async (branch, options) => {
|
|
224
|
+
if (!branch && interactive.isInteractive()) {
|
|
225
|
+
const picked = await interactive.promptCheckout();
|
|
226
|
+
branch = picked.branch;
|
|
227
|
+
options.create = picked.create; // picker decides: existing branch ⇒ switch, not create
|
|
228
|
+
}
|
|
229
|
+
if (!branch) {
|
|
230
|
+
console.error(chalk.red('error: missing branch — usage: gent checkout <branch>'));
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
return checkoutCommand(branch, options);
|
|
234
|
+
});
|
|
199
235
|
|
|
200
236
|
program
|
|
201
|
-
.command('merge
|
|
237
|
+
.command('merge [branch]')
|
|
202
238
|
.description('Merge a branch into the current branch (3-way smart merge)')
|
|
203
239
|
.option('-m, --message <message>', 'Merge commit message')
|
|
204
|
-
.action(
|
|
240
|
+
.action(async (branch, options) => {
|
|
241
|
+
if (!branch && interactive.isInteractive()) {
|
|
242
|
+
const picked = await interactive.promptMerge();
|
|
243
|
+
if (!picked.branch) {
|
|
244
|
+
// In a repo with only one branch → nothing to merge; otherwise
|
|
245
|
+
// fall through so mergeCommand reports "Not a gent repository".
|
|
246
|
+
if (picked.current) {
|
|
247
|
+
console.log(chalk.yellow('No other branches to merge.'));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
branch = picked.branch;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!branch && !interactive.isInteractive()) {
|
|
255
|
+
console.error(chalk.red('error: missing branch — usage: gent merge <branch>'));
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
return mergeCommand(branch, options);
|
|
259
|
+
});
|
|
205
260
|
|
|
206
261
|
program
|
|
207
262
|
.command('resolve')
|
|
@@ -286,9 +341,14 @@ program
|
|
|
286
341
|
// ─── Platform-special (AI-powered) ──────────────────────
|
|
287
342
|
|
|
288
343
|
program
|
|
289
|
-
.command('ask
|
|
344
|
+
.command('ask [question]')
|
|
290
345
|
.description('Ask Claude a question about this repo (needs AI key)')
|
|
291
|
-
.action(
|
|
346
|
+
.action(async (question, options) => {
|
|
347
|
+
if (!question && interactive.isInteractive()) {
|
|
348
|
+
question = await interactive.promptAsk();
|
|
349
|
+
}
|
|
350
|
+
return askCommand(question, options);
|
|
351
|
+
});
|
|
292
352
|
|
|
293
353
|
program
|
|
294
354
|
.command('review [ref]')
|
|
@@ -384,6 +444,54 @@ program
|
|
|
384
444
|
}
|
|
385
445
|
});
|
|
386
446
|
|
|
447
|
+
// ─── Grouped help ───────────────────────────────────────
|
|
448
|
+
// Commander's default lists all ~45 commands in one flat block, which reads as
|
|
449
|
+
// noise. Group them by purpose instead, and render our own list after Options.
|
|
450
|
+
const HELP_GROUPS = [
|
|
451
|
+
['Start here', ['auto', 'setup', 'init', 'clone']],
|
|
452
|
+
['Work on changes', ['status', 'add', 'rm', 'reset', 'diff', 'commit']],
|
|
453
|
+
['History', ['log', 'show', 'tag', 'explain', 'summary']],
|
|
454
|
+
['Branches & merging', ['branch', 'checkout', 'merge', 'resolve', 'stash', 'undo', 'redo']],
|
|
455
|
+
['Remote & sync', ['remote', 'repos', 'members', 'push', 'pull', 'search', 'web', 'share']],
|
|
456
|
+
['Account', ['register', 'login', 'logout', 'whoami', 'password']],
|
|
457
|
+
['AI', ['ask', 'review', 'docs', 'changelog', 'ai']],
|
|
458
|
+
['Config & tools', ['config', 'doctor', 'template', 'help']],
|
|
459
|
+
];
|
|
460
|
+
|
|
461
|
+
function configureGroupedHelp(program) {
|
|
462
|
+
// Hide the default flat "Commands:" block…
|
|
463
|
+
program.configureHelp({ visibleCommands: () => [] });
|
|
464
|
+
|
|
465
|
+
// …and print a grouped one in its place.
|
|
466
|
+
program.addHelpText('after', () => {
|
|
467
|
+
const byName = new Map(program.commands.map(c => [c.name(), c]));
|
|
468
|
+
const pad = 13;
|
|
469
|
+
const listed = new Set();
|
|
470
|
+
const lines = ['Commands:', ''];
|
|
471
|
+
|
|
472
|
+
const row = (name) =>
|
|
473
|
+
` ${chalk.cyan(name.padEnd(pad))}${chalk.gray(byName.get(name).description())}`;
|
|
474
|
+
|
|
475
|
+
for (const [title, names] of HELP_GROUPS) {
|
|
476
|
+
const rows = names.filter(n => byName.has(n));
|
|
477
|
+
if (!rows.length) continue;
|
|
478
|
+
rows.forEach(n => listed.add(n));
|
|
479
|
+
lines.push(chalk.bold(title), ...rows.map(row), '');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Anything not placed in a group still shows up, so help stays complete.
|
|
483
|
+
const leftovers = [...byName.keys()].filter(n => !listed.has(n));
|
|
484
|
+
if (leftovers.length) {
|
|
485
|
+
lines.push(chalk.bold('Other'), ...leftovers.map(row), '');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
lines.push(chalk.gray('Run ') + chalk.cyan('gent help <command>') + chalk.gray(' for details.'));
|
|
489
|
+
return '\n' + lines.join('\n');
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
configureGroupedHelp(program);
|
|
494
|
+
|
|
387
495
|
// Friendlier global error mapping. Per-command handlers still own their own
|
|
388
496
|
// errors; this catches anything that bubbles up (e.g. unknown command).
|
|
389
497
|
function explainError(err) {
|
|
@@ -402,7 +510,8 @@ function showQuickstart() {
|
|
|
402
510
|
console.log(chalk.bold.cyan('Gent CLI ') + chalk.gray(`v${packageJson.version}`));
|
|
403
511
|
console.log(chalk.gray('A Git-like VCS with cloud sync + AI superpowers.\n'));
|
|
404
512
|
console.log(chalk.bold('First time? Try:'));
|
|
405
|
-
console.log(` ${chalk.cyan('gent
|
|
513
|
+
console.log(` ${chalk.cyan('gent auto')} ${chalk.gray('guided init → commit → push (interactive)')}`);
|
|
514
|
+
console.log(` ${chalk.cyan('gent setup')} ${chalk.gray('configure login + AI key + remote')}`);
|
|
406
515
|
console.log(` ${chalk.cyan('gent doctor')} ${chalk.gray('check everything is wired up')}`);
|
|
407
516
|
console.log(` ${chalk.cyan('gent template list')} ${chalk.gray('scaffold a starter project')}`);
|
|
408
517
|
console.log();
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive helpers — shared prompt resolvers for commands that need an
|
|
3
|
+
* argument the user didn't pass on the command line.
|
|
4
|
+
*
|
|
5
|
+
* The rule everywhere: if we're in a TTY, ask; otherwise leave the value
|
|
6
|
+
* undefined so the command prints its usual "usage" error (keeps scripts/CI
|
|
7
|
+
* behaving exactly as before).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const inquirer = require('inquirer');
|
|
12
|
+
const { getGentPath, readJSON } = require('./fileSystem');
|
|
13
|
+
const { COMMITS_FILE } = require('./constants');
|
|
14
|
+
|
|
15
|
+
// Sentinel for the "create a new branch" choice — deliberately not a legal
|
|
16
|
+
// branch name, so it can never collide with a real one.
|
|
17
|
+
const CREATE_NEW = '__gent_create_new_branch__';
|
|
18
|
+
|
|
19
|
+
/** True only when both stdin and stdout are a real terminal. */
|
|
20
|
+
function isInteractive() {
|
|
21
|
+
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Local branches from commits.json. { current, names } — empty if not a repo. */
|
|
25
|
+
async function loadBranches() {
|
|
26
|
+
try {
|
|
27
|
+
const gentPath = await getGentPath();
|
|
28
|
+
const repo = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
29
|
+
return {
|
|
30
|
+
current: repo.currentBranch || 'main',
|
|
31
|
+
names: Object.keys(repo.branches || {}),
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return { current: null, names: [] };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** File paths tracked in the current branch's HEAD commit. [] if not a repo. */
|
|
39
|
+
async function listTrackedFiles() {
|
|
40
|
+
try {
|
|
41
|
+
const gentPath = await getGentPath();
|
|
42
|
+
const repo = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
43
|
+
const head = repo.branches?.[repo.currentBranch];
|
|
44
|
+
const commit = head ? (repo.commits || []).find(c => c.hash === head) : null;
|
|
45
|
+
const tree = commit ? (commit.tree || commit.files || []) : [];
|
|
46
|
+
return tree.map(f => f.name || f.path).filter(Boolean);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** gent clone — ask for the URL and (optionally) a target directory. */
|
|
53
|
+
async function promptClone() {
|
|
54
|
+
const ans = await inquirer.prompt([
|
|
55
|
+
{
|
|
56
|
+
type: 'input',
|
|
57
|
+
name: 'url',
|
|
58
|
+
message: 'Repository URL to clone (/api/repos/{owner_id}/{repo}):',
|
|
59
|
+
validate: v => v.trim().length > 0 || 'Please enter a URL',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'input',
|
|
63
|
+
name: 'directory',
|
|
64
|
+
message: 'Target directory (blank = repo name):',
|
|
65
|
+
default: '',
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
return { url: ans.url.trim(), directory: ans.directory.trim() || undefined };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** gent checkout — pick an existing branch or create a new one. */
|
|
72
|
+
async function promptCheckout() {
|
|
73
|
+
const { current, names } = await loadBranches();
|
|
74
|
+
const others = names.filter(n => n !== current);
|
|
75
|
+
|
|
76
|
+
const choices = [
|
|
77
|
+
...others.map(n => ({ name: n, value: n })),
|
|
78
|
+
...(others.length ? [new inquirer.Separator()] : []),
|
|
79
|
+
{ name: '+ Create a new branch…', value: CREATE_NEW },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const { pick } = await inquirer.prompt([{
|
|
83
|
+
type: 'list',
|
|
84
|
+
name: 'pick',
|
|
85
|
+
message: current ? `Switch to which branch? (current: ${current})` : 'Branch:',
|
|
86
|
+
choices,
|
|
87
|
+
}]);
|
|
88
|
+
|
|
89
|
+
if (pick === CREATE_NEW) {
|
|
90
|
+
const { name } = await inquirer.prompt([{
|
|
91
|
+
type: 'input',
|
|
92
|
+
name: 'name',
|
|
93
|
+
message: 'New branch name:',
|
|
94
|
+
validate: v => v.trim().length > 0 || 'Please enter a name',
|
|
95
|
+
}]);
|
|
96
|
+
return { branch: name.trim(), create: true };
|
|
97
|
+
}
|
|
98
|
+
return { branch: pick, create: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** gent merge — pick a branch (other than the current one) to merge in.
|
|
102
|
+
* `current` is null when we're not in a repo, so the caller can tell
|
|
103
|
+
* "no other branches" apart from "not a repo". */
|
|
104
|
+
async function promptMerge() {
|
|
105
|
+
const { current, names } = await loadBranches();
|
|
106
|
+
const others = names.filter(n => n !== current);
|
|
107
|
+
if (!others.length) return { branch: null, current };
|
|
108
|
+
|
|
109
|
+
const { branch } = await inquirer.prompt([{
|
|
110
|
+
type: 'list',
|
|
111
|
+
name: 'branch',
|
|
112
|
+
message: `Merge which branch into '${current}'?`,
|
|
113
|
+
choices: others,
|
|
114
|
+
}]);
|
|
115
|
+
return { branch, current };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** gent ask — free-text question. */
|
|
119
|
+
async function promptAsk() {
|
|
120
|
+
const { question } = await inquirer.prompt([{
|
|
121
|
+
type: 'input',
|
|
122
|
+
name: 'question',
|
|
123
|
+
message: 'What do you want to ask about this repo?',
|
|
124
|
+
validate: v => v.trim().length > 0 || 'Please enter a question',
|
|
125
|
+
}]);
|
|
126
|
+
return question.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** gent rm — checkbox of tracked files, or free-text if none are tracked. */
|
|
130
|
+
async function promptRm() {
|
|
131
|
+
const tracked = await listTrackedFiles();
|
|
132
|
+
if (tracked.length) {
|
|
133
|
+
const { files } = await inquirer.prompt([{
|
|
134
|
+
type: 'checkbox',
|
|
135
|
+
name: 'files',
|
|
136
|
+
message: 'Select files to remove:',
|
|
137
|
+
choices: tracked,
|
|
138
|
+
validate: v => v.length > 0 || 'Select at least one file (space to toggle)',
|
|
139
|
+
}]);
|
|
140
|
+
return files;
|
|
141
|
+
}
|
|
142
|
+
const { paths } = await inquirer.prompt([{
|
|
143
|
+
type: 'input',
|
|
144
|
+
name: 'paths',
|
|
145
|
+
message: 'Files to remove (space-separated):',
|
|
146
|
+
validate: v => v.trim().length > 0 || 'Enter at least one path',
|
|
147
|
+
}]);
|
|
148
|
+
return paths.trim().split(/\s+/);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
isInteractive,
|
|
153
|
+
loadBranches,
|
|
154
|
+
listTrackedFiles,
|
|
155
|
+
promptClone,
|
|
156
|
+
promptCheckout,
|
|
157
|
+
promptMerge,
|
|
158
|
+
promptAsk,
|
|
159
|
+
promptRm,
|
|
160
|
+
};
|