From 039cc426b101313375ed84fdc3d8db28ae331a36 Mon Sep 17 00:00:00 2001 From: Developer1011x Date: Tue, 2 Dec 2025 10:44:25 +0530 Subject: [PATCH] fix: improve code quality, type safety, and documentation Code Quality Improvements: - Replace `any` types with `unknown` in error handling (index.ts, processor.ts) - Fix type safety in provider.ts by using proper type assertions - Fix plugin hook typing to avoid @ts-expect-error - Fix fetch type compatibility in plugin/index.ts - Remove unused debug file (util/scrap.ts) - Replace console.log with UI.println in stats.ts for consistent output Test Improvements: - Enable permission boundary test in patch.test.ts - Enable directory traversal test in bash.test.ts - Use polling pattern instead of arbitrary setTimeout for async tests Documentation: - Redesigned README with better structure and formatting - Added feature highlights section - Added installation table for easier reference - Added agents comparison table - Added collapsible FAQ sections - Added configuration section - Improved community links with badges --- README.md | 168 +++++++++++++++------ packages/opencode/src/cli/cmd/stats.ts | 57 +++---- packages/opencode/src/index.ts | 4 +- packages/opencode/src/plugin/index.ts | 8 +- packages/opencode/src/provider/provider.ts | 5 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/util/scrap.ts | 10 -- packages/opencode/test/tool/bash.test.ts | 45 +++--- packages/opencode/test/tool/patch.test.ts | 18 ++- 9 files changed, 197 insertions(+), 120 deletions(-) delete mode 100644 packages/opencode/src/util/scrap.ts diff --git a/README.md b/README.md index 799cf00a2a8..6ae493735d8 100644 --- a/README.md +++ b/README.md @@ -3,99 +3,171 @@ - OpenCode logo + OpenCode logo

-

The AI coding agent built for the terminal.

+ +

The open-source AI coding agent for your terminal

+ +

+ Discord + npm + Build status + License +

+

- Discord - npm - Build status + Documentation | + Agents | + OpenCode Zen | + Community

