From a2bfc4d1aeb66639e8ebe518c769818a6c8441e1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:15:35 -0700 Subject: [PATCH 1/6] Add Agent Skills Save SKILL.md directories to a workspace, personally or org-shared, and serve them to every connected agent through the MCP skills tool and the MCP Skills Extension. Adds the skill table, the skills HTTP API, console pages, and executor skills list/pull. Co-Authored-By: Claude Fable 5.1 --- .changeset/agent-skills.md | 5 + apps/cli/src/main.ts | 136 ++ apps/cli/src/skills.test.ts | 325 ++++ apps/cli/src/skills.ts | 354 ++++ apps/cloud/drizzle/0018_eager_jack_power.sql | 14 + apps/cloud/drizzle/meta/0018_snapshot.json | 1611 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/db/executor-schema.ts | 20 + apps/cloud/src/db/org-deletion.test.ts | 14 + apps/cloud/src/db/org-deletion.ts | 2 + apps/cloud/src/mcp/session-durable-object.ts | 1 + apps/cloud/src/mcp/telemetry.ts | 6 + apps/cloud/src/routeTree.gen.ts | 85 + .../src/mcp/session-durable-object.ts | 1 + apps/host-cloudflare/web/routeTree.gen.ts | 85 + apps/local/drizzle/0007_same_vengeance.sql | 14 + apps/local/drizzle/meta/0007_snapshot.json | 1034 +++++++++++ apps/local/drizzle/meta/_journal.json | 7 + apps/local/src/db/executor-schema.ts | 17 + apps/local/src/main.ts | 3 + bun.lock | 13 +- e2e/scenarios/skills.test.ts | 142 ++ packages/app/src/routeTree.gen.ts | 83 + packages/app/src/web/shell.tsx | 7 + packages/core/api/src/account/org-slug.ts | 3 +- packages/core/api/src/api.ts | 2 + packages/core/api/src/handlers/index.ts | 3 + packages/core/api/src/handlers/skills.ts | 60 + packages/core/api/src/index.ts | 1 + packages/core/api/src/server/mcp-build.ts | 1 + packages/core/api/src/skills/api.ts | 88 + packages/core/sdk/package.json | 3 +- packages/core/sdk/src/core-schema.ts | 34 + packages/core/sdk/src/errors.ts | 41 +- packages/core/sdk/src/executor.ts | 114 ++ packages/core/sdk/src/ids.ts | 5 + packages/core/sdk/src/index.ts | 32 + packages/core/sdk/src/shared.ts | 29 + packages/core/sdk/src/skill.ts | 384 ++++ packages/core/sdk/src/skills.test.ts | 199 ++ packages/hosts/mcp/src/tool-server.ts | 190 +- .../hosts/mcp/src/workspace-skills.test.ts | 296 +++ packages/hosts/mcp/src/workspace-skills.ts | 268 +++ packages/react/src/api/atoms.tsx | 34 + packages/react/src/api/reactivity-keys.tsx | 7 + packages/react/src/console-routes.ts | 11 + packages/react/src/multiplayer/shell.tsx | 1 + packages/react/src/pages/skill-detail.tsx | 285 +++ packages/react/src/pages/skill-editor.tsx | 458 +++++ packages/react/src/pages/skills.tsx | 159 ++ packages/react/src/routes/routeTree.gen.ts | 75 + packages/react/src/routes/skills-route.tsx | 36 + .../routes/skills.$skillOwner.$skillName.tsx | 13 + packages/react/src/routes/skills.new.tsx | 11 + packages/react/src/routes/skills.tsx | 37 + 55 files changed, 6837 insertions(+), 29 deletions(-) create mode 100644 .changeset/agent-skills.md create mode 100644 apps/cli/src/skills.test.ts create mode 100644 apps/cli/src/skills.ts create mode 100644 apps/cloud/drizzle/0018_eager_jack_power.sql create mode 100644 apps/cloud/drizzle/meta/0018_snapshot.json create mode 100644 apps/local/drizzle/0007_same_vengeance.sql create mode 100644 apps/local/drizzle/meta/0007_snapshot.json create mode 100644 e2e/scenarios/skills.test.ts create mode 100644 packages/core/api/src/handlers/skills.ts create mode 100644 packages/core/api/src/skills/api.ts create mode 100644 packages/core/sdk/src/skill.ts create mode 100644 packages/core/sdk/src/skills.test.ts create mode 100644 packages/hosts/mcp/src/workspace-skills.test.ts create mode 100644 packages/hosts/mcp/src/workspace-skills.ts create mode 100644 packages/react/src/pages/skill-detail.tsx create mode 100644 packages/react/src/pages/skill-editor.tsx create mode 100644 packages/react/src/pages/skills.tsx create mode 100644 packages/react/src/routes/skills-route.tsx create mode 100644 packages/react/src/routes/skills.$skillOwner.$skillName.tsx create mode 100644 packages/react/src/routes/skills.new.tsx create mode 100644 packages/react/src/routes/skills.tsx diff --git a/.changeset/agent-skills.md b/.changeset/agent-skills.md new file mode 100644 index 0000000000..c54455aa24 --- /dev/null +++ b/.changeset/agent-skills.md @@ -0,0 +1,5 @@ +--- +"executor": minor +--- + +Add Agent Skills. Save a SKILL.md directory to Executor, personally or shared with the whole workspace, and every connected agent can load it: the MCP `skills` tool lists and serves workspace skills next to Executor's own docs, and the server speaks the MCP Skills Extension (`skills/list`, `skills/get`, `skill://` resources). The console gets a Skills page for adding, editing, and removing them, and `executor skills pull` syncs them to local agent skill directories. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index fc2a9bf391..f8f4ee51a4 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -86,6 +86,7 @@ import { normalizeExecutorServerOrigin, resolveExecutorServerConfiguredHeaders, resolveExecutorServerRequestHeaders, + SkillName, type ExecutorLocalServerKind, type ExecutorLocalServerManifest, type ExecutorServerConnection, @@ -184,6 +185,21 @@ import { sanitizeCliOutputText, shellQuoteArg, } from "./tooling"; +import { + defaultClaudeSkillsDir, + defaultSkillsDir, + formatSkillPullSummaryLine, + formatSkillsTable, + planSkillsPull, + readExistingSkillEntries, + removeSkillDirectory, + resolveEffectiveSkills, + summarizeSkillPullActions, + writeSkillDirectory, + type SkillDetail, + type SkillPullAction, + type SkillSummary, +} from "./skills"; // Embedded web UI — baked into compiled binaries via `with { type: "file" }` import embeddedWebUI from "./embedded-web-ui.gen"; @@ -2209,6 +2225,125 @@ const toolsCommand = Command.make("tools").pipe( Command.withDescription("Discover available tools and integrations"), ); +// --------------------------------------------------------------------------- +// Skills — `executor skills list` / `executor skills pull` +// --------------------------------------------------------------------------- + +const skillsListCommand = Command.make( + "list", + { + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ baseUrl, server, scope }) => + Effect.gen(function* () { + applyScope(scope); + const target = serverTargetFromOptions({ baseUrl, server }); + const connection = yield* resolveExecutorServerConnection(target); + const client = yield* makeApiClient(connection, target); + const skills = yield* client.skills.list(); + for (const line of formatSkillsTable(skills)) { + console.log(line); + } + }).pipe(Effect.mapError(toError)), +).pipe(Command.withDescription("List Agent Skills visible to this owner scope")); + +const skillsPullDirOption = Options.string("dir").pipe( + Options.optional, + Options.withDescription("Directory to write skills into. Defaults to ~/.agents/skills."), +); + +const skillsPullNoClaudeOption = Options.boolean("no-claude").pipe( + Options.withDefault(false), + Options.withDescription("Skip mirroring skills into ~/.claude/skills."), +); + +/** Pull every skill this owner scope can see into `root`, applying the + * marker-file safety rules from `planSkillsPull`. Returns the actions taken + * so the caller can print one combined summary across every target root. */ +const pullSkillsIntoRoot = (input: { + readonly root: string; + readonly origin: string; + readonly effectiveSkills: readonly SkillSummary[]; + readonly fetchSkill: (skill: SkillSummary) => Effect.Effect; +}) => + Effect.gen(function* () { + const existing = yield* readExistingSkillEntries(input.root).pipe(Effect.mapError(toError)); + const actions = planSkillsPull({ + origin: input.origin, + skills: input.effectiveSkills, + existing, + }); + const skillsByName = new Map(input.effectiveSkills.map((skill) => [skill.name, skill])); + + for (const action of actions) { + if (action.kind === "add" || action.kind === "update") { + const skill = skillsByName.get(action.name); + if (!skill) continue; + const detail = yield* input.fetchSkill(skill); + yield* writeSkillDirectory({ root: input.root, origin: input.origin, skill: detail }).pipe( + Effect.mapError(toError), + ); + } else if (action.kind === "remove") { + yield* removeSkillDirectory(input.root, action.name).pipe(Effect.mapError(toError)); + } + } + + return actions; + }); + +const skillsPullCommand = Command.make( + "pull", + { + dir: skillsPullDirOption, + noClaude: skillsPullNoClaudeOption, + baseUrl: serverBaseUrl, + server: serverProfile, + scope, + }, + ({ dir, noClaude, baseUrl, server, scope }) => + Effect.gen(function* () { + applyScope(scope); + const target = serverTargetFromOptions({ baseUrl, server }); + const connection = yield* resolveExecutorServerConnection(target); + const client = yield* makeApiClient(connection, target); + + const skills = yield* client.skills.list(); + const effectiveSkills = resolveEffectiveSkills(skills); + const fetchSkill = (skill: SkillSummary) => + client.skills + .get({ params: { owner: skill.owner, name: SkillName.make(skill.name) } }) + .pipe(Effect.mapError(toError)); + + const targetRoot = Option.getOrElse(dir, defaultSkillsDir); + const roots = noClaude ? [targetRoot] : [targetRoot, defaultClaudeSkillsDir()]; + + const allActions: SkillPullAction[] = []; + for (const root of roots) { + const actions = yield* pullSkillsIntoRoot({ + root, + origin: connection.origin, + effectiveSkills, + fetchSkill, + }); + allActions.push(...actions); + } + + const summary = summarizeSkillPullActions(allActions); + console.log(`Pulled ${effectiveSkills.length} skill(s) into ${roots.join(", ")}.`); + console.log(formatSkillPullSummaryLine(summary)); + for (const action of allActions) { + if (action.reason) console.log(` ${action.kind}: ${action.name} (${action.reason})`); + } + }).pipe(Effect.mapError(toError)), +).pipe(Command.withDescription("Pull Agent Skills from the server into a local directory")); + +const skillsCommand = Command.make("skills").pipe( + Command.withSubcommands([skillsListCommand, skillsPullCommand] as const), + Command.withDescription("List and pull Agent Skills from the connected server"), +); + const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -3299,6 +3434,7 @@ const root = Command.make("executor").pipe( callCommand, resumeCommand, toolsCommand, + skillsCommand, installCommand, loginCommand, logoutCommand, diff --git a/apps/cli/src/skills.test.ts b/apps/cli/src/skills.test.ts new file mode 100644 index 0000000000..b48bbfdc3c --- /dev/null +++ b/apps/cli/src/skills.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Effect from "effect/Effect"; + +import { + formatSkillPullSummaryLine, + formatSkillsTable, + parseSkillMarker, + planSkillsPull, + readExistingSkillEntries, + removeSkillDirectory, + resolveEffectiveSkills, + serializeSkillMarker, + skillMarkerFor, + summarizeSkillPullActions, + writeSkillDirectory, + SKILL_MARKER_FILENAME, + type SkillDetail, + type SkillSummary, +} from "./skills"; + +const withTmp = (body: (dir: string) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-skills-"))), + body, + (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })), + ); + +const ORIGIN = "https://example.executor.sh"; + +const summary = ( + input: Partial & { owner: "org" | "user"; name: string }, +): SkillSummary => ({ + description: "A test skill.", + files: [{ path: "SKILL.md", size: 10, digest: "sha256:aaa" }], + updatedAt: Date.parse("2026-01-01T00:00:00.000Z"), + ...input, +}); + +describe("resolveEffectiveSkills", () => { + it("keeps a single skill unaffected", () => { + const skills = [summary({ owner: "org", name: "docs" })]; + expect(resolveEffectiveSkills(skills)).toEqual(skills); + }); + + it("prefers the user copy over an org copy of the same name", () => { + const org = summary({ owner: "org", name: "docs", description: "org copy" }); + const user = summary({ owner: "user", name: "docs", description: "user copy" }); + expect(resolveEffectiveSkills([org, user])).toEqual([user]); + // Order shouldn't matter. + expect(resolveEffectiveSkills([user, org])).toEqual([user]); + }); + + it("keeps distinct names separate", () => { + const a = summary({ owner: "org", name: "docs" }); + const b = summary({ owner: "user", name: "release-notes" }); + expect(resolveEffectiveSkills([a, b])).toEqual([a, b]); + }); +}); + +describe("formatSkillsTable", () => { + it("reports an empty table", () => { + expect(formatSkillsTable([])).toEqual(["No skills found."]); + }); + + it("includes a header and one row per skill", () => { + const lines = formatSkillsTable([summary({ owner: "org", name: "docs" })]); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("OWNER"); + expect(lines[1]).toContain("org"); + expect(lines[1]).toContain("docs"); + }); +}); + +describe("skill markers", () => { + it("round-trips through serialize/parse", () => { + const marker = skillMarkerFor({ + origin: ORIGIN, + skill: { owner: "user", name: "docs" }, + files: [{ path: "SKILL.md", size: 10, digest: "sha256:aaa" }], + }); + const parsed = parseSkillMarker(serializeSkillMarker(marker)); + expect(parsed).toEqual(marker); + }); + + it("rejects malformed JSON", () => { + expect(parseSkillMarker("not json")).toBeUndefined(); + }); + + it("rejects an object missing required fields", () => { + expect(parseSkillMarker(JSON.stringify({ origin: ORIGIN }))).toBeUndefined(); + }); + + it("rejects an invalid owner", () => { + expect( + parseSkillMarker( + JSON.stringify({ origin: ORIGIN, owner: "nobody", name: "docs", digests: {} }), + ), + ).toBeUndefined(); + }); + + it("rejects non-string digest values", () => { + expect( + parseSkillMarker( + JSON.stringify({ origin: ORIGIN, owner: "user", name: "docs", digests: { "SKILL.md": 1 } }), + ), + ).toBeUndefined(); + }); +}); + +describe("planSkillsPull", () => { + it("adds a skill with no existing directory", () => { + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [summary({ owner: "org", name: "docs" })], + existing: [], + }); + expect(actions).toEqual([{ name: "docs", kind: "add" }]); + }); + + it("marks a matching-digest skill unchanged", () => { + const skill = summary({ owner: "org", name: "docs" }); + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [skill], + existing: [ + { + name: "docs", + marker: skillMarkerFor({ origin: ORIGIN, skill, files: skill.files }), + }, + ], + }); + expect(actions).toEqual([{ name: "docs", kind: "unchanged" }]); + }); + + it("updates a skill whose digests changed", () => { + const skill = summary({ owner: "org", name: "docs" }); + const staleMarker = skillMarkerFor({ + origin: ORIGIN, + skill, + files: [{ path: "SKILL.md", size: 5, digest: "sha256:old" }], + }); + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [skill], + existing: [{ name: "docs", marker: staleMarker }], + }); + expect(actions).toEqual([{ name: "docs", kind: "update" }]); + }); + + it("never overwrites a directory with no marker", () => { + const skill = summary({ owner: "org", name: "docs" }); + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [skill], + existing: [{ name: "docs", marker: undefined }], + }); + expect(actions).toEqual([ + { name: "docs", kind: "skip", reason: expect.stringContaining(SKILL_MARKER_FILENAME) }, + ]); + }); + + it("skips a directory owned by a different server", () => { + const skill = summary({ owner: "org", name: "docs" }); + const otherOriginMarker = skillMarkerFor({ + origin: "https://other.example", + skill, + files: skill.files, + }); + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [skill], + existing: [{ name: "docs", marker: otherOriginMarker }], + }); + expect(actions).toEqual([ + { name: "docs", kind: "skip", reason: expect.stringContaining("different server") }, + ]); + }); + + it("removes a marker-bearing directory whose skill no longer exists upstream", () => { + const marker = skillMarkerFor({ + origin: ORIGIN, + skill: { owner: "org", name: "gone" }, + files: [{ path: "SKILL.md", size: 1, digest: "sha256:x" }], + }); + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [], + existing: [{ name: "gone", marker }], + }); + expect(actions).toEqual([{ name: "gone", kind: "remove", reason: expect.any(String) }]); + }); + + it("never removes an unmanaged directory that has no matching upstream skill", () => { + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [], + existing: [{ name: "my-notes", marker: undefined }], + }); + expect(actions).toEqual([]); + }); + + it("never removes a directory owned by a different server", () => { + const marker = skillMarkerFor({ + origin: "https://other.example", + skill: { owner: "org", name: "gone" }, + files: [], + }); + const actions = planSkillsPull({ + origin: ORIGIN, + skills: [], + existing: [{ name: "gone", marker }], + }); + expect(actions).toEqual([]); + }); +}); + +describe("summarizeSkillPullActions / formatSkillPullSummaryLine", () => { + it("counts each action kind", () => { + const totals = summarizeSkillPullActions([ + { name: "a", kind: "add" }, + { name: "b", kind: "update" }, + { name: "c", kind: "unchanged" }, + { name: "d", kind: "remove" }, + { name: "e", kind: "skip" }, + { name: "f", kind: "add" }, + ]); + expect(totals).toEqual({ added: 2, updated: 1, unchanged: 1, removed: 1, skipped: 1 }); + expect(formatSkillPullSummaryLine(totals)).toBe( + "2 added, 1 updated, 1 removed, 1 skipped (1 unchanged)", + ); + }); + + it("omits the unchanged suffix when there are none", () => { + const totals = summarizeSkillPullActions([{ name: "a", kind: "add" }]); + expect(formatSkillPullSummaryLine(totals)).toBe("1 added, 0 updated, 0 removed, 0 skipped"); + }); +}); + +describe("filesystem I/O", () => { + it.effect("readExistingSkillEntries returns [] for a missing root", () => + withTmp((dir) => + Effect.gen(function* () { + const entries = yield* readExistingSkillEntries(join(dir, "does-not-exist")); + expect(entries).toEqual([]); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("readExistingSkillEntries reports marker and unmarked directories", () => + withTmp((dir) => + Effect.gen(function* () { + const managed = join(dir, "docs"); + mkdirSync(managed, { recursive: true }); + const marker = skillMarkerFor({ + origin: ORIGIN, + skill: { owner: "org", name: "docs" }, + files: [{ path: "SKILL.md", size: 3, digest: "sha256:aaa" }], + }); + writeFileSync(join(managed, SKILL_MARKER_FILENAME), serializeSkillMarker(marker)); + + const unmanaged = join(dir, "my-notes"); + mkdirSync(unmanaged, { recursive: true }); + + // A plain file at the root must not be mistaken for a skill directory. + writeFileSync(join(dir, "readme.txt"), "hi"); + + const entries = yield* readExistingSkillEntries(dir); + const byName = new Map(entries.map((entry) => [entry.name, entry])); + expect(byName.size).toBe(2); + expect(byName.get("docs")?.marker).toEqual(marker); + expect(byName.get("my-notes")?.marker).toBeUndefined(); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("writeSkillDirectory writes nested files and a marker", () => + withTmp((dir) => + Effect.gen(function* () { + const detail: SkillDetail = { + owner: "user", + name: "docs", + description: "A test skill.", + updatedAt: Date.now(), + files: [ + { path: "SKILL.md", size: 5, digest: "sha256:aaa", content: "# Docs" }, + { + path: "references/guide.md", + size: 6, + digest: "sha256:bbb", + content: "Guide.", + }, + ], + }; + yield* writeSkillDirectory({ root: dir, origin: ORIGIN, skill: detail }); + + const entries = yield* readExistingSkillEntries(dir); + expect(entries).toHaveLength(1); + expect(entries[0]?.marker?.origin).toBe(ORIGIN); + expect(entries[0]?.marker?.digests).toEqual({ + "SKILL.md": "sha256:aaa", + "references/guide.md": "sha256:bbb", + }); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("removeSkillDirectory deletes the directory recursively", () => + withTmp((dir) => + Effect.gen(function* () { + const target = join(dir, "docs", "references"); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "guide.md"), "Guide."); + + yield* removeSkillDirectory(dir, "docs"); + + const entries = yield* readExistingSkillEntries(dir); + expect(entries).toEqual([]); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/skills.ts b/apps/cli/src/skills.ts new file mode 100644 index 0000000000..ebadd41e61 --- /dev/null +++ b/apps/cli/src/skills.ts @@ -0,0 +1,354 @@ +import { homedir } from "node:os"; +import { FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as Effect from "effect/Effect"; + +import type { Owner } from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// `executor skills` — list and pull Agent Skills from the connected server. +// +// Pure planning lives here, separated from the filesystem/network I/O that +// drives it, so the marker-file safety rules (never touch a directory that +// isn't ours) are unit-testable without a server or real disk writes. See +// plans/agent-skills.md, "CLI (apps/cli)" for the contract, and +// packages/core/api/src/skills/api.ts for the response shapes this mirrors. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Types mirroring the skills HTTP API responses. +// --------------------------------------------------------------------------- + +export interface SkillFileEntry { + readonly path: string; + readonly size: number; + readonly digest: string; +} + +export interface SkillFile extends SkillFileEntry { + readonly content: string; +} + +/** What `GET /skills` returns per skill: the manifest, no file contents. */ +export interface SkillSummary { + readonly owner: Owner; + readonly name: string; + readonly description: string; + readonly files: readonly SkillFileEntry[]; + readonly updatedAt: number; +} + +/** What `GET /skills/:owner/:name` returns: the summary plus file contents. */ +export interface SkillDetail extends Omit { + readonly files: readonly SkillFile[]; +} + +// --------------------------------------------------------------------------- +// Default install locations +// --------------------------------------------------------------------------- + +export const defaultSkillsDir = (): string => `${homedir()}/.agents/skills`; +export const defaultClaudeSkillsDir = (): string => `${homedir()}/.claude/skills`; + +// --------------------------------------------------------------------------- +// Owner shadowing: a `user` skill hides an `org` skill of the same name. +// --------------------------------------------------------------------------- + +/** Resolve the set of skills every agent actually sees: at most one entry per + * name, preferring the `user` (personal) copy over an `org` (workspace) one. */ +export const resolveEffectiveSkills = ( + skills: readonly S[], +): readonly S[] => { + const byName = new Map(); + for (const skill of skills) { + const existing = byName.get(skill.name); + if (!existing || (existing.owner === "org" && skill.owner === "user")) { + byName.set(skill.name, skill); + } + } + return Array.from(byName.values()); +}; + +// --------------------------------------------------------------------------- +// `list` — table formatting (pure). +// --------------------------------------------------------------------------- + +export const formatSkillsTable = (skills: readonly SkillSummary[]): readonly string[] => { + if (skills.length === 0) { + return ["No skills found."]; + } + + const header = { + owner: "OWNER", + name: "NAME", + description: "DESCRIPTION", + files: "FILES", + updated: "UPDATED", + }; + const rows = skills.map((skill) => ({ + owner: skill.owner, + name: skill.name, + description: skill.description, + files: String(skill.files.length), + updated: new Date(skill.updatedAt).toISOString(), + })); + + const widthOf = (key: "owner" | "name" | "files"): number => + Math.max(header[key].length, ...rows.map((row) => row[key].length)); + const ownerWidth = widthOf("owner"); + const nameWidth = widthOf("name"); + const filesWidth = widthOf("files"); + + const line = (row: (typeof rows)[number] | typeof header): string => + `${row.owner.padEnd(ownerWidth)} ${row.name.padEnd(nameWidth)} ${row.description} ${row.files.padEnd(filesWidth)} ${row.updated}`; + + return [line(header), ...rows.map(line)]; +}; + +// --------------------------------------------------------------------------- +// Marker file — `.executor-skill.json`, written into every skill directory +// this CLI manages. Its presence (and matching `origin`) is the only thing +// that authorizes `pull` to overwrite or delete a directory. +// --------------------------------------------------------------------------- + +export const SKILL_MARKER_FILENAME = ".executor-skill.json"; + +export interface SkillMarker { + readonly origin: string; + readonly owner: Owner; + readonly name: string; + /** File path (relative to the skill root) -> digest, as last written. */ + readonly digests: Readonly>; +} + +const isOwner = (value: unknown): value is Owner => value === "org" || value === "user"; + +/** Parse a marker file's contents. Returns `undefined` for anything that + * isn't a well-formed marker — including a directory some other tool made, + * which must never be treated as ours. */ +export const parseSkillMarker = (raw: string): SkillMarker | undefined => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) return undefined; + const candidate = parsed as Record; + if (typeof candidate.origin !== "string") return undefined; + if (!isOwner(candidate.owner)) return undefined; + if (typeof candidate.name !== "string") return undefined; + if (typeof candidate.digests !== "object" || candidate.digests === null) return undefined; + + const digests: Record = {}; + for (const [path, digest] of Object.entries(candidate.digests as Record)) { + if (typeof digest !== "string") return undefined; + digests[path] = digest; + } + + return { origin: candidate.origin, owner: candidate.owner, name: candidate.name, digests }; +}; + +export const serializeSkillMarker = (marker: SkillMarker): string => + `${JSON.stringify(marker, null, 2)}\n`; + +export const skillMarkerFor = (input: { + readonly origin: string; + readonly skill: Pick; + readonly files: readonly SkillFileEntry[]; +}): SkillMarker => ({ + origin: input.origin, + owner: input.skill.owner, + name: input.skill.name, + digests: Object.fromEntries(input.files.map((file) => [file.path, file.digest])), +}); + +// --------------------------------------------------------------------------- +// `pull` — planning (pure). Decides add/update/unchanged/remove/skip per +// skill directory without touching the network or filesystem. +// --------------------------------------------------------------------------- + +export type SkillPullActionKind = "add" | "update" | "unchanged" | "remove" | "skip"; + +export interface SkillPullAction { + readonly name: string; + readonly kind: SkillPullActionKind; + readonly reason?: string; +} + +/** What `pull` found on disk for one directory entry under the target root. */ +export interface ExistingSkillEntry { + readonly name: string; + /** `undefined` when there's no marker file, or it doesn't parse: an + * unmanaged directory that `pull` must never overwrite or delete. */ + readonly marker: SkillMarker | undefined; +} + +const digestsEqual = ( + a: Readonly>, + b: Readonly>, +): boolean => { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => a[key] === b[key]); +}; + +/** Plan one target root's worth of `pull` actions. Call once per root (the + * main `--dir` and, unless `--no-claude`, the `~/.claude/skills` mirror). */ +export const planSkillsPull = (input: { + readonly origin: string; + /** Effective (already-shadowed) skills this owner scope can see. */ + readonly skills: readonly SkillSummary[]; + /** What's already on disk under the target root. */ + readonly existing: readonly ExistingSkillEntry[]; +}): readonly SkillPullAction[] => { + const actions: SkillPullAction[] = []; + const skillNames = new Set(input.skills.map((skill) => skill.name)); + const existingByName = new Map(input.existing.map((entry) => [entry.name, entry])); + + for (const skill of input.skills) { + const existing = existingByName.get(skill.name); + const desiredDigests = Object.fromEntries(skill.files.map((file) => [file.path, file.digest])); + + if (!existing) { + actions.push({ name: skill.name, kind: "add" }); + continue; + } + if (!existing.marker) { + actions.push({ + name: skill.name, + kind: "skip", + reason: `${skill.name}/ exists without a ${SKILL_MARKER_FILENAME} marker`, + }); + continue; + } + if (existing.marker.origin !== input.origin) { + actions.push({ + name: skill.name, + kind: "skip", + reason: `${skill.name}/ is managed by a different server (${existing.marker.origin})`, + }); + continue; + } + actions.push({ + name: skill.name, + kind: digestsEqual(existing.marker.digests, desiredDigests) ? "unchanged" : "update", + }); + } + + for (const existing of input.existing) { + if (skillNames.has(existing.name)) continue; + if (!existing.marker) continue; // never touch a directory we don't own + if (existing.marker.origin !== input.origin) continue; // owned by another server + actions.push({ name: existing.name, kind: "remove", reason: "no longer exists upstream" }); + } + + return actions; +}; + +export interface SkillPullSummary { + readonly added: number; + readonly updated: number; + readonly unchanged: number; + readonly removed: number; + readonly skipped: number; +} + +export const summarizeSkillPullActions = ( + actions: readonly SkillPullAction[], +): SkillPullSummary => ({ + added: actions.filter((action) => action.kind === "add").length, + updated: actions.filter((action) => action.kind === "update").length, + unchanged: actions.filter((action) => action.kind === "unchanged").length, + removed: actions.filter((action) => action.kind === "remove").length, + skipped: actions.filter((action) => action.kind === "skip").length, +}); + +export const formatSkillPullSummaryLine = (summary: SkillPullSummary): string => + `${summary.added} added, ${summary.updated} updated, ${summary.removed} removed, ${summary.skipped} skipped` + + (summary.unchanged > 0 ? ` (${summary.unchanged} unchanged)` : ""); + +// --------------------------------------------------------------------------- +// Filesystem I/O — reading what's on disk and applying a plan. Kept small and +// separate from the planning above so tests can exercise the pure logic +// without a real (or even temp) filesystem; only the handful of tests that +// need real directory semantics use a temp dir. +// --------------------------------------------------------------------------- + +/** List the immediate subdirectories of `root` and read each one's marker, + * if any. Returns `[]` when `root` doesn't exist yet. */ +export const readExistingSkillEntries = ( + root: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const rootExists = yield* fs.exists(root); + if (!rootExists) return []; + + const entries = yield* fs.readDirectory(root); + const results: ExistingSkillEntry[] = []; + for (const name of entries) { + const entryPath = path.join(root, name); + const info = yield* fs.stat(entryPath); + if (info.type !== "Directory") continue; + + const markerPath = path.join(entryPath, SKILL_MARKER_FILENAME); + const hasMarker = yield* fs.exists(markerPath); + if (!hasMarker) { + results.push({ name, marker: undefined }); + continue; + } + const raw = yield* fs.readFileString(markerPath).pipe(Effect.orElseSucceed(() => "")); + results.push({ name, marker: parseSkillMarker(raw) }); + } + return results; + }); + +/** Write one skill's files (and its marker) into `/`, creating + * parent directories for nested file paths as needed. */ +export const writeSkillDirectory = (input: { + readonly root: string; + readonly origin: string; + readonly skill: SkillDetail; +}): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(input.root, input.skill.name); + + yield* fs.makeDirectory(skillDir, { recursive: true }); + for (const file of input.skill.files) { + const filePath = path.join(skillDir, file.path); + const parent = path.dirname(filePath); + if (parent !== skillDir) { + yield* fs.makeDirectory(parent, { recursive: true }); + } + yield* fs.writeFileString(filePath, file.content); + } + + const marker = skillMarkerFor({ + origin: input.origin, + skill: input.skill, + files: input.skill.files, + }); + yield* fs.writeFileString( + path.join(skillDir, SKILL_MARKER_FILENAME), + serializeSkillMarker(marker), + ); + }); + +/** Remove `/` entirely. Only called for `"remove"` actions, which + * `planSkillsPull` only ever produces for marker-bearing directories whose + * marker origin matches the current server. */ +export const removeSkillDirectory = ( + root: string, + name: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(path.join(root, name), { recursive: true, force: true }); + }); diff --git a/apps/cloud/drizzle/0018_eager_jack_power.sql b/apps/cloud/drizzle/0018_eager_jack_power.sql new file mode 100644 index 0000000000..ac359ad4b2 --- /dev/null +++ b/apps/cloud/drizzle/0018_eager_jack_power.sql @@ -0,0 +1,14 @@ +CREATE TABLE "skill" ( + "name" varchar(255) NOT NULL, + "description" text NOT NULL, + "frontmatter" json NOT NULL, + "files" json NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "skill_uidx" ON "skill" USING btree ("tenant","owner","subject","name"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0018_snapshot.json b/apps/cloud/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000000..e2adab831f --- /dev/null +++ b/apps/cloud/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1611 @@ +{ + "id": "6782ceae-ca96-4a99-b64e-1841949f2366", + "prevId": "42251aa3-ae24-4010-ac65-9f41e26cdc20", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frontmatter": { + "name": "frontmatter", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "skill_uidx": { + "name": "skill_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 375397ceca..fbb16d643c 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1788287088210, "tag": "0017_lush_thunderbolts", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1789347558605, + "tag": "0018_eager_jack_power", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..e6bcbb40e8 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -251,6 +251,26 @@ export const artifact = pgTable( (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], ); +export const skill = pgTable( + "skill", + { + name: varchar("name", { length: 255 }).notNull(), + description: text("description").notNull(), + frontmatter: json("frontmatter").notNull(), + files: json("files").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("skill_uidx").on(table.tenant, table.owner, table.subject, table.name)], +); + export const plugin_storage = pgTable( "plugin_storage", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb9..f4da6e7f9c 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -36,6 +36,7 @@ import { oauth_client, oauth_session, plugin_storage, + skill, subject, tool, tool_policy, @@ -158,6 +159,18 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(skill).values({ + name: `skill-${tag}`, + description: "House style", + frontmatter: { name: `skill-${tag}`, description: "House style" }, + files: [], + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -185,6 +198,7 @@ const TENANT_TABLES = [ plugin_storage, subject, artifact, + skill, ] as const; // Tables that are NOT purged by org id, each with the reason it is exempt. Any diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index b2a922a3fd..4ee7b75f14 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -24,6 +24,7 @@ import { oauth_client, oauth_session, plugin_storage, + skill, subject, tool, tool_policy, @@ -52,6 +53,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); + await tx.delete(skill).where(eq(skill.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 2646a785a0..56f55a3a0b 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -383,6 +383,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase => { onSome: ({ uri }) => (uri ? { "mcp.resource.uri": uri } : {}), }), ), + Match.when("skills/get", () => + Option.match(decodeUriParams(params), { + onNone: () => ({}) as Record, + onSome: ({ uri }) => (uri ? { "mcp.resource.uri": uri } : {}), + }), + ), Match.when("prompts/get", () => Option.match(decodeNamedParams(params), { onNone: () => ({}) as Record, diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index d9641402b0..916a74a946 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport } from './../../../packages/react/src/routes/skills' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as OrgRouteImport } from './routes/app/org' @@ -23,12 +24,14 @@ import { Route as BillingRouteImport } from './routes/app/billing' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport } from './../../../packages/react/src/routes/skills.new' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport } from './../../../packages/react/src/routes/skills.$skillOwner.$skillName' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' const SetupMcpRoute = SetupMcpRouteImport.update({ @@ -70,6 +73,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = path: '/{-$orgSlug}/toolkits', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const SecretsRoute = SecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -111,6 +120,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any) const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', path: '/{-$orgSlug}/resume/$executionId', @@ -154,6 +170,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update( + { + id: '/$skillOwner/$skillName', + path: '/$skillOwner/$skillName', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update( { @@ -173,6 +198,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -183,8 +209,10 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRoutesByTo { '/create-org': typeof CreateOrgRoute @@ -196,6 +224,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -206,8 +235,10 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -220,6 +251,7 @@ export interface FileRoutesById { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -230,8 +262,10 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -245,6 +279,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -255,8 +290,10 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' fileRoutesByTo: FileRoutesByTo to: | '/create-org' @@ -268,6 +305,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -278,8 +316,10 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' id: | '__root__' | '/create-org' @@ -291,6 +331,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -301,8 +342,10 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -315,6 +358,7 @@ export interface RootRouteChildren { OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -378,6 +422,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -427,6 +478,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -469,6 +527,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillOwner/$skillName': { + id: '/{-$orgSlug}/skills/$skillOwner/$skillName' + path: '/$skillOwner/$skillName' + fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/integrations/add/$pluginKey': { id: '/{-$orgSlug}/integrations/add/$pluginKey' path: '/{-$orgSlug}/integrations/add/$pluginKey' @@ -494,6 +559,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -521,6 +604,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index ba4f2ff861..67ffcb1b8a 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -164,6 +164,7 @@ export class McpSessionDO extends McpAgentSessionDOBase rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', @@ -69,6 +78,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( { @@ -110,6 +126,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update( + { + id: '/$skillOwner/$skillName', + path: '/$skillOwner/$skillName', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -131,6 +156,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -139,14 +165,17 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -155,15 +184,18 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -172,9 +204,11 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -182,6 +216,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -190,14 +225,17 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' @@ -206,14 +244,17 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' id: | '__root__' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -222,15 +263,18 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' fileRoutesById: FileRoutesById } export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute @@ -265,6 +309,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -293,6 +344,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -328,6 +386,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillOwner/$skillName': { + id: '/{-$orgSlug}/skills/$skillOwner/$skillName' + path: '/$skillOwner/$skillName' + fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -360,6 +425,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -382,6 +465,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: diff --git a/apps/local/drizzle/0007_same_vengeance.sql b/apps/local/drizzle/0007_same_vengeance.sql new file mode 100644 index 0000000000..6972d06aac --- /dev/null +++ b/apps/local/drizzle/0007_same_vengeance.sql @@ -0,0 +1,14 @@ +CREATE TABLE `skill` ( + `name` text NOT NULL, + `description` text NOT NULL, + `frontmatter` text NOT NULL, + `files` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `skill_uidx` ON `skill` (`tenant`,`owner`,`subject`,`name`); \ No newline at end of file diff --git a/apps/local/drizzle/meta/0007_snapshot.json b/apps/local/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..92f21115c2 --- /dev/null +++ b/apps/local/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1034 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e5c498c3-9c8a-4bcb-b781-e4a48afac4cc", + "prevId": "89a4fd1b-f0f6-4482-a991-0db78c859f76", + "tables": { + "blob": { + "name": "blob", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connection": { + "name": "connection", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill": { + "name": "skill", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "files": { + "name": "files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skill_uidx": { + "name": "skill_uidx", + "columns": ["tenant", "owner", "subject", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_policy": { + "name": "tool_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index dbe786204c..f5878ffc58 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1788255902609, "tag": "0006_bored_landau", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1789348020175, + "tag": "0007_same_vengeance", + "breakpoints": true } ] } diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index a7f6ceb48e..b8e8ae9eac 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -188,6 +188,23 @@ export const tool_policy = sqliteTable( ], ); +export const skill = sqliteTable( + "skill", + { + name: text("name").notNull(), + description: text("description").notNull(), + frontmatter: text("frontmatter").notNull(), + files: text("files").notNull(), + created_at: integer("created_at").notNull(), + updated_at: integer("updated_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), + }, + (table) => [uniqueIndex("skill_uidx").on(table.tenant, table.owner, table.subject, table.name)], +); + export const plugin_storage = sqliteTable( "plugin_storage", { diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 2ef674c572..80cc0418bd 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -122,6 +122,7 @@ export const createServerHandlers = async (token: string): Promise=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], @@ -6401,8 +6402,6 @@ "agents/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "agents/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -6503,6 +6502,8 @@ "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "effect/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -6597,6 +6598,8 @@ "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "knip/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "macos-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], diff --git a/e2e/scenarios/skills.test.ts b/e2e/scenarios/skills.test.ts new file mode 100644 index 0000000000..ddd5a77cf5 --- /dev/null +++ b/e2e/scenarios/skills.test.ts @@ -0,0 +1,142 @@ +// Cross-target: Agent Skills, end to end. +// +// A user saves a SKILL.md directory to their workspace through the console's +// API. The product promise under test is that every agent connected over MCP +// sees it next — in the `skills` tool's description (the catalog), in its index, +// and as loadable instructions with the bundled files reachable one at a time. +// Delete it, and the agent stops seeing it. The console and the agent share one +// store, not two caches. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { SkillName } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); + +/** Selfhost shares one workspace across scenarios, so every name is unique to + * this run and assertions look for "mine", never "the only one". */ +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +const skillMarkdown = (name: string, marker: string) => + [ + "---", + `name: ${name}`, + `description: Release-notes house style. Use when drafting release notes (${marker}).`, + "metadata:", + ' version: "1.0"', + "---", + "", + "# Release notes", + "", + `Lead with the user-visible change. Marker: ${marker}.`, + "See `references/tone.md` for the voice.", + ].join("\n"); + +scenario( + "Skills · a skill saved to the workspace is what a connected agent loads next", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + const session = mcp.session(identity); + + const suffix = uniqueSuffix(); + const name = SkillName.make(`release-notes-${suffix}`); + const marker = `skill-ok-${suffix}`; + const toneMarker = `tone-ok-${suffix}`; + + yield* Effect.gen(function* () { + // Save through the same endpoint the console's editor uses. + const saved = yield* client.skills.save({ + payload: { + owner: "user", + files: [ + { path: "SKILL.md", content: skillMarkdown(name, marker) }, + { path: "references/tone.md", content: `# Tone\n\nPlain and direct. ${toneMarker}` }, + ], + }, + }); + expect(saved.name, "the name comes from the frontmatter").toBe(name); + expect( + saved.files.map((file) => file.path), + "SKILL.md leads the stored manifest", + ).toEqual(["SKILL.md", "references/tone.md"]); + for (const file of saved.files) { + expect(file.digest, `${file.path} carries a sha256 digest`).toMatch( + /^sha256:[0-9a-f]{64}$/, + ); + } + + // A fresh MCP session sees the skill in the tool's own description — the + // catalog a model reads before it asks for anything. + const tools = yield* session.describeTools(); + const skillsTool = tools.find((tool) => tool.name === "skills"); + expect(skillsTool?.description, "the skills tool advertises the saved skill").toContain( + `\`${name}\``, + ); + + const index = yield* session.call("skills", {}); + expect(index.text, "the index lists the saved skill next to Executor's docs").toContain( + `\`${name}\``, + ); + expect(index.text).toContain("`execute`"); + + const loaded = yield* session.call("skills", { name }); + expect(loaded.ok, `loading the skill succeeds: ${loaded.text}`).toBe(true); + expect(loaded.text, "the body is served with its frontmatter stripped").toContain( + `Marker: ${marker}`, + ); + expect(loaded.text).not.toContain("description: Release-notes"); + expect(loaded.text, "bundled files are listed, not inlined").toContain( + "references/tone.md", + ); + expect(loaded.text).not.toContain(toneMarker); + + const tone = yield* session.call("skills", { name, file: "references/tone.md" }); + expect(tone.ok, `reading a bundled file succeeds: ${tone.text}`).toBe(true); + expect(tone.text, "the bundled file's content is returned verbatim").toContain(toneMarker); + + // Delete in the console; the agent's next call misses. + yield* client.skills.remove({ params: { owner: "user", name } }); + const gone = yield* session.call("skills", { name }); + expect(gone.ok, "a removed skill no longer loads").toBe(false); + expect(gone.text).toContain(`No skill named "${name}"`); + }).pipe( + Effect.ensuring( + client.skills.remove({ params: { owner: "user", name } }).pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Skills · a malformed SKILL.md is refused with the reason", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + + const result = yield* client.skills + .save({ + payload: { + owner: "user", + files: [{ path: "SKILL.md", content: "---\nname: Not Valid\ndescription: x\n---\n" }], + }, + }) + .pipe(Effect.result); + expect(result._tag, "the save is refused").toBe("Failure"); + if (result._tag !== "Failure") return; + expect(String(result.failure._tag), "as an invalid-skill error").toBe("InvalidSkillError"); + }), +); diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 649117c693..19ec0b7e31 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -12,15 +12,18 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport } from './../../react/src/routes/skills' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport } from './../../react/src/routes/skills.new' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' +import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport } from './../../react/src/routes/skills.$skillOwner.$skillName' import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey' @@ -42,6 +45,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute = path: '/{-$orgSlug}/toolkits', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport.update({ + id: '/{-$orgSlug}/skills', + path: '/{-$orgSlug}/skills', + getParentRoute: () => rootRouteImport, + } as any) const SecretsRoute = SecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -65,6 +74,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = path: '/$toolkitSlug', getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute, } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsRoute, + } as any) const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', @@ -95,6 +110,14 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute = path: '/$artifactId', getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesArtifactsRoute, } as any) +const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute = + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update( + { + id: '/$skillOwner/$skillName', + path: '/$skillOwner/$skillName', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsRoute, + } as any, + ) const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({ id: '/{-$orgSlug}/plugins/$pluginId/$', @@ -114,6 +137,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -122,14 +146,17 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -138,15 +165,18 @@ export interface FileRoutesByTo { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -155,9 +185,11 @@ export interface FileRoutesById { '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute + '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -165,6 +197,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -173,14 +206,17 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' @@ -189,14 +225,17 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' id: | '__root__' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' @@ -205,15 +244,18 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/skills/new' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' + | '/{-$orgSlug}/skills/$skillOwner/$skillName' fileRoutesById: FileRoutesById } export interface RootRouteChildren { DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute + DotDotDotDotDotDotReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute @@ -248,6 +290,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/skills': { + id: '/{-$orgSlug}/skills' + path: '/{-$orgSlug}/skills' + fullPath: '/{-$orgSlug}/skills' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -276,6 +325,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRoute } + '/{-$orgSlug}/skills/new': { + id: '/{-$orgSlug}/skills/new' + path: '/new' + fullPath: '/{-$orgSlug}/skills/new' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -311,6 +367,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRoute } + '/{-$orgSlug}/skills/$skillOwner/$skillName': { + id: '/{-$orgSlug}/skills/$skillOwner/$skillName' + path: '/$skillOwner/$skillName' + fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -343,6 +406,24 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren = DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren, ) +interface DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren { + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute +} + +const DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren = + { + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute, + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute, + } + +const DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren = + DotDotDotDotDotDotReactSrcRoutesSkillsRoute._addFileChildren( + DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren, + ) + interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -364,6 +445,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, + DotDotDotDotDotDotReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesToolsRoute: diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 5a339889e6..37d0fbed07 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -177,6 +177,7 @@ function SidebarContent(props: { const isPolicies = props.pathname === "/policies"; const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/"); const isArtifacts = props.pathname === "/artifacts" || props.pathname.startsWith("/artifacts/"); + const isSkills = props.pathname === "/skills" || props.pathname.startsWith("/skills/"); return ( <> @@ -216,6 +217,12 @@ function SidebarContent(props: { active={isToolkits} onNavigate={props.onNavigate} /> + = new Set([ "tools", "toolkits", "artifacts", + "skills", "resume", "plugins", "api-keys", diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 4bbe145e23..6f2ef28aec 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -9,6 +9,7 @@ import { ExecutionsApi } from "./executions/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; import { ArtifactsApi } from "./artifacts/api"; +import { SkillsApi } from "./skills/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -19,6 +20,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(OAuthApi) .add(PoliciesApi) .add(ArtifactsApi) + .add(SkillsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 360952bd7d..03879431e2 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -8,6 +8,7 @@ import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; import { ArtifactsHandlers } from "./artifacts"; +import { SkillsHandlers } from "./skills"; export { ToolsHandlers } from "./tools"; export { IntegrationsHandlers } from "./integrations"; @@ -17,6 +18,7 @@ export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; export { ArtifactsHandlers } from "./artifacts"; +export { SkillsHandlers } from "./skills"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -27,4 +29,5 @@ export const CoreHandlers = Layer.mergeAll( OAuthHandlers, PoliciesHandlers, ArtifactsHandlers, + SkillsHandlers, ); diff --git a/packages/core/api/src/handlers/skills.ts b/packages/core/api/src/handlers/skills.ts new file mode 100644 index 0000000000..70f88e7b78 --- /dev/null +++ b/packages/core/api/src/handlers/skills.ts @@ -0,0 +1,60 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import type { Skill, SkillSummary } from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +const summaryToResponse = (skill: SkillSummary) => ({ + owner: skill.owner, + name: skill.name, + description: skill.description, + frontmatter: skill.frontmatter, + files: skill.files, + createdAt: skill.createdAt.getTime(), + updatedAt: skill.updatedAt.getTime(), +}); + +const skillToResponse = (skill: Skill) => ({ + ...summaryToResponse(skill), + files: skill.files, +}); + +export const SkillsHandlers = HttpApiBuilder.group(ExecutorApi, "skills", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const skills = yield* executor.skills.list(); + return skills.map(summaryToResponse); + }), + ), + ) + .handle("get", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse(yield* executor.skills.get(params)); + }), + ), + ) + .handle("save", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return skillToResponse(yield* executor.skills.save(payload)); + }), + ), + ) + .handle("remove", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.skills.remove(params); + return { removed: true }; + }), + ), + ), +); diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 2b7f8ea2e1..da56a677df 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -38,6 +38,7 @@ export { } from "./oauth-popup"; export { PoliciesApi } from "./policies/api"; export { ArtifactsApi } from "./artifacts/api"; +export { SkillsApi } from "./skills/api"; export { AccountApi, AccountHttpApi, diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 2512436557..77dd5194cb 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -70,6 +70,7 @@ export const makeMcpBuildServer = createExecutorMcpServer({ engine, artifacts: executor.artifacts, + skills: executor.skills, connections: executor.connections, ...(hostOptions?.loadAppShellHtml ? { loadAppShellHtml: hostOptions.loadAppShellHtml } diff --git a/packages/core/api/src/skills/api.ts b/packages/core/api/src/skills/api.ts new file mode 100644 index 0000000000..b753545904 --- /dev/null +++ b/packages/core/api/src/skills/api.ts @@ -0,0 +1,88 @@ +// --------------------------------------------------------------------------- +// Skills HTTP API — Agent Skills (SKILL.md directories) saved to the workspace. +// +// A skill is identified by `(owner, name)`: `org` skills are shared with the +// whole workspace, `user` skills are personal. The name comes from the SKILL.md +// frontmatter, so `save` takes only the owner and the files. Reads return what +// the bound owner scope may see, exactly like connections. +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { + InternalError, + InvalidSkillError, + OrgWriteDeniedError, + Owner, + SkillName, + SkillNotFoundError, +} from "@executor-js/sdk/shared"; + +const SkillParams = { owner: Owner, name: SkillName }; + +const SkillFileEntryResponse = Schema.Struct({ + path: Schema.String, + size: Schema.Number, + digest: Schema.String, +}); + +const SkillFileResponse = Schema.Struct({ + ...SkillFileEntryResponse.fields, + content: Schema.String, +}); + +/** What a list returns: the manifest without file contents. */ +export const SkillSummaryResponse = Schema.Struct({ + owner: Owner, + name: SkillName, + description: Schema.String, + frontmatter: Schema.Record(Schema.String, Schema.Unknown), + files: Schema.Array(SkillFileEntryResponse), + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +export const SkillResponse = Schema.Struct({ + ...SkillSummaryResponse.fields, + files: Schema.Array(SkillFileResponse), +}); + +const SkillFileInputSchema = Schema.Struct({ + path: Schema.String, + content: Schema.String, +}); + +/** Create or replace: the skill's name is read from `SKILL.md`. */ +const SaveSkillPayload = Schema.Struct({ + owner: Owner, + files: Schema.Array(SkillFileInputSchema), +}); + +export const SkillsApi = HttpApiGroup.make("skills") + .add( + HttpApiEndpoint.get("list", "/skills", { + success: Schema.Array(SkillSummaryResponse), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get("get", "/skills/:owner/:name", { + params: SkillParams, + success: SkillResponse, + error: [InternalError, SkillNotFoundError], + }), + ) + .add( + HttpApiEndpoint.put("save", "/skills", { + payload: SaveSkillPayload, + success: SkillResponse, + error: [InternalError, InvalidSkillError, OrgWriteDeniedError], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/skills/:owner/:name", { + params: SkillParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: [InternalError, OrgWriteDeniedError], + }), + ); diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 52f5ebced7..df1035478f 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -104,7 +104,8 @@ "@standard-schema/spec": "^1.1.0", "fractional-indexing": "^3.2.0", "oauth4webapi": "^3.8.5", - "tldts": "^7.0.28" + "tldts": "^7.0.28", + "yaml": "^2.9.0" }, "devDependencies": { "@effect/atom-react": "catalog:", diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..b56c79f96c 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -416,6 +416,29 @@ export const coreTables = defineTables({ ["tenant", "owner", "subject", "id"], ), + // An Agent Skill (https://agentskills.io) saved to the workspace: a SKILL.md + // plus any bundled files, served to every connected agent through the MCP + // `skills` tool and the MCP Skills Extension. Owner-scoped like a connection: + // `org` rows are shared with the whole workspace, `user` rows are personal. + skill: ownedExecutorTable( + "skill", + { + // The frontmatter `name`; also the identity within the owner partition. + name: keyColumn("name"), + // The frontmatter `description`, denormalized so lists never parse files. + description: textColumn("description"), + // The SKILL.md frontmatter, verbatim, as a JSON object. The MCP Skills + // Extension serves it field-for-field, unknown keys included. + frontmatter: jsonColumn("frontmatter"), + // Every file of the skill, SKILL.md included: + // `[{ path, content, size, digest }]`. Text only in v1. + files: jsonColumn("files"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "name"], + ), + // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped). plugin_storage: ownedExecutorTable( "plugin_storage", @@ -492,6 +515,17 @@ export const ARTIFACT_SUMMARY_COLUMNS = [ ] as const satisfies readonly (keyof ArtifactRow)[]; /** The artifact-row projection {@link ARTIFACT_SUMMARY_COLUMNS} selects. */ export type ArtifactSummaryRow = Pick; +export type SkillRow = FumaRow; +/** The skill-row projection lists select: everything but the file contents. */ +export const SKILL_SUMMARY_COLUMNS = [ + "owner", + "name", + "description", + "frontmatter", + "files", + "created_at", + "updated_at", +] as const satisfies readonly (keyof SkillRow)[]; export type PluginStorageRow = FumaRow; export type BlobRow = FumaRow; diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 2e22ff0ca7..660bc1b70c 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -8,6 +8,7 @@ import { IntegrationSlug, Owner, ProviderKey, + SkillName, ToolAddress, } from "./ids"; @@ -279,6 +280,42 @@ export class ArtifactNotFoundError extends Schema.TaggedErrorClass()( + "SkillNotFoundError", + { owner: Owner, name: SkillName }, + { httpApiStatus: 404 }, +) { + override get message(): string { + return `Skill not found: ${this.owner}/${this.name}`; + } +} + +/** The uploaded skill does not conform to the Agent Skills specification (bad + * frontmatter, an invalid name, a missing SKILL.md, a file path outside the + * skill, or a size over the limit). `reason` is written for the author. */ +export class InvalidSkillError + extends Schema.TaggedErrorClass()( + "InvalidSkillError", + { reason: Schema.String }, + { httpApiStatus: 400 }, + ) + implements UserActionableError +{ + readonly __executorUserActionable = true; + readonly code = "invalid_skill"; + get userMessage(): string { + return this.reason; + } + override get message(): string { + return `Invalid skill: ${this.reason}`; + } +} + // --------------------------------------------------------------------------- // Union — the failure channel of `execute`. // --------------------------------------------------------------------------- @@ -300,4 +337,6 @@ export type ExecutorError = | ExecuteError | IntegrationNotFoundError | IntegrationRemovalNotAllowedError - | ArtifactNotFoundError; + | ArtifactNotFoundError + | SkillNotFoundError + | InvalidSkillError; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index efaf119212..1196443ac3 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -10,6 +10,7 @@ import { Option, Predicate, Ref, + Result, Schema, Semaphore, } from "effect"; @@ -97,6 +98,17 @@ import { type SetArtifactPreviewInput, } from "./artifact"; import { + rowToSkill, + toSkillSummary, + prepareSkillFiles, + type SaveSkillInput, + type Skill, + type SkillRef, + type SkillSummary, +} from "./skill"; +import { + SkillNotFoundError, + InvalidSkillError, ArtifactNotFoundError, ConnectionAlreadyExistsError, ConnectionNotFoundError, @@ -115,6 +127,7 @@ import { } from "./errors"; import { ArtifactId, + SkillName, AuthTemplateSlug, ConnectionAddress, ConnectionName, @@ -518,6 +531,22 @@ export type Executor = { ) => Effect.Effect; }; + /** + * Agent Skills saved to the workspace, visible to the bound owner scope: this + * subject's personal skills plus the org's shared ones. + */ + readonly skills: { + /** Newest first, manifests only — file contents stay out of lists. */ + readonly list: () => Effect.Effect; + readonly get: (ref: SkillRef) => Effect.Effect; + /** Create, or replace in place the skill with the same `(owner, name)`. + * The name is read from the SKILL.md frontmatter. */ + readonly save: ( + input: SaveSkillInput, + ) => Effect.Effect; + readonly remove: (ref: SkillRef) => Effect.Effect; + }; + /** * Approvals recorded for artifact-originated calls that paused on a human. * @@ -6118,6 +6147,85 @@ export const createExecutor = => core.deleteMany("artifact", { where: artifactById(input.id) }); + // ------------------------------------------------------------------ + // Skills + // ------------------------------------------------------------------ + + // Reads take no explicit owner beyond the ref: the owner policy already + // narrows to the rows this binding may see (org rows plus this subject's + // own), so `(owner, name)` is enough to pin one visible row. + const skillByRef = + (ref: SkillRef): CoreWhere => + (b: AnyCb) => + b.and(b("owner", "=", ref.owner), b("name", "=", ref.name)); + + const skillsList = (): Effect.Effect => + core + .findMany("skill", { + orderBy: [ + ["updated_at", "desc"], + ["name", "asc"], + ], + }) + .pipe(Effect.map((rows) => rows.map((row) => toSkillSummary(rowToSkill(row))))); + + const skillsGet = (ref: SkillRef): Effect.Effect => + Effect.gen(function* () { + const row = yield* core.findFirst("skill", { where: skillByRef(ref) }); + if (!row) { + return yield* new SkillNotFoundError({ + owner: ref.owner, + name: SkillName.make(ref.name), + }); + } + return rowToSkill(row); + }); + + const skillsSave = ( + input: SaveSkillInput, + ): Effect.Effect => + Effect.gen(function* () { + yield* guardOrgWrite(input.owner); + yield* requireUserSubject(input.owner); + const prepared = yield* Effect.promise(() => prepareSkillFiles(input.files)); + if (Result.isFailure(prepared)) return yield* prepared.failure; + const { parsed, files } = prepared.success; + const now = new Date(); + const ref: SkillRef = { owner: input.owner, name: parsed.name }; + const set = { + description: parsed.description, + frontmatter: parsed.frontmatter, + files, + updated_at: now, + }; + const existing = yield* core.findFirst("skill", { where: skillByRef(ref) }); + if (existing) { + yield* core.updateMany("skill", { where: skillByRef(ref), set }); + return rowToSkill({ ...existing, ...set }); + } + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const created = yield* core.create("skill", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + name: parsed.name, + ...set, + created_at: now, + }); + return rowToSkill(created); + }); + + const skillsRemove = ( + ref: SkillRef, + ): Effect.Effect => + Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); + yield* core.deleteMany("skill", { where: skillByRef(ref) }); + }); + // ------------------------------------------------------------------ // Elicitation // ------------------------------------------------------------------ @@ -7082,6 +7190,12 @@ export const createExecutor = ` over the same bytes. */ + readonly digest: string; +} + +export type SkillFile = SkillFileEntry & { readonly content: string }; + +export interface SkillSummary { + readonly owner: Owner; + readonly name: SkillName; + readonly description: string; + /** The SKILL.md frontmatter, verbatim, every field the author wrote. */ + readonly frontmatter: Readonly>; + /** The manifest — paths, sizes, digests — without content. */ + readonly files: readonly SkillFileEntry[]; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface Skill extends Omit { + readonly files: readonly SkillFile[]; +} + +/** Save (create or replace in place) the skill the files describe. The name + * comes from the SKILL.md frontmatter, never from the caller. */ +export interface SaveSkillInput { + readonly owner: Owner; + readonly files: readonly SkillFileInput[]; +} + +export interface SkillRef { + readonly owner: Owner; + readonly name: string; +} + +// --------------------------------------------------------------------------- +// Limits and reserved names +// --------------------------------------------------------------------------- + +export const SKILL_MD_PATH = "SKILL.md"; +/** Files per skill, SKILL.md included. */ +export const SKILL_MAX_FILES = 64; +/** Total UTF-8 bytes across every file of one skill. */ +export const SKILL_MAX_TOTAL_BYTES = 1024 * 1024; +export const SKILL_NAME_MAX_LENGTH = 64; +export const SKILL_DESCRIPTION_MAX_LENGTH = 1024; +export const SKILL_COMPATIBILITY_MAX_LENGTH = 500; + +/** Names the MCP `skills` tool already answers with Executor's own docs; a + * workspace skill under one of them would be unreachable by name. */ +export const SKILL_RESERVED_NAMES: ReadonlySet = new Set([ + "execute", + "create-artifact", + "artifact-style", +]); + +const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Whether `name` satisfies the specification's `name` rules. */ +export const isValidSkillName = (name: string): boolean => + name.length >= 1 && name.length <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name); + +// --------------------------------------------------------------------------- +// Frontmatter +// --------------------------------------------------------------------------- + +export interface ParsedSkillMarkdown { + readonly name: SkillName; + readonly description: string; + readonly frontmatter: Readonly>; + /** The markdown after the closing `---`, trimmed. */ + readonly body: string; +} + +const invalid = (reason: string) => Result.fail(new InvalidSkillError({ reason })); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Locate the frontmatter block: `---` on the first line, `---` alone on a + * later line. Returns the YAML between them and the body after. */ +const splitFrontmatter = ( + markdown: string, +): Option.Option<{ readonly yaml: string; readonly body: string }> => { + const text = markdown.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); + const lines = text.split("\n"); + if (lines[0]?.trim() !== "---") return Option.none(); + const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + if (closing === -1) return Option.none(); + return Option.some({ + yaml: lines.slice(1, closing).join("\n"), + body: lines + .slice(closing + 1) + .join("\n") + .trim(), + }); +}; + +/** + * Split a SKILL.md into frontmatter and body and validate the required fields. + * + * Strict on what the specification makes strict (`name` shape, `description` + * presence and length, `compatibility` length, `metadata` shape) and open on + * everything else: unknown keys are kept verbatim, because the MCP Skills + * Extension promises hosts the frontmatter exactly as the author wrote it. + */ +export const parseSkillMarkdown = ( + markdown: string, +): Result.Result => { + const split = splitFrontmatter(markdown); + if (Option.isNone(split)) { + return invalid( + "SKILL.md must begin with a `---` line, followed by YAML frontmatter and a closing `---` line.", + ); + } + const { yaml, body } = split.value; + + const parsed = Result.try({ + try: () => parseYaml(yaml) as unknown, + catch: () => new InvalidSkillError({ reason: "SKILL.md frontmatter is not valid YAML." }), + }); + if (Result.isFailure(parsed)) return Result.fail(parsed.failure); + const frontmatter = parsed.success; + if (!isRecord(frontmatter)) { + return invalid("SKILL.md frontmatter must be a YAML mapping of fields."); + } + + const name = frontmatter.name; + if (typeof name !== "string" || !isValidSkillName(name)) { + return invalid( + "Frontmatter `name` must be 1–64 characters of lowercase letters, digits, and single hyphens, and cannot start or end with a hyphen.", + ); + } + if (SKILL_RESERVED_NAMES.has(name)) { + return invalid(`\`${name}\` is reserved for Executor's built-in docs; choose another name.`); + } + const description = frontmatter.description; + if (typeof description !== "string" || description.trim().length === 0) { + return invalid("Frontmatter `description` is required and must be a non-empty string."); + } + if (description.length > SKILL_DESCRIPTION_MAX_LENGTH) { + return invalid( + `Frontmatter \`description\` must be at most ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`, + ); + } + if ("compatibility" in frontmatter) { + const compatibility = frontmatter.compatibility; + if ( + typeof compatibility !== "string" || + compatibility.length === 0 || + compatibility.length > SKILL_COMPATIBILITY_MAX_LENGTH + ) { + return invalid( + `Frontmatter \`compatibility\` must be a string of 1–${SKILL_COMPATIBILITY_MAX_LENGTH} characters.`, + ); + } + } + if ("license" in frontmatter && typeof frontmatter.license !== "string") { + return invalid("Frontmatter `license` must be a string."); + } + if ("allowed-tools" in frontmatter && typeof frontmatter["allowed-tools"] !== "string") { + return invalid("Frontmatter `allowed-tools` must be a space-separated string."); + } + if ("metadata" in frontmatter) { + const metadata = frontmatter.metadata; + if (!isRecord(metadata) || Object.values(metadata).some((v) => typeof v !== "string")) { + return invalid("Frontmatter `metadata` must be a mapping of string keys to string values."); + } + } + + return Result.succeed({ + name: SkillName.make(name), + description: description.trim(), + frontmatter, + body, + }); +}; + +// --------------------------------------------------------------------------- +// Files +// --------------------------------------------------------------------------- + +const encoder = new TextEncoder(); + +const toHex = (bytes: ArrayBuffer): string => + Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join(""); + +/** `sha256:` of the UTF-8 encoding of `content` — the digest shape the + * MCP Skills Extension specifies. Web Crypto, so it runs on Node, Bun, and + * workerd alike. */ +export const digestSkillContent = async (content: string): Promise => + `sha256:${toHex(await crypto.subtle.digest("SHA-256", encoder.encode(content)))}`; + +export const skillContentByteLength = (content: string): number => + encoder.encode(content).byteLength; + +// eslint-disable-next-line no-control-regex -- the point is to refuse control characters in paths +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + +/** A relative POSIX path inside a skill: no leading `/`, no `.`/`..` segments, + * no empty segments, no backslashes, no control characters. */ +export const isValidSkillFilePath = (path: string): boolean => { + if (path.length === 0 || path.length > 512) return false; + if (path.includes("\\") || CONTROL_CHARACTERS.test(path)) return false; + const segments = path.split("/"); + return segments.every((segment) => segment !== "" && segment !== "." && segment !== ".."); +}; + +const compareSkillPaths = (a: SkillFileInput, b: SkillFileInput): number => + a.path === SKILL_MD_PATH + ? -1 + : b.path === SKILL_MD_PATH + ? 1 + : a.path < b.path + ? -1 + : a.path > b.path + ? 1 + : 0; + +export interface PreparedSkill { + readonly parsed: ParsedSkillMarkdown; + /** SKILL.md first, then the rest in path order. */ + readonly files: readonly SkillFile[]; +} + +/** + * Validate an uploaded file set and produce the parsed SKILL.md plus the + * normalized, digested file list — exactly what gets stored. + * + * Async only for the digests; everything else is pure. + */ +export const prepareSkillFiles = async ( + inputs: readonly SkillFileInput[], +): Promise> => { + if (inputs.length === 0) return invalid("A skill needs at least a SKILL.md file."); + if (inputs.length > SKILL_MAX_FILES) { + return invalid(`A skill can have at most ${SKILL_MAX_FILES} files.`); + } + const seen = new Set(); + for (const file of inputs) { + if (!isValidSkillFilePath(file.path)) { + return invalid(`File path "${file.path}" is not a relative path inside the skill.`); + } + if (seen.has(file.path)) return invalid(`File path "${file.path}" appears more than once.`); + seen.add(file.path); + } + const skillMd = inputs.find((file) => file.path === SKILL_MD_PATH); + if (!skillMd) return invalid("A skill must contain a SKILL.md file at its root."); + const parsed = parseSkillMarkdown(skillMd.content); + if (Result.isFailure(parsed)) return Result.fail(parsed.failure); + + let total = 0; + const files: SkillFile[] = []; + for (const file of [...inputs].sort(compareSkillPaths)) { + const size = skillContentByteLength(file.content); + total += size; + if (total > SKILL_MAX_TOTAL_BYTES) { + return invalid(`A skill's files can total at most ${SKILL_MAX_TOTAL_BYTES} bytes.`); + } + files.push({ + path: file.path, + content: file.content, + size, + digest: await digestSkillContent(file.content), + }); + } + return Result.succeed({ parsed: parsed.success, files }); +}; + +// --------------------------------------------------------------------------- +// URIs — the MCP Skills Extension form. `skill:////`; the +// final skill-path segment is the name, as the extension requires, and the +// owner is the server-chosen prefix that keeps a personal and a workspace skill +// of the same name distinct. +// --------------------------------------------------------------------------- + +export const SKILL_URI_SCHEME = "skill://"; + +export const skillRootUri = (ref: SkillRef): string => + `${SKILL_URI_SCHEME}${ref.owner}/${ref.name}`; + +export const skillFileUri = (ref: SkillRef, path: string): string => `${skillRootUri(ref)}/${path}`; + +export interface ParsedSkillUri extends SkillRef { + /** Empty for the skill root directory. */ + readonly path: string; +} + +/** Parse `skill:///[/]` back into its parts, or `None`. */ +export const parseSkillUri = (uri: string): Option.Option => { + if (!uri.startsWith(SKILL_URI_SCHEME)) return Option.none(); + const [owner, name, ...pathSegments] = uri.slice(SKILL_URI_SCHEME.length).split("/"); + if ((owner !== "org" && owner !== "user") || name === undefined || !isValidSkillName(name)) { + return Option.none(); + } + const path = pathSegments.join("/"); + if (path.length > 0 && !isValidSkillFilePath(path)) return Option.none(); + return Option.some({ owner, name, path }); +}; + +// --------------------------------------------------------------------------- +// Row projections +// --------------------------------------------------------------------------- + +// The stored `files` column is decoded through this; it is the same shape as +// `SkillFile`, kept as a schema here because the row is the only place bytes +// arrive untyped. +const StoredSkillFile = Schema.Struct({ + path: Schema.String, + content: Schema.String, + size: Schema.Number, + digest: Schema.String, +}); +const decodeFiles = Schema.decodeUnknownOption(Schema.Array(StoredSkillFile)); +const decodeJsonString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); + +/** JSON arrives as an object on Postgres and as a string on SQLite. */ +const jsonFromColumn = (value: unknown): Option.Option => + typeof value === "string" ? decodeJsonString(value) : Option.some(value); + +const filesFromColumn = (value: unknown): readonly SkillFile[] => + Option.flatMap(jsonFromColumn(value), decodeFiles).pipe(Option.getOrElse(() => [])); + +const frontmatterFromColumn = (value: unknown): Readonly> => + Option.filter(jsonFromColumn(value), isRecord).pipe(Option.getOrElse(() => ({}))); + +const asDate = (value: Date | number | string): Date => + value instanceof Date ? value : new Date(value); + +export const rowToSkill = (row: SkillRow): Skill => ({ + owner: row.owner as Owner, + name: SkillName.make(row.name), + description: row.description, + frontmatter: frontmatterFromColumn(row.frontmatter), + files: filesFromColumn(row.files), + createdAt: asDate(row.created_at), + updatedAt: asDate(row.updated_at), +}); + +export const toSkillSummary = (skill: Skill): SkillSummary => ({ + ...skill, + files: skill.files.map(({ path, size, digest }) => ({ path, size, digest })), +}); + +/** The markdown body of a stored skill's SKILL.md (frontmatter stripped). */ +export const skillBody = (skill: Skill): string => { + const skillMd = skill.files.find((file) => file.path === SKILL_MD_PATH); + if (!skillMd) return ""; + const parsed = parseSkillMarkdown(skillMd.content); + return Result.isSuccess(parsed) ? parsed.success.body : skillMd.content; +}; diff --git a/packages/core/sdk/src/skills.test.ts b/packages/core/sdk/src/skills.test.ts new file mode 100644 index 0000000000..2b0aa786df --- /dev/null +++ b/packages/core/sdk/src/skills.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Predicate, Result } from "effect"; + +import { makeTestExecutor } from "./testing"; +import { + parseSkillMarkdown, + parseSkillUri, + prepareSkillFiles, + skillBody, + skillFileUri, +} from "./skill"; + +// The `executor.skills` surface against the real SQLite test db, plus the pure +// SKILL.md validation it is built on. Skills are owner-scoped rows with no +// plugin involvement, so the default test executor (one tenant + one bound +// subject) is the whole fixture. + +const SKILL_MD = [ + "---", + "name: pdf-processing", + "description: Extract text from PDFs. Use when the user mentions PDFs.", + "license: Apache-2.0", + "metadata:", + " author: example-org", + ' version: "1.0"', + "unknown-field: kept verbatim", + "---", + "", + "# PDF processing", + "", + "Run `scripts/extract.py`.", +].join("\n"); + +const files = (skillMd = SKILL_MD) => [ + { path: "SKILL.md", content: skillMd }, + { path: "scripts/extract.py", content: "print('hi')\n" }, +]; + +describe("parseSkillMarkdown", () => { + it("splits frontmatter from body and keeps unknown fields", () => { + const parsed = parseSkillMarkdown(SKILL_MD); + expect(Result.isSuccess(parsed)).toBe(true); + if (Result.isFailure(parsed)) return; + expect(parsed.success.name).toBe("pdf-processing"); + expect(parsed.success.description).toBe( + "Extract text from PDFs. Use when the user mentions PDFs.", + ); + expect(parsed.success.frontmatter["unknown-field"]).toBe("kept verbatim"); + expect(parsed.success.frontmatter.metadata).toEqual({ author: "example-org", version: "1.0" }); + expect(parsed.success.body.startsWith("# PDF processing")).toBe(true); + }); + + it.each([ + ["no frontmatter", "# Just markdown"], + ["unclosed frontmatter", "---\nname: x\ndescription: y\n"], + ["uppercase name", "---\nname: PDF\ndescription: y\n---\n"], + ["leading hyphen", "---\nname: -pdf\ndescription: y\n---\n"], + ["double hyphen", "---\nname: pdf--x\ndescription: y\n---\n"], + ["missing description", "---\nname: pdf\n---\n"], + ["reserved name", "---\nname: execute\ndescription: y\n---\n"], + ["non-string metadata value", "---\nname: pdf\ndescription: y\nmetadata:\n n: 1\n---\n"], + ])("rejects %s", (_label, markdown) => { + expect(Result.isFailure(parseSkillMarkdown(markdown))).toBe(true); + }); +}); + +describe("prepareSkillFiles", () => { + it("digests every file and puts SKILL.md first", async () => { + const prepared = await prepareSkillFiles([...files()].reverse()); + expect(Result.isSuccess(prepared)).toBe(true); + if (Result.isFailure(prepared)) return; + expect(prepared.success.files.map((f) => f.path)).toEqual(["SKILL.md", "scripts/extract.py"]); + for (const file of prepared.success.files) { + expect(file.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(file.size).toBe(new TextEncoder().encode(file.content).byteLength); + } + }); + + it.each([ + ["a path escaping the skill", [{ path: "../SKILL.md", content: "" }]], + ["an absolute path", [{ path: "/SKILL.md", content: "" }]], + ["no SKILL.md", [{ path: "README.md", content: "" }]], + ["a duplicate path", [...files(), { path: "scripts/extract.py", content: "" }]], + ])("rejects %s", async (_label, inputs) => { + expect(Result.isFailure(await prepareSkillFiles(inputs))).toBe(true); + }); +}); + +describe("skill URIs", () => { + it("round-trips owner, name, and path", () => { + const uri = skillFileUri({ owner: "org", name: "pdf-processing" }, "references/FORMS.md"); + expect(uri).toBe("skill://org/pdf-processing/references/FORMS.md"); + expect(parseSkillUri(uri)).toEqual( + Option.some({ owner: "org", name: "pdf-processing", path: "references/FORMS.md" }), + ); + }); + + it("rejects other schemes, unknown owners, and escaping paths", () => { + expect(Option.isNone(parseSkillUri("ui://executor/shell.html"))).toBe(true); + expect(Option.isNone(parseSkillUri("skill://team/pdf/SKILL.md"))).toBe(true); + expect(Option.isNone(parseSkillUri("skill://org/pdf/../x"))).toBe(true); + }); +}); + +describe("executor.skills", () => { + it.effect("list is empty when nothing is saved", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + expect(yield* executor.skills.list()).toEqual([]); + }), + ); + + it.effect("save stores the directory under the frontmatter name", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.skills.save({ owner: "user", files: files() }); + expect(saved.owner).toBe("user"); + expect(saved.name).toBe("pdf-processing"); + expect(saved.files.map((f) => f.path)).toEqual(["SKILL.md", "scripts/extract.py"]); + expect(skillBody(saved).startsWith("# PDF processing")).toBe(true); + + const fetched = yield* executor.skills.get({ owner: "user", name: "pdf-processing" }); + expect(fetched).toEqual(saved); + + // Lists carry the manifest, never the contents. + const [summary] = yield* executor.skills.list(); + expect(summary?.files).toEqual( + saved.files.map(({ path, size, digest }) => ({ path, size, digest })), + ); + expect(summary?.frontmatter["unknown-field"]).toBe("kept verbatim"); + }), + ); + + it.effect("save replaces an existing skill of the same owner and name in place", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + yield* executor.skills.save({ owner: "user", files: files() }); + const updated = yield* executor.skills.save({ + owner: "user", + files: [{ path: "SKILL.md", content: SKILL_MD.replace("Extract text", "Extract tables") }], + }); + expect(updated.description).toContain("Extract tables"); + expect(updated.files).toHaveLength(1); + expect(yield* executor.skills.list()).toHaveLength(1); + }), + ); + + it.effect("a personal and a workspace skill may share a name", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + yield* executor.skills.save({ owner: "user", files: files() }); + yield* executor.skills.save({ owner: "org", files: files() }); + const listed = yield* executor.skills.list(); + expect(listed.map((s) => s.owner).sort()).toEqual(["org", "user"]); + }), + ); + + it.effect("an invalid skill is refused with a reason", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* executor.skills + .save({ owner: "user", files: [{ path: "SKILL.md", content: "# no frontmatter" }] }) + .pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(Predicate.isTagged("InvalidSkillError")(result.failure)).toBe(true); + }), + ); + + it.effect("get and remove target one (owner, name)", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + yield* executor.skills.save({ owner: "user", files: files() }); + yield* executor.skills.save({ owner: "org", files: files() }); + yield* executor.skills.remove({ owner: "user", name: "pdf-processing" }); + const remaining = yield* executor.skills.list(); + expect(remaining.map((s) => s.owner)).toEqual(["org"]); + const missing = yield* executor.skills + .get({ owner: "user", name: "pdf-processing" }) + .pipe(Effect.result); + expect(Result.isFailure(missing)).toBe(true); + }), + ); + + it.effect("org writes are refused when workspace writes are denied", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ orgWrites: "denied" }); + const result = yield* executor.skills + .save({ owner: "org", files: files() }) + .pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(Predicate.isTagged("OrgWriteDeniedError")(result.failure)).toBe(true); + // Personal skills are untouched by the workspace gate. + const personal = yield* executor.skills.save({ owner: "user", files: files() }); + expect(personal.owner).toBe("user"); + }), + ); +}); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 730c5bfd89..922013c9cf 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -68,6 +68,16 @@ import { } from "./create-artifact"; import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; import { resolveArtifactAction } from "./artifact-action"; +import { + MCP_SKILLS_EXTENSION_ID, + registerWorkspaceSkills, + renderSkillsToolDescription, + renderWorkspaceSkill, + renderWorkspaceSkillsIndex, + resolveWorkspaceSkill, + type McpSkillsPort, +} from "./workspace-skills"; +import type { Skill as WorkspaceSkill, SkillSummary } from "@executor-js/sdk"; import { extractArtifactRoles, resolveArtifactBindings, @@ -206,6 +216,14 @@ type SharedMcpServerConfig = { * whole `Executor` across this boundary. */ readonly artifacts?: McpArtifactsPort; + /** + * The Agent Skills saved in the caller's workspace (personal + org-shared). + * Structurally satisfied by `executor.skills`. Present, the `skills` tool + * serves them next to Executor's own docs and the server declares the MCP + * Skills Extension; absent (stdio-only hosts, tests), the surface is exactly + * the docs-only one it always was. + */ + readonly skills?: McpSkillsPort; /** * The caller's saved connections, for binding an artifact's integration roles * at create time. Structurally satisfied by `executor.connections`; hosts pass @@ -841,6 +859,76 @@ const skillsResult = ( return { content: [{ type: "text", text }] }; }; +const textResult = (text: string, isError = false): McpToolResult => ({ + content: [{ type: "text", text }], + ...(isError ? { isError: true } : {}), +}); + +// The `skills` tool when the host serves workspace skills too. Built-in docs +// keep their exact behavior and win on a name clash (their names are reserved at +// save time, so the clash cannot happen for a skill saved through Executor). +// The index lists both sections; a miss lists both too, so the model retries +// with a name that exists rather than the same miss. +// +// `file` reads one bundled file of a workspace skill — the third tier of +// progressive disclosure. It is meaningless for a built-in doc, which has no +// files, and says so instead of silently returning the body. +const workspaceSkillsResult = ( + input: { readonly name: string | undefined; readonly file: string | undefined }, + executeInventory: string, + catalog: readonly Skill[], + port: McpSkillsPort, +): Effect.Effect => + Effect.gen(function* () { + const skills: readonly SkillSummary[] = yield* port + .list() + .pipe(Effect.catchCause(() => Effect.succeed([]))); + const index = () => `${renderSkillsIndex(catalog)}\n\n${renderWorkspaceSkillsIndex(skills)}`; + const name = input.name?.trim(); + const file = input.file?.trim(); + if (!name) return textResult(index()); + + const builtIn = findSkill(name, catalog); + if (builtIn) { + if (file) { + return textResult( + `\`${name}\` is one of Executor's own docs and has no bundled files. Call \`skills({ name: "${name}" })\` without \`file\`.`, + true, + ); + } + return skillsResult(name, executeInventory, catalog); + } + + const summary = resolveWorkspaceSkill(name, skills); + if (!summary) { + return textResult( + `No skill named "${name}". The skills this server can serve are listed below — a skill on your own disk or in your harness is not reachable from here.\n\n${index()}`, + true, + ); + } + const skill: WorkspaceSkill | null = yield* port + .get({ owner: summary.owner, name: summary.name }) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!skill) { + return textResult( + `Skill "${name}" could not be loaded right now. Retry, or call with no name to list what is available.`, + true, + ); + } + if (file) { + const found = skill.files.find((entry) => entry.path === file); + if (!found) { + const paths = skill.files.map((entry) => `- \`${entry.path}\``).join("\n"); + return textResult( + `Skill "${skill.name}" has no file "${file}". Its files are:\n${paths}`, + true, + ); + } + return textResult(found.content); + } + return textResult(renderWorkspaceSkill(skill)); + }); + /** Pull the live integration inventory block out of the built execute * description (it runs from its header to the end), so the `skills` tool can * re-use it without rebuilding the inventory from the executor. */ @@ -1225,7 +1313,13 @@ export const createExecutorMcpServer = ( // `ui://executor/shell.html`; it stays advertised even when no // shell loader is configured so the capability set doesn't vary // per host. - capabilities: { resources: {}, tools: {} }, + capabilities: { + resources: {}, + tools: {}, + // The MCP Skills Extension (SEP-2640) rides on resources, so it + // is declared only when a skills source is wired in. + ...(config.skills ? { extensions: { [MCP_SKILLS_EXTENSION_ID]: {} } } : {}), + }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), }, ), @@ -1562,34 +1656,88 @@ export const createExecutorMcpServer = ( }), ); + // The catalog in the description is read once per session: it is what the + // model sees before it asks, and a description that changed under a + // client's cached tool list would be worse than one that is a few minutes + // stale. Every CALL reads live. + const workspaceSkills = config.skills; + const skillsCatalogAtBuild: readonly SkillSummary[] = workspaceSkills + ? yield* workspaceSkills.list().pipe( + Effect.catchCause(() => Effect.succeed([])), + Effect.withSpan("mcp.host.list_workspace_skills"), + ) + : []; + yield* Effect.sync(() => - server.registerTool( - "skills", - { - description: [ - "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", - "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', - "Call with no name to list the few docs available.", - ].join("\n"), - inputSchema: { - name: z - .string() - .optional() - .describe( - 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.', + workspaceSkills + ? server.registerTool( + "skills", + { + description: renderSkillsToolDescription(skillsCatalogAtBuild), + inputSchema: { + name: z + .string() + .optional() + .describe( + 'A skill from the catalog, by `name` or `owner/name`, or one of Executor\'s own docs such as "execute". Omit to list everything.', + ), + file: z + .string() + .optional() + .describe( + 'A bundled file of the named workspace skill to read, as the relative path the skill lists (e.g. "references/guide.md").', + ), + }, + }, + ({ name, file }, extra) => + runToolEffect( + workspaceSkillsResult( + { name, file }, + executeInventory, + skillCatalog, + workspaceSkills, + ), + extra, ), - }, - }, - ({ name }, extra) => - runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra), - ), + ) + : server.registerTool( + "skills", + { + description: [ + "Documentation for THIS server's own tools. Not a general skill reader: it serves a short, fixed set of how-to docs about using `execute` and artifacts here, and it cannot reach your harness's skills, a SKILL.md on disk, or any user- or project-authored skill. The argument is a name from its own catalog, never a path or an outside skill's id.", + "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", + 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', + "Call with no name to list the few docs available.", + ].join("\n"), + inputSchema: { + name: z + .string() + .optional() + .describe( + 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.', + ), + }, + }, + ({ name }, extra) => + runToolEffect( + Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), + extra, + ), + ), ).pipe( Effect.withSpan("mcp.host.register_tool", { attributes: { "mcp.tool.name": "skills" }, }), ); + if (workspaceSkills) { + yield* Effect.sync(() => + registerWorkspaceSkills(server, workspaceSkills, (effect) => + Effect.runPromiseWith(context)(anchor(effect)), + ), + ).pipe(Effect.withSpan("mcp.host.register_workspace_skills")); + } + yield* Effect.sync(() => { if (elicitationMode.mode === "native") { return undefined; diff --git a/packages/hosts/mcp/src/workspace-skills.test.ts b/packages/hosts/mcp/src/workspace-skills.test.ts new file mode 100644 index 0000000000..77310cd4ab --- /dev/null +++ b/packages/hosts/mcp/src/workspace-skills.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import * as z from "zod/v4"; +import type * as Cause from "effect/Cause"; + +import { SkillName, SkillNotFoundError, type Skill, type SkillSummary } from "@executor-js/sdk"; +import type { ExecutionEngine } from "@executor-js/execution"; + +import { createExecutorMcpServer } from "./tool-server"; +import { + MCP_SKILLS_EXTENSION_ID, + resolveWorkspaceSkill, + type McpSkillsPort, +} from "./workspace-skills"; + +// Workspace skills on the MCP surface: the `skills` tool grows a workspace +// section and a `file` argument, and the server speaks the MCP Skills +// Extension (SEP-2640). Both read the same port, faked here as a static list. + +const makeStubEngine = (): ExecutionEngine => ({ + execute: () => Effect.succeed({ result: "default" }), + executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "default" } }), + resume: () => Effect.succeed(null), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), + shutdown: Effect.void, +}); + +const SKILL_MD = [ + "---", + "name: pdf-processing", + "description: Extract text from PDFs. Use when the user mentions PDFs.", + "metadata:", + ' version: "1.0"', + "---", + "", + "# PDF processing", + "", + "Read `references/FORMS.md` before filling forms.", +].join("\n"); + +const skill = (owner: "org" | "user", name = "pdf-processing"): Skill => ({ + owner, + name: SkillName.make(name), + description: "Extract text from PDFs. Use when the user mentions PDFs.", + frontmatter: { + name, + description: "Extract text from PDFs. Use when the user mentions PDFs.", + metadata: { version: "1.0" }, + }, + files: [ + { + path: "SKILL.md", + content: SKILL_MD.replace("pdf-processing", name), + size: 111, + digest: `sha256:${"a".repeat(64)}`, + }, + { + path: "references/FORMS.md", + content: "# Forms\n\nFill every field.", + size: 26, + digest: `sha256:${"b".repeat(64)}`, + }, + ], + createdAt: new Date(0), + updatedAt: new Date(0), +}); + +const summaryOf = (s: Skill): SkillSummary => ({ + ...s, + files: s.files.map(({ path, size, digest }) => ({ path, size, digest })), +}); + +const portOf = (skills: readonly Skill[]): McpSkillsPort => ({ + list: () => Effect.succeed(skills.map(summaryOf)), + get: (ref) => { + const found = skills.find((s) => s.owner === ref.owner && s.name === ref.name); + return found + ? Effect.succeed(found) + : Effect.fail(new SkillNotFoundError({ owner: ref.owner, name: SkillName.make(ref.name) })); + }, +}); + +const withClient = async ( + skills: McpSkillsPort | undefined, + fn: (client: Client) => Promise, +) => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ engine: makeStubEngine(), ...(skills ? { skills } : {}) }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions + try { + await fn(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +const textOf = (result: Awaited>): string => + (result.content as Array<{ type: string; text: string }>)[0].text; + +const SkillsListResult = z.object({ + skills: z.array( + z.object({ + uri: z.string(), + frontmatter: z.record(z.string(), z.unknown()), + resources: z.array(z.object({ uri: z.string(), digest: z.string(), size: z.number() })), + }), + ), +}); +const SkillsGetResult = z.object({ skill: SkillsListResult.shape.skills.element }); + +describe("resolveWorkspaceSkill", () => { + const both = [summaryOf(skill("org")), summaryOf(skill("user"))]; + it("prefers the personal skill on a bare-name clash", () => { + expect(resolveWorkspaceSkill("pdf-processing", both)?.owner).toBe("user"); + }); + it("honours an explicit owner", () => { + expect(resolveWorkspaceSkill("org/pdf-processing", both)?.owner).toBe("org"); + expect(resolveWorkspaceSkill("user/nope", both)).toBeUndefined(); + }); +}); + +describe("skills tool without a skills source", () => { + it("keeps the docs-only description and input", async () => { + await withClient(undefined, async (client) => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "skills"); + expect(tool?.description).toContain("Not a general skill reader"); + expect(Object.keys(tool?.inputSchema.properties ?? {})).toEqual(["name"]); + expect(client.getServerCapabilities()?.extensions).toBeUndefined(); + }); + }); +}); + +describe("skills tool with workspace skills", () => { + it("advertises the catalog in the description", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "skills"); + expect(tool?.description).toContain("`pdf-processing` (workspace)"); + expect(tool?.description).toContain("Use when the user mentions PDFs"); + expect(Object.keys(tool?.inputSchema.properties ?? {}).sort()).toEqual(["file", "name"]); + }); + }); + + it("lists built-in docs and workspace skills in the index", async () => { + await withClient(portOf([skill("user")]), async (client) => { + const text = textOf(await client.callTool({ name: "skills", arguments: {} })); + expect(text).toContain("`execute`"); + expect(text).toContain("`pdf-processing` (personal)"); + }); + }); + + it("returns the body with frontmatter stripped and lists bundled files", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const result = await client.callTool({ + name: "skills", + arguments: { name: "pdf-processing" }, + }); + const text = textOf(result); + expect(result.isError).toBeFalsy(); + expect(text).toContain(''); + expect(text).toContain("# PDF processing"); + expect(text).not.toContain("description: Extract text"); + expect(text).toContain("references/FORMS.md"); + expect(text).not.toContain("Fill every field"); + expect(result.structuredContent).toBeUndefined(); + }); + }); + + it("reads a bundled file by path and reports a missing one", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const hit = await client.callTool({ + name: "skills", + arguments: { name: "org/pdf-processing", file: "references/FORMS.md" }, + }); + expect(textOf(hit)).toBe("# Forms\n\nFill every field."); + const miss = await client.callTool({ + name: "skills", + arguments: { name: "pdf-processing", file: "references/NOPE.md" }, + }); + expect(miss.isError).toBe(true); + expect(textOf(miss)).toContain("- `references/FORMS.md`"); + }); + }); + + it("still serves the built-in docs and refuses `file` on them", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const doc = await client.callTool({ name: "skills", arguments: { name: "execute" } }); + expect(textOf(doc)).toContain("## Workflow"); + const bad = await client.callTool({ + name: "skills", + arguments: { name: "execute", file: "x.md" }, + }); + expect(bad.isError).toBe(true); + }); + }); + + it("names both sections on a miss", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const result = await client.callTool({ name: "skills", arguments: { name: "nope" } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('No skill named "nope"'); + expect(textOf(result)).toContain("`execute`"); + expect(textOf(result)).toContain("`pdf-processing`"); + }); + }); +}); + +describe("MCP Skills Extension", () => { + it("declares the extension and answers skills/list with digest manifests", async () => { + await withClient(portOf([skill("org"), skill("user", "release-notes")]), async (client) => { + expect(client.getServerCapabilities()?.extensions).toEqual({ + [MCP_SKILLS_EXTENSION_ID]: {}, + }); + const result = await client.request({ method: "skills/list", params: {} }, SkillsListResult); + expect(result.skills.map((s) => s.uri)).toEqual([ + "skill://org/pdf-processing/SKILL.md", + "skill://user/release-notes/SKILL.md", + ]); + const [first] = result.skills; + expect(first?.frontmatter).toEqual({ + name: "pdf-processing", + description: "Extract text from PDFs. Use when the user mentions PDFs.", + metadata: { version: "1.0" }, + }); + expect(first?.resources).toEqual([ + { + uri: "skill://org/pdf-processing/SKILL.md", + digest: `sha256:${"a".repeat(64)}`, + size: 111, + }, + { + uri: "skill://org/pdf-processing/references/FORMS.md", + digest: `sha256:${"b".repeat(64)}`, + size: 26, + }, + ]); + }); + }); + + it("answers skills/get by SKILL.md URI and -32602 for anything else", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const result = await client.request( + { method: "skills/get", params: { uri: "skill://org/pdf-processing/SKILL.md" } }, + SkillsGetResult, + ); + expect(result.skill.uri).toBe("skill://org/pdf-processing/SKILL.md"); + await expect( + client.request( + { method: "skills/get", params: { uri: "skill://user/pdf-processing/SKILL.md" } }, + SkillsGetResult, + ), + ).rejects.toMatchObject({ code: -32602 }); + await expect( + client.request( + { + method: "skills/get", + params: { uri: "skill://org/pdf-processing/references/FORMS.md" }, + }, + SkillsGetResult, + ), + ).rejects.toMatchObject({ code: -32602 }); + }); + }); + + it("serves every file as a skill:// resource and lists one entry per skill", async () => { + await withClient(portOf([skill("org")]), async (client) => { + const listed = await client.listResources(); + expect(listed.resources.map((r) => r.uri)).toEqual(["skill://org/pdf-processing/SKILL.md"]); + expect(listed.resources[0]?.mimeType).toBe("text/markdown"); + + const read = await client.readResource({ + uri: "skill://org/pdf-processing/references/FORMS.md", + }); + expect(read.contents[0]).toMatchObject({ + uri: "skill://org/pdf-processing/references/FORMS.md", + mimeType: "text/markdown", + text: "# Forms\n\nFill every field.", + }); + await expect( + client.readResource({ uri: "skill://org/pdf-processing/missing.md" }), + ).rejects.toMatchObject({ code: -32602 }); + }); + }); +}); diff --git a/packages/hosts/mcp/src/workspace-skills.ts b/packages/hosts/mcp/src/workspace-skills.ts new file mode 100644 index 0000000000..6ef5c6aad3 --- /dev/null +++ b/packages/hosts/mcp/src/workspace-skills.ts @@ -0,0 +1,268 @@ +// --------------------------------------------------------------------------- +// Workspace skills on the MCP surface. +// +// A workspace skill is an Agent Skill (a SKILL.md directory, see +// https://agentskills.io) saved to Executor by a user, personally or for the +// whole org. Every agent connected to this server should be able to find and +// load it, so it is served two ways: +// +// 1. Through the `skills` tool, alongside Executor's own how-to docs. Every +// MCP client can call a tool today, so this is the channel that works +// everywhere. The tool description carries the catalog (name + description) +// so the model knows what exists before it asks. +// 2. Through the MCP Skills Extension (SEP-2640): the server declares +// `io.modelcontextprotocol/skills`, answers `skills/list` and `skills/get` +// with digest manifests, and serves every file as a `skill://` resource. +// Clients that adopt the standard pick skills up with no tool call at all. +// +// Both channels read the same live source (`executor.skills`), so a skill saved +// in the console is visible to an already-connected agent on its next call. +// --------------------------------------------------------------------------- + +import { Effect, Option } from "effect"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; +import * as z from "zod/v4"; + +import { + parseSkillUri, + skillBody, + skillFileUri, + SKILL_MD_PATH, + type Owner, + type Skill, + type SkillFileEntry, + type SkillRef, + type SkillSummary, +} from "@executor-js/sdk"; + +/** + * The two reads this surface needs. Structurally satisfied by + * `executor.skills`; hosts pass that so the whole `Executor` never crosses this + * boundary. Both are LIVE: nothing here caches skill content. + */ +export type McpSkillsPort = { + readonly list: () => Effect.Effect; + readonly get: (ref: SkillRef) => Effect.Effect; +}; + +/** The extension id SEP-2640 assigns, as it appears under `capabilities.extensions`. */ +export const MCP_SKILLS_EXTENSION_ID = "io.modelcontextprotocol/skills"; + +// --------------------------------------------------------------------------- +// Naming +// --------------------------------------------------------------------------- + +/** How a workspace skill is referred to in the `skills` tool: `owner/name`. */ +export const workspaceSkillLabel = (ref: SkillRef): string => `${ref.owner}/${ref.name}`; + +/** + * Resolve what the model typed into one workspace skill. + * + * Accepts the bare `name` or the explicit `owner/name`. A bare name that exists + * both personally and for the org resolves to the personal one: that is the + * same shadowing rule local skill directories use (the nearer scope wins), and + * the explicit form is always there to reach the other. + */ +export const resolveWorkspaceSkill = ( + input: string, + skills: readonly SkillSummary[], +): SkillSummary | undefined => { + const slash = input.indexOf("/"); + if (slash !== -1) { + const owner = input.slice(0, slash); + const name = input.slice(slash + 1); + return skills.find((skill) => skill.owner === owner && skill.name === name); + } + const matches = skills.filter((skill) => skill.name === input); + return matches.find((skill) => skill.owner === "user") ?? matches[0]; +}; + +// --------------------------------------------------------------------------- +// Rendering for the `skills` tool +// --------------------------------------------------------------------------- + +const ownerWord = (owner: Owner): string => (owner === "org" ? "workspace" : "personal"); + +/** One-line catalog entry, the shape both the index and the description use. */ +const catalogLine = (skill: SkillSummary): string => + `- \`${skill.name}\` (${ownerWord(skill.owner)}) — ${skill.description}`; + +/** How many workspace skills the tool description names before it truncates. + * The description is loaded into every session's prompt, so it stays bounded + * no matter how many skills a workspace saves; the index is the full list. */ +export const SKILL_CATALOG_DESCRIPTION_LIMIT = 40; + +/** + * The `skills` tool description when workspace skills can be served. Replaces + * the docs-only wording: this IS now a skill reader for the skills saved in + * Executor, and it says so, while still marking the boundary for skills that + * live on the agent's own disk (which it still cannot reach). + */ +export const renderSkillsToolDescription = (skills: readonly SkillSummary[]): string => { + const shown = skills.slice(0, SKILL_CATALOG_DESCRIPTION_LIMIT); + const hidden = skills.length - shown.length; + const catalog = + skills.length === 0 + ? ["No workspace skills are saved yet; the catalog below is Executor's docs only."] + : [ + "Skills saved in this Executor workspace — load one when a task matches its description:", + ...shown.map(catalogLine), + ...(hidden > 0 ? [`- …and ${hidden} more; call with no name for the full index.`] : []), + ]; + return [ + "Skills for THIS server: Executor's own how-to docs (`execute`, artifacts) plus the Agent Skills saved in this Executor workspace. It cannot reach a SKILL.md on your own disk or your harness's skills — only what is listed here.", + 'Call `skills({ name: "" })` to load a skill\'s instructions; the result lists its bundled files, which you read with `skills({ name, file: "" })`. A name that exists both personally and for the workspace resolves to the personal one; use `owner/name` (`org/` or `user/`) to be explicit.', + 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool. Call with no name to list everything.', + "", + ...catalog, + ].join("\n"); +}; + +/** The workspace section appended to the `skills` index. */ +export const renderWorkspaceSkillsIndex = (skills: readonly SkillSummary[]): string => + skills.length === 0 + ? "No skills are saved in this Executor workspace yet. Add one in the console under Skills and it appears here for every connected agent." + : [ + 'Skills saved in this Executor workspace. Load one with `skills({ name: "" })`; read a bundled file with `skills({ name, file: "" })`.', + "", + ...skills.map(catalogLine), + ].join("\n"); + +const bundledFiles = (skill: Skill): readonly SkillFileEntry[] => + skill.files.filter((file) => file.path !== SKILL_MD_PATH); + +/** + * What the model receives when it loads a workspace skill: the SKILL.md body + * with the frontmatter stripped (name and description were already in the + * catalog), wrapped in identifying tags, followed by the list of bundled files + * and how to read one. The files themselves are NOT inlined — that is the + * progressive disclosure the standard asks for. + */ +export const renderWorkspaceSkill = (skill: Skill): string => { + const files = bundledFiles(skill); + const resources = + files.length === 0 + ? [] + : [ + "", + "", + ...files.map((file) => ` ${file.path}`), + "", + `Read a bundled file with \`skills({ name: "${workspaceSkillLabel(skill)}", file: "" })\`. Relative paths in the instructions above are relative to the skill's root.`, + ]; + return [ + ``, + skillBody(skill), + ...resources, + "", + ].join("\n"); +}; + +// --------------------------------------------------------------------------- +// MCP Skills Extension (SEP-2640) +// --------------------------------------------------------------------------- + +/** MIME type for a skill file, by extension. Markdown is what the standard is + * made of; everything else is served as plain text. */ +export const skillFileMimeType = (path: string): string => + path.endsWith(".md") ? "text/markdown" : "text/plain"; + +/** One `skills/list` / `skills/get` entry: the SKILL.md URI, the verbatim + * frontmatter, and the complete digest manifest. */ +export const skillEntry = (skill: SkillSummary) => ({ + uri: skillFileUri(skill, SKILL_MD_PATH), + frontmatter: skill.frontmatter, + resources: skill.files.map((file) => ({ + uri: skillFileUri(skill, file.path), + digest: file.digest, + size: file.size, + })), +}); + +const SkillsListRequestSchema = z.object({ + method: z.literal("skills/list"), + params: z.object({ cursor: z.string().optional() }).loose().optional(), +}); + +const SkillsGetRequestSchema = z.object({ + method: z.literal("skills/get"), + params: z.object({ uri: z.string() }).loose(), +}); + +const SKILL_RESOURCE_TEMPLATE = "skill://{owner}/{name}/{+path}"; + +/** Runs an Effect at an SDK callback edge with the server's captured context. */ +export type RunAtEdge = (effect: Effect.Effect) => Promise; + +/** + * Register the extension's methods and the `skill://` resource space on a + * server that has already declared the `resources` capability and the + * extension under `capabilities.extensions`. + * + * `skills/list` never paginates: a workspace's catalog is small by + * construction (each entry is one saved row), and an unpaginated listing is + * what every client handles. + */ +export const registerWorkspaceSkills = ( + server: McpServer, + port: McpSkillsPort, + run: RunAtEdge, +): void => { + const list = () => run(port.list()); + // The SDK turns a thrown McpError into the JSON-RPC error the method + // requires (-32602 for an unknown skill or file), so the not-found path is a + // failed Effect run through the same edge as every other read. + const notFound = (uri: string) => + run( + Effect.fail(new McpError(ErrorCode.InvalidParams, `Unknown skill resource: ${uri}`)), + ) as Promise; + + server.server.setRequestHandler(SkillsListRequestSchema, async () => ({ + skills: (await list()).map(skillEntry), + })); + + server.server.setRequestHandler(SkillsGetRequestSchema, async ({ params }) => { + const parsed = parseSkillUri(params.uri); + if (Option.isNone(parsed) || parsed.value.path !== SKILL_MD_PATH) return notFound(params.uri); + const { owner, name } = parsed.value; + const skill = (await list()).find((entry) => entry.owner === owner && entry.name === name); + if (!skill) return notFound(params.uri); + return { skill: skillEntry(skill) }; + }); + + server.registerResource( + "Workspace skills", + new ResourceTemplate(SKILL_RESOURCE_TEMPLATE, { + // `resources/list` shows one entry per skill — its SKILL.md — so a + // resource browser sees the skills, not every bundled file. + list: async () => ({ + resources: (await list()).map((skill) => ({ + uri: skillFileUri(skill, SKILL_MD_PATH), + name: skill.name, + description: skill.description, + mimeType: skillFileMimeType(SKILL_MD_PATH), + })), + }), + }), + { + description: "Agent Skills saved in this Executor workspace, one file per resource.", + mimeType: "text/markdown", + }, + async (uri) => { + const href = uri.toString(); + const parsed = parseSkillUri(href); + if (Option.isNone(parsed) || parsed.value.path === "") return notFound(href); + const { owner, name, path } = parsed.value; + const skill = await run( + port.get({ owner, name }).pipe(Effect.catchCause(() => Effect.succeed(null))), + ); + const file = skill?.files.find((entry) => entry.path === path); + if (!file) return notFound(href); + return { + contents: [{ uri: href, mimeType: skillFileMimeType(path), text: file.content }], + }; + }, + ); +}; diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 0d6fb9af7d..cc0e46d640 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -12,6 +12,7 @@ import { type OAuthGrant, type Owner, type ProviderItemId, + type SkillName, type TokenEndpointAuthMethod, type ToolAddress, } from "@executor-js/sdk/shared"; @@ -173,6 +174,32 @@ export const artifactAtom = Atom.family((artifactId: ArtifactId) => }), ); +// --------------------------------------------------------------------------- +// Agent Skills — SKILL.md directories saved to the workspace, owner-scoped the +// same way connections are. +// --------------------------------------------------------------------------- + +export const skillsAtom = ExecutorApiClient.query("skills", "list", { + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.skills], +}); + +/** + * One skill WITH the content of every file it bundles. `skills.list` returns + * the manifest only (path, size, digest), so the detail and edit views have to + * fetch the row rather than derive it from the list. + * + * `Atom.family` (not a bare arrow) because the pages rebuild the `{owner,name}` + * key object on every render — a fresh atom per render would refetch in a loop. + */ +export const skillAtom = Atom.family((ref: { readonly owner: Owner; readonly name: SkillName }) => + ExecutorApiClient.query("skills", "get", { + params: { owner: ref.owner, name: ref.name }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.skills], + }), +); + // --------------------------------------------------------------------------- // Mutation atoms — reactivityKeys must be passed at call site (effect-atom // does not accept them at definition time). See `reactivity-keys.tsx` for the @@ -281,6 +308,13 @@ export const removeArtifact = ExecutorApiClient.mutation("artifacts", "remove"); */ export const setArtifactPreview = ExecutorApiClient.mutation("artifacts", "setPreview"); +/** Create or replace a skill in place. The name is read from the uploaded + * SKILL.md, never sent separately, so a renamed frontmatter saves a NEW skill. + * Pass `reactivityKeys: skillWriteKeys` at the call site. */ +export const saveSkill = ExecutorApiClient.mutation("skills", "save"); + +export const removeSkill = ExecutorApiClient.mutation("skills", "remove"); + export const resumeExecution = ExecutorApiClient.mutation("executions", "resume"); /** Run codemode source (`POST /executions`). Used by the per-tool Run/Test panel diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index 7f9bc27407..3e56947181 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -28,6 +28,8 @@ export const ReactivityKey = { policies: "policies", /** Saved generative-UI artifacts. */ artifacts: "artifacts", + /** Agent Skills (SKILL.md directories) saved to the workspace. */ + skills: "skills", /** Registered OAuth clients (apps). */ oauthClients: "oauth-clients", /** An integration's declared health check (the operation/identity-field spec). */ @@ -80,6 +82,11 @@ export const policyWriteKeys = [ReactivityKey.policies, ReactivityKey.tools] as * resource — nothing else reads them — so they invalidate only themselves. */ export const artifactWriteKeys = [ReactivityKey.artifacts] as const; +/** Mutations that save or delete a skill. Nothing else in the console reads + * skills (the MCP host serves them straight from storage), so they invalidate + * only themselves. */ +export const skillWriteKeys = [ReactivityKey.skills] as const; + /** Cloud-only: org membership mutations. */ export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index def5841e58..c4717946d6 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -46,6 +46,9 @@ export const CONSOLE_ROUTE_PATHS = [ "/toolkits/$toolkitSlug", "/artifacts", "/artifacts/$artifactId", + "/skills", + "/skills/new", + "/skills/$skillOwner/$skillName", "/resume/$executionId", "/plugins/$pluginId/$", ] as const; @@ -91,6 +94,14 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array = [ { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, { to: "/toolkits", label: "Toolkits" }, + { to: "/skills", label: "Skills" }, { to: "/artifacts", label: "Artifacts" }, ]; diff --git a/packages/react/src/pages/skill-detail.tsx b/packages/react/src/pages/skill-detail.tsx new file mode 100644 index 0000000000..ea64d11839 --- /dev/null +++ b/packages/react/src/pages/skill-detail.tsx @@ -0,0 +1,285 @@ +import { useMemo } from "react"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import { toast } from "sonner"; +import { + parseSkillMarkdown, + skillFileUri, + SKILL_MD_PATH, + type Owner, + type SkillName, +} from "@executor-js/sdk/shared"; + +import { removeSkill, skillAtom } from "../api/atoms"; +import { messageFromExit } from "../api/error-reporting"; +import { skillWriteKeys } from "../api/reactivity-keys"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/alert-dialog"; +import { Button } from "../components/button"; +import { CopyButton } from "../components/copy-button"; +import { ErrorState } from "../components/error-state"; +import { Markdown } from "../components/markdown"; +import { PageContainer, PageHeader } from "../components/page"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; +import { SkillOwnerTag } from "./skills"; + +interface SkillFileRow { + readonly path: string; + readonly size: number; + readonly digest: string; + readonly content: string; +} + +const BackLink = () => ( + +); + +/** A URL that names no skill this host could ever have (a bad owner segment). */ +export function SkillMissingPage() { + useExecutorDocumentTitle("Skill"); + return ( + + +

+ That isn't a skill address. A skill lives at{" "} + /skills/user/<name> or{" "} + /skills/org/<name>. +

+
+ ); +} + +/** A labelled row of machine metadata — mono value, quiet mono key. */ +function MetaRow(props: { readonly label: string; readonly children: React.ReactNode }) { + return ( +
+
+ {props.label} +
+
{props.children}
+
+ ); +} + +/** One bundled file: path and size always visible, content behind a disclosure + * so a skill with a dozen references still reads as a list. */ +function SkillFileEntry(props: { readonly file: SkillFileRow }) { + const { file } = props; + return ( +
+ + {file.path} + + {file.size} B + + +
+        {file.content}
+      
+
+ ); +} + +/** + * What an agent gets. The console is where a person decides whether a skill is + * doing its job, and they cannot judge that without seeing the two addresses + * their agent will actually use. + */ +function AgentAddressing(props: { readonly owner: Owner; readonly name: string }) { + const toolCall = `skills({ name: "${props.name}" })`; + const uri = skillFileUri({ owner: props.owner, name: props.name }, SKILL_MD_PATH); + return ( +
+

+ Agents see this as +

+
+ {[toolCall, uri].map((value) => ( +
+ {value} + +
+ ))} +
+

+ The tool call works on every MCP client today. The{" "} + skill:// resource is for clients that speak the MCP + Skills extension. +

+
+ ); +} + +export function SkillDetailPage(props: { readonly owner: Owner; readonly name: SkillName }) { + // Rebuilt every render, so it MUST go through the atom family — see + // `skillAtom`. `useMemo` keeps the key object stable for the hook deps too. + const ref = useMemo(() => ({ owner: props.owner, name: props.name }), [props.owner, props.name]); + const skill = useAtomValue(skillAtom(ref)); + const refresh = useAtomRefresh(skillAtom(ref)); + const doRemove = useAtomSet(removeSkill, { mode: "promiseExit" }); + const navigate = useNavigate(); + + useExecutorDocumentTitle(props.name || "Skill"); + + const handleRemove = async () => { + const exit = await doRemove({ params: ref, reactivityKeys: skillWriteKeys }); + if (Exit.isFailure(exit)) { + toast.error(messageFromExit(exit, "Couldn't delete the skill. Try again.")); + return; + } + toast.success("Skill deleted"); + // `params` is omitted so the router keeps the active org slug. + await navigate({ to: "/{-$orgSlug}/skills" }); + }; + + return ( + + + + {isAsyncResultLoading(skill) ? ( +

Loading skill…

+ ) : ( + AsyncResult.match(skill, { + onInitial: () =>

Loading skill…

, + onFailure: () => ( + + ), + onSuccess: ({ value }) => { + const files = value.files as ReadonlyArray; + const skillMd = files.find((file) => file.path === SKILL_MD_PATH); + const parsed = skillMd ? parseSkillMarkdown(skillMd.content) : undefined; + const body = + parsed && Result.isSuccess(parsed) ? parsed.success.body : (skillMd?.content ?? ""); + // Name and description have their own place in the header; the rest + // of the frontmatter is whatever the author wrote and is shown + // verbatim, because that is what the MCP host hands to agents. + const extraFrontmatter = Object.entries(value.frontmatter).filter( + ([key]) => key !== "name" && key !== "description", + ); + + return ( +
+ {value.name}} + description={value.description} + actions={ + <> + + + + + + + + Delete {value.name}? + + This removes the skill and every file in it. Agents will no longer + find it by name. + + + + Cancel + void handleRemove()} + > + Delete Skill + + + + + + } + > +
+ + + Updated {formatRelativeTime(value.updatedAt)} + +
+
+ + {extraFrontmatter.length > 0 ? ( +
+

+ Frontmatter +

+
+ {extraFrontmatter.map(([key, fieldValue]) => ( + + + {typeof fieldValue === "string" + ? fieldValue + : JSON.stringify(fieldValue)} + + + ))} +
+
+ ) : null} + +
+

+ Instructions +

+
+ {body} +
+
+ +
+

+ Files + {files.length} +

+
+ {files.map((file) => ( + + ))} +
+
+ + +
+ ); + }, + }) + )} +
+ ); +} diff --git a/packages/react/src/pages/skill-editor.tsx b/packages/react/src/pages/skill-editor.tsx new file mode 100644 index 0000000000..7944479fdd --- /dev/null +++ b/packages/react/src/pages/skill-editor.tsx @@ -0,0 +1,458 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + isValidSkillFilePath, + parseSkillMarkdown, + SKILL_MD_PATH, + type Owner, + type SkillName, +} from "@executor-js/sdk/shared"; + +import { saveSkill, skillAtom } from "../api/atoms"; +import { messageFromExit } from "../api/error-reporting"; +import { useOrganizationId } from "../api/organization-context"; +import { skillWriteKeys } from "../api/reactivity-keys"; +import { Button } from "../components/button"; +import { FieldLabel } from "../components/field"; +import { Input } from "../components/input"; +import { PageContainer, PageHeader } from "../components/page"; +import { Textarea } from "../components/textarea"; +import { FormErrorAlert } from "../lib/integration-add"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { + connectionOwnerOptionsForAccess, + ConnectionOwnerDropdown, + defaultConnectionOwnerForHost, + normalizeConnectionOwner, +} from "../plugins/connection-owner"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; + +/** A skill that already parses, so the validation panel is green from the first + * keystroke and the author edits a working example instead of guessing. */ +const NEW_SKILL_TEMPLATE = `--- +name: my-skill +description: What this skill does and when an agent should load it. +--- + +# My skill + +Write the instructions an agent should follow here. + +## Steps + +1. ... +`; + +/** Files above this are almost never instructions, and the whole skill is + * capped at 1 MiB anyway — better to say which file was dropped than to fail + * the save with a total-size error. */ +const MAX_IMPORT_FILE_BYTES = 512 * 1024; + +const SkillErrorReason = Schema.Struct({ reason: Schema.String }); +const decodeReason = Schema.decodeUnknownOption(SkillErrorReason); + +/** `InvalidSkillError` carries the actionable sentence in `reason`; its + * `message` prefixes it with "Invalid skill:", which the alert's placement + * already says. Everything else (org-write denied, transport) uses `message`. */ +const saveErrorMessage = (exit: Exit.Exit): string => + Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeReason), { + onNone: () => messageFromExit(exit, "Couldn't save the skill. Try again."), + onSome: ({ reason }) => reason, + }); + +interface ExtraFileRow { + /** Stable across edits so a row keeps focus while its path is retyped. */ + readonly id: number; + readonly path: string; + readonly content: string; +} + +interface EditorSeed { + readonly owner: Owner | null; + readonly skillMd: string; + readonly extras: readonly ExtraFileRow[]; + /** The `(owner, name)` this editor opened, when it is editing one. */ + readonly original: { readonly owner: Owner; readonly name: string } | null; +} + +export function SkillEditorPage(props: { + readonly editing?: { readonly owner: Owner; readonly name: SkillName } | undefined; +}) { + if (props.editing === undefined) { + return ( + + ); + } + return ; +} + +/** Editing needs the stored file CONTENTS, which only `skills.get` returns. The + * form is mounted once the row lands so its state can be seeded directly, + * rather than syncing an effect against a changing fetch. */ +function SkillEditorLoader(props: { + readonly editing: { readonly owner: Owner; readonly name: SkillName }; +}) { + const ref = useMemo( + () => ({ owner: props.editing.owner, name: props.editing.name }), + [props.editing.owner, props.editing.name], + ); + const skill = useAtomValue(skillAtom(ref)); + + if (!AsyncResult.isSuccess(skill)) { + return ( + +

+ {AsyncResult.isFailure(skill) ? "This skill isn't available." : "Loading skill…"} +

+
+ ); + } + + const files = skill.value.files as ReadonlyArray<{ + readonly path: string; + readonly content: string; + }>; + const skillMd = files.find((file) => file.path === SKILL_MD_PATH); + return ( + file.path !== SKILL_MD_PATH) + .map((file, index) => ({ id: index, path: file.path, content: file.content })), + original: { owner: skill.value.owner, name: skill.value.name }, + }} + /> + ); +} + +function SkillEditorForm(props: { readonly seed: EditorSeed }) { + const { seed } = props; + const editing = seed.original !== null; + useExecutorDocumentTitle(editing ? `Edit ${seed.original?.name ?? ""}` : "New skill"); + + const organizationId = useOrganizationId(); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); + const ownerOptions = connectionOwnerOptionsForAccess( + organizationId, + canCreateWorkspaceConnections, + ); + const [owner, setOwner] = useState( + seed.owner ?? defaultConnectionOwnerForHost(organizationId), + ); + const [skillMd, setSkillMd] = useState(seed.skillMd); + const [extras, setExtras] = useState(seed.extras); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [importNotice, setImportNotice] = useState(null); + const nextRowId = useRef(seed.extras.length); + const folderInput = useRef(null); + const navigate = useNavigate(); + + // `webkitdirectory` is what turns a file input into a folder picker. React's + // typings don't declare it, so it is set on the element rather than in JSX. + useEffect(() => { + folderInput.current?.setAttribute("webkitdirectory", ""); + }, []); + + // Always keep the selection valid against the options this host offers — on + // local there is exactly one, and the picker hides itself. + const activeOwner = normalizeConnectionOwner(owner, ownerOptions); + const parsed = useMemo(() => parseSkillMarkdown(skillMd), [skillMd]); + const parsedName = Result.isSuccess(parsed) ? parsed.success.name : null; + // Identity is `(owner, name)` and `save` reads the name out of the file, so + // retitling the frontmatter or moving the owner writes a SECOND skill and + // leaves this one alone. Say that before the save, not after. + const forksExisting = + seed.original !== null && + parsedName !== null && + (parsedName !== seed.original.name || activeOwner !== seed.original.owner); + + const doSave = useAtomSet(saveSkill, { mode: "promiseExit" }); + + const addRow = () => { + setExtras((rows) => [...rows, { id: nextRowId.current++, path: "", content: "" }]); + }; + + const updateRow = (id: number, patch: Partial>) => { + setExtras((rows) => rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + }; + + const removeRow = (id: number) => { + setExtras((rows) => rows.filter((row) => row.id !== id)); + }; + + /** + * Read a picked folder into the editor. + * + * A folder pick reports every file as `/`, so the wrapping + * segment is stripped — otherwise SKILL.md would land at `my-skill/SKILL.md` + * and the save would reject a skill with no root file. Binary and oversized + * files are dropped rather than mangled into UTF-8, and dotfiles go with them + * (`.DS_Store` is in every folder a Mac has ever opened). + */ + const importFolder = async (picked: FileList) => { + const chosen = Array.from(picked); + const relative = chosen.map((file) => file.webkitRelativePath || file.name); + const topSegments = new Set(relative.map((path) => path.split("/")[0])); + const strip = topSegments.size === 1 && relative.every((path) => path.includes("/")); + + const imported: Array<{ path: string; content: string }> = []; + const skipped: string[] = []; + for (const [index, file] of chosen.entries()) { + const raw = relative[index] ?? file.name; + const path = strip ? raw.split("/").slice(1).join("/") : raw; + if ( + path === "" || + !isValidSkillFilePath(path) || + path.split("/").some((segment) => segment.startsWith(".")) || + file.size > MAX_IMPORT_FILE_BYTES + ) { + skipped.push(raw); + continue; + } + const content = await file.text(); + // A NUL byte is the cheap, reliable tell that this was never text. + if (content.includes("\u0000")) { + skipped.push(raw); + continue; + } + imported.push({ path, content }); + } + + const rootFile = imported.find((file) => file.path === SKILL_MD_PATH); + if (rootFile) setSkillMd(rootFile.content); + setExtras( + imported + .filter((file) => file.path !== SKILL_MD_PATH) + .map((file) => ({ id: nextRowId.current++, path: file.path, content: file.content })), + ); + + const parts = [`Imported ${imported.length} ${imported.length === 1 ? "file" : "files"}.`]; + if (!rootFile) parts.push("No SKILL.md in that folder — write one below."); + if (skipped.length > 0) { + parts.push( + `Skipped ${skipped.length} (binary, hidden, or over 512 KiB): ${skipped.slice(0, 5).join(", ")}${skipped.length > 5 ? "…" : ""}.`, + ); + } + setImportNotice(parts.join(" ")); + }; + + const submit = async () => { + if (!Result.isSuccess(parsed) || saving) return; + setSaving(true); + setError(null); + const exit = await doSave({ + payload: { + owner: activeOwner, + files: [ + { path: SKILL_MD_PATH, content: skillMd }, + ...extras + .filter((row) => row.path.trim() !== "") + .map((row) => ({ path: row.path.trim(), content: row.content })), + ], + }, + reactivityKeys: skillWriteKeys, + }); + setSaving(false); + if (Exit.isFailure(exit)) { + setError(saveErrorMessage(exit)); + return; + } + // `params` names the saved row, not the one the editor opened — the save + // may have written a different name than the URL carried. + await navigate({ + to: "/{-$orgSlug}/skills/$skillOwner/$skillName", + params: { skillOwner: exit.value.owner, skillName: exit.value.name }, + search: {}, + }); + }; + + return ( + + + + void submit()} + > + Save Skill + + } + /> + +
+ {error ? : null} + + + +
+
+
+ SKILL.md +

+ YAML frontmatter with name and{" "} + description, then the instructions. +

+
+ + { + const picked = event.target.files; + if (picked && picked.length > 0) void importFolder(picked); + // Clear it so picking the SAME folder again still fires. + event.target.value = ""; + }} + /> +
+ {importNotice ?

{importNotice}

: null} +