feat(core): register built-in skill

This commit is contained in:
Dax Raad 2026-06-03 18:02:57 -04:00
commit 8a97cb55d3
18 changed files with 159 additions and 41 deletions

View file

@ -18,9 +18,14 @@ export const Plugin = PluginV2.define({
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => {
for (const directory of directories) {
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))

4
packages/core/src/markdown.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.md" {
const content: string
export default content
}

View file

@ -19,6 +19,7 @@ import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
@ -94,6 +95,7 @@ export const layer = Layer.effect(
yield* add(AccountPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
for (const item of ProviderPlugins) {
yield* add(item)
}

View file

@ -0,0 +1,30 @@
export * as SkillPlugin from "./skill"
import { Effect } from "effect"
import { PluginV2 } from "../plugin"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./skill/customize-opencode.md" with { type: "text" }
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("skill"),
effect: Effect.gen(function* () {
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
yield* transform((editor) => {
editor.source(
new SkillV2.EmbeddedSource({
type: "embedded",
skill: new SkillV2.Info({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
}),
}),
)
})
}),
})

View file

@ -1,6 +1,6 @@
<!--
Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (see CUSTOMIZE_OPENCODE_SKILL_NAME
packages/core/src/plugin/skill.ts
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
skill's content.
-->

View file

@ -21,17 +21,23 @@ export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
url: Schema.String,
}) {}
export const Source = Schema.Union([DirectorySource, UrlSource]).pipe(
export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.EmbeddedSource")({
type: Schema.Literal("embedded"),
skill: Schema.suspend(() => Info),
}) {}
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
Schema.toTaggedUnion("type"),
withStatics(() => ({
equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => {
equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
if (a.type !== b.type) return false
if (a.type === "directory" && b.type === "directory") return a.path === b.path
if (a.type === "url" && b.type === "url") return a.url === b.url
if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name
return false
},
key: (source: DirectorySource | UrlSource) =>
source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`,
key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
source.type === "directory" ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` : `embedded:${source.skill.name}`,
})),
)
export type Source = typeof Source.Type
@ -89,6 +95,7 @@ export const layer = Layer.effect(
const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") return [source.skill]
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
for (const directory of directories) {
const files = yield* fs

View file

@ -33,6 +33,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({
type: "document",
info: decode({
@ -56,6 +57,8 @@ describe("ConfigSkillPlugin.Plugin", () => {
)
expect(sources).toEqual([
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
new SkillV2.DirectorySource({
type: "directory",

View file

@ -0,0 +1,32 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { testEffect } from "../lib/effect"
const it = testEffect(
SkillV2.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(SkillDiscovery.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
),
)
describe("SkillPlugin.Plugin", () => {
it.effect("registers the built-in customize-opencode skill", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill))
expect(yield* skill.list()).toContainEqual(
expect.objectContaining({
name: "customize-opencode",
description: expect.stringContaining("opencode's own configuration"),
}),
)
}),
)
})

View file

@ -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({

View file

@ -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",

View file

@ -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 }
}

View file

@ -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({

View file

@ -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,
},
}
})
}),
)
}),

View file

@ -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(

View file

@ -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
),
)
}),
)
))
}),
)
}),

View file

@ -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" })

View file

@ -42,6 +42,22 @@ function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
function data(validate: (value: any) => void) {
return (body: any) => {
object(body)
validate(body.data)
}
}
function locationData(validate: (value: any) => void) {
return (body: any) => {
object(body)
object(body.location)
object(body.location.project)
validate(body.data)
}
}
const scenarios: Scenario[] = [
http.protected
.get("/global/health", "global.health")
@ -608,19 +624,19 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}),
),
http.protected.get("/api/model", "v2.model.list").json(200, array),
http.protected.get("/api/provider", "v2.provider.list").json(200, array),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected
.get("/api/fs/read", "v2.fs.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, object),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, array),
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, locationData(array)),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
@ -628,7 +644,7 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, array),
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
@ -641,7 +657,7 @@ const scenarios: Scenario[] = [
body: { reply: "once" },
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, data(array)),
http.protected
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
@ -653,8 +669,9 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
object(body.data)
array(body.data.items)
object(body.data.cursor)
},
"none",
),
@ -676,8 +693,9 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
object(body.data)
array(body.data.items)
object(body.data.cursor)
},
"none",
),
@ -698,8 +716,9 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
object(body.data)
array(body.data.items)
object(body.data.cursor)
},
"none",
),

View file

@ -388,8 +388,9 @@ describe("session HttpApi", () => {
yield* insertLegacyAssistantMessage(parent.id)
expect(
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
.items,
(yield* requestJson<{ data: { items: SessionMessage.Message[] } }>(`/api/session/${parent.id}/message`, {
headers,
})).data.items,
).toMatchObject([{ type: "assistant" }])
}),
{ git: true, config: { formatter: false, lsp: false } },
@ -453,7 +454,7 @@ describe("session HttpApi", () => {
})}`,
{ headers },
)
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
const sessionCursor = (yield* json<{ data: { cursor: { next?: string } } }>(sessionPage)).data.cursor.next
expect(sessionCursor).toBeTruthy()
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
order: "asc",
@ -480,7 +481,7 @@ describe("session HttpApi", () => {
})
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next
const messageCursor = (yield* json<{ data: { cursor: { next?: string } } }>(messagePage)).data.cursor.next
expect(messageCursor).toBeTruthy()
const messageCursorWithOrder = yield* request(