feat(core): add skill autoinvoke metadata

This commit is contained in:
Dax Raad
2026-07-01 15:13:40 -04:00
parent 9f1a8f2149
commit 716f6658db
7 changed files with 86 additions and 5 deletions
+16 -1
View File
@@ -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<Source>[]
}
@@ -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,
})
+3 -1
View File
@@ -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({
+40
View File
@@ -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()),
+9 -1
View File
@@ -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", () => {
"</available_skills>",
].join("\n"),
)
expect(initialized.baseline).not.toContain("manual")
skills = []
expect(