Skip to content
Open
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
14 changes: 14 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "opencode-dev",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},
"postCreateCommand": "curl -fsSL https://bun.sh/install | bash && echo 'export PATH=\"$HOME/.bun/bin:$PATH\"' >> ~/.bashrc",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint"]
}
}
}

188 changes: 110 additions & 78 deletions packages/opencode/src/cli/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { isRecord } from "@/util/record"

type ConfigIssue = { message: string; path: string[] }

type ErrorFormatter = (input: unknown) => string | undefined

function isTaggedError(error: unknown, tag: string): error is Record<string, unknown> {
return isRecord(error) && error._tag === tag
}
Expand Down Expand Up @@ -32,99 +34,129 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
: []
}

export function FormatError(input: unknown): string | undefined {
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = FormatError(input.cause.body)
if (formatted) return formatted
}
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
function formatCliError(input: unknown): string | undefined {
if (!isTaggedError(input, "CliError")) return undefined
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return stringField(input, "message") ?? ""
}

// CliError: domain failure surfaced from an effectCmd handler via fail("...")
if (isTaggedError(input, "CliError")) {
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return stringField(input, "message") ?? ""
}
// MCPFailed: { name: string }
function formatMcpFailed(input: unknown): string | undefined {
if (!NamedError.hasName(input, "MCPFailed")) return undefined
const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
}

// MCPFailed: { name: string }
if (NamedError.hasName(input, "MCPFailed")) {
const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
}
// AccountServiceError, AccountTransportError: TaggedErrorClass
function formatAccountError(input: unknown): string | undefined {
if (!isTaggedError(input, "AccountServiceError") && !isTaggedError(input, "AccountTransportError")) return undefined
return stringField(input, "message") ?? ""
}

