gent-cli 7.0.0 → 8.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/changelog.js +121 -0
- package/src/commands/clone.js +6 -3
- package/src/commands/config.js +147 -0
- package/src/commands/docs.js +141 -0
- package/src/commands/doctor.js +169 -0
- package/src/commands/pull.js +4 -3
- 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/template.js +135 -0
- package/src/commands/web.js +72 -0
- package/src/index.js +151 -10
- package/src/utils/ai-service.js +100 -37
- package/src/utils/api-client.js +28 -6
- package/src/utils/constants.js +3 -1
- package/src/utils/env-loader.js +61 -0
- package/src/utils/user-config.js +225 -0
package/src/utils/api-client.js
CHANGED
|
@@ -5,11 +5,26 @@
|
|
|
5
5
|
|
|
6
6
|
const axios = require('axios');
|
|
7
7
|
const { API_BASE_URL } = require('./constants');
|
|
8
|
+
const userConfig = require('./user-config');
|
|
8
9
|
const authStorage = require('./auth-storage');
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
+
// Resolved once per process so commands see a stable URL. CLI runs are short,
|
|
12
|
+
// so we don't bother with cache invalidation — the next invocation re-reads.
|
|
13
|
+
let _resolvedBaseUrl = null;
|
|
14
|
+
async function resolveBaseUrl() {
|
|
15
|
+
if (_resolvedBaseUrl) return _resolvedBaseUrl;
|
|
16
|
+
try {
|
|
17
|
+
const { value } = await userConfig.getResolved('api.base_url');
|
|
18
|
+
_resolvedBaseUrl = value || API_BASE_URL;
|
|
19
|
+
} catch {
|
|
20
|
+
_resolvedBaseUrl = API_BASE_URL;
|
|
21
|
+
}
|
|
22
|
+
return _resolvedBaseUrl;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create axios instance with base configuration. baseURL is set per-request
|
|
26
|
+
// by the interceptor below so config/env changes take effect immediately.
|
|
11
27
|
const apiClient = axios.create({
|
|
12
|
-
baseURL: API_BASE_URL,
|
|
13
28
|
headers: {
|
|
14
29
|
'Content-Type': 'application/json'
|
|
15
30
|
},
|
|
@@ -37,9 +52,13 @@ function processQueue(error, token = null) {
|
|
|
37
52
|
failedRequestsQueue = [];
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
// Request interceptor -
|
|
55
|
+
// Request interceptor - Resolve base URL + add JWT token to headers
|
|
41
56
|
apiClient.interceptors.request.use(
|
|
42
57
|
async (config) => {
|
|
58
|
+
if (!config.baseURL) {
|
|
59
|
+
config.baseURL = await resolveBaseUrl();
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
const token = await authStorage.getAccessToken();
|
|
44
63
|
|
|
45
64
|
if (token) {
|
|
@@ -89,9 +108,11 @@ apiClient.interceptors.response.use(
|
|
|
89
108
|
throw new Error('Session expired. Please login again.');
|
|
90
109
|
}
|
|
91
110
|
|
|
92
|
-
// Call refresh endpoint
|
|
111
|
+
// Call refresh endpoint (raw axios — bypasses our interceptor
|
|
112
|
+
// intentionally so a 401 here doesn't loop back into refresh).
|
|
113
|
+
const baseUrl = await resolveBaseUrl();
|
|
93
114
|
const response = await axios.post(
|
|
94
|
-
`${
|
|
115
|
+
`${baseUrl}/api/auth/token/refresh/`,
|
|
95
116
|
{ refresh: refreshToken }
|
|
96
117
|
);
|
|
97
118
|
|
|
@@ -188,5 +209,6 @@ module.exports = {
|
|
|
188
209
|
put,
|
|
189
210
|
delete: del,
|
|
190
211
|
patch,
|
|
191
|
-
apiClient // Export raw client if needed
|
|
212
|
+
apiClient, // Export raw client if needed
|
|
213
|
+
resolveBaseUrl,
|
|
192
214
|
};
|
package/src/utils/constants.js
CHANGED
|
@@ -12,7 +12,9 @@ module.exports = {
|
|
|
12
12
|
HEAD_FILE: 'HEAD',
|
|
13
13
|
AUTH_FILE: 'auth.json',
|
|
14
14
|
|
|
15
|
-
// API Configuration
|
|
15
|
+
// API Configuration — default used when no env/config override.
|
|
16
|
+
// Use getResolvedApiBaseUrl() in code that runs after process boot
|
|
17
|
+
// to respect GENT_API_URL env or user config (~/.gent/cli-config.json).
|
|
16
18
|
API_BASE_URL: 'https://gent-api.onrender.com',
|
|
17
19
|
API_ENDPOINTS: {
|
|
18
20
|
// Auth
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env Loader - Load .env files into process.env before commands run.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (lower wins — does NOT clobber existing env):
|
|
5
|
+
* 1. process.env (real shell vars) ← highest, untouched
|
|
6
|
+
* 2. <cwd>/.env ← project-local
|
|
7
|
+
* 3. ~/.gent/.env ← user-global
|
|
8
|
+
*
|
|
9
|
+
* No new dependency: a tiny KEY=VALUE parser (supports quoted values + comments).
|
|
10
|
+
* Silently no-ops if files are missing or malformed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const { GENT_DIR } = require('./constants');
|
|
17
|
+
|
|
18
|
+
function parse(content) {
|
|
19
|
+
const out = {};
|
|
20
|
+
const lines = content.split(/\r?\n/);
|
|
21
|
+
for (const raw of lines) {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (!line || line.startsWith('#')) continue;
|
|
24
|
+
const eq = line.indexOf('=');
|
|
25
|
+
if (eq < 1) continue;
|
|
26
|
+
const key = line.slice(0, eq).trim();
|
|
27
|
+
let value = line.slice(eq + 1).trim();
|
|
28
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
29
|
+
|
|
30
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
31
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
} else {
|
|
34
|
+
const hash = value.indexOf(' #');
|
|
35
|
+
if (hash !== -1) value = value.slice(0, hash).trim();
|
|
36
|
+
}
|
|
37
|
+
out[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadOne(filePath) {
|
|
43
|
+
try {
|
|
44
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
45
|
+
const parsed = parse(content);
|
|
46
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
47
|
+
if (process.env[k] === undefined) {
|
|
48
|
+
process.env[k] = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Missing or unreadable — fine.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function load() {
|
|
57
|
+
loadOne(path.join(process.cwd(), '.env'));
|
|
58
|
+
loadOne(path.join(os.homedir(), GENT_DIR, '.env'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { load, parse };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* User Config - Global per-user CLI settings (~/.gent/config.json)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Persist CLI-wide settings that should NOT live in a project's .gent/ dir:
|
|
8
|
+
* - AI key, AI model
|
|
9
|
+
* - API base URL (so users can point at a local backend without code edits)
|
|
10
|
+
* - Default identity (name/email) used when project .gent/config.json lacks one
|
|
11
|
+
*
|
|
12
|
+
* RESOLUTION ORDER (used by getResolved):
|
|
13
|
+
* env var > ~/.gent/config.json > built-in default
|
|
14
|
+
*
|
|
15
|
+
* 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.
|
|
19
|
+
*
|
|
20
|
+
* 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
|
+
* api.base_url Backend base URL (e.g. http://localhost:8000)
|
|
24
|
+
* user.name Default author name
|
|
25
|
+
* user.email Default author email
|
|
26
|
+
*
|
|
27
|
+
* ============================================================================
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs').promises;
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const CryptoJS = require('crypto-js');
|
|
34
|
+
const { GENT_DIR } = require('./constants');
|
|
35
|
+
|
|
36
|
+
const CONFIG_FILE_NAME = 'cli-config.json';
|
|
37
|
+
const SECRET_KEYS = new Set(['ai.api_key']);
|
|
38
|
+
const OBFUSCATION_KEY = 'gent-cli-config-v1';
|
|
39
|
+
const OBFUSCATION_PREFIX = 'enc:v1:';
|
|
40
|
+
|
|
41
|
+
const ALLOWED_KEYS = new Set([
|
|
42
|
+
'ai.api_key',
|
|
43
|
+
'ai.model',
|
|
44
|
+
'api.base_url',
|
|
45
|
+
'user.name',
|
|
46
|
+
'user.email',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const DEFAULTS = {
|
|
50
|
+
'ai.model': 'claude-opus-4-7',
|
|
51
|
+
'api.base_url': 'https://gent-api.onrender.com',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ENV_OVERRIDES = {
|
|
55
|
+
'ai.api_key': 'ANTHROPIC_API_KEY',
|
|
56
|
+
'ai.model': 'GENT_AI_MODEL',
|
|
57
|
+
'api.base_url': 'GENT_API_URL',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function getConfigPath() {
|
|
61
|
+
return path.join(os.homedir(), GENT_DIR, CONFIG_FILE_NAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function obfuscate(plaintext) {
|
|
65
|
+
if (typeof plaintext !== 'string' || plaintext.length === 0) return plaintext;
|
|
66
|
+
return OBFUSCATION_PREFIX + CryptoJS.AES.encrypt(plaintext, OBFUSCATION_KEY).toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function deobfuscate(value) {
|
|
70
|
+
if (typeof value !== 'string' || !value.startsWith(OBFUSCATION_PREFIX)) return value;
|
|
71
|
+
try {
|
|
72
|
+
const bytes = CryptoJS.AES.decrypt(value.slice(OBFUSCATION_PREFIX.length), OBFUSCATION_KEY);
|
|
73
|
+
return bytes.toString(CryptoJS.enc.Utf8);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setDeep(obj, dottedKey, value) {
|
|
80
|
+
const parts = dottedKey.split('.');
|
|
81
|
+
let cur = obj;
|
|
82
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
83
|
+
if (typeof cur[parts[i]] !== 'object' || cur[parts[i]] === null) {
|
|
84
|
+
cur[parts[i]] = {};
|
|
85
|
+
}
|
|
86
|
+
cur = cur[parts[i]];
|
|
87
|
+
}
|
|
88
|
+
cur[parts[parts.length - 1]] = value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getDeep(obj, dottedKey) {
|
|
92
|
+
const parts = dottedKey.split('.');
|
|
93
|
+
let cur = obj;
|
|
94
|
+
for (const p of parts) {
|
|
95
|
+
if (!cur || typeof cur !== 'object') return undefined;
|
|
96
|
+
cur = cur[p];
|
|
97
|
+
}
|
|
98
|
+
return cur;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unsetDeep(obj, dottedKey) {
|
|
102
|
+
const parts = dottedKey.split('.');
|
|
103
|
+
let cur = obj;
|
|
104
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
105
|
+
if (!cur || typeof cur[parts[i]] !== 'object') return;
|
|
106
|
+
cur = cur[parts[i]];
|
|
107
|
+
}
|
|
108
|
+
delete cur[parts[parts.length - 1]];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readRaw() {
|
|
112
|
+
try {
|
|
113
|
+
const raw = await fs.readFile(getConfigPath(), 'utf-8');
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function writeRaw(data) {
|
|
121
|
+
const dir = path.dirname(getConfigPath());
|
|
122
|
+
await fs.mkdir(dir, { recursive: true });
|
|
123
|
+
await fs.writeFile(getConfigPath(), JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isAllowedKey(key) {
|
|
127
|
+
return ALLOWED_KEYS.has(key);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function listAllowedKeys() {
|
|
131
|
+
return Array.from(ALLOWED_KEYS);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get raw stored value (decoded if secret). Does NOT consult env or defaults.
|
|
136
|
+
*/
|
|
137
|
+
async function get(key) {
|
|
138
|
+
if (!isAllowedKey(key)) throw new Error(`Unknown config key: ${key}`);
|
|
139
|
+
const data = await readRaw();
|
|
140
|
+
const raw = getDeep(data, key);
|
|
141
|
+
if (raw === undefined || raw === null) return undefined;
|
|
142
|
+
if (SECRET_KEYS.has(key)) return deobfuscate(raw);
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a config value using: env > stored > default.
|
|
148
|
+
* Returns { value, source } where source is 'env' | 'config' | 'default' | 'unset'.
|
|
149
|
+
*/
|
|
150
|
+
async function getResolved(key) {
|
|
151
|
+
if (!isAllowedKey(key)) throw new Error(`Unknown config key: ${key}`);
|
|
152
|
+
const envName = ENV_OVERRIDES[key];
|
|
153
|
+
if (envName && process.env[envName]) {
|
|
154
|
+
return { value: process.env[envName], source: 'env', envName };
|
|
155
|
+
}
|
|
156
|
+
const stored = await get(key);
|
|
157
|
+
if (stored !== undefined && stored !== null && stored !== '') {
|
|
158
|
+
return { value: stored, source: 'config' };
|
|
159
|
+
}
|
|
160
|
+
if (DEFAULTS[key] !== undefined) {
|
|
161
|
+
return { value: DEFAULTS[key], source: 'default' };
|
|
162
|
+
}
|
|
163
|
+
return { value: undefined, source: 'unset' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function set(key, value) {
|
|
167
|
+
if (!isAllowedKey(key)) {
|
|
168
|
+
throw new Error(`Unknown config key '${key}'. Allowed: ${listAllowedKeys().join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
if (typeof value !== 'string') value = String(value);
|
|
171
|
+
const data = await readRaw();
|
|
172
|
+
const toStore = SECRET_KEYS.has(key) ? obfuscate(value) : value;
|
|
173
|
+
setDeep(data, key, toStore);
|
|
174
|
+
await writeRaw(data);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function unset(key) {
|
|
178
|
+
if (!isAllowedKey(key)) throw new Error(`Unknown config key: ${key}`);
|
|
179
|
+
const data = await readRaw();
|
|
180
|
+
unsetDeep(data, key);
|
|
181
|
+
await writeRaw(data);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Return all stored values (secrets masked) plus their resolved value/source.
|
|
186
|
+
*/
|
|
187
|
+
async function listAll() {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const key of listAllowedKeys()) {
|
|
190
|
+
const resolved = await getResolved(key);
|
|
191
|
+
const isSecret = SECRET_KEYS.has(key);
|
|
192
|
+
const display = isSecret && resolved.value
|
|
193
|
+
? maskSecret(resolved.value)
|
|
194
|
+
: resolved.value;
|
|
195
|
+
out.push({
|
|
196
|
+
key,
|
|
197
|
+
value: display,
|
|
198
|
+
rawValue: resolved.value,
|
|
199
|
+
source: resolved.source,
|
|
200
|
+
envName: resolved.envName,
|
|
201
|
+
isSecret,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function maskSecret(s) {
|
|
208
|
+
if (!s || typeof s !== 'string') return s;
|
|
209
|
+
if (s.length <= 12) return '****';
|
|
210
|
+
return s.slice(0, 8) + '...' + s.slice(-4);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
get,
|
|
215
|
+
set,
|
|
216
|
+
unset,
|
|
217
|
+
getResolved,
|
|
218
|
+
listAll,
|
|
219
|
+
isAllowedKey,
|
|
220
|
+
listAllowedKeys,
|
|
221
|
+
getConfigPath,
|
|
222
|
+
maskSecret,
|
|
223
|
+
ENV_OVERRIDES,
|
|
224
|
+
DEFAULTS,
|
|
225
|
+
};
|