Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/opencode/script/seed-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ const seed = async () => {
await prepareConfigDependencies()

const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
Expand All @@ -66,7 +65,6 @@ const seed = async () => {
try {
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()

Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { LocalContext } from "@/util/local-context"

export namespace Bus {
const log = Log.create({ service: "bus" })
Expand Down Expand Up @@ -177,7 +178,15 @@ export namespace Bus {
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
try {
return await runPromise((svc) => svc.publish(def, properties))
} catch (error) {
if (!(error instanceof LocalContext.NotFound) || error.name !== "instance") throw error
GlobalBus.emit("event", {
directory: "global",
payload: { type: def.type, properties },
})
}
}

export function subscribe<D extends BusEvent.Definition>(
Expand Down
3 changes: 0 additions & 3 deletions packages/opencode/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"

export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: InstanceBootstrap,
fn: async () => {
try {
const result = await cb()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/cmd.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CommandModule } from "yargs"

type WithDoubleDash<T> = T & { "--"?: string[] }
export type WithDoubleDash<T> = T & { "--"?: string[] }

export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
return input
Expand Down
83 changes: 41 additions & 42 deletions packages/opencode/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { Effect } from "effect"
import { Provider } from "../../provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import { EOL } from "os"

export const ModelsCommand = cmd({
export const ModelsCommand = effectCmd({
command: "models [provider]",
describe: "list all available models",
builder: (yargs: Argv) => {
Expand All @@ -26,53 +26,52 @@ export const ModelsCommand = cmd({
type: "boolean",
})
},
handler: async (args) => {
handler: Effect.fn("Cli.models")(function* (args) {
if (args.refresh) {
await ModelsDev.refresh(true)
yield* Effect.tryPromise({
try: () => ModelsDev.refresh(true),
catch: (cause) =>
new CliError({
message: `Failed to refresh models cache: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
})
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}

await Instance.provide({
directory: process.cwd(),
async fn() {
const providers = await Provider.list()
const provider = yield* Provider.Service
const providers = yield* provider.list()

function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
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 provider = providers[ProviderID.make(args.provider)]
if (!provider) {
UI.error(`Provider not found: ${args.provider}`)
return
}
if (args.provider) {
const provider = providers[ProviderID.make(args.provider)]
if (!provider) return yield* fail(`Provider not found: ${args.provider}`)

printModels(ProviderID.make(args.provider), args.verbose)
return
}

const providerIDs = 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)
})
printModels(ProviderID.make(args.provider), args.verbose)
return
}

for (const providerID of providerIDs) {
printModels(ProviderID.make(providerID), args.verbose)
}
},
const providerIDs = 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 providerIDs) {
printModels(ProviderID.make(providerID), args.verbose)
}
}),
})
55 changes: 55 additions & 0 deletions packages/opencode/src/cli/effect-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Argv } from "yargs"
import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { cmd, type WithDoubleDash } from "./cmd/cmd"

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 }))

interface EffectCmdOpts<Args, A> {
command: string | readonly string[]
aliases?: string | readonly string[]
describe: string | false
builder?: (yargs: Argv) => Argv<Args>
instance?: boolean | ((args: WithDoubleDash<Args>) => boolean)
directory?: (args: WithDoubleDash<Args>) => string
handler: (args: WithDoubleDash<Args>) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
}

export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
cmd<{}, Args>({
command: opts.command,
aliases: opts.aliases,
describe: opts.describe,
builder: opts.builder as never,
async handler(rawArgs) {
const args = rawArgs as unknown as WithDoubleDash<Args>
const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false
if (!useInstance) {
await AppRuntime.runPromise(opts.handler(args))
return
}

const directory = opts.directory?.(args) ?? process.cwd()
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
)

try {
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally {
// A yargs command is a one-shot boundary. Dispose here so watchers and
// subscriptions cannot keep CLI processes alive after the handler returns.
await AppRuntime.runPromise(store.dispose(ctx))
}
Comment thread
Astro-Han marked this conversation as resolved.
},
})
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ function isTaggedError(error: unknown, tag: string): boolean {
}

export function FormatError(input: unknown) {
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 (hasName(input, "MCPFailed")) {
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { ShareRuntime } from "@/share/runtime"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { InstanceLayer } from "@/project/instance-layer"

export const AppLayer = Layer.mergeAll(
Observability.layer,
Expand Down Expand Up @@ -102,10 +103,11 @@ export const AppLayer = Layer.mergeAll(
ShareNext.defaultLayer,
SessionShare.defaultLayer,
ShareRuntime.cloudShareGateDefaultLayer,
)
).pipe(Layer.provideMerge(InstanceLayer.layer))

const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
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 = {
Expand Down
31 changes: 21 additions & 10 deletions packages/opencode/src/effect/run-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
Expand All @@ -24,15 +24,26 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
}

export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
return attachWith(effect, {
instance: Instance.current,
workspace: WorkspaceContext.workspaceID,
})
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
return effect
const workspace = (() => {
try {
return WorkspaceContext.workspaceID
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const instance = (() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
const fiber = Fiber.getCurrent()
return attachWith(effect, {
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
})
}

export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
Expand Down
Loading
Loading