// AccountServiceError, AccountTransportError: TaggedErrorClass
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
return stringField(input, "message") ?? ""
}
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
function formatProviderModelNotFound(input: unknown): string | undefined {
const data = configData(input, "ProviderModelNotFoundError")
if (!data) return undefined
const suggestions = Array.isArray(data.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
return [
`Model not found: ${stringField(data, "providerID")}/${stringField(data, "modelID")}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
}

// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
const providerModelNotFound = configData(input, "ProviderModelNotFoundError")
if (providerModelNotFound) {
const suggestions = Array.isArray(providerModelNotFound.suggestions)
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: []
return [
`Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
}
// ProviderInitError: { providerID: string }
function formatProviderInit(input: unknown): string | undefined {
const data = configData(input, "ProviderInitError")
if (!data) return undefined
return `Failed to initialize provider "${stringField(data, "providerID")}". Check credentials and configuration.`
}

// ProviderInitError: { providerID: string }
const providerInit = configData(input, "ProviderInitError")
if (providerInit) {
return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.`
}
// ConfigJsonError: { path: string, message?: string }
function formatConfigJson(input: unknown): string | undefined {
const data = configData(input, "ConfigJsonError")
if (!data) return undefined
const message = stringField(data, "message")
return `Config file at ${stringField(data, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
}

// ConfigJsonError: { path: string, message?: string }
const configJson = configData(input, "ConfigJsonError")
if (configJson) {
const message = stringField(configJson, "message")
return `Config file at ${stringField(configJson, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
}
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
function formatConfigDirectoryTypo(input: unknown): string | undefined {
const data = configData(input, "ConfigDirectoryTypoError")
if (!data) return undefined
return `Directory "${stringField(data, "dir")}" in ${stringField(data, "path")} is not valid. Rename the directory to "${stringField(data, "suggestion")}" or remove it. This is a common typo.`
}

// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError")
if (configDirectoryTypo) {
return `Directory "${stringField(configDirectoryTypo, "dir")}" in ${stringField(configDirectoryTypo, "path")} is not valid. Rename the directory to "${stringField(configDirectoryTypo, "suggestion")}" or remove it. This is a common typo.`
}
// ConfigFrontmatterError: { message: string }
function formatConfigFrontmatter(input: unknown): string | undefined {
const data = configData(input, "ConfigFrontmatterError")
if (!data) return undefined
return stringField(data, "message") ?? ""
}

// ConfigFrontmatterError: { message: string }
const configFrontmatter = configData(input, "ConfigFrontmatterError")
if (configFrontmatter) {
return stringField(configFrontmatter, "message") ?? ""
}
// ConfigRemoteAuthError: { url: string, remote: string }
function formatConfigRemoteAuth(input: unknown): string | undefined {
const data = configData(input, "ConfigRemoteAuthError")
if (!data) return undefined
const url = stringField(data, "url")
const remote = stringField(data, "remote")
return [
`Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`,
`Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).`,
...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []),
].join("\n")
}

// ConfigRemoteAuthError: { url: string, remote: string }
const remoteAuth = configData(input, "ConfigRemoteAuthError")
if (remoteAuth) {
const url = stringField(remoteAuth, "url")
const remote = stringField(remoteAuth, "remote")
return [
`Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`,
`Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).`,
...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []),
].join("\n")
}
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
function formatConfigInvalid(input: unknown): string | undefined {
const data = configData(input, "ConfigInvalidError")
if (!data) return undefined
const path = stringField(data, "path")
const message = stringField(data, "message")
const issues = configIssues(data)
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
}

// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
const configInvalid = configData(input, "ConfigInvalidError")
if (configInvalid) {
const path = stringField(configInvalid, "path")
const message = stringField(configInvalid, "message")
const issues = configIssues(configInvalid)
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
// UICancelledError: user cancelled an interactive CLI prompt
function formatUICancelled(input: unknown): string | undefined {
if (!isTaggedError(input, "UICancelledError") && !NamedError.hasName(input, "UICancelledError")) return undefined
return ""
}

// Order matters: the first formatter that recognizes the input wins.
const FORMATTERS: ErrorFormatter[] = [
formatCliError,
formatMcpFailed,
formatAccountError,
formatProviderModelNotFound,
formatProviderInit,
formatConfigJson,
formatConfigDirectoryTypo,
formatConfigFrontmatter,
formatConfigRemoteAuth,
formatConfigInvalid,
formatUICancelled,
]

export function FormatError(input: unknown): string | undefined {
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = FormatError(input.cause.body)
if (formatted) return formatted
}

// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
for (const format of FORMATTERS) {
const result = format(input)
if (result !== undefined) return result
}

return undefined
}

export function FormatUnknownError(input: unknown): string {
return errorFormat(input)
}
}
59 changes: 58 additions & 1 deletion packages/opencode/test/cli/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,61 @@ describe("cli.error", () => {
test("formats cancelled UI errors as empty output", () => {
expect(FormatError(new UI.CancelledError())).toBe("")
})
})

test("formats CLI errors and applies their exit code", () => {
const previous = process.exitCode
try {
expect(FormatError({ _tag: "CliError", message: "something broke", exitCode: 3 })).toBe("something broke")
expect(process.exitCode).toBe(3)
} finally {
process.exitCode = previous ?? 0
}
expect(process.exitCode).toBe(previous ?? 0)
})

test("formats CLI errors without a message as empty output", () => {
const previous = process.exitCode
try {
expect(FormatError({ _tag: "CliError" })).toBe("")
} finally {
process.exitCode = previous ?? 0
}
})

test("formats remote config auth errors with a login hint", () => {
const data = { url: "https://config.example.com", remote: "team-config" }
const expected = [
"Failed to load remote config from team-config: the server returned a login page instead of JSON.",
"Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).",
"Run `opencode auth login https://config.example.com` to re-authenticate.",
].join("\n")

expect(FormatError({ name: "ConfigRemoteAuthError", data })).toBe(expected)
expect(FormatError({ _tag: "ConfigRemoteAuthError", ...data })).toBe(expected)
})

test("formats remote config auth errors without a url", () => {
const expected = [
"Failed to load remote config: the server returned a login page instead of JSON.",
"Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).",
].join("\n")

expect(FormatError({ _tag: "ConfigRemoteAuthError" })).toBe(expected)
})

test("formats config invalid errors with no path and no issues", () => {
expect(FormatError({ _tag: "ConfigInvalidError", path: "config" })).toBe("Configuration is invalid")
})

test("unwraps errors nested under cause.body", () => {
const wrapped = new Error("outer", { cause: { body: { _tag: "ProviderInitError", providerID: "anthropic" } } })

expect(FormatError(wrapped)).toBe('Failed to initialize provider "anthropic". Check credentials and configuration.')
})

test("returns undefined for errors it does not recognize", () => {
expect(FormatError({ _tag: "SomethingElse", message: "nope" })).toBeUndefined()
expect(FormatError("just a string")).toBeUndefined()
expect(FormatError(undefined)).toBeUndefined()
})
})
Loading