feat(skills): fix browse cap, add source links + copy buttons + category cleanup (#37143)

Skills discovery surfaced ~136 of 88k skills in the CLI and gave community
skills no clickable source on the docs page. Three coupled fixes:

CLI browse:
- hermes skills browse capped at 50 because the per-source limit dict had no
  'hermes-index' key — when the centralized index is available the router
  skips external APIs and serves only the index, so the default-50 fallthrough
  silently truncated the whole hub. Add hermes-index: 5000. Browse now loads
  5367 (269 pages) instead of 136.
- Add an Identifier column + install/inspect hint to the browse table so users
  can act on what they see without a second 'search'.
- Route the TUI browse_skills() helper through parallel_search_sources so it
  inherits the same index-aware source-skip (was double-counting); expose
  identifier in its output.

Docs Skills Hub page:
- Synthesize a sourceUrl for every community skill (github tree URL, clawhub /
  skills.sh / lobehub / browse.sh detail pages), preferring the adapter's
  explicit extra.detail_url/source_url/repo_url. Expanded cards now show
  'View source' for community skills (was nothing) and keep 'View full
  documentation' for built-in/optional. 99% coverage.
- Add a Copy button on the install command.
- Add a loading state instead of flashing '0 skills / No skills found' while
  the 45MB catalog fetches.

Category cleanup:
- _guess_category fell back to tags[0] verbatim, producing ~430 junk one-off
  categories (version strings, brand names: '0.10.7 Dev', 'Doramagic Crystal').
  Now only curated buckets are accepted; unknowns fold into 'Other'. Widen the
  tag->category map so common community tags route to real buckets. 430 -> 173
  categories, top 20 all meaningful.

Tests: tests/website/test_extract_skills.py covers _source_url synthesis +
precedence and _guess_category curation (13 tests). All 27 skills-hub CLI
tests still pass. Docusaurus build verified; expanded cards confirmed in
browser for both community (View source) and built-in (View full docs).
This commit is contained in:
Teknium
2026-06-01 19:52:28 -07:00
committed by GitHub
parent 0cd5867bbb
commit 59510d7b44
5 changed files with 399 additions and 43 deletions
+78 -10
View File
@@ -19,6 +19,10 @@ interface Skill {
docsPath?: string;
identifier?: string;
installCmd?: string;
/** Clickable URL to the skill's origin (repo / detail page). Synthesized
* in extract-skills.py for community skills that have no generated docs
* page, so the expanded card always has somewhere to send the user. */
sourceUrl?: string;
/** Lowercase pre-joined haystack used by the search filter.
* Built once at load time so per-keystroke filtering is a single
* `.includes()` per skill instead of array-join + toLowerCase on
@@ -240,6 +244,47 @@ function highlightMatch(text: string, query: string): React.ReactNode {
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard?.writeText(text).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
() => {},
);
},
[text],
);
return (
<button
className={styles.copyBtn}
onClick={onCopy}
title="Copy install command"
aria-label="Copy install command"
>
{copied ? (
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path
fillRule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clipRule="evenodd"
/>
</svg>
) : (
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.12A1.5 1.5 0 0117 6.622V12.5a1.5 1.5 0 01-1.5 1.5h-1v-3.379a3 3 0 00-.879-2.121L10.5 5.379A3 3 0 008.379 4.5H7v-1z" />
<path d="M4.5 6A1.5 1.5 0 003 7.5v9A1.5 1.5 0 004.5 18h7a1.5 1.5 0 001.5-1.5v-5.879a1.5 1.5 0 00-.44-1.06L9.44 6.439A1.5 1.5 0 008.378 6H4.5z" />
</svg>
)}
<span className={styles.copyBtnLabel}>{copied ? "Copied" : "Copy"}</span>
</button>
);
}
function SkillCard({
skill,
query,
@@ -379,16 +424,31 @@ function SkillCard({
)}
<div className={styles.installHint}>
<code>{skill.installCmd || `hermes skills install ${skill.name}`}</code>
<CopyButton
text={skill.installCmd || `hermes skills install ${skill.name}`}
/>
</div>
<div className={styles.cardLinks}>
{skill.docsPath ? (
<a
className={styles.docsLink}
href={`/docs/user-guide/skills/${skill.docsPath}`}
onClick={(e) => e.stopPropagation()}
>
View full documentation
</a>
) : skill.sourceUrl ? (
<a
className={styles.docsLink}
href={skill.sourceUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
View source
</a>
) : null}
</div>
{skill.docsPath && (
<a
className={styles.docsLink}
href={`/docs/user-guide/skills/${skill.docsPath}`}
onClick={(e) => e.stopPropagation()}
>
View full documentation
</a>
)}
</div>
)}
</div>
@@ -792,7 +852,15 @@ export default function SkillsDashboard() {
</div>
)}
{visible.length > 0 ? (
{!data && !loadError ? (
<div className={styles.empty}>
<div className={styles.loadingSpinner} />
<h3 className={styles.emptyTitle}>Loading the catalog</h3>
<p className={styles.emptyDesc}>
Fetching 88k+ skills across every registry. One moment.
</p>
</div>
) : visible.length > 0 ? (
<>
<div className={styles.grid}>
{visible.map((skill, i) => {
@@ -628,6 +628,9 @@
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 215, 0, 0.06);
border-radius: 5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.installHint code {
@@ -636,6 +639,64 @@
color: rgba(255, 215, 0, 0.7);
background: none;
padding: 0;
flex: 1;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
.installHint code::-webkit-scrollbar {
display: none;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
padding: 0.2rem 0.45rem;
border: 1px solid rgba(255, 215, 0, 0.18);
border-radius: 4px;
background: rgba(255, 215, 0, 0.06);
color: rgba(255, 215, 0, 0.85);
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.copyBtn:hover {
background: rgba(255, 215, 0, 0.14);
color: rgba(255, 215, 0, 1);
}
.copyBtnLabel {
line-height: 1;
}
.cardLinks {
display: flex;
gap: 0.5rem;
}
.cardLinks .docsLink {
flex: 1;
}
.loadingSpinner {
width: 2.25rem;
height: 2.25rem;
margin: 0 auto 1rem;
border: 3px solid rgba(255, 215, 0, 0.15);
border-top-color: rgba(255, 215, 0, 0.7);
border-radius: 50%;
animation: skillsSpin 0.8s linear infinite;
}
@keyframes skillsSpin {
to {
transform: rotate(360deg);
}
}
.overviewBlock {