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
17 changes: 17 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM oven/bun:debian

# Config Bun
ENV PATH="~/.bun/bin:${PATH}"
RUN ln -s /usr/local/bin/bun /usr/local/bin/node

# Update packages
RUN if [ "debian" == "alpine" ] ; then apk update ; else apt-get update ; fi

# Install Git
RUN if [ "debian" == "alpine" ] ; then apk add git ; else apt-get install -y git ; fi

# Install curl
RUN if [ "debian" == "alpine" ] ; then apk add curl; else apt-get install -y curl ; fi
RUN if [ "debian" == "alpine" ] ; then apk add xz-utils; else apt-get install -y xz-utils ; fi

RUN curl https://qlty.sh | bash
16 changes: 16 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/marcosgomesneto/bun-devcontainers/tree/main/src/basic-bun
{
"name": "Bun",
"dockerFile": "Dockerfile",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"oven.bun-vscode"
]
}
}
}
71 changes: 36 additions & 35 deletions packages/tui/src/util/error.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,56 @@
import { error } from "node:console";
import { isRecord } from "./record"

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

export function cliErrorMessage(input: unknown): string | undefined {

var error_msg: string | undefined = undefined;

if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = cliErrorMessage(input.cause.body)
if (formatted) return formatted
if (formatted && error_msg === undefined) error_msg = formatted

} else if (tagged(input, "CliError") ||
tagged(input, "AccountServiceError") ||
tagged(input, "AccountTransportError")) {
if (tagged(input, "CliError") && typeof input.exitCode === "number") {
process.exitCode = input.exitCode
}
error_msg = field(input, "message") ?? ""
}

