Просмотр исходного кода

feat(cli): add effectCmd wrapper + convert models command (#25429)

Kit Langton 4 месяцев назад
Родитель
Сommit
146ff8ad85

+ 39 - 60
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)
+  }),
 })

+ 52 - 0
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))),
+      )
+    },
+  })

+ 7 - 0
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.`

+ 3 - 0
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 = {