devsplain 2.3.0 → 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 +4 -2
- package/bin/cli.js +131 -14
- package/lib/config.js +108 -7
- package/lib/llm.js +201 -52
- 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,12 +78,40 @@ 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';
|
|
64
|
-
|
|
65
|
-
|
|
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]
|
|
94
|
+
const model = process.env.DEVSPLAIN_MODEL || (
|
|
95
|
+
provider === 'gemini' ? 'gemini-2.0-flash' : (
|
|
96
|
+
provider === 'claude' ? 'claude-3-5-sonnet-20240620' : (
|
|
97
|
+
provider === 'deepseek' ? 'deepseek-chat' : (
|
|
98
|
+
provider === 'openai' ? 'gpt-4o' : 'llama-3.3-70b-versatile'
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
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]
|
|
106
|
+
const baseUrl = process.env.DEVSPLAIN_BASE_URL || (
|
|
107
|
+
provider === 'gemini' ? null : (
|
|
108
|
+
provider === 'claude' ? 'https://api.anthropic.com' : (
|
|
109
|
+
provider === 'deepseek' ? 'https://api.deepseek.com' : (
|
|
110
|
+
provider === 'openai' ? 'https://api.openai.com' : 'https://api.groq.com/openai'
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
);
|
|
66
115
|
return {
|
|
67
116
|
provider,
|
|
68
117
|
apiKey: process.env.DEVSPLAIN_API_KEY || '',
|
|
@@ -72,6 +121,9 @@ async function getConfig(forceWizard = false) {
|
|
|
72
121
|
}
|
|
73
122
|
|
|
74
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]
|
|
75
127
|
if (fs.existsSync(configPath)) {
|
|
76
128
|
try {
|
|
77
129
|
const rawData = fs.readFileSync(configPath, 'utf8');
|
|
@@ -80,6 +132,9 @@ async function getConfig(forceWizard = false) {
|
|
|
80
132
|
}
|
|
81
133
|
}
|
|
82
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]
|
|
83
138
|
if (!fileConfig || !fileConfig.activeProvider || !fileConfig.providers || !fileConfig.providers[fileConfig.activeProvider] || forceWizard) {
|
|
84
139
|
let rl = readline.createInterface({
|
|
85
140
|
input: process.stdin,
|
|
@@ -90,12 +145,17 @@ async function getConfig(forceWizard = false) {
|
|
|
90
145
|
let config = fileConfig || { activeProvider: '', providers: {} };
|
|
91
146
|
let confirmed = false;
|
|
92
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]
|
|
93
150
|
while (!confirmed) {
|
|
94
151
|
const savedProviders = Object.keys(config.providers);
|
|
95
152
|
let providerToConfig = null;
|
|
96
153
|
let wantToUpdate = true;
|
|
97
154
|
let isNewProvider = false;
|
|
98
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]
|
|
99
159
|
if (savedProviders.length > 0) {
|
|
100
160
|
console.log("\nSaved Providers:");
|
|
101
161
|
savedProviders.forEach((p, i) => {
|
|
@@ -106,6 +166,8 @@ async function getConfig(forceWizard = false) {
|
|
|
106
166
|
const c = await askQuestion(`Select (1-${savedProviders.length + 1}): `);
|
|
107
167
|
const idx = parseInt(c) - 1;
|
|
108
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]
|
|
109
171
|
if (idx >= 0 && idx < savedProviders.length) {
|
|
110
172
|
providerToConfig = savedProviders[idx];
|
|
111
173
|
const update = await askQuestion(`Do you want to update the API key or model for ${providerToConfig}? (y/N): `);
|
|
@@ -129,6 +191,9 @@ async function getConfig(forceWizard = false) {
|
|
|
129
191
|
let autoPrune = false;
|
|
130
192
|
|
|
131
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]
|
|
132
197
|
if (isNewProvider || !providerToConfig) {
|
|
133
198
|
console.log("\nWhich AI Provider Do You want to use?");
|
|
134
199
|
console.log("1. Groq (Free, Fast, Llama-3)");
|
|
@@ -136,13 +201,19 @@ async function getConfig(forceWizard = false) {
|
|
|
136
201
|
console.log("3. OpenAI (Paid)");
|
|
137
202
|
console.log("4. Custom (Ollama, local, etc)");
|
|
138
203
|
console.log("5. Claude (Anthropic)");
|
|
204
|
+
console.log("6. DeepSeek (deepseek-chat, deepseek-reasoner)");
|
|
139
205
|
|
|
140
|
-
const choice = await askQuestion("Select (1-
|
|
206
|
+
const choice = await askQuestion("Select (1-6): ");
|
|
141
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]
|
|
142
211
|
if (choice === '1') {
|
|
143
212
|
provider = 'groq';
|
|
144
213
|
baseUrl = 'https://api.groq.com/openai';
|
|
145
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]
|
|
146
217
|
const customModel = await askQuestion("Model name (press Enter for default 'llama-3.3-70b-versatile'): ");
|
|
147
218
|
model = customModel.trim() || 'llama-3.3-70b-versatile';
|
|
148
219
|
} else if (choice === '2') {
|
|
@@ -159,11 +230,15 @@ async function getConfig(forceWizard = false) {
|
|
|
159
230
|
model = customModel.trim() || 'gpt-4o';
|
|
160
231
|
} else if (choice === '4') {
|
|
161
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]
|
|
162
235
|
while (true) {
|
|
163
236
|
model = (await askQuestion("Model name (e.g., llama3): ")).trim();
|
|
164
237
|
if (model) break;
|
|
165
238
|
console.log("Model name cannot be empty.");
|
|
166
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]
|
|
167
242
|
while (true) {
|
|
168
243
|
baseUrl = (await askQuestion("Base URL (e.g., http://localhost:11434): ")).trim();
|
|
169
244
|
if (baseUrl) break;
|
|
@@ -173,12 +248,24 @@ async function getConfig(forceWizard = false) {
|
|
|
173
248
|
provider = 'claude';
|
|
174
249
|
baseUrl = 'https://api.anthropic.com';
|
|
175
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]
|
|
176
252
|
const customModel = await askQuestion("Model name (press Enter for default 'claude-3-5-sonnet-20240620'): ");
|
|
177
253
|
model = customModel.trim() || 'claude-3-5-sonnet-20240620';
|
|
254
|
+
} else if (choice === '6') {
|
|
255
|
+
provider = 'deepseek';
|
|
256
|
+
baseUrl = 'https://api.deepseek.com';
|
|
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]
|
|
259
|
+
const customModel = await askQuestion("Model name (press Enter for default 'deepseek-chat'): ");
|
|
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]
|
|
178
262
|
} else {
|
|
179
|
-
|
|
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]
|
|
265
|
+
console.log("Invalid choice. Please select 1, 2, 3, 4, 5, or 6.");
|
|
180
266
|
continue;
|
|
181
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]
|
|
182
269
|
} else {
|
|
183
270
|
provider = providerToConfig;
|
|
184
271
|
const old = config.providers[provider];
|
|
@@ -187,17 +274,20 @@ async function getConfig(forceWizard = false) {
|
|
|
187
274
|
const customModel = await askQuestion(`Model name (press Enter for default '${defaultModel}'): `);
|
|
188
275
|
model = customModel.trim() || defaultModel;
|
|
189
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]
|
|
190
278
|
if (provider === 'custom') {
|
|
191
279
|
const customBase = await askQuestion(`Base URL (press Enter for default '${baseUrl}'): `);
|
|
192
280
|
baseUrl = customBase.trim() || baseUrl;
|
|
193
281
|
}
|
|
194
282
|
}
|
|
195
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]
|
|
196
285
|
while (true) {
|
|
197
286
|
const promptMsg = provider === 'custom'
|
|
198
287
|
? "Paste your API key (leave blank for local models): "
|
|
199
288
|
: "Paste your API key: ";
|
|
200
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]
|
|
201
291
|
if (process.stdin.isTTY) {
|
|
202
292
|
rl.close();
|
|
203
293
|
apiKey = await askSecret(promptMsg);
|
|
@@ -207,17 +297,20 @@ async function getConfig(forceWizard = false) {
|
|
|
207
297
|
output: process.stdout
|
|
208
298
|
});
|
|
209
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]
|
|
210
301
|
} else {
|
|
211
302
|
apiKey = await askQuestion(promptMsg);
|
|
212
303
|
}
|
|
213
304
|
|
|
214
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]
|
|
215
307
|
if (provider === 'custom' || apiKey) {
|
|
216
308
|
break;
|
|
217
309
|
}
|
|
218
310
|
console.log(`API key is required for provider '${provider}'.`);
|
|
219
311
|
}
|
|
220
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]
|
|
221
314
|
while (true) {
|
|
222
315
|
const pruneAns = (await askQuestion("Do you want devsplain to aggressively prune (overwrite) existing human/AI comments? (y/n, default: n): ")).trim().toLowerCase();
|
|
223
316
|
if (pruneAns === '' || pruneAns === 'n' || pruneAns === 'no') {
|
|
@@ -234,14 +327,17 @@ async function getConfig(forceWizard = false) {
|
|
|
234
327
|
console.log(`Provider: ${provider}`);
|
|
235
328
|
console.log(`Model: ${model}`);
|
|
236
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]
|
|
237
331
|
console.log(`API Key: ${apiKey ? apiKey.substring(0, 4) + '*'.repeat(Math.max(0, apiKey.length - 4)) : 'None'}`);
|
|
238
332
|
console.log(`Auto-Prune: ${autoPrune ? 'Yes' : 'No'}`);
|
|
239
333
|
console.log("-----------------------------\n");
|
|
240
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]
|
|
241
336
|
while (true) {
|
|
242
337
|
const confirm = (await askQuestion("Does this look correct? (y/n, default: y): ")).trim().toLowerCase();
|
|
243
338
|
if (confirm === '' || confirm === 'y' || confirm === 'yes') {
|
|
244
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]
|
|
245
341
|
config.providers[provider] = {
|
|
246
342
|
apiKey,
|
|
247
343
|
model,
|
|
@@ -255,6 +351,7 @@ async function getConfig(forceWizard = false) {
|
|
|
255
351
|
}
|
|
256
352
|
console.log("Invalid choice. Please enter 'y' or 'n'.");
|
|
257
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]
|
|
258
355
|
} else {
|
|
259
356
|
config.activeProvider = providerToConfig;
|
|
260
357
|
console.log(`\nSwitched active provider to ${config.activeProvider}.`);
|
|
@@ -264,18 +361,22 @@ async function getConfig(forceWizard = false) {
|
|
|
264
361
|
|
|
265
362
|
rl.close();
|
|
266
363
|
|
|
364
|
+
// Persist config with 2-space indentation for human readability. Writing synchronously ensures the process cannot exit before the file is flushed. [ds]
|
|
267
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]
|
|
268
367
|
try {
|
|
269
368
|
if (process.platform !== 'win32') {
|
|
270
369
|
fs.chmodSync(configPath, 0o600);
|
|
271
370
|
}
|
|
272
371
|
} catch (chmodErr) {}
|
|
273
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]
|
|
274
374
|
return {
|
|
275
375
|
provider: config.activeProvider,
|
|
276
376
|
...config.providers[config.activeProvider]
|
|
277
377
|
};
|
|
278
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]
|
|
279
380
|
const active = fileConfig.providers[fileConfig.activeProvider] || {};
|
|
280
381
|
return {
|
|
281
382
|
provider: fileConfig.activeProvider,
|