if (tagged(input, "CliError")) {
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return field(input, "message") ?? ""
}
if (tagged(input, "AccountServiceError") || tagged(input, "AccountTransportError")) {
return field(input, "message") ?? ""
if (error_msg !== undefined) {
return error_msg
}

const model = configData(input, "ProviderModelNotFoundError")
const provider = configData(input, "ProviderInitError")
const json = configData(input, "ConfigJsonError")
const directory = configData(input, "ConfigDirectoryTypoError")
const frontmatter = configData(input, "ConfigFrontmatterError")
const invalid = configData(input, "ConfigInvalidError")

if (model) {
const suggestions = Array.isArray(model.suggestions)
? model.suggestions.filter((item): item is string => typeof item === "string")
: []
return [
error_msg = [
`Model not found: ${field(model, "providerID")}/${field(model, "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")
}

const provider = configData(input, "ProviderInitError")
if (provider)
return `Failed to initialize provider "${field(provider, "providerID")}". Check credentials and configuration.`

const json = configData(input, "ConfigJsonError")
if (json) {
} else if (provider) {
error_msg = `Failed to initialize provider "${field(provider, "providerID")}". Check credentials and configuration.`
} else if (json) {
const message = field(json, "message")
return `Config file at ${field(json, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
}

const directory = configData(input, "ConfigDirectoryTypoError")
if (directory) {
return `Directory "${field(directory, "dir")}" in ${field(directory, "path")} is not valid. Rename the directory to "${field(directory, "suggestion")}" or remove it. This is a common typo.`
}

const frontmatter = configData(input, "ConfigFrontmatterError")
if (frontmatter) return field(frontmatter, "message") ?? ""

const invalid = configData(input, "ConfigInvalidError")
if (invalid) {
error_msg = `Config file at ${field(json, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
} else if (directory) {
error_msg = `Directory "${field(directory, "dir")}" in ${field(directory, "path")} is not valid. Rename the directory to "${field(directory, "suggestion")}" or remove it. This is a common typo.`
} else if (frontmatter) {
error_msg = field(frontmatter, "message") ?? ""
} else if (invalid) {
const path = field(invalid, "path")
const message = field(invalid, "message")
const issues = Array.isArray(invalid.issues)
Expand All @@ -61,18 +63,17 @@ export function cliErrorMessage(input: unknown): string | undefined {
)
})
: []
return [
error_msg = [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
}

if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) return ""
if (isRecord(input) && named(input, "MCPFailed")) {
else if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) error_msg = ""
else if (isRecord(input) && named(input, "MCPFailed")) {
const name = isRecord(input.data) ? field(input.data, "name") : undefined
return `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.`
error_msg = `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.`
}
return undefined
return error_msg
}

function tagged(input: unknown, tag: string): input is Record<string, unknown> {
Expand Down
165 changes: 164 additions & 1 deletion packages/tui/test/util/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
import { cliErrorMessage, errorData, errorFormat, errorMessage } from "../../src/util/error"

describe("util.error", () => {
test("formats native Error instances", () => {
Expand Down Expand Up @@ -46,4 +46,167 @@ describe("util.error", () => {
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
expect(String(data.formatted)).toContain("ResolveMessage")
})

// Test cliErrorMessage
// written by AI

test("formats native Error instances with a matching cause.body", () => {
const err = new Error("wrapper")
;(err as any).cause = { body: { _tag: "CliError", message: "inner failure", exitCode: 1 } }
expect(cliErrorMessage(err)).toBe("inner failure")
})

test("returns undefined for Errors without a usable cause", () => {
expect(cliErrorMessage(new Error("plain"))).toBeUndefined()

const stringCause = new Error("wrapper")
;(stringCause as any).cause = "just a string"
expect(cliErrorMessage(stringCause)).toBeUndefined()

const noBody = new Error("wrapper")
;(noBody as any).cause = { something: "else" }
expect(cliErrorMessage(noBody)).toBeUndefined()
})

test("propagates empty-string results from cause.body", () => {
const err = new Error("wrapper")
;(err as any).cause = { body: { _tag: "UICancelledError" } }
expect(cliErrorMessage(err)).toBeUndefined()
})

test("extracts message from tagged errors and sets exitCode for CliError", () => {
const prev = process.exitCode
try {
const err = { _tag: "CliError", message: "boom", exitCode: 42 }
expect(cliErrorMessage(err)).toBe("boom")
expect(process.exitCode).toBe(42)

const zero = { _tag: "CliError", message: "ok-ish", exitCode: 0 }
cliErrorMessage(zero)
expect(process.exitCode).toBe(0)
} finally {
process.exitCode = prev
}
})

test("falls back to empty string when tagged error has no message field", () => {
expect(cliErrorMessage({ _tag: "AccountServiceError" })).toBe("")
})

test("formats model-not-found errors from name+data envelopes", () => {
const err = {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "cluade-4",
suggestions: ["claude-4", "claude-4-sonnet", 42, null],
},
}
expect(cliErrorMessage(err)).toBe(
[
"Model not found: anthropic/cluade-4",
"Did you mean: claude-4, claude-4-sonnet",
"Try: `opencode models` to list available models",
"Or check your config (opencode.json) provider/model names",
].join("\n"),
)
})

test("also supports the _tag shape for config errors", () => {
const err = {
_tag: "ProviderInitError",
providerID: "openai",
}
expect(cliErrorMessage(err)).toBe(
'Failed to initialize provider "openai". Check credentials and configuration.',
)
})

test("omits suggestion line when suggestions are missing or malformed", () => {
const err = {
name: "ProviderModelNotFoundError",
data: { providerID: "openai", modelID: "gpt-x", suggestions: "not-an-array" },
}
const result = cliErrorMessage(err)!
expect(result).toContain("Model not found: openai/gpt-x")
expect(result).not.toContain("Did you mean")
})

test("formats config json errors, with and without parse detail", () => {
const bare = { name: "ConfigJsonError", data: { path: "/home/user/.config/opencode.json" } }
expect(cliErrorMessage(bare)).toBe(
"Config file at /home/user/.config/opencode.json is not valid JSON(C)",
)

const detailed = { name: "ConfigJsonError", data: { path: "opencode.json", message: "Unexpected token }" } }
expect(cliErrorMessage(detailed)).toBe(
"Config file at opencode.json is not valid JSON(C): Unexpected token }",
)
})

test("formats directory typo errors", () => {
const err = {
name: "ConfigDirectoryTypoError",
data: { dir: "cmmands", path: "/project/.opencode", suggestion: "commands" },
}
expect(cliErrorMessage(err)).toBe(
'Directory "cmmands" in /project/.opencode is not valid. Rename the directory to "commands" or remove it. This is a common typo.',
)
})

test("formats frontmatter errors from their message field", () => {
expect(cliErrorMessage({ name: "ConfigFrontmatterError", data: { message: "missing title" } })).toBe("missing title")
expect(cliErrorMessage({ name: "ConfigFrontmatterError" })).toBeUndefined()
})

test("formats invalid-config errors with path, message, and filtered issues", () => {
const err = {
name: "ConfigInvalidError",
data: {
path: "agents",
message: "validation failed",
issues: [
{ message: "missing field", path: ["agent", "model"] },
{ message: 123, path: ["x"] },
{ message: "bad path", path: "not-array" },
"not-a-record",
],
},
}
expect(cliErrorMessage(err)).toBe(
["Configuration is invalid at agents: validation failed", "↳ missing field agent.model"].join("\n"),
)
})

test("uses generic header and omits 'at' when path is 'config'", () => {
const err = { name: "ConfigInvalidError", data: { path: "config", message: "broken" } }
const result = cliErrorMessage(err)!
expect(result.startsWith("Configuration is invalid: broken")).toBe(true)
expect(result).not.toContain(" at ")
})

test("formats UICancelledError as an empty string, via _tag or name", () => {
expect(cliErrorMessage({ _tag: "UICancelledError" })).toBe("")
expect(cliErrorMessage({ name: "UICancelledError" })).toBe("")
})

test("formats MCP failures with the server name", () => {
const err = { name: "MCPFailed", data: { name: "my-server" } }
expect(cliErrorMessage(err)).toBe(
'MCP server "my-server" failed. Note, opencode does not support MCP authentication yet.',
)
})

test("handles MCP failures with missing or malformed data", () => {
expect(cliErrorMessage({ name: "MCPFailed" })).toContain('MCP server "undefined" failed')
expect(cliErrorMessage({ _tag: "MCPFailed", data: "not-a-record" })).toContain('MCP server "undefined" failed')
})

test("returns undefined for unrecognized inputs", () => {
expect(cliErrorMessage(undefined)).toBeUndefined()
expect(cliErrorMessage(null)).toBeUndefined()
expect(cliErrorMessage(42)).toBeUndefined()
expect(cliErrorMessage("plain string")).toBeUndefined()
expect(cliErrorMessage({ name: "SomeUnknownError", message: "hi" })).toBeUndefined()
})
})
Loading