ineedcodes 0.2.0 → 1.0.1

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/src/session.js ADDED
@@ -0,0 +1,156 @@
1
+ // session.js: interactive REPL. Slash commands are shortcuts, never required.
2
+
3
+ import * as readline from 'node:readline';
4
+ import { loadConfig, clearConfig, normalize } from './config.js';
5
+ import { runObjective, pushTurn } from './agent.js';
6
+ import { fetchModels } from './provider.js';
7
+ import { makeInput, bold, dim, red, green, yellow, cyan, gray, trunc, BANNER } from './ui.js';
8
+ import { wizard } from './wizard.js';
9
+
10
+ export async function startSession(cfg) {
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
12
+ let handleRef = null;
13
+ const ask = makeInput(rl, l => handleRef?.(l));
14
+ let state = normalize(cfg);
15
+ let history = [];
16
+ let busy = false;
17
+ let activeRun = null;
18
+ let mode = state.mode;
19
+ let pendingLines = [];
20
+ let lastSigint = 0;
21
+ let closed = false;
22
+
23
+ rl.on('SIGINT', () => {
24
+ if (activeRun) { activeRun.abort(); console.log(dim('\nStopping current task... press Ctrl+C again to force exit.')); return; }
25
+ const now = Date.now();
26
+ if (now - lastSigint < 3000) { console.log(dim('\nGoodbye.')); process.exit(0); }
27
+ lastSigint = now;
28
+ console.log(dim('\n(Ctrl+C again to exit)'));
29
+ rl.prompt();
30
+ });
31
+
32
+ const prompt = () => {
33
+ rl.setPrompt(`\n${bold(green('ineed'))}${dim(` [${mode}/${state.reasoning}]`)} ${dim('› ')}`);
34
+ rl.prompt();
35
+ };
36
+
37
+ const header = () => {
38
+ console.log(BANNER() + dim(` · ${state.model} · ${process.cwd()}`));
39
+ console.log(dim('Just say what you want. /help shows shortcuts. Ctrl+C twice exits.'));
40
+ };
41
+
42
+ const showHelp = () => {
43
+ console.log(' ' + cyan('/model') + ' pick a model from your provider');
44
+ console.log(' ' + cyan('/plan') + ' plan mode: read only, agent suggests, changes nothing');
45
+ console.log(' ' + cyan('/build') + ' build mode: agent makes real changes (default)');
46
+ console.log(' ' + cyan('/reason') + ' toggle reasoning effort low/high');
47
+ console.log(' ' + cyan('/setup') + ' redo provider setup');
48
+ console.log(' ' + cyan('/reset') + ' clear saved config');
49
+ console.log(' ' + cyan('/clear') + ' forget this session\'s conversation');
50
+ console.log(' ' + cyan('/exit') + ' quit');
51
+ console.log(dim(' Everything else you type is a task, in normal language.'));
52
+ };
53
+
54
+ const pickModel = async () => {
55
+ console.log(dim(' Fetching models...'));
56
+ let models = [];
57
+ try { models = await fetchModels(state); } catch (err) { console.log(red(' ' + err.message)); return; }
58
+ if (models.length === 0) {
59
+ const m = await ask(' Server sent no list. Type the model id: ');
60
+ if (m) { state = normalize({ ...state, model: m }); console.log(green(' Model: ' + state.model)); }
61
+ return;
62
+ }
63
+ const max = 5;
64
+ const list = models.slice(0, max);
65
+ console.log(dim(` ${models.length} models available, showing ${list.length}. Type a number, or a full model id.`));
66
+ list.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
67
+ const pick = await ask(' Model: ');
68
+ if (!pick) return;
69
+ const idx = Number(pick);
70
+ if (Number.isInteger(idx) && idx >= 1 && idx <= list.length) state = normalize({ ...state, model: list[idx - 1] });
71
+ else state = normalize({ ...state, model: pick });
72
+ console.log(green(' Model: ' + state.model));
73
+ };
74
+
75
+ const runTask = async input => {
76
+ busy = true;
77
+ try {
78
+ const res = await runObjective(state, input, process.cwd(), history, {
79
+ onTool: (name, input2) => {
80
+ console.log(' ' + cyan('· ' + name) + ' ' + gray(trunc(JSON.stringify(input2), 100)));
81
+ },
82
+ onResult: out => { console.log(' ' + gray(trunc(out, 120))); },
83
+ onText: t => { console.log(' ' + dim(trunc(t, 300))); },
84
+ onRunStart: c => { activeRun = c; },
85
+ onRunEnd: () => { activeRun = null; }
86
+ });
87
+ history = pushTurn(history, input, res);
88
+ if (res.aborted) console.log('\n' + yellow('Stopped.') + dim(' Partly done, tell me to continue if you want.'));
89
+ else {
90
+ console.log('\n' + bold(green('Done')));
91
+ if (res.changed?.length) console.log(dim(' Changed: ' + res.changed.join(', ')));
92
+ if (res.answer) console.log(' ' + res.answer.split('\n').slice(0, 14).join('\n '));
93
+ }
94
+ } catch (err) {
95
+ console.log('\n' + red('Error: ' + err.message));
96
+ console.log(dim(' Check /setup if this keeps happening.'));
97
+ } finally {
98
+ busy = false;
99
+ activeRun = null;
100
+ if (!closed) {
101
+ prompt();
102
+ drain();
103
+ }
104
+ }
105
+ };
106
+
107
+ const drain = () => {
108
+ while (!busy && !closed) {
109
+ const next = pendingLines.shift();
110
+ if (!next) break;
111
+ handle(next);
112
+ }
113
+ };
114
+
115
+ const handle = input => {
116
+ if (!input) { if (!busy && !closed) prompt(); return; }
117
+ if (busy) { pendingLines.push(input); return; }
118
+ if (['/exit', '/quit', 'exit', 'quit'].includes(input)) { console.log(dim('Goodbye.')); closed = true; rl.close(); process.exit(0); }
119
+ if (input === '/help' || input === '?') { showHelp(); prompt(); return; }
120
+ if (input === '/model') {
121
+ busy = true;
122
+ pickModel().then(() => {
123
+ busy = false;
124
+ if (!closed) {
125
+ prompt();
126
+ drain();
127
+ }
128
+ });
129
+ return;
130
+ }
131
+ if (input === '/plan') { mode = 'plan'; state = normalize({ ...state, mode: 'plan' }); console.log(yellow('Plan mode: read only.')); prompt(); return; }
132
+ if (input === '/build') { mode = 'build'; state = normalize({ ...state, mode: 'build' }); console.log(green('Build mode: real changes allowed.')); prompt(); return; }
133
+ if (input === '/reason') {
134
+ state = normalize({ ...state, reasoning: state.reasoning === 'high' ? 'low' : 'high' });
135
+ console.log(dim('Reasoning effort: ' + state.reasoning));
136
+ prompt(); return;
137
+ }
138
+ if (input === '/clear') { history = []; console.log(dim('Conversation forgotten.')); prompt(); return; }
139
+ if (input === '/setup' || input === '/reset') {
140
+ clearConfig();
141
+ console.log(dim('Config cleared.'));
142
+ wizard(ask, { fromCommand: true }).then(c => {
143
+ state = normalize(c);
144
+ mode = state.mode;
145
+ console.log(green('\nReady. ' + state.model));
146
+ prompt();
147
+ }).catch(() => process.exit(1));
148
+ return;
149
+ }
150
+ runTask(input);
151
+ };
152
+
153
+ handleRef = handle;
154
+ header();
155
+ prompt();
156
+ }
package/src/tools.js ADDED
@@ -0,0 +1,185 @@
1
+ // tools.js: real execution tools. Every result is { output: string } so the model never sees undefined.
2
+
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ const underRoot = (p, root) => p === root || p.startsWith(root + path.sep);
8
+
9
+ const SECRET_PATTERNS = [
10
+ /(^|\/)\.env($|\.)/,
11
+ /(^|\/)\.ssh\//,
12
+ /(^|\/)id_rsa/,
13
+ /(^|\/)id_ed25519/,
14
+ /\.pem$/
15
+ ];
16
+
17
+ function isSecret(abs) {
18
+ return SECRET_PATTERNS.some(re => re.test(abs));
19
+ }
20
+
21
+ export const TOOLS = [
22
+ {
23
+ name: 'list_files',
24
+ description: 'List files in a directory, recursive, skips node_modules/.git/dist.',
25
+ parameters: { type: 'object', properties: { path: { type: 'string', description: 'relative to working directory' } } },
26
+ allowedInPlan: true
27
+ },
28
+ {
29
+ name: 'read_file',
30
+ description: 'Read a text file (UTF-8).',
31
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
32
+ allowedInPlan: true
33
+ },
34
+ {
35
+ name: 'search_text',
36
+ description: 'Search file contents for a string, returns file:line: matches (max 100).',
37
+ parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string', description: 'directory, defaults to working directory' } }, required: ['pattern'] },
38
+ allowedInPlan: true
39
+ },
40
+ {
41
+ name: 'write_file',
42
+ description: 'Create or overwrite a file with content.',
43
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
44
+ allowedInPlan: false
45
+ },
46
+ {
47
+ name: 'edit_file',
48
+ description: 'Replace an exact string in a file with a new string (targeted edit).',
49
+ parameters: { type: 'object', properties: { path: { type: 'string' }, search: { type: 'string' }, replace: { type: 'string' } }, required: ['path', 'search', 'replace'] },
50
+ allowedInPlan: false
51
+ },
52
+ {
53
+ name: 'delete_file',
54
+ description: 'Delete one file.',
55
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
56
+ allowedInPlan: false
57
+ },
58
+ {
59
+ name: 'shell',
60
+ description: 'Run a shell command in the working directory. Returns exit code with stdout and stderr.',
61
+ parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
62
+ allowedInPlan: false
63
+ }
64
+ ];
65
+
66
+ export function runTool(name, input, cwd) {
67
+ try {
68
+ const abs = path.resolve(cwd, String(input.path ?? ''));
69
+ if (!underRoot(abs, cwd)) return { output: 'Refused: path is outside the working directory.' };
70
+ if (name === 'list_files') {
71
+ const out = [];
72
+ const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache']);
73
+ (function walk(d, depth) {
74
+ if (out.length > 500 || depth > 5) return;
75
+ let entries;
76
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
77
+ for (const e of entries) {
78
+ if (skip.has(e.name)) continue;
79
+ out.push((e.isDirectory() ? e.name + '/' : e.name) + (e.isDirectory() && depth < 4 ? '/' : ''));
80
+ if (e.isDirectory()) walk(path.join(d, e.name), depth + 1);
81
+ }
82
+ })(abs, 0);
83
+ return { output: out.length ? out.join('\n') : '(empty)' };
84
+ }
85
+ if (name === 'read_file') {
86
+ if (isSecret(abs)) return { output: 'Refused: that looks like a secret file, and secrets never enter the model context.' };
87
+ const st = fs.statSync(abs);
88
+ if (st.isDirectory()) return { output: 'Error: that is a directory, use list_files.' };
89
+ return { output: fs.readFileSync(abs, 'utf8').slice(0, 60_000) };
90
+ }
91
+ if (name === 'search_text') {
92
+ const pattern = String(input.pattern ?? '');
93
+ if (!pattern) return { output: 'Error: empty pattern.' };
94
+ const out = [];
95
+ const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache']);
96
+ (function walk(d, depth) {
97
+ if (out.length >= 100 || depth > 5) return;
98
+ let entries;
99
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
100
+ for (const e of entries) {
101
+ if (out.length >= 100) return;
102
+ if (skip.has(e.name)) continue;
103
+ const p = path.join(d, e.name);
104
+ if (e.isDirectory()) { walk(p, depth + 1); continue; }
105
+ if (e.name.startsWith('.env') || e.name.endsWith('.pem')) continue;
106
+ let lines;
107
+ try { lines = fs.readFileSync(p, 'utf8').split('\n'); } catch { continue; }
108
+ for (let i = 0; i < lines.length && out.length < 100; i++) {
109
+ if (lines[i].includes(pattern)) out.push(`${path.relative(cwd, p)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
110
+ }
111
+ }
112
+ })(abs, 0);
113
+ return { output: out.length ? out.join('\n') : '(no matches)' };
114
+ }
115
+ if (name === 'write_file') {
116
+ const content = String(input.content ?? '');
117
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
118
+ fs.writeFileSync(abs, content);
119
+ return { output: `Wrote ${path.relative(cwd, abs) || '.'} (${content.length} bytes).` };
120
+ }
121
+ if (name === 'edit_file') {
122
+ if (isSecret(abs)) return { output: 'Refused: that looks like a secret file.' };
123
+ const search = String(input.search ?? '');
124
+ const replace = String(input.replace ?? '');
125
+ if (!search) return { output: 'Error: empty search string.' };
126
+ const src = fs.readFileSync(abs, 'utf8');
127
+ const count = src.split(search).length - 1;
128
+ if (count === 0) return { output: 'Error: search string not found in file.' };
129
+ if (count > 1) return { output: `Error: search string matches ${count} times, give a longer unique string.` };
130
+ fs.writeFileSync(abs, src.replace(search, replace));
131
+ return { output: `Edited ${path.relative(cwd, abs)}.` };
132
+ }
133
+ if (name === 'delete_file') {
134
+ fs.unlinkSync(abs);
135
+ return { output: `Deleted ${path.relative(cwd, abs)}.` };
136
+ }
137
+ return { output: `Unknown tool: ${name}` };
138
+ } catch (err) {
139
+ return { output: `Error: ${err.message}` };
140
+ }
141
+ }
142
+
143
+ const DESTRUCTIVE = [
144
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+\/(\s|$)/,
145
+ /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f/,
146
+ /\bmkfs\b/,
147
+ /\bdd\s+if=/,
148
+ /\bgit\s+push\s+.*--force/,
149
+ /\bgit\s+reset\s+--hard\s+origin/,
150
+ /:\(\)\{\s*:\|:\s*&\s*\}\s*;:/
151
+ ];
152
+
153
+ export function isDestructive(command) {
154
+ return DESTRUCTIVE.some(re => re.test(command));
155
+ }
156
+
157
+ export function shellRun(command, cwd, signal) {
158
+ return new Promise(resolve => {
159
+ const child = spawn(command, { cwd, shell: true, env: { ...process.env, NO_COLOR: '1' } });
160
+ let out = '';
161
+ let settled = false;
162
+ let timedOut = false;
163
+ const finish = () => {
164
+ if (settled) return;
165
+ settled = true;
166
+ clearTimeout(timer);
167
+ signal?.removeEventListener('abort', onAbort);
168
+ resolve({ output: `exit code: ${timedOut ? 'timeout' : child.exitCode}\n${out.slice(0, 20_000)}` });
169
+ };
170
+ const timer = setTimeout(() => {
171
+ timedOut = true;
172
+ try { child.kill('SIGKILL'); } catch {}
173
+ out += '\n[timeout after 120s]';
174
+ }, 120_000);
175
+ const onAbort = () => { try { child.kill('SIGKILL'); } catch {} out += '\n[stopped by user]'; };
176
+ signal?.addEventListener('abort', onAbort, { once: true });
177
+ child.stdout.on('data', c => {
178
+ out += c.toString();
179
+ if (out.length > 100_000) { try { child.kill('SIGKILL'); } catch {} }
180
+ });
181
+ child.stderr.on('data', c => { out += c.toString(); });
182
+ child.on('close', finish);
183
+ child.on('error', err => { out += `spawn error: ${err.message}`; finish(); });
184
+ });
185
+ }
package/src/ui.js ADDED
@@ -0,0 +1,74 @@
1
+ // ui.js: terminal helpers. No dependencies, respects NO_COLOR and non-TTY.
2
+
3
+ export const VERSION = '1.0.0';
4
+
5
+ const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
6
+ const wrap = (code, t) => USE_COLOR ? `\x1b[${code}m${t}\x1b[0m` : String(t);
7
+
8
+ export const bold = t => wrap('1', t);
9
+ export const dim = t => wrap('2', t);
10
+ export const red = t => wrap('31', t);
11
+ export const green = t => wrap('32', t);
12
+ export const yellow = t => wrap('33', t);
13
+ export const cyan = t => wrap('36', t);
14
+ export const gray = t => wrap('90', t);
15
+
16
+ export const trunc = (s, n = 120) => {
17
+ const o = String(s).replaceAll('\n', ' ');
18
+ return o.length > n ? o.slice(0, n - 1) + '...' : o;
19
+ };
20
+
21
+ export const LOGO = [
22
+ '#### ## ',
23
+ ' ## ## ',
24
+ ' ## ## ',
25
+ ' ## ## ',
26
+ ' #### ### '
27
+ ].join('\n');
28
+
29
+ export const BANNER = () =>
30
+ bold(green('ineed')) + dim(` v${VERSION}`) + dim(' · your terminal, now autonomous');
31
+
32
+ // Ask a question and await one line. `secret` hides typed characters.
33
+ // onLine: receiver for lines typed when no question is pending (REPL dispatch).
34
+ export function makeInput(rl, onLine) {
35
+ let pending = null;
36
+ let closed = false;
37
+ const queue = [];
38
+
39
+ rl.on('line', line => {
40
+ const l = line.trim();
41
+ if (pending) { const r = pending; pending = null; r(l); return; }
42
+ if (onLine) { onLine(l); return; }
43
+ queue.push(l);
44
+ });
45
+ rl.on('close', () => {
46
+ closed = true;
47
+ if (pending) { const r = pending; pending = null; r(''); }
48
+ });
49
+
50
+ const ask = (q, { secret = false } = {}) => new Promise(res => {
51
+ process.stdout.write(q + ' ');
52
+ let prevEcho = null;
53
+ if (secret && process.stdout.isTTY) {
54
+ prevEcho = rl._writeToOutput;
55
+ rl._writeToOutput = () => {}; // hide keystrokes while the user types
56
+ }
57
+ const deliver = v => {
58
+ if (prevEcho) rl._writeToOutput = prevEcho;
59
+ else if (secret) delete rl._writeToOutput;
60
+ if (secret) process.stdout.write('\n');
61
+ res(v);
62
+ };
63
+ if (queue.length > 0) deliver(queue.shift());
64
+ else if (closed) deliver('');
65
+ else pending = deliver;
66
+ });
67
+
68
+ // push a line as if typed: lands on a pending question, or waits in the queue
69
+ ask.feed = l => {
70
+ if (pending) { const r = pending; pending = null; r(l); return; }
71
+ queue.push(l);
72
+ };
73
+ return ask;
74
+ }
package/src/wizard.js ADDED
@@ -0,0 +1,111 @@
1
+ // wizard.js: first open. Asks base URL, API key, then offers real models to pick from. Saved once, never asked again.
2
+
3
+ import { fetchModels, chat } from './provider.js';
4
+ import { saveConfig } from './config.js';
5
+ import { bold, dim, red, yellow, green, cyan, trunc } from './ui.js';
6
+
7
+ export async function testConnection(cfg, signal) {
8
+ // proves reachability + auth + a working chat in one shot
9
+ const m = await chat(cfg, [{ role: 'user', content: 'Reply with exactly: OK' }], undefined, signal);
10
+ return String(m.content ?? '').trim();
11
+ }
12
+
13
+ export async function wizard(ask, { fromCommand = false } = {}) {
14
+ const inputEnded = () => process.stdin.readableEnded === true && !process.stdout.isTTY;
15
+ const abortIfEnded = () => { if (inputEnded()) throw Object.assign(new Error('Setup aborted: input ended.'), { aborted: true }); };
16
+
17
+ console.log('');
18
+ console.log(bold('Welcome to ineed') + dim(' - let\'s connect you to an AI provider. You only do this once.'));
19
+ console.log(dim('Any OpenAI-compatible API works: OpenAI, OmniRoute, LM Studio, Ollama, vLLM, and more.'));
20
+ console.log('');
21
+
22
+ let baseUrl = '';
23
+ while (true) {
24
+ baseUrl = await ask('1. API base URL (example: https://api.openai.com/v1): ');
25
+ if (/^https?:\/\//.test(baseUrl)) break;
26
+ abortIfEnded();
27
+ console.log(red(' It must start with http:// or https://'));
28
+ }
29
+
30
+ let apiKey = '';
31
+ while (apiKey === '') {
32
+ apiKey = await ask('2. API key (input hidden): ', { secret: true });
33
+ if (apiKey === '') {
34
+ abortIfEnded();
35
+ console.log(red(' API key is required. Paste it and press Enter.'));
36
+ }
37
+ }
38
+
39
+ const probe = { baseUrl, apiKey, model: 'x' };
40
+ console.log(dim('\n Checking connection...'));
41
+ let models = [];
42
+ try {
43
+ models = await fetchModels(probe);
44
+ } catch {}
45
+ if (models.length > 0) {
46
+ console.log(green(` Connected. ${models.length} models available.`));
47
+ } else {
48
+ console.log(yellow(' Connected, but the server did not return a model list (many routers hide it).'));
49
+ }
50
+
51
+ const suggest = models.find(m => /gpt-4o-mini/i.test(m))
52
+ ?? models.find(m => /flash/i.test(m))
53
+ ?? models.find(m => /mini|fast|small/i.test(m))
54
+ ?? models[0];
55
+ let model = '';
56
+ while (model === '') {
57
+ model = await ask('3. Model id' + (suggest ? ` (Enter = ${suggest})` : '') + ': ');
58
+ if (model === '' && suggest) model = suggest;
59
+ if (model === '') {
60
+ abortIfEnded();
61
+ console.log(red(' Model id is required.'));
62
+ }
63
+ }
64
+
65
+ console.log(dim(` Testing ${model}...`));
66
+ let saved = false;
67
+ while (!saved) {
68
+ try {
69
+ const reply = await testConnection({ baseUrl, apiKey, model });
70
+ console.log(green(' Works.') + dim(` Replied: ${trunc(reply, 40)}`));
71
+ saved = true;
72
+ } catch (err) {
73
+ console.log(red(' Test failed: ' + err.message));
74
+ const choice = await ask(' [r]etry key, [m]odel, [l]ist models, [b]ase url, or [s]ave anyway? ');
75
+ if (choice === '') abortIfEnded();
76
+ if (/^r/i.test(choice)) {
77
+ apiKey = await ask(' API key: ', { secret: true });
78
+ if (apiKey === '') abortIfEnded();
79
+ } else if (/^m/i.test(choice)) {
80
+ model = await ask(' Model id: ') || model;
81
+ console.log(dim(` Testing ${model}...`));
82
+ } else if (/^l/i.test(choice)) {
83
+ try {
84
+ const list = await fetchModels({ baseUrl, apiKey, model: 'x' });
85
+ if (list.length === 0) { console.log(yellow(' The server sent no list. Use [m] to type an id.')); continue; }
86
+ const show = list.slice(0, 15);
87
+ console.log(dim(` ${list.length} models available, showing ${show.length}:`));
88
+ show.forEach((m, i) => console.log(' ' + (i + 1) + '. ' + m));
89
+ const pick = await ask(' Number or full model id: ');
90
+ if (pick === '') abortIfEnded();
91
+ const n = Number(pick);
92
+ model = Number.isInteger(n) && n >= 1 && n <= show.length ? show[n - 1] : pick;
93
+ console.log(dim(` Testing ${model}...`));
94
+ } catch (listErr) {
95
+ console.log(red(' Could not list models: ' + listErr.message));
96
+ }
97
+ } else if (/^b/i.test(choice)) {
98
+ baseUrl = await ask(' API base URL: ') || baseUrl;
99
+ console.log(dim(' Testing again...'));
100
+ try { models = await fetchModels({ baseUrl, apiKey, model: 'x' }); } catch {}
101
+ } else if (/^s/i.test(choice)) {
102
+ saved = true;
103
+ }
104
+ }
105
+ }
106
+
107
+ const cfg = saveConfig({ baseUrl, apiKey, model });
108
+ console.log(green(' Saved to ~/.ineedcodes/config.json. You will not be asked again.'));
109
+ console.log('');
110
+ return cfg;
111
+ }