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.
- package/README.md +135 -2
- package/bin/agent-data.js +242 -0
- package/bin/agent-help.js +106 -0
- package/bin/agent-install.js +354 -0
- package/bin/agent-launch.js +199 -0
- package/bin/agent-spawn.js +137 -0
- package/bin/agents.js +284 -840
- package/bin/auth.js +89 -33
- package/bin/branding.js +3 -1
- package/bin/claude-picker.js +143 -0
- package/bin/cli.js +87 -20
- package/bin/clipboard-image.js +24 -5
- package/bin/colors.js +1 -1
- package/bin/feedback.js +71 -21
- package/bin/harness-manifest.js +286 -0
- package/bin/headless-args.js +35 -0
- package/bin/launch-values.js +25 -0
- package/bin/model-capabilities.js +12 -8
- package/bin/models.js +35 -19
- package/bin/native/subc-tui-darwin-amd64 +0 -0
- package/bin/native/subc-tui-darwin-arm64 +0 -0
- package/bin/native/subc-tui-linux-amd64 +0 -0
- package/bin/native/subc-tui-linux-arm64 +0 -0
- package/bin/native/subc-tui-windows-amd64.exe +0 -0
- package/bin/native/subc-tui-windows-arm64.exe +0 -0
- package/bin/opencode-provider.js +48 -5
- package/bin/profile-settings.js +129 -0
- package/bin/profiles.js +136 -449
- package/bin/runbook/README.md +66 -23
- package/bin/runbook/claude-code/agent.json +300 -0
- package/bin/runbook/claude-code/install.sh +9 -252
- package/bin/runbook/codex/agent.json +461 -0
- package/bin/runbook/codex/hooks-lib.sh +17 -4
- package/bin/runbook/codex/install.sh +0 -4
- package/bin/runbook/codex/run.sh +16 -235
- package/bin/runbook/copilot/agent.json +185 -0
- package/bin/runbook/copilot/install.sh +45 -63
- package/bin/runbook/cursor/agent.json +137 -0
- package/bin/runbook/cursor/install.sh +8 -35
- package/bin/runbook/deepseek-harness/agent.json +163 -0
- package/bin/runbook/deepseek-harness/run.sh +20 -59
- package/bin/runbook/image-window/index.ts +14 -0
- package/bin/runbook/image-window/window.d.ts +19 -0
- package/bin/runbook/image-window/window.js +140 -0
- package/bin/runbook/lib.sh +41 -0
- package/bin/runbook/opencode/agent.json +158 -0
- package/bin/runbook/opencode/install.sh +9 -183
- package/bin/runbook/opencode/opencode-plugin.d.ts +16 -0
- package/bin/runbook/opencode/subconscious-image-window.ts +24 -0
- package/bin/runbook/pi/agent.json +216 -0
- package/bin/runbook/pi/install.sh +18 -44
- package/bin/runbook/pi/run.sh +6 -18
- package/bin/runbook/pi/subconscious-compaction.ts +71 -49
- package/bin/runbook/shared.json +58 -0
- package/bin/runbook/subconscious-code/agent.json +156 -0
- package/bin/sessions.js +173 -58
- package/bin/tui.js +104 -32
- package/bin/update-check.js +54 -21
- package/bin/upgrade.js +67 -43
- package/bin/usage.js +47 -24
- package/bin/windows/agents.js +141 -37
- package/bin/windows/common.js +36 -25
- package/bin/windows/install.js +116 -34
- package/bin/windows/launch.js +260 -77
- package/bin/windows/pi-compaction.ts +68 -13
- package/bin/windows/process.js +210 -57
- package/bin/windows/setup.js +251 -56
- package/package.json +9 -4
- package/bin/registry.generated.json +0 -406
- package/bin/runbook/claude-code/run.sh +0 -254
- package/bin/runbook/model-capabilities.generated.sh +0 -9
- package/bin/runbook/opencode/provider.sh +0 -93
- package/bin/runbook/opencode/run.sh +0 -99
- package/bin/runbook/opencode/subconscious-compaction.ts +0 -100
- package/bin/runbook/subconscious-code/run.sh +0 -21
- package/bin/windows/claude.js +0 -71
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import {
|
|
8
|
+
agentBinary,
|
|
9
|
+
agentById,
|
|
10
|
+
agentCommandName,
|
|
11
|
+
agentInstallDirs,
|
|
12
|
+
installCommands,
|
|
13
|
+
} from './agent-data.js';
|
|
14
|
+
import { runbookScriptPath } from './agent-spawn.js';
|
|
15
|
+
import { c } from './colors.js';
|
|
16
|
+
import { compareVersions } from './update-check.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The install command for this OS, falling back to the Linux one, then any
|
|
20
|
+
* command present; plus the optional fallback command.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveInstall(agent) {
|
|
23
|
+
const commands = installCommands(agent);
|
|
24
|
+
if (!commands) return { command: undefined, fallback: undefined };
|
|
25
|
+
const command =
|
|
26
|
+
commands[process.platform] ||
|
|
27
|
+
commands.linux ||
|
|
28
|
+
Object.entries(commands).find(([os]) => os !== 'fallback')?.[1];
|
|
29
|
+
return { command, fallback: commands.fallback };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Common locations a freshly-installed coding-agent binary lands in but which
|
|
34
|
+
* are often NOT on the current process's PATH (e.g. aider/claude install into
|
|
35
|
+
* `~/.local/bin`; npm globals into the npm prefix bin). Best-effort, deduped.
|
|
36
|
+
*/
|
|
37
|
+
export function candidateBinDirs() {
|
|
38
|
+
const home = os.homedir();
|
|
39
|
+
const dirs = [];
|
|
40
|
+
|
|
41
|
+
if (process.platform === 'win32') {
|
|
42
|
+
if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm'));
|
|
43
|
+
if (process.env.USERPROFILE) {
|
|
44
|
+
dirs.push(path.join(process.env.USERPROFILE, '.local', 'bin'));
|
|
45
|
+
}
|
|
46
|
+
if (home) dirs.push(path.join(home, '.local', 'bin'));
|
|
47
|
+
} else {
|
|
48
|
+
dirs.push(path.join(home, '.local', 'bin'));
|
|
49
|
+
dirs.push(path.join(home, '.cargo', 'bin'));
|
|
50
|
+
dirs.push('/opt/homebrew/bin');
|
|
51
|
+
dirs.push('/usr/local/bin');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// npm global bin (best-effort — npm may be absent).
|
|
55
|
+
try {
|
|
56
|
+
const prefix = execFileSync('npm', ['prefix', '-g'], {
|
|
57
|
+
encoding: 'utf-8',
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
}).trim();
|
|
60
|
+
if (prefix) {
|
|
61
|
+
dirs.push(
|
|
62
|
+
process.platform === 'win32' ? prefix : path.join(prefix, 'bin'),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// npm not available — skip.
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Dedupe, drop empties.
|
|
70
|
+
return [...new Set(dirs.filter(Boolean))];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The binary directory the agent's own installer uses, if it declares one. */
|
|
74
|
+
export function preferredBinDirsForAgent(
|
|
75
|
+
agent,
|
|
76
|
+
environment = process.env,
|
|
77
|
+
home = os.homedir(),
|
|
78
|
+
) {
|
|
79
|
+
return agentInstallDirs(agent, environment, home);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Executable extensions to probe (Windows uses PATHEXT). */
|
|
83
|
+
function binExts() {
|
|
84
|
+
return process.platform === 'win32'
|
|
85
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
|
|
86
|
+
: [''];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve `bin` against PATH plus the candidate bin dirs. Returns the directory
|
|
91
|
+
* containing the executable if found, otherwise null. Searching the candidate
|
|
92
|
+
* dirs lets us find binaries installed this session that aren't on PATH yet.
|
|
93
|
+
*/
|
|
94
|
+
async function resolveBinPath(bin, preferredDirs = []) {
|
|
95
|
+
const pathDirs = (process.env.PATH || '')
|
|
96
|
+
.split(path.delimiter)
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
const dirs = [
|
|
99
|
+
...new Set([...preferredDirs, ...pathDirs, ...candidateBinDirs()]),
|
|
100
|
+
];
|
|
101
|
+
const exts = binExts();
|
|
102
|
+
for (const dir of dirs) {
|
|
103
|
+
for (const ext of exts) {
|
|
104
|
+
const candidate = path.join(dir, bin + ext);
|
|
105
|
+
try {
|
|
106
|
+
await fs.access(candidate, fsConstants.F_OK);
|
|
107
|
+
return dir;
|
|
108
|
+
} catch {
|
|
109
|
+
// keep scanning
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a PATH string with `extraDirs` prepended (deduped against PATH).
|
|
118
|
+
* Returns the augmented PATH value for use in a child env.
|
|
119
|
+
*/
|
|
120
|
+
export function augmentPath(
|
|
121
|
+
extraDirs,
|
|
122
|
+
preferredDir,
|
|
123
|
+
currentPath = process.env.PATH || '',
|
|
124
|
+
) {
|
|
125
|
+
const current = currentPath.split(path.delimiter).filter(Boolean);
|
|
126
|
+
const seen = new Set(current);
|
|
127
|
+
const prepend = extraDirs.filter(
|
|
128
|
+
(dir, index) =>
|
|
129
|
+
dir &&
|
|
130
|
+
dir !== preferredDir &&
|
|
131
|
+
!seen.has(dir) &&
|
|
132
|
+
extraDirs.indexOf(dir) === index,
|
|
133
|
+
);
|
|
134
|
+
const remaining = current.filter((dir) => dir !== preferredDir);
|
|
135
|
+
return [
|
|
136
|
+
...(preferredDir ? [preferredDir] : []),
|
|
137
|
+
...prepend,
|
|
138
|
+
...remaining,
|
|
139
|
+
].join(path.delimiter);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Ask a yes/no question on the TTY. Empty answer counts as yes. */
|
|
143
|
+
function askYesNo(question) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const rl = readline.createInterface({
|
|
146
|
+
input: process.stdin,
|
|
147
|
+
output: process.stdout,
|
|
148
|
+
});
|
|
149
|
+
rl.question(question, (answer) => {
|
|
150
|
+
rl.close();
|
|
151
|
+
const a = answer.trim().toLowerCase();
|
|
152
|
+
resolve(a === '' || a === 'y' || a === 'yes');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function waitForEnter(prompt) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const rl = readline.createInterface({
|
|
160
|
+
input: process.stdin,
|
|
161
|
+
output: process.stdout,
|
|
162
|
+
});
|
|
163
|
+
rl.question(prompt, () => {
|
|
164
|
+
rl.close();
|
|
165
|
+
resolve();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Run the agent's packaged binary installer, or its shell install command. */
|
|
171
|
+
function runInstaller(
|
|
172
|
+
agent,
|
|
173
|
+
install = resolveInstall(agent).command,
|
|
174
|
+
usePackagedScript = true,
|
|
175
|
+
) {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
const installScript =
|
|
178
|
+
usePackagedScript && agent.runbook.binary_install_script;
|
|
179
|
+
const child = installScript
|
|
180
|
+
? spawn('bash', [runbookScriptPath(agent, installScript), 'install'], {
|
|
181
|
+
stdio: 'inherit',
|
|
182
|
+
})
|
|
183
|
+
: spawn(install, { shell: true, stdio: 'inherit' });
|
|
184
|
+
child.on('error', () => resolve(false));
|
|
185
|
+
child.on('exit', (code) => resolve(code === 0));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Print the resolved install command (plus any fallback) for an agent. */
|
|
190
|
+
function printInstallCommands(agent) {
|
|
191
|
+
const { command, fallback } = resolveInstall(agent);
|
|
192
|
+
console.error(` ${c.cyan}${command}${c.reset}`);
|
|
193
|
+
if (fallback) {
|
|
194
|
+
console.error(` ${c.dim}or, as a fallback:${c.reset}`);
|
|
195
|
+
console.error(` ${c.cyan}${fallback}${c.reset}`);
|
|
196
|
+
}
|
|
197
|
+
console.error('');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Ensure the agent's binary is resolvable. If missing:
|
|
202
|
+
* - interactive TTY: offer to run the per-OS installer (with fallback), then
|
|
203
|
+
* re-resolve against PATH + candidate dirs.
|
|
204
|
+
* - non-interactive: print the resolved install command (+ fallback) and
|
|
205
|
+
* exit 127 without running anything.
|
|
206
|
+
*
|
|
207
|
+
* Returns the directory containing the bin (to prepend to the child's PATH) on
|
|
208
|
+
* success. May exit the process on failure or when manual action is needed.
|
|
209
|
+
*/
|
|
210
|
+
export async function ensureInstalled(agent, { mayPrompt = true } = {}) {
|
|
211
|
+
const bin = agentBinary(agent);
|
|
212
|
+
const { command: install, fallback } = resolveInstall(agent);
|
|
213
|
+
const preferredDirs = preferredBinDirsForAgent(agent);
|
|
214
|
+
const existing = await resolveBinPath(bin, preferredDirs);
|
|
215
|
+
if (existing) return existing;
|
|
216
|
+
|
|
217
|
+
// Agents without an installer are launch-only. Their setup integration may
|
|
218
|
+
// configure the provider, but `subc <agent>` must never install the binary.
|
|
219
|
+
if (!install) {
|
|
220
|
+
console.error(
|
|
221
|
+
`\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${bin}\` not found on PATH).${c.reset}`,
|
|
222
|
+
);
|
|
223
|
+
console.error(
|
|
224
|
+
` Install ${agent.name} separately, then re-run ${c.cyan}subc ${agentCommandName(agent)}${c.reset}.\n`,
|
|
225
|
+
);
|
|
226
|
+
process.exit(127);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const interactive = mayPrompt && process.stdin.isTTY && process.stdout.isTTY;
|
|
230
|
+
|
|
231
|
+
if (!interactive) {
|
|
232
|
+
console.error(
|
|
233
|
+
`\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${bin}\` not found on PATH).${c.reset}`,
|
|
234
|
+
);
|
|
235
|
+
console.error(` Install it with:\n`);
|
|
236
|
+
printInstallCommands(agent);
|
|
237
|
+
process.exit(127);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.error(`\n ${c.bold}${agent.name}${c.reset} isn't installed.`);
|
|
241
|
+
const ok = await askYesNo(` Install it now? ${c.dim}[Y/n]${c.reset} `);
|
|
242
|
+
if (!ok) {
|
|
243
|
+
console.error(`\n No problem. Install it yourself with:\n`);
|
|
244
|
+
printInstallCommands(agent);
|
|
245
|
+
process.exit(127);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.error(
|
|
249
|
+
`\n ${c.dim}Running ${c.reset}${c.cyan}${install}${c.reset}\n`,
|
|
250
|
+
);
|
|
251
|
+
let installed = await runInstaller(agent);
|
|
252
|
+
|
|
253
|
+
// Primary failed and a fallback exists — try it once.
|
|
254
|
+
if (!installed && fallback) {
|
|
255
|
+
console.error(
|
|
256
|
+
`\n ${c.dim}That didn't work. Trying the fallback: ${c.reset}${c.cyan}${fallback}${c.reset}\n`,
|
|
257
|
+
);
|
|
258
|
+
installed = await runInstaller(agent, fallback, false);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!installed) {
|
|
262
|
+
console.error(`\n ${c.red}Install failed.${c.reset} Try it manually:\n`);
|
|
263
|
+
printInstallCommands(agent);
|
|
264
|
+
process.exit(127);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// PATH hardening: the freshly-installed binary is often not on the current
|
|
268
|
+
// process's PATH. Re-resolve against PATH + candidate dirs.
|
|
269
|
+
const found = await resolveBinPath(bin, preferredDirs);
|
|
270
|
+
if (found) return found;
|
|
271
|
+
|
|
272
|
+
console.error(
|
|
273
|
+
`\n ${c.dim}Installed ${agent.name}, but it isn't on this shell's PATH yet. ` +
|
|
274
|
+
`Open a new terminal (or add a bin dir to PATH) and re-run \`subc ${agentCommandName(agent)}\`.${c.reset}\n`,
|
|
275
|
+
);
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function parseClaudeVersion(text) {
|
|
280
|
+
const match = String(text ?? '').match(/v?(\d+\.\d+\.\d+)/);
|
|
281
|
+
return match?.[1] ?? null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const MIN_CLAUDE_CODE_VERSION =
|
|
285
|
+
agentById('claude-code').install.minimum_version;
|
|
286
|
+
export const MIN_CODEX_VERSION = agentById('codex').install.minimum_version;
|
|
287
|
+
|
|
288
|
+
export function claudeVersionNeedsUpgrade(
|
|
289
|
+
installed,
|
|
290
|
+
minimum = MIN_CLAUDE_CODE_VERSION,
|
|
291
|
+
) {
|
|
292
|
+
return versionNeedsUpgrade(installed, minimum);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function versionNeedsUpgrade(installed, minimum) {
|
|
296
|
+
return Boolean(
|
|
297
|
+
installed && minimum && compareVersions(installed, minimum) < 0,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function readClaudeVersion(bin, binDir, options = {}) {
|
|
302
|
+
const execFile = options.execFileSync || execFileSync;
|
|
303
|
+
try {
|
|
304
|
+
const stdout = execFile(bin, ['--version'], {
|
|
305
|
+
encoding: 'utf-8',
|
|
306
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
|
+
env: {
|
|
308
|
+
...process.env,
|
|
309
|
+
PATH: augmentPath(
|
|
310
|
+
[binDir, ...candidateBinDirs()].filter(Boolean),
|
|
311
|
+
binDir,
|
|
312
|
+
),
|
|
313
|
+
},
|
|
314
|
+
timeout: options.timeoutMs ?? 3000,
|
|
315
|
+
});
|
|
316
|
+
return parseClaudeVersion(
|
|
317
|
+
typeof stdout === 'string' ? stdout : stdout?.toString?.(),
|
|
318
|
+
);
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Warn, without blocking, when the agent is older than its minimum version. */
|
|
325
|
+
export async function ensureMinimumVersion(
|
|
326
|
+
agent,
|
|
327
|
+
binDir,
|
|
328
|
+
{ mayPrompt = true } = {},
|
|
329
|
+
) {
|
|
330
|
+
const minimum = agent.install.minimum_version;
|
|
331
|
+
if (!minimum) return;
|
|
332
|
+
const version = readClaudeVersion(agentBinary(agent), binDir);
|
|
333
|
+
if (!versionNeedsUpgrade(version, minimum)) return;
|
|
334
|
+
|
|
335
|
+
console.error(
|
|
336
|
+
`\n Minimum supported ${agent.name} version is ${minimum}. Your version is ${version}. Upgrade to get the best experience.\n`,
|
|
337
|
+
);
|
|
338
|
+
const { command, fallback } = resolveInstall(agent);
|
|
339
|
+
console.error(` Upgrade it with:`);
|
|
340
|
+
console.error(` ${c.cyan}${command}${c.reset}`);
|
|
341
|
+
if (fallback) {
|
|
342
|
+
console.error(` or`);
|
|
343
|
+
console.error(` ${c.cyan}${fallback}${c.reset}`);
|
|
344
|
+
}
|
|
345
|
+
console.error('');
|
|
346
|
+
if (
|
|
347
|
+
mayPrompt &&
|
|
348
|
+
process.stdin.isTTY === true &&
|
|
349
|
+
process.stdout.isTTY === true
|
|
350
|
+
) {
|
|
351
|
+
await waitForEnter(` ${c.dim}Press Enter to continue.${c.reset} `);
|
|
352
|
+
console.error('');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { agentInputs, agentKeyName } from './agent-data.js';
|
|
2
|
+
import { openCodeConfigFromEnv } from './opencode-provider.js';
|
|
3
|
+
import { isUnsetSetting } from './profiles.js';
|
|
4
|
+
|
|
5
|
+
// One pass, so a substituted value (a prompt, a settings document) is never
|
|
6
|
+
// scanned again for placeholders.
|
|
7
|
+
const PLACEHOLDER = /\$\{([A-Z_][A-Z0-9_]*)\}(\/)?|\{([A-Za-z][A-Za-z0-9]*)\}/g;
|
|
8
|
+
const present = (value) =>
|
|
9
|
+
value !== undefined && value !== null && value !== '';
|
|
10
|
+
|
|
11
|
+
function tokensFor(agent, env, extra) {
|
|
12
|
+
const baseUrl = String(env.GATEWAY_URL ?? '').replace(/\/+$/, '');
|
|
13
|
+
const keyName = agentKeyName(agent);
|
|
14
|
+
return {
|
|
15
|
+
baseUrl,
|
|
16
|
+
baseUrlV1: `${baseUrl}/v1`,
|
|
17
|
+
apiKey: (keyName && env[keyName]) || env.API_KEY || '',
|
|
18
|
+
model: env.MODEL ?? '',
|
|
19
|
+
// Left for the runbook, which writes the file and substitutes its path.
|
|
20
|
+
tempFile: '{tempFile}',
|
|
21
|
+
opencodeConfig: () => JSON.stringify(openCodeConfigFromEnv(env)),
|
|
22
|
+
...extra,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fill `{token}` placeholders and `${NAME}` env references. A reference
|
|
28
|
+
* followed by `/` drops the value's trailing slashes, so URLs join cleanly.
|
|
29
|
+
*/
|
|
30
|
+
export function render(template, agent, env, extra = {}) {
|
|
31
|
+
const tokens = tokensFor(agent, env, extra);
|
|
32
|
+
return String(template).replace(PLACEHOLDER, (match, ref, slash, token) => {
|
|
33
|
+
if (ref) {
|
|
34
|
+
const value = String(env[ref] ?? '');
|
|
35
|
+
return slash ? `${value.replace(/\/+$/, '')}/` : value;
|
|
36
|
+
}
|
|
37
|
+
if (!(token in tokens) || tokens[token] === undefined)
|
|
38
|
+
throw new Error(`${agent.id}: ${match} has no value here`);
|
|
39
|
+
const value = tokens[token];
|
|
40
|
+
return typeof value === 'function' ? value() : value;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pull the agent's declared flags out of `args`, up to the first `--`, which
|
|
46
|
+
* is dropped. Everything else passes through in order.
|
|
47
|
+
*/
|
|
48
|
+
export function takeFlags(agent, args, { skip = [] } = {}) {
|
|
49
|
+
const flags = new Map(
|
|
50
|
+
agentInputs(agent)
|
|
51
|
+
.filter((input) => input.flag && !skip.includes(input.name))
|
|
52
|
+
.map((input) => [input.flag, input]),
|
|
53
|
+
);
|
|
54
|
+
const values = {};
|
|
55
|
+
const rest = [];
|
|
56
|
+
for (let i = 0; i < args.length; i++) {
|
|
57
|
+
const arg = args[i];
|
|
58
|
+
if (arg === '--') {
|
|
59
|
+
rest.push(...args.slice(i + 1));
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
63
|
+
const name = equals > 0 ? arg.slice(0, equals) : arg;
|
|
64
|
+
const input = flags.get(name);
|
|
65
|
+
if (!input) {
|
|
66
|
+
rest.push(arg);
|
|
67
|
+
} else if ('flag_value' in input) {
|
|
68
|
+
if (equals > 0) throw new Error(`${name} does not take a value`);
|
|
69
|
+
values[input.name] = input.flag_value;
|
|
70
|
+
} else if (equals > 0) {
|
|
71
|
+
values[input.name] = arg.slice(equals + 1);
|
|
72
|
+
} else if (i + 1 < args.length) {
|
|
73
|
+
values[input.name] = args[++i];
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`${name} requires a value`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { values, rest };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function inputValue(input, env, agent) {
|
|
82
|
+
const own = env[input.name];
|
|
83
|
+
const usable = (value) =>
|
|
84
|
+
present(value) && !(input.unset_words && isUnsetSetting(value));
|
|
85
|
+
if (usable(own)) return own;
|
|
86
|
+
const alias = (input.aliases || []).map((name) => env[name]).find(usable);
|
|
87
|
+
if (alias !== undefined) return alias;
|
|
88
|
+
if (own === '' && input.keep_empty) return own;
|
|
89
|
+
if (input.default === undefined) return own;
|
|
90
|
+
return render(input.default, agent, env);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply flags, then each input's aliases and default, in file order. A
|
|
95
|
+
* launch rejects a strict input outside its choices; status and uninstall
|
|
96
|
+
* pass `{ strict: false }` so a bad setting cannot block the way out.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveInputs(
|
|
99
|
+
agent,
|
|
100
|
+
env,
|
|
101
|
+
flagged = {},
|
|
102
|
+
{ strict = true } = {},
|
|
103
|
+
) {
|
|
104
|
+
const resolved = { ...env, ...flagged };
|
|
105
|
+
for (const input of agentInputs(agent)) {
|
|
106
|
+
if (input.name in flagged) continue;
|
|
107
|
+
const value = inputValue(input, resolved, agent);
|
|
108
|
+
if (value !== undefined) resolved[input.name] = String(value);
|
|
109
|
+
}
|
|
110
|
+
for (const input of agentInputs(agent)) {
|
|
111
|
+
if (!strict || !input.strict) continue;
|
|
112
|
+
if (input.choices.includes(resolved[input.name])) continue;
|
|
113
|
+
const source = input.name in flagged ? input.flag : input.name;
|
|
114
|
+
throw new Error(`${source} must be one of: ${input.choices.join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
return resolved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Set each env entry: the first non-empty override, otherwise its value. */
|
|
120
|
+
export function resolveAgentEnv(agent, env) {
|
|
121
|
+
const resolved = { ...env };
|
|
122
|
+
for (const entry of agent.env) {
|
|
123
|
+
const override = (entry.override || [])
|
|
124
|
+
.map((name) => resolved[name])
|
|
125
|
+
.find(present);
|
|
126
|
+
resolved[entry.name] =
|
|
127
|
+
override !== undefined ? override : render(entry.value, agent, resolved);
|
|
128
|
+
}
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function holds(when, env) {
|
|
133
|
+
return Object.entries(when || {}).every(([name, test]) => {
|
|
134
|
+
const value = env[name] ?? '';
|
|
135
|
+
if (test === 'set') return value !== '';
|
|
136
|
+
if (test === 'empty') return value === '';
|
|
137
|
+
if (test && 'not' in test) return value !== test.not;
|
|
138
|
+
throw new Error(`Unknown condition for ${name}: ${JSON.stringify(test)}`);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `-c name=value` pairs (or the agent's config flag) for cli-config entries. */
|
|
143
|
+
export function configArgs(agent, env) {
|
|
144
|
+
const flag = agent.launch?.config_flag;
|
|
145
|
+
return agent.config
|
|
146
|
+
.filter((entry) => entry.kind === 'cli-config' && holds(entry.when, env))
|
|
147
|
+
.flatMap((entry) => [
|
|
148
|
+
flag,
|
|
149
|
+
`${entry.name}=${render(entry.value, agent, env)}`,
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function expandArgv(agent, template, env, args, prompt) {
|
|
154
|
+
const argv = [];
|
|
155
|
+
let templateWords;
|
|
156
|
+
for (const word of template) {
|
|
157
|
+
if (word === '{args}') {
|
|
158
|
+
templateWords ??= argv.length;
|
|
159
|
+
argv.push(...args);
|
|
160
|
+
} else if (word === '{config}') {
|
|
161
|
+
argv.push(...configArgs(agent, env));
|
|
162
|
+
} else {
|
|
163
|
+
if (word.includes('{prompt}')) templateWords ??= argv.length;
|
|
164
|
+
argv.push(render(word, agent, env, { prompt }));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { argv, templateWords: templateWords ?? argv.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Everything needed to start an agent: its argv, env, and stdin. `args` are
|
|
172
|
+
* the words after the agent name with subc's own flags already removed;
|
|
173
|
+
* `prompt` is set for a headless run. `templateWords` counts the leading argv
|
|
174
|
+
* words that came from the template, the only ones a runbook may rewrite.
|
|
175
|
+
*/
|
|
176
|
+
export function launchPlan(agent, { env, args = [], prompt } = {}) {
|
|
177
|
+
const headless = prompt !== undefined;
|
|
178
|
+
const { launch } = agent;
|
|
179
|
+
const words =
|
|
180
|
+
!headless && launch.mode_word && args[0] === launch.mode_word
|
|
181
|
+
? args.slice(1)
|
|
182
|
+
: args;
|
|
183
|
+
const { values, rest } = takeFlags(agent, words);
|
|
184
|
+
const resolved = resolveAgentEnv(agent, resolveInputs(agent, env, values));
|
|
185
|
+
const template = headless ? launch.headless_argv : launch.argv;
|
|
186
|
+
const { argv, templateWords } = expandArgv(
|
|
187
|
+
agent,
|
|
188
|
+
template,
|
|
189
|
+
resolved,
|
|
190
|
+
rest,
|
|
191
|
+
prompt,
|
|
192
|
+
);
|
|
193
|
+
if (!headless)
|
|
194
|
+
return { argv, env: resolved, stdin: 'inherit', templateWords };
|
|
195
|
+
if (!launch.headless_stdin)
|
|
196
|
+
return { argv, env: resolved, stdin: 'ignore', templateWords };
|
|
197
|
+
const input = render(launch.headless_stdin, agent, resolved, { prompt });
|
|
198
|
+
return { argv, env: resolved, stdin: 'pipe', input, templateWords };
|
|
199
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { RUNBOOK_DIR } from './agent-data.js';
|
|
5
|
+
import { modelIds } from './launch-values.js';
|
|
6
|
+
import { visionModelList } from './model-capabilities.js';
|
|
7
|
+
|
|
8
|
+
/** Resolve a script inside the agent's own runbook directory. */
|
|
9
|
+
export function runbookScriptPath(agent, relativeScript) {
|
|
10
|
+
const dir = path.join(RUNBOOK_DIR, agent.id);
|
|
11
|
+
const script = path.resolve(dir, relativeScript);
|
|
12
|
+
const relative = path.relative(dir, script);
|
|
13
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
14
|
+
throw new Error(`Invalid runbook script path for ${agent.name}`);
|
|
15
|
+
}
|
|
16
|
+
return script;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function forwardStops(child) {
|
|
20
|
+
// The runbook execs the agent, so it is our direct child: a runner that
|
|
21
|
+
// stops subc must stop the agent too. A terminal already delivers SIGINT
|
|
22
|
+
// to the whole process group, so it is not forwarded.
|
|
23
|
+
const forwarded = ['SIGTERM', 'SIGHUP'];
|
|
24
|
+
const forward = (signal) => {
|
|
25
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
26
|
+
child.kill(signal);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
for (const signal of forwarded) process.on(signal, forward);
|
|
30
|
+
return () => {
|
|
31
|
+
for (const signal of forwarded) process.off(signal, forward);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run `file args`, mirror its exit status or signal, and pass SIGTERM/SIGHUP
|
|
37
|
+
* on to it. `stdin` is 'inherit', 'ignore', or 'pipe' with `input` written
|
|
38
|
+
* then closed. `notFound` builds the error when `file` does not exist.
|
|
39
|
+
*/
|
|
40
|
+
export function spawnChild(
|
|
41
|
+
file,
|
|
42
|
+
args,
|
|
43
|
+
{ env, stdin = 'inherit', input, notFound },
|
|
44
|
+
) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(file, args, {
|
|
47
|
+
stdio: [stdin, 'inherit', 'inherit'],
|
|
48
|
+
env,
|
|
49
|
+
});
|
|
50
|
+
if (stdin === 'pipe') {
|
|
51
|
+
// The agent may exit without reading all of it.
|
|
52
|
+
child.stdin.on('error', () => {});
|
|
53
|
+
child.stdin.end(input);
|
|
54
|
+
}
|
|
55
|
+
const stopForwarding = forwardStops(child);
|
|
56
|
+
|
|
57
|
+
child.on('error', (error) => {
|
|
58
|
+
stopForwarding();
|
|
59
|
+
reject(error.code === 'ENOENT' && notFound ? notFound() : error);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
child.on('exit', (code, signal) => {
|
|
63
|
+
stopForwarding();
|
|
64
|
+
if (signal) {
|
|
65
|
+
// Node ignores some signals (SIGPIPE), so re-raising may not end the
|
|
66
|
+
// process; the shell convention still reports the agent's failure.
|
|
67
|
+
process.exitCode = 128 + (os.constants.signals[signal] ?? 0);
|
|
68
|
+
process.kill(process.pid, signal);
|
|
69
|
+
resolve(process.exitCode);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (code) process.exitCode = code;
|
|
73
|
+
resolve(code ?? 0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const BASH_MISSING = () =>
|
|
79
|
+
new Error(
|
|
80
|
+
'These coding-agent integrations require `bash`, but it was not found on PATH.',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What a runbook script reads beyond the launch env. With `strict: false`
|
|
85
|
+
* an invalid model leaves SUBC_MODEL_IDS unset instead of failing, so a
|
|
86
|
+
* script that needs it sees an empty list.
|
|
87
|
+
*/
|
|
88
|
+
export function scriptEnv(env, { strict = true } = {}) {
|
|
89
|
+
let ids;
|
|
90
|
+
try {
|
|
91
|
+
ids = modelIds(env).join('\n');
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (strict) throw error;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...env,
|
|
97
|
+
...(ids === undefined ? {} : { SUBC_MODEL_IDS: ids }),
|
|
98
|
+
SUBC_VISION_MODELS: visionModelList(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The command for one of the agent's runbook scripts, given its env. */
|
|
103
|
+
export function scriptCommand(agent, relativeScript, args, env, options) {
|
|
104
|
+
return {
|
|
105
|
+
file: 'bash',
|
|
106
|
+
args: [runbookScriptPath(agent, relativeScript), ...args],
|
|
107
|
+
env: scriptEnv(env, options),
|
|
108
|
+
notFound: BASH_MISSING,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The command that starts a launch plan: the agent's runbook script when it
|
|
114
|
+
* has one, otherwise the binary itself, from `binDir` when it is known.
|
|
115
|
+
*/
|
|
116
|
+
export function planCommand(agent, plan, binDir) {
|
|
117
|
+
const io = { stdin: plan.stdin, input: plan.input };
|
|
118
|
+
const { script } = agent.runbook;
|
|
119
|
+
if (script) {
|
|
120
|
+
const command = scriptCommand(agent, script, plan.argv, plan.env);
|
|
121
|
+
command.env.SUBC_TEMPLATE_WORDS = String(plan.templateWords);
|
|
122
|
+
return { ...command, ...io };
|
|
123
|
+
}
|
|
124
|
+
const [bin, ...args] = plan.argv;
|
|
125
|
+
return {
|
|
126
|
+
file: binDir ? path.join(binDir, bin) : bin,
|
|
127
|
+
args,
|
|
128
|
+
env: plan.env,
|
|
129
|
+
...io,
|
|
130
|
+
notFound: () => new Error(`Could not launch \`${bin}\`.`),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Run a command built by planCommand or scriptCommand. */
|
|
135
|
+
export function runCommand(command) {
|
|
136
|
+
return spawnChild(command.file, command.args, command);
|
|
137
|
+
}
|