subconscious-cli 0.2.1 → 0.3.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.
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Runbook-compatible profiles stored independently of the installed package.
3
+ *
4
+ * Profiles use a small .env format so users can inspect or edit them directly:
5
+ * ~/.subconscious/profiles/default.env
6
+ * ~/.subconscious/profiles/<name>.env
7
+ */
8
+
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { readFileSync } from 'node:fs';
13
+ import readline from 'node:readline';
14
+ import { Writable } from 'node:stream';
15
+ import { c } from './colors.js';
16
+
17
+ const registry = JSON.parse(
18
+ readFileSync(new URL('./registry.generated.json', import.meta.url), 'utf-8'),
19
+ );
20
+
21
+ export const DEFAULT_PROFILE = 'default';
22
+ export const SUPPORTED_MODELS =
23
+ Array.isArray(registry.defaults.models) && registry.defaults.models.length
24
+ ? registry.defaults.models
25
+ : [registry.defaults.model];
26
+ const PREVIOUS_DEFAULT_GATEWAYS = new Set([
27
+ 'https://api.subconscious.dev',
28
+ 'https://api.subconscious.dev/',
29
+ ]);
30
+ const CONFIG_OVERRIDE = process.env.SUBC_CONFIG_DIR?.trim();
31
+ const CONFIG_DIR = CONFIG_OVERRIDE || path.join(os.homedir(), '.subconscious');
32
+ export const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles');
33
+ const LEGACY_PROFILES_DIR = CONFIG_OVERRIDE
34
+ ? null
35
+ : path.join(os.homedir(), '.subcon', 'profiles');
36
+
37
+ export const RUNBOOK_DEFAULTS = {
38
+ GATEWAY_URL: registry.defaults.baseUrl,
39
+ API_KEY: '',
40
+ MODEL: registry.defaults.model,
41
+ CLAUDE_GATEWAY_URL: '',
42
+ CLAUDE_CODE_API_KEY: '',
43
+ CODEX_API_KEY: '',
44
+ OPENCODE_API_KEY: '',
45
+ CURSOR_API_KEY: '',
46
+ COPILOT_API_KEY: '',
47
+ PI_API_KEY: '',
48
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000',
49
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: '3000000',
50
+ MAX_CONCURRENT_SUBAGENTS: '4',
51
+ MAX_SUBAGENT_SPAWN_DEPTH: '1',
52
+ CODEX_CONTEXT_WINDOW: '5000000',
53
+ CODEX_MAX_CONTEXT_WINDOW: '5000000',
54
+ CODEX_AUTO_COMPACT_TOKEN_LIMIT: '4500000',
55
+ CODEX_REASONING_EFFORT: 'max',
56
+ CODEX_EXTERNAL_TOOLS: 'false',
57
+ OPENCODE_CONTEXT_LIMIT: '5000000',
58
+ OPENCODE_OUTPUT_LIMIT: '65536',
59
+ PI_CONTEXT_WINDOW: '5000000',
60
+ PI_MAX_TOKENS: '65536',
61
+ COPILOT_MAX_INPUT_TOKENS: '5000000',
62
+ COPILOT_MAX_OUTPUT_TOKENS: '65536',
63
+ VSCODE_APP: '',
64
+ };
65
+
66
+ const SETTINGS = {
67
+ GATEWAY_URL: {
68
+ key: 'GATEWAY_URL',
69
+ label: 'Gateway URL',
70
+ description: 'Subconscious gateway origin shared by every integration.',
71
+ type: 'url',
72
+ required: true,
73
+ },
74
+ API_KEY: {
75
+ key: 'API_KEY',
76
+ label: 'Shared API key',
77
+ description: 'Default credential used when an agent-specific key is blank.',
78
+ type: 'secret',
79
+ },
80
+ MODEL: {
81
+ key: 'MODEL',
82
+ label: 'Default model',
83
+ description: 'Initial model used by coding-agent launches and setup.',
84
+ type: 'choice',
85
+ choices: SUPPORTED_MODELS,
86
+ },
87
+ CLAUDE_GATEWAY_URL: {
88
+ key: 'CLAUDE_GATEWAY_URL',
89
+ label: 'Claude gateway override',
90
+ description: 'Optional Claude-only gateway origin; blank uses GATEWAY_URL.',
91
+ type: 'url',
92
+ },
93
+ CLAUDE_CODE_API_KEY: {
94
+ key: 'CLAUDE_CODE_API_KEY',
95
+ label: 'Claude API key',
96
+ description: 'Claude-specific credential override; blank uses API_KEY.',
97
+ type: 'secret',
98
+ },
99
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: {
100
+ key: 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
101
+ label: 'Auto-compact window',
102
+ description: 'Claude token window before compaction (100000–1000000).',
103
+ type: 'integer',
104
+ min: 100000,
105
+ max: 1000000,
106
+ },
107
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: {
108
+ key: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
109
+ label: 'Maximum context tokens',
110
+ description: 'Maximum context advertised to Claude Code.',
111
+ type: 'integer',
112
+ min: 1,
113
+ },
114
+ MAX_CONCURRENT_SUBAGENTS: {
115
+ key: 'MAX_CONCURRENT_SUBAGENTS',
116
+ label: 'Concurrent subagents',
117
+ description: 'Maximum concurrent Claude/Codex subagent threads.',
118
+ type: 'integer',
119
+ min: 1,
120
+ },
121
+ MAX_SUBAGENT_SPAWN_DEPTH: {
122
+ key: 'MAX_SUBAGENT_SPAWN_DEPTH',
123
+ label: 'Subagent spawn depth',
124
+ description: 'Maximum Claude subagent nesting depth.',
125
+ type: 'integer',
126
+ min: 0,
127
+ },
128
+ CODEX_API_KEY: {
129
+ key: 'CODEX_API_KEY',
130
+ label: 'Codex API key',
131
+ description: 'Codex-specific credential override; blank uses API_KEY.',
132
+ type: 'secret',
133
+ },
134
+ CODEX_CONTEXT_WINDOW: {
135
+ key: 'CODEX_CONTEXT_WINDOW',
136
+ label: 'Context window',
137
+ description: 'Context window written into the Codex model catalog.',
138
+ type: 'integer',
139
+ min: 1,
140
+ },
141
+ CODEX_MAX_CONTEXT_WINDOW: {
142
+ key: 'CODEX_MAX_CONTEXT_WINDOW',
143
+ label: 'Maximum context window',
144
+ description: 'Maximum context window written into the Codex catalog.',
145
+ type: 'integer',
146
+ min: 1,
147
+ },
148
+ CODEX_AUTO_COMPACT_TOKEN_LIMIT: {
149
+ key: 'CODEX_AUTO_COMPACT_TOKEN_LIMIT',
150
+ label: 'Auto-compact token limit',
151
+ description: 'Codex token threshold that triggers automatic compaction.',
152
+ type: 'integer',
153
+ min: 1,
154
+ },
155
+ CODEX_REASONING_EFFORT: {
156
+ key: 'CODEX_REASONING_EFFORT',
157
+ label: 'Reasoning effort',
158
+ description: 'Default Codex reasoning effort.',
159
+ type: 'choice',
160
+ choices: ['none', 'low', 'medium', 'high', 'max'],
161
+ },
162
+ CODEX_EXTERNAL_TOOLS: {
163
+ key: 'CODEX_EXTERNAL_TOOLS',
164
+ label: 'External tools',
165
+ description: 'Enable Codex apps/plugins; may exceed the gateway tool limit.',
166
+ type: 'choice',
167
+ choices: ['false', 'true'],
168
+ },
169
+ OPENCODE_API_KEY: {
170
+ key: 'OPENCODE_API_KEY',
171
+ label: 'OpenCode API key',
172
+ description: 'OpenCode-specific credential override; blank uses API_KEY.',
173
+ type: 'secret',
174
+ },
175
+ OPENCODE_CONTEXT_LIMIT: {
176
+ key: 'OPENCODE_CONTEXT_LIMIT',
177
+ label: 'Context limit',
178
+ description: 'OpenCode provider context limit used for compaction.',
179
+ type: 'integer',
180
+ min: 1,
181
+ },
182
+ OPENCODE_OUTPUT_LIMIT: {
183
+ key: 'OPENCODE_OUTPUT_LIMIT',
184
+ label: 'Output limit',
185
+ description: 'Maximum output tokens advertised to OpenCode.',
186
+ type: 'integer',
187
+ min: 1,
188
+ },
189
+ CURSOR_API_KEY: {
190
+ key: 'CURSOR_API_KEY',
191
+ label: 'Cursor hook API key',
192
+ description: 'Cursor hook credential override; blank uses API_KEY.',
193
+ type: 'secret',
194
+ },
195
+ COPILOT_API_KEY: {
196
+ key: 'COPILOT_API_KEY',
197
+ label: 'Copilot hook API key',
198
+ description: 'Copilot hook credential override; blank uses API_KEY.',
199
+ type: 'secret',
200
+ },
201
+ COPILOT_MAX_INPUT_TOKENS: {
202
+ key: 'COPILOT_MAX_INPUT_TOKENS',
203
+ label: 'Maximum input tokens',
204
+ description: 'Input-token capacity advertised to VS Code.',
205
+ type: 'integer',
206
+ min: 1,
207
+ },
208
+ COPILOT_MAX_OUTPUT_TOKENS: {
209
+ key: 'COPILOT_MAX_OUTPUT_TOKENS',
210
+ label: 'Maximum output tokens',
211
+ description: 'Output-token capacity advertised to VS Code.',
212
+ type: 'integer',
213
+ min: 1,
214
+ },
215
+ VSCODE_APP: {
216
+ key: 'VSCODE_APP',
217
+ label: 'VS Code application',
218
+ description: 'Optional application override; blank enables auto-detection.',
219
+ type: 'choice',
220
+ choices: ['', 'Code', 'Code - Insiders', 'VSCodium'],
221
+ },
222
+ PI_API_KEY: {
223
+ key: 'PI_API_KEY',
224
+ label: 'Pi API key',
225
+ description: 'Pi-specific credential override; blank uses API_KEY.',
226
+ type: 'secret',
227
+ },
228
+ PI_CONTEXT_WINDOW: {
229
+ key: 'PI_CONTEXT_WINDOW',
230
+ label: 'Context window',
231
+ description: 'Pi context window used to determine compaction.',
232
+ type: 'integer',
233
+ min: 1,
234
+ },
235
+ PI_MAX_TOKENS: {
236
+ key: 'PI_MAX_TOKENS',
237
+ label: 'Maximum output tokens',
238
+ description: 'Maximum output tokens advertised to Pi.',
239
+ type: 'integer',
240
+ min: 1,
241
+ },
242
+ };
243
+
244
+ const AGENT_SETTING_KEYS = {
245
+ 'claude-code': [
246
+ 'CLAUDE_GATEWAY_URL',
247
+ 'CLAUDE_CODE_API_KEY',
248
+ 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
249
+ 'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
250
+ 'MAX_CONCURRENT_SUBAGENTS',
251
+ 'MAX_SUBAGENT_SPAWN_DEPTH',
252
+ ],
253
+ codex: [
254
+ 'CODEX_API_KEY',
255
+ 'CODEX_CONTEXT_WINDOW',
256
+ 'CODEX_MAX_CONTEXT_WINDOW',
257
+ 'CODEX_AUTO_COMPACT_TOKEN_LIMIT',
258
+ 'CODEX_REASONING_EFFORT',
259
+ 'CODEX_EXTERNAL_TOOLS',
260
+ 'MAX_CONCURRENT_SUBAGENTS',
261
+ ],
262
+ opencode: ['OPENCODE_API_KEY', 'OPENCODE_CONTEXT_LIMIT', 'OPENCODE_OUTPUT_LIMIT'],
263
+ cursor: ['CURSOR_API_KEY'],
264
+ copilot: [
265
+ 'COPILOT_API_KEY',
266
+ 'COPILOT_MAX_INPUT_TOKENS',
267
+ 'COPILOT_MAX_OUTPUT_TOKENS',
268
+ 'VSCODE_APP',
269
+ ],
270
+ pi: ['PI_API_KEY', 'PI_CONTEXT_WINDOW', 'PI_MAX_TOKENS'],
271
+ };
272
+
273
+ const GROUP_KEYS = [
274
+ { id: 'shared', label: 'Shared settings', keys: ['GATEWAY_URL', 'API_KEY', 'MODEL'] },
275
+ ...registry.agents
276
+ .filter((agent) => agent.cli !== false)
277
+ .map((agent) => ({
278
+ id: agent.id,
279
+ label: agent.name,
280
+ keys: AGENT_SETTING_KEYS[agent.id] || [],
281
+ })),
282
+ ];
283
+
284
+ export const PROFILE_SETTING_GROUPS = GROUP_KEYS.map((group) => ({
285
+ ...group,
286
+ settings: group.keys.map((key) => SETTINGS[key]),
287
+ }));
288
+
289
+ export function profileSettingsForAgent(agentId) {
290
+ const shared = PROFILE_SETTING_GROUPS.find((group) => group.id === 'shared');
291
+ const agent = PROFILE_SETTING_GROUPS.find((group) => group.id === agentId);
292
+ return [...(shared?.settings || []), ...(agent?.settings || [])];
293
+ }
294
+
295
+ export function resolvedProfileValues(profile) {
296
+ return { ...RUNBOOK_DEFAULTS, ...(profile?.values || {}) };
297
+ }
298
+
299
+ function profileTemplate() {
300
+ const modelComments = SUPPORTED_MODELS.map((model) => `# ${model}`).join('\n');
301
+ return `# Subconscious coding-agent profile
302
+ # Generated by subc. Values are shared by the packaged ol-runbook integrations.
303
+
304
+ GATEWAY_URL={GATEWAY_URL}
305
+ API_KEY={API_KEY}
306
+ # Available models:
307
+ ${modelComments}
308
+ MODEL={MODEL}
309
+
310
+ # Optional per-agent keys. Leave blank to use API_KEY above.
311
+ CLAUDE_GATEWAY_URL={CLAUDE_GATEWAY_URL}
312
+ CLAUDE_CODE_API_KEY={CLAUDE_CODE_API_KEY}
313
+ CODEX_API_KEY={CODEX_API_KEY}
314
+ OPENCODE_API_KEY={OPENCODE_API_KEY}
315
+ CURSOR_API_KEY={CURSOR_API_KEY}
316
+ COPILOT_API_KEY={COPILOT_API_KEY}
317
+ PI_API_KEY={PI_API_KEY}
318
+
319
+ # Claude Code
320
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW={CLAUDE_CODE_AUTO_COMPACT_WINDOW}
321
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS={CLAUDE_CODE_MAX_CONTEXT_TOKENS}
322
+ MAX_CONCURRENT_SUBAGENTS={MAX_CONCURRENT_SUBAGENTS}
323
+ MAX_SUBAGENT_SPAWN_DEPTH={MAX_SUBAGENT_SPAWN_DEPTH}
324
+
325
+ # Codex
326
+ CODEX_CONTEXT_WINDOW={CODEX_CONTEXT_WINDOW}
327
+ CODEX_MAX_CONTEXT_WINDOW={CODEX_MAX_CONTEXT_WINDOW}
328
+ CODEX_AUTO_COMPACT_TOKEN_LIMIT={CODEX_AUTO_COMPACT_TOKEN_LIMIT}
329
+ CODEX_REASONING_EFFORT={CODEX_REASONING_EFFORT}
330
+ CODEX_EXTERNAL_TOOLS={CODEX_EXTERNAL_TOOLS}
331
+
332
+ # OpenCode
333
+ OPENCODE_CONTEXT_LIMIT={OPENCODE_CONTEXT_LIMIT}
334
+ OPENCODE_OUTPUT_LIMIT={OPENCODE_OUTPUT_LIMIT}
335
+
336
+ # Pi
337
+ PI_CONTEXT_WINDOW={PI_CONTEXT_WINDOW}
338
+ PI_MAX_TOKENS={PI_MAX_TOKENS}
339
+
340
+ # GitHub Copilot
341
+ COPILOT_MAX_INPUT_TOKENS={COPILOT_MAX_INPUT_TOKENS}
342
+ COPILOT_MAX_OUTPUT_TOKENS={COPILOT_MAX_OUTPUT_TOKENS}
343
+ VSCODE_APP={VSCODE_APP}
344
+ `;
345
+ }
346
+
347
+ export function validateProfileName(name) {
348
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
349
+ throw new Error(`Invalid profile name '${name}' (use letters, digits, _ or -)`);
350
+ }
351
+ return name;
352
+ }
353
+
354
+ export function profilePath(name = DEFAULT_PROFILE) {
355
+ return path.join(PROFILES_DIR, `${validateProfileName(name)}.env`);
356
+ }
357
+
358
+ function decodeValue(raw) {
359
+ const value = raw.trim();
360
+ if (value.startsWith('"') && value.endsWith('"')) {
361
+ try {
362
+ return JSON.parse(value);
363
+ } catch {
364
+ return value.slice(1, -1);
365
+ }
366
+ }
367
+ if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
368
+ return value;
369
+ }
370
+
371
+ export function parseProfile(text) {
372
+ const values = {};
373
+ for (const line of text.split('\n')) {
374
+ const trimmed = line.trim();
375
+ if (!trimmed || trimmed.startsWith('#')) continue;
376
+ const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
377
+ if (!match) continue;
378
+ values[match[1]] = decodeValue(match[2]);
379
+ }
380
+ return values;
381
+ }
382
+
383
+ function encodeValue(value) {
384
+ const string = String(value ?? '');
385
+ if (string.includes('\n') || string.includes('\r')) {
386
+ throw new Error('Profile values cannot contain newlines');
387
+ }
388
+ return /^[A-Za-z0-9_./:@+-]*$/.test(string) ? string : JSON.stringify(string);
389
+ }
390
+
391
+ function renderTemplate(values) {
392
+ return profileTemplate().replace(/\{([A-Z0-9_]+)\}/g, (_, key) => encodeValue(values[key]));
393
+ }
394
+
395
+ function upsertValues(text, updates) {
396
+ const lines = text.replace(/\n$/, '').split('\n');
397
+ const remaining = new Map(Object.entries(updates));
398
+ const output = lines.map((line) => {
399
+ const match = line.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)=/);
400
+ if (!match || !remaining.has(match[2])) return line;
401
+ const value = remaining.get(match[2]);
402
+ remaining.delete(match[2]);
403
+ return `${match[1]}${match[2]}=${encodeValue(value)}`;
404
+ });
405
+ for (const [key, value] of remaining) output.push(`${key}=${encodeValue(value)}`);
406
+ return `${output.join('\n')}\n`;
407
+ }
408
+
409
+ function migrateDefaultGateway(text) {
410
+ const gateway = parseProfile(text).GATEWAY_URL;
411
+ return PREVIOUS_DEFAULT_GATEWAYS.has(gateway)
412
+ ? upsertValues(text, { GATEWAY_URL: RUNBOOK_DEFAULTS.GATEWAY_URL })
413
+ : text;
414
+ }
415
+
416
+ async function writeProfile(file, text) {
417
+ await fs.mkdir(PROFILES_DIR, { recursive: true });
418
+ await fs.writeFile(file, text, { encoding: 'utf-8', mode: 0o600 });
419
+ await fs.chmod(file, 0o600);
420
+ }
421
+
422
+ export async function loadProfile(name = DEFAULT_PROFILE) {
423
+ const file = profilePath(name);
424
+ try {
425
+ const text = await fs.readFile(file, 'utf-8');
426
+ const migratedText = migrateDefaultGateway(text);
427
+ if (migratedText !== text) await writeProfile(file, migratedText);
428
+ return { name, path: file, exists: true, values: parseProfile(migratedText) };
429
+ } catch (error) {
430
+ if (error.code !== 'ENOENT') throw error;
431
+ }
432
+
433
+ // Migrate an existing profile on first use, leaving the legacy copy intact.
434
+ if (LEGACY_PROFILES_DIR) {
435
+ try {
436
+ const text = migrateDefaultGateway(
437
+ await fs.readFile(path.join(LEGACY_PROFILES_DIR, `${name}.env`), 'utf-8'),
438
+ );
439
+ await writeProfile(file, text);
440
+ return { name, path: file, exists: true, values: parseProfile(text) };
441
+ } catch (error) {
442
+ if (error.code !== 'ENOENT') throw error;
443
+ }
444
+ }
445
+
446
+ return { name, path: file, exists: false, values: {} };
447
+ }
448
+
449
+ export async function ensureProfile(name = DEFAULT_PROFILE, apiKey) {
450
+ const profile = await loadProfile(name);
451
+ const values = { ...RUNBOOK_DEFAULTS };
452
+ if (apiKey !== undefined) values.API_KEY = apiKey;
453
+
454
+ const text = profile.exists
455
+ ? upsertValues(
456
+ await fs.readFile(profile.path, 'utf-8'),
457
+ Object.fromEntries(
458
+ Object.entries(values).filter(
459
+ ([key]) =>
460
+ profile.values[key] === undefined || (key === 'API_KEY' && apiKey !== undefined),
461
+ ),
462
+ ),
463
+ )
464
+ : renderTemplate(values);
465
+
466
+ await writeProfile(profile.path, text);
467
+ return loadProfile(name);
468
+ }
469
+
470
+ export async function updateProfile(name, updates) {
471
+ const profile = await ensureProfile(name);
472
+ const text = upsertValues(await fs.readFile(profile.path, 'utf-8'), updates);
473
+ await writeProfile(profile.path, text);
474
+ return loadProfile(name);
475
+ }
476
+
477
+ export async function clearProfileApiKey(name = DEFAULT_PROFILE) {
478
+ const profile = await loadProfile(name);
479
+ if (!profile.exists || !profile.values.API_KEY) return false;
480
+ await updateProfile(name, { API_KEY: '' });
481
+ return true;
482
+ }
483
+
484
+ function redact(key, value) {
485
+ if (!key.endsWith('API_KEY') || !value) return value;
486
+ return value.length <= 8 ? '********' : `${value.slice(0, 4)}…${value.slice(-4)}`;
487
+ }
488
+
489
+ export async function listProfiles() {
490
+ try {
491
+ const entries = await fs.readdir(PROFILES_DIR, { withFileTypes: true });
492
+ return entries
493
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.env'))
494
+ .map((entry) => entry.name.slice(0, -4))
495
+ .sort();
496
+ } catch (error) {
497
+ if (error.code === 'ENOENT') return [];
498
+ throw error;
499
+ }
500
+ }
501
+
502
+ function printProfile(profile) {
503
+ console.log(`\n ${c.bold}Profile: ${profile.name}${c.reset}`);
504
+ console.log(` ${c.dim}Path: ${profile.path}${c.reset}`);
505
+ if (!profile.exists) {
506
+ console.log(` ${c.yellow}Not configured.${c.reset}\n`);
507
+ return;
508
+ }
509
+ for (const key of ['GATEWAY_URL', 'API_KEY', 'MODEL']) {
510
+ console.log(` ${c.dim}${key.padEnd(12)}${c.reset}${redact(key, profile.values[key] || '')}`);
511
+ }
512
+ console.log();
513
+ }
514
+
515
+ function createPrompter() {
516
+ let muted = false;
517
+ let rejectPending = null;
518
+ const output = new Writable({
519
+ write(chunk, _encoding, callback) {
520
+ if (!muted) process.stdout.write(chunk);
521
+ callback();
522
+ },
523
+ });
524
+ const rl = readline.createInterface({ input: process.stdin, output, terminal: true });
525
+
526
+ rl.on('SIGINT', () => {
527
+ const error = new Error('Interactive configuration cancelled.');
528
+ error.code = 'SUBC_CANCELLED';
529
+ rejectPending?.(error);
530
+ });
531
+
532
+ return {
533
+ question(prompt, options = {}) {
534
+ return new Promise((resolve, reject) => {
535
+ rejectPending = reject;
536
+ if (options.secret) {
537
+ process.stdout.write(prompt);
538
+ muted = true;
539
+ }
540
+ rl.question(options.secret ? '' : prompt, (answer) => {
541
+ if (options.secret) {
542
+ muted = false;
543
+ process.stdout.write('\n');
544
+ }
545
+ rejectPending = null;
546
+ resolve(answer);
547
+ });
548
+ });
549
+ },
550
+ close() {
551
+ muted = false;
552
+ rejectPending = null;
553
+ rl.close();
554
+ },
555
+ };
556
+ }
557
+
558
+ export function validateSettingValue(setting, value) {
559
+ if (!value) {
560
+ if (setting.required) return `${setting.label} cannot be blank`;
561
+ return null;
562
+ }
563
+ if (setting.type === 'url') {
564
+ try {
565
+ const parsed = new URL(value);
566
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
567
+ return 'Enter a valid http:// or https:// URL';
568
+ }
569
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
570
+ return 'URL cannot include credentials, a query string, or a fragment';
571
+ }
572
+ } catch {
573
+ return 'Enter a valid http:// or https:// URL';
574
+ }
575
+ }
576
+ if (setting.type === 'integer') {
577
+ if (!/^\d+$/.test(value)) return 'Enter a whole number';
578
+ const number = Number(value);
579
+ if (!Number.isSafeInteger(number)) return 'Number is too large';
580
+ if (setting.min !== undefined && number < setting.min) {
581
+ return `Value must be at least ${setting.min}`;
582
+ }
583
+ if (setting.max !== undefined && number > setting.max) {
584
+ return `Value must be at most ${setting.max}`;
585
+ }
586
+ }
587
+ return null;
588
+ }
589
+
590
+ async function promptSetting(prompter, setting, current) {
591
+ console.log(`\n ${c.bold}${setting.label}${c.reset} ${c.dim}(${setting.key})${c.reset}`);
592
+ console.log(` ${c.dim}${setting.description}${c.reset}`);
593
+
594
+ if (setting.type === 'choice') {
595
+ setting.choices.forEach((choice, index) => {
596
+ const label = choice || '(auto-detect)';
597
+ const selected = choice === current ? ` ${c.green}current${c.reset}` : '';
598
+ console.log(` ${index + 1}. ${label}${selected}`);
599
+ });
600
+ while (true) {
601
+ const answer = (await prompter.question(' Select a number (Enter keeps current): ')).trim();
602
+ if (!answer) return current;
603
+ const index = Number(answer) - 1;
604
+ if (Number.isInteger(index) && setting.choices[index] !== undefined) {
605
+ return setting.choices[index];
606
+ }
607
+ if (setting.choices.includes(answer)) return answer;
608
+ console.log(` ${c.yellow}Choose one of the listed values.${c.reset}`);
609
+ }
610
+ }
611
+
612
+ const currentLabel = setting.type === 'secret' ? (current ? 'set' : 'not set') : current || 'blank';
613
+ const suffix = setting.required ? 'Enter keeps current' : 'Enter keeps current; - clears';
614
+ while (true) {
615
+ const answer = (
616
+ await prompter.question(
617
+ ` Value [${currentLabel}] (${suffix}): `,
618
+ { secret: setting.type === 'secret' },
619
+ )
620
+ ).trim();
621
+ if (!answer) return current;
622
+ const value = answer === '-' && !setting.required ? '' : answer;
623
+ const error = validateSettingValue(setting, value);
624
+ if (!error) return value;
625
+ console.log(` ${c.yellow}${error}.${c.reset}`);
626
+ }
627
+ }
628
+
629
+ function uniqueSettings(groups) {
630
+ const settings = new Map();
631
+ for (const group of groups) {
632
+ for (const setting of group.settings) settings.set(setting.key, setting);
633
+ }
634
+ return [...settings.values()];
635
+ }
636
+
637
+ async function interactiveConfig(profileName) {
638
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
639
+ throw new Error('Interactive settings require a terminal. Use subc config flags in scripts.');
640
+ }
641
+
642
+ const prompter = createPrompter();
643
+ try {
644
+ const profiles = await listProfiles();
645
+ console.log(`\n ${c.bold}Interactive coding-agent settings${c.reset}`);
646
+ if (profiles.length) {
647
+ console.log(` ${c.dim}Existing profiles: ${profiles.join(', ')}${c.reset}`);
648
+ }
649
+ const requestedName = (
650
+ await prompter.question(` Profile name [${profileName}]: `)
651
+ ).trim();
652
+ const targetName = validateProfileName(requestedName || profileName);
653
+ const profile = await loadProfile(targetName);
654
+ const baseValues = resolvedProfileValues(profile);
655
+ const updates = {};
656
+
657
+ while (true) {
658
+ console.log(`\n ${c.bold}Profile: ${targetName}${c.reset}`);
659
+ PROFILE_SETTING_GROUPS.forEach((group, index) => {
660
+ console.log(` ${index + 1}. ${group.label}`);
661
+ });
662
+ const allIndex = PROFILE_SETTING_GROUPS.length + 1;
663
+ const saveIndex = allIndex + 1;
664
+ console.log(` ${allIndex}. All settings`);
665
+ console.log(` ${saveIndex}. Save and exit`);
666
+ console.log(' 0. Cancel');
667
+ if (Object.keys(updates).length) {
668
+ console.log(` ${c.dim}${Object.keys(updates).length} pending change(s)${c.reset}`);
669
+ }
670
+
671
+ const answer = (await prompter.question(' Choose a section: ')).trim();
672
+ if (answer === '0') {
673
+ console.log(`\n ${c.dim}No settings were written.${c.reset}\n`);
674
+ return;
675
+ }
676
+ if (answer === String(saveIndex)) {
677
+ const confirmation = (
678
+ await prompter.question(` Save profile '${targetName}'? [Y/n] `)
679
+ ).trim().toLowerCase();
680
+ if (confirmation && !['y', 'yes'].includes(confirmation)) continue;
681
+ const saved = Object.keys(updates).length
682
+ ? await updateProfile(targetName, updates)
683
+ : await ensureProfile(targetName);
684
+ console.log(`\n ${c.green}${c.bold}✓ Saved profile '${targetName}'.${c.reset}`);
685
+ console.log(` ${c.dim}${saved.path}${c.reset}\n`);
686
+ return;
687
+ }
688
+
689
+ let selectedSettings;
690
+ if (answer === String(allIndex)) {
691
+ selectedSettings = uniqueSettings(PROFILE_SETTING_GROUPS);
692
+ } else {
693
+ const group = PROFILE_SETTING_GROUPS[Number(answer) - 1];
694
+ if (!group) {
695
+ console.log(` ${c.yellow}Choose a listed section.${c.reset}`);
696
+ continue;
697
+ }
698
+ selectedSettings = group.settings;
699
+ }
700
+
701
+ for (const setting of selectedSettings) {
702
+ const current = updates[setting.key] ?? baseValues[setting.key] ?? '';
703
+ const value = await promptSetting(prompter, setting, current);
704
+ if (value === (baseValues[setting.key] ?? '')) {
705
+ delete updates[setting.key];
706
+ } else if (value !== current) {
707
+ updates[setting.key] = value;
708
+ }
709
+ }
710
+ }
711
+ } catch (error) {
712
+ if (error.code === 'SUBC_CANCELLED') {
713
+ console.log(`\n ${c.dim}No settings were written.${c.reset}\n`);
714
+ return;
715
+ }
716
+ throw error;
717
+ } finally {
718
+ prompter.close();
719
+ }
720
+ }
721
+
722
+ export async function configCommand(argv, profileName = DEFAULT_PROFILE) {
723
+ let action = 'show';
724
+ const updates = {};
725
+
726
+ for (let i = 0; i < argv.length; i++) {
727
+ const arg = argv[i];
728
+ if (['show', 'path', 'list', 'delete', 'interactive'].includes(arg)) {
729
+ action = arg;
730
+ } else if (arg === '--gateway-url' || arg === '--api-key' || arg === '--model') {
731
+ const value = argv[++i];
732
+ if (!value) throw new Error(`${arg} requires a value`);
733
+ const key = {
734
+ '--gateway-url': 'GATEWAY_URL',
735
+ '--api-key': 'API_KEY',
736
+ '--model': 'MODEL',
737
+ }[arg];
738
+ updates[key] = value;
739
+ } else if (arg === '-h' || arg === '--help') {
740
+ console.log(`
741
+ Usage:
742
+ subc config [show|path|list|delete|interactive]
743
+ [--gateway-url URL] [--api-key KEY] [--model MODEL]
744
+ subc --profile NAME config [...]
745
+
746
+ Interactive wizard:
747
+ subc settings
748
+ subc --profile NAME config interactive
749
+ `);
750
+ return;
751
+ } else {
752
+ throw new Error(`Unknown config argument: ${arg}`);
753
+ }
754
+ }
755
+
756
+ if (action === 'interactive') {
757
+ await interactiveConfig(profileName);
758
+ return;
759
+ }
760
+
761
+ if (action === 'list') {
762
+ const profiles = await listProfiles();
763
+ if (!profiles.length) {
764
+ console.log(`\n ${c.dim}No profiles yet. Run subc login.${c.reset}\n`);
765
+ return;
766
+ }
767
+ console.log();
768
+ for (const name of profiles) console.log(` ${name} ${profilePath(name)}`);
769
+ console.log();
770
+ return;
771
+ }
772
+
773
+ if (action === 'path') {
774
+ console.log(profilePath(profileName));
775
+ return;
776
+ }
777
+
778
+ if (action === 'delete') {
779
+ if (profileName === DEFAULT_PROFILE) {
780
+ throw new Error('Refusing to delete the default profile; use subc logout to clear its key');
781
+ }
782
+ const profile = await loadProfile(profileName);
783
+ if (!profile.exists) {
784
+ console.log(`Profile '${profileName}' does not exist.`);
785
+ return;
786
+ }
787
+ await fs.unlink(profile.path);
788
+ console.log(`Deleted profile '${profileName}'.`);
789
+ return;
790
+ }
791
+
792
+ if (Object.keys(updates).length) {
793
+ const profile = await updateProfile(profileName, updates);
794
+ console.log(`Updated profile '${profileName}'.`);
795
+ printProfile(profile);
796
+ return;
797
+ }
798
+
799
+ printProfile(await loadProfile(profileName));
800
+ }
801
+
802
+ export function modelsCommand() {
803
+ console.log(`\n ${c.bold}Available models${c.reset}\n`);
804
+ for (const model of SUPPORTED_MODELS) {
805
+ const suffix = model === registry.defaults.model ? ` ${c.dim}(default)${c.reset}` : '';
806
+ console.log(` ${c.cyan}${model}${c.reset}${suffix}`);
807
+ }
808
+ console.log();
809
+ }
810
+
811
+ export async function updateUrlCommand(argv = [], options = {}) {
812
+ if (argv.length !== 1 || !argv[0]?.trim()) {
813
+ throw new Error('Usage: subc update-url <gateway-url>');
814
+ }
815
+
816
+ const gatewayUrl = argv[0].trim().replace(/\/+$/, '');
817
+ let parsed;
818
+ try {
819
+ parsed = new URL(gatewayUrl);
820
+ } catch {
821
+ throw new Error('Gateway URL must be a valid http:// or https:// URL');
822
+ }
823
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
824
+ throw new Error('Gateway URL must be a valid http:// or https:// URL');
825
+ }
826
+ if (parsed.username || parsed.password) {
827
+ throw new Error('Gateway URL cannot contain embedded credentials');
828
+ }
829
+ if (parsed.search || parsed.hash) {
830
+ throw new Error('Gateway URL cannot contain a query string or fragment');
831
+ }
832
+
833
+ const profileName = options.profileName || DEFAULT_PROFILE;
834
+ const profile = await updateProfile(profileName, { GATEWAY_URL: gatewayUrl });
835
+
836
+ console.log(`\n ${c.green}${c.bold}✓ Gateway URL updated.${c.reset}`);
837
+ console.log(` ${c.dim}Updated profile automatically: ${profile.path}${c.reset}`);
838
+ console.log(` ${c.dim}URL: ${gatewayUrl}${c.reset}`);
839
+ if (process.env.SUBCONSCIOUS_BASE_URL?.trim()) {
840
+ console.log(
841
+ `\n ${c.yellow}SUBCONSCIOUS_BASE_URL is set and will override this saved URL.${c.reset}`,
842
+ );
843
+ }
844
+ const claudeOverride =
845
+ process.env.CLAUDE_GATEWAY_URL?.trim() || profile.values.CLAUDE_GATEWAY_URL?.trim();
846
+ if (claudeOverride) {
847
+ console.log(
848
+ ` ${c.yellow}CLAUDE_GATEWAY_URL is set, so Claude Code will continue using ${claudeOverride}.${c.reset}`,
849
+ );
850
+ }
851
+ console.log();
852
+ }