Skip to content

Commit ad062f6

Browse files
committed
feat(skills): auto-register GitHub repos as skills
Enable users to register public GitHub repos as skills by running `npx skillx use org/repo`. When a skill doesn't exist, the CLI now fetches repo metadata + content from GitHub, creates it in D1, and indexes in Vectorize for semantic search. - New endpoint POST /api/skills/register handles GitHub repo ingestion - GitHub skill fetcher extracts metadata (stars, topics, content files) - Category inference from GitHub topics - Idempotent registration (returns existing skill if already created) - Vectorize indexing non-blocking (skill usable even if indexing fails) - CLI use command updated to handle org/repo format and auto-register
1 parent 4eee614 commit ad062f6

4 files changed

Lines changed: 298 additions & 7 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Fetch skill data from a public GitHub repository.
3+
* Tries SKILL.md → CLAUDE.md → README.md for content.
4+
* Uses GitHub REST API (unauthenticated — rate-limited to 60 req/hr/IP).
5+
*/
6+
7+
export interface GitHubSkillData {
8+
name: string;
9+
slug: string;
10+
description: string;
11+
content: string;
12+
author: string;
13+
source_url: string;
14+
category: string;
15+
install_command: string;
16+
github_stars: number;
17+
}
18+
19+
interface GitHubRepoResponse {
20+
name: string;
21+
full_name: string;
22+
description: string | null;
23+
owner: { login: string };
24+
stargazers_count: number;
25+
topics: string[];
26+
html_url: string;
27+
default_branch: string;
28+
}
29+
30+
/** Map GitHub topics to SkillX categories */
31+
const TOPIC_CATEGORY_MAP: Record<string, string> = {
32+
"ai-agent": "agent",
33+
"ai-agents": "agent",
34+
"agent-skills": "agent",
35+
"claude": "agent",
36+
"llm": "agent",
37+
devops: "devops",
38+
deployment: "devops",
39+
"ci-cd": "devops",
40+
testing: "testing",
41+
security: "security",
42+
database: "database",
43+
frontend: "frontend",
44+
backend: "backend",
45+
api: "backend",
46+
documentation: "documentation",
47+
design: "design",
48+
};
49+
50+
function inferCategory(topics: string[]): string {
51+
for (const topic of topics) {
52+
const category = TOPIC_CATEGORY_MAP[topic.toLowerCase()];
53+
if (category) return category;
54+
}
55+
return "general";
56+
}
57+
58+
/** Content files to try, in priority order */
59+
const CONTENT_FILES = ["SKILL.md", "CLAUDE.md", "README.md"];
60+
61+
/**
62+
* Fetch raw file content from GitHub repo's default branch.
63+
* Returns null if file doesn't exist (404).
64+
*/
65+
async function fetchRepoFile(
66+
owner: string,
67+
repo: string,
68+
branch: string,
69+
path: string,
70+
): Promise<string | null> {
71+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
72+
const res = await fetch(url);
73+
if (!res.ok) return null;
74+
return res.text();
75+
}
76+
77+
/**
78+
* Fetch skill data from a public GitHub repo.
79+
* @throws Error if repo not found or not accessible.
80+
*/
81+
export async function fetchGitHubSkill(
82+
owner: string,
83+
repo: string,
84+
): Promise<GitHubSkillData> {
85+
// Fetch repo metadata
86+
const repoRes = await fetch(
87+
`https://api.github.com/repos/${owner}/${repo}`,
88+
{ headers: { Accept: "application/vnd.github.v3+json", "User-Agent": "SkillX/1.0" } },
89+
);
90+
91+
if (repoRes.status === 404) {
92+
throw new Error(`GitHub repository ${owner}/${repo} not found`);
93+
}
94+
if (repoRes.status === 403) {
95+
throw new Error("GitHub API rate limit exceeded. Try again later.");
96+
}
97+
if (!repoRes.ok) {
98+
throw new Error(`GitHub API error: ${repoRes.status}`);
99+
}
100+
101+
const repoData = (await repoRes.json()) as GitHubRepoResponse;
102+
103+
// Fetch content: try SKILL.md → CLAUDE.md → README.md
104+
let content: string | null = null;
105+
for (const file of CONTENT_FILES) {
106+
content = await fetchRepoFile(owner, repo, repoData.default_branch, file);
107+
if (content) break;
108+
}
109+
110+
if (!content) {
111+
// Fallback: use description as content
112+
content = repoData.description || `# ${repoData.name}\n\nNo skill documentation found.`;
113+
}
114+
115+
const slug = `${owner}-${repo}`.toLowerCase();
116+
117+
return {
118+
name: repoData.name,
119+
slug,
120+
description: repoData.description || `${owner}/${repo} skill`,
121+
content,
122+
author: repoData.owner.login,
123+
source_url: repoData.html_url,
124+
category: inferCategory(repoData.topics || []),
125+
install_command: `npx skillx use ${owner}/${repo}`,
126+
github_stars: repoData.stargazers_count,
127+
};
128+
}

