From 716f6658db4754186605949757872f638660c192 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 15:13:40 -0400 Subject: [PATCH] feat(core): add skill autoinvoke metadata --- packages/client/src/generated/types.ts | 1 + packages/core/src/skill.ts | 17 +++++++++- packages/core/src/skill/guidance.ts | 4 ++- packages/core/test/skill.test.ts | 40 +++++++++++++++++++++++ packages/core/test/skill/guidance.test.ts | 10 +++++- packages/schema/src/skill.ts | 1 + packages/web/src/content/docs/skills.mdx | 18 ++++++++-- 7 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 4873a9c455..d2e68c13b5 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2803,6 +2803,7 @@ export type SkillListOutput = { readonly name: string readonly description?: string readonly slash?: boolean + readonly autoinvoke?: boolean readonly location: string readonly content: string }> diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index eeafe60885..93c48b0b07 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -38,9 +38,23 @@ const Frontmatter = Schema.Struct({ name: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional), slash: Schema.Boolean.pipe(Schema.optional), + metadata: Schema.Unknown.pipe(Schema.optional), }) const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) +const metadataBoolean = (metadata: unknown, key: string) => { + if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { + return undefined + } + const value = (metadata as { readonly [key: string]: unknown })[key] + if (typeof value === "boolean") return value + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "true") return true + if (normalized === "false") return false + return undefined +} + export type Data = { sources: Types.DeepMutable[] } @@ -108,7 +122,8 @@ export const layer = Layer.effect( skills.push({ name, description: frontmatter.description, - slash: frontmatter.slash, + slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash, + autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"), location: AbsolutePath.make(filepath), content: markdown.content, }) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 4f4be39e39..9fe41d6641 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -51,7 +51,9 @@ export const layer = Layer.effect( return SystemContext.empty const available = permitted .flatMap((skill) => - skill.description === undefined ? [] : [{ name: skill.name, description: skill.description }], + skill.description === undefined || skill.autoinvoke === false + ? [] + : [{ name: skill.name, description: skill.description }], ) .toSorted((a, b) => a.name.localeCompare(b.name)) return SystemContext.make({ diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 7b662b388d..f65752395d 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -140,6 +140,46 @@ describe("SkillV2", () => { ), ) + it.live("parses opencode metadata flags from skill frontmatter", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true }) + await fs.writeFile( + path.join(tmp.path, "manual", "SKILL.md"), + `--- +name: manual +description: Manual only +metadata: + opencode/slash: true + opencode/autoinvoke: false +--- +# manual`, + ) + }) + + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) + + expect(yield* skill.list()).toEqual([ + { + name: "manual", + description: "Manual only", + slash: true, + autoinvoke: false, + location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")), + content: "# manual", + }, + ]) + }), + ), + ), + ) + it.live("invalidates cached skills and publishes updates for watcher changes", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 4ebd0e4574..5a93ac4e5d 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -26,6 +26,13 @@ const denied = SkillV2.Info.make({ location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), content: "Denied guidance", }) +const manual = SkillV2.Info.make({ + name: "manual", + description: "Load only when explicitly selected", + autoinvoke: false, + location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")), + content: "Manual guidance", +}) const layer = (list: () => SkillV2.Info[]) => SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) }))) @@ -36,7 +43,7 @@ describe("SkillGuidance", () => { ...AgentV2.Info.empty(build), permissions: [{ action: "skill", resource: "denied", effect: "deny" }], }) - let skills = [hidden, denied, effect] + let skills = [hidden, denied, manual, effect] return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service const initialized = yield* guidance @@ -55,6 +62,7 @@ describe("SkillGuidance", () => { "", ].join("\n"), ) + expect(initialized.baseline).not.toContain("manual") skills = [] expect( diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index 30099b7a90..bf0dd7aa2b 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -22,6 +22,7 @@ export const Info = Schema.Struct({ name: Schema.String, description: Schema.String.pipe(optional), slash: Schema.Boolean.pipe(optional), + autoinvoke: Schema.Boolean.pipe(optional), location: AbsolutePath, content: Schema.String, }).annotate({ identifier: "SkillV2.Info" }) diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 2ce88ea568..baddbec284 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -34,7 +34,7 @@ Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, ## Write frontmatter Each `SKILL.md` must start with YAML frontmatter. -Only these fields are recognized: +Use these Agent Skills fields: - `name` (required) - `description` (required) @@ -42,7 +42,15 @@ Only these fields are recognized: - `compatibility` (optional) - `metadata` (optional, string-to-string map) -Unknown frontmatter fields are ignored. +Put OpenCode-specific behavior in `metadata` instead of adding custom top-level fields. + +OpenCode recognizes these optional `metadata` keys: + +- `opencode/slash`: set to `"true"` to expose the skill as a `/name` command in the TUI +- `opencode/autoinvoke`: set to `"false"` to keep the skill out of model-facing auto-selection guidance + +The Agent Skills spec defines `metadata` values as strings, so quoted values are the portable form. +OpenCode also accepts YAML booleans like `opencode/slash: true` and `opencode/autoinvoke: false`. --- @@ -84,6 +92,8 @@ compatibility: opencode metadata: audience: maintainers workflow: github + opencode/slash: "true" + opencode/autoinvoke: "false" --- ## What I do @@ -120,6 +130,9 @@ The agent loads a skill by calling the tool: skill({ name: "git-release" }) ``` +Skills with `metadata["opencode/autoinvoke"]` set to `"false"` are not included in ``. +They can still be selected manually and loaded by exact name. + --- ## Configure permissions @@ -220,3 +233,4 @@ If a skill does not show up: 2. Check that frontmatter includes `name` and `description` 3. Ensure skill names are unique across all locations 4. Check permissions—skills with `deny` are hidden from agents +5. Check `metadata["opencode/autoinvoke"]`—skills set to `"false"` are hidden from model-facing guidance