gent-cli 7.0.0 → 9.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 +2 -2
- package/src/commands/ai.js +82 -0
- package/src/commands/ask.js +121 -0
- package/src/commands/branch.js +4 -0
- package/src/commands/changelog.js +121 -0
- package/src/commands/clone.js +36 -131
- package/src/commands/config.js +147 -0
- package/src/commands/docs.js +141 -0
- package/src/commands/doctor.js +169 -0
- package/src/commands/log.js +1 -2
- package/src/commands/members.js +134 -0
- package/src/commands/password.js +134 -0
- package/src/commands/pull.js +37 -95
- package/src/commands/push.js +27 -5
- 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/show.js +3 -2
- package/src/commands/tag.js +13 -4
- package/src/commands/template.js +135 -0
- package/src/commands/web.js +72 -0
- package/src/index.js +166 -11
- package/src/services/auth-service.js +3 -4
- package/src/utils/ai-service.js +100 -37
- package/src/utils/api-client.js +33 -10
- package/src/utils/auth-storage.js +15 -3
- package/src/utils/constants.js +11 -2
- package/src/utils/env-loader.js +61 -0
- package/src/utils/user-config.js +225 -0
|
@@ -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;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor Command - Health check for the gent CLI.
|
|
3
|
+
*
|
|
4
|
+
* gent doctor → run all checks
|
|
5
|
+
* gent doctor --ai → also ping Anthropic with a 1-token request
|
|
6
|
+
*
|
|
7
|
+
* Each row prints PASS / WARN / FAIL plus a hint on how to fix the issue.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs').promises;
|
|
12
|
+
const chalk = require('chalk');
|
|
13
|
+
const axios = require('axios');
|
|
14
|
+
const packageJson = require('../../package.json');
|
|
15
|
+
const { GENT_DIR } = require('../utils/constants');
|
|
16
|
+
const userConfig = require('../utils/user-config');
|
|
17
|
+
const apiClient = require('../utils/api-client');
|
|
18
|
+
const authStorage = require('../utils/auth-storage');
|
|
19
|
+
const ai = require('../utils/ai-service');
|
|
20
|
+
|
|
21
|
+
const MIN_NODE_MAJOR = 18;
|
|
22
|
+
|
|
23
|
+
async function doctor(options = {}) {
|
|
24
|
+
console.log(chalk.bold.cyan('\nGent CLI health check\n'));
|
|
25
|
+
|
|
26
|
+
const checks = [];
|
|
27
|
+
|
|
28
|
+
checks.push(await checkNodeVersion());
|
|
29
|
+
checks.push(await checkCliVersion());
|
|
30
|
+
checks.push(await checkRepo());
|
|
31
|
+
checks.push(await checkAuth());
|
|
32
|
+
checks.push(await checkApi());
|
|
33
|
+
checks.push(await checkAiKey(!!options.ai));
|
|
34
|
+
|
|
35
|
+
for (const c of checks) {
|
|
36
|
+
printCheck(c);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const failed = checks.filter(c => c.status === 'fail').length;
|
|
40
|
+
const warned = checks.filter(c => c.status === 'warn').length;
|
|
41
|
+
const passed = checks.filter(c => c.status === 'pass').length;
|
|
42
|
+
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(
|
|
45
|
+
chalk.green(` ${passed} pass`) + ' ' +
|
|
46
|
+
chalk.yellow(`${warned} warn`) + ' ' +
|
|
47
|
+
chalk.red(`${failed} fail`)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (failed > 0) process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printCheck(c) {
|
|
54
|
+
const badge = c.status === 'pass' ? chalk.green('✓ PASS')
|
|
55
|
+
: c.status === 'warn' ? chalk.yellow('! WARN')
|
|
56
|
+
: chalk.red('✗ FAIL');
|
|
57
|
+
console.log(` ${badge} ${chalk.bold(c.name)} ${chalk.gray(`— ${c.detail}`)}`);
|
|
58
|
+
if (c.hint) console.log(chalk.gray(` hint: ${c.hint}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function checkNodeVersion() {
|
|
62
|
+
const v = process.versions.node;
|
|
63
|
+
const major = parseInt(v.split('.')[0], 10);
|
|
64
|
+
if (major >= MIN_NODE_MAJOR) {
|
|
65
|
+
return { name: 'Node version', status: 'pass', detail: `v${v}` };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
name: 'Node version',
|
|
69
|
+
status: 'fail',
|
|
70
|
+
detail: `v${v} (need ≥${MIN_NODE_MAJOR})`,
|
|
71
|
+
hint: `Install Node ${MIN_NODE_MAJOR}+ — e.g. via nvm.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function checkCliVersion() {
|
|
76
|
+
return {
|
|
77
|
+
name: 'Gent CLI',
|
|
78
|
+
status: 'pass',
|
|
79
|
+
detail: `v${packageJson.version}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function checkRepo() {
|
|
84
|
+
const gentPath = path.join(process.cwd(), GENT_DIR);
|
|
85
|
+
try {
|
|
86
|
+
const stat = await fs.stat(gentPath);
|
|
87
|
+
if (!stat.isDirectory()) throw new Error('not a directory');
|
|
88
|
+
return { name: 'Repository (.gent)', status: 'pass', detail: gentPath };
|
|
89
|
+
} catch {
|
|
90
|
+
return {
|
|
91
|
+
name: 'Repository (.gent)',
|
|
92
|
+
status: 'warn',
|
|
93
|
+
detail: 'not a gent repo (this directory)',
|
|
94
|
+
hint: 'Run `gent init` to start a repo here, or cd into one.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkAuth() {
|
|
100
|
+
const isAuth = await authStorage.isAuthenticated();
|
|
101
|
+
if (!isAuth) {
|
|
102
|
+
return {
|
|
103
|
+
name: 'Authentication',
|
|
104
|
+
status: 'warn',
|
|
105
|
+
detail: 'not logged in',
|
|
106
|
+
hint: 'Run `gent login` or `gent register`.',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const user = await authStorage.getUser();
|
|
110
|
+
const who = user ? `${user.email}` : 'unknown user';
|
|
111
|
+
return { name: 'Authentication', status: 'pass', detail: who };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function checkApi() {
|
|
115
|
+
const { value: baseUrl, source } = await userConfig.getResolved('api.base_url');
|
|
116
|
+
try {
|
|
117
|
+
await axios.get(baseUrl, { timeout: 8000, validateStatus: () => true });
|
|
118
|
+
return {
|
|
119
|
+
name: 'Backend reachable',
|
|
120
|
+
status: 'pass',
|
|
121
|
+
detail: `${baseUrl} [${source}]`,
|
|
122
|
+
};
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
name: 'Backend reachable',
|
|
126
|
+
status: 'fail',
|
|
127
|
+
detail: `${baseUrl} → ${err.code || err.message}`,
|
|
128
|
+
hint: 'If running a local server: `gent config set api.base_url http://localhost:8000`.',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function checkAiKey(probe) {
|
|
134
|
+
const { value: key, source } = await ai.resolveKey();
|
|
135
|
+
if (!key) {
|
|
136
|
+
return {
|
|
137
|
+
name: 'AI key',
|
|
138
|
+
status: 'warn',
|
|
139
|
+
detail: 'not configured (AI features will be skipped, not failed)',
|
|
140
|
+
hint: 'Run `gent config set ai.api_key <key>` or set ANTHROPIC_API_KEY.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!probe) {
|
|
145
|
+
return {
|
|
146
|
+
name: 'AI key',
|
|
147
|
+
status: 'pass',
|
|
148
|
+
detail: `present [${source}], model: ${await ai.resolveModel()} (use --ai to live-test)`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await ai.complete({ prompt: 'ping', maxTokens: 4 });
|
|
154
|
+
return {
|
|
155
|
+
name: 'AI key',
|
|
156
|
+
status: 'pass',
|
|
157
|
+
detail: `verified — model ${await ai.resolveModel()} responded`,
|
|
158
|
+
};
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return {
|
|
161
|
+
name: 'AI key',
|
|
162
|
+
status: 'fail',
|
|
163
|
+
detail: err.message,
|
|
164
|
+
hint: 'Re-check the key (`gent config set ai.api_key`) or model (`gent config set ai.model`).',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = doctor;
|
package/src/commands/log.js
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* Reads commits.json, filters by branch HEAD → parent chain, displays
|
|
17
17
|
* in reverse chronological order.
|
|
18
18
|
*
|
|
19
|
-
* BACKEND
|
|
20
|
-
* GET /api/repos/:id/commits/?branch=main&limit=10
|
|
19
|
+
* BACKEND: none — fully local. Reads commits.json; makes no HTTP request.
|
|
21
20
|
*
|
|
22
21
|
* ============================================================================
|
|
23
22
|
*/
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members Command - Manage repository collaborators (owner-only for add/remove).
|
|
3
|
+
*
|
|
4
|
+
* USAGE:
|
|
5
|
+
* gent members → list owner + members with roles
|
|
6
|
+
* gent members add <email> → add a collaborator (default role: write)
|
|
7
|
+
* gent members add <email> --role read
|
|
8
|
+
* gent members remove <email> → remove a collaborator
|
|
9
|
+
*
|
|
10
|
+
* BACKEND:
|
|
11
|
+
* GET /api/repos/:owner_id/:repo_name/members/ → [{ user_id, email, role, created_at }]
|
|
12
|
+
* POST /api/repos/:owner_id/:repo_name/members/ { email, role: 'write'|'read' }
|
|
13
|
+
* DELETE /api/repos/:owner_id/:repo_name/members/:user_id/
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const chalk = require('chalk');
|
|
17
|
+
const ora = require('ora');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { readJSON, getGentPath } = require('../utils/fileSystem');
|
|
20
|
+
const { CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
|
|
21
|
+
const apiClient = require('../utils/api-client');
|
|
22
|
+
const authStorage = require('../utils/auth-storage');
|
|
23
|
+
|
|
24
|
+
const VALID_ROLES = ['write', 'read'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the origin remote's { owner_id, repo_name } for the current repo.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveRepoInfo() {
|
|
30
|
+
const gentPath = await getGentPath();
|
|
31
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
32
|
+
const remoteConfig = config.remotes && config.remotes.origin;
|
|
33
|
+
if (!remoteConfig) {
|
|
34
|
+
throw new Error("No 'origin' remote. Use \"gent remote add origin <url>\" first.");
|
|
35
|
+
}
|
|
36
|
+
const repoInfo = parseRemoteUrl(remoteConfig.url);
|
|
37
|
+
if (!repoInfo) {
|
|
38
|
+
throw new Error('Invalid origin remote URL. Expected /api/repos/{owner_id}/{repo_name}');
|
|
39
|
+
}
|
|
40
|
+
return repoInfo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function members(action, target, options = {}) {
|
|
44
|
+
try {
|
|
45
|
+
if (!(await authStorage.isAuthenticated())) {
|
|
46
|
+
console.error(chalk.red('Not authenticated'));
|
|
47
|
+
console.log(chalk.yellow('Run "gent login" first'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const repoInfo = await resolveRepoInfo();
|
|
52
|
+
|
|
53
|
+
if (!action || action === 'list') {
|
|
54
|
+
await listMembers(repoInfo);
|
|
55
|
+
} else if (action === 'add') {
|
|
56
|
+
await addMember(repoInfo, target, options);
|
|
57
|
+
} else if (action === 'remove' || action === 'rm') {
|
|
58
|
+
await removeMember(repoInfo, target);
|
|
59
|
+
} else {
|
|
60
|
+
console.error(chalk.red(`Unknown action '${action}'`));
|
|
61
|
+
console.log(chalk.yellow('Usage: gent members [list | add <email> | remove <email>]'));
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
handleError(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function listMembers(repoInfo) {
|
|
69
|
+
const spinner = ora('Fetching members...').start();
|
|
70
|
+
const data = await apiClient.get(buildRepoUrl(API_ENDPOINTS.REPO_MEMBERS, repoInfo));
|
|
71
|
+
spinner.stop();
|
|
72
|
+
|
|
73
|
+
console.log(chalk.bold.cyan('\nRepository members:\n'));
|
|
74
|
+
for (const m of data || []) {
|
|
75
|
+
const role = m.role === 'owner'
|
|
76
|
+
? chalk.magenta('owner')
|
|
77
|
+
: m.role === 'write' ? chalk.green('write') : chalk.gray('read');
|
|
78
|
+
console.log(` ${chalk.white.bold(m.email)} [${role}] ${chalk.gray(`#${m.user_id}`)}`);
|
|
79
|
+
}
|
|
80
|
+
console.log();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function addMember(repoInfo, email, options) {
|
|
84
|
+
if (!email) {
|
|
85
|
+
console.error(chalk.red('Usage: gent members add <email> [--role write|read]'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const role = (options.role || 'write').toLowerCase();
|
|
89
|
+
if (!VALID_ROLES.includes(role)) {
|
|
90
|
+
console.error(chalk.red(`Invalid role '${role}'. Use one of: ${VALID_ROLES.join(', ')}`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const spinner = ora(`Adding ${email} as ${role}...`).start();
|
|
95
|
+
await apiClient.post(buildRepoUrl(API_ENDPOINTS.REPO_MEMBERS, repoInfo), { email, role });
|
|
96
|
+
spinner.succeed(chalk.green(`Added ${email} (${role})`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function removeMember(repoInfo, email) {
|
|
100
|
+
if (!email) {
|
|
101
|
+
console.error(chalk.red('Usage: gent members remove <email>'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const spinner = ora(`Removing ${email}...`).start();
|
|
106
|
+
// The remove endpoint keys on user_id, so resolve it from the member list.
|
|
107
|
+
const list = await apiClient.get(buildRepoUrl(API_ENDPOINTS.REPO_MEMBERS, repoInfo));
|
|
108
|
+
const member = (list || []).find(m => m.email === email && m.role !== 'owner');
|
|
109
|
+
if (!member) {
|
|
110
|
+
spinner.fail(chalk.red(`${email} is not a member of this repository`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await apiClient.delete(
|
|
115
|
+
buildRepoUrl(API_ENDPOINTS.REPO_MEMBER_DETAIL, { ...repoInfo, user_id: member.user_id })
|
|
116
|
+
);
|
|
117
|
+
spinner.succeed(chalk.green(`Removed ${email}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleError(error) {
|
|
121
|
+
if (error.response?.status === 401) {
|
|
122
|
+
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
123
|
+
} else if (error.response?.status === 403) {
|
|
124
|
+
console.error(chalk.red(error.response.data?.error || 'Only the repository owner can manage members'));
|
|
125
|
+
} else if (error.response?.data) {
|
|
126
|
+
const d = error.response.data;
|
|
127
|
+
console.error(chalk.red(d.error || (typeof d === 'object' ? JSON.stringify(d) : d)));
|
|
128
|
+
} else {
|
|
129
|
+
console.error(chalk.red('Error:'), error.message);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = members;
|