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
11 changes: 0 additions & 11 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"

function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
Expand All @@ -11,13 +10,6 @@ function falsy(key: string) {
return value === "false" || value === "0"
}

// Channels where new experiments default to ON (unstable / internal users).
// Stable channels (`prod`, `latest`) stay opt-in.
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
function unstableDefault(key: string) {
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
}

function number(key: string) {
const value = process.env[key]
if (!value) return undefined
Expand Down Expand Up @@ -56,9 +48,6 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
// Default-on for dev/beta/local; opt-in for stable. Set
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
Expand Down
12 changes: 5 additions & 7 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,11 @@ export const layer = Layer.effect(
const s: State = { skills: {}, dirs: new Set() }
// Register the built-in skill BEFORE disk discovery so a user-disk
// skill with the same name can override it.
if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) {
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
location: "<built-in>",
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
}
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
location: "<built-in>",
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
}
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
return s
Expand Down
43 changes: 33 additions & 10 deletions packages/opencode/src/skill/prompt/customize-opencode.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,39 @@
<!--
Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (see SKILL_NAME and SKILL_DESCRIPTION).
The body below becomes the skill's content.
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 are the accepted shapes. When in doubt, fetch
`https://opencode.ai/config.json` (the JSON Schema) and validate against it.
is wrong. The shapes below cover the common surface area, but they are a
**summary, not the source of truth**.

Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"`
so the user's editor catches mistakes as they type.
## 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

Expand Down Expand Up @@ -343,12 +365,13 @@ When a user's config is broken and opencode won't start, these env vars help:
## When proposing edits

- Validate against the schema before writing. If you are unsure of a field's
exact shape, fetch `https://opencode.ai/config.json` rather than guessing.
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
hatch above so they can edit from inside opencode without breaking their
hatches above so they can edit from inside opencode without breaking their
session.
- opencode hard-fails on invalid config by design. There is no graceful
degradation, so get the shape right the first time.
- After saving any config change, remind the user to quit and restart opencode
— running sessions keep using the already-loaded config.
5 changes: 0 additions & 5 deletions packages/opencode/test/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
// Tests assert exact skill counts from disk discovery; the built-in
// customize-opencode skill is opt-in for stable channels and on by default
// for unstable channels (including "local" where CI runs). Disable it here
// so disk-discovery tests aren't off-by-one.
process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false"

// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
Expand Down
20 changes: 10 additions & 10 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Instructions here.
)

const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "test-skill")
expect(item).toBeDefined()
Expand Down Expand Up @@ -133,7 +133,7 @@ description: Second test skill.
)

const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
Expand All @@ -157,7 +157,7 @@ Just some content without YAML frontmatter.
)

const skill = yield* Skill.Service
expect(yield* skill.all()).toEqual([])
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
}),
{ git: true },
),
Expand All @@ -182,7 +182,7 @@ Instructions here.
)

const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "manual-skill")
expect(item).toBeDefined()
Expand Down Expand Up @@ -212,7 +212,7 @@ description: A skill in the .claude/skills directory.
)

const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "claude-skill")
expect(item).toBeDefined()
Expand All @@ -235,7 +235,7 @@ description: A skill in the .claude/skills directory.
yield* Effect.promise(() => createGlobalSkill(tmp.path))
yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-test-skill")
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
Expand All @@ -251,7 +251,7 @@ description: A skill in the .claude/skills directory.
() =>
Effect.gen(function* () {
const skill = yield* Skill.Service
expect(yield* skill.all()).toEqual([])
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
}),
{ git: true },
),
Expand All @@ -275,7 +275,7 @@ description: A skill in the .agents/skills directory.
)

const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "agent-skill")
expect(item).toBeDefined()
Expand Down Expand Up @@ -314,7 +314,7 @@ This skill is loaded from the global home directory.

yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-agent-skill")
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
Expand Down Expand Up @@ -355,7 +355,7 @@ description: A skill in the .agents/skills directory.
)

const skill = yield* Skill.Service
const list = yield* skill.all()
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
Expand Down
Loading