From c46999d8d94708e49b4262b78a1ec50075497d0b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:38:08 -0400 Subject: [PATCH 1/3] feat(skill): enable customize-opencode by default, point at full schema Flips OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL on for every channel; set the env var to false to opt out. The unstable-channel default helper had no other users, so it goes away. Adds a 'Full schema reference' section at the top of the skill so the model knows to fetch https://opencode.ai/config.json for any field the skill body does not cover, instead of guessing. --- packages/core/src/flag/flag.ts | 14 +++---------- .../src/skill/prompt/customize-opencode.md | 21 +++++++++++++++---- packages/opencode/test/preload.ts | 5 ++--- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3fe765575997..2584f1e5f389 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,5 +1,4 @@ import { Config } from "effect" -import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -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 @@ -56,9 +48,9 @@ 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"), + // Default-on for every channel. Set OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false + // to opt out. + OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: !falsy("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"], diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index 6158aae085ff..fede935b5305 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -7,10 +7,22 @@ # 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"` +## Full schema reference + +The authoritative list of every config option, with exact field types, enums, +defaults, and descriptions, lives in the published JSON Schema: + +**** + +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. + +Every `opencode.json` should also declare `"$schema": "https://opencode.ai/config.json"` so the user's editor catches mistakes as they type. ## Where files live @@ -343,7 +355,8 @@ 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 above, 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`. diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1ba0554d3ee4..8fb3ed518892 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -36,9 +36,8 @@ 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. +// customize-opencode skill is on by default. 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 From bb769badca6624afeb9cd5341e2f2010270d3724 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:49:18 -0400 Subject: [PATCH 2/3] docs(skill): add 'Applying changes' section + review polish Config is loaded once at startup and is not hot-reloaded, but the skill never said so. Add an 'Applying changes' section so the model will tell the user to quit and restart opencode after editing config, agents, skills, or plugins. Mirror that as a final bullet in 'When proposing edits'. While reviewing the file, fix a few smaller issues: - HTML comment referenced SKILL_NAME / SKILL_DESCRIPTION; the actual constants are CUSTOMIZE_OPENCODE_SKILL_NAME and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION. - Replace ambiguous 'or the field is not covered above' with 'in this skill' (the schema reference is at the top, not above the bullet). - Reword '$schema' paragraph from 'also' (mid-topic-shift) to 'Independently' for cleaner flow. - Tighten 'Full schema reference' intro with em-dashes. --- .../src/skill/prompt/customize-opencode.md | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index fede935b5305..744690b15a0f 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -1,7 +1,8 @@ # Customizing opencode @@ -12,8 +13,8 @@ is wrong. The shapes below cover the common surface area, but they are a ## Full schema reference -The authoritative list of every config option, with exact field types, enums, -defaults, and descriptions, lives in the published JSON Schema: +The authoritative list of every config option — with field types, enums, +defaults, and descriptions — lives in the published JSON Schema: **** @@ -22,8 +23,17 @@ 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. -Every `opencode.json` should also declare `"$schema": "https://opencode.ai/config.json"` -so the user's editor catches mistakes as they type. +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 @@ -355,13 +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, or the field is not covered above, fetch + 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. From b79cc05db28bda09529792ed884cf73b71f0f998 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 13:07:39 -0400 Subject: [PATCH 3/3] feat(skill): drop customize-opencode opt-out flag Always register the built-in customize-opencode skill. The previous commit flipped the default on for every channel; this removes the opt-out env var entirely. Users with a local skill of the same name still override the built-in via the existing 'register before disk discovery' ordering. Tests filter '' out of skill.all() results so disk-discovery assertions remain exact. --- packages/core/src/flag/flag.ts | 3 --- packages/opencode/src/skill/index.ts | 12 +++++------- packages/opencode/test/preload.ts | 4 ---- packages/opencode/test/skill/skill.test.ts | 20 ++++++++++---------- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 2584f1e5f389..175c723c5fd8 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -48,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 every channel. Set OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false - // to opt out. - OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: !falsy("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"], diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 01bffdb02adb..e532efa3d823 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -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: "", - content: CUSTOMIZE_OPENCODE_SKILL_BODY, - } + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 8fb3ed518892..b408f7ef11b8 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,10 +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 on by default. 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 diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index d73750b0831b..969014e6b384 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -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 !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "test-skill") expect(item).toBeDefined() @@ -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 !== "") expect(list.length).toBe(2) expect(list.find((x) => x.name === "skill-one")).toBeDefined() expect(list.find((x) => x.name === "skill-two")).toBeDefined() @@ -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 !== "")).toEqual([]) }), { git: true }, ), @@ -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 !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "manual-skill") expect(item).toBeDefined() @@ -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 !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "claude-skill") expect(item).toBeDefined() @@ -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 !== "") 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.") @@ -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 !== "")).toEqual([]) }), { git: true }, ), @@ -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 !== "") expect(list.length).toBe(1) const item = list.find((x) => x.name === "agent-skill") expect(item).toBeDefined() @@ -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 !== "") 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.") @@ -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 !== "") expect(list.length).toBe(2) expect(list.find((x) => x.name === "claude-skill")).toBeDefined() expect(list.find((x) => x.name === "agent-skill")).toBeDefined()