Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,23 @@ For local development setup, see the [Development Guide](https://chriswritescode
- **Skills** — Extend agent capabilities with shareable, scoped skill definitions
- **Notifications** — Push notifications for session events, questions, errors, and completions
- **Audio** — Text-to-speech and speech-to-text (browser native and OpenAI-compatible APIs)
- **Themes** — Light/dark/system appearance plus a color theme picker with the Manager default and 36 bundled OpenCode palettes
- **Mobile & PWA** — Responsive mobile-first UI, installable on any device, iOS-optimized

## Architecture

OpenCode Manager is a pnpm workspace with three TypeScript packages:
OpenCode Manager is a pnpm workspace with four TypeScript packages:

- `backend/` — Bun + Hono API server with Better Auth, SQLite migrations, OpenCode process management, SSE, schedules, and push notifications.
- `frontend/` — React + Vite SPA using React Router, TanStack Query, Radix UI/Tailwind, service worker support, and mobile-first navigation.
- `shared/` — shared Zod schemas, config helpers, types, and utilities consumed by both backend and frontend.
- `ocm-cli/` — `ocm` CLI that attaches your local OpenCode TUI to a repo hosted on the Manager.

A MkDocs Material site (`docs/`) provides guides, feature docs, configuration, and troubleshooting.

## Development

This repo uses pnpm workspaces for `shared`, `backend`, and `frontend`.
This repo uses pnpm workspaces for `shared`, `backend`, `frontend`, and `ocm-cli`.

```bash
pnpm install
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function createInternalRoutes(
app.route('/schedules', createScheduleRoutes(scheduleService))
app.route('/notifications', createInternalNotificationRoutes(notificationService))
app.route('/settings', createInternalSettingsRoutes(settingsService))
app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient))
app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true }))
const repos = new Hono()
repos.route('/', createInternalRepoRoutes(db, settingsService))
repos.route('/:id/schedules', createScheduleRoutes(scheduleService))
Expand Down
184 changes: 147 additions & 37 deletions backend/src/routes/opencode-config.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,63 @@
import { Hono } from 'hono'
import { Hono, type Context } from 'hono'
import { z } from 'zod'
import { UpdateOpenCodeConfigRequestSchema } from '@opencode-manager/shared/schemas'
import {
UpdateOpenCodeConfigPatchRequestSchema,
UpdateOpenCodeConfigRequestSchema,
} from '@opencode-manager/shared/schemas'
import { getWorkspacePath } from '@opencode-manager/shared/config/env'
import { ClientError, openCodeLocation } from '@opencode-manager/shared/opencode'
import {
ClientError,
mcpServerViewsFromConfig,
mcpStatusByName,
openCodeLocation,
type McpServerView,
type McpStatusMap,
} from '@opencode-manager/shared/opencode'
import type { SettingsService } from '../services/settings'
import type { OpenCodeClient } from '../services/opencode/client'
import {
OpenCodeConfigConflictError,
OpenCodeConfigRedactedValueError,
OpenCodeConfigShadowedRemovalError,
OpenCodeConfigSourceInvalidError,
readOpenCodeConfigFile,
withOpenCodeConfigLock,
} from '../services/opencode-config-file'
import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../services/opencode-config-apply'
import { redactOpenCodeConfigContent, redactOpenCodeConfigFile } from '../services/opencode-config-redact'
import { logger } from '../utils/logger'

export function createOpenCodeConfigRoutes(settingsService: SettingsService, openCodeClient: OpenCodeClient) {
interface OpenCodeConfigRoutesOptions {
redactSecrets?: boolean
}

async function readMcpStatus(openCodeClient: OpenCodeClient): Promise<McpStatusMap> {
try {
const servers = await openCodeClient.api.mcp.list(openCodeLocation(getWorkspacePath()))
return mcpStatusByName(servers.data)
} catch (error) {
logger.warn('Failed to read live MCP server status:', error)
return {}
}
}

function mergeMcpServerStatus(views: McpServerView[], status: McpStatusMap) {
return views.map((view) => {
const live = status[view.name]
if (!live) return view
return {
...view,
status: live.status,
...('error' in live && live.error ? { error: live.error } : {}),
}
})
}

export function createOpenCodeConfigRoutes(
settingsService: SettingsService,
openCodeClient: OpenCodeClient,
options: OpenCodeConfigRoutesOptions = {},
) {
const app = new Hono()

app.get('/', async (c) => {
Expand All @@ -24,7 +66,7 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
if (!config) {
return c.json({ error: 'No OpenCode config file found' }, 404)
}
return c.json(config)
return c.json(options.redactSecrets ? redactOpenCodeConfigFile(config) : config)
} catch (error) {
logger.error('Failed to get OpenCode config:', error)
return c.json({ error: 'Failed to get OpenCode config' }, 500)
Expand All @@ -34,7 +76,18 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
app.get('/effective', async (c) => {
try {
const entries = await openCodeClient.api.config.get(openCodeLocation(getWorkspacePath()))
return c.json({ entries })
return c.json({
entries: options.redactSecrets
? entries.map((entry) =>
entry.type === 'document'
? {
...entry,
info: redactOpenCodeConfigContent(entry.info).content as typeof entry.info,
}
: entry,
)
: entries,
})
} catch (error) {
logger.error('Failed to get effective OpenCode config:', error)
if (error instanceof ClientError) {
Expand All @@ -47,6 +100,23 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
}
})

app.get('/mcp', async (c) => {
try {
const [config, status] = await Promise.all([
withOpenCodeConfigLock(readOpenCodeConfigFile),
readMcpStatus(openCodeClient),
])
const views = mcpServerViewsFromConfig(config?.content.mcp)
return c.json({
revision: config?.revision ?? null,
servers: mergeMcpServerStatus(views, status),
})
} catch (error) {
logger.error('Failed to get OpenCode MCP servers:', error)
return c.json({ error: 'Failed to get OpenCode MCP servers' }, 500)
}
})

app.put('/', async (c) => {
let body: unknown
try {
Expand All @@ -60,40 +130,80 @@ export function createOpenCodeConfigRoutes(settingsService: SettingsService, ope
return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400)
}

return applyUpdate(c, {
content: parsed.data.content,
source: parsed.data.source,
expectedRevision: parsed.data.expectedRevision,
settingsService,
openCodeClient,
}, options.redactSecrets)
})

app.patch('/', async (c) => {
let body: unknown
try {
const result = await applyOpenCodeConfigUpdate({
content: parsed.data.content,
source: parsed.data.source,
expectedRevision: parsed.data.expectedRevision,
settingsService,
openCodeClient,
})
const { status, body: responseBody } = toOpenCodeConfigApplyResponse(result)
return c.json(responseBody, status)
} catch (error) {
logger.error('Failed to update OpenCode config:', error)
if (error instanceof OpenCodeConfigConflictError) {
return c.json({
error: error.message,
expectedRevision: error.expectedRevision,
actualRevision: error.actualRevision,
}, 409)
}
if (error instanceof OpenCodeConfigSourceInvalidError) {
return c.json({ error: error.message, sources: error.sources }, 400)
}
if (error instanceof OpenCodeConfigShadowedRemovalError) {
return c.json({ error: error.message, paths: error.paths, sources: error.sources }, 409)
}
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid config data', details: error.issues }, 400)
}
if (error instanceof SyntaxError) {
return c.json({ error: 'Invalid config data', details: error.message }, 400)
}
return c.json({ error: 'Failed to update OpenCode config' }, 500)
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON' }, 400)
}

const parsed = UpdateOpenCodeConfigPatchRequestSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400)
}

return applyUpdate(c, {
content: parsed.data.patch,
source: parsed.data.source,
expectedRevision: parsed.data.expectedRevision,
mode: 'merge',
settingsService,
openCodeClient,
}, options.redactSecrets)
})

return app
}

async function applyUpdate(
c: Context,
input: Parameters<typeof applyOpenCodeConfigUpdate>[0],
redactSecrets = false,
) {
try {
const result = await applyOpenCodeConfigUpdate(input)
const response = toOpenCodeConfigApplyResponse(result)
if (!redactSecrets) {
return c.json(response.body, response.status)
}
return c.json({
...redactOpenCodeConfigFile(result.config),
...(result.status === 'restart_pending' ? { restartRequired: true } : {}),
}, response.status)
} catch (error) {
logger.error('Failed to update OpenCode config:', error)
if (error instanceof OpenCodeConfigConflictError) {
return c.json({
error: error.message,
expectedRevision: error.expectedRevision,
actualRevision: error.actualRevision,
}, 409)
}
if (error instanceof OpenCodeConfigSourceInvalidError) {
return c.json({ error: error.message, sources: error.sources }, 400)
}
if (error instanceof OpenCodeConfigShadowedRemovalError) {
return c.json({ error: error.message, paths: error.paths, sources: error.sources }, 409)
}
if (error instanceof OpenCodeConfigRedactedValueError) {
return c.json({ error: error.message, paths: error.paths }, 400)
}
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid config data', details: error.issues }, 400)
}
if (error instanceof SyntaxError) {
return c.json({ error: 'Invalid config data', details: error.message }, 400)
}
return c.json({ error: 'Failed to update OpenCode config' }, 500)
}
}
63 changes: 45 additions & 18 deletions backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,19 +721,19 @@ The global configuration files on disk are the source of truth. Use the \`ocm\`

### GET /opencode-config

Read the merged persisted global configuration and its source files. Returns \`404\` when no source exists. This is not the running instance configuration: project overrides and expanded environment values are not included. \`GET /opencode-config/effective\` reads the running server's configuration separately as \`entries\`: the configuration documents and discovery directories in precedence order, lowest first, each shaped as \`{ type: 'document', path, info }\` or \`{ type: 'directory', path }\`. Its \`info\` values are expanded for the running server; never copy this response into a save.
Read the merged persisted global configuration and its source files. Returns \`404\` when no source exists. Secret values are replaced with \`<redacted>\` and the raw source text is omitted, so a read never returns credentials; \`redactedPaths\` lists the hidden paths. This is not the running instance configuration: project overrides and expanded environment values are not included. \`GET /opencode-config/effective\` reads the running server's configuration separately as \`entries\`: the configuration documents and discovery directories in precedence order, lowest first, each shaped as \`{ type: 'document', path, info }\` or \`{ type: 'directory', path }\`. Its \`info\` values are expanded for the running server, with secrets redacted; never copy this response into a save.

**Response (\`OpenCodeConfigFile\`):**
**Response:**
\`\`\`ts
{
path: string
content: object
rawContent: string
sources: Array<{ name: string, path: string, rawContent: string, content: object, isValid: boolean }>
revision: string
content: object // secrets replaced with "<redacted>"
isValid: boolean
validationIssues?: Array<{ path: string, message: string }>
updatedAt: number
sources: Array<{ name: string, path: string, content: object, isValid: boolean, validationIssues?: Array<{ path: string, message: string }>, updatedAt: number }>
revision: string
redactedPaths: string[]
}
\`\`\`

Expand All @@ -748,40 +748,66 @@ Read the merged persisted global configuration and its source files. Returns \`4
}
\`\`\`

### PUT /opencode-config
### PATCH /opencode-config

Read the merged persisted configuration first, change only the keys the user asked for, and send the complete object back with its revision. Only changed fields are patched into the preferred existing source: JSONC, then JSON. New installations use opencode.jsonc. Unchanged inherited values and comments are preserved. Removing a field removes only its override in the write target; a lower-priority value can reappear.
Change only the paths the user asked for. Send a nested \`patch\` object naming those paths and the values to set; every path you do not name is left untouched, and a \`null\` value removes a path. Only changed fields are patched into the preferred existing source: JSONC, then JSON. New installations use opencode.jsonc. Unchanged inherited values and comments are preserved.

For a raw edit, send a string with the exact source name from \`sources\`. Never send merged JSON as raw source text. A \`409\` means the source files changed: read again and reconcile rather than retrying stale content.
Send \`expectedRevision\` from a read. A \`409\` means the source files changed: read again and reconcile rather than retrying stale content.

**Request Body:**
\`\`\`ts
{ content: object | string, expectedRevision: string, source?: "opencode.json" | "opencode.jsonc" }
{ patch: object, expectedRevision?: string, source?: "opencode.json" | "opencode.jsonc" }
\`\`\`

**Example:**
**Example** — change one MCP server's bearer token without reading the file:
\`\`\`json
{
"action": "request",
"params": {
"method": "PUT",
"method": "PATCH",
"path": "/opencode-config",
"body": {
"expectedRevision": "revision-from-get",
"content": {
"theme": "dark"
"patch": {
"mcp": {
"servers": {
"linear": { "headers": { "Authorization": "Bearer <token>" } }
}
}
}
}
}
}
\`\`\`

**Response:**
Returns the refreshed merged configuration and source files. A semantic change is applied automatically: the Manager reloads OpenCode without restarting the server, so agents, permissions, providers, models, and plugins take effect on the next message and running sessions keep running. Changes limited to \`mcp\` are saved without a reload; to make an MCP change take effect immediately, tell the user to reconnect the server from Settings → MCP. Comment-only changes do nothing. Saving never silently drops unsupported fields.
Returns the refreshed merged configuration and source files, with secrets redacted. A semantic change is applied automatically: the Manager reloads OpenCode without restarting the server, so agents, permissions, providers, models, and plugins take effect on the next message and running sessions keep running. An \`mcp\` change is applied the same way, and only the servers whose configuration changed reconnect. Comment-only changes do nothing. Saving never silently drops unsupported fields.

The response adds \`restartRequired: true\` only when the automatic reload failed, for example because the OpenCode server is unavailable.

Returns \`400\` for invalid configuration and \`409\` for a stale revision.
Returns \`400\` for invalid configuration or a \`<redacted>\` value (\`paths\` lists them), and \`409\` for a stale revision.

### GET /opencode-config/mcp

List the configured MCP servers with their stored shape, enabled state, and live connection status. Use this to answer questions about MCP servers instead of reading the whole configuration. Header and environment values are never returned.

\`type\` is \`local\` or \`remote\`, and \`command\` or \`url\` is included for the matching type. \`enabled\` reflects the stored flag. \`shape\` is \`servers\` for a native \`mcp.servers.<name>\` entry or \`legacy\` for a flat \`mcp.<name>\` entry, so you can address the entry by the path it actually uses. \`status\` is the live OpenCode status (\`connected\`, \`pending\`, \`disabled\`, \`failed\`, or \`needs_auth\`) when the server is reachable, and \`error\` carries the failure reason.

**Response:**
\`\`\`ts
{
revision: string | null
servers: Array<{
name: string
type: 'local' | 'remote'
command?: string[]
url?: string
enabled: boolean
shape: 'servers' | 'legacy'
status?: 'connected' | 'pending' | 'disabled' | 'failed' | 'needs_auth'
error?: string
}>
}
\`\`\`

When the response contains \`restartRequired: true\`, tell the user to restart the OpenCode server from Settings. Never attempt the restart yourself: it would terminate your own session.

Expand All @@ -790,7 +816,8 @@ Only changes to how the OpenCode process is launched need a user restart from Se
## Safety

- The settings PATCH endpoint rejects any attempt to modify credentials, API keys, or other sensitive settings; guide the user to the full UI for Git, TTS, and STT credentials
- PUT /opencode-config patches changed global settings, including \`plugin\`, \`mcp\`, and \`provider\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request
- PATCH /opencode-config patches only the paths you name, including \`plugin\`, \`mcp\`, and \`provider\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request
- GET /opencode-config redacts secrets; never reconstruct a secret you did not read, and never send a \`<redacted>\` value back
- The settings PATCH endpoint does NOT trigger an OpenCode reload or restart
`
}
Expand Down
Loading
Loading