subconscious-cli 6.0.1 → 6.2.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 +167 -43
- package/bin/agent-data.js +278 -0
- package/bin/agent-help.js +114 -0
- package/bin/agent-install.js +238 -0
- package/bin/agent-launch.js +199 -0
- package/bin/agent-spawn.js +137 -0
- package/bin/agents.js +236 -931
- package/bin/auth.js +9 -1
- package/bin/claude-picker.js +143 -0
- package/bin/cli.js +62 -2
- package/bin/clipboard-image.js +2 -0
- package/bin/feedback.js +14 -3
- 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 +10 -9
- package/bin/models.js +25 -13
- 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 +21 -0
- package/bin/profile-settings.js +129 -0
- package/bin/profiles.js +29 -424
- package/bin/runbook/README.md +77 -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 +462 -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 +186 -0
- package/bin/runbook/copilot/install.sh +45 -63
- package/bin/runbook/cursor/agent.json +138 -0
- package/bin/runbook/cursor/install.sh +8 -35
- package/bin/runbook/deepseek-harness/agent.json +164 -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 +159 -0
- package/bin/runbook/opencode/install.sh +9 -183
- package/bin/runbook/opencode/opencode-plugin.d.ts +10 -3
- package/bin/runbook/opencode/subconscious-image-window.ts +24 -0
- package/bin/runbook/pi/agent.json +217 -0
- package/bin/runbook/pi/install.sh +18 -44
- package/bin/runbook/pi/run.sh +6 -18
- package/bin/runbook/pi/subconscious-compaction.ts +3 -1
- package/bin/runbook/shared.json +57 -0
- package/bin/sessions.js +53 -48
- package/bin/tui.js +13 -0
- package/bin/update-check.js +35 -1
- package/bin/upgrade.js +27 -34
- package/bin/usage.js +21 -10
- package/bin/windows/agents.js +28 -60
- package/bin/windows/common.js +2 -28
- package/bin/windows/launch.js +252 -231
- package/bin/windows/process.js +12 -1
- package/bin/windows/setup.js +29 -9
- package/package.json +2 -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/install.sh +0 -154
- package/bin/runbook/subconscious-code/run.sh +0 -21
- package/bin/windows/claude.js +0 -100
- package/bin/windows/install.js +0 -188
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentBinary,
|
|
3
|
+
agentCommandName,
|
|
4
|
+
agentInputs,
|
|
5
|
+
agentSetupActions,
|
|
6
|
+
installAdvice,
|
|
7
|
+
} from './agent-data.js';
|
|
8
|
+
import { c } from './colors.js';
|
|
9
|
+
import { profileSettingsForAgent, resolvedProfileValues } from './profiles.js';
|
|
10
|
+
|
|
11
|
+
export function isAgentHelpRequest(argv = []) {
|
|
12
|
+
return ['help', '-h', '--help'].includes(argv[0]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function displayProfileValue(setting, value, values) {
|
|
16
|
+
if (setting.type === 'secret') {
|
|
17
|
+
if (value) return '(set)';
|
|
18
|
+
return setting.key !== 'API_KEY' && values.API_KEY
|
|
19
|
+
? '(shared key)'
|
|
20
|
+
: '(not set)';
|
|
21
|
+
}
|
|
22
|
+
if (!value && setting.model) return 'UNSET';
|
|
23
|
+
return value || '(auto)';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function optionText(description) {
|
|
27
|
+
if (typeof description === 'string') return description;
|
|
28
|
+
return description[process.platform] ?? description.default;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Static options, then documented flags, then the pass-through argument. */
|
|
32
|
+
function helpOptions(agent) {
|
|
33
|
+
const { options, args } = agent.help;
|
|
34
|
+
const flags = agentInputs(agent)
|
|
35
|
+
.filter((input) => input.flag && input.help)
|
|
36
|
+
.map((input) => [
|
|
37
|
+
input.metavar ? `${input.flag} ${input.metavar}` : input.flag,
|
|
38
|
+
input.help,
|
|
39
|
+
]);
|
|
40
|
+
const listed = [...options, ...flags, ...(args ? [args] : [])].map(
|
|
41
|
+
([option, description]) => [option, optionText(description)],
|
|
42
|
+
);
|
|
43
|
+
const headless =
|
|
44
|
+
agent.launch?.headless_argv &&
|
|
45
|
+
!listed.some(([option]) => option.startsWith('headless'));
|
|
46
|
+
return headless
|
|
47
|
+
? [
|
|
48
|
+
...listed,
|
|
49
|
+
['headless PROMPT', 'Run one task without prompting, then exit'],
|
|
50
|
+
]
|
|
51
|
+
: listed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printSettings(agent, profile) {
|
|
55
|
+
const settings = profileSettingsForAgent(agent.id);
|
|
56
|
+
const values = resolvedProfileValues(profile);
|
|
57
|
+
const width = Math.max(0, ...settings.map((setting) => setting.key.length));
|
|
58
|
+
console.log(
|
|
59
|
+
` ${c.bold}Profile settings${c.reset} ${c.dim}(${profile?.name || 'default'})${c.reset}`,
|
|
60
|
+
);
|
|
61
|
+
for (const setting of settings) {
|
|
62
|
+
const value = displayProfileValue(setting, values[setting.key], values);
|
|
63
|
+
console.log(
|
|
64
|
+
` ${c.cyan}${setting.key.padEnd(width)}${c.reset} ${value}`,
|
|
65
|
+
);
|
|
66
|
+
console.log(
|
|
67
|
+
` ${' '.repeat(width)} ${c.dim}${setting.description}${c.reset}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function printSetupHints(agent, profile) {
|
|
73
|
+
const command = agentCommandName(agent);
|
|
74
|
+
console.log(
|
|
75
|
+
`\n Edit the env file with ${c.cyan}subc -p ${profile?.name || 'default'} config edit${c.reset}.`,
|
|
76
|
+
);
|
|
77
|
+
if (agentBinary(agent)) {
|
|
78
|
+
const { command: install, url } = installAdvice(agent);
|
|
79
|
+
const how = install
|
|
80
|
+
? `with ${c.cyan}${install}${c.reset}, or see ${c.cyan}${url}${c.reset}`
|
|
81
|
+
: `from ${c.cyan}${url}${c.reset}`;
|
|
82
|
+
console.log(` subc doesn't install ${agent.name}. Install it ${how}.`);
|
|
83
|
+
}
|
|
84
|
+
const actions = agentSetupActions(agent);
|
|
85
|
+
if (actions.includes('install')) {
|
|
86
|
+
console.log(
|
|
87
|
+
` Install the persistent integration with ${c.cyan}subc ${command} install${c.reset}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (actions.includes('uninstall')) {
|
|
91
|
+
console.log(
|
|
92
|
+
` Remove it with ${c.cyan}subc ${command} uninstall${c.reset}.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function printAgentHelp(agent, profile) {
|
|
98
|
+
const options = helpOptions(agent);
|
|
99
|
+
const width = Math.max(0, ...options.map(([option]) => option.length));
|
|
100
|
+
console.log(`\n ${c.bold}${agent.name} + Subconscious${c.reset}\n`);
|
|
101
|
+
console.log(` ${agent.help.behavior}\n`);
|
|
102
|
+
console.log(` ${c.bold}Usage${c.reset}\n ${agent.help.usage}\n`);
|
|
103
|
+
if (options.length) {
|
|
104
|
+
console.log(` ${c.bold}Commands and options${c.reset}`);
|
|
105
|
+
for (const [option, description] of options)
|
|
106
|
+
console.log(
|
|
107
|
+
` ${c.cyan}${option.padEnd(width)}${c.reset} ${description}`,
|
|
108
|
+
);
|
|
109
|
+
console.log();
|
|
110
|
+
}
|
|
111
|
+
printSettings(agent, profile);
|
|
112
|
+
printSetupHints(agent, profile);
|
|
113
|
+
console.log();
|
|
114
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFileSync } 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
|
+
installAdvice,
|
|
13
|
+
} from './agent-data.js';
|
|
14
|
+
import { c } from './colors.js';
|
|
15
|
+
import { compareVersions } from './update-check.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Common locations a freshly-installed coding-agent binary lands in but which
|
|
19
|
+
* are often NOT on the current process's PATH (e.g. aider/claude install into
|
|
20
|
+
* `~/.local/bin`; npm globals into the npm prefix bin). Best-effort, deduped.
|
|
21
|
+
*/
|
|
22
|
+
export function candidateBinDirs() {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
const dirs = [];
|
|
25
|
+
|
|
26
|
+
if (process.platform === 'win32') {
|
|
27
|
+
if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm'));
|
|
28
|
+
if (process.env.USERPROFILE) {
|
|
29
|
+
dirs.push(path.join(process.env.USERPROFILE, '.local', 'bin'));
|
|
30
|
+
}
|
|
31
|
+
if (home) dirs.push(path.join(home, '.local', 'bin'));
|
|
32
|
+
} else {
|
|
33
|
+
dirs.push(path.join(home, '.local', 'bin'));
|
|
34
|
+
dirs.push(path.join(home, '.cargo', 'bin'));
|
|
35
|
+
dirs.push('/opt/homebrew/bin');
|
|
36
|
+
dirs.push('/usr/local/bin');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// npm global bin (best-effort — npm may be absent).
|
|
40
|
+
try {
|
|
41
|
+
const prefix = execFileSync('npm', ['prefix', '-g'], {
|
|
42
|
+
encoding: 'utf-8',
|
|
43
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
44
|
+
}).trim();
|
|
45
|
+
if (prefix) {
|
|
46
|
+
dirs.push(
|
|
47
|
+
process.platform === 'win32' ? prefix : path.join(prefix, 'bin'),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// npm not available — skip.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Dedupe, drop empties.
|
|
55
|
+
return [...new Set(dirs.filter(Boolean))];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The binary directory the agent's own installer uses, if it declares one. */
|
|
59
|
+
export function preferredBinDirsForAgent(
|
|
60
|
+
agent,
|
|
61
|
+
environment = process.env,
|
|
62
|
+
home = os.homedir(),
|
|
63
|
+
) {
|
|
64
|
+
return agentInstallDirs(agent, environment, home);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Executable extensions to probe (Windows uses PATHEXT). */
|
|
68
|
+
function binExts() {
|
|
69
|
+
return process.platform === 'win32'
|
|
70
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
|
|
71
|
+
: [''];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve `bin` against PATH plus the candidate bin dirs. Returns the directory
|
|
76
|
+
* containing the executable if found, otherwise null. Searching the candidate
|
|
77
|
+
* dirs lets us find binaries installed this session that aren't on PATH yet.
|
|
78
|
+
*/
|
|
79
|
+
async function resolveBinPath(bin, preferredDirs = []) {
|
|
80
|
+
const pathDirs = (process.env.PATH || '')
|
|
81
|
+
.split(path.delimiter)
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
const dirs = [
|
|
84
|
+
...new Set([...preferredDirs, ...pathDirs, ...candidateBinDirs()]),
|
|
85
|
+
];
|
|
86
|
+
const exts = binExts();
|
|
87
|
+
for (const dir of dirs) {
|
|
88
|
+
for (const ext of exts) {
|
|
89
|
+
const candidate = path.join(dir, bin + ext);
|
|
90
|
+
try {
|
|
91
|
+
await fs.access(candidate, fsConstants.F_OK);
|
|
92
|
+
return dir;
|
|
93
|
+
} catch {
|
|
94
|
+
// keep scanning
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build a PATH string with `extraDirs` prepended (deduped against PATH).
|
|
103
|
+
* Returns the augmented PATH value for use in a child env.
|
|
104
|
+
*/
|
|
105
|
+
export function augmentPath(
|
|
106
|
+
extraDirs,
|
|
107
|
+
preferredDir,
|
|
108
|
+
currentPath = process.env.PATH || '',
|
|
109
|
+
) {
|
|
110
|
+
const current = currentPath.split(path.delimiter).filter(Boolean);
|
|
111
|
+
const seen = new Set(current);
|
|
112
|
+
const prepend = extraDirs.filter(
|
|
113
|
+
(dir, index) =>
|
|
114
|
+
dir &&
|
|
115
|
+
dir !== preferredDir &&
|
|
116
|
+
!seen.has(dir) &&
|
|
117
|
+
extraDirs.indexOf(dir) === index,
|
|
118
|
+
);
|
|
119
|
+
const remaining = current.filter((dir) => dir !== preferredDir);
|
|
120
|
+
return [
|
|
121
|
+
...(preferredDir ? [preferredDir] : []),
|
|
122
|
+
...prepend,
|
|
123
|
+
...remaining,
|
|
124
|
+
].join(path.delimiter);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function waitForEnter(prompt) {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const rl = readline.createInterface({
|
|
130
|
+
input: process.stdin,
|
|
131
|
+
output: process.stdout,
|
|
132
|
+
});
|
|
133
|
+
rl.question(prompt, () => {
|
|
134
|
+
rl.close();
|
|
135
|
+
resolve();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Print the install command and official install page; subc runs neither. */
|
|
141
|
+
function printInstallAdvice(agent) {
|
|
142
|
+
const { command, url } = installAdvice(agent);
|
|
143
|
+
if (command) console.error(` ${c.cyan}${command}${c.reset}`);
|
|
144
|
+
console.error(
|
|
145
|
+
` ${c.dim}Official install page:${c.reset} ${c.cyan}${url}${c.reset}\n`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Return the directory that holds the agent's binary. subc never installs a
|
|
151
|
+
* third-party agent: when the binary is missing it prints how to install it
|
|
152
|
+
* and exits 127, in every shell.
|
|
153
|
+
*/
|
|
154
|
+
export async function ensureInstalled(agent) {
|
|
155
|
+
const bin = agentBinary(agent);
|
|
156
|
+
const existing = await resolveBinPath(bin, preferredBinDirsForAgent(agent));
|
|
157
|
+
if (existing) return existing;
|
|
158
|
+
|
|
159
|
+
console.error(
|
|
160
|
+
`\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${bin}\` not found on PATH).${c.reset}`,
|
|
161
|
+
);
|
|
162
|
+
console.error(
|
|
163
|
+
` subc doesn't install ${agent.name}. Install it yourself, then re-run ${c.cyan}subc ${agentCommandName(agent)}${c.reset}.\n`,
|
|
164
|
+
);
|
|
165
|
+
printInstallAdvice(agent);
|
|
166
|
+
process.exit(127);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseClaudeVersion(text) {
|
|
170
|
+
const match = String(text ?? '').match(/v?(\d+\.\d+\.\d+)/);
|
|
171
|
+
return match?.[1] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const MIN_CLAUDE_CODE_VERSION =
|
|
175
|
+
agentById('claude-code').install.minimum_version;
|
|
176
|
+
export const MIN_CODEX_VERSION = agentById('codex').install.minimum_version;
|
|
177
|
+
|
|
178
|
+
export function claudeVersionNeedsUpgrade(
|
|
179
|
+
installed,
|
|
180
|
+
minimum = MIN_CLAUDE_CODE_VERSION,
|
|
181
|
+
) {
|
|
182
|
+
return versionNeedsUpgrade(installed, minimum);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function versionNeedsUpgrade(installed, minimum) {
|
|
186
|
+
return Boolean(
|
|
187
|
+
installed && minimum && compareVersions(installed, minimum) < 0,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function readClaudeVersion(bin, binDir, options = {}) {
|
|
192
|
+
const execFile = options.execFileSync || execFileSync;
|
|
193
|
+
try {
|
|
194
|
+
const stdout = execFile(bin, ['--version'], {
|
|
195
|
+
encoding: 'utf-8',
|
|
196
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
197
|
+
env: {
|
|
198
|
+
...process.env,
|
|
199
|
+
PATH: augmentPath(
|
|
200
|
+
[binDir, ...candidateBinDirs()].filter(Boolean),
|
|
201
|
+
binDir,
|
|
202
|
+
),
|
|
203
|
+
},
|
|
204
|
+
timeout: options.timeoutMs ?? 3000,
|
|
205
|
+
});
|
|
206
|
+
return parseClaudeVersion(
|
|
207
|
+
typeof stdout === 'string' ? stdout : stdout?.toString?.(),
|
|
208
|
+
);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Warn, without blocking, when the agent is older than its minimum version. */
|
|
215
|
+
export async function ensureMinimumVersion(
|
|
216
|
+
agent,
|
|
217
|
+
binDir,
|
|
218
|
+
{ mayPrompt = true } = {},
|
|
219
|
+
) {
|
|
220
|
+
const minimum = agent.install.minimum_version;
|
|
221
|
+
if (!minimum) return;
|
|
222
|
+
const version = readClaudeVersion(agentBinary(agent), binDir);
|
|
223
|
+
if (!versionNeedsUpgrade(version, minimum)) return;
|
|
224
|
+
|
|
225
|
+
console.error(
|
|
226
|
+
`\n Minimum supported ${agent.name} version is ${minimum}. Your version is ${version}. Upgrade to get the best experience.\n`,
|
|
227
|
+
);
|
|
228
|
+
console.error(` Upgrade it with:`);
|
|
229
|
+
printInstallAdvice(agent);
|
|
230
|
+
if (
|
|
231
|
+
mayPrompt &&
|
|
232
|
+
process.stdin.isTTY === true &&
|
|
233
|
+
process.stdout.isTTY === true
|
|
234
|
+
) {
|
|
235
|
+
await waitForEnter(` ${c.dim}Press Enter to continue.${c.reset} `);
|
|
236
|
+
console.error('');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -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
|
+
}
|