gent-cli 23.0.0 → 25.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 +6 -5
- package/README.md +7 -6
- package/package.json +3 -3
- package/src/commands/ai.js +15 -30
- package/src/commands/ask.js +4 -1
- package/src/commands/canonical.js +116 -7
- package/src/commands/changelog.js +2 -0
- package/src/commands/chat.js +80 -0
- package/src/commands/commit.js +1 -0
- package/src/commands/config.js +1 -16
- package/src/commands/docs.js +2 -0
- package/src/commands/doctor.js +12 -12
- package/src/commands/explain.js +3 -3
- package/src/commands/merge.js +25 -3
- package/src/commands/repos.js +114 -9
- package/src/commands/resolve.js +68 -23
- package/src/commands/review.js +4 -3
- package/src/commands/setup.js +2 -52
- package/src/commands/summary.js +5 -1
- package/src/index.js +22 -11
- package/src/utils/ai-service.js +86 -163
- package/src/utils/user-config.js +3 -33
package/src/utils/ai-service.js
CHANGED
|
@@ -1,209 +1,131 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================================
|
|
3
|
-
* AI Service - Optional, key-gated Claude integration (hybrid layer)
|
|
4
|
-
* ============================================================================
|
|
5
|
-
*
|
|
6
|
-
* PURPOSE:
|
|
7
|
-
* Power Gent's *optional* "smart" features (commit-message suggestions, diff
|
|
8
|
-
* explanations, AI-assisted conflict resolution). Every feature has a
|
|
9
|
-
* reliable algorithmic path; this layer only activates when the user has set
|
|
10
|
-
* an API key, and degrades gracefully (never throws into a command) when it
|
|
11
|
-
* is absent or the request fails.
|
|
12
|
-
*
|
|
13
|
-
* ENABLEMENT:
|
|
14
|
-
* Either set ANTHROPIC_API_KEY in the environment, OR save it once with
|
|
15
|
-
* `gent config set ai.api_key <key>` (stored in ~/.gent/cli-config.json).
|
|
16
|
-
* Optionally pick a model with GENT_AI_MODEL or `gent config set ai.model`.
|
|
17
|
-
* Default model: claude-opus-4-7. For a cheaper / faster option try
|
|
18
|
-
* claude-haiku-4-5 or claude-sonnet-4-6.
|
|
19
|
-
*
|
|
20
|
-
* IMPLEMENTATION NOTE:
|
|
21
|
-
* Calls the Anthropic Messages API (POST /v1/messages) directly over the
|
|
22
|
-
* project's existing `axios` dependency, to honour Gent's "no new runtime
|
|
23
|
-
* dependencies" constraint. A production app would normally use the official
|
|
24
|
-
* `@anthropic-ai/sdk`; raw HTTP is a deliberate trade-off here because the AI
|
|
25
|
-
* layer is optional and self-contained.
|
|
26
|
-
*
|
|
27
|
-
* ============================================================================
|
|
28
|
-
*/
|
|
1
|
+
/** Direct, low-latency OpenAI client for Gent's local AI commands. */
|
|
29
2
|
|
|
30
3
|
const axios = require('axios');
|
|
31
|
-
const userConfig = require('./user-config');
|
|
32
4
|
|
|
33
|
-
const API_URL = 'https://api.
|
|
34
|
-
const
|
|
35
|
-
const DEFAULT_MODEL = 'claude-opus-4-7';
|
|
5
|
+
const API_URL = 'https://api.openai.com/v1/responses';
|
|
6
|
+
const DEFAULT_MODEL = 'gpt-4.1-mini';
|
|
36
7
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
8
|
+
const PROMPTS = Object.freeze({
|
|
9
|
+
chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
|
|
10
|
+
review: 'Review fast. Return only concrete correctness, security, or regression risks, then brief fixes. If none, say "No blocking issues."',
|
|
11
|
+
merge: 'Resolve this merge conflict fast. Preserve both intended behaviors. Return only the final merged text with no markdown fence or explanation.',
|
|
12
|
+
commit: 'Write one concise conventional commit message. Return only the message.',
|
|
13
|
+
explain: 'Explain this change briefly and concretely. Return short bullets only.',
|
|
14
|
+
docs: 'Write concise, accurate repository documentation from only the supplied context.',
|
|
15
|
+
changelog: 'Create a concise user-facing changelog. Group related changes and omit filler.',
|
|
16
|
+
summary: 'Give a concise repository health assessment with the most important risk first.',
|
|
17
|
+
});
|
|
41
18
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return { value: _resolvedKey, source: _resolvedKeySource };
|
|
45
|
-
}
|
|
46
|
-
const r = await userConfig.getResolved('ai.api_key');
|
|
47
|
-
_resolvedKey = r.value || null;
|
|
48
|
-
_resolvedKeySource = r.source;
|
|
49
|
-
return { value: _resolvedKey, source: _resolvedKeySource };
|
|
19
|
+
function getApiKey() {
|
|
20
|
+
return process.env.OPENAI_API_KEY || null;
|
|
50
21
|
}
|
|
51
22
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const r = await userConfig.getResolved('ai.model');
|
|
55
|
-
_resolvedModel = r.value || DEFAULT_MODEL;
|
|
56
|
-
return _resolvedModel;
|
|
23
|
+
function getModel() {
|
|
24
|
+
return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
|
|
57
25
|
}
|
|
58
26
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* or falls back to env-only (the original behavior) on cold start.
|
|
62
|
-
*/
|
|
63
|
-
function getApiKey() {
|
|
64
|
-
if (_resolvedKey !== undefined) return _resolvedKey;
|
|
65
|
-
return process.env.ANTHROPIC_API_KEY || null;
|
|
27
|
+
function getApiUrl() {
|
|
28
|
+
return process.env.GENT_AI_API_URL || API_URL;
|
|
66
29
|
}
|
|
67
30
|
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
return
|
|
31
|
+
async function resolveKey() {
|
|
32
|
+
const value = getApiKey();
|
|
33
|
+
return { value, source: value ? 'local Gent installation' : 'unset' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resolveModel() {
|
|
37
|
+
return getModel();
|
|
71
38
|
}
|
|
72
39
|
|
|
73
|
-
/**
|
|
74
|
-
* Async pre-flight resolver — call once from a command before doing AI work
|
|
75
|
-
* so isEnabled()/getModel() see the user-config values even if env is empty.
|
|
76
|
-
*/
|
|
77
40
|
async function prime() {
|
|
78
|
-
|
|
79
|
-
await resolveModel();
|
|
41
|
+
return resolveKey();
|
|
80
42
|
}
|
|
81
43
|
|
|
82
44
|
function isEnabled() {
|
|
83
|
-
return
|
|
45
|
+
return Boolean(getApiKey());
|
|
84
46
|
}
|
|
85
47
|
|
|
86
48
|
function disabledHint() {
|
|
87
|
-
return 'AI
|
|
49
|
+
return 'Gent AI is unavailable in this CLI installation.';
|
|
88
50
|
}
|
|
89
51
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// didn't prime() first.
|
|
101
|
-
await prime();
|
|
52
|
+
function extractText(payload) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (const item of payload?.output || []) {
|
|
55
|
+
if (item.type !== 'message') continue;
|
|
56
|
+
for (const content of item.content || []) {
|
|
57
|
+
if (content.type === 'output_text' && content.text) chunks.push(content.text);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return chunks.join('').trim();
|
|
61
|
+
}
|
|
102
62
|
|
|
63
|
+
async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
|
|
103
64
|
const apiKey = getApiKey();
|
|
104
|
-
if (!apiKey) throw new Error(
|
|
105
|
-
|
|
106
|
-
const body = {
|
|
107
|
-
model: getModel(),
|
|
108
|
-
max_tokens: maxTokens,
|
|
109
|
-
messages: [{ role: 'user', content: prompt }]
|
|
110
|
-
};
|
|
111
|
-
if (system) body.system = system;
|
|
112
|
-
// Adaptive thinking — opt-in per caller. We leave display at the API
|
|
113
|
-
// default ("omitted") so reasoning never leaks into CLI output; this just
|
|
114
|
-
// lets the model think harder on complex tasks (review, conflict resolve)
|
|
115
|
-
// without changing what the user sees.
|
|
116
|
-
if (thinking) body.thinking = { type: 'adaptive' };
|
|
65
|
+
if (!apiKey) throw new Error(disabledHint());
|
|
117
66
|
|
|
67
|
+
const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
|
|
118
68
|
try {
|
|
119
|
-
const
|
|
69
|
+
const response = await axios.post(getApiUrl(), {
|
|
70
|
+
model: getModel(),
|
|
71
|
+
input: prompt,
|
|
72
|
+
instructions,
|
|
73
|
+
max_output_tokens: maxTokens,
|
|
74
|
+
store: false,
|
|
75
|
+
}, {
|
|
120
76
|
headers: {
|
|
121
|
-
|
|
122
|
-
'
|
|
123
|
-
'content-type': 'application/json'
|
|
77
|
+
Authorization: `Bearer ${apiKey}`,
|
|
78
|
+
'Content-Type': 'application/json',
|
|
124
79
|
},
|
|
125
|
-
timeout:
|
|
80
|
+
timeout: 30000,
|
|
126
81
|
});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.join('')
|
|
133
|
-
.trim();
|
|
134
|
-
} catch (err) {
|
|
135
|
-
throw enrichAiError(err);
|
|
82
|
+
const text = extractText(response.data);
|
|
83
|
+
if (!text) throw new Error('OpenAI returned an empty response');
|
|
84
|
+
return text;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw enrichAiError(error);
|
|
136
87
|
}
|
|
137
88
|
}
|
|
138
89
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
if (status === 404 || (apiMsg && /model/i.test(apiMsg))) {
|
|
149
|
-
return new Error(`Anthropic rejected the model "${getModel()}" — set a valid one with \`gent config set ai.model claude-opus-4-7\`.`);
|
|
150
|
-
}
|
|
151
|
-
if (status === 429) {
|
|
152
|
-
return new Error('Anthropic rate-limited the request (429). Retry in a moment or switch to a lighter model.');
|
|
153
|
-
}
|
|
154
|
-
if (apiMsg) return new Error(`AI request failed: ${apiMsg}`);
|
|
155
|
-
return err;
|
|
90
|
+
function enrichAiError(error) {
|
|
91
|
+
const status = error?.response?.status;
|
|
92
|
+
const apiError = error?.response?.data?.error;
|
|
93
|
+
if (status === 401 || status === 403) return new Error('Gent AI credential was rejected.');
|
|
94
|
+
if (apiError?.code === 'insufficient_quota') return new Error('Gent AI quota is exhausted.');
|
|
95
|
+
if (status === 429) return new Error('Gent AI is busy. Retry in a moment.');
|
|
96
|
+
if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
|
|
97
|
+
return error;
|
|
156
98
|
}
|
|
157
99
|
|
|
158
|
-
// ─── High-level helpers ─────────────────────────────────
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Suggest a concise commit message from a staged diff / summary.
|
|
162
|
-
* @param {String} diffSummary
|
|
163
|
-
* @returns {Promise<String>}
|
|
164
|
-
*/
|
|
165
100
|
async function suggestCommitMessage(diffSummary) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return complete({ system, prompt, maxTokens: 512 });
|
|
101
|
+
return complete({ profile: 'commit', prompt: diffSummary, maxTokens: 160 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function explainChanges(content, profile = 'explain') {
|
|
105
|
+
return complete({ profile, prompt: content, maxTokens: 500 });
|
|
172
106
|
}
|
|
173
107
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
'You are a senior engineer explaining a code change to a teammate. Summarize what ' +
|
|
182
|
-
'changed and why it matters in a few short bullet points. Be specific and concise.';
|
|
183
|
-
const prompt = `Explain these changes:\n\n${content}`;
|
|
184
|
-
return complete({ system, prompt, maxTokens: 1024 });
|
|
108
|
+
async function reviewChanges(content, context = '') {
|
|
109
|
+
return complete({
|
|
110
|
+
profile: 'review',
|
|
111
|
+
system: context,
|
|
112
|
+
prompt: content,
|
|
113
|
+
maxTokens: 800,
|
|
114
|
+
});
|
|
185
115
|
}
|
|
186
116
|
|
|
187
|
-
/**
|
|
188
|
-
* Propose a resolution for a single merge-conflict hunk.
|
|
189
|
-
* @param {Object} hunk - { base?, ours, theirs, fileName? }
|
|
190
|
-
* @returns {Promise<String>} the suggested merged text (no conflict markers)
|
|
191
|
-
*/
|
|
192
117
|
async function resolveConflictHunk({ base, ours, theirs, fileName }) {
|
|
193
|
-
const system =
|
|
194
|
-
'You resolve git merge conflicts. Combine the intent of BOTH sides into a single ' +
|
|
195
|
-
'correct version. Reply with ONLY the resolved file section — no conflict markers, ' +
|
|
196
|
-
'no explanation, no markdown fences.';
|
|
197
118
|
const prompt =
|
|
198
|
-
`File: ${fileName || 'unknown'}\n
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return complete({ system, prompt, maxTokens: 2048, thinking: true });
|
|
119
|
+
`File: ${fileName || 'unknown'}\n` +
|
|
120
|
+
`BASE:\n${base || '(none)'}\n\n` +
|
|
121
|
+
`OURS:\n${ours}\n\n` +
|
|
122
|
+
`THEIRS:\n${theirs}`;
|
|
123
|
+
return complete({ profile: 'merge', prompt, maxTokens: 1400 });
|
|
204
124
|
}
|
|
205
125
|
|
|
206
126
|
module.exports = {
|
|
127
|
+
PROMPTS,
|
|
128
|
+
DEFAULT_MODEL,
|
|
207
129
|
isEnabled,
|
|
208
130
|
getModel,
|
|
209
131
|
getApiKey,
|
|
@@ -214,6 +136,7 @@ module.exports = {
|
|
|
214
136
|
complete,
|
|
215
137
|
suggestCommitMessage,
|
|
216
138
|
explainChanges,
|
|
139
|
+
reviewChanges,
|
|
217
140
|
resolveConflictHunk,
|
|
218
|
-
|
|
141
|
+
extractText,
|
|
219
142
|
};
|
package/src/utils/user-config.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*
|
|
6
6
|
* PURPOSE:
|
|
7
7
|
* Persist CLI-wide settings that should NOT live in a project's .gent/ dir:
|
|
8
|
-
* - AI key, AI model
|
|
9
8
|
* - API base URL (so users can point at a local backend without code edits)
|
|
10
9
|
* - Default identity (name/email) used when project .gent/config.json lacks one
|
|
11
10
|
*
|
|
@@ -13,13 +12,9 @@
|
|
|
13
12
|
* env var > ~/.gent/config.json > built-in default
|
|
14
13
|
*
|
|
15
14
|
* STORAGE:
|
|
16
|
-
* Plain JSON at ~/.gent/config.json.
|
|
17
|
-
* (AES via crypto-js, same scheme as auth-storage) so it isn't readable at a
|
|
18
|
-
* glance — not a real secret store, but better than plaintext on disk.
|
|
15
|
+
* Plain JSON at ~/.gent/config.json.
|
|
19
16
|
*
|
|
20
17
|
* KEYS (dot-notation):
|
|
21
|
-
* ai.api_key Anthropic API key
|
|
22
|
-
* ai.model Model id (e.g. claude-opus-4-7, claude-haiku-4-5)
|
|
23
18
|
* api.base_url Backend base URL (e.g. http://localhost:8000)
|
|
24
19
|
* web.base_url Web app (frontend) base URL (e.g. https://gent-nu2e.onrender.com)
|
|
25
20
|
* Used by `gent web` / `gent share`. This is a SEPARATE
|
|
@@ -34,17 +29,12 @@
|
|
|
34
29
|
const fs = require('fs').promises;
|
|
35
30
|
const path = require('path');
|
|
36
31
|
const os = require('os');
|
|
37
|
-
const CryptoJS = require('crypto-js');
|
|
38
32
|
const { GENT_DIR } = require('./constants');
|
|
39
33
|
|
|
40
34
|
const CONFIG_FILE_NAME = 'cli-config.json';
|
|
41
|
-
const SECRET_KEYS = new Set(
|
|
42
|
-
const OBFUSCATION_KEY = 'gent-cli-config-v1';
|
|
43
|
-
const OBFUSCATION_PREFIX = 'enc:v1:';
|
|
35
|
+
const SECRET_KEYS = new Set();
|
|
44
36
|
|
|
45
37
|
const ALLOWED_KEYS = new Set([
|
|
46
|
-
'ai.api_key',
|
|
47
|
-
'ai.model',
|
|
48
38
|
'api.base_url',
|
|
49
39
|
'web.base_url',
|
|
50
40
|
'user.name',
|
|
@@ -52,7 +42,6 @@ const ALLOWED_KEYS = new Set([
|
|
|
52
42
|
]);
|
|
53
43
|
|
|
54
44
|
const DEFAULTS = {
|
|
55
|
-
'ai.model': 'claude-opus-4-7',
|
|
56
45
|
'api.base_url': 'https://gent-api.onrender.com',
|
|
57
46
|
// The frontend has no production deployment yet; the server's own
|
|
58
47
|
// FRONTEND_URL setting defaults to the same value. Override with
|
|
@@ -61,8 +50,6 @@ const DEFAULTS = {
|
|
|
61
50
|
};
|
|
62
51
|
|
|
63
52
|
const ENV_OVERRIDES = {
|
|
64
|
-
'ai.api_key': 'ANTHROPIC_API_KEY',
|
|
65
|
-
'ai.model': 'GENT_AI_MODEL',
|
|
66
53
|
'api.base_url': 'GENT_API_URL',
|
|
67
54
|
'web.base_url': 'GENT_WEB_URL',
|
|
68
55
|
};
|
|
@@ -71,21 +58,6 @@ function getConfigPath() {
|
|
|
71
58
|
return path.join(os.homedir(), GENT_DIR, CONFIG_FILE_NAME);
|
|
72
59
|
}
|
|
73
60
|
|
|
74
|
-
function obfuscate(plaintext) {
|
|
75
|
-
if (typeof plaintext !== 'string' || plaintext.length === 0) return plaintext;
|
|
76
|
-
return OBFUSCATION_PREFIX + CryptoJS.AES.encrypt(plaintext, OBFUSCATION_KEY).toString();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function deobfuscate(value) {
|
|
80
|
-
if (typeof value !== 'string' || !value.startsWith(OBFUSCATION_PREFIX)) return value;
|
|
81
|
-
try {
|
|
82
|
-
const bytes = CryptoJS.AES.decrypt(value.slice(OBFUSCATION_PREFIX.length), OBFUSCATION_KEY);
|
|
83
|
-
return bytes.toString(CryptoJS.enc.Utf8);
|
|
84
|
-
} catch {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
61
|
function setDeep(obj, dottedKey, value) {
|
|
90
62
|
const parts = dottedKey.split('.');
|
|
91
63
|
let cur = obj;
|
|
@@ -149,7 +121,6 @@ async function get(key) {
|
|
|
149
121
|
const data = await readRaw();
|
|
150
122
|
const raw = getDeep(data, key);
|
|
151
123
|
if (raw === undefined || raw === null) return undefined;
|
|
152
|
-
if (SECRET_KEYS.has(key)) return deobfuscate(raw);
|
|
153
124
|
return raw;
|
|
154
125
|
}
|
|
155
126
|
|
|
@@ -179,8 +150,7 @@ async function set(key, value) {
|
|
|
179
150
|
}
|
|
180
151
|
if (typeof value !== 'string') value = String(value);
|
|
181
152
|
const data = await readRaw();
|
|
182
|
-
|
|
183
|
-
setDeep(data, key, toStore);
|
|
153
|
+
setDeep(data, key, value);
|
|
184
154
|
await writeRaw(data);
|
|
185
155
|
}
|
|
186
156
|
|