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/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 4d09b63152..7675a5ee12 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -13,6 +13,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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' @@ -20,11 +21,13 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' 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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport } from './../../../packages/react/src/routes/skills.$skillOwner.$skillName' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -52,6 +55,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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', @@ -94,6 +103,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( { @@ -135,6 +151,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update( + { + id: '/$skillOwner/$skillName', + path: '/$skillOwner/$skillName', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -159,6 +184,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}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -168,9 +194,11 @@ 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 { '/join/$code': typeof JoinDotcodeRoute @@ -179,6 +207,7 @@ 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}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -188,9 +217,11 @@ 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 @@ -200,6 +231,7 @@ export interface FileRoutesById { '/{-$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}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -209,9 +241,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 @@ -222,6 +256,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -231,9 +266,11 @@ 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: | '/join/$code' @@ -242,6 +279,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -251,9 +289,11 @@ 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__' | '/join/$code' @@ -262,6 +302,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/skills' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/users' @@ -271,9 +312,11 @@ 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 { @@ -283,6 +326,7 @@ export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute @@ -325,6 +369,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' @@ -374,6 +425,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' @@ -409,6 +467,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/$' @@ -441,6 +506,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 } @@ -466,6 +549,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: diff --git a/apps/local/.preview/dev.log b/apps/local/.preview/dev.log new file mode 100644 index 0000000000..efd81d7701 --- /dev/null +++ b/apps/local/.preview/dev.log @@ -0,0 +1,28 @@ +$ bun run dev:proxy && bun run dev:vite +$ portless proxy start --multiplex --port 1355 || true +Proxy is already running on port 1355. +To restart: portless proxy stop -p 1355 && portless proxy start -p 1355 +$ EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name "executor-local-$(basename "$(cd ../.. && pwd)")" bunx --bun vite dev + +portless + +-- Proxy is running +-- executor-local-agent-skills.localhost (auto-resolves to 127.0.0.1) +-- Using port 4877 + + -> https://executor-local-agent-skills.localhost:1355 + +Running: PORT=4877 HOST=127.0.0.1 PORTLESS_URL=https://executor-local-agent-skills.localhost:1355 bunx --bun vite dev --port 4877 --strictPort --host 127.0.0.1 + + + Open with auth: http://127.0.0.1:4877/?_token=Jh2Z1knuzRaiXPgZsB2Isb8K2PirdRhqu141j1uyPr4 + + + VITE v8.0.8 ready in 731 ms + + ➜ Local: http://127.0.0.1:4877/ +[23:00:44.885] INFO (#35) http.span.1=1242ms: Sent HTTP response { + "http.method": "POST", + "http.url": "/skills/import", + "http.status": 200, +} 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..3b5798fbb5 --- /dev/null +++ b/packages/core/api/src/handlers/skills.ts @@ -0,0 +1,75 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import { parseGitHubSkillSource, type Skill, type SkillSummary } from "@executor-js/sdk"; +import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { importSkillsFromGitHub, parsedSourceOrError } from "../skills/github-import"; +import { capture } from "@executor-js/api"; + +// GitHub is public internet, so the default hosted client — which refuses +// private and loopback addresses — is the right guard regardless of what the +// host allows integrations to reach. +const githubHttpClient = makeHostedHttpClientLayer(); + +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("import", ({ payload }) => + capture( + Effect.gen(function* () { + const source = yield* parsedSourceOrError(parseGitHubSkillSource(payload.source)); + return yield* importSkillsFromGitHub(source); + }).pipe(Effect.provide(githubHttpClient)), + ), + ) + .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..0d954d018c --- /dev/null +++ b/packages/core/api/src/skills/api.ts @@ -0,0 +1,119 @@ +// --------------------------------------------------------------------------- +// 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, + SkillSourceError, +} 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), +}); + +/** What the user pasted: a GitHub repo, a path inside one, or a skills.sh link. */ +const ImportSkillsPayload = Schema.Struct({ + source: Schema.String, +}); + +/** One skill found at the source, validated, with its files ready to save. */ +const ImportedSkillCandidate = Schema.Struct({ + directory: Schema.String, + name: SkillName, + description: Schema.String, + files: Schema.Array(SkillFileInputSchema), +}); + +export const ImportSkillsResponse = Schema.Struct({ + source: Schema.String, + ref: Schema.String, + skills: Schema.Array(ImportedSkillCandidate), + rejected: Schema.Array(Schema.Struct({ directory: Schema.String, reason: Schema.String })), + truncated: Schema.Boolean, +}); + +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( + // Read-only: fetches the repository and returns candidates. Saving what the + // user picks goes through `save`, so import never writes on its own. + HttpApiEndpoint.post("import", "/skills/import", { + payload: ImportSkillsPayload, + success: ImportSkillsResponse, + error: [InternalError, SkillSourceError], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/skills/:owner/:name", { + params: SkillParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: [InternalError, OrgWriteDeniedError], + }), + ); diff --git a/packages/core/api/src/skills/github-import.test.ts b/packages/core/api/src/skills/github-import.test.ts new file mode 100644 index 0000000000..92f67d07cb --- /dev/null +++ b/packages/core/api/src/skills/github-import.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Result } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { parseGitHubSkillSource } from "@executor-js/sdk"; + +import { + importSkillsFromGitHub, + parsedSourceOrError, + skillDirectoriesInTree, +} from "./github-import"; + +describe("skillDirectoriesInTree", () => { + const paths = [ + "README.md", + "skills/pdf/SKILL.md", + "skills/pdf/references/forms.md", + "skills/pdf/nested/SKILL.md", + "skills/csv/SKILL.md", + "other/notes.md", + ]; + + it("finds every SKILL.md parent, shallow first", () => { + expect(skillDirectoriesInTree(paths, "")).toEqual([ + "skills/csv", + "skills/pdf", + "skills/pdf/nested", + ]); + }); + + it("scopes to the requested path", () => { + expect(skillDirectoriesInTree(paths, "skills/pdf")).toEqual([ + "skills/pdf", + "skills/pdf/nested", + ]); + expect(skillDirectoriesInTree(paths, "other")).toEqual([]); + }); + + it("treats a root SKILL.md as the empty directory", () => { + expect(skillDirectoriesInTree(["SKILL.md", "ref.md"], "")).toEqual([""]); + }); +}); + +describe("parsedSourceOrError", () => { + it("turns an unparseable source into a user-facing error", async () => { + const result = await Effect.runPromise(Effect.result(parsedSourceOrError(Option.none()))); + expect(Result.isFailure(result)).toBe(true); + }); +}); + +// A fake GitHub: the repo lookup, one recursive tree, and raw file reads. +const fakeGitHub = (files: Record): typeof globalThis.fetch => + (async (input) => { + const url = String(input); + if (url === "https://api.github.com/repos/acme/skills") { + return Response.json({ default_branch: "main" }); + } + if (url.startsWith("https://api.github.com/repos/acme/skills/git/trees/main")) { + return Response.json({ + sha: "abc", + tree: Object.entries(files).map(([path, content]) => ({ + path, + type: "blob", + size: content.length, + })), + }); + } + const raw = "https://raw.githubusercontent.com/acme/skills/main/"; + if (url.startsWith(raw)) { + const path = decodeURIComponent(url.slice(raw.length)); + const content = files[path]; + return content === undefined + ? new Response("not found", { status: 404 }) + : new Response(content, { status: 200 }); + } + return new Response("nope", { status: 404 }); + }) as typeof globalThis.fetch; + +describe("importSkillsFromGitHub", () => { + const run = (files: Record, source: string) => + Effect.runPromise( + Effect.result( + importSkillsFromGitHub(Option.getOrThrow(parseGitHubSkillSource(source))).pipe( + // The real layer reads its fetch from this tag, so the fake plugs in + // beneath it with no other change. + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fakeGitHub(files)), + ), + ), + ); + + const valid = [ + "---", + "name: pdf", + "description: Extract text from PDFs. Use when the user mentions PDFs.", + "---", + "# PDF", + ].join("\n"); + + it("returns validated candidates with their files and reports rejects", async () => { + const result = await run( + { + "README.md": "# repo", + "skills/pdf/SKILL.md": valid, + "skills/pdf/references/forms.md": "# Forms", + "skills/pdf/logo.png": "binary", + "skills/broken/SKILL.md": "# no frontmatter", + }, + "acme/skills", + ); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isFailure(result)) return; + expect(result.success.ref).toBe("main"); + expect(result.success.skills.map((s) => s.name)).toEqual(["pdf"]); + expect(result.success.skills[0]?.files.map((f) => f.path)).toEqual([ + "SKILL.md", + "references/forms.md", + ]); + expect(result.success.rejected.map((r) => r.directory)).toEqual(["skills/broken"]); + }); + + it("narrows to the names a pasted `--skill` flag asked for", async () => { + const files = { + "skills/pdf/SKILL.md": valid, + "skills/csv/SKILL.md": valid.replace("name: pdf", "name: csv"), + }; + const result = await run(files, "npx skills add acme/skills --skill csv"); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isFailure(result)) return; + expect(result.success.skills.map((s) => s.name)).toEqual(["csv"]); + + const missing = await run(files, "npx skills add acme/skills --skill nope"); + expect(Result.isFailure(missing)).toBe(true); + if (Result.isSuccess(missing)) return; + expect(missing.failure.reason).toContain("No skill named `nope`"); + }); + + it("fails with a reason when the repo has no skills", async () => { + const result = await run({ "README.md": "# repo" }, "acme/skills"); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure.reason).toContain("No SKILL.md found"); + }); +}); diff --git a/packages/core/api/src/skills/github-import.ts b/packages/core/api/src/skills/github-import.ts new file mode 100644 index 0000000000..842ddf72d4 --- /dev/null +++ b/packages/core/api/src/skills/github-import.ts @@ -0,0 +1,326 @@ +// --------------------------------------------------------------------------- +// Import skills from a GitHub repository. +// +// One tree listing (`GET /repos/{owner}/{repo}/git/trees/{ref}?recursive=1`) +// finds every SKILL.md under the requested path, then each skill directory's +// text files are read through the raw content host. That is the same route +// `npx skills`, `gh skill`, and skills.sh take, minus the git clone: nothing +// touches disk, and a private repo simply reports as not found. +// +// The result is a list of CANDIDATES — each already validated by +// `prepareSkillFiles` so the console can show the name and description before +// the user picks which to save. Saving goes through the ordinary `skills.save` +// endpoint; this route never writes. +// --------------------------------------------------------------------------- + +import { Duration, Effect, Option, Predicate, Result, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + isValidSkillFilePath, + prepareSkillFiles, + SKILL_MD_PATH, + SkillSourceError, + formatGitHubSkillSource, + type GitHubSkillSource, + type SkillFileInput, + type SkillName, +} from "@executor-js/sdk"; + +const GITHUB_API = "https://api.github.com"; +const GITHUB_RAW = "https://raw.githubusercontent.com"; +const USER_AGENT = "executor-skills-import"; + +/** Skills per import; a monorepo of hundreds gets the first N and a note. */ +export const GITHUB_IMPORT_MAX_SKILLS = 50; +/** Files read per skill; matches the save limit so nothing is fetched for naught. */ +const MAX_FILES_PER_SKILL = 64; +/** Per-file byte cap from the tree listing, before any content is read. */ +const MAX_FILE_BYTES = 512 * 1024; +/** Extensions treated as text. Anything else is a binary the format cannot carry. */ +const TEXT_EXTENSIONS = new Set([ + "md", + "txt", + "json", + "yaml", + "yml", + "toml", + "csv", + "tsv", + "xml", + "html", + "css", + "js", + "mjs", + "cjs", + "ts", + "tsx", + "jsx", + "py", + "rb", + "sh", + "bash", + "zsh", + "fish", + "sql", + "graphql", + "gql", + "ini", + "cfg", + "conf", + "env", + "example", + "template", + "tpl", + "hbs", + "mustache", + "j2", + "rs", + "go", + "java", + "kt", + "swift", + "c", + "h", + "cpp", + "hpp", + "cs", + "php", + "pl", + "lua", + "r", + "jl", + "ex", + "exs", + "erl", + "hs", + "scala", + "clj", + "ps1", + "bat", + "cmd", + "mdx", + "rst", + "adoc", + "tex", + "svg", +]); + +const TreeResponse = Schema.Struct({ + sha: Schema.String, + truncated: Schema.optional(Schema.Boolean), + tree: Schema.Array( + Schema.Struct({ + path: Schema.String, + type: Schema.String, + size: Schema.optional(Schema.Number), + }), + ), +}); + +const RepoResponse = Schema.Struct({ default_branch: Schema.String }); + +const decodeTree = Schema.decodeUnknownResult(Schema.fromJsonString(TreeResponse)); +const decodeRepo = Schema.decodeUnknownResult(Schema.fromJsonString(RepoResponse)); + +export interface GitHubSkillCandidate { + /** Directory inside the repo, `""` when the repo root is the skill. */ + readonly directory: string; + readonly name: SkillName; + readonly description: string; + readonly files: readonly SkillFileInput[]; +} + +export interface GitHubImportResult { + readonly source: string; + readonly ref: string; + readonly skills: readonly GitHubSkillCandidate[]; + /** Directories with a SKILL.md that did not validate, with the reason. */ + readonly rejected: readonly { readonly directory: string; readonly reason: string }[]; + /** True when more skills existed than {@link GITHUB_IMPORT_MAX_SKILLS}. */ + readonly truncated: boolean; +} + +const fail = (reason: string) => Effect.fail(new SkillSourceError({ reason })); + +const isTextPath = (path: string): boolean => { + const base = path.slice(path.lastIndexOf("/") + 1); + const dot = base.lastIndexOf("."); + if (dot === -1) return /^(LICENSE|README|Makefile|Dockerfile|CHANGELOG|NOTICE)$/i.test(base); + return TEXT_EXTENSIONS.has(base.slice(dot + 1).toLowerCase()); +}; + +/** Skill directories are the parents of every SKILL.md at or under `path`. */ +export const skillDirectoriesInTree = ( + paths: readonly string[], + root: string, +): readonly string[] => { + const prefix = root === "" ? "" : `${root}/`; + const found = new Set(); + for (const path of paths) { + if (!path.startsWith(prefix)) continue; + if (path === SKILL_MD_PATH || path.endsWith(`/${SKILL_MD_PATH}`)) { + found.add(path === SKILL_MD_PATH ? "" : path.slice(0, -(SKILL_MD_PATH.length + 1))); + } + } + // Shallow first so `skills/foo` lists before `skills/foo/nested`; ties by name. + return [...found].sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)); +}; + +export const importSkillsFromGitHub = ( + source: GitHubSkillSource, +): Effect.Effect => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const label = formatGitHubSkillSource(source); + + const get = (url: string, accept: string) => + http + .execute( + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("user-agent", USER_AGENT), + HttpClientRequest.setHeader("accept", accept), + ), + ) + .pipe( + Effect.flatMap((response) => + Effect.map(response.text, (text) => ({ status: response.status, text })), + ), + Effect.timeout(Duration.seconds(20)), + Effect.catch(() => fail(`GitHub could not be reached while importing ${label}.`)), + ); + + const notFound = `${label} was not found on GitHub. Check the URL, and note that private repositories cannot be imported.`; + const guardStatus = (status: number) => + status === 404 + ? fail(notFound) + : status === 403 || status === 429 + ? fail("GitHub rate-limited the import. Try again in a few minutes.") + : status >= 400 + ? fail(`GitHub answered ${status} while importing ${label}.`) + : Effect.void; + + // The ref: what the URL named, else the repository's default branch. + const ref = yield* source.ref + ? Effect.succeed(source.ref) + : Effect.gen(function* () { + const repo = yield* get( + `${GITHUB_API}/repos/${source.owner}/${source.repo}`, + "application/vnd.github+json", + ); + yield* guardStatus(repo.status); + const decoded = decodeRepo(repo.text); + if (Result.isFailure(decoded)) return yield* fail(notFound); + return decoded.success.default_branch; + }); + + const treeResponse = yield* get( + `${GITHUB_API}/repos/${source.owner}/${source.repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`, + "application/vnd.github+json", + ); + yield* guardStatus(treeResponse.status); + const tree = decodeTree(treeResponse.text); + if (Result.isFailure(tree)) return yield* fail(notFound); + + const blobs = new Map(); + for (const entry of tree.success.tree) { + if (entry.type === "blob") blobs.set(entry.path, entry.size ?? 0); + } + const directories = skillDirectoriesInTree([...blobs.keys()], source.path); + if (directories.length === 0) { + return yield* fail( + source.path === "" + ? `No SKILL.md found anywhere in ${label}.` + : `No SKILL.md found under ${source.path} in ${source.owner}/${source.repo}.`, + ); + } + const truncated = directories.length > GITHUB_IMPORT_MAX_SKILLS; + const chosen = directories.slice(0, GITHUB_IMPORT_MAX_SKILLS); + + const readSkill = (directory: string) => + Effect.gen(function* () { + const prefix = directory === "" ? "" : `${directory}/`; + // Files of THIS skill only: a nested skill's files belong to it. + const nested = directories.filter( + (other) => other !== directory && other.startsWith(prefix), + ); + const paths = [...blobs.entries()] + .filter(([path]) => path.startsWith(prefix)) + .map(([path, size]) => ({ full: path, path: path.slice(prefix.length), size })) + .filter( + ({ path, size }) => + isValidSkillFilePath(path) && + !path.split("/").some((segment) => segment.startsWith(".")) && + size <= MAX_FILE_BYTES && + isTextPath(path) && + !nested.some((dir) => path.startsWith(`${dir.slice(prefix.length)}/`)), + ) + .sort((a, b) => (a.path === SKILL_MD_PATH ? -1 : b.path === SKILL_MD_PATH ? 1 : 0)) + .slice(0, MAX_FILES_PER_SKILL); + + const files = yield* Effect.forEach( + paths, + ({ full, path }) => + Effect.map( + get( + `${GITHUB_RAW}/${source.owner}/${source.repo}/${encodeURIComponent(ref)}/${full + .split("/") + .map(encodeURIComponent) + .join("/")}`, + "text/plain", + ), + (response): SkillFileInput | null => + response.status === 200 && !response.text.includes("") + ? { path, content: response.text } + : null, + ), + { concurrency: 6 }, + ); + const present = files.filter(Predicate.isNotNull); + const prepared = yield* Effect.promise(() => prepareSkillFiles(present)); + return Result.match(prepared, { + onFailure: (error) => ({ directory, reason: error.reason }) as const, + onSuccess: ({ parsed, files: validated }) => + ({ + directory, + name: parsed.name, + description: parsed.description, + files: validated.map(({ path, content }) => ({ path, content })), + }) as const, + }); + }); + + const outcomes = yield* Effect.forEach(chosen, readSkill, { concurrency: 3 }); + // `--skill x` names the skills wanted; the rest are read but not offered. + // Matching is by frontmatter name, then by directory basename, so a name + // that only exists on disk still resolves. + const wanted = new Set(source.skills); + const isWanted = (outcome: { readonly directory: string; readonly name?: string }) => + wanted.size === 0 || + (outcome.name !== undefined && wanted.has(outcome.name)) || + wanted.has(outcome.directory.slice(outcome.directory.lastIndexOf("/") + 1)); + const skills: GitHubSkillCandidate[] = []; + const rejected: { directory: string; reason: string }[] = []; + for (const outcome of outcomes) { + if (!isWanted(outcome)) continue; + if ("reason" in outcome) rejected.push(outcome); + else skills.push(outcome); + } + if (wanted.size > 0 && skills.length === 0 && rejected.length === 0) { + return yield* fail( + `No skill named ${[...wanted].map((name) => `\`${name}\``).join(", ")} in ${label}.`, + ); + } + return { source: label, ref, skills, rejected, truncated }; + }).pipe(Effect.withSpan("skills.import.github")); + +/** Exposed for tests: the parse step the handler runs before any network. */ +export const parsedSourceOrError = ( + parsed: Option.Option, +): Effect.Effect => + Option.match(parsed, { + onNone: () => + fail( + "Enter a GitHub repository (owner/repo), a path inside one, a github.com URL, a skills.sh link, or an `npx skills add …` command.", + ), + onSome: Effect.succeed, + }); 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..45f105e118 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,63 @@ 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}`; + } +} + +/** A skill import by URL could not be served: the URL is not a GitHub or + * skills.sh location, the repository or path does not exist, GitHub refused + * or rate-limited the request, or nothing under the path is a skill. */ +export class SkillSourceError + extends Schema.TaggedErrorClass()( + "SkillSourceError", + { reason: Schema.String }, + { httpApiStatus: 400 }, + ) + implements UserActionableError +{ + readonly __executorUserActionable = true; + readonly code = "skill_source"; + get userMessage(): string { + return this.reason; + } + override get message(): string { + return `Skill import failed: ${this.reason}`; + } +} + // --------------------------------------------------------------------------- // Union — the failure channel of `execute`. // --------------------------------------------------------------------------- @@ -300,4 +358,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 = Option.getOrNull(parseGitHubSkillSource(input)); + +describe("parseGitHubSkillSource", () => { + it.each([ + [ + "npx skills add https://github.com/kitlangton/skills --skill effect", + { owner: "kitlangton", repo: "skills", ref: null, path: "", skills: ["effect"] }, + ], + [ + "bunx skills add owner/repo -s a,b --skill=c", + { owner: "owner", repo: "repo", ref: null, path: "", skills: ["a", "b", "c"] }, + ], + [ + "gh skill install owner/repo/skills/pdf", + { owner: "owner", repo: "repo", ref: null, path: "skills/pdf", skills: [] }, + ], + ["owner/repo", { owner: "owner", repo: "repo", ref: null, path: "" }], + ["owner/repo/skills/pdf", { owner: "owner", repo: "repo", ref: null, path: "skills/pdf" }], + ["github.com/owner/repo", { owner: "owner", repo: "repo", ref: null, path: "" }], + ["https://github.com/owner/repo.git", { owner: "owner", repo: "repo", ref: null, path: "" }], + [ + "https://github.com/owner/repo/tree/main/skills/pdf", + { owner: "owner", repo: "repo", ref: "main", path: "skills/pdf" }, + ], + [ + "https://github.com/owner/repo/blob/v1.2/skills/pdf/SKILL.md", + { owner: "owner", repo: "repo", ref: "v1.2", path: "skills/pdf" }, + ], + ["https://skills.sh/owner/repo/pdf", { owner: "owner", repo: "repo", ref: null, path: "pdf" }], + ["https://www.github.com/owner/repo/", { owner: "owner", repo: "repo", ref: null, path: "" }], + ])("parses %s", (input, expected) => { + expect(parse(input)).toEqual({ skills: [], ...expected }); + }); + + it.each([ + "", + "owner", + "https://gitlab.com/owner/repo", + "https://github.com/owner", + "owner/repo/../etc", + "not a url at all", + ])("rejects %s", (input) => { + expect(parse(input)).toBeNull(); + }); + + it("formats a source as owner/repo@ref/path", () => { + expect( + formatGitHubSkillSource({ owner: "o", repo: "r", ref: "main", path: "skills/x", skills: [] }), + ).toBe("o/r@main/skills/x"); + expect( + formatGitHubSkillSource({ owner: "o", repo: "r", ref: null, path: "", skills: [] }), + ).toBe("o/r"); + }); +}); diff --git a/packages/core/sdk/src/skill-source.ts b/packages/core/sdk/src/skill-source.ts new file mode 100644 index 0000000000..8cf43acfec --- /dev/null +++ b/packages/core/sdk/src/skill-source.ts @@ -0,0 +1,164 @@ +// --------------------------------------------------------------------------- +// Where a skill comes from when it is imported by URL. +// +// Every public skill registry today (skills.sh, `npx skills`, `gh skill`, +// skillshare) is GitHub underneath, so one resolver covers them all: a repo, +// an optional ref, and an optional path inside it. The resolver is pure — it +// turns the strings a user pastes into a `GitHubSkillSource` — and the fetch +// lives beside the HTTP handler that needs a network. +// --------------------------------------------------------------------------- + +import { Option } from "effect"; + +export interface GitHubSkillSource { + readonly owner: string; + readonly repo: string; + /** Branch, tag, or commit. Absent means the repository's default branch. */ + readonly ref: string | null; + /** Directory inside the repo to scan, `""` for the root. */ + readonly path: string; + /** Only skills with these names, when the input named some (`--skill x`). + * Empty means every skill found. */ + readonly skills: readonly string[]; +} + +const GITHUB_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +const isSegment = (value: string | undefined): value is string => + value !== undefined && GITHUB_SEGMENT.test(value) && value !== "." && value !== ".."; + +const cleanRepo = (repo: string): string => repo.replace(/\.git$/, ""); + +const cleanPath = (segments: readonly string[]): string => + segments.filter((segment) => segment !== "").join("/"); + +/** + * Parse what a user pastes into a GitHub source. + * + * Accepted forms, all with optional `.git`, trailing slash, and query string: + * + * owner/repo + * owner/repo/path/to/skill + * github.com/owner/repo[/tree//path] + * https://github.com/owner/repo[/tree//path | /blob//path/SKILL.md] + * https://skills.sh/owner/repo[/skill-name] + * + * A `blob` link to a SKILL.md resolves to the directory that holds it. Anything + * else — another host, an owner-only URL, a path with `..` — is `None`, and the + * caller tells the user what is accepted rather than guessing. + */ +/** + * Pull the location and any `--skill` names out of a pasted install command. + * + * skills.sh shows `npx skills add --skill `; `gh skill install`, + * `skillshare install`, and `bunx`/`pnpx` variants have the same shape. The + * location is the first token that is not a command word or a flag; every + * `--skill`/`-s` value (space- or `=`-separated, comma lists allowed) narrows + * the import to those names. + */ +const splitCommand = ( + input: string, +): { readonly location: string; readonly skills: readonly string[] } => { + const tokens = input.split(/\s+/).filter((token) => token !== ""); + const commandWords = new Set([ + "npx", + "bunx", + "pnpx", + "pnpm", + "yarn", + "bun", + "npm", + "dlx", + "x", + "skills", + "skill", + "skillshare", + "gh", + "add", + "install", + "i", + "-y", + "--yes", + ]); + const skills: string[] = []; + let location: string | null = null; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + if (token === "--skill" || token === "-s" || token === "--skills") { + const value = tokens[index + 1]; + if (value !== undefined && !value.startsWith("-")) { + skills.push(...value.split(",")); + index += 1; + } + continue; + } + const inline = /^--skills?=(.+)$/.exec(token); + if (inline?.[1]) { + skills.push(...inline[1].split(",")); + continue; + } + if (token.startsWith("-")) continue; + if (commandWords.has(token.toLowerCase())) continue; + if (location === null) location = token; + } + return { + location: location ?? "", + skills: skills.map((name) => name.trim()).filter((name) => name !== ""), + }; +}; + +export const parseGitHubSkillSource = (input: string): Option.Option => { + const { location, skills } = splitCommand(input.trim()); + const trimmed = location.replace(/^["']|["']$/g, ""); + if (trimmed === "") return Option.none(); + + let segments: string[]; + let viaHost = false; + const firstSegment = trimmed.split("/")[0]?.toLowerCase() ?? ""; + const looksLikeHost = firstSegment.includes(".") && !trimmed.includes("://"); + if ( + /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+(\/|$)/.test(trimmed) && + !trimmed.includes("://") && + !looksLikeHost + ) { + // `owner/repo[/path]` shorthand — the form every skills CLI accepts. + segments = trimmed.split("?")[0]?.split("/") ?? []; + } else { + const withScheme = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + let url: URL; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing throws on malformed input; None is the honest answer + try { + url = new URL(withScheme); + } catch { + return Option.none(); + } + const host = url.hostname.toLowerCase().replace(/^www\./, ""); + if (host !== "github.com" && host !== "skills.sh") return Option.none(); + viaHost = true; + segments = url.pathname.split("/").slice(1); + } + + const [owner, repoRaw, ...rest] = segments; + if (!isSegment(owner) || repoRaw === undefined) return Option.none(); + const repo = cleanRepo(repoRaw); + if (!isSegment(repo)) return Option.none(); + + // GitHub's `tree`/`blob` URLs carry the ref as the next segment; a bare + // shorthand carries no ref and the rest is the path. + let ref: string | null = null; + let pathSegments = rest; + if (viaHost && (rest[0] === "tree" || rest[0] === "blob") && rest.length >= 2) { + const kind = rest[0]; + ref = rest[1] ?? null; + pathSegments = rest.slice(2); + if (kind === "blob" && pathSegments[pathSegments.length - 1] === "SKILL.md") { + pathSegments = pathSegments.slice(0, -1); + } + } + if (pathSegments.some((segment) => segment === "." || segment === "..")) return Option.none(); + return Option.some({ owner, repo, ref, path: cleanPath(pathSegments), skills }); +}; + +/** The canonical `owner/repo[@ref][/path]` label for a resolved source. */ +export const formatGitHubSkillSource = (source: GitHubSkillSource): string => + `${source.owner}/${source.repo}${source.ref ? `@${source.ref}` : ""}${source.path ? `/${source.path}` : ""}`; diff --git a/packages/core/sdk/src/skill.ts b/packages/core/sdk/src/skill.ts new file mode 100644 index 0000000000..6f8ab001e4 --- /dev/null +++ b/packages/core/sdk/src/skill.ts @@ -0,0 +1,425 @@ +// --------------------------------------------------------------------------- +// Agent Skills — the SKILL.md standard (https://agentskills.io/specification) +// saved into the workspace. Row → public projection, the operation inputs, and +// the pure validation that turns a set of uploaded files into a skill. +// +// A skill IS a directory: a SKILL.md with YAML frontmatter (`name`, +// `description`, a few optional fields) followed by markdown instructions, plus +// any bundled files (`references/`, `scripts/`, `assets/`). We store the whole +// directory as one row so the MCP host can serve it back file-by-file with +// digests, which is what the MCP Skills Extension (SEP-2640) requires. +// +// Owner-scoped like a connection: an `org` skill is shared with everyone in the +// workspace, a `user` skill is personal. Identity is `(owner, name)`. +// --------------------------------------------------------------------------- + +import { Option, Result, Schema } from "effect"; +import { parse as parseYaml } from "yaml"; + +import type { SkillRow } from "./core-schema"; +import { InvalidSkillError } from "./errors"; +import { SkillName, type Owner } from "./ids"; + +/** One file as uploaded: a relative POSIX path and its UTF-8 text. */ +export interface SkillFileInput { + readonly path: string; + readonly content: string; +} + +/** One file in a skill's manifest: what the MCP Skills Extension lists. */ +export interface SkillFileEntry { + /** Relative to the skill root, `/`-separated. `SKILL.md` for the root file. */ + readonly path: string; + /** Byte length of the UTF-8 content. */ + readonly size: number; + /** `sha256:<64 lowercase hex>` 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(), + }); +}; + +const tryYaml = (text: string): Result.Result => + Result.try({ + try: () => parseYaml(text) as unknown, + catch: () => new InvalidSkillError({ reason: "SKILL.md frontmatter is not valid YAML." }), + }); + +/** + * Quote the value of every top-level `key: value` line whose value contains + * a colon and is not already quoted or a block scalar. The most common + * frontmatter authored for other clients is technically invalid YAML — + * `description: Use when: the user asks about PDFs` — and their parsers + * happen to accept it. Rewriting only unquoted scalar values keeps nested + * mappings (`metadata:`) and block scalars (`description: >`) untouched. + */ +const quoteUnquotedScalars = (yaml: string): string => + yaml + .split("\n") + .map((line) => { + const match = /^([ \t]*[A-Za-z0-9_-]+):[ \t]+(.*)$/.exec(line); + if (!match) return line; + const [, key, value] = match; + if (value === undefined || key === undefined) return line; + const trimmed = value.trim(); + if ( + !trimmed.includes(":") || + /^["'>|]/.test(trimmed) || + trimmed.startsWith("[") || + trimmed.startsWith("{") + ) { + return line; + } + return `${key}: ${JSON.stringify(trimmed)}`; + }) + .join("\n"); + +/** Parse the frontmatter block, retrying once with unquoted colon-bearing + * values quoted so skills written for lenient clients still load. */ +const parseFrontmatterYaml = (yaml: string): Result.Result => { + const strict = tryYaml(yaml); + if (Result.isSuccess(strict)) return strict; + const quoted = quoteUnquotedScalars(yaml); + return quoted === yaml ? strict : tryYaml(quoted); +}; + +/** + * 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 = parseFrontmatterYaml(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..205f780abe --- /dev/null +++ b/packages/core/sdk/src/skills.test.ts @@ -0,0 +1,208 @@ +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("accepts an unquoted colon in a scalar value, as lenient clients do", () => { + const parsed = parseSkillMarkdown( + "---\nname: pdf\ndescription: Use when: the user asks about PDFs\nmetadata:\n note: a: b\n---\nBody", + ); + expect(Result.isSuccess(parsed)).toBe(true); + if (Result.isFailure(parsed)) return; + expect(parsed.success.description).toBe("Use when: the user asks about PDFs"); + }); + + 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..31a3c73e2d 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,17 @@ 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"); + +/** Read-only: lists the skills found at a GitHub URL. Saving is a separate + * `saveSkill` per pick, so this carries no reactivity keys of its own. */ +export const importSkills = ExecutorApiClient.mutation("skills", "import"); + 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} +