From edb28520569f255c836c35b5e8ad016a4e4b2b30 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 11:53:57 -0400 Subject: [PATCH 1/2] feat(cli): add effectCmd wrapper + convert models command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cli/effect-cmd.ts with: - effectCmd({command, describe, builder, directory?, handler}) — wraps yargs cmd() so the handler is an Effect, with InstanceRef provided, AppServices yieldable, and a Cli. tracing span via Effect.fn at the call site - CliError + fail() helpers for clean exit-with-message paths Converts cli/cmd/models.ts as the first end-to-end smoke test: - Drops Instance.provide({fn}) ALS wrapper - Drops AppRuntime.runPromise(Effect.gen(...)) per-call boundary crossing - Yields Provider.Service once at the top, uses freely - Replaces process.exit(1) with yield* fail('...') - Uses Effect.fn('Cli.models') for traced span Net diff: models.ts 88 → 66 lines; new helper 54 lines. Smoke tested: - bun run dev models — prints model list - bun run dev models nonexistent-provider — prints error, exits 1 Path to effect/cli: command bodies are already Effect-typed. Once we swap the cmd() factory for effect/cli's Command.make(), nothing in the handler bodies has to change — only the args parser. --- packages/opencode/src/cli/cmd/models.ts | 98 ++++++++------------- packages/opencode/src/cli/effect-cmd.ts | 54 ++++++++++++ packages/opencode/src/effect/app-runtime.ts | 3 + 3 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 packages/opencode/src/cli/effect-cmd.ts diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 0b5d352755f8..8ae974528e8d 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,19 +1,16 @@ -import type { Argv } from "yargs" -import { Instance } from "../../project/instance" +import { EOL } from "os" +import { Effect } from "effect" import { Provider } from "@/provider/provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "@/provider/models" -import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" -import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" -export const ModelsCommand = cmd({ +export const ModelsCommand = effectCmd({ command: "models [provider]", describe: "list all available models", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("provider", { describe: "provider ID to filter models by", type: "string", @@ -26,63 +23,44 @@ export const ModelsCommand = cmd({ .option("refresh", { describe: "refresh the models cache from models.dev", type: "boolean", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - await ModelsDev.refresh(true) + yield* Effect.promise(() => ModelsDev.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - await Instance.provide({ - directory: process.cwd(), - async fn() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() + const provider = yield* Provider.Service + const providers = yield* provider.list() - const print = (providerID: ProviderID, verbose?: boolean) => { - const provider = providers[providerID] - const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } - - if (args.provider) { - const providerID = ProviderID.make(args.provider) - const provider = providers[providerID] - if (!provider) { - yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) - return - } - - yield* Effect.sync(() => print(providerID, args.verbose)) - return - } + const print = (providerID: ProviderID, verbose?: boolean) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } + } - const ids = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) + if (args.provider) { + const providerID = ProviderID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + print(providerID, args.verbose) + return + } - yield* Effect.sync(() => { - for (const providerID of ids) { - print(ProviderID.make(providerID), args.verbose) - } - }) - }), - ) - }, + const ids = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) }) - }, + + for (const providerID of ids) print(ProviderID.make(providerID), args.verbose) + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 000000000000..d03c095f51b9 --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,54 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { cmd } from "./cmd/cmd" +import { UI } from "./ui" + +/** + * User-visible command failure. Use `fail("...")` from a handler to surface a + * non-zero exit with a printed message. Anything else escapes as a defect and + * the runtime prints the cause. + */ +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +/** + * Effect-native CLI command builder. Wraps yargs `cmd()` with: + * - `InstanceStore.provide` so `InstanceRef` is available inside the handler + * - `AppRuntime.runPromise` so any AppServices can be yielded directly + * - `CliError` interception so domain failures translate to a clean exit + * + * The handler is typically an `Effect.fn("Cli.")(function*(args){...})` value, + * which gives each CLI run a named tracing span automatically. Eventually `cmd()` can + * swap to effect/cli's `Command.make(...)` without touching the handler bodies. + */ +export const effectCmd = (opts: { + command: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +}) => + cmd({ + command: opts.command, + describe: opts.describe, + builder: opts.builder as never, + async handler(args) { + const directory = opts.directory?.(args as Args) ?? process.cwd() + const program = InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args as Args))).pipe( + Effect.catchTag("CliError", (e) => + Effect.sync(() => { + UI.error(e.message) + process.exit(e.exitCode ?? 1) + }), + ), + ) + await AppRuntime.runPromise(program) + }, + }) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f3376ad8590e..97cd2f629e42 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -105,6 +105,9 @@ export const AppLayer = Layer.mergeAll( const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick + +/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */ +export type AppServices = ManagedRuntime.ManagedRuntime.Services const wrap = (effect: Parameters[0]) => attach(effect as never) as never export const AppRuntime: Runtime = { From 4bfd61cb4beb212cfaac0d6414b07302bb34956e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:04:51 -0400 Subject: [PATCH 2/2] fix(cli): propagate CliError through global FormatError instead of process.exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings addressed: 1. process.exit(1) inside Effect.sync killed the process before the AppRuntime fiber's scope could close — file handles, plugin disposers, etc. would leak. Now CliError propagates as a normal Effect failure; the existing top-level try/catch in src/index.ts handles formatting + exit after the runtime settles. 2. CliError now hits the global FormatError pipeline (cli/error.ts) rather than bypassing it. Adds a CliError branch that respects the optional exitCode field via process.exitCode. 3. effectCmd's generic signature tightened to cmd<{}, Args>(...); the args cast is unavoidable because yargs wraps the type in ArgumentsCamelCase>. Localized to one cast at the boundary with a comment. Smoke tested: - bun run dev models — happy path ok - bun run dev models nonexistent — formatted error, exit 1 Followup noted inline in models.ts: lift ModelsDev into an Effect Service so the Effect.promise wrap drops out. --- packages/opencode/src/cli/cmd/models.ts | 1 + packages/opencode/src/cli/effect-cmd.ts | 42 ++++++++++++------------- packages/opencode/src/cli/error.ts | 7 +++++ 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 8ae974528e8d..cfbb959e7af0 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -26,6 +26,7 @@ export const ModelsCommand = effectCmd({ }), handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { + // followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap. yield* Effect.promise(() => ModelsDev.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index d03c095f51b9..758ac6904a0c 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,12 +3,12 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { cmd } from "./cmd/cmd" -import { UI } from "./ui" /** - * User-visible command failure. Use `fail("...")` from a handler to surface a - * non-zero exit with a printed message. Anything else escapes as a defect and - * the runtime prints the cause. + * User-visible command failure. Throw via `fail("...")` from an effectCmd handler + * to surface a printed message + non-zero exit. Recognised by the global error + * formatter in `src/cli/error.ts` (FormatError), so the existing top-level + * catch + cleanup in `src/index.ts` runs normally. */ export class CliError extends Schema.TaggedErrorClass()("CliError", { message: Schema.String, @@ -18,14 +18,16 @@ export class CliError extends Schema.TaggedErrorClass()("CliError", { export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) /** - * Effect-native CLI command builder. Wraps yargs `cmd()` with: - * - `InstanceStore.provide` so `InstanceRef` is available inside the handler - * - `AppRuntime.runPromise` so any AppServices can be yielded directly - * - `CliError` interception so domain failures translate to a clean exit + * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is + * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. * - * The handler is typically an `Effect.fn("Cli.")(function*(args){...})` value, - * which gives each CLI run a named tracing span automatically. Eventually `cmd()` can - * swap to effect/cli's `Command.make(...)` without touching the handler bodies. + * Errors propagate to the existing top-level handler in `src/index.ts`; use + * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * + * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, + * which adds a named tracing span per CLI invocation. Once all commands use + * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's + * `Command.make(...)` won't touch any handler bodies. */ export const effectCmd = (opts: { command: string | readonly string[] @@ -35,20 +37,16 @@ export const effectCmd = (opts: { directory?: (args: Args) => string handler: (args: Args) => Effect.Effect }) => - cmd({ + cmd<{}, Args>({ command: opts.command, describe: opts.describe, builder: opts.builder as never, - async handler(args) { - const directory = opts.directory?.(args as Args) ?? process.cwd() - const program = InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args as Args))).pipe( - Effect.catchTag("CliError", (e) => - Effect.sync(() => { - UI.error(e.message) - process.exit(e.exitCode ?? 1) - }), - ), + async handler(rawArgs) { + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as Args + const directory = opts.directory?.(args) ?? process.cwd() + await AppRuntime.runPromise( + InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))), ) - await AppRuntime.runPromise(program) }, }) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index adf52f5683a2..628aa95696aa 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean { } export function FormatError(input: unknown) { + // CliError: domain failure surfaced from an effectCmd handler via fail("...") + if (isTaggedError(input, "CliError")) { + const data = input as ErrorLike & { exitCode?: number } + if (data.exitCode != null) process.exitCode = data.exitCode + return data.message ?? "" + } + // MCPFailed: { name: string } if (NamedError.hasName(input, "MCPFailed")) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`