subconscious-cli 6.0.0 → 6.1.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.
Files changed (76) hide show
  1. package/README.md +135 -2
  2. package/bin/agent-data.js +242 -0
  3. package/bin/agent-help.js +106 -0
  4. package/bin/agent-install.js +354 -0
  5. package/bin/agent-launch.js +199 -0
  6. package/bin/agent-spawn.js +137 -0
  7. package/bin/agents.js +284 -840
  8. package/bin/auth.js +89 -33
  9. package/bin/branding.js +3 -1
  10. package/bin/claude-picker.js +143 -0
  11. package/bin/cli.js +87 -20
  12. package/bin/clipboard-image.js +24 -5
  13. package/bin/colors.js +1 -1
  14. package/bin/feedback.js +71 -21
  15. package/bin/harness-manifest.js +286 -0
  16. package/bin/headless-args.js +35 -0
  17. package/bin/launch-values.js +25 -0
  18. package/bin/model-capabilities.js +12 -8
  19. package/bin/models.js +35 -19
  20. package/bin/native/subc-tui-darwin-amd64 +0 -0
  21. package/bin/native/subc-tui-darwin-arm64 +0 -0
  22. package/bin/native/subc-tui-linux-amd64 +0 -0
  23. package/bin/native/subc-tui-linux-arm64 +0 -0
  24. package/bin/native/subc-tui-windows-amd64.exe +0 -0
  25. package/bin/native/subc-tui-windows-arm64.exe +0 -0
  26. package/bin/opencode-provider.js +48 -5
  27. package/bin/profile-settings.js +129 -0
  28. package/bin/profiles.js +136 -449
  29. package/bin/runbook/README.md +66 -23
  30. package/bin/runbook/claude-code/agent.json +300 -0
  31. package/bin/runbook/claude-code/install.sh +9 -252
  32. package/bin/runbook/codex/agent.json +461 -0
  33. package/bin/runbook/codex/hooks-lib.sh +17 -4
  34. package/bin/runbook/codex/install.sh +0 -4
  35. package/bin/runbook/codex/run.sh +16 -235
  36. package/bin/runbook/copilot/agent.json +185 -0
  37. package/bin/runbook/copilot/install.sh +45 -63
  38. package/bin/runbook/cursor/agent.json +137 -0
  39. package/bin/runbook/cursor/install.sh +8 -35
  40. package/bin/runbook/deepseek-harness/agent.json +163 -0
  41. package/bin/runbook/deepseek-harness/run.sh +20 -59
  42. package/bin/runbook/image-window/index.ts +14 -0
  43. package/bin/runbook/image-window/window.d.ts +19 -0
  44. package/bin/runbook/image-window/window.js +140 -0
  45. package/bin/runbook/lib.sh +41 -0
  46. package/bin/runbook/opencode/agent.json +158 -0
  47. package/bin/runbook/opencode/install.sh +9 -183
  48. package/bin/runbook/opencode/opencode-plugin.d.ts +16 -0
  49. package/bin/runbook/opencode/subconscious-image-window.ts +24 -0
  50. package/bin/runbook/pi/agent.json +216 -0
  51. package/bin/runbook/pi/install.sh +18 -44
  52. package/bin/runbook/pi/run.sh +6 -18
  53. package/bin/runbook/pi/subconscious-compaction.ts +71 -49
  54. package/bin/runbook/shared.json +58 -0
  55. package/bin/runbook/subconscious-code/agent.json +156 -0
  56. package/bin/sessions.js +173 -58
  57. package/bin/tui.js +104 -32
  58. package/bin/update-check.js +54 -21
  59. package/bin/upgrade.js +67 -43
  60. package/bin/usage.js +47 -24
  61. package/bin/windows/agents.js +141 -37
  62. package/bin/windows/common.js +36 -25
  63. package/bin/windows/install.js +116 -34
  64. package/bin/windows/launch.js +260 -77
  65. package/bin/windows/pi-compaction.ts +68 -13
  66. package/bin/windows/process.js +210 -57
  67. package/bin/windows/setup.js +251 -56
  68. package/package.json +9 -4
  69. package/bin/registry.generated.json +0 -406
  70. package/bin/runbook/claude-code/run.sh +0 -254
  71. package/bin/runbook/model-capabilities.generated.sh +0 -9
  72. package/bin/runbook/opencode/provider.sh +0 -93
  73. package/bin/runbook/opencode/run.sh +0 -99
  74. package/bin/runbook/opencode/subconscious-compaction.ts +0 -100
  75. package/bin/runbook/subconscious-code/run.sh +0 -21
  76. package/bin/windows/claude.js +0 -71