+--- + [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) +## Features + +- **100% Open Source** - Fully transparent, community-driven development +- **Provider Agnostic** - Works with Claude, OpenAI, Google, Azure, local models, and more +- **Built-in LSP Support** - Language Server Protocol integration out of the box +- **Terminal-First Design** - Crafted by Neovim enthusiasts for power users +- **Client/Server Architecture** - Run locally, control remotely from any device +- **Multiple Agents** - Switch between build and plan modes for different workflows + --- -### Installation +## Quick Start ```bash -# YOLO +# One-line install curl -fsSL https://opencode.ai/install | bash -# Package managers -npm i -g opencode-ai@latest # or bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows -choco install opencode # Windows -brew install opencode # macOS and Linux -paru -S opencode-bin # Arch Linux -mise use --pin -g ubi:sst/opencode # Any OS -nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch +# Then run +opencode ``` +## Installation + +Choose your preferred installation method: + +### Package Managers + +| Platform | Command | +|----------|---------| +| **npm/bun/pnpm/yarn** | `npm i -g opencode-ai@latest` | +| **Homebrew** (macOS/Linux) | `brew install opencode` | +| **Scoop** (Windows) | `scoop bucket add extras && scoop install extras/opencode` | +| **Chocolatey** (Windows) | `choco install opencode` | +| **Arch Linux** | `paru -S opencode-bin` | +| **mise** | `mise use --pin -g ubi:sst/opencode` | +| **Nix** | `nix run nixpkgs#opencode` | + > [!TIP] > Remove versions older than 0.1.x before installing. -#### Installation Directory +### Custom Installation Directory -The install script respects the following priority order for the installation path: +The install script respects these paths in order of priority: -1. `$OPENCODE_INSTALL_DIR` - Custom installation directory -2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path -3. `$HOME/bin` - Standard user binary directory (if exists or can be created) +1. `$OPENCODE_INSTALL_DIR` - Custom directory +2. `$XDG_BIN_DIR` - XDG compliant path +3. `$HOME/bin` - User binary directory 4. `$HOME/.opencode/bin` - Default fallback ```bash -# Examples +# Custom install examples OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` -### Agents +--- + +## Agents + +OpenCode provides multiple agents optimized for different workflows. Switch between them using the `Tab` key. -OpenCode includes two built-in agents you can switch between, -you can switch between these using the `Tab` key. +| Agent | Description | Best For | +|-------|-------------|----------| +| **build** | Full access agent (default) | Active development, writing code, running commands | +| **plan** | Read-only analysis agent | Exploring codebases, planning changes, code review | +| **general** | Complex task subagent | Multi-step searches, invoked via `@general` | -- **build** - Default, full access agent for development work -- **plan** - Read-only agent for analysis and code exploration - - Denies file edits by default - - Asks permission before running bash commands - - Ideal for exploring unfamiliar codebases or planning changes +The **plan** agent is particularly useful when: +- Exploring unfamiliar codebases safely +- Planning architectural changes before implementation +- Reviewing code without accidental modifications -Also, included is a **general** subagent for complex searches and multi-step tasks. -This is used internally and can be invoked using `@general` in messages. +Learn more about [agents in our documentation](https://opencode.ai/docs/agents). -Learn more about [agents](https://opencode.ai/docs/agents). +--- + +## Configuration -### Documentation +OpenCode can be configured via: +- `opencode.json` in your project root +- `~/.config/opencode/config.json` for global settings -For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs). +For detailed configuration options, see our [documentation](https://opencode.ai/docs). + +--- -### Contributing +## Contributing -If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request. +We welcome contributions! Please read our [contributing guide](./CONTRIBUTING.md) before submitting a pull request. ### Building on OpenCode -If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in anyway. +If you're creating a project related to OpenCode (e.g., "opencode-dashboard", "opencode-mobile"), please clarify in your README that it's a community project and not officially affiliated with the OpenCode team. + +--- + +## FAQ + +
+What makes OpenCode different from other AI coding tools? -### FAQ +OpenCode stands out through: -#### How is this different than Claude Code? +- **Complete transparency** - 100% open source codebase +- **Provider flexibility** - Use any LLM provider or local models via [OpenCode Zen](https://opencode.ai/zen) +- **Native LSP integration** - Language-aware assistance out of the box +- **Terminal-first philosophy** - Built by terminal power users for terminal power users +- **Extensible architecture** - Client/server design enables remote control and custom integrations -It's very similar to Claude Code in terms of capability. Here are the key differences: +
-- 100% open source -- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important. -- Out of the box LSP support -- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. -- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. +
+Which AI providers are supported? -#### What's the other repo? +OpenCode supports a wide range of providers: +- Anthropic Claude +- OpenAI +- Google (Gemini, Vertex AI) +- Azure OpenAI +- Amazon Bedrock +- OpenRouter +- Local models (via OpenAI-compatible APIs) -The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466). +
+ +
+What's the story behind the name? + +There's another repository with a similar name - you can [read about it here](https://x.com/thdxr/status/1933561254481666466). + +
--- -**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) +## Community + +Join our growing community of developers: + +

+ Discord + X.com +

+ diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index f41b23ee971..5a481ea4440 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,6 +5,7 @@ import { bootstrap } from "../bootstrap" import { Storage } from "../../storage/storage" import { Project } from "../../project/project" import { Instance } from "../../project/instance" +import { UI } from "../ui" interface SessionStats { totalSessions: number @@ -123,7 +124,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } if (filteredSessions.length > 1000) { - console.log(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`) + UI.println(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`) } if (filteredSessions.length === 0) { @@ -230,42 +231,42 @@ export function displayStats(stats: SessionStats, toolLimit?: number) { } // Overview section - console.log("┌────────────────────────────────────────────────────────┐") - console.log("│ OVERVIEW │") - console.log("├────────────────────────────────────────────────────────┤") - console.log(renderRow("Sessions", stats.totalSessions.toLocaleString())) - console.log(renderRow("Messages", stats.totalMessages.toLocaleString())) - console.log(renderRow("Days", stats.days.toString())) - console.log("└────────────────────────────────────────────────────────┘") - console.log() + UI.println("┌────────────────────────────────────────────────────────┐") + UI.println("│ OVERVIEW │") + UI.println("├────────────────────────────────────────────────────────┤") + UI.println(renderRow("Sessions", stats.totalSessions.toLocaleString())) + UI.println(renderRow("Messages", stats.totalMessages.toLocaleString())) + UI.println(renderRow("Days", stats.days.toString())) + UI.println("└────────────────────────────────────────────────────────┘") + UI.empty() // Cost & Tokens section - console.log("┌────────────────────────────────────────────────────────┐") - console.log("│ COST & TOKENS │") - console.log("├────────────────────────────────────────────────────────┤") + UI.println("┌────────────────────────────────────────────────────────┐") + UI.println("│ COST & TOKENS │") + UI.println("├────────────────────────────────────────────────────────┤") const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession - console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`)) - console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`)) - console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession)))) + UI.println(renderRow("Total Cost", `$${cost.toFixed(2)}`)) + UI.println(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`)) + UI.println(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession)))) const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession - console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession)))) - console.log(renderRow("Input", formatNumber(stats.totalTokens.input))) - console.log(renderRow("Output", formatNumber(stats.totalTokens.output))) - console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read))) - console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write))) - console.log("└────────────────────────────────────────────────────────┘") - console.log() + UI.println(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession)))) + UI.println(renderRow("Input", formatNumber(stats.totalTokens.input))) + UI.println(renderRow("Output", formatNumber(stats.totalTokens.output))) + UI.println(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read))) + UI.println(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write))) + UI.println("└────────────────────────────────────────────────────────┘") + UI.empty() // Tool Usage section if (Object.keys(stats.toolUsage).length > 0) { const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a) const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools - console.log("┌────────────────────────────────────────────────────────┐") - console.log("│ TOOL USAGE │") - console.log("├────────────────────────────────────────────────────────┤") + UI.println("┌────────────────────────────────────────────────────────┐") + UI.println("│ TOOL USAGE │") + UI.println("├────────────────────────────────────────────────────────┤") const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count)) const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0) @@ -281,11 +282,11 @@ export function displayStats(stats: SessionStats, toolLimit?: number) { const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)` const padding = Math.max(0, width - content.length - 1) - console.log(`│${content}${" ".repeat(padding)} │`) + UI.println(`│${content}${" ".repeat(padding)} │`) } - console.log("└────────────────────────────────────────────────────────┘") + UI.println("└────────────────────────────────────────────────────────┘") } - console.log() + UI.empty() } function formatNumber(num: number): string { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 38b6b5a3f0b..61d04e35cfd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -106,8 +106,8 @@ const cli = yargs(hideBin(process.argv)) try { await cli.parse() -} catch (e) { - let data: Record = {} +} catch (e: unknown) { + let data: Record = {} if (e instanceof NamedError) { const obj = e.toObject() Object.assign(data, { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e617e045418..4ef958e2933 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,8 +14,7 @@ export namespace Plugin { const state = Instance.state(async () => { const client = createOpencodeClient({ baseUrl: "http://localhost:4096", - // @ts-ignore - fetch type incompatibility - fetch: async (...args) => Server.App().fetch(...args), + fetch: (async (...args: Parameters) => Server.App().fetch(...args)) as typeof fetch, }) const config = await Config.get() const hooks = [] @@ -59,11 +58,8 @@ export namespace Plugin { >(name: Name, input: Input, output: Output): Promise { if (!name) return output for (const hook of await state().then((x) => x.hooks)) { - const fn = hook[name] + const fn = hook[name] as ((input: Input, output: Output) => Promise) | undefined if (!fn) continue - // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you - // give up. - // try-counter: 2 await fn(input, output) } return output diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 06e1257b97a..b6720bf1ca8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,7 +28,7 @@ import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from ". export namespace Provider { const log = Log.create({ service: "provider" }) - const BUNDLED_PROVIDERS: Record SDK> = { + const BUNDLED_PROVIDERS: Record) => SDK> = { "@ai-sdk/amazon-bedrock": createAmazonBedrock, "@ai-sdk/anthropic": createAnthropic, "@ai-sdk/azure": createAzure, @@ -38,8 +38,7 @@ export namespace Provider { "@ai-sdk/openai": createOpenAI, "@ai-sdk/openai-compatible": createOpenAICompatible, "@openrouter/ai-sdk-provider": createOpenRouter, - // @ts-ignore (TODO: kill this code so we dont have to maintain it) - "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, + "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible as (options: Record) => SDK, } type CustomLoader = (provider: ModelsDev.Provider) => Promise<{ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8655781d5ed..3728aaf9553 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -209,7 +209,7 @@ export namespace SessionProcessor { state: { status: "error", input: value.input, - error: (value.error as any).toString(), + error: value.error instanceof Error ? value.error.message : String(value.error), metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, time: { start: match.state.time.start, diff --git a/packages/opencode/src/util/scrap.ts b/packages/opencode/src/util/scrap.ts deleted file mode 100644 index 554dba1c54a..00000000000 --- a/packages/opencode/src/util/scrap.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const foo: string = "42" -export const bar: number = 123 - -export function dummyFunction(): void { - console.log("This is a dummy function") -} - -export function randomHelper(): boolean { - return Math.random() > 0.5 -} diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 55b9ba77d66..b45b454946e 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -34,22 +34,31 @@ describe("tool.bash", () => { }) }) - // TODO: better test - // test("cd ../ should ask for permission for external directory", async () => { - // await Instance.provide({ - // directory: projectRoot, - // fn: async () => { - // bash.execute( - // { - // command: "cd ../", - // description: "Try to cd to parent directory", - // }, - // ctx, - // ) - // // Give time for permission to be asked - // await new Promise((resolve) => setTimeout(resolve, 1000)) - // expect(Permission.pending()[ctx.sessionID]).toBeDefined() - // }, - // }) - // }) + test("cd ../ should ask for permission for external directory", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // Execute in the background - don't await since it may block on permission + const executePromise = bash.execute( + { + command: "cd ../", + description: "Try to cd to parent directory", + }, + ctx, + ) + // Poll for permission request with timeout + const maxAttempts = 50 + let attempts = 0 + while (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 50)) + if (Permission.pending()[ctx.sessionID]) break + attempts++ + } + expect(Permission.pending()[ctx.sessionID]).toBeDefined() + // Clean up - reject the permission to allow the promise to resolve + Permission.respond(ctx.sessionID, false) + await executePromise.catch(() => {}) + }, + }) + }) }) diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts index 6d7d6db87f5..3356a0c9b15 100644 --- a/packages/opencode/test/tool/patch.test.ts +++ b/packages/opencode/test/tool/patch.test.ts @@ -48,7 +48,7 @@ describe("tool.patch", () => { }) }) - test.skip("should ask permission for files outside working directory", async () => { + test("should ask permission for files outside working directory", async () => { await Instance.provide({ directory: "/tmp", fn: async () => { @@ -56,10 +56,20 @@ describe("tool.patch", () => { *** Add File: /etc/passwd +malicious content *** End Patch` - patchTool.execute({ patchText: maliciousPatch }, ctx) - // TODO: this sucks - await new Promise((resolve) => setTimeout(resolve, 1000)) + // Execute in the background - don't await since it will block on permission + const executePromise = patchTool.execute({ patchText: maliciousPatch }, ctx) + // Poll for permission request with timeout + const maxAttempts = 50 + let attempts = 0 + while (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 50)) + if (Permission.pending()[ctx.sessionID]) break + attempts++ + } expect(Permission.pending()[ctx.sessionID]).toBeDefined() + // Clean up - reject the permission to allow the promise to resolve + Permission.respond(ctx.sessionID, false) + await executePromise.catch(() => {}) }, }) })