From 65c5d9b3a29ea7a877d24bf50aa583ecc512fd91 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:30:10 -0700 Subject: [PATCH 1/4] skills: add @executor/plugin-skills (skills-as-tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill is a named markdown document surfaced through the existing tool catalog — no new registry, no new agent API, no storage. The plugin registers each skill as a static tool under a `skills` control source whose handler returns the body verbatim. Discovery is `tools.list({ query })`; loading is `tools.invoke(id)`. Naming is the only signal: a `Skill:` description prefix plus a `.` tool name so skills surface alongside the tools they document when callers search the relevant keyword. Ships one skill — `skills.openapi.adding-a-source` — describing the preview → resolve-auth → addSource flow, wired into apps/local. --- apps/local/package.json | 1 + apps/local/src/server/executor.ts | 4 +- bun.lock | 18 ++++++ packages/plugins/openapi/package.json | 1 + packages/plugins/openapi/src/sdk/index.ts | 2 + packages/plugins/openapi/src/sdk/skills.ts | 69 +++++++++++++++++++++ packages/plugins/skills/package.json | 56 +++++++++++++++++ packages/plugins/skills/src/index.ts | 71 ++++++++++++++++++++++ packages/plugins/skills/src/promise.ts | 7 +++ packages/plugins/skills/tsconfig.json | 22 +++++++ packages/plugins/skills/tsup.config.ts | 13 ++++ 11 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/openapi/src/sdk/skills.ts create mode 100644 packages/plugins/skills/package.json create mode 100644 packages/plugins/skills/src/index.ts create mode 100644 packages/plugins/skills/src/promise.ts create mode 100644 packages/plugins/skills/tsconfig.json create mode 100644 packages/plugins/skills/tsup.config.ts 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..e5eae2f03 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -30,13 +30,14 @@ import { makeFileConfigSink, type ConfigFileSink } from "@executor/config"; import * as executorSchema from "./executor-schema"; import { syncFromConfig, resolveConfigPath } from "./config-sync"; -import { openApiPlugin } from "@executor/plugin-openapi"; +import { openApiPlugin, openapiSkills } from "@executor/plugin-openapi"; import { mcpPlugin } from "@executor/plugin-mcp"; import { googleDiscoveryPlugin } from "@executor/plugin-google-discovery"; 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,7 @@ const createLocalPlugins = (configFile: ConfigFileSink) => keychainPlugin(), fileSecretsPlugin(), onepasswordPlugin(), + skillsPlugin({ skills: [...openapiSkills] }), ] as const; type LocalPlugins = ReturnType; diff --git a/bun.lock b/bun.lock index 8371d8f52..dda56229c 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,20 @@ "react", ], }, + "packages/plugins/skills": { + "name": "@executor/plugin-skills", + "version": "0.0.1", + "dependencies": { + "@executor/sdk": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@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 +1279,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/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/skills.ts b/packages/plugins/openapi/src/sdk/skills.ts new file mode 100644 index 000000000..a801b1e54 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/skills.ts @@ -0,0 +1,69 @@ +import type { Skill } from "@executor/plugin-skills"; + +// Skills shipped alongside the OpenAPI plugin. Consumers wire them in by +// passing this array to `skillsPlugin({ skills: [...openapiSkills] })`. +// Every skill id is prefixed `openapi.` so a catch-all `tools.list({ query: "openapi" })` +// surfaces it next to the real openapi tools. +export const openapiSkills: readonly Skill[] = [ + { + id: "openapi.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. Resolve authentication + +Look at the preview's \`securitySchemes\`: + +- **API key / bearer** — ask the user for the value, then store it via + \`secrets.set\` under an id you'll reference in step 3. Pick a + descriptive id like \`\${namespace}-api-key\`. +- **OAuth2 (authorization code)** — call \`openapi.startOAuth\` with the + spec and the scheme name. It returns a URL to open in the browser; + when the user completes the flow, \`openapi.completeOAuth\` stores the + token for you. +- **OAuth2 (client credentials)** — store the client id/secret as + secrets; the invoker will mint access tokens on demand. +- **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. +- Storing the API key at the wrong scope — write it to the same scope + the source will live in (the outermost scope by default). +- 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..07182910b --- /dev/null +++ b/packages/plugins/skills/package.json @@ -0,0 +1,56 @@ +{ + "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": { + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/plugins/skills/src/index.ts b/packages/plugins/skills/src/index.ts new file mode 100644 index 000000000..d194b55d4 --- /dev/null +++ b/packages/plugins/skills/src/index.ts @@ -0,0 +1,71 @@ +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: "; + +const toStaticTool = (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(toStaticTool), + }, + ], + }; + }, +); diff --git a/packages/plugins/skills/src/promise.ts b/packages/plugins/skills/src/promise.ts new file mode 100644 index 000000000..0334bcb38 --- /dev/null +++ b/packages/plugins/skills/src/promise.ts @@ -0,0 +1,7 @@ +import { skillsPlugin as skillsPluginEffect } from "./index"; + +export type { Skill, SkillsPluginOptions } 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\//], +}); From e443f4ea146dcd0b9193595f8c39fb062366f9fa Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:42:21 -0700 Subject: [PATCH 2/4] skills: add tests covering the agent discovery UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test file doubles as executable documentation for how a skill is discovered (`tools.list({ query })`) and loaded (`tools.invoke(id)`) — if it stops reading like the agent-facing flow, the plugin has drifted. Also adds an openapi-side integration test that pins the naming-as- attachment convention: `openapi.adding-a-source` lands in the same result set as `openapi.previewSpec` / `openapi.addSource` for a plain `query: "openapi"` search, and the skill body mentions both tools (so renaming either one will fail the test). --- bun.lock | 1 + .../plugins/openapi/src/sdk/skills.test.ts | 81 ++++++++++ packages/plugins/skills/package.json | 1 + packages/plugins/skills/src/index.test.ts | 146 ++++++++++++++++++ packages/plugins/skills/vitest.config.ts | 8 + 5 files changed, 237 insertions(+) create mode 100644 packages/plugins/openapi/src/sdk/skills.test.ts create mode 100644 packages/plugins/skills/src/index.test.ts create mode 100644 packages/plugins/skills/vitest.config.ts diff --git a/bun.lock b/bun.lock index dda56229c..75d4851f5 100644 --- a/bun.lock +++ b/bun.lock @@ -742,6 +742,7 @@ "effect": "catalog:", }, "devDependencies": { + "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", "tsup": "catalog:", 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..e1b4f40f7 --- /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 { skillsPlugin } from "@executor/plugin-skills"; + +import { openApiPlugin } from "./plugin"; +import { openapiSkills } from "./skills"; + +// These tests demonstrate the naming-as-attachment convention: the +// openapi plugin's skill lives under id `openapi.adding-a-source`, so a +// query like `"openapi adding"` surfaces it right next to the real +// openapi static tools. No `appliesTo` field, no special linking — just +// the tool catalog doing substring matching across name + description. + +describe("openapiSkills wired into skillsPlugin", () => { + it.effect("shows up next to openapi static tools when queried by name", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin(), + skillsPlugin({ skills: [...openapiSkills] }), + ] as const, + }), + ); + + const tools = yield* executor.tools.list({ query: "openapi" }); + const ids = tools.map((t) => t.id); + + // The skill lands in the same result set as openapi.previewSpec / + // openapi.addSource — that's the whole point of the naming convention. + expect(ids).toContain("skills.openapi.adding-a-source"); + expect(ids).toContain("openapi.previewSpec"); + expect(ids).toContain("openapi.addSource"); + }), + ); + + it.effect("more specific queries still find the skill", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin(), + skillsPlugin({ skills: [...openapiSkills] }), + ] as const, + }), + ); + + const tools = yield* executor.tools.list({ query: "adding" }); + expect(tools.map((t) => t.id)).toContain( + "skills.openapi.adding-a-source", + ); + }), + ); + + it.effect("invoking the skill returns markdown that references the real tools", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin(), + skillsPlugin({ skills: [...openapiSkills] }), + ] as const, + }), + ); + + const body = (yield* executor.tools.invoke( + "skills.openapi.adding-a-source", + {}, + )) as string; + + // The skill is useless if it doesn't name the tools an agent is + // supposed to chain. These assertions pin the body to the public + // API surface — if a tool gets renamed, this test catches the + // skill going stale. + expect(body).toContain("openapi.previewSpec"); + expect(body).toContain("openapi.addSource"); + }), + ); +}); diff --git a/packages/plugins/skills/package.json b/packages/plugins/skills/package.json index 07182910b..070b9ec09 100644 --- a/packages/plugins/skills/package.json +++ b/packages/plugins/skills/package.json @@ -48,6 +48,7 @@ "effect": "catalog:" }, "devDependencies": { + "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", "tsup": "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/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, + }, +}); From a68ef1f59d8fafe81ca96bab3d9febf4d6c8c54e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:45:56 -0700 Subject: [PATCH 3/4] openapi: stop telling the skill to secrets.set user-typed values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous body said "ask the user for the value, then store it via secrets.set under an id you'll reference in step 3." That routes the raw secret through the LLM's context window — once it's there it's already leaked, so doing `secrets.set` on it doesn't help. Same logic for the client-credentials bullet ("store the client id/secret as secrets"). Reword to the rule that's actually correct anyway: the agent only ever picks an id and references it in step 3; the user provisions the value out of band through the UI or CLI. The authorization-code flow was already safe (URL handoff, token captured server-side) and is left alone. Secure secret capture from the agent's perspective is still an open problem — whatever UI we wire up next has to land the value without round-tripping through the model. For now the skill just forbids it. --- .../plugins/openapi/src/sdk/skills.test.ts | 28 ++++++++++++++++ packages/plugins/openapi/src/sdk/skills.ts | 32 ++++++++++++------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/plugins/openapi/src/sdk/skills.test.ts b/packages/plugins/openapi/src/sdk/skills.test.ts index e1b4f40f7..dba6d3c71 100644 --- a/packages/plugins/openapi/src/sdk/skills.test.ts +++ b/packages/plugins/openapi/src/sdk/skills.test.ts @@ -78,4 +78,32 @@ describe("openapiSkills wired into skillsPlugin", () => { expect(body).toContain("openapi.addSource"); }), ); + + // Pins the "no secret values in chat" policy. If a future edit + // reintroduces `secrets.set` in the skill body, this fails — that + // pattern routes user-typed secrets through the LLM context, which + // is the leak we're trying to avoid. + 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(), + skillsPlugin({ skills: [...openapiSkills] }), + ] as const, + }), + ); + + const body = (yield* executor.tools.invoke( + "skills.openapi.adding-a-source", + {}, + )) as string; + + // The original text read "store it via `secrets.set`" — removed + // because it meant the agent would receive the value first. + expect(body).not.toContain("store it via `secrets.set`"); + // The replacement rule must be present verbatim. + 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 index a801b1e54..d18734b05 100644 --- a/packages/plugins/openapi/src/sdk/skills.ts +++ b/packages/plugins/openapi/src/sdk/skills.ts @@ -27,19 +27,28 @@ get back: credentials, and which scheme to use. Do not skip it — \`addSpec\` will fail at invoke time if required auth isn't wired. -## 2. Resolve authentication +## 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** — ask the user for the value, then store it via - \`secrets.set\` under an id you'll reference in step 3. Pick a - descriptive id like \`\${namespace}-api-key\`. +- **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 to open in the browser; - when the user completes the flow, \`openapi.completeOAuth\` stores the - token for you. -- **OAuth2 (client credentials)** — store the client id/secret as - secrets; the invoker will mint access tokens on demand. + 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 @@ -60,8 +69,9 @@ tool under \`.\`, listable via - Calling \`addSpec\` before \`previewSpec\` — you'll miss required auth schemes and invocations will 401 later. -- Storing the API key at the wrong scope — write it to the same scope - the source will live in (the outermost scope by default). +- 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. `, From 78a83b657009e69448ab220cb141741d12dda828 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:17:32 -0700 Subject: [PATCH 4/4] skills: move openapi skill onto its owning source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-source skills are the primary attachment model — the MCP Skills Over MCP WG has converged on skills-as-resources where a skill's URI scopes it to the server that ships it. Our static-tool system is effectively in-process MCP, so the attachment point should match: skills live under the same sourceId as the tools they document. Previously `openapi.adding-a-source` sat under the global `skills` source and relied on substring-search ranking to land next to the real openapi tools. Now it's registered by the openapi plugin's own `staticSources`, so `tools.list({ sourceId: "openapi" })` returns previewSpec, addSource, and the playbook together — no convention needed. The global `skillsPlugin` stays wired in apps/local for future cross-cutting / user-authored skills (currently empty). To keep on-the-wire shape identical between the two registration sites, `@executor/plugin-skills` now exports `toStaticSkill(skill): StaticToolDecl` — shared "Skill:" prefix, empty input schema, handler that returns the markdown body. When the MCP Skills SEP stabilizes, replacing this helper's output with a dedicated StaticSkillDecl is the mechanical part of the refactor. Background and long-term plan: notes/skills.md. --- apps/local/src/server/executor.ts | 7 +- notes/skills.md | 144 ++++++++++++++++++ .../plugins/openapi/src/sdk/plugin.test.ts | 4 + packages/plugins/openapi/src/sdk/plugin.ts | 7 + .../plugins/openapi/src/sdk/skills.test.ts | 86 ++++------- packages/plugins/openapi/src/sdk/skills.ts | 12 +- packages/plugins/skills/src/index.ts | 11 +- packages/plugins/skills/src/promise.ts | 1 + 8 files changed, 206 insertions(+), 66 deletions(-) create mode 100644 notes/skills.md diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index e5eae2f03..63ed8e28a 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -30,7 +30,7 @@ import { makeFileConfigSink, type ConfigFileSink } from "@executor/config"; import * as executorSchema from "./executor-schema"; import { syncFromConfig, resolveConfigPath } from "./config-sync"; -import { openApiPlugin, openapiSkills } from "@executor/plugin-openapi"; +import { openApiPlugin } from "@executor/plugin-openapi"; import { mcpPlugin } from "@executor/plugin-mcp"; import { googleDiscoveryPlugin } from "@executor/plugin-google-discovery"; import { graphqlPlugin } from "@executor/plugin-graphql"; @@ -102,7 +102,10 @@ const createLocalPlugins = (configFile: ConfigFileSink) => keychainPlugin(), fileSecretsPlugin(), onepasswordPlugin(), - skillsPlugin({ skills: [...openapiSkills] }), + // 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/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/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 index dba6d3c71..55ffeb8eb 100644 --- a/packages/plugins/openapi/src/sdk/skills.test.ts +++ b/packages/plugins/openapi/src/sdk/skills.test.ts @@ -2,107 +2,79 @@ import { describe, it, expect } from "@effect/vitest"; import { Effect } from "effect"; import { createExecutor, makeTestConfig } from "@executor/sdk"; -import { skillsPlugin } from "@executor/plugin-skills"; import { openApiPlugin } from "./plugin"; -import { openapiSkills } from "./skills"; -// These tests demonstrate the naming-as-attachment convention: the -// openapi plugin's skill lives under id `openapi.adding-a-source`, so a -// query like `"openapi adding"` surfaces it right next to the real -// openapi static tools. No `appliesTo` field, no special linking — just -// the tool catalog doing substring matching across name + description. +// 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("openapiSkills wired into skillsPlugin", () => { - it.effect("shows up next to openapi static tools when queried by name", () => +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(), - skillsPlugin({ skills: [...openapiSkills] }), - ] as const, - }), + makeTestConfig({ plugins: [openApiPlugin()] as const }), ); - const tools = yield* executor.tools.list({ query: "openapi" }); - const ids = tools.map((t) => t.id); + const tools = yield* executor.tools.list({ sourceId: "openapi" }); + const ids = tools.map((t) => t.id).sort(); - // The skill lands in the same result set as openapi.previewSpec / - // openapi.addSource — that's the whole point of the naming convention. - expect(ids).toContain("skills.openapi.adding-a-source"); - expect(ids).toContain("openapi.previewSpec"); - expect(ids).toContain("openapi.addSource"); + // 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("more specific queries still find the skill", () => + it.effect("skill description uses the `Skill:` prefix shared across skill helpers", () => Effect.gen(function* () { const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - openApiPlugin(), - skillsPlugin({ skills: [...openapiSkills] }), - ] as const, - }), + makeTestConfig({ plugins: [openApiPlugin()] as const }), ); - const tools = yield* executor.tools.list({ query: "adding" }); - expect(tools.map((t) => t.id)).toContain( - "skills.openapi.adding-a-source", - ); + 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(), - skillsPlugin({ skills: [...openapiSkills] }), - ] as const, - }), + makeTestConfig({ plugins: [openApiPlugin()] as const }), ); const body = (yield* executor.tools.invoke( - "skills.openapi.adding-a-source", + "openapi.adding-a-source", {}, )) as string; - // The skill is useless if it doesn't name the tools an agent is - // supposed to chain. These assertions pin the body to the public - // API surface — if a tool gets renamed, this test catches the - // skill going stale. + // 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 `secrets.set` in the skill body, this fails — that - // pattern routes user-typed secrets through the LLM context, which - // is the leak we're trying to avoid. + // 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(), - skillsPlugin({ skills: [...openapiSkills] }), - ] as const, - }), + makeTestConfig({ plugins: [openApiPlugin()] as const }), ); const body = (yield* executor.tools.invoke( - "skills.openapi.adding-a-source", + "openapi.adding-a-source", {}, )) as string; - // The original text read "store it via `secrets.set`" — removed - // because it meant the agent would receive the value first. expect(body).not.toContain("store it via `secrets.set`"); - // The replacement rule must be present verbatim. 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 index d18734b05..0753d35f4 100644 --- a/packages/plugins/openapi/src/sdk/skills.ts +++ b/packages/plugins/openapi/src/sdk/skills.ts @@ -1,12 +1,14 @@ import type { Skill } from "@executor/plugin-skills"; -// Skills shipped alongside the OpenAPI plugin. Consumers wire them in by -// passing this array to `skillsPlugin({ skills: [...openapiSkills] })`. -// Every skill id is prefixed `openapi.` so a catch-all `tools.list({ query: "openapi" })` -// surfaces it next to the real openapi tools. +// 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: "openapi.adding-a-source", + id: "adding-a-source", description: "How to add an OpenAPI spec as a source — preview, resolve auth, then addSpec", body: `# Adding an OpenAPI source diff --git a/packages/plugins/skills/src/index.ts b/packages/plugins/skills/src/index.ts index d194b55d4..a20483465 100644 --- a/packages/plugins/skills/src/index.ts +++ b/packages/plugins/skills/src/index.ts @@ -24,7 +24,14 @@ export interface SkillsPluginOptions { const SKILL_DESCRIPTION_PREFIX = "Skill: "; -const toStaticTool = (skill: Skill): StaticToolDecl => ({ +// 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: { @@ -63,7 +70,7 @@ export const skillsPlugin = definePlugin( id: "skills", kind: "control", name: "Skills", - tools: skills.map(toStaticTool), + tools: skills.map(toStaticSkill), }, ], }; diff --git a/packages/plugins/skills/src/promise.ts b/packages/plugins/skills/src/promise.ts index 0334bcb38..d68ab933e 100644 --- a/packages/plugins/skills/src/promise.ts +++ b/packages/plugins/skills/src/promise.ts @@ -1,6 +1,7 @@ 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[];