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.
@@ -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.anthropic.com/v1/messages';
34
- const API_VERSION = '2023-06-01';
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
- // Per-process cache so repeated AI calls don't keep hitting disk.
38
- let _resolvedKey;
39
- let _resolvedKeySource;
40
- let _resolvedModel;
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
- async function resolveKey() {
43
- if (_resolvedKey !== undefined) {
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
- async function resolveModel() {
53
- if (_resolvedModel) return _resolvedModel;
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
- * Synchronous getter used in hot paths. Returns whatever was last resolved,
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 getModel() {
69
- if (_resolvedModel) return _resolvedModel;
70
- return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
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
- await resolveKey();
79
- await resolveModel();
41
+ return resolveKey();
80
42
  }
81
43
 
82
44
  function isEnabled() {
83
- return !!getApiKey();
45
+ return Boolean(getApiKey());
84
46
  }
85
47
 
86
48
  function disabledHint() {
87
- return 'AI features are off save a key with `gent config set ai.api_key <key>` or set ANTHROPIC_API_KEY in your env.';
49
+ return 'Gent AI is unavailable in this CLI installation.';
88
50
  }
89
51
 
90
- /**
91
- * Low-level single-shot completion. Returns the assistant's text.
92
- * @param {Object} opts
93
- * @param {String} opts.prompt - user content
94
- * @param {String} [opts.system] - system prompt
95
- * @param {Number} [opts.maxTokens]
96
- * @returns {Promise<String>}
97
- */
98
- async function complete({ prompt, system, maxTokens = 1024, thinking = false }) {
99
- // Make sure env/config-stored values are resolved even if the caller
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('AI not enabled');
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 res = await axios.post(API_URL, body, {
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
- 'x-api-key': apiKey,
122
- 'anthropic-version': API_VERSION,
123
- 'content-type': 'application/json'
77
+ Authorization: `Bearer ${apiKey}`,
78
+ 'Content-Type': 'application/json',
124
79
  },
125
- timeout: 60000
80
+ timeout: 30000,
126
81
  });
127
-
128
- const blocks = (res.data && res.data.content) || [];
129
- return blocks
130
- .filter(b => b.type === 'text')
131
- .map(b => b.text)
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
- * Wrap raw Anthropic errors with hints that actually help the user.
141
- */
142
- function enrichAiError(err) {
143
- const status = err?.response?.status;
144
- const apiMsg = err?.response?.data?.error?.message || err?.response?.data?.message;
145
- if (status === 401) {
146
- return new Error('Anthropic rejected the API key (401). Check `gent config get ai.api_key` and try `gent ai test`.');
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
- const system =
167
- 'You write clear, conventional git commit messages. Reply with ONLY the commit ' +
168
- 'message: a concise imperative subject line (<=72 chars), optionally followed by ' +
169
- 'a blank line and short body. No quotes, no preamble, no markdown fences.';
170
- const prompt = `Write a commit message for these staged changes:\n\n${diffSummary}`;
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
- * Explain a commit or diff in plain language.
176
- * @param {String} content - diff or commit details
177
- * @returns {Promise<String>}
178
- */
179
- async function explainChanges(content) {
180
- const system =
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\n` +
199
- `<<<<<<< BASE (common ancestor)\n${base || '(none)'}\n` +
200
- `======= OURS\n${ours}\n` +
201
- `======= THEIRS\n${theirs}\n>>>>>>>\n\n` +
202
- 'Return the merged result for this section.';
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
- DEFAULT_MODEL,
141
+ extractText,
219
142
  };
@@ -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. The AI key field is lightly obfuscated
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(['ai.api_key']);
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
- const toStore = SECRET_KEYS.has(key) ? obfuscate(value) : value;
183
- setDeep(data, key, toStore);
153
+ setDeep(data, key, value);
184
154
  await writeRaw(data);
185
155
  }
186
156