feat(core): register built-in skill
This commit is contained in:
@@ -5,6 +5,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
import { data } from "./response"
|
||||
|
||||
export const MessagesQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
@@ -29,13 +30,13 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
|
||||
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
|
||||
params: { sessionID: SessionID },
|
||||
query: MessagesQuery,
|
||||
success: Schema.Struct({
|
||||
success: data(Schema.Struct({
|
||||
items: Schema.Array(SessionMessage.Message),
|
||||
cursor: Schema.Struct({
|
||||
previous: Schema.String.pipe(Schema.optional),
|
||||
next: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionMessagesResponse" }),
|
||||
}).annotate({ identifier: "V2SessionMessagesResponse" })),
|
||||
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/un
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||
import { data } from "./response"
|
||||
|
||||
export const PermissionGroup = HttpApiGroup.make("v2.permission")
|
||||
.add(
|
||||
@@ -32,7 +33,7 @@ export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
|
||||
params: { sessionID: SessionV2.ID },
|
||||
success: Schema.Array(PermissionV2.Request),
|
||||
success: data(Schema.Array(PermissionV2.Request)),
|
||||
error: SessionNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -68,7 +69,7 @@ export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
|
||||
.add(
|
||||
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
|
||||
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
|
||||
success: Schema.Array(PermissionSaved.Info),
|
||||
success: data(Schema.Array(PermissionSaved.Info)),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.permission.saved.list",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export function data<S extends Schema.Top>(schema: S) {
|
||||
return Schema.Struct({ data: schema })
|
||||
}
|
||||
|
||||
export function make<A>(data: A) {
|
||||
return { data }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
|
||||
import { data } from "./response"
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
@@ -88,13 +89,13 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessions", "/api/session", {
|
||||
query: SessionsQuery,
|
||||
success: Schema.Struct({
|
||||
success: data(Schema.Struct({
|
||||
items: Schema.Array(SessionV2.Info),
|
||||
cursor: Schema.Struct({
|
||||
previous: SessionsCursor.pipe(Schema.optional),
|
||||
next: SessionsCursor.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionsResponse" }),
|
||||
}).annotate({ identifier: "V2SessionsResponse" })),
|
||||
error: [InvalidCursorError, InvalidRequestError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -113,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
prompt: Prompt,
|
||||
delivery: SessionV2.Delivery.pipe(Schema.optional),
|
||||
}),
|
||||
success: SessionMessage.Message,
|
||||
success: data(SessionMessage.Message),
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -155,7 +156,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Array(SessionMessage.Message),
|
||||
success: data(Schema.Array(SessionMessage.Message)),
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as DateTime from "effect/DateTime"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
import { make } from "../../groups/v2/response"
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
@@ -75,13 +76,13 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
|
||||
)
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
return make({
|
||||
items: messages,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
},
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
|
||||
import { response } from "../../groups/v2/location"
|
||||
import { make } from "../../groups/v2/response"
|
||||
|
||||
function missingRequest(id: PermissionV2.ID) {
|
||||
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
|
||||
@@ -62,7 +63,7 @@ export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "
|
||||
"sessionPermissionRequests",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
|
||||
permission.forSession(ctx.params.sessionID),
|
||||
permission.forSession(ctx.params.sessionID).pipe(Effect.map(make)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -92,7 +93,7 @@ export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2
|
||||
.handle(
|
||||
"savedPermissions",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* saved.list({ projectID: ctx.query.projectID })
|
||||
return make(yield* saved.list({ projectID: ctx.query.projectID }))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { SessionsCursor } from "../../groups/v2/session"
|
||||
import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
import { make } from "../../groups/v2/response"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -28,7 +29,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
})
|
||||
const first = sessions[0]
|
||||
const last = sessions.at(-1)
|
||||
return {
|
||||
return make({
|
||||
items: sessions,
|
||||
cursor: {
|
||||
previous: first
|
||||
@@ -52,13 +53,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session
|
||||
return make(yield* session
|
||||
.prompt({
|
||||
sessionID: ctx.params.sessionID,
|
||||
prompt: ctx.payload.prompt,
|
||||
@@ -81,7 +82,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
@@ -135,7 +136,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
.handle(
|
||||
"context",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session.context(ctx.params.sessionID).pipe(
|
||||
return make(yield* session.context(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
@@ -158,7 +159,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
|
||||
import CUSTOMIZE_OPENCODE_SKILL_BODY from "../../../core/src/plugin/skill/customize-opencode.md" with { type: "text" }
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
<!--
|
||||
Built-in skill. Name and description are registered in code at
|
||||
packages/opencode/src/skill/index.ts (see CUSTOMIZE_OPENCODE_SKILL_NAME
|
||||
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
|
||||
skill's content.
|
||||
-->
|
||||
|
||||
# Customizing opencode
|
||||
|
||||
opencode validates its own config strictly and refuses to start when a field
|
||||
is wrong. The shapes below cover the common surface area, but they are a
|
||||
**summary, not the source of truth**.
|
||||
|
||||
## Full schema reference
|
||||
|
||||
The authoritative list of every config option — with field types, enums,
|
||||
defaults, and descriptions — lives in the published JSON Schema:
|
||||
|
||||
**<https://opencode.ai/config.json>**
|
||||
|
||||
If a field is not documented in this skill, or you need to confirm an exact
|
||||
shape before writing config, **fetch that URL and read the schema directly**
|
||||
rather than guessing. opencode hard-fails on invalid config, so the cost of a
|
||||
wrong shape is a broken startup.
|
||||
|
||||
Independently, every `opencode.json` should declare
|
||||
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
|
||||
mistakes as they type.
|
||||
|
||||
## Applying changes
|
||||
|
||||
Config is loaded once when opencode starts and is not hot-reloaded. After
|
||||
saving changes to `opencode.json`, an agent file, a skill, a plugin, or any
|
||||
other config-time file, **tell the user to quit and restart opencode** for
|
||||
the changes to take effect. The running session will keep using the
|
||||
already-loaded config until then.
|
||||
|
||||
## Where files live
|
||||
|
||||
| Scope | Path |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
|
||||
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
|
||||
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
|
||||
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
|
||||
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
|
||||
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
|
||||
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
|
||||
|
||||
Configs from each scope are deep-merged. Project overrides global. Unknown
|
||||
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
|
||||
|
||||
## opencode.json
|
||||
|
||||
Every field is optional.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"username": "string",
|
||||
"model": "provider/model-id",
|
||||
"small_model": "provider/model-id",
|
||||
"default_agent": "agent-name",
|
||||
"shell": "/bin/zsh",
|
||||
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
|
||||
"share": "manual" | "auto" | "disabled",
|
||||
"autoupdate": true | false | "notify",
|
||||
"snapshot": true,
|
||||
"instructions": ["AGENTS.md", "docs/style.md"],
|
||||
|
||||
"skills": {
|
||||
"paths": [".opencode/skills", "/abs/path/to/skills"],
|
||||
"urls": ["https://example.com/.well-known/skills/"]
|
||||
},
|
||||
|
||||
"agent": {
|
||||
"my-agent": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"mode": "subagent",
|
||||
"description": "...",
|
||||
"permission": { "edit": "deny" }
|
||||
}
|
||||
},
|
||||
|
||||
"command": {
|
||||
"deploy": { "description": "...", "prompt": "..." }
|
||||
},
|
||||
|
||||
"provider": {
|
||||
"anthropic": { "options": { "apiKey": "..." } }
|
||||
},
|
||||
"disabled_providers": ["openai"],
|
||||
"enabled_providers": ["anthropic"],
|
||||
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp"],
|
||||
"enabled": true,
|
||||
"env": {}
|
||||
},
|
||||
"remote-thing": {
|
||||
"type": "remote",
|
||||
"url": "https://...",
|
||||
"headers": { "Authorization": "Bearer ..." }
|
||||
}
|
||||
},
|
||||
|
||||
"plugin": [
|
||||
"opencode-gemini-auth",
|
||||
"opencode-foo@1.2.3",
|
||||
"./local-plugin.ts",
|
||||
["opencode-bar", { "option": "value" }]
|
||||
],
|
||||
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": { "git *": "allow", "*": "ask" }
|
||||
},
|
||||
|
||||
"formatter": false,
|
||||
"lsp": false,
|
||||
|
||||
"experimental": {
|
||||
"primary_tools": ["edit"],
|
||||
"mcp_timeout": 30000
|
||||
},
|
||||
|
||||
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
|
||||
|
||||
"compaction": { "auto": true, "tail_turns": 15 }
|
||||
}
|
||||
```
|
||||
|
||||
Shape notes worth being explicit about:
|
||||
|
||||
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
|
||||
- `skills` is an object with `paths` and/or `urls`, not an array.
|
||||
- `agent` is an object keyed by agent name, not an array.
|
||||
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
|
||||
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
|
||||
- `permission` is either a string action or an object keyed by tool name.
|
||||
|
||||
## Skills
|
||||
|
||||
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
|
||||
file is named `SKILL.md` exactly, and lives in its own folder named after the
|
||||
skill:
|
||||
|
||||
```
|
||||
.opencode/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
(skill body in markdown: instructions, examples, references)
|
||||
```
|
||||
|
||||
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
|
||||
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
|
||||
- Optional: `license`, `compatibility`, `metadata` (string-string map).
|
||||
|
||||
Register skills from non-default locations via `skills.paths` (scanned
|
||||
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
|
||||
skills).
|
||||
|
||||
## Agents
|
||||
|
||||
Two ways to define an agent. Use the file form for anything non-trivial.
|
||||
|
||||
### Inline (in `opencode.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"my-reviewer": {
|
||||
"description": "Reviews PRs for style violations.",
|
||||
"mode": "subagent",
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"permission": { "edit": "deny", "bash": "ask" },
|
||||
"prompt": "You are a strict PR reviewer..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File
|
||||
|
||||
```
|
||||
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews PRs for style violations.
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
permission:
|
||||
edit: deny
|
||||
bash: ask
|
||||
---
|
||||
|
||||
You are a strict PR reviewer. Focus on...
|
||||
```
|
||||
|
||||
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
|
||||
frontmatter.
|
||||
|
||||
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
|
||||
|
||||
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
|
||||
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
|
||||
unknown field is silently routed into `options`.
|
||||
|
||||
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
|
||||
file, `disable: true` in frontmatter.
|
||||
|
||||
`default_agent` must point to a non-hidden, primary-mode agent.
|
||||
|
||||
### Built-in agents
|
||||
|
||||
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
|
||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||
same key in `agent: { <name>: { ... } }`.
|
||||
|
||||
## Plugins
|
||||
|
||||
`plugin:` is an array. Each entry is one of:
|
||||
|
||||
```json
|
||||
"plugin": [
|
||||
"opencode-gemini-auth", // npm spec, latest
|
||||
"opencode-foo@1.2.3", // npm spec, pinned
|
||||
"./local-plugin.ts", // file path, relative to the declaring config
|
||||
"file:///abs/path/plugin.js", // file URL
|
||||
["opencode-bar", { "key": "val" }] // tuple form with options
|
||||
]
|
||||
```
|
||||
|
||||
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
|
||||
`.opencode/plugin/` or `.opencode/plugins/`.
|
||||
|
||||
A plugin module exports `default` (or any named export) of type
|
||||
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
|
||||
function, not a plain object literal, and the function returns an object
|
||||
(return `{}` if there is nothing to register).
|
||||
|
||||
```ts
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default (async ({ client, project, directory, $ }) => {
|
||||
return {
|
||||
config: (cfg) => {
|
||||
// cfg is the live merged config; mutate fields here.
|
||||
},
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// mutate output.args before the tool runs
|
||||
},
|
||||
}
|
||||
}) satisfies Plugin
|
||||
```
|
||||
|
||||
Hook surface (mutate `output` in place; return `void`):
|
||||
|
||||
- `event(input)`: every bus event
|
||||
- `config(cfg)`: once on init with the merged config
|
||||
- `chat.message`, `chat.params`, `chat.headers`
|
||||
- `tool.execute.before`, `tool.execute.after`
|
||||
- `tool.definition`
|
||||
- `command.execute.before`
|
||||
- `shell.env`
|
||||
- `permission.ask`
|
||||
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
|
||||
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
|
||||
`experimental.text.complete`
|
||||
|
||||
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
|
||||
`auth: { ... }`, `provider: { ... }`.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`mcp:` is an object keyed by server name. Each server is discriminated by
|
||||
`type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp"],
|
||||
"enabled": true,
|
||||
"env": { "BROWSER": "chromium" }
|
||||
},
|
||||
"github": {
|
||||
"type": "remote",
|
||||
"url": "https://...",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
|
||||
},
|
||||
"old-server": { "enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`command` is an array of strings. `type` is required. Use `enabled: false` to
|
||||
disable a server inherited from a parent config.
|
||||
|
||||
## Permissions
|
||||
|
||||
```json
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
|
||||
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
|
||||
}
|
||||
```
|
||||
|
||||
Actions: `"allow"`, `"ask"`, `"deny"`.
|
||||
|
||||
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
|
||||
object `{ pattern: action }`. Within an object, **insertion order matters**.
|
||||
opencode evaluates the LAST matching rule, so put broad rules first and narrow
|
||||
rules last.
|
||||
|
||||
`permission: "allow"` (a string at the top level) is shorthand for "allow
|
||||
everything" and is rarely what the user wants.
|
||||
|
||||
Known permission keys: `read, edit, glob, grep, list, bash, task,
|
||||
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
|
||||
skill`. Some of these (`todowrite,
|
||||
question, webfetch, websearch, doom_loop`) only accept a flat
|
||||
action, not a per-pattern object.
|
||||
|
||||
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
|
||||
or globs like `~/projects/**`).
|
||||
|
||||
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
|
||||
the `plan` agent's permission ruleset (`edit: deny *`).
|
||||
|
||||
## Escape hatches
|
||||
|
||||
When a user's config is broken and opencode won't start, these env vars help:
|
||||
|
||||
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
and start from globals only. Run from the project directory, opencode loads,
|
||||
the user edits the broken file, then they restart without the flag.
|
||||
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
inject inline JSON as a final local-scope merge.
|
||||
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `OPENCODE_PURE=1`: skip external plugins entirely.
|
||||
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
`~/.claude/` and `~/.agents/`.
|
||||
|
||||
## When proposing edits
|
||||
|
||||
- Validate against the schema before writing. If you are unsure of a field's
|
||||
exact shape, or the field is not covered in this skill, fetch
|
||||
`https://opencode.ai/config.json` and read the schema rather than guessing.
|
||||
- Preserve `$schema` and any existing fields the user did not ask to change.
|
||||
- For agent, skill, and plugin definitions, prefer creating new files in the
|
||||
correct location over inlining everything in `opencode.json`.
|
||||
- If the user's existing config is malformed, point them at the env-var escape
|
||||
hatches above so they can edit from inside opencode without breaking their
|
||||
session.
|
||||
- After saving any config change, remind the user to quit and restart opencode
|
||||
— running sessions keep using the already-loaded config.
|
||||
Reference in New Issue
Block a user