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.
| @@ -138,14 +138,32 @@ TS_CLOUD_UI_PASSWORD= | ||
| 138 | 138 | # forward this process cannot see, or 0 to keep a public box off public DNS. |
| 139 | 139 | # TS_CLOUD_SSH_PUBLISH_DNS= |
| 140 | 140 | |
| 141 | # Uplink - text this Mac, get Claude. See README.md. | |
| 141 | # Uplink - text this Mac, get an agent. See README.md. | |
| 142 | 142 | # Who may command it. Empty = this Mac's own Messages handles only ("text myself"). |
| 143 | 143 | # Anyone listed can make an agent act on this Mac, so list only yourself. |
| 144 | 144 | UPLINK_ALLOWED= |
| 145 | # Which agent CLI answers: claude or codex. Empty = claude. | |
| 146 | UPLINK_ENGINE= | |
| 147 | # --- Claude Code --- | |
| 145 | 148 | # From `claude setup-token` (your Claude Max login, good for a year). Encrypt it: ./buddy env:set |
| 146 | 149 | CLAUDE_CODE_OAUTH_TOKEN= |
| 147 | 150 | # 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. | |
| 152 | UPLINK_CLAUDE_MODEL= | |
| 148 | 153 | UPLINK_MODEL= |
| 154 | # Optional: where the claude binary is, if it is not on PATH. | |
| 155 | UPLINK_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. | |
| 160 | UPLINK_CODEX_BIN= | |
| 161 | # Optional: model the agent should use. Empty = the CLI's default. | |
| 162 | UPLINK_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. | |
| 166 | UPLINK_CODEX_PERMISSION=bypass | |
| 149 | 167 | # Where runs start when a text names no path. |
| 150 | 168 | UPLINK_WORKDIR=~ |
| 151 | 169 | # Replies longer than UPLINK_MAX_CHARS are split; past UPLINK_MAX_PARTS the rest waits for "more". |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import { copyFileSync, existsSync, watch } from 'node:fs' |
| 2 | 2 | import process from 'node:process' |
| 3 | 3 | import { defineCommand } from '@stacksjs/cli' |
| 4 | import { loadConfig, systemPrompt } from '../Uplink/config' | |
| 5 | import { runChecks, writeHeartbeat } from '../Uplink/doctor' | |
| 6 | import { ClaudeEngine } from '../Uplink/engine' | |
| 4 | import type { EngineId } from '../Uplink/engine' | |
| 5 | import { loadConfig } from '../Uplink/config' | |
| 6 | import { checksPass, runChecks, writeHeartbeat } from '../Uplink/doctor' | |
| 7 | import { ENGINE_IDS, isEngineId } from '../Uplink/engine' | |
| 8 | import { engineFor, selectedEngine } from '../Uplink/engines' | |
| 7 | 9 | import { formatDuration, toPlainText } from '../Uplink/format' |
| 8 | 10 | import { MessagesAccessError, MessagesDb } from '../Uplink/messages-db' |
| 9 | 11 | import { ModelStore } from '../Uplink/model-store' |
| @@ -18,16 +20,6 @@ import { detectWorkdir } from '../Uplink/workdir' | ||
| 18 | 20 | const HEARTBEAT_MS = 15_000 |
| 19 | 21 | const RETRY_OPEN_MS = 30_000 |
| 20 | 22 | |
| 21 | function 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 | ||
| 31 | 23 | async function openMessages(config: ReturnType<typeof loadConfig>, appDir: string, startedAt: number): Promise<MessagesDb> { |
| 32 | 24 | // Without Full Disk Access this fails. Under launchd, exiting would only |
| 33 | 25 | // restart-loop, so wait for the grant instead and say why in the heartbeat. |
| @@ -85,7 +77,7 @@ export default defineCommand((cli) => { | ||
| 85 | 77 | config, |
| 86 | 78 | messages, |
| 87 | 79 | sender: new AppleScriptSender(), |
| 88 | engine: engineFor(config), | |
| 80 | engine: selectedEngine(config), | |
| 89 | 81 | store: new ModelStore(`${appDir}/storage/uplink/cursor`), |
| 90 | 82 | }) |
| 91 | 83 | await uplink.start() |
| @@ -134,11 +126,19 @@ export default defineCommand((cli) => { | ||
| 134 | 126 | cli |
| 135 | 127 | .command('uplink:ask <prompt>', 'Run one prompt through the agent exactly as a text would, and print the reply') |
| 136 | 128 | .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 }) => { | |
| 138 | 131 | const config = loadConfig() |
| 139 | 132 | 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({ | |
| 142 | 142 | prompt, |
| 143 | 143 | cwd, |
| 144 | 144 | onEvent: (event) => { |
| @@ -161,7 +161,7 @@ export default defineCommand((cli) => { | ||
| 161 | 161 | if (!check.ok && check.fix) |
| 162 | 162 | console.log(` → ${check.fix}`) |
| 163 | 163 | } |
| 164 | process.exit(checks.every(c => c.ok) ? 0 : 1) | |
| 164 | process.exit(checksPass(checks) ? 0 : 1) | |
| 165 | 165 | }) |
| 166 | 166 | |
| 167 | 167 | cli |
| @@ -3,7 +3,7 @@ import { homedir } from 'node:os' | ||
| 3 | 3 | import process from 'node:process' |
| 4 | 4 | import Run from '../Models/Run' |
| 5 | 5 | import { loadConfig } from './config' |
| 6 | import { readHeartbeat, runChecks } from './doctor' | |
| 6 | import { checksPass, readHeartbeat, runChecks } from './doctor' | |
| 7 | 7 | import { formatDuration, truncate } from './format' |
| 8 | 8 | |
| 9 | 9 | /** |
| @@ -62,7 +62,7 @@ export async function loadDashboard(appDir: string = process.cwd()): Promise<Das | ||
| 62 | 62 | ]) |
| 63 | 63 | |
| 64 | 64 | const live = isLive(heartbeat) |
| 65 | const ready = checks.every(check => check.ok) | |
| 65 | const ready = checksPass(checks) | |
| 66 | 66 | const runs = rows.map((row): RunRow => ({ |
| 67 | 67 | id: row.id, |
| 68 | 68 | prompt: truncate(row.prompt ?? '', 140), |
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | import type { Server } from 'bun' |
| 3 | 3 | import type { UplinkConfig } from './config' |
| 4 | 4 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 5 | import { homedir, tmpdir } from 'node:os' | |
| 5 | import { homedir } from 'node:os' | |
| 6 | 6 | import { join } from 'node:path' |
| 7 | 7 | import process from 'node:process' |
| 8 | import { loadConfig, systemPrompt } from './config' | |
| 9 | import { ClaudeEngine } from './engine' | |
| 8 | import { loadConfig } from './config' | |
| 9 | import { isEngineId } from './engine' | |
| 10 | import { allEngines, selectedEngine } from './engines' | |
| 10 | 11 | import { formatDuration, truncate } from './format' |
| 11 | 12 | import { MessagesAccessError, MessagesDb } from './messages-db' |
| 12 | 13 | import { AppleScriptSender } from './sender' |
| @@ -40,7 +41,7 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid') | ||
| 40 | 41 | const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) |
| 41 | 42 | const RETRY_OPEN_MS = 5_000 |
| 42 | 43 | |
| 43 | type ClaudeState = { ok: boolean, detail: string, checkedAt: number } | null | |
| 44 | type EngineState = { ok: boolean, detail: string, checkedAt: number } | |
| 44 | 45 | |
| 45 | 46 | export interface DesktopAgent { |
| 46 | 47 | port: number |
| @@ -124,29 +125,6 @@ export function setOpenAtLogin(enabled: boolean): void { | ||
| 124 | 125 | `) |
| 125 | 126 | } |
| 126 | 127 | |
| 127 | async 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 | } | |
| 150 | 128 | |
| 151 | 129 | export async function startDesktopAgent(options: { version: string }): Promise<DesktopAgent> { |
| 152 | 130 | mkdirSync(DATA_DIR, { recursive: true }) |
| @@ -169,23 +147,25 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 169 | 147 | let messages: MessagesDb | null = null |
| 170 | 148 | let messagesError: string | null = null |
| 171 | 149 | 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) | |
| 185 | 160 | 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 | } | |
| 189 | 169 | } |
| 190 | 170 | |
| 191 | 171 | const startWatching = async (): Promise<void> => { |
| @@ -199,7 +179,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 199 | 179 | messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error) |
| 200 | 180 | return |
| 201 | 181 | } |
| 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 }) | |
| 203 | 183 | await uplink.start() |
| 204 | 184 | } |
| 205 | 185 | |
| @@ -222,7 +202,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 222 | 202 | }, 15_000) |
| 223 | 203 | |
| 224 | 204 | await startWatching() |
| 225 | void checkClaude() | |
| 205 | void checkEngines() | |
| 226 | 206 | |
| 227 | 207 | const status = () => { |
| 228 | 208 | const recent = store.recentRuns(8).map(run => ({ |
| @@ -245,12 +225,21 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 245 | 225 | ok: messages !== null, |
| 246 | 226 | detail: messages ? 'Full Disk Access granted' : (messagesError ?? 'Waiting for Full Disk Access'), |
| 247 | 227 | }, |
| 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 | }), | |
| 254 | 243 | { |
| 255 | 244 | id: 'handles', |
| 256 | 245 | name: 'Who can text it', |
| @@ -258,7 +247,8 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 258 | 247 | detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.', |
| 259 | 248 | }, |
| 260 | 249 | ] |
| 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) | |
| 262 | 252 | const state = settings.paused ? 'paused' : !ready ? 'setup' : active.length > 0 ? 'working' : 'listening' |
| 263 | 253 | return { |
| 264 | 254 | state, |
| @@ -267,11 +257,18 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 267 | 257 | checks, |
| 268 | 258 | active, |
| 269 | 259 | 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 })), | |
| 271 | 262 | lastError: uplink?.lastError ?? null, |
| 272 | 263 | } |
| 273 | 264 | } |
| 274 | 265 | |
| 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 | ||
| 275 | 272 | const json = (body: unknown, status = 200): Response => Response.json(body, { status, headers: { 'cache-control': 'no-store' } }) |
| 276 | 273 | |
| 277 | 274 | const server = Bun.serve({ |
| @@ -301,10 +298,12 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 301 | 298 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles']) |
| 302 | 299 | return json({ ok: true }) |
| 303 | 300 | |
| 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 | } | |
| 308 | 307 | |
| 309 | 308 | case '/api/token': { |
| 310 | 309 | const cleaned = cleanToken(String(body.token ?? '')) |
| @@ -313,17 +312,22 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 313 | 312 | writeToken(cleaned) |
| 314 | 313 | token = cleaned |
| 315 | 314 | 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()) | |
| 318 | 317 | } |
| 319 | 318 | |
| 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. | |
| 320 | 321 | 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()) | |
| 323 | 325 | |
| 324 | 326 | case '/api/settings': { |
| 325 | 327 | if (Array.isArray(body.allowed)) |
| 326 | 328 | 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 | |
| 327 | 331 | if (typeof body.paused === 'boolean') |
| 328 | 332 | settings.paused = body.paused |
| 329 | 333 | if (typeof body.openAtLogin === 'boolean') { |
| @@ -334,6 +338,9 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 334 | 338 | settings = readSettings() |
| 335 | 339 | config = loadConfig(settingsEnv(settings)) |
| 336 | 340 | 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() | |
| 337 | 344 | return json(status()) |
| 338 | 345 | } |
| 339 | 346 | |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import type { UplinkConfig } from './config' |
| 2 | import type { Engine } from './engine' | |
| 2 | 3 | import { existsSync } from 'node:fs' |
| 3 | 4 | import { join } from 'node:path' |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import process from 'node:process' | |
| 6 | import { formatDuration, truncate } from './format' | |
| 5 | import { allEngines } from './engines' | |
| 6 | import { formatDuration } from './format' | |
| 7 | 7 | import { MessagesDb } from './messages-db' |
| 8 | 8 | import { servicePaths, serviceState } from './service' |
| 9 | 9 | |
| @@ -18,6 +18,16 @@ export interface Check { | ||
| 18 | 18 | ok: boolean |
| 19 | 19 | detail: string |
| 20 | 20 | 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. */ | |
| 29 | export function checksPass(checks: Check[]): boolean { | |
| 30 | return checks.every(check => check.ok || check.informational) | |
| 21 | 31 | } |
| 22 | 32 | |
| 23 | 33 | export interface Heartbeat { |
| @@ -51,34 +61,14 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> { | ||
| 51 | 61 | const HEARTBEAT_STALE_MS = 2 * 60_000 |
| 52 | 62 | |
| 53 | 63 | /** |
| 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. | |
| 58 | 67 | */ |
| 59 | async 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 | } | |
| 68 | function 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' | |
| 82 | 72 | } |
| 83 | 73 | |
| 84 | 74 | export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> { |
| @@ -123,10 +113,24 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C | ||
| 123 | 113 | ? { name: 'Who can text it', ok: true, detail: config.allowed.length > 0 ? `UPLINK_ALLOWED: ${allowed.join(', ')}` : `your own handles: ${allowed.join(', ')}` } |
| 124 | 114 | : { 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)' }) |
| 125 | 115 | |
| 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 | } | |
| 130 | 134 | |
| 131 | 135 | const messages = Bun.spawnSync(['pgrep', '-x', 'Messages']) |
| 132 | 136 | checks.push(messages.exitCode === 0 |
| @@ -366,8 +366,12 @@ export class Uplink { | ||
| 366 | 366 | }) |
| 367 | 367 | |
| 368 | 368 | 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 | |
| 369 | 373 | 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}.` | |
| 371 | 375 | : result.ok ? result.text : `That failed: ${result.text}` |
| 372 | 376 | await this.reply(active.target, text, { keepRest: true }) |
| 373 | 377 | } |
| @@ -190,12 +190,15 @@ describe('Uplink', () => { | ||
| 190 | 190 | expect(h.engine.runs[1].request.sessionId).toBeNull() |
| 191 | 191 | }) |
| 192 | 192 | |
| 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 () => { | |
| 194 | 194 | const h = await harness() |
| 195 | 195 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) |
| 196 | 196 | await h.uplink.tick() |
| 197 | 197 | h.engine.finish(0, 'Failed to authenticate', { ok: false, authFailure: true }) |
| 198 | 198 | 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') | |
| 199 | 202 | expect(h.sender.texts()[0]).toContain('claude setup-token') |
| 200 | 203 | expect(h.store.runs.get(1)?.status).toBe('failed') |
| 201 | 204 | }) |