diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000000..4d6d3aef2cf1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## [Unreleased] — feat/on-demand-skill-picker + +### Added + +- `type: core | non-core` frontmatter field for SKILL.md files +- `skills.autoLoad` config option: `"all"` | `"core"` | `"none"` (default: `"all"`) +- Skill scanner: `scanAvailableSkills()` returning typed metadata +- Skill loader: `loadSkillContent()` for session-scoped preview +- Backend skill API group: `GET /skills`, `POST /skills/load` +- `/skills` slash command with SolidJS Dialog picker +- Core/Non-Core grouped sections in picker with typeahead filtering +- Session-loaded skill indicators (✓ marker) in picker list +- i18n strings for all new UI text + +### Changed + +- `session/system.ts`: skill injection now filtered by `autoLoad` + `type` + +### Backward Compatible + +- `autoLoad` defaults to `"all"` — zero behaviour change for existing users +- SKILL.md files with no `type` field default to `"non-core"` — no errors diff --git a/README.md b/README.md index b5a4c8ddd979..ca4dabaa3816 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,48 @@ Learn more about [agents](https://opencode.ai/docs/agents). For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs). +### Skills + +OpenCode uses skills — structured markdown files containing reusable instructions +that are injected into the system prompt. Skills are auto-loaded at session start, +but you can control this behaviour. + +#### autoLoad config + +Control which skills are injected into the system prompt at session start: + +```json +{ + "skills": { + "autoLoad": "core" + } +} +``` + +| Value | Behaviour | +|-------|-----------| +| `"all"` | All skills injected at startup (default — existing behaviour) | +| `"core"` | Only skills tagged `type: core` in their SKILL.md frontmatter | +| `"none"` | No skills injected; use /skills picker to load on demand | + +#### /skills picker + +Type `/skills` in the chat input to open the interactive skill picker. +Browse core and non-core skills, filter by typing, press Enter to load +a skill into the current session. + +#### Tagging a skill as core + +Add a `type` field to your skill's SKILL.md frontmatter: + +```yaml +--- +name: my-skill +type: core +description: Does X +--- +``` + ### Contributing If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request. diff --git a/docs/adr/002-on-demand-skill-loading.md b/docs/adr/002-on-demand-skill-loading.md new file mode 100644 index 000000000000..895f44e0725c --- /dev/null +++ b/docs/adr/002-on-demand-skill-loading.md @@ -0,0 +1,52 @@ +# ADR 002: On-Demand Skill Loading via /skills Command + +## Status + +Accepted + +## Context + +All skills were injected into the system prompt at every session start, +consuming context window tokens regardless of relevance. There was no way +to browse or selectively load a single skill mid-session. The only workaround +(an fzf picker) required an external terminal window — broken UX. + +## Decision + +- Added optional `type: core | non-core` field to SKILL.md frontmatter + (default: non-core — conservative, core is opt-in) +- Added `skills.autoLoad: "all" | "core" | "none"` config option + (default: "all" — full backward compatibility) +- Built /skills SolidJS Dialog picker using @opencode-ai/ui primitives, + matching the DialogSelectModel pattern +- Skills loaded via picker are session-scoped only — not persisted, + not injected into system prompt +- Backend skill API group: GET /skills, POST /skills/load + +## Alternatives Considered + +- **fzf in bash tool** — Rejected: no TTY available in OpenCode's bash tool, + keyboard input never reaches subprocess +- **Binary true/false autoLoad** — Rejected: too coarse, no middle ground + for users who want engineering skills but not domain-specific ones +- **External terminal picker** — Rejected: breaks single-window workflow + +## Consequences + +### Positive + +- Context window used only for skills relevant to the current task +- Users can discover skills they didn't know existed via the picker +- Backward compatible — existing users see zero behaviour change +- Core skills (engineering workflow) auto-load; domain skills on demand + +### Negative + +- Users must tag their SKILL.md files with `type: core` to use autoLoad: "core" +- The preview panel (width ≥ 120) is not yet implemented — deferred + +## References + +- skill-picker.tsx +- packages/core/src/v1/config/skills.ts +- packages/opencode/src/session/system.ts diff --git a/packages/app/src/components/skill-picker.tsx b/packages/app/src/components/skill-picker.tsx new file mode 100644 index 000000000000..e91a1791c52f --- /dev/null +++ b/packages/app/src/components/skill-picker.tsx @@ -0,0 +1,159 @@ +import { Component, createMemo, createResource, createSignal, Match, Show, Switch } from "solid-js" +import { useSDK } from "@/context/sdk" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" +import { makeEventListener } from "@solid-primitives/event-listener" + +interface SkillItem { + name: string + description?: string + type: "core" | "non-core" + loaded: boolean +} + +export const SkillPicker: Component = () => { + const sdk = useSDK() + const dialog = useDialog() + const language = useLanguage() + const [width, setWidth] = createSignal(window.innerWidth) + const [highlighted, setHighlighted] = createSignal() + + makeEventListener(window, "resize", () => setWidth(window.innerWidth)) + + const [skills, { refetch }] = createResource(async () => { + const res = await fetch(`${sdk().url}/skills?directory=${encodeURIComponent(sdk().directory)}`, { + headers: { "x-opencode-directory": encodeURIComponent(sdk().directory) }, + }) + if (!res.ok) return [] + return (await res.json()) as SkillItem[] + }) + + const coreSkills = createMemo(() => skills()?.filter((s) => s.type === "core") ?? []) + const nonCoreSkills = createMemo(() => skills()?.filter((s) => s.type === "non-core") ?? []) + + const handleLoad = async (name: string) => { + try { + const res = await fetch(`${sdk().url}/skills/load?directory=${encodeURIComponent(sdk().directory)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencode-directory": encodeURIComponent(sdk().directory), + }, + body: JSON.stringify({ name }), + }) + if (!res.ok) return + refetch() + } catch { + // ignore + } + } + + const [previewContent, setPreviewContent] = createSignal() + + let pendingPreview: string | undefined + const fetchPreview = (name: string | undefined) => { + pendingPreview = name + if (!name || !previewEnabled()) { + setPreviewContent(undefined) + return + } + void (async () => { + if (pendingPreview !== name) return + try { + const url = `${sdk().url}/skills/content?name=${encodeURIComponent(name)}&directory=${encodeURIComponent(sdk().directory)}` + const res = await fetch(url, { + headers: { "x-opencode-directory": encodeURIComponent(sdk().directory) }, + }) + if (pendingPreview !== name) return + if (!res.ok) return + const data = await res.json() + if (pendingPreview !== name) return + setPreviewContent(data.content.substring(0, 2000)) + } catch { + // ignore + } + })() + } + + const previewEnabled = createMemo(() => width() >= 120) + + const wideLayout = (content: any) => ( +
+
{content}
+ +
+ {language.t("command.skills.preview.hint")}
}> +
+ {highlighted()} +
+ + +
{language.t("common.loading")}
+
+ +
{previewContent()}
+
+
+
+
+ + + ) + + const listContent = ( + x.name} + items={skills() ?? []} + filterKeys={["name", "description"]} + groupBy={(x) => (x.type === "core" ? language.t("command.skills.group.core") : language.t("command.skills.group.nonCore"))} + sortGroupsBy={(a, b) => { + const order = [language.t("command.skills.group.core"), language.t("command.skills.group.nonCore")] + return order.indexOf(a.category) - order.indexOf(b.category) + }} + groupHeader={(group) => ( +
+ {group.category} + ({group.items.length}) +
+ )} + onSelect={(item: SkillItem | undefined) => { + if (!item) return + if (!item.loaded) handleLoad(item.name) + }} + onMove={(item: SkillItem | undefined) => { + const name = item?.name + setHighlighted(name) + fetchPreview(name) + }} + > + {(item: SkillItem) => ( +
+
+
+ {item.name} +
+ + {item.description} + +
+ + + +
+ )} +
+ ) + + return ( + + {wideLayout(listContent)} + + ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1ba0c3d230a0..5962fa48b010 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -12,6 +12,7 @@ export const dict = { "command.category.terminal": "Terminal", "command.category.model": "Model", "command.category.mcp": "MCP", + "command.category.skills": "Skills", "command.category.agent": "Agent", "command.category.permissions": "Permissions", "command.category.workspace": "Workspace", @@ -66,6 +67,13 @@ export const dict = { "command.model.choose.description": "Select a different model", "command.mcp.toggle": "Toggle MCPs", "command.mcp.toggle.description": "Toggle MCPs", + "command.skills.picker": "Skills Picker", + "command.skills.picker.description": "Browse and load available skills", + "command.skills.title": "Skills", + "command.skills.empty": "No skills found", + "command.skills.group.core": "Core Skills", + "command.skills.group.nonCore": "Non-Core Skills", + "command.skills.preview.hint": "Select a skill to preview", "command.agent.cycle": "Cycle agent", "command.agent.cycle.description": "Switch to the next agent", "command.agent.cycle.reverse": "Cycle agent backwards", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index e3f0c517df1f..b75c4d16deb8 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -118,6 +118,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const modelCommand = withCategory(language.t("command.category.model")) const mcpCommand = withCategory(language.t("command.category.mcp")) const agentCommand = withCategory(language.t("command.category.agent")) + const skillsCommand = withCategory(language.t("command.category.skills")) const permissionsCommand = withCategory(language.t("command.category.permissions")) const isAutoAcceptActive = () => { @@ -539,6 +540,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ] + const skillsCmds = () => [ + skillsCommand({ + id: "skills.picker", + title: language.t("command.skills.picker"), + description: language.t("command.skills.picker.description"), + slash: "skills", + onSelect: () => { + void import("@/components/skill-picker").then((x) => { + dialog.show(() => ) + }) + }, + }), + ] + const agentCmds = () => [ agentCommand({ id: "agent.cycle", @@ -581,6 +596,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ...messageCmds(), ...modelCmds(), ...mcpCmds(), + ...skillsCmds(), ...agentCmds(), ...permissionsCmds(), ]) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 620fdc8b9abb..7c89ac8e337b 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({ 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, commands, 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.", + "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: CustomizeOpencodeContent, }), diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 6932dbfd54cc..1c1cbdf3c296 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -43,8 +43,6 @@ already-loaded config until then. | Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | | Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | -| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | -| Global commands | `~/.config/opencode/command(s)/.md` | | Project skills | `.opencode/skill(s)//SKILL.md` | | Global skills | `~/.config/opencode/skill(s)//SKILL.md` | | External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | @@ -98,7 +96,7 @@ Every field is optional. }, "command": { - "deploy": { "description": "...", "template": "..." } + "deploy": { "description": "...", "prompt": "..." } }, "provider": { @@ -153,7 +151,6 @@ Shape notes worth being explicit about: - `skills` is an object with `paths` and/or `urls`, not an array. - `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. - `agent` is an object keyed by agent name, not an array. -- `command` is an object keyed by command name, not an array. - `plugin` is an array of strings or `[name, options]` tuples, not an object. - `mcp[name].command` is an array of strings, never a single string. `type` is required. - `permission` is either a string action or an object keyed by tool name. @@ -280,31 +277,6 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent `compaction`, `title`, `summary`. To override a built-in's fields, define the same key in `agent: { : { ... } }`. -## Commands - -opencode's command loader scans for `**/*.md` inside command directories. The -file is named after the command, and lives directly inside the `command` folder: - -``` -.opencode/command/deploy.md -``` - -Frontmatter: - -```markdown ---- -description: One sentence describing what the command does. -agent: build -model: anthropic/claude-sonnet-4-6 ---- - -(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input) -``` - -- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. -- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. -- Optional: `description`, `agent`, `model`, `variant`, `subtask`. - ## Plugins `plugin:` is an array. Each entry is one of: @@ -443,8 +415,8 @@ When a user's config is broken and opencode won't start, these env vars help: 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, command, skill, and plugin definitions, prefer creating new files - in the correct location over inlining everything in `opencode.json`. +- 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 hatches above so they can edit from inside opencode without breaking their session. diff --git a/packages/core/src/v1/config/skills.ts b/packages/core/src/v1/config/skills.ts index 9879634b4720..ef72ab2e33c6 100644 --- a/packages/core/src/v1/config/skills.ts +++ b/packages/core/src/v1/config/skills.ts @@ -9,5 +9,9 @@ export const Info = Schema.Struct({ urls: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", }), + autoLoad: Schema.optional(Schema.Literals(["all", "core", "none"])).annotate({ + description: + 'Control which skills are auto-loaded into the system prompt: "all" (default) loads all skills, "core" loads only core-tagged skills, "none" loads no skills at startup', + }), }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 60c410408434..b4846d45edc5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -18,6 +18,7 @@ import { ProviderApi } from "./groups/provider" import { PtyApi, PtyConnectApi } from "./groups/pty" import { QuestionApi } from "./groups/question" import { SessionApi } from "./groups/session" +import { SkillApi } from "./groups/skill" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" @@ -61,6 +62,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(PermissionApi) .addHttpApi(ProviderApi) .addHttpApi(SessionApi) + .addHttpApi(SkillApi) .addHttpApi(SyncApi) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/skill.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/skill.ts new file mode 100644 index 000000000000..2af638318c9d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/skill.ts @@ -0,0 +1,90 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" +import { described } from "./metadata" + +const SKILL_TYPES = ["core", "non-core"] as const + +export const SkillSnapshot = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + type: Schema.optional(Schema.Literals([...SKILL_TYPES])), + loaded: Schema.Boolean, +}) + +export const SkillListResponse = Schema.Array(SkillSnapshot) + +export const SkillContentQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + name: Schema.String, +}) + +export const SkillContentResponse = Schema.Struct({ + name: Schema.String, + content: Schema.String, +}) + +export const SkillLoadPayload = Schema.Struct({ + name: Schema.String, +}) + +export const SkillLoadResponse = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + content: Schema.String, + type: Schema.optional(Schema.Literals([...SKILL_TYPES])), + loaded: Schema.Literal(true as const), +}) + +export const SkillPaths = { + list: "/skills", + content: "/skills/content", + load: "/skills/load", +} as const + +export const SkillApi = HttpApi.make("skill") + .add( + HttpApiGroup.make("skill") + .add( + HttpApiEndpoint.get("list", SkillPaths.list, { + query: WorkspaceRoutingQuery, + success: described(SkillListResponse, "List of available skills"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "skill.list", + summary: "List available skills", + description: "Returns all available skills with their type and loaded status.", + }), + ), + HttpApiEndpoint.get("content", SkillPaths.content, { + query: SkillContentQuery, + success: described(SkillContentResponse, "Skill content preview"), + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "skill.content", + summary: "Get skill content preview", + description: "Returns the first portion of a skill's SKILL.md content without loading it into the session.", + }), + ), + HttpApiEndpoint.post("load", SkillPaths.load, { + query: WorkspaceRoutingQuery, + payload: SkillLoadPayload, + success: described(SkillLoadResponse, "Skill loaded successfully"), + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "skill.load", + summary: "Load a skill", + description: "Load a skill into the current session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "skill", description: "Skill management routes." })) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/skill.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/skill.ts new file mode 100644 index 000000000000..2e6d4cd866ec --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/skill.ts @@ -0,0 +1,45 @@ +import { Skill } from "@/skill" +import { loadSkillContent } from "@/skill/loader" +import { scanAvailableSkills } from "@/skill/scanner" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { ApiNotFoundError } from "../errors" +import { SkillContentQuery, SkillLoadPayload } from "../groups/skill" + +export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "skill", (handlers) => + Effect.gen(function* () { + const list = () => + Effect.gen(function* () { + return yield* scanAvailableSkills() + }) + + const content = (ctx: { query: typeof SkillContentQuery.Type }) => + Effect.gen(function* () { + const content = yield* loadSkillContent(ctx.query.name).pipe( + Effect.catchTag("Skill.NotFoundError", (error) => + Effect.fail(new ApiNotFoundError({ name: "NotFoundError", data: { message: error.message } })), + ), + ) + return { name: ctx.query.name, content } + }) + + const load = (ctx: { payload: typeof SkillLoadPayload.Type }) => + Effect.gen(function* () { + const skill = yield* Skill.Service + const info = yield* skill.require(ctx.payload.name).pipe( + Effect.catchTag("Skill.NotFoundError", (error) => + Effect.fail(new ApiNotFoundError({ name: "NotFoundError", data: { message: error.message } })), + ), + ) + yield* skill.loadIntoSession(ctx.payload.name).pipe( + Effect.catchTag("Skill.NotFoundError", (error) => + Effect.fail(new ApiNotFoundError({ name: "NotFoundError", data: { message: error.message } })), + ), + ) + return { name: info.name, description: info.description, content: info.content, type: info.type, loaded: true as const } + }) + + return handlers.handle("list", list).handle("content", content).handle("load", load) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 09e25de8cb89..172dc491cd89 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -92,6 +92,7 @@ import { providerHandlers } from "./handlers/provider" import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty" import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" +import { skillHandlers } from "./handlers/skill" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { handlers } from "@opencode-ai/server/handlers" @@ -156,6 +157,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( permissionHandlers, providerHandlers, sessionHandlers, + skillHandlers, syncHandlers, tuiHandlers, workspaceHandlers, diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 74401779d353..4cca2c97c557 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -16,6 +16,7 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { Config } from "@/config/config" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" @@ -49,6 +50,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const skill = yield* Skill.Service + const config = yield* Config.Service const locations = yield* LocationServiceMap return Service.of({ @@ -94,7 +96,15 @@ export const layer = Layer.effect( skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) { if (Permission.disabled(["skill"], agent.permission).has("skill")) return - const list = yield* skill.available(agent) + let list = yield* skill.available(agent) + const cfg = yield* config.get() + const autoLoad = cfg.skills?.autoLoad ?? "all" + + if (autoLoad === "core") { + list = list.filter((s) => s.type === "core") + } else if (autoLoad === "none") { + list = [] + } return [ "Skills provide specialized instructions and workflows for specific tasks.", @@ -108,10 +118,14 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(LocationServiceMap.layer)) +export const defaultLayer = layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(LocationServiceMap.layer), +) const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, []) -export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode]) +export const node = LayerNode.make(layer, [Skill.node, Config.node, locationServiceMapNode]) export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b8bd6bef6e11..a566f0e2e8d5 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -34,9 +34,13 @@ const CUSTOMIZE_OPENCODE_SKILL_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." const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent +const SKILL_TYPES = ["core", "non-core"] as const +export type SkillType = (typeof SKILL_TYPES)[number] + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), + type: Schema.optional(Schema.Literals([...SKILL_TYPES])), location: Schema.String, content: Schema.String, }) @@ -50,7 +54,7 @@ const Issue = Schema.StructWithRest( [Schema.Record(Schema.String, Schema.Unknown)], ) -function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string; type?: string } { return ( isRecord(data) && typeof data.name === "string" && @@ -82,6 +86,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ski type State = { skills: Record dirs: Set + loadedSkills: Set } type DiscoveryState = { @@ -100,6 +105,8 @@ export interface Interface { readonly all: () => Effect.Effect readonly dirs: () => Effect.Effect readonly available: (agent?: Agent.Info) => Effect.Effect + readonly loadIntoSession: (name: string) => Effect.Effect + readonly isLoaded: (name: string) => Effect.Effect } const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { @@ -131,9 +138,19 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev } state.dirs.add(path.dirname(match)) + + if (md.data.type !== undefined && md.data.type !== "core" && md.data.type !== "non-core") { + yield* Effect.logWarning("invalid skill type, defaulting to non-core", { + skill: md.data.name, + type: md.data.type, + }) + } + const skillType: SkillType = md.data.type === "core" ? "core" : "non-core" + state.skills[md.data.name] = { name: md.data.name, description: md.data.description, + type: skillType, location: match, content: md.content, } @@ -272,12 +289,13 @@ export const layer = Layer.effect( ) const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { - const s: State = { skills: {}, dirs: new Set() } + const s: State = { skills: {}, dirs: new Set(), loadedSkills: new Set() } // Register the built-in skill BEFORE disk discovery so a user-disk // skill with the same name can override it. s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { name: CUSTOMIZE_OPENCODE_SKILL_NAME, description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + type: "core", location: "", content: CUSTOMIZE_OPENCODE_SKILL_BODY, } @@ -314,7 +332,20 @@ export const layer = Layer.effect( return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") }) - return Service.of({ get, require, all, dirs, available }) + const loadIntoSession = Effect.fn("Skill.loadIntoSession")(function* (name: string) { + const info = yield* require(name) + const s = yield* InstanceState.get(state) + if (s.loadedSkills.has(name)) return info.content + s.loadedSkills.add(name) + return info.content + }) + + const isLoaded = Effect.fn("Skill.isLoaded")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.loadedSkills.has(name) + }) + + return Service.of({ get, require, all, dirs, available, loadIntoSession, isLoaded }) }), ) diff --git a/packages/opencode/src/skill/loader.ts b/packages/opencode/src/skill/loader.ts new file mode 100644 index 000000000000..78e0d19637d8 --- /dev/null +++ b/packages/opencode/src/skill/loader.ts @@ -0,0 +1,8 @@ +import { Effect } from "effect" +import { Skill } from "." + +export const loadSkillContent = Effect.fn("SkillLoader.loadContent")(function* (name: string) { + const skill = yield* Skill.Service + const info = yield* skill.require(name) + return info.content +}) diff --git a/packages/opencode/src/skill/scanner.ts b/packages/opencode/src/skill/scanner.ts new file mode 100644 index 000000000000..6726499bc08b --- /dev/null +++ b/packages/opencode/src/skill/scanner.ts @@ -0,0 +1,28 @@ +import { Effect } from "effect" +import { Skill } from "." + +export interface SkillMeta { + name: string + type: "core" | "non-core" + description: string | undefined + path: string + loaded: boolean +} + +export const scanAvailableSkills = Effect.fn("SkillScanner.scanAvailable")(function* () { + const skill = yield* Skill.Service + const items = yield* skill.all() + const metas: SkillMeta[] = [] + + for (const item of items) { + metas.push({ + name: item.name, + type: item.type ?? "non-core", + description: item.description, + path: item.location, + loaded: yield* skill.isLoaded(item.name), + }) + } + + return metas +}) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 69cec7bdcca7..a09562b9f94e 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -3,6 +3,7 @@ import { Effect, Layer } from "effect" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" +import { Config } from "../../src/config/config" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { LocationServiceMap } from "@opencode-ai/core/location-layer" @@ -12,23 +13,27 @@ const skills: Skill.Info[] = [ { name: "zeta-skill", description: "Zeta skill.", + type: "non-core", location: "/tmp/zeta-skill/SKILL.md", content: "# zeta-skill", }, { name: "alpha-skill", description: "Alpha skill.", + type: "core", location: "/tmp/alpha-skill/SKILL.md", content: "# alpha-skill", }, { name: "middle-skill", description: "Middle skill.", + type: "non-core", location: "/tmp/middle-skill/SKILL.md", content: "# middle-skill", }, { name: "manual-skill", + type: "non-core", location: "/tmp/manual-skill/SKILL.md", content: "# manual-skill", }, @@ -41,27 +46,49 @@ const build: Agent.Info = { options: {}, } -const it = testEffect( - SystemPrompt.layer.pipe( +function makeLayer(skillsConfig?: { autoLoad?: "all" | "core" | "none" }) { + const configInfo = skillsConfig ? { skills: skillsConfig } : {} + return SystemPrompt.layer.pipe( Layer.provide(LocationServiceMap.layer), Layer.provide( Layer.succeed( Skill.Service, Skill.Service.of({ - get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)), + get: (name) => Effect.succeed(skills.find((s) => s.name === name)), require: (name) => { - const info = skills.find((skill) => skill.name === name) + const info = skills.find((s) => s.name === name) if (info) return Effect.succeed(info) - return Effect.fail(new Skill.NotFoundError({ name, available: skills.map((skill) => skill.name) })) + return Effect.fail(new Skill.NotFoundError({ name, available: skills.map((s) => s.name) })) }, all: () => Effect.succeed(skills), dirs: () => Effect.succeed([]), available: () => Effect.succeed(skills), + loadIntoSession: () => Effect.succeed(""), + isLoaded: () => Effect.succeed(false), }), ), ), - ), -) + Layer.provide( + Layer.succeed( + Config.Service, + Config.Service.of({ + get: () => Effect.succeed(configInfo), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed({ consoleManagedProviders: [], activeOrgName: undefined, switchableOrgCount: 0 }), + update: () => Effect.void, + updateGlobal: () => Effect.succeed({ info: {}, changed: false }), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + }), + ), + ), + ) +} + +const it = testEffect(makeLayer()) +const itCore = testEffect(makeLayer({ autoLoad: "core" })) +const itNone = testEffect(makeLayer({ autoLoad: "none" })) describe("session.system", () => { it.effect("skills output is sorted by name and stable across calls", () => @@ -83,4 +110,52 @@ describe("session.system", () => { expect(output).not.toContain("manual-skill") }), ) + + it.effect("autoLoad all includes all skills with descriptions", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = (yield* prompt.skills(build))! + expect(output).toContain("alpha-skill") + expect(output).toContain("middle-skill") + expect(output).toContain("zeta-skill") + expect(output).not.toContain("manual-skill") + }), + ) + + itCore.effect("autoLoad core includes only core-tagged skills", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = (yield* prompt.skills(build))! + expect(output).toContain("alpha-skill") + expect(output).not.toContain("middle-skill") + expect(output).not.toContain("zeta-skill") + }), + ) + + itCore.effect("autoLoad core returns undefined when no core skills match agent", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.skills({ ...build, permission: Permission.fromConfig({ "*": "deny" }) }) + expect(output).toBeUndefined() + }), + ) + + itNone.effect("autoLoad none includes no skills", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = (yield* prompt.skills(build))! + expect(output).not.toContain("") + expect(output).toContain("No skills are currently available.") + }), + ) + + it.effect("autoLoad missing config defaults to all", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = (yield* prompt.skills(build))! + expect(output).toContain("alpha-skill") + expect(output).toContain("middle-skill") + expect(output).toContain("zeta-skill") + }), + ) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index fd79a68cee21..ed6888649e31 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" +import { scanAvailableSkills } from "../../src/skill/scanner" +import { loadSkillContent } from "../../src/skill/loader" import { Discovery } from "../../src/skill/discovery" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -280,6 +282,147 @@ description: A skill in the .claude/skills directory. }), ) + it.live("parses skill with type: core frontmatter", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "core-skill", "SKILL.md"), + `--- +name: core-skill +type: core +description: A core skill. +--- + +# Core Skill +`, + ), + ) + + const skill = yield* Skill.Service + const item = (yield* skill.all()).find((s) => s.name === "core-skill") + expect(item).toBeDefined() + expect(item!.type).toBe("core") + }), + { git: true }, + ), + ) + + it.live("parses skill with type: non-core frontmatter", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "noncore-skill", "SKILL.md"), + `--- +name: noncore-skill +type: non-core +description: A non-core skill. +--- + +# Non-Core Skill +`, + ), + ) + + const skill = yield* Skill.Service + const item = (yield* skill.all()).find((s) => s.name === "noncore-skill") + expect(item).toBeDefined() + expect(item!.type).toBe("non-core") + }), + { git: true }, + ), + ) + + it.live("defaults to non-core when type is absent from frontmatter", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "untyped-skill", "SKILL.md"), + `--- +name: untyped-skill +description: A skill without type. +--- + +# Untyped Skill +`, + ), + ) + + const skill = yield* Skill.Service + const item = (yield* skill.all()).find((s) => s.name === "untyped-skill") + expect(item).toBeDefined() + expect(item!.type).toBe("non-core") + }), + { git: true }, + ), + ) + + it.live("defaults to non-core when type has invalid value", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "bad-skill", "SKILL.md"), + `--- +name: bad-skill +type: invalid +description: A skill with bad type. +--- + +# Bad Skill +`, + ), + ) + + const skill = yield* Skill.Service + const item = (yield* skill.all()).find((s) => s.name === "bad-skill") + expect(item).toBeDefined() + expect(item!.type).toBe("non-core") + }), + { git: true }, + ), + ) + + it.live("built-in customize-opencode skill has type: core", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const skill = yield* Skill.Service + const item = yield* skill.get("customize-opencode") + expect(item).toBeDefined() + expect(item!.type).toBe("core") + }), + { git: true }, + ), + ) + + it.live("type field does not break skills without frontmatter", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"), + `# No Frontmatter + +Just content. +`, + ), + ) + + const skill = yield* Skill.Service + expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) + }), + { git: true }, + ), + ) + it.live("returns empty array when no skills exist", () => provideTmpdirInstance( () => @@ -568,4 +711,116 @@ description: A skill in the .opencode/skills directory. { git: true }, ), ) + + it.live("scanAvailableSkills returns metadata for all skills", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "scan-skill", "SKILL.md"), + `--- +name: scan-skill +type: core +description: A scanned skill. +--- + +# Scan Skill +`, + ), + ) + + const metas = yield* scanAvailableSkills() + const scanSkill = metas.find((m) => m.name === "scan-skill") + expect(scanSkill).toBeDefined() + expect(scanSkill!.type).toBe("core") + expect(scanSkill!.description).toBe("A scanned skill.") + expect(scanSkill!.loaded).toBe(false) + }), + { git: true }, + ), + ) + + it.live("scanAvailableSkills shows loaded status after loadIntoSession", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "loadable", "SKILL.md"), + `--- +name: loadable +type: non-core +description: A loadable skill. +--- + +# Loadable Skill +`, + ), + ) + + const skill = yield* Skill.Service + yield* skill.loadIntoSession("loadable") + const metas = yield* scanAvailableSkills() + const loaded = metas.find((m) => m.name === "loadable") + expect(loaded).toBeDefined() + expect(loaded!.loaded).toBe(true) + }), + { git: true }, + ), + ) + + it.live("loadIntoSession is idempotent (duplicate load is no-op)", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "dup-skill", "SKILL.md"), + `--- +name: dup-skill +description: A duplicate skill. +--- + +# Dup Skill +`, + ), + ) + + const skill = yield* Skill.Service + const first = yield* skill.loadIntoSession("dup-skill") + const second = yield* skill.loadIntoSession("dup-skill") + expect(first).toBe(second) + }), + { git: true }, + ), + ) + + it.live("loadSkillContent returns skill content without marking as loaded", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "preview-skill", "SKILL.md"), + `--- +name: preview-skill +description: A skill for preview. +--- + +# Preview Skill +`, + ), + ) + + const content = yield* loadSkillContent("preview-skill") + expect(content).toContain("# Preview Skill") + + const skill = yield* Skill.Service + const loaded = yield* skill.isLoaded("preview-skill") + expect(loaded).toBe(false) + }), + { git: true }, + ), + ) })