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",
|
name: "skill_update",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
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";
|
} 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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user