feat(api): publish v2 reference

This commit is contained in:
Dax Raad 2026-07-06 01:29:08 -04:00
commit eed23d8ee9
40 changed files with 26473 additions and 147 deletions

View file

@ -450,20 +450,24 @@ export interface CredentialApi<E = never> {
readonly remove: CredentialRemoveOperation<E> readonly remove: CredentialRemoveOperation<E>
} }
type Endpoint12_0Request = Parameters<RawClient["server.project"]["project.current"]>[0] export type Endpoint12_0Output = EffectValue<ReturnType<RawClient["server.project"]["project.list"]>>
export type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint12_0Output, E>
export type Endpoint12_0Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
export type ProjectCurrentOperation<E = never> = (input?: Endpoint12_0Input) => Effect.Effect<Endpoint12_0Output, E>
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0] type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
export type Endpoint12_1Input = { export type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] }
readonly projectID: Endpoint12_1Request["params"]["projectID"] export type Endpoint12_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
readonly location?: Endpoint12_1Request["query"]["location"] export type ProjectCurrentOperation<E = never> = (input?: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
type Endpoint12_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
export type Endpoint12_2Input = {
readonly projectID: Endpoint12_2Request["params"]["projectID"]
readonly location?: Endpoint12_2Request["query"]["location"]
} }
export type Endpoint12_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>> export type Endpoint12_2Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>>
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E> export type ProjectDirectoriesOperation<E = never> = (input: Endpoint12_2Input) => Effect.Effect<Endpoint12_2Output, E>
export interface ProjectApi<E = never> { export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly current: ProjectCurrentOperation<E> readonly current: ProjectCurrentOperation<E>
readonly directories: ProjectDirectoriesOperation<E> readonly directories: ProjectDirectoriesOperation<E>
} }

View file

@ -544,25 +544,29 @@ const Endpoint11_1 = (raw: RawClient["server.credential"]) => (input: Endpoint11
const adaptGroup11 = (raw: RawClient["server.credential"]) => ({ update: Endpoint11_0(raw), remove: Endpoint11_1(raw) }) const adaptGroup11 = (raw: RawClient["server.credential"]) => ({ update: Endpoint11_0(raw), remove: Endpoint11_1(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.project"]["project.current"]>[0] const Endpoint12_0 = (raw: RawClient["server.project"]) => () =>
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } raw["project.list"]({}).pipe(Effect.mapError(mapClientError))
const Endpoint12_0 = (raw: RawClient["server.project"]) => (input?: Endpoint12_0Input) =>
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] }
const Endpoint12_1 = (raw: RawClient["server.project"]) => (input?: Endpoint12_1Input) =>
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0] type Endpoint12_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint12_1Input = { type Endpoint12_2Input = {
readonly projectID: Endpoint12_1Request["params"]["projectID"] readonly projectID: Endpoint12_2Request["params"]["projectID"]
readonly location?: Endpoint12_1Request["query"]["location"] readonly location?: Endpoint12_2Request["query"]["location"]
} }
const Endpoint12_1 = (raw: RawClient["server.project"]) => (input: Endpoint12_1Input) => const Endpoint12_2 = (raw: RawClient["server.project"]) => (input: Endpoint12_2Input) =>
raw["project.directories"]({ raw["project.directories"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.project"]) => ({ const adaptGroup12 = (raw: RawClient["server.project"]) => ({
current: Endpoint12_0(raw), list: Endpoint12_0(raw),
directories: Endpoint12_1(raw), current: Endpoint12_1(raw),
directories: Endpoint12_2(raw),
}) })
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0] type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]

View file

