also looking at this
feat(engine): answer texts with Codex as well as Claude Code
#7
18 files
+1080
-142
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,378 @@ | ||
| 1 | import type { Subprocess } from 'bun' | |
| 2 | import type { Engine, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine' | |
| 3 | import { randomUUID } from 'node:crypto' | |
| 4 | import { rmSync } from 'node:fs' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | import { join } from 'node:path' | |
| 7 | import process from 'node:process' | |
| 8 | import { PROBE_TIMEOUT_MS } from './engine' | |
| 9 | import { truncate } from './format' | |
| 10 | ||
| 11 | /** | |
| 12 | * Runs a prompt through the OpenAI Codex CLI in its non-interactive mode | |
| 13 | * (`codex exec`). | |
| 14 | * | |
| 15 | * Same reasoning as the Claude engine: going through the CLI uses whatever | |
| 16 | * account it is logged into - a ChatGPT plan via `codex login`, no API key | |
| 17 | * needed - and brings Codex's own toolset and each repository's AGENTS.md. | |
| 18 | * | |
| 19 | * Four things about this CLI differ from Claude Code and shape everything | |
| 20 | * below. They were confirmed against a real run, not read off documentation. | |
| 21 | * | |
| 22 | * 1. Resuming is a subcommand, `codex exec resume <id> <prompt>`, not a flag. | |
| 23 | * It accepts neither `-C/--cd` nor `-s/--sandbox`, so a resumed run takes | |
| 24 | * its working directory from the recorded session. Session lookup is | |
| 25 | * filtered by cwd, so the process still has to be spawned in the right | |
| 26 | * directory. | |
| 27 | * 2. There is no `--append-system-prompt`. Uplink's instructions are prepended | |
| 28 | * to the prompt instead, which works on every version. | |
| 29 | * 3. Codex refuses to run outside a git repository without | |
| 30 | * `--skip-git-repo-check`, and Uplink's default working directory is the | |
| 31 | * home directory. The flag is mandatory, not optional. | |
| 32 | * 4. The JSONL flag is spelled `--json` on some versions and | |
| 33 | * `--experimental-json` on others, and without it the CLI prints prose | |
| 34 | * there is nothing to parse. It is resolved against the installed binary | |
| 35 | * rather than hardcoded. | |
| 36 | */ | |
| 37 | ||
| 38 | export interface CodexEngineOptions { | |
| 39 | bin: string | |
| 40 | model?: string | null | |
| 41 | /** | |
| 42 | * How much the agent may do without asking. `bypass` is the equivalent of | |
| 43 | * Claude Code's `bypassPermissions`, which is what Uplink has always used: | |
| 44 | * the point of the product is that nobody is at the Mac to approve anything. | |
| 45 | */ | |
| 46 | permission: CodexPermission | |
| 47 | systemPrompt: string | |
| 48 | timeoutMs: number | |
| 49 | env?: Record<string, string | undefined> | |
| 50 | } | |
| 51 | ||
| 52 | export type CodexPermission = 'bypass' | 'danger-full-access' | 'workspace-write' | 'read-only' | |
| 53 | ||
| 54 | export const CODEX_PERMISSIONS: readonly CodexPermission[] = ['bypass', 'danger-full-access', 'workspace-write', 'read-only'] | |
| 55 | ||
| 56 | /** A 401 from the backend, which is what a signed-out CLI produces. */ | |
| 57 | const UNAUTHORIZED = /\b401\b|unauthorized|missing bearer|not logged in|no codex credentials/i | |
| 58 | ||
| 59 | export class CodexEngine implements Engine { | |
| 60 | readonly id = 'codex' as const | |
| 61 | readonly label = 'Codex' | |
| 62 | readonly authFailureHint = 'run "codex login" on the Mac (or "codex login --device-auth" over SSH)' | |
| 63 | ||
| 64 | constructor(private readonly options: CodexEngineOptions) {} | |
| 65 | ||
| 66 | /** | |
| 67 | * `codex login status` exits 0 and says which account when signed in. Unlike | |
| 68 | * Claude Code, proof of life costs nothing here: no turn is spent, so this | |
| 69 | * can be checked as often as the UI likes. | |
| 70 | */ | |
| 71 | async probe(): Promise<EngineProbe> { | |
| 72 | try { | |
| 73 | const proc = Bun.spawn([this.options.bin, 'login', 'status'], { | |
| 74 | cwd: tmpdir(), | |
| 75 | env: this.env(), | |
| 76 | stdin: 'ignore', | |
| 77 | stdout: 'pipe', | |
| 78 | stderr: 'pipe', | |
| 79 | }) | |
| 80 | const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS) | |
| 81 | const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) | |
| 82 | const exitCode = await proc.exited | |
| 83 | clearTimeout(timer) | |
| 84 | const said = truncate((out.trim() || err.trim()) || 'Codex said nothing', 140) | |
| 85 | return exitCode === 0 | |
| 86 | ? { ok: true, detail: said } | |
| 87 | : { ok: false, detail: said === 'Codex said nothing' ? 'Not signed in yet.' : said } | |
| 88 | } | |
| 89 | catch { | |
| 90 | return { ok: false, detail: `Could not run ${this.options.bin}. Is Codex installed?` } | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Which spelling of the JSONL flag this binary offers, asked once per binary | |
| 96 | * per process. The two are not interchangeable and passing the wrong one | |
| 97 | * fails silently: the CLI prints prose instead of events and the run looks | |
| 98 | * empty rather than broken. `--help` is inert, so asking is cheap and safe. | |
| 99 | */ | |
| 100 | resolveJsonFlag(): Promise<string> { | |
| 101 | return resolveJsonFlag(this.options.bin, this.env()) | |
| 102 | } | |
| 103 | ||
| 104 | args(request: EngineRequest, jsonFlag: string, lastMessageFile: string): string[] { | |
| 105 | const prompt = `${this.options.systemPrompt}\n\n---\n\n${request.prompt}` | |
| 106 | ||
| 107 | // `codex exec resume` accepts a much shorter flag list than `codex exec`: | |
| 108 | // `--sandbox` and `--cd` are both rejected with "unexpected argument" and | |
| 109 | // exit 2. So the sandbox is set through `-c sandbox_mode=`, which both | |
| 110 | // forms accept, and only a fresh run gets `--cd`. | |
| 111 | const permission = this.options.permission === 'bypass' | |
| 112 | ? ['--dangerously-bypass-approvals-and-sandbox'] | |
| 113 | : ['--config', `sandbox_mode="${this.options.permission}"`] | |
| 114 | ||
| 115 | const args = request.sessionId | |
| 116 | ? ['exec', 'resume', request.sessionId] | |
| 117 | : ['exec'] | |
| 118 | ||
| 119 | args.push(jsonFlag, '--skip-git-repo-check', '--output-last-message', lastMessageFile) | |
| 120 | // Resume has no --cd: it takes the working directory from the recorded | |
| 121 | // session, which is why the process is spawned in `request.cwd` regardless. | |
| 122 | if (!request.sessionId) | |
| 123 | args.push('--cd', request.cwd) | |
| 124 | args.push(...permission) | |
| 125 | if (this.options.model) | |
| 126 | args.push('--model', this.options.model) | |
| 127 | args.push(prompt) | |
| 128 | return args | |
| 129 | } | |
| 130 | ||
| 131 | run(request: EngineRequest): EngineRun { | |
| 132 | const started = Date.now() | |
| 133 | let cancelled = false | |
| 134 | let timedOut = false | |
| 135 | const lastMessageFile = join(tmpdir(), `uplink-codex-${randomUUID()}.txt`) | |
| 136 | ||
| 137 | let proc: Subprocess<'ignore', 'pipe', 'pipe'> | null = null | |
| 138 | const killTree = (): void => { | |
| 139 | if (!proc) | |
| 140 | return | |
| 141 | try { | |
| 142 | process.kill(-proc.pid, 'SIGTERM') | |
| 143 | } | |
| 144 | catch { | |
| 145 | proc.kill('SIGTERM') | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | let timer: ReturnType<typeof setTimeout> | null = null | |
| 150 | ||
| 151 | const done = (async (): Promise<EngineResult> => { | |
| 152 | const jsonFlag = await this.resolveJsonFlag() | |
| 153 | ||
| 154 | proc = Bun.spawn([this.options.bin, ...this.args(request, jsonFlag, lastMessageFile)], { | |
| 155 | cwd: request.cwd, | |
| 156 | stdin: 'ignore', | |
| 157 | stdout: 'pipe', | |
| 158 | stderr: 'pipe', | |
| 159 | env: this.env(), | |
| 160 | // Its own process group, so stopping a run also stops what the agent | |
| 161 | // started. Killing only `codex` leaves those holding stdout open and | |
| 162 | // the run never ends. | |
| 163 | detached: true, | |
| 164 | }) | |
| 165 | ||
| 166 | // Cancelling before the process existed still has to take effect. | |
| 167 | if (cancelled) { | |
| 168 | killTree() | |
| 169 | return failure('Stopped.', request.sessionId ?? null, Date.now() - started) | |
| 170 | } | |
| 171 | ||
| 172 | timer = setTimeout(() => { | |
| 173 | timedOut = true | |
| 174 | killTree() | |
| 175 | }, this.options.timeoutMs) | |
| 176 | ||
| 177 | let sessionId = request.sessionId ?? null | |
| 178 | let lastText = '' | |
| 179 | let turns = 0 | |
| 180 | let failed: string | null = null | |
| 181 | ||
| 182 | const handle = (line: string): void => { | |
| 183 | const event = parseLine(line) | |
| 184 | if (!event) | |
| 185 | return | |
| 186 | switch (event.type) { | |
| 187 | case 'thread.started': { | |
| 188 | const id = typeof event.thread_id === 'string' ? event.thread_id : null | |
| 189 | if (id && id !== sessionId) { | |
| 190 | sessionId = id | |
| 191 | request.onEvent?.({ kind: 'session', sessionId: id }) | |
| 192 | } | |
| 193 | break | |
| 194 | } | |
| 195 | case 'item.completed': { | |
| 196 | const item = event.item as Record<string, any> | undefined | |
| 197 | if (!item) | |
| 198 | break | |
| 199 | if (item.type === 'agent_message') { | |
| 200 | const text = String(item.text ?? '') | |
| 201 | if (text.trim()) { | |
| 202 | lastText = text | |
| 203 | request.onEvent?.({ kind: 'text', text }) | |
| 204 | } | |
| 205 | } | |
| 206 | // `reasoning` is the model thinking aloud, not an action worth | |
| 207 | // texting as progress. Everything else is something it did. | |
| 208 | else if (item.type !== 'reasoning') { | |
| 209 | request.onEvent?.({ kind: 'tool', name: String(item.type ?? 'tool'), summary: summarizeItem(item) }) | |
| 210 | } | |
| 211 | break | |
| 212 | } | |
| 213 | case 'turn.completed': | |
| 214 | turns += 1 | |
| 215 | break | |
| 216 | case 'turn.failed': | |
| 217 | failed = errorText(event) ?? 'the turn failed' | |
| 218 | break | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | const stderr = new Response(proc.stderr).text() | |
| 223 | const decoder = new TextDecoder() | |
| 224 | let buffered = '' | |
| 225 | for await (const bytes of proc.stdout) { | |
| 226 | buffered += decoder.decode(bytes, { stream: true }) | |
| 227 | let newline = buffered.indexOf('\n') | |
| 228 | while (newline !== -1) { | |
| 229 | handle(buffered.slice(0, newline)) | |
| 230 | buffered = buffered.slice(newline + 1) | |
| 231 | newline = buffered.indexOf('\n') | |
| 232 | } | |
| 233 | } | |
| 234 | if (buffered.trim()) | |
| 235 | handle(buffered) | |
| 236 | ||
| 237 | const exitCode = await proc.exited | |
| 238 | if (timer) | |
| 239 | clearTimeout(timer) | |
| 240 | const errorOutput = (await stderr).trim() | |
| 241 | const durationMs = Date.now() - started | |
| 242 | ||
| 243 | // The last message is written to a file as well as streamed, so a stream | |
| 244 | // this version spells differently still yields the answer. | |
| 245 | const fromFile = await readAndRemove(lastMessageFile) | |
| 246 | ||
| 247 | if (cancelled) | |
| 248 | return failure('Stopped.', sessionId, durationMs) | |
| 249 | if (timedOut) | |
| 250 | return failure(`Timed out after ${Math.round(this.options.timeoutMs / 60000)} minutes.`, sessionId, durationMs) | |
| 251 | ||
| 252 | const text = (fromFile || lastText).trim() | |
| 253 | ||
| 254 | if (exitCode === 0 && !failed) { | |
| 255 | return { | |
| 256 | ok: true, | |
| 257 | text: text || 'Done.', | |
| 258 | sessionId, | |
| 259 | // Codex reports tokens, never a cost. A figure invented from a price | |
| 260 | // table would read as measured, the way Claude's genuinely is. | |
| 261 | costCents: null, | |
| 262 | durationMs, | |
| 263 | turns: turns || null, | |
| 264 | authFailure: false, | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | const reason = failed ?? errorOutput ?? '' | |
| 269 | const said = truncate(reason || text || `codex exited with code ${exitCode}`, 400) | |
| 270 | return { ...failure(said, sessionId, durationMs), authFailure: await this.looksUnauthenticated(said) } | |
| 271 | })() | |
| 272 | ||
| 273 | return { | |
| 274 | done, | |
| 275 | cancel: () => { | |
| 276 | cancelled = true | |
| 277 | killTree() | |
| 278 | }, | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | /** | |
| 283 | * Asked rather than pattern-matched where possible. A regex over failure text | |
| 284 | * is how Claude's engine has to do it, but Codex will answer the question | |
| 285 | * directly, and a wrong answer here sends someone to the wrong fix. | |
| 286 | */ | |
| 287 | private async looksUnauthenticated(said: string): Promise<boolean> { | |
| 288 | if (UNAUTHORIZED.test(said)) | |
| 289 | return true | |
| 290 | const probe = await this.probe() | |
| 291 | return !probe.ok | |
| 292 | } | |
| 293 | ||
| 294 | private env(): Record<string, string | undefined> { | |
| 295 | return { ...process.env, ...this.options.env } | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | /** One answer per binary, for the life of the process. */ | |
| 300 | const jsonFlags = new Map<string, Promise<string>>() | |
| 301 | ||
| 302 | export function resolveJsonFlag(bin: string, env: Record<string, string | undefined>): Promise<string> { | |
| 303 | const cached = jsonFlags.get(bin) | |
| 304 | if (cached) | |
| 305 | return cached | |
| 306 | const resolved = (async (): Promise<string> => { | |
| 307 | try { | |
| 308 | const proc = Bun.spawn([bin, 'exec', '--help'], { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore', env }) | |
| 309 | const help = await new Response(proc.stdout).text() | |
| 310 | await proc.exited | |
| 311 | // Prefer the plain spelling when a version offers both. | |
| 312 | if (/(^|\s)--json(\s|$)/m.test(help)) | |
| 313 | return '--json' | |
| 314 | return /--experimental-json/.test(help) ? '--experimental-json' : '--json' | |
| 315 | } | |
| 316 | catch { | |
| 317 | return '--json' | |
| 318 | } | |
| 319 | })() | |
| 320 | jsonFlags.set(bin, resolved) | |
| 321 | return resolved | |
| 322 | } | |
| 323 | ||
| 324 | /** Forget the cached answers. For tests, which use a different stub per case. */ | |
| 325 | export function clearJsonFlagCache(): void { | |
| 326 | jsonFlags.clear() | |
| 327 | } | |
| 328 | ||
| 329 | function failure(text: string, sessionId: string | null, durationMs: number): EngineResult { | |
| 330 | return { ok: false, text, sessionId, costCents: null, durationMs, turns: null, authFailure: false } | |
| 331 | } | |
| 332 | ||
| 333 | function parseLine(line: string): Record<string, any> | null { | |
| 334 | const trimmed = line.trim() | |
| 335 | if (!trimmed.startsWith('{')) | |
| 336 | return null | |
| 337 | try { | |
| 338 | return JSON.parse(trimmed) | |
| 339 | } | |
| 340 | catch { | |
| 341 | return null | |
| 342 | } | |
| 343 | } | |
| 344 | ||
| 345 | function errorText(event: Record<string, any>): string | null { | |
| 346 | const error = event.error ?? event.message | |
| 347 | if (typeof error === 'string') | |
| 348 | return error | |
| 349 | if (error && typeof error.message === 'string') | |
| 350 | return error.message | |
| 351 | return null | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * One human-readable line for something the agent did. The item kinds beyond | |
| 356 | * `agent_message` and `reasoning` are not a closed set across versions, so this | |
| 357 | * reads whichever descriptive field is present rather than switching on a name. | |
| 358 | */ | |
| 359 | export function summarizeItem(item: Record<string, any>): string { | |
| 360 | const name = String(item.type ?? 'tool') | |
| 361 | const detail = item.command ?? item.path ?? item.query ?? item.url ?? item.name ?? item.text ?? '' | |
| 362 | const shown = typeof detail === 'string' | |
| 363 | ? detail.replace(/^\/Users\/[^/]+/, '~') | |
| 364 | : Array.isArray(detail) ? detail.join(' ') : '' | |
| 365 | return shown ? `${name}: ${truncate(shown, 80)}` : name | |
| 366 | } | |
| 367 | ||
| 368 | async function readAndRemove(path: string): Promise<string> { | |
| 369 | try { | |
| 370 | const file = Bun.file(path) | |
| 371 | const text = await file.exists() ? await file.text() : '' | |
| 372 | rmSync(path, { force: true }) | |
| 373 | return text | |
| 374 | } | |
| 375 | catch { | |
| 376 | return '' | |
| 377 | } | |
| 378 | } | |
| @@ -1,6 +1,10 @@ | ||
| 1 | import type { CodexPermission } from './codex-engine' | |
| 2 | import type { EngineId } from './engine' | |
| 1 | 3 | import { homedir } from 'node:os' |
| 2 | 4 | import { join } from 'node:path' |
| 3 | 5 | import process from 'node:process' |
| 6 | import { CODEX_PERMISSIONS } from './codex-engine' | |
| 7 | import { isEngineId } from './engine' | |
| 4 | 8 | import { parseHandleList } from './handles' |
| 5 | 9 | import { DEFAULT_MESSAGES_DB } from './messages-db' |
| 6 | 10 | |
| @@ -23,9 +27,16 @@ export interface UplinkConfig { | ||
| 23 | 27 | /** A thread idle longer than this starts a fresh agent session. */ |
| 24 | 28 | sessionIdleMs: number |
| 25 | 29 | workdir: string |
| 30 | /** Which agent CLI answers a text, unless a thread says otherwise. */ | |
| 31 | engine: EngineId | |
| 26 | 32 | claudeBin: string |
| 27 | model: string | null | |
| 33 | claudeModel: string | null | |
| 34 | /** How much Claude Code may do without asking. */ | |
| 28 | 35 | permissionMode: string |
| 36 | codexBin: string | |
| 37 | codexModel: string | null | |
| 38 | /** How much Codex may do without asking. The two CLIs spell this differently. */ | |
| 39 | codexPermission: CodexPermission | |
| 29 | 40 | timeoutMs: number |
| 30 | 41 | } |
| 31 | 42 | |
| @@ -62,13 +73,31 @@ export function loadConfig(env: Env = process.env): UplinkConfig { | ||
| 62 | 73 | progressEveryMs: int(env, 'UPLINK_PROGRESS_EVERY_MS', 10 * MINUTE), |
| 63 | 74 | sessionIdleMs: int(env, 'UPLINK_SESSION_IDLE_MS', 6 * 60 * MINUTE), |
| 64 | 75 | workdir: expandHome(str(env, 'UPLINK_WORKDIR', homedir())), |
| 76 | engine: engineId(env.UPLINK_ENGINE), | |
| 65 | 77 | claudeBin: expandHome(str(env, 'UPLINK_CLAUDE_BIN', findClaude())), |
| 66 | model: env.UPLINK_MODEL || null, | |
| 78 | // UPLINK_MODEL predates the second engine and meant Claude's model alias, | |
| 79 | // so an existing .env keeps working. | |
| 80 | claudeModel: env.UPLINK_CLAUDE_MODEL || env.UPLINK_MODEL || null, | |
| 67 | 81 | permissionMode: str(env, 'UPLINK_PERMISSION_MODE', 'bypassPermissions'), |
| 82 | codexBin: expandHome(str(env, 'UPLINK_CODEX_BIN', findCodex())), | |
| 83 | codexModel: env.UPLINK_CODEX_MODEL || null, | |
| 84 | codexPermission: codexPermission(env.UPLINK_CODEX_PERMISSION), | |
| 68 | 85 | timeoutMs: int(env, 'UPLINK_TIMEOUT_MS', 90 * MINUTE), |
| 69 | 86 | } |
| 70 | 87 | } |
| 71 | 88 | |
| 89 | function engineId(value: string | undefined): EngineId { | |
| 90 | return value && isEngineId(value) ? value : 'claude' | |
| 91 | } | |
| 92 | ||
| 93 | function codexPermission(value: string | undefined): CodexPermission { | |
| 94 | return value && (CODEX_PERMISSIONS as readonly string[]).includes(value) | |
| 95 | ? value as CodexPermission | |
| 96 | // Claude Code runs with bypassPermissions, because nobody is at the Mac to | |
| 97 | // approve anything. Codex's equivalent is the same trade, spelled its way. | |
| 98 | : 'bypass' | |
| 99 | } | |
| 100 | ||
| 72 | 101 | /** |
| 73 | 102 | * launchd starts agents with a bare PATH, so `claude` from an interactive |
| 74 | 103 | * shell is not necessarily findable. Check where the installers put it. |
| @@ -89,6 +118,29 @@ function findClaude(): string { | ||
| 89 | 118 | return 'claude' |
| 90 | 119 | } |
| 91 | 120 | |
| 121 | /** | |
| 122 | * The same bare-PATH problem as findClaude, plus one path that is easy to miss: | |
| 123 | * the ChatGPT desktop app bundles its own codex binary and does not put it on | |
| 124 | * PATH. On a Mac with ChatGPT installed that is the working Codex, so a search | |
| 125 | * that skips it reports Codex missing on a machine that has it. | |
| 126 | */ | |
| 127 | function findCodex(): string { | |
| 128 | const fromPath = Bun.which('codex') | |
| 129 | if (fromPath) | |
| 130 | return fromPath | |
| 131 | for (const candidate of [ | |
| 132 | '/Applications/ChatGPT.app/Contents/Resources/codex', | |
| 133 | join(homedir(), '.local', 'bin', 'codex'), | |
| 134 | join(homedir(), '.bun', 'bin', 'codex'), | |
| 135 | '/opt/homebrew/bin/codex', | |
| 136 | '/usr/local/bin/codex', | |
| 137 | ]) { | |
| 138 | if (Bun.file(candidate).size > 0) | |
| 139 | return candidate | |
| 140 | } | |
| 141 | return 'codex' | |
| 142 | } | |
| 143 | ||
| 92 | 144 | export function systemPrompt(config: UplinkConfig): string { |
| 93 | 145 | return [ |
| 94 | 146 | 'You are Uplink, reached by text message. The user is away from this Mac and texting from a phone,', |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { Subprocess } from 'bun' |
| 2 | import { tmpdir } from 'node:os' | |
| 2 | 3 | import process from 'node:process' |
| 3 | 4 | import { truncate } from './format' |
| 4 | 5 | |
| @@ -43,8 +44,37 @@ export interface EngineRun { | ||
| 43 | 44 | cancel: () => void |
| 44 | 45 | } |
| 45 | 46 | |
| 47 | /** Which agent CLI answers a text. */ | |
| 48 | export type EngineId = 'claude' | 'codex' | |
| 49 | ||
| 50 | export const ENGINE_IDS: readonly EngineId[] = ['claude', 'codex'] | |
| 51 | ||
| 52 | export function isEngineId(value: string): value is EngineId { | |
| 53 | return (ENGINE_IDS as readonly string[]).includes(value) | |
| 54 | } | |
| 55 | ||
| 56 | /** Whether a CLI can answer right now, for the doctor and the menubar. */ | |
| 57 | export interface EngineProbe { | |
| 58 | ok: boolean | |
| 59 | detail: string | |
| 60 | } | |
| 61 | ||
| 46 | 62 | export interface Engine { |
| 63 | readonly id: EngineId | |
| 64 | /** How the engine is named to a person: "Claude Code", "Codex". */ | |
| 65 | readonly label: string | |
| 66 | /** | |
| 67 | * What to text back when a run failed on authentication. The person is away | |
| 68 | * from the Mac, so this has to name the CLI and the exact command. | |
| 69 | */ | |
| 70 | readonly authFailureHint: string | |
| 47 | 71 | run: (request: EngineRequest) => EngineRun |
| 72 | /** | |
| 73 | * Can this CLI answer right now. Separate from `run` because setup has to | |
| 74 | * answer it before any text arrives, and because the two CLIs disagree on | |
| 75 | * what proof of life costs: Claude spends a turn, Codex does not. | |
| 76 | */ | |
| 77 | probe: () => Promise<EngineProbe> | |
| 48 | 78 | } |
| 49 | 79 | |
| 50 | 80 | export interface ClaudeEngineOptions { |
| @@ -58,9 +88,59 @@ export interface ClaudeEngineOptions { | ||
| 58 | 88 | |
| 59 | 89 | const AUTH_FAILURE = /failed to authenticate|oauth|invalid api key|please run \/login|not logged in|credit balance/i |
| 60 | 90 | |
| 91 | /** A probe that hangs is worse than one that fails: setup shows "Checking" for ever. */ | |
| 92 | export const PROBE_TIMEOUT_MS = 60_000 | |
| 93 | ||
| 61 | 94 | export class ClaudeEngine implements Engine { |
| 95 | readonly id = 'claude' as const | |
| 96 | readonly label = 'Claude Code' | |
| 97 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' | |
| 98 | ||
| 62 | 99 | constructor(private readonly options: ClaudeEngineOptions) {} |
| 63 | 100 | |
| 101 | /** | |
| 102 | * One real turn, on the cheapest model. Only asking "is a token set" passed a | |
| 103 | * token truncated at the terminal's 80-column wrap: set, well-formed, and | |
| 104 | * rejected with a 401 on first use. | |
| 105 | */ | |
| 106 | async probe(): Promise<EngineProbe> { | |
| 107 | try { | |
| 108 | const proc = Bun.spawn([this.options.bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { | |
| 109 | cwd: tmpdir(), | |
| 110 | env: this.env(), | |
| 111 | stdin: 'ignore', | |
| 112 | stdout: 'pipe', | |
| 113 | stderr: 'pipe', | |
| 114 | }) | |
| 115 | const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS) | |
| 116 | const text = await new Response(proc.stdout).text() | |
| 117 | clearTimeout(timer) | |
| 118 | await proc.exited | |
| 119 | const result = JSON.parse(text) as { is_error?: boolean, result?: string } | |
| 120 | if (result.is_error) { | |
| 121 | const said = truncate(result.result ?? 'Claude did not answer', 140) | |
| 122 | return { ok: false, detail: AUTH_FAILURE.test(said) ? 'Not signed in yet.' : said } | |
| 123 | } | |
| 124 | return { ok: true, detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI' } | |
| 125 | } | |
| 126 | catch { | |
| 127 | return { ok: false, detail: `Could not run ${this.options.bin}. Is Claude Code installed?` } | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * A .env copied from .env.example carries `CLAUDE_CODE_OAUTH_TOKEN=`; an | |
| 133 | * empty token must not stand in for the CLI's own login. The probe needs the | |
| 134 | * same treatment as a run, or a fresh install reports a failure a real run | |
| 135 | * would not have. | |
| 136 | */ | |
| 137 | private env(): Record<string, string | undefined> { | |
| 138 | const env: Record<string, string | undefined> = { ...process.env, ...this.options.env } | |
| 139 | if (!env.CLAUDE_CODE_OAUTH_TOKEN) | |
| 140 | delete env.CLAUDE_CODE_OAUTH_TOKEN | |
| 141 | return env | |
| 142 | } | |
| 143 | ||
| 64 | 144 | args(request: EngineRequest): string[] { |
| 65 | 145 | const args = [ |
| 66 | 146 | '-p', |
| @@ -85,11 +165,7 @@ export class ClaudeEngine implements Engine { | ||
| 85 | 165 | let cancelled = false |
| 86 | 166 | let timedOut = false |
| 87 | 167 | |
| 88 | const env: Record<string, string | undefined> = { ...process.env, ...this.options.env } | |
| 89 | // A .env copied from .env.example carries `CLAUDE_CODE_OAUTH_TOKEN=`; an | |
| 90 | // empty token must not stand in for the CLI's own login. | |
| 91 | if (!env.CLAUDE_CODE_OAUTH_TOKEN) | |
| 92 | delete env.CLAUDE_CODE_OAUTH_TOKEN | |
| 168 | const env = this.env() | |
| 93 | 169 | |
| 94 | 170 | const proc: Subprocess<'ignore', 'pipe', 'pipe'> = Bun.spawn([this.options.bin, ...this.args(request)], { |
| 95 | 171 | cwd: request.cwd, |
| @@ -0,0 +1,47 @@ | ||
| 1 | import type { UplinkConfig } from './config' | |
| 2 | import type { Engine, EngineId } from './engine' | |
| 3 | import { CodexEngine } from './codex-engine' | |
| 4 | import { systemPrompt } from './config' | |
| 5 | import { ClaudeEngine } from './engine' | |
| 6 | import { ENGINE_IDS } from './engine' | |
| 7 | ||
| 8 | /** | |
| 9 | * Builds engines from config, so the watcher, the app and the doctor all select | |
| 10 | * one the same way. Both construction sites used to say `new ClaudeEngine(...)` | |
| 11 | * with their own copy of the options, which is two places to forget. | |
| 12 | */ | |
| 13 | ||
| 14 | export function engineFor(id: EngineId, config: UplinkConfig): Engine { | |
| 15 | const prompt = systemPrompt(config) | |
| 16 | if (id === 'codex') { | |
| 17 | return new CodexEngine({ | |
| 18 | bin: config.codexBin, | |
| 19 | model: config.codexModel, | |
| 20 | permission: config.codexPermission, | |
| 21 | systemPrompt: prompt, | |
| 22 | timeoutMs: config.timeoutMs, | |
| 23 | }) | |
| 24 | } | |
| 25 | return new ClaudeEngine({ | |
| 26 | bin: config.claudeBin, | |
| 27 | model: config.claudeModel, | |
| 28 | permissionMode: config.permissionMode, | |
| 29 | systemPrompt: prompt, | |
| 30 | timeoutMs: config.timeoutMs, | |
| 31 | }) | |
| 32 | } | |
| 33 | ||
| 34 | /** The configured engine. */ | |
| 35 | export function selectedEngine(config: UplinkConfig): Engine { | |
| 36 | return engineFor(config.engine, config) | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Every engine, selected one first. The doctor and the menubar report on all of | |
| 41 | * them: an engine the person has not set up is information, not a failure, and | |
| 42 | * seeing that the other one is available is how they learn they can switch. | |
| 43 | */ | |
| 44 | export function allEngines(config: UplinkConfig): Engine[] { | |
| 45 | const ordered = [config.engine, ...ENGINE_IDS.filter(id => id !== config.engine)] | |
| 46 | return ordered.map(id => engineFor(id, config)) | |
| 47 | } | |
| @@ -1,6 +1,8 @@ | ||
| 1 | import type { EngineId } from './engine' | |
| 1 | 2 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 2 | 3 | import { homedir } from 'node:os' |
| 3 | 4 | import { join } from 'node:path' |
| 5 | import { isEngineId } from './engine' | |
| 4 | 6 | |
| 5 | 7 | /** |
| 6 | 8 | * Settings for the downloadable app, which has no .env: a JSON file for |
| @@ -17,12 +19,16 @@ export const DATABASE_PATH = join(DATA_DIR, 'uplink.sqlite') | ||
| 17 | 19 | export const LOG_PATH = join(homedir(), 'Library', 'Logs', 'Uplink.log') |
| 18 | 20 | |
| 19 | 21 | const KEYCHAIN_SERVICE = 'com.stacksjs.uplink' |
| 22 | // Claude Code's token. Codex keeps its own credentials in CODEX_HOME via | |
| 23 | // `codex login`, so there is nothing of its to store here. | |
| 20 | 24 | const KEYCHAIN_ACCOUNT = 'claude-oauth-token' |
| 21 | 25 | |
| 22 | 26 | export interface Settings { |
| 23 | 27 | /** Handles allowed to command Uplink. Empty: this Mac's own handles. */ |
| 24 | 28 | allowed: string[] |
| 25 | /** Model alias for runs, or null for the CLI's default. */ | |
| 29 | /** Which agent CLI answers a text. */ | |
| 30 | engine: EngineId | |
| 31 | /** Claude's model alias for runs, or null for the CLI's default. */ | |
| 26 | 32 | model: string | null |
| 27 | 33 | /** Where a run starts when a text names no path. */ |
| 28 | 34 | workdir: string |
| @@ -34,6 +40,7 @@ export interface Settings { | ||
| 34 | 40 | |
| 35 | 41 | export const DEFAULT_SETTINGS: Settings = { |
| 36 | 42 | allowed: [], |
| 43 | engine: 'claude', | |
| 37 | 44 | model: null, |
| 38 | 45 | workdir: homedir(), |
| 39 | 46 | openAtLogin: true, |
| @@ -45,7 +52,12 @@ export function readSettings(path: string = SETTINGS_PATH): Settings { | ||
| 45 | 52 | return { ...DEFAULT_SETTINGS } |
| 46 | 53 | try { |
| 47 | 54 | const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<Settings> |
| 48 | return { ...DEFAULT_SETTINGS, ...parsed } | |
| 55 | const settings = { ...DEFAULT_SETTINGS, ...parsed } | |
| 56 | // A hand-edited or downgraded file can name an engine this build does not | |
| 57 | // have. Falling back beats refusing to start. | |
| 58 | if (!isEngineId(String(settings.engine))) | |
| 59 | settings.engine = DEFAULT_SETTINGS.engine | |
| 60 | return settings | |
| 49 | 61 | } |
| 50 | 62 | catch { |
| 51 | 63 | // A hand-edited file with a typo must not stop the app from starting. |
| @@ -65,6 +77,7 @@ export function writeSettings(settings: Settings, path: string = SETTINGS_PATH): | ||
| 65 | 77 | export function settingsEnv(settings: Settings, env: Record<string, string | undefined> = process.env): Record<string, string | undefined> { |
| 66 | 78 | return { |
| 67 | 79 | UPLINK_ALLOWED: settings.allowed.join(','), |
| 80 | UPLINK_ENGINE: settings.engine, | |
| 68 | 81 | UPLINK_MODEL: settings.model ?? '', |
| 69 | 82 | UPLINK_WORKDIR: settings.workdir, |
| 70 | 83 | ...Object.fromEntries(Object.entries(env).filter(([key]) => key.startsWith('UPLINK_'))), |
| @@ -0,0 +1,230 @@ | ||
| 1 | import type { EngineEvent } from '../../../app/Uplink/engine' | |
| 2 | import { afterAll, beforeEach, describe, expect, it } from 'bun:test' | |
| 3 | import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { clearJsonFlagCache, CodexEngine, summarizeItem } from '../../../app/Uplink/codex-engine' | |
| 7 | ||
| 8 | const dir = mkdtempSync(join(tmpdir(), 'uplink-codex-')) | |
| 9 | afterAll(() => rmSync(dir, { recursive: true, force: true })) | |
| 10 | beforeEach(() => clearJsonFlagCache()) | |
| 11 | ||
| 12 | let counter = 0 | |
| 13 | ||
| 14 | /** | |
| 15 | * A stand-in `codex` printing the JSONL that `codex exec --json` really emits, | |
| 16 | * captured from a run of codex-cli 0.155: a thread.started carrying the id, a | |
| 17 | * turn.started, one item.completed per thing the agent did, and a | |
| 18 | * turn.completed carrying token usage and no cost. | |
| 19 | * | |
| 20 | * It also answers `exec --help`, because the engine asks the binary which | |
| 21 | * spelling of the JSONL flag it offers before using it. | |
| 22 | */ | |
| 23 | function stubCodex(options: { events?: object[], exit?: number, stderr?: string, jsonFlag?: string, lastMessage?: string } = {}): string { | |
| 24 | const path = join(dir, `codex-${counter += 1}`) | |
| 25 | const events = options.events ?? [ | |
| 26 | { type: 'thread.started', thread_id: 'th-9' }, | |
| 27 | { type: 'turn.started' }, | |
| 28 | { type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'git log --oneline -3', exit_code: 0 } }, | |
| 29 | { type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'Chiefs 24-21' } }, | |
| 30 | { type: 'turn.completed', usage: { input_tokens: 15119, cached_input_tokens: 12032, cache_write_input_tokens: 0, output_tokens: 5, reasoning_output_tokens: 0 } }, | |
| 31 | ] | |
| 32 | const lines = events.map(event => `echo '${JSON.stringify(event)}'`).join('\n') | |
| 33 | // `-o <file>` is the last-message file; the real CLI writes it on success only. | |
| 34 | const writeLast = options.lastMessage === undefined | |
| 35 | ? `for a in "$@"; do if [ "$prev" = "--output-last-message" ]; then printf '%s' "${options.lastMessage ?? 'Chiefs 24-21'}" > "$a"; fi; prev="$a"; done` | |
| 36 | : `for a in "$@"; do if [ "$prev" = "--output-last-message" ]; then printf '%s' "${options.lastMessage}" > "$a"; fi; prev="$a"; done` | |
| 37 | ||
| 38 | writeFileSync(path, `#!/bin/sh | |
| 39 | if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then | |
| 40 | echo "Usage: codex exec [OPTIONS] [PROMPT]" | |
| 41 | echo " ${options.jsonFlag ?? '--json'}" | |
| 42 | echo " Print events to stdout as JSONL" | |
| 43 | exit 0 | |
| 44 | fi | |
| 45 | printf '%s\\n' "$@" > "${path}.args" | |
| 46 | pwd > "${path}.cwd" | |
| 47 | prev="" | |
| 48 | ${writeLast} | |
| 49 | ${lines} | |
| 50 | ${options.stderr ? `echo "${options.stderr}" >&2` : ''} | |
| 51 | exit ${options.exit ?? 0} | |
| 52 | `) | |
| 53 | chmodSync(path, 0o755) | |
| 54 | return path | |
| 55 | } | |
| 56 | ||
| 57 | function engine(bin: string, overrides: Partial<{ model: string | null, permission: 'bypass' | 'workspace-write', timeoutMs: number }> = {}): CodexEngine { | |
| 58 | return new CodexEngine({ | |
| 59 | bin, | |
| 60 | model: overrides.model ?? null, | |
| 61 | permission: overrides.permission ?? 'bypass', | |
| 62 | systemPrompt: 'Answer in plain text.', | |
| 63 | timeoutMs: overrides.timeoutMs ?? 10_000, | |
| 64 | }) | |
| 65 | } | |
| 66 | ||
| 67 | function argsOf(bin: string): string[] { | |
| 68 | return readFileSync(`${bin}.args`, 'utf8').trim().split('\n') | |
| 69 | } | |
| 70 | ||
| 71 | describe('CodexEngine', () => { | |
| 72 | it('parses the event stream into a result and events', async () => { | |
| 73 | const bin = stubCodex() | |
| 74 | const events: EngineEvent[] = [] | |
| 75 | const result = await engine(bin).run({ prompt: 'scores?', cwd: dir, onEvent: e => events.push(e) }).done | |
| 76 | ||
| 77 | expect(result.ok).toBe(true) | |
| 78 | expect(result.text).toBe('Chiefs 24-21') | |
| 79 | expect(result.sessionId).toBe('th-9') | |
| 80 | // Codex reports tokens and no dollar figure, so a cost here would be invented. | |
| 81 | expect(result.costCents).toBeNull() | |
| 82 | expect(result.turns).toBe(1) | |
| 83 | expect(result.authFailure).toBe(false) | |
| 84 | ||
| 85 | expect(events).toContainEqual({ kind: 'session', sessionId: 'th-9' }) | |
| 86 | expect(events).toContainEqual({ kind: 'text', text: 'Chiefs 24-21' }) | |
| 87 | expect(events).toContainEqual({ kind: 'tool', name: 'command_execution', summary: 'command_execution: git log --oneline -3' }) | |
| 88 | }) | |
| 89 | ||
| 90 | it('always passes --skip-git-repo-check, because the default workdir is not a repo', async () => { | |
| 91 | const bin = stubCodex() | |
| 92 | await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 93 | expect(argsOf(bin)).toContain('--skip-git-repo-check') | |
| 94 | }) | |
| 95 | ||
| 96 | it('starts a fresh run with exec, --cd and the bypass flag', async () => { | |
| 97 | const bin = stubCodex() | |
| 98 | await engine(bin).run({ prompt: 'scores?', cwd: dir }).done | |
| 99 | const args = argsOf(bin) | |
| 100 | ||
| 101 | expect(args[0]).toBe('exec') | |
| 102 | expect(args).toContain('--json') | |
| 103 | expect(args.slice(args.indexOf('--cd'), args.indexOf('--cd') + 2)).toEqual(['--cd', dir]) | |
| 104 | expect(args).toContain('--dangerously-bypass-approvals-and-sandbox') | |
| 105 | // No --append-system-prompt exists, so the instructions ride on the prompt. | |
| 106 | expect(args[args.length - 1]).toContain('Answer in plain text.') | |
| 107 | expect(args[args.length - 1]).toContain('scores?') | |
| 108 | expect(readFileSync(`${bin}.cwd`, 'utf8').trim()).toEndWith(dir.replace(/^\/private/, '')) | |
| 109 | }) | |
| 110 | ||
| 111 | it('resumes through the subcommand, with the session id positional', async () => { | |
| 112 | const bin = stubCodex() | |
| 113 | await engine(bin).run({ prompt: 'who won?', cwd: dir, sessionId: 'th-7' }).done | |
| 114 | const args = argsOf(bin) | |
| 115 | ||
| 116 | expect(args.slice(0, 3)).toEqual(['exec', 'resume', 'th-7']) | |
| 117 | // `resume` rejects both of these with "unexpected argument" and exit 2. | |
| 118 | expect(args).not.toContain('--cd') | |
| 119 | expect(args).not.toContain('--sandbox') | |
| 120 | }) | |
| 121 | ||
| 122 | it('sets a non-bypass sandbox through -c, which resume accepts', async () => { | |
| 123 | const bin = stubCodex() | |
| 124 | await engine(bin, { permission: 'workspace-write' }).run({ prompt: 'x', cwd: dir, sessionId: 'th-7' }).done | |
| 125 | const args = argsOf(bin) | |
| 126 | ||
| 127 | expect(args).not.toContain('--sandbox') | |
| 128 | expect(args.slice(args.indexOf('--config'), args.indexOf('--config') + 2)) | |
| 129 | .toEqual(['--config', 'sandbox_mode="workspace-write"']) | |
| 130 | }) | |
| 131 | ||
| 132 | it('passes the model only when one is set', async () => { | |
| 133 | const withModel = stubCodex() | |
| 134 | await engine(withModel, { model: 'gpt-5-codex' }).run({ prompt: 'x', cwd: dir }).done | |
| 135 | const args = argsOf(withModel) | |
| 136 | expect(args.slice(args.indexOf('--model'), args.indexOf('--model') + 2)).toEqual(['--model', 'gpt-5-codex']) | |
| 137 | ||
| 138 | clearJsonFlagCache() | |
| 139 | const without = stubCodex() | |
| 140 | await engine(without).run({ prompt: 'x', cwd: dir }).done | |
| 141 | expect(argsOf(without)).not.toContain('--model') | |
| 142 | }) | |
| 143 | ||
| 144 | it('uses the version spelling of the JSONL flag that the binary offers', async () => { | |
| 145 | const bin = stubCodex({ jsonFlag: '--experimental-json' }) | |
| 146 | await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 147 | const args = argsOf(bin) | |
| 148 | expect(args).toContain('--experimental-json') | |
| 149 | expect(args).not.toContain('--json') | |
| 150 | }) | |
| 151 | ||
| 152 | it('falls back to the streamed message when the last-message file is absent', async () => { | |
| 153 | // The real CLI does not write that file when a turn fails, so the stream has | |
| 154 | // to be the source of truth. | |
| 155 | const bin = stubCodex({ lastMessage: '' }) | |
| 156 | const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 157 | expect(result.text).toBe('Chiefs 24-21') | |
| 158 | }) | |
| 159 | ||
| 160 | it('reports a turn.failed as a failure', async () => { | |
| 161 | const bin = stubCodex({ | |
| 162 | events: [ | |
| 163 | { type: 'thread.started', thread_id: 'th-9' }, | |
| 164 | { type: 'turn.failed', error: { message: 'context window exceeded' } }, | |
| 165 | ], | |
| 166 | exit: 1, | |
| 167 | lastMessage: '', | |
| 168 | }) | |
| 169 | const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 170 | expect(result.ok).toBe(false) | |
| 171 | expect(result.text).toContain('context window exceeded') | |
| 172 | expect(result.sessionId).toBe('th-9') | |
| 173 | }) | |
| 174 | ||
| 175 | it('flags a signed-out CLI from the failure text', async () => { | |
| 176 | const bin = stubCodex({ | |
| 177 | events: [], | |
| 178 | exit: 1, | |
| 179 | stderr: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header', | |
| 180 | lastMessage: '', | |
| 181 | }) | |
| 182 | const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done | |
| 183 | expect(result.ok).toBe(false) | |
| 184 | expect(result.authFailure).toBe(true) | |
| 185 | }) | |
| 186 | ||
| 187 | it('cancels a run', async () => { | |
| 188 | const bin = stubCodex() | |
| 189 | const run = engine(bin).run({ prompt: 'x', cwd: dir }) | |
| 190 | run.cancel() | |
| 191 | const result = await run.done | |
| 192 | expect(result.ok).toBe(false) | |
| 193 | expect(result.text).toBe('Stopped.') | |
| 194 | }) | |
| 195 | ||
| 196 | describe('probe', () => { | |
| 197 | it('is signed in when codex login status exits 0', async () => { | |
| 198 | const path = join(dir, `codex-login-ok-${counter += 1}`) | |
| 199 | // The real CLI prints this on stderr, not stdout. | |
| 200 | writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n') | |
| 201 | chmodSync(path, 0o755) | |
| 202 | expect(await engine(path).probe()).toEqual({ ok: true, detail: 'Logged in using ChatGPT' }) | |
| 203 | }) | |
| 204 | ||
| 205 | it('is not signed in when it exits non-zero', async () => { | |
| 206 | const path = join(dir, `codex-login-bad-${counter += 1}`) | |
| 207 | writeFileSync(path, '#!/bin/sh\necho "Not logged in" >&2\nexit 1\n') | |
| 208 | chmodSync(path, 0o755) | |
| 209 | const probe = await engine(path).probe() | |
| 210 | expect(probe.ok).toBe(false) | |
| 211 | expect(probe.detail).toContain('Not logged in') | |
| 212 | }) | |
| 213 | ||
| 214 | it('says so when the binary is missing', async () => { | |
| 215 | const probe = await engine(join(dir, 'not-installed')).probe() | |
| 216 | expect(probe.ok).toBe(false) | |
| 217 | expect(probe.detail).toContain('Is Codex installed?') | |
| 218 | }) | |
| 219 | }) | |
| 220 | }) | |
| 221 | ||
| 222 | describe('summarizeItem', () => { | |
| 223 | it('reads whichever descriptive field an item carries', () => { | |
| 224 | expect(summarizeItem({ type: 'command_execution', command: 'bun test' })).toBe('command_execution: bun test') | |
| 225 | expect(summarizeItem({ type: 'web_search', query: 'nfl scores' })).toBe('web_search: nfl scores') | |
| 226 | expect(summarizeItem({ type: 'file_change', path: '/Users/someone/Code/app.ts' })).toBe('file_change: ~/Code/app.ts') | |
| 227 | // Unknown kinds still produce a line, because the set is not closed. | |
| 228 | expect(summarizeItem({ type: 'something_new' })).toBe('something_new') | |
| 229 | }) | |
| 230 | }) | |
| @@ -141,6 +141,20 @@ interface PendingRun { | ||
| 141 | 141 | /** An engine whose runs finish when the test says so. */ |
| 142 | 142 | export class FakeEngine implements Engine { |
| 143 | 143 | runs: PendingRun[] = [] |
| 144 | probes = 0 | |
| 145 | ||
| 146 | // Identity matching the real Claude engine, so a test that asserts on a | |
| 147 | // reply naming the CLI is asserting the text a user would actually get. | |
| 148 | readonly id = 'claude' as const | |
| 149 | readonly label = 'Claude Code' | |
| 150 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' | |
| 151 | ||
| 152 | probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' } | |
| 153 | ||
| 154 | async probe(): Promise<{ ok: boolean, detail: string }> { | |
| 155 | this.probes += 1 | |
| 156 | return this.probeResult | |
| 157 | } | |
| 144 | 158 | |
| 145 | 159 | run(request: EngineRequest): EngineRun { |
| 146 | 160 | let resolve!: (result: EngineResult) => void |
| @@ -75,8 +75,18 @@ describe('settings', () => { | ||
| 75 | 75 | }) |
| 76 | 76 | |
| 77 | 77 | it('feeds loadConfig without any .env', () => { |
| 78 | const config = loadConfig(settingsEnv({ allowed: ['(555) 000-1111'], model: 'opus', workdir: '/tmp', openAtLogin: true, paused: false }, {})) | |
| 79 | expect(config).toMatchObject({ allowed: ['+15550001111'], model: 'opus', workdir: '/tmp' }) | |
| 78 | const config = loadConfig(settingsEnv({ allowed: ['(555) 000-1111'], engine: 'claude', model: 'opus', workdir: '/tmp', openAtLogin: true, paused: false }, {})) | |
| 79 | expect(config).toMatchObject({ allowed: ['+15550001111'], engine: 'claude', claudeModel: 'opus', workdir: '/tmp' }) | |
| 80 | }) | |
| 81 | ||
| 82 | it('carries the selected engine through to config', () => { | |
| 83 | const settings = { allowed: [], engine: 'codex' as const, model: null, workdir: '/tmp', openAtLogin: true, paused: false } | |
| 84 | expect(loadConfig(settingsEnv(settings, {})).engine).toBe('codex') | |
| 85 | // A settings.json naming an engine this build does not have must not stop | |
| 86 | // the app from starting. | |
| 87 | const path = join(tempDir(), 'settings.json') | |
| 88 | writeSettings({ ...settings, engine: 'gemini' as never }, path) | |
| 89 | expect(readSettings(path).engine).toBe('claude') | |
| 80 | 90 | }) |
| 81 | 91 | |
| 82 | 92 | it('cleans a token pasted across a line wrap', () => { |