subconscious-cli 4.0.0 → 4.0.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 CHANGED
@@ -10,6 +10,16 @@ subc login
10
10
  subc claude
11
11
  ```
12
12
 
13
+ Every interactive `subc` command checks npm for a newer CLI release. When an
14
+ update is available, a notice shows the installed and latest versions and lets
15
+ you select **Update now** or **Skip for now** with the arrow keys and Enter.
16
+ Each option describes what it will do, and the active option is highlighted.
17
+ Update runs `npm install -g subconscious-cli@latest`; Skip continues the
18
+ requested command.
19
+ Non-interactive commands automatically skip, and registry errors and timeouts
20
+ never block the requested command. Set `SUBC_DISABLE_UPDATE_CHECK=1` to suppress
21
+ the check in offline automation.
22
+
13
23
  Login creates both the saved credential and a ready-to-use `default` profile,
14
24
  so Claude Code, Codex, and OpenCode can launch immediately. Persistent editor
15
25
  and Pi integrations are installed per agent:
@@ -156,7 +166,9 @@ values. A command-line `--model` override has the highest model precedence.
156
166
 
157
167
  ## Models and endpoint overrides
158
168
 
159
- List the available models with `subc models`:
169
+ List the available models with `subc models`. The command fetches the active
170
+ gateway's authenticated `/v1/models` catalog and falls back to the models
171
+ packaged with the CLI when discovery is unavailable:
160
172
 
161
173
  ```text
162
174
  subconscious/glm-5.2 (default)
@@ -173,14 +185,19 @@ subc config --model subconscious/deepseek-v4-flash-marathon
173
185
  export SUBCONSCIOUS_MODEL=subconscious/glm-5.2
174
186
  ```
175
187
 
176
- The Claude Code, Codex, OpenCode, Pi, and Copilot integrations register all
177
- three models with their native model pickers. The profile's `MODEL` remains the
178
- active model where the agent supports setting one and is listed first in the
179
- other catalogs. Cursor requires adding the three model IDs in its OpenAI API
180
- Key Override settings; `subc cursor install` prints the complete list and the model
181
- selected by the active profile. Use the printed `/v1` Base URL in Cursor
182
- Settings; the profile itself stores the gateway origin so correlation hooks can
183
- post to `/v1/agent-hooks`.
188
+ Every launch and install fetches the same live catalog without caching. Codex,
189
+ OpenCode, Pi, Copilot, and Cursor receive the complete model list. The selected
190
+ profile model is listed first only while the gateway still advertises it.
191
+ OpenCode rebuilds its isolated runtime provider on every `subc opencode` launch,
192
+ so models left in older OpenCode configuration files do not leak into the list.
193
+ If a saved profile default has been removed, launches use the first live model;
194
+ an explicit `--model` or `SUBCONSCIOUS_MODEL` override is always preserved.
195
+ Claude Code exposes four native Opus/Sonnet/Haiku/Fable picker slots plus one
196
+ custom model option, so the CLI maps the first five models into those slots; any other
197
+ model can still be selected with `subc claude --model MODEL`. Cursor requires adding
198
+ the printed model IDs in its OpenAI API Key Override settings. Use the printed
199
+ `/v1` Base URL in Cursor Settings; the profile itself stores the gateway origin
200
+ so correlation hooks can post to `/v1/agent-hooks`.
184
201
 
185
202
  The default gateway is `https://api.subconscious.dev`. Profiles containing
186
203
  the former exact default (`https://api.subconscious.dev`) migrate automatically;
package/bin/agents.js CHANGED
@@ -21,13 +21,14 @@ import { fileURLToPath } from 'node:url';
21
21
  import { c } from './colors.js';
22
22
  import { getApiKey } from './auth.js';
23
23
  import { profileSettingsForAgent, resolvedProfileValues } from './profiles.js';
24
+ import { resolveModelCatalog } from './models.js';
24
25
 
25
26
  // --- Registry (single source of truth, generated copy shipped in the package).
26
27
  const registry = JSON.parse(
27
28
  readFileSync(new URL('./registry.generated.json', import.meta.url), 'utf-8'),
28
29
  );
29
30
  const DEFAULTS = registry.defaults;
30
- const SUPPORTED_MODELS =
31
+ const PACKAGED_MODELS =
31
32
  Array.isArray(DEFAULTS.models) && DEFAULTS.models.length
32
33
  ? DEFAULTS.models
33
34
  : [DEFAULTS.model];
