Skip to content

Commit 146ff8a

Browse files
authored
feat(cli): add effectCmd wrapper + convert models command (#25429)
1 parent 0d0ec7d commit 146ff8a

4 files changed

Lines changed: 101 additions & 60 deletions

File tree

Lines changed: 39 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
import type { Argv } from "yargs"
2-
import { Instance } from "../../project/instance"
1+
import { EOL } from "os"
2+
import { Effect } from "effect"
33
import { Provider } from "@/provider/provider"
44
import { ProviderID } from "../../provider/schema"
55
import { ModelsDev } from "@/provider/models"
6-
import { cmd } from "./cmd"
6+
import { effectCmd, fail } from "../effect-cmd"
77
import { UI } from "../ui"
8-
import { EOL } from "os"
9-
import { AppRuntime } from "@/effect/app-runtime"
10-
import { Effect } from "effect"
118

12-
export const ModelsCommand = cmd({
9+
export const ModelsCommand = effectCmd({
1310
command: "models [provider]",
1411
describe: "list all available models",
15-
builder: (yargs: Argv) => {
16-
return yargs
12+
builder: (yargs) =>
13+
yargs
1714
.positional("provider", {
1815
describe: "provider ID to filter models by",
1916
type: "string",
@@ -26,63 +23,45 @@ export const ModelsCommand = cmd({
2623
.option("refresh", {
2724
describe: "refresh the models cache from models.dev",
2825
type: "boolean",
29-
})
30-
},
31-
handler: async (args) => {
26+
}),
27+
handler: Effect.fn("Cli.models")(function* (args) {
3228
if (args.refresh) {
33-
await ModelsDev.refresh(true)
29+
// followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap.
30+
yield* Effect.promise(() => ModelsDev.refresh(true))
3431
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
3532
}
3633

37-
await Instance.provide({
38-
directory: process.cwd(),
39-
async fn() {
40-
await AppRuntime.runPromise(
41-
Effect.gen(function* () {
42-
const svc = yield* Provider.Service
43-
const providers = yield* svc.list()
34+
const provider = yield* Provider.Service
35+
const providers = yield* provider.list()
4436

45-
const print = (providerID: ProviderID, verbose?: boolean) => {
46-
const provider = providers[providerID]
47-
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
48-
for (const [modelID, model] of sorted) {
49-
process.stdout.write(`${providerID}/${modelID}`)
50-
process.stdout.write(EOL)
51-
if (verbose) {
52-
process.stdout.write(JSON.stringify(model, null, 2))
53-
process.stdout.write(EOL)
54-
}
55-
}
56-
}
57-
58-
if (args.provider) {
59-
const providerID = ProviderID.make(args.provider)
60-
const provider = providers[providerID]
61-
if (!provider) {
62-
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
63-
return
64-
}
65-
66-
yield* Effect.sync(() => print(providerID, args.verbose))
67-
return
68-
}
37+
const print = (providerID: ProviderID, verbose?: boolean) => {
38+
const p = providers[providerID]
39+
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
40+
for (const [modelID, model] of sorted) {
41+
process.stdout.write(`${providerID}/${modelID}`)
42+
process.stdout.write(EOL)
43+
if (verbose) {
44+
process.stdout.write(JSON.stringify(model, null, 2))
45+
process.stdout.write(EOL)
46+
}
47+
}
48+
}
6949

70-
const ids = Object.keys(providers).sort((a, b) => {
71-
const aIsOpencode = a.startsWith("opencode")
72-
const bIsOpencode = b.startsWith("opencode")
73-
if (aIsOpencode && !bIsOpencode) return -1
74-
if (!aIsOpencode && bIsOpencode) return 1
75-
return a.localeCompare(b)
76-
})
50+
if (args.provider) {
51+
const providerID = ProviderID.make(args.provider)
52+
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
53+
print(providerID, args.verbose)
54+
return
55+
}
7756

78-
yield* Effect.sync(() => {
79-
for (const providerID of ids) {
80-
print(ProviderID.make(providerID), args.verbose)
81-
}
82-
})
83-
}),
84-
)
85-
},
57+
const ids = Object.keys(providers).sort((a, b) => {
58+
const aIsOpencode = a.startsWith("opencode")
59+
const bIsOpencode = b.startsWith("opencode")
60+
if (aIsOpencode && !bIsOpencode) return -1
61+
if (!aIsOpencode && bIsOpencode) return 1
62+
return a.localeCompare(b)
8663
})
87-
},
64+
65+
for (const providerID of ids) print(ProviderID.make(providerID), args.verbose)
66+
}),
8867
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Argv } from "yargs"
2+
import { Effect, Schema } from "effect"
3+
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
4+
import { InstanceStore } from "@/project/instance-store"
5+
import { cmd } from "./cmd/cmd"
6+
7+
/**
8+
* User-visible command failure. Throw via `fail("...")` from an effectCmd handler
9+
* to surface a printed message + non-zero exit. Recognised by the global error
10+
* formatter in `src/cli/error.ts` (FormatError), so the existing top-level
11+
* catch + cleanup in `src/index.ts` runs normally.
12+
*/
13+
export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
14+
message: Schema.String,
15+
exitCode: Schema.optional(Schema.Number),
16+
}) {}
17+
18+
export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))
19+
20+
/**
21+
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
22+
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
23+
*
24+
* Errors propagate to the existing top-level handler in `src/index.ts`; use
25+
* `fail("...")` for user-visible domain failures (clean exit, formatted message).
26+
*
27+
* Handlers are typically `Effect.fn("Cli.<name>")(function*(args) { ... })`,
28+
* which adds a named tracing span per CLI invocation. Once all commands use
29+
* `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's
30+
* `Command.make(...)` won't touch any handler bodies.
31+
*/
32+
export const effectCmd = <Args, A>(opts: {
33+
command: string | readonly string[]
34+
describe: string | false
35+
builder?: (yargs: Argv) => Argv<Args>
36+
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
37+
directory?: (args: Args) => string
38+
handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
39+
}) =>
40+
cmd<{}, Args>({
41+
command: opts.command,
42+
describe: opts.describe,
43+
builder: opts.builder as never,
44+
async handler(rawArgs) {
45+
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
46+
const args = rawArgs as unknown as Args
47+
const directory = opts.directory?.(args) ?? process.cwd()
48+
await AppRuntime.runPromise(
49+
InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))),
50+
)
51+
},
52+
})

packages/opencode/src/cli/error.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean {
1515
}
1616

1717
export function FormatError(input: unknown) {
18+
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
19+
if (isTaggedError(input, "CliError")) {
20+
const data = input as ErrorLike & { exitCode?: number }
21+
if (data.exitCode != null) process.exitCode = data.exitCode
22+
return data.message ?? ""
23+
}
24+
1825
// MCPFailed: { name: string }
1926
if (NamedError.hasName(input, "MCPFailed")) {
2027
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`

packages/opencode/src/effect/app-runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ export const AppLayer = Layer.mergeAll(
105105

106106
const rt = ManagedRuntime.make(AppLayer, { memoMap })
107107
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
108+
109+
/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */
110+
export type AppServices = ManagedRuntime.ManagedRuntime.Services<typeof rt>
108111
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
109112

110113
export const AppRuntime: Runtime = {

0 commit comments

Comments
 (0)