From 6886c0d7f2a0e5da1bc3a36dc17857ccafabde34 Mon Sep 17 00:00:00 2001 From: PatrickYu17 Date: Tue, 21 Apr 2026 01:52:10 -0400 Subject: [PATCH 1/2] feat: add cli --- apps/tui/package.json | 13 +- apps/tui/src/__tests__/cli.e2e.test.ts | 74 +- apps/tui/src/__tests__/release.test.ts | 66 +- apps/tui/src/cli.ts | 904 ++++++++++++------ apps/tui/src/components/app.tsx | 60 +- apps/tui/src/lib/cli-output.ts | 77 ++ apps/tui/src/lib/cli-version.ts | 14 + apps/tui/src/lib/onboarding.test.ts | 111 +++ apps/tui/src/lib/onboarding.ts | 70 +- apps/tui/src/lib/providers.ts | 96 ++ bun.lock | 42 +- package.json | 1 + packages/daemon/package.json | 2 + packages/daemon/src/__tests__/helpers.ts | 24 +- .../daemon/src/__tests__/integration.test.ts | 32 +- packages/daemon/src/bin/ralphd.ts | 8 +- packages/daemon/src/cli.ts | 42 + scripts/release/shared.ts | 87 +- 18 files changed, 1322 insertions(+), 401 deletions(-) create mode 100644 apps/tui/src/lib/cli-output.ts create mode 100644 apps/tui/src/lib/cli-version.ts create mode 100644 apps/tui/src/lib/onboarding.test.ts create mode 100644 apps/tui/src/lib/providers.ts create mode 100644 packages/daemon/src/cli.ts diff --git a/apps/tui/package.json b/apps/tui/package.json index 7fca44c..854df3d 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -20,13 +20,18 @@ "typescript": "^5" }, "dependencies": { - "@crustjs/core": "^0.0.13", + "@crustjs/core": "^0.0.16", "@crustjs/create": "^0.0.4", - "@crustjs/plugins": "^0.0.16", + "@crustjs/plugins": "^0.0.21", + "@crustjs/progress": "^0.0.2", + "@crustjs/prompts": "^0.0.12", "@crustjs/store": "^0.0.4", - "@techatnyu/ralphd": "workspace:*", + "@crustjs/style": "^0.0.6", + "@crustjs/validate": "^0.0.15", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", - "react": "^19.2.4" + "@techatnyu/ralphd": "workspace:*", + "react": "^19.2.4", + "zod": "^4.3.6" } } diff --git a/apps/tui/src/__tests__/cli.e2e.test.ts b/apps/tui/src/__tests__/cli.e2e.test.ts index 0be52a1..6cb4ff3 100644 --- a/apps/tui/src/__tests__/cli.e2e.test.ts +++ b/apps/tui/src/__tests__/cli.e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; async function runCli( args: string[], homeDir: string, -): Promise<{ stdout: string; stderr: string }> { +): Promise<{ stdout: string; stderr: string; exitCode: number }> { const proc = Bun.spawn(["bun", "run", "src/cli.ts", ...args], { cwd: join(import.meta.dir, "..", ".."), env: { @@ -22,13 +22,21 @@ async function runCli( new Response(proc.stderr).text(), proc.exited, ]); - if (exitCode !== 0) { + + return { stdout, stderr, exitCode }; +} + +async function runCliOrThrow( + args: string[], + homeDir: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const result = await runCli(args, homeDir); + if (result.exitCode !== 0) { throw new Error( - `cli failed (${args.join(" ")}):\n${stdout}\n${stderr}`.trim(), + `cli failed (${args.join(" ")}):\n${result.stdout}\n${result.stderr}`.trim(), ); } - - return { stdout, stderr }; + return result; } describe("cli daemon lifecycle", () => { @@ -49,10 +57,10 @@ describe("cli daemon lifecycle", () => { test("daemon start is idempotent and health stays on the same pid", async () => { tempHome = await mkdtemp(join(tmpdir(), "ralph-cli-e2e-")); - await runCli(["daemon", "start"], tempHome); - const firstHealth = await runCli(["daemon", "health"], tempHome); - const secondStart = await runCli(["daemon", "start"], tempHome); - const secondHealth = await runCli(["daemon", "health"], tempHome); + await runCliOrThrow(["daemon", "start"], tempHome); + const firstHealth = await runCliOrThrow(["daemon", "health"], tempHome); + const secondStart = await runCliOrThrow(["daemon", "start"], tempHome); + const secondHealth = await runCliOrThrow(["daemon", "health"], tempHome); const firstPid = JSON.parse(firstHealth.stdout) as { pid: number }; const secondPid = JSON.parse(secondHealth.stdout) as { pid: number }; @@ -61,4 +69,52 @@ describe("cli daemon lifecycle", () => { expect(secondPid.pid).toBe(firstPid.pid); expect(secondStart.stdout).toContain("already running"); }); + + test("version flag prints the package version", async () => { + tempHome = await mkdtemp(join(tmpdir(), "ralph-cli-version-")); + const result = await runCliOrThrow(["--version"], tempHome); + expect(result.stdout).toContain("ralph v0.0.1"); + }); + + test("help command alias prints root help", async () => { + tempHome = await mkdtemp(join(tmpdir(), "ralph-cli-help-")); + const result = await runCliOrThrow(["help"], tempHome); + expect(result.stdout).toContain("Coding agent orchestration TUI and CLI"); + expect(result.stdout).toContain("setup"); + }); + + test("daemon start supports json output", async () => { + tempHome = await mkdtemp(join(tmpdir(), "ralph-cli-json-")); + const result = await runCliOrThrow(["daemon", "start", "--json"], tempHome); + const parsed = JSON.parse(result.stdout) as { + ok: boolean; + health: { pid: number }; + }; + + expect(parsed.ok).toBe(true); + expect(parsed.health.pid).toBeGreaterThan(0); + }); + + test("model get returns json when requested", async () => { + tempHome = await mkdtemp(join(tmpdir(), "ralph-cli-model-")); + await runCliOrThrow( + ["model", "set", "anthropic/claude-sonnet-4-5", "--json"], + tempHome, + ); + + const result = await runCliOrThrow(["model", "get", "--json"], tempHome); + expect(JSON.parse(result.stdout)).toEqual({ + model: "anthropic/claude-sonnet-4-5", + }); + }); + + test("typoed commands show autocomplete suggestions", async () => { + tempHome = await mkdtemp(join(tmpdir(), "ralph-cli-typo-")); + const result = await runCli(["deamon"], tempHome); + const combined = `${result.stdout}\n${result.stderr}`; + + expect(result.exitCode).toBeGreaterThan(0); + expect(combined).toContain('Did you mean "daemon"'); + expect(combined).not.toContain("TypeError"); + }, 10_000); }); diff --git a/apps/tui/src/__tests__/release.test.ts b/apps/tui/src/__tests__/release.test.ts index 6f171a6..609bbf5 100644 --- a/apps/tui/src/__tests__/release.test.ts +++ b/apps/tui/src/__tests__/release.test.ts @@ -35,6 +35,29 @@ function installedBinPath(projectDir: string, binaryName: "ralph" | "ralphd") { : join(projectDir, "node_modules", ".bin", binaryName); } +async function waitForDaemonHealth( + command: string, + installDir: string, + env: NodeJS.ProcessEnv, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const proc = Bun.spawn([command, "daemon", "health"], { + cwd: installDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + if ((await proc.exited) === 0) { + return; + } + await Bun.sleep(200); + } + + throw new Error("timed out waiting for daemon health"); +} + describe("release packaging", () => { test("stages root and target packages with both binaries", async () => { const tempDir = await mkdtemp(join(tmpdir(), "ralph-stage-")); @@ -109,6 +132,7 @@ describe("release packaging", () => { await buildBinaries({ targets: [currentTarget], outDir: compiledDir, + version: "0.0.0-smoke", }); await stageDistribution({ targets: [currentTarget], @@ -160,8 +184,8 @@ describe("release packaging", () => { ); expect(await help.exited).toBe(0); - const start = Bun.spawn( - [installedBinPath(installDir, "ralph"), "daemon", "start"], + const version = Bun.spawn( + [installedBinPath(installDir, "ralph"), "--version"], { cwd: installDir, env, @@ -169,7 +193,40 @@ describe("release packaging", () => { stderr: "pipe", }, ); - expect(await start.exited).toBe(0); + expect(await version.exited).toBe(0); + expect(await new Response(version.stdout).text()).toContain( + "ralph v0.0.0-smoke", + ); + + const daemonVersion = Bun.spawn( + [installedBinPath(installDir, "ralphd"), "--version"], + { + cwd: installDir, + env, + stdout: "pipe", + stderr: "pipe", + }, + ); + expect(await daemonVersion.exited).toBe(0); + expect(await new Response(daemonVersion.stdout).text()).toContain( + "ralphd v0.0.0-smoke", + ); + + const daemonProcess = Bun.spawn( + [installedBinPath(installDir, "ralphd")], + { + cwd: installDir, + env, + stdout: "pipe", + stderr: "pipe", + }, + ); + + await waitForDaemonHealth( + installedBinPath(installDir, "ralph"), + installDir, + env, + ); const health = Bun.spawn( [installedBinPath(installDir, "ralph"), "daemon", "health"], @@ -193,10 +250,11 @@ describe("release packaging", () => { }, ); expect(await stop.exited).toBe(0); + expect(await daemonProcess.exited).toBe(0); await access(installedBinPath(installDir, "ralphd")); } finally { await rm(tempDir, { recursive: true, force: true }); } - }); + }, 20_000); }); diff --git a/apps/tui/src/cli.ts b/apps/tui/src/cli.ts index b5e6fb8..a376d7c 100644 --- a/apps/tui/src/cli.ts +++ b/apps/tui/src/cli.ts @@ -1,16 +1,78 @@ import { Crust } from "@crustjs/core"; -import { helpPlugin } from "@crustjs/plugins"; +import { + autoCompletePlugin, + helpPlugin, + versionPlugin, +} from "@crustjs/plugins"; +import { spinner } from "@crustjs/progress"; +import { select } from "@crustjs/prompts"; +import { commandValidator, flag } from "@crustjs/validate/zod"; import { daemon, type JobState, + type ProviderListResult, runForegroundDaemon, startDetached, stopDaemon, waitUntilReady, } from "@techatnyu/ralphd"; +import { z } from "zod"; import { runTui } from "./index"; +import { + printJson, + printOnboardingSummary, + printProviderList, + printSetupSummary, +} from "./lib/cli-output"; +import { resolveCliVersion } from "./lib/cli-version"; +import type { OnboardingResult } from "./lib/onboarding"; +import { runOnboardingChecks } from "./lib/onboarding"; +import { listConnectedModels } from "./lib/providers"; import { parseModelRef, ralphStore, setModelAndRecent } from "./lib/store"; +interface JsonFlags { + json?: boolean; +} + +const ROOT_COMMANDS = new Set([ + "setup", + "doctor", + "provider", + "daemon", + "model", +]); + +interface SetupCommandResult extends OnboardingResult { + interactive: boolean; + currentModel?: string; + selectedModel?: string; + providerSummary?: { + connected: number; + total: number; + }; + providerError?: string; +} + +function jsonFlagDef() { + return flag(z.boolean().default(false).describe("Print JSON output")); +} + +function wantsJson(flags: JsonFlags): boolean { + return Boolean(flags.json); +} + +function isInteractiveSetup(flags: { + json: boolean; + "non-interactive": boolean; +}): boolean { + return ( + !flags.json && + !flags["non-interactive"] && + Boolean(process.stdin.isTTY) && + Boolean(process.stderr.isTTY) + ); +} + async function requireDaemon(): Promise { const running = await daemon.isDaemonRunning(); if (!running) { @@ -27,308 +89,576 @@ function withDaemon( }; } -function printJson(value: unknown): void { - console.log(JSON.stringify(value, null, 2)); +async function fetchProviders( + options: { directory?: string; refresh?: boolean }, + json: boolean, +): Promise { + if (json) { + return daemon.providerList(options); + } + + return spinner({ + message: options.refresh + ? "Refreshing providers..." + : "Loading providers...", + task: async () => daemon.providerList(options), + }); } -const cli = new Crust("ralph") - .meta({ description: "Coding agent orchestration TUI" }) - .use(helpPlugin()) - .run(async () => { - await runTui(); - }) - .command("daemon", (daemonCommand) => - daemonCommand - .meta({ description: "Manage the background daemon" }) - .command("serve", (cmd) => - cmd - .meta({ description: "Run the daemon in the foreground" }) - .run(async () => { - await runForegroundDaemon(); - }), - ) - .command("start", (cmd) => - cmd - .meta({ description: "Start the daemon in the background" }) - .run(async () => { - if (await daemon.isDaemonRunning()) { - const result = await daemon.health(); - console.log( - `ralphd is already running (pid ${result.pid}, uptime ${result.uptimeSeconds}s)`, - ); - return; - } +async function maybeSelectModel( + providerResult: ProviderListResult, + interactive: boolean, +): Promise { + const models = listConnectedModels(providerResult); + if (!interactive || models.length === 0) { + return undefined; + } + + return select({ + message: "Choose a default model", + choices: models.map((model) => ({ + label: model.label, + value: model.ref, + hint: model.providerId, + })), + maxVisible: 12, + }); +} + +async function runSetup(flags: { + json: boolean; + "non-interactive": boolean; +}): Promise { + const interactive = isInteractiveSetup(flags); + const checks = await runOnboardingChecks({ autoStartDaemon: true }); + const currentStore = await ralphStore.read(); + const result: SetupCommandResult = { + ...checks, + interactive, + currentModel: currentStore.model || undefined, + }; + + if (!checks.ok) { + return result; + } + + try { + const providerResult = await fetchProviders({ refresh: true }, flags.json); + result.providerSummary = { + connected: providerResult.connected.length, + total: providerResult.providers.length, + }; + + if (!result.currentModel) { + const selectedModel = await maybeSelectModel(providerResult, interactive); + if (selectedModel) { + await setModelAndRecent(selectedModel); + result.selectedModel = selectedModel; + result.currentModel = selectedModel; + } + } + } catch (error) { + result.providerError = + error instanceof Error ? error.message : "Failed to refresh providers"; + } + + return result; +} + +function printSetupResult(result: SetupCommandResult): void { + printOnboardingSummary(result, "Ralph Setup"); + printSetupSummary({ + currentModel: result.currentModel, + providerSummary: result.providerSummary, + providerError: result.providerError, + }); + if (result.ok) { + console.log("Run `ralph` to launch the TUI."); + } +} - await startDetached(); - const ok = await waitUntilReady(); - if (!ok) { - throw new Error("Failed to start ralphd"); +function printModelResult(model: string, flags: JsonFlags): void { + if (wantsJson(flags)) { + printJson({ model: model || null }); + return; + } + console.log(model || "No model set (using SDK default)"); +} + +const cliVersion = await resolveCliVersion( + new URL("../package.json", import.meta.url), +); + +export function createRootCli() { + return new Crust("ralph") + .meta({ description: "Coding agent orchestration TUI and CLI" }) + .use(versionPlugin(cliVersion)) + .use(autoCompletePlugin({ mode: "help" })) + .use(helpPlugin()) + .command("setup", (cmd) => + cmd + .meta({ description: "Run guided first-time setup" }) + .flags({ + json: jsonFlagDef(), + "non-interactive": flag( + z.boolean().default(false).describe("Skip interactive prompts"), + ), + }) + .run( + commandValidator(async ({ flags }) => { + const result = await runSetup(flags); + if (flags.json) { + printJson(result); + } else { + printSetupResult(result); } - const result = await daemon.health(); - console.log(`ralphd started (pid ${result.pid})`); - }), - ) - .command("stop", (cmd) => - cmd.meta({ description: "Stop the running daemon" }).run(async () => { - await stopDaemon(); - console.log("ralphd stopped"); - }), - ) - .command("health", (cmd) => - cmd.meta({ description: "Show daemon health status" }).run( - withDaemon(async () => { - printJson(await daemon.health()); + if (!result.ok) { + process.exitCode = 1; + } }), ), - ) - .command("submit", (cmd) => - cmd - .meta({ description: "Submit a new job" }) - .args([ - { - name: "prompt", - type: "string", - required: true, - variadic: true, - description: "The prompt for the loop job", - }, - ]) - .flags({ - instance: { - type: "string", - required: true, - description: "Target instance ID", - }, - session: { - type: "string", - description: "Existing session ID", - }, - }) - .run( - withDaemon(async ({ args, flags }) => { - const prompt = args.prompt.join(" ").trim(); - const stored = await ralphStore.read(); - const model = parseModelRef(stored.model); - const result = await daemon.submitJob({ - instanceId: flags.instance, - session: flags.session - ? { type: "existing", sessionId: flags.session } - : { type: "new" }, - task: { - type: "prompt", - prompt, - model, - }, - }); + ) + .command("doctor", (cmd) => + cmd + .meta({ description: "Check local Ralph prerequisites" }) + .flags({ + json: jsonFlagDef(), + }) + .run( + commandValidator(async ({ flags }) => { + const result = await runOnboardingChecks({ + autoStartDaemon: false, + }); + if (flags.json) { printJson(result); - }), - ), - ) - .command("list", (cmd) => - cmd - .meta({ description: "List all jobs" }) - .flags({ - instance: { - type: "string", - description: "Filter by instance ID", - }, - state: { - type: "string", - description: "Filter by job state", - }, - }) - .run( - withDaemon(async ({ flags }) => { - printJson( - await daemon.listJobs({ - instanceId: flags.instance, - state: flags.state as JobState, + } else { + printOnboardingSummary(result, "Ralph Doctor"); + } + + if (!result.ok) { + process.exitCode = 1; + } + }), + ), + ) + .command("provider", (providerCommand) => + providerCommand + .meta({ description: "Inspect available providers" }) + .command("list", (cmd) => + cmd + .meta({ description: "List configured providers" }) + .flags({ + directory: flag( + z.string().describe("Workspace directory to query").optional(), + ), + refresh: flag( + z + .boolean() + .default(false) + .describe("Refresh provider metadata before listing"), + ), + json: jsonFlagDef(), + }) + .run( + commandValidator( + withDaemon(async ({ flags }) => { + const result = await fetchProviders( + { + directory: flags.directory, + refresh: flags.refresh, + }, + flags.json, + ); + if (flags.json) { + printJson(result); + return; + } + printProviderList(result); }), - ); + ), + ), + ), + ) + .command("daemon", (daemonCommand) => + daemonCommand + .meta({ description: "Manage the background daemon" }) + .command("serve", (cmd) => + cmd + .meta({ description: "Run the daemon in the foreground" }) + .run(async () => { + await runForegroundDaemon(); }), - ), - ) - .command("get", (cmd) => - cmd - .meta({ description: "Get details of a specific job" }) - .args([ - { - name: "jobId", - type: "string", - required: true, - description: "The job ID", - }, - ]) - .run( - withDaemon(async ({ args }) => { - printJson(await daemon.getJob(args.jobId)); + ) + .command("start", (cmd) => + cmd + .meta({ description: "Start the daemon in the background" }) + .flags({ + json: { + type: "boolean", + description: "Print JSON output", + }, + }) + .run(async ({ flags }) => { + if (await daemon.isDaemonRunning()) { + const result = await daemon.health(); + if (wantsJson(flags)) { + printJson({ ok: true, alreadyRunning: true, health: result }); + return; + } + console.log( + `ralphd is already running (pid ${result.pid}, uptime ${result.uptimeSeconds}s)`, + ); + return; + } + + await startDetached(); + const ok = await waitUntilReady(); + if (!ok) { + throw new Error("Failed to start ralphd"); + } + + const result = await daemon.health(); + if (wantsJson(flags)) { + printJson({ ok: true, alreadyRunning: false, health: result }); + return; + } + console.log(`ralphd started (pid ${result.pid})`); }), - ), - ) - .command("cancel", (cmd) => - cmd - .meta({ description: "Cancel a job" }) - .args([ - { - name: "jobId", - type: "string", - required: true, - description: "The job ID", - }, - ]) - .run( - withDaemon(async ({ args }) => { - printJson(await daemon.cancelJob(args.jobId)); + ) + .command("stop", (cmd) => + cmd + .meta({ description: "Stop the running daemon" }) + .flags({ + json: { + type: "boolean", + description: "Print JSON output", + }, + }) + .run(async ({ flags }) => { + await stopDaemon(); + if (wantsJson(flags)) { + printJson({ ok: true }); + return; + } + console.log("ralphd stopped"); + }), + ) + .command("health", (cmd) => + cmd.meta({ description: "Show daemon health status" }).run( + withDaemon(async () => { + printJson(await daemon.health()); }), ), - ) - .command("instance", (instanceCommand) => - instanceCommand - .meta({ description: "Manage daemon instances" }) - .command("create", (cmd) => - cmd - .meta({ description: "Create a managed instance" }) - .args([ - { - name: "name", - type: "string", - required: true, - description: "Instance name", - }, - ]) - .flags({ - directory: { - type: "string", - required: true, - description: "Workspace directory", - }, - "max-concurrency": { - type: "number", - description: "Per-instance concurrency", - }, - }) - .run( - withDaemon(async ({ args, flags }) => { - printJson( - await daemon.createInstance({ - name: args.name, - directory: flags.directory, - maxConcurrency: flags["max-concurrency"], - }), - ); - }), - ), - ) - .command("list", (cmd) => - cmd.meta({ description: "List registered instances" }).run( - withDaemon(async () => { - printJson(await daemon.listInstances()); + ) + .command("submit", (cmd) => + cmd + .meta({ description: "Submit a new job" }) + .args([ + { + name: "prompt", + type: "string", + required: true, + variadic: true, + description: "The prompt for the loop job", + }, + ]) + .flags({ + instance: { + type: "string", + required: true, + description: "Target instance ID", + }, + session: { + type: "string", + description: "Existing session ID", + }, + }) + .run( + withDaemon(async ({ args, flags }) => { + const prompt = args.prompt.join(" ").trim(); + const stored = await ralphStore.read(); + const model = parseModelRef(stored.model); + const result = await daemon.submitJob({ + instanceId: flags.instance, + session: flags.session + ? { type: "existing", sessionId: flags.session } + : { type: "new" }, + task: { + type: "prompt", + prompt, + model, + }, + }); + printJson(result); }), ), - ) - .command("get", (cmd) => - cmd - .meta({ description: "Get a registered instance" }) - .args([ - { - name: "instanceId", - type: "string", - required: true, - description: "Instance ID", - }, - ]) - .run( - withDaemon(async ({ args }) => { - printJson(await daemon.getInstance(args.instanceId)); - }), - ), - ) - .command("start", (cmd) => - cmd - .meta({ description: "Start an instance" }) - .args([ - { - name: "instanceId", - type: "string", - required: true, - description: "Instance ID", - }, - ]) - .run( - withDaemon(async ({ args }) => { - printJson(await daemon.startInstance(args.instanceId)); - }), - ), - ) - .command("stop", (cmd) => - cmd - .meta({ description: "Stop an instance" }) - .args([ - { - name: "instanceId", - type: "string", - required: true, - description: "Instance ID", - }, - ]) - .run( - withDaemon(async ({ args }) => { - printJson(await daemon.stopInstance(args.instanceId)); - }), - ), - ) - .command("remove", (cmd) => - cmd - .meta({ description: "Remove an instance" }) - .args([ - { - name: "instanceId", - type: "string", - required: true, - description: "Instance ID", - }, - ]) - .run( - withDaemon(async ({ args }) => { - printJson(await daemon.removeInstance(args.instanceId)); + ) + .command("list", (cmd) => + cmd + .meta({ description: "List all jobs" }) + .flags({ + instance: { + type: "string", + description: "Filter by instance ID", + }, + state: { + type: "string", + description: "Filter by job state", + }, + }) + .run( + withDaemon(async ({ flags }) => { + printJson( + await daemon.listJobs({ + instanceId: flags.instance, + state: flags.state as JobState, + }), + ); + }), + ), + ) + .command("get", (cmd) => + cmd + .meta({ description: "Get details of a specific job" }) + .args([ + { + name: "jobId", + type: "string", + required: true, + description: "The job ID", + }, + ]) + .run( + withDaemon(async ({ args }) => { + printJson(await daemon.getJob(args.jobId)); + }), + ), + ) + .command("cancel", (cmd) => + cmd + .meta({ description: "Cancel a job" }) + .args([ + { + name: "jobId", + type: "string", + required: true, + description: "The job ID", + }, + ]) + .run( + withDaemon(async ({ args }) => { + printJson(await daemon.cancelJob(args.jobId)); + }), + ), + ) + .command("instance", (instanceCommand) => + instanceCommand + .meta({ description: "Manage daemon instances" }) + .command("create", (cmd) => + cmd + .meta({ description: "Create a managed instance" }) + .args([ + { + name: "name", + type: "string", + required: true, + description: "Instance name", + }, + ]) + .flags({ + directory: { + type: "string", + required: true, + description: "Workspace directory", + }, + "max-concurrency": { + type: "number", + description: "Per-instance concurrency", + }, + }) + .run( + withDaemon(async ({ args, flags }) => { + printJson( + await daemon.createInstance({ + name: args.name, + directory: flags.directory, + maxConcurrency: flags["max-concurrency"], + }), + ); + }), + ), + ) + .command("list", (cmd) => + cmd.meta({ description: "List registered instances" }).run( + withDaemon(async () => { + printJson(await daemon.listInstances()); }), ), - ), - ), - ) - .command("model", (modelCommand) => - modelCommand - .meta({ description: "Manage model selection" }) - .command("set", (cmd) => - cmd - .meta({ description: "Set the active model" }) - .args([ - { - name: "model", - type: "string", - required: true, - description: - "Model in provider/model format (e.g. anthropic/claude-sonnet-4-5)", - }, - ]) - .run(async ({ args }) => { - const parsed = parseModelRef(args.model); - if (!parsed) { - throw new Error( - "Invalid model format. Use provider/model (e.g. anthropic/claude-sonnet-4-5)", - ); - } - await setModelAndRecent(args.model); - console.log(`Model set to: ${args.model}`); - }), - ) - .command("get", (cmd) => - cmd.meta({ description: "Show the active model" }).run(async () => { - const { model } = await ralphStore.read(); - console.log(model || "No model set (using SDK default)"); - }), - ), + ) + .command("get", (cmd) => + cmd + .meta({ description: "Get a registered instance" }) + .args([ + { + name: "instanceId", + type: "string", + required: true, + description: "Instance ID", + }, + ]) + .run( + withDaemon(async ({ args }) => { + printJson(await daemon.getInstance(args.instanceId)); + }), + ), + ) + .command("start", (cmd) => + cmd + .meta({ description: "Start an instance" }) + .args([ + { + name: "instanceId", + type: "string", + required: true, + description: "Instance ID", + }, + ]) + .run( + withDaemon(async ({ args }) => { + printJson(await daemon.startInstance(args.instanceId)); + }), + ), + ) + .command("stop", (cmd) => + cmd + .meta({ description: "Stop an instance" }) + .args([ + { + name: "instanceId", + type: "string", + required: true, + description: "Instance ID", + }, + ]) + .run( + withDaemon(async ({ args }) => { + printJson(await daemon.stopInstance(args.instanceId)); + }), + ), + ) + .command("remove", (cmd) => + cmd + .meta({ description: "Remove an instance" }) + .args([ + { + name: "instanceId", + type: "string", + required: true, + description: "Instance ID", + }, + ]) + .run( + withDaemon(async ({ args }) => { + printJson(await daemon.removeInstance(args.instanceId)); + }), + ), + ), + ), + ) + .command("model", (modelCommand) => + modelCommand + .meta({ description: "Manage model selection" }) + .command("set", (cmd) => + cmd + .meta({ description: "Set the active model" }) + .args([ + { + name: "model", + type: "string", + required: true, + description: + "Model in provider/model format (e.g. anthropic/claude-sonnet-4-5)", + }, + ]) + .flags({ + json: { + type: "boolean", + description: "Print JSON output", + }, + }) + .run(async ({ args, flags }) => { + const parsed = parseModelRef(args.model); + if (!parsed) { + throw new Error( + "Invalid model format. Use provider/model (e.g. anthropic/claude-sonnet-4-5)", + ); + } + await setModelAndRecent(args.model); + if (wantsJson(flags)) { + printJson({ model: args.model }); + return; + } + console.log(`Model set to: ${args.model}`); + }), + ) + .command("get", (cmd) => + cmd + .meta({ description: "Show the active model" }) + .flags({ + json: { + type: "boolean", + description: "Print JSON output", + }, + }) + .run(async ({ flags }) => { + const { model } = await ralphStore.read(); + printModelResult(model, flags); + }), + ), + ); +} + +export const app = createRootCli(); + +export default app; + +function normalizeRootArgv(argv: string[]): string[] { + if (argv.length === 1 && argv[0] === "help") { + return ["--help"]; + } + + if (argv.length === 1 && argv[0] === "version") { + return ["--version"]; + } + + return argv; +} + +function isUnknownRootCommand(argv: string[]): boolean { + const first = argv[0]; + return Boolean( + first && + !first.startsWith("-") && + first !== "help" && + first !== "version" && + !ROOT_COMMANDS.has(first), ); +} -await cli.execute(); +export async function executeCli(argv = Bun.argv.slice(2)): Promise { + if (argv.length === 0) { + await runTui(); + return; + } + + const unknownRootCommand = isUnknownRootCommand(argv); + await app.execute({ argv: normalizeRootArgv(argv) }); + if (unknownRootCommand && (process.exitCode ?? 0) === 0) { + process.exitCode = 1; + } +} + +if (import.meta.main) { + await executeCli(); +} diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 15d6c7f..73b1748 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -8,6 +8,10 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; +import { + buildModelSelectOptions, + MODEL_SELECT_SEPARATOR_VALUE, +} from "../lib/providers"; import { ralphStore, setModelAndRecent } from "../lib/store"; import { Chat } from "./chat"; @@ -21,61 +25,12 @@ interface DashboardData { jobs: DaemonJob[]; } -/** Provider IDs sorted by popularity — used to push well-known providers to the top. */ -const PROVIDER_PRIORITY: Record = { - anthropic: 0, - openai: 1, - google: 2, - openrouter: 3, -}; - -const SEPARATOR_VALUE = "__separator__"; - async function fetchModelOptions(): Promise { const [result, store] = await Promise.all([ daemon.providerList({ refresh: true }), ralphStore.read(), ]); - const connected = new Set(result.connected); - const recentRefs = new Set(store.recentModels ?? []); - - // Build flat list of all connected models - const allModels: SelectOption[] = result.providers - .filter((provider) => connected.has(provider.id)) - .sort( - (a, b) => - (PROVIDER_PRIORITY[a.id] ?? 99) - (PROVIDER_PRIORITY[b.id] ?? 99) || - a.name.localeCompare(b.name), - ) - .flatMap((provider) => - Object.values(provider.models) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((model) => ({ - name: `${provider.name}/${model.name}`, - description: `${provider.id}/${model.id}`, - value: `${provider.id}/${model.id}`, - })), - ); - - // Build recent section from stored order, only including models that still exist - const allByRef = new Map(allModels.map((m) => [m.value, m])); - const recentOptions: SelectOption[] = (store.recentModels ?? []) - .filter((ref) => allByRef.has(ref)) - .map((ref) => allByRef.get(ref) as SelectOption); - - if (recentOptions.length === 0) return allModels; - - // Filter recents out of the "all" section to avoid duplicates - const restModels = allModels.filter( - (m) => !recentRefs.has(m.value as string), - ); - - return [ - { name: "── Recent ──", description: "", value: SEPARATOR_VALUE }, - ...recentOptions, - { name: "── All Models ──", description: "", value: SEPARATOR_VALUE }, - ...restModels, - ]; + return buildModelSelectOptions(result, store.recentModels ?? []); } interface AppProps { @@ -236,7 +191,10 @@ function Dashboard({ showScrollIndicator wrapSelection onSelect={(_index, option) => { - if (option?.value && option.value !== SEPARATOR_VALUE) { + if ( + option?.value && + option.value !== MODEL_SELECT_SEPARATOR_VALUE + ) { const modelRef = option.value as string; void setModelAndRecent(modelRef).then(() => { setCurrentModel(modelRef); diff --git a/apps/tui/src/lib/cli-output.ts b/apps/tui/src/lib/cli-output.ts new file mode 100644 index 0000000..5b9c1ef --- /dev/null +++ b/apps/tui/src/lib/cli-output.ts @@ -0,0 +1,77 @@ +import { bold, cyan, dim, green, red, table } from "@crustjs/style"; +import type { ProviderListResult } from "@techatnyu/ralphd"; +import type { OnboardingResult } from "./onboarding"; +import { countProviderModels, sortProviders } from "./providers"; + +export function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +export function printOnboardingSummary( + result: OnboardingResult, + title: string, +): void { + console.log(bold(title)); + for (const check of result.checks) { + const status = check.ok ? green("OK ") : red("FAIL"); + const message = check.message ? ` ${dim(`(${check.message})`)}` : ""; + console.log(`${status} ${check.label}${message}`); + } + console.log( + result.ok + ? green("Ralph is ready.") + : red("Ralph setup is incomplete. Fix the failed checks above."), + ); +} + +export function printProviderList(result: ProviderListResult): void { + const connected = new Set(result.connected); + const providers = sortProviders(result.providers); + + if (providers.length === 0) { + console.log(dim("No providers available.")); + return; + } + + console.log(bold("Providers")); + console.log( + table( + ["Provider", "Status", "Models"], + providers.map((provider) => [ + `${provider.name} ${dim(`(${provider.id})`)}`, + connected.has(provider.id) ? green("connected") : red("disconnected"), + String(countProviderModels(provider)), + ]), + ), + ); + if (result.connected.length > 0) { + console.log(cyan(`Connected: ${result.connected.join(", ")}`)); + } +} + +export function printSetupSummary(options: { + currentModel?: string; + providerSummary?: { + connected: number; + total: number; + }; + providerError?: string; +}): void { + if (options.currentModel) { + console.log(`Active model: ${cyan(options.currentModel)}`); + } else { + console.log(dim("No active model selected yet.")); + } + + if (options.providerSummary) { + console.log( + dim( + `${options.providerSummary.connected} connected provider(s), ${options.providerSummary.total} total provider(s) detected.`, + ), + ); + } + + if (options.providerError) { + console.log(red(`Provider refresh failed: ${options.providerError}`)); + } +} diff --git a/apps/tui/src/lib/cli-version.ts b/apps/tui/src/lib/cli-version.ts new file mode 100644 index 0000000..27530c7 --- /dev/null +++ b/apps/tui/src/lib/cli-version.ts @@ -0,0 +1,14 @@ +async function readPackageVersion(packageJsonUrl: URL): Promise { + const packageJson = (await Bun.file(packageJsonUrl).json()) as { + version?: string; + }; + return packageJson.version ?? "0.0.0"; +} + +export async function resolveCliVersion(packageJsonUrl: URL): Promise { + return ( + process.env.PUBLIC_RALPH_VERSION ?? + process.env.RALPH_VERSION ?? + (await readPackageVersion(packageJsonUrl)) + ); +} diff --git a/apps/tui/src/lib/onboarding.test.ts b/apps/tui/src/lib/onboarding.test.ts new file mode 100644 index 0000000..6005d6d --- /dev/null +++ b/apps/tui/src/lib/onboarding.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import type { CommandRunner } from "./onboarding"; +import { runOnboardingChecks } from "./onboarding"; + +const healthyDaemon = { + isDaemonRunning: async () => true, + health: async () => ({ + pid: 123, + uptimeSeconds: 5, + queued: 0, + running: 0, + finished: 0, + instances: [], + }), +}; + +function createCommandRunner( + responses: Record< + string, + { exitCode: number; stdout: string; stderr: string } + >, +): CommandRunner { + return async (command, args) => { + const key = `${command} ${args.join(" ")}`; + const response = responses[key]; + if (!response) { + throw new Error(`unexpected command: ${key}`); + } + return response; + }; +} + +describe("runOnboardingChecks", () => { + test("reports success when opencode and daemon are ready", async () => { + const result = await runOnboardingChecks({ + commandRunner: createCommandRunner({ + "opencode --version": { exitCode: 0, stdout: "1.0.0", stderr: "" }, + "opencode auth list": { exitCode: 0, stdout: "anthropic", stderr: "" }, + }), + daemonClient: healthyDaemon, + ensureDaemon: async () => true, + }); + + expect(result.ok).toBe(true); + expect(result.checks.every((check) => check.ok)).toBe(true); + }); + + test("doctor mode does not try to autostart the daemon", async () => { + let autostarted = false; + const result = await runOnboardingChecks({ + autoStartDaemon: false, + commandRunner: createCommandRunner({ + "opencode --version": { exitCode: 0, stdout: "1.0.0", stderr: "" }, + "opencode auth list": { exitCode: 0, stdout: "anthropic", stderr: "" }, + }), + daemonClient: { + isDaemonRunning: async () => false, + health: healthyDaemon.health, + }, + ensureDaemon: async () => { + autostarted = true; + return true; + }, + }); + + expect(autostarted).toBe(false); + expect(result.ok).toBe(false); + expect( + result.checks.find((check) => check.label === "Daemon running")?.ok, + ).toBe(false); + }); + + test("setup mode tries to autostart the daemon", async () => { + let autostarted = false; + const result = await runOnboardingChecks({ + autoStartDaemon: true, + commandRunner: createCommandRunner({ + "opencode --version": { exitCode: 0, stdout: "1.0.0", stderr: "" }, + "opencode auth list": { exitCode: 0, stdout: "anthropic", stderr: "" }, + }), + daemonClient: { + isDaemonRunning: async () => false, + health: healthyDaemon.health, + }, + ensureDaemon: async () => { + autostarted = true; + return false; + }, + }); + + expect(autostarted).toBe(true); + expect(result.ok).toBe(false); + }); + + test("marks auth as missing when opencode auth output is empty", async () => { + const result = await runOnboardingChecks({ + commandRunner: createCommandRunner({ + "opencode --version": { exitCode: 0, stdout: "1.0.0", stderr: "" }, + "opencode auth list": { exitCode: 0, stdout: "", stderr: "" }, + }), + daemonClient: healthyDaemon, + ensureDaemon: async () => true, + }); + + expect(result.ok).toBe(false); + expect( + result.checks.find((check) => check.label === "OpenCode authenticated") + ?.message, + ).toContain("opencode auth login"); + }); +}); diff --git a/apps/tui/src/lib/onboarding.ts b/apps/tui/src/lib/onboarding.ts index a73b034..538847c 100644 --- a/apps/tui/src/lib/onboarding.ts +++ b/apps/tui/src/lib/onboarding.ts @@ -8,6 +8,25 @@ type CommandResult = { stderr: string; }; +export type CommandRunner = ( + command: string, + args: string[], + options?: { timeoutMs?: number }, +) => Promise; + +interface OnboardingDependencies { + commandRunner: CommandRunner; + daemonClient: Pick; + ensureDaemon: typeof ensureDaemonRunning; +} + +export interface RunOnboardingChecksOptions { + autoStartDaemon?: boolean; + commandRunner?: CommandRunner; + daemonClient?: Pick; + ensureDaemon?: typeof ensureDaemonRunning; +} + async function runCommand( command: string, args: string[], @@ -46,9 +65,11 @@ export interface OnboardingResult { checks: OnboardingCheck[]; } -async function checkOpencodeInstalled(): Promise { +async function checkOpencodeInstalled( + commandRunner: CommandRunner, +): Promise { try { - const result = await runCommand("opencode", ["--version"]); + const result = await commandRunner("opencode", ["--version"]); if (result.exitCode === 0) { return { label: "OpenCode installed", ok: true }; } @@ -68,9 +89,11 @@ async function checkOpencodeInstalled(): Promise { } } -async function checkOpencodeAuth(): Promise { +async function checkOpencodeAuth( + commandRunner: CommandRunner, +): Promise { try { - const result = await runCommand("opencode", ["auth", "list"]); + const result = await commandRunner("opencode", ["auth", "list"]); if (result.exitCode !== 0) { return { label: "OpenCode authenticated", @@ -98,11 +121,16 @@ async function checkOpencodeAuth(): Promise { } } -async function checkDaemonRunning(): Promise { +async function checkDaemonRunning( + dependencies: OnboardingDependencies, + autoStartDaemon: boolean, +): Promise { try { - const ready = await ensureDaemonRunning(); + const ready = autoStartDaemon + ? await dependencies.ensureDaemon() + : await dependencies.daemonClient.isDaemonRunning(); if (ready) { - const health = await daemon.health(); + const health = await dependencies.daemonClient.health(); return { label: "Daemon running", ok: true, @@ -112,32 +140,44 @@ async function checkDaemonRunning(): Promise { return { label: "Daemon running", ok: false, - message: - "ralphd could not be started. Run `ralph daemon start` manually.", + message: autoStartDaemon + ? "ralphd could not be started. Run `ralph daemon start` manually." + : "ralphd is not running. Run `ralph daemon start` manually.", }; } catch { return { label: "Daemon running", ok: false, - message: - "ralphd could not be reached. Run `ralph daemon start` manually.", + message: autoStartDaemon + ? "ralphd could not be reached. Run `ralph daemon start` manually." + : "ralphd could not be reached. Run `ralph daemon start` manually.", }; } } -export async function runOnboardingChecks(): Promise { - const opencodeInstalled = await checkOpencodeInstalled(); +export async function runOnboardingChecks( + options: RunOnboardingChecksOptions = {}, +): Promise { + const dependencies: OnboardingDependencies = { + commandRunner: options.commandRunner ?? runCommand, + daemonClient: options.daemonClient ?? daemon, + ensureDaemon: options.ensureDaemon ?? ensureDaemonRunning, + }; + const autoStartDaemon = options.autoStartDaemon ?? true; + const opencodeInstalled = await checkOpencodeInstalled( + dependencies.commandRunner, + ); // Only check auth if opencode is installed const opencodeAuth = opencodeInstalled.ok - ? await checkOpencodeAuth() + ? await checkOpencodeAuth(dependencies.commandRunner) : { label: "OpenCode authenticated", ok: false, message: "Skipped (opencode not installed)", }; - const daemonRunning = await checkDaemonRunning(); + const daemonRunning = await checkDaemonRunning(dependencies, autoStartDaemon); const checks = [opencodeInstalled, opencodeAuth, daemonRunning]; return { diff --git a/apps/tui/src/lib/providers.ts b/apps/tui/src/lib/providers.ts new file mode 100644 index 0000000..9792e02 --- /dev/null +++ b/apps/tui/src/lib/providers.ts @@ -0,0 +1,96 @@ +import type { SelectOption } from "@opentui/core"; +import type { ProviderListResult } from "@techatnyu/ralphd"; + +/** Provider IDs sorted by popularity to keep common choices near the top. */ +const PROVIDER_PRIORITY: Record = { + anthropic: 0, + openai: 1, + google: 2, + openrouter: 3, +}; + +export const MODEL_SELECT_SEPARATOR_VALUE = "__separator__"; + +type ProviderEntry = ProviderListResult["providers"][number]; + +export interface ModelChoice { + ref: string; + providerId: string; + providerName: string; + modelId: string; + modelName: string; + label: string; +} + +export function sortProviders(providers: ProviderEntry[]): ProviderEntry[] { + return [...providers].sort( + (a, b) => + (PROVIDER_PRIORITY[a.id] ?? 99) - (PROVIDER_PRIORITY[b.id] ?? 99) || + a.name.localeCompare(b.name), + ); +} + +export function countProviderModels(provider: ProviderEntry): number { + return Object.keys(provider.models).length; +} + +export function listConnectedModels(result: ProviderListResult): ModelChoice[] { + const connected = new Set(result.connected); + + return sortProviders(result.providers) + .filter((provider) => connected.has(provider.id)) + .flatMap((provider) => + Object.values(provider.models) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((model) => ({ + ref: `${provider.id}/${model.id}`, + providerId: provider.id, + providerName: provider.name, + modelId: model.id, + modelName: model.name, + label: `${provider.name}/${model.name}`, + })), + ); +} + +export function buildModelSelectOptions( + result: ProviderListResult, + recentModels: string[], +): SelectOption[] { + const allModels: SelectOption[] = listConnectedModels(result).map( + (choice) => ({ + name: choice.label, + description: choice.ref, + value: choice.ref, + }), + ); + + const allByRef = new Map(allModels.map((model) => [model.value, model])); + const recentOptions: SelectOption[] = recentModels + .filter((ref) => allByRef.has(ref)) + .map((ref) => allByRef.get(ref) as SelectOption); + + if (recentOptions.length === 0) { + return allModels; + } + + const recentRefs = new Set(recentModels); + const remainingModels = allModels.filter( + (model) => !recentRefs.has(model.value as string), + ); + + return [ + { + name: "-- Recent --", + description: "", + value: MODEL_SELECT_SEPARATOR_VALUE, + }, + ...recentOptions, + { + name: "-- All Models --", + description: "", + value: MODEL_SELECT_SEPARATOR_VALUE, + }, + ...remainingModels, + ]; +} diff --git a/bun.lock b/bun.lock index 07d0f03..4a62287 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@biomejs/biome": "2.3.14", "@changesets/changelog-git": "^0.2.1", "@changesets/cli": "^2.29.8", + "@crustjs/crust": "^0.0.23", "turbo": "^2.8.3", "typescript": "5.9.2", }, @@ -45,14 +46,19 @@ "name": "@techatnyu/ralph", "version": "0.0.1", "dependencies": { - "@crustjs/core": "^0.0.13", + "@crustjs/core": "^0.0.16", "@crustjs/create": "^0.0.4", - "@crustjs/plugins": "^0.0.16", + "@crustjs/plugins": "^0.0.21", + "@crustjs/progress": "^0.0.2", + "@crustjs/prompts": "^0.0.12", "@crustjs/store": "^0.0.4", + "@crustjs/style": "^0.0.6", + "@crustjs/validate": "^0.0.15", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", "@techatnyu/ralphd": "workspace:*", "react": "^19.2.4", + "zod": "^4.3.6", }, "devDependencies": { "@types/bun": "latest", @@ -69,6 +75,8 @@ "name": "@techatnyu/ralphd", "version": "0.0.0", "dependencies": { + "@crustjs/core": "^0.0.16", + "@crustjs/plugins": "^0.0.21", "@opencode-ai/sdk": "^1.2.10", "zod": "^4.3.6", }, @@ -180,15 +188,35 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@crustjs/core": ["@crustjs/core@0.0.13", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-kAjSL68kjEdO68uGQVWn6RQwaFFx17skogokyTF3UutF7WZepMhzQ7Zhw4Q5joKrVZJjCcSkRkDYzb6bye/Rgg=="], + "@crustjs/core": ["@crustjs/core@0.0.16", "", { "peerDependencies": { "typescript": "^6.0.2" } }, "sha512-2yybZnmBjdNH9XisCtDAWUVuBQSobcrdvhVcW8O4GumCF1oM6FHsBVq+gsxOM5PemEqtdpEo78h0rzI7clVcAA=="], "@crustjs/create": ["@crustjs/create@0.0.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-vGbJKCc0zQQdSjfYo4gMYplGvfh1UNRupcyGTR9Rg4U8ej0UpmVLkoSz7b4wcC7XBuLs3pmYaLYZhT/k24SWNQ=="], - "@crustjs/plugins": ["@crustjs/plugins@0.0.16", "", { "dependencies": { "@crustjs/style": "0.0.5" }, "peerDependencies": { "@crustjs/core": "0.0.13", "typescript": "^5" } }, "sha512-Y3MuwpHxPEutEqBzpR8sLuG3z61BRPDiWDOQppw1M2xaEI/RTTfuYRVfPF58NGrcKSRFFDn8vfty+rUdkacnwg=="], + "@crustjs/crust": ["@crustjs/crust@0.0.23", "", { "optionalDependencies": { "@crustjs/crust-darwin-arm64": "0.0.23", "@crustjs/crust-darwin-x64": "0.0.23", "@crustjs/crust-linux-arm64": "0.0.23", "@crustjs/crust-linux-x64": "0.0.23", "@crustjs/crust-windows-arm64": "0.0.23", "@crustjs/crust-windows-x64": "0.0.23" }, "bin": { "crust": "bin/crust.js" } }, "sha512-mghonO4AhMQiNTendaj7qpkguCnp/77tnf1EbMwqSwvTZpAkApcjEwu5+USyky1TLOX9L+UBWA2p7Skz3NIRew=="], + + "@crustjs/crust-darwin-arm64": ["@crustjs/crust-darwin-arm64@0.0.23", "", { "os": "darwin", "cpu": "arm64", "bin": { "crust": "bin/crust-bun-darwin-arm64" } }, "sha512-vsrbtYf6TAURsJ4AzuS9g/CMBwoQ0yDgysuF+Z+8SPSUYfmV4W6MGUVO2rlL+/mhxgiK7Xoo7VxwlR/vDhfy4g=="], + + "@crustjs/crust-darwin-x64": ["@crustjs/crust-darwin-x64@0.0.23", "", { "os": "darwin", "cpu": "x64", "bin": { "crust": "bin/crust-bun-darwin-x64" } }, "sha512-MtDSg5NnpczB1l24H6wAeAhdjVbtHuWGLw2MNO2nm5XwylISoFRTAB4TlNd62nLuxrdXxPtLf5WpRJEU0ZY9SQ=="], + + "@crustjs/crust-linux-arm64": ["@crustjs/crust-linux-arm64@0.0.23", "", { "os": "linux", "cpu": "arm64", "bin": { "crust": "bin/crust-bun-linux-arm64" } }, "sha512-KOgeI+NUNS1xmua+AECsPZN+r2I+6EROnXYTV2iye1bFP0l4rumZMTH4RdX//75smwXqDX301IIPQgfUTLLcog=="], + + "@crustjs/crust-linux-x64": ["@crustjs/crust-linux-x64@0.0.23", "", { "os": "linux", "cpu": "x64", "bin": { "crust": "bin/crust-bun-linux-x64-baseline" } }, "sha512-E1SMop3+kFkPJXHB8g0IqBfz7IvZEyVLYbNhr0SB4mi/8xQPBmR4vfEfPA96kpXQsjgntHN/+sH4G33pc/D6YQ=="], + + "@crustjs/crust-windows-arm64": ["@crustjs/crust-windows-arm64@0.0.23", "", { "os": "win32", "cpu": "arm64", "bin": { "crust": "bin/crust-bun-windows-arm64.exe" } }, "sha512-KERE/UGL7Yn/0DXxO3ickAxkA1zDTmLF8bKQQ1s6f2vbfDjqsZL7bOfkl+B9K5+BSgmnU9j3LdIhpSmxov2cKA=="], + + "@crustjs/crust-windows-x64": ["@crustjs/crust-windows-x64@0.0.23", "", { "os": "win32", "cpu": "x64", "bin": { "crust": "bin/crust-bun-windows-x64-baseline.exe" } }, "sha512-zBl37qIt/MksZfoVKYFoK+CRo/WaDgtrCyIo+NrJxr7esfrrxwwE6y7GfhwRv13AzwsvyXNAwXtVo8Mdo7c61A=="], + + "@crustjs/plugins": ["@crustjs/plugins@0.0.21", "", { "dependencies": { "@crustjs/style": "0.0.6" }, "peerDependencies": { "@crustjs/core": "0.0.16", "typescript": "^6.0.2" } }, "sha512-8XAB3UU0ztUxeTsxGuXhOpsDRFXZ1N3/g5fS8QR3GPD/qV940GMp0wsjjAlAyqrY9b7mudYFs0PQmyTTYTTXpw=="], + + "@crustjs/progress": ["@crustjs/progress@0.0.2", "", { "dependencies": { "@crustjs/style": "0.0.6" }, "peerDependencies": { "typescript": "^6.0.2" } }, "sha512-1ExIE8COOYPX/rR6aeOX5IH/S6VfCZNDI4wEq8u21ekFs/lo7pOi5tdabWFhJrWWQuaINa7n2tkN+vNUWSqXEQ=="], + + "@crustjs/prompts": ["@crustjs/prompts@0.0.12", "", { "dependencies": { "@crustjs/progress": "0.0.2", "@crustjs/style": "0.0.6" }, "peerDependencies": { "typescript": "^6.0.2" } }, "sha512-7XFvbt7+TiN+ot1Qn1Ij36cphfNafrnKCq6FYZDsc/V+crpG/JwrtQFfsag/dm+hbYDfpVz8XTNEkt+HIorA5w=="], "@crustjs/store": ["@crustjs/store@0.0.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pfYuykVBH08HKBpycPcBZrk4CSQMVhccENDANbJoGXX86YjZk5urjzaJGeGXR1S4DVCt6hGC15aiRf6SEbpyag=="], - "@crustjs/style": ["@crustjs/style@0.0.5", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-KZhxi2iCMYEMihMDmOrHkC4gl5INtogR9AEfbOkjwot74cmNQ2vTUfiZsLmWzBQ23nhPQ0vBwZBNFN+IpH+F0w=="], + "@crustjs/style": ["@crustjs/style@0.0.6", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-GB15j1g/IeOa+ZhacHOzEj9D7TKDFJ2axdwvTCyyfl3U1c+tbi+gBs+2HWmExDjEDhuN3ABwuUlLMljTUlxI4w=="], + + "@crustjs/validate": ["@crustjs/validate@0.0.15", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@crustjs/core": "0.0.16", "effect": "^3.19.0", "typescript": "^6.0.2", "zod": "^4.0.0" }, "optionalPeers": ["effect", "zod"] }, "sha512-jlSv6OYcXUuvQT9KswzPW/KrhrIbGbYV2n0AR1MCT3Mlt2GjrXbkxZ8tjVNVCmNA7suoRxDF3V9nNvDk6V1CXQ=="], "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], @@ -602,7 +630,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -684,7 +712,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], diff --git a/package.json b/package.json index d6ad592..0d2bad4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@biomejs/biome": "2.3.14", "@changesets/changelog-git": "^0.2.1", "@changesets/cli": "^2.29.8", + "@crustjs/crust": "^0.0.23", "turbo": "^2.8.3", "typescript": "5.9.2" }, diff --git a/packages/daemon/package.json b/packages/daemon/package.json index d9cec2f..86818d6 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -12,6 +12,8 @@ "check:types": "tsc --noEmit" }, "dependencies": { + "@crustjs/core": "^0.0.16", + "@crustjs/plugins": "^0.0.21", "@opencode-ai/sdk": "^1.2.10", "zod": "^4.3.6" }, diff --git a/packages/daemon/src/__tests__/helpers.ts b/packages/daemon/src/__tests__/helpers.ts index 1bf3b25..1ec422c 100644 --- a/packages/daemon/src/__tests__/helpers.ts +++ b/packages/daemon/src/__tests__/helpers.ts @@ -4,6 +4,7 @@ import type { ManagedOpencodeRuntime, OpencodeRuntimeManager, } from "../opencode"; +import type { ProviderListResult } from "../protocol"; function fakeSession(overrides: Partial & { id: string }): Session { return { @@ -53,6 +54,7 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { private sessionSequence = 0; private messageSequence = 0; private readonly activeByInstance = new Map(); + private readonly providerResult: ProviderListResult; readonly maxConcurrentByInstance = new Map(); readonly promptCalls: Array<{ instanceId: string; @@ -60,9 +62,18 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { prompt: string; }> = []; readonly abortCalls: Array<{ instanceId: string; sessionId: string }> = []; + readonly queryProviderCalls: Array<{ + directory?: string; + refresh?: boolean; + }> = []; globalMaxConcurrent = 0; - constructor(private readonly delayMs = 25) {} + constructor( + private readonly delayMs = 25, + providerResult: ProviderListResult = { providers: [], connected: [] }, + ) { + this.providerResult = providerResult; + } async ensureStarted(instanceId: string): Promise { const existing = this.runtimes.get(instanceId); @@ -124,10 +135,7 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { }, }, provider: { - list: async () => ({ - providers: [], - connected: [], - }), + list: async () => this.queryProviders(), }, async ping() { return true; @@ -159,6 +167,10 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { } async queryProviders(_directory?: string, _refresh?: boolean) { - return { providers: [], connected: [] }; + this.queryProviderCalls.push({ + directory: _directory, + refresh: _refresh, + }); + return structuredClone(this.providerResult); } } diff --git a/packages/daemon/src/__tests__/integration.test.ts b/packages/daemon/src/__tests__/integration.test.ts index 57be1ce..7a8f3c3 100644 --- a/packages/daemon/src/__tests__/integration.test.ts +++ b/packages/daemon/src/__tests__/integration.test.ts @@ -16,12 +16,28 @@ describe("Integration: server + client over Unix socket", () => { let server: Server; let daemon: Daemon; let client: DaemonClient; + let registry: FakeOpencodeRegistry; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), "ralph-integration-")); testSocketPath = join(tmpDir, "test.sock"); const store = new StateStore(join(tmpDir, "state.json")); - daemon = new Daemon(store, { registry: new FakeOpencodeRegistry(20) }); + registry = new FakeOpencodeRegistry(20, { + providers: [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + }, + }, + ], + connected: ["anthropic"], + }); + daemon = new Daemon(store, { registry }); await daemon.bootstrap(); server = createServer(createConnectionHandler(daemon)); @@ -85,4 +101,18 @@ describe("Integration: server + client over Unix socket", () => { "job missing not found", ); }); + + test("can query providers through the daemon", async () => { + const result = await client.providerList({ + directory: "/tmp/project-one", + refresh: true, + }); + + expect(result.connected).toEqual(["anthropic"]); + expect(result.providers).toHaveLength(1); + expect(result.providers[0]?.id).toBe("anthropic"); + expect(registry.queryProviderCalls).toEqual([ + { directory: "/tmp/project-one", refresh: true }, + ]); + }); }); diff --git a/packages/daemon/src/bin/ralphd.ts b/packages/daemon/src/bin/ralphd.ts index 4f97c07..b19d0d1 100644 --- a/packages/daemon/src/bin/ralphd.ts +++ b/packages/daemon/src/bin/ralphd.ts @@ -1,3 +1,7 @@ -import { runDaemonServer } from "../server"; +import app from "../cli"; -void runDaemonServer(); +export { app }; + +if (import.meta.main) { + await app.execute(); +} diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts new file mode 100644 index 0000000..7474710 --- /dev/null +++ b/packages/daemon/src/cli.ts @@ -0,0 +1,42 @@ +import { Crust } from "@crustjs/core"; +import { helpPlugin, versionPlugin } from "@crustjs/plugins"; +import { runDaemonServer } from "./server"; + +async function readPackageVersion(): Promise { + if (process.env.PUBLIC_RALPH_VERSION || process.env.RALPH_VERSION) { + return ( + process.env.PUBLIC_RALPH_VERSION ?? process.env.RALPH_VERSION ?? "0.0.0" + ); + } + + const packageJson = (await Bun.file( + new URL("../../../apps/tui/package.json", import.meta.url), + ).json()) as { + version?: string; + }; + + return packageJson.version ?? "0.0.0"; +} + +const cliVersion = await readPackageVersion(); + +export function createDaemonCli() { + return new Crust("ralphd") + .meta({ description: "Ralph background daemon" }) + .use(versionPlugin(cliVersion)) + .use(helpPlugin()) + .run(async () => { + await runDaemonServer(); + }) + .command("serve", (cmd) => + cmd + .meta({ description: "Run the daemon in the foreground" }) + .run(async () => { + await runDaemonServer(); + }), + ); +} + +export const app = createDaemonCli(); + +export default app; diff --git a/scripts/release/shared.ts b/scripts/release/shared.ts index 5e36df2..0cb4bcb 100644 --- a/scripts/release/shared.ts +++ b/scripts/release/shared.ts @@ -152,6 +152,51 @@ async function runCommand(command: string, args: string[], cwd?: string) { await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, + env: process.env, + stdio: "inherit", + }); + + child.once("error", reject); + child.once("exit", (code) => { + if ((code ?? 0) !== 0) { + reject( + new Error( + `${command} ${args.join(" ")} exited with code ${code ?? 1}`, + ), + ); + return; + } + resolve(); + }); + }); +} + +function getCrustTarget(target: SupportedTarget): string { + switch (target) { + case "bun-linux-x64": + return "linux-x64"; + case "bun-linux-arm64": + return "linux-arm64"; + case "bun-windows-x64": + return "windows-x64"; + case "bun-windows-arm64": + return "windows-arm64"; + case "bun-darwin-x64": + return "darwin-x64"; + case "bun-darwin-arm64": + return "darwin-arm64"; + } +} + +async function runCommandWithEnv( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, stdio: "inherit", }); @@ -251,10 +296,15 @@ if not exist "%bin_path%" ( } export async function buildBinaries( - options: { targets?: SupportedTarget[]; outDir?: string } = {}, + options: { + targets?: SupportedTarget[]; + outDir?: string; + version?: string; + } = {}, ): Promise { const targets = options.targets ?? [...SUPPORTED_TARGETS]; const outDir = options.outDir ?? DEFAULT_COMPILED_DIR; + const version = options.version ?? (await getRootPackageVersion()); const entries = [ { name: "ralph" as const, @@ -290,22 +340,29 @@ export async function buildBinaries( for (const entry of entries) { const outfile = join(targetDir, getBinaryFilename(entry.name, spec)); - const build = await Bun.build({ - entrypoints: [entry.entrypoint], - minify: false, - compile: { - target, + await runCommandWithEnv( + "bunx", + [ + "crust", + "build", + "--entry", + entry.entrypoint, + "--target", + getCrustTarget(target), + "--outfile", outfile, + "--name", + entry.name, + "--no-minify", + ], + { + cwd: REPO_ROOT, + env: { + ...process.env, + PUBLIC_RALPH_VERSION: version, + }, }, - }); - if (!build.success) { - const errors = build.logs - .map((log) => log.message ?? String(log)) - .join("\n"); - throw new Error( - `Failed to build ${entry.name} for ${target}\n${errors}`, - ); - } + ); binaries[entry.name] = outfile; } From ba1fa4e7edd49648e7b22c729ca0a8b95ebee06d Mon Sep 17 00:00:00 2001 From: PatrickYu17 Date: Tue, 21 Apr 2026 02:10:11 -0400 Subject: [PATCH 2/2] refactor: narrow cli branch scope Keep the CLI work isolated to apps/tui so the branch no longer carries daemon, release, or TUI refactors that could interfere with teammates' work. --- apps/tui/src/__tests__/release.test.ts | 66 +------------- apps/tui/src/components/app.tsx | 60 +++++++++++-- apps/tui/src/lib/providers.ts | 45 ---------- bun.lock | 17 ---- package.json | 1 - packages/daemon/package.json | 2 - packages/daemon/src/__tests__/helpers.ts | 24 ++--- .../daemon/src/__tests__/integration.test.ts | 32 +------ packages/daemon/src/bin/ralphd.ts | 8 +- packages/daemon/src/cli.ts | 42 --------- scripts/release/shared.ts | 87 ++++--------------- 11 files changed, 79 insertions(+), 305 deletions(-) delete mode 100644 packages/daemon/src/cli.ts diff --git a/apps/tui/src/__tests__/release.test.ts b/apps/tui/src/__tests__/release.test.ts index 609bbf5..6f171a6 100644 --- a/apps/tui/src/__tests__/release.test.ts +++ b/apps/tui/src/__tests__/release.test.ts @@ -35,29 +35,6 @@ function installedBinPath(projectDir: string, binaryName: "ralph" | "ralphd") { : join(projectDir, "node_modules", ".bin", binaryName); } -async function waitForDaemonHealth( - command: string, - installDir: string, - env: NodeJS.ProcessEnv, - timeoutMs = 10_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const proc = Bun.spawn([command, "daemon", "health"], { - cwd: installDir, - env, - stdout: "pipe", - stderr: "pipe", - }); - if ((await proc.exited) === 0) { - return; - } - await Bun.sleep(200); - } - - throw new Error("timed out waiting for daemon health"); -} - describe("release packaging", () => { test("stages root and target packages with both binaries", async () => { const tempDir = await mkdtemp(join(tmpdir(), "ralph-stage-")); @@ -132,7 +109,6 @@ describe("release packaging", () => { await buildBinaries({ targets: [currentTarget], outDir: compiledDir, - version: "0.0.0-smoke", }); await stageDistribution({ targets: [currentTarget], @@ -184,8 +160,8 @@ describe("release packaging", () => { ); expect(await help.exited).toBe(0); - const version = Bun.spawn( - [installedBinPath(installDir, "ralph"), "--version"], + const start = Bun.spawn( + [installedBinPath(installDir, "ralph"), "daemon", "start"], { cwd: installDir, env, @@ -193,40 +169,7 @@ describe("release packaging", () => { stderr: "pipe", }, ); - expect(await version.exited).toBe(0); - expect(await new Response(version.stdout).text()).toContain( - "ralph v0.0.0-smoke", - ); - - const daemonVersion = Bun.spawn( - [installedBinPath(installDir, "ralphd"), "--version"], - { - cwd: installDir, - env, - stdout: "pipe", - stderr: "pipe", - }, - ); - expect(await daemonVersion.exited).toBe(0); - expect(await new Response(daemonVersion.stdout).text()).toContain( - "ralphd v0.0.0-smoke", - ); - - const daemonProcess = Bun.spawn( - [installedBinPath(installDir, "ralphd")], - { - cwd: installDir, - env, - stdout: "pipe", - stderr: "pipe", - }, - ); - - await waitForDaemonHealth( - installedBinPath(installDir, "ralph"), - installDir, - env, - ); + expect(await start.exited).toBe(0); const health = Bun.spawn( [installedBinPath(installDir, "ralph"), "daemon", "health"], @@ -250,11 +193,10 @@ describe("release packaging", () => { }, ); expect(await stop.exited).toBe(0); - expect(await daemonProcess.exited).toBe(0); await access(installedBinPath(installDir, "ralphd")); } finally { await rm(tempDir, { recursive: true, force: true }); } - }, 20_000); + }); }); diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 73b1748..15d6c7f 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -8,10 +8,6 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; -import { - buildModelSelectOptions, - MODEL_SELECT_SEPARATOR_VALUE, -} from "../lib/providers"; import { ralphStore, setModelAndRecent } from "../lib/store"; import { Chat } from "./chat"; @@ -25,12 +21,61 @@ interface DashboardData { jobs: DaemonJob[]; } +/** Provider IDs sorted by popularity — used to push well-known providers to the top. */ +const PROVIDER_PRIORITY: Record = { + anthropic: 0, + openai: 1, + google: 2, + openrouter: 3, +}; + +const SEPARATOR_VALUE = "__separator__"; + async function fetchModelOptions(): Promise { const [result, store] = await Promise.all([ daemon.providerList({ refresh: true }), ralphStore.read(), ]); - return buildModelSelectOptions(result, store.recentModels ?? []); + const connected = new Set(result.connected); + const recentRefs = new Set(store.recentModels ?? []); + + // Build flat list of all connected models + const allModels: SelectOption[] = result.providers + .filter((provider) => connected.has(provider.id)) + .sort( + (a, b) => + (PROVIDER_PRIORITY[a.id] ?? 99) - (PROVIDER_PRIORITY[b.id] ?? 99) || + a.name.localeCompare(b.name), + ) + .flatMap((provider) => + Object.values(provider.models) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((model) => ({ + name: `${provider.name}/${model.name}`, + description: `${provider.id}/${model.id}`, + value: `${provider.id}/${model.id}`, + })), + ); + + // Build recent section from stored order, only including models that still exist + const allByRef = new Map(allModels.map((m) => [m.value, m])); + const recentOptions: SelectOption[] = (store.recentModels ?? []) + .filter((ref) => allByRef.has(ref)) + .map((ref) => allByRef.get(ref) as SelectOption); + + if (recentOptions.length === 0) return allModels; + + // Filter recents out of the "all" section to avoid duplicates + const restModels = allModels.filter( + (m) => !recentRefs.has(m.value as string), + ); + + return [ + { name: "── Recent ──", description: "", value: SEPARATOR_VALUE }, + ...recentOptions, + { name: "── All Models ──", description: "", value: SEPARATOR_VALUE }, + ...restModels, + ]; } interface AppProps { @@ -191,10 +236,7 @@ function Dashboard({ showScrollIndicator wrapSelection onSelect={(_index, option) => { - if ( - option?.value && - option.value !== MODEL_SELECT_SEPARATOR_VALUE - ) { + if (option?.value && option.value !== SEPARATOR_VALUE) { const modelRef = option.value as string; void setModelAndRecent(modelRef).then(() => { setCurrentModel(modelRef); diff --git a/apps/tui/src/lib/providers.ts b/apps/tui/src/lib/providers.ts index 9792e02..c3b8ea8 100644 --- a/apps/tui/src/lib/providers.ts +++ b/apps/tui/src/lib/providers.ts @@ -1,4 +1,3 @@ -import type { SelectOption } from "@opentui/core"; import type { ProviderListResult } from "@techatnyu/ralphd"; /** Provider IDs sorted by popularity to keep common choices near the top. */ @@ -9,8 +8,6 @@ const PROVIDER_PRIORITY: Record = { openrouter: 3, }; -export const MODEL_SELECT_SEPARATOR_VALUE = "__separator__"; - type ProviderEntry = ProviderListResult["providers"][number]; export interface ModelChoice { @@ -52,45 +49,3 @@ export function listConnectedModels(result: ProviderListResult): ModelChoice[] { })), ); } - -export function buildModelSelectOptions( - result: ProviderListResult, - recentModels: string[], -): SelectOption[] { - const allModels: SelectOption[] = listConnectedModels(result).map( - (choice) => ({ - name: choice.label, - description: choice.ref, - value: choice.ref, - }), - ); - - const allByRef = new Map(allModels.map((model) => [model.value, model])); - const recentOptions: SelectOption[] = recentModels - .filter((ref) => allByRef.has(ref)) - .map((ref) => allByRef.get(ref) as SelectOption); - - if (recentOptions.length === 0) { - return allModels; - } - - const recentRefs = new Set(recentModels); - const remainingModels = allModels.filter( - (model) => !recentRefs.has(model.value as string), - ); - - return [ - { - name: "-- Recent --", - description: "", - value: MODEL_SELECT_SEPARATOR_VALUE, - }, - ...recentOptions, - { - name: "-- All Models --", - description: "", - value: MODEL_SELECT_SEPARATOR_VALUE, - }, - ...remainingModels, - ]; -} diff --git a/bun.lock b/bun.lock index 4a62287..af12ec0 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,6 @@ "@biomejs/biome": "2.3.14", "@changesets/changelog-git": "^0.2.1", "@changesets/cli": "^2.29.8", - "@crustjs/crust": "^0.0.23", "turbo": "^2.8.3", "typescript": "5.9.2", }, @@ -75,8 +74,6 @@ "name": "@techatnyu/ralphd", "version": "0.0.0", "dependencies": { - "@crustjs/core": "^0.0.16", - "@crustjs/plugins": "^0.0.21", "@opencode-ai/sdk": "^1.2.10", "zod": "^4.3.6", }, @@ -192,20 +189,6 @@ "@crustjs/create": ["@crustjs/create@0.0.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-vGbJKCc0zQQdSjfYo4gMYplGvfh1UNRupcyGTR9Rg4U8ej0UpmVLkoSz7b4wcC7XBuLs3pmYaLYZhT/k24SWNQ=="], - "@crustjs/crust": ["@crustjs/crust@0.0.23", "", { "optionalDependencies": { "@crustjs/crust-darwin-arm64": "0.0.23", "@crustjs/crust-darwin-x64": "0.0.23", "@crustjs/crust-linux-arm64": "0.0.23", "@crustjs/crust-linux-x64": "0.0.23", "@crustjs/crust-windows-arm64": "0.0.23", "@crustjs/crust-windows-x64": "0.0.23" }, "bin": { "crust": "bin/crust.js" } }, "sha512-mghonO4AhMQiNTendaj7qpkguCnp/77tnf1EbMwqSwvTZpAkApcjEwu5+USyky1TLOX9L+UBWA2p7Skz3NIRew=="], - - "@crustjs/crust-darwin-arm64": ["@crustjs/crust-darwin-arm64@0.0.23", "", { "os": "darwin", "cpu": "arm64", "bin": { "crust": "bin/crust-bun-darwin-arm64" } }, "sha512-vsrbtYf6TAURsJ4AzuS9g/CMBwoQ0yDgysuF+Z+8SPSUYfmV4W6MGUVO2rlL+/mhxgiK7Xoo7VxwlR/vDhfy4g=="], - - "@crustjs/crust-darwin-x64": ["@crustjs/crust-darwin-x64@0.0.23", "", { "os": "darwin", "cpu": "x64", "bin": { "crust": "bin/crust-bun-darwin-x64" } }, "sha512-MtDSg5NnpczB1l24H6wAeAhdjVbtHuWGLw2MNO2nm5XwylISoFRTAB4TlNd62nLuxrdXxPtLf5WpRJEU0ZY9SQ=="], - - "@crustjs/crust-linux-arm64": ["@crustjs/crust-linux-arm64@0.0.23", "", { "os": "linux", "cpu": "arm64", "bin": { "crust": "bin/crust-bun-linux-arm64" } }, "sha512-KOgeI+NUNS1xmua+AECsPZN+r2I+6EROnXYTV2iye1bFP0l4rumZMTH4RdX//75smwXqDX301IIPQgfUTLLcog=="], - - "@crustjs/crust-linux-x64": ["@crustjs/crust-linux-x64@0.0.23", "", { "os": "linux", "cpu": "x64", "bin": { "crust": "bin/crust-bun-linux-x64-baseline" } }, "sha512-E1SMop3+kFkPJXHB8g0IqBfz7IvZEyVLYbNhr0SB4mi/8xQPBmR4vfEfPA96kpXQsjgntHN/+sH4G33pc/D6YQ=="], - - "@crustjs/crust-windows-arm64": ["@crustjs/crust-windows-arm64@0.0.23", "", { "os": "win32", "cpu": "arm64", "bin": { "crust": "bin/crust-bun-windows-arm64.exe" } }, "sha512-KERE/UGL7Yn/0DXxO3ickAxkA1zDTmLF8bKQQ1s6f2vbfDjqsZL7bOfkl+B9K5+BSgmnU9j3LdIhpSmxov2cKA=="], - - "@crustjs/crust-windows-x64": ["@crustjs/crust-windows-x64@0.0.23", "", { "os": "win32", "cpu": "x64", "bin": { "crust": "bin/crust-bun-windows-x64-baseline.exe" } }, "sha512-zBl37qIt/MksZfoVKYFoK+CRo/WaDgtrCyIo+NrJxr7esfrrxwwE6y7GfhwRv13AzwsvyXNAwXtVo8Mdo7c61A=="], - "@crustjs/plugins": ["@crustjs/plugins@0.0.21", "", { "dependencies": { "@crustjs/style": "0.0.6" }, "peerDependencies": { "@crustjs/core": "0.0.16", "typescript": "^6.0.2" } }, "sha512-8XAB3UU0ztUxeTsxGuXhOpsDRFXZ1N3/g5fS8QR3GPD/qV940GMp0wsjjAlAyqrY9b7mudYFs0PQmyTTYTTXpw=="], "@crustjs/progress": ["@crustjs/progress@0.0.2", "", { "dependencies": { "@crustjs/style": "0.0.6" }, "peerDependencies": { "typescript": "^6.0.2" } }, "sha512-1ExIE8COOYPX/rR6aeOX5IH/S6VfCZNDI4wEq8u21ekFs/lo7pOi5tdabWFhJrWWQuaINa7n2tkN+vNUWSqXEQ=="], diff --git a/package.json b/package.json index 0d2bad4..d6ad592 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@biomejs/biome": "2.3.14", "@changesets/changelog-git": "^0.2.1", "@changesets/cli": "^2.29.8", - "@crustjs/crust": "^0.0.23", "turbo": "^2.8.3", "typescript": "5.9.2" }, diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 86818d6..d9cec2f 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -12,8 +12,6 @@ "check:types": "tsc --noEmit" }, "dependencies": { - "@crustjs/core": "^0.0.16", - "@crustjs/plugins": "^0.0.21", "@opencode-ai/sdk": "^1.2.10", "zod": "^4.3.6" }, diff --git a/packages/daemon/src/__tests__/helpers.ts b/packages/daemon/src/__tests__/helpers.ts index 1ec422c..1bf3b25 100644 --- a/packages/daemon/src/__tests__/helpers.ts +++ b/packages/daemon/src/__tests__/helpers.ts @@ -4,7 +4,6 @@ import type { ManagedOpencodeRuntime, OpencodeRuntimeManager, } from "../opencode"; -import type { ProviderListResult } from "../protocol"; function fakeSession(overrides: Partial & { id: string }): Session { return { @@ -54,7 +53,6 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { private sessionSequence = 0; private messageSequence = 0; private readonly activeByInstance = new Map(); - private readonly providerResult: ProviderListResult; readonly maxConcurrentByInstance = new Map(); readonly promptCalls: Array<{ instanceId: string; @@ -62,18 +60,9 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { prompt: string; }> = []; readonly abortCalls: Array<{ instanceId: string; sessionId: string }> = []; - readonly queryProviderCalls: Array<{ - directory?: string; - refresh?: boolean; - }> = []; globalMaxConcurrent = 0; - constructor( - private readonly delayMs = 25, - providerResult: ProviderListResult = { providers: [], connected: [] }, - ) { - this.providerResult = providerResult; - } + constructor(private readonly delayMs = 25) {} async ensureStarted(instanceId: string): Promise { const existing = this.runtimes.get(instanceId); @@ -135,7 +124,10 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { }, }, provider: { - list: async () => this.queryProviders(), + list: async () => ({ + providers: [], + connected: [], + }), }, async ping() { return true; @@ -167,10 +159,6 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { } async queryProviders(_directory?: string, _refresh?: boolean) { - this.queryProviderCalls.push({ - directory: _directory, - refresh: _refresh, - }); - return structuredClone(this.providerResult); + return { providers: [], connected: [] }; } } diff --git a/packages/daemon/src/__tests__/integration.test.ts b/packages/daemon/src/__tests__/integration.test.ts index 7a8f3c3..57be1ce 100644 --- a/packages/daemon/src/__tests__/integration.test.ts +++ b/packages/daemon/src/__tests__/integration.test.ts @@ -16,28 +16,12 @@ describe("Integration: server + client over Unix socket", () => { let server: Server; let daemon: Daemon; let client: DaemonClient; - let registry: FakeOpencodeRegistry; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), "ralph-integration-")); testSocketPath = join(tmpDir, "test.sock"); const store = new StateStore(join(tmpDir, "state.json")); - registry = new FakeOpencodeRegistry(20, { - providers: [ - { - id: "anthropic", - name: "Anthropic", - models: { - "claude-sonnet-4-5": { - id: "claude-sonnet-4-5", - name: "Claude Sonnet 4.5", - }, - }, - }, - ], - connected: ["anthropic"], - }); - daemon = new Daemon(store, { registry }); + daemon = new Daemon(store, { registry: new FakeOpencodeRegistry(20) }); await daemon.bootstrap(); server = createServer(createConnectionHandler(daemon)); @@ -101,18 +85,4 @@ describe("Integration: server + client over Unix socket", () => { "job missing not found", ); }); - - test("can query providers through the daemon", async () => { - const result = await client.providerList({ - directory: "/tmp/project-one", - refresh: true, - }); - - expect(result.connected).toEqual(["anthropic"]); - expect(result.providers).toHaveLength(1); - expect(result.providers[0]?.id).toBe("anthropic"); - expect(registry.queryProviderCalls).toEqual([ - { directory: "/tmp/project-one", refresh: true }, - ]); - }); }); diff --git a/packages/daemon/src/bin/ralphd.ts b/packages/daemon/src/bin/ralphd.ts index b19d0d1..4f97c07 100644 --- a/packages/daemon/src/bin/ralphd.ts +++ b/packages/daemon/src/bin/ralphd.ts @@ -1,7 +1,3 @@ -import app from "../cli"; +import { runDaemonServer } from "../server"; -export { app }; - -if (import.meta.main) { - await app.execute(); -} +void runDaemonServer(); diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts deleted file mode 100644 index 7474710..0000000 --- a/packages/daemon/src/cli.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Crust } from "@crustjs/core"; -import { helpPlugin, versionPlugin } from "@crustjs/plugins"; -import { runDaemonServer } from "./server"; - -async function readPackageVersion(): Promise { - if (process.env.PUBLIC_RALPH_VERSION || process.env.RALPH_VERSION) { - return ( - process.env.PUBLIC_RALPH_VERSION ?? process.env.RALPH_VERSION ?? "0.0.0" - ); - } - - const packageJson = (await Bun.file( - new URL("../../../apps/tui/package.json", import.meta.url), - ).json()) as { - version?: string; - }; - - return packageJson.version ?? "0.0.0"; -} - -const cliVersion = await readPackageVersion(); - -export function createDaemonCli() { - return new Crust("ralphd") - .meta({ description: "Ralph background daemon" }) - .use(versionPlugin(cliVersion)) - .use(helpPlugin()) - .run(async () => { - await runDaemonServer(); - }) - .command("serve", (cmd) => - cmd - .meta({ description: "Run the daemon in the foreground" }) - .run(async () => { - await runDaemonServer(); - }), - ); -} - -export const app = createDaemonCli(); - -export default app; diff --git a/scripts/release/shared.ts b/scripts/release/shared.ts index 0cb4bcb..5e36df2 100644 --- a/scripts/release/shared.ts +++ b/scripts/release/shared.ts @@ -152,51 +152,6 @@ async function runCommand(command: string, args: string[], cwd?: string) { await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, - env: process.env, - stdio: "inherit", - }); - - child.once("error", reject); - child.once("exit", (code) => { - if ((code ?? 0) !== 0) { - reject( - new Error( - `${command} ${args.join(" ")} exited with code ${code ?? 1}`, - ), - ); - return; - } - resolve(); - }); - }); -} - -function getCrustTarget(target: SupportedTarget): string { - switch (target) { - case "bun-linux-x64": - return "linux-x64"; - case "bun-linux-arm64": - return "linux-arm64"; - case "bun-windows-x64": - return "windows-x64"; - case "bun-windows-arm64": - return "windows-arm64"; - case "bun-darwin-x64": - return "darwin-x64"; - case "bun-darwin-arm64": - return "darwin-arm64"; - } -} - -async function runCommandWithEnv( - command: string, - args: string[], - options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, -) { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env ?? process.env, stdio: "inherit", }); @@ -296,15 +251,10 @@ if not exist "%bin_path%" ( } export async function buildBinaries( - options: { - targets?: SupportedTarget[]; - outDir?: string; - version?: string; - } = {}, + options: { targets?: SupportedTarget[]; outDir?: string } = {}, ): Promise { const targets = options.targets ?? [...SUPPORTED_TARGETS]; const outDir = options.outDir ?? DEFAULT_COMPILED_DIR; - const version = options.version ?? (await getRootPackageVersion()); const entries = [ { name: "ralph" as const, @@ -340,29 +290,22 @@ export async function buildBinaries( for (const entry of entries) { const outfile = join(targetDir, getBinaryFilename(entry.name, spec)); - await runCommandWithEnv( - "bunx", - [ - "crust", - "build", - "--entry", - entry.entrypoint, - "--target", - getCrustTarget(target), - "--outfile", + const build = await Bun.build({ + entrypoints: [entry.entrypoint], + minify: false, + compile: { + target, outfile, - "--name", - entry.name, - "--no-minify", - ], - { - cwd: REPO_ROOT, - env: { - ...process.env, - PUBLIC_RALPH_VERSION: version, - }, }, - ); + }); + if (!build.success) { + const errors = build.logs + .map((log) => log.message ?? String(log)) + .join("\n"); + throw new Error( + `Failed to build ${entry.name} for ${target}\n${errors}`, + ); + } binaries[entry.name] = outfile; }