Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton
86c4438910 refactor(cli/github+run): Stage 4 — drop AppRuntime.runPromise bridges
github.ts: yield Git/Session/SessionShare/SessionPrompt/ModelsDev once at
the top of each handler; replace inline AppRuntime.runPromise(Service.use(...))
calls with Effect.runPromise on the in-scope service.

run.ts: same pattern for the single Agent.Service.get site.

Drop the now-unused AppRuntime imports.
2026-05-02 23:51:56 -04:00
3 changed files with 233 additions and 218 deletions

View File

@@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
@@ -206,6 +205,8 @@ export const GithubInstallCommand = effectCmd({
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const modelsDev = yield* ModelsDev.Service
const gitSvc = yield* Git.Service
yield* Effect.promise(async () => {
{
UI.empty()
@@ -213,7 +214,7 @@ export const GithubInstallCommand = effectCmd({
const app = await getAppInfo()
await installGitHubApp()
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
const providers = await Effect.runPromise(modelsDev.get()).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
@@ -261,9 +262,9 @@ export const GithubInstallCommand = effectCmd({
}
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
).then((x) => x.text().trim())
const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then(
(x) => x.text().trim(),
)
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -440,6 +441,10 @@ export const GithubRunCommand = effectCmd({
handler: Effect.fn("Cli.github.run")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
const gitSvc = yield* Git.Service
const sessionSvc = yield* Session.Service
const sessionShare = yield* SessionShare.Service
const sessionPrompt = yield* SessionPrompt.Service
yield* Effect.promise(async () => {
const isMock = args.token || args.event
@@ -503,21 +508,20 @@ export const GithubRunCommand = effectCmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -554,24 +558,22 @@ export const GithubRunCommand = effectCmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}),
),
session = await Effect.runPromise(
sessionSvc.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}),
)
subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
await Effect.runPromise(sessionShare.share(session.id))
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
@@ -944,9 +946,9 @@ export const GithubRunCommand = effectCmd({
async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
return AppRuntime.runPromise(
return Effect.runPromise(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const prompt = sessionPrompt
const result = yield* prompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),

View File

@@ -6,6 +6,8 @@ import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "@/provider/models"
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
@@ -239,45 +241,46 @@ export const ProvidersListCommand = effectCmd({
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Effect.runPromise(authSvc.all()))
const database = await Effect.runPromise(modelsDev.get())
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
})
}),
})
@@ -303,174 +306,185 @@ export const ProvidersLoginCommand = effectCmd({
handler: Effect.fn("Cli.providers.login")(function* (args) {
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
const modelsDev = yield* ModelsDev.Service
const authSvc = yield* Auth.Service
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* Effect.promise(() =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as { auth: { command: string[]; env: string } }
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
if (!proc.stdout) {
prompts.log.error("Failed")
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
stderr: "inherit",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
await refreshModels().catch(() => {})
const config = await Effect.runPromise(cfgSvc.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await Effect.runPromise(pluginSvc.list())
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
yield* Effect.ignore(modelsDev.refresh(true))
const config = yield* cfgSvc.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = yield* modelsDev.get()
const providers: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
}
const hooks = yield* pluginSvc.list()
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
map((x) => ({
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
hint: "plugin",
})),
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
]
]
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = yield* Effect.promise(() =>
prompts.autocomplete({
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [...options, { value: "other", label: "Other" }],
}),
)
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
provider = selected as string
}
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
if (handled) return
}
if (provider === "other") {
const custom = yield* Effect.promise(() =>
prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
}),
)
if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
provider = (custom as string).replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = yield* Effect.promise(() =>
handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
)
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
const key = yield* Effect.promise(() =>
prompts.password({
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}),
)
if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
prompts.outro("Done")
prompts.outro("Done")
})
}),
})
@@ -482,27 +496,26 @@ export const ProvidersLogoutCommand = effectCmd({
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = yield* modelsDev.get()
const selected = yield* Effect.promise(() =>
prompts.select({
yield* Effect.promise(async () => {
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await Effect.runPromise(modelsDev.get())
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
}),
)
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
const providerID = selected as string
yield* Effect.orDie(authSvc.remove(providerID))
prompts.outro("Logout successful")
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await Effect.runPromise(authSvc.remove(providerID))
prompts.outro("Logout successful")
})
}),
})

View File

@@ -26,7 +26,6 @@ import { ShellTool } from "../../tool/shell"
import { ShellID } from "../../tool/shell/id"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "@/util/locale"
import { AppRuntime } from "@/effect/app-runtime"
type ToolProps<T> = {
input: Tool.InferParameters<T>
@@ -299,6 +298,7 @@ export const RunCommand = effectCmd({
default: false,
}),
handler: Effect.fn("Cli.run")(function* (args) {
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
@@ -602,7 +602,7 @@ export const RunCommand = effectCmd({
return name
}
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
const entry = await Effect.runPromise(agentSvc.get(name))
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",