ReviewOS

also looking at this

stacks/uplink

feat(engine): answer texts with Codex as well as Claude Code

#7
Merged glennmichael123 wants to merge feat/codex-engine into main
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.

.env.examplemodified+19-1
Changes to .env.example
@@ -138,14 +138,32 @@ TS_CLOUD_UI_PASSWORD=
138138# forward this process cannot see, or 0 to keep a public box off public DNS.
139139# TS_CLOUD_SSH_PUBLISH_DNS=
140140
141# Uplink - text this Mac, get Claude. See README.md.
141# Uplink - text this Mac, get an agent. See README.md.
142142# Who may command it. Empty = this Mac's own Messages handles only ("text myself").
143143# Anyone listed can make an agent act on this Mac, so list only yourself.
144144UPLINK_ALLOWED=
145# Which agent CLI answers: claude or codex. Empty = claude.
146UPLINK_ENGINE=
147# --- Claude Code ---
145148# From `claude setup-token` (your Claude Max login, good for a year). Encrypt it: ./buddy env:set
146149CLAUDE_CODE_OAUTH_TOKEN=
147150# Optional: model alias for runs (sonnet, opus, haiku). Empty = the CLI's default.
151# UPLINK_MODEL is the older name for this and still works.
152UPLINK_CLAUDE_MODEL=
148153UPLINK_MODEL=
154# Optional: where the claude binary is, if it is not on PATH.
155UPLINK_CLAUDE_BIN=
156# --- Codex ---
157# Sign in with `codex login` (or `codex login --device-auth` over SSH). Codex keeps the
158# credential itself, under CODEX_HOME, so there is no token to put here.
159# Optional: where the codex binary is. Uplink also looks inside ChatGPT.app, which bundles one.
160UPLINK_CODEX_BIN=
161# Optional: model the agent should use. Empty = the CLI's default.
162UPLINK_CODEX_MODEL=
163# How much Codex may do without asking. `bypass` matches what Uplink asks of Claude Code:
164# no approval prompts, no sandbox, because nobody is at the Mac to answer one.
165# Also: danger-full-access, workspace-write, read-only.
166UPLINK_CODEX_PERMISSION=bypass
149167# Where runs start when a text names no path.
150168UPLINK_WORKDIR=~
151169# Replies longer than UPLINK_MAX_CHARS are split; past UPLINK_MAX_PARTS the rest waits for "more".
app/Commands/Uplink.tsmodified+18-18
Changes to app/Commands/Uplink.ts
@@ -1,9 +1,11 @@
11import { copyFileSync, existsSync, watch } from 'node:fs'
22import process from 'node:process'
33import { defineCommand } from '@stacksjs/cli'
4import { loadConfig, systemPrompt } from '../Uplink/config'
5import { runChecks, writeHeartbeat } from '../Uplink/doctor'
6import { ClaudeEngine } from '../Uplink/engine'
4import type { EngineId } from '../Uplink/engine'
5import { loadConfig } from '../Uplink/config'
6import { checksPass, runChecks, writeHeartbeat } from '../Uplink/doctor'
7import { ENGINE_IDS, isEngineId } from '../Uplink/engine'
8import { engineFor, selectedEngine } from '../Uplink/engines'
79import { formatDuration, toPlainText } from '../Uplink/format'
810import { MessagesAccessError, MessagesDb } from '../Uplink/messages-db'
911import { ModelStore } from '../Uplink/model-store'
@@ -18,16 +20,6 @@ import { detectWorkdir } from '../Uplink/workdir'
1820const HEARTBEAT_MS = 15_000
1921const RETRY_OPEN_MS = 30_000
2022
21function engineFor(config: ReturnType<typeof loadConfig>): ClaudeEngine {
22 return new ClaudeEngine({
23 bin: config.claudeBin,
24 model: config.model,
25 permissionMode: config.permissionMode,
26 systemPrompt: systemPrompt(config),
27 timeoutMs: config.timeoutMs,
28 })
29}
30
3123async function openMessages(config: ReturnType<typeof loadConfig>, appDir: string, startedAt: number): Promise<MessagesDb> {
3224 // Without Full Disk Access this fails. Under launchd, exiting would only
3325 // restart-loop, so wait for the grant instead and say why in the heartbeat.
@@ -85,7 +77,7 @@ export default defineCommand((cli) => {
8577 config,
8678 messages,
8779 sender: new AppleScriptSender(),
88 engine: engineFor(config),
80 engine: selectedEngine(config),
8981 store: new ModelStore(`${appDir}/storage/uplink/cursor`),
9082 })
9183 await uplink.start()
@@ -134,11 +126,19 @@ export default defineCommand((cli) => {
134126 cli
135127 .command('uplink:ask <prompt>', 'Run one prompt through the agent exactly as a text would, and print the reply')
136128 .option('--cwd <dir>', 'Working directory (default: detected from the prompt)')
137 .action(async (prompt: string, options: { cwd?: string }) => {
129 .option('--engine <name>', 'claude or codex (default: UPLINK_ENGINE)')
130 .action(async (prompt: string, options: { cwd?: string, engine?: string }) => {
138131 const config = loadConfig()
139132 const cwd = options.cwd ?? detectWorkdir(prompt, config.workdir)
140 console.log(`[uplink] running in ${cwd}`)
141 const run = engineFor(config).run({
133 // Naming an engine here is how the other one gets tried without editing
134 // .env, which is the whole point of this command.
135 if (options.engine && !isEngineId(options.engine)) {
136 console.error(`[uplink] unknown engine "${options.engine}". Use ${ENGINE_IDS.join(' or ')}.`)
137 process.exit(1)
138 }
139 const engine = options.engine ? engineFor(options.engine as EngineId, config) : selectedEngine(config)
140 console.log(`[uplink] running in ${cwd} on ${engine.label}`)
141 const run = engine.run({
142142 prompt,
143143 cwd,
144144 onEvent: (event) => {
@@ -161,7 +161,7 @@ export default defineCommand((cli) => {
161161 if (!check.ok && check.fix)
162162 console.log(` → ${check.fix}`)
163163 }
164 process.exit(checks.every(c => c.ok) ? 0 : 1)
164 process.exit(checksPass(checks) ? 0 : 1)
165165 })
166166
167167 cli
app/Uplink/dashboard.tsmodified+2-2
Changes to app/Uplink/dashboard.ts
@@ -3,7 +3,7 @@ import { homedir } from 'node:os'
33import process from 'node:process'
44import Run from '../Models/Run'
55import { loadConfig } from './config'
6import { readHeartbeat, runChecks } from './doctor'
6import { checksPass, readHeartbeat, runChecks } from './doctor'
77import { formatDuration, truncate } from './format'
88
99/**
@@ -62,7 +62,7 @@ export async function loadDashboard(appDir: string = process.cwd()): Promise<Das
6262 ])
6363
6464 const live = isLive(heartbeat)
65 const ready = checks.every(check => check.ok)
65 const ready = checksPass(checks)
6666 const runs = rows.map((row): RunRow => ({
6767 id: row.id,
6868 prompt: truncate(row.prompt ?? '', 140),
app/Uplink/desktop-agent.tsmodified+68-61
Changes to app/Uplink/desktop-agent.ts
@@ -2,11 +2,12 @@
22import type { Server } from 'bun'
33import type { UplinkConfig } from './config'
44import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5import { homedir, tmpdir } from 'node:os'
5import { homedir } from 'node:os'
66import { join } from 'node:path'
77import process from 'node:process'
8import { loadConfig, systemPrompt } from './config'
9import { ClaudeEngine } from './engine'
8import { loadConfig } from './config'
9import { isEngineId } from './engine'
10import { allEngines, selectedEngine } from './engines'
1011import { formatDuration, truncate } from './format'
1112import { MessagesAccessError, MessagesDb } from './messages-db'
1213import { AppleScriptSender } from './sender'
@@ -40,7 +41,7 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid')
4041const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
4142const RETRY_OPEN_MS = 5_000
4243
43type ClaudeState = { ok: boolean, detail: string, checkedAt: number } | null
44type EngineState = { ok: boolean, detail: string, checkedAt: number }
4445
4546export interface DesktopAgent {
4647 port: number
@@ -124,29 +125,6 @@ export function setOpenAtLogin(enabled: boolean): void {
124125`)
125126}
126127
127async function probeClaude(bin: string, token: string | null): Promise<ClaudeState> {
128 const env: Record<string, string | undefined> = { ...process.env }
129 if (token)
130 env.CLAUDE_CODE_OAUTH_TOKEN = token
131 else
132 delete env.CLAUDE_CODE_OAUTH_TOKEN
133 try {
134 const proc = Bun.spawn([bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { cwd: tmpdir(), env, stdout: 'pipe', stderr: 'pipe' })
135 const timer = setTimeout(() => proc.kill(), 60_000)
136 const text = await new Response(proc.stdout).text()
137 clearTimeout(timer)
138 await proc.exited
139 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
140 if (result.is_error && !token && /authenticate|oauth|log ?in/i.test(result.result ?? ''))
141 return { ok: false, detail: 'Not signed in yet.', checkedAt: Date.now() }
142 return result.is_error
143 ? { ok: false, detail: truncate(result.result ?? 'Claude did not answer', 140), checkedAt: Date.now() }
144 : { ok: true, detail: token ? 'Signed in with your token' : 'Signed in through the Claude CLI', checkedAt: Date.now() }
145 }
146 catch {
147 return { ok: false, detail: `Could not run ${bin}. Is Claude Code installed?`, checkedAt: Date.now() }
148 }
149}
150128
151129export async function startDesktopAgent(options: { version: string }): Promise<DesktopAgent> {
152130 mkdirSync(DATA_DIR, { recursive: true })
@@ -169,23 +147,25 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
169147 let messages: MessagesDb | null = null
170148 let messagesError: string | null = null
171149 let uplink: Uplink | null = null
172 let claude: ClaudeState = null
173 let checkingClaude = false
174
175 const engine = (): ClaudeEngine => new ClaudeEngine({
176 bin: config.claudeBin,
177 model: config.model,
178 permissionMode: config.permissionMode,
179 systemPrompt: systemPrompt(config),
180 timeoutMs: config.timeoutMs,
181 })
182
183 const checkClaude = async (): Promise<void> => {
184 if (checkingClaude)
150 const engines = new Map<string, EngineState>()
151 let checkingEngines = false
152
153 /**
154 * Probe every engine, not only the selected one: the popover shows the other
155 * as available rather than as a failure, which is how a person discovers they
156 * can switch.
157 */
158 const checkEngines = async (): Promise<void> => {
159 if (checkingEngines)
185160 return
186 checkingClaude = true
187 claude = await probeClaude(config.claudeBin, token)
188 checkingClaude = false
161 checkingEngines = true
162 try {
163 for (const engine of allEngines(config))
164 engines.set(engine.id, { ...await engine.probe(), checkedAt: Date.now() })
165 }
166 finally {
167 checkingEngines = false
168 }
189169 }
190170
191171 const startWatching = async (): Promise<void> => {
@@ -199,7 +179,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
199179 messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error)
200180 return
201181 }
202 uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: engine(), store })
182 uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: selectedEngine(config), store })
203183 await uplink.start()
204184 }
205185
@@ -222,7 +202,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
222202 }, 15_000)
223203
224204 await startWatching()
225 void checkClaude()
205 void checkEngines()
226206
227207 const status = () => {
228208 const recent = store.recentRuns(8).map(run => ({
@@ -245,12 +225,21 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
245225 ok: messages !== null,
246226 detail: messages ? 'Full Disk Access granted' : (messagesError ?? 'Waiting for Full Disk Access'),
247227 },
248 {
249 id: 'claude',
250 name: 'Sign in to Claude',
251 ok: claude?.ok ?? false,
252 detail: checkingClaude ? 'Checking' : (claude?.detail ?? 'Not checked yet'),
253 },
228 ...allEngines(config).map((engine) => {
229 const state = engines.get(engine.id)
230 const selected = engine.id === config.engine
231 return {
232 // `engine` is the selected one, and the popover draws its sign-in
233 // step. The other is listed so switching is discoverable.
234 id: selected ? 'engine' : `engine:${engine.id}`,
235 name: selected ? `Sign in to ${engine.label}` : engine.label,
236 engineId: engine.id,
237 label: engine.label,
238 ok: state?.ok ?? false,
239 detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'),
240 informational: !selected,
241 }
242 }),
254243 {
255244 id: 'handles',
256245 name: 'Who can text it',
@@ -258,7 +247,8 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
258247 detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.',
259248 },
260249 ]
261 const ready = checks.every(check => check.ok)
250 // An engine the person has not selected must not hold the app in setup.
251 const ready = checks.every(check => check.ok || check.informational)
262252 const state = settings.paused ? 'paused' : !ready ? 'setup' : active.length > 0 ? 'working' : 'listening'
263253 return {
264254 state,
@@ -267,11 +257,18 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
267257 checks,
268258 active,
269259 recent,
270 settings: { allowed: settings.allowed, openAtLogin: settings.openAtLogin, paused: settings.paused, model: settings.model },
260 settings: { allowed: settings.allowed, engine: settings.engine, openAtLogin: settings.openAtLogin, paused: settings.paused, model: settings.model },
261 engines: allEngines(config).map(engine => ({ id: engine.id, label: engine.label })),
271262 lastError: uplink?.lastError ?? null,
272263 }
273264 }
274265
266 /** The selected engine's probe, which is what a sign-in button wants back. */
267 const engineResult = (): { ok: boolean, detail: string } => {
268 const state = engines.get(config.engine)
269 return { ok: state?.ok ?? false, detail: state?.detail ?? 'Not checked yet' }
270 }
271
275272 const json = (body: unknown, status = 200): Response => Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
276273
277274 const server = Bun.serve({
@@ -301,10 +298,12 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
301298 Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'])
302299 return json({ ok: true })
303300
304 case '/api/open/terminal':
305 // `claude setup-token` needs a real terminal for its browser sign-in.
306 Bun.spawnSync(['osascript', '-e', 'tell application "Terminal" to do script "claude setup-token"', '-e', 'tell application "Terminal" to activate'])
307 return json({ ok: true })
301 case '/api/open/terminal': {
302 // Both sign-ins need a real terminal for their browser round trip.
303 const command = config.engine === 'codex' ? 'codex login' : 'claude setup-token'
304 Bun.spawnSync(['osascript', '-e', `tell application "Terminal" to do script "${command}"`, '-e', 'tell application "Terminal" to activate'])
305 return json({ ok: true, command })
306 }
308307
309308 case '/api/token': {
310309 const cleaned = cleanToken(String(body.token ?? ''))
@@ -313,17 +312,22 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
313312 writeToken(cleaned)
314313 token = cleaned
315314 process.env.CLAUDE_CODE_OAUTH_TOKEN = cleaned
316 await checkClaude()
317 return json({ ok: claude?.ok ?? false, detail: claude?.detail })
315 await checkEngines()
316 return json(engineResult())
318317 }
319318
319 // `/api/claude/test` is the old name for this and still answers, because
320 // a popover cached from an older build would otherwise have a dead button.
320321 case '/api/claude/test':
321 await checkClaude()
322 return json({ ok: claude?.ok ?? false, detail: claude?.detail })
322 case '/api/engine/test':
323 await checkEngines()
324 return json(engineResult())
323325
324326 case '/api/settings': {
325327 if (Array.isArray(body.allowed))
326328 settings.allowed = body.allowed.map(String).map((s: string) => s.trim()).filter(Boolean)
329 if (typeof body.engine === 'string' && isEngineId(body.engine))
330 settings.engine = body.engine
327331 if (typeof body.paused === 'boolean')
328332 settings.paused = body.paused
329333 if (typeof body.openAtLogin === 'boolean') {
@@ -334,6 +338,9 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
334338 settings = readSettings()
335339 config = loadConfig(settingsEnv(settings))
336340 await restartWatching()
341 // A switched engine has not been probed under the new selection yet,
342 // and the popover reads the answer straight back.
343 await checkEngines()
337344 return json(status())
338345 }
339346
app/Uplink/doctor.tsmodified+38-34
Changes to app/Uplink/doctor.ts
@@ -1,9 +1,9 @@
11import type { UplinkConfig } from './config'
2import type { Engine } from './engine'
23import { existsSync } from 'node:fs'
34import { join } from 'node:path'
4import { tmpdir } from 'node:os'
5import process from 'node:process'
6import { formatDuration, truncate } from './format'
5import { allEngines } from './engines'
6import { formatDuration } from './format'
77import { MessagesDb } from './messages-db'
88import { servicePaths, serviceState } from './service'
99
@@ -18,6 +18,16 @@ export interface Check {
1818 ok: boolean
1919 detail: string
2020 fix?: string
21 /**
22 * Shown, but not counted towards whether Uplink is ready. An engine the
23 * person has not selected belongs here: it is information, not a problem.
24 */
25 informational?: boolean
26}
27
28/** Whether every check that counts passed. */
29export function checksPass(checks: Check[]): boolean {
30 return checks.every(check => check.ok || check.informational)
2131}
2232
2333export interface Heartbeat {
@@ -51,34 +61,14 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> {
5161const HEARTBEAT_STALE_MS = 2 * 60_000
5262
5363/**
54 * Whether the claude CLI can actually answer, checked with one real turn.
55 *
56 * Only asking "is a token set" passed a token truncated at the terminal's
57 * 80-column wrap: set, well-formed, and rejected with a 401 on first use.
64 * What to do about an engine that cannot answer. The engine's own
65 * `authFailureHint` is written for a text message, so it names the CLI and the
66 * command but not the surrounding `.env` step this install needs.
5867 */
59async function claudeAuth(bin: string): Promise<{ ok: boolean, detail: string }> {
60 try {
61 const proc = Bun.spawn([bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], {
62 stdout: 'pipe',
63 stderr: 'pipe',
64 cwd: tmpdir(),
65 })
66 const timer = setTimeout(() => proc.kill(), 60_000)
67 const text = await new Response(proc.stdout).text()
68 clearTimeout(timer)
69 await proc.exited
70 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
71 const via = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'the CLI login'
72 if (!result.is_error)
73 return { ok: true, detail: `answers (via ${via})` }
74 const hint = process.env.CLAUDE_CODE_OAUTH_TOKEN
75 ? ` - the token is ${process.env.CLAUDE_CODE_OAUTH_TOKEN.length} characters; a paste cut at a line wrap is the usual cause`
76 : ''
77 return { ok: false, detail: `${truncate(result.result ?? 'no answer', 120)}${hint}` }
78 }
79 catch (error) {
80 return { ok: false, detail: `could not run ${bin}: ${error instanceof Error ? error.message : String(error)}` }
81 }
68function engineFix(engine: Engine): string {
69 return engine.id === 'claude'
70 ? 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart'
71 : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart'
8272}
8373
8474export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> {
@@ -123,10 +113,24 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C
123113 ? { name: 'Who can text it', ok: true, detail: config.allowed.length > 0 ? `UPLINK_ALLOWED: ${allowed.join(', ')}` : `your own handles: ${allowed.join(', ')}` }
124114 : { name: 'Who can text it', ok: false, detail: 'nobody yet', fix: 'Set UPLINK_ALLOWED in .env to your phone number (and/or Apple ID email)' })
125115
126 const auth = await claudeAuth(config.claudeBin)
127 checks.push(auth.ok
128 ? { name: 'Claude account', ok: true, detail: auth.detail }
129 : { name: 'Claude account', ok: false, detail: auth.detail, fix: 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart' })
116 // One check per engine. Only the selected one is allowed to fail the doctor:
117 // a person using Codex has done nothing wrong by never signing in to Claude,
118 // and seeing the other engine listed is how they learn they can switch.
119 for (const engine of allEngines(config)) {
120 const selected = engine.id === config.engine
121 const probe = await engine.probe()
122 checks.push({
123 name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`,
124 ok: probe.ok,
125 detail: probe.detail,
126 informational: !selected,
127 fix: probe.ok
128 ? undefined
129 : selected
130 ? engineFix(engine)
131 : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine)}`,
132 })
133 }
130134
131135 const messages = Bun.spawnSync(['pgrep', '-x', 'Messages'])
132136 checks.push(messages.exitCode === 0
app/Uplink/menubar.htmlmodified+53-13
Changes to app/Uplink/menubar.html
@@ -46,8 +46,8 @@
4646 button.primary { background: var(--accent); border-color: var(--accent); color: #18181b; }
4747 button:active { transform: translateY(1px); }
4848 button:disabled { opacity: .5; cursor: default; }
49 input, textarea { width: 100%; font: 12px/1.4 ui-monospace, SFMono-Regular, monospace; color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; -webkit-user-select: text; user-select: text; }
50 input:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
49 input, textarea, select { width: 100%; font: 12px/1.4 ui-monospace, SFMono-Regular, monospace; color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; -webkit-user-select: text; user-select: text; }
50 input:focus, textarea:focus, select:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
5151 label { display: block; margin: 10px 0 4px; font-weight: 600; font-size: 12px; }
5252 .help { margin: 4px 0 0; color: var(--muted); font-size: 11.5px; }
5353 .error { margin: 6px 0 0; color: #dc2626; font-size: 12px; }
@@ -86,18 +86,28 @@
8686 <p class="help" data-when-todo>Switch on Uplink in the list. It notices within a few seconds.</p>
8787 </div>
8888 </div>
89 <div class="card step" id="step-claude">
89 <div class="card step" id="step-engine">
9090 <span class="mark" data-mark>2</span>
9191 <div>
92 <strong>Sign in to Claude</strong>
92 <strong data-engine-name>Sign in</strong>
9393 <p data-detail></p>
9494 <div data-when-todo>
95 <p class="help">Uplink uses your Claude plan through Claude Code. Run <code>claude setup-token</code>, sign in, and paste the token it prints.</p>
96 <div class="actions"><button data-action="open-terminal">Run it in Terminal</button></div>
97 <label for="token">Token</label>
98 <textarea id="token" rows="3" placeholder="sk-ant-oat01-..." spellcheck="false" autocomplete="off"></textarea>
99 <div class="actions"><button class="primary" data-action="save-token">Save to Keychain</button><button data-action="test-claude">Check again</button></div>
100 <p class="error" id="token-error" hidden></p>
95 <label for="engine">Answer texts with</label>
96 <select id="engine"></select>
97 <!-- Each CLI signs in its own way, so only the selected one's
98 instructions are shown. -->
99 <div data-engine-only="claude">
100 <p class="help">Uplink uses your Claude plan through Claude Code. Run <code>claude setup-token</code>, sign in, and paste the token it prints.</p>
101 <div class="actions"><button data-action="open-terminal">Run it in Terminal</button></div>
102 <label for="token">Token</label>
103 <textarea id="token" rows="3" placeholder="sk-ant-oat01-..." spellcheck="false" autocomplete="off"></textarea>
104 <div class="actions"><button class="primary" data-action="save-token">Save to Keychain</button><button data-action="test-engine">Check again</button></div>
105 <p class="error" id="token-error" hidden></p>
106 </div>
107 <div data-engine-only="codex">
108 <p class="help">Uplink uses your ChatGPT plan through Codex. Run <code>codex login</code> and sign in; Codex keeps the credential itself, so there is no token to paste here.</p>
109 <div class="actions"><button data-action="open-terminal">Run it in Terminal</button><button class="primary" data-action="test-engine">Check again</button></div>
110 </div>
101111 </div>
102112 </div>
103113 </div>
@@ -166,6 +176,32 @@
166176 node.hidden = check.ok
167177 }
168178
179 /**
180 * The sign-in step follows whichever engine is selected: its name, its
181 * instructions, and nothing belonging to the other one.
182 */
183 function renderEngine(status, check) {
184 const selected = status.settings.engine
185 const label = check?.label || selected
186 $('[data-engine-name]').textContent = `Sign in to ${label}`
187
188 const picker = $('#engine')
189 const options = (status.engines || []).map(engine => engine.id).join(',')
190 if (picker.dataset.options !== options) {
191 picker.replaceChildren(...(status.engines || []).map((engine) => {
192 const option = document.createElement('option')
193 option.value = engine.id
194 option.textContent = engine.label
195 return option
196 }))
197 picker.dataset.options = options
198 }
199 picker.value = selected
200
201 for (const node of document.querySelectorAll('[data-engine-only]'))
202 node.hidden = node.dataset.engineOnly !== selected
203 }
204
169205 function render(status) {
170206 current = status
171207 $('#dot').className = `dot ${status.state}`
@@ -177,8 +213,9 @@
177213 const byId = Object.fromEntries(status.checks.map(check => [check.id, check]))
178214 $('#setup').hidden = status.state !== 'setup'
179215 renderStep('#step-fda', byId.fda)
180 renderStep('#step-claude', byId.claude)
216 renderStep('#step-engine', byId.engine)
181217 renderStep('#step-handles', byId.handles)
218 renderEngine(status, byId.engine)
182219 if (!allowedEdited)
183220 $('#allowed').value = status.settings.allowed.join('\n')
184221
@@ -229,7 +266,7 @@
229266 { type: 'separator' },
230267 { label: 'Open Uplink', action: 'open' },
231268 { label: status.settings.paused ? 'Resume' : 'Pause', action: 'toggle-pause' },
232 { label: 'Check Claude sign-in', action: 'test-claude' },
269 { label: `Check ${status.settings.engine === 'codex' ? 'Codex' : 'Claude'} sign-in`, action: 'test-engine' },
233270 { type: 'separator' },
234271 { label: 'Quit Uplink', action: 'quit', shortcut: 'q' },
235272 ]
@@ -273,7 +310,7 @@
273310 'open': () => show(),
274311 'open-fda': () => api('/api/open/full-disk-access', {}),
275312 'open-terminal': () => api('/api/open/terminal', {}),
276 'test-claude': () => api('/api/claude/test', {}).then(refresh),
313 'test-engine': () => api('/api/engine/test', {}).then(refresh),
277314 'save-token': async () => {
278315 const error = $('#token-error')
279316 error.hidden = true
@@ -303,6 +340,9 @@
303340 Promise.resolve(actions[button.dataset.action]?.()).finally(() => { button.disabled = false })
304341 })
305342 $('#allowed').addEventListener('input', () => { allowedEdited = true })
343 // Switching engines restarts the watcher and re-probes, so the answer comes
344 // back in the same response.
345 $('#engine').addEventListener('change', async event => render(await api('/api/settings', { engine: event.target.value })))
306346 $('#login').addEventListener('change', async event => render(await api('/api/settings', { openAtLogin: event.target.checked })))
307347
308348 if (craft?.tray) {
app/Uplink/uplink.tsmodified+5-1
Changes to app/Uplink/uplink.ts
@@ -366,8 +366,12 @@ export class Uplink {
366366 })
367367
368368 if (!active.stoppedByUser) {
369 // Naming the wrong CLI, or a .env the downloadable app does not have,
370 // sends someone at the Mac to fix something that is not broken. The
371 // engine carries its own remedy.
372 const engine = this.deps.engine
369373 const text = result.authFailure
370 ? 'I cannot reach Claude: the claude CLI on the Mac is not logged in. Someone at the Mac needs to run "claude setup-token" and put the token in Uplink\'s .env.'
374 ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.`
371375 : result.ok ? result.text : `That failed: ${result.text}`
372376 await this.reply(active.target, text, { keepRest: true })
373377 }
tests/unit/uplink/uplink.test.tsmodified+4-1
Changes to tests/unit/uplink/uplink.test.ts
@@ -190,12 +190,15 @@ describe('Uplink', () => {
190190 expect(h.engine.runs[1].request.sessionId).toBeNull()
191191 })
192192
193 it('tells the user when the claude CLI is not logged in', async () => {
193 it('tells the user which CLI is not logged in, and how to fix it', async () => {
194194 const h = await harness()
195195 h.fake.add({ chat: ME, text: 'hello', fromMe: true })
196196 await h.uplink.tick()
197197 h.engine.finish(0, 'Failed to authenticate', { ok: false, authFailure: true })
198198 await settle()
199 // The remedy comes from the engine, so a Codex install is told about Codex
200 // rather than about a claude token and a .env the app does not have.
201 expect(h.sender.texts()[0]).toContain('Claude Code')
199202 expect(h.sender.texts()[0]).toContain('claude setup-token')
200203 expect(h.store.runs.get(1)?.status).toBe('failed')
201204 })