Skip to content

Commit 109a7a2

Browse files
authored
feat(skills): browse + select nested GitHub-repo skills for a pack (#161)
Importing a repo like mattpocock/skills failed with "no skills found": the repo nests skills as skills/<category>/<id>/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.
1 parent b6b74a5 commit 109a7a2

4 files changed

Lines changed: 832 additions & 127 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
import { convexQuery, useConvexAction } from "@convex-dev/react-query";
2+
import { api } from "@harness/convex-backend/convex/_generated/api";
3+
import { useQuery } from "@tanstack/react-query";
4+
import { Check, FileText, Folder, Github, Loader2, Search } from "lucide-react";
5+
import { useMemo, useState } from "react";
6+
import toast from "react-hot-toast";
7+
import type { SkillEntry } from "../lib/skills";
8+
import { MarkdownMessage } from "./markdown-message";
9+
import { RoseCurveSpinner } from "./rose-curve-spinner";
10+
import { Button } from "./ui/button";
11+
import { Checkbox } from "./ui/checkbox";
12+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
13+
import { Input } from "./ui/input";
14+
15+
type RepoSkill = {
16+
skillId: string;
17+
fullId: string;
18+
category: string;
19+
description: string;
20+
};
21+
22+
type BrowseResult = {
23+
source: string;
24+
skills: RepoSkill[];
25+
agentsMd: string;
26+
claudeMd: string;
27+
discovered: number;
28+
truncated: boolean;
29+
};
30+
31+
export function RepoImportDialog({
32+
open,
33+
onOpenChange,
34+
initialSource,
35+
existingNames,
36+
onImport,
37+
}: {
38+
open: boolean;
39+
onOpenChange: (open: boolean) => void;
40+
initialSource: string;
41+
/** Skill fullIds already in the pack — shown as added, not re-selectable. */
42+
existingNames: Set<string>;
43+
onImport: (
44+
skills: SkillEntry[],
45+
meta: { agentsMd: string; claudeMd: string; source: string },
46+
) => void;
47+
}) {
48+
const listRepoSkills = useConvexAction(api.skills.listRepoSkills);
49+
const [source, setSource] = useState(initialSource);
50+
const [browsing, setBrowsing] = useState(false);
51+
const [result, setResult] = useState<BrowseResult | null>(null);
52+
const [selected, setSelected] = useState<Set<string>>(new Set());
53+
const [preview, setPreview] = useState<string | null>(null);
54+
const [importAgents, setImportAgents] = useState(true);
55+
const [importClaude, setImportClaude] = useState(true);
56+
57+
const browse = async (raw?: string) => {
58+
const repo = (raw ?? source).trim();
59+
if (!repo || browsing) return;
60+
setSource(repo);
61+
setBrowsing(true);
62+
setResult(null);
63+
setSelected(new Set());
64+
setPreview(null);
65+
try {
66+
const res = (await listRepoSkills({ source: repo })) as BrowseResult;
67+
setResult(res);
68+
// Pre-select everything not already in the pack — one click to add all.
69+
setSelected(
70+
new Set(
71+
res.skills
72+
.filter((s) => !existingNames.has(s.fullId))
73+
.map((s) => s.fullId),
74+
),
75+
);
76+
setPreview(res.skills[0]?.fullId ?? null);
77+
setImportAgents(!!res.agentsMd);
78+
setImportClaude(!!res.claudeMd);
79+
} catch (e) {
80+
const data =
81+
e && typeof e === "object" && "data" in e
82+
? (e as { data: unknown }).data
83+
: undefined;
84+
toast.error(
85+
typeof data === "string"
86+
? data
87+
: e instanceof Error
88+
? e.message
89+
: "Couldn't read that repo",
90+
);
91+
} finally {
92+
setBrowsing(false);
93+
}
94+
};
95+
96+
// Group skills by category, preserving the server's sorted order.
97+
const groups = useMemo(() => {
98+
const map = new Map<string, RepoSkill[]>();
99+
for (const s of result?.skills ?? []) {
100+
const key = s.category || "";
101+
const arr = map.get(key);
102+
if (arr) arr.push(s);
103+
else map.set(key, [s]);
104+
}
105+
return [...map.entries()];
106+
}, [result]);
107+
108+
const selectableCount =
109+
result?.skills.filter((s) => !existingNames.has(s.fullId)).length ?? 0;
110+
const allSelected = selectableCount > 0 && selected.size === selectableCount;
111+
112+
const toggle = (fullId: string) =>
113+
setSelected((prev) => {
114+
const next = new Set(prev);
115+
if (next.has(fullId)) next.delete(fullId);
116+
else next.add(fullId);
117+
return next;
118+
});
119+
120+
const toggleGroup = (skills: RepoSkill[], on: boolean) =>
121+
setSelected((prev) => {
122+
const next = new Set(prev);
123+
for (const s of skills) {
124+
if (existingNames.has(s.fullId)) continue;
125+
if (on) next.add(s.fullId);
126+
else next.delete(s.fullId);
127+
}
128+
return next;
129+
});
130+
131+
const toggleAll = () =>
132+
setSelected(
133+
allSelected
134+
? new Set()
135+
: new Set(
136+
(result?.skills ?? [])
137+
.filter((s) => !existingNames.has(s.fullId))
138+
.map((s) => s.fullId),
139+
),
140+
);
141+
142+
const addSelected = () => {
143+
if (!result || selected.size === 0) return;
144+
const skills: SkillEntry[] = result.skills
145+
.filter((s) => selected.has(s.fullId))
146+
.map((s) => ({ name: s.fullId, description: s.description }));
147+
onImport(skills, {
148+
agentsMd: importAgents ? result.agentsMd : "",
149+
claudeMd: importClaude ? result.claudeMd : "",
150+
source: result.source,
151+
});
152+
onOpenChange(false);
153+
};
154+
155+
const previewQuery = useQuery({
156+
...convexQuery(api.skills.getByName, { name: preview ?? "" }),
157+
enabled: !!preview,
158+
});
159+
160+
return (
161+
<Dialog open={open} onOpenChange={onOpenChange}>
162+
<DialogContent className="flex h-[85vh] max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
163+
<DialogHeader className="border-b border-border px-5 py-3.5">
164+
<DialogTitle className="flex items-center gap-2 text-sm">
165+
<Github size={15} />
166+
Import skills from a GitHub repo
167+
</DialogTitle>
168+
</DialogHeader>
169+
170+
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
171+
<div className="relative flex-1">
172+
<Search
173+
size={13}
174+
className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 text-muted-foreground"
175+
/>
176+
<Input
177+
autoFocus
178+
value={source}
179+
onChange={(e) => setSource(e.target.value)}
180+
onKeyDown={(e) => {
181+
if (e.key === "Enter") browse();
182+
}}
183+
placeholder="owner/repo (e.g. mattpocock/skills)"
184+
className="pl-7"
185+
/>
186+
</div>
187+
<Button
188+
size="sm"
189+
disabled={!source.trim() || browsing}
190+
onClick={() => browse()}
191+
>
192+
{browsing ? (
193+
<Loader2 size={14} className="animate-spin" />
194+
) : (
195+
"Browse"
196+
)}
197+
</Button>
198+
</div>
199+
200+
{/* Body */}
201+
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
202+
{/* Left: selectable, grouped list */}
203+
<div className="flex min-h-0 flex-1 flex-col border-border lg:w-1/2 lg:border-r">
204+
{result ? (
205+
<>
206+
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2 text-[11px] text-muted-foreground">
207+
<button
208+
type="button"
209+
onClick={toggleAll}
210+
className="flex items-center gap-1.5 font-medium text-foreground hover:text-foreground/80"
211+
>
212+
<Checkbox checked={allSelected} className="size-3.5" />
213+
{allSelected ? "Deselect all" : "Select all"}
214+
</button>
215+
<span>
216+
{result.discovered} skill
217+
{result.discovered === 1 ? "" : "s"} found
218+
{result.truncated && " (truncated)"}
219+
</span>
220+
</div>
221+
<div className="min-h-0 flex-1 overflow-y-auto py-1">
222+
{groups.map(([category, skills]) => {
223+
const groupAll = skills.every(
224+
(s) =>
225+
existingNames.has(s.fullId) || selected.has(s.fullId),
226+
);
227+
return (
228+
<div key={category || "_root"} className="mb-1">
229+
{category && (
230+
<button
231+
type="button"
232+
onClick={() => toggleGroup(skills, !groupAll)}
233+
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground"
234+
>
235+
<Folder size={11} />
236+
{category}
237+
</button>
238+
)}
239+
{skills.map((s) => {
240+
const added = existingNames.has(s.fullId);
241+
const isChecked = added || selected.has(s.fullId);
242+
const isPreview = preview === s.fullId;
243+
return (
244+
<div
245+
key={s.fullId}
246+
className={`flex items-start gap-2.5 px-4 py-1.5 ${
247+
isPreview
248+
? "bg-foreground/5"
249+
: "hover:bg-foreground/3"
250+
}`}
251+
>
252+
<Checkbox
253+
checked={isChecked}
254+
disabled={added}
255+
onCheckedChange={() =>
256+
!added && toggle(s.fullId)
257+
}
258+
className="mt-0.5 shrink-0"
259+
aria-label={`Select ${s.skillId}`}
260+
/>
261+
<button
262+
type="button"
263+
onClick={() => setPreview(s.fullId)}
264+
className="min-w-0 flex-1 cursor-pointer text-left"
265+
>
266+
<div className="flex items-center gap-1.5">
267+
<span className="truncate text-xs font-medium text-foreground">
268+
{s.skillId}
269+
</span>
270+
{added && (
271+
<span className="shrink-0 text-[10px] text-muted-foreground">
272+
· added
273+
</span>
274+
)}
275+
</div>
276+
{s.description && (
277+
<p className="line-clamp-2 text-[11px] text-muted-foreground">
278+
{s.description}
279+
</p>
280+
)}
281+
</button>
282+
</div>
283+
);
284+
})}
285+
</div>
286+
);
287+
})}
288+
</div>
289+
</>
290+
) : (
291+
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
292+
{browsing ? (
293+
<RoseCurveSpinner
294+
size={22}
295+
className="text-muted-foreground"
296+
label="Reading repository"
297+
/>
298+
) : (
299+
<>
300+
<Github size={22} className="text-muted-foreground/40" />
301+
<p className="text-xs text-muted-foreground">
302+
Enter a repo and press Browse to see its skills. Nested
303+
folders (e.g.{" "}
304+
<code className="text-[10px]">
305+
skills/&lt;category&gt;/&lt;name&gt;
306+
</code>
307+
) are supported.
308+
</p>
309+
</>
310+
)}
311+
</div>
312+
)}
313+
</div>
314+
315+
{/* Right: live SKILL.md preview */}
316+
<div className="hidden min-h-0 flex-1 flex-col lg:flex lg:w-1/2">
317+
{preview ? (
318+
<>
319+
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-[11px] text-muted-foreground">
320+
<FileText size={12} />
321+
<span className="truncate font-medium text-foreground">
322+
{preview.split("/").pop()}
323+
</span>
324+
<span className="truncate">/ SKILL.md</span>
325+
</div>
326+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
327+
{previewQuery.data?.detail ? (
328+
<MarkdownMessage
329+
content={previewQuery.data.detail.replace(
330+
/^---\s*\n[\s\S]*?\n---\s*\n?/,
331+
"",
332+
)}
333+
/>
334+
) : (
335+
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
336+
Loading…
337+
</div>
338+
)}
339+
</div>
340+
</>
341+
) : (
342+
<div className="flex flex-1 items-center justify-center p-8 text-center text-xs text-muted-foreground">
343+
Select a skill to preview its SKILL.md.
344+
</div>
345+
)}
346+
</div>
347+
</div>
348+
349+
{/* Footer */}
350+
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-3">
351+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-foreground">
352+
{result?.agentsMd && (
353+
<label
354+
htmlFor="repo-import-agents"
355+
className="flex items-center gap-1.5"
356+
>
357+
<Checkbox
358+
id="repo-import-agents"
359+
checked={importAgents}
360+
onCheckedChange={(c) => setImportAgents(c === true)}
361+
className="size-3.5"
362+
/>
363+
Import AGENTS.md
364+
</label>
365+
)}
366+
{result?.claudeMd && (
367+
<label
368+
htmlFor="repo-import-claude"
369+
className="flex items-center gap-1.5"
370+
>
371+
<Checkbox
372+
id="repo-import-claude"
373+
checked={importClaude}
374+
onCheckedChange={(c) => setImportClaude(c === true)}
375+
className="size-3.5"
376+
/>
377+
Import CLAUDE.md
378+
</label>
379+
)}
380+
</div>
381+
<Button
382+
size="sm"
383+
disabled={selected.size === 0}
384+
onClick={addSelected}
385+
>
386+
<Check size={14} />
387+
Add {selected.size > 0 ? selected.size : ""} skill
388+
{selected.size === 1 ? "" : "s"}
389+
</Button>
390+
</div>
391+
</DialogContent>
392+
</Dialog>
393+
);
394+
}

0 commit comments

Comments
 (0)