Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ Other launchers: `apex run opencode`, `apex run claude`, `apex run pi`, `apex ru
Launchers pass extra arguments through unchanged (without a shell), can be overridden by explicit
user arguments, and never run `init`. `apex run claude` sets the gateway URL, token, attribution
flag and model aliases in the child process only, leaving your default Claude provider alone.
`apex run opencode` checks which OpenCode is installed: OpenCode 1 gets `--model`; OpenCode 2,
whose terminal interface has no `--model`, starts with `--standalone` and the model in
`OPENCODE_CONFIG_CONTENT`, so the private server sees the model and, for a config that references
the key variable, your key too.

## Safety and recovery

Expand Down
51 changes: 39 additions & 12 deletions src/assistants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFile } from 'node:child_process';
import { access, readFile, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import { join, delimiter } from 'node:path';
Expand Down Expand Up @@ -96,6 +97,19 @@ export async function executable(command, env = process.env, platform = process.
return null;
}

// OpenCode 1 prints `1.18.34`, OpenCode 2 prints `opencode v2.0.29`, each on a line of its own,
// so a notice naming another version is not mistaken for it. Anything unreadable is treated as 1,
// the launch Apex CLI has always used.
export function openCodeMajor(binary) {
if (!binary) return Promise.resolve(1);
return new Promise(resolve => {
execFile(binary, ['--version'], { timeout: 10000 }, (error, stdout) => {
const match = !error && /^(?:opencode )?v?(\d+)\.\d+\.\d+\s*$/m.exec(stdout);
resolve(match ? Number(match[1]) : 1);
});
});
}

export function locations(home = homedir(), env = process.env, platform = process.platform) {
const config = env.XDG_CONFIG_HOME || join(home, '.config');
const userData = platform === 'darwin' ? join(home, 'Library', 'Application Support')
Expand Down Expand Up @@ -251,13 +265,28 @@ export async function planAssistant(assistant, provider = PROVIDERS.vercel) {
return changes;
}

export function launchOptions(id, env = process.env, provider = defaultProvider(env)) {
const { model, envVar } = provider;
const token = env[envVar];
if (!token?.trim()) throw new Error(`Set ${envVar} in your environment before launching with ${provider.name}.`);
// The key a launch needs, so a missing one can be reported before anything else is checked.
export function launchToken(env, provider) {
const token = env[provider.envVar];
if (!token?.trim()) throw new Error(`Set ${provider.envVar} in your environment before launching with ${provider.name}.`);
return token;
}

export function launchOptions(id, env = process.env, provider = defaultProvider(env), { openCode = 1 } = {}) {
const { model } = provider;
const token = launchToken(env, provider);
const childEnv = { ...env };
switch (id) {
case 'opencode': return { args: ['--model', `${provider.opencode}/${model}`], env: childEnv };
case 'opencode': {
const selected = `${provider.opencode}/${model}`;
if (openCode < 2) return { args: ['--model', selected], env: childEnv };
// OpenCode 2's interface has no --model, and its shared background service never sees this
// environment. A private server (--standalone) does: it reads the model from inline config,
// and the gateway key the config references. Its flags only follow a subcommand.
const inline = env.OPENCODE_CONFIG_CONTENT?.trim() ? parseJson(env.OPENCODE_CONFIG_CONTENT, 'OPENCODE_CONFIG_CONTENT') : {};
childEnv.OPENCODE_CONFIG_CONTENT = JSON.stringify({ ...inline, model: selected });
return { args: ['--standalone'], env: childEnv, afterCommand: true, note: '(OPENCODE_CONFIG_CONTENT model set)' };
}
case 'codex': return { args: ['--profile', provider.codex], env: childEnv };
case 'pi': return { args: ['--provider', provider.pi, '--model', model, '--thinking', 'medium'], env: childEnv };
case 'claude':
Expand All @@ -270,18 +299,16 @@ export function launchOptions(id, env = process.env, provider = defaultProvider(
CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(CONTEXT_WINDOW),
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(MAX_OUTPUT),
});
return { args: ['--model', model], env: childEnv };
return { args: ['--model', model], env: childEnv, note: '(ANTHROPIC_* gateway environment set)' };
default: throw new Error(`Cannot launch ${id}. Use the editor's model selector.`);
}
}

