diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index ad7c2a9..9db3929 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -1040,6 +1040,18 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + { + name: "skill_install", + description: + "Install skills from a public GitHub link the user pasted: a repository, a folder, or one SKILL.md. Every SKILL.md found becomes one of the user's own skills. Only when the user asks to install from that link.", + inputSchema: { + type: "object", + properties: { + url: { type: "string", description: "The github.com link the user gave." }, + }, + required: ["url"], + }, + }, { name: "skill_update", description: diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 6d1b5c5..d116dab 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -389,6 +389,7 @@ import { listAgentSkillRecords, skillCreateFromTool, skillDeleteFromTool, + skillInstallFromTool, skillReadFromTool, skillUpdateFromTool, } from "./skill-tools.js"; @@ -2982,6 +2983,15 @@ export function createRunExecutor(deps: ExecutorDeps) { ), ); } + if (name === "skill_install") { + return finish( + await skillInstallFromTool( + deps.prisma, + { spaceId: run.spaceId, userId: run.userId }, + { url: args.url ? String(args.url) : undefined }, + ), + ); + } if (name === "skill_update") { return finish( await skillUpdateFromTool( diff --git a/packages/adapters/src/index.test.ts b/packages/adapters/src/index.test.ts index 28fd8f3..9bb85ff 100644 --- a/packages/adapters/src/index.test.ts +++ b/packages/adapters/src/index.test.ts @@ -182,6 +182,7 @@ describe("builtin tools", () => { "skill_create", "skill_update", "skill_delete", + "skill_install", "web_search", "web_fetch", "browser_navigate", diff --git a/packages/adapters/src/pi-runtime.ts b/packages/adapters/src/pi-runtime.ts index bcfed46..7c71d6c 100644 --- a/packages/adapters/src/pi-runtime.ts +++ b/packages/adapters/src/pi-runtime.ts @@ -491,6 +491,8 @@ export function describeToolActivity(toolName: string, args: unknown): string { if (toolName === "web_fetch") return `Reading page: ${detail(redactActivityUrl(record.url))}`; if (toolName === "skill_read") return `Reading skill: ${detail(record.name)}`; if (toolName === "skill_create") return `Creating skill: ${detail(record.name ?? "skill")}`; + if (toolName === "skill_install") + return `Installing skills: ${detail(redactActivityUrl(record.url))}`; if (toolName === "skill_update") return `Updating skill: ${detail(record.name ?? record.skillId)}`; if (toolName === "skill_delete") diff --git a/packages/adapters/src/skill-github.test.ts b/packages/adapters/src/skill-github.test.ts new file mode 100644 index 0000000..6172356 --- /dev/null +++ b/packages/adapters/src/skill-github.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GithubFetch } from "./skill-github.js"; +import { fetchGithubSkills, parseGithubSkillUrl } from "./skill-github.js"; +import { skillInstallFromTool } from "./skill-tools.js"; + +const skill = (name: string) => + `---\nname: ${name}\ndescription: Pretend ${name} recipe\n---\n\n1. Do the thing.\n`; + +/** A pretend public repo: two skills under skills/, one at the root, one unrelated file. */ +function pretendGithub(files: Record, defaultBranch = "main") { + const calls: string[] = []; + const fetchImpl: GithubFetch = vi.fn(async (url: string) => { + calls.push(url); + const { hostname, pathname } = new URL(url); + if (hostname === "api.github.com" && pathname === "/repos/someone/founder-kit") { + return Response.json({ default_branch: defaultBranch }); + } + if ( + hostname === "api.github.com" && + pathname.startsWith("/repos/someone/founder-kit/git/trees/") + ) { + return Response.json({ + tree: [ + ...Object.keys(files).map((path) => ({ path, type: "blob" })), + { path: "README.md", type: "blob" }, + { path: "skills", type: "tree" }, + ], + }); + } + if (hostname === "raw.githubusercontent.com") { + const path = pathname.split("/").slice(4).join("/"); + const body = files[decodeURIComponent(path)]; + return body === undefined ? new Response("", { status: 404 }) : new Response(body); + } + return new Response("", { status: 404 }); + }); + return { fetchImpl, calls }; +} + +function makePrisma() { + const store: Array> = []; + return { + agentSkill: { + findMany: vi.fn(async () => store.map((row) => ({ ...row }))), + create: vi.fn(async ({ data }: { data: Record }) => { + const row = { id: `skill-${store.length + 1}`, source: "user", ...data }; + store.push(row); + return row; + }), + }, + store, + }; +} + +const owner = { spaceId: "ws-1", userId: "user-1" }; + +describe("GitHub skill links", () => { + it("reads repository, folder, file and raw links and rejects other hosts", () => { + expect(parseGithubSkillUrl("https://github.com/someone/founder-kit")).toEqual({ + owner: "someone", + repo: "founder-kit", + path: "", + }); + expect(parseGithubSkillUrl("github.com/someone/founder-kit.git/tree/dev/skills/cfo")).toEqual({ + owner: "someone", + repo: "founder-kit", + ref: "dev", + path: "skills/cfo", + }); + expect( + parseGithubSkillUrl("https://raw.githubusercontent.com/someone/founder-kit/main/SKILL.md"), + ).toMatchObject({ ref: "main", path: "SKILL.md" }); + expect(parseGithubSkillUrl("https://example.com/someone/founder-kit")).toBeNull(); + expect(parseGithubSkillUrl("https://github.com/someone")).toBeNull(); + expect(parseGithubSkillUrl("https://github.com.evil.test/someone/kit")).toBeNull(); + }); + + it("installs every SKILL.md of a repo and only fetches from GitHub", async () => { + const { fetchImpl, calls } = pretendGithub({ + "SKILL.md": skill("Launch kit"), + "skills/cfo/SKILL.md": skill("CFO"), + "skills/panel/SKILL.md": skill("Buyer panel"), + }); + const prisma = makePrisma(); + const result = await skillInstallFromTool( + prisma as never, + owner, + { url: "https://github.com/someone/founder-kit" }, + fetchImpl, + ); + expect(result).toMatchObject({ + ok: true, + repo: "someone/founder-kit", + installed: ["Launch kit", "CFO", "Buyer panel"], + skipped: [], + }); + expect(prisma.store.map((row) => row.source)).toEqual(["user", "user", "user"]); + expect( + calls.every((url) => /^https:\/\/(api\.github\.com|raw\.githubusercontent\.com)\//.test(url)), + ).toBe(true); + }); + + it("limits a folder link to that folder and skips invalid or clashing skills", async () => { + const { fetchImpl } = pretendGithub({ + "skills/cfo/SKILL.md": skill("CFO"), + "skills/broken/SKILL.md": "no frontmatter", + "skills/zz-copy/SKILL.md": skill("cfo"), + "other/SKILL.md": skill("Elsewhere"), + }); + const prisma = makePrisma(); + const result = await skillInstallFromTool( + prisma as never, + owner, + { url: "https://github.com/someone/founder-kit/tree/main/skills" }, + fetchImpl, + ); + expect(result).toMatchObject({ ok: true, installed: ["CFO"] }); + expect((result.skipped as Array<{ path: string }>).map((s) => s.path)).toEqual([ + "skills/broken/SKILL.md", + "skills/zz-copy/SKILL.md", + ]); + }); + + it("explains links that hold no skill", async () => { + const { fetchImpl } = pretendGithub({}); + await expect( + fetchGithubSkills("https://github.com/someone/founder-kit", fetchImpl), + ).rejects.toThrow("No SKILL.md found"); + expect( + await skillInstallFromTool(makePrisma() as never, owner, { url: "https://example.com/x/y" }), + ).toEqual({ error: "Paste a github.com link to a repository, folder or SKILL.md." }); + expect( + await skillInstallFromTool( + makePrisma() as never, + owner, + { url: "https://github.com/nobody/missing" }, + pretendGithub({}).fetchImpl, + ), + ).toEqual({ error: "Repository not found or not public." }); + }); +}); diff --git a/packages/adapters/src/skill-github.ts b/packages/adapters/src/skill-github.ts new file mode 100644 index 0000000..3e1b21e --- /dev/null +++ b/packages/adapters/src/skill-github.ts @@ -0,0 +1,87 @@ +/** + * Reads SKILL.md recipes from a public GitHub repository so a pasted link installs them. + * Only GitHub's API and raw hosts are contacted; the link only selects owner, repo, ref and folder. + */ + +const MAX_SKILLS_PER_INSTALL = 40; +const MAX_SKILL_FILE_CHARS = 100_000; +const SEGMENT = /^[A-Za-z0-9_.-]+$/; + +export type GithubFetch = ( + url: string, + init?: { headers?: Record }, +) => Promise; + +type GithubTarget = { owner: string; repo: string; ref?: string; path: string }; + +/** `github.com/owner/repo`, `…/tree//`, `…/blob//` or a raw.githubusercontent.com file. */ +export function parseGithubSkillUrl(input: string): GithubTarget | null { + let url: URL; + try { + const trimmed = input.trim(); + url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`); + } catch { + return null; + } + const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (url.hostname === "raw.githubusercontent.com") { + const [owner, repo, ref, ...rest] = parts; + if (!owner || !repo || !ref || !SEGMENT.test(owner) || !SEGMENT.test(repo)) return null; + return { owner, repo, ref, path: rest.join("/") }; + } + if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return null; + const [owner, rawRepo, kind, ref, ...rest] = parts; + const repo = rawRepo?.replace(/\.git$/, ""); + if (!owner || !repo || !SEGMENT.test(owner) || !SEGMENT.test(repo)) return null; + if ((kind === "tree" || kind === "blob") && ref) + return { owner, repo, ref, path: rest.join("/") }; + return { owner, repo, path: "" }; +} + +function inScope(path: string, scope: string): boolean { + if (!/(^|\/)SKILL\.md$/i.test(path)) return false; + const dir = scope.replace(/\/+$/, ""); + return !dir || path === dir || path.startsWith(`${dir}/`); +} + +async function githubJson(fetchImpl: GithubFetch, url: string): Promise> { + const response = await fetchImpl(url, { headers: { accept: "application/vnd.github+json" } }); + if (response.status === 404) throw new Error("Repository not found or not public."); + if (!response.ok) throw new Error(`GitHub answered ${response.status}.`); + return (await response.json()) as Record; +} + +/** Every SKILL.md under the linked repository or folder, or the single linked file. */ +export async function fetchGithubSkills( + input: string, + fetchImpl: GithubFetch = fetch, +): Promise<{ repo: string; files: Array<{ path: string; content: string }> }> { + const target = parseGithubSkillUrl(input); + if (!target) throw new Error("Paste a github.com link to a repository, folder or SKILL.md."); + const api = `https://api.github.com/repos/${target.owner}/${target.repo}`; + const ref = target.ref ?? String((await githubJson(fetchImpl, api)).default_branch ?? "main"); + const tree = await githubJson( + fetchImpl, + `${api}/git/trees/${encodeURIComponent(ref)}?recursive=1`, + ); + const entries = Array.isArray(tree.tree) ? (tree.tree as Array>) : []; + const paths = entries + .filter((entry) => entry.type === "blob" && typeof entry.path === "string") + .map((entry) => String(entry.path)) + .filter((path) => inScope(path, target.path)) + .sort() + .slice(0, MAX_SKILLS_PER_INSTALL); + if (paths.length === 0) throw new Error("No SKILL.md found at that link."); + + const files: Array<{ path: string; content: string }> = []; + for (const path of paths) { + const file = path.split("/").map(encodeURIComponent).join("/"); + const response = await fetchImpl( + `https://raw.githubusercontent.com/${target.owner}/${target.repo}/${encodeURIComponent(ref)}/${file}`, + ); + if (!response.ok) continue; + const content = await response.text(); + if (content.length <= MAX_SKILL_FILE_CHARS) files.push({ path, content }); + } + return { repo: `${target.owner}/${target.repo}`, files }; +} diff --git a/packages/adapters/src/skill-tools.ts b/packages/adapters/src/skill-tools.ts index e37e886..a7f9b87 100644 --- a/packages/adapters/src/skill-tools.ts +++ b/packages/adapters/src/skill-tools.ts @@ -9,12 +9,15 @@ import { } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { BUILTIN_AGENT_SKILLS } from "./builtin-skills.js"; +import type { GithubFetch } from "./skill-github.js"; +import { fetchGithubSkills } from "./skill-github.js"; export const SKILL_TOOL_NAMES = new Set([ "skill_read", "skill_create", "skill_update", "skill_delete", + "skill_install", ]); /** Match CreateAgentSkillInput / UpdateAgentSkillInput / parseSkillMd bounds. */ @@ -283,3 +286,35 @@ export async function skillDeleteFromTool( if (deleted.count !== 1) return { error: "Could not delete skill." }; return { ok: true, name: existing.name }; } + +/** Install every SKILL.md behind a GitHub link as the owner's own, editable skills. */ +export async function skillInstallFromTool( + prisma: PrismaClient, + owner: SkillOwner, + input: { url?: string }, + fetchImpl?: GithubFetch, +): Promise> { + let found: Awaited>; + try { + found = await fetchGithubSkills(String(input.url ?? ""), fetchImpl); + } catch (error) { + return { error: error instanceof Error ? error.message : "Could not read that link." }; + } + const installed: string[] = []; + const skipped: Array<{ path: string; reason: string }> = []; + for (const file of found.files) { + const result = await skillCreateFromTool(prisma, owner, { content: file.content }); + if (result.ok && typeof result.name === "string") installed.push(result.name); + else skipped.push({ path: file.path, reason: String(result.error ?? "Invalid SKILL.md.") }); + } + if (installed.length === 0) { + return { error: "No skill could be installed.", repo: found.repo, skipped }; + } + return { + ok: true, + repo: found.repo, + installed, + skipped, + hint: "Tell the user which skills are ready, as /Name.", + }; +}