summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-05-02 12:08:04 -0400
committerGitHub <[email protected]>2026-05-02 12:08:04 -0400
commit146ff8ad855072b8ea5b8e92a91a5ffe53f2ed78 (patch)
treed53b525173f1c3f9012f1cea2b8d005590d9bed8
parent0d0ec7dc4663cd0319351443fed4d981001724c6 (diff)
downloadopencode-146ff8ad855072b8ea5b8e92a91a5ffe53f2ed78.tar.gz
opencode-146ff8ad855072b8ea5b8e92a91a5ffe53f2ed78.zip
feat(cli): add effectCmd wrapper + convert models command (#25429)
-rw-r--r--packages/opencode/src/cli/cmd/models.ts99
-rw-r--r--packages/opencode/src/cli/effect-cmd.ts52
-rw-r--r--packages/opencode/src/cli/error.ts7
-rw-r--r--packages/opencode/src/effect/app-runtime.ts3
4 files changed, 101 insertions, 60 deletions
diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts
index 0b5d35275..cfbb959e7 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,45 @@ 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)
+ // 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)
}
- 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 000000000..758ac6904
--- /dev/null
+++ b/packages/opencode/src/cli/effect-cmd.ts
@@ -0,0 +1,52 @@
+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"
+
+/**
+ * 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>()("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()` so the handler body is
+ * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
+ *
+ * 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.<name>")(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 = <Args, A>(opts: {
+ command: string | readonly string[]
+ describe: string | false
+ builder?: (yargs: Argv) => Argv<Args>
+ /** Defaults to process.cwd(). Override for commands that take a directory positional. */
+ directory?: (args: Args) => string
+ handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
+}) =>
+ cmd<{}, Args>({
+ command: opts.command,
+ describe: opts.describe,
+ builder: opts.builder as never,
+ async handler(rawArgs) {
+ // yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; 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))),
+ )
+ },
+ })
diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts
index adf52f568..628aa9569 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.`
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index f3376ad85..97cd2f629 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<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
+
+/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */
+export type AppServices = ManagedRuntime.ManagedRuntime.Services<typeof rt>
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
export const AppRuntime: Runtime = {