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:
parent
4585d3b0ec
commit
4cd9468b6f
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -182,6 +182,7 @@ describe("builtin tools", () => {
|
||||
"skill_create",
|
||||
"skill_update",
|
||||
"skill_delete",
|
||||
"skill_install",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"browser_navigate",
|
||||
|
||||
@ -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")
|
||||
|
||||
141
packages/adapters/src/skill-github.test.ts
Normal file
141
packages/adapters/src/skill-github.test.ts
Normal 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." });
|
||||
});
|
||||
});
|
||||
87
packages/adapters/src/skill-github.ts
Normal file
87
packages/adapters/src/skill-github.ts
Normal 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 };
|
||||
}
|
||||
@ -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<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.",
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user