History

Add Agent Skills

Save SKILL.md directories to a workspace, personally or org-shared, and serve them to every connected agent through the MCP skills tool and the MCP Skills Extension. Adds the skill table, the skills HTTP API, console pages, and executor skills list/pull.

Rhys Sullivan committed 2 months ago

Commit a2bfc4d1 parent f1d95f255 files · +6837 −29Browse files

Unified · Split

55 files
  1. .changeset/agent-skills.md +5 −0
  2. apps/cli/src/main.ts +136 −0
  3. apps/cli/src/skills.test.ts +325 −0
  4. apps/cli/src/skills.ts +354 −0
  5. apps/cloud/drizzle/0018_eager_jack_power.sql +14 −0
  6. apps/cloud/drizzle/meta/0018_snapshot.json +1611 −0
  7. apps/cloud/drizzle/meta/_journal.json +7 −0
  8. apps/cloud/src/db/executor-schema.ts +20 −0
  9. apps/cloud/src/db/org-deletion.test.ts +14 −0
  10. apps/cloud/src/db/org-deletion.ts +2 −0
  11. apps/cloud/src/mcp/session-durable-object.ts +1 −0
  12. apps/cloud/src/mcp/telemetry.ts +6 −0
  13. apps/cloud/src/routeTree.gen.ts +85 −0
  14. apps/host-cloudflare/src/mcp/session-durable-object.ts +1 −0
  15. apps/host-cloudflare/web/routeTree.gen.ts +85 −0
  16. apps/local/drizzle/0007_same_vengeance.sql +14 −0
  17. apps/local/drizzle/meta/0007_snapshot.json +1034 −0
  18. apps/local/drizzle/meta/_journal.json +7 −0
  19. apps/local/src/db/executor-schema.ts +17 −0
  20. apps/local/src/main.ts +3 −0
  21. bun.lock +8 −5
  22. e2e/scenarios/skills.test.ts +142 −0
  23. packages/app/src/routeTree.gen.ts +83 −0
  24. packages/app/src/web/shell.tsx +7 −0
  25. packages/core/api/src/account/org-slug.ts +2 −1
  26. packages/core/api/src/api.ts +2 −0
  27. packages/core/api/src/handlers/index.ts +3 −0
  28. packages/core/api/src/handlers/skills.ts +60 −0
  29. packages/core/api/src/index.ts +1 −0
  30. packages/core/api/src/server/mcp-build.ts +1 −0
  31. packages/core/api/src/skills/api.ts +88 −0
  32. packages/core/sdk/package.json +2 −1
  33. packages/core/sdk/src/core-schema.ts +34 −0
  34. packages/core/sdk/src/errors.ts +40 −1
  35. packages/core/sdk/src/executor.ts +114 −0
  36. packages/core/sdk/src/ids.ts +5 −0
  37. packages/core/sdk/src/index.ts +32 −0
  38. packages/core/sdk/src/shared.ts +29 −0
  39. packages/core/sdk/src/skill.ts +384 −0
  40. packages/core/sdk/src/skills.test.ts +199 −0
  41. packages/hosts/mcp/src/tool-server.ts +169 −21
  42. packages/hosts/mcp/src/workspace-skills.test.ts +296 −0
  43. packages/hosts/mcp/src/workspace-skills.ts +268 −0
  44. packages/react/src/api/atoms.tsx +34 −0
  45. packages/react/src/api/reactivity-keys.tsx +7 −0
  46. packages/react/src/console-routes.ts +11 −0
  47. packages/react/src/multiplayer/shell.tsx +1 −0
  48. packages/react/src/pages/skill-detail.tsx +285 −0
  49. packages/react/src/pages/skill-editor.tsx +458 −0
  50. packages/react/src/pages/skills.tsx +159 −0
  51. packages/react/src/routes/routeTree.gen.ts +75 −0
  52. packages/react/src/routes/skills-route.tsx +36 −0
  53. packages/react/src/routes/skills.$skillOwner.$skillName.tsx +13 −0
  54. packages/react/src/routes/skills.new.tsx +11 −0
  55. packages/react/src/routes/skills.tsx +37 −0