@ -89,6 +89,7 @@ import type {
CredentialUpdateOutput, CredentialUpdateOutput,
CredentialRemoveInput, CredentialRemoveInput,
CredentialRemoveOutput, CredentialRemoveOutput,
ProjectListOutput,
ProjectCurrentInput, ProjectCurrentInput,
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectDirectoriesInput, ProjectDirectoriesInput,
@ -899,6 +900,11 @@ export function make(options: ClientOptions) {
), ),
}, },
project: { project: {
list: (requestOptions?: RequestOptions) =>
request<ProjectListOutput>(
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) => current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>( request<ProjectCurrentOutput>(
{ {

View file

@ -2458,6 +2458,17 @@ export type CredentialRemoveInput = {
export type CredentialRemoveOutput = void export type CredentialRemoveOutput = void
export type ProjectListOutput = ReadonlyArray<{
readonly id: string
readonly worktree: string
readonly vcs?: "git" | "hg"
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
readonly time: { readonly created: number; readonly updated: number; readonly initialized?: number }
readonly sandboxes: ReadonlyArray<string>
}>
export type ProjectCurrentInput = { export type ProjectCurrentInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

View file

@ -3,8 +3,10 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import path from "path" import path from "path"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Git } from "./git" import { Git } from "./git"
import { AppProcess } from "./process" import { AppProcess } from "./process"
@ -12,6 +14,7 @@ import { makeGlobalNode } from "./effect/app-node"
import { Hash } from "./util/hash" import { Hash } from "./util/hash"
import { ProjectDirectories } from "./project/directories" import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema" import { ProjectSchema } from "./project/schema"
import { ProjectTable } from "./project/sql"
export const ID = ProjectSchema.ID export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID export type ID = ProjectSchema.ID
@ -25,9 +28,8 @@ export type Current = ProjectSchema.Current
export const Directory = ProjectSchema.Directory export const Directory = ProjectSchema.Directory
export type Directory = ProjectSchema.Directory export type Directory = ProjectSchema.Directory
export class Info extends Schema.Class<Info>("Project.Info")({ export const Info = ProjectSchema.Info
id: ID, export interface Info extends Schema.Schema.Type<typeof Info> {}
}) {}
export const DirectoriesInput = ProjectSchema.DirectoriesInput export const DirectoriesInput = ProjectSchema.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type export type DirectoriesInput = typeof DirectoriesInput.Type
@ -54,6 +56,7 @@ export const root = Effect.fn("Project.root")(function* (
}) })
export interface Interface { export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories> readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved> readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/** /**
@ -70,14 +73,50 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
function fromRow(row: typeof ProjectTable.$inferSelect): Info {
const icon =
row.icon_url || row.icon_url_override || row.icon_color
? {
url: row.icon_url ?? undefined,
override: row.icon_url_override ?? undefined,
color: row.icon_color ?? undefined,
}
: undefined
return {
id: row.id,
worktree: row.worktree,
vcs: row.vcs ?? undefined,
name: row.name ?? undefined,
icon,
commands: row.commands ?? undefined,
time: {
created: row.time_created,
updated: row.time_updated,
initialized: row.time_initialized ?? undefined,
},
sandboxes: row.sandboxes,
}
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const git = yield* Git.Service const git = yield* Git.Service
const proc = yield* AppProcess.Service const proc = yield* AppProcess.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service const projectDirectories = yield* ProjectDirectories.Service
const list = Effect.fn("Project.list")(function* () {
const rows = yield* db
.select()
.from(ProjectTable)
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
return yield* projectDirectories.list(input.projectID) return yield* projectDirectories.list(input.projectID)
}) })
@ -190,12 +229,12 @@ const layer = Layer.effect(
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
}) })
return Service.of({ directories, resolve, commit }) return Service.of({ list, directories, resolve, commit })
}), }),
) )
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node], deps: [Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
}) })

View file

@ -13,6 +13,9 @@ export type Current = typeof Current.Type
export const Directory = Project.Directory export const Directory = Project.Directory
export type Directory = typeof Directory.Type export type Directory = typeof Directory.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = Project.DirectoriesInput export const DirectoriesInput = Project.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type export type DirectoriesInput = typeof DirectoriesInput.Type

View file

@ -6,7 +6,7 @@ import { ProjectSchema } from "./schema"
export const ProjectTable = sqliteTable("project", { export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(), id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: absoluteColumn().notNull(), worktree: absoluteColumn().notNull(),
vcs: text(), vcs: text().$type<"git" | "hg">(),
name: text(), name: text(),
icon_url: text(), icon_url: text(),
icon_url_override: text(), icon_url_override: text(),

View file

@ -77,6 +77,7 @@ describe("node build", () => {
Effect.sync(() => { Effect.sync(() => {
acquisitions++ acquisitions++
return Project.Service.of({ return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
commit: () => Effect.void, commit: () => Effect.void,

View file

@ -12,6 +12,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const projectLayer = Layer.succeed( const projectLayer = Layer.succeed(
Project.Service, Project.Service,
Project.Service.of({ Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
resolve: () => resolve: () =>
Effect.succeed({ Effect.succeed({

View file

@ -2,15 +2,71 @@ import { describe, expect } from "bun:test"
import { $ } from "bun" import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(ProjectV2.node)) const it = testEffect(
Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)),
)
describe("ProjectV2.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* ProjectV2.Service
yield* db
.insert(ProjectTable)
.values([
{
id: ProjectV2.ID.make("older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon_color: "#000000",
commands: { start: "bun dev" },
sandboxes: [abs("/older/sandbox")],
time_created: 1,
time_updated: 1,
},
{
id: ProjectV2.ID.make("newer"),
worktree: abs("/newer"),
sandboxes: [],
time_created: 2,
time_updated: 2,
time_initialized: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: ProjectV2.ID.make("newer"),
worktree: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: ProjectV2.ID.make("older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
sandboxes: [abs("/older/sandbox")],
},
])
}),
)
})
function remoteID(remote: string) { function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))

View file

@ -33,6 +33,7 @@ const model = Model.make({
const projects = Layer.succeed( const projects = Layer.succeed(
ProjectV2.Service, ProjectV2.Service,
ProjectV2.Service.of({ ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
commit: () => Effect.void, commit: () => Effect.void,

View file

@ -31,6 +31,7 @@ import { tmpdir } from "./fixture/tmpdir"
const projects = Layer.succeed( const projects = Layer.succeed(
ProjectV2.Service, ProjectV2.Service,
ProjectV2.Service.of({ ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
commit: () => Effect.void, commit: () => Effect.void,

View file

@ -55,6 +55,7 @@ const readToolNode = makeLocationNode({
const projects = Layer.succeed( const projects = Layer.succeed(
ProjectV2.Service, ProjectV2.Service,
ProjectV2.Service.of({ ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
commit: () => Effect.void, commit: () => Effect.void,

View file

@ -18,6 +18,7 @@ import { testEffect } from "./lib/effect"
const projects = Layer.succeed( const projects = Layer.succeed(
ProjectV2.Service, ProjectV2.Service,
ProjectV2.Service.of({ ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
commit: () => Effect.void, commit: () => Effect.void,

View file

@ -0,0 +1,6 @@
---
title: "API Reference"
description: "OpenCode HTTP API."
---
The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json).

4
packages/docs/config.mdx Normal file
View file

@ -0,0 +1,4 @@
---
title: "Config"
description: "Configure OpenCode."
---

View file

@ -17,11 +17,24 @@
"tabs": [ "tabs": [
{ {
"tab": "Docs", "tab": "Docs",
"pages": ["index"] "pages": ["index", "config", "plugins", "troubleshooting"]
}, },
{ {
"tab": "SDK", "tab": "SDK",
"pages": ["sdk/index"] "pages": ["sdk/index"]
},
{
"tab": "API",
"groups": [
{
"group": "Overview",
"pages": ["api/index"]
},
{
"group": "Endpoints",
"openapi": "openapi.json"
}
]
} }
], ],
"global": {} "global": {}

View file

@ -98,19 +98,19 @@ Standalone binaries are not available in beta.
With OpenCode you can use any LLM provider by configuring its API key. With OpenCode you can use any LLM provider by configuring its API key.
If you are new to LLM providers, we recommend [OpenCode Zen](https://opencode.ai/docs/zen). It's a curated list of models Run `/connect` in the TUI and select your provider.
that have been tested and verified by the OpenCode team.
1. Run `/connect` in the TUI, select **opencode**, and open [opencode.ai/auth](https://opencode.ai/auth). ```text
/connect
```
```text If you'd like easy access to all the best coding models you can try out
/connect [OpenCode Console](https://console.opencode.ai).
```
2. Sign in, add your billing details, and copy your API key. You can also try [OpenCode Go](https://opencode.ai/go) a $10/month subscription
3. Paste your API key into the prompt. plan that grants you access to the best open source models.
Alternatively, select another provider. See the current [provider directory](https://opencode.ai/docs/providers#directory). See the current [provider directory](https://opencode.ai/docs/providers#directory).
--- ---
@ -130,54 +130,16 @@ How is authentication handled in @packages/functions/src/api/index.ts
### Add features ### Add features
For larger features, start by asking OpenCode to create a plan. Ask OpenCode to add a feature by describing the desired behavior and providing relevant context.
1. **Create a plan**
Switch to **Plan mode** with `Tab`. Plan mode prevents OpenCode from making changes while it proposes an implementation.
```text
<TAB>
```
Describe the feature with enough context to understand the desired behavior.
```text
When a user deletes a note, flag it as deleted in the database.
Create a screen that shows recently deleted notes.
From this screen, the user can restore a note or permanently delete it.
```
<Tip>Give OpenCode plenty of context and examples.</Tip>
2. **Iterate on the plan**
Give feedback or add more detail after OpenCode proposes a plan.
```text
Use the attached image as a visual reference for the new screen.
```
You can drag and drop images into the terminal to add them to your prompt.
3. **Build the feature**
When the plan looks right, press `Tab` to return to **Build mode** and ask OpenCode to implement it.
```text
Sounds good. Go ahead and make the changes.
```
### Make changes
For straightforward work, ask OpenCode to make the change directly and include relevant files and examples.
```text ```text
Add authentication to the /settings route. Follow the approach used in When a user deletes a note, flag it as deleted in the database.
@packages/functions/src/notes.ts and implement it in Create a screen that shows recently deleted notes.
@packages/functions/src/settings.ts. From this screen, the user can restore a note or permanently delete it.
``` ```
<Tip>Give OpenCode plenty of context and examples.</Tip>
### Undo changes ### Undo changes
Use `/undo` when a change isn't what you wanted. Use `/undo` when a change isn't what you wanted.

View file

@ -1 +0,0 @@
../sdk/openapi.json

26010
packages/docs/openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
---
title: "Plugins"
description: "Extend OpenCode with plugins."
---

View file

@ -0,0 +1,177 @@
---
title: "Troubleshooting"
description: "Diagnose OpenCode startup, server, and session issues."
---
<Tip>
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the
steps below, inspect its service and logs, and help identify the issue.
</Tip>
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
other application state. Start by determining whether an issue is in the client, the shared server, or a specific project.
## Check the background service
Show the current server status:
```bash
opencode2 service status
```
Verify that its API is healthy:
```bash
opencode2 api get /api/health
```
If the service is stuck or unhealthy, restart it:
```bash
opencode2 service restart
```
From inside the TUI, run `/reload` to restart the managed service and reconnect:
```text
/reload
```
You can also stop and start it explicitly:
```bash
opencode2 service stop
opencode2 service start
```
<Note>
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed
when diagnosing its lifecycle.
</Note>
## Run an isolated session
Use standalone mode to run the TUI with a private server that exits with it:
```bash
opencode2 --standalone
```
If an issue disappears in standalone mode, it is likely related to the shared background service rather than the TUI or
project itself.
## Inspect the API
The `api` command uses the same discovery and authentication flow as the TUI. It accepts either an HTTP method and path or
an OpenAPI operation ID.
See the [API reference](/api) for all endpoints and operation IDs.
Pass a JSON request body with `--data` or `-d`, and add headers with `--header` or `-H`.
<Warning>
Running `opencode2 api` may start the background service when no compatible healthy service is available.
</Warning>
## Read logs
Installed builds write logs to:
```text
~/.local/share/opencode/log/opencode.log
```
Follow the log while reproducing the problem:
```bash
tail -f ~/.local/share/opencode/log/opencode.log
```
Each line includes a process `run` ID and a `role` field. Use `role=cli` for TUI and command startup, and `role=server` for
session, provider, plugin, permission, and tool activity.
```bash
grep 'role=cli' ~/.local/share/opencode/log/opencode.log
grep 'role=server' ~/.local/share/opencode/log/opencode.log
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode.log
```
Increase verbosity for one reproduction:
```bash
OPENCODE_LOG_LEVEL=DEBUG opencode2
```
## Service files
The shared server registers itself at:
```text
~/.local/state/opencode/service.json
```
Its private service configuration is stored separately at:
```text
~/.config/opencode/service.json
```
The database normally lives at:
```text
~/.local/share/opencode/opencode-next.db
```
`OPENCODE_DB` can override the database location.
<Warning>
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon,
and make a backup before inspecting persistent data with external tools.
</Warning>
## Explicit servers
When connecting with `--server`, set `OPENCODE_PASSWORD` if the server requires authentication:
```bash
OPENCODE_PASSWORD=secret opencode2 --server http://127.0.0.1:4096
```
The CLI checks the server before opening the TUI and reports whether it is unreachable, requires a password, or rejected
the supplied password.
## Report an issue
Include the following when reporting a reproducible problem:
- Output from `opencode2 --version`
- Output from `opencode2 service status`
- The smallest sequence of steps that reproduces the issue
- Whether the issue also occurs with `opencode2 --standalone`
- Relevant log lines, including their `run` and `role` fields
Remove API keys, authorization headers, prompts, file contents, and other sensitive data before sharing logs.
## Local development
When working from the OpenCode repository, run V2 commands from the repository root:
```bash
bun dev
```
The local development channel keeps its logs, SQLite database, and service registration separate from installed builds:
```text
~/.local/share/opencode/log/opencode-local.log
~/.local/share/opencode/opencode-local.db
~/.local/state/opencode/service-local.json
```
Use the same diagnostics through the package development command:
```bash
bun dev service status
bun dev service restart
bun dev api get /api/health
```

View file

@ -4,17 +4,19 @@ import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js" import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const AgentGroup = HttpApiGroup.make("server.agent").add( export const AgentGroup = HttpApiGroup.make("server.agent")
HttpApiEndpoint.get("agent.list", "/api/agent", { .add(
query: LocationQuery, HttpApiEndpoint.get("agent.list", "/api/agent", {
success: Location.response(Schema.Array(Agent.Info)), query: LocationQuery,
}) success: Location.response(Schema.Array(Agent.Info)),
.annotateMerge(locationQueryOpenApi) })
.annotateMerge( .annotateMerge(locationQueryOpenApi)
OpenApi.annotations({ .annotateMerge(
identifier: "v2.agent.list", OpenApi.annotations({
summary: "List agents", identifier: "v2.agent.list",
description: "Retrieve currently registered agents.", summary: "List agents",
}), description: "Retrieve currently registered agents.",
), }),
) ),
)
.annotateMerge(OpenApi.annotations({ title: "agent" }))

View file

@ -21,7 +21,7 @@ export const CommandGroup = HttpApiGroup.make("server.command")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "commands", title: "command",
description: "Experimental command routes.", description: "Experimental command routes.",
}), }),
) )

View file

@ -20,6 +20,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "credential" }))
.add( .add(
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID }, params: { credentialID: Credential.ID },

View file

@ -2,14 +2,16 @@ import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const DebugGroup = HttpApiGroup.make("server.debug").add( export const DebugGroup = HttpApiGroup.make("server.debug")
HttpApiEndpoint.get("debug.location", "/api/debug/location", { .add(
success: Schema.Array(Location.Ref), HttpApiEndpoint.get("debug.location", "/api/debug/location", {
}).annotateMerge( success: Schema.Array(Location.Ref),
OpenApi.annotations({ }).annotateMerge(
identifier: "v2.debug.location", OpenApi.annotations({
summary: "List loaded locations", identifier: "v2.debug.location",
description: "List locations currently loaded by the server.", summary: "List loaded locations",
}), description: "List locations currently loaded by the server.",
), }),
) ),
)
.annotateMerge(OpenApi.annotations({ title: "debug" }))

View file

@ -55,7 +55,7 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream routes." })), .annotateMerge(OpenApi.annotations({ title: "event", description: "Experimental event stream routes." })),
} }
} }

View file

@ -144,4 +144,4 @@ export const makeFormGroup = <
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "forms", description: "Session form routes." })) .annotateMerge(OpenApi.annotations({ title: "form", description: "Session form routes." }))

View file

@ -1,14 +1,16 @@
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const HealthGroup = HttpApiGroup.make("server.health").add( export const HealthGroup = HttpApiGroup.make("server.health")
HttpApiEndpoint.get("health.get", "/api/health", { .add(
success: Schema.Struct({ healthy: Schema.Literal(true) }), HttpApiEndpoint.get("health.get", "/api/health", {
}).annotateMerge( success: Schema.Struct({ healthy: Schema.Literal(true) }),
OpenApi.annotations({ }).annotateMerge(
identifier: "v2.health.get", OpenApi.annotations({
summary: "Check server health", identifier: "v2.health.get",
description: "Check whether the API server is ready to accept requests.", summary: "Check server health",
}), description: "Check whether the API server is ready to accept requests.",
), }),
) ),
)
.annotateMerge(OpenApi.annotations({ title: "health" }))

View file

@ -126,5 +126,5 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
), ),
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }), OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }),
) )

View file

@ -26,17 +26,19 @@ export const locationQueryOpenApi = OpenApi.annotations({
}, },
}) })
export const LocationGroup = HttpApiGroup.make("server.location").add( export const LocationGroup = HttpApiGroup.make("server.location")
HttpApiEndpoint.get("location.get", "/api/location", { .add(
query: LocationQuery, HttpApiEndpoint.get("location.get", "/api/location", {
success: Location.Info, query: LocationQuery,
}) success: Location.Info,
.annotateMerge(locationQueryOpenApi) })
.annotateMerge( .annotateMerge(locationQueryOpenApi)
OpenApi.annotations({ .annotateMerge(
identifier: "v2.location.get", OpenApi.annotations({
summary: "Get location", identifier: "v2.location.get",
description: "Resolve the requested location or the server default location.", summary: "Get location",
}), description: "Resolve the requested location or the server default location.",
), }),
) ),
)
.annotateMerge(OpenApi.annotations({ title: "location" }))

View file

@ -51,7 +51,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "messages", title: "session",
description: "Experimental message routes.", description: "Experimental message routes.",
}), }),
) )

View file

@ -38,7 +38,7 @@ export const ModelGroup = HttpApiGroup.make("server.model")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "models", title: "model",
description: "Experimental model routes.", description: "Experimental model routes.",
}), }),
) )

View file

@ -134,4 +134,4 @@ export const makePermissionGroup = <
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." })) .annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))

View file

@ -21,7 +21,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "plugins", title: "plugin",
description: "Experimental plugin routes.", description: "Experimental plugin routes.",
}), }),
) )

View file

@ -1,10 +1,22 @@
import { Project } from "@opencode-ai/schema/project" import { Project } from "@opencode-ai/schema/project"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js" import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/project" const root = "/api/project"
export const ProjectGroup = HttpApiGroup.make("server.project") export const ProjectGroup = HttpApiGroup.make("server.project")
.add(
HttpApiEndpoint.get("project.list", root, {
success: Schema.Array(Project.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.project.list",
summary: "List projects",
description: "List known projects.",
}),
),
)
.add( .add(
HttpApiEndpoint.get("project.current", `${root}/current`, { HttpApiEndpoint.get("project.current", `${root}/current`, {
query: LocationQuery, query: LocationQuery,
@ -36,7 +48,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "projects", title: "project",
description: "Location-scoped project routes.", description: "Location-scoped project routes.",
}), }),
) )

View file

@ -39,7 +39,7 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "providers", title: "provider",
description: "Experimental provider routes.", description: "Experimental provider routes.",
}), }),
) )

View file

@ -30,7 +30,7 @@ export const makeQuestionGroup = <
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." })) .annotateMerge(OpenApi.annotations({ title: "question", description: "Experimental question routes." }))
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below. // Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
.middleware(locationMiddleware) .middleware(locationMiddleware)
.add( .add(
@ -80,5 +80,5 @@ export const makeQuestionGroup = <
), ),
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }), OpenApi.annotations({ title: "question", description: "Experimental session question routes." }),
) )

View file

@ -567,7 +567,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "sessions", title: "session",
description: "Experimental session routes.", description: "Experimental session routes.",
}), }),
) )

View file

@ -21,7 +21,7 @@ export const SkillGroup = HttpApiGroup.make("server.skill")
) )
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "skills", title: "skill",
description: "Experimental skill routes.", description: "Experimental skill routes.",
}), }),
) )

View file

@ -6,6 +6,7 @@ import { Api } from "../api"
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) => export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
handlers handlers
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.current", () => .handle("project.current", () =>
Location.Service.use((location) => Location.Service.use((location) =>
Effect.succeed({ id: location.project.id, directory: location.project.directory }), Effect.succeed({ id: location.project.id, directory: location.project.directory }),