diff --git a/apps/local/package.json b/apps/local/package.json index 2d89e733b..d2fcef81b 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -32,6 +32,7 @@ "@executor/plugin-mcp": "workspace:*", "@executor/plugin-onepassword": "workspace:*", "@executor/plugin-openapi": "workspace:*", + "@executor/plugin-skills": "workspace:*", "@executor/react": "workspace:*", "@executor/runtime-quickjs": "workspace:*", "@executor/sdk": "workspace:*", diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 4e9d1d248..63ed8e28a 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -37,6 +37,7 @@ import { graphqlPlugin } from "@executor/plugin-graphql"; import { keychainPlugin } from "@executor/plugin-keychain"; import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; import { onepasswordPlugin } from "@executor/plugin-onepassword"; +import { skillsPlugin } from "@executor/plugin-skills"; // In dev mode the drizzle folder sits next to the source tree. In a compiled // binary the files are inlined via the build-time gen module below, and we @@ -101,6 +102,10 @@ const createLocalPlugins = (configFile: ConfigFileSink) => keychainPlugin(), fileSecretsPlugin(), onepasswordPlugin(), + // Global / cross-cutting skills slot. Per-source skills (like the + // openapi playbook) are declared by their owning plugin under its + // own sourceId — see notes/skills.md. + skillsPlugin(), ] as const; type LocalPlugins = ReturnType; diff --git a/bun.lock b/bun.lock index 8371d8f52..75d4851f5 100644 --- a/bun.lock +++ b/bun.lock @@ -140,6 +140,7 @@ "@executor/plugin-mcp": "workspace:*", "@executor/plugin-onepassword": "workspace:*", "@executor/plugin-openapi": "workspace:*", + "@executor/plugin-skills": "workspace:*", "@executor/react": "workspace:*", "@executor/runtime-quickjs": "workspace:*", "@executor/sdk": "workspace:*", @@ -699,6 +700,7 @@ "@effect/platform-node": "catalog:", "@executor/config": "workspace:*", "@executor/plugin-oauth2": "workspace:*", + "@executor/plugin-skills": "workspace:*", "@executor/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", @@ -732,6 +734,21 @@ "react", ], }, + "packages/plugins/skills": { + "name": "@executor/plugin-skills", + "version": "0.0.1", + "dependencies": { + "@executor/sdk": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + }, "packages/plugins/workos-vault": { "name": "@executor/plugin-workos-vault", "version": "0.0.2", @@ -1263,6 +1280,8 @@ "@executor/plugin-openapi": ["@executor/plugin-openapi@workspace:packages/plugins/openapi"], + "@executor/plugin-skills": ["@executor/plugin-skills@workspace:packages/plugins/skills"], + "@executor/plugin-workos-vault": ["@executor/plugin-workos-vault@workspace:packages/plugins/workos-vault"], "@executor/react": ["@executor/react@workspace:packages/react"], diff --git a/notes/skills.md b/notes/skills.md new file mode 100644 index 000000000..c8c7188b6 --- /dev/null +++ b/notes/skills.md @@ -0,0 +1,144 @@ +# Skills + +Skills are documentation for agents — a markdown body plus a name and a +description, loaded on demand instead of living in every system prompt. +The goal is progressive disclosure: agents search a catalog, pull the +skill they need, follow its instructions to chain tools. + +## Where we are today + +`@executor/plugin-skills` exposes skills as static tools whose handler +returns the body. Discovery goes through the normal +`tools.list({ query })`; loading goes through `tools.invoke(id)`. No new +primitive. That shape is fine as a v1 because it costs nothing and is +forward-compatible with every client that speaks tools. + +The naming-as-attachment convention (skill id `.`, +description prefixed `Skill:`) made substring queries land the skill +next to related real tools. It's a stopgap — a real attachment point +arrives when skills move off the global plugin (below). + +## The tension the convention was papering over + +Global skills and per-source skills are different concepts. Today the +plugin lumps both into one flat source id `skills`. But `openapi.adding- +a-source` is semantically owned by the openapi source — it describes +that source's tools. It should live under the same source id as the +tools it documents, so `tools.list({ sourceId: "openapi" })` returns +its own documentation alongside its operations. + +The per-source case will also dominate in practice. Anyone shipping a +Cloudflare / Linear / Stripe integration will ship skills alongside +their tools, not in a shared global bucket. MCP is standardizing on +exactly this. + +## What MCP is doing + +The first-class-primitive draft (SEP-2076: `skills/list`, `skills/get`) +was **closed 2026-02-24**. Author pivoted to the alternative. The live +direction is maintained by the Skills Over MCP Working Group (promoted +from Interest Group on 2026-04-16, charter +[here](https://modelcontextprotocol.io/community/skills-over-mcp/charter)). +Their docs are mirrored in `.references/experimental-ext-skills/`. + +Accepted decisions so far: + +- **Skills are Resources, not a new primitive.** Discovery via the + existing `resources/list`. Addressable URIs, not opaque names. +- **URI scheme is `skill:///SKILL.md`.** Sub-resources + (reference docs, examples) are siblings under the same path. Four + independent implementations converged on `skill://` before the WG + formalized it — NimbleBrain, skilljack-mcp, skills-over-mcp, + FastMCP 3.0. +- **Name and path are decoupled.** The path locates; the + `SKILL.md` frontmatter `name` identifies. A skill at + `skill://acme/billing/refunds/SKILL.md` can be named `refund- + handling` in its frontmatter. +- **Instructor format only.** Markdown content, not executable code. + Skills that need to execute local code use existing distribution + mechanisms (npx, repos) and are explicitly out of scope for + MCP-served skills. +- **Skill semantics live in frontmatter.** `version`, `allowed-tools`, + `invocation` — all in SKILL.md YAML, not in MCP `_meta`. `_meta` is + reserved for transport-specific concerns with no natural home + elsewhere. +- **Clients get a helper for loading.** Rather than every server + shipping a `load_skill` tool, clients get a built-in `read_resource` + affordance or SDK-level `list_skill_uris()`. + +This informs our SDK design because our static-tool system is +effectively in-process MCP. Whatever shape MCP lands on, ours should +match 1:1 so the MCP-source adapter passes skills through without +translation. + +## Short-term move + +Keep skills-as-tools internally (no churn), but stop flattening +per-source skills into the global `skills` source: + +1. Each plugin that ships skills owns them. `openapiSkills` moves from + "exported from `@executor/plugin-openapi` to be re-wired in + `apps/local`" to "registered directly by the openapi plugin in its + own `staticSources`." +2. `@executor/plugin-skills` exports a small `toStaticSkill(skill): + StaticToolDecl` helper — three lines of shared code so plugins don't + reimplement the `Skill:` prefix + empty schema + `Effect.succeed(body)` + boilerplate. +3. `skillsPlugin({ skills: [...] })` stays wired in `apps/local` as the + home for **cross-cutting / user-authored** skills that don't belong + to any specific source. Today it's empty. + +After the move, `tools.list({ sourceId: "openapi" })` returns +`openapi.previewSpec`, `openapi.addSource`, `openapi.adding-a-source` — +the skill is literally next to the tools, without relying on substring +search ranking. The naming-as-attachment convention becomes a natural +consequence of the sourceId, not a convention. + +## Longer-term refactor (deferred until SEP stabilizes) + +Grow `StaticSource` to carry a sibling `skills` field next to `tools`: + +```ts +interface StaticSource { + id: string; + name: string; + kind: "control" | "data"; + tools: StaticToolDecl[]; + skills?: StaticSkillDecl[]; // future +} +``` + +`StaticSkillDecl` shape tracks the WG's output — at minimum URI +(`skill:////SKILL.md`), frontmatter (name, +description, version, allowed-tools), body. The MCP-source adapter +converts `resources/list` filtered to `skill://` into +`StaticSkillDecl[]` 1:1. Our own plugins declare them directly. + +At that point the `skillsPlugin` becomes "a plugin that exposes one +static source whose `skills` array is user-authored" — not a special +concept, just another source. + +Not building this now because: + +- The Skills Extension SEP is still in active drafting (WG formed + four days ago as of writing). +- Sub-resource URI shape is still being refined (see + `.references/experimental-ext-skills/skill-uri-scheme.md` PR notes + about multi-segment paths and path-name decoupling). +- We don't have a Resources system in core today. Adding one to track + a moving SEP is bad timing. + +When the SEP lands: rename "static tool with markdown body" → +"static skill," add the URI, thread it through the MCP adapter. The +attachment point is already correct by then, so it's a shape change +inside the same place. + +## Secret capture is still open + +The openapi skill currently forbids the agent from accepting secret +values in chat (see `packages/plugins/openapi/src/sdk/skills.ts`). The +user provisions values out of band. That's the safe rule, but it +leaves the UX half-built — we don't have a first-class "ask the user +for a secret, route it past the model" flow. MCP elicitation gives us +a mechanism but the UI wiring hasn't been done. Worth its own note +when we get there. diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 5f9154442..40e7ac175 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -51,6 +51,7 @@ "@effect/platform-node": "catalog:", "@executor/config": "workspace:*", "@executor/plugin-oauth2": "workspace:*", + "@executor/plugin-skills": "workspace:*", "@executor/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index 1798bf964..5f181e731 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -51,6 +51,8 @@ export { OpenApiOAuthError, } from "./errors"; +export { openapiSkills } from "./skills"; + export { ExtractedOperation, ExtractionResult, diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 763aa601d..f44f18851 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -452,8 +452,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { const remaining = yield* executor.tools.list(); const ids = remaining.map((t) => t.id).sort(); + // The openapi plugin also ships skills under its own sourceId + // (see notes/skills.md). ASCII sort puts uppercase `addSource` + // before lowercase `adding-a-source`. expect(ids).toEqual([ "openapi.addSource", + "openapi.adding-a-source", "openapi.previewSpec", ]); }), diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 333bb49b1..bc6cd76cf 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -47,6 +47,8 @@ import { } from "./invoke"; import { resolveBaseUrl } from "./openapi-utils"; import { previewSpec, SpecPreview } from "./preview"; +import { toStaticSkill } from "@executor/plugin-skills"; +import { openapiSkills } from "./skills"; import { makeDefaultOpenapiStore, openapiSchema, @@ -915,6 +917,11 @@ export const openApiPlugin = definePlugin( scope: ctx.scopes.at(-1)!.id as string, }), }, + // Skills ship under the same sourceId as the tools they + // document so `tools.list({ sourceId: "openapi" })` returns + // the playbook next to the operations. toStaticSkill keeps + // the on-the-wire shape identical to the global skillsPlugin. + ...openapiSkills.map(toStaticSkill), ], }, ], diff --git a/packages/plugins/openapi/src/sdk/skills.test.ts b/packages/plugins/openapi/src/sdk/skills.test.ts new file mode 100644 index 000000000..55ffeb8eb --- /dev/null +++ b/packages/plugins/openapi/src/sdk/skills.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createExecutor, makeTestConfig } from "@executor/sdk"; + +import { openApiPlugin } from "./plugin"; + +// The openapi plugin ships its own skills under its own sourceId — +// NOT via the global skillsPlugin. These tests pin that attachment: +// `tools.list({ sourceId: "openapi" })` returns the playbook +// alongside the operations, no naming-convention substring trick +// needed. See notes/skills.md for why. + +describe("openapi-owned skills", () => { + it.effect("the playbook lives under sourceId `openapi`, next to previewSpec/addSource", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [openApiPlugin()] as const }), + ); + + const tools = yield* executor.tools.list({ sourceId: "openapi" }); + const ids = tools.map((t) => t.id).sort(); + + // ASCII order: uppercase S in `addSource` sorts before lowercase + // i in `adding-a-source`. + expect(ids).toEqual([ + "openapi.addSource", + "openapi.adding-a-source", + "openapi.previewSpec", + ]); + }), + ); + + it.effect("skill description uses the `Skill:` prefix shared across skill helpers", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [openApiPlugin()] as const }), + ); + + const tools = yield* executor.tools.list({ sourceId: "openapi" }); + const skill = tools.find((t) => t.id === "openapi.adding-a-source"); + expect(skill?.description.startsWith("Skill: ")).toBe(true); + }), + ); + + it.effect("invoking the skill returns markdown that references the real tools", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [openApiPlugin()] as const }), + ); + + const body = (yield* executor.tools.invoke( + "openapi.adding-a-source", + {}, + )) as string; + + // If a tool gets renamed, the skill goes stale — catch it here. + expect(body).toContain("openapi.previewSpec"); + expect(body).toContain("openapi.addSource"); + }), + ); + + // Pins the "no secret values in chat" policy. If a future edit + // reintroduces the old `secrets.set` instruction, this fails — + // that pattern routes user-typed secrets through the LLM context. + it.effect("skill body never tells the agent to secrets.set a user-typed value", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [openApiPlugin()] as const }), + ); + + const body = (yield* executor.tools.invoke( + "openapi.adding-a-source", + {}, + )) as string; + + expect(body).not.toContain("store it via `secrets.set`"); + expect(body).toContain("Never accept a secret value in-chat"); + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/skills.ts b/packages/plugins/openapi/src/sdk/skills.ts new file mode 100644 index 000000000..0753d35f4 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/skills.ts @@ -0,0 +1,81 @@ +import type { Skill } from "@executor/plugin-skills"; + +// Skills shipped by the OpenAPI plugin. Registered into the plugin's +// own `staticSources` (see `plugin.ts`) — NOT passed to the global +// `skillsPlugin`. Living under sourceId `openapi` means the full tool +// id is `openapi.`, right next to `openapi.previewSpec` etc. +// Skill ids therefore omit any `openapi.` prefix; the source id is the +// attachment point. +export const openapiSkills: readonly Skill[] = [ + { + id: "adding-a-source", + description: + "How to add an OpenAPI spec as a source — preview, resolve auth, then addSpec", + body: `# Adding an OpenAPI source + +The full flow to register an OpenAPI document as a source on the current +executor. Three tools, called in order. + +## 1. Preview the spec + +Call \`openapi.previewSpec\` with the raw spec string (JSON or YAML). You +get back: + +- the operations that will be registered as tools +- any security schemes declared in the spec (API key, bearer, OAuth2, …) +- the resolved server base URL + +**Why this step:** the preview tells you whether the spec needs +credentials, and which scheme to use. Do not skip it — \`addSpec\` will +fail at invoke time if required auth isn't wired. + +## 2. Reference authentication + +**Never accept a secret value in-chat.** If an API key, bearer token, +client secret, or password ends up in your context window, it is leaked +— you cannot unsee it, and you must not call \`secrets.set\` with a value +the user typed to you. Your job here is to pick the ids the secrets +will live under and reference them by id in step 3. The user provisions +the actual values out of band (UI / CLI). + +Look at the preview's \`securitySchemes\`: + +- **API key / bearer** — pick a descriptive id like + \`\${namespace}-api-key\`. Reference it from \`headers\` in step 3. Tell + the user to add the secret manually under that id; do not ask them to + paste it. +- **OAuth2 (authorization code)** — call \`openapi.startOAuth\` with the + spec and the scheme name. It returns a URL the user opens in the + browser; the token is captured by \`openapi.completeOAuth\` without + ever passing through you. +- **OAuth2 (client credentials)** — pick ids for the client id and + client secret. Reference them in step 3; the invoker mints access + tokens on demand. Same rule: do not accept the values in-chat. +- **No auth declared** — skip straight to step 3. + +## 3. Register the source + +Call \`openapi.addSource\` with: + +- \`spec\` — the same spec string from step 1 +- \`namespace\` — short slug used as the source id (e.g. \`"linear"\`) +- \`baseUrl\` — optional override of the spec's server URL +- \`headers\` — optional static headers, with secret references + (\`{ "Authorization": { "$secret": "linear-api-key", format: "Bearer {}" } }\`) + +On success you get \`{ sourceId, toolCount }\`. Every operation becomes a +tool under \`.\`, listable via +\`tools.list({ sourceId: namespace })\`. + +## Common mistakes + +- Calling \`addSpec\` before \`previewSpec\` — you'll miss required auth + schemes and invocations will 401 later. +- Accepting the secret value from the user in chat and calling + \`secrets.set\` with it. The value is now in your context. Use the id + the user provisions out of band; never handle the raw secret. +- Passing the spec URL instead of the spec string — \`addSpec\` expects + the document body, not a URL. Fetch it first. +`, + }, +]; diff --git a/packages/plugins/skills/package.json b/packages/plugins/skills/package.json new file mode 100644 index 000000000..070b9ec09 --- /dev/null +++ b/packages/plugins/skills/package.json @@ -0,0 +1,57 @@ +{ + "name": "@executor/plugin-skills", + "version": "0.0.1", + "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/skills", + "bugs": { + "url": "https://github.com/RhysSullivan/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RhysSullivan/executor.git", + "directory": "packages/plugins/skills" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./promise": "./src/promise.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/promise.d.ts", + "default": "./dist/index.js" + } + }, + "./core": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/core.js" + } + } + } + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor/sdk": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/plugins/skills/src/index.test.ts b/packages/plugins/skills/src/index.test.ts new file mode 100644 index 000000000..3c161ec5d --- /dev/null +++ b/packages/plugins/skills/src/index.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createExecutor, makeTestConfig } from "@executor/sdk"; + +import { skillsPlugin, type Skill } from "./index"; + +// These tests double as executable documentation for the "skills are +// tools" UX. Everything an agent does with a skill goes through the +// normal tool catalog API: `tools.list({ query })` to discover, +// `tools.invoke(id)` to load the body. If this file ever stops reading +// like the agent-facing flow, the plugin has drifted. + +const sampleSkills: readonly Skill[] = [ + { + id: "demo.hello", + description: "Say hello to the world", + body: "# Hello\n\nThis is the hello skill body.", + }, + { + id: "demo.setup-auth", + description: "How to wire API key auth for the demo API", + body: "# Setup\n\n1. Store the key as a secret.\n2. Reference it from headers.", + }, +]; + +describe("skillsPlugin — agent discovery UX", () => { + it.effect("skills show up in tools.list under the `skills` source", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [skillsPlugin({ skills: sampleSkills })] as const, + }), + ); + + // Filtering by sourceId is the "give me only skills" query. + const tools = yield* executor.tools.list({ sourceId: "skills" }); + const ids = tools.map((t) => t.id).sort(); + + expect(ids).toEqual(["skills.demo.hello", "skills.demo.setup-auth"]); + }), + ); + + it.effect("descriptions are prefixed `Skill: ` so agents can tell them apart", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [skillsPlugin({ skills: sampleSkills })] as const, + }), + ); + + const [tool] = yield* executor.tools.list({ + sourceId: "skills", + query: "hello", + }); + expect(tool.description).toBe("Skill: Say hello to the world"); + }), + ); + + it.effect("query matches against skill id AND description", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [skillsPlugin({ skills: sampleSkills })] as const, + }), + ); + + // Match via the description ("auth" isn't in the id directly, but is + // close — "setup-auth" contains it. Let's pick a word only in the + // description to make sure description text is indexed.) + const byDescription = yield* executor.tools.list({ query: "wire" }); + expect(byDescription.map((t) => t.id)).toEqual(["skills.demo.setup-auth"]); + + const byId = yield* executor.tools.list({ query: "hello" }); + expect(byId.map((t) => t.id)).toEqual(["skills.demo.hello"]); + }), + ); + + it.effect("invoking a skill returns its markdown body verbatim", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [skillsPlugin({ skills: sampleSkills })] as const, + }), + ); + + const body = yield* executor.tools.invoke("skills.demo.hello", {}); + expect(body).toBe("# Hello\n\nThis is the hello skill body."); + }), + ); + + it.effect("skill handlers take no input (empty object schema)", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [skillsPlugin({ skills: sampleSkills })] as const, + }), + ); + + const schema = yield* executor.tools.schema("skills.demo.hello"); + expect(schema?.inputSchema).toEqual({ + type: "object", + properties: {}, + additionalProperties: false, + }); + }), + ); + + it.effect("plugin works when no skills are registered", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [skillsPlugin()] as const }), + ); + + const tools = yield* executor.tools.list({ sourceId: "skills" }); + expect(tools).toEqual([]); + }), + ); +}); + +describe("skillsPlugin — registration errors", () => { + it("rejects duplicate skill ids at plugin construction time", () => { + expect(() => + skillsPlugin({ + skills: [ + { id: "dup", description: "a", body: "first" }, + { id: "dup", description: "b", body: "second" }, + ], + }), + ).toThrow(/Duplicate skill id: dup/); + }); +}); + +describe("skillsPlugin — extension surface", () => { + it.effect("exposes the raw skill list via executor.skills.skills", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [skillsPlugin({ skills: sampleSkills })] as const, + }), + ); + + expect(executor.skills.skills).toEqual(sampleSkills); + }), + ); +}); diff --git a/packages/plugins/skills/src/index.ts b/packages/plugins/skills/src/index.ts new file mode 100644 index 000000000..a20483465 --- /dev/null +++ b/packages/plugins/skills/src/index.ts @@ -0,0 +1,78 @@ +import { Effect } from "effect"; + +import { definePlugin, type StaticToolDecl } from "@executor/sdk"; + +// A Skill is a named markdown document surfaced through the tool catalog. +// There is no new agent-facing primitive: the plugin registers each skill +// as a static tool whose handler returns the body. Discovery is +// `executor.tools.list({ query })`, loading is `executor.tools.invoke(id)`. +export interface Skill { + /** Tool-name segment. The full tool id becomes `skills.`. Dots are + * allowed and render nicely under `executor call skills ...`. */ + readonly id: string; + /** One-line human summary. Indexed by `tools.list({ query })` together + * with the tool name. Kept as-is; the plugin prefixes `Skill: ` when + * registering so results stand out in the tool list. */ + readonly description: string; + /** Markdown body returned verbatim from the tool handler. */ + readonly body: string; +} + +export interface SkillsPluginOptions { + readonly skills?: readonly Skill[]; +} + +const SKILL_DESCRIPTION_PREFIX = "Skill: "; + +// Shared by the global plugin below AND by plugins that want to ship +// skills alongside their own tools — see `openApiPlugin` for an +// example. Exported so every skill has identical on-the-wire shape: +// `Skill:` prefix, empty-object input schema, handler that returns the +// markdown body. When MCP's Skills Extension SEP stabilizes we'll +// replace this with a dedicated StaticSkillDecl (see notes/skills.md), +// and the only callers to update are the ones that use this helper. +export const toStaticSkill = (skill: Skill): StaticToolDecl => ({ + name: skill.id, + description: `${SKILL_DESCRIPTION_PREFIX}${skill.description}`, + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: () => Effect.succeed(skill.body), +}); + +export const skillsPlugin = definePlugin( + (options?: SkillsPluginOptions) => { + const skills = options?.skills ?? []; + // Duplicate-id check mirrors the core executor's staticTools collision + // check — catching it here yields a pointer to the skill list instead + // of the generic "Duplicate static tool id" error at executor startup. + const seen = new Set(); + for (const skill of skills) { + if (seen.has(skill.id)) { + throw new Error(`Duplicate skill id: ${skill.id}`); + } + seen.add(skill.id); + } + + return { + id: "skills" as const, + storage: () => ({}), + extension: () => ({ + /** Raw skill list as registered. Handy for tests and for hosts + * that want to render skills with richer UI than the generic + * tool list. */ + skills, + }), + staticSources: () => [ + { + id: "skills", + kind: "control", + name: "Skills", + tools: skills.map(toStaticSkill), + }, + ], + }; + }, +); diff --git a/packages/plugins/skills/src/promise.ts b/packages/plugins/skills/src/promise.ts new file mode 100644 index 000000000..d68ab933e --- /dev/null +++ b/packages/plugins/skills/src/promise.ts @@ -0,0 +1,8 @@ +import { skillsPlugin as skillsPluginEffect } from "./index"; + +export type { Skill, SkillsPluginOptions } from "./index"; +export { toStaticSkill } from "./index"; + +export const skillsPlugin = (options?: { + readonly skills?: readonly import("./index").Skill[]; +}) => skillsPluginEffect(options); diff --git a/packages/plugins/skills/tsconfig.json b/packages/plugins/skills/tsconfig.json new file mode 100644 index 000000000..1354b0259 --- /dev/null +++ b/packages/plugins/skills/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/plugins/skills/tsup.config.ts b/packages/plugins/skills/tsup.config.ts new file mode 100644 index 000000000..d1c885590 --- /dev/null +++ b/packages/plugins/skills/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/promise.ts", + core: "src/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor\//, /^effect/, /^@effect\//], +}); diff --git a/packages/plugins/skills/vitest.config.ts b/packages/plugins/skills/vitest.config.ts new file mode 100644 index 000000000..5bfa2d586 --- /dev/null +++ b/packages/plugins/skills/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +});