Compare commits

...

7 Commits

Author SHA1 Message Date
Kit Langton
eb4a90eb2a refactor(cli/providers): flatten — drop Effect.promise wraps + dead helpers
Follow-up to #25532. Each handler now runs Effect-native end-to-end:
- Drop the outer Effect.promise(async () => {...}) wraps in all 3 handlers
- Replace each await Effect.runPromise(svc.X()) with yield* svc.X() (with
  Effect.orDie where the service has typed errors)
- Wrap individual prompts.X() / fetch() / handlePluginAuth() awaits with
  yield* Effect.promise(() => ...) instead of one big body wrap
- throw new UI.CancelledError() → yield* Effect.die(new UI.CancelledError())
- Drop unused top-level helpers getModels + refreshModels (handlers now
  yield ModelsDev.Service directly and call .get() / .refresh(true))

Net: 8 → 1 AppRuntime.runPromise call (only the put helper remains, used
by handlePluginAuth which is still a plain Promise function).

Same observable behavior. Just the right shape.
2026-05-02 23:47:52 -04:00
Kit Langton
bd32252a7e refactor(cli/providers): Stage 4 — drop inline AppRuntime.runPromise calls (#25532) 2026-05-02 23:42:40 -04:00
Kit Langton
1717d636a2 refactor(cli/mcp+agent): Stage 4 — drop AppRuntime.runPromise bridges (#25530) 2026-05-02 23:40:59 -04:00
Aiden Cline
8e016b4703 fix: regression w/ auth login where stderr was ignored instead of inherited (#25529) 2026-05-02 22:36:02 -05:00
opencode-agent[bot]
b89d48a2a4 chore: update nix node_modules hashes 2026-05-03 03:25:46 +00:00
Dax
33312bfd1b fix(session): encode v2 session responses (#25528) 2026-05-03 03:24:46 +00:00
opencode-agent[bot]
3f1ce36418 chore: generate 2026-05-03 03:23:47 +00:00
5 changed files with 604 additions and 642 deletions

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
}
}

View File

@@ -1,6 +1,5 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../agent/agent"
@@ -66,170 +65,166 @@ const AgentCreateCommand = effectCmd({
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
const project = ctx.project
const project = ctx.project
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"),
"agent",
)
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
})
}),
})

View File

@@ -19,7 +19,6 @@ import { Global } from "@opencode-ai/core/global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "@/util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -440,158 +439,158 @@ export const McpAddCommand = effectCmd({
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add MCP server")
UI.empty()
prompts.intro("Add MCP server")
const project = ctx.project
const project = ctx.project
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const type = await prompts.select({
message: "Select MCP server type",
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
} else {
mcpConfig = {
type: "remote",
url,
}
}
prompts.outro("MCP server added successfully")
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
prompts.outro("MCP server added successfully")
})
}),
})
@@ -606,178 +605,171 @@ export const McpDebugCommand = effectCmd({
demandOption: true,
}),
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
const config = yield* Config.Service.use((cfg) => cfg.get())
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("MCP OAuth Debug")
UI.empty()
prompts.intro("MCP OAuth Debug")
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const serverName = args.name
const mcpServers = config.mcp ?? {}
const serverName = args.name
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
}
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
}
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status — services already in hand, run inline.
const { authStatus, entry } = await Effect.runPromise(
Effect.all({
authStatus: mcp.getAuthStatus(serverName),
entry: auth.get(serverName),
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
}
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
const spinner = prompts.spinner()
spinner.start("Testing connection...")
// Check stored auth status
const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
}
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
const spinner = prompts.spinner()
spinner.start("Testing connection...")
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
id: 1,
}),
})
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
})
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const auth = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}),
)
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.info("No client ID - dynamic registration will be attempted")
}
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
prompts.log.info("No client ID - dynamic registration will be attempted")
}
}
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
}
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
}
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
prompts.outro("Debug complete")
prompts.outro("Debug complete")
})
}),
})

View File

@@ -6,8 +6,6 @@ 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,19 +237,16 @@ export const ProvidersListCommand = effectCmd({
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
yield* Effect.promise(async () => {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
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 = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await getModels()
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
@@ -283,7 +278,6 @@ export const ProvidersListCommand = effectCmd({
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
})
}),
})
@@ -307,189 +301,176 @@ export const ProvidersLoginCommand = effectCmd({
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
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",
})
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
}
await refreshModels().catch(() => {})
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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 AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.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,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
...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 = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
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 = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
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\//, "")
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
}
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 === "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 (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
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)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
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")
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
}
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,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
...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({
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
}
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),
)
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 === "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 (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
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 = yield* Effect.promise(() =>
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 }))
prompts.outro("Done")
}),
})
@@ -499,36 +480,29 @@ export const ProvidersLogoutCommand = effectCmd({
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
yield* Effect.promise(async () => {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
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 = await getModels()
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)) throw new UI.CancelledError()
const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
const database = yield* modelsDev.get()
const selected = yield* Effect.promise(() =>
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")
})
}),
})

View File

@@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
identifier: "Session.Delivery",
@@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionID,
parentID: SessionID.pipe(Schema.optional),
parentID: optionalOmitUndefined(SessionID),
projectID: ProjectID,
workspaceID: WorkspaceID.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Schema.Struct({
id: ModelID,
providerID: ProviderID,
variant: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
}),
title: Schema.String,
/*
@@ -109,7 +110,7 @@ export const layer = Layer.effect(
decodeMessage({ ...row.data, id: row.id, type: row.type })
function fromRow(row: typeof SessionTable.$inferSelect): Info {
return {
return new Info({
id: SessionID.make(row.id),
projectID: ProjectID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
@@ -129,7 +130,7 @@ export const layer = Layer.effect(
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
}
})
}
const result: Interface = {