apps/web/app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default [
1212
route("api/search", "routes/api.search.ts"),
1313
route("api/leaderboard", "routes/api.leaderboard.ts"),
1414
route("api/admin/seed", "routes/api.admin.seed.ts"),
15+
route("api/skills/register", "routes/api.skill-register.ts"),
1516
route("api/skills/:slug", "routes/api.skill-detail.ts"),
1617
route("api/skills/:slug/rate", "routes/api.skill-rate.ts"),
1718
route("api/skills/:slug/review", "routes/api.skill-review.ts"),
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* POST /api/skills/register — Auto-register a GitHub repo as a skill.
3+
* Fetches repo metadata + content from GitHub, creates skill in D1, indexes in Vectorize.
4+
* If skill already exists (by slug), returns existing skill without re-creating.
5+
*/
6+
7+
import type { ActionFunctionArgs } from "react-router";
8+
import { getDb } from "~/lib/db";
9+
import { skills } from "~/lib/db/schema";
10+
import { eq } from "drizzle-orm";
11+
import { fetchGitHubSkill } from "~/lib/github/fetch-github-skill";
12+
import { indexSkill } from "~/lib/vectorize/index-skill";
13+
14+
const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
15+
16+
export async function action({ request, context }: ActionFunctionArgs) {
17+
try {
18+
const body = (await request.json()) as { owner?: string; repo?: string };
19+
const { owner, repo } = body;
20+
21+
if (!owner || !repo || !GITHUB_REPO_PATTERN.test(`${owner}/${repo}`)) {
22+
return Response.json(
23+
{ error: "Valid owner and repo required (e.g. { owner: 'org', repo: 'name' })" },
24+
{ status: 400 },
25+
);
26+
}
27+
28+
const env = context.cloudflare.env as Env;
29+
const db = getDb(env.DB);
30+
const slug = `${owner}-${repo}`.toLowerCase();
31+
32+
// Check if skill already exists
33+
const [existing] = await db
34+
.select()
35+
.from(skills)
36+
.where(eq(skills.slug, slug))
37+
.limit(1);
38+
39+
if (existing) {
40+
return Response.json({ skill: existing, created: false });
41+
}
42+
43+
// Fetch from GitHub
44+
const ghSkill = await fetchGitHubSkill(owner, repo);
45+
46+
// Insert into D1
47+
const skillId = crypto.randomUUID();
48+
const now = new Date();
49+
50+
await db.insert(skills).values({
51+
id: skillId,
52+
name: ghSkill.name,
53+
slug: ghSkill.slug,
54+
description: ghSkill.description,
55+
content: ghSkill.content,
56+
author: ghSkill.author,
57+
source_url: ghSkill.source_url,
58+
category: ghSkill.category,
59+
install_command: ghSkill.install_command,
60+
version: "1.0.0",
61+
is_paid: false,
62+
price_cents: 0,
63+
avg_rating: 0,
64+
rating_count: 0,
65+
github_stars: ghSkill.github_stars,
66+
install_count: 0,
67+
created_at: now,
68+
updated_at: now,
69+
});
70+
71+
// Index in Vectorize (non-blocking — skill is usable even if indexing fails)
72+
let vectorCount = 0;
73+
try {
74+
vectorCount = await indexSkill(env.VECTORIZE, env.AI, {
75+
id: skillId,
76+
name: ghSkill.name,
77+
description: ghSkill.description,
78+
content: ghSkill.content,
79+
category: ghSkill.category,
80+
is_paid: false,
81+
avg_rating: 0,
82+
});
83+
} catch (vecError) {
84+
console.warn(
85+
`Vectorize indexing failed for ${slug}:`,
86+
vecError instanceof Error ? vecError.message : vecError,
87+
);
88+
}
89+
90+
// Re-fetch the inserted skill to return complete data
91+
const [created] = await db
92+
.select()
93+
.from(skills)
94+
.where(eq(skills.slug, slug))
95+
.limit(1);
96+
97+
return Response.json({
98+
skill: created,
99+
created: true,
100+
vectors: vectorCount,
101+
});
102+
} catch (error) {
103+
const message = error instanceof Error ? error.message : "Unknown error";
104+
105+
// Surface GitHub-specific errors with appropriate status codes
106+
if (message.includes("not found")) {
107+
return Response.json({ error: message }, { status: 404 });
108+
}
109+
if (message.includes("rate limit")) {
110+
return Response.json({ error: message }, { status: 429 });
111+
}
112+
113+
console.error("Skill register error:", error);
114+
return Response.json(
115+
{ error: "Failed to register skill", details: message },
116+
{ status: 500 },
117+
);
118+
}
119+
}

packages/cli/src/commands/use.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,53 @@ interface SkillDetails {
1414
content: string;
1515
}
1616

17+
interface RegisterResponse {
18+
skill: SkillDetails;
19+
created: boolean;
20+
}
21+
22+
/** Detect org/repo GitHub format and return [owner, repo] or null */
23+
function parseGitHubSlug(slug: string): [string, string] | null {
24+
const match = slug.match(/^([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)$/);
25+
return match ? [match[1], match[2]] : null;
26+
}
27+
28+
/** Convert org/repo to API-safe slug: org-repo */
29+
function toApiSlug(owner: string, repo: string): string {
30+
return `${owner}-${repo}`.toLowerCase();
31+
}
32+
1733
export const useCommand = new Command('use')
1834
.description('View and use a skill from SkillX marketplace')
1935
.argument('<slug>', 'Skill slug identifier')
2036
.option('-r, --raw', 'Output raw content only (for piping)')
21-
.action(async (slug: string, options: { raw?: boolean }) => {
22-
const spinner = ora(`Fetching skill: ${slug}...`).start();
37+
.action(async (slugArg: string, options: { raw?: boolean }) => {
38+
const spinner = ora(`Fetching skill: ${slugArg}...`).start();
39+
const ghParts = parseGitHubSlug(slugArg);
40+
const apiSlug = ghParts ? toApiSlug(ghParts[0], ghParts[1]) : slugArg;
2341

2442
try {
25-
const skill = await apiRequest<SkillDetails>(`/api/skills/${slug}`);
43+
44+
let skill: SkillDetails;
45+
try {
46+
skill = await apiRequest<SkillDetails>(`/api/skills/${apiSlug}`);
47+
} catch (fetchErr) {
48+
// On 404 + GitHub org/repo format → auto-register from GitHub
49+
if (fetchErr instanceof ApiError && fetchErr.status === 404 && ghParts) {
50+
spinner.text = `Skill not found. Registering from GitHub: ${slugArg}...`;
51+
const res = await apiRequest<RegisterResponse>('/api/skills/register', {
52+
method: 'POST',
53+
body: JSON.stringify({ owner: ghParts[0], repo: ghParts[1] }),
54+
});
55+
skill = res.skill;
56+
if (res.created) {
57+
spinner.succeed(`Registered new skill from GitHub: ${slugArg}`);
58+
}
59+
} else {
60+
throw fetchErr;
61+
}
62+
}
63+
2664
spinner.stop();
2765

2866
// Fire-and-forget install tracking (silent failure)
@@ -33,7 +71,7 @@ export const useCommand = new Command('use')
3371
if (apiKey) {
3472
installHeaders['Authorization'] = `Bearer ${apiKey}`;
3573
}
36-
fetch(`${getBaseUrl()}/api/skills/${slug}/install`, {
74+
fetch(`${getBaseUrl()}/api/skills/${skill.slug}/install`, {
3775
method: 'POST',
3876
headers: installHeaders,
3977
}).catch(() => {});
@@ -69,15 +107,20 @@ export const useCommand = new Command('use')
69107
}
70108

71109
console.log(chalk.dim('\n─'.repeat(80)));
72-
console.log(chalk.dim(`\nUse ${chalk.cyan(`skillx use ${slug} --raw`)} to output full content`));
73-
console.log(chalk.dim(`View online at: ${chalk.underline(`https://skillx.sh/skills/${slug}`)}`));
110+
console.log(chalk.dim(`\nUse ${chalk.cyan(`skillx use ${slugArg} --raw`)} to output full content`));
111+
console.log(chalk.dim(`View online at: ${chalk.underline(`https://skillx.sh/skills/${skill.slug}`)}`));
74112
} catch (error) {
75113
spinner.stop();
76114

77115
if (error instanceof ApiError) {
78116
if (error.status === 404) {
79-
console.error(chalk.red(`\n✗ Skill not found: ${slug}`));
117+
console.error(chalk.red(`\n✗ Skill not found: ${slugArg}`));
118+
if (ghParts) {
119+
console.error(chalk.dim(`GitHub repo ${slugArg} may not exist or is private.`));
120+
}
80121
console.error(chalk.dim(`Search for skills with: ${chalk.cyan('skillx search <query>')}`));
122+
} else if (error.status === 429) {
123+
console.error(chalk.red(`\n✗ Rate limited. Try again later.`));
81124
} else {
82125
console.error(chalk.red(`\n✗ API Error: ${error.message}`));
83126
}

0 commit comments

Comments
 (0)