@@ -312,8 +313,10 @@ function buildContext(apiKey, model, profile) {
312
313
  * Falls back to SUBCONSCIOUS_MODEL, then the registry default.
313
314
  */
314
315
  function extractModel(argv, profile) {
315
- let model =
316
- process.env.SUBCONSCIOUS_MODEL?.trim() || profile?.values?.MODEL?.trim() || DEFAULTS.model;
316
+ const environmentModel = process.env.SUBCONSCIOUS_MODEL?.trim();
317
+ const profileModel = profile?.values?.MODEL?.trim();
318
+ let model = environmentModel || profileModel || DEFAULTS.model;
319
+ let modelSource = environmentModel ? 'environment' : profileModel ? 'profile' : 'default';
317
320
  const rest = [];
318
321
  for (let i = 0; i < argv.length; i++) {
319
322
  const a = argv[i];
@@ -321,17 +324,19 @@ function extractModel(argv, profile) {
321
324
  const v = argv[i + 1];
322
325
  if (v && !v.startsWith('-')) {
323
326
  model = v;
327
+ modelSource = 'command';
324
328
  i++;
325
329
  }
326
330
  continue;
327
331
  }
328
332
  if (a.startsWith('--model=')) {
329
333
  model = a.slice('--model='.length);
334
+ modelSource = 'command';
330
335
  continue;
331
336
  }
332
337
  rest.push(a);
333
338
  }
334
- return { model, rest };
339
+ return { model, modelSource, rest };
335
340
  }
336
341
 
337
342
  /**
@@ -611,34 +616,102 @@ const CLAUDE_MODEL_PICKER_KEYS = [
611
616
  'ANTHROPIC_DEFAULT_HAIKU_MODEL',
612
617
  'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME',
613
618
  'ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION',
619
+ 'ANTHROPIC_DEFAULT_FABLE_MODEL',
620
+ 'ANTHROPIC_DEFAULT_FABLE_MODEL_NAME',
621
+ 'ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION',
622
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION',
623
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME',
624
+ 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION',
614
625
  ];
615
626
 
616
- function claudeModelPickerEnv(agent, ctx) {
627
+ function claudeModelPickerEnv(agent, ctx, models) {
617
628
  if (agent.id !== 'claude-code') return {};
618
629
  const configured = substitute(agent.env || {}, ctx);
630
+ const configuredModels = [
631
+ configured.ANTHROPIC_DEFAULT_OPUS_MODEL,
632
+ configured.ANTHROPIC_DEFAULT_SONNET_MODEL,
633
+ configured.ANTHROPIC_DEFAULT_HAIKU_MODEL,
634
+ configured.ANTHROPIC_DEFAULT_FABLE_MODEL,
635
+ configured.ANTHROPIC_CUSTOM_MODEL_OPTION,
636
+ ];
637
+ const pickerModels = [];
638
+ for (const model of [...models, ...configuredModels]) {
639
+ if (model && !pickerModels.includes(model)) pickerModels.push(model);
640
+ }
641
+ while (pickerModels.length < 3) pickerModels.push(pickerModels.at(-1) || ctx.model);
642
+
643
+ const roles = ['OPUS', 'SONNET', 'HAIKU', 'FABLE'];
644
+ const env = {};
645
+ for (let index = 0; index < roles.length; index++) {
646
+ const role = roles[index];
647
+ const model = pickerModels[index];
648
+ if (!model) continue;
649
+ env[`ANTHROPIC_DEFAULT_${role}_MODEL`] = model;
650
+ env[`ANTHROPIC_DEFAULT_${role}_MODEL_NAME`] = model;
651
+ env[`ANTHROPIC_DEFAULT_${role}_MODEL_DESCRIPTION`] = `Subconscious model ${model}`;
652
+ }
653
+ const customModel = pickerModels[4];
654
+ if (customModel) {
655
+ env.ANTHROPIC_CUSTOM_MODEL_OPTION = customModel;
656
+ env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = customModel;
657
+ env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = `Subconscious model ${customModel}`;
658
+ }
619
659
  return Object.fromEntries(
620
- CLAUDE_MODEL_PICKER_KEYS.map((key) => [key, configured[key]]).filter(([, value]) => value),
660
+ CLAUDE_MODEL_PICKER_KEYS.map((key) => [key, env[key]]).filter(([, value]) => value),
621
661
  );
622
662
  }
623
663
 
624
- export function runbookEnv(apiKey, model, binDir, profile, agent) {
664
+ export function runbookEnv(
665
+ apiKey,
666
+ model,
667
+ binDir,
668
+ profile,
669
+ agent,
670
+ models = PACKAGED_MODELS,
671
+ ) {
625
672
  const ctx = buildContext(apiKey, model, profile);
626
673
  const extraDirs = [binDir, ...candidateBinDirs()].filter(Boolean);
627
674
  const specificApiKey = agentApiKeySetting(agent)?.key;
628
675
  return {
629
- ...claudeModelPickerEnv(agent, ctx),
676
+ ...claudeModelPickerEnv(agent, ctx, models),
630
677
  ...(profile?.values || {}),
631
678
  ...process.env,
632
679
  GATEWAY_URL: ctx.baseUrl,
633
680
  API_KEY: apiKey,
634
681
  ...(specificApiKey ? { [specificApiKey]: apiKey } : {}),
635
682
  MODEL: model,
636
- SUBCONSCIOUS_MODELS: SUPPORTED_MODELS.join('\n'),
683
+ SUBCONSCIOUS_MODELS: models.join('\n'),
637
684
  SUBC_ENV_FILE: os.devNull,
638
685
  PATH: augmentPath(extraDirs),
639
686
  };
640
687
  }
641
688
 
689
+ async function resolvedModelsForLaunch(profile, apiKey, selectedModel) {
690
+ const ctx = buildContext(apiKey, selectedModel, profile);
691
+ const catalog = await resolveModelCatalog({
692
+ baseUrl: ctx.baseUrl,
693
+ apiKey,
694
+ selectedModel,
695
+ fallbackModels: PACKAGED_MODELS,
696
+ });
697
+ if (catalog.error) {
698
+ console.error(
699
+ ` ${c.yellow}Could not fetch the live model catalog; using packaged defaults.${c.reset}`,
700
+ );
701
+ console.error(` ${c.dim}${catalog.error.message}${c.reset}\n`);
702
+ }
703
+ return catalog;
704
+ }
705
+
706
+ export function selectLaunchModel(requestedModel, modelSource, catalog) {
707
+ const useLiveDefault =
708
+ catalog.source === 'gateway' &&
709
+ catalog.models.length > 0 &&
710
+ !catalog.models.includes(requestedModel) &&
711
+ (modelSource === 'profile' || modelSource === 'default');
712
+ return useLiveDefault ? catalog.models[0] : requestedModel;
713
+ }
714
+
642
715
  async function runRunbookSetup(agent, argv, profile, relativeScript = agent.runbook.script) {
643
716
  if (isSetupWithoutAuth(argv)) {
644
717
  return spawnRunbook(agent, argv, {
@@ -648,9 +721,16 @@ async function runRunbookSetup(agent, argv, profile, relativeScript = agent.runb
648
721
  }, relativeScript);
649
722
  }
650
723
 
651
- const { model, rest } = extractModel(argv, profile);
724
+ const { model: requestedModel, modelSource, rest } = extractModel(argv, profile);
652
725
  const apiKey = optionValue(rest, '--api-key') || (await requireApiKey(profile, agent));
653
726
  if (!apiKey) return 1;
727
+ const catalog = await resolvedModelsForLaunch(profile, apiKey, requestedModel);
728
+ const model = selectLaunchModel(requestedModel, modelSource, catalog);
729
+ if (model !== requestedModel) {
730
+ console.error(
731
+ ` ${c.yellow}Configured model ${requestedModel} is not in the live catalog; using ${model}.${c.reset}\n`,
732
+ );
733
+ }
654
734
  const ctx = buildContext(apiKey, model, profile);
655
735
  const authArgs = substitute(agent.runbook.authArgs || [], ctx);
656
736
 
@@ -660,7 +740,7 @@ async function runRunbookSetup(agent, argv, profile, relativeScript = agent.runb
660
740
  const code = await spawnRunbook(
661
741
  agent,
662
742
  [...authArgs, ...rest],
663
- runbookEnv(apiKey, model, undefined, profile, agent),
743
+ runbookEnv(apiKey, model, undefined, profile, agent, catalog.models),
664
744
  relativeScript,
665
745
  );
666
746
  const installed = !['status', 'uninstall'].includes(rest[0]);
@@ -708,17 +788,28 @@ export async function runAgent(agent, argv, options = {}) {
708
788
  return code;
709
789
  }
710
790
 
711
- const { model, rest } = extractModel(argv, profile);
791
+ const { model: requestedModel, modelSource, rest } = extractModel(argv, profile);
712
792
  const apiKey = await requireApiKey(profile, agent);
713
793
  if (!apiKey) return 1;
714
794
 
715
795
  const binDir = await ensureInstalled(agent);
796
+ const catalog = await resolvedModelsForLaunch(profile, apiKey, requestedModel);
797
+ const model = selectLaunchModel(requestedModel, modelSource, catalog);
798
+ if (model !== requestedModel) {
799
+ console.error(
800
+ ` ${c.yellow}Configured model ${requestedModel} is not in the live catalog; using ${model}.${c.reset}\n`,
801
+ );
802
+ }
716
803
 
717
804
  if (agent.runbook?.mode === 'launch') {
718
805
  console.log(
719
806
  ` ${c.dim}Launching ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}on Subconscious ${c.reset}${c.dim}(${model})${c.reset}\n`,
720
807
  );
721
- return spawnRunbook(agent, rest, runbookEnv(apiKey, model, binDir, profile, agent));
808
+ return spawnRunbook(
809
+ agent,
810
+ rest,
811
+ runbookEnv(apiKey, model, binDir, profile, agent, catalog.models),
812
+ );
722
813
  }
723
814
 
724
815
  const ctx = buildContext(apiKey, model, profile);
package/bin/cli.js CHANGED
@@ -15,6 +15,7 @@ import { renderBanner } from './branding.js';
15
15
  import {
16
16
  loginCommand,
17
17
  logoutCommand,
18
+ getApiKey,
18
19
  updateApiKeyCommand,
19
20
  whoamiCommand,
20
21
  } from './auth.js';
@@ -33,9 +34,13 @@ import {
33
34
  loadProfile,
34
35
  modelsCommand,
35
36
  printConfigHelp,
37
+ RUNBOOK_DEFAULTS,
38
+ SUPPORTED_MODELS as PACKAGED_MODELS,
36
39
  updateUrlCommand,
37
40
  validateProfileName,
38
41
  } from './profiles.js';
42
+ import { resolveModelCatalog } from './models.js';
43
+ import { showUpdateNotice } from './update-check.js';
39
44
 
40
45
  function isHelpArg(arg) {
41
46
  return arg === 'help' || arg === '-h' || arg === '--help';
@@ -220,6 +225,9 @@ function requireNamedProfile(profile) {
220
225
  }
221
226
 
222
227
  async function main() {
228
+ const update = await showUpdateNotice();
229
+ if (update?.action === 'updated' || update?.action === 'cancel') return;
230
+
223
231
  const parsed = extractProfile(process.argv.slice(2));
224
232
  const { args, profileName, profileExplicit } = parsed;
225
233
  const command = args[0];
@@ -285,7 +293,23 @@ async function main() {
285
293
  console.log(COMMAND_HELP.models);
286
294
  return;
287
295
  }
288
- modelsCommand();
296
+ const profile = await loadProfile(profileName);
297
+ const auth = await getApiKey(profile);
298
+ const selectedModel =
299
+ process.env.SUBCONSCIOUS_MODEL?.trim() ||
300
+ profile.values.MODEL?.trim() ||
301
+ RUNBOOK_DEFAULTS.MODEL;
302
+ const baseUrl =
303
+ process.env.SUBCONSCIOUS_BASE_URL?.trim() ||
304
+ profile.values.GATEWAY_URL?.trim() ||
305
+ RUNBOOK_DEFAULTS.GATEWAY_URL;
306
+ const catalog = await resolveModelCatalog({
307
+ baseUrl,
308
+ apiKey: auth?.key,
309
+ selectedModel,
310
+ fallbackModels: PACKAGED_MODELS,
311
+ });
312
+ modelsCommand(catalog.models, { selectedModel, error: catalog.error });
289
313
  return;
290
314
  }
291
315
 
package/bin/colors.js CHANGED
@@ -21,4 +21,5 @@ export const c = {
21
21
  yellow: ansi(33),
22
22
  magenta: ansi(35),
23
23
  underline: ansi(4),
24
+ inverse: ansi(7),
24
25
  };
package/bin/models.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Resolve the model catalog exposed by an OpenAI-compatible gateway.
3
+ *
4
+ * Live discovery is best-effort: launches should keep working with the
5
+ * packaged registry when the gateway is offline or does not expose /v1/models.
6
+ */
7
+
8
+ const MODEL_ID_PATTERN = /^[-A-Za-z0-9._:/+]+$/;
9
+ export const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 3000;
10
+
11
+ export function normalizeModelIds(modelIds = [], selectedModel) {
12
+ const models = [];
13
+ const seen = new Set();
14
+
15
+ for (const value of modelIds) {
16
+ const model = typeof value === 'string' ? value.trim() : '';
17
+ if (!model || !MODEL_ID_PATTERN.test(model) || seen.has(model)) continue;
18
+ seen.add(model);
19
+ models.push(model);
20
+ }
21
+
22
+ const selectedIndex = models.indexOf(selectedModel?.trim());
23
+ if (selectedIndex > 0) {
24
+ models.unshift(models.splice(selectedIndex, 1)[0]);
25
+ }
26
+
27
+ return models;
28
+ }
29
+
30
+ export async function fetchGatewayModels({
31
+ baseUrl,
32
+ apiKey,
33
+ fetchImpl = globalThis.fetch,
34
+ timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
35
+ }) {
36
+ if (!baseUrl?.trim()) throw new Error('Gateway URL is not configured');
37
+ if (!apiKey?.trim()) throw new Error('No API key is available for model discovery');
38
+ if (typeof fetchImpl !== 'function') {
39
+ throw new Error('This Node.js version does not support fetch');
40
+ }
41
+
42
+ const endpoint = `${baseUrl.trim().replace(/\/+$/, '')}/v1/models`;
43
+ const controller = new AbortController();
44
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
45
+ timeout.unref?.();
46
+
47
+ try {
48
+ const response = await fetchImpl(endpoint, {
49
+ method: 'GET',
50
+ headers: {
51
+ Accept: 'application/json',
52
+ Authorization: `Bearer ${apiKey.trim()}`,
53
+ 'Cache-Control': 'no-cache, no-store',
54
+ Pragma: 'no-cache',
55
+ },
56
+ cache: 'no-store',
57
+ signal: controller.signal,
58
+ });
59
+
60
+ if (!response.ok) {
61
+ throw new Error(`Model discovery returned HTTP ${response.status}`);
62
+ }
63
+
64
+ const payload = await response.json();
65
+ if (!Array.isArray(payload?.data)) {
66
+ throw new Error('Model discovery returned an invalid response');
67
+ }
68
+
69
+ const models = normalizeModelIds(payload.data.map((model) => model?.id));
70
+ if (!models.length) throw new Error('Model discovery returned no usable model IDs');
71
+ return models;
72
+ } catch (error) {
73
+ if (controller.signal.aborted) {
74
+ throw new Error(`Model discovery timed out after ${timeoutMs}ms`);
75
+ }
76
+ throw error;
77
+ } finally {
78
+ clearTimeout(timeout);
79
+ }
80
+ }
81
+
82
+ export async function resolveModelCatalog({
83
+ baseUrl,
84
+ apiKey,
85
+ selectedModel,
86
+ fallbackModels = [],
87
+ fetchImpl = globalThis.fetch,
88
+ timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
89
+ }) {
90
+ try {
91
+ const discovered = await fetchGatewayModels({ baseUrl, apiKey, fetchImpl, timeoutMs });
92
+ return {
93
+ models: normalizeModelIds(discovered, selectedModel),
94
+ source: 'gateway',
95
+ error: null,
96
+ };
97
+ } catch (error) {
98
+ return {
99
+ models: normalizeModelIds([selectedModel, ...fallbackModels], selectedModel),
100
+ source: 'packaged',
101
+ error,
102
+ };
103
+ }
104
+ }
package/bin/profiles.js CHANGED
@@ -415,6 +415,22 @@ function migrateDefaultGateway(text) {
415
415
  : text;
416
416
  }
417
417
 
418
+ function migrateCopilotTokenDefaults(text) {
419
+ const values = parseProfile(text);
420
+ const updates = {};
421
+ if (values.COPILOT_MAX_INPUT_TOKENS === '12288') {
422
+ updates.COPILOT_MAX_INPUT_TOKENS = RUNBOOK_DEFAULTS.COPILOT_MAX_INPUT_TOKENS;
423
+ }
424
+ if (values.COPILOT_MAX_OUTPUT_TOKENS === '4096') {
425
+ updates.COPILOT_MAX_OUTPUT_TOKENS = RUNBOOK_DEFAULTS.COPILOT_MAX_OUTPUT_TOKENS;
426
+ }
427
+ return Object.keys(updates).length ? upsertValues(text, updates) : text;
428
+ }
429
+
430
+ function migrateDefaults(text) {
431
+ return migrateCopilotTokenDefaults(migrateDefaultGateway(text));
432
+ }
433
+
418
434
  async function writeProfile(file, text) {
419
435
  await fs.mkdir(PROFILES_DIR, { recursive: true });
420
436
  await fs.writeFile(file, text, { encoding: 'utf-8', mode: 0o600 });
@@ -425,7 +441,7 @@ export async function loadProfile(name = DEFAULT_PROFILE) {
425
441
  const file = profilePath(name);
426
442
  try {
427
443
  const text = await fs.readFile(file, 'utf-8');
428
- const migratedText = migrateDefaultGateway(text);
444
+ const migratedText = migrateDefaults(text);
429
445
  if (migratedText !== text) await writeProfile(file, migratedText);
430
446
  return { name, path: file, exists: true, values: parseProfile(migratedText) };
431
447
  } catch (error) {
@@ -435,7 +451,7 @@ export async function loadProfile(name = DEFAULT_PROFILE) {
435
451
  // Migrate an existing profile on first use, leaving the legacy copy intact.
436
452
  if (LEGACY_PROFILES_DIR) {
437
453
  try {
438
- const text = migrateDefaultGateway(
454
+ const text = migrateDefaults(
439
455
  await fs.readFile(path.join(LEGACY_PROFILES_DIR, `${name}.env`), 'utf-8'),
440
456
  );
441
457
  await writeProfile(file, text);
@@ -729,12 +745,19 @@ export async function configCommand(argv, profileName = DEFAULT_PROFILE, options
729
745
  await printProfile(await loadProfile(profileName));
730
746
  }
731
747
 
732
- export function modelsCommand() {
748
+ export function modelsCommand(models = SUPPORTED_MODELS, options = {}) {
749
+ const selectedModel = options.selectedModel || registry.defaults.model;
733
750
  console.log(`\n ${c.bold}Available models${c.reset}\n`);
734
- for (const model of SUPPORTED_MODELS) {
735
- const suffix = model === registry.defaults.model ? ` ${c.dim}(default)${c.reset}` : '';
751
+ for (const model of models) {
752
+ const suffix = model === selectedModel ? ` ${c.dim}(default)${c.reset}` : '';
736
753
  console.log(` ${c.cyan}${model}${c.reset}${suffix}`);
737
754
  }
755
+ if (options.error) {
756
+ console.error(
757
+ `\n ${c.yellow}Could not fetch the live model catalog; showing packaged defaults.${c.reset}`,
758
+ );
759
+ console.error(` ${c.dim}${options.error.message}${c.reset}`);
760
+ }
738
761
  console.log();
739
762
  }
740
763
 
@@ -123,8 +123,8 @@ add_supported_model() {
123
123
  }
124
124
 
125
125
  # Keep the requested model first while exposing the complete Subconscious
126
- # catalog in Codex's /model picker. The CLI supplies this list from the registry;
127
- # the fallback keeps the vendored runbook useful on its own.
126
+ # catalog in Codex's /model picker. The CLI supplies the live gateway catalog;
127
+ # the packaged fallback keeps the vendored runbook useful on its own.
128
128
  add_supported_model "$MODEL"
129
129
  while IFS= read -r model_id; do
130
130
  add_supported_model "$model_id"
@@ -40,6 +40,7 @@
40
40
  # "vision": false,
41
41
  # "maxInputTokens": 5000000,
42
42
  # "maxOutputTokens": 65536,
43
+ # "thinking": true,
43
44
  # "streaming": true,
44
45
  # "requestHeaders": { "x-subconscious-client": "copilot" }
45
46
  # }
@@ -279,7 +280,12 @@ write_config() {
279
280
  local user_dir="$1"
280
281
  local models_json="${user_dir}/chatLanguageModels.json"
281
282
  local base_url="${GATEWAY_URL%/}"
282
- local chat_url="${base_url}/v1/chat/completions"
283
+ local chat_url
284
+ case "$base_url" in
285
+ */v1/chat/completions) chat_url="$base_url" ;;
286
+ */v1) chat_url="${base_url}/chat/completions" ;;
287
+ *) chat_url="${base_url}/v1/chat/completions" ;;
288
+ esac
283
289
 
284
290
  mkdir -p "$user_dir"
285
291
  local existing
@@ -302,6 +308,7 @@ write_config() {
302
308
  vision: false,
303
309
  maxInputTokens: $maxIn,
304
310
  maxOutputTokens: $maxOut,
311
+ thinking: true,
305
312
  streaming: true,
306
313
  requestHeaders: { "x-subconscious-client": "copilot" }
307
314
  }]')
@@ -89,12 +89,13 @@ if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then
89
89
  fi
90
90
 
91
91
  BASE_URL="${GATEWAY_URL%/}/v1"
92
+ RUNTIME_PROVIDER_ID="subconscious-cli"
92
93
 
93
94
  export SUBCONSCIOUS_API_KEY="$API_KEY"
94
95
  # Compaction reporting needs the plugin on disk; this launch path writes nothing.
95
96
  export SUBCONSCIOUS_GATEWAY_URL="${GATEWAY_URL%/}"
96
97
  export OPENCODE_CONFIG_CONTENT=$(cat <<EOF
97
- {"\$schema":"https://opencode.ai/config.json","provider":{"subconscious":{"npm":"@ai-sdk/openai-compatible","name":"Subconscious Gateway","options":{"baseURL":"${BASE_URL}","apiKey":"{env:SUBCONSCIOUS_API_KEY}","headers":{"x-subconscious-client":"opencode"}},"models":{${MODELS_JSON}}}},"model":"subconscious/${MODEL}"}
98
+ {"\$schema":"https://opencode.ai/config.json","disabled_providers":["subconscious"],"provider":{"${RUNTIME_PROVIDER_ID}":{"npm":"@ai-sdk/openai-compatible","name":"Subconscious Gateway","options":{"baseURL":"${BASE_URL}","apiKey":"{env:SUBCONSCIOUS_API_KEY}","headers":{"x-subconscious-client":"opencode"}},"models":{${MODELS_JSON}}}},"model":"${RUNTIME_PROVIDER_ID}/${MODEL}"}
98
99
  EOF
99
100
  )
100
101
 
@@ -0,0 +1,237 @@
1
+ import fs from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import readline from 'node:readline';
4
+ import { c } from './colors.js';
5
+
6
+ export const PACKAGE_NAME = 'subconscious-cli';
7
+ export const UPDATE_CHECK_TIMEOUT_MS = 1500;
8
+ const UPDATE_ACTIONS = ['Update now', 'Skip for now'];
9
+
10
+ function parseVersion(version) {
11
+ const match = String(version)
12
+ .trim()
13
+ .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
14
+ if (!match) return null;
15
+ return {
16
+ core: match.slice(1, 4).map(Number),
17
+ prerelease: match[4]?.split('.') || [],
18
+ };
19
+ }
20
+
21
+ function comparePrerelease(a, b) {
22
+ if (!a.length && !b.length) return 0;
23
+ if (!a.length) return 1;
24
+ if (!b.length) return -1;
25
+
26
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
27
+ if (a[index] === undefined) return -1;
28
+ if (b[index] === undefined) return 1;
29
+ if (a[index] === b[index]) continue;
30
+
31
+ const aNumber = /^\d+$/.test(a[index]) ? Number(a[index]) : null;
32
+ const bNumber = /^\d+$/.test(b[index]) ? Number(b[index]) : null;
33
+ if (aNumber !== null && bNumber !== null) return Math.sign(aNumber - bNumber);
34
+ if (aNumber !== null) return -1;
35
+ if (bNumber !== null) return 1;
36
+ return a[index].localeCompare(b[index]);
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ export function compareVersions(a, b) {
42
+ const parsedA = parseVersion(a);
43
+ const parsedB = parseVersion(b);
44
+ if (!parsedA || !parsedB) return 0;
45
+
46
+ for (let index = 0; index < parsedA.core.length; index++) {
47
+ if (parsedA.core[index] !== parsedB.core[index]) {
48
+ return Math.sign(parsedA.core[index] - parsedB.core[index]);
49
+ }
50
+ }
51
+ return comparePrerelease(parsedA.prerelease, parsedB.prerelease);
52
+ }
53
+
54
+ export async function currentVersion() {
55
+ const pkg = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8'));
56
+ return pkg.version;
57
+ }
58
+
59
+ export async function fetchLatestVersion(options = {}) {
60
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
61
+ const timeoutMs = options.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS;
62
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable');
63
+
64
+ const controller = new AbortController();
65
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
66
+ timeout.unref?.();
67
+
68
+ try {
69
+ const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
70
+ headers: {
71
+ Accept: 'application/json',
72
+ 'Cache-Control': 'no-cache, no-store',
73
+ Pragma: 'no-cache',
74
+ },
75
+ cache: 'no-store',
76
+ signal: controller.signal,
77
+ });
78
+ if (!response.ok) throw new Error(`npm returned HTTP ${response.status}`);
79
+ const payload = await response.json();
80
+ if (!parseVersion(payload?.version)) throw new Error('npm returned an invalid version');
81
+ return payload.version;
82
+ } finally {
83
+ clearTimeout(timeout);
84
+ }
85
+ }
86
+
87
+ export function renderUpdateNotice(installedVersion, latestVersion) {
88
+ const title = 'Subconscious CLI update available';
89
+ const versions = `${installedVersion} -> ${latestVersion}`;
90
+ const width = Math.max(title.length, versions.length);
91
+ const border = `+${'-'.repeat(width + 2)}+`;
92
+ const row = (text, style = '') =>
93
+ `${c.yellow}|${c.reset} ${style}${text.padEnd(width)}${c.reset} ${c.yellow}|${c.reset}`;
94
+
95
+ return [
96
+ ` ${c.yellow}${border}${c.reset}`,
97
+ ` ${row(title, c.bold)}`,
98
+ ` ${row(versions, c.cyan)}`,
99
+ ` ${c.yellow}${border}${c.reset}`,
100
+ ].join('\n');
101
+ }
102
+
103
+ export function renderUpdateOptions(selectedIndex = 0, versions = {}) {
104
+ const descriptions = [
105
+ `Runs \`npm install -g ${PACKAGE_NAME}@latest\``,
106
+ versions.installedVersion
107
+ ? `Continue this command with version ${versions.installedVersion}`
108
+ : 'Continue this command without updating',
109
+ ];
110
+
111
+ return UPDATE_ACTIONS.map((label, index) => {
112
+ const active = index === selectedIndex;
113
+ const pointer = active ? `${c.cyan}>${c.reset}` : ' ';
114
+ const option = active
115
+ ? `${c.inverse}${c.bold} ${label} ${c.reset}`
116
+ : ` ${label} `;
117
+ const descriptionStyle = active ? c.cyan : c.dim;
118
+ return ` ${pointer} ${option}\n ${descriptionStyle}${descriptions[index]}${c.reset}`;
119
+ }).join('\n');
120
+ }
121
+
122
+ export function moveUpdateSelection(selectedIndex, keyName) {
123
+ if (keyName === 'up') {
124
+ return (selectedIndex - 1 + UPDATE_ACTIONS.length) % UPDATE_ACTIONS.length;
125
+ }
126
+ if (keyName === 'down') return (selectedIndex + 1) % UPDATE_ACTIONS.length;
127
+ return selectedIndex;
128
+ }
129
+
130
+ export async function selectUpdateAction(options = {}) {
131
+ const input = options.input || process.stdin;
132
+ const output = options.output || process.stderr;
133
+ const initiallyRaw = input.isRaw === true;
134
+ let selectedIndex = options.selectedIndex ?? 0;
135
+ const instructions = ` ${c.dim}Use ↑/↓ to select • Enter to confirm${c.reset}`;
136
+ const render = () =>
137
+ `${renderUpdateOptions(selectedIndex, {
138
+ installedVersion: options.installedVersion,
139
+ latestVersion: options.latestVersion,
140
+ })}\n\n${instructions}`;
141
+ const renderedLineCount = render().split('\n').length;
142
+
143
+ readline.emitKeypressEvents(input);
144
+ input.setRawMode(true);
145
+ input.resume();
146
+ output.write(`\x1b[?25l${render()}`);
147
+
148
+ return new Promise((resolve) => {
149
+ const finish = (action) => {
150
+ input.off('keypress', onKeypress);
151
+ if (!initiallyRaw) input.setRawMode(false);
152
+ input.pause();
153
+ output.write('\n\x1b[?25h');
154
+ resolve(action);
155
+ };
156
+
157
+ const onKeypress = (_character, key = {}) => {
158
+ if (key.ctrl && key.name === 'c') {
159
+ process.exitCode = 130;
160
+ finish('cancel');
161
+ return;
162
+ }
163
+ if (key.name === 'return' || key.name === 'enter') {
164
+ finish(selectedIndex === 0 ? 'update' : 'skip');
165
+ return;
166
+ }
167
+
168
+ const nextIndex = moveUpdateSelection(selectedIndex, key.name);
169
+ if (nextIndex === selectedIndex) return;
170
+ selectedIndex = nextIndex;
171
+ output.write(`\r\x1b[${renderedLineCount - 1}A\x1b[0J${render()}`);
172
+ };
173
+
174
+ input.on('keypress', onKeypress);
175
+ });
176
+ }
177
+
178
+ export async function installLatest(options = {}) {
179
+ const spawnImpl = options.spawnImpl || spawn;
180
+ return new Promise((resolve) => {
181
+ const child = spawnImpl('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
182
+ stdio: 'inherit',
183
+ });
184
+ child.on('error', () => resolve(false));
185
+ child.on('exit', (code) => resolve(code === 0));
186
+ });
187
+ }
188
+
189
+ export async function showUpdateNotice(options = {}) {
190
+ const disabled =
191
+ options.disabled ?? process.env.SUBC_DISABLE_UPDATE_CHECK?.trim() === '1';
192
+ if (disabled) return null;
193
+
194
+ try {
195
+ const installedVersion = options.currentVersion || (await currentVersion());
196
+ const latestVersion =
197
+ options.latestVersion ||
198
+ (await fetchLatestVersion({
199
+ fetchImpl: options.fetchImpl,
200
+ timeoutMs: options.timeoutMs,
201
+ }));
202
+ if (compareVersions(latestVersion, installedVersion) <= 0) return null;
203
+
204
+ const notice = renderUpdateNotice(installedVersion, latestVersion);
205
+ const write = options.write || ((text) => process.stderr.write(text));
206
+ write(`\n${notice}\n\n`);
207
+
208
+ const interactive =
209
+ options.interactive ?? (process.stdin.isTTY === true && process.stderr.isTTY === true);
210
+ if (!interactive) return { installedVersion, latestVersion, action: 'skip' };
211
+
212
+ const select = options.select || selectUpdateAction;
213
+ const action = await select({ installedVersion, latestVersion });
214
+ if (action === 'cancel') return { installedVersion, latestVersion, action };
215
+ if (action === 'skip') {
216
+ write(`\n ${c.dim}Skipping update.${c.reset}\n\n`);
217
+ return { installedVersion, latestVersion, action };
218
+ }
219
+
220
+ write(`\n ${c.cyan}Updating ${PACKAGE_NAME} to ${latestVersion}...${c.reset}\n\n`);
221
+ const install = options.install || installLatest;
222
+ const installed = await install();
223
+ if (installed) {
224
+ write(
225
+ `\n ${c.green}${c.bold}Update complete.${c.reset} Re-run your subc command.\n\n`,
226
+ );
227
+ return { installedVersion, latestVersion, action: 'updated' };
228
+ }
229
+
230
+ write(`\n ${c.red}Update failed.${c.reset} Try it manually:\n\n`);
231
+ write(` ${c.cyan}npm install -g ${PACKAGE_NAME}@latest${c.reset}\n\n`);
232
+ return { installedVersion, latestVersion, action: 'failed' };
233
+ } catch {
234
+ // Update discovery must never prevent the requested command from running.
235
+ return null;
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "subconscious-cli",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "CLI for Subconscious — run Claude Code, Codex, OpenCode, Cursor, Copilot, and Pi",
5
5
  "bin": {
6
- "subc": "./bin/cli.js"
6
+ "subc": "bin/cli.js"
7
7
  },
8
8
  "scripts": {
9
9
  "test": "node --test"
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://github.com/subconscious-systems/subconscious.git",
20
+ "url": "git+https://github.com/subconscious-systems/subconscious.git",
21
21
  "directory": "cli"
22
22
  },
23
23
  "keywords": [