// What `apex run <id>` actually types for you, so the summary can show the command it replaces.
// The placeholder token is never shown: only the argument list is part of the expansion.
const RUN_NOTE = { claude: '(ANTHROPIC_* gateway environment set)' };

export function runExpansion(id, provider) {
const { args } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider);
return [`${id} ${args.join(' ')}`, RUN_NOTE[id]].filter(Boolean).join(' ');
// The placeholder token is never shown: only the argument list and the launch's note are.
export function runExpansion(id, provider, installed = {}) {
const { args, note } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, installed);
return [`${id} ${args.join(' ')}`, note].filter(Boolean).join(' ');
}

// One line per key: the whole object on one line cannot be folded to a terminal, and a step that
Expand Down
27 changes: 22 additions & 5 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env node
import { parseArgs, styleText } from 'node:util';
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import { basename } from 'node:path';
import { randomUUID } from 'node:crypto';
import { IDS, MANUAL_IDS, NAMES, PROVIDERS, PROVIDER_IDS, RUNNABLE, defaultProvider, detect, executable, launchOptions, manualSteps, planAssistant, runExpansion } from './assistants.js';
import { IDS, MANUAL_IDS, NAMES, PROVIDERS, PROVIDER_IDS, RUNNABLE, defaultProvider, detect, executable, launchOptions, launchToken, manualSteps, openCodeMajor, planAssistant, runExpansion } from './assistants.js';
import { removeChange, writeChange } from './config.js';
import { MODE_NOTE, describeFile, resolveMode, runWrites } from './plan.js';
import { appendEntry, batchIds, journalPath, markUndone, newEntry, planUndo, readJournal } from './journal.js';
Expand Down Expand Up @@ -197,6 +197,7 @@ async function planAll(assistants, options, provider) {
name: NAMES[assistant.id],
detected: assistant.detected,
evidence: assistant.evidence || null,
binary: assistant.binary ?? null,
manual: MANUAL_IDS.includes(assistant.id),
error: null,
files: [],
Expand Down Expand Up @@ -378,7 +379,8 @@ async function commandInit(rest) {
ui.dim('Apex CLI never reads, stores or logs the key itself.'),
]);
const runnable = active.map(entry => entry.id).filter(id => RUNNABLE.includes(id));
const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider) }));
const installed = { openCode: await openCodeMajor(active.find(entry => entry.id === 'opencode')?.binary) };
const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider, installed) }));
const commandWidth = ui.columnWidth(rows.map(row => row.command));
ui.section(runnable.length
? `Use these commands to run ${provider.model} with your selected harnesses:`
Expand Down Expand Up @@ -537,6 +539,17 @@ function takeProvider(args) {
return { value: null, rest: args };
}

const isDirectory = path => stat(path).then(info => info.isDirectory(), () => false);

// OpenCode 2 flags belong to the command they follow. The interface (bare, flags first or a
// directory), `run` and `mini` take --standalone; other subcommands such as `auth list` reject it.
async function launchArgs(options, args) {
const [first, ...rest] = args;
if (options.afterCommand && ['run', 'mini'].includes(first)) return [first, ...options.args, ...rest];
if (options.afterCommand && first && !first.startsWith('-') && !await isDirectory(first)) return args;
return [...options.args, ...args];
}

async function commandRun(rest) {
const before = takeProvider(rest);
const [id, ...after] = before.rest;
Expand All @@ -545,14 +558,18 @@ async function commandRun(rest) {
const chosen = before.value ?? behind.value;
if (chosen !== null && !PROVIDER_IDS.includes(chosen)) throw new Error(`Unknown --provider. Choose from: ${PROVIDER_IDS.join(', ')}`);
const passed = behind.rest;
const options = launchOptions(id, process.env, chosen === null ? defaultProvider() : PROVIDERS[chosen]);
const provider = chosen === null ? defaultProvider() : PROVIDERS[chosen];
// A missing key is reported before a missing binary.
launchToken(process.env, provider);
const binary = await executable(id);
if (!binary) throw new Error(`${id} was not found on PATH. Install it first, then run ${APEX} init.`);
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(binary)) {
throw new Error('Windows .cmd/.bat launchers are not executed through a shell. Use WSL or launch the configured assistant directly.');
}
// OpenCode 1 and 2 take the model in different ways, so the launch depends on which is installed.
const options = launchOptions(id, process.env, provider, { openCode: await openCodeMajor(id === 'opencode' && binary) });
const args = passed[0] === '--' ? passed.slice(1) : passed;
const child = spawn(binary, [...options.args, ...args], { env: options.env, stdio: 'inherit', shell: false });
const child = spawn(binary, await launchArgs(options, args), { env: options.env, stdio: 'inherit', shell: false });
await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => { process.exitCode = code ?? (signal === 'SIGINT' ? 130 : 1); resolve(); });
Expand Down
36 changes: 35 additions & 1 deletion test/apex.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { chmod, mkdtemp, mkdir, writeFile, readFile, readdir, stat, lstat, symli
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { PROVIDERS, defaultProvider, detect, locations, planAssistant, launchOptions, manualSteps } from '../src/assistants.js';
import { PROVIDERS, defaultProvider, detect, locations, planAssistant, launchOptions, manualSteps, runExpansion } from '../src/assistants.js';
import { addToml, updateToml, editJson, parseJson, parseToml, readConfig, sha256, writeChange } from '../src/config.js';
import { diffValues, unifiedDiff } from '../src/diff.js';
import { isSecretKey, redactText } from '../src/secrets.js';
Expand Down Expand Up @@ -890,6 +890,40 @@ test('CLI run forwards argument boundaries, child environment and exit status',
assert.equal(output.token, env.CALLSTACK_AUTH_TOKEN);
});

test('CLI run picks the model the way the installed OpenCode accepts it', async context => {
const { home, env } = await fixture(context);
const bin = join(home, 'bin');
await mkdir(bin);
await writeFile(join(bin, 'opencode'), `#!${process.execPath}
if (process.argv[2] === '--version') { console.log(process.env.FAKE_OPENCODE_VERSION); process.exit(0); }
console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OPENCODE_CONFIG_CONTENT }));
`, { mode: 0o755 });
const run = (version, extra = {}, passed = ['--x']) => {
const result = cli(['run', 'opencode', '--', ...passed], { ...env, PATH: bin, FAKE_OPENCODE_VERSION: version, ...extra });
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
};
assert.deepEqual(run('1.18.34'), { args: ['--model', `callstack.ai/${MODEL}`, '--x'] });
assert.deepEqual(run('1.18.34', {}, ['run', 'hi']).args, ['--model', `callstack.ai/${MODEL}`, 'run', 'hi']);
// OpenCode 2's TUI rejects --model; a private server reads the model from inline config.
const v2 = run('opencode v2.0.29', { OPENCODE_CONFIG_CONTENT: '{ // OpenCode reads JSONC here too\n"theme":"dark", }' });
assert.deepEqual(v2.args, ['--standalone', '--x']);
assert.deepEqual(run('2.0.29', {}, ['run', 'hi']).args, ['run', '--standalone', 'hi'], 'v2 flags follow the subcommand');
assert.deepEqual(run('2.0.29', {}, [home]).args, ['--standalone', home], 'a directory starts the interface');
assert.deepEqual(run('2.0.29', {}, ['mini']).args, ['mini', '--standalone']);
assert.deepEqual(run('Update available: 2.1.0\n1.18.34').args, ['--model', `callstack.ai/${MODEL}`, '--x'],
'a notice naming another version is not the version');
assert.deepEqual(run('2.0.29', {}, ['auth', 'list']).args, ['auth', 'list'], 'subcommands without --standalone pass through');
assert.deepEqual(JSON.parse(v2.inline), { theme: 'dark', model: `callstack.ai/${MODEL}` });
assert.deepEqual(run('unreadable').args, ['--model', `callstack.ai/${MODEL}`, '--x'], 'unknown versions keep the v1 launch');
const broken = cli(['run', 'opencode'], { ...env, PATH: bin, FAKE_OPENCODE_VERSION: '2.0.0', OPENCODE_CONFIG_CONTENT: '{' });
assert.equal(broken.status, 1);
assert.match(broken.stderr, /Invalid JSON\/JSONC object in OPENCODE_CONFIG_CONTENT/);

assert.equal(runExpansion('opencode', CALLSTACK), `opencode --model callstack.ai/${MODEL}`);
assert.equal(runExpansion('opencode', CALLSTACK, { openCode: 2 }), 'opencode --standalone (OPENCODE_CONFIG_CONTENT model set)');
});

test('closing the pipe early exits quietly instead of crashing', async context => {
const { env } = await fixture(context);
await seedHome(env);
Expand Down
Loading