package/bin/feedback.js CHANGED
@@ -9,14 +9,25 @@ import fs from 'node:fs/promises';
9
9
  import os from 'node:os';
10
10
  import readline from 'node:readline';
11
11
  import { createInterface } from 'node:readline/promises';
12
- import { c } from './colors.js';
13
12
  import { getApiKey, getPlatformUrl } from './auth.js';
14
13
  import { imageContentType, readClipboardImage } from './clipboard-image.js';
14
+ import { c } from './colors.js';
15
15
  import { DEFAULT_PROFILE } from './profiles.js';
16
16
 
17
17
  export const FEEDBACK_API_PATH = '/api/cli/feedback';
18
18
  export const SUPPORT_EMAIL = 'support@subconscious.dev';
19
19
 
20
+ function feedbackOptionValue(argv, index) {
21
+ const value = argv[index + 1];
22
+ if (
23
+ value === undefined ||
24
+ /^(?:--[A-Za-z][\w-]*|-[A-Za-z])(?:=|$)/.test(value)
25
+ ) {
26
+ throw new Error(`${argv[index]} requires a value`);
27
+ }
28
+ return value;
29
+ }
30
+
20
31
  /**
21
32
  * Parses feedback command arguments. Shared with tests.
22
33
  *
@@ -31,15 +42,15 @@ export function parseFeedbackArgs(argv = []) {
31
42
  for (let i = 0; i < argv.length; i++) {
32
43
  const arg = argv[i];
33
44
  if (arg === '-s' || arg === '--subject') {
34
- subject = argv[++i] ?? '';
45
+ subject = feedbackOptionValue(argv, i++);
35
46
  } else if (arg.startsWith('--subject=')) {
36
47
  subject = arg.slice('--subject='.length);
37
48
  } else if (arg === '-m' || arg === '--message') {
38
- message = argv[++i] ?? '';
49
+ message = feedbackOptionValue(argv, i++);
39
50
  } else if (arg.startsWith('--message=')) {
40
51
  message = arg.slice('--message='.length);
41
52
  } else if (arg === '--image') {
42
- images.push(argv[++i] ?? '');
53
+ images.push(feedbackOptionValue(argv, i++));
43
54
  } else if (arg.startsWith('--image=')) {
44
55
  images.push(arg.slice('--image='.length));
45
56
  } else if (arg !== '') {
@@ -64,12 +75,19 @@ export async function collectFeedbackContext(options = {}) {
64
75
  };
65
76
  }
66
77
 
67
- export function buildFeedbackPayload({ subject, message, context, attachments }) {
78
+ export function buildFeedbackPayload({
79
+ subject,
80
+ message,
81
+ context,
82
+ attachments,
83
+ }) {
68
84
  const payload = {
69
85
  subject: subject || 'CLI feedback',
70
86
  message,
71
87
  context: Object.fromEntries(
72
- Object.entries(context ?? {}).filter(([, value]) => Boolean(String(value).trim())),
88
+ Object.entries(context ?? {}).filter(([, value]) =>
89
+ Boolean(String(value).trim()),
90
+ ),
73
91
  ),
74
92
  };
75
93
  if (attachments?.length) payload.attachments = attachments;
@@ -79,7 +97,8 @@ export function buildFeedbackPayload({ subject, message, context, attachments })
79
97
  export async function loadFeedbackImages(paths) {
80
98
  const attachments = [];
81
99
  for (const filePath of paths) {
82
- if (!filePath?.trim()) throw new Error('An image path is required after --image');
100
+ if (!filePath?.trim())
101
+ throw new Error('An image path is required after --image');
83
102
  const buffer = await fs.readFile(filePath);
84
103
  const contentType = imageContentType(buffer);
85
104
  if (!contentType) throw new Error(`${filePath} is not a JPEG or PNG`);
@@ -88,7 +107,12 @@ export async function loadFeedbackImages(paths) {
88
107
  return attachments;
89
108
  }
90
109
 
91
- export async function submitFeedback(apiKey, platformUrl, payload, fetchImpl = fetch) {
110
+ export async function submitFeedback(
111
+ apiKey,
112
+ platformUrl,
113
+ payload,
114
+ fetchImpl = fetch,
115
+ ) {
92
116
  const res = await fetchImpl(
93
117
  `${platformUrl.replace(/\/$/, '')}${FEEDBACK_API_PATH}`,
94
118
  {
@@ -164,7 +188,9 @@ function readAttachmentLine(onPaste) {
164
188
  async function promptForAttachments(already = 0) {
165
189
  if (process.stdin.isTTY !== true || already >= MAX_FEEDBACK_IMAGES) return [];
166
190
  const attachments = [];
167
- console.log(`\n Attachments (optional). Ctrl+V pastes a screenshot from the clipboard.`);
191
+ console.log(
192
+ `\n Attachments (optional). Ctrl+V pastes a screenshot from the clipboard.`,
193
+ );
168
194
  console.log(` ${c.dim}Press Enter when you are done.${c.reset}`);
169
195
  while (already + attachments.length < MAX_FEEDBACK_IMAGES) {
170
196
  const line = await readAttachmentLine(async () => {
@@ -172,11 +198,15 @@ async function promptForAttachments(already = 0) {
172
198
  const buffer = await readClipboardImage();
173
199
  const contentType = buffer ? imageContentType(buffer) : null;
174
200
  if (!contentType || !buffer) {
175
- console.log(`\n ${c.yellow}No JPEG or PNG image on the clipboard.${c.reset}`);
201
+ console.log(
202
+ `\n ${c.yellow}No JPEG or PNG image on the clipboard.${c.reset}`,
203
+ );
176
204
  return;
177
205
  }
178
206
  attachments.push({ contentType, data: buffer.toString('base64') });
179
- console.log(` ${c.green}Added screenshot ${already + attachments.length}.${c.reset}`);
207
+ console.log(
208
+ ` ${c.green}Added screenshot ${already + attachments.length}.${c.reset}`,
209
+ );
180
210
  });
181
211
  if (line == null || !line.trim()) break;
182
212
  }
@@ -186,7 +216,8 @@ async function promptForAttachments(already = 0) {
186
216
  async function promptForFeedback(argv) {
187
217
  const rl = createInterface({ input: process.stdin, output: process.stdout });
188
218
  try {
189
- const subject = argv.subject || (await rl.question(`\n Subject (optional): `)).trim();
219
+ const subject =
220
+ argv.subject || (await rl.question(`\n Subject (optional): `)).trim();
190
221
  let message = '';
191
222
  while (!message) {
192
223
  message = (await rl.question(` Message: `)).trim();
@@ -208,7 +239,9 @@ export async function feedbackCommand(argv = [], options = {}) {
208
239
  const auth = await getApiKey(options.profile);
209
240
  if (!auth) {
210
241
  console.log(`\n ${c.dim}Not logged in.${c.reset}`);
211
- console.log(` Run ${c.cyan}subc ${profileFlag}login${c.reset} to get started.\n`);
242
+ console.log(
243
+ ` Run ${c.cyan}subc ${profileFlag}login${c.reset} to get started.\n`,
244
+ );
212
245
  return;
213
246
  }
214
247
 
@@ -216,7 +249,9 @@ export async function feedbackCommand(argv = [], options = {}) {
216
249
  if (!feedback.message) {
217
250
  if (process.stdin.isTTY !== true) {
218
251
  console.error(`\n ${c.red}A message is required.${c.reset}`);
219
- console.error(` Pass one with ${c.cyan}subc feedback --message "..."${c.reset}\n`);
252
+ console.error(
253
+ ` Pass one with ${c.cyan}subc feedback --message "..."${c.reset}\n`,
254
+ );
220
255
  process.exitCode = 1;
221
256
  return;
222
257
  }
@@ -224,7 +259,10 @@ export async function feedbackCommand(argv = [], options = {}) {
224
259
  }
225
260
 
226
261
  const fromFlags = await loadFeedbackImages(parsed.images);
227
- const fromPrompt = feedback.message && parsed.message ? [] : await promptForAttachments(fromFlags.length);
262
+ const fromPrompt =
263
+ feedback.message && parsed.message
264
+ ? []
265
+ : await promptForAttachments(fromFlags.length);
228
266
  const platformUrl = getPlatformUrl(options.profile);
229
267
  const payload = buildFeedbackPayload({
230
268
  subject: feedback.subject,
@@ -237,9 +275,13 @@ export async function feedbackCommand(argv = [], options = {}) {
237
275
  const res = await submitFeedback(auth.key, platformUrl, payload);
238
276
 
239
277
  if (res.status === 404) {
240
- console.error(`\n ${c.yellow}This platform has no feedback endpoint (404).${c.reset}`);
278
+ console.error(
279
+ `\n ${c.yellow}This platform has no feedback endpoint (404).${c.reset}`,
280
+ );
241
281
  console.error(` ${c.dim}Host: ${platformUrl}${c.reset}`);
242
- console.error(` ${c.dim}Point PLATFORM_URL at a platform that includes /api/cli/feedback.${c.reset}\n`);
282
+ console.error(
283
+ ` ${c.dim}Point PLATFORM_URL at a platform that includes /api/cli/feedback.${c.reset}\n`,
284
+ );
243
285
  process.exitCode = 1;
244
286
  return;
245
287
  }
@@ -253,14 +295,18 @@ export async function feedbackCommand(argv = [], options = {}) {
253
295
 
254
296
  if (!res.ok) {
255
297
  const detail = typeof data?.error === 'string' ? data.error : '';
256
- const invalidKey = detail === 'Invalid or revoked API key' || detail === 'No API key provided';
298
+ const invalidKey =
299
+ detail === 'Invalid or revoked API key' ||
300
+ detail === 'No API key provided';
257
301
  console.log(`\n ${c.red}✗ Couldn't send your message.${c.reset}`);
258
302
  if (invalidKey) {
259
303
  console.log(
260
304
  ` Run ${c.cyan}subc ${profileFlag}logout${c.reset} then ${c.cyan}subc ${profileFlag}login${c.reset} to re-authenticate.`,
261
305
  );
262
306
  }
263
- console.log(` Please email ${c.cyan}${SUPPORT_EMAIL}${c.reset} directly.\n`);
307
+ console.log(
308
+ ` Please email ${c.cyan}${SUPPORT_EMAIL}${c.reset} directly.\n`,
309
+ );
264
310
  process.exitCode = 1;
265
311
  return;
266
312
  }
@@ -269,12 +315,16 @@ export async function feedbackCommand(argv = [], options = {}) {
269
315
  console.log(` ${c.dim}Our support team will get back to you.\n`);
270
316
  } catch (error) {
271
317
  if (error.name === 'TimeoutError' || error.name === 'AbortError') {
272
- console.log(`\n ${c.yellow}Could not reach the platform (timed out).${c.reset}`);
318
+ console.log(
319
+ `\n ${c.yellow}Could not reach the platform (timed out).${c.reset}`,
320
+ );
273
321
  console.log(` ${c.dim}Host: ${platformUrl}${c.reset}`);
274
322
  } else {
275
323
  console.log(`\n ${c.red}✗ Couldn't send your message.${c.reset}`);
276
324
  }
277
- console.log(` Please email ${c.cyan}${SUPPORT_EMAIL}${c.reset} directly.\n`);
325
+ console.log(
326
+ ` Please email ${c.cyan}${SUPPORT_EMAIL}${c.reset} directly.\n`,
327
+ );
278
328
  process.exitCode = 1;
279
329
  }
280
330
  }
@@ -0,0 +1,286 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ AGENTS,
5
+ agentBinary,
6
+ agentCommandName,
7
+ agentInputs,
8
+ installCommands,
9
+ RUNBOOK_DIR,
10
+ resolveRefs,
11
+ staticLookup,
12
+ } from './agent-data.js';
13
+ import { RUNBOOK_ENV_DESCRIPTION } from './agents.js';
14
+ import { CLAUDE_PICKER_ENV_DESCRIPTION } from './claude-picker.js';
15
+ import { c } from './colors.js';
16
+ import { buildOpenCodeConfig } from './opencode-provider.js';
17
+
18
+ // Bump on any change that can break a reader: a renamed or removed field, or a
19
+ // changed meaning. Adding a field does not need a bump.
20
+ export const HARNESS_MANIFEST_SCHEMA_VERSION = 1;
21
+
22
+ export const MANIFEST_TOKENS = {
23
+ '{baseUrl}': 'Gateway origin, without a trailing slash.',
24
+ '{baseUrlV1}': 'Gateway origin followed by /v1.',
25
+ '{apiKey}': "The agent's API key input when set, otherwise API_KEY.",
26
+ '{model}': 'Launch model ID.',
27
+ '${NAME}':
28
+ 'Value of env var NAME at launch. The manifest replaces it with the default when NAME is an input or env entry; a reference followed by / drops trailing slashes.',
29
+ '{catalog}': 'Every live catalog model ID, newline-separated.',
30
+ '{catalog[N]}':
31
+ 'Catalog model at index N, clamped to the last entry; {model} when the catalog is empty.',
32
+ '{args}': 'Arguments passed through from subc, after its own flags.',
33
+ '{config}':
34
+ 'config_flag NAME=VALUE for each cli-config entry whose condition holds.',
35
+ '{prompt}': 'The task given to subc <agent> headless PROMPT.',
36
+ '{sessionId}': 'A local session ID, for launch.resume.',
37
+ '{sessionFile}': "A local session's file, or its ID when it has none.",
38
+ '{tempFile}':
39
+ 'Temporary file the runbook writes; the matching config entry says whether it is removed.',
40
+ '{tmp}': 'System temporary directory.',
41
+ '{opencodeConfig}':
42
+ 'The OpenCode config document shown in the env-json config entry.',
43
+ '{claudeSettings}': 'JSON document described by the --settings config entry.',
44
+ '{target}':
45
+ 'Release target triple, one of install.targets or install.windows_targets.',
46
+ '{binDir}': "Directory the agent's binary was found in.",
47
+ '{PATH}': "The caller's PATH.",
48
+ '<install dirs>': 'Common install directories subc adds to PATH.',
49
+ '<agent key>': 'The agent-specific API key input, for example CODEX_API_KEY.',
50
+ '<id>': 'Any model ID in the catalog; the field repeats for each model.',
51
+ 'models[]': 'Every element of the models array.',
52
+ '<runbook>': 'Absolute path of the installed bin/runbook directory.',
53
+ '<VS Code user dir>':
54
+ 'The VS Code user settings directory, for example ~/Library/Application Support/Code/User.',
55
+ };
56
+
57
+ const PACKAGE_URL = new URL('../package.json', import.meta.url);
58
+
59
+ function conditionText(when) {
60
+ return Object.entries(when)
61
+ .map(([name, test]) =>
62
+ test === 'set'
63
+ ? `${name} non-empty`
64
+ : test === 'empty'
65
+ ? `${name} empty`
66
+ : `${name} != ${test.not}`,
67
+ )
68
+ .join(' and ');
69
+ }
70
+
71
+ /** The OpenCode config with placeholders, built by the launch code itself. */
72
+ function openCodeConfigDocument(lookup) {
73
+ const document = buildOpenCodeConfig({
74
+ baseUrl: '{baseUrl}',
75
+ model: '{model}',
76
+ modelIds: ['<id>'],
77
+ context: lookup('OPENCODE_CONTEXT_LIMIT'),
78
+ output: lookup('OPENCODE_OUTPUT_LIMIT'),
79
+ });
80
+ // The manifest is the same on every platform, so a Windows path keeps `/`.
81
+ const portable = (file) =>
82
+ file.startsWith(RUNBOOK_DIR)
83
+ ? `<runbook>/${path.relative(RUNBOOK_DIR, file).split(path.sep).join('/')}`
84
+ : file;
85
+ return { ...document, plugin: document.plugin.map(portable) };
86
+ }
87
+
88
+ function resolver(agent) {
89
+ const lookup = staticLookup(agent);
90
+ return (value) =>
91
+ value === '{opencodeConfig}'
92
+ ? openCodeConfigDocument(lookup)
93
+ : resolveRefs(value, lookup);
94
+ }
95
+
96
+ function inputEntry(input, resolve) {
97
+ return input.default === undefined
98
+ ? input
99
+ : { ...input, default: resolve(input.default) };
100
+ }
101
+
102
+ function exportedEnv(input, resolve) {
103
+ return {
104
+ name: input.name,
105
+ value: String(resolve(input.default)),
106
+ override: [input.name, ...(input.aliases || [])],
107
+ description: input.description,
108
+ };
109
+ }
110
+
111
+ function envEntries(agent, resolve) {
112
+ const inputs = agentInputs(agent).filter((input) => input.export);
113
+ const own = agent.env.map((entry) => ({
114
+ ...entry,
115
+ value:
116
+ entry.value === '{opencodeConfig}'
117
+ ? entry.value
118
+ : String(resolve(entry.value)),
119
+ override: entry.override || [],
120
+ }));
121
+ const launcher =
122
+ agent.id === 'claude-code' ? CLAUDE_PICKER_ENV_DESCRIPTION : [];
123
+ return [
124
+ ...inputs.map((input) => exportedEnv(input, resolve)),
125
+ ...own,
126
+ ...launcher,
127
+ ];
128
+ }
129
+
130
+ function inputBehindRef(agent, value) {
131
+ const name = /^\$\{([A-Z_][A-Z0-9_]*)\}$/.exec(value ?? '')?.[1];
132
+ return name && agentInputs(agent).find((input) => input.name === name);
133
+ }
134
+
135
+ /** Where a value that reads an input can be changed: its env names and flag. */
136
+ function overrideFor(input) {
137
+ return [
138
+ input.name,
139
+ ...(input.aliases || []),
140
+ ...(input.flag ? [input.flag] : []),
141
+ ];
142
+ }
143
+
144
+ function configEntry(agent, entry, resolve) {
145
+ const { when, ...rest } = entry;
146
+ const out = { ...rest };
147
+ if ('value' in entry) out.value = resolve(entry.value);
148
+ const input = inputBehindRef(agent, entry.value);
149
+ if (input) out.override = overrideFor(input);
150
+ if (when) out.condition = conditionText(when);
151
+ return out;
152
+ }
153
+
154
+ /** A knob that reads an input says where the value comes from and its bounds. */
155
+ function knob(agent, setting, resolve) {
156
+ const input = inputBehindRef(agent, setting.value);
157
+ const value = resolve(setting.value);
158
+ const out = {
159
+ ...setting,
160
+ value: setting.kind === 'env' ? String(value) : value,
161
+ };
162
+ if (input && !setting.override) out.override = overrideFor(input);
163
+ if (input && setting.range)
164
+ out.range = { min: input.min, max: input.max, ...setting.range };
165
+ return out;
166
+ }
167
+
168
+ function capabilities(agent, resolve) {
169
+ const { compaction, ...rest } = agent.capabilities;
170
+ const knobs = Object.fromEntries(
171
+ ['on', 'off', 'threshold'].map((mode) => [
172
+ mode,
173
+ knob(agent, compaction[mode], resolve),
174
+ ]),
175
+ );
176
+ return { ...resolve(rest), compaction: { ...compaction, ...knobs } };
177
+ }
178
+
179
+ function harnessEntry(agent) {
180
+ const resolve = resolver(agent);
181
+ const { runbook } = agent;
182
+ const script = (name) => (name ? `${agent.id}/${name}` : null);
183
+ return {
184
+ id: agent.id,
185
+ name: agent.name,
186
+ command: `subc ${agentCommandName(agent)}`,
187
+ aliases: agent.aliases,
188
+ mode: runbook.mode,
189
+ protocol: agent.protocol,
190
+ runbook: {
191
+ dir: agent.id,
192
+ launch: script(runbook.script),
193
+ setup: script(runbook.setup_script),
194
+ setup_actions: runbook.setup_actions || [],
195
+ },
196
+ binary: agentBinary(agent),
197
+ install: { ...agent.install, commands: installCommands(agent) },
198
+ prerequisites: agent.prerequisites,
199
+ launch: agent.launch ?? null,
200
+ inputs: agentInputs(agent).map((input) => inputEntry(input, resolve)),
201
+ env: envEntries(agent, resolve),
202
+ config: agent.config.map((entry) => configEntry(agent, entry, resolve)),
203
+ capabilities: capabilities(agent, resolve),
204
+ };
205
+ }
206
+
207
+ /** The manifest, computed from the agent files this subc ships. */
208
+ export function loadHarnessManifest() {
209
+ return {
210
+ schema_version: HARNESS_MANIFEST_SCHEMA_VERSION,
211
+ cli_version: JSON.parse(readFileSync(PACKAGE_URL, 'utf-8')).version,
212
+ cli_package: 'subconscious-cli',
213
+ tokens: MANIFEST_TOKENS,
214
+ runbook_env: RUNBOOK_ENV_DESCRIPTION,
215
+ harnesses: Object.fromEntries(
216
+ AGENTS.map((agent) => [agent.id, harnessEntry(agent)]),
217
+ ),
218
+ };
219
+ }
220
+
221
+ export function printHarnessManifestHelp() {
222
+ console.log(`
223
+ Usage:
224
+ subc harness-manifest [--json]
225
+ subc harness-manifest help
226
+
227
+ Print how this subc version installs, configures, and launches each coding
228
+ harness: install source, binary, env vars, config files, compaction knobs,
229
+ MCP, headers, and hooks.
230
+
231
+ --json Print the full manifest as JSON. This is the default when stdout
232
+ is not a terminal; in a terminal, a summary table is printed.
233
+ `);
234
+ }
235
+
236
+ function compactionSummary(compaction) {
237
+ const supported = ['on', 'off', 'threshold'].filter(
238
+ (mode) => compaction[mode]?.set_by_subc,
239
+ );
240
+ return supported.length ? supported.join(',') : '-';
241
+ }
242
+
243
+ function tableRows(manifest) {
244
+ return Object.values(manifest.harnesses).map((harness) => [
245
+ harness.id,
246
+ harness.binary || '-',
247
+ harness.install.package || harness.install.repository || '-',
248
+ String(harness.env.length),
249
+ compactionSummary(harness.capabilities.compaction),
250
+ String(harness.capabilities.hooks.length),
251
+ ]);
252
+ }
253
+
254
+ function printTable(manifest) {
255
+ const header = ['harness', 'binary', 'install', 'env', 'compaction', 'hooks'];
256
+ const rows = tableRows(manifest);
257
+ const widths = header.map((title, index) =>
258
+ Math.max(title.length, ...rows.map((row) => row[index].length)),
259
+ );
260
+ const line = (cells) =>
261
+ ` ${cells.map((cell, index) => cell.padEnd(widths[index])).join(' ')}`;
262
+ console.log(
263
+ `\n ${c.bold}Harness manifest${c.reset} ${c.dim}schema ${manifest.schema_version}, subc ${manifest.cli_version}${c.reset}\n`,
264
+ );
265
+ console.log(`${c.dim}${line(header)}${c.reset}`);
266
+ for (const row of rows) console.log(line(row));
267
+ console.log(
268
+ `\n ${c.dim}compaction lists the modes subc sets. Use --json for every field.${c.reset}\n`,
269
+ );
270
+ }
271
+
272
+ export function harnessManifestCommand(args = [], options = {}) {
273
+ const unknown = args.filter((arg) => arg !== '--json');
274
+ if (unknown.length) {
275
+ throw new Error(
276
+ `Unknown argument: ${unknown[0]}. Try subc harness-manifest help.`,
277
+ );
278
+ }
279
+ const isTTY = options.isTTY ?? process.stdout.isTTY === true;
280
+ const manifest = loadHarnessManifest();
281
+ if (args.includes('--json') || !isTTY) {
282
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
283
+ return;
284
+ }
285
+ printTable(manifest);
286
+ }
@@ -0,0 +1,35 @@
1
+ const SUBC_PROFILE_FLAGS = new Set(['-p', '--profile']);
2
+ const SUBC_INLINE_FLAGS = ['--model=', '--profile='];
3
+
4
+ /** Whether `--model` consumes the word after it. An empty word is a value. */
5
+ export function takesModelValue(value) {
6
+ return value !== undefined && !value.startsWith('-');
7
+ }
8
+
9
+ /**
10
+ * Index of the headless prompt when `argv` (the words after the agent name)
11
+ * asks for a headless run, otherwise -1. Only subc's own flags may come
12
+ * before "headless"; later on it is an ordinary agent argument.
13
+ */
14
+ export function headlessPromptIndex(argv) {
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const arg = argv[i];
17
+ if (SUBC_PROFILE_FLAGS.has(arg)) {
18
+ i++;
19
+ continue;
20
+ }
21
+ if (arg === '--model') {
22
+ if (takesModelValue(argv[i + 1])) i++;
23
+ continue;
24
+ }
25
+ if (SUBC_INLINE_FLAGS.some((prefix) => arg.startsWith(prefix))) continue;
26
+ return arg === 'headless' ? i + 1 : -1;
27
+ }
28
+ return -1;
29
+ }
30
+
31
+ /** Index of the first "--" separator; a headless prompt of "--" is not one. */
32
+ export function separatorIndex(argv) {
33
+ const prompt = headlessPromptIndex(argv);
34
+ return argv.findIndex((arg, index) => arg === '--' && index !== prompt);
35
+ }
@@ -0,0 +1,25 @@
1
+ import { DEFAULTS, PACKAGED_MODELS } from './agent-data.js';
2
+
3
+ const MODEL_ID = /^[-A-Za-z0-9._:/+]+$/;
4
+
5
+ /** The launch model first, then the catalog, deduplicated and validated. */
6
+ export function modelIds(env) {
7
+ const catalog = (
8
+ env.SUBCONSCIOUS_MODELS?.trim() || PACKAGED_MODELS.join('\n')
9
+ ).split(/\r?\n/);
10
+ const models = [
11
+ ...new Set([env.MODEL?.trim() || DEFAULTS.model, ...catalog]),
12
+ ].filter(Boolean);
13
+ for (const model of models)
14
+ if (!MODEL_ID.test(model)) throw new Error(`Invalid model id: ${model}`);
15
+ return models;
16
+ }
17
+
18
+ export function positiveInteger(input, name) {
19
+ if (
20
+ !/^[1-9][0-9]*$/.test(String(input)) ||
21
+ !Number.isSafeInteger(Number(input))
22
+ )
23
+ throw new Error(`${name} must be a positive integer`);
24
+ return Number(input);
25
+ }
@@ -1,11 +1,15 @@
1
- import { readFileSync } from 'node:fs';
2
-
3
- // The models endpoint currently supplies IDs, not modality metadata. Keep
4
- // explicit capabilities in the same registry used to generate the Unix lookup.
5
- const { modelCapabilities = {} } = JSON.parse(
6
- readFileSync(new URL('./registry.generated.json', import.meta.url), 'utf8'),
7
- );
1
+ import { MODEL_CAPABILITIES } from './agent-data.js';
8
2
 
