diff --git a/README.md b/README.md index 68271a5..3e4cb1b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/assistants.js b/src/assistants.js index 9ea6fb8..526c848 100644 --- a/src/assistants.js +++ b/src/assistants.js @@ -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'; @@ -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') @@ -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': @@ -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 ` 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 diff --git a/src/cli.js b/src/cli.js index 55b4638..8143f37 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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'; @@ -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: [], @@ -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:` @@ -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; @@ -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(); }); diff --git a/test/apex.test.js b/test/apex.test.js index 3af8a24..39ff25d 100644 --- a/test/apex.test.js +++ b/test/apex.test.js @@ -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'; @@ -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);