From 09f27dd2965cbaf75c086cba36f25c410ee650cc Mon Sep 17 00:00:00 2001 From: DIodide Date: Fri, 3 Jul 2026 23:33:13 -0400 Subject: [PATCH] feat(skills): browse + select nested GitHub-repo skills for a pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing a repo like mattpocock/skills failed with "no skills found": the repo nests skills as skills///SKILL.md, but discovery only matched a fixed set of top-level bases (skills, .claude/skills, .agents/skills) exactly, so anything one folder deeper was invisible. Content-fetching already resolved nested skills by leaf-dir name (Convex viewer + gateway backfill both do), so only discovery was broken. Backend (convex/skills.ts): - discoverRepoSkills finds every SKILL.md at ANY depth in one git/trees call, derives a display "category" by stripping a recognized skills-root prefix (skills/.claude/.agents/.github), and skips node_modules. Leaf id must be unique within the repo (it's the fetch key) — first wins. - New listRepoSkills action: discovers + fetches + caches each SKILL.md (so previews are instant) and returns them with category + description + the repo's AGENTS.md/CLAUDE.md, for a choose-what-you-want flow. - importSkillRepo (curated templates, add-all) now shares the same discovery, so it benefits from nested support too. A repo that resolves but has no skills now reports "no skills found", not "repo not found". Frontend: - New RepoImportDialog: a two-pane browse-and-select modal — left is the discovered skills grouped by category with per-skill / per-group / select-all checkboxes and an added-already state; right is a live SKILL.md preview (rendered markdown, served from the browse cache so it's instant). Footer lets you also pull in AGENTS.md / CLAUDE.md. This brings repo import to parity with `npx skills add` (pick individual skills, see their contents). - The pack editor's "Import a GitHub repo" now opens this dialog (Browse) instead of a blind "Import all". Tests: convex/skills.test.ts stubs a nested (mattpocock-shaped) tree and pins discovery-at-depth, category derivation, node_modules exclusion, URL normalization, caching, and the no-skills vs not-found messages. convex 239 passed; web build + biome clean. --- .../web/src/components/repo-import-dialog.tsx | 394 ++++++++++++++++++ apps/web/src/components/skill-pack-editor.tsx | 47 ++- packages/convex-backend/convex/skills.test.ts | 148 +++++++ packages/convex-backend/convex/skills.ts | 370 ++++++++++------ 4 files changed, 832 insertions(+), 127 deletions(-) create mode 100644 apps/web/src/components/repo-import-dialog.tsx create mode 100644 packages/convex-backend/convex/skills.test.ts diff --git a/apps/web/src/components/repo-import-dialog.tsx b/apps/web/src/components/repo-import-dialog.tsx new file mode 100644 index 0000000..33fdf06 --- /dev/null +++ b/apps/web/src/components/repo-import-dialog.tsx @@ -0,0 +1,394 @@ +import { convexQuery, useConvexAction } from "@convex-dev/react-query"; +import { api } from "@harness/convex-backend/convex/_generated/api"; +import { useQuery } from "@tanstack/react-query"; +import { Check, FileText, Folder, Github, Loader2, Search } from "lucide-react"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import type { SkillEntry } from "../lib/skills"; +import { MarkdownMessage } from "./markdown-message"; +import { RoseCurveSpinner } from "./rose-curve-spinner"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Input } from "./ui/input"; + +type RepoSkill = { + skillId: string; + fullId: string; + category: string; + description: string; +}; + +type BrowseResult = { + source: string; + skills: RepoSkill[]; + agentsMd: string; + claudeMd: string; + discovered: number; + truncated: boolean; +}; + +export function RepoImportDialog({ + open, + onOpenChange, + initialSource, + existingNames, + onImport, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + initialSource: string; + /** Skill fullIds already in the pack — shown as added, not re-selectable. */ + existingNames: Set; + onImport: ( + skills: SkillEntry[], + meta: { agentsMd: string; claudeMd: string; source: string }, + ) => void; +}) { + const listRepoSkills = useConvexAction(api.skills.listRepoSkills); + const [source, setSource] = useState(initialSource); + const [browsing, setBrowsing] = useState(false); + const [result, setResult] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [preview, setPreview] = useState(null); + const [importAgents, setImportAgents] = useState(true); + const [importClaude, setImportClaude] = useState(true); + + const browse = async (raw?: string) => { + const repo = (raw ?? source).trim(); + if (!repo || browsing) return; + setSource(repo); + setBrowsing(true); + setResult(null); + setSelected(new Set()); + setPreview(null); + try { + const res = (await listRepoSkills({ source: repo })) as BrowseResult; + setResult(res); + // Pre-select everything not already in the pack — one click to add all. + setSelected( + new Set( + res.skills + .filter((s) => !existingNames.has(s.fullId)) + .map((s) => s.fullId), + ), + ); + setPreview(res.skills[0]?.fullId ?? null); + setImportAgents(!!res.agentsMd); + setImportClaude(!!res.claudeMd); + } catch (e) { + const data = + e && typeof e === "object" && "data" in e + ? (e as { data: unknown }).data + : undefined; + toast.error( + typeof data === "string" + ? data + : e instanceof Error + ? e.message + : "Couldn't read that repo", + ); + } finally { + setBrowsing(false); + } + }; + + // Group skills by category, preserving the server's sorted order. + const groups = useMemo(() => { + const map = new Map(); + for (const s of result?.skills ?? []) { + const key = s.category || ""; + const arr = map.get(key); + if (arr) arr.push(s); + else map.set(key, [s]); + } + return [...map.entries()]; + }, [result]); + + const selectableCount = + result?.skills.filter((s) => !existingNames.has(s.fullId)).length ?? 0; + const allSelected = selectableCount > 0 && selected.size === selectableCount; + + const toggle = (fullId: string) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(fullId)) next.delete(fullId); + else next.add(fullId); + return next; + }); + + const toggleGroup = (skills: RepoSkill[], on: boolean) => + setSelected((prev) => { + const next = new Set(prev); + for (const s of skills) { + if (existingNames.has(s.fullId)) continue; + if (on) next.add(s.fullId); + else next.delete(s.fullId); + } + return next; + }); + + const toggleAll = () => + setSelected( + allSelected + ? new Set() + : new Set( + (result?.skills ?? []) + .filter((s) => !existingNames.has(s.fullId)) + .map((s) => s.fullId), + ), + ); + + const addSelected = () => { + if (!result || selected.size === 0) return; + const skills: SkillEntry[] = result.skills + .filter((s) => selected.has(s.fullId)) + .map((s) => ({ name: s.fullId, description: s.description })); + onImport(skills, { + agentsMd: importAgents ? result.agentsMd : "", + claudeMd: importClaude ? result.claudeMd : "", + source: result.source, + }); + onOpenChange(false); + }; + + const previewQuery = useQuery({ + ...convexQuery(api.skills.getByName, { name: preview ?? "" }), + enabled: !!preview, + }); + + return ( + + + + + + Import skills from a GitHub repo + + + +
+
+ + setSource(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") browse(); + }} + placeholder="owner/repo (e.g. mattpocock/skills)" + className="pl-7" + /> +
+ +
+ + {/* Body */} +
+ {/* Left: selectable, grouped list */} +
+ {result ? ( + <> +
+ + + {result.discovered} skill + {result.discovered === 1 ? "" : "s"} found + {result.truncated && " (truncated)"} + +
+
+ {groups.map(([category, skills]) => { + const groupAll = skills.every( + (s) => + existingNames.has(s.fullId) || selected.has(s.fullId), + ); + return ( +
+ {category && ( + + )} + {skills.map((s) => { + const added = existingNames.has(s.fullId); + const isChecked = added || selected.has(s.fullId); + const isPreview = preview === s.fullId; + return ( +
+ + !added && toggle(s.fullId) + } + className="mt-0.5 shrink-0" + aria-label={`Select ${s.skillId}`} + /> + +
+ ); + })} +
+ ); + })} +
+ + ) : ( +
+ {browsing ? ( + + ) : ( + <> + +

+ Enter a repo and press Browse to see its skills. Nested + folders (e.g.{" "} + + skills/<category>/<name> + + ) are supported. +

+ + )} +
+ )} +
+ + {/* Right: live SKILL.md preview */} +
+ {preview ? ( + <> +
+ + + {preview.split("/").pop()} + + / SKILL.md +
+
+ {previewQuery.data?.detail ? ( + + ) : ( +
+ Loading… +
+ )} +
+ + ) : ( +
+ Select a skill to preview its SKILL.md. +
+ )} +
+
+ + {/* Footer */} +
+
+ {result?.agentsMd && ( + + )} + {result?.claudeMd && ( + + )} +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/skill-pack-editor.tsx b/apps/web/src/components/skill-pack-editor.tsx index 6926cd6..37a6c8c 100644 --- a/apps/web/src/components/skill-pack-editor.tsx +++ b/apps/web/src/components/skill-pack-editor.tsx @@ -16,6 +16,7 @@ import type { SkillPackTemplate } from "../lib/skill-pack-templates"; import { SKILL_PACK_TEMPLATES } from "../lib/skill-pack-templates"; import type { SkillEntry } from "../lib/skills"; import { RecommendedSkillsGrid } from "./recommended-skills-grid"; +import { RepoImportDialog } from "./repo-import-dialog"; import { SkillViewerDialog } from "./skill-viewer-dialog"; import { SkillsBrowser } from "./skills-browser"; import { Button } from "./ui/button"; @@ -65,6 +66,25 @@ export function SkillPackEditor({ const importRepo = useConvexAction(api.skills.importSkillRepo); const [repoInput, setRepoInput] = useState(""); const [importingRepo, setImportingRepo] = useState(null); + const [repoDialogOpen, setRepoDialogOpen] = useState(false); + + // Merge a repo-dialog selection into the pack, prefilling empty fields. + const addSkillsFromRepo = ( + imported: SkillEntry[], + meta: { agentsMd: string; claudeMd: string; source: string }, + ) => { + const seen = new Set(skills.map((s) => s.name)); + const fresh = imported.filter((s) => !seen.has(s.name)); + if (fresh.length > 0) setSkills((prev) => [...prev, ...fresh]); + if (!name.trim()) setName(meta.source.split("/").pop() ?? meta.source); + if (meta.agentsMd && !agentsMd.trim()) setAgentsMd(meta.agentsMd); + if (meta.claudeMd && !claudeMd.trim()) setClaudeMd(meta.claudeMd); + toast.success( + fresh.length > 0 + ? `Added ${fresh.length} skill${fresh.length === 1 ? "" : "s"}` + : "Those skills are already in the pack", + ); + }; const toggleSkill = (skill: SkillEntry) => setSkills((prev) => @@ -341,28 +361,25 @@ export function SkillPackEditor({ value={repoInput} onChange={(e) => setRepoInput(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") importFromRepo(repoInput); + if (e.key === "Enter" && repoInput.trim()) + setRepoDialogOpen(true); }} - placeholder="owner/repo (e.g. greensock/gsap-skills)" + placeholder="owner/repo (e.g. mattpocock/skills)" className="pl-7" />

- Adds every skill in the repo's skills/ folder, plus - its AGENTS.md / CLAUDE.md. + Browse a repo's skills (nested folders supported), preview each + one, and pick which to add — plus its AGENTS.md / CLAUDE.md.

@@ -386,6 +403,14 @@ export function SkillPackEditor({ fullId={viewingSkillId} onClose={() => setViewingSkillId(null)} /> + + s.name))} + onImport={addSkillsFromRepo} + /> ); } diff --git a/packages/convex-backend/convex/skills.test.ts b/packages/convex-backend/convex/skills.test.ts new file mode 100644 index 0000000..f6e0bd9 --- /dev/null +++ b/packages/convex-backend/convex/skills.test.ts @@ -0,0 +1,148 @@ +import { convexTest } from "convex-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api } from "./_generated/api"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.*s"); + +function makeT() { + const raw = convexTest(schema, modules); + const asUser = (userId: string) => + raw.withIdentity({ subject: userId, issuer: "test" }); + return { raw, asUser }; +} + +function res( + body: unknown, + { status = 200, text }: { status?: number; text?: string } = {}, +) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => text ?? "", + } as unknown as Response; +} + +const skillMd = (name: string, description: string) => + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nBody of ${name}.`; + +// A mattpocock/skills-shaped repo: SKILL.md nested under skills//. +const NESTED_TREE = { + truncated: false, + tree: [ + { type: "blob", path: "README.md" }, + { + type: "blob", + path: "skills/engineering/improve-codebase-architecture/SKILL.md", + }, + { type: "blob", path: "skills/engineering/tdd/SKILL.md" }, + { type: "blob", path: "skills/productivity/teach/SKILL.md" }, + // deeper nesting is still discovered + { type: "blob", path: "skills/misc/deep/nested/edit-article/SKILL.md" }, + // node_modules is ignored + { type: "blob", path: "node_modules/pkg/skills/x/SKILL.md" }, + ], +}; + +function installNestedRepoFetch() { + vi.stubGlobal("fetch", async (url: string) => { + if (url.includes("/git/trees/main")) return res(NESTED_TREE); + if (url.includes("/git/trees/master")) return res({}, { status: 404 }); + if (url.endsWith("/SKILL.md")) { + const id = url.split("/").slice(-2, -1)[0]; + return res(null, { text: skillMd(id, `Use ${id} when relevant.`) }); + } + // AGENTS.md / CLAUDE.md / anything else + return res(null, { status: 404 }); + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("skills.listRepoSkills (nested-repo browse)", () => { + it("discovers SKILL.md at any depth and groups them by category", async () => { + installNestedRepoFetch(); + const { asUser } = makeT(); + const result = await asUser("u1").action(api.skills.listRepoSkills, { + source: "mattpocock/skills", + }); + + expect(result.source).toBe("mattpocock/skills"); + const byId = Object.fromEntries(result.skills.map((s) => [s.skillId, s])); + + // Nested skills the OLD fixed-base discovery missed are now found. + expect(byId["improve-codebase-architecture"]).toMatchObject({ + fullId: "mattpocock/skills/improve-codebase-architecture", + category: "engineering", + description: "Use improve-codebase-architecture when relevant.", + }); + expect(byId.tdd.category).toBe("engineering"); + expect(byId.teach.category).toBe("productivity"); + // The skills-root prefix is stripped; deeper folders remain as category. + expect(byId["edit-article"].category).toBe("misc/deep/nested"); + // node_modules is excluded. + expect(result.skills.some((s) => s.skillId === "x")).toBe(false); + expect(result.discovered).toBe(4); + }); + + it("caches each SKILL.md so previews are instant (getByName resolves)", async () => { + installNestedRepoFetch(); + const { raw, asUser } = makeT(); + await asUser("u1").action(api.skills.listRepoSkills, { + source: "mattpocock/skills", + }); + const detail = await raw.query(api.skills.getByName, { + name: "mattpocock/skills/tdd", + }); + expect(detail?.detail).toContain("Body of tdd."); + expect(detail?.skillName).toBe("tdd"); + }); + + it("accepts a full github.com URL and normalizes it", async () => { + installNestedRepoFetch(); + const { asUser } = makeT(); + const result = await asUser("u1").action(api.skills.listRepoSkills, { + source: "https://github.com/mattpocock/skills.git", + }); + expect(result.source).toBe("mattpocock/skills"); + expect(result.skills.length).toBe(4); + }); + + it("errors clearly when the repo has no SKILL.md files", async () => { + vi.stubGlobal("fetch", async (url: string) => { + if (url.includes("/git/trees/main")) + return res({ tree: [{ type: "blob", path: "README.md" }] }); + return res({}, { status: 404 }); + }); + const { asUser } = makeT(); + await expect( + asUser("u1").action(api.skills.listRepoSkills, { source: "owner/empty" }), + ).rejects.toThrow(/No skills found/); + }); + + it("rejects a malformed source", async () => { + const { asUser } = makeT(); + await expect( + asUser("u1").action(api.skills.listRepoSkills, { source: "not-a-repo" }), + ).rejects.toThrow(/owner\/repo/); + }); +}); + +describe("skills.importSkillRepo (add-all, shared discovery)", () => { + it("returns every nested skill as a pack entry (name = fullId)", async () => { + installNestedRepoFetch(); + const { asUser } = makeT(); + const result = await asUser("u1").action(api.skills.importSkillRepo, { + source: "mattpocock/skills", + }); + expect(result.imported).toBe(4); + expect(result.skills.map((s) => s.name)).toContain("mattpocock/skills/tdd"); + expect(result.skills.every((s) => typeof s.description === "string")).toBe( + true, + ); + }); +}); diff --git a/packages/convex-backend/convex/skills.ts b/packages/convex-backend/convex/skills.ts index e84586a..009075c 100644 --- a/packages/convex-backend/convex/skills.ts +++ b/packages/convex-backend/convex/skills.ts @@ -1,11 +1,12 @@ import { ConvexError, v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { ActionCtx } from "./_generated/server"; import { action, internalMutation, internalQuery, query, } from "./_generated/server"; -import { internal } from "./_generated/api"; // ── skillDetails (full SKILL.md content, cached) ──────────────────── @@ -108,7 +109,6 @@ export const searchSkillsIndex = query({ }, }); - /** Upsert a batch of skills discovered from skills.sh search API */ export const upsertSkillsIndexBatch = internalMutation({ args: { @@ -254,7 +254,10 @@ async function fetchSkillMdFromRepo( const normalizedId = normalizeSkillId(skillId); const branches = ["main", "master"]; - const idsToTry = [skillId, ...(normalizedId !== skillId ? [normalizedId] : [])]; + const idsToTry = [ + skillId, + ...(normalizedId !== skillId ? [normalizedId] : []), + ]; // 1. Try direct paths (both branches) for (const branch of branches) { @@ -312,8 +315,7 @@ async function fetchSkillMdFromRepo( const dir = p.split("/").slice(-2, -1)[0] ?? ""; const normDir = dir.toLowerCase(); return ( - normalizedId.includes(normDir) || - normDir.includes(normalizedId) + normalizedId.includes(normDir) || normDir.includes(normalizedId) ); }); @@ -326,9 +328,7 @@ async function fetchSkillMdFromRepo( } // Check for a shallow SKILL.md (e.g. skill/SKILL.md at repo root) - const rootSkillMd = skillFiles.find( - (p) => p.split("/").length <= 2, - ); + const rootSkillMd = skillFiles.find((p) => p.split("/").length <= 2); if (rootSkillMd) { const mdResp = await fetch( `${ghRaw}/${source}/${branch}/${rootSkillMd}`, @@ -414,17 +414,50 @@ async function fetchRepoFile( return null; } +// Folder prefixes that read as a skills "root": stripped so the remaining +// folders become the human-facing category. Order matters (longest first). +const SKILL_ROOTS = [ + ".claude/skills", + ".agents/skills", + ".github/skills", + "skills", +]; + +type DiscoveredSkill = { skillId: string; path: string; category: string }; + +/** Drop a leading skills-root prefix from a skill's parent folders so the + * remainder reads as a category (e.g. ["skills","engineering"] → "engineering", + * ["skills"] → "", ["packages","x","skills","ui"] → "packages/x/skills/ui"). */ +function skillCategory(dirsAboveSkill: string[]): string { + for (const root of SKILL_ROOTS) { + const segs = root.split("/"); + if ( + dirsAboveSkill.length >= segs.length && + segs.every((s, i) => dirsAboveSkill[i] === s) + ) { + return dirsAboveSkill.slice(segs.length).join("/"); + } + } + return dirsAboveSkill.join("/"); +} + /** - * List the skill directory names in a repo via the git/trees API: any - * `//SKILL.md` where base is a known skills dir. One API call - * per branch — handles repos like greensock/gsap-skills (skills//SKILL.md). + * Discover every SKILL.md in a repo at ANY depth via one git/trees call per + * branch. Handles flat repos (`skills//SKILL.md`) AND nested, category- + * organized ones (`skills///SKILL.md`, any depth) like + * mattpocock/skills — the previous logic only matched a fixed set of top-level + * bases and silently found nothing in nested repos. Returns each skill's leaf + * id, full path (for a direct content fetch), and display category. */ -async function listRepoSkillIds( - source: string, -): Promise<{ ids: string[]; rateLimited: boolean; notFound: boolean }> { - const bases = new Set(["skills", ".agents/skills", ".claude/skills"]); +async function discoverRepoSkills(source: string): Promise<{ + skills: DiscoveredSkill[]; + rateLimited: boolean; + notFound: boolean; + truncated: boolean; +}> { let rateLimited = false; let notFound = false; + let sawRepo = false; for (const branch of ["main", "master"]) { try { const resp = await fetch( @@ -449,133 +482,239 @@ async function listRepoSkillIds( continue; } if (!resp.ok) continue; + // The branch resolved: the repo exists even if it has zero skills — so + // the caller reports "no skills found", not "repo not found" (a 404 on + // the OTHER branch must not override that). + sawRepo = true; const data = (await resp.json()) as { + truncated?: boolean; tree?: Array<{ path: string; type: string }>; }; - const ids = new Set(); + const seen = new Set(); + const skills: DiscoveredSkill[] = []; for (const e of data.tree ?? []) { if (e.type !== "blob" || !e.path.endsWith("/SKILL.md")) continue; + if (e.path.includes("node_modules/")) continue; const parts = e.path.split("/"); - const id = parts[parts.length - 2]; - const base = parts.slice(0, parts.length - 2).join("/"); - if (id && bases.has(base)) ids.add(id); + const skillId = parts[parts.length - 2]; + if (!skillId) continue; // a bare repo-root SKILL.md has no folder + // The leaf dir name is how both the viewer and the gateway backfill + // resolve a skill, so it must be unique within the repo; keep the + // first and skip a later collision rather than shadow it. + if (seen.has(skillId)) continue; + seen.add(skillId); + skills.push({ + skillId, + path: e.path, + category: skillCategory(parts.slice(0, parts.length - 2)), + }); } - if (ids.size > 0) { - return { ids: [...ids], rateLimited: false, notFound: false }; + if (skills.length > 0) { + skills.sort( + (a, b) => + a.category.localeCompare(b.category) || + a.skillId.localeCompare(b.skillId), + ); + return { + skills, + rateLimited: false, + notFound: false, + truncated: data.truncated === true, + }; } } catch { // try next branch } } - return { ids: [], rateLimited, notFound }; + return { + skills: [], + rateLimited, + notFound: sawRepo ? false : notFound, + truncated: false, + }; +} + +const MAX_REPO_IMPORT_SKILLS = 100; + +function normalizeRepoSource(raw: string): string { + return raw + .trim() + .replace(/^https?:\/\/github\.com\//i, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); } -const MAX_REPO_IMPORT_SKILLS = 60; +/** Normalize + validate a repo source, throwing a user-facing ConvexError. */ +function resolveRepoSource(raw: string): string { + const source = normalizeRepoSource(raw); + const segments = source.split("/"); + if ( + !/^[\w.-]+\/[\w.-]+$/.test(source) || + segments.some((s) => s === "." || s === "..") + ) { + // ConvexError (not Error): the deployment masks plain Errors as a + // generic "Server Error", so user-facing reasons must use ConvexError. + throw new ConvexError( + "Enter a GitHub repo as owner/repo (e.g. mattpocock/skills).", + ); + } + return source; +} + +/** Discover skills in a repo (throwing user-facing errors on rate-limit / not- + * found / empty), then fetch + cache + index each SKILL.md. Shared by the + * browse-and-select flow (listRepoSkills) and the add-all flow + * (importSkillRepo). */ +async function discoverAndCacheRepoSkills( + ctx: ActionCtx, + source: string, +): Promise<{ + skills: Array<{ + skillId: string; + fullId: string; + category: string; + description: string; + }>; + discovered: number; + truncated: boolean; +}> { + const { + skills: discovered, + rateLimited, + notFound, + truncated, + } = await discoverRepoSkills(source); + if (rateLimited) { + throw new ConvexError( + "GitHub's API rate limit was hit while reading the repo. Set a " + + "GITHUB_TOKEN environment variable on the Convex deployment to " + + "raise it (5000/hr), then try again.", + ); + } + if (discovered.length === 0) { + throw new ConvexError( + notFound + ? `Couldn't find a public GitHub repo "${source}" with any SKILL.md files.` + : `No skills found in ${source}. Expected SKILL.md files (e.g. skills//SKILL.md, at any depth).`, + ); + } + const limited = discovered.slice(0, MAX_REPO_IMPORT_SKILLS); + + const out: Array<{ + skillId: string; + fullId: string; + category: string; + description: string; + }> = []; + const indexEntries: Array<{ + skillId: string; + fullId: string; + source: string; + description: string; + installs: number; + }> = []; + + const CONCURRENCY = 6; + async function processSkill(d: DiscoveredSkill) { + const fullId = `${source}/${d.skillId}`; + // We already know the exact path from the tree, so fetch it directly; + // fall back to the resilient resolver only if that misses. + const detail = + (await fetchRepoFile(source, d.path)) ?? + (await fetchSkillMd(source, d.skillId)); + if (!detail) return; + const description = parseSkillDescription(detail); + await ctx.runMutation(internal.skills.upsertSkillDetail, { + name: fullId, + skillName: d.skillId, + description, + detail, + code: `npx skills add https://github.com/${source} --skill ${d.skillId}`, + }); + out.push({ skillId: d.skillId, fullId, category: d.category, description }); + indexEntries.push({ + skillId: d.skillId, + fullId, + source, + description, + installs: 0, + }); + } + for (let i = 0; i < limited.length; i += CONCURRENCY) { + await Promise.all(limited.slice(i, i + CONCURRENCY).map(processSkill)); + } + // Index the discovered skills so they're searchable in the catalog too. + if (indexEntries.length > 0) { + await ctx.runMutation(internal.skills.upsertSkillsIndexBatch, { + skills: indexEntries, + }); + } + out.sort( + (a, b) => + a.category.localeCompare(b.category) || + a.skillId.localeCompare(b.skillId), + ); + return { skills: out, discovered: discovered.length, truncated }; +} /** - * Import every skill in a GitHub repo (e.g. "greensock/gsap-skills") in one - * shot: discover the skill dirs, fetch + cache each SKILL.md, index them, and - * return them (plus the repo's top-level AGENTS.md / CLAUDE.md) so the caller - * can drop them into a skill pack. Reuses the same GitHub resolution as - * ensureSkillDetails (honors GITHUB_TOKEN for rate limits). + * Browse a GitHub repo's skills for the pack editor's select-what-you-want + * flow: discover every SKILL.md at any depth, fetch + cache each (so previews + * are instant), and return them grouped-ready (with a `category`) plus the + * repo's AGENTS.md / CLAUDE.md. The caller chooses which to add. */ -export const importSkillRepo = action({ +export const listRepoSkills = action({ args: { source: v.string() }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Unauthenticated"); + const source = resolveRepoSource(args.source); - // Accept a bare owner/repo or a github.com URL; normalize to owner/repo. - const source = args.source - .trim() - .replace(/^https?:\/\/github\.com\//i, "") - .replace(/\.git$/i, "") - .replace(/\/+$/, ""); - const segments = source.split("/"); - if ( - !/^[\w.-]+\/[\w.-]+$/.test(source) || - segments.some((s) => s === "." || s === "..") - ) { - // ConvexError (not Error): the deployment masks plain Errors as a - // generic "Server Error", so user-facing reasons must use ConvexError. - throw new ConvexError( - "Enter a GitHub repo as owner/repo (e.g. greensock/gsap-skills).", - ); - } + const [{ skills, discovered, truncated }, agentsMd, claudeMd] = + await Promise.all([ + discoverAndCacheRepoSkills(ctx, source), + fetchRepoFile(source, "AGENTS.md"), + fetchRepoFile(source, "CLAUDE.md"), + ]); - const { ids: skillIds, rateLimited, notFound } = - await listRepoSkillIds(source); - if (rateLimited) { - throw new ConvexError( - "GitHub's API rate limit was hit while reading the repo. Set a " + - "GITHUB_TOKEN environment variable on the Convex deployment to " + - "raise it (5000/hr), then try again.", - ); - } - if (skillIds.length === 0) { - throw new ConvexError( - notFound - ? `Couldn't find a public GitHub repo "${source}" with a skills/ folder.` - : `No skills found in ${source}. Expected SKILL.md files at skills//SKILL.md.`, - ); - } - const limited = skillIds.slice(0, MAX_REPO_IMPORT_SKILLS); + return { + source, + skills, + agentsMd: agentsMd ?? "", + claudeMd: claudeMd ?? "", + discovered, + truncated, + }; + }, +}); - const [agentsMd, claudeMd] = await Promise.all([ +/** + * Import EVERY skill in a GitHub repo in one shot (used by the curated + * templates). Discovers at any depth, fetches + caches each SKILL.md, and + * returns them (plus AGENTS.md / CLAUDE.md) as pack entries. + */ +export const importSkillRepo = action({ + args: { source: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Unauthenticated"); + const source = resolveRepoSource(args.source); + + const [{ skills, discovered }, agentsMd, claudeMd] = await Promise.all([ + discoverAndCacheRepoSkills(ctx, source), fetchRepoFile(source, "AGENTS.md"), fetchRepoFile(source, "CLAUDE.md"), ]); - const skills: Array<{ name: string; description: string }> = []; - const indexEntries: Array<{ - skillId: string; - fullId: string; - source: string; - description: string; - installs: number; - }> = []; - - const CONCURRENCY = 4; - async function processSkill(skillId: string) { - const name = `${source}/${skillId}`; - const detail = await fetchSkillMd(source, skillId); - if (!detail) return; - const description = parseSkillDescription(detail); - await ctx.runMutation(internal.skills.upsertSkillDetail, { - name, - skillName: skillId, - description, - detail, - code: `npx skills add https://github.com/${source} --skill ${skillId}`, - }); - skills.push({ name, description }); - indexEntries.push({ - skillId, - fullId: name, - source, - description, - installs: 0, - }); - } - for (let i = 0; i < limited.length; i += CONCURRENCY) { - await Promise.all(limited.slice(i, i + CONCURRENCY).map(processSkill)); - } - - // Index the discovered skills so they're searchable in the catalog too. - if (indexEntries.length > 0) { - await ctx.runMutation(internal.skills.upsertSkillsIndexBatch, { - skills: indexEntries, - }); - } - - // Stable order (the repo tree order isn't meaningful). - skills.sort((a, b) => a.name.localeCompare(b.name)); return { source, - skills, + skills: skills.map((s) => ({ + name: s.fullId, + description: s.description, + })), agentsMd: agentsMd ?? "", claudeMd: claudeMd ?? "", - discovered: skillIds.length, + discovered, imported: skills.length, }; }, @@ -620,7 +759,7 @@ export const ensureSkillDetails = action({ // skillId is the portion of fullId after the source prefix skillId = name.startsWith(source + "/") ? name.slice(source.length + 1) - : name.split("/").pop() ?? name; + : (name.split("/").pop() ?? name); } else { const parts = name.split("/"); skillId = parts.pop() ?? name; @@ -731,7 +870,6 @@ export const discoverSkillsFromSearch = action({ ), }, handler: async (ctx, args): Promise => { - const identity = await ctx.auth.getUserIdentity(); if (!identity) return 0; // Check which ones we already have @@ -742,9 +880,7 @@ export const discoverSkillsFromSearch = action({ ); const existingSet = new Set(existingIds); - const newSkills = args.skills.filter( - (s) => !existingSet.has(s.fullId), - ); + const newSkills = args.skills.filter((s) => !existingSet.has(s.fullId)); if (newSkills.length === 0) return 0; // Just insert index entries with empty descriptions — SKILL.md fetching @@ -773,7 +909,9 @@ export const searchForCreationAssistant = query({ const [byName, byDesc] = await Promise.all([ ctx.db .query("skillsIndex") - .withSearchIndex("search_skills", (q) => q.search("skillId", args.query)) + .withSearchIndex("search_skills", (q) => + q.search("skillId", args.query), + ) .take(20), ctx.db .query("skillsIndex")