From f703627b3ab19c99bf9bb86391e5148837bfdd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 16:29:20 +0200 Subject: [PATCH 1/3] fix: launch OpenCode 2 without the --model flag it no longer accepts OpenCode 2's terminal interface dropped --model, so `apex run opencode` failed with "Unrecognized flag: --model". apex now reads `opencode --version`: OpenCode 1 keeps --model; OpenCode 2 starts a private server with --standalone and gets the model through OPENCODE_CONFIG_CONTENT (merged into any inline config already set). The shared background service never sees the child's environment, so --standalone is what makes both the model and the gateway key reach it. OpenCode 2 only accepts flags after a subcommand, so they follow one (`opencode run --standalone ...`). The init summary shows the matching expansion. --- README.md | 3 +++ src/assistants.js | 42 +++++++++++++++++++++++++++++++++++++----- src/cli.js | 19 +++++++++++++++---- test/apex.test.js | 31 ++++++++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 68271a5..d915516 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,9 @@ 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 both the model and your key. ## Safety and recovery diff --git a/src/assistants.js b/src/assistants.js index 9ea6fb8..15a53a3 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,31 @@ 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`. Anything unreadable is +// treated as 1, the launch Apex CLI has always used. +export function openCodeMajor(binary) { + return new Promise(resolve => { + execFile(binary, ['--version'], { timeout: 10000 }, (error, stdout) => { + const match = !error && /(\d+)\.\d+\.\d+/.exec(stdout); + resolve(match ? Number(match[1]) : 1); + }); + }); +} + +// OpenCode 2's TUI 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. +function openCode2Env(env, model) { + let inline = {}; + if (env.OPENCODE_CONFIG_CONTENT?.trim()) { + try { inline = JSON.parse(env.OPENCODE_CONFIG_CONTENT); } catch { inline = null; } + if (!inline || typeof inline !== 'object' || Array.isArray(inline)) { + throw new Error('OPENCODE_CONFIG_CONTENT is not a JSON object, so Apex CLI cannot add the model to it.'); + } + } + return { ...env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...inline, model }) }; +} + 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 +277,18 @@ export async function planAssistant(assistant, provider = PROVIDERS.vercel) { return changes; } -export function launchOptions(id, env = process.env, provider = defaultProvider(env)) { +export function launchOptions(id, env = process.env, provider = defaultProvider(env), { openCode = 1 } = {}) { const { model, envVar } = provider; const token = env[envVar]; if (!token?.trim()) throw new Error(`Set ${envVar} in your environment before launching with ${provider.name}.`); const childEnv = { ...env }; switch (id) { - case 'opencode': return { args: ['--model', `${provider.opencode}/${model}`], env: childEnv }; + case 'opencode': { + const selected = `${provider.opencode}/${model}`; + // OpenCode 2 only takes flags after its subcommand: `opencode run --standalone`. + if (openCode >= 2) return { args: ['--standalone'], env: openCode2Env(childEnv, selected), afterCommand: true }; + return { args: ['--model', selected], env: childEnv }; + } case 'codex': return { args: ['--profile', provider.codex], env: childEnv }; case 'pi': return { args: ['--provider', provider.pi, '--model', model, '--thinking', 'medium'], env: childEnv }; case 'claude': @@ -279,9 +310,10 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( // 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(' '); +export function runExpansion(id, provider, versions = {}) { + const { args } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, versions); + const note = id === 'opencode' && versions.openCode >= 2 ? '(OPENCODE_CONFIG_CONTENT model set)' : RUN_NOTE[id]; + 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..244fef1 100644 --- a/src/cli.js +++ b/src/cli.js @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import { readFile } 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, 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'; @@ -378,7 +378,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 versions = await launchVersions(runnable.includes('opencode') ? await executable('opencode') : null); + const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider, versions) })); 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 +538,11 @@ function takeProvider(args) { return { value: null, rest: args }; } +// OpenCode 1 and 2 take the model in different ways, so the launch depends on which is installed. +async function launchVersions(openCodeBinary) { + return openCodeBinary ? { openCode: await openCodeMajor(openCodeBinary) } : {}; +} + async function commandRun(rest) { const before = takeProvider(rest); const [id, ...after] = before.rest; @@ -545,14 +551,19 @@ 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. + launchOptions(id, 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.'); } + const options = launchOptions(id, process.env, provider, await launchVersions(id === 'opencode' ? binary : null)); const args = passed[0] === '--' ? passed.slice(1) : passed; - const child = spawn(binary, [...options.args, ...args], { env: options.env, stdio: 'inherit', shell: false }); + const leading = options.afterCommand && args[0] && !args[0].startsWith('-') ? 1 : 0; + const argv = [...args.slice(0, leading), ...options.args, ...args.slice(leading)]; + const child = spawn(binary, argv, { 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..ef89c84 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,35 @@ 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: '{"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(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, /OPENCODE_CONFIG_CONTENT is not a JSON object/); + + 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); From 75e024bbc6a4f53fc980f394b6d3076286d7ab0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 17:26:42 +0200 Subject: [PATCH 2/3] fix: only pass --standalone where OpenCode 2 accepts it Review follow-ups: --standalone goes to the interface, `run` and `mini` and is left off other subcommands (`auth list` rejected it). The version is read from a line of its own, init reuses the binary detect found, the key check is its own function, and each launch carries its summary note. --- README.md | 3 ++- src/assistants.js | 32 +++++++++++++++++++------------- src/cli.js | 25 ++++++++++++++++++------- test/apex.test.js | 5 +++++ 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d915516..3e4cb1b 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,8 @@ user arguments, and never run `init`. `apex run claude` sets the gateway URL, to 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 both the model and your key. +`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 15a53a3..2dc0e1e 100644 --- a/src/assistants.js +++ b/src/assistants.js @@ -97,12 +97,13 @@ 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`. Anything unreadable is -// treated as 1, the launch Apex CLI has always used. +// 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) { return new Promise(resolve => { execFile(binary, ['--version'], { timeout: 10000 }, (error, stdout) => { - const match = !error && /(\d+)\.\d+\.\d+/.exec(stdout); + const match = !error && /^(?:opencode )?v?(\d+)\.\d+\.\d+\s*$/m.exec(stdout); resolve(match ? Number(match[1]) : 1); }); }); @@ -277,16 +278,24 @@ export async function planAssistant(assistant, provider = PROVIDERS.vercel) { return changes; } +// 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, envVar } = provider; - const token = env[envVar]; - if (!token?.trim()) throw new Error(`Set ${envVar} in your environment before launching with ${provider.name}.`); + const { model } = provider; + const token = launchToken(env, provider); const childEnv = { ...env }; switch (id) { case 'opencode': { const selected = `${provider.opencode}/${model}`; // OpenCode 2 only takes flags after its subcommand: `opencode run --standalone`. - if (openCode >= 2) return { args: ['--standalone'], env: openCode2Env(childEnv, selected), afterCommand: true }; + if (openCode >= 2) { + return { args: ['--standalone'], env: openCode2Env(childEnv, selected), afterCommand: true, note: '(OPENCODE_CONFIG_CONTENT model set)' }; + } return { args: ['--model', selected], env: childEnv }; } case 'codex': return { args: ['--profile', provider.codex], env: childEnv }; @@ -301,18 +310,15 @@ 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)' }; - +// The placeholder token is never shown: only the argument list and the launch's note are. export function runExpansion(id, provider, versions = {}) { - const { args } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, versions); - const note = id === 'opencode' && versions.openCode >= 2 ? '(OPENCODE_CONFIG_CONTENT model set)' : RUN_NOTE[id]; + const { args, note } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, versions); return [`${id} ${args.join(' ')}`, note].filter(Boolean).join(' '); } diff --git a/src/cli.js b/src/cli.js index 244fef1..98d4d3e 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, openCodeMajor, 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,7 @@ 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 versions = await launchVersions(runnable.includes('opencode') ? await executable('opencode') : null); + const versions = await launchVersions(runnable.includes('opencode') ? active.find(entry => entry.id === 'opencode').binary : null); const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider, versions) })); const commandWidth = ui.columnWidth(rows.map(row => row.command)); ui.section(runnable.length @@ -543,6 +544,18 @@ async function launchVersions(openCodeBinary) { return openCodeBinary ? { openCode: await openCodeMajor(openCodeBinary) } : {}; } +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) { + if (!options.afterCommand) return [...options.args, ...args]; + const [first] = args; + if (first === 'run' || first === 'mini') return [first, ...options.args, ...args.slice(1)]; + if (first === undefined || first.startsWith('-') || await isDirectory(first)) return [...options.args, ...args]; + return args; +} + async function commandRun(rest) { const before = takeProvider(rest); const [id, ...after] = before.rest; @@ -553,7 +566,7 @@ async function commandRun(rest) { const passed = behind.rest; const provider = chosen === null ? defaultProvider() : PROVIDERS[chosen]; // A missing key is reported before a missing binary. - launchOptions(id, process.env, provider); + 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)) { @@ -561,9 +574,7 @@ async function commandRun(rest) { } const options = launchOptions(id, process.env, provider, await launchVersions(id === 'opencode' ? binary : null)); const args = passed[0] === '--' ? passed.slice(1) : passed; - const leading = options.afterCommand && args[0] && !args[0].startsWith('-') ? 1 : 0; - const argv = [...args.slice(0, leading), ...options.args, ...args.slice(leading)]; - const child = spawn(binary, argv, { 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 ef89c84..980addc 100644 --- a/test/apex.test.js +++ b/test/apex.test.js @@ -909,6 +909,11 @@ console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OP const v2 = run('opencode v2.0.29', { OPENCODE_CONFIG_CONTENT: '{"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: '{' }); From 8891dcb4055b7eebbdd7fe4eb0ae9661d50196d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 17:34:39 +0200 Subject: [PATCH 3/3] refactor: simplify the OpenCode 2 launch The inline config is read with the same JSONC parser as config files, so a comment or trailing comma OpenCode itself accepts no longer stops the launch. The v2 env is built in launchOptions like Claude's, the version plumbing helper is gone, and openCodeMajor answers 1 without a binary. --- src/assistants.js | 31 ++++++++++--------------------- src/cli.js | 21 ++++++++------------- test/apex.test.js | 4 ++-- 3 files changed, 20 insertions(+), 36 deletions(-) diff --git a/src/assistants.js b/src/assistants.js index 2dc0e1e..526c848 100644 --- a/src/assistants.js +++ b/src/assistants.js @@ -101,6 +101,7 @@ export async function executable(command, env = process.env, platform = process. // 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); @@ -109,20 +110,6 @@ export function openCodeMajor(binary) { }); } -// OpenCode 2's TUI 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. -function openCode2Env(env, model) { - let inline = {}; - if (env.OPENCODE_CONFIG_CONTENT?.trim()) { - try { inline = JSON.parse(env.OPENCODE_CONFIG_CONTENT); } catch { inline = null; } - if (!inline || typeof inline !== 'object' || Array.isArray(inline)) { - throw new Error('OPENCODE_CONFIG_CONTENT is not a JSON object, so Apex CLI cannot add the model to it.'); - } - } - return { ...env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...inline, model }) }; -} - 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') @@ -292,11 +279,13 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( switch (id) { case 'opencode': { const selected = `${provider.opencode}/${model}`; - // OpenCode 2 only takes flags after its subcommand: `opencode run --standalone`. - if (openCode >= 2) { - return { args: ['--standalone'], env: openCode2Env(childEnv, selected), afterCommand: true, note: '(OPENCODE_CONFIG_CONTENT model set)' }; - } - return { args: ['--model', selected], env: childEnv }; + 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 }; @@ -317,8 +306,8 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( // 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 and the launch's note are. -export function runExpansion(id, provider, versions = {}) { - const { args, note } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, versions); +export function runExpansion(id, provider, installed = {}) { + const { args, note } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, installed); return [`${id} ${args.join(' ')}`, note].filter(Boolean).join(' '); } diff --git a/src/cli.js b/src/cli.js index 98d4d3e..8143f37 100644 --- a/src/cli.js +++ b/src/cli.js @@ -379,8 +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 versions = await launchVersions(runnable.includes('opencode') ? active.find(entry => entry.id === 'opencode').binary : null); - const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider, versions) })); + 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:` @@ -539,21 +539,15 @@ function takeProvider(args) { return { value: null, rest: args }; } -// OpenCode 1 and 2 take the model in different ways, so the launch depends on which is installed. -async function launchVersions(openCodeBinary) { - return openCodeBinary ? { openCode: await openCodeMajor(openCodeBinary) } : {}; -} - 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) { - if (!options.afterCommand) return [...options.args, ...args]; - const [first] = args; - if (first === 'run' || first === 'mini') return [first, ...options.args, ...args.slice(1)]; - if (first === undefined || first.startsWith('-') || await isDirectory(first)) return [...options.args, ...args]; - return 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) { @@ -572,7 +566,8 @@ async function commandRun(rest) { 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.'); } - const options = launchOptions(id, process.env, provider, await launchVersions(id === 'opencode' ? binary : null)); + // 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, await launchArgs(options, args), { env: options.env, stdio: 'inherit', shell: false }); await new Promise((resolve, reject) => { diff --git a/test/apex.test.js b/test/apex.test.js index 980addc..39ff25d 100644 --- a/test/apex.test.js +++ b/test/apex.test.js @@ -906,7 +906,7 @@ console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OP 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: '{"theme":"dark"}' }); + 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'); @@ -918,7 +918,7 @@ console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OP 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, /OPENCODE_CONFIG_CONTENT is not a JSON object/); + 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)');