devsplain 2.3.1 → 2.3.2
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 +2 -0
- package/bin/cli.js +128 -11
- package/lib/config.js +81 -3
- package/lib/llm.js +174 -27
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -4,7 +4,10 @@ const os = require('os');
|
|
|
4
4
|
const readline = require('readline');
|
|
5
5
|
const configPath = path.join(os.homedir(), '.devsplainrc');
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Prompts user for a secret (API key) with masked input by using raw-mode stdin [ds]
|
|
8
|
+
// instead of readline's question(), which cannot suppress terminal echo. Manually [ds]
|
|
9
|
+
// handles Enter, Backspace, and Ctrl+C (SIGINT) so the typed characters never [ds]
|
|
10
|
+
// appear on screen. Returns a Promise that resolves once the user presses Enter. [ds]
|
|
8
11
|
function askSecret(query) {
|
|
9
12
|
return new Promise((resolve) => {
|
|
10
13
|
const rl = readline.createInterface({
|
|
@@ -12,21 +15,33 @@ function askSecret(query) {
|
|
|
12
15
|
output: process.stdout
|
|
13
16
|
});
|
|
14
17
|
process.stdout.write(query);
|
|
18
|
+
// Switch stdin into raw mode to prevent the terminal from echoing typed characters [ds]
|
|
19
|
+
// and to receive keystrokes one-by-one via the 'data' event. [ds]
|
|
15
20
|
process.stdin.setRawMode(true);
|
|
16
21
|
let key = '';
|
|
22
|
+
// Per-keystroke handler. Raw-mode data events may deliver multi-byte chunks, [ds]
|
|
23
|
+
// so we coerce to string first. Ctrl+C is treated specially because raw mode [ds]
|
|
24
|
+
// disables the OS-level SIGINT delivery, so we must exit manually with 130. [ds]
|
|
17
25
|
const onData = (char) => {
|
|
18
26
|
char = char.toString();
|
|
19
27
|
if (char === '\n' || char === '\r') {
|
|
28
|
+
// Restore canonical (cooked) mode before exiting raw capture so subsequent [ds]
|
|
29
|
+
// prompts / terminal behavior behave normally. [ds]
|
|
20
30
|
process.stdin.setRawMode(false);
|
|
21
31
|
process.stdin.removeListener('data', onData);
|
|
22
32
|
process.stdout.write('\n');
|
|
23
33
|
rl.close();
|
|
24
34
|
resolve(key);
|
|
35
|
+
// Handle both DEL (0x7f, sent by most terminals' Backspace) and BS (0x08, [ds]
|
|
36
|
+
// sent by some legacy terminals). Erase visually with '\b \b' since we cannot [ds]
|
|
37
|
+
// rely on the terminal to redraw the masked line. [ds]
|
|
25
38
|
} else if (char === '\u0008' || char === '\u007f') {
|
|
26
39
|
if (key.length > 0) {
|
|
27
40
|
key = key.slice(0, -1);
|
|
28
41
|
process.stdout.write('\b \b');
|
|
29
42
|
}
|
|
43
|
+
// 0x03 is Ctrl+C. In raw mode the default SIGINT handler is bypassed, so we [ds]
|
|
44
|
+
// exit with the conventional 130 code (128 + SIGINT) to match shell expectations. [ds]
|
|
30
45
|
} else if (char === '\u0003') {
|
|
31
46
|
process.stdin.setRawMode(false);
|
|
32
47
|
process.exit(130);
|
|
@@ -39,8 +54,14 @@ function askSecret(query) {
|
|
|
39
54
|
});
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
|
|
57
|
+
// Normalizes legacy single-provider configs (shape: { provider, apiKey, model, [ds]
|
|
58
|
+
// baseUrl, autoPrune }) into the current multi-provider schema (shape: [ds]
|
|
59
|
+
// { activeProvider, providers: { <name>: {...} } }). This lets users with older [ds]
|
|
60
|
+
// config files keep their credentials without re-running the wizard. [ds]
|
|
43
61
|
function migrateConfig(oldConfig) {
|
|
62
|
+
// Only migrate when the old shape is detected: a top-level `provider` string [ds]
|
|
63
|
+
// exists AND the new `providers` map is absent. Guards against undefined/null [ds]
|
|
64
|
+
// and against already-migrated configs. [ds]
|
|
44
65
|
if (oldConfig && typeof oldConfig === 'object' && oldConfig.provider && !oldConfig.providers) {
|
|
45
66
|
return {
|
|
46
67
|
activeProvider: oldConfig.provider,
|
|
@@ -57,10 +78,19 @@ function migrateConfig(oldConfig) {
|
|
|
57
78
|
return oldConfig;
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
|
|
81
|
+
// Loads configuration for the CLI. Resolution order: (1) environment variables [ds]
|
|
82
|
+
// (non-interactive / CI-friendly, takes precedence), (2) existing config file at [ds]
|
|
83
|
+
// ~/.devsplainrc, (3) interactive wizard as a fallback. When `forceWizard` is [ds]
|
|
84
|
+
// true the wizard runs even if a valid file config exists. [ds]
|
|
61
85
|
async function getConfig(forceWizard = false) {
|
|
86
|
+
// Env-var path short-circuits the file lookup and wizard entirely, allowing [ds]
|
|
87
|
+
// headless invocation. Either DEVSPLAIN_API_KEY or DEVSPLAIN_PROVIDER is enough [ds]
|
|
88
|
+
// to trigger this branch. [ds]
|
|
62
89
|
if (process.env.DEVSPLAIN_API_KEY || process.env.DEVSPLAIN_PROVIDER) {
|
|
63
90
|
const provider = process.env.DEVSPLAIN_PROVIDER || 'gemini';
|
|
91
|
+
// Nested ternaries select a sensible default model per provider so that setting [ds]
|
|
92
|
+
// only DEVSPLAIN_PROVIDER (without an explicit DEVSPLAIN_MODEL) yields a working [ds]
|
|
93
|
+
// configuration out of the box. [ds]
|
|
64
94
|
const model = process.env.DEVSPLAIN_MODEL || (
|
|
65
95
|
provider === 'gemini' ? 'gemini-2.0-flash' : (
|
|
66
96
|
provider === 'claude' ? 'claude-3-5-sonnet-20240620' : (
|
|
@@ -70,6 +100,9 @@ async function getConfig(forceWizard = false) {
|
|
|
70
100
|
)
|
|
71
101
|
)
|
|
72
102
|
);
|
|
103
|
+
// Similarly derive the default base URL. Gemini uses the SDK's built-in endpoint [ds]
|
|
104
|
+
// (so baseUrl stays null), while OpenAI-compatible providers need an explicit [ds]
|
|
105
|
+
// host. Groq is the else-branch default since it exposes an OpenAI-compatible API. [ds]
|
|
73
106
|
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (
|
|
74
107
|
provider === 'gemini' ? null : (
|
|
75
108
|
provider === 'claude' ? 'https://api.anthropic.com' : (
|
|
@@ -88,6 +121,9 @@ async function getConfig(forceWizard = false) {
|
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
let fileConfig = null;
|
|
124
|
+
// Parse the on-disk config defensively: missing/malformed JSON should not crash [ds]
|
|
125
|
+
// the CLI. Any error (read failure, JSON parse error) is swallowed and we fall [ds]
|
|
126
|
+
// through to the wizard so the user can recreate the config. [ds]
|
|
91
127
|
if (fs.existsSync(configPath)) {
|
|
92
128
|
try {
|
|
93
129
|
const rawData = fs.readFileSync(configPath, 'utf8');
|
|
@@ -96,6 +132,9 @@ async function getConfig(forceWizard = false) {
|
|
|
96
132
|
}
|
|
97
133
|
}
|
|
98
134
|
|
|
135
|
+
// Enter the interactive wizard when no usable config exists OR when the caller [ds]
|
|
136
|
+
// explicitly forces it. A config is considered usable only if activeProvider, [ds]
|
|
137
|
+
// providers, and the entry for the active provider are all present. [ds]
|
|
99
138
|
if (!fileConfig || !fileConfig.activeProvider || !fileConfig.providers || !fileConfig.providers[fileConfig.activeProvider] || forceWizard) {
|
|
100
139
|
let rl = readline.createInterface({
|
|
101
140
|
input: process.stdin,
|
|
@@ -106,12 +145,17 @@ async function getConfig(forceWizard = false) {
|
|
|
106
145
|
let config = fileConfig || { activeProvider: '', providers: {} };
|
|
107
146
|
let confirmed = false;
|
|
108
147
|
|
|
148
|
+
// Outer loop keeps the user in the wizard on invalid input (instead of aborting) [ds]
|
|
149
|
+
// until they complete a full configuration and confirm it. [ds]
|
|
109
150
|
while (!confirmed) {
|
|
110
151
|
const savedProviders = Object.keys(config.providers);
|
|
111
152
|
let providerToConfig = null;
|
|
112
153
|
let wantToUpdate = true;
|
|
113
154
|
let isNewProvider = false;
|
|
114
155
|
|
|
156
|
+
// If providers already exist (e.g. wizard invoked via forceWizard on an existing [ds]
|
|
157
|
+
// config), display a numbered menu so the user can pick an existing one to update [ds]
|
|
158
|
+
// or add a new one. [ds]
|
|
115
159
|
if (savedProviders.length > 0) {
|
|
116
160
|
console.log("\nSaved Providers:");
|
|
117
161
|
savedProviders.forEach((p, i) => {
|
|
@@ -122,6 +166,8 @@ async function getConfig(forceWizard = false) {
|
|
|
122
166
|
const c = await askQuestion(`Select (1-${savedProviders.length + 1}): `);
|
|
123
167
|
const idx = parseInt(c) - 1;
|
|
124
168
|
|
|
169
|
+
// Option index 0..len-1 maps to an existing provider; index === len is the [ds]
|
|
170
|
+
// 'Add/Configure a different provider' sentinel. Anything else is invalid input. [ds]
|
|
125
171
|
if (idx >= 0 && idx < savedProviders.length) {
|
|
126
172
|
providerToConfig = savedProviders[idx];
|
|
127
173
|
const update = await askQuestion(`Do you want to update the API key or model for ${providerToConfig}? (y/N): `);
|
|
@@ -145,6 +191,9 @@ async function getConfig(forceWizard = false) {
|
|
|
145
191
|
let autoPrune = false;
|
|
146
192
|
|
|
147
193
|
if (wantToUpdate) {
|
|
194
|
+
// Provider-selection flow only runs when we actually want to modify something. [ds]
|
|
195
|
+
// When updating an existing provider and the user declined the y/N prompt, we [ds]
|
|
196
|
+
// skip straight to confirmation with the existing values intact. [ds]
|
|
148
197
|
if (isNewProvider || !providerToConfig) {
|
|
149
198
|
console.log("\nWhich AI Provider Do You want to use?");
|
|
150
199
|
console.log("1. Groq (Free, Fast, Llama-3)");
|
|
@@ -156,10 +205,15 @@ async function getConfig(forceWizard = false) {
|
|
|
156
205
|
|
|
157
206
|
const choice = await askQuestion("Select (1-6): ");
|
|
158
207
|
|
|
208
|
+
// Preferred-provider menu. Most options pre-fill baseUrl and offer a default [ds]
|
|
209
|
+
// model so users can just press Enter; 'Custom' falls through to the loop below [ds]
|
|
210
|
+
// that forces non-empty model and baseUrl values. [ds]
|
|
159
211
|
if (choice === '1') {
|
|
160
212
|
provider = 'groq';
|
|
161
213
|
baseUrl = 'https://api.groq.com/openai';
|
|
162
214
|
console.log("\nGet your free Groq key here: https://console.groq.com/keys");
|
|
215
|
+
// Empty input is treated as 'use the default'; trim() guards against stray [ds]
|
|
216
|
+
// whitespace counting as a real value. [ds]
|
|
163
217
|
const customModel = await askQuestion("Model name (press Enter for default 'llama-3.3-70b-versatile'): ");
|
|
164
218
|
model = customModel.trim() || 'llama-3.3-70b-versatile';
|
|
165
219
|
} else if (choice === '2') {
|
|
@@ -176,11 +230,15 @@ async function getConfig(forceWizard = false) {
|
|
|
176
230
|
model = customModel.trim() || 'gpt-4o';
|
|
177
231
|
} else if (choice === '4') {
|
|
178
232
|
provider = 'custom';
|
|
233
|
+
// Loop because an empty model name would produce a broken request later; keep [ds]
|
|
234
|
+
// re-prompting until the user supplies a non-empty value. [ds]
|
|
179
235
|
while (true) {
|
|
180
236
|
model = (await askQuestion("Model name (e.g., llama3): ")).trim();
|
|
181
237
|
if (model) break;
|
|
182
238
|
console.log("Model name cannot be empty.");
|
|
183
239
|
}
|
|
240
|
+
// Same rationale as the model loop: a custom provider cannot function without [ds]
|
|
241
|
+
// a base URL, so we refuse to accept an empty answer. [ds]
|
|
184
242
|
while (true) {
|
|
185
243
|
baseUrl = (await askQuestion("Base URL (e.g., http://localhost:11434): ")).trim();
|
|
186
244
|
if (baseUrl) break;
|
|
@@ -190,18 +248,24 @@ async function getConfig(forceWizard = false) {
|
|
|
190
248
|
provider = 'claude';
|
|
191
249
|
baseUrl = 'https://api.anthropic.com';
|
|
192
250
|
console.log("\nGet your Anthropic key here: https://console.anthropic.com/settings/keys");
|
|
251
|
+
// Offer a default model so users unfamiliar with Anthropic's naming don't have to look up the identifier; an empty trimmed input falls back via short-circuit OR. [ds]
|
|
193
252
|
const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
|
|
194
253
|
model = customModel.trim() || 'claude-3-5-sonnet-20240620';
|
|
195
254
|
} else if (choice === '6') {
|
|
196
255
|
provider = 'deepseek';
|
|
197
256
|
baseUrl = 'https://api.deepseek.com';
|
|
198
257
|
console.log("\nGet your DeepSeek key here: https://platform.deepseek.com/api_keys");
|
|
258
|
+
// Same default-model fallback pattern as the Anthropic branch: empty Enter preserves the DeepSeek default, avoiding an undefined model string. [ds]
|
|
199
259
|
const customModel = await askQuestion("Model name (press Enter for default 'deepseek-chat'): ");
|
|
200
260
|
model = customModel.trim() || 'deepseek-chat';
|
|
261
|
+
// Any unrecognized menu selection restarts the loop via `continue` rather than throwing, so the wizard remains re-entrant and never partially writes config. [ds]
|
|
201
262
|
} else {
|
|
263
|
+
// Invalid menu selection restarts the outer while-loop without persisting [ds]
|
|
264
|
+
// anything, so partial state is discarded and the user gets a clean retry. [ds]
|
|
202
265
|
console.log("Invalid choice. Please select 1, 2, 3, 4, 5, or 6.");
|
|
203
266
|
continue;
|
|
204
267
|
}
|
|
268
|
+
// Edit-existing-provider branch: reuse the stored baseUrl/model as defaults so the user can simply hit Enter to keep prior values. The custom provider additionally re-prompts for baseUrl since users may self-host at a different address. [ds]
|
|
205
269
|
} else {
|
|
206
270
|
provider = providerToConfig;
|
|
207
271
|
const old = config.providers[provider];
|
|
@@ -210,17 +274,20 @@ async function getConfig(forceWizard = false) {
|
|
|
210
274
|
const customModel = await askQuestion(`Model name (press Enter for default '${defaultModel}'): `);
|
|
211
275
|
model = customModel.trim() || defaultModel;
|
|
212
276
|
|
|
277
|
+
// Only the 'custom' provider exposes its baseUrl for editing; hosted providers pin their endpoints to avoid users accidentally pointing Anthropic/DeepSeek credentials at attacker-controlled hosts. [ds]
|
|
213
278
|
if (provider === 'custom') {
|
|
214
279
|
const customBase = await askQuestion(`Base URL (press Enter for default '${baseUrl}'): `);
|
|
215
280
|
baseUrl = customBase.trim() || baseUrl;
|
|
216
281
|
}
|
|
217
282
|
}
|
|
218
283
|
|
|
284
|
+
// API key acquisition loop. It must re-prompt on empty input for hosted providers but must allow empty keys for local/custom endpoints (e.g. Ollama) which don't require authentication. [ds]
|
|
219
285
|
while (true) {
|
|
220
286
|
const promptMsg = provider === 'custom'
|
|
221
287
|
? "Paste your API key (leave blank for local models): "
|
|
222
288
|
: "Paste your API key: ";
|
|
223
289
|
|
|
290
|
+
// When stdin is a TTY we temporarily close the readline interface so raw-mode secret input (echo suppressed) can be read without readline echoing the typed characters back. After capturing the secret we re-create the readline interface and rebuild the askQuestion helper bound to the new rl instance. [ds]
|
|
224
291
|
if (process.stdin.isTTY) {
|
|
225
292
|
rl.close();
|
|
226
293
|
apiKey = await askSecret(promptMsg);
|
|
@@ -230,17 +297,20 @@ async function getConfig(forceWizard = false) {
|
|
|
230
297
|
output: process.stdout
|
|
231
298
|
});
|
|
232
299
|
askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
|
|
300
|
+
// Non-TTY (piped/CI) input cannot use raw mode; fall back to plain readline so the key still gets read but will be visible in the stream — acceptable for automated provisioning. [ds]
|
|
233
301
|
} else {
|
|
234
302
|
apiKey = await askQuestion(promptMsg);
|
|
235
303
|
}
|
|
236
304
|
|
|
237
305
|
apiKey = apiKey.trim();
|
|
306
|
+
// Custom/local providers explicitly bypass the non-empty key requirement; hosted providers must supply a non-empty key or we loop and warn the user. [ds]
|
|
238
307
|
if (provider === 'custom' || apiKey) {
|
|
239
308
|
break;
|
|
240
309
|
}
|
|
241
310
|
console.log(`API key is required for provider '${provider}'.`);
|
|
242
311
|
}
|
|
243
312
|
|
|
313
|
+
// Tri-state confirmation for aggressive pruning. Accepting 'y'/'yes'/'n'/'no'/empty makes the prompt forgiving; anything else re-prompts to avoid silently misconfiguring destructive overwrite behavior. [ds]
|
|
244
314
|
while (true) {
|
|
245
315
|
const pruneAns = (await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ")).trim().toLowerCase();
|
|
246
316
|
if (pruneAns === '' || pruneAns === 'n' || pruneAns === 'no') {
|
|
@@ -257,14 +327,17 @@ async function getConfig(forceWizard = false) {
|
|
|
257
327
|
console.log(`Provider: ${provider}`);
|
|
258
328
|
console.log(`Model: ${model}`);
|
|
259
329
|
console.log(`Base URL: ${baseUrl || 'N/A'}`);
|
|
330
|
+
// Mask the API key in the summary while preserving the first 4 chars for user recognition. Math.max(0, ...) guards against keys shorter than 4 chars which would otherwise produce a negative repeat count and throw. [ds]
|
|
260
331
|
console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
|
|
261
332
|
console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
|
|
262
333
|
console.log("-----------------------------\n");
|
|
263
334
|
|
|
335
|
+
// Final confirmation gate. Only on explicit 'y' (or bare Enter, defaulting to yes) do we commit the provider into config.providers and set it as active. Choosing 'n' breaks out without mutating config, effectively cancelling the wizard. `confirmed` signals the outer loop to exit. [ds]
|
|
264
336
|
while (true) {
|
|
265
337
|
const confirm = (await askQuestion("Does this look correct? (y/n, default: y): ")).trim().toLowerCase();
|
|
266
338
|
if (confirm === '' || confirm === 'y' || confirm === 'yes') {
|
|
267
339
|
config.activeProvider = provider;
|
|
340
|
+
// Persist the collected settings under the provider key. Overwrites any prior entry for this provider, which is the intended behavior when re-running the setup wizard for an existing provider. [ds]
|
|
268
341
|
config.providers[provider] = {
|
|
269
342
|
apiKey,
|
|
270
343
|
model,
|
|
@@ -278,6 +351,7 @@ async function getConfig(forceWizard = false) {
|
|
|
278
351
|
}
|
|
279
352
|
console.log("Invalid choice. Please enter 'y' or 'n'.");
|
|
280
353
|
}
|
|
354
|
+
// The user chose to merely switch the active provider without re-entering credentials; mark confirmed so the enclosing loop terminates and proceed to write. [ds]
|
|
281
355
|
} else {
|
|
282
356
|
config.activeProvider = providerToConfig;
|
|
283
357
|
console.log(`\nSwitched active provider to ${config.activeProvider}.`);
|
|
@@ -287,18 +361,22 @@ async function getConfig(forceWizard = false) {
|
|
|
287
361
|
|
|
288
362
|
rl.close();
|
|
289
363
|
|
|
364
|
+
// Persist config with 2-space indentation for human readability. Writing synchronously ensures the process cannot exit before the file is flushed. [ds]
|
|
290
365
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
366
|
+
// Harden file permissions to owner-only (0600) on POSIX systems because config stores API keys in plaintext. Windows lacks POSIX mode semantics, hence the platform guard, and chmod failures are swallowed since the config is already written. [ds]
|
|
291
367
|
try {
|
|
292
368
|
if (process.platform !== 'win32') {
|
|
293
369
|
fs.chmodSync(configPath, 0o600);
|
|
294
370
|
}
|
|
295
371
|
} catch (chmodErr) {}
|
|
296
372
|
|
|
373
|
+
// Return a flattened view of the active provider so callers don't have to remember whether they need config.activeProvider or config.providers[...]; the spread exposes apiKey/model/baseUrl/autoPrune at the top level. [ds]
|
|
297
374
|
return {
|
|
298
375
|
provider: config.activeProvider,
|
|
299
376
|
...config.providers[config.activeProvider]
|
|
300
377
|
};
|
|
301
378
|
} else {
|
|
379
|
+
// Read-only path: no wizard needed. The `|| {}` defends against a stale activeProvider name that no longer exists in the providers map, ensuring the spread below never throws on undefined. [ds]
|
|
302
380
|
const active = fileConfig.providers[fileConfig.activeProvider] || {};
|
|
303
381
|
return {
|
|
304
382
|
provider: fileConfig.activeProvider,
|