Install skills from a pasted GitHub link (#197)

A new skill_install agent tool reads every SKILL.md behind a public github.com
repository, folder or file link and saves each as one of the user's own,
editable skills. Only GitHub's API and raw hosts are contacted; invalid files
and name clashes are skipped and reported.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-10 14:38:04 +02:00 committed by GitHub
parent 4585d3b0ec
commit 4cd9468b6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 288 additions and 0 deletions

View File

@ -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", name: "skill_update",
description: description:

View File

@ -389,6 +389,7 @@ import {
listAgentSkillRecords, listAgentSkillRecords,
skillCreateFromTool, skillCreateFromTool,
skillDeleteFromTool, skillDeleteFromTool,
skillInstallFromTool,
skillReadFromTool, skillReadFromTool,
skillUpdateFromTool, skillUpdateFromTool,
} from "./skill-tools.js"; } 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") { if (name === "skill_update") {
return finish( return finish(
await skillUpdateFromTool( await skillUpdateFromTool(

View File

@ -182,6 +182,7 @@ describe("builtin tools", () => {
"skill_create", "skill_create",
"skill_update", "skill_update",
"skill_delete", "skill_delete",
"skill_install",
"web_search", "web_search",
"web_fetch", "web_fetch",
"browser_navigate", "browser_navigate",

View File

@ -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 === "web_fetch") return `Reading page: ${detail(redactActivityUrl(record.url))}`;
if (toolName === "skill_read") return `Reading skill: ${detail(record.name)}`; if (toolName === "skill_read") return `Reading skill: ${detail(record.name)}`;
if (toolName === "skill_create") return `Creating skill: ${detail(record.name ?? "skill")}`; 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") if (toolName === "skill_update")
return `Updating skill: ${detail(record.name ?? record.skillId)}`; return `Updating skill: ${detail(record.name ?? record.skillId)}`;
if (toolName === "skill_delete") if (toolName === "skill_delete")

View File

@ -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<string, string>, 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<Record<string, unknown>> = [];
return {
agentSkill: {
findMany: vi.fn(async () => store.map((row) => ({ ...row }))),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
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." });
});
});

View File

@ -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<string, string> },
) => Promise<Response>;
type GithubTarget = { owner: string; repo: string; ref?: string; path: string };
/** `github.com/owner/repo`, `…/tree/<ref>/<dir>`, `…/blob/<ref>/<file>` 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<Record<string, unknown>> {
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<string, unknown>;
}
/** 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<Record<string, unknown>>) : [];
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 };
}

View File

@ -9,12 +9,15 @@ import {
} from "@rakazo/core"; } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { BUILTIN_AGENT_SKILLS } from "./builtin-skills.js"; 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([ export const SKILL_TOOL_NAMES = new Set([
"skill_read", "skill_read",
"skill_create", "skill_create",
"skill_update", "skill_update",
"skill_delete", "skill_delete",
"skill_install",
]); ]);
/** Match CreateAgentSkillInput / UpdateAgentSkillInput / parseSkillMd bounds. */ /** Match CreateAgentSkillInput / UpdateAgentSkillInput / parseSkillMd bounds. */
@ -283,3 +286,35 @@ export async function skillDeleteFromTool(
if (deleted.count !== 1) return { error: "Could not delete skill." }; if (deleted.count !== 1) return { error: "Could not delete skill." };
return { ok: true, name: existing.name }; 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<Record<string, unknown>> {
let found: Awaited<ReturnType<typeof fetchGithubSkills>>;
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.",
};
}