.changeset/agent-skills.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": minor
3+---
4+
5+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.
apps/cli/src/main.ts+136−0
@@ -86,6 +86,7 @@ import { · 85 unmodified lines
8686 normalizeExecutorServerOrigin,
8787 resolveExecutorServerConfiguredHeaders,
8888 resolveExecutorServerRequestHeaders,
89+ SkillName,
8990 type ExecutorLocalServerKind,
9091 type ExecutorLocalServerManifest,
9192 type ExecutorServerConnection,
@@ -184,6 +185,21 @@ import { · 92 unmodified lines
184185 sanitizeCliOutputText,
185186 shellQuoteArg,
186187 } from "./tooling";
188+import {
189+ defaultClaudeSkillsDir,
190+ defaultSkillsDir,
191+ formatSkillPullSummaryLine,
192+ formatSkillsTable,
193+ planSkillsPull,
194+ readExistingSkillEntries,
195+ removeSkillDirectory,
196+ resolveEffectiveSkills,
197+ summarizeSkillPullActions,
198+ writeSkillDirectory,
199+ type SkillDetail,
200+ type SkillPullAction,
201+ type SkillSummary,
202+} from "./skills";
187203
188204 // Embedded web UI — baked into compiled binaries via `with { type: "file" }`
189205 import embeddedWebUI from "./embedded-web-ui.gen";
@@ -2209,6 +2225,125 @@ const toolsCommand = Command.make("tools").pipe( · 2019 unmodified lines
22092225 Command.withDescription("Discover available tools and integrations"),
22102226 );
22112227
2228+// ---------------------------------------------------------------------------
2229+// Skills — `executor skills list` / `executor skills pull`
2230+// ---------------------------------------------------------------------------
2231+
2232+const skillsListCommand = Command.make(
2233+ "list",
2234+ {
2235+ baseUrl: serverBaseUrl,
2236+ server: serverProfile,
2237+ scope,
2238+ },
2239+ ({ baseUrl, server, scope }) =>
2240+ Effect.gen(function* () {
2241+ applyScope(scope);
2242+ const target = serverTargetFromOptions({ baseUrl, server });
2243+ const connection = yield* resolveExecutorServerConnection(target);
2244+ const client = yield* makeApiClient(connection, target);
2245+ const skills = yield* client.skills.list();
2246+ for (const line of formatSkillsTable(skills)) {
2247+ console.log(line);
2248+ }
2249+ }).pipe(Effect.mapError(toError)),
2250+).pipe(Command.withDescription("List Agent Skills visible to this owner scope"));
2251+
2252+const skillsPullDirOption = Options.string("dir").pipe(
2253+ Options.optional,
2254+ Options.withDescription("Directory to write skills into. Defaults to ~/.agents/skills."),
2255+);
2256+
2257+const skillsPullNoClaudeOption = Options.boolean("no-claude").pipe(
2258+ Options.withDefault(false),
2259+ Options.withDescription("Skip mirroring skills into ~/.claude/skills."),
2260+);
2261+
2262+/** Pull every skill this owner scope can see into `root`, applying the
2263+ * marker-file safety rules from `planSkillsPull`. Returns the actions taken
2264+ * so the caller can print one combined summary across every target root. */
2265+const pullSkillsIntoRoot = (input: {
2266+ readonly root: string;
2267+ readonly origin: string;
2268+ readonly effectiveSkills: readonly SkillSummary[];
2269+ readonly fetchSkill: (skill: SkillSummary) => Effect.Effect<SkillDetail, Error>;
2270+}) =>
2271+ Effect.gen(function* () {
2272+ const existing = yield* readExistingSkillEntries(input.root).pipe(Effect.mapError(toError));
2273+ const actions = planSkillsPull({
2274+ origin: input.origin,
2275+ skills: input.effectiveSkills,
2276+ existing,
2277+ });
2278+ const skillsByName = new Map(input.effectiveSkills.map((skill) => [skill.name, skill]));
2279+
2280+ for (const action of actions) {
2281+ if (action.kind === "add" || action.kind === "update") {
2282+ const skill = skillsByName.get(action.name);
2283+ if (!skill) continue;
2284+ const detail = yield* input.fetchSkill(skill);
2285+ yield* writeSkillDirectory({ root: input.root, origin: input.origin, skill: detail }).pipe(
2286+ Effect.mapError(toError),
2287+ );
2288+ } else if (action.kind === "remove") {
2289+ yield* removeSkillDirectory(input.root, action.name).pipe(Effect.mapError(toError));
2290+ }
2291+ }
2292+
2293+ return actions;
2294+ });
2295+
2296+const skillsPullCommand = Command.make(
2297+ "pull",
2298+ {
2299+ dir: skillsPullDirOption,
2300+ noClaude: skillsPullNoClaudeOption,
2301+ baseUrl: serverBaseUrl,
2302+ server: serverProfile,
2303+ scope,
2304+ },
2305+ ({ dir, noClaude, baseUrl, server, scope }) =>
2306+ Effect.gen(function* () {
2307+ applyScope(scope);
2308+ const target = serverTargetFromOptions({ baseUrl, server });
2309+ const connection = yield* resolveExecutorServerConnection(target);
2310+ const client = yield* makeApiClient(connection, target);
2311+
2312+ const skills = yield* client.skills.list();
2313+ const effectiveSkills = resolveEffectiveSkills(skills);
2314+ const fetchSkill = (skill: SkillSummary) =>
2315+ client.skills
2316+ .get({ params: { owner: skill.owner, name: SkillName.make(skill.name) } })
2317+ .pipe(Effect.mapError(toError));
2318+
2319+ const targetRoot = Option.getOrElse(dir, defaultSkillsDir);
2320+ const roots = noClaude ? [targetRoot] : [targetRoot, defaultClaudeSkillsDir()];
2321+
2322+ const allActions: SkillPullAction[] = [];
2323+ for (const root of roots) {
2324+ const actions = yield* pullSkillsIntoRoot({
2325+ root,
2326+ origin: connection.origin,
2327+ effectiveSkills,
2328+ fetchSkill,
2329+ });
2330+ allActions.push(...actions);
2331+ }
2332+
2333+ const summary = summarizeSkillPullActions(allActions);
2334+ console.log(`Pulled ${effectiveSkills.length} skill(s) into ${roots.join(", ")}.`);
2335+ console.log(formatSkillPullSummaryLine(summary));
2336+ for (const action of allActions) {
2337+ if (action.reason) console.log(` ${action.kind}: ${action.name} (${action.reason})`);
2338+ }
2339+ }).pipe(Effect.mapError(toError)),
2340+).pipe(Command.withDescription("Pull Agent Skills from the server into a local directory"));
2341+
2342+const skillsCommand = Command.make("skills").pipe(
2343+ Command.withSubcommands([skillsListCommand, skillsPullCommand] as const),
2344+ Command.withDescription("List and pull Agent Skills from the connected server"),
2345+);
2346+
22122347 const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
22132348 const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
22142349
@@ -3299,6 +3434,7 @@ const root = Command.make("executor").pipe( · 1084 unmodified lines
32993434 callCommand,
33003435 resumeCommand,
33013436 toolsCommand,
3437+ skillsCommand,
33023438 installCommand,
33033439 loginCommand,
33043440 logoutCommand,
apps/cli/src/skills.test.ts+325−0
@@ -0,0 +1,325 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { BunServices } from "@effect/platform-bun";
3+import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import * as Effect from "effect/Effect";
7+
8+import {
9+ formatSkillPullSummaryLine,
10+ formatSkillsTable,
11+ parseSkillMarker,
12+ planSkillsPull,
13+ readExistingSkillEntries,
14+ removeSkillDirectory,
15+ resolveEffectiveSkills,
16+ serializeSkillMarker,
17+ skillMarkerFor,
18+ summarizeSkillPullActions,
19+ writeSkillDirectory,
20+ SKILL_MARKER_FILENAME,
21+ type SkillDetail,
22+ type SkillSummary,
23+} from "./skills";
24+
25+const withTmp = <A, E, R>(body: (dir: string) => Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
26+ Effect.acquireUseRelease(
27+ Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-skills-"))),
28+ body,
29+ (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })),
30+ );
31+
32+const ORIGIN = "https://example.executor.sh";
33+
34+const summary = (
35+ input: Partial<SkillSummary> & { owner: "org" | "user"; name: string },
36+): SkillSummary => ({
37+ description: "A test skill.",
38+ files: [{ path: "SKILL.md", size: 10, digest: "sha256:aaa" }],
39+ updatedAt: Date.parse("2026-01-01T00:00:00.000Z"),
40+ ...input,
41+});
42+
43+describe("resolveEffectiveSkills", () => {
44+ it("keeps a single skill unaffected", () => {
45+ const skills = [summary({ owner: "org", name: "docs" })];
46+ expect(resolveEffectiveSkills(skills)).toEqual(skills);
47+ });
48+
49+ it("prefers the user copy over an org copy of the same name", () => {
50+ const org = summary({ owner: "org", name: "docs", description: "org copy" });
51+ const user = summary({ owner: "user", name: "docs", description: "user copy" });
52+ expect(resolveEffectiveSkills([org, user])).toEqual([user]);
53+ // Order shouldn't matter.
54+ expect(resolveEffectiveSkills([user, org])).toEqual([user]);
55+ });
56+
57+ it("keeps distinct names separate", () => {
58+ const a = summary({ owner: "org", name: "docs" });
59+ const b = summary({ owner: "user", name: "release-notes" });
60+ expect(resolveEffectiveSkills([a, b])).toEqual([a, b]);
61+ });
62+});
63+
64+describe("formatSkillsTable", () => {
65+ it("reports an empty table", () => {
66+ expect(formatSkillsTable([])).toEqual(["No skills found."]);
67+ });
68+
69+ it("includes a header and one row per skill", () => {
70+ const lines = formatSkillsTable([summary({ owner: "org", name: "docs" })]);
71+ expect(lines).toHaveLength(2);
72+ expect(lines[0]).toContain("OWNER");
73+ expect(lines[1]).toContain("org");
74+ expect(lines[1]).toContain("docs");
75+ });
76+});
77+
78+describe("skill markers", () => {
79+ it("round-trips through serialize/parse", () => {
80+ const marker = skillMarkerFor({
81+ origin: ORIGIN,
82+ skill: { owner: "user", name: "docs" },
83+ files: [{ path: "SKILL.md", size: 10, digest: "sha256:aaa" }],
84+ });
85+ const parsed = parseSkillMarker(serializeSkillMarker(marker));
86+ expect(parsed).toEqual(marker);
87+ });
88+
89+ it("rejects malformed JSON", () => {
90+ expect(parseSkillMarker("not json")).toBeUndefined();
91+ });
92+
93+ it("rejects an object missing required fields", () => {
94+ expect(parseSkillMarker(JSON.stringify({ origin: ORIGIN }))).toBeUndefined();
95+ });
96+
97+ it("rejects an invalid owner", () => {
98+ expect(
99+ parseSkillMarker(
100+ JSON.stringify({ origin: ORIGIN, owner: "nobody", name: "docs", digests: {} }),
101+ ),
102+ ).toBeUndefined();
103+ });
104+
105+ it("rejects non-string digest values", () => {
106+ expect(
107+ parseSkillMarker(
108+ JSON.stringify({ origin: ORIGIN, owner: "user", name: "docs", digests: { "SKILL.md": 1 } }),
109+ ),
110+ ).toBeUndefined();
111+ });
112+});
113+
114+describe("planSkillsPull", () => {
115+ it("adds a skill with no existing directory", () => {
116+ const actions = planSkillsPull({
117+ origin: ORIGIN,
118+ skills: [summary({ owner: "org", name: "docs" })],
119+ existing: [],
120+ });
121+ expect(actions).toEqual([{ name: "docs", kind: "add" }]);
122+ });
123+
124+ it("marks a matching-digest skill unchanged", () => {
125+ const skill = summary({ owner: "org", name: "docs" });
126+ const actions = planSkillsPull({
127+ origin: ORIGIN,
128+ skills: [skill],
129+ existing: [
130+ {
131+ name: "docs",
132+ marker: skillMarkerFor({ origin: ORIGIN, skill, files: skill.files }),
133+ },
134+ ],
135+ });
136+ expect(actions).toEqual([{ name: "docs", kind: "unchanged" }]);
137+ });
138+
139+ it("updates a skill whose digests changed", () => {
140+ const skill = summary({ owner: "org", name: "docs" });
141+ const staleMarker = skillMarkerFor({
142+ origin: ORIGIN,
143+ skill,
144+ files: [{ path: "SKILL.md", size: 5, digest: "sha256:old" }],
145+ });
146+ const actions = planSkillsPull({
147+ origin: ORIGIN,
148+ skills: [skill],
149+ existing: [{ name: "docs", marker: staleMarker }],
150+ });
151+ expect(actions).toEqual([{ name: "docs", kind: "update" }]);
152+ });
153+
154+ it("never overwrites a directory with no marker", () => {
155+ const skill = summary({ owner: "org", name: "docs" });
156+ const actions = planSkillsPull({
157+ origin: ORIGIN,
158+ skills: [skill],
159+ existing: [{ name: "docs", marker: undefined }],
160+ });
161+ expect(actions).toEqual([
162+ { name: "docs", kind: "skip", reason: expect.stringContaining(SKILL_MARKER_FILENAME) },
163+ ]);
164+ });
165+
166+ it("skips a directory owned by a different server", () => {
167+ const skill = summary({ owner: "org", name: "docs" });
168+ const otherOriginMarker = skillMarkerFor({
169+ origin: "https://other.example",
170+ skill,
171+ files: skill.files,
172+ });
173+ const actions = planSkillsPull({
174+ origin: ORIGIN,
175+ skills: [skill],
176+ existing: [{ name: "docs", marker: otherOriginMarker }],
177+ });
178+ expect(actions).toEqual([
179+ { name: "docs", kind: "skip", reason: expect.stringContaining("different server") },
180+ ]);
181+ });
182+
183+ it("removes a marker-bearing directory whose skill no longer exists upstream", () => {
184+ const marker = skillMarkerFor({
185+ origin: ORIGIN,
186+ skill: { owner: "org", name: "gone" },
187+ files: [{ path: "SKILL.md", size: 1, digest: "sha256:x" }],
188+ });
189+ const actions = planSkillsPull({
190+ origin: ORIGIN,
191+ skills: [],
192+ existing: [{ name: "gone", marker }],
193+ });
194+ expect(actions).toEqual([{ name: "gone", kind: "remove", reason: expect.any(String) }]);
195+ });
196+
197+ it("never removes an unmanaged directory that has no matching upstream skill", () => {
198+ const actions = planSkillsPull({
199+ origin: ORIGIN,
200+ skills: [],
201+ existing: [{ name: "my-notes", marker: undefined }],
202+ });
203+ expect(actions).toEqual([]);
204+ });
205+
206+ it("never removes a directory owned by a different server", () => {
207+ const marker = skillMarkerFor({
208+ origin: "https://other.example",
209+ skill: { owner: "org", name: "gone" },
210+ files: [],
211+ });
212+ const actions = planSkillsPull({
213+ origin: ORIGIN,
214+ skills: [],
215+ existing: [{ name: "gone", marker }],
216+ });
217+ expect(actions).toEqual([]);
218+ });
219+});
220+
221+describe("summarizeSkillPullActions / formatSkillPullSummaryLine", () => {
222+ it("counts each action kind", () => {
223+ const totals = summarizeSkillPullActions([
224+ { name: "a", kind: "add" },
225+ { name: "b", kind: "update" },
226+ { name: "c", kind: "unchanged" },
227+ { name: "d", kind: "remove" },
228+ { name: "e", kind: "skip" },
229+ { name: "f", kind: "add" },
230+ ]);
231+ expect(totals).toEqual({ added: 2, updated: 1, unchanged: 1, removed: 1, skipped: 1 });
232+ expect(formatSkillPullSummaryLine(totals)).toBe(
233+ "2 added, 1 updated, 1 removed, 1 skipped (1 unchanged)",
234+ );
235+ });
236+
237+ it("omits the unchanged suffix when there are none", () => {
238+ const totals = summarizeSkillPullActions([{ name: "a", kind: "add" }]);
239+ expect(formatSkillPullSummaryLine(totals)).toBe("1 added, 0 updated, 0 removed, 0 skipped");
240+ });
241+});
242+
243+describe("filesystem I/O", () => {
244+ it.effect("readExistingSkillEntries returns [] for a missing root", () =>
245+ withTmp((dir) =>
246+ Effect.gen(function* () {
247+ const entries = yield* readExistingSkillEntries(join(dir, "does-not-exist"));
248+ expect(entries).toEqual([]);
249+ }),
250+ ).pipe(Effect.provide(BunServices.layer)),
251+ );
252+
253+ it.effect("readExistingSkillEntries reports marker and unmarked directories", () =>
254+ withTmp((dir) =>
255+ Effect.gen(function* () {
256+ const managed = join(dir, "docs");
257+ mkdirSync(managed, { recursive: true });
258+ const marker = skillMarkerFor({
259+ origin: ORIGIN,
260+ skill: { owner: "org", name: "docs" },
261+ files: [{ path: "SKILL.md", size: 3, digest: "sha256:aaa" }],
262+ });
263+ writeFileSync(join(managed, SKILL_MARKER_FILENAME), serializeSkillMarker(marker));
264+
265+ const unmanaged = join(dir, "my-notes");
266+ mkdirSync(unmanaged, { recursive: true });
267+
268+ // A plain file at the root must not be mistaken for a skill directory.
269+ writeFileSync(join(dir, "readme.txt"), "hi");
270+
271+ const entries = yield* readExistingSkillEntries(dir);
272+ const byName = new Map(entries.map((entry) => [entry.name, entry]));
273+ expect(byName.size).toBe(2);
274+ expect(byName.get("docs")?.marker).toEqual(marker);
275+ expect(byName.get("my-notes")?.marker).toBeUndefined();
276+ }),
277+ ).pipe(Effect.provide(BunServices.layer)),
278+ );
279+
280+ it.effect("writeSkillDirectory writes nested files and a marker", () =>
281+ withTmp((dir) =>
282+ Effect.gen(function* () {
283+ const detail: SkillDetail = {
284+ owner: "user",
285+ name: "docs",
286+ description: "A test skill.",
287+ updatedAt: Date.now(),
288+ files: [
289+ { path: "SKILL.md", size: 5, digest: "sha256:aaa", content: "# Docs" },
290+ {
291+ path: "references/guide.md",
292+ size: 6,
293+ digest: "sha256:bbb",
294+ content: "Guide.",
295+ },
296+ ],
297+ };
298+ yield* writeSkillDirectory({ root: dir, origin: ORIGIN, skill: detail });
299+
300+ const entries = yield* readExistingSkillEntries(dir);
301+ expect(entries).toHaveLength(1);
302+ expect(entries[0]?.marker?.origin).toBe(ORIGIN);
303+ expect(entries[0]?.marker?.digests).toEqual({
304+ "SKILL.md": "sha256:aaa",
305+ "references/guide.md": "sha256:bbb",
306+ });
307+ }),
308+ ).pipe(Effect.provide(BunServices.layer)),
309+ );
310+
311+ it.effect("removeSkillDirectory deletes the directory recursively", () =>
312+ withTmp((dir) =>
313+ Effect.gen(function* () {
314+ const target = join(dir, "docs", "references");
315+ mkdirSync(target, { recursive: true });
316+ writeFileSync(join(target, "guide.md"), "Guide.");
317+
318+ yield* removeSkillDirectory(dir, "docs");
319+
320+ const entries = yield* readExistingSkillEntries(dir);
321+ expect(entries).toEqual([]);
322+ }),
323+ ).pipe(Effect.provide(BunServices.layer)),
324+ );
325+});
apps/cli/src/skills.ts+354−0
@@ -0,0 +1,354 @@
1+import { homedir } from "node:os";
2+import { FileSystem, Path } from "effect";
3+import type { PlatformError } from "effect/PlatformError";
4+import * as Effect from "effect/Effect";
5+
6+import type { Owner } from "@executor-js/sdk/shared";
7+
8+// ---------------------------------------------------------------------------
9+// `executor skills` — list and pull Agent Skills from the connected server.
10+//
11+// Pure planning lives here, separated from the filesystem/network I/O that
12+// drives it, so the marker-file safety rules (never touch a directory that
13+// isn't ours) are unit-testable without a server or real disk writes. See
14+// plans/agent-skills.md, "CLI (apps/cli)" for the contract, and
15+// packages/core/api/src/skills/api.ts for the response shapes this mirrors.
16+// ---------------------------------------------------------------------------
17+
18+// ---------------------------------------------------------------------------
19+// Types mirroring the skills HTTP API responses.
20+// ---------------------------------------------------------------------------
21+
22+export interface SkillFileEntry {
23+ readonly path: string;
24+ readonly size: number;
25+ readonly digest: string;
26+}
27+
28+export interface SkillFile extends SkillFileEntry {
29+ readonly content: string;
30+}
31+
32+/** What `GET /skills` returns per skill: the manifest, no file contents. */
33+export interface SkillSummary {
34+ readonly owner: Owner;
35+ readonly name: string;
36+ readonly description: string;
37+ readonly files: readonly SkillFileEntry[];
38+ readonly updatedAt: number;
39+}
40+
41+/** What `GET /skills/:owner/:name` returns: the summary plus file contents. */
42+export interface SkillDetail extends Omit<SkillSummary, "files"> {
43+ readonly files: readonly SkillFile[];
44+}
45+
46+// ---------------------------------------------------------------------------
47+// Default install locations
48+// ---------------------------------------------------------------------------
49+
50+export const defaultSkillsDir = (): string => `${homedir()}/.agents/skills`;
51+export const defaultClaudeSkillsDir = (): string => `${homedir()}/.claude/skills`;
52+
53+// ---------------------------------------------------------------------------
54+// Owner shadowing: a `user` skill hides an `org` skill of the same name.
55+// ---------------------------------------------------------------------------
56+
57+/** Resolve the set of skills every agent actually sees: at most one entry per
58+ * name, preferring the `user` (personal) copy over an `org` (workspace) one. */
59+export const resolveEffectiveSkills = <S extends { readonly owner: Owner; readonly name: string }>(
60+ skills: readonly S[],
61+): readonly S[] => {
62+ const byName = new Map<string, S>();
63+ for (const skill of skills) {
64+ const existing = byName.get(skill.name);
65+ if (!existing || (existing.owner === "org" && skill.owner === "user")) {
66+ byName.set(skill.name, skill);
67+ }
68+ }
69+ return Array.from(byName.values());
70+};
71+
72+// ---------------------------------------------------------------------------
73+// `list` — table formatting (pure).
74+// ---------------------------------------------------------------------------
75+
76+export const formatSkillsTable = (skills: readonly SkillSummary[]): readonly string[] => {
77+ if (skills.length === 0) {
78+ return ["No skills found."];
79+ }
80+
81+ const header = {
82+ owner: "OWNER",
83+ name: "NAME",
84+ description: "DESCRIPTION",
85+ files: "FILES",
86+ updated: "UPDATED",
87+ };
88+ const rows = skills.map((skill) => ({
89+ owner: skill.owner,
90+ name: skill.name,
91+ description: skill.description,
92+ files: String(skill.files.length),
93+ updated: new Date(skill.updatedAt).toISOString(),
94+ }));
95+
96+ const widthOf = (key: "owner" | "name" | "files"): number =>
97+ Math.max(header[key].length, ...rows.map((row) => row[key].length));
98+ const ownerWidth = widthOf("owner");
99+ const nameWidth = widthOf("name");
100+ const filesWidth = widthOf("files");
101+
102+ const line = (row: (typeof rows)[number] | typeof header): string =>
103+ `${row.owner.padEnd(ownerWidth)} ${row.name.padEnd(nameWidth)} ${row.description} ${row.files.padEnd(filesWidth)} ${row.updated}`;
104+
105+ return [line(header), ...rows.map(line)];
106+};
107+
108+// ---------------------------------------------------------------------------
109+// Marker file — `.executor-skill.json`, written into every skill directory
110+// this CLI manages. Its presence (and matching `origin`) is the only thing
111+// that authorizes `pull` to overwrite or delete a directory.
112+// ---------------------------------------------------------------------------
113+
114+export const SKILL_MARKER_FILENAME = ".executor-skill.json";
115+
116+export interface SkillMarker {
117+ readonly origin: string;
118+ readonly owner: Owner;
119+ readonly name: string;
120+ /** File path (relative to the skill root) -> digest, as last written. */
121+ readonly digests: Readonly<Record<string, string>>;
122+}
123+
124+const isOwner = (value: unknown): value is Owner => value === "org" || value === "user";
125+
126+/** Parse a marker file's contents. Returns `undefined` for anything that
127+ * isn't a well-formed marker — including a directory some other tool made,
128+ * which must never be treated as ours. */
129+export const parseSkillMarker = (raw: string): SkillMarker | undefined => {
130+ let parsed: unknown;
131+ try {
132+ parsed = JSON.parse(raw);
133+ } catch {
134+ return undefined;
135+ }
136+ if (typeof parsed !== "object" || parsed === null) return undefined;
137+ const candidate = parsed as Record<string, unknown>;
138+ if (typeof candidate.origin !== "string") return undefined;
139+ if (!isOwner(candidate.owner)) return undefined;
140+ if (typeof candidate.name !== "string") return undefined;
141+ if (typeof candidate.digests !== "object" || candidate.digests === null) return undefined;
142+
143+ const digests: Record<string, string> = {};
144+ for (const [path, digest] of Object.entries(candidate.digests as Record<string, unknown>)) {
145+ if (typeof digest !== "string") return undefined;
146+ digests[path] = digest;
147+ }
148+
149+ return { origin: candidate.origin, owner: candidate.owner, name: candidate.name, digests };
150+};
151+
152+export const serializeSkillMarker = (marker: SkillMarker): string =>
153+ `${JSON.stringify(marker, null, 2)}\n`;
154+
155+export const skillMarkerFor = (input: {
156+ readonly origin: string;
157+ readonly skill: Pick<SkillSummary, "owner" | "name">;
158+ readonly files: readonly SkillFileEntry[];
159+}): SkillMarker => ({
160+ origin: input.origin,
161+ owner: input.skill.owner,
162+ name: input.skill.name,
163+ digests: Object.fromEntries(input.files.map((file) => [file.path, file.digest])),
164+});
165+
166+// ---------------------------------------------------------------------------
167+// `pull` — planning (pure). Decides add/update/unchanged/remove/skip per
168+// skill directory without touching the network or filesystem.
169+// ---------------------------------------------------------------------------
170+
171+export type SkillPullActionKind = "add" | "update" | "unchanged" | "remove" | "skip";
172+
173+export interface SkillPullAction {
174+ readonly name: string;
175+ readonly kind: SkillPullActionKind;
176+ readonly reason?: string;
177+}
178+
179+/** What `pull` found on disk for one directory entry under the target root. */
180+export interface ExistingSkillEntry {
181+ readonly name: string;
182+ /** `undefined` when there's no marker file, or it doesn't parse: an
183+ * unmanaged directory that `pull` must never overwrite or delete. */
184+ readonly marker: SkillMarker | undefined;
185+}
186+
187+const digestsEqual = (
188+ a: Readonly<Record<string, string>>,
189+ b: Readonly<Record<string, string>>,
190+): boolean => {
191+ const aKeys = Object.keys(a);
192+ const bKeys = Object.keys(b);
193+ if (aKeys.length !== bKeys.length) return false;
194+ return aKeys.every((key) => a[key] === b[key]);
195+};
196+
197+/** Plan one target root's worth of `pull` actions. Call once per root (the
198+ * main `--dir` and, unless `--no-claude`, the `~/.claude/skills` mirror). */
199+export const planSkillsPull = (input: {
200+ readonly origin: string;
201+ /** Effective (already-shadowed) skills this owner scope can see. */
202+ readonly skills: readonly SkillSummary[];
203+ /** What's already on disk under the target root. */
204+ readonly existing: readonly ExistingSkillEntry[];
205+}): readonly SkillPullAction[] => {
206+ const actions: SkillPullAction[] = [];
207+ const skillNames = new Set(input.skills.map((skill) => skill.name));
208+ const existingByName = new Map(input.existing.map((entry) => [entry.name, entry]));
209+
210+ for (const skill of input.skills) {
211+ const existing = existingByName.get(skill.name);
212+ const desiredDigests = Object.fromEntries(skill.files.map((file) => [file.path, file.digest]));
213+
214+ if (!existing) {
215+ actions.push({ name: skill.name, kind: "add" });
216+ continue;
217+ }
218+ if (!existing.marker) {
219+ actions.push({
220+ name: skill.name,
221+ kind: "skip",
222+ reason: `${skill.name}/ exists without a ${SKILL_MARKER_FILENAME} marker`,
223+ });
224+ continue;
225+ }
226+ if (existing.marker.origin !== input.origin) {
227+ actions.push({
228+ name: skill.name,
229+ kind: "skip",
230+ reason: `${skill.name}/ is managed by a different server (${existing.marker.origin})`,
231+ });
232+ continue;
233+ }
234+ actions.push({
235+ name: skill.name,
236+ kind: digestsEqual(existing.marker.digests, desiredDigests) ? "unchanged" : "update",
237+ });
238+ }
239+
240+ for (const existing of input.existing) {
241+ if (skillNames.has(existing.name)) continue;
242+ if (!existing.marker) continue; // never touch a directory we don't own
243+ if (existing.marker.origin !== input.origin) continue; // owned by another server
244+ actions.push({ name: existing.name, kind: "remove", reason: "no longer exists upstream" });
245+ }
246+
247+ return actions;
248+};
249+
250+export interface SkillPullSummary {
251+ readonly added: number;
252+ readonly updated: number;
253+ readonly unchanged: number;
254+ readonly removed: number;
255+ readonly skipped: number;
256+}
257+
258+export const summarizeSkillPullActions = (
259+ actions: readonly SkillPullAction[],
260+): SkillPullSummary => ({
261+ added: actions.filter((action) => action.kind === "add").length,
262+ updated: actions.filter((action) => action.kind === "update").length,
263+ unchanged: actions.filter((action) => action.kind === "unchanged").length,
264+ removed: actions.filter((action) => action.kind === "remove").length,
265+ skipped: actions.filter((action) => action.kind === "skip").length,
266+});
267+
268+export const formatSkillPullSummaryLine = (summary: SkillPullSummary): string =>
269+ `${summary.added} added, ${summary.updated} updated, ${summary.removed} removed, ${summary.skipped} skipped` +
270+ (summary.unchanged > 0 ? ` (${summary.unchanged} unchanged)` : "");
271+
272+// ---------------------------------------------------------------------------
273+// Filesystem I/O — reading what's on disk and applying a plan. Kept small and
274+// separate from the planning above so tests can exercise the pure logic
275+// without a real (or even temp) filesystem; only the handful of tests that
276+// need real directory semantics use a temp dir.
277+// ---------------------------------------------------------------------------
278+
279+/** List the immediate subdirectories of `root` and read each one's marker,
280+ * if any. Returns `[]` when `root` doesn't exist yet. */
281+export const readExistingSkillEntries = (
282+ root: string,
283+): Effect.Effect<readonly ExistingSkillEntry[], PlatformError, FileSystem.FileSystem | Path.Path> =>
284+ Effect.gen(function* () {
285+ const fs = yield* FileSystem.FileSystem;
286+ const path = yield* Path.Path;
287+
288+ const rootExists = yield* fs.exists(root);
289+ if (!rootExists) return [];
290+
291+ const entries = yield* fs.readDirectory(root);
292+ const results: ExistingSkillEntry[] = [];
293+ for (const name of entries) {
294+ const entryPath = path.join(root, name);
295+ const info = yield* fs.stat(entryPath);
296+ if (info.type !== "Directory") continue;
297+
298+ const markerPath = path.join(entryPath, SKILL_MARKER_FILENAME);
299+ const hasMarker = yield* fs.exists(markerPath);
300+ if (!hasMarker) {
301+ results.push({ name, marker: undefined });
302+ continue;
303+ }
304+ const raw = yield* fs.readFileString(markerPath).pipe(Effect.orElseSucceed(() => ""));
305+ results.push({ name, marker: parseSkillMarker(raw) });
306+ }
307+ return results;
308+ });
309+
310+/** Write one skill's files (and its marker) into `<root>/<name>`, creating
311+ * parent directories for nested file paths as needed. */
312+export const writeSkillDirectory = (input: {
313+ readonly root: string;
314+ readonly origin: string;
315+ readonly skill: SkillDetail;
316+}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
317+ Effect.gen(function* () {
318+ const fs = yield* FileSystem.FileSystem;
319+ const path = yield* Path.Path;
320+ const skillDir = path.join(input.root, input.skill.name);
321+
322+ yield* fs.makeDirectory(skillDir, { recursive: true });
323+ for (const file of input.skill.files) {
324+ const filePath = path.join(skillDir, file.path);
325+ const parent = path.dirname(filePath);
326+ if (parent !== skillDir) {
327+ yield* fs.makeDirectory(parent, { recursive: true });
328+ }
329+ yield* fs.writeFileString(filePath, file.content);
330+ }
331+
332+ const marker = skillMarkerFor({
333+ origin: input.origin,
334+ skill: input.skill,
335+ files: input.skill.files,
336+ });
337+ yield* fs.writeFileString(
338+ path.join(skillDir, SKILL_MARKER_FILENAME),
339+ serializeSkillMarker(marker),
340+ );
341+ });
342+
343+/** Remove `<root>/<name>` entirely. Only called for `"remove"` actions, which
344+ * `planSkillsPull` only ever produces for marker-bearing directories whose
345+ * marker origin matches the current server. */
346+export const removeSkillDirectory = (
347+ root: string,
348+ name: string,
349+): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
350+ Effect.gen(function* () {
351+ const fs = yield* FileSystem.FileSystem;
352+ const path = yield* Path.Path;
353+ yield* fs.remove(path.join(root, name), { recursive: true, force: true });
354+ });
apps/cloud/drizzle/0018_eager_jack_power.sql+14−0
@@ -0,0 +1,14 @@
1+CREATE TABLE "skill" (
2+ "name" varchar(255) NOT NULL,
3+ "description" text NOT NULL,
4+ "frontmatter" json NOT NULL,
5+ "files" json NOT NULL,
6+ "created_at" timestamp NOT NULL,
7+ "updated_at" timestamp NOT NULL,
8+ "row_id" varchar(255) PRIMARY KEY NOT NULL,
9+ "tenant" varchar(255) NOT NULL,
10+ "owner" varchar(255) NOT NULL,
11+ "subject" varchar(255) NOT NULL
12+);
13+--> statement-breakpoint
14+CREATE UNIQUE INDEX "skill_uidx" ON "skill" USING btree ("tenant","owner","subject","name");
apps/cloud/drizzle/meta/0018_snapshot.json+1611−0
@@ -0,0 +1,1611 @@
1+{
2+ "id": "6782ceae-ca96-4a99-b64e-1841949f2366",
3+ "prevId": "42251aa3-ae24-4010-ac65-9f41e26cdc20",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": ["account_id"],
64+ "columnsTo": ["id"],
65+ "onDelete": "cascade",
66+ "onUpdate": "no action"
67+ },
68+ "memberships_organization_id_organizations_id_fk": {
69+ "name": "memberships_organization_id_organizations_id_fk",
70+ "tableFrom": "memberships",
71+ "tableTo": "organizations",
72+ "columnsFrom": ["organization_id"],
73+ "columnsTo": ["id"],
74+ "onDelete": "cascade",
75+ "onUpdate": "no action"
76+ }
77+ },
78+ "compositePrimaryKeys": {
79+ "memberships_account_id_organization_id_pk": {
80+ "name": "memberships_account_id_organization_id_pk",
81+ "columns": ["account_id", "organization_id"]
82+ }
83+ },
84+ "uniqueConstraints": {},
85+ "policies": {},
86+ "checkConstraints": {},
87+ "isRLSEnabled": false
88+ },
89+ "public.organizations": {
90+ "name": "organizations",
91+ "schema": "",
92+ "columns": {
93+ "id": {
94+ "name": "id",
95+ "type": "text",
96+ "primaryKey": true,
97+ "notNull": true
98+ },
99+ "name": {
100+ "name": "name",
101+ "type": "text",
102+ "primaryKey": false,
103+ "notNull": true
104+ },
105+ "slug": {
106+ "name": "slug",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": true
110+ },
111+ "created_at": {
112+ "name": "created_at",
113+ "type": "timestamp with time zone",
114+ "primaryKey": false,
115+ "notNull": true,
116+ "default": "now()"
117+ }
118+ },
119+ "indexes": {
120+ "organizations_slug_unique": {
121+ "name": "organizations_slug_unique",
122+ "columns": [
123+ {
124+ "expression": "slug",
125+ "isExpression": false,
126+ "asc": true,
127+ "nulls": "last"
128+ }
129+ ],
130+ "isUnique": true,
131+ "concurrently": false,
132+ "method": "btree",
133+ "with": {}
134+ }
135+ },
136+ "foreignKeys": {},
137+ "compositePrimaryKeys": {},
138+ "uniqueConstraints": {},
139+ "policies": {},
140+ "checkConstraints": {},
141+ "isRLSEnabled": false
142+ },
143+ "public.artifact": {
144+ "name": "artifact",
145+ "schema": "",
146+ "columns": {
147+ "id": {
148+ "name": "id",
149+ "type": "varchar(255)",
150+ "primaryKey": false,
151+ "notNull": true
152+ },
153+ "title": {
154+ "name": "title",
155+ "type": "text",
156+ "primaryKey": false,
157+ "notNull": true
158+ },
159+ "description": {
160+ "name": "description",
161+ "type": "text",
162+ "primaryKey": false,
163+ "notNull": false
164+ },
165+ "code": {
166+ "name": "code",
167+ "type": "text",
168+ "primaryKey": false,
169+ "notNull": true
170+ },
171+ "bindings": {
172+ "name": "bindings",
173+ "type": "json",
174+ "primaryKey": false,
175+ "notNull": false
176+ },
177+ "preview": {
178+ "name": "preview",
179+ "type": "text",
180+ "primaryKey": false,
181+ "notNull": false
182+ },
183+ "created_at": {
184+ "name": "created_at",
185+ "type": "timestamp",
186+ "primaryKey": false,
187+ "notNull": true
188+ },
189+ "updated_at": {
190+ "name": "updated_at",
191+ "type": "timestamp",
192+ "primaryKey": false,
193+ "notNull": true
194+ },
195+ "row_id": {
196+ "name": "row_id",
197+ "type": "varchar(255)",
198+ "primaryKey": true,
199+ "notNull": true
200+ },
201+ "tenant": {
202+ "name": "tenant",
203+ "type": "varchar(255)",
204+ "primaryKey": false,
205+ "notNull": true
206+ },
207+ "owner": {
208+ "name": "owner",
209+ "type": "varchar(255)",
210+ "primaryKey": false,
211+ "notNull": true
212+ },
213+ "subject": {
214+ "name": "subject",
215+ "type": "varchar(255)",
216+ "primaryKey": false,
217+ "notNull": true
218+ }
219+ },
220+ "indexes": {
221+ "artifact_uidx": {
222+ "name": "artifact_uidx",
223+ "columns": [
224+ {
225+ "expression": "tenant",
226+ "isExpression": false,
227+ "asc": true,
228+ "nulls": "last"
229+ },
230+ {
231+ "expression": "owner",
232+ "isExpression": false,
233+ "asc": true,
234+ "nulls": "last"
235+ },
236+ {
237+ "expression": "subject",
238+ "isExpression": false,
239+ "asc": true,
240+ "nulls": "last"
241+ },
242+ {
243+ "expression": "id",
244+ "isExpression": false,
245+ "asc": true,
246+ "nulls": "last"
247+ }
248+ ],
249+ "isUnique": true,
250+ "concurrently": false,
251+ "method": "btree",
252+ "with": {}
253+ }
254+ },
255+ "foreignKeys": {},
256+ "compositePrimaryKeys": {},
257+ "uniqueConstraints": {},
258+ "policies": {},
259+ "checkConstraints": {},
260+ "isRLSEnabled": false
261+ },
262+ "public.blob": {
263+ "name": "blob",
264+ "schema": "",
265+ "columns": {
266+ "namespace": {
267+ "name": "namespace",
268+ "type": "varchar(255)",
269+ "primaryKey": false,
270+ "notNull": true
271+ },
272+ "key": {
273+ "name": "key",
274+ "type": "varchar(255)",
275+ "primaryKey": false,
276+ "notNull": true
277+ },
278+ "value": {
279+ "name": "value",
280+ "type": "text",
281+ "primaryKey": false,
282+ "notNull": true
283+ },
284+ "row_id": {
285+ "name": "row_id",
286+ "type": "varchar(255)",
287+ "primaryKey": true,
288+ "notNull": true
289+ },
290+ "id": {
291+ "name": "id",
292+ "type": "varchar(255)",
293+ "primaryKey": false,
294+ "notNull": true
295+ }
296+ },
297+ "indexes": {
298+ "blob_id_uidx": {
299+ "name": "blob_id_uidx",
300+ "columns": [
301+ {
302+ "expression": "id",
303+ "isExpression": false,
304+ "asc": true,
305+ "nulls": "last"
306+ }
307+ ],
308+ "isUnique": true,
309+ "concurrently": false,
310+ "method": "btree",
311+ "with": {}
312+ }
313+ },
314+ "foreignKeys": {},
315+ "compositePrimaryKeys": {},
316+ "uniqueConstraints": {},
317+ "policies": {},
318+ "checkConstraints": {},
319+ "isRLSEnabled": false
320+ },
321+ "public.connection": {
322+ "name": "connection",
323+ "schema": "",
324+ "columns": {
325+ "integration": {
326+ "name": "integration",
327+ "type": "varchar(255)",
328+ "primaryKey": false,
329+ "notNull": true
330+ },
331+ "name": {
332+ "name": "name",
333+ "type": "varchar(255)",
334+ "primaryKey": false,
335+ "notNull": true
336+ },
337+ "template": {
338+ "name": "template",
339+ "type": "text",
340+ "primaryKey": false,
341+ "notNull": true
342+ },
343+ "provider": {
344+ "name": "provider",
345+ "type": "text",
346+ "primaryKey": false,
347+ "notNull": true
348+ },
349+ "item_ids": {
350+ "name": "item_ids",
351+ "type": "json",
352+ "primaryKey": false,
353+ "notNull": true
354+ },
355+ "credential_write": {
356+ "name": "credential_write",
357+ "type": "json",
358+ "primaryKey": false,
359+ "notNull": false
360+ },
361+ "identity_label": {
362+ "name": "identity_label",
363+ "type": "text",
364+ "primaryKey": false,
365+ "notNull": false
366+ },
367+ "description": {
368+ "name": "description",
369+ "type": "text",
370+ "primaryKey": false,
371+ "notNull": false
372+ },
373+ "last_health": {
374+ "name": "last_health",
375+ "type": "json",
376+ "primaryKey": false,
377+ "notNull": false
378+ },
379+ "tools_synced_at": {
380+ "name": "tools_synced_at",
381+ "type": "bigint",
382+ "primaryKey": false,
383+ "notNull": false
384+ },
385+ "oauth_client": {
386+ "name": "oauth_client",
387+ "type": "text",
388+ "primaryKey": false,
389+ "notNull": false
390+ },
391+ "oauth_client_owner": {
392+ "name": "oauth_client_owner",
393+ "type": "text",
394+ "primaryKey": false,
395+ "notNull": false
396+ },
397+ "refresh_item_id": {
398+ "name": "refresh_item_id",
399+ "type": "text",
400+ "primaryKey": false,
401+ "notNull": false
402+ },
403+ "expires_at": {
404+ "name": "expires_at",
405+ "type": "bigint",
406+ "primaryKey": false,
407+ "notNull": false
408+ },
409+ "oauth_scope": {
410+ "name": "oauth_scope",
411+ "type": "text",
412+ "primaryKey": false,
413+ "notNull": false
414+ },
415+ "oauth_token_url": {
416+ "name": "oauth_token_url",
417+ "type": "text",
418+ "primaryKey": false,
419+ "notNull": false
420+ },
421+ "provider_state": {
422+ "name": "provider_state",
423+ "type": "json",
424+ "primaryKey": false,
425+ "notNull": false
426+ },
427+ "created_at": {
428+ "name": "created_at",
429+ "type": "timestamp",
430+ "primaryKey": false,
431+ "notNull": true
432+ },
433+ "updated_at": {
434+ "name": "updated_at",
435+ "type": "timestamp",
436+ "primaryKey": false,
437+ "notNull": true
438+ },
439+ "row_id": {
440+ "name": "row_id",
441+ "type": "varchar(255)",
442+ "primaryKey": true,
443+ "notNull": true
444+ },
445+ "tenant": {
446+ "name": "tenant",
447+ "type": "varchar(255)",
448+ "primaryKey": false,
449+ "notNull": true
450+ },
451+ "owner": {
452+ "name": "owner",
453+ "type": "varchar(255)",
454+ "primaryKey": false,
455+ "notNull": true
456+ },
457+ "subject": {
458+ "name": "subject",
459+ "type": "varchar(255)",
460+ "primaryKey": false,
461+ "notNull": true
462+ }
463+ },
464+ "indexes": {
465+ "connection_uidx": {
466+ "name": "connection_uidx",
467+ "columns": [
468+ {
469+ "expression": "tenant",
470+ "isExpression": false,
471+ "asc": true,
472+ "nulls": "last"
473+ },
474+ {
475+ "expression": "owner",
476+ "isExpression": false,
477+ "asc": true,
478+ "nulls": "last"
479+ },
480+ {
481+ "expression": "subject",
482+ "isExpression": false,
483+ "asc": true,
484+ "nulls": "last"
485+ },
486+ {
487+ "expression": "integration",
488+ "isExpression": false,
489+ "asc": true,
490+ "nulls": "last"
491+ },
492+ {
493+ "expression": "name",
494+ "isExpression": false,
495+ "asc": true,
496+ "nulls": "last"
497+ }
498+ ],
499+ "isUnique": true,
500+ "concurrently": false,
501+ "method": "btree",
502+ "with": {}
503+ }
504+ },
505+ "foreignKeys": {},
506+ "compositePrimaryKeys": {},
507+ "uniqueConstraints": {},
508+ "policies": {},
509+ "checkConstraints": {},
510+ "isRLSEnabled": false
511+ },
512+ "public.definition": {
513+ "name": "definition",
514+ "schema": "",
515+ "columns": {
516+ "integration": {
517+ "name": "integration",
518+ "type": "varchar(255)",
519+ "primaryKey": false,
520+ "notNull": true
521+ },
522+ "connection": {
523+ "name": "connection",
524+ "type": "varchar(255)",
525+ "primaryKey": false,
526+ "notNull": true
527+ },
528+ "plugin_id": {
529+ "name": "plugin_id",
530+ "type": "text",
531+ "primaryKey": false,
532+ "notNull": true
533+ },
534+ "name": {
535+ "name": "name",
536+ "type": "text",
537+ "primaryKey": false,
538+ "notNull": true
539+ },
540+ "schema": {
541+ "name": "schema",
542+ "type": "json",
543+ "primaryKey": false,
544+ "notNull": true
545+ },
546+ "created_at": {
547+ "name": "created_at",
548+ "type": "timestamp",
549+ "primaryKey": false,
550+ "notNull": true
551+ },
552+ "row_id": {
553+ "name": "row_id",
554+ "type": "varchar(255)",
555+ "primaryKey": true,
556+ "notNull": true
557+ },
558+ "tenant": {
559+ "name": "tenant",
560+ "type": "varchar(255)",
561+ "primaryKey": false,
562+ "notNull": true
563+ },
564+ "owner": {
565+ "name": "owner",
566+ "type": "varchar(255)",
567+ "primaryKey": false,
568+ "notNull": true
569+ },
570+ "subject": {
571+ "name": "subject",
572+ "type": "varchar(255)",
573+ "primaryKey": false,
574+ "notNull": true
575+ }
576+ },
577+ "indexes": {
578+ "definition_uidx": {
579+ "name": "definition_uidx",
580+ "columns": [
581+ {
582+ "expression": "tenant",
583+ "isExpression": false,
584+ "asc": true,
585+ "nulls": "last"
586+ },
587+ {
588+ "expression": "owner",
589+ "isExpression": false,
590+ "asc": true,
591+ "nulls": "last"
592+ },
593+ {
594+ "expression": "subject",
595+ "isExpression": false,
596+ "asc": true,
597+ "nulls": "last"
598+ },
599+ {
600+ "expression": "integration",
601+ "isExpression": false,
602+ "asc": true,
603+ "nulls": "last"
604+ },
605+ {
606+ "expression": "connection",
607+ "isExpression": false,
608+ "asc": true,
609+ "nulls": "last"
610+ },
611+ {
612+ "expression": "name",
613+ "isExpression": false,
614+ "asc": true,
615+ "nulls": "last"
616+ }
617+ ],
618+ "isUnique": true,
619+ "concurrently": false,
620+ "method": "btree",
621+ "with": {}
622+ }
623+ },
624+ "foreignKeys": {},
625+ "compositePrimaryKeys": {},
626+ "uniqueConstraints": {},
627+ "policies": {},
628+ "checkConstraints": {},
629+ "isRLSEnabled": false
630+ },
631+ "public.integration": {
632+ "name": "integration",
633+ "schema": "",
634+ "columns": {
635+ "slug": {
636+ "name": "slug",
637+ "type": "varchar(255)",
638+ "primaryKey": false,
639+ "notNull": true
640+ },
641+ "plugin_id": {
642+ "name": "plugin_id",
643+ "type": "text",
644+ "primaryKey": false,
645+ "notNull": true
646+ },
647+ "name": {
648+ "name": "name",
649+ "type": "text",
650+ "primaryKey": false,
651+ "notNull": false
652+ },
653+ "description": {
654+ "name": "description",
655+ "type": "text",
656+ "primaryKey": false,
657+ "notNull": false
658+ },
659+ "config": {
660+ "name": "config",
661+ "type": "json",
662+ "primaryKey": false,
663+ "notNull": false
664+ },
665+ "health_check": {
666+ "name": "health_check",
667+ "type": "json",
668+ "primaryKey": false,
669+ "notNull": false
670+ },
671+ "config_revised_at": {
672+ "name": "config_revised_at",
673+ "type": "bigint",
674+ "primaryKey": false,
675+ "notNull": false
676+ },
677+ "can_remove": {
678+ "name": "can_remove",
679+ "type": "boolean",
680+ "primaryKey": false,
681+ "notNull": true,
682+ "default": true
683+ },
684+ "can_refresh": {
685+ "name": "can_refresh",
686+ "type": "boolean",
687+ "primaryKey": false,
688+ "notNull": true,
689+ "default": false
690+ },
691+ "created_at": {
692+ "name": "created_at",
693+ "type": "timestamp",
694+ "primaryKey": false,
695+ "notNull": true
696+ },
697+ "updated_at": {
698+ "name": "updated_at",
699+ "type": "timestamp",
700+ "primaryKey": false,
701+ "notNull": true
702+ },
703+ "row_id": {
704+ "name": "row_id",
705+ "type": "varchar(255)",
706+ "primaryKey": true,
707+ "notNull": true
708+ },
709+ "tenant": {
710+ "name": "tenant",
711+ "type": "varchar(255)",
712+ "primaryKey": false,
713+ "notNull": true
714+ }
715+ },
716+ "indexes": {
717+ "integration_uidx": {
718+ "name": "integration_uidx",
719+ "columns": [
720+ {
721+ "expression": "tenant",
722+ "isExpression": false,
723+ "asc": true,
724+ "nulls": "last"
725+ },
726+ {
727+ "expression": "slug",
728+ "isExpression": false,
729+ "asc": true,
730+ "nulls": "last"
731+ }
732+ ],
733+ "isUnique": true,
734+ "concurrently": false,
735+ "method": "btree",
736+ "with": {}
737+ }
738+ },
739+ "foreignKeys": {},
740+ "compositePrimaryKeys": {},
741+ "uniqueConstraints": {},
742+ "policies": {},
743+ "checkConstraints": {},
744+ "isRLSEnabled": false
745+ },
746+ "public.oauth_client": {
747+ "name": "oauth_client",
748+ "schema": "",
749+ "columns": {
750+ "slug": {
751+ "name": "slug",
752+ "type": "varchar(255)",
753+ "primaryKey": false,
754+ "notNull": true
755+ },
756+ "authorization_url": {
757+ "name": "authorization_url",
758+ "type": "text",
759+ "primaryKey": false,
760+ "notNull": true
761+ },
762+ "token_url": {
763+ "name": "token_url",
764+ "type": "text",
765+ "primaryKey": false,
766+ "notNull": true
767+ },
768+ "grant": {
769+ "name": "grant",
770+ "type": "text",
771+ "primaryKey": false,
772+ "notNull": true
773+ },
774+ "client_id": {
775+ "name": "client_id",
776+ "type": "text",
777+ "primaryKey": false,
778+ "notNull": true
779+ },
780+ "client_secret_item_id": {
781+ "name": "client_secret_item_id",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": false
785+ },
786+ "credential_write": {
787+ "name": "credential_write",
788+ "type": "json",
789+ "primaryKey": false,
790+ "notNull": false
791+ },
792+ "token_endpoint_auth_method": {
793+ "name": "token_endpoint_auth_method",
794+ "type": "text",
795+ "primaryKey": false,
796+ "notNull": false
797+ },
798+ "resource": {
799+ "name": "resource",
800+ "type": "text",
801+ "primaryKey": false,
802+ "notNull": false
803+ },
804+ "origin_kind": {
805+ "name": "origin_kind",
806+ "type": "text",
807+ "primaryKey": false,
808+ "notNull": false
809+ },
810+ "origin_integration": {
811+ "name": "origin_integration",
812+ "type": "text",
813+ "primaryKey": false,
814+ "notNull": false
815+ },
816+ "origin_issuer": {
817+ "name": "origin_issuer",
818+ "type": "text",
819+ "primaryKey": false,
820+ "notNull": false
821+ },
822+ "origin_redirect_uri": {
823+ "name": "origin_redirect_uri",
824+ "type": "text",
825+ "primaryKey": false,
826+ "notNull": false
827+ },
828+ "created_at": {
829+ "name": "created_at",
830+ "type": "timestamp",
831+ "primaryKey": false,
832+ "notNull": true
833+ },
834+ "row_id": {
835+ "name": "row_id",
836+ "type": "varchar(255)",
837+ "primaryKey": true,
838+ "notNull": true
839+ },
840+ "tenant": {
841+ "name": "tenant",
842+ "type": "varchar(255)",
843+ "primaryKey": false,
844+ "notNull": true
845+ },
846+ "owner": {
847+ "name": "owner",
848+ "type": "varchar(255)",
849+ "primaryKey": false,
850+ "notNull": true
851+ },
852+ "subject": {
853+ "name": "subject",
854+ "type": "varchar(255)",
855+ "primaryKey": false,
856+ "notNull": true
857+ }
858+ },
859+ "indexes": {
860+ "oauth_client_uidx": {
861+ "name": "oauth_client_uidx",
862+ "columns": [
863+ {
864+ "expression": "tenant",
865+ "isExpression": false,
866+ "asc": true,
867+ "nulls": "last"
868+ },
869+ {
870+ "expression": "owner",
871+ "isExpression": false,
872+ "asc": true,
873+ "nulls": "last"
874+ },
875+ {
876+ "expression": "subject",
877+ "isExpression": false,
878+ "asc": true,
879+ "nulls": "last"
880+ },
881+ {
882+ "expression": "slug",
883+ "isExpression": false,
884+ "asc": true,
885+ "nulls": "last"
886+ }
887+ ],
888+ "isUnique": true,
889+ "concurrently": false,
890+ "method": "btree",
891+ "with": {}
892+ }
893+ },
894+ "foreignKeys": {},
895+ "compositePrimaryKeys": {},
896+ "uniqueConstraints": {},
897+ "policies": {},
898+ "checkConstraints": {},
899+ "isRLSEnabled": false
900+ },
901+ "public.oauth_session": {
902+ "name": "oauth_session",
903+ "schema": "",
904+ "columns": {
905+ "state": {
906+ "name": "state",
907+ "type": "varchar(255)",
908+ "primaryKey": false,
909+ "notNull": true
910+ },
911+ "client_slug": {
912+ "name": "client_slug",
913+ "type": "text",
914+ "primaryKey": false,
915+ "notNull": true
916+ },
917+ "integration": {
918+ "name": "integration",
919+ "type": "text",
920+ "primaryKey": false,
921+ "notNull": true
922+ },
923+ "name": {
924+ "name": "name",
925+ "type": "text",
926+ "primaryKey": false,
927+ "notNull": true
928+ },
929+ "template": {
930+ "name": "template",
931+ "type": "text",
932+ "primaryKey": false,
933+ "notNull": true
934+ },
935+ "redirect_url": {
936+ "name": "redirect_url",
937+ "type": "text",
938+ "primaryKey": false,
939+ "notNull": true
940+ },
941+ "pkce_verifier": {
942+ "name": "pkce_verifier",
943+ "type": "text",
944+ "primaryKey": false,
945+ "notNull": false
946+ },
947+ "identity_label": {
948+ "name": "identity_label",
949+ "type": "text",
950+ "primaryKey": false,
951+ "notNull": false
952+ },
953+ "payload": {
954+ "name": "payload",
955+ "type": "json",
956+ "primaryKey": false,
957+ "notNull": true
958+ },
959+ "expires_at": {
960+ "name": "expires_at",
961+ "type": "bigint",
962+ "primaryKey": false,
963+ "notNull": true
964+ },
965+ "created_at": {
966+ "name": "created_at",
967+ "type": "timestamp",
968+ "primaryKey": false,
969+ "notNull": true
970+ },
971+ "row_id": {
972+ "name": "row_id",
973+ "type": "varchar(255)",
974+ "primaryKey": true,
975+ "notNull": true
976+ },
977+ "tenant": {
978+ "name": "tenant",
979+ "type": "varchar(255)",
980+ "primaryKey": false,
981+ "notNull": true
982+ },
983+ "owner": {
984+ "name": "owner",
985+ "type": "varchar(255)",
986+ "primaryKey": false,
987+ "notNull": true
988+ },
989+ "subject": {
990+ "name": "subject",
991+ "type": "varchar(255)",
992+ "primaryKey": false,
993+ "notNull": true
994+ }
995+ },
996+ "indexes": {
997+ "oauth_session_uidx": {
998+ "name": "oauth_session_uidx",
999+ "columns": [
1000+ {
1001+ "expression": "tenant",
1002+ "isExpression": false,
1003+ "asc": true,
1004+ "nulls": "last"
1005+ },
1006+ {
1007+ "expression": "state",
1008+ "isExpression": false,
1009+ "asc": true,
1010+ "nulls": "last"
1011+ }
1012+ ],
1013+ "isUnique": true,
1014+ "concurrently": false,
1015+ "method": "btree",
1016+ "with": {}
1017+ }
1018+ },
1019+ "foreignKeys": {},
1020+ "compositePrimaryKeys": {},
1021+ "uniqueConstraints": {},
1022+ "policies": {},
1023+ "checkConstraints": {},
1024+ "isRLSEnabled": false
1025+ },
1026+ "public.plugin_storage": {
1027+ "name": "plugin_storage",
1028+ "schema": "",
1029+ "columns": {
1030+ "plugin_id": {
1031+ "name": "plugin_id",
1032+ "type": "varchar(255)",
1033+ "primaryKey": false,
1034+ "notNull": true
1035+ },
1036+ "collection": {
1037+ "name": "collection",
1038+ "type": "varchar(255)",
1039+ "primaryKey": false,
1040+ "notNull": true
1041+ },
1042+ "key": {
1043+ "name": "key",
1044+ "type": "varchar(255)",
1045+ "primaryKey": false,
1046+ "notNull": true
1047+ },
1048+ "data": {
1049+ "name": "data",
1050+ "type": "json",
1051+ "primaryKey": false,
1052+ "notNull": true
1053+ },
1054+ "created_at": {
1055+ "name": "created_at",
1056+ "type": "timestamp",
1057+ "primaryKey": false,
1058+ "notNull": true
1059+ },
1060+ "updated_at": {
1061+ "name": "updated_at",
1062+ "type": "timestamp",
1063+ "primaryKey": false,
1064+ "notNull": true
1065+ },
1066+ "row_id": {
1067+ "name": "row_id",
1068+ "type": "varchar(255)",
1069+ "primaryKey": true,
1070+ "notNull": true
1071+ },
1072+ "tenant": {
1073+ "name": "tenant",
1074+ "type": "varchar(255)",
1075+ "primaryKey": false,
1076+ "notNull": true
1077+ },
1078+ "owner": {
1079+ "name": "owner",
1080+ "type": "varchar(255)",
1081+ "primaryKey": false,
1082+ "notNull": true
1083+ },
1084+ "subject": {
1085+ "name": "subject",
1086+ "type": "varchar(255)",
1087+ "primaryKey": false,
1088+ "notNull": true
1089+ }
1090+ },
1091+ "indexes": {
1092+ "plugin_storage_uidx": {
1093+ "name": "plugin_storage_uidx",
1094+ "columns": [
1095+ {
1096+ "expression": "tenant",
1097+ "isExpression": false,
1098+ "asc": true,
1099+ "nulls": "last"
1100+ },
1101+ {
1102+ "expression": "owner",
1103+ "isExpression": false,
1104+ "asc": true,
1105+ "nulls": "last"
1106+ },
1107+ {
1108+ "expression": "subject",
1109+ "isExpression": false,
1110+ "asc": true,
1111+ "nulls": "last"
1112+ },
1113+ {
1114+ "expression": "plugin_id",
1115+ "isExpression": false,
1116+ "asc": true,
1117+ "nulls": "last"
1118+ },
1119+ {
1120+ "expression": "collection",
1121+ "isExpression": false,
1122+ "asc": true,
1123+ "nulls": "last"
1124+ },
1125+ {
1126+ "expression": "key",
1127+ "isExpression": false,
1128+ "asc": true,
1129+ "nulls": "last"
1130+ }
1131+ ],
1132+ "isUnique": true,
1133+ "concurrently": false,
1134+ "method": "btree",
1135+ "with": {}
1136+ }
1137+ },
1138+ "foreignKeys": {},
1139+ "compositePrimaryKeys": {},
1140+ "uniqueConstraints": {},
1141+ "policies": {},
1142+ "checkConstraints": {},
1143+ "isRLSEnabled": false
1144+ },
1145+ "public.private_executor_cloud_settings": {
1146+ "name": "private_executor_cloud_settings",
1147+ "schema": "",
1148+ "columns": {
1149+ "id": {
1150+ "name": "id",
1151+ "type": "varchar(255)",
1152+ "primaryKey": true,
1153+ "notNull": true
1154+ },
1155+ "version": {
1156+ "name": "version",
1157+ "type": "varchar(255)",
1158+ "primaryKey": false,
1159+ "notNull": true,
1160+ "default": "'1.0.0'"
1161+ }
1162+ },
1163+ "indexes": {},
1164+ "foreignKeys": {},
1165+ "compositePrimaryKeys": {},
1166+ "uniqueConstraints": {},
1167+ "policies": {},
1168+ "checkConstraints": {},
1169+ "isRLSEnabled": false
1170+ },
1171+ "public.skill": {
1172+ "name": "skill",
1173+ "schema": "",
1174+ "columns": {
1175+ "name": {
1176+ "name": "name",
1177+ "type": "varchar(255)",
1178+ "primaryKey": false,
1179+ "notNull": true
1180+ },
1181+ "description": {
1182+ "name": "description",
1183+ "type": "text",
1184+ "primaryKey": false,
1185+ "notNull": true
1186+ },
1187+ "frontmatter": {
1188+ "name": "frontmatter",
1189+ "type": "json",
1190+ "primaryKey": false,
1191+ "notNull": true
1192+ },
1193+ "files": {
1194+ "name": "files",
1195+ "type": "json",
1196+ "primaryKey": false,
1197+ "notNull": true
1198+ },
1199+ "created_at": {
1200+ "name": "created_at",
1201+ "type": "timestamp",
1202+ "primaryKey": false,
1203+ "notNull": true
1204+ },
1205+ "updated_at": {
1206+ "name": "updated_at",
1207+ "type": "timestamp",
1208+ "primaryKey": false,
1209+ "notNull": true
1210+ },
1211+ "row_id": {
1212+ "name": "row_id",
1213+ "type": "varchar(255)",
1214+ "primaryKey": true,
1215+ "notNull": true
1216+ },
1217+ "tenant": {
1218+ "name": "tenant",
1219+ "type": "varchar(255)",
1220+ "primaryKey": false,
1221+ "notNull": true
1222+ },
1223+ "owner": {
1224+ "name": "owner",
1225+ "type": "varchar(255)",
1226+ "primaryKey": false,
1227+ "notNull": true
1228+ },
1229+ "subject": {
1230+ "name": "subject",
1231+ "type": "varchar(255)",
1232+ "primaryKey": false,
1233+ "notNull": true
1234+ }
1235+ },
1236+ "indexes": {
1237+ "skill_uidx": {
1238+ "name": "skill_uidx",
1239+ "columns": [
1240+ {
1241+ "expression": "tenant",
1242+ "isExpression": false,
1243+ "asc": true,
1244+ "nulls": "last"
1245+ },
1246+ {
1247+ "expression": "owner",
1248+ "isExpression": false,
1249+ "asc": true,
1250+ "nulls": "last"
1251+ },
1252+ {
1253+ "expression": "subject",
1254+ "isExpression": false,
1255+ "asc": true,
1256+ "nulls": "last"
1257+ },
1258+ {
1259+ "expression": "name",
1260+ "isExpression": false,
1261+ "asc": true,
1262+ "nulls": "last"
1263+ }
1264+ ],
1265+ "isUnique": true,
1266+ "concurrently": false,
1267+ "method": "btree",
1268+ "with": {}
1269+ }
1270+ },
1271+ "foreignKeys": {},
1272+ "compositePrimaryKeys": {},
1273+ "uniqueConstraints": {},
1274+ "policies": {},
1275+ "checkConstraints": {},
1276+ "isRLSEnabled": false
1277+ },
1278+ "public.subject": {
1279+ "name": "subject",
1280+ "schema": "",
1281+ "columns": {
1282+ "external_id": {
1283+ "name": "external_id",
1284+ "type": "varchar(255)",
1285+ "primaryKey": false,
1286+ "notNull": true
1287+ },
1288+ "created_at": {
1289+ "name": "created_at",
1290+ "type": "timestamp",
1291+ "primaryKey": false,
1292+ "notNull": true
1293+ },
1294+ "last_seen_at": {
1295+ "name": "last_seen_at",
1296+ "type": "bigint",
1297+ "primaryKey": false,
1298+ "notNull": false
1299+ },
1300+ "status": {
1301+ "name": "status",
1302+ "type": "text",
1303+ "primaryKey": false,
1304+ "notNull": false
1305+ },
1306+ "row_id": {
1307+ "name": "row_id",
1308+ "type": "varchar(255)",
1309+ "primaryKey": true,
1310+ "notNull": true
1311+ },
1312+ "tenant": {
1313+ "name": "tenant",
1314+ "type": "varchar(255)",
1315+ "primaryKey": false,
1316+ "notNull": true
1317+ }
1318+ },
1319+ "indexes": {
1320+ "subject_uidx": {
1321+ "name": "subject_uidx",
1322+ "columns": [
1323+ {
1324+ "expression": "tenant",
1325+ "isExpression": false,
1326+ "asc": true,
1327+ "nulls": "last"
1328+ },
1329+ {
1330+ "expression": "external_id",
1331+ "isExpression": false,
1332+ "asc": true,
1333+ "nulls": "last"
1334+ }
1335+ ],
1336+ "isUnique": true,
1337+ "concurrently": false,
1338+ "method": "btree",
1339+ "with": {}
1340+ }
1341+ },
1342+ "foreignKeys": {},
1343+ "compositePrimaryKeys": {},
1344+ "uniqueConstraints": {},
1345+ "policies": {},
1346+ "checkConstraints": {},
1347+ "isRLSEnabled": false
1348+ },
1349+ "public.tool": {
1350+ "name": "tool",
1351+ "schema": "",
1352+ "columns": {
1353+ "integration": {
1354+ "name": "integration",
1355+ "type": "varchar(255)",
1356+ "primaryKey": false,
1357+ "notNull": true
1358+ },
1359+ "connection": {
1360+ "name": "connection",
1361+ "type": "varchar(255)",
1362+ "primaryKey": false,
1363+ "notNull": true
1364+ },
1365+ "plugin_id": {
1366+ "name": "plugin_id",
1367+ "type": "text",
1368+ "primaryKey": false,
1369+ "notNull": true
1370+ },
1371+ "name": {
1372+ "name": "name",
1373+ "type": "varchar(255)",
1374+ "primaryKey": false,
1375+ "notNull": true
1376+ },
1377+ "description": {
1378+ "name": "description",
1379+ "type": "text",
1380+ "primaryKey": false,
1381+ "notNull": true
1382+ },
1383+ "input_schema": {
1384+ "name": "input_schema",
1385+ "type": "json",
1386+ "primaryKey": false,
1387+ "notNull": false
1388+ },
1389+ "output_schema": {
1390+ "name": "output_schema",
1391+ "type": "json",
1392+ "primaryKey": false,
1393+ "notNull": false
1394+ },
1395+ "annotations": {
1396+ "name": "annotations",
1397+ "type": "json",
1398+ "primaryKey": false,
1399+ "notNull": false
1400+ },
1401+ "created_at": {
1402+ "name": "created_at",
1403+ "type": "timestamp",
1404+ "primaryKey": false,
1405+ "notNull": true
1406+ },
1407+ "updated_at": {
1408+ "name": "updated_at",
1409+ "type": "timestamp",
1410+ "primaryKey": false,
1411+ "notNull": true
1412+ },
1413+ "row_id": {
1414+ "name": "row_id",
1415+ "type": "varchar(255)",
1416+ "primaryKey": true,
1417+ "notNull": true
1418+ },
1419+ "tenant": {
1420+ "name": "tenant",
1421+ "type": "varchar(255)",
1422+ "primaryKey": false,
1423+ "notNull": true
1424+ },
1425+ "owner": {
1426+ "name": "owner",
1427+ "type": "varchar(255)",
1428+ "primaryKey": false,
1429+ "notNull": true
1430+ },
1431+ "subject": {
1432+ "name": "subject",
1433+ "type": "varchar(255)",
1434+ "primaryKey": false,
1435+ "notNull": true
1436+ }
1437+ },
1438+ "indexes": {
1439+ "tool_uidx": {
1440+ "name": "tool_uidx",
1441+ "columns": [
1442+ {
1443+ "expression": "tenant",
1444+ "isExpression": false,
1445+ "asc": true,
1446+ "nulls": "last"
1447+ },
1448+ {
1449+ "expression": "owner",
1450+ "isExpression": false,
1451+ "asc": true,
1452+ "nulls": "last"
1453+ },
1454+ {
1455+ "expression": "subject",
1456+ "isExpression": false,
1457+ "asc": true,
1458+ "nulls": "last"
1459+ },
1460+ {
1461+ "expression": "integration",
1462+ "isExpression": false,
1463+ "asc": true,
1464+ "nulls": "last"
1465+ },
1466+ {
1467+ "expression": "connection",
1468+ "isExpression": false,
1469+ "asc": true,
1470+ "nulls": "last"
1471+ },
1472+ {
1473+ "expression": "name",
1474+ "isExpression": false,
1475+ "asc": true,
1476+ "nulls": "last"
1477+ }
1478+ ],
1479+ "isUnique": true,
1480+ "concurrently": false,
1481+ "method": "btree",
1482+ "with": {}
1483+ }
1484+ },
1485+ "foreignKeys": {},
1486+ "compositePrimaryKeys": {},
1487+ "uniqueConstraints": {},
1488+ "policies": {},
1489+ "checkConstraints": {},
1490+ "isRLSEnabled": false
1491+ },
1492+ "public.tool_policy": {
1493+ "name": "tool_policy",
1494+ "schema": "",
1495+ "columns": {
1496+ "id": {
1497+ "name": "id",
1498+ "type": "varchar(255)",
1499+ "primaryKey": false,
1500+ "notNull": true
1501+ },
1502+ "pattern": {
1503+ "name": "pattern",
1504+ "type": "text",
1505+ "primaryKey": false,
1506+ "notNull": true
1507+ },
1508+ "action": {
1509+ "name": "action",
1510+ "type": "text",
1511+ "primaryKey": false,
1512+ "notNull": true
1513+ },
1514+ "position": {
1515+ "name": "position",
1516+ "type": "text",
1517+ "primaryKey": false,
1518+ "notNull": true
1519+ },
1520+ "created_at": {
1521+ "name": "created_at",
1522+ "type": "timestamp",
1523+ "primaryKey": false,
1524+ "notNull": true
1525+ },
1526+ "updated_at": {
1527+ "name": "updated_at",
1528+ "type": "timestamp",
1529+ "primaryKey": false,
1530+ "notNull": true
1531+ },
1532+ "row_id": {
1533+ "name": "row_id",
1534+ "type": "varchar(255)",
1535+ "primaryKey": true,
1536+ "notNull": true
1537+ },
1538+ "tenant": {
1539+ "name": "tenant",
1540+ "type": "varchar(255)",
1541+ "primaryKey": false,
1542+ "notNull": true
1543+ },
1544+ "owner": {
1545+ "name": "owner",
1546+ "type": "varchar(255)",
1547+ "primaryKey": false,
1548+ "notNull": true
1549+ },
1550+ "subject": {
1551+ "name": "subject",
1552+ "type": "varchar(255)",
1553+ "primaryKey": false,
1554+ "notNull": true
1555+ }
1556+ },
1557+ "indexes": {
1558+ "tool_policy_uidx": {
1559+ "name": "tool_policy_uidx",
1560+ "columns": [
1561+ {
1562+ "expression": "tenant",
1563+ "isExpression": false,
1564+ "asc": true,
1565+ "nulls": "last"
1566+ },
1567+ {
1568+ "expression": "owner",
1569+ "isExpression": false,
1570+ "asc": true,
1571+ "nulls": "last"
1572+ },
1573+ {
1574+ "expression": "subject",
1575+ "isExpression": false,
1576+ "asc": true,
1577+ "nulls": "last"
1578+ },
1579+ {
1580+ "expression": "id",
1581+ "isExpression": false,
1582+ "asc": true,
1583+ "nulls": "last"
1584+ }
1585+ ],
1586+ "isUnique": true,
1587+ "concurrently": false,
1588+ "method": "btree",
1589+ "with": {}
1590+ }
1591+ },
1592+ "foreignKeys": {},
1593+ "compositePrimaryKeys": {},
1594+ "uniqueConstraints": {},
1595+ "policies": {},
1596+ "checkConstraints": {},
1597+ "isRLSEnabled": false
1598+ }
1599+ },
1600+ "enums": {},
1601+ "schemas": {},
1602+ "sequences": {},
1603+ "roles": {},
1604+ "policies": {},
1605+ "views": {},
1606+ "_meta": {
1607+ "columns": {},
1608+ "schemas": {},
1609+ "tables": {}
1610+ }
1611+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -127,6 +127,13 @@ · 126 unmodified lines
127127 "when": 1788287088210,
128128 "tag": "0017_lush_thunderbolts",
129129 "breakpoints": true
130+ },
131+ {
132+ "idx": 18,
133+ "version": "7",
134+ "when": 1789347558605,
135+ "tag": "0018_eager_jack_power",
136+ "breakpoints": true
130137 }
131138 ]
132139 }
apps/cloud/src/db/executor-schema.ts+20−0
@@ -251,6 +251,26 @@ export const artifact = pgTable( · 250 unmodified lines
251251 (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)],
252252 );
253253
254+export const skill = pgTable(
255+ "skill",
256+ {
257+ name: varchar("name", { length: 255 }).notNull(),
258+ description: text("description").notNull(),
259+ frontmatter: json("frontmatter").notNull(),
260+ files: json("files").notNull(),
261+ created_at: timestamp("created_at").notNull(),
262+ updated_at: timestamp("updated_at").notNull(),
263+ row_id: varchar("row_id", { length: 255 })
264+ .primaryKey()
265+ .notNull()
266+ .$defaultFn(() => createId()),
267+ tenant: varchar("tenant", { length: 255 }).notNull(),
268+ owner: varchar("owner", { length: 255 }).notNull(),
269+ subject: varchar("subject", { length: 255 }).notNull(),
270+ },
271+ (table) => [uniqueIndex("skill_uidx").on(table.tenant, table.owner, table.subject, table.name)],
272+);
273+
254274 export const plugin_storage = pgTable(
255275 "plugin_storage",
256276 {
apps/cloud/src/db/org-deletion.test.ts+14−0
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 oauth_client,
3737 oauth_session,
3838 plugin_storage,
39+ skill,
3940 subject,
4041 tool,
4142 tool_policy,
@@ -158,6 +159,18 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 116 unmodified lines
158159 subject: "s",
159160 });
160161
162+ await db.insert(skill).values({
163+ name: `skill-${tag}`,
164+ description: "House style",
165+ frontmatter: { name: `skill-${tag}`, description: "House style" },
166+ files: [],
167+ created_at: now,
168+ updated_at: now,
169+ tenant,
170+ owner: "o",
171+ subject: "s",
172+ });
173+
161174 const orgNs = `o:${tenant}/plugin`;
162175 const userNs = `u:${tenant}:subject/plugin`;
163176 await db.insert(blob).values({
@@ -185,6 +198,7 @@ const TENANT_TABLES = [ · 21 unmodified lines
185198 plugin_storage,
186199 subject,
187200 artifact,
201+ skill,
188202 ] as const;
189203
190204 // Tables that are NOT purged by org id, each with the reason it is exempt. Any
apps/cloud/src/db/org-deletion.ts+2−0
@@ -24,6 +24,7 @@ import { · 23 unmodified lines
2424 oauth_client,
2525 oauth_session,
2626 plugin_storage,
27+ skill,
2728 subject,
2829 tool,
2930 tool_policy,
@@ -52,6 +53,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 22 unmodified lines
5253 await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId));
5354 await tx.delete(subject).where(eq(subject.tenant, organizationId));
5455 await tx.delete(artifact).where(eq(artifact.tenant, organizationId));
56+ await tx.delete(skill).where(eq(skill.tenant, organizationId));
5557
5658 // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by
5759 // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>`
apps/cloud/src/mcp/session-durable-object.ts+1−0
@@ -383,6 +383,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 382 unmodified lines
383383 engine,
384384 description,
385385 artifacts: executor.artifacts,
386+ skills: executor.skills,
386387 connections: executor.connections,
387388 // Artifacts are on by default, opt-out per connection. A session
388389 // persisted without a value restores to the default, same as a fresh
apps/cloud/src/mcp/telemetry.ts+6−0
@@ -192,6 +192,12 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 191 unmodified lines
192192 onSome: ({ uri }) => (uri ? { "mcp.resource.uri": uri } : {}),
193193 }),
194194 ),
195+ Match.when("skills/get", () =>
196+ Option.match(decodeUriParams(params), {
197+ onNone: () => ({}) as Record<string, unknown>,
198+ onSome: ({ uri }) => (uri ? { "mcp.resource.uri": uri } : {}),
199+ }),
200+ ),
195201 Match.when("prompts/get", () =>
196202 Option.match(decodeNamedParams(params), {
197203 onNone: () => ({}) as Record<string, unknown>,
apps/cloud/src/routeTree.gen.ts+85−0
@@ -16,6 +16,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport · 15 unmodified lines
1616 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
1717 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
1818 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
19+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport } from './../../../packages/react/src/routes/skills'
1920 import { Route as SecretsRouteImport } from './routes/app/secrets'
2021 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
2122 import { Route as OrgRouteImport } from './routes/app/org'
@@ -23,12 +24,14 @@ import { Route as BillingRouteImport } from './routes/app/billing' · 1 unmodified line
2324 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'
2425 import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
2526 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
27+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport } from './../../../packages/react/src/routes/skills.new'
2628 import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
2729 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
2830 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2931 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
3032 import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
3133 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
34+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport } from './../../../packages/react/src/routes/skills.$skillOwner.$skillName'
3235 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
3336
3437 const SetupMcpRoute = SetupMcpRouteImport.update({
@@ -70,6 +73,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = · 35 unmodified lines
7073 path: '/{-$orgSlug}/toolkits',
7174 getParentRoute: () => rootRouteImport,
7275 } as any)
76+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute =
77+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({
78+ id: '/{-$orgSlug}/skills',
79+ path: '/{-$orgSlug}/skills',
80+ getParentRoute: () => rootRouteImport,
81+ } as any)
7382 const SecretsRoute = SecretsRouteImport.update({
7483 id: '/{-$orgSlug}/secrets',
7584 path: '/{-$orgSlug}/secrets',
@@ -111,6 +120,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute · 35 unmodified lines
111120 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
112121 } as any,
113122 )
123+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute =
124+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({
125+ id: '/new',
126+ path: '/new',
127+ getParentRoute: () =>
128+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute,
129+ } as any)
114130 const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({
115131 id: '/{-$orgSlug}/resume/$executionId',
116132 path: '/{-$orgSlug}/resume/$executionId',
@@ -154,6 +170,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute · 37 unmodified lines
154170 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute,
155171 } as any,
156172 )
173+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute =
174+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update(
175+ {
176+ id: '/$skillOwner/$skillName',
177+ path: '/$skillOwner/$skillName',
178+ getParentRoute: () =>
179+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute,
180+ } as any,
181+ )
157182 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute =
158183 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update(
159184 {
@@ -173,6 +198,7 @@ export interface FileRoutesByFullPath { · 13 unmodified lines
173198 '/{-$orgSlug}/org': typeof OrgRoute
174199 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
175200 '/{-$orgSlug}/secrets': typeof SecretsRoute
201+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
176202 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
177203 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
178204 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
@@ -183,8 +209,10 @@ export interface FileRoutesByFullPath { · 4 unmodified lines
183209 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
184210 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
185211 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
212+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
186213 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
187214 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
215+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
188216 }
189217 export interface FileRoutesByTo {
190218 '/create-org': typeof CreateOrgRoute
@@ -196,6 +224,7 @@ export interface FileRoutesByTo { · 5 unmodified lines
196224 '/{-$orgSlug}/org': typeof OrgRoute
197225 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
198226 '/{-$orgSlug}/secrets': typeof SecretsRoute
227+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
199228 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
200229 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
201230 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
@@ -206,8 +235,10 @@ export interface FileRoutesByTo { · 4 unmodified lines
206235 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
207236 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
208237 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
238+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
209239 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
210240 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
241+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
211242 }
212243 export interface FileRoutesById {
213244 __root__: typeof rootRouteImport
@@ -220,6 +251,7 @@ export interface FileRoutesById { · 6 unmodified lines
220251 '/{-$orgSlug}/org': typeof OrgRoute
221252 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
222253 '/{-$orgSlug}/secrets': typeof SecretsRoute
254+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
223255 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
224256 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
225257 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
@@ -230,8 +262,10 @@ export interface FileRoutesById { · 4 unmodified lines
230262 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
231263 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
232264 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
265+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
233266 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
234267 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
268+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
235269 }
236270 export interface FileRouteTypes {
237271 fileRoutesByFullPath: FileRoutesByFullPath
@@ -245,6 +279,7 @@ export interface FileRouteTypes { · 7 unmodified lines
245279 | '/{-$orgSlug}/org'
246280 | '/{-$orgSlug}/policies'
247281 | '/{-$orgSlug}/secrets'
282+ | '/{-$orgSlug}/skills'
248283 | '/{-$orgSlug}/toolkits'
249284 | '/{-$orgSlug}/tools'
250285 | '/{-$orgSlug}/users'
@@ -255,8 +290,10 @@ export interface FileRouteTypes { · 4 unmodified lines
255290 | '/{-$orgSlug}/integrations/$namespace'
256291 | '/{-$orgSlug}/integrations/browse'
257292 | '/{-$orgSlug}/resume/$executionId'
293+ | '/{-$orgSlug}/skills/new'
258294 | '/{-$orgSlug}/toolkits/$toolkitSlug'
259295 | '/{-$orgSlug}/integrations/add/$pluginKey'
296+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
260297 fileRoutesByTo: FileRoutesByTo
261298 to:
262299 | '/create-org'
@@ -268,6 +305,7 @@ export interface FileRouteTypes { · 5 unmodified lines
268305 | '/{-$orgSlug}/org'
269306 | '/{-$orgSlug}/policies'
270307 | '/{-$orgSlug}/secrets'
308+ | '/{-$orgSlug}/skills'
271309 | '/{-$orgSlug}/toolkits'
272310 | '/{-$orgSlug}/tools'
273311 | '/{-$orgSlug}/users'
@@ -278,8 +316,10 @@ export interface FileRouteTypes { · 4 unmodified lines
278316 | '/{-$orgSlug}/integrations/$namespace'
279317 | '/{-$orgSlug}/integrations/browse'
280318 | '/{-$orgSlug}/resume/$executionId'
319+ | '/{-$orgSlug}/skills/new'
281320 | '/{-$orgSlug}/toolkits/$toolkitSlug'
282321 | '/{-$orgSlug}/integrations/add/$pluginKey'
322+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
283323 id:
284324 | '__root__'
285325 | '/create-org'
@@ -291,6 +331,7 @@ export interface FileRouteTypes { · 5 unmodified lines
291331 | '/{-$orgSlug}/org'
292332 | '/{-$orgSlug}/policies'
293333 | '/{-$orgSlug}/secrets'
334+ | '/{-$orgSlug}/skills'
294335 | '/{-$orgSlug}/toolkits'
295336 | '/{-$orgSlug}/tools'
296337 | '/{-$orgSlug}/users'
@@ -301,8 +342,10 @@ export interface FileRouteTypes { · 4 unmodified lines
301342 | '/{-$orgSlug}/integrations/$namespace'
302343 | '/{-$orgSlug}/integrations/browse'
303344 | '/{-$orgSlug}/resume/$executionId'
345+ | '/{-$orgSlug}/skills/new'
304346 | '/{-$orgSlug}/toolkits/$toolkitSlug'
305347 | '/{-$orgSlug}/integrations/add/$pluginKey'
348+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
306349 fileRoutesById: FileRoutesById
307350 }
308351 export interface RootRouteChildren {
@@ -315,6 +358,7 @@ export interface RootRouteChildren { · 6 unmodified lines
315358 OrgRoute: typeof OrgRoute
316359 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
317360 SecretsRoute: typeof SecretsRoute
361+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
318362 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
319363 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
320364 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
@@ -378,6 +422,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
378422 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
379423 parentRoute: typeof rootRouteImport
380424 }
425+ '/{-$orgSlug}/skills': {
426+ id: '/{-$orgSlug}/skills'
427+ path: '/{-$orgSlug}/skills'
428+ fullPath: '/{-$orgSlug}/skills'
429+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport
430+ parentRoute: typeof rootRouteImport
431+ }
381432 '/{-$orgSlug}/secrets': {
382433 id: '/{-$orgSlug}/secrets'
383434 path: '/{-$orgSlug}/secrets'
@@ -427,6 +478,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines
427478 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport
428479 parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute
429480 }
481+ '/{-$orgSlug}/skills/new': {
482+ id: '/{-$orgSlug}/skills/new'
483+ path: '/new'
484+ fullPath: '/{-$orgSlug}/skills/new'
485+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport
486+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute
487+ }
430488 '/{-$orgSlug}/resume/$executionId': {
431489 id: '/{-$orgSlug}/resume/$executionId'
432490 path: '/{-$orgSlug}/resume/$executionId'
@@ -469,6 +527,13 @@ declare module '@tanstack/react-router' { · 36 unmodified lines
469527 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport
470528 parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute
471529 }
530+ '/{-$orgSlug}/skills/$skillOwner/$skillName': {
531+ id: '/{-$orgSlug}/skills/$skillOwner/$skillName'
532+ path: '/$skillOwner/$skillName'
533+ fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName'
534+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport
535+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute
536+ }
472537 '/{-$orgSlug}/integrations/add/$pluginKey': {
473538 id: '/{-$orgSlug}/integrations/add/$pluginKey'
474539 path: '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -494,6 +559,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = · 19 unmodified lines
494559 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren,
495560 )
496561
562+interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren {
563+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
564+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
565+}
566+
567+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren =
568+ {
569+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute:
570+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute,
571+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute:
572+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute,
573+ }
574+
575+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren =
576+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren(
577+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren,
578+ )
579+
497580 interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
498581 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
499582 }
@@ -521,6 +604,8 @@ const rootRouteChildren: RootRouteChildren = { · 21 unmodified lines
521604 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:
522605 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
523606 SecretsRoute: SecretsRoute,
607+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute:
608+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren,
524609 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
525610 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
526611 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
apps/host-cloudflare/src/mcp/session-durable-object.ts+1−0
@@ -164,6 +164,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 163 unmodified lines
164164 const mcpServer = yield* createExecutorMcpServer({
165165 engine,
166166 artifacts: executor.artifacts,
167+ skills: executor.skills,
167168 connections: executor.connections,
168169 // Artifacts are on by default, opt-out per connection. A session
169170 // persisted without a value restores to the default, same as a fresh
apps/host-cloudflare/web/routeTree.gen.ts+85−0
@@ -12,15 +12,18 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
1313 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
1414 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
15+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport } from './../../../packages/react/src/routes/skills'
1516 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
1617 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
1718 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'
1819 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
20+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport } from './../../../packages/react/src/routes/skills.new'
1921 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
2022 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
2123 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2224 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2325 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
26+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport } from './../../../packages/react/src/routes/skills.$skillOwner.$skillName'
2427 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
2528 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2629
@@ -42,6 +45,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = · 15 unmodified lines
4245 path: '/{-$orgSlug}/toolkits',
4346 getParentRoute: () => rootRouteImport,
4447 } as any)
48+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute =
49+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport.update({
50+ id: '/{-$orgSlug}/skills',
51+ path: '/{-$orgSlug}/skills',
52+ getParentRoute: () => rootRouteImport,
53+ } as any)
4554 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =
4655 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({
4756 id: '/{-$orgSlug}/secrets',
@@ -69,6 +78,13 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute · 21 unmodified lines
6978 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
7079 } as any,
7180 )
81+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute =
82+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport.update({
83+ id: '/new',
84+ path: '/new',
85+ getParentRoute: () =>
86+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute,
87+ } as any)
7288 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =
7389 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(
7490 {
@@ -110,6 +126,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute · 35 unmodified lines
110126 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute,
111127 } as any,
112128 )
129+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute =
130+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update(
131+ {
132+ id: '/$skillOwner/$skillName',
133+ path: '/$skillOwner/$skillName',
134+ getParentRoute: () =>
135+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute,
136+ } as any,
137+ )
113138 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
114139 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
115140 {
@@ -131,6 +156,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines
131156 '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren
132157 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
133158 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
159+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
134160 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
135161 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
136162 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
@@ -139,14 +165,17 @@ export interface FileRoutesByFullPath { · 2 unmodified lines
139165 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
140166 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
141167 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
168+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
142169 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
143170 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
144171 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
172+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
145173 }
146174 export interface FileRoutesByTo {
147175 '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren
148176 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
149177 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
178+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
150179 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
151180 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
152181 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
@@ -155,15 +184,18 @@ export interface FileRoutesByTo { · 2 unmodified lines
155184 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156185 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
157186 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
187+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
158188 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
159189 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
160190 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
191+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
161192 }
162193 export interface FileRoutesById {
163194 __root__: typeof rootRouteImport
164195 '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren
165196 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
166197 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
198+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
167199 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
168200 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
169201 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
@@ -172,9 +204,11 @@ export interface FileRoutesById { · 2 unmodified lines
172204 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
173205 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
174206 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
207+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
175208 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
176209 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
177210 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
211+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
178212 }
179213 export interface FileRouteTypes {
180214 fileRoutesByFullPath: FileRoutesByFullPath
@@ -182,6 +216,7 @@ export interface FileRouteTypes { · 1 unmodified line
182216 | '/{-$orgSlug}/artifacts'
183217 | '/{-$orgSlug}/policies'
184218 | '/{-$orgSlug}/secrets'
219+ | '/{-$orgSlug}/skills'
185220 | '/{-$orgSlug}/toolkits'
186221 | '/{-$orgSlug}/tools'
187222 | '/{-$orgSlug}/'
@@ -190,14 +225,17 @@ export interface FileRouteTypes { · 2 unmodified lines
190225 | '/{-$orgSlug}/integrations/$namespace'
191226 | '/{-$orgSlug}/integrations/browse'
192227 | '/{-$orgSlug}/resume/$executionId'
228+ | '/{-$orgSlug}/skills/new'
193229 | '/{-$orgSlug}/toolkits/$toolkitSlug'
194230 | '/{-$orgSlug}/integrations/add/$pluginKey'
195231 | '/{-$orgSlug}/plugins/$pluginId/$'
232+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
196233 fileRoutesByTo: FileRoutesByTo
197234 to:
198235 | '/{-$orgSlug}/artifacts'
199236 | '/{-$orgSlug}/policies'
200237 | '/{-$orgSlug}/secrets'
238+ | '/{-$orgSlug}/skills'
201239 | '/{-$orgSlug}/toolkits'
202240 | '/{-$orgSlug}/tools'
203241 | '/{-$orgSlug}'
@@ -206,14 +244,17 @@ export interface FileRouteTypes { · 2 unmodified lines
206244 | '/{-$orgSlug}/integrations/$namespace'
207245 | '/{-$orgSlug}/integrations/browse'
208246 | '/{-$orgSlug}/resume/$executionId'
247+ | '/{-$orgSlug}/skills/new'
209248 | '/{-$orgSlug}/toolkits/$toolkitSlug'
210249 | '/{-$orgSlug}/integrations/add/$pluginKey'
211250 | '/{-$orgSlug}/plugins/$pluginId/$'
251+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
212252 id:
213253 | '__root__'
214254 | '/{-$orgSlug}/artifacts'
215255 | '/{-$orgSlug}/policies'
216256 | '/{-$orgSlug}/secrets'
257+ | '/{-$orgSlug}/skills'
217258 | '/{-$orgSlug}/toolkits'
218259 | '/{-$orgSlug}/tools'
219260 | '/{-$orgSlug}/'
@@ -222,15 +263,18 @@ export interface FileRouteTypes { · 2 unmodified lines
222263 | '/{-$orgSlug}/integrations/$namespace'
223264 | '/{-$orgSlug}/integrations/browse'
224265 | '/{-$orgSlug}/resume/$executionId'
266+ | '/{-$orgSlug}/skills/new'
225267 | '/{-$orgSlug}/toolkits/$toolkitSlug'
226268 | '/{-$orgSlug}/integrations/add/$pluginKey'
227269 | '/{-$orgSlug}/plugins/$pluginId/$'
270+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
228271 fileRoutesById: FileRoutesById
229272 }
230273 export interface RootRouteChildren {
231274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren
232275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
233276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
277+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren
234278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
235279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
236280 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
@@ -265,6 +309,13 @@ declare module '@tanstack/react-router' { · 28 unmodified lines
265309 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
266310 parentRoute: typeof rootRouteImport
267311 }
312+ '/{-$orgSlug}/skills': {
313+ id: '/{-$orgSlug}/skills'
314+ path: '/{-$orgSlug}/skills'
315+ fullPath: '/{-$orgSlug}/skills'
316+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteImport
317+ parentRoute: typeof rootRouteImport
318+ }
268319 '/{-$orgSlug}/secrets': {
269320 id: '/{-$orgSlug}/secrets'
270321 path: '/{-$orgSlug}/secrets'
@@ -293,6 +344,13 @@ declare module '@tanstack/react-router' { · 22 unmodified lines
293344 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport
294345 parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute
295346 }
347+ '/{-$orgSlug}/skills/new': {
348+ id: '/{-$orgSlug}/skills/new'
349+ path: '/new'
350+ fullPath: '/{-$orgSlug}/skills/new'
351+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRouteImport
352+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute
353+ }
296354 '/{-$orgSlug}/resume/$executionId': {
297355 id: '/{-$orgSlug}/resume/$executionId'
298356 path: '/{-$orgSlug}/resume/$executionId'
@@ -328,6 +386,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
328386 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport
329387 parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute
330388 }
389+ '/{-$orgSlug}/skills/$skillOwner/$skillName': {
390+ id: '/{-$orgSlug}/skills/$skillOwner/$skillName'
391+ path: '/$skillOwner/$skillName'
392+ fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName'
393+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport
394+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute
395+ }
331396 '/{-$orgSlug}/plugins/$pluginId/$': {
332397 id: '/{-$orgSlug}/plugins/$pluginId/$'
333398 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -360,6 +425,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = · 26 unmodified lines
360425 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren,
361426 )
362427
428+interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren {
429+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute
430+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
431+}
432+
433+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren =
434+ {
435+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute:
436+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotnewRoute,
437+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute:
438+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute,
439+ }
440+
441+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren =
442+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute._addFileChildren(
443+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteChildren,
444+ )
445+
363446 interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
364447 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
365448 }
@@ -382,6 +465,8 @@ const rootRouteChildren: RootRouteChildren = { · 16 unmodified lines
382465 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
383466 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:
384467 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,
468+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRoute:
469+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSkillsRouteWithChildren,
385470 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
386471 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
387472 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
apps/local/drizzle/0007_same_vengeance.sql+14−0
@@ -0,0 +1,14 @@
1+CREATE TABLE `skill` (
2+ `name` text NOT NULL,
3+ `description` text NOT NULL,
4+ `frontmatter` text NOT NULL,
5+ `files` text NOT NULL,
6+ `created_at` integer NOT NULL,
7+ `updated_at` integer NOT NULL,
8+ `row_id` text PRIMARY KEY NOT NULL,
9+ `tenant` text NOT NULL,
10+ `owner` text NOT NULL,
11+ `subject` text NOT NULL
12+);
13+--> statement-breakpoint
14+CREATE UNIQUE INDEX `skill_uidx` ON `skill` (`tenant`,`owner`,`subject`,`name`);
apps/local/drizzle/meta/0007_snapshot.json+1034−0
@@ -0,0 +1,1034 @@
1+{
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "e5c498c3-9c8a-4bcb-b781-e4a48afac4cc",
5+ "prevId": "89a4fd1b-f0f6-4482-a991-0db78c859f76",
6+ "tables": {
7+ "blob": {
8+ "name": "blob",
9+ "columns": {
10+ "namespace": {
11+ "name": "namespace",
12+ "type": "text",
13+ "primaryKey": false,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "key": {
18+ "name": "key",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "autoincrement": false
23+ },
24+ "value": {
25+ "name": "value",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false
30+ },
31+ "row_id": {
32+ "name": "row_id",
33+ "type": "text",
34+ "primaryKey": true,
35+ "notNull": true,
36+ "autoincrement": false
37+ },
38+ "id": {
39+ "name": "id",
40+ "type": "text",
41+ "primaryKey": false,
42+ "notNull": true,
43+ "autoincrement": false
44+ }
45+ },
46+ "indexes": {
47+ "blob_id_uidx": {
48+ "name": "blob_id_uidx",
49+ "columns": ["id"],
50+ "isUnique": true
51+ }
52+ },
53+ "foreignKeys": {},
54+ "compositePrimaryKeys": {},
55+ "uniqueConstraints": {},
56+ "checkConstraints": {}
57+ },
58+ "connection": {
59+ "name": "connection",
60+ "columns": {
61+ "integration": {
62+ "name": "integration",
63+ "type": "text",
64+ "primaryKey": false,
65+ "notNull": true,
66+ "autoincrement": false
67+ },
68+ "name": {
69+ "name": "name",
70+ "type": "text",
71+ "primaryKey": false,
72+ "notNull": true,
73+ "autoincrement": false
74+ },
75+ "template": {
76+ "name": "template",
77+ "type": "text",
78+ "primaryKey": false,
79+ "notNull": true,
80+ "autoincrement": false
81+ },
82+ "provider": {
83+ "name": "provider",
84+ "type": "text",
85+ "primaryKey": false,
86+ "notNull": true,
87+ "autoincrement": false
88+ },
89+ "item_ids": {
90+ "name": "item_ids",
91+ "type": "text",
92+ "primaryKey": false,
93+ "notNull": true,
94+ "autoincrement": false
95+ },
96+ "credential_write": {
97+ "name": "credential_write",
98+ "type": "text",
99+ "primaryKey": false,
100+ "notNull": false,
101+ "autoincrement": false
102+ },
103+ "identity_label": {
104+ "name": "identity_label",
105+ "type": "text",
106+ "primaryKey": false,
107+ "notNull": false,
108+ "autoincrement": false
109+ },
110+ "oauth_client": {
111+ "name": "oauth_client",
112+ "type": "text",
113+ "primaryKey": false,
114+ "notNull": false,
115+ "autoincrement": false
116+ },
117+ "oauth_client_owner": {
118+ "name": "oauth_client_owner",
119+ "type": "text",
120+ "primaryKey": false,
121+ "notNull": false,
122+ "autoincrement": false
123+ },
124+ "refresh_item_id": {
125+ "name": "refresh_item_id",
126+ "type": "text",
127+ "primaryKey": false,
128+ "notNull": false,
129+ "autoincrement": false
130+ },
131+ "expires_at": {
132+ "name": "expires_at",
133+ "type": "blob",
134+ "primaryKey": false,
135+ "notNull": false,
136+ "autoincrement": false
137+ },
138+ "oauth_scope": {
139+ "name": "oauth_scope",
140+ "type": "text",
141+ "primaryKey": false,
142+ "notNull": false,
143+ "autoincrement": false
144+ },
145+ "oauth_token_url": {
146+ "name": "oauth_token_url",
147+ "type": "text",
148+ "primaryKey": false,
149+ "notNull": false,
150+ "autoincrement": false
151+ },
152+ "provider_state": {
153+ "name": "provider_state",
154+ "type": "text",
155+ "primaryKey": false,
156+ "notNull": false,
157+ "autoincrement": false
158+ },
159+ "created_at": {
160+ "name": "created_at",
161+ "type": "integer",
162+ "primaryKey": false,
163+ "notNull": true,
164+ "autoincrement": false
165+ },
166+ "updated_at": {
167+ "name": "updated_at",
168+ "type": "integer",
169+ "primaryKey": false,
170+ "notNull": true,
171+ "autoincrement": false
172+ },
173+ "row_id": {
174+ "name": "row_id",
175+ "type": "text",
176+ "primaryKey": true,
177+ "notNull": true,
178+ "autoincrement": false
179+ },
180+ "tenant": {
181+ "name": "tenant",
182+ "type": "text",
183+ "primaryKey": false,
184+ "notNull": true,
185+ "autoincrement": false
186+ },
187+ "owner": {
188+ "name": "owner",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true,
192+ "autoincrement": false
193+ },
194+ "subject": {
195+ "name": "subject",
196+ "type": "text",
197+ "primaryKey": false,
198+ "notNull": true,
199+ "autoincrement": false
200+ }
201+ },
202+ "indexes": {
203+ "connection_uidx": {
204+ "name": "connection_uidx",
205+ "columns": ["tenant", "owner", "subject", "integration", "name"],
206+ "isUnique": true
207+ }
208+ },
209+ "foreignKeys": {},
210+ "compositePrimaryKeys": {},
211+ "uniqueConstraints": {},
212+ "checkConstraints": {}
213+ },
214+ "definition": {
215+ "name": "definition",
216+ "columns": {
217+ "integration": {
218+ "name": "integration",
219+ "type": "text",
220+ "primaryKey": false,
221+ "notNull": true,
222+ "autoincrement": false
223+ },
224+ "connection": {
225+ "name": "connection",
226+ "type": "text",
227+ "primaryKey": false,
228+ "notNull": true,
229+ "autoincrement": false
230+ },
231+ "plugin_id": {
232+ "name": "plugin_id",
233+ "type": "text",
234+ "primaryKey": false,
235+ "notNull": true,
236+ "autoincrement": false
237+ },
238+ "name": {
239+ "name": "name",
240+ "type": "text",
241+ "primaryKey": false,
242+ "notNull": true,
243+ "autoincrement": false
244+ },
245+ "schema": {
246+ "name": "schema",
247+ "type": "text",
248+ "primaryKey": false,
249+ "notNull": true,
250+ "autoincrement": false
251+ },
252+ "created_at": {
253+ "name": "created_at",
254+ "type": "integer",
255+ "primaryKey": false,
256+ "notNull": true,
257+ "autoincrement": false
258+ },
259+ "row_id": {
260+ "name": "row_id",
261+ "type": "text",
262+ "primaryKey": true,
263+ "notNull": true,
264+ "autoincrement": false
265+ },
266+ "tenant": {
267+ "name": "tenant",
268+ "type": "text",
269+ "primaryKey": false,
270+ "notNull": true,
271+ "autoincrement": false
272+ },
273+ "owner": {
274+ "name": "owner",
275+ "type": "text",
276+ "primaryKey": false,
277+ "notNull": true,
278+ "autoincrement": false
279+ },
280+ "subject": {
281+ "name": "subject",
282+ "type": "text",
283+ "primaryKey": false,
284+ "notNull": true,
285+ "autoincrement": false
286+ }
287+ },
288+ "indexes": {
289+ "definition_uidx": {
290+ "name": "definition_uidx",
291+ "columns": ["tenant", "owner", "subject", "integration", "connection", "name"],
292+ "isUnique": true
293+ }
294+ },
295+ "foreignKeys": {},
296+ "compositePrimaryKeys": {},
297+ "uniqueConstraints": {},
298+ "checkConstraints": {}
299+ },
300+ "integration": {
301+ "name": "integration",
302+ "columns": {
303+ "slug": {
304+ "name": "slug",
305+ "type": "text",
306+ "primaryKey": false,
307+ "notNull": true,
308+ "autoincrement": false
309+ },
310+ "plugin_id": {
311+ "name": "plugin_id",
312+ "type": "text",
313+ "primaryKey": false,
314+ "notNull": true,
315+ "autoincrement": false
316+ },
317+ "description": {
318+ "name": "description",
319+ "type": "text",
320+ "primaryKey": false,
321+ "notNull": true,
322+ "autoincrement": false
323+ },
324+ "config": {
325+ "name": "config",
326+ "type": "text",
327+ "primaryKey": false,
328+ "notNull": false,
329+ "autoincrement": false
330+ },
331+ "can_remove": {
332+ "name": "can_remove",
333+ "type": "integer",
334+ "primaryKey": false,
335+ "notNull": true,
336+ "autoincrement": false,
337+ "default": 1
338+ },
339+ "can_refresh": {
340+ "name": "can_refresh",
341+ "type": "integer",
342+ "primaryKey": false,
343+ "notNull": true,
344+ "autoincrement": false,
345+ "default": 0
346+ },
347+ "created_at": {
348+ "name": "created_at",
349+ "type": "integer",
350+ "primaryKey": false,
351+ "notNull": true,
352+ "autoincrement": false
353+ },
354+ "updated_at": {
355+ "name": "updated_at",
356+ "type": "integer",
357+ "primaryKey": false,
358+ "notNull": true,
359+ "autoincrement": false
360+ },
361+ "row_id": {
362+ "name": "row_id",
363+ "type": "text",
364+ "primaryKey": true,
365+ "notNull": true,
366+ "autoincrement": false
367+ },
368+ "tenant": {
369+ "name": "tenant",
370+ "type": "text",
371+ "primaryKey": false,
372+ "notNull": true,
373+ "autoincrement": false
374+ }
375+ },
376+ "indexes": {
377+ "integration_uidx": {
378+ "name": "integration_uidx",
379+ "columns": ["tenant", "slug"],
380+ "isUnique": true
381+ }
382+ },
383+ "foreignKeys": {},
384+ "compositePrimaryKeys": {},
385+ "uniqueConstraints": {},
386+ "checkConstraints": {}
387+ },
388+ "oauth_client": {
389+ "name": "oauth_client",
390+ "columns": {
391+ "slug": {
392+ "name": "slug",
393+ "type": "text",
394+ "primaryKey": false,
395+ "notNull": true,
396+ "autoincrement": false
397+ },
398+ "authorization_url": {
399+ "name": "authorization_url",
400+ "type": "text",
401+ "primaryKey": false,
402+ "notNull": true,
403+ "autoincrement": false
404+ },
405+ "token_url": {
406+ "name": "token_url",
407+ "type": "text",
408+ "primaryKey": false,
409+ "notNull": true,
410+ "autoincrement": false
411+ },
412+ "grant": {
413+ "name": "grant",
414+ "type": "text",
415+ "primaryKey": false,
416+ "notNull": true,
417+ "autoincrement": false
418+ },
419+ "client_id": {
420+ "name": "client_id",
421+ "type": "text",
422+ "primaryKey": false,
423+ "notNull": true,
424+ "autoincrement": false
425+ },
426+ "client_secret_item_id": {
427+ "name": "client_secret_item_id",
428+ "type": "text",
429+ "primaryKey": false,
430+ "notNull": false,
431+ "autoincrement": false
432+ },
433+ "credential_write": {
434+ "name": "credential_write",
435+ "type": "text",
436+ "primaryKey": false,
437+ "notNull": false,
438+ "autoincrement": false
439+ },
440+ "token_endpoint_auth_method": {
441+ "name": "token_endpoint_auth_method",
442+ "type": "text",
443+ "primaryKey": false,
444+ "notNull": false,
445+ "autoincrement": false
446+ },
447+ "resource": {
448+ "name": "resource",
449+ "type": "text",
450+ "primaryKey": false,
451+ "notNull": false,
452+ "autoincrement": false
453+ },
454+ "origin_kind": {
455+ "name": "origin_kind",
456+ "type": "text",
457+ "primaryKey": false,
458+ "notNull": false,
459+ "autoincrement": false
460+ },
461+ "origin_integration": {
462+ "name": "origin_integration",
463+ "type": "text",
464+ "primaryKey": false,
465+ "notNull": false,
466+ "autoincrement": false
467+ },
468+ "origin_issuer": {
469+ "name": "origin_issuer",
470+ "type": "text",
471+ "primaryKey": false,
472+ "notNull": false,
473+ "autoincrement": false
474+ },
475+ "origin_redirect_uri": {
476+ "name": "origin_redirect_uri",
477+ "type": "text",
478+ "primaryKey": false,
479+ "notNull": false,
480+ "autoincrement": false
481+ },
482+ "created_at": {
483+ "name": "created_at",
484+ "type": "integer",
485+ "primaryKey": false,
486+ "notNull": true,
487+ "autoincrement": false
488+ },
489+ "row_id": {
490+ "name": "row_id",
491+ "type": "text",
492+ "primaryKey": true,
493+ "notNull": true,
494+ "autoincrement": false
495+ },
496+ "tenant": {
497+ "name": "tenant",
498+ "type": "text",
499+ "primaryKey": false,
500+ "notNull": true,
501+ "autoincrement": false
502+ },
503+ "owner": {
504+ "name": "owner",
505+ "type": "text",
506+ "primaryKey": false,
507+ "notNull": true,
508+ "autoincrement": false
509+ },
510+ "subject": {
511+ "name": "subject",
512+ "type": "text",
513+ "primaryKey": false,
514+ "notNull": true,
515+ "autoincrement": false
516+ }
517+ },
518+ "indexes": {
519+ "oauth_client_uidx": {
520+ "name": "oauth_client_uidx",
521+ "columns": ["tenant", "owner", "subject", "slug"],
522+ "isUnique": true
523+ }
524+ },
525+ "foreignKeys": {},
526+ "compositePrimaryKeys": {},
527+ "uniqueConstraints": {},
528+ "checkConstraints": {}
529+ },
530+ "oauth_session": {
531+ "name": "oauth_session",
532+ "columns": {
533+ "state": {
534+ "name": "state",
535+ "type": "text",
536+ "primaryKey": false,
537+ "notNull": true,
538+ "autoincrement": false
539+ },
540+ "client_slug": {
541+ "name": "client_slug",
542+ "type": "text",
543+ "primaryKey": false,
544+ "notNull": true,
545+ "autoincrement": false
546+ },
547+ "integration": {
548+ "name": "integration",
549+ "type": "text",
550+ "primaryKey": false,
551+ "notNull": true,
552+ "autoincrement": false
553+ },
554+ "name": {
555+ "name": "name",
556+ "type": "text",
557+ "primaryKey": false,
558+ "notNull": true,
559+ "autoincrement": false
560+ },
561+ "template": {
562+ "name": "template",
563+ "type": "text",
564+ "primaryKey": false,
565+ "notNull": true,
566+ "autoincrement": false
567+ },
568+ "redirect_url": {
569+ "name": "redirect_url",
570+ "type": "text",
571+ "primaryKey": false,
572+ "notNull": true,
573+ "autoincrement": false
574+ },
575+ "pkce_verifier": {
576+ "name": "pkce_verifier",
577+ "type": "text",
578+ "primaryKey": false,
579+ "notNull": false,
580+ "autoincrement": false
581+ },
582+ "identity_label": {
583+ "name": "identity_label",
584+ "type": "text",
585+ "primaryKey": false,
586+ "notNull": false,
587+ "autoincrement": false
588+ },
589+ "payload": {
590+ "name": "payload",
591+ "type": "text",
592+ "primaryKey": false,
593+ "notNull": true,
594+ "autoincrement": false
595+ },
596+ "expires_at": {
597+ "name": "expires_at",
598+ "type": "blob",
599+ "primaryKey": false,
600+ "notNull": true,
601+ "autoincrement": false
602+ },
603+ "created_at": {
604+ "name": "created_at",
605+ "type": "integer",
606+ "primaryKey": false,
607+ "notNull": true,
608+ "autoincrement": false
609+ },
610+ "row_id": {
611+ "name": "row_id",
612+ "type": "text",
613+ "primaryKey": true,
614+ "notNull": true,
615+ "autoincrement": false
616+ },
617+ "tenant": {
618+ "name": "tenant",
619+ "type": "text",
620+ "primaryKey": false,
621+ "notNull": true,
622+ "autoincrement": false
623+ },
624+ "owner": {
625+ "name": "owner",
626+ "type": "text",
627+ "primaryKey": false,
628+ "notNull": true,
629+ "autoincrement": false
630+ },
631+ "subject": {
632+ "name": "subject",
633+ "type": "text",
634+ "primaryKey": false,
635+ "notNull": true,
636+ "autoincrement": false
637+ }
638+ },
639+ "indexes": {
640+ "oauth_session_uidx": {
641+ "name": "oauth_session_uidx",
642+ "columns": ["tenant", "state"],
643+ "isUnique": true
644+ }
645+ },
646+ "foreignKeys": {},
647+ "compositePrimaryKeys": {},
648+ "uniqueConstraints": {},
649+ "checkConstraints": {}
650+ },
651+ "plugin_storage": {
652+ "name": "plugin_storage",
653+ "columns": {
654+ "plugin_id": {
655+ "name": "plugin_id",
656+ "type": "text",
657+ "primaryKey": false,
658+ "notNull": true,
659+ "autoincrement": false
660+ },
661+ "collection": {
662+ "name": "collection",
663+ "type": "text",
664+ "primaryKey": false,
665+ "notNull": true,
666+ "autoincrement": false
667+ },
668+ "key": {
669+ "name": "key",
670+ "type": "text",
671+ "primaryKey": false,
672+ "notNull": true,
673+ "autoincrement": false
674+ },
675+ "data": {
676+ "name": "data",
677+ "type": "text",
678+ "primaryKey": false,
679+ "notNull": true,
680+ "autoincrement": false
681+ },
682+ "created_at": {
683+ "name": "created_at",
684+ "type": "integer",
685+ "primaryKey": false,
686+ "notNull": true,
687+ "autoincrement": false
688+ },
689+ "updated_at": {
690+ "name": "updated_at",
691+ "type": "integer",
692+ "primaryKey": false,
693+ "notNull": true,
694+ "autoincrement": false
695+ },
696+ "row_id": {
697+ "name": "row_id",
698+ "type": "text",
699+ "primaryKey": true,
700+ "notNull": true,
701+ "autoincrement": false
702+ },
703+ "tenant": {
704+ "name": "tenant",
705+ "type": "text",
706+ "primaryKey": false,
707+ "notNull": true,
708+ "autoincrement": false
709+ },
710+ "owner": {
711+ "name": "owner",
712+ "type": "text",
713+ "primaryKey": false,
714+ "notNull": true,
715+ "autoincrement": false
716+ },
717+ "subject": {
718+ "name": "subject",
719+ "type": "text",
720+ "primaryKey": false,
721+ "notNull": true,
722+ "autoincrement": false
723+ }
724+ },
725+ "indexes": {
726+ "plugin_storage_uidx": {
727+ "name": "plugin_storage_uidx",
728+ "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"],
729+ "isUnique": true
730+ }
731+ },
732+ "foreignKeys": {},
733+ "compositePrimaryKeys": {},
734+ "uniqueConstraints": {},
735+ "checkConstraints": {}
736+ },
737+ "skill": {
738+ "name": "skill",
739+ "columns": {
740+ "name": {
741+ "name": "name",
742+ "type": "text",
743+ "primaryKey": false,
744+ "notNull": true,
745+ "autoincrement": false
746+ },
747+ "description": {
748+ "name": "description",
749+ "type": "text",
750+ "primaryKey": false,
751+ "notNull": true,
752+ "autoincrement": false
753+ },
754+ "frontmatter": {
755+ "name": "frontmatter",
756+ "type": "text",
757+ "primaryKey": false,
758+ "notNull": true,
759+ "autoincrement": false
760+ },
761+ "files": {
762+ "name": "files",
763+ "type": "text",
764+ "primaryKey": false,
765+ "notNull": true,
766+ "autoincrement": false
767+ },
768+ "created_at": {
769+ "name": "created_at",
770+ "type": "integer",
771+ "primaryKey": false,
772+ "notNull": true,
773+ "autoincrement": false
774+ },
775+ "updated_at": {
776+ "name": "updated_at",
777+ "type": "integer",
778+ "primaryKey": false,
779+ "notNull": true,
780+ "autoincrement": false
781+ },
782+ "row_id": {
783+ "name": "row_id",
784+ "type": "text",
785+ "primaryKey": true,
786+ "notNull": true,
787+ "autoincrement": false
788+ },
789+ "tenant": {
790+ "name": "tenant",
791+ "type": "text",
792+ "primaryKey": false,
793+ "notNull": true,
794+ "autoincrement": false
795+ },
796+ "owner": {
797+ "name": "owner",
798+ "type": "text",
799+ "primaryKey": false,
800+ "notNull": true,
801+ "autoincrement": false
802+ },
803+ "subject": {
804+ "name": "subject",
805+ "type": "text",
806+ "primaryKey": false,
807+ "notNull": true,
808+ "autoincrement": false
809+ }
810+ },
811+ "indexes": {
812+ "skill_uidx": {
813+ "name": "skill_uidx",
814+ "columns": ["tenant", "owner", "subject", "name"],
815+ "isUnique": true
816+ }
817+ },
818+ "foreignKeys": {},
819+ "compositePrimaryKeys": {},
820+ "uniqueConstraints": {},
821+ "checkConstraints": {}
822+ },
823+ "tool": {
824+ "name": "tool",
825+ "columns": {
826+ "integration": {
827+ "name": "integration",
828+ "type": "text",
829+ "primaryKey": false,
830+ "notNull": true,
831+ "autoincrement": false
832+ },
833+ "connection": {
834+ "name": "connection",
835+ "type": "text",
836+ "primaryKey": false,
837+ "notNull": true,
838+ "autoincrement": false
839+ },
840+ "plugin_id": {
841+ "name": "plugin_id",
842+ "type": "text",
843+ "primaryKey": false,
844+ "notNull": true,
845+ "autoincrement": false
846+ },
847+ "name": {
848+ "name": "name",
849+ "type": "text",
850+ "primaryKey": false,
851+ "notNull": true,
852+ "autoincrement": false
853+ },
854+ "description": {
855+ "name": "description",
856+ "type": "text",
857+ "primaryKey": false,
858+ "notNull": true,
859+ "autoincrement": false
860+ },
861+ "input_schema": {
862+ "name": "input_schema",
863+ "type": "text",
864+ "primaryKey": false,
865+ "notNull": false,
866+ "autoincrement": false
867+ },
868+ "output_schema": {
869+ "name": "output_schema",
870+ "type": "text",
871+ "primaryKey": false,
872+ "notNull": false,
873+ "autoincrement": false
874+ },
875+ "annotations": {
876+ "name": "annotations",
877+ "type": "text",
878+ "primaryKey": false,
879+ "notNull": false,
880+ "autoincrement": false
881+ },
882+ "created_at": {
883+ "name": "created_at",
884+ "type": "integer",
885+ "primaryKey": false,
886+ "notNull": true,
887+ "autoincrement": false
888+ },
889+ "updated_at": {
890+ "name": "updated_at",
891+ "type": "integer",
892+ "primaryKey": false,
893+ "notNull": true,
894+ "autoincrement": false
895+ },
896+ "row_id": {
897+ "name": "row_id",
898+ "type": "text",
899+ "primaryKey": true,
900+ "notNull": true,
901+ "autoincrement": false
902+ },
903+ "tenant": {
904+ "name": "tenant",
905+ "type": "text",
906+ "primaryKey": false,
907+ "notNull": true,
908+ "autoincrement": false
909+ },
910+ "owner": {
911+ "name": "owner",
912+ "type": "text",
913+ "primaryKey": false,
914+ "notNull": true,
915+ "autoincrement": false
916+ },
917+ "subject": {
918+ "name": "subject",
919+ "type": "text",
920+ "primaryKey": false,
921+ "notNull": true,
922+ "autoincrement": false
923+ }
924+ },
925+ "indexes": {
926+ "tool_uidx": {
927+ "name": "tool_uidx",
928+ "columns": ["tenant", "owner", "subject", "integration", "connection", "name"],
929+ "isUnique": true
930+ }
931+ },
932+ "foreignKeys": {},
933+ "compositePrimaryKeys": {},
934+ "uniqueConstraints": {},
935+ "checkConstraints": {}
936+ },
937+ "tool_policy": {
938+ "name": "tool_policy",
939+ "columns": {
940+ "id": {
941+ "name": "id",
942+ "type": "text",
943+ "primaryKey": false,
944+ "notNull": true,
945+ "autoincrement": false
946+ },
947+ "pattern": {
948+ "name": "pattern",
949+ "type": "text",
950+ "primaryKey": false,
951+ "notNull": true,
952+ "autoincrement": false
953+ },
954+ "action": {
955+ "name": "action",
956+ "type": "text",
957+ "primaryKey": false,
958+ "notNull": true,
959+ "autoincrement": false
960+ },
961+ "position": {
962+ "name": "position",
963+ "type": "text",
964+ "primaryKey": false,
965+ "notNull": true,
966+ "autoincrement": false
967+ },
968+ "created_at": {
969+ "name": "created_at",
970+ "type": "integer",
971+ "primaryKey": false,
972+ "notNull": true,
973+ "autoincrement": false
974+ },
975+ "updated_at": {
976+ "name": "updated_at",
977+ "type": "integer",
978+ "primaryKey": false,
979+ "notNull": true,
980+ "autoincrement": false
981+ },
982+ "row_id": {
983+ "name": "row_id",
984+ "type": "text",
985+ "primaryKey": true,
986+ "notNull": true,
987+ "autoincrement": false
988+ },
989+ "tenant": {
990+ "name": "tenant",
991+ "type": "text",
992+ "primaryKey": false,
993+ "notNull": true,
994+ "autoincrement": false
995+ },
996+ "owner": {
997+ "name": "owner",
998+ "type": "text",
999+ "primaryKey": false,
1000+ "notNull": true,
1001+ "autoincrement": false
1002+ },
1003+ "subject": {
1004+ "name": "subject",
1005+ "type": "text",
1006+ "primaryKey": false,
1007+ "notNull": true,
1008+ "autoincrement": false
1009+ }
1010+ },
1011+ "indexes": {
1012+ "tool_policy_uidx": {
1013+ "name": "tool_policy_uidx",
1014+ "columns": ["tenant", "owner", "subject", "id"],
1015+ "isUnique": true
1016+ }
1017+ },
1018+ "foreignKeys": {},
1019+ "compositePrimaryKeys": {},
1020+ "uniqueConstraints": {},
1021+ "checkConstraints": {}
1022+ }
1023+ },
1024+ "views": {},
1025+ "enums": {},
1026+ "_meta": {
1027+ "schemas": {},
1028+ "tables": {},
1029+ "columns": {}
1030+ },
1031+ "internal": {
1032+ "indexes": {}
1033+ }
1034+}
apps/local/drizzle/meta/_journal.json+7−0
@@ -50,6 +50,13 @@ · 49 unmodified lines
5050 "when": 1788255902609,
5151 "tag": "0006_bored_landau",
5252 "breakpoints": true
53+ },
54+ {
55+ "idx": 7,
56+ "version": "6",
57+ "when": 1789348020175,
58+ "tag": "0007_same_vengeance",
59+ "breakpoints": true
5360 }
5461 ]
5562 }
apps/local/src/db/executor-schema.ts+17−0
@@ -188,6 +188,23 @@ export const tool_policy = sqliteTable( · 187 unmodified lines
188188 ],
189189 );
190190
191+export const skill = sqliteTable(
192+ "skill",
193+ {
194+ name: text("name").notNull(),
195+ description: text("description").notNull(),
196+ frontmatter: text("frontmatter").notNull(),
197+ files: text("files").notNull(),
198+ created_at: integer("created_at").notNull(),
199+ updated_at: integer("updated_at").notNull(),
200+ row_id: text("row_id").primaryKey().notNull(),
201+ tenant: text("tenant").notNull(),
202+ owner: text("owner").notNull(),
203+ subject: text("subject").notNull(),
204+ },
205+ (table) => [uniqueIndex("skill_uidx").on(table.tenant, table.owner, table.subject, table.name)],
206+);
207+
191208 export const plugin_storage = sqliteTable(
192209 "plugin_storage",
193210 {
apps/local/src/main.ts+3−0
@@ -122,6 +122,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 121 unmodified lines
122122 defaultConfig: {
123123 engine,
124124 artifacts: executor.artifacts,
125+ skills: executor.skills,
125126 connections: executor.connections,
126127 ...appsConfig,
127128 },
@@ -131,6 +132,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines
131132 config: {
132133 engine,
133134 artifacts: executor.artifacts,
135+ skills: executor.skills,
134136 connections: executor.connections,
135137 ...appsConfig,
136138 },
@@ -156,6 +158,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 19 unmodified lines
156158 config: {
157159 engine: toolkitEngine,
158160 artifacts: handle.executor.artifacts,
161+ skills: handle.executor.skills,
159162 connections: handle.executor.connections,
160163 ...appsConfig,
161164 },
bun.lock+8−5
@@ -612,6 +612,7 @@ · 611 unmodified lines
612612 "fractional-indexing": "^3.2.0",
613613 "oauth4webapi": "^3.8.5",
614614 "tldts": "^7.0.28",
615+ "yaml": "^2.9.0",
615616 },
616617 "devDependencies": {
617618 "@effect/atom-react": "catalog:",
@@ -5827,7 +5828,7 @@ · 5209 unmodified lines
58275828
58285829 "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
58295830
5830− "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
5831+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
58315832
58325833 "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
58335834
@@ -5999,8 +6000,6 @@ · 165 unmodified lines
59996000
60006001 "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
60016002
6002− "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
6003−
60046003 "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
60056004
60066005 "@executor-js/example-promise-sdk/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
@@ -6113,6 +6112,8 @@ · 106 unmodified lines
61136112
61146113 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
61156114
6115+ "@opentelemetry/configuration/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
6116+
61166117 "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
61176118
61186119 "@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 @@ · 282 unmodified lines
64016402
64026403 "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=="],
64036404
6404− "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
6405−
64066405 "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=="],
64076406
64086407 "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 @@ · 94 unmodified lines
65036502
65046503 "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=="],
65056504
6505+ "effect/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
6506+
65066507 "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
65076508
65086509 "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 @@ · 88 unmodified lines
65976598
65986599 "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
65996600
6601+ "knip/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
6602+
66006603 "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
66016604
66026605 "macos-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
e2e/scenarios/skills.test.ts+142−0
@@ -0,0 +1,142 @@
1+// Cross-target: Agent Skills, end to end.
2+//
3+// A user saves a SKILL.md directory to their workspace through the console's
4+// API. The product promise under test is that every agent connected over MCP
5+// sees it next — in the `skills` tool's description (the catalog), in its index,
6+// and as loadable instructions with the bundled files reachable one at a time.
7+// Delete it, and the agent stops seeing it. The console and the agent share one
8+// store, not two caches.
9+import { randomBytes } from "node:crypto";
10+
11+import { expect } from "@effect/vitest";
12+import { Effect } from "effect";
13+import { composePluginApi } from "@executor-js/api/server";
14+import { SkillName } from "@executor-js/sdk/shared";
15+
16+import { scenario } from "../src/scenario";
17+import { Api, Mcp, Target } from "../src/services";
18+
19+const api = composePluginApi([] as const);
20+
21+/** Selfhost shares one workspace across scenarios, so every name is unique to
22+ * this run and assertions look for "mine", never "the only one". */
23+const uniqueSuffix = () => randomBytes(4).toString("hex");
24+
25+const skillMarkdown = (name: string, marker: string) =>
26+ [
27+ "---",
28+ `name: ${name}`,
29+ `description: Release-notes house style. Use when drafting release notes (${marker}).`,
30+ "metadata:",
31+ ' version: "1.0"',
32+ "---",
33+ "",
34+ "# Release notes",
35+ "",
36+ `Lead with the user-visible change. Marker: ${marker}.`,
37+ "See `references/tone.md` for the voice.",
38+ ].join("\n");
39+
40+scenario(
41+ "Skills · a skill saved to the workspace is what a connected agent loads next",
42+ { timeout: 120_000 },
43+ Effect.gen(function* () {
44+ const target = yield* Target;
45+ const mcp = yield* Mcp;
46+ const { client: apiClient } = yield* Api;
47+
48+ const identity = yield* target.newIdentity();
49+ const client = yield* apiClient(api, identity);
50+ const session = mcp.session(identity);
51+
52+ const suffix = uniqueSuffix();
53+ const name = SkillName.make(`release-notes-${suffix}`);
54+ const marker = `skill-ok-${suffix}`;
55+ const toneMarker = `tone-ok-${suffix}`;
56+
57+ yield* Effect.gen(function* () {
58+ // Save through the same endpoint the console's editor uses.
59+ const saved = yield* client.skills.save({
60+ payload: {
61+ owner: "user",
62+ files: [
63+ { path: "SKILL.md", content: skillMarkdown(name, marker) },
64+ { path: "references/tone.md", content: `# Tone\n\nPlain and direct. ${toneMarker}` },
65+ ],
66+ },
67+ });
68+ expect(saved.name, "the name comes from the frontmatter").toBe(name);
69+ expect(
70+ saved.files.map((file) => file.path),
71+ "SKILL.md leads the stored manifest",
72+ ).toEqual(["SKILL.md", "references/tone.md"]);
73+ for (const file of saved.files) {
74+ expect(file.digest, `${file.path} carries a sha256 digest`).toMatch(
75+ /^sha256:[0-9a-f]{64}$/,
76+ );
77+ }
78+
79+ // A fresh MCP session sees the skill in the tool's own description — the
80+ // catalog a model reads before it asks for anything.
81+ const tools = yield* session.describeTools();
82+ const skillsTool = tools.find((tool) => tool.name === "skills");
83+ expect(skillsTool?.description, "the skills tool advertises the saved skill").toContain(
84+ `\`${name}\``,
85+ );
86+
87+ const index = yield* session.call("skills", {});
88+ expect(index.text, "the index lists the saved skill next to Executor's docs").toContain(
89+ `\`${name}\``,
90+ );
91+ expect(index.text).toContain("`execute`");
92+
93+ const loaded = yield* session.call("skills", { name });
94+ expect(loaded.ok, `loading the skill succeeds: ${loaded.text}`).toBe(true);
95+ expect(loaded.text, "the body is served with its frontmatter stripped").toContain(
96+ `Marker: ${marker}`,
97+ );
98+ expect(loaded.text).not.toContain("description: Release-notes");
99+ expect(loaded.text, "bundled files are listed, not inlined").toContain(
100+ "<file>references/tone.md</file>",
101+ );
102+ expect(loaded.text).not.toContain(toneMarker);
103+
104+ const tone = yield* session.call("skills", { name, file: "references/tone.md" });
105+ expect(tone.ok, `reading a bundled file succeeds: ${tone.text}`).toBe(true);
106+ expect(tone.text, "the bundled file's content is returned verbatim").toContain(toneMarker);
107+
108+ // Delete in the console; the agent's next call misses.
109+ yield* client.skills.remove({ params: { owner: "user", name } });
110+ const gone = yield* session.call("skills", { name });
111+ expect(gone.ok, "a removed skill no longer loads").toBe(false);
112+ expect(gone.text).toContain(`No skill named "${name}"`);
113+ }).pipe(
114+ Effect.ensuring(
115+ client.skills.remove({ params: { owner: "user", name } }).pipe(Effect.ignore),
116+ ),
117+ );
118+ }),
119+);
120+
121+scenario(
122+ "Skills · a malformed SKILL.md is refused with the reason",
123+ {},
124+ Effect.gen(function* () {
125+ const target = yield* Target;
126+ const { client: apiClient } = yield* Api;
127+ const identity = yield* target.newIdentity();
128+ const client = yield* apiClient(api, identity);
129+
130+ const result = yield* client.skills
131+ .save({
132+ payload: {
133+ owner: "user",
134+ files: [{ path: "SKILL.md", content: "---\nname: Not Valid\ndescription: x\n---\n" }],
135+ },
136+ })
137+ .pipe(Effect.result);
138+ expect(result._tag, "the save is refused").toBe("Failure");
139+ if (result._tag !== "Failure") return;
140+ expect(String(result.failure._tag), "as an invalid-skill error").toBe("InvalidSkillError");
141+ }),
142+);
packages/app/src/routeTree.gen.ts+83−0
@@ -12,15 +12,18 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index'
1313 import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools'
1414 import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits'
15+import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport } from './../../react/src/routes/skills'
1516 import { Route as SecretsRouteImport } from './routes/app/secrets'
1617 import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies'
1718 import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts'
1819 import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
20+import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport } from './../../react/src/routes/skills.new'
1921 import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
2022 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse'
2123 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
2224 import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
2325 import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId'
26+import { Route as DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport } from './../../react/src/routes/skills.$skillOwner.$skillName'
2427 import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
2528 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'
2629
@@ -42,6 +45,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute = · 15 unmodified lines
4245 path: '/{-$orgSlug}/toolkits',
4346 getParentRoute: () => rootRouteImport,
4447 } as any)
48+const DotDotDotDotDotDotReactSrcRoutesSkillsRoute =
49+ DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport.update({
50+ id: '/{-$orgSlug}/skills',
51+ path: '/{-$orgSlug}/skills',
52+ getParentRoute: () => rootRouteImport,
53+ } as any)
4554 const SecretsRoute = SecretsRouteImport.update({
4655 id: '/{-$orgSlug}/secrets',
4756 path: '/{-$orgSlug}/secrets',
@@ -65,6 +74,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = · 17 unmodified lines
6574 path: '/$toolkitSlug',
6675 getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute,
6776 } as any)
77+const DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute =
78+ DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport.update({
79+ id: '/new',
80+ path: '/new',
81+ getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsRoute,
82+ } as any)
6883 const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute =
6984 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({
7085 id: '/{-$orgSlug}/resume/$executionId',
@@ -95,6 +110,14 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute = · 24 unmodified lines
95110 path: '/$artifactId',
96111 getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesArtifactsRoute,
97112 } as any)
113+const DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute =
114+ DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport.update(
115+ {
116+ id: '/$skillOwner/$skillName',
117+ path: '/$skillOwner/$skillName',
118+ getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesSkillsRoute,
119+ } as any,
120+ )
98121 const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
99122 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({
100123 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -114,6 +137,7 @@ export interface FileRoutesByFullPath { · 13 unmodified lines
114137 '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren
115138 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
116139 '/{-$orgSlug}/secrets': typeof SecretsRoute
140+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren
117141 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
118142 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
119143 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
@@ -122,14 +146,17 @@ export interface FileRoutesByFullPath { · 2 unmodified lines
122146 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
123147 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
124148 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
149+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute
125150 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
126151 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
127152 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
153+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
128154 }
129155 export interface FileRoutesByTo {
130156 '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren
131157 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
132158 '/{-$orgSlug}/secrets': typeof SecretsRoute
159+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren
133160 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
134161 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
135162 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
@@ -138,15 +165,18 @@ export interface FileRoutesByTo { · 2 unmodified lines
138165 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
139166 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
140167 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
168+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute
141169 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
142170 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
143171 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
172+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
144173 }
145174 export interface FileRoutesById {
146175 __root__: typeof rootRouteImport
147176 '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren
148177 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
149178 '/{-$orgSlug}/secrets': typeof SecretsRoute
179+ '/{-$orgSlug}/skills': typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren
150180 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
151181 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
152182 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
@@ -155,9 +185,11 @@ export interface FileRoutesById { · 2 unmodified lines
155185 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
156186 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
157187 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
188+ '/{-$orgSlug}/skills/new': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute
158189 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
159190 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
160191 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
192+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
161193 }
162194 export interface FileRouteTypes {
163195 fileRoutesByFullPath: FileRoutesByFullPath
@@ -165,6 +197,7 @@ export interface FileRouteTypes { · 1 unmodified line
165197 | '/{-$orgSlug}/artifacts'
166198 | '/{-$orgSlug}/policies'
167199 | '/{-$orgSlug}/secrets'
200+ | '/{-$orgSlug}/skills'
168201 | '/{-$orgSlug}/toolkits'
169202 | '/{-$orgSlug}/tools'
170203 | '/{-$orgSlug}/'
@@ -173,14 +206,17 @@ export interface FileRouteTypes { · 2 unmodified lines
173206 | '/{-$orgSlug}/integrations/$namespace'
174207 | '/{-$orgSlug}/integrations/browse'
175208 | '/{-$orgSlug}/resume/$executionId'
209+ | '/{-$orgSlug}/skills/new'
176210 | '/{-$orgSlug}/toolkits/$toolkitSlug'
177211 | '/{-$orgSlug}/integrations/add/$pluginKey'
178212 | '/{-$orgSlug}/plugins/$pluginId/$'
213+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
179214 fileRoutesByTo: FileRoutesByTo
180215 to:
181216 | '/{-$orgSlug}/artifacts'
182217 | '/{-$orgSlug}/policies'
183218 | '/{-$orgSlug}/secrets'
219+ | '/{-$orgSlug}/skills'
184220 | '/{-$orgSlug}/toolkits'
185221 | '/{-$orgSlug}/tools'
186222 | '/{-$orgSlug}'
@@ -189,14 +225,17 @@ export interface FileRouteTypes { · 2 unmodified lines
189225 | '/{-$orgSlug}/integrations/$namespace'
190226 | '/{-$orgSlug}/integrations/browse'
191227 | '/{-$orgSlug}/resume/$executionId'
228+ | '/{-$orgSlug}/skills/new'
192229 | '/{-$orgSlug}/toolkits/$toolkitSlug'
193230 | '/{-$orgSlug}/integrations/add/$pluginKey'
194231 | '/{-$orgSlug}/plugins/$pluginId/$'
232+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
195233 id:
196234 | '__root__'
197235 | '/{-$orgSlug}/artifacts'
198236 | '/{-$orgSlug}/policies'
199237 | '/{-$orgSlug}/secrets'
238+ | '/{-$orgSlug}/skills'
200239 | '/{-$orgSlug}/toolkits'
201240 | '/{-$orgSlug}/tools'
202241 | '/{-$orgSlug}/'
@@ -205,15 +244,18 @@ export interface FileRouteTypes { · 2 unmodified lines
205244 | '/{-$orgSlug}/integrations/$namespace'
206245 | '/{-$orgSlug}/integrations/browse'
207246 | '/{-$orgSlug}/resume/$executionId'
247+ | '/{-$orgSlug}/skills/new'
208248 | '/{-$orgSlug}/toolkits/$toolkitSlug'
209249 | '/{-$orgSlug}/integrations/add/$pluginKey'
210250 | '/{-$orgSlug}/plugins/$pluginId/$'
251+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
211252 fileRoutesById: FileRoutesById
212253 }
213254 export interface RootRouteChildren {
214255 DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren
215256 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
216257 SecretsRoute: typeof SecretsRoute
258+ DotDotDotDotDotDotReactSrcRoutesSkillsRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren
217259 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
218260 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
219261 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
@@ -248,6 +290,13 @@ declare module '@tanstack/react-router' { · 28 unmodified lines
248290 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport
249291 parentRoute: typeof rootRouteImport
250292 }
293+ '/{-$orgSlug}/skills': {
294+ id: '/{-$orgSlug}/skills'
295+ path: '/{-$orgSlug}/skills'
296+ fullPath: '/{-$orgSlug}/skills'
297+ preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRouteImport
298+ parentRoute: typeof rootRouteImport
299+ }
251300 '/{-$orgSlug}/secrets': {
252301 id: '/{-$orgSlug}/secrets'
253302 path: '/{-$orgSlug}/secrets'
@@ -276,6 +325,13 @@ declare module '@tanstack/react-router' { · 22 unmodified lines
276325 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport
277326 parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRoute
278327 }
328+ '/{-$orgSlug}/skills/new': {
329+ id: '/{-$orgSlug}/skills/new'
330+ path: '/new'
331+ fullPath: '/{-$orgSlug}/skills/new'
332+ preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRouteImport
333+ parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRoute
334+ }
279335 '/{-$orgSlug}/resume/$executionId': {
280336 id: '/{-$orgSlug}/resume/$executionId'
281337 path: '/{-$orgSlug}/resume/$executionId'
@@ -311,6 +367,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
311367 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport
312368 parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRoute
313369 }
370+ '/{-$orgSlug}/skills/$skillOwner/$skillName': {
371+ id: '/{-$orgSlug}/skills/$skillOwner/$skillName'
372+ path: '/$skillOwner/$skillName'
373+ fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName'
374+ preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRouteImport
375+ parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsRoute
376+ }
314377 '/{-$orgSlug}/plugins/$pluginId/$': {
315378 id: '/{-$orgSlug}/plugins/$pluginId/$'
316379 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -343,6 +406,24 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren = · 26 unmodified lines
343406 DotDotDotDotDotDotReactSrcRoutesArtifactsRouteChildren,
344407 )
345408
409+interface DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren {
410+ DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute
411+ DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute: typeof DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute
412+}
413+
414+const DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren: DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren =
415+ {
416+ DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute:
417+ DotDotDotDotDotDotReactSrcRoutesSkillsDotnewRoute,
418+ DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute:
419+ DotDotDotDotDotDotReactSrcRoutesSkillsDotskillOwnerDotskillNameRoute,
420+ }
421+
422+const DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren =
423+ DotDotDotDotDotDotReactSrcRoutesSkillsRoute._addFileChildren(
424+ DotDotDotDotDotDotReactSrcRoutesSkillsRouteChildren,
425+ )
426+
346427 interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren {
347428 DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
348429 }
@@ -364,6 +445,8 @@ const rootRouteChildren: RootRouteChildren = { · 15 unmodified lines
364445 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute:
365446 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute,
366447 SecretsRoute: SecretsRoute,
448+ DotDotDotDotDotDotReactSrcRoutesSkillsRoute:
449+ DotDotDotDotDotDotReactSrcRoutesSkillsRouteWithChildren,
367450 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute:
368451 DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren,
369452 DotDotDotDotDotDotReactSrcRoutesToolsRoute:
packages/app/src/web/shell.tsx+7−0
@@ -177,6 +177,7 @@ function SidebarContent(props: { · 176 unmodified lines
177177 const isPolicies = props.pathname === "/policies";
178178 const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/");
179179 const isArtifacts = props.pathname === "/artifacts" || props.pathname.startsWith("/artifacts/");
180+ const isSkills = props.pathname === "/skills" || props.pathname.startsWith("/skills/");
180181
181182 return (
182183 <>
@@ -216,6 +217,12 @@ function SidebarContent(props: { · 33 unmodified lines
216217 active={isToolkits}
217218 onNavigate={props.onNavigate}
218219 />
220+ <NavItem
221+ to="/{-$orgSlug}/skills"
222+ label="Skills"
223+ active={isSkills}
224+ onNavigate={props.onNavigate}
225+ />
219226 <NavItem
220227 to="/{-$orgSlug}/artifacts"
221228 label="Artifacts"
packages/core/api/src/account/org-slug.ts+2−1
@@ -18,7 +18,7 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; · 17 unmodified lines
1818 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost
1919 * envelope, cloudflare `run_worker_first`)
2020 * - Console routes: connect, integrations, policies, secrets, tools, users,
21− * toolkits, artifacts, resume, plugins (the shared
21+ * toolkits, artifacts, skills, resume, plugins (the shared
2222 * contract, i.e. every entry in `CONSOLE_ROUTE_PATHS`),
2323 * plus host extras:
2424 * api-keys, org, billing, create-org, setup-mcp (cloud),
@@ -50,6 +50,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 25 unmodified lines
5050 "tools",
5151 "toolkits",
5252 "artifacts",
53+ "skills",
5354 "resume",
5455 "plugins",
5556 "api-keys",
packages/core/api/src/api.ts+2−0
@@ -9,6 +9,7 @@ import { ExecutionsApi } from "./executions/api"; · 8 unmodified lines
99 import { OAuthApi } from "./oauth/api";
1010 import { PoliciesApi } from "./policies/api";
1111 import { ArtifactsApi } from "./artifacts/api";
12+import { SkillsApi } from "./skills/api";
1213
1314 export const CoreExecutorApi = HttpApi.make("executor")
1415 .add(ToolsApi)
@@ -19,6 +20,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 4 unmodified lines
1920 .add(OAuthApi)
2021 .add(PoliciesApi)
2122 .add(ArtifactsApi)
23+ .add(SkillsApi)
2224 .annotateMerge(
2325 OpenApi.annotations({
2426 title: "Executor API",
packages/core/api/src/handlers/index.ts+3−0
@@ -8,6 +8,7 @@ import { ExecutionsHandlers } from "./executions"; · 7 unmodified lines
88 import { OAuthHandlers } from "./oauth";
99 import { PoliciesHandlers } from "./policies";
1010 import { ArtifactsHandlers } from "./artifacts";
11+import { SkillsHandlers } from "./skills";
1112
1213 export { ToolsHandlers } from "./tools";
1314 export { IntegrationsHandlers } from "./integrations";
@@ -17,6 +18,7 @@ export { ExecutionsHandlers } from "./executions"; · 3 unmodified lines
1718 export { OAuthHandlers } from "./oauth";
1819 export { PoliciesHandlers } from "./policies";
1920 export { ArtifactsHandlers } from "./artifacts";
21+export { SkillsHandlers } from "./skills";
2022
2123 export const CoreHandlers = Layer.mergeAll(
2224 ToolsHandlers,
@@ -27,4 +29,5 @@ export const CoreHandlers = Layer.mergeAll( · 4 unmodified lines
2729 OAuthHandlers,
2830 PoliciesHandlers,
2931 ArtifactsHandlers,
32+ SkillsHandlers,
3033 );
packages/core/api/src/handlers/skills.ts+60−0
@@ -0,0 +1,60 @@
1+import { HttpApiBuilder } from "effect/unstable/httpapi";
2+import { Effect } from "effect";
3+import type { Skill, SkillSummary } from "@executor-js/sdk";
4+
5+import { ExecutorApi } from "../api";
6+import { ExecutorService } from "../services";
7+import { capture } from "@executor-js/api";
8+
9+const summaryToResponse = (skill: SkillSummary) => ({
10+ owner: skill.owner,
11+ name: skill.name,
12+ description: skill.description,
13+ frontmatter: skill.frontmatter,
14+ files: skill.files,
15+ createdAt: skill.createdAt.getTime(),
16+ updatedAt: skill.updatedAt.getTime(),
17+});
18+
19+const skillToResponse = (skill: Skill) => ({
20+ ...summaryToResponse(skill),
21+ files: skill.files,
22+});
23+
24+export const SkillsHandlers = HttpApiBuilder.group(ExecutorApi, "skills", (handlers) =>
25+ handlers
26+ .handle("list", () =>
27+ capture(
28+ Effect.gen(function* () {
29+ const executor = yield* ExecutorService;
30+ const skills = yield* executor.skills.list();
31+ return skills.map(summaryToResponse);
32+ }),
33+ ),
34+ )
35+ .handle("get", ({ params }) =>
36+ capture(
37+ Effect.gen(function* () {
38+ const executor = yield* ExecutorService;
39+ return skillToResponse(yield* executor.skills.get(params));
40+ }),
41+ ),
42+ )
43+ .handle("save", ({ payload }) =>
44+ capture(
45+ Effect.gen(function* () {
46+ const executor = yield* ExecutorService;
47+ return skillToResponse(yield* executor.skills.save(payload));
48+ }),
49+ ),
50+ )
51+ .handle("remove", ({ params }) =>
52+ capture(
53+ Effect.gen(function* () {
54+ const executor = yield* ExecutorService;
55+ yield* executor.skills.remove(params);
56+ return { removed: true };
57+ }),
58+ ),
59+ ),
60+);
packages/core/api/src/index.ts+1−0
@@ -38,6 +38,7 @@ export { · 37 unmodified lines
3838 } from "./oauth-popup";
3939 export { PoliciesApi } from "./policies/api";
4040 export { ArtifactsApi } from "./artifacts/api";
41+export { SkillsApi } from "./skills/api";
4142 export {
4243 AccountApi,
4344 AccountHttpApi,
packages/core/api/src/server/mcp-build.ts+1−0
@@ -70,6 +70,7 @@ export const makeMcpBuildServer = · 69 unmodified lines
7070 createExecutorMcpServer({
7171 engine,
7272 artifacts: executor.artifacts,
73+ skills: executor.skills,
7374 connections: executor.connections,
7475 ...(hostOptions?.loadAppShellHtml
7576 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
packages/core/api/src/skills/api.ts+88−0
@@ -0,0 +1,88 @@
1+// ---------------------------------------------------------------------------
2+// Skills HTTP API — Agent Skills (SKILL.md directories) saved to the workspace.
3+//
4+// A skill is identified by `(owner, name)`: `org` skills are shared with the
5+// whole workspace, `user` skills are personal. The name comes from the SKILL.md
6+// frontmatter, so `save` takes only the owner and the files. Reads return what
7+// the bound owner scope may see, exactly like connections.
8+// ---------------------------------------------------------------------------
9+
10+import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
11+import { Schema } from "effect";
12+import {
13+ InternalError,
14+ InvalidSkillError,
15+ OrgWriteDeniedError,
16+ Owner,
17+ SkillName,
18+ SkillNotFoundError,
19+} from "@executor-js/sdk/shared";
20+
21+const SkillParams = { owner: Owner, name: SkillName };
22+
23+const SkillFileEntryResponse = Schema.Struct({
24+ path: Schema.String,
25+ size: Schema.Number,
26+ digest: Schema.String,
27+});
28+
29+const SkillFileResponse = Schema.Struct({
30+ ...SkillFileEntryResponse.fields,
31+ content: Schema.String,
32+});
33+
34+/** What a list returns: the manifest without file contents. */
35+export const SkillSummaryResponse = Schema.Struct({
36+ owner: Owner,
37+ name: SkillName,
38+ description: Schema.String,
39+ frontmatter: Schema.Record(Schema.String, Schema.Unknown),
40+ files: Schema.Array(SkillFileEntryResponse),
41+ createdAt: Schema.Number,
42+ updatedAt: Schema.Number,
43+});
44+
45+export const SkillResponse = Schema.Struct({
46+ ...SkillSummaryResponse.fields,
47+ files: Schema.Array(SkillFileResponse),
48+});
49+
50+const SkillFileInputSchema = Schema.Struct({
51+ path: Schema.String,
52+ content: Schema.String,
53+});
54+
55+/** Create or replace: the skill's name is read from `SKILL.md`. */
56+const SaveSkillPayload = Schema.Struct({
57+ owner: Owner,
58+ files: Schema.Array(SkillFileInputSchema),
59+});
60+
61+export const SkillsApi = HttpApiGroup.make("skills")
62+ .add(
63+ HttpApiEndpoint.get("list", "/skills", {
64+ success: Schema.Array(SkillSummaryResponse),
65+ error: InternalError,
66+ }),
67+ )
68+ .add(
69+ HttpApiEndpoint.get("get", "/skills/:owner/:name", {
70+ params: SkillParams,
71+ success: SkillResponse,
72+ error: [InternalError, SkillNotFoundError],
73+ }),
74+ )
75+ .add(
76+ HttpApiEndpoint.put("save", "/skills", {
77+ payload: SaveSkillPayload,
78+ success: SkillResponse,
79+ error: [InternalError, InvalidSkillError, OrgWriteDeniedError],
80+ }),
81+ )
82+ .add(
83+ HttpApiEndpoint.delete("remove", "/skills/:owner/:name", {
84+ params: SkillParams,
85+ success: Schema.Struct({ removed: Schema.Boolean }),
86+ error: [InternalError, OrgWriteDeniedError],
87+ }),
88+ );
packages/core/sdk/package.json+2−1
@@ -104,7 +104,8 @@ · 103 unmodified lines
104104 "@standard-schema/spec": "^1.1.0",
105105 "fractional-indexing": "^3.2.0",
106106 "oauth4webapi": "^3.8.5",
107− "tldts": "^7.0.28"
107+ "tldts": "^7.0.28",
108+ "yaml": "^2.9.0"
108109 },
109110 "devDependencies": {
110111 "@effect/atom-react": "catalog:",
packages/core/sdk/src/core-schema.ts+34−0
@@ -416,6 +416,29 @@ export const coreTables = defineTables({ · 415 unmodified lines
416416 ["tenant", "owner", "subject", "id"],
417417 ),
418418
419+ // An Agent Skill (https://agentskills.io) saved to the workspace: a SKILL.md
420+ // plus any bundled files, served to every connected agent through the MCP
421+ // `skills` tool and the MCP Skills Extension. Owner-scoped like a connection:
422+ // `org` rows are shared with the whole workspace, `user` rows are personal.
423+ skill: ownedExecutorTable(
424+ "skill",
425+ {
426+ // The frontmatter `name`; also the identity within the owner partition.
427+ name: keyColumn("name"),
428+ // The frontmatter `description`, denormalized so lists never parse files.
429+ description: textColumn("description"),
430+ // The SKILL.md frontmatter, verbatim, as a JSON object. The MCP Skills
431+ // Extension serves it field-for-field, unknown keys included.
432+ frontmatter: jsonColumn("frontmatter"),
433+ // Every file of the skill, SKILL.md included:
434+ // `[{ path, content, size, digest }]`. Text only in v1.
435+ files: jsonColumn("files"),
436+ created_at: dateColumn("created_at"),
437+ updated_at: dateColumn("updated_at"),
438+ },
439+ ["tenant", "owner", "subject", "name"],
440+ ),
441+
419442 // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped).
420443 plugin_storage: ownedExecutorTable(
421444 "plugin_storage",
@@ -492,6 +515,17 @@ export const ARTIFACT_SUMMARY_COLUMNS = [ · 70 unmodified lines
492515 ] as const satisfies readonly (keyof ArtifactRow)[];
493516 /** The artifact-row projection {@link ARTIFACT_SUMMARY_COLUMNS} selects. */
494517 export type ArtifactSummaryRow = Pick<ArtifactRow, (typeof ARTIFACT_SUMMARY_COLUMNS)[number]>;
518+export type SkillRow = FumaRow<CoreSchema["skill"]>;
519+/** The skill-row projection lists select: everything but the file contents. */
520+export const SKILL_SUMMARY_COLUMNS = [
521+ "owner",
522+ "name",
523+ "description",
524+ "frontmatter",
525+ "files",
526+ "created_at",
527+ "updated_at",
528+] as const satisfies readonly (keyof SkillRow)[];
495529 export type PluginStorageRow = FumaRow<CoreSchema["plugin_storage"]>;
496530 export type BlobRow = FumaRow<CoreSchema["blob"]>;
497531
packages/core/sdk/src/errors.ts+40−1
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 IntegrationSlug,
99 Owner,
1010 ProviderKey,
11+ SkillName,
1112 ToolAddress,
1213 } from "./ids";
1314
@@ -279,6 +280,42 @@ export class ArtifactNotFoundError extends Schema.TaggedErrorClass<ArtifactNotFo · 265 unmodified lines
279280 }
280281 }
281282
283+// ---------------------------------------------------------------------------
284+// Skills
285+// ---------------------------------------------------------------------------
286+
287+/** No skill with this owner + name is visible to the bound owner scope. */
288+export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()(
289+ "SkillNotFoundError",
290+ { owner: Owner, name: SkillName },
291+ { httpApiStatus: 404 },
292+) {
293+ override get message(): string {
294+ return `Skill not found: ${this.owner}/${this.name}`;
295+ }
296+}
297+
298+/** The uploaded skill does not conform to the Agent Skills specification (bad
299+ * frontmatter, an invalid name, a missing SKILL.md, a file path outside the
300+ * skill, or a size over the limit). `reason` is written for the author. */
301+export class InvalidSkillError
302+ extends Schema.TaggedErrorClass<InvalidSkillError>()(
303+ "InvalidSkillError",
304+ { reason: Schema.String },
305+ { httpApiStatus: 400 },
306+ )
307+ implements UserActionableError
308+{
309+ readonly __executorUserActionable = true;
310+ readonly code = "invalid_skill";
311+ get userMessage(): string {
312+ return this.reason;
313+ }
314+ override get message(): string {
315+ return `Invalid skill: ${this.reason}`;
316+ }
317+}
318+
282319 // ---------------------------------------------------------------------------
283320 // Union — the failure channel of `execute`.
284321 // ---------------------------------------------------------------------------
@@ -300,4 +337,6 @@ export type ExecutorError = · 15 unmodified lines
300337 | ExecuteError
301338 | IntegrationNotFoundError
302339 | IntegrationRemovalNotAllowedError
303− | ArtifactNotFoundError;
340+ | ArtifactNotFoundError
341+ | SkillNotFoundError
342+ | InvalidSkillError;
packages/core/sdk/src/executor.ts+114−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 Option,
1111 Predicate,
1212 Ref,
13+ Result,
1314 Schema,
1415 Semaphore,
1516 } from "effect";
@@ -97,6 +98,17 @@ import { · 81 unmodified lines
9798 type SetArtifactPreviewInput,
9899 } from "./artifact";
99100 import {
101+ rowToSkill,
102+ toSkillSummary,
103+ prepareSkillFiles,
104+ type SaveSkillInput,
105+ type Skill,
106+ type SkillRef,
107+ type SkillSummary,
108+} from "./skill";
109+import {
110+ SkillNotFoundError,
111+ InvalidSkillError,
100112 ArtifactNotFoundError,
101113 ConnectionAlreadyExistsError,
102114 ConnectionNotFoundError,
@@ -115,6 +127,7 @@ import { · 12 unmodified lines
115127 } from "./errors";
116128 import {
117129 ArtifactId,
130+ SkillName,
118131 AuthTemplateSlug,
119132 ConnectionAddress,
120133 ConnectionName,
@@ -518,6 +531,22 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 397 unmodified lines
518531 ) => Effect.Effect<void, ArtifactNotFoundError | StorageFailure>;
519532 };
520533
534+ /**
535+ * Agent Skills saved to the workspace, visible to the bound owner scope: this
536+ * subject's personal skills plus the org's shared ones.
537+ */
538+ readonly skills: {
539+ /** Newest first, manifests only — file contents stay out of lists. */
540+ readonly list: () => Effect.Effect<readonly SkillSummary[], StorageFailure>;
541+ readonly get: (ref: SkillRef) => Effect.Effect<Skill, SkillNotFoundError | StorageFailure>;
542+ /** Create, or replace in place the skill with the same `(owner, name)`.
543+ * The name is read from the SKILL.md frontmatter. */
544+ readonly save: (
545+ input: SaveSkillInput,
546+ ) => Effect.Effect<Skill, InvalidSkillError | OrgWriteDeniedError | StorageFailure>;
547+ readonly remove: (ref: SkillRef) => Effect.Effect<void, OrgWriteDeniedError | StorageFailure>;
548+ };
549+
521550 /**
522551 * Approvals recorded for artifact-originated calls that paused on a human.
523552 *
@@ -6118,6 +6147,85 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5594 unmodified lines
61186147 const artifactsRemove = (input: RemoveArtifactInput): Effect.Effect<void, StorageFailure> =>
61196148 core.deleteMany("artifact", { where: artifactById(input.id) });
61206149
6150+ // ------------------------------------------------------------------
6151+ // Skills
6152+ // ------------------------------------------------------------------
6153+
6154+ // Reads take no explicit owner beyond the ref: the owner policy already
6155+ // narrows to the rows this binding may see (org rows plus this subject's
6156+ // own), so `(owner, name)` is enough to pin one visible row.
6157+ const skillByRef =
6158+ (ref: SkillRef): CoreWhere =>
6159+ (b: AnyCb) =>
6160+ b.and(b("owner", "=", ref.owner), b("name", "=", ref.name));
6161+
6162+ const skillsList = (): Effect.Effect<readonly SkillSummary[], StorageFailure> =>
6163+ core
6164+ .findMany("skill", {
6165+ orderBy: [
6166+ ["updated_at", "desc"],
6167+ ["name", "asc"],
6168+ ],
6169+ })
6170+ .pipe(Effect.map((rows) => rows.map((row) => toSkillSummary(rowToSkill(row)))));
6171+
6172+ const skillsGet = (ref: SkillRef): Effect.Effect<Skill, SkillNotFoundError | StorageFailure> =>
6173+ Effect.gen(function* () {
6174+ const row = yield* core.findFirst("skill", { where: skillByRef(ref) });
6175+ if (!row) {
6176+ return yield* new SkillNotFoundError({
6177+ owner: ref.owner,
6178+ name: SkillName.make(ref.name),
6179+ });
6180+ }
6181+ return rowToSkill(row);
6182+ });
6183+
6184+ const skillsSave = (
6185+ input: SaveSkillInput,
6186+ ): Effect.Effect<Skill, InvalidSkillError | OrgWriteDeniedError | StorageFailure> =>
6187+ Effect.gen(function* () {
6188+ yield* guardOrgWrite(input.owner);
6189+ yield* requireUserSubject(input.owner);
6190+ const prepared = yield* Effect.promise(() => prepareSkillFiles(input.files));
6191+ if (Result.isFailure(prepared)) return yield* prepared.failure;
6192+ const { parsed, files } = prepared.success;
6193+ const now = new Date();
6194+ const ref: SkillRef = { owner: input.owner, name: parsed.name };
6195+ const set = {
6196+ description: parsed.description,
6197+ frontmatter: parsed.frontmatter,
6198+ files,
6199+ updated_at: now,
6200+ };
6201+ const existing = yield* core.findFirst("skill", { where: skillByRef(ref) });
6202+ if (existing) {
6203+ yield* core.updateMany("skill", { where: skillByRef(ref), set });
6204+ return rowToSkill({ ...existing, ...set });
6205+ }
6206+ const keys = yield* Effect.try({
6207+ try: () => ownedKeys(input.owner),
6208+ catch: (cause) => storageFailureFromUnknown("invalid owner", cause),
6209+ });
6210+ const created = yield* core.create("skill", {
6211+ tenant: keys.tenant,
6212+ owner: keys.owner,
6213+ subject: keys.subject,
6214+ name: parsed.name,
6215+ ...set,
6216+ created_at: now,
6217+ });
6218+ return rowToSkill(created);
6219+ });
6220+
6221+ const skillsRemove = (
6222+ ref: SkillRef,
6223+ ): Effect.Effect<void, OrgWriteDeniedError | StorageFailure> =>
6224+ Effect.gen(function* () {
6225+ yield* guardOrgWrite(ref.owner);
6226+ yield* core.deleteMany("skill", { where: skillByRef(ref) });
6227+ });
6228+
61216229 // ------------------------------------------------------------------
61226230 // Elicitation
61236231 // ------------------------------------------------------------------
@@ -7082,6 +7190,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 958 unmodified lines
70827190 remove: artifactsRemove,
70837191 setPreview: artifactsSetPreview,
70847192 },
7193+ skills: {
7194+ list: skillsList,
7195+ get: skillsGet,
7196+ save: skillsSave,
7197+ remove: skillsRemove,
7198+ },
70857199 pendingApprovals,
70867200 execute,
70877201 close,
packages/core/sdk/src/ids.ts+5−0
@@ -63,6 +63,11 @@ export type PolicyId = typeof PolicyId.Type; · 62 unmodified lines
6363 export const ArtifactId = Schema.String.pipe(Schema.brand("ArtifactId"));
6464 export type ArtifactId = typeof ArtifactId.Type;
6565
66+/** An Agent Skill's name — the `name` in its SKILL.md frontmatter, unique within
67+ * its owner partition. Lowercase letters, digits, and single hyphens. */
68+export const SkillName = Schema.String.pipe(Schema.brand("SkillName"));
69+export type SkillName = typeof SkillName.Type;
70+
6671 /**
6772 * The isolation partition (the org/workspace). Owns the catalog and namespaces
6873 * every connection. The executor is bound to one; `owner: "org"` files at this
packages/core/sdk/src/index.ts+32−0
@@ -59,6 +59,7 @@ export { · 58 unmodified lines
5959 ElicitationId,
6060 PolicyId,
6161 ArtifactId,
62+ SkillName,
6263 Tenant,
6364 Subject,
6465 Owner,
@@ -81,6 +82,8 @@ export { · 16 unmodified lines
8182 CredentialProviderNotRegisteredError,
8283 CredentialResolutionError,
8384 ArtifactNotFoundError,
85+ SkillNotFoundError,
86+ InvalidSkillError,
8487 isUserActionableError,
8588 type ExecuteError,
8689 type ExecutorError,
@@ -220,6 +223,35 @@ export { · 133 unmodified lines
220223 } from "./artifact";
221224 export { sanitizeArtifactPreviewMarkup, ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./artifact-preview";
222225
226+// Agent Skills — SKILL.md directories saved to the workspace.
227+export {
228+ rowToSkill,
229+ toSkillSummary,
230+ parseSkillMarkdown,
231+ prepareSkillFiles,
232+ digestSkillContent,
233+ skillBody,
234+ skillRootUri,
235+ skillFileUri,
236+ parseSkillUri,
237+ isValidSkillName,
238+ isValidSkillFilePath,
239+ SKILL_MD_PATH,
240+ SKILL_MAX_FILES,
241+ SKILL_MAX_TOTAL_BYTES,
242+ SKILL_RESERVED_NAMES,
243+ SKILL_URI_SCHEME,
244+ type Skill,
245+ type SkillSummary,
246+ type SkillFile,
247+ type SkillFileEntry,
248+ type SkillFileInput,
249+ type SkillRef,
250+ type SaveSkillInput,
251+ type ParsedSkillMarkdown,
252+ type ParsedSkillUri,
253+} from "./skill";
254+
223255 // Elicitation.
224256 export {
225257 ElicitationMeta,
packages/core/sdk/src/shared.ts+29−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 // Branded ids + the owner literal.
1212 export {
1313 ArtifactId,
14+ SkillName,
1415 AuthTemplateSlug,
1516 ConnectionAddress,
1617 ConnectionName,
@@ -66,6 +67,8 @@ export { · 49 unmodified lines
6667 CredentialProviderNotRegisteredError,
6768 CredentialResolutionError,
6869 ArtifactNotFoundError,
70+ SkillNotFoundError,
71+ InvalidSkillError,
6972 isUserActionableError,
7073 type ExecuteError,
7174 type ExecutorError,
@@ -120,6 +123,32 @@ export type { · 48 unmodified lines
120123 } from "./artifact";
121124 export { ARTIFACT_PREVIEW_MARKUP_LIMIT } from "./artifact-preview";
122125
126+// Agent Skills — the pure parts the console and CLI share with the server.
127+export {
128+ parseSkillMarkdown,
129+ isValidSkillName,
130+ isValidSkillFilePath,
131+ skillRootUri,
132+ skillFileUri,
133+ parseSkillUri,
134+ SKILL_MD_PATH,
135+ SKILL_MAX_FILES,
136+ SKILL_MAX_TOTAL_BYTES,
137+ SKILL_RESERVED_NAMES,
138+ SKILL_URI_SCHEME,
139+} from "./skill";
140+export type {
141+ Skill,
142+ SkillSummary,
143+ SkillFile,
144+ SkillFileEntry,
145+ SkillFileInput,
146+ SkillRef,
147+ SaveSkillInput,
148+ ParsedSkillMarkdown,
149+ ParsedSkillUri,
150+} from "./skill";
151+
123152 // Schema-side views + onboarding autodetect.
124153 export { ToolSchemaView, IntegrationDetectionResult } from "./types";
125154
packages/core/sdk/src/skill.ts+384−0
@@ -0,0 +1,384 @@
1+// ---------------------------------------------------------------------------
2+// Agent Skills — the SKILL.md standard (https://agentskills.io/specification)
3+// saved into the workspace. Row → public projection, the operation inputs, and
4+// the pure validation that turns a set of uploaded files into a skill.
5+//
6+// A skill IS a directory: a SKILL.md with YAML frontmatter (`name`,
7+// `description`, a few optional fields) followed by markdown instructions, plus
8+// any bundled files (`references/`, `scripts/`, `assets/`). We store the whole
9+// directory as one row so the MCP host can serve it back file-by-file with
10+// digests, which is what the MCP Skills Extension (SEP-2640) requires.
11+//
12+// Owner-scoped like a connection: an `org` skill is shared with everyone in the
13+// workspace, a `user` skill is personal. Identity is `(owner, name)`.
14+// ---------------------------------------------------------------------------
15+
16+import { Option, Result, Schema } from "effect";
17+import { parse as parseYaml } from "yaml";
18+
19+import type { SkillRow } from "./core-schema";
20+import { InvalidSkillError } from "./errors";
21+import { SkillName, type Owner } from "./ids";
22+
23+/** One file as uploaded: a relative POSIX path and its UTF-8 text. */
24+export interface SkillFileInput {
25+ readonly path: string;
26+ readonly content: string;
27+}
28+
29+/** One file in a skill's manifest: what the MCP Skills Extension lists. */
30+export interface SkillFileEntry {
31+ /** Relative to the skill root, `/`-separated. `SKILL.md` for the root file. */
32+ readonly path: string;
33+ /** Byte length of the UTF-8 content. */
34+ readonly size: number;
35+ /** `sha256:<64 lowercase hex>` over the same bytes. */
36+ readonly digest: string;
37+}
38+
39+export type SkillFile = SkillFileEntry & { readonly content: string };
40+
41+export interface SkillSummary {
42+ readonly owner: Owner;
43+ readonly name: SkillName;
44+ readonly description: string;
45+ /** The SKILL.md frontmatter, verbatim, every field the author wrote. */
46+ readonly frontmatter: Readonly<Record<string, unknown>>;
47+ /** The manifest — paths, sizes, digests — without content. */
48+ readonly files: readonly SkillFileEntry[];
49+ readonly createdAt: Date;
50+ readonly updatedAt: Date;
51+}
52+
53+export interface Skill extends Omit<SkillSummary, "files"> {
54+ readonly files: readonly SkillFile[];
55+}
56+
57+/** Save (create or replace in place) the skill the files describe. The name
58+ * comes from the SKILL.md frontmatter, never from the caller. */
59+export interface SaveSkillInput {
60+ readonly owner: Owner;
61+ readonly files: readonly SkillFileInput[];
62+}
63+
64+export interface SkillRef {
65+ readonly owner: Owner;
66+ readonly name: string;
67+}
68+
69+// ---------------------------------------------------------------------------
70+// Limits and reserved names
71+// ---------------------------------------------------------------------------
72+
73+export const SKILL_MD_PATH = "SKILL.md";
74+/** Files per skill, SKILL.md included. */
75+export const SKILL_MAX_FILES = 64;
76+/** Total UTF-8 bytes across every file of one skill. */
77+export const SKILL_MAX_TOTAL_BYTES = 1024 * 1024;
78+export const SKILL_NAME_MAX_LENGTH = 64;
79+export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
80+export const SKILL_COMPATIBILITY_MAX_LENGTH = 500;
81+
82+/** Names the MCP `skills` tool already answers with Executor's own docs; a
83+ * workspace skill under one of them would be unreachable by name. */
84+export const SKILL_RESERVED_NAMES: ReadonlySet<string> = new Set([
85+ "execute",
86+ "create-artifact",
87+ "artifact-style",
88+]);
89+
90+const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
91+
92+/** Whether `name` satisfies the specification's `name` rules. */
93+export const isValidSkillName = (name: string): boolean =>
94+ name.length >= 1 && name.length <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name);
95+
96+// ---------------------------------------------------------------------------
97+// Frontmatter
98+// ---------------------------------------------------------------------------
99+
100+export interface ParsedSkillMarkdown {
101+ readonly name: SkillName;
102+ readonly description: string;
103+ readonly frontmatter: Readonly<Record<string, unknown>>;
104+ /** The markdown after the closing `---`, trimmed. */
105+ readonly body: string;
106+}
107+
108+const invalid = (reason: string) => Result.fail(new InvalidSkillError({ reason }));
109+
110+const isRecord = (value: unknown): value is Record<string, unknown> =>
111+ typeof value === "object" && value !== null && !Array.isArray(value);
112+
113+/** Locate the frontmatter block: `---` on the first line, `---` alone on a
114+ * later line. Returns the YAML between them and the body after. */
115+const splitFrontmatter = (
116+ markdown: string,
117+): Option.Option<{ readonly yaml: string; readonly body: string }> => {
118+ const text = markdown.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
119+ const lines = text.split("\n");
120+ if (lines[0]?.trim() !== "---") return Option.none();
121+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
122+ if (closing === -1) return Option.none();
123+ return Option.some({
124+ yaml: lines.slice(1, closing).join("\n"),
125+ body: lines
126+ .slice(closing + 1)
127+ .join("\n")
128+ .trim(),
129+ });
130+};
131+
132+/**
133+ * Split a SKILL.md into frontmatter and body and validate the required fields.
134+ *
135+ * Strict on what the specification makes strict (`name` shape, `description`
136+ * presence and length, `compatibility` length, `metadata` shape) and open on
137+ * everything else: unknown keys are kept verbatim, because the MCP Skills
138+ * Extension promises hosts the frontmatter exactly as the author wrote it.
139+ */
140+export const parseSkillMarkdown = (
141+ markdown: string,
142+): Result.Result<ParsedSkillMarkdown, InvalidSkillError> => {
143+ const split = splitFrontmatter(markdown);
144+ if (Option.isNone(split)) {
145+ return invalid(
146+ "SKILL.md must begin with a `---` line, followed by YAML frontmatter and a closing `---` line.",
147+ );
148+ }
149+ const { yaml, body } = split.value;
150+
151+ const parsed = Result.try({
152+ try: () => parseYaml(yaml) as unknown,
153+ catch: () => new InvalidSkillError({ reason: "SKILL.md frontmatter is not valid YAML." }),
154+ });
155+ if (Result.isFailure(parsed)) return Result.fail(parsed.failure);
156+ const frontmatter = parsed.success;
157+ if (!isRecord(frontmatter)) {
158+ return invalid("SKILL.md frontmatter must be a YAML mapping of fields.");
159+ }
160+
161+ const name = frontmatter.name;
162+ if (typeof name !== "string" || !isValidSkillName(name)) {
163+ return invalid(
164+ "Frontmatter `name` must be 1–64 characters of lowercase letters, digits, and single hyphens, and cannot start or end with a hyphen.",
165+ );
166+ }
167+ if (SKILL_RESERVED_NAMES.has(name)) {
168+ return invalid(`\`${name}\` is reserved for Executor's built-in docs; choose another name.`);
169+ }
170+ const description = frontmatter.description;
171+ if (typeof description !== "string" || description.trim().length === 0) {
172+ return invalid("Frontmatter `description` is required and must be a non-empty string.");
173+ }
174+ if (description.length > SKILL_DESCRIPTION_MAX_LENGTH) {
175+ return invalid(
176+ `Frontmatter \`description\` must be at most ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`,
177+ );
178+ }
179+ if ("compatibility" in frontmatter) {
180+ const compatibility = frontmatter.compatibility;
181+ if (
182+ typeof compatibility !== "string" ||
183+ compatibility.length === 0 ||
184+ compatibility.length > SKILL_COMPATIBILITY_MAX_LENGTH
185+ ) {
186+ return invalid(
187+ `Frontmatter \`compatibility\` must be a string of 1–${SKILL_COMPATIBILITY_MAX_LENGTH} characters.`,
188+ );
189+ }
190+ }
191+ if ("license" in frontmatter && typeof frontmatter.license !== "string") {
192+ return invalid("Frontmatter `license` must be a string.");
193+ }
194+ if ("allowed-tools" in frontmatter && typeof frontmatter["allowed-tools"] !== "string") {
195+ return invalid("Frontmatter `allowed-tools` must be a space-separated string.");
196+ }
197+ if ("metadata" in frontmatter) {
198+ const metadata = frontmatter.metadata;
199+ if (!isRecord(metadata) || Object.values(metadata).some((v) => typeof v !== "string")) {
200+ return invalid("Frontmatter `metadata` must be a mapping of string keys to string values.");
201+ }
202+ }
203+
204+ return Result.succeed({
205+ name: SkillName.make(name),
206+ description: description.trim(),
207+ frontmatter,
208+ body,
209+ });
210+};
211+
212+// ---------------------------------------------------------------------------
213+// Files
214+// ---------------------------------------------------------------------------
215+
216+const encoder = new TextEncoder();
217+
218+const toHex = (bytes: ArrayBuffer): string =>
219+ Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
220+
221+/** `sha256:<hex>` of the UTF-8 encoding of `content` — the digest shape the
222+ * MCP Skills Extension specifies. Web Crypto, so it runs on Node, Bun, and
223+ * workerd alike. */
224+export const digestSkillContent = async (content: string): Promise<string> =>
225+ `sha256:${toHex(await crypto.subtle.digest("SHA-256", encoder.encode(content)))}`;
226+
227+export const skillContentByteLength = (content: string): number =>
228+ encoder.encode(content).byteLength;
229+
230+// eslint-disable-next-line no-control-regex -- the point is to refuse control characters in paths
231+const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
232+
233+/** A relative POSIX path inside a skill: no leading `/`, no `.`/`..` segments,
234+ * no empty segments, no backslashes, no control characters. */
235+export const isValidSkillFilePath = (path: string): boolean => {
236+ if (path.length === 0 || path.length > 512) return false;
237+ if (path.includes("\\") || CONTROL_CHARACTERS.test(path)) return false;
238+ const segments = path.split("/");
239+ return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
240+};
241+
242+const compareSkillPaths = (a: SkillFileInput, b: SkillFileInput): number =>
243+ a.path === SKILL_MD_PATH
244+ ? -1
245+ : b.path === SKILL_MD_PATH
246+ ? 1
247+ : a.path < b.path
248+ ? -1
249+ : a.path > b.path
250+ ? 1
251+ : 0;
252+
253+export interface PreparedSkill {
254+ readonly parsed: ParsedSkillMarkdown;
255+ /** SKILL.md first, then the rest in path order. */
256+ readonly files: readonly SkillFile[];
257+}
258+
259+/**
260+ * Validate an uploaded file set and produce the parsed SKILL.md plus the
261+ * normalized, digested file list — exactly what gets stored.
262+ *
263+ * Async only for the digests; everything else is pure.
264+ */
265+export const prepareSkillFiles = async (
266+ inputs: readonly SkillFileInput[],
267+): Promise<Result.Result<PreparedSkill, InvalidSkillError>> => {
268+ if (inputs.length === 0) return invalid("A skill needs at least a SKILL.md file.");
269+ if (inputs.length > SKILL_MAX_FILES) {
270+ return invalid(`A skill can have at most ${SKILL_MAX_FILES} files.`);
271+ }
272+ const seen = new Set<string>();
273+ for (const file of inputs) {
274+ if (!isValidSkillFilePath(file.path)) {
275+ return invalid(`File path "${file.path}" is not a relative path inside the skill.`);
276+ }
277+ if (seen.has(file.path)) return invalid(`File path "${file.path}" appears more than once.`);
278+ seen.add(file.path);
279+ }
280+ const skillMd = inputs.find((file) => file.path === SKILL_MD_PATH);
281+ if (!skillMd) return invalid("A skill must contain a SKILL.md file at its root.");
282+ const parsed = parseSkillMarkdown(skillMd.content);
283+ if (Result.isFailure(parsed)) return Result.fail(parsed.failure);
284+
285+ let total = 0;
286+ const files: SkillFile[] = [];
287+ for (const file of [...inputs].sort(compareSkillPaths)) {
288+ const size = skillContentByteLength(file.content);
289+ total += size;
290+ if (total > SKILL_MAX_TOTAL_BYTES) {
291+ return invalid(`A skill's files can total at most ${SKILL_MAX_TOTAL_BYTES} bytes.`);
292+ }
293+ files.push({
294+ path: file.path,
295+ content: file.content,
296+ size,
297+ digest: await digestSkillContent(file.content),
298+ });
299+ }
300+ return Result.succeed({ parsed: parsed.success, files });
301+};
302+
303+// ---------------------------------------------------------------------------
304+// URIs — the MCP Skills Extension form. `skill://<owner>/<name>/<path>`; the
305+// final skill-path segment is the name, as the extension requires, and the
306+// owner is the server-chosen prefix that keeps a personal and a workspace skill
307+// of the same name distinct.
308+// ---------------------------------------------------------------------------
309+
310+export const SKILL_URI_SCHEME = "skill://";
311+
312+export const skillRootUri = (ref: SkillRef): string =>
313+ `${SKILL_URI_SCHEME}${ref.owner}/${ref.name}`;
314+
315+export const skillFileUri = (ref: SkillRef, path: string): string => `${skillRootUri(ref)}/${path}`;
316+
317+export interface ParsedSkillUri extends SkillRef {
318+ /** Empty for the skill root directory. */
319+ readonly path: string;
320+}
321+
322+/** Parse `skill://<owner>/<name>[/<path>]` back into its parts, or `None`. */
323+export const parseSkillUri = (uri: string): Option.Option<ParsedSkillUri> => {
324+ if (!uri.startsWith(SKILL_URI_SCHEME)) return Option.none();
325+ const [owner, name, ...pathSegments] = uri.slice(SKILL_URI_SCHEME.length).split("/");
326+ if ((owner !== "org" && owner !== "user") || name === undefined || !isValidSkillName(name)) {
327+ return Option.none();
328+ }
329+ const path = pathSegments.join("/");
330+ if (path.length > 0 && !isValidSkillFilePath(path)) return Option.none();
331+ return Option.some({ owner, name, path });
332+};
333+
334+// ---------------------------------------------------------------------------
335+// Row projections
336+// ---------------------------------------------------------------------------
337+
338+// The stored `files` column is decoded through this; it is the same shape as
339+// `SkillFile`, kept as a schema here because the row is the only place bytes
340+// arrive untyped.
341+const StoredSkillFile = Schema.Struct({
342+ path: Schema.String,
343+ content: Schema.String,
344+ size: Schema.Number,
345+ digest: Schema.String,
346+});
347+const decodeFiles = Schema.decodeUnknownOption(Schema.Array(StoredSkillFile));
348+const decodeJsonString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
349+
350+/** JSON arrives as an object on Postgres and as a string on SQLite. */
351+const jsonFromColumn = (value: unknown): Option.Option<unknown> =>
352+ typeof value === "string" ? decodeJsonString(value) : Option.some(value);
353+
354+const filesFromColumn = (value: unknown): readonly SkillFile[] =>
355+ Option.flatMap(jsonFromColumn(value), decodeFiles).pipe(Option.getOrElse(() => []));
356+
357+const frontmatterFromColumn = (value: unknown): Readonly<Record<string, unknown>> =>
358+ Option.filter(jsonFromColumn(value), isRecord).pipe(Option.getOrElse(() => ({})));
359+
360+const asDate = (value: Date | number | string): Date =>
361+ value instanceof Date ? value : new Date(value);
362+
363+export const rowToSkill = (row: SkillRow): Skill => ({
364+ owner: row.owner as Owner,
365+ name: SkillName.make(row.name),
366+ description: row.description,
367+ frontmatter: frontmatterFromColumn(row.frontmatter),
368+ files: filesFromColumn(row.files),
369+ createdAt: asDate(row.created_at),
370+ updatedAt: asDate(row.updated_at),
371+});
372+
373+export const toSkillSummary = (skill: Skill): SkillSummary => ({
374+ ...skill,
375+ files: skill.files.map(({ path, size, digest }) => ({ path, size, digest })),
376+});
377+
378+/** The markdown body of a stored skill's SKILL.md (frontmatter stripped). */
379+export const skillBody = (skill: Skill): string => {
380+ const skillMd = skill.files.find((file) => file.path === SKILL_MD_PATH);
381+ if (!skillMd) return "";
382+ const parsed = parseSkillMarkdown(skillMd.content);
383+ return Result.isSuccess(parsed) ? parsed.success.body : skillMd.content;
384+};
packages/core/sdk/src/skills.test.ts+199−0
@@ -0,0 +1,199 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Option, Predicate, Result } from "effect";
3+
4+import { makeTestExecutor } from "./testing";
5+import {
6+ parseSkillMarkdown,
7+ parseSkillUri,
8+ prepareSkillFiles,
9+ skillBody,
10+ skillFileUri,
11+} from "./skill";
12+
13+// The `executor.skills` surface against the real SQLite test db, plus the pure
14+// SKILL.md validation it is built on. Skills are owner-scoped rows with no
15+// plugin involvement, so the default test executor (one tenant + one bound
16+// subject) is the whole fixture.
17+
18+const SKILL_MD = [
19+ "---",
20+ "name: pdf-processing",
21+ "description: Extract text from PDFs. Use when the user mentions PDFs.",
22+ "license: Apache-2.0",
23+ "metadata:",
24+ " author: example-org",
25+ ' version: "1.0"',
26+ "unknown-field: kept verbatim",
27+ "---",
28+ "",
29+ "# PDF processing",
30+ "",
31+ "Run `scripts/extract.py`.",
32+].join("\n");
33+
34+const files = (skillMd = SKILL_MD) => [
35+ { path: "SKILL.md", content: skillMd },
36+ { path: "scripts/extract.py", content: "print('hi')\n" },
37+];
38+
39+describe("parseSkillMarkdown", () => {
40+ it("splits frontmatter from body and keeps unknown fields", () => {
41+ const parsed = parseSkillMarkdown(SKILL_MD);
42+ expect(Result.isSuccess(parsed)).toBe(true);
43+ if (Result.isFailure(parsed)) return;
44+ expect(parsed.success.name).toBe("pdf-processing");
45+ expect(parsed.success.description).toBe(
46+ "Extract text from PDFs. Use when the user mentions PDFs.",
47+ );
48+ expect(parsed.success.frontmatter["unknown-field"]).toBe("kept verbatim");
49+ expect(parsed.success.frontmatter.metadata).toEqual({ author: "example-org", version: "1.0" });
50+ expect(parsed.success.body.startsWith("# PDF processing")).toBe(true);
51+ });
52+
53+ it.each([
54+ ["no frontmatter", "# Just markdown"],
55+ ["unclosed frontmatter", "---\nname: x\ndescription: y\n"],
56+ ["uppercase name", "---\nname: PDF\ndescription: y\n---\n"],
57+ ["leading hyphen", "---\nname: -pdf\ndescription: y\n---\n"],
58+ ["double hyphen", "---\nname: pdf--x\ndescription: y\n---\n"],
59+ ["missing description", "---\nname: pdf\n---\n"],
60+ ["reserved name", "---\nname: execute\ndescription: y\n---\n"],
61+ ["non-string metadata value", "---\nname: pdf\ndescription: y\nmetadata:\n n: 1\n---\n"],
62+ ])("rejects %s", (_label, markdown) => {
63+ expect(Result.isFailure(parseSkillMarkdown(markdown))).toBe(true);
64+ });
65+});
66+
67+describe("prepareSkillFiles", () => {
68+ it("digests every file and puts SKILL.md first", async () => {
69+ const prepared = await prepareSkillFiles([...files()].reverse());
70+ expect(Result.isSuccess(prepared)).toBe(true);
71+ if (Result.isFailure(prepared)) return;
72+ expect(prepared.success.files.map((f) => f.path)).toEqual(["SKILL.md", "scripts/extract.py"]);
73+ for (const file of prepared.success.files) {
74+ expect(file.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
75+ expect(file.size).toBe(new TextEncoder().encode(file.content).byteLength);
76+ }
77+ });
78+
79+ it.each([
80+ ["a path escaping the skill", [{ path: "../SKILL.md", content: "" }]],
81+ ["an absolute path", [{ path: "/SKILL.md", content: "" }]],
82+ ["no SKILL.md", [{ path: "README.md", content: "" }]],
83+ ["a duplicate path", [...files(), { path: "scripts/extract.py", content: "" }]],
84+ ])("rejects %s", async (_label, inputs) => {
85+ expect(Result.isFailure(await prepareSkillFiles(inputs))).toBe(true);
86+ });
87+});
88+
89+describe("skill URIs", () => {
90+ it("round-trips owner, name, and path", () => {
91+ const uri = skillFileUri({ owner: "org", name: "pdf-processing" }, "references/FORMS.md");
92+ expect(uri).toBe("skill://org/pdf-processing/references/FORMS.md");
93+ expect(parseSkillUri(uri)).toEqual(
94+ Option.some({ owner: "org", name: "pdf-processing", path: "references/FORMS.md" }),
95+ );
96+ });
97+
98+ it("rejects other schemes, unknown owners, and escaping paths", () => {
99+ expect(Option.isNone(parseSkillUri("ui://executor/shell.html"))).toBe(true);
100+ expect(Option.isNone(parseSkillUri("skill://team/pdf/SKILL.md"))).toBe(true);
101+ expect(Option.isNone(parseSkillUri("skill://org/pdf/../x"))).toBe(true);
102+ });
103+});
104+
105+describe("executor.skills", () => {
106+ it.effect("list is empty when nothing is saved", () =>
107+ Effect.gen(function* () {
108+ const executor = yield* makeTestExecutor();
109+ expect(yield* executor.skills.list()).toEqual([]);
110+ }),
111+ );
112+
113+ it.effect("save stores the directory under the frontmatter name", () =>
114+ Effect.gen(function* () {
115+ const executor = yield* makeTestExecutor();
116+ const saved = yield* executor.skills.save({ owner: "user", files: files() });
117+ expect(saved.owner).toBe("user");
118+ expect(saved.name).toBe("pdf-processing");
119+ expect(saved.files.map((f) => f.path)).toEqual(["SKILL.md", "scripts/extract.py"]);
120+ expect(skillBody(saved).startsWith("# PDF processing")).toBe(true);
121+
122+ const fetched = yield* executor.skills.get({ owner: "user", name: "pdf-processing" });
123+ expect(fetched).toEqual(saved);
124+
125+ // Lists carry the manifest, never the contents.
126+ const [summary] = yield* executor.skills.list();
127+ expect(summary?.files).toEqual(
128+ saved.files.map(({ path, size, digest }) => ({ path, size, digest })),
129+ );
130+ expect(summary?.frontmatter["unknown-field"]).toBe("kept verbatim");
131+ }),
132+ );
133+
134+ it.effect("save replaces an existing skill of the same owner and name in place", () =>
135+ Effect.gen(function* () {
136+ const executor = yield* makeTestExecutor();
137+ yield* executor.skills.save({ owner: "user", files: files() });
138+ const updated = yield* executor.skills.save({
139+ owner: "user",
140+ files: [{ path: "SKILL.md", content: SKILL_MD.replace("Extract text", "Extract tables") }],
141+ });
142+ expect(updated.description).toContain("Extract tables");
143+ expect(updated.files).toHaveLength(1);
144+ expect(yield* executor.skills.list()).toHaveLength(1);
145+ }),
146+ );
147+
148+ it.effect("a personal and a workspace skill may share a name", () =>
149+ Effect.gen(function* () {
150+ const executor = yield* makeTestExecutor();
151+ yield* executor.skills.save({ owner: "user", files: files() });
152+ yield* executor.skills.save({ owner: "org", files: files() });
153+ const listed = yield* executor.skills.list();
154+ expect(listed.map((s) => s.owner).sort()).toEqual(["org", "user"]);
155+ }),
156+ );
157+
158+ it.effect("an invalid skill is refused with a reason", () =>
159+ Effect.gen(function* () {
160+ const executor = yield* makeTestExecutor();
161+ const result = yield* executor.skills
162+ .save({ owner: "user", files: [{ path: "SKILL.md", content: "# no frontmatter" }] })
163+ .pipe(Effect.result);
164+ expect(Result.isFailure(result)).toBe(true);
165+ if (Result.isSuccess(result)) return;
166+ expect(Predicate.isTagged("InvalidSkillError")(result.failure)).toBe(true);
167+ }),
168+ );
169+
170+ it.effect("get and remove target one (owner, name)", () =>
171+ Effect.gen(function* () {
172+ const executor = yield* makeTestExecutor();
173+ yield* executor.skills.save({ owner: "user", files: files() });
174+ yield* executor.skills.save({ owner: "org", files: files() });
175+ yield* executor.skills.remove({ owner: "user", name: "pdf-processing" });
176+ const remaining = yield* executor.skills.list();
177+ expect(remaining.map((s) => s.owner)).toEqual(["org"]);
178+ const missing = yield* executor.skills
179+ .get({ owner: "user", name: "pdf-processing" })
180+ .pipe(Effect.result);
181+ expect(Result.isFailure(missing)).toBe(true);
182+ }),
183+ );
184+
185+ it.effect("org writes are refused when workspace writes are denied", () =>
186+ Effect.gen(function* () {
187+ const executor = yield* makeTestExecutor({ orgWrites: "denied" });
188+ const result = yield* executor.skills
189+ .save({ owner: "org", files: files() })
190+ .pipe(Effect.result);
191+ expect(Result.isFailure(result)).toBe(true);
192+ if (Result.isSuccess(result)) return;
193+ expect(Predicate.isTagged("OrgWriteDeniedError")(result.failure)).toBe(true);
194+ // Personal skills are untouched by the workspace gate.
195+ const personal = yield* executor.skills.save({ owner: "user", files: files() });
196+ expect(personal.owner).toBe("user");
197+ }),
198+ );
199+});
packages/hosts/mcp/src/tool-server.ts+169−21
@@ -68,6 +68,16 @@ import { · 67 unmodified lines
6868 } from "./create-artifact";
6969 import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
7070 import { resolveArtifactAction } from "./artifact-action";
71+import {
72+ MCP_SKILLS_EXTENSION_ID,
73+ registerWorkspaceSkills,
74+ renderSkillsToolDescription,
75+ renderWorkspaceSkill,
76+ renderWorkspaceSkillsIndex,
77+ resolveWorkspaceSkill,
78+ type McpSkillsPort,
79+} from "./workspace-skills";
80+import type { Skill as WorkspaceSkill, SkillSummary } from "@executor-js/sdk";
7181 import {
7282 extractArtifactRoles,
7383 resolveArtifactBindings,
@@ -206,6 +216,14 @@ type SharedMcpServerConfig = { · 132 unmodified lines
206216 * whole `Executor` across this boundary.
207217 */
208218 readonly artifacts?: McpArtifactsPort;
219+ /**
220+ * The Agent Skills saved in the caller's workspace (personal + org-shared).
221+ * Structurally satisfied by `executor.skills`. Present, the `skills` tool
222+ * serves them next to Executor's own docs and the server declares the MCP
223+ * Skills Extension; absent (stdio-only hosts, tests), the surface is exactly
224+ * the docs-only one it always was.
225+ */
226+ readonly skills?: McpSkillsPort;
209227 /**
210228 * The caller's saved connections, for binding an artifact's integration roles
211229 * at create time. Structurally satisfied by `executor.connections`; hosts pass
@@ -841,6 +859,76 @@ const skillsResult = ( · 629 unmodified lines
841859 return { content: [{ type: "text", text }] };
842860 };
843861
862+const textResult = (text: string, isError = false): McpToolResult => ({
863+ content: [{ type: "text", text }],
864+ ...(isError ? { isError: true } : {}),
865+});
866+
867+// The `skills` tool when the host serves workspace skills too. Built-in docs
868+// keep their exact behavior and win on a name clash (their names are reserved at
869+// save time, so the clash cannot happen for a skill saved through Executor).
870+// The index lists both sections; a miss lists both too, so the model retries
871+// with a name that exists rather than the same miss.
872+//
873+// `file` reads one bundled file of a workspace skill — the third tier of
874+// progressive disclosure. It is meaningless for a built-in doc, which has no
875+// files, and says so instead of silently returning the body.
876+const workspaceSkillsResult = (
877+ input: { readonly name: string | undefined; readonly file: string | undefined },
878+ executeInventory: string,
879+ catalog: readonly Skill[],
880+ port: McpSkillsPort,
881+): Effect.Effect<McpToolResult> =>
882+ Effect.gen(function* () {
883+ const skills: readonly SkillSummary[] = yield* port
884+ .list()
885+ .pipe(Effect.catchCause(() => Effect.succeed<readonly SkillSummary[]>([])));
886+ const index = () => `${renderSkillsIndex(catalog)}\n\n${renderWorkspaceSkillsIndex(skills)}`;
887+ const name = input.name?.trim();
888+ const file = input.file?.trim();
889+ if (!name) return textResult(index());
890+
891+ const builtIn = findSkill(name, catalog);
892+ if (builtIn) {
893+ if (file) {
894+ return textResult(
895+ `\`${name}\` is one of Executor's own docs and has no bundled files. Call \`skills({ name: "${name}" })\` without \`file\`.`,
896+ true,
897+ );
898+ }
899+ return skillsResult(name, executeInventory, catalog);
900+ }
901+
902+ const summary = resolveWorkspaceSkill(name, skills);
903+ if (!summary) {
904+ return textResult(
905+ `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()}`,
906+ true,
907+ );
908+ }
909+ const skill: WorkspaceSkill | null = yield* port
910+ .get({ owner: summary.owner, name: summary.name })
911+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
912+ if (!skill) {
913+ return textResult(
914+ `Skill "${name}" could not be loaded right now. Retry, or call with no name to list what is available.`,
915+ true,
916+ );
917+ }
918+ if (file) {
919+ const found = skill.files.find((entry) => entry.path === file);
920+ if (!found) {
921+ const paths = skill.files.map((entry) => `- \`${entry.path}\``).join("\n");
922+ return textResult(
923+ `Skill "${skill.name}" has no file "${file}". Its files are:\n${paths}`,
924+ true,
925+ );
926+ }
927+ return textResult(found.content);
928+ }
929+ return textResult(renderWorkspaceSkill(skill));
930+ });
931+
844932 /** Pull the live integration inventory block out of the built execute
845933 * description (it runs from its header to the end), so the `skills` tool can
846934 * re-use it without rebuilding the inventory from the executor. */
@@ -1225,7 +1313,13 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 378 unmodified lines
12251313 // `ui://executor/shell.html`; it stays advertised even when no
12261314 // shell loader is configured so the capability set doesn't vary
12271315 // per host.
1228− capabilities: { resources: {}, tools: {} },
1316+ capabilities: {
1317+ resources: {},
1318+ tools: {},
1319+ // The MCP Skills Extension (SEP-2640) rides on resources, so it
1320+ // is declared only when a skills source is wired in.
1321+ ...(config.skills ? { extensions: { [MCP_SKILLS_EXTENSION_ID]: {} } } : {}),
1322+ },
12291323 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
12301324 },
12311325 ),
@@ -1562,34 +1656,88 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 330 unmodified lines
15621656 }),
15631657 );
15641658
1659+ // The catalog in the description is read once per session: it is what the
1660+ // model sees before it asks, and a description that changed under a
1661+ // client's cached tool list would be worse than one that is a few minutes
1662+ // stale. Every CALL reads live.
1663+ const workspaceSkills = config.skills;
1664+ const skillsCatalogAtBuild: readonly SkillSummary[] = workspaceSkills
1665+ ? yield* workspaceSkills.list().pipe(
1666+ Effect.catchCause(() => Effect.succeed<readonly SkillSummary[]>([])),
1667+ Effect.withSpan("mcp.host.list_workspace_skills"),
1668+ )
1669+ : [];
1670+
15651671 yield* Effect.sync(() =>
1566− server.registerTool(
1567− "skills",
1568− {
1569− description: [
1570− "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.",
1571− "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1572− '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).',
1573− "Call with no name to list the few docs available.",
1574− ].join("\n"),
1575− inputSchema: {
1576− name: z
1577− .string()
1578− .optional()
1579− .describe(
1580− 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.',
1672+ workspaceSkills
1673+ ? server.registerTool(
1674+ "skills",
1675+ {
1676+ description: renderSkillsToolDescription(skillsCatalogAtBuild),
1677+ inputSchema: {
1678+ name: z
1679+ .string()
1680+ .optional()
1681+ .describe(
1682+ 'A skill from the catalog, by `name` or `owner/name`, or one of Executor\'s own docs such as "execute". Omit to list everything.',
1683+ ),
1684+ file: z
1685+ .string()
1686+ .optional()
1687+ .describe(
1688+ 'A bundled file of the named workspace skill to read, as the relative path the skill lists (e.g. "references/guide.md").',
1689+ ),
1690+ },
1691+ },
1692+ ({ name, file }, extra) =>
1693+ runToolEffect(
1694+ workspaceSkillsResult(
1695+ { name, file },
1696+ executeInventory,
1697+ skillCatalog,
1698+ workspaceSkills,
1699+ ),
1700+ extra,
15811701 ),
1582− },
1583− },
1584− ({ name }, extra) =>
1585− runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog)), extra),
1586− ),
1702+ )
1703+ : server.registerTool(
1704+ "skills",
1705+ {
1706+ description: [
1707+ "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.",
1708+ "These docs hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1709+ '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).',
1710+ "Call with no name to list the few docs available.",
1711+ ].join("\n"),
1712+ inputSchema: {
1713+ name: z
1714+ .string()
1715+ .optional()
1716+ .describe(
1717+ 'A doc from this server\'s own catalog, e.g. "execute" — not a path or an outside skill name. Omit to list the catalog.',
1718+ ),
1719+ },
1720+ },
1721+ ({ name }, extra) =>
1722+ runToolEffect(
1723+ Effect.succeed(skillsResult(name, executeInventory, skillCatalog)),
1724+ extra,
1725+ ),
1726+ ),
15871727 ).pipe(
15881728 Effect.withSpan("mcp.host.register_tool", {
15891729 attributes: { "mcp.tool.name": "skills" },
15901730 }),
15911731 );
15921732
1733+ if (workspaceSkills) {
1734+ yield* Effect.sync(() =>
1735+ registerWorkspaceSkills(server, workspaceSkills, (effect) =>
1736+ Effect.runPromiseWith(context)(anchor(effect)),
1737+ ),
1738+ ).pipe(Effect.withSpan("mcp.host.register_workspace_skills"));
1739+ }
1740+
15931741 yield* Effect.sync(() => {
15941742 if (elicitationMode.mode === "native") {
15951743 return undefined;
packages/hosts/mcp/src/workspace-skills.test.ts+296−0
@@ -0,0 +1,296 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5+import * as z from "zod/v4";
6+import type * as Cause from "effect/Cause";
7+
8+import { SkillName, SkillNotFoundError, type Skill, type SkillSummary } from "@executor-js/sdk";
9+import type { ExecutionEngine } from "@executor-js/execution";
10+
11+import { createExecutorMcpServer } from "./tool-server";
12+import {
13+ MCP_SKILLS_EXTENSION_ID,
14+ resolveWorkspaceSkill,
15+ type McpSkillsPort,
16+} from "./workspace-skills";
17+
18+// Workspace skills on the MCP surface: the `skills` tool grows a workspace
19+// section and a `file` argument, and the server speaks the MCP Skills
20+// Extension (SEP-2640). Both read the same port, faked here as a static list.
21+
22+const makeStubEngine = <E extends Cause.YieldableError = never>(): ExecutionEngine<E> => ({
23+ execute: () => Effect.succeed({ result: "default" }),
24+ executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "default" } }),
25+ resume: () => Effect.succeed(null),
26+ getPausedExecution: () => Effect.succeed(null),
27+ pausedExecutionCount: () => Effect.succeed(0),
28+ hasPausedExecutions: () => Effect.succeed(false),
29+ getDescription: Effect.succeed("test executor"),
30+ shutdown: Effect.void,
31+});
32+
33+const SKILL_MD = [
34+ "---",
35+ "name: pdf-processing",
36+ "description: Extract text from PDFs. Use when the user mentions PDFs.",
37+ "metadata:",
38+ ' version: "1.0"',
39+ "---",
40+ "",
41+ "# PDF processing",
42+ "",
43+ "Read `references/FORMS.md` before filling forms.",
44+].join("\n");
45+
46+const skill = (owner: "org" | "user", name = "pdf-processing"): Skill => ({
47+ owner,
48+ name: SkillName.make(name),
49+ description: "Extract text from PDFs. Use when the user mentions PDFs.",
50+ frontmatter: {
51+ name,
52+ description: "Extract text from PDFs. Use when the user mentions PDFs.",
53+ metadata: { version: "1.0" },
54+ },
55+ files: [
56+ {
57+ path: "SKILL.md",
58+ content: SKILL_MD.replace("pdf-processing", name),
59+ size: 111,
60+ digest: `sha256:${"a".repeat(64)}`,
61+ },
62+ {
63+ path: "references/FORMS.md",
64+ content: "# Forms\n\nFill every field.",
65+ size: 26,
66+ digest: `sha256:${"b".repeat(64)}`,
67+ },
68+ ],
69+ createdAt: new Date(0),
70+ updatedAt: new Date(0),
71+});
72+
73+const summaryOf = (s: Skill): SkillSummary => ({
74+ ...s,
75+ files: s.files.map(({ path, size, digest }) => ({ path, size, digest })),
76+});
77+
78+const portOf = (skills: readonly Skill[]): McpSkillsPort => ({
79+ list: () => Effect.succeed(skills.map(summaryOf)),
80+ get: (ref) => {
81+ const found = skills.find((s) => s.owner === ref.owner && s.name === ref.name);
82+ return found
83+ ? Effect.succeed(found)
84+ : Effect.fail(new SkillNotFoundError({ owner: ref.owner, name: SkillName.make(ref.name) }));
85+ },
86+});
87+
88+const withClient = async (
89+ skills: McpSkillsPort | undefined,
90+ fn: (client: Client) => Promise<void>,
91+) => {
92+ const mcpServer = await Effect.runPromise(
93+ createExecutorMcpServer({ engine: makeStubEngine(), ...(skills ? { skills } : {}) }),
94+ );
95+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
96+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
97+ await mcpServer.connect(serverTransport);
98+ await client.connect(clientTransport);
99+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions
100+ try {
101+ await fn(client);
102+ } finally {
103+ await clientTransport.close();
104+ await serverTransport.close();
105+ }
106+};
107+
108+const textOf = (result: Awaited<ReturnType<Client["callTool"]>>): string =>
109+ (result.content as Array<{ type: string; text: string }>)[0].text;
110+
111+const SkillsListResult = z.object({
112+ skills: z.array(
113+ z.object({
114+ uri: z.string(),
115+ frontmatter: z.record(z.string(), z.unknown()),
116+ resources: z.array(z.object({ uri: z.string(), digest: z.string(), size: z.number() })),
117+ }),
118+ ),
119+});
120+const SkillsGetResult = z.object({ skill: SkillsListResult.shape.skills.element });
121+
122+describe("resolveWorkspaceSkill", () => {
123+ const both = [summaryOf(skill("org")), summaryOf(skill("user"))];
124+ it("prefers the personal skill on a bare-name clash", () => {
125+ expect(resolveWorkspaceSkill("pdf-processing", both)?.owner).toBe("user");
126+ });
127+ it("honours an explicit owner", () => {
128+ expect(resolveWorkspaceSkill("org/pdf-processing", both)?.owner).toBe("org");
129+ expect(resolveWorkspaceSkill("user/nope", both)).toBeUndefined();
130+ });
131+});
132+
133+describe("skills tool without a skills source", () => {
134+ it("keeps the docs-only description and input", async () => {
135+ await withClient(undefined, async (client) => {
136+ const { tools } = await client.listTools();
137+ const tool = tools.find((t) => t.name === "skills");
138+ expect(tool?.description).toContain("Not a general skill reader");
139+ expect(Object.keys(tool?.inputSchema.properties ?? {})).toEqual(["name"]);
140+ expect(client.getServerCapabilities()?.extensions).toBeUndefined();
141+ });
142+ });
143+});
144+
145+describe("skills tool with workspace skills", () => {
146+ it("advertises the catalog in the description", async () => {
147+ await withClient(portOf([skill("org")]), async (client) => {
148+ const { tools } = await client.listTools();
149+ const tool = tools.find((t) => t.name === "skills");
150+ expect(tool?.description).toContain("`pdf-processing` (workspace)");
151+ expect(tool?.description).toContain("Use when the user mentions PDFs");
152+ expect(Object.keys(tool?.inputSchema.properties ?? {}).sort()).toEqual(["file", "name"]);
153+ });
154+ });
155+
156+ it("lists built-in docs and workspace skills in the index", async () => {
157+ await withClient(portOf([skill("user")]), async (client) => {
158+ const text = textOf(await client.callTool({ name: "skills", arguments: {} }));
159+ expect(text).toContain("`execute`");
160+ expect(text).toContain("`pdf-processing` (personal)");
161+ });
162+ });
163+
164+ it("returns the body with frontmatter stripped and lists bundled files", async () => {
165+ await withClient(portOf([skill("org")]), async (client) => {
166+ const result = await client.callTool({
167+ name: "skills",
168+ arguments: { name: "pdf-processing" },
169+ });
170+ const text = textOf(result);
171+ expect(result.isError).toBeFalsy();
172+ expect(text).toContain('<skill_content name="pdf-processing" owner="org">');
173+ expect(text).toContain("# PDF processing");
174+ expect(text).not.toContain("description: Extract text");
175+ expect(text).toContain("<file>references/FORMS.md</file>");
176+ expect(text).not.toContain("Fill every field");
177+ expect(result.structuredContent).toBeUndefined();
178+ });
179+ });
180+
181+ it("reads a bundled file by path and reports a missing one", async () => {
182+ await withClient(portOf([skill("org")]), async (client) => {
183+ const hit = await client.callTool({
184+ name: "skills",
185+ arguments: { name: "org/pdf-processing", file: "references/FORMS.md" },
186+ });
187+ expect(textOf(hit)).toBe("# Forms\n\nFill every field.");
188+ const miss = await client.callTool({
189+ name: "skills",
190+ arguments: { name: "pdf-processing", file: "references/NOPE.md" },
191+ });
192+ expect(miss.isError).toBe(true);
193+ expect(textOf(miss)).toContain("- `references/FORMS.md`");
194+ });
195+ });
196+
197+ it("still serves the built-in docs and refuses `file` on them", async () => {
198+ await withClient(portOf([skill("org")]), async (client) => {
199+ const doc = await client.callTool({ name: "skills", arguments: { name: "execute" } });
200+ expect(textOf(doc)).toContain("## Workflow");
201+ const bad = await client.callTool({
202+ name: "skills",
203+ arguments: { name: "execute", file: "x.md" },
204+ });
205+ expect(bad.isError).toBe(true);
206+ });
207+ });
208+
209+ it("names both sections on a miss", async () => {
210+ await withClient(portOf([skill("org")]), async (client) => {
211+ const result = await client.callTool({ name: "skills", arguments: { name: "nope" } });
212+ expect(result.isError).toBe(true);
213+ expect(textOf(result)).toContain('No skill named "nope"');
214+ expect(textOf(result)).toContain("`execute`");
215+ expect(textOf(result)).toContain("`pdf-processing`");
216+ });
217+ });
218+});
219+
220+describe("MCP Skills Extension", () => {
221+ it("declares the extension and answers skills/list with digest manifests", async () => {
222+ await withClient(portOf([skill("org"), skill("user", "release-notes")]), async (client) => {
223+ expect(client.getServerCapabilities()?.extensions).toEqual({
224+ [MCP_SKILLS_EXTENSION_ID]: {},
225+ });
226+ const result = await client.request({ method: "skills/list", params: {} }, SkillsListResult);
227+ expect(result.skills.map((s) => s.uri)).toEqual([
228+ "skill://org/pdf-processing/SKILL.md",
229+ "skill://user/release-notes/SKILL.md",
230+ ]);
231+ const [first] = result.skills;
232+ expect(first?.frontmatter).toEqual({
233+ name: "pdf-processing",
234+ description: "Extract text from PDFs. Use when the user mentions PDFs.",
235+ metadata: { version: "1.0" },
236+ });
237+ expect(first?.resources).toEqual([
238+ {
239+ uri: "skill://org/pdf-processing/SKILL.md",
240+ digest: `sha256:${"a".repeat(64)}`,
241+ size: 111,
242+ },
243+ {
244+ uri: "skill://org/pdf-processing/references/FORMS.md",
245+ digest: `sha256:${"b".repeat(64)}`,
246+ size: 26,
247+ },
248+ ]);
249+ });
250+ });
251+
252+ it("answers skills/get by SKILL.md URI and -32602 for anything else", async () => {
253+ await withClient(portOf([skill("org")]), async (client) => {
254+ const result = await client.request(
255+ { method: "skills/get", params: { uri: "skill://org/pdf-processing/SKILL.md" } },
256+ SkillsGetResult,
257+ );
258+ expect(result.skill.uri).toBe("skill://org/pdf-processing/SKILL.md");
259+ await expect(
260+ client.request(
261+ { method: "skills/get", params: { uri: "skill://user/pdf-processing/SKILL.md" } },
262+ SkillsGetResult,
263+ ),
264+ ).rejects.toMatchObject({ code: -32602 });
265+ await expect(
266+ client.request(
267+ {
268+ method: "skills/get",
269+ params: { uri: "skill://org/pdf-processing/references/FORMS.md" },
270+ },
271+ SkillsGetResult,
272+ ),
273+ ).rejects.toMatchObject({ code: -32602 });
274+ });
275+ });
276+
277+ it("serves every file as a skill:// resource and lists one entry per skill", async () => {
278+ await withClient(portOf([skill("org")]), async (client) => {
279+ const listed = await client.listResources();
280+ expect(listed.resources.map((r) => r.uri)).toEqual(["skill://org/pdf-processing/SKILL.md"]);
281+ expect(listed.resources[0]?.mimeType).toBe("text/markdown");
282+
283+ const read = await client.readResource({
284+ uri: "skill://org/pdf-processing/references/FORMS.md",
285+ });
286+ expect(read.contents[0]).toMatchObject({
287+ uri: "skill://org/pdf-processing/references/FORMS.md",
288+ mimeType: "text/markdown",
289+ text: "# Forms\n\nFill every field.",
290+ });
291+ await expect(
292+ client.readResource({ uri: "skill://org/pdf-processing/missing.md" }),
293+ ).rejects.toMatchObject({ code: -32602 });
294+ });
295+ });
296+});
packages/hosts/mcp/src/workspace-skills.ts+268−0
@@ -0,0 +1,268 @@
1+// ---------------------------------------------------------------------------
2+// Workspace skills on the MCP surface.
3+//
4+// A workspace skill is an Agent Skill (a SKILL.md directory, see
5+// https://agentskills.io) saved to Executor by a user, personally or for the
6+// whole org. Every agent connected to this server should be able to find and
7+// load it, so it is served two ways:
8+//
9+// 1. Through the `skills` tool, alongside Executor's own how-to docs. Every
10+// MCP client can call a tool today, so this is the channel that works
11+// everywhere. The tool description carries the catalog (name + description)
12+// so the model knows what exists before it asks.
13+// 2. Through the MCP Skills Extension (SEP-2640): the server declares
14+// `io.modelcontextprotocol/skills`, answers `skills/list` and `skills/get`
15+// with digest manifests, and serves every file as a `skill://` resource.
16+// Clients that adopt the standard pick skills up with no tool call at all.
17+//
18+// Both channels read the same live source (`executor.skills`), so a skill saved
19+// in the console is visible to an already-connected agent on its next call.
20+// ---------------------------------------------------------------------------
21+
22+import { Effect, Option } from "effect";
23+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24+import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
25+import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
26+import * as z from "zod/v4";
27+
28+import {
29+ parseSkillUri,
30+ skillBody,
31+ skillFileUri,
32+ SKILL_MD_PATH,
33+ type Owner,
34+ type Skill,
35+ type SkillFileEntry,
36+ type SkillRef,
37+ type SkillSummary,
38+} from "@executor-js/sdk";
39+
40+/**
41+ * The two reads this surface needs. Structurally satisfied by
42+ * `executor.skills`; hosts pass that so the whole `Executor` never crosses this
43+ * boundary. Both are LIVE: nothing here caches skill content.
44+ */
45+export type McpSkillsPort = {
46+ readonly list: () => Effect.Effect<readonly SkillSummary[], unknown>;
47+ readonly get: (ref: SkillRef) => Effect.Effect<Skill, unknown>;
48+};
49+
50+/** The extension id SEP-2640 assigns, as it appears under `capabilities.extensions`. */
51+export const MCP_SKILLS_EXTENSION_ID = "io.modelcontextprotocol/skills";
52+
53+// ---------------------------------------------------------------------------
54+// Naming
55+// ---------------------------------------------------------------------------
56+
57+/** How a workspace skill is referred to in the `skills` tool: `owner/name`. */
58+export const workspaceSkillLabel = (ref: SkillRef): string => `${ref.owner}/${ref.name}`;
59+
60+/**
61+ * Resolve what the model typed into one workspace skill.
62+ *
63+ * Accepts the bare `name` or the explicit `owner/name`. A bare name that exists
64+ * both personally and for the org resolves to the personal one: that is the
65+ * same shadowing rule local skill directories use (the nearer scope wins), and
66+ * the explicit form is always there to reach the other.
67+ */
68+export const resolveWorkspaceSkill = (
69+ input: string,
70+ skills: readonly SkillSummary[],
71+): SkillSummary | undefined => {
72+ const slash = input.indexOf("/");
73+ if (slash !== -1) {
74+ const owner = input.slice(0, slash);
75+ const name = input.slice(slash + 1);
76+ return skills.find((skill) => skill.owner === owner && skill.name === name);
77+ }
78+ const matches = skills.filter((skill) => skill.name === input);
79+ return matches.find((skill) => skill.owner === "user") ?? matches[0];
80+};
81+
82+// ---------------------------------------------------------------------------
83+// Rendering for the `skills` tool
84+// ---------------------------------------------------------------------------
85+
86+const ownerWord = (owner: Owner): string => (owner === "org" ? "workspace" : "personal");
87+
88+/** One-line catalog entry, the shape both the index and the description use. */
89+const catalogLine = (skill: SkillSummary): string =>
90+ `- \`${skill.name}\` (${ownerWord(skill.owner)}) — ${skill.description}`;
91+
92+/** How many workspace skills the tool description names before it truncates.
93+ * The description is loaded into every session's prompt, so it stays bounded
94+ * no matter how many skills a workspace saves; the index is the full list. */
95+export const SKILL_CATALOG_DESCRIPTION_LIMIT = 40;
96+
97+/**
98+ * The `skills` tool description when workspace skills can be served. Replaces
99+ * the docs-only wording: this IS now a skill reader for the skills saved in
100+ * Executor, and it says so, while still marking the boundary for skills that
101+ * live on the agent's own disk (which it still cannot reach).
102+ */
103+export const renderSkillsToolDescription = (skills: readonly SkillSummary[]): string => {
104+ const shown = skills.slice(0, SKILL_CATALOG_DESCRIPTION_LIMIT);
105+ const hidden = skills.length - shown.length;
106+ const catalog =
107+ skills.length === 0
108+ ? ["No workspace skills are saved yet; the catalog below is Executor's docs only."]
109+ : [
110+ "Skills saved in this Executor workspace — load one when a task matches its description:",
111+ ...shown.map(catalogLine),
112+ ...(hidden > 0 ? [`- …and ${hidden} more; call with no name for the full index.`] : []),
113+ ];
114+ return [
115+ "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.",
116+ 'Call `skills({ name: "<name>" })` to load a skill\'s instructions; the result lists its bundled files, which you read with `skills({ name, file: "<path>" })`. A name that exists both personally and for the workspace resolves to the personal one; use `owner/name` (`org/<name>` or `user/<name>`) to be explicit.',
117+ 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool. Call with no name to list everything.',
118+ "",
119+ ...catalog,
120+ ].join("\n");
121+};
122+
123+/** The workspace section appended to the `skills` index. */
124+export const renderWorkspaceSkillsIndex = (skills: readonly SkillSummary[]): string =>
125+ skills.length === 0
126+ ? "No skills are saved in this Executor workspace yet. Add one in the console under Skills and it appears here for every connected agent."
127+ : [
128+ 'Skills saved in this Executor workspace. Load one with `skills({ name: "<name>" })`; read a bundled file with `skills({ name, file: "<path>" })`.',
129+ "",
130+ ...skills.map(catalogLine),
131+ ].join("\n");
132+
133+const bundledFiles = (skill: Skill): readonly SkillFileEntry[] =>
134+ skill.files.filter((file) => file.path !== SKILL_MD_PATH);
135+
136+/**
137+ * What the model receives when it loads a workspace skill: the SKILL.md body
138+ * with the frontmatter stripped (name and description were already in the
139+ * catalog), wrapped in identifying tags, followed by the list of bundled files
140+ * and how to read one. The files themselves are NOT inlined — that is the
141+ * progressive disclosure the standard asks for.
142+ */
143+export const renderWorkspaceSkill = (skill: Skill): string => {
144+ const files = bundledFiles(skill);
145+ const resources =
146+ files.length === 0
147+ ? []
148+ : [
149+ "",
150+ "<skill_resources>",
151+ ...files.map((file) => ` <file>${file.path}</file>`),
152+ "</skill_resources>",
153+ `Read a bundled file with \`skills({ name: "${workspaceSkillLabel(skill)}", file: "<path>" })\`. Relative paths in the instructions above are relative to the skill's root.`,
154+ ];
155+ return [
156+ `<skill_content name="${skill.name}" owner="${skill.owner}">`,
157+ skillBody(skill),
158+ ...resources,
159+ "</skill_content>",
160+ ].join("\n");
161+};
162+
163+// ---------------------------------------------------------------------------
164+// MCP Skills Extension (SEP-2640)
165+// ---------------------------------------------------------------------------
166+
167+/** MIME type for a skill file, by extension. Markdown is what the standard is
168+ * made of; everything else is served as plain text. */
169+export const skillFileMimeType = (path: string): string =>
170+ path.endsWith(".md") ? "text/markdown" : "text/plain";
171+
172+/** One `skills/list` / `skills/get` entry: the SKILL.md URI, the verbatim
173+ * frontmatter, and the complete digest manifest. */
174+export const skillEntry = (skill: SkillSummary) => ({
175+ uri: skillFileUri(skill, SKILL_MD_PATH),
176+ frontmatter: skill.frontmatter,
177+ resources: skill.files.map((file) => ({
178+ uri: skillFileUri(skill, file.path),
179+ digest: file.digest,
180+ size: file.size,
181+ })),
182+});
183+
184+const SkillsListRequestSchema = z.object({
185+ method: z.literal("skills/list"),
186+ params: z.object({ cursor: z.string().optional() }).loose().optional(),
187+});
188+
189+const SkillsGetRequestSchema = z.object({
190+ method: z.literal("skills/get"),
191+ params: z.object({ uri: z.string() }).loose(),
192+});
193+
194+const SKILL_RESOURCE_TEMPLATE = "skill://{owner}/{name}/{+path}";
195+
196+/** Runs an Effect at an SDK callback edge with the server's captured context. */
197+export type RunAtEdge = <A>(effect: Effect.Effect<A, unknown>) => Promise<A>;
198+
199+/**
200+ * Register the extension's methods and the `skill://` resource space on a
201+ * server that has already declared the `resources` capability and the
202+ * extension under `capabilities.extensions`.
203+ *
204+ * `skills/list` never paginates: a workspace's catalog is small by
205+ * construction (each entry is one saved row), and an unpaginated listing is
206+ * what every client handles.
207+ */
208+export const registerWorkspaceSkills = (
209+ server: McpServer,
210+ port: McpSkillsPort,
211+ run: RunAtEdge,
212+): void => {
213+ const list = () => run(port.list());
214+ // The SDK turns a thrown McpError into the JSON-RPC error the method
215+ // requires (-32602 for an unknown skill or file), so the not-found path is a
216+ // failed Effect run through the same edge as every other read.
217+ const notFound = (uri: string) =>
218+ run(
219+ Effect.fail(new McpError(ErrorCode.InvalidParams, `Unknown skill resource: ${uri}`)),
220+ ) as Promise<never>;
221+
222+ server.server.setRequestHandler(SkillsListRequestSchema, async () => ({
223+ skills: (await list()).map(skillEntry),
224+ }));
225+
226+ server.server.setRequestHandler(SkillsGetRequestSchema, async ({ params }) => {
227+ const parsed = parseSkillUri(params.uri);
228+ if (Option.isNone(parsed) || parsed.value.path !== SKILL_MD_PATH) return notFound(params.uri);
229+ const { owner, name } = parsed.value;
230+ const skill = (await list()).find((entry) => entry.owner === owner && entry.name === name);
231+ if (!skill) return notFound(params.uri);
232+ return { skill: skillEntry(skill) };
233+ });
234+
235+ server.registerResource(
236+ "Workspace skills",
237+ new ResourceTemplate(SKILL_RESOURCE_TEMPLATE, {
238+ // `resources/list` shows one entry per skill — its SKILL.md — so a
239+ // resource browser sees the skills, not every bundled file.
240+ list: async () => ({
241+ resources: (await list()).map((skill) => ({
242+ uri: skillFileUri(skill, SKILL_MD_PATH),
243+ name: skill.name,
244+ description: skill.description,
245+ mimeType: skillFileMimeType(SKILL_MD_PATH),
246+ })),
247+ }),
248+ }),
249+ {
250+ description: "Agent Skills saved in this Executor workspace, one file per resource.",
251+ mimeType: "text/markdown",
252+ },
253+ async (uri) => {
254+ const href = uri.toString();
255+ const parsed = parseSkillUri(href);
256+ if (Option.isNone(parsed) || parsed.value.path === "") return notFound(href);
257+ const { owner, name, path } = parsed.value;
258+ const skill = await run(
259+ port.get({ owner, name }).pipe(Effect.catchCause(() => Effect.succeed(null))),
260+ );
261+ const file = skill?.files.find((entry) => entry.path === path);
262+ if (!file) return notFound(href);
263+ return {
264+ contents: [{ uri: href, mimeType: skillFileMimeType(path), text: file.content }],
265+ };
266+ },
267+ );
268+};
packages/react/src/api/atoms.tsx+34−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 type OAuthGrant,
1313 type Owner,
1414 type ProviderItemId,
15+ type SkillName,
1516 type TokenEndpointAuthMethod,
1617 type ToolAddress,
1718 } from "@executor-js/sdk/shared";
@@ -173,6 +174,32 @@ export const artifactAtom = Atom.family((artifactId: ArtifactId) => · 155 unmodified lines
173174 }),
174175 );
175176
177+// ---------------------------------------------------------------------------
178+// Agent Skills — SKILL.md directories saved to the workspace, owner-scoped the
179+// same way connections are.
180+// ---------------------------------------------------------------------------
181+
182+export const skillsAtom = ExecutorApiClient.query("skills", "list", {
183+ timeToLive: "30 seconds",
184+ reactivityKeys: [ReactivityKey.skills],
185+});
186+
187+/**
188+ * One skill WITH the content of every file it bundles. `skills.list` returns
189+ * the manifest only (path, size, digest), so the detail and edit views have to
190+ * fetch the row rather than derive it from the list.
191+ *
192+ * `Atom.family` (not a bare arrow) because the pages rebuild the `{owner,name}`
193+ * key object on every render — a fresh atom per render would refetch in a loop.
194+ */
195+export const skillAtom = Atom.family((ref: { readonly owner: Owner; readonly name: SkillName }) =>
196+ ExecutorApiClient.query("skills", "get", {
197+ params: { owner: ref.owner, name: ref.name },
198+ timeToLive: "30 seconds",
199+ reactivityKeys: [ReactivityKey.skills],
200+ }),
201+);
202+
176203 // ---------------------------------------------------------------------------
177204 // Mutation atoms — reactivityKeys must be passed at call site (effect-atom
178205 // does not accept them at definition time). See `reactivity-keys.tsx` for the
@@ -281,6 +308,13 @@ export const removeArtifact = ExecutorApiClient.mutation("artifacts", "remove"); · 102 unmodified lines
281308 */
282309 export const setArtifactPreview = ExecutorApiClient.mutation("artifacts", "setPreview");
283310
311+/** Create or replace a skill in place. The name is read from the uploaded
312+ * SKILL.md, never sent separately, so a renamed frontmatter saves a NEW skill.
313+ * Pass `reactivityKeys: skillWriteKeys` at the call site. */
314+export const saveSkill = ExecutorApiClient.mutation("skills", "save");
315+
316+export const removeSkill = ExecutorApiClient.mutation("skills", "remove");
317+
284318 export const resumeExecution = ExecutorApiClient.mutation("executions", "resume");
285319
286320 /** Run codemode source (`POST /executions`). Used by the per-tool Run/Test panel
packages/react/src/api/reactivity-keys.tsx+7−0
@@ -28,6 +28,8 @@ export const ReactivityKey = { · 27 unmodified lines
2828 policies: "policies",
2929 /** Saved generative-UI artifacts. */
3030 artifacts: "artifacts",
31+ /** Agent Skills (SKILL.md directories) saved to the workspace. */
32+ skills: "skills",
3133 /** Registered OAuth clients (apps). */
3234 oauthClients: "oauth-clients",
3335 /** An integration's declared health check (the operation/identity-field spec). */
@@ -80,6 +82,11 @@ export const policyWriteKeys = [ReactivityKey.policies, ReactivityKey.tools] as · 46 unmodified lines
8082 * resource — nothing else reads them — so they invalidate only themselves. */
8183 export const artifactWriteKeys = [ReactivityKey.artifacts] as const;
8284
85+/** Mutations that save or delete a skill. Nothing else in the console reads
86+ * skills (the MCP host serves them straight from storage), so they invalidate
87+ * only themselves. */
88+export const skillWriteKeys = [ReactivityKey.skills] as const;
89+
8390 /** Cloud-only: org membership mutations. */
8491 export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const;
8592
packages/react/src/console-routes.ts+11−0
@@ -46,6 +46,9 @@ export const CONSOLE_ROUTE_PATHS = [ · 45 unmodified lines
4646 "/toolkits/$toolkitSlug",
4747 "/artifacts",
4848 "/artifacts/$artifactId",
49+ "/skills",
50+ "/skills/new",
51+ "/skills/$skillOwner/$skillName",
4952 "/resume/$executionId",
5053 "/plugins/$pluginId/$",
5154 ] as const;
@@ -91,6 +94,14 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 39 unmodified lines
9194 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
9295 ["/artifacts", route("/artifacts", file("artifacts.tsx"))],
9396 ["/artifacts/$artifactId", route("/artifacts/$artifactId", file("artifacts.$artifactId.tsx"))],
97+ // A skill is addressed by `(owner, name)`, so both segments are in the URL:
98+ // a personal and a workspace skill can share a name and stay distinct.
99+ ["/skills", route("/skills", file("skills.tsx"))],
100+ ["/skills/new", route("/skills/new", file("skills.new.tsx"))],
101+ [
102+ "/skills/$skillOwner/$skillName",
103+ route("/skills/$skillOwner/$skillName", file("skills.$skillOwner.$skillName.tsx")),
104+ ],
94105 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
95106 ["/plugins/$pluginId/$", route("/plugins/$pluginId/$", file("plugins.$pluginId.$.tsx"))],
96107 ];
packages/react/src/multiplayer/shell.tsx+1−0
@@ -52,6 +52,7 @@ export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ · 51 unmodified lines
5252 { to: "/secrets", label: "Providers" },
5353 { to: "/policies", label: "Policies" },
5454 { to: "/toolkits", label: "Toolkits" },
55+ { to: "/skills", label: "Skills" },
5556 { to: "/artifacts", label: "Artifacts" },
5657 ];
5758
packages/react/src/pages/skill-detail.tsx+285−0
@@ -0,0 +1,285 @@
1+import { useMemo } from "react";
2+import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
3+import { Link, useNavigate } from "@tanstack/react-router";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import * as Exit from "effect/Exit";
6+import * as Result from "effect/Result";
7+import { toast } from "sonner";
8+import {
9+ parseSkillMarkdown,
10+ skillFileUri,
11+ SKILL_MD_PATH,
12+ type Owner,
13+ type SkillName,
14+} from "@executor-js/sdk/shared";
15+
16+import { removeSkill, skillAtom } from "../api/atoms";
17+import { messageFromExit } from "../api/error-reporting";
18+import { skillWriteKeys } from "../api/reactivity-keys";
19+import {
20+ AlertDialog,
21+ AlertDialogAction,
22+ AlertDialogCancel,
23+ AlertDialogContent,
24+ AlertDialogDescription,
25+ AlertDialogFooter,
26+ AlertDialogHeader,
27+ AlertDialogTitle,
28+ AlertDialogTrigger,
29+} from "../components/alert-dialog";
30+import { Button } from "../components/button";
31+import { CopyButton } from "../components/copy-button";
32+import { ErrorState } from "../components/error-state";
33+import { Markdown } from "../components/markdown";
34+import { PageContainer, PageHeader } from "../components/page";
35+import { isAsyncResultLoading } from "../lib/async-result";
36+import { useExecutorDocumentTitle } from "../lib/document-title";
37+import { formatRelativeTime } from "../lib/relative-time";
38+import { SkillOwnerTag } from "./skills";
39+
40+interface SkillFileRow {
41+ readonly path: string;
42+ readonly size: number;
43+ readonly digest: string;
44+ readonly content: string;
45+}
46+
47+const BackLink = () => (
48+ <Button asChild variant="ghost" size="sm" className="-ml-3 mb-4 text-muted-foreground">
49+ <Link to="/{-$orgSlug}/skills">Skills</Link>
50+ </Button>
51+);
52+
53+/** A URL that names no skill this host could ever have (a bad owner segment). */
54+export function SkillMissingPage() {
55+ useExecutorDocumentTitle("Skill");
56+ return (
57+ <PageContainer>
58+ <BackLink />
59+ <p className="text-sm text-muted-foreground">
60+ That isn&apos;t a skill address. A skill lives at{" "}
61+ <span className="font-mono text-foreground">/skills/user/&lt;name&gt;</span> or{" "}
62+ <span className="font-mono text-foreground">/skills/org/&lt;name&gt;</span>.
63+ </p>
64+ </PageContainer>
65+ );
66+}
67+
68+/** A labelled row of machine metadata — mono value, quiet mono key. */
69+function MetaRow(props: { readonly label: string; readonly children: React.ReactNode }) {
70+ return (
71+ <div className="flex gap-4 py-2">
72+ <dt className="w-40 shrink-0 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
73+ {props.label}
74+ </dt>
75+ <dd className="min-w-0 flex-1 text-sm text-foreground">{props.children}</dd>
76+ </div>
77+ );
78+}
79+
80+/** One bundled file: path and size always visible, content behind a disclosure
81+ * so a skill with a dozen references still reads as a list. */
82+function SkillFileEntry(props: { readonly file: SkillFileRow }) {
83+ const { file } = props;
84+ return (
85+ <details className="group/skill-file px-4 py-2.5">
86+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 outline-none focus-visible:underline">
87+ <span className="truncate font-mono text-[13px] text-foreground">{file.path}</span>
88+ <span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground">
89+ {file.size} B
90+ </span>
91+ </summary>
92+ <pre className="mt-2.5 max-h-96 overflow-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-[12px] leading-relaxed whitespace-pre-wrap text-foreground">
93+ {file.content}
94+ </pre>
95+ </details>
96+ );
97+}
98+
99+/**
100+ * What an agent gets. The console is where a person decides whether a skill is
101+ * doing its job, and they cannot judge that without seeing the two addresses
102+ * their agent will actually use.
103+ */
104+function AgentAddressing(props: { readonly owner: Owner; readonly name: string }) {
105+ const toolCall = `skills({ name: "${props.name}" })`;
106+ const uri = skillFileUri({ owner: props.owner, name: props.name }, SKILL_MD_PATH);
107+ return (
108+ <section>
109+ <h2 className="mb-3 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
110+ Agents see this as
111+ </h2>
112+ <div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card">
113+ {[toolCall, uri].map((value) => (
114+ <div key={value} className="flex items-center justify-between gap-3 px-4 py-2.5">
115+ <code className="truncate font-mono text-[12px] text-foreground">{value}</code>
116+ <CopyButton value={value} />
117+ </div>
118+ ))}
119+ </div>
120+ <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
121+ The tool call works on every MCP client today. The{" "}
122+ <span className="font-mono">skill://</span> resource is for clients that speak the MCP
123+ Skills extension.
124+ </p>
125+ </section>
126+ );
127+}
128+
129+export function SkillDetailPage(props: { readonly owner: Owner; readonly name: SkillName }) {
130+ // Rebuilt every render, so it MUST go through the atom family — see
131+ // `skillAtom`. `useMemo` keeps the key object stable for the hook deps too.
132+ const ref = useMemo(() => ({ owner: props.owner, name: props.name }), [props.owner, props.name]);
133+ const skill = useAtomValue(skillAtom(ref));
134+ const refresh = useAtomRefresh(skillAtom(ref));
135+ const doRemove = useAtomSet(removeSkill, { mode: "promiseExit" });
136+ const navigate = useNavigate();
137+
138+ useExecutorDocumentTitle(props.name || "Skill");
139+
140+ const handleRemove = async () => {
141+ const exit = await doRemove({ params: ref, reactivityKeys: skillWriteKeys });
142+ if (Exit.isFailure(exit)) {
143+ toast.error(messageFromExit(exit, "Couldn't delete the skill. Try again."));
144+ return;
145+ }
146+ toast.success("Skill deleted");
147+ // `params` is omitted so the router keeps the active org slug.
148+ await navigate({ to: "/{-$orgSlug}/skills" });
149+ };
150+
151+ return (
152+ <PageContainer>
153+ <BackLink />
154+
155+ {isAsyncResultLoading(skill) ? (
156+ <p className="text-sm text-muted-foreground">Loading skill…</p>
157+ ) : (
158+ AsyncResult.match(skill, {
159+ onInitial: () => <p className="text-sm text-muted-foreground">Loading skill…</p>,
160+ onFailure: () => (
161+ <ErrorState
162+ message="This skill isn't available. It may have been deleted."
163+ onRetry={refresh}
164+ />
165+ ),
166+ onSuccess: ({ value }) => {
167+ const files = value.files as ReadonlyArray<SkillFileRow>;
168+ const skillMd = files.find((file) => file.path === SKILL_MD_PATH);
169+ const parsed = skillMd ? parseSkillMarkdown(skillMd.content) : undefined;
170+ const body =
171+ parsed && Result.isSuccess(parsed) ? parsed.success.body : (skillMd?.content ?? "");
172+ // Name and description have their own place in the header; the rest
173+ // of the frontmatter is whatever the author wrote and is shown
174+ // verbatim, because that is what the MCP host hands to agents.
175+ const extraFrontmatter = Object.entries(value.frontmatter).filter(
176+ ([key]) => key !== "name" && key !== "description",
177+ );
178+
179+ return (
180+ <div className="space-y-10">
181+ <PageHeader
182+ className="mb-0"
183+ title={<span className="font-mono text-[1.6rem]">{value.name}</span>}
184+ description={value.description}
185+ actions={
186+ <>
187+ <Button asChild variant="outline" size="sm">
188+ <Link
189+ to="/{-$orgSlug}/skills/$skillOwner/$skillName"
190+ params={{ skillOwner: value.owner, skillName: value.name }}
191+ search={{ edit: true }}
192+ >
193+ Edit
194+ </Link>
195+ </Button>
196+ <AlertDialog>
197+ <AlertDialogTrigger asChild>
198+ <Button
199+ type="button"
200+ variant="outline"
201+ size="sm"
202+ className="text-muted-foreground hover:text-destructive"
203+ >
204+ Delete
205+ </Button>
206+ </AlertDialogTrigger>
207+ <AlertDialogContent size="sm">
208+ <AlertDialogHeader>
209+ <AlertDialogTitle>Delete {value.name}?</AlertDialogTitle>
210+ <AlertDialogDescription>
211+ This removes the skill and every file in it. Agents will no longer
212+ find it by name.
213+ </AlertDialogDescription>
214+ </AlertDialogHeader>
215+ <AlertDialogFooter>
216+ <AlertDialogCancel>Cancel</AlertDialogCancel>
217+ <AlertDialogAction
218+ variant="destructive"
219+ onClick={() => void handleRemove()}
220+ >
221+ Delete Skill
222+ </AlertDialogAction>
223+ </AlertDialogFooter>
224+ </AlertDialogContent>
225+ </AlertDialog>
226+ </>
227+ }
228+ >
229+ <div className="mt-3 flex items-center gap-2">
230+ <SkillOwnerTag owner={value.owner} />
231+ <span className="font-mono text-[11px] text-muted-foreground">
232+ Updated {formatRelativeTime(value.updatedAt)}
233+ </span>
234+ </div>
235+ </PageHeader>
236+
237+ {extraFrontmatter.length > 0 ? (
238+ <section>
239+ <h2 className="mb-1 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
240+ Frontmatter
241+ </h2>
242+ <dl className="divide-y divide-border">
243+ {extraFrontmatter.map(([key, fieldValue]) => (
244+ <MetaRow key={key} label={key}>
245+ <span className="font-mono text-[12px] break-words">
246+ {typeof fieldValue === "string"
247+ ? fieldValue
248+ : JSON.stringify(fieldValue)}
249+ </span>
250+ </MetaRow>
251+ ))}
252+ </dl>
253+ </section>
254+ ) : null}
255+
256+ <section>
257+ <h2 className="mb-3 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
258+ Instructions
259+ </h2>
260+ <div className="rounded-lg border border-border bg-card px-5 py-4 text-sm leading-relaxed">
261+ <Markdown>{body}</Markdown>
262+ </div>
263+ </section>
264+
265+ <section>
266+ <h2 className="mb-3 flex items-center font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
267+ Files
268+ <span className="ml-2 font-normal tabular-nums">{files.length}</span>
269+ </h2>
270+ <div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card">
271+ {files.map((file) => (
272+ <SkillFileEntry key={file.path} file={file} />
273+ ))}
274+ </div>
275+ </section>
276+
277+ <AgentAddressing owner={value.owner} name={value.name} />
278+ </div>
279+ );
280+ },
281+ })
282+ )}
283+ </PageContainer>
284+ );
285+}
packages/react/src/pages/skill-editor.tsx+458−0
@@ -0,0 +1,458 @@
1+import { useEffect, useMemo, useRef, useState } from "react";
2+import { useAtomSet, useAtomValue } from "@effect/atom-react";
3+import { Link, useNavigate } from "@tanstack/react-router";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import * as Exit from "effect/Exit";
6+import * as Option from "effect/Option";
7+import * as Result from "effect/Result";
8+import * as Schema from "effect/Schema";
9+import {
10+ isValidSkillFilePath,
11+ parseSkillMarkdown,
12+ SKILL_MD_PATH,
13+ type Owner,
14+ type SkillName,
15+} from "@executor-js/sdk/shared";
16+
17+import { saveSkill, skillAtom } from "../api/atoms";
18+import { messageFromExit } from "../api/error-reporting";
19+import { useOrganizationId } from "../api/organization-context";
20+import { skillWriteKeys } from "../api/reactivity-keys";
21+import { Button } from "../components/button";
22+import { FieldLabel } from "../components/field";
23+import { Input } from "../components/input";
24+import { PageContainer, PageHeader } from "../components/page";
25+import { Textarea } from "../components/textarea";
26+import { FormErrorAlert } from "../lib/integration-add";
27+import { useExecutorDocumentTitle } from "../lib/document-title";
28+import {
29+ connectionOwnerOptionsForAccess,
30+ ConnectionOwnerDropdown,
31+ defaultConnectionOwnerForHost,
32+ normalizeConnectionOwner,
33+} from "../plugins/connection-owner";
34+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
35+
36+/** A skill that already parses, so the validation panel is green from the first
37+ * keystroke and the author edits a working example instead of guessing. */
38+const NEW_SKILL_TEMPLATE = `---
39+name: my-skill
40+description: What this skill does and when an agent should load it.
41+---
42+
43+# My skill
44+
45+Write the instructions an agent should follow here.
46+
47+## Steps
48+
49+1. ...
50+`;
51+
52+/** Files above this are almost never instructions, and the whole skill is
53+ * capped at 1 MiB anyway — better to say which file was dropped than to fail
54+ * the save with a total-size error. */
55+const MAX_IMPORT_FILE_BYTES = 512 * 1024;
56+
57+const SkillErrorReason = Schema.Struct({ reason: Schema.String });
58+const decodeReason = Schema.decodeUnknownOption(SkillErrorReason);
59+
60+/** `InvalidSkillError` carries the actionable sentence in `reason`; its
61+ * `message` prefixes it with "Invalid skill:", which the alert's placement
62+ * already says. Everything else (org-write denied, transport) uses `message`. */
63+const saveErrorMessage = (exit: Exit.Exit<unknown, unknown>): string =>
64+ Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeReason), {
65+ onNone: () => messageFromExit(exit, "Couldn't save the skill. Try again."),
66+ onSome: ({ reason }) => reason,
67+ });
68+
69+interface ExtraFileRow {
70+ /** Stable across edits so a row keeps focus while its path is retyped. */
71+ readonly id: number;
72+ readonly path: string;
73+ readonly content: string;
74+}
75+
76+interface EditorSeed {
77+ readonly owner: Owner | null;
78+ readonly skillMd: string;
79+ readonly extras: readonly ExtraFileRow[];
80+ /** The `(owner, name)` this editor opened, when it is editing one. */
81+ readonly original: { readonly owner: Owner; readonly name: string } | null;
82+}
83+
84+export function SkillEditorPage(props: {
85+ readonly editing?: { readonly owner: Owner; readonly name: SkillName } | undefined;
86+}) {
87+ if (props.editing === undefined) {
88+ return (
89+ <SkillEditorForm
90+ seed={{ owner: null, skillMd: NEW_SKILL_TEMPLATE, extras: [], original: null }}
91+ />
92+ );
93+ }
94+ return <SkillEditorLoader editing={props.editing} />;
95+}
96+
97+/** Editing needs the stored file CONTENTS, which only `skills.get` returns. The
98+ * form is mounted once the row lands so its state can be seeded directly,
99+ * rather than syncing an effect against a changing fetch. */
100+function SkillEditorLoader(props: {
101+ readonly editing: { readonly owner: Owner; readonly name: SkillName };
102+}) {
103+ const ref = useMemo(
104+ () => ({ owner: props.editing.owner, name: props.editing.name }),
105+ [props.editing.owner, props.editing.name],
106+ );
107+ const skill = useAtomValue(skillAtom(ref));
108+
109+ if (!AsyncResult.isSuccess(skill)) {
110+ return (
111+ <PageContainer>
112+ <p className="text-sm text-muted-foreground">
113+ {AsyncResult.isFailure(skill) ? "This skill isn't available." : "Loading skill…"}
114+ </p>
115+ </PageContainer>
116+ );
117+ }
118+
119+ const files = skill.value.files as ReadonlyArray<{
120+ readonly path: string;
121+ readonly content: string;
122+ }>;
123+ const skillMd = files.find((file) => file.path === SKILL_MD_PATH);
124+ return (
125+ <SkillEditorForm
126+ seed={{
127+ owner: skill.value.owner,
128+ skillMd: skillMd?.content ?? NEW_SKILL_TEMPLATE,
129+ extras: files
130+ .filter((file) => file.path !== SKILL_MD_PATH)
131+ .map((file, index) => ({ id: index, path: file.path, content: file.content })),
132+ original: { owner: skill.value.owner, name: skill.value.name },
133+ }}
134+ />
135+ );
136+}
137+
138+function SkillEditorForm(props: { readonly seed: EditorSeed }) {
139+ const { seed } = props;
140+ const editing = seed.original !== null;
141+ useExecutorDocumentTitle(editing ? `Edit ${seed.original?.name ?? ""}` : "New skill");
142+
143+ const organizationId = useOrganizationId();
144+ const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections();
145+ const ownerOptions = connectionOwnerOptionsForAccess(
146+ organizationId,
147+ canCreateWorkspaceConnections,
148+ );
149+ const [owner, setOwner] = useState<Owner>(
150+ seed.owner ?? defaultConnectionOwnerForHost(organizationId),
151+ );
152+ const [skillMd, setSkillMd] = useState(seed.skillMd);
153+ const [extras, setExtras] = useState<readonly ExtraFileRow[]>(seed.extras);
154+ const [saving, setSaving] = useState(false);
155+ const [error, setError] = useState<string | null>(null);
156+ const [importNotice, setImportNotice] = useState<string | null>(null);
157+ const nextRowId = useRef(seed.extras.length);
158+ const folderInput = useRef<HTMLInputElement | null>(null);
159+ const navigate = useNavigate();
160+
161+ // `webkitdirectory` is what turns a file input into a folder picker. React's
162+ // typings don't declare it, so it is set on the element rather than in JSX.
163+ useEffect(() => {
164+ folderInput.current?.setAttribute("webkitdirectory", "");
165+ }, []);
166+
167+ // Always keep the selection valid against the options this host offers — on
168+ // local there is exactly one, and the picker hides itself.
169+ const activeOwner = normalizeConnectionOwner(owner, ownerOptions);
170+ const parsed = useMemo(() => parseSkillMarkdown(skillMd), [skillMd]);
171+ const parsedName = Result.isSuccess(parsed) ? parsed.success.name : null;
172+ // Identity is `(owner, name)` and `save` reads the name out of the file, so
173+ // retitling the frontmatter or moving the owner writes a SECOND skill and
174+ // leaves this one alone. Say that before the save, not after.
175+ const forksExisting =
176+ seed.original !== null &&
177+ parsedName !== null &&
178+ (parsedName !== seed.original.name || activeOwner !== seed.original.owner);
179+
180+ const doSave = useAtomSet(saveSkill, { mode: "promiseExit" });
181+
182+ const addRow = () => {
183+ setExtras((rows) => [...rows, { id: nextRowId.current++, path: "", content: "" }]);
184+ };
185+
186+ const updateRow = (id: number, patch: Partial<Omit<ExtraFileRow, "id">>) => {
187+ setExtras((rows) => rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
188+ };
189+
190+ const removeRow = (id: number) => {
191+ setExtras((rows) => rows.filter((row) => row.id !== id));
192+ };
193+
194+ /**
195+ * Read a picked folder into the editor.
196+ *
197+ * A folder pick reports every file as `<folder>/<path>`, so the wrapping
198+ * segment is stripped — otherwise SKILL.md would land at `my-skill/SKILL.md`
199+ * and the save would reject a skill with no root file. Binary and oversized
200+ * files are dropped rather than mangled into UTF-8, and dotfiles go with them
201+ * (`.DS_Store` is in every folder a Mac has ever opened).
202+ */
203+ const importFolder = async (picked: FileList) => {
204+ const chosen = Array.from(picked);
205+ const relative = chosen.map((file) => file.webkitRelativePath || file.name);
206+ const topSegments = new Set(relative.map((path) => path.split("/")[0]));
207+ const strip = topSegments.size === 1 && relative.every((path) => path.includes("/"));
208+
209+ const imported: Array<{ path: string; content: string }> = [];
210+ const skipped: string[] = [];
211+ for (const [index, file] of chosen.entries()) {
212+ const raw = relative[index] ?? file.name;
213+ const path = strip ? raw.split("/").slice(1).join("/") : raw;
214+ if (
215+ path === "" ||
216+ !isValidSkillFilePath(path) ||
217+ path.split("/").some((segment) => segment.startsWith(".")) ||
218+ file.size > MAX_IMPORT_FILE_BYTES
219+ ) {
220+ skipped.push(raw);
221+ continue;
222+ }
223+ const content = await file.text();
224+ // A NUL byte is the cheap, reliable tell that this was never text.
225+ if (content.includes("\u0000")) {
226+ skipped.push(raw);
227+ continue;
228+ }
229+ imported.push({ path, content });
230+ }
231+
232+ const rootFile = imported.find((file) => file.path === SKILL_MD_PATH);
233+ if (rootFile) setSkillMd(rootFile.content);
234+ setExtras(
235+ imported
236+ .filter((file) => file.path !== SKILL_MD_PATH)
237+ .map((file) => ({ id: nextRowId.current++, path: file.path, content: file.content })),
238+ );
239+
240+ const parts = [`Imported ${imported.length} ${imported.length === 1 ? "file" : "files"}.`];
241+ if (!rootFile) parts.push("No SKILL.md in that folder — write one below.");
242+ if (skipped.length > 0) {
243+ parts.push(
244+ `Skipped ${skipped.length} (binary, hidden, or over 512 KiB): ${skipped.slice(0, 5).join(", ")}${skipped.length > 5 ? "…" : ""}.`,
245+ );
246+ }
247+ setImportNotice(parts.join(" "));
248+ };
249+
250+ const submit = async () => {
251+ if (!Result.isSuccess(parsed) || saving) return;
252+ setSaving(true);
253+ setError(null);
254+ const exit = await doSave({
255+ payload: {
256+ owner: activeOwner,
257+ files: [
258+ { path: SKILL_MD_PATH, content: skillMd },
259+ ...extras
260+ .filter((row) => row.path.trim() !== "")
261+ .map((row) => ({ path: row.path.trim(), content: row.content })),
262+ ],
263+ },
264+ reactivityKeys: skillWriteKeys,
265+ });
266+ setSaving(false);
267+ if (Exit.isFailure(exit)) {
268+ setError(saveErrorMessage(exit));
269+ return;
270+ }
271+ // `params` names the saved row, not the one the editor opened — the save
272+ // may have written a different name than the URL carried.
273+ await navigate({
274+ to: "/{-$orgSlug}/skills/$skillOwner/$skillName",
275+ params: { skillOwner: exit.value.owner, skillName: exit.value.name },
276+ search: {},
277+ });
278+ };
279+
280+ return (
281+ <PageContainer>
282+ <Button asChild variant="ghost" size="sm" className="-ml-3 mb-4 text-muted-foreground">
283+ <Link to="/{-$orgSlug}/skills">Skills</Link>
284+ </Button>
285+
286+ <PageHeader
287+ title={editing ? "Edit skill" : "New skill"}
288+ description={
289+ editing
290+ ? "Change the instructions or the files that travel with them. Agents pick the new version up on their next load."
291+ : "Write a SKILL.md, or import a folder you already have. Every agent connected to this workspace can load it by name."
292+ }
293+ actions={
294+ <Button
295+ type="button"
296+ size="sm"
297+ loading={saving}
298+ disabled={!Result.isSuccess(parsed)}
299+ onClick={() => void submit()}
300+ >
301+ Save Skill
302+ </Button>
303+ }
304+ />
305+
306+ <div className="space-y-8">
307+ {error ? <FormErrorAlert message={error} /> : null}
308+
309+ <ConnectionOwnerDropdown
310+ value={activeOwner}
311+ options={ownerOptions}
312+ onChange={setOwner}
313+ label="Shared with"
314+ help="Personal skills are yours alone. Workspace skills load for every agent in this workspace."
315+ className="max-w-xs space-y-1.5"
316+ />
317+
318+ <section className="space-y-2">
319+ <div className="flex items-end justify-between gap-3">
320+ <div>
321+ <FieldLabel className="text-[11px]">SKILL.md</FieldLabel>
322+ <p className="mt-1 text-xs text-muted-foreground">
323+ YAML frontmatter with <span className="font-mono">name</span> and{" "}
324+ <span className="font-mono">description</span>, then the instructions.
325+ </p>
326+ </div>
327+ <Button
328+ type="button"
329+ variant="outline"
330+ size="sm"
331+ onClick={() => folderInput.current?.click()}
332+ >
333+ Import Folder
334+ </Button>
335+ <Input
336+ ref={folderInput}
337+ type="file"
338+ multiple
339+ className="hidden"
340+ onChange={(event) => {
341+ const picked = event.target.files;
342+ if (picked && picked.length > 0) void importFolder(picked);
343+ // Clear it so picking the SAME folder again still fires.
344+ event.target.value = "";
345+ }}
346+ />
347+ </div>
348+ {importNotice ? <p className="text-xs text-muted-foreground">{importNotice}</p> : null}
349+ <Textarea
350+ value={skillMd}
351+ spellCheck={false}
352+ onChange={(event) => setSkillMd(event.target.value)}
353+ className="min-h-96 font-mono text-[12px] leading-relaxed"
354+ aria-label="SKILL.md"
355+ />
356+ <ValidationPanel parsed={parsed} forksExisting={forksExisting} original={seed.original} />
357+ </section>
358+
359+ <section className="space-y-2">
360+ <div className="flex items-end justify-between gap-3">
361+ <div>
362+ <FieldLabel className="text-[11px]">Additional files</FieldLabel>
363+ <p className="mt-1 text-xs text-muted-foreground">
364+ References and scripts the instructions point at, by relative path.
365+ </p>
366+ </div>
367+ <Button type="button" variant="outline" size="sm" onClick={addRow}>
368+ Add File
369+ </Button>
370+ </div>
371+ {extras.length === 0 ? (
372+ <p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-muted-foreground">
373+ No additional files. A SKILL.md on its own is a complete skill.
374+ </p>
375+ ) : (
376+ <ul className="space-y-3">
377+ {extras.map((row) => (
378+ <li key={row.id} className="rounded-lg border border-border bg-card p-3">
379+ <div className="flex items-center gap-2">
380+ <Input
381+ value={row.path}
382+ spellCheck={false}
383+ placeholder="references/api.md"
384+ onChange={(event) => updateRow(row.id, { path: event.target.value })}
385+ className="font-mono text-[12px]"
386+ aria-label="File path"
387+ />
388+ <Button
389+ type="button"
390+ variant="ghost"
391+ size="sm"
392+ className="text-muted-foreground hover:text-destructive"
393+ onClick={() => removeRow(row.id)}
394+ >
395+ Remove
396+ </Button>
397+ </div>
398+ <Textarea
399+ value={row.content}
400+ spellCheck={false}
401+ onChange={(event) => updateRow(row.id, { content: event.target.value })}
402+ className="mt-2 min-h-32 font-mono text-[12px] leading-relaxed"
403+ aria-label={`Contents of ${row.path || "file"}`}
404+ />
405+ </li>
406+ ))}
407+ </ul>
408+ )}
409+ </section>
410+ </div>
411+ </PageContainer>
412+ );
413+}
414+
415+/** What the server will read out of the file, shown while it is being typed —
416+ * the frontmatter is the only part of a SKILL.md with rules, and finding out
417+ * it is wrong at save time is the slowest possible way to learn it. */
418+function ValidationPanel(props: {
419+ readonly parsed: ReturnType<typeof parseSkillMarkdown>;
420+ readonly forksExisting: boolean;
421+ readonly original: { readonly owner: Owner; readonly name: string } | null;
422+}) {
423+ if (Result.isFailure(props.parsed)) {
424+ return (
425+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
426+ <p className="text-[12px] text-destructive">{props.parsed.failure.reason}</p>
427+ </div>
428+ );
429+ }
430+ const { name, description } = props.parsed.success;
431+ return (
432+ <div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5">
433+ <dl className="space-y-1">
434+ <div className="flex gap-3">
435+ <dt className="w-24 shrink-0 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
436+ Name
437+ </dt>
438+ <dd className="font-mono text-[12px] text-foreground">{name}</dd>
439+ </div>
440+ <div className="flex gap-3">
441+ <dt className="w-24 shrink-0 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
442+ Description
443+ </dt>
444+ <dd className="min-w-0 flex-1 text-[12px] text-muted-foreground">{description}</dd>
445+ </div>
446+ </dl>
447+ {props.forksExisting && props.original ? (
448+ <p className="mt-2 border-t border-border pt-2 text-[12px] text-destructive">
449+ Saving this creates a new skill. The original,{" "}
450+ <span className="font-mono">
451+ {props.original.owner}/{props.original.name}
452+ </span>
453+ , stays as it is.
454+ </p>
455+ ) : null}
456+ </div>
457+ );
458+}
packages/react/src/pages/skills.tsx+159−0
@@ -0,0 +1,159 @@
1+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2+import { Link } from "@tanstack/react-router";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import type { Owner } from "@executor-js/sdk/shared";
5+
6+import { skillsAtom } from "../api/atoms";
7+import { useOrganizationId } from "../api/organization-context";
8+import { Button } from "../components/button";
9+import { ErrorState } from "../components/error-state";
10+import { PageContainer, PageHeader } from "../components/page";
11+import { isAsyncResultLoading } from "../lib/async-result";
12+import { useExecutorDocumentTitle } from "../lib/document-title";
13+import { formatRelativeTime } from "../lib/relative-time";
14+import { connectionOwnerOptionsForHost } from "../plugins/connection-owner";
15+
16+/** The wire row `skills.list` returns — the manifest, without file contents. */
17+interface SkillSummaryRow {
18+ readonly owner: Owner;
19+ readonly name: string;
20+ readonly description: string;
21+ readonly files: ReadonlyArray<{ readonly path: string }>;
22+ readonly updatedAt: number;
23+}
24+
25+/**
26+ * Personal / Workspace / Local, in the host's own words.
27+ *
28+ * A skill is owner-scoped exactly like a connection, so it says "Workspace"
29+ * where connections say "Workspace" and "Local" on a host that has one bucket —
30+ * two vocabularies for the same `Owner` would be a lie about what it means.
31+ */
32+export function useSkillOwnerLabel(): (owner: Owner) => string {
33+ const options = connectionOwnerOptionsForHost(useOrganizationId());
34+ return (owner) =>
35+ options.find((option) => option.owner === owner)?.label ??
36+ (owner === "org" ? "Workspace" : "Personal");
37+}
38+
39+/** The owner badge: a mono chip, since the owner is metadata about the skill. */
40+export function SkillOwnerTag(props: { readonly owner: Owner }) {
41+ const label = useSkillOwnerLabel();
42+ return (
43+ <span className="rounded-sm border border-border bg-secondary px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
44+ {label(props.owner)}
45+ </span>
46+ );
47+}
48+
49+const LoadingState = () => (
50+ <div className="flex items-center gap-2 py-8">
51+ <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" />
52+ <p className="text-sm text-muted-foreground">Loading skills…</p>
53+ </div>
54+);
55+
56+/** Skills are the one workspace resource a PERSON authors, so the empty state
57+ * says what one is and what adding it does, then points at the button. */
58+const EmptyState = () => (
59+ <div
60+ data-slot="skill-empty"
61+ className="rounded-lg border border-dashed border-border px-6 py-16 text-center"
62+ >
63+ <p className="mx-auto max-w-md text-sm leading-relaxed text-muted-foreground">
64+ No skills yet. A skill is a SKILL.md file — instructions an agent loads on demand, plus any
65+ files it references. Add one and every agent connected to this workspace can find it by name.
66+ </p>
67+ </div>
68+);
69+
70+function SkillRow(props: { readonly skill: SkillSummaryRow }) {
71+ const { skill } = props;
72+ return (
73+ <li className="group/skill-row relative isolate flex items-start justify-between gap-4 px-4 py-3.5 transition-colors duration-150 focus-within:bg-accent/40 hover:bg-accent/40">
74+ <div className="min-w-0 flex-1">
75+ <div className="flex items-center gap-2">
76+ {/* The whole row is the target; the link overlays it so the row keeps
77+ one accessible name and one focus stop. */}
78+ <h3 className="truncate font-mono text-sm text-foreground">
79+ <Link
80+ to="/{-$orgSlug}/skills/$skillOwner/$skillName"
81+ params={{ skillOwner: skill.owner, skillName: skill.name }}
82+ className="outline-none after:absolute after:inset-0 after:content-[''] focus-visible:underline"
83+ >
84+ {skill.name}
85+ </Link>
86+ </h3>
87+ <SkillOwnerTag owner={skill.owner} />
88+ </div>
89+ <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
90+ {skill.description}
91+ </p>
92+ </div>
93+ <div className="flex shrink-0 flex-col items-end gap-1 font-mono text-[11px] text-muted-foreground">
94+ <span className="tabular-nums">
95+ {skill.files.length} {skill.files.length === 1 ? "file" : "files"}
96+ </span>
97+ <span>{formatRelativeTime(skill.updatedAt)}</span>
98+ </div>
99+ </li>
100+ );
101+}
102+
103+export function SkillsPage() {
104+ useExecutorDocumentTitle("Skills");
105+ const skills = useAtomValue(skillsAtom);
106+ const refresh = useAtomRefresh(skillsAtom);
107+
108+ return (
109+ <PageContainer>
110+ <PageHeader
111+ title="Skills"
112+ description="SKILL.md instructions your agents can load on demand. Save one here and every agent connected to this workspace discovers it — personally, or shared with everyone."
113+ actions={
114+ <Button asChild size="sm">
115+ <Link to="/{-$orgSlug}/skills/new">Add Skill</Link>
116+ </Button>
117+ }
118+ />
119+
120+ {isAsyncResultLoading(skills) ? (
121+ <LoadingState />
122+ ) : (
123+ AsyncResult.match(skills, {
124+ onInitial: () => <LoadingState />,
125+ onFailure: () => <ErrorState message="Failed to load skills" onRetry={refresh} />,
126+ onSuccess: ({ value }) => {
127+ // Workspace skills first, then by name: the shared set is the one a
128+ // reader is checking against, and name order makes it scannable.
129+ const rows = [...value].sort((a, b) =>
130+ a.owner === b.owner ? a.name.localeCompare(b.name) : a.owner === "org" ? -1 : 1,
131+ );
132+ return (
133+ <section>
134+ <h2 className="mb-3 flex items-center font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
135+ Saved skills
136+ {rows.length > 0 ? (
137+ <span className="ml-2 font-normal tabular-nums">{rows.length}</span>
138+ ) : null}
139+ </h2>
140+ {rows.length === 0 ? (
141+ <EmptyState />
142+ ) : (
143+ <ul
144+ data-slot="skill-list"
145+ className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card"
146+ >
147+ {rows.map((skill) => (
148+ <SkillRow key={`${skill.owner}/${skill.name}`} skill={skill} />
149+ ))}
150+ </ul>
151+ )}
152+ </section>
153+ );
154+ },
155+ })
156+ )}
157+ </PageContainer>
158+ );
159+}
packages/react/src/routes/routeTree.gen.ts+75−0
@@ -13,15 +13,18 @@ import { Route as DotIndexRouteImport } from './index' · 12 unmodified lines
1313 import { Route as DotUsersRouteImport } from './users'
1414 import { Route as DotToolsRouteImport } from './tools'
1515 import { Route as DotToolkitsRouteImport } from './toolkits'
16+import { Route as DotSkillsRouteImport } from './skills'
1617 import { Route as DotSecretsRouteImport } from './secrets'
1718 import { Route as DotPoliciesRouteImport } from './policies'
1819 import { Route as DotArtifactsRouteImport } from './artifacts'
1920 import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
21+import { Route as DotSkillsDotnewRouteImport } from './skills.new'
2022 import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
2123 import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse'
2224 import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
2325 import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
2426 import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId'
27+import { Route as DotSkillsDotskillOwnerDotskillNameRouteImport } from './skills.$skillOwner.$skillName'
2528 import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
2629 import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'
2730
@@ -45,6 +48,11 @@ const DotToolkitsRoute = DotToolkitsRouteImport.update({ · 17 unmodified lines
4548 path: '/{-$orgSlug}/toolkits',
4649 getParentRoute: () => rootRouteImport,
4750 } as any)
51+const DotSkillsRoute = DotSkillsRouteImport.update({
52+ id: '/{-$orgSlug}/skills',
53+ path: '/{-$orgSlug}/skills',
54+ getParentRoute: () => rootRouteImport,
55+} as any)
4856 const DotSecretsRoute = DotSecretsRouteImport.update({
4957 id: '/{-$orgSlug}/secrets',
5058 path: '/{-$orgSlug}/secrets',
@@ -66,6 +74,11 @@ const DotToolkitsDottoolkitSlugRoute = · 15 unmodified lines
6674 path: '/$toolkitSlug',
6775 getParentRoute: () => DotToolkitsRoute,
6876 } as any)
77+const DotSkillsDotnewRoute = DotSkillsDotnewRouteImport.update({
78+ id: '/new',
79+ path: '/new',
80+ getParentRoute: () => DotSkillsRoute,
81+} as any)
6982 const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({
7083 id: '/{-$orgSlug}/resume/$executionId',
7184 path: '/{-$orgSlug}/resume/$executionId',
@@ -95,6 +108,12 @@ const DotArtifactsDotartifactIdRoute = · 23 unmodified lines
95108 path: '/$artifactId',
96109 getParentRoute: () => DotArtifactsRoute,
97110 } as any)
111+const DotSkillsDotskillOwnerDotskillNameRoute =
112+ DotSkillsDotskillOwnerDotskillNameRouteImport.update({
113+ id: '/$skillOwner/$skillName',
114+ path: '/$skillOwner/$skillName',
115+ getParentRoute: () => DotSkillsRoute,
116+ } as any)
98117 const DotPluginsDotpluginIdDotsplatRoute =
99118 DotPluginsDotpluginIdDotsplatRouteImport.update({
100119 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -112,6 +131,7 @@ export interface FileRoutesByFullPath { · 11 unmodified lines
112131 '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren
113132 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
114133 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
134+ '/{-$orgSlug}/skills': typeof DotSkillsRouteWithChildren
115135 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
116136 '/{-$orgSlug}/tools': typeof DotToolsRoute
117137 '/{-$orgSlug}/users': typeof DotUsersRoute
@@ -121,14 +141,17 @@ export interface FileRoutesByFullPath { · 3 unmodified lines
121141 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
122142 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
123143 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
144+ '/{-$orgSlug}/skills/new': typeof DotSkillsDotnewRoute
124145 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
125146 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
126147 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
148+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotSkillsDotskillOwnerDotskillNameRoute
127149 }
128150 export interface FileRoutesByTo {
129151 '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren
130152 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
131153 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
154+ '/{-$orgSlug}/skills': typeof DotSkillsRouteWithChildren
132155 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
133156 '/{-$orgSlug}/tools': typeof DotToolsRoute
134157 '/{-$orgSlug}/users': typeof DotUsersRoute
@@ -138,15 +161,18 @@ export interface FileRoutesByTo { · 3 unmodified lines
138161 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
139162 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
140163 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
164+ '/{-$orgSlug}/skills/new': typeof DotSkillsDotnewRoute
141165 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
142166 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
143167 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
168+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotSkillsDotskillOwnerDotskillNameRoute
144169 }
145170 export interface FileRoutesById {
146171 __root__: typeof rootRouteImport
147172 '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren
148173 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
149174 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
175+ '/{-$orgSlug}/skills': typeof DotSkillsRouteWithChildren
150176 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
151177 '/{-$orgSlug}/tools': typeof DotToolsRoute
152178 '/{-$orgSlug}/users': typeof DotUsersRoute
@@ -156,9 +182,11 @@ export interface FileRoutesById { · 3 unmodified lines
156182 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
157183 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
158184 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
185+ '/{-$orgSlug}/skills/new': typeof DotSkillsDotnewRoute
159186 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
160187 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
161188 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
189+ '/{-$orgSlug}/skills/$skillOwner/$skillName': typeof DotSkillsDotskillOwnerDotskillNameRoute
162190 }
163191 export interface FileRouteTypes {
164192 fileRoutesByFullPath: FileRoutesByFullPath
@@ -166,6 +194,7 @@ export interface FileRouteTypes { · 1 unmodified line
166194 | '/{-$orgSlug}/artifacts'
167195 | '/{-$orgSlug}/policies'
168196 | '/{-$orgSlug}/secrets'
197+ | '/{-$orgSlug}/skills'
169198 | '/{-$orgSlug}/toolkits'
170199 | '/{-$orgSlug}/tools'
171200 | '/{-$orgSlug}/users'
@@ -175,14 +204,17 @@ export interface FileRouteTypes { · 3 unmodified lines
175204 | '/{-$orgSlug}/integrations/$namespace'
176205 | '/{-$orgSlug}/integrations/browse'
177206 | '/{-$orgSlug}/resume/$executionId'
207+ | '/{-$orgSlug}/skills/new'
178208 | '/{-$orgSlug}/toolkits/$toolkitSlug'
179209 | '/{-$orgSlug}/integrations/add/$pluginKey'
180210 | '/{-$orgSlug}/plugins/$pluginId/$'
211+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
181212 fileRoutesByTo: FileRoutesByTo
182213 to:
183214 | '/{-$orgSlug}/artifacts'
184215 | '/{-$orgSlug}/policies'
185216 | '/{-$orgSlug}/secrets'
217+ | '/{-$orgSlug}/skills'
186218 | '/{-$orgSlug}/toolkits'
187219 | '/{-$orgSlug}/tools'
188220 | '/{-$orgSlug}/users'
@@ -192,14 +224,17 @@ export interface FileRouteTypes { · 3 unmodified lines
192224 | '/{-$orgSlug}/integrations/$namespace'
193225 | '/{-$orgSlug}/integrations/browse'
194226 | '/{-$orgSlug}/resume/$executionId'
227+ | '/{-$orgSlug}/skills/new'
195228 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196229 | '/{-$orgSlug}/integrations/add/$pluginKey'
197230 | '/{-$orgSlug}/plugins/$pluginId/$'
231+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
198232 id:
199233 | '__root__'
200234 | '/{-$orgSlug}/artifacts'
201235 | '/{-$orgSlug}/policies'
202236 | '/{-$orgSlug}/secrets'
237+ | '/{-$orgSlug}/skills'
203238 | '/{-$orgSlug}/toolkits'
204239 | '/{-$orgSlug}/tools'
205240 | '/{-$orgSlug}/users'
@@ -209,15 +244,18 @@ export interface FileRouteTypes { · 3 unmodified lines
209244 | '/{-$orgSlug}/integrations/$namespace'
210245 | '/{-$orgSlug}/integrations/browse'
211246 | '/{-$orgSlug}/resume/$executionId'
247+ | '/{-$orgSlug}/skills/new'
212248 | '/{-$orgSlug}/toolkits/$toolkitSlug'
213249 | '/{-$orgSlug}/integrations/add/$pluginKey'
214250 | '/{-$orgSlug}/plugins/$pluginId/$'
251+ | '/{-$orgSlug}/skills/$skillOwner/$skillName'
215252 fileRoutesById: FileRoutesById
216253 }
217254 export interface RootRouteChildren {
218255 DotArtifactsRoute: typeof DotArtifactsRouteWithChildren
219256 DotPoliciesRoute: typeof DotPoliciesRoute
220257 DotSecretsRoute: typeof DotSecretsRoute
258+ DotSkillsRoute: typeof DotSkillsRouteWithChildren
221259 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
222260 DotToolsRoute: typeof DotToolsRoute
223261 DotUsersRoute: typeof DotUsersRoute
@@ -260,6 +298,13 @@ declare module '@tanstack/react-router' { · 36 unmodified lines
260298 preLoaderRoute: typeof DotToolkitsRouteImport
261299 parentRoute: typeof rootRouteImport
262300 }
301+ '/{-$orgSlug}/skills': {
302+ id: '/{-$orgSlug}/skills'
303+ path: '/{-$orgSlug}/skills'
304+ fullPath: '/{-$orgSlug}/skills'
305+ preLoaderRoute: typeof DotSkillsRouteImport
306+ parentRoute: typeof rootRouteImport
307+ }
263308 '/{-$orgSlug}/secrets': {
264309 id: '/{-$orgSlug}/secrets'
265310 path: '/{-$orgSlug}/secrets'
@@ -288,6 +333,13 @@ declare module '@tanstack/react-router' { · 22 unmodified lines
288333 preLoaderRoute: typeof DotToolkitsDottoolkitSlugRouteImport
289334 parentRoute: typeof DotToolkitsRoute
290335 }
336+ '/{-$orgSlug}/skills/new': {
337+ id: '/{-$orgSlug}/skills/new'
338+ path: '/new'
339+ fullPath: '/{-$orgSlug}/skills/new'
340+ preLoaderRoute: typeof DotSkillsDotnewRouteImport
341+ parentRoute: typeof DotSkillsRoute
342+ }
291343 '/{-$orgSlug}/resume/$executionId': {
292344 id: '/{-$orgSlug}/resume/$executionId'
293345 path: '/{-$orgSlug}/resume/$executionId'
@@ -323,6 +375,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
323375 preLoaderRoute: typeof DotArtifactsDotartifactIdRouteImport
324376 parentRoute: typeof DotArtifactsRoute
325377 }
378+ '/{-$orgSlug}/skills/$skillOwner/$skillName': {
379+ id: '/{-$orgSlug}/skills/$skillOwner/$skillName'
380+ path: '/$skillOwner/$skillName'
381+ fullPath: '/{-$orgSlug}/skills/$skillOwner/$skillName'
382+ preLoaderRoute: typeof DotSkillsDotskillOwnerDotskillNameRouteImport
383+ parentRoute: typeof DotSkillsRoute
384+ }
326385 '/{-$orgSlug}/plugins/$pluginId/$': {
327386 id: '/{-$orgSlug}/plugins/$pluginId/$'
328387 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -352,6 +411,21 @@ const DotArtifactsRouteWithChildren = DotArtifactsRoute._addFileChildren( · 23 unmodified lines
352411 DotArtifactsRouteChildren,
353412 )
354413
414+interface DotSkillsRouteChildren {
415+ DotSkillsDotnewRoute: typeof DotSkillsDotnewRoute
416+ DotSkillsDotskillOwnerDotskillNameRoute: typeof DotSkillsDotskillOwnerDotskillNameRoute
417+}
418+
419+const DotSkillsRouteChildren: DotSkillsRouteChildren = {
420+ DotSkillsDotnewRoute: DotSkillsDotnewRoute,
421+ DotSkillsDotskillOwnerDotskillNameRoute:
422+ DotSkillsDotskillOwnerDotskillNameRoute,
423+}
424+
425+const DotSkillsRouteWithChildren = DotSkillsRoute._addFileChildren(
426+ DotSkillsRouteChildren,
427+)
428+
355429 interface DotToolkitsRouteChildren {
356430 DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute
357431 }
@@ -368,6 +442,7 @@ const rootRouteChildren: RootRouteChildren = { · 10 unmodified lines
368442 DotArtifactsRoute: DotArtifactsRouteWithChildren,
369443 DotPoliciesRoute: DotPoliciesRoute,
370444 DotSecretsRoute: DotSecretsRoute,
445+ DotSkillsRoute: DotSkillsRouteWithChildren,
371446 DotToolkitsRoute: DotToolkitsRouteWithChildren,
372447 DotToolsRoute: DotToolsRoute,
373448 DotUsersRoute: DotUsersRoute,
packages/react/src/routes/skills-route.tsx+36−0
@@ -0,0 +1,36 @@
1+import { Owner, SkillName } from "@executor-js/sdk/shared";
2+
3+import { SkillDetailPage, SkillMissingPage } from "../pages/skill-detail";
4+import { SkillEditorPage } from "../pages/skill-editor";
5+import { SkillsPage } from "../pages/skills";
6+
7+/** The URL's owner segment, or `null` when it is neither `org` nor `user`. */
8+const parseOwner = (value: string): Owner | null =>
9+ value === "org" || value === "user" ? value : null;
10+
11+/**
12+ * The one component every skills route renders. `/skills/new` and
13+ * `/skills/$skillOwner/$skillName` generate as CHILDREN of `/skills` and the
14+ * parent renders no `<Outlet/>`, so the parent resolves which view to show —
15+ * the shape `artifacts-route.tsx` established.
16+ */
17+export function SkillsRoute(props: {
18+ readonly skillOwner?: string | undefined;
19+ readonly skillName?: string | undefined;
20+ /** `?edit` on a skill URL. Ignored on the list and on `/skills/new`. */
21+ readonly editing?: boolean | undefined;
22+ /** True on `/skills/new`, which carries no params to recognize it by. */
23+ readonly creating?: boolean | undefined;
24+}) {
25+ if (props.skillOwner === undefined || props.skillName === undefined) {
26+ return props.creating ? <SkillEditorPage /> : <SkillsPage />;
27+ }
28+
29+ const owner = parseOwner(props.skillOwner);
30+ // Only a hand-typed URL gets here with an owner that is neither `org` nor
31+ // `user`. There is no row to ask for, so say so without a fetch.
32+ if (owner === null) return <SkillMissingPage />;
33+
34+ const ref = { owner, name: SkillName.make(props.skillName) } as const;
35+ return props.editing ? <SkillEditorPage editing={ref} /> : <SkillDetailPage {...ref} />;
36+}
packages/react/src/routes/skills.$skillOwner.$skillName.tsx+13−0
@@ -0,0 +1,13 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { SkillsRoute } from "./skills-route";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/skills/$skillOwner/$skillName")({
6+ component: SkillDetailRouteComponent,
7+});
8+
9+function SkillDetailRouteComponent() {
10+ const { skillOwner, skillName } = Route.useParams();
11+ const { edit } = Route.useSearch();
12+ return <SkillsRoute skillOwner={skillOwner} skillName={skillName} editing={edit} />;
13+}
packages/react/src/routes/skills.new.tsx+11−0
@@ -0,0 +1,11 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { SkillsRoute } from "./skills-route";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/skills/new")({
6+ component: NewSkillRouteComponent,
7+});
8+
9+function NewSkillRouteComponent() {
10+ return <SkillsRoute creating />;
11+}
packages/react/src/routes/skills.tsx+37−0
@@ -0,0 +1,37 @@
1+import { createFileRoute, useLocation, useParams } from "@tanstack/react-router";
2+
3+import { SkillsRoute } from "./skills-route";
4+
5+/** `?edit` puts the skill URL into its editor. Declared on the PARENT because
6+ * `/skills/new` and `/skills/$skillOwner/$skillName` are generated as its
7+ * children and inherit its search contract. */
8+interface SkillsSearch {
9+ readonly edit?: true;
10+}
11+
12+export const Route = createFileRoute("/{-$orgSlug}/skills")({
13+ component: SkillsRouteComponent,
14+ validateSearch: (search: Record<string, unknown>): SkillsSearch =>
15+ search.edit === undefined ? {} : { edit: true },
16+});
17+
18+function SkillsRouteComponent() {
19+ // Both child routes generate UNDER this one and this component renders no
20+ // <Outlet/>, so the parent has to serve all three URLs — the shape
21+ // `artifacts.tsx` established. `/skills/new` carries no params at all, so the
22+ // pathname is the only thing that tells it apart from the list.
23+ const { skillOwner, skillName } = useParams({ strict: false }) as {
24+ skillOwner?: string;
25+ skillName?: string;
26+ };
27+ const pathname = useLocation({ select: (location) => location.pathname });
28+ const { edit } = Route.useSearch();
29+ return (
30+ <SkillsRoute
31+ skillOwner={skillOwner}
32+ skillName={skillName}
33+ editing={edit}
34+ creating={pathname.endsWith("/skills/new")}
35+ />
36+ );
37+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t