gent-cli 28.0.0 → 29.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/README.md +5 -3
- package/package.json +1 -1
- package/src/commands/ai.js +34 -16
- package/src/commands/doctor.js +2 -2
- package/src/index.js +2 -1
- package/src/utils/ai-service.js +54 -15
- package/src/utils/local-ai-config.js +20 -8
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Beyond a faithful git-like workflow, Gent adds:
|
|
|
24
24
|
- **`gent undo` / `gent redo`** — a one-command safety net over an operation journal (friendlier than `git reflog`).
|
|
25
25
|
- **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
|
|
26
26
|
- **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
|
|
27
|
-
- **Direct local AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency
|
|
27
|
+
- **Direct local AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency OpenRouter calls from the CLI with task-specific prompts. Configure once with `gent ai configure`.
|
|
28
28
|
- **Genti, your terminal mascot** — a mint one-eyed sky-jelly that *acts out* your workflow: it floats a file crate to the cloud on `gent push`, carries one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
|
|
29
29
|
|
|
30
30
|
See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
|
|
@@ -501,12 +501,14 @@ gent explain --staged # explain currently staged changes
|
|
|
501
501
|
### Optional AI features
|
|
502
502
|
|
|
503
503
|
AI calls run directly from the CLI. Run `gent ai configure` once to enter the
|
|
504
|
-
|
|
504
|
+
OpenRouter key through a masked prompt; Gent stores it locally with owner-only
|
|
505
505
|
permissions. Fast task-specific prompts keep review, merge resolution, chat,
|
|
506
506
|
and summaries concise.
|
|
507
507
|
|
|
508
508
|
```bash
|
|
509
|
-
gent ai configure # configure
|
|
509
|
+
gent ai configure # configure OpenRouter once on this computer
|
|
510
|
+
gent ai configure <your-key> # same, non-interactive (scripts, SSH)
|
|
511
|
+
gent ai configure <your-key> --model vendor/model # pin a different model
|
|
510
512
|
gent commit --ai # suggest a commit message from the staged diff
|
|
511
513
|
gent explain # narrate a diff instead of just printing it
|
|
512
514
|
gent resolve # ours/theirs/both/AI/edit/skip per conflict hunk
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "29.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/ai.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI Command - Manage and verify AI integration.
|
|
3
3
|
*
|
|
4
|
-
* gent ai configure → securely save
|
|
4
|
+
* gent ai configure → securely save your own key for this computer
|
|
5
|
+
* gent ai configure <key> → same, without the interactive prompt
|
|
5
6
|
* gent ai status → show local AI status
|
|
6
7
|
* gent ai test → make a tiny live request to confirm it works
|
|
7
8
|
* gent ai models → show provider management information
|
|
@@ -13,10 +14,10 @@ const ora = require('ora');
|
|
|
13
14
|
const ai = require('../utils/ai-service');
|
|
14
15
|
const localAiConfig = require('../utils/local-ai-config');
|
|
15
16
|
|
|
16
|
-
async function aiCommand(subcommand) {
|
|
17
|
+
async function aiCommand(subcommand, key, options = {}) {
|
|
17
18
|
const sub = (subcommand || 'status').toLowerCase();
|
|
18
19
|
switch (sub) {
|
|
19
|
-
case 'configure': return configure();
|
|
20
|
+
case 'configure': return configure(key, options);
|
|
20
21
|
case 'status': return status();
|
|
21
22
|
case 'test': return test();
|
|
22
23
|
case 'models': return models();
|
|
@@ -27,22 +28,39 @@ async function aiCommand(subcommand) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
async function configure() {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
async function configure(key, options = {}) {
|
|
32
|
+
// A key passed as an argument keeps `gent ai configure` usable in scripts,
|
|
33
|
+
// over SSH, and anywhere stdin is not a terminal.
|
|
34
|
+
let apiKey = typeof key === 'string' ? key.trim() : '';
|
|
35
|
+
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
38
|
+
console.error(chalk.red('No terminal available for the masked prompt.'));
|
|
39
|
+
console.log(chalk.gray(' Pass the key directly: gent ai configure <your-openrouter-key>'));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
({ apiKey } = await inquirer.prompt([{
|
|
44
|
+
type: 'password',
|
|
45
|
+
name: 'apiKey',
|
|
46
|
+
message: 'OpenRouter API key:',
|
|
47
|
+
mask: '*',
|
|
48
|
+
validate: value => localAiConfig.validateApiKey(value) || 'Enter a valid OpenRouter key beginning with sk-or-v1-.',
|
|
49
|
+
}]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!localAiConfig.validateApiKey(apiKey)) {
|
|
53
|
+
console.error(chalk.red('That is not a valid OpenRouter key. Keys begin with sk-or-v1-.'));
|
|
54
|
+
console.log(chalk.gray(' Create one at https://openrouter.ai/keys'));
|
|
33
55
|
process.exitCode = 1;
|
|
34
56
|
return;
|
|
35
57
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
message: 'OpenAI API key:',
|
|
40
|
-
mask: '*',
|
|
41
|
-
validate: value => localAiConfig.validateApiKey(value) || 'Enter a valid key beginning with sk-.',
|
|
42
|
-
}]);
|
|
43
|
-
const savedPath = await localAiConfig.saveApiKey(apiKey);
|
|
44
|
-
console.log(chalk.green('✓ Gent AI configured for this computer.'));
|
|
58
|
+
|
|
59
|
+
const savedPath = await localAiConfig.saveApiKey(apiKey, { model: options.model });
|
|
60
|
+
console.log(chalk.green('✓ Gent AI configured for this computer with your own key.'));
|
|
45
61
|
console.log(chalk.gray(` Stored locally in ${savedPath} with owner-only permissions.`));
|
|
62
|
+
console.log(chalk.gray(` Model: ${await ai.resolveModel()}`));
|
|
63
|
+
console.log(chalk.gray(' Run `gent ai test` to verify it.'));
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
async function status() {
|
|
@@ -71,7 +89,7 @@ async function test() {
|
|
|
71
89
|
try {
|
|
72
90
|
const reply = await ai.complete({
|
|
73
91
|
prompt: 'Reply with the single word: pong',
|
|
74
|
-
maxTokens:
|
|
92
|
+
maxTokens: 32,
|
|
75
93
|
});
|
|
76
94
|
spinner.succeed(chalk.green(`✓ Reachable. Reply: "${reply}"`));
|
|
77
95
|
} catch (err) {
|
package/src/commands/doctor.js
CHANGED
|
@@ -154,14 +154,14 @@ async function checkAiKey(probe) {
|
|
|
154
154
|
return {
|
|
155
155
|
name: 'AI service',
|
|
156
156
|
status: 'pass',
|
|
157
|
-
detail: 'verified —
|
|
157
|
+
detail: 'verified — OpenRouter responded',
|
|
158
158
|
};
|
|
159
159
|
} catch (err) {
|
|
160
160
|
return {
|
|
161
161
|
name: 'AI service',
|
|
162
162
|
status: 'fail',
|
|
163
163
|
detail: err.message,
|
|
164
|
-
hint: 'Run `gent ai configure` to replace the key, or check
|
|
164
|
+
hint: 'Run `gent ai configure` to replace the key, or check credits at https://openrouter.ai/credits.',
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
}
|
package/src/index.js
CHANGED
|
@@ -369,8 +369,9 @@ program
|
|
|
369
369
|
.action(doctorCommand);
|
|
370
370
|
|
|
371
371
|
program
|
|
372
|
-
.command('ai [subcommand]')
|
|
372
|
+
.command('ai [subcommand] [key]')
|
|
373
373
|
.description('Configure or inspect local AI (configure|status|test|models)')
|
|
374
|
+
.option('--model <id>', 'Pin an OpenRouter model id for this computer')
|
|
374
375
|
.action(aiCommand);
|
|
375
376
|
|
|
376
377
|
// ─── Platform-special (AI-powered) ──────────────────────
|
package/src/utils/ai-service.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
/** Direct, low-latency
|
|
1
|
+
/** Direct, low-latency OpenRouter client for Gent's local AI commands. */
|
|
2
2
|
|
|
3
3
|
const axios = require('axios');
|
|
4
4
|
|
|
5
|
-
const API_URL = 'https://
|
|
6
|
-
const DEFAULT_MODEL = '
|
|
5
|
+
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
6
|
+
const DEFAULT_MODEL = 'xiaomi/mimo-v2.5:nitro';
|
|
7
|
+
// The default model reasons before answering, and those tokens come out of the
|
|
8
|
+
// same budget as the reply. Too small a budget returns content: null with
|
|
9
|
+
// finish_reason "length", so keep a floor and add headroom on every request.
|
|
10
|
+
const MIN_OUTPUT_TOKENS = 256;
|
|
11
|
+
const REASONING_HEADROOM = 512;
|
|
7
12
|
|
|
8
13
|
const PROMPTS = Object.freeze({
|
|
9
14
|
chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
|
|
@@ -17,7 +22,9 @@ const PROMPTS = Object.freeze({
|
|
|
17
22
|
});
|
|
18
23
|
|
|
19
24
|
function getApiKey() {
|
|
20
|
-
|
|
25
|
+
// OPENAI_API_KEY stays readable so installs configured before the
|
|
26
|
+
// OpenRouter switch keep working until they reconfigure.
|
|
27
|
+
return process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY || null;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
function getModel() {
|
|
@@ -30,7 +37,7 @@ function getApiUrl() {
|
|
|
30
37
|
|
|
31
38
|
async function resolveKey() {
|
|
32
39
|
const value = getApiKey();
|
|
33
|
-
return { value, source: value ? 'local CLI configuration' : 'unset' };
|
|
40
|
+
return { value, source: value ? 'local CLI configuration (OpenRouter)' : 'unset' };
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
async function resolveModel() {
|
|
@@ -51,6 +58,18 @@ function disabledHint() {
|
|
|
51
58
|
|
|
52
59
|
function extractText(payload) {
|
|
53
60
|
const chunks = [];
|
|
61
|
+
// OpenRouter / chat-completions shape.
|
|
62
|
+
for (const choice of payload?.choices || []) {
|
|
63
|
+
const content = choice?.message?.content;
|
|
64
|
+
if (typeof content === 'string') {
|
|
65
|
+
chunks.push(content);
|
|
66
|
+
} else if (Array.isArray(content)) {
|
|
67
|
+
for (const part of content) {
|
|
68
|
+
if (typeof part?.text === 'string') chunks.push(part.text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Responses-API shape, still accepted so a custom GENT_AI_API_URL works.
|
|
54
73
|
for (const item of payload?.output || []) {
|
|
55
74
|
if (item.type !== 'message') continue;
|
|
56
75
|
for (const content of item.content || []) {
|
|
@@ -60,26 +79,40 @@ function extractText(payload) {
|
|
|
60
79
|
return chunks.join('').trim();
|
|
61
80
|
}
|
|
62
81
|
|
|
82
|
+
function truncatedByBudget(payload) {
|
|
83
|
+
return (payload?.choices || []).some(choice => choice?.finish_reason === 'length');
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
|
|
64
87
|
const apiKey = getApiKey();
|
|
65
88
|
if (!apiKey) throw new Error(disabledHint());
|
|
66
89
|
const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
|
|
90
|
+
const budget = Math.max(MIN_OUTPUT_TOKENS, maxTokens) + REASONING_HEADROOM;
|
|
67
91
|
try {
|
|
68
92
|
const response = await axios.post(getApiUrl(), {
|
|
69
93
|
model: getModel(),
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
94
|
+
messages: [
|
|
95
|
+
...(instructions ? [{ role: 'system', content: instructions }] : []),
|
|
96
|
+
{ role: 'user', content: prompt },
|
|
97
|
+
],
|
|
98
|
+
max_tokens: budget,
|
|
99
|
+
// Keep reasoning models brief; ignored by models without reasoning.
|
|
100
|
+
reasoning: { effort: 'low' },
|
|
74
101
|
}, {
|
|
75
102
|
headers: {
|
|
76
103
|
Authorization: `Bearer ${apiKey}`,
|
|
77
104
|
'Content-Type': 'application/json',
|
|
105
|
+
'HTTP-Referer': 'https://github.com/gent-cli',
|
|
106
|
+
'X-Title': 'Gent CLI',
|
|
78
107
|
},
|
|
79
|
-
timeout:
|
|
108
|
+
timeout: 60000,
|
|
80
109
|
});
|
|
81
110
|
const text = extractText(response.data);
|
|
82
|
-
if (!text)
|
|
111
|
+
if (!text) {
|
|
112
|
+
throw new Error(truncatedByBudget(response.data)
|
|
113
|
+
? `Model "${getModel()}" used the whole ${budget}-token budget before replying. Raise it or set GENT_AI_MODEL to a lighter model.`
|
|
114
|
+
: `Model "${getModel()}" returned an empty response.`);
|
|
115
|
+
}
|
|
83
116
|
return text;
|
|
84
117
|
} catch (error) {
|
|
85
118
|
throw enrichAiError(error);
|
|
@@ -90,10 +123,14 @@ function enrichAiError(error) {
|
|
|
90
123
|
const status = error?.response?.status;
|
|
91
124
|
const apiError = error?.response?.data?.error;
|
|
92
125
|
const apiCode = typeof apiError === 'object' ? apiError?.code : null;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
if (status ===
|
|
126
|
+
const apiType = typeof apiError === 'object' ? apiError?.type : null;
|
|
127
|
+
const quotaCodes = ['insufficient_quota', 'credit_balance_exhausted', 'billing_hard_limit_reached'];
|
|
128
|
+
if (status === 401 || status === 403) return new Error('OpenRouter rejected the configured CLI credential. Run `gent ai configure` with a valid key.');
|
|
129
|
+
if (status === 404) return new Error(`OpenRouter has no model "${getModel()}". Set GENT_AI_MODEL to a model id from https://openrouter.ai/models.`);
|
|
130
|
+
if (status === 402 || quotaCodes.includes(apiCode) || quotaCodes.includes(apiType)) {
|
|
131
|
+
return new Error('OpenRouter billing: this key has no credits left. Add credits at https://openrouter.ai/credits — the key itself is valid.');
|
|
132
|
+
}
|
|
133
|
+
if (status === 429) return new Error('Gent AI is rate limited. Retry in a moment.');
|
|
97
134
|
if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
|
|
98
135
|
if (typeof apiError === 'string') return new Error(apiError);
|
|
99
136
|
return error;
|
|
@@ -163,6 +200,8 @@ function briefSummary(value) {
|
|
|
163
200
|
module.exports = {
|
|
164
201
|
PROMPTS,
|
|
165
202
|
DEFAULT_MODEL,
|
|
203
|
+
MIN_OUTPUT_TOKENS,
|
|
204
|
+
REASONING_HEADROOM,
|
|
166
205
|
isEnabled,
|
|
167
206
|
getModel,
|
|
168
207
|
getApiKey,
|
|
@@ -3,17 +3,22 @@ const os = require('os');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { GENT_DIR } = require('./constants');
|
|
5
5
|
|
|
6
|
+
const KEY_NAME = 'OPENROUTER_API_KEY';
|
|
7
|
+
// Written alongside the key so an install configured before the OpenRouter
|
|
8
|
+
// switch does not keep pinning a stale OpenAI model id.
|
|
9
|
+
const LEGACY_KEY_NAMES = ['OPENAI_API_KEY'];
|
|
10
|
+
|
|
6
11
|
function getEnvPath() {
|
|
7
12
|
return path.join(os.homedir(), GENT_DIR, '.env');
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
function validateApiKey(value) {
|
|
11
|
-
return typeof value === 'string' && /^sk-[A-Za-z0-
|
|
16
|
+
return typeof value === 'string' && /^sk-or-v1-[A-Za-z0-9]{32,}$/.test(value.trim());
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
async function saveApiKey(value) {
|
|
15
|
-
const apiKey = value.trim();
|
|
16
|
-
if (!validateApiKey(apiKey)) throw new Error('Enter a valid
|
|
19
|
+
async function saveApiKey(value, { model } = {}) {
|
|
20
|
+
const apiKey = typeof value === 'string' ? value.trim() : '';
|
|
21
|
+
if (!validateApiKey(apiKey)) throw new Error('Enter a valid OpenRouter API key beginning with sk-or-v1-.');
|
|
17
22
|
|
|
18
23
|
const envPath = getEnvPath();
|
|
19
24
|
const directory = path.dirname(envPath);
|
|
@@ -21,19 +26,26 @@ async function saveApiKey(value) {
|
|
|
21
26
|
if (error.code === 'ENOENT') return '';
|
|
22
27
|
throw error;
|
|
23
28
|
});
|
|
29
|
+
const managed = [KEY_NAME, ...LEGACY_KEY_NAMES];
|
|
24
30
|
const lines = existing
|
|
25
31
|
.split(/\r?\n/)
|
|
26
|
-
.filter(line =>
|
|
32
|
+
.filter(line => !managed.some(name => new RegExp(`^\\s*${name}\\s*=`).test(line)))
|
|
33
|
+
// Drop any model pinned for a previous provider; the service default wins.
|
|
34
|
+
.filter(line => !/^\s*GENT_AI_MODEL\s*=/.test(line));
|
|
27
35
|
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
28
|
-
lines.push(
|
|
36
|
+
lines.push(`${KEY_NAME}=${apiKey}`);
|
|
37
|
+
if (model) lines.push(`GENT_AI_MODEL=${model}`);
|
|
29
38
|
|
|
30
39
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
31
40
|
const temporary = `${envPath}.tmp-${process.pid}`;
|
|
32
41
|
await fs.writeFile(temporary, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
33
42
|
await fs.rename(temporary, envPath);
|
|
34
43
|
await fs.chmod(envPath, 0o600);
|
|
35
|
-
process.env
|
|
44
|
+
process.env[KEY_NAME] = apiKey;
|
|
45
|
+
for (const name of LEGACY_KEY_NAMES) delete process.env[name];
|
|
46
|
+
if (model) process.env.GENT_AI_MODEL = model;
|
|
47
|
+
else delete process.env.GENT_AI_MODEL;
|
|
36
48
|
return envPath;
|
|
37
49
|
}
|
|
38
50
|
|
|
39
|
-
module.exports = { getEnvPath, validateApiKey, saveApiKey };
|
|
51
|
+
module.exports = { getEnvPath, validateApiKey, saveApiKey, KEY_NAME };
|