3
+ // The models endpoint supplies IDs, not modality metadata, so capabilities
4
+ // are listed by exact model ID: similarly named models inherit nothing.
9
5
  export function modelSupportsVision(modelId) {
10
- return Object.hasOwn(modelCapabilities, modelId) && modelCapabilities[modelId].vision === true;
6
+ return (
7
+ Object.hasOwn(MODEL_CAPABILITIES, modelId) &&
8
+ MODEL_CAPABILITIES[modelId].vision === true
9
+ );
10
+ }
11
+
12
+ /** Vision model IDs, newline-separated, for the runbooks' shell lookup. */
13
+ export function visionModelList() {
14
+ return Object.keys(MODEL_CAPABILITIES).filter(modelSupportsVision).join('\n');
11
15
  }
package/bin/models.js CHANGED
@@ -99,31 +99,33 @@ export async function fetchGatewayModels({
99
99
  if (signal?.aborted) throw abortError('Model discovery was cancelled');
100
100
  const aborted = new Promise((_, reject) => {
101
101
  const fail = () => {
102
- if (signal?.aborted) reject(abortError('Model discovery was cancelled'));
103
- else reject(new Error(`Model discovery timed out after ${timeoutMs}ms`));
102
+ if (signal?.aborted)
103
+ reject(abortError('Model discovery was cancelled'));
104
+ else
105
+ reject(new Error(`Model discovery timed out after ${timeoutMs}ms`));
104
106
  };
105
107
  controller.signal.addEventListener('abort', fail, { once: true });
106
108
  });
107
- const response = await Promise.race([
108
- fetchImpl(endpoint, {
109
+ const discover = async () => {
110
+ const response = await fetchImpl(endpoint, {
109
111
  method: 'GET',
110
112
  headers,
111
113
  cache: 'no-store',
112
114
  signal: controller.signal,
113
- }),
114
- aborted,
115
- ]);
115
+ });
116
116
 
117
- if (!response.ok) {
118
- throw new Error(`Model discovery returned HTTP ${response.status}`);
119
- }
117
+ if (!response.ok) {
118
+ throw new Error(`Model discovery returned HTTP ${response.status}`);
119
+ }
120
120
 
121
- const payload = await response.json();
122
- if (!Array.isArray(payload?.data)) {
123
- throw new Error('Model discovery returned an invalid response');
124
- }
121
+ const payload = await response.json();
122
+ if (!Array.isArray(payload?.data)) {
123
+ throw new Error('Model discovery returned an invalid response');
124
+ }
125
125
 
126
- return normalizeModelIds(payload.data.map((model) => model?.id));
126
+ return normalizeModelIds(payload.data.map((model) => model?.id));
127
+ };
128
+ return await Promise.race([discover(), aborted]);
127
129
  } catch (error) {
128
130
  if (signal?.aborted) throw abortError('Model discovery was cancelled');
129
131
  if (controller.signal.aborted) {
@@ -144,13 +146,17 @@ async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal }) {
144
146
  timeoutMs,
145
147
  signal,
146
148
  });
147
- if (!models.length) throw new Error('Model discovery returned no usable model IDs');
149
+ if (!models.length)
150
+ throw new Error('Model discovery returned no usable model IDs');
148
151
  return models;
149
152
  }
150
153
 
151
154
  function packagedCatalog(selectedModel, fallbackModels, error) {
152
155
  return {
153
- models: normalizeModelIds([selectedModel, ...fallbackModels], selectedModel),
156
+ models: normalizeModelIds(
157
+ [selectedModel, ...fallbackModels],
158
+ selectedModel,
159
+ ),
154
160
  source: 'packaged',
155
161
  error,
156
162
  };
@@ -189,7 +195,12 @@ export async function resolveModelCatalog({
189
195
  } catch (availableError) {
190
196
  if (isAbortError(availableError) || signal?.aborted) throw availableError;
191
197
  try {
192
- const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal });
198
+ const discovered = await fetchPublicModels({
199
+ baseUrl,
200
+ fetchImpl,
201
+ timeoutMs,
202
+ signal,
203
+ });
193
204
  return liveCatalog(discovered, selectedModel, 'public');
194
205
  } catch (publicError) {
195
206
  if (isAbortError(publicError) || signal?.aborted) throw publicError;
@@ -199,7 +210,12 @@ export async function resolveModelCatalog({
199
210
  }
200
211
 
201
212
  try {
202
- const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal });
213
+ const discovered = await fetchPublicModels({
214
+ baseUrl,
215
+ fetchImpl,
216
+ timeoutMs,
217
+ signal,
218
+ });
203
219
  return liveCatalog(discovered, selectedModel, 'public');
204
220
  } catch (error) {
205
221
  if (isAbortError(error) || signal?.aborted) throw error;
Binary file
Binary file