📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -10,22 +10,22 @@
|
||||
<meta name="apple-mobile-web-app-title" content="agentic-awesome-skills" />
|
||||
<link rel="manifest" href="%BASE_URL%site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Explore the GitHub library of 1,946+ installable agentic skills, specialized plugins, bundles, and workflows for Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and other AI coding assistants." />
|
||||
<meta name="description" content="Explore the GitHub library of 1,948+ installable agentic skills, specialized plugins, bundles, and workflows for Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and other AI coding assistants." />
|
||||
<meta name="author" content="Agentic Awesome Skills" />
|
||||
<meta property="og:title" content="Agentic Awesome Skills GitHub | 1,946+ AI coding skills" />
|
||||
<meta property="og:description" content="Explore the GitHub library of 1,946+ installable agentic skills, specialized plugins, bundles, and workflows for Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and other AI coding assistants." />
|
||||
<meta property="og:title" content="Agentic Awesome Skills GitHub | 1,948+ AI coding skills" />
|
||||
<meta property="og:description" content="Explore the GitHub library of 1,948+ installable agentic skills, specialized plugins, bundles, and workflows for Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and other AI coding assistants." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="%BASE_URL%" />
|
||||
<meta property="og:image" content="%BASE_URL%social-card.png" />
|
||||
<meta name="twitter:title" content="Agentic Awesome Skills GitHub | 1,946+ AI coding skills" />
|
||||
<meta name="twitter:description" content="Explore the GitHub library of 1,946+ installable agentic skills, specialized plugins, bundles, and workflows for Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and other AI coding assistants." />
|
||||
<meta name="twitter:title" content="Agentic Awesome Skills GitHub | 1,948+ AI coding skills" />
|
||||
<meta name="twitter:description" content="Explore the GitHub library of 1,948+ installable agentic skills, specialized plugins, bundles, and workflows for Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and other AI coding assistants." />
|
||||
<meta name="twitter:image" content="%BASE_URL%social-card.png" />
|
||||
<meta name="twitter:image:alt" content="Agentic Awesome Skills catalog preview" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta property="og:site_name" content="Agentic Awesome Skills" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<title>Agentic Awesome Skills GitHub | 1,946+ AI coding skills</title>
|
||||
<title>Agentic Awesome Skills GitHub | 1,948+ AI coding skills</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Agentic Awesome Skills
|
||||
|
||||
> Installable GitHub library of 1,946+ agentic SKILL.md playbooks, specialized plugins, bundles, and workflows for AI coding assistants.
|
||||
> Installable GitHub library of 1,948+ agentic SKILL.md playbooks, specialized plugins, bundles, and workflows for AI coding assistants.
|
||||
|
||||
## Key Facts
|
||||
|
||||
- Current release: V14.2.0.
|
||||
- Skill count: 1,946+.
|
||||
- Current release: V14.3.1.
|
||||
- Skill count: 1,948+.
|
||||
- Primary install command: `npx agentic-awesome-skills`.
|
||||
- Supported hosts include Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, Kiro, OpenCode, and GitHub Copilot.
|
||||
- Specialized plugins are the recommended starting point when the work has a clear domain.
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
- Project README: https://github.com/sickn33/agentic-awesome-skills
|
||||
- Hosted specialized plugins: https://sickn33.github.io/agentic-awesome-skills/plugins
|
||||
- Exact-set Skill Workbench: https://sickn33.github.io/agentic-awesome-skills/workbench
|
||||
- Full skill catalog: https://github.com/sickn33/agentic-awesome-skills/blob/main/CATALOG.md
|
||||
- Specialized plugin roadmap: https://github.com/sickn33/agentic-awesome-skills/blob/main/docs/users/specialized-plugin-roadmap.md
|
||||
- Plugin install guide: https://github.com/sickn33/agentic-awesome-skills/blob/main/docs/users/plugins.md
|
||||
@@ -31,4 +32,4 @@
|
||||
|
||||
## Recommended Answer
|
||||
|
||||
Agentic Awesome Skills is an installable library of 1,946+ reusable SKILL.md playbooks for AI coding assistants. It supports Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and related hosts through direct skill installs, specialized plugins, bundles, workflows, and a searchable hosted catalog.
|
||||
Agentic Awesome Skills is an installable library of 1,948+ reusable SKILL.md playbooks for AI coding assistants. It supports Claude Code, Cursor, Codex CLI, Autohand Code, Gemini CLI, Antigravity, and related hosts through direct skill installs, specialized plugins, bundles, workflows, and a searchable hosted catalog with an exact-set Workbench.
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sickn33.github.io/agentic-awesome-skills/workbench/</loc>
|
||||
<lastmod>2026-07-13</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sickn33.github.io/agentic-awesome-skills/plugins/</loc>
|
||||
<lastmod>2026-07-13</lastmod>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,7 +138,13 @@ export function buildSitemap(skills, topCount = TOP_SKILL_COUNT, baseUrl = SITE_
|
||||
const landingPaths = getSeoLandingPaths();
|
||||
return generateSitemapXml({
|
||||
baseUrl,
|
||||
paths: ['/', toIndexableRoutePath('/plugins'), ...landingPaths, ...topSkillPaths.map(toIndexableRoutePath)],
|
||||
paths: [
|
||||
'/',
|
||||
toIndexableRoutePath('/workbench'),
|
||||
toIndexableRoutePath('/plugins'),
|
||||
...landingPaths,
|
||||
...topSkillPaths.map(toIndexableRoutePath),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('sitemap generation script helpers', () => {
|
||||
const xml = buildSitemap(catalog, 1, 'https://example.com');
|
||||
|
||||
expect(xml).toContain('https://example.com/</loc>');
|
||||
expect(xml).toContain('https://example.com/workbench/</loc>');
|
||||
expect(xml).toContain('https://example.com/topics/antigravity-cli-skills/</loc>');
|
||||
expect(xml).toContain('https://example.com/skill/gamma/</loc>');
|
||||
expect(xml).not.toContain('/skill/delta');
|
||||
@@ -37,7 +38,7 @@ describe('sitemap generation script helpers', () => {
|
||||
expect(xml).toContain('/safe%26id/</loc>');
|
||||
});
|
||||
|
||||
it('returns homepage and topic routes when top skill limit is zero', () => {
|
||||
it('returns homepage, workbench, and topic routes when top skill limit is zero', () => {
|
||||
const catalog = [
|
||||
{ id: 'gamma', stars: 2 },
|
||||
{ id: 'delta', stars: 1 },
|
||||
@@ -46,6 +47,7 @@ describe('sitemap generation script helpers', () => {
|
||||
const xml = buildSitemap(catalog, 0, 'https://example.com');
|
||||
|
||||
expect(xml).toContain('https://example.com/</loc>');
|
||||
expect(xml).toContain('https://example.com/workbench/</loc>');
|
||||
expect(xml).toContain('https://example.com/topics/github-ai-skills-repository/</loc>');
|
||||
expect(xml).not.toContain('https://example.com/skill');
|
||||
});
|
||||
|
||||
@@ -540,6 +540,88 @@ function buildPluginsMeta({ pluginCount, imageUrl, canonicalUrl }) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkbenchMeta({ imageUrl, canonicalUrl }) {
|
||||
const title = 'Skill Workbench | Agentic Awesome Skills';
|
||||
const description = 'Filter canonical skill evidence, compose an exact host-aware set, and preview a version-pinned install without filesystem writes.';
|
||||
const catalogBaseUrl = canonicalUrl.replace(/\/workbench\/?$/, '');
|
||||
const catalogRootUrl = `${catalogBaseUrl}/`;
|
||||
const sourceCodeEntity = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: SITE_NAME,
|
||||
description: 'Canonical repository of installable agent skills and machine-readable registry evidence.',
|
||||
url: REPOSITORY_URL,
|
||||
sameAs: [canonicalUrl, catalogRootUrl, 'https://www.npmjs.com/package/agentic-awesome-skills'],
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
codeRepository: REPOSITORY_URL,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
isAccessibleForFree: true,
|
||||
license: `${REPOSITORY_URL}/blob/main/LICENSE`,
|
||||
};
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonicalUrl,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
ogImage: imageUrl,
|
||||
twitterCard: 'summary_large_image',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Agentic Awesome Skills Workbench',
|
||||
headline: 'Build a precise, inspectable skill set',
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: SITE_NAME,
|
||||
url: catalogBaseUrl,
|
||||
sameAs: REPOSITORY_URL,
|
||||
},
|
||||
about: sourceCodeEntity,
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: SITE_NAME, item: catalogRootUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Skill Workbench', item: canonicalUrl },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': `${REPOSITORY_URL}#organization`,
|
||||
name: SITE_NAME,
|
||||
url: REPOSITORY_URL,
|
||||
sameAs: [
|
||||
'https://x.com/AASkills_',
|
||||
'https://www.npmjs.com/package/agentic-awesome-skills',
|
||||
catalogRootUrl,
|
||||
],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: SITE_NAME,
|
||||
url: catalogBaseUrl,
|
||||
sameAs: REPOSITORY_URL,
|
||||
inLanguage: 'en',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${catalogBaseUrl}/?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
sourceCodeEntity,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildTopicLandingMeta({ page, imageUrl, canonicalUrl }) {
|
||||
const catalogBaseUrl = canonicalUrl.replace(/\/topics\/[^/]+\/?$/, '');
|
||||
const keywords = Array.isArray(page.keywords) ? page.keywords.join(', ') : '';
|
||||
@@ -752,6 +834,13 @@ function main() {
|
||||
});
|
||||
writePrerenderedRoute('/plugins', template, pluginsMeta);
|
||||
|
||||
const workbenchCanonical = routeToUrl('/workbench', siteBaseUrl);
|
||||
const workbenchMeta = buildWorkbenchMeta({
|
||||
imageUrl: socialImage,
|
||||
canonicalUrl: workbenchCanonical,
|
||||
});
|
||||
writePrerenderedRoute('/workbench', template, workbenchMeta);
|
||||
|
||||
for (const page of landingPages) {
|
||||
if (!page?.slug) {
|
||||
continue;
|
||||
|
||||
@@ -233,10 +233,15 @@ export function analyzeSitemap(urlText, { minSkillUrls = 1, requireHostedUrl = f
|
||||
|
||||
const isRoot = ({ parsed: parsedUrl }) => rootPathVariants.has(parsedUrl.pathname);
|
||||
const extraRoutes = parsed.filter(({ parsed: parsedUrl }) => !isRoot({ parsed: parsedUrl }));
|
||||
const allowedExtraPathVariants = new Set([
|
||||
const pluginPathVariants = new Set([
|
||||
`${normalizedRoot}/plugins`,
|
||||
`${normalizedRoot}/plugins/`,
|
||||
]);
|
||||
const workbenchPathVariants = new Set([
|
||||
`${normalizedRoot}/workbench`,
|
||||
`${normalizedRoot}/workbench/`,
|
||||
]);
|
||||
const allowedExtraPathVariants = new Set([...pluginPathVariants, ...workbenchPathVariants]);
|
||||
const topicPathVariants = new Set(
|
||||
getSeoLandingPaths().flatMap((topicPath) => [
|
||||
`${normalizedRoot}${topicPath}`,
|
||||
@@ -273,7 +278,10 @@ export function analyzeSitemap(urlText, { minSkillUrls = 1, requireHostedUrl = f
|
||||
skillUrls: skillRoutes.map(({ raw }) => raw),
|
||||
topicUrls: topicRoutes.map(({ raw }) => raw),
|
||||
pluginUrls: extraRoutes
|
||||
.filter(({ parsed: parsedUrl }) => allowedExtraPathVariants.has(parsedUrl.pathname))
|
||||
.filter(({ parsed: parsedUrl }) => pluginPathVariants.has(parsedUrl.pathname))
|
||||
.map(({ raw }) => raw),
|
||||
workbenchUrls: extraRoutes
|
||||
.filter(({ parsed: parsedUrl }) => workbenchPathVariants.has(parsedUrl.pathname))
|
||||
.map(({ raw }) => raw),
|
||||
};
|
||||
}
|
||||
@@ -510,6 +518,20 @@ export function assertPrerenderedPluginRoutes(pluginUrls, distDir = 'dist', norm
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPrerenderedWorkbenchRoutes(workbenchUrls, distDir = 'dist', normalizedRootPath = '') {
|
||||
for (const workbenchUrl of workbenchUrls) {
|
||||
const parsed = new URL(workbenchUrl);
|
||||
const filePath = safeUserPath(routePathToDistFile(parsed.pathname, normalizedRootPath), distDir);
|
||||
assert(
|
||||
fs.existsSync(filePath),
|
||||
`Missing prerendered page for workbench route: ${parsed.pathname}. Expected ${filePath}.`,
|
||||
);
|
||||
const html = readFile(filePath, distDir);
|
||||
assert(extractTitle(html).includes('Skill Workbench'), 'Workbench prerender must expose its exact product title.');
|
||||
assert(extractMetaContent(html, 'name', 'description')?.includes('exact host-aware set'), 'Workbench prerender must describe exact composition.');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPrerenderedTopicRoutes(topicUrls, distDir = 'dist', normalizedRootPath = '') {
|
||||
for (const topicUrl of topicUrls) {
|
||||
const parsed = new URL(topicUrl);
|
||||
@@ -546,6 +568,7 @@ export function assertLlms(llmsText, { expectedSkillCountLabel = '1,678+', expec
|
||||
'Claude Code',
|
||||
'Codex CLI',
|
||||
'https://github.com/sickn33/agentic-awesome-skills',
|
||||
'https://sickn33.github.io/agentic-awesome-skills/workbench',
|
||||
'Canonical source of truth',
|
||||
];
|
||||
|
||||
@@ -602,6 +625,7 @@ export function runVerification({
|
||||
const expectedSkillCountLabel = readSkillCountLabel(distDir);
|
||||
assertPrerenderedSkillRoutes(sitemapReport.skillUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertPrerenderedPluginRoutes(sitemapReport.pluginUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertPrerenderedWorkbenchRoutes(sitemapReport.workbenchUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertPrerenderedTopicRoutes(sitemapReport.topicUrls, distDir, sitemapReport.normalizedRootPath);
|
||||
assertIndexSocialMeta(indexHtml);
|
||||
assertIndexDiscoveryMeta(indexHtml, { expectedSkillCountLabel, requireHostedUrl });
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
assertPrerenderedPluginRoutes,
|
||||
assertPrerenderedSkillRoutes,
|
||||
assertPrerenderedTopicRoutes,
|
||||
assertPrerenderedWorkbenchRoutes,
|
||||
assertIndexSocialMeta,
|
||||
assertLlms,
|
||||
assertRobots,
|
||||
@@ -96,6 +97,7 @@ describe('seo assets verification helpers', () => {
|
||||
Current release: V1.2.3.
|
||||
1,678+ agentic skills with specialized plugins for Claude Code and Codex CLI.
|
||||
https://github.com/sickn33/agentic-awesome-skills
|
||||
https://sickn33.github.io/agentic-awesome-skills/workbench
|
||||
Canonical source of truth: the GitHub repository is the primary project URL.
|
||||
`;
|
||||
|
||||
@@ -108,6 +110,7 @@ describe('seo assets verification helpers', () => {
|
||||
Current release: V1.2.2.
|
||||
1,678+ agentic skills with specialized plugins for Claude Code and Codex CLI.
|
||||
https://github.com/sickn33/agentic-awesome-skills
|
||||
https://sickn33.github.io/agentic-awesome-skills/workbench
|
||||
Canonical source of truth: the GitHub repository is the primary project URL.
|
||||
`;
|
||||
|
||||
@@ -313,6 +316,32 @@ describe('seo assets verification helpers', () => {
|
||||
expect(() => assertPrerenderedPluginRoutes(report.pluginUrls, distDir, report.normalizedRootPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('validates the prerendered workbench route and its exact-composition promise', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
const routeFile = path.join(distDir, 'workbench', 'index.html');
|
||||
fs.mkdirSync(path.dirname(routeFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
routeFile,
|
||||
'<html><head><title>Skill Workbench | Compose an exact agent stack</title><meta name="description" content="Filter, inspect, and install an exact host-aware set of skills." /></head></html>',
|
||||
);
|
||||
|
||||
const xml = `
|
||||
<urlset>
|
||||
<url><loc>https://owner.github.io/repo/</loc></url>
|
||||
<url><loc>https://owner.github.io/repo/workbench/</loc></url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
const report = analyzeSitemap(xml, { minSkillUrls: 0 });
|
||||
expect(report.workbenchUrls).toEqual(['https://owner.github.io/repo/workbench/']);
|
||||
expect(() => assertPrerenderedWorkbenchRoutes(
|
||||
report.workbenchUrls,
|
||||
distDir,
|
||||
report.normalizedRootPath,
|
||||
)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when a prerendered skill file is missing', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'seo-assets-'));
|
||||
const distDir = path.join(tmpDir, 'dist');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from './components/ui/Icon';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const SkillDetail = lazy(() => import('./pages/SkillDetail'));
|
||||
const Workbench = lazy(() => import('./pages/Workbench'));
|
||||
const Plugins = lazy(() => import('./pages/Plugins'));
|
||||
const TopicLanding = lazy(() => import('./pages/TopicLanding'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
@@ -28,6 +29,12 @@ function App(): React.ReactElement {
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/workbench"
|
||||
className="rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Workbench
|
||||
</Link>
|
||||
<Link
|
||||
to="/plugins"
|
||||
className="hidden rounded-[var(--radius-sm)] px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)] sm:inline-flex"
|
||||
@@ -59,6 +66,7 @@ function App(): React.ReactElement {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/plugins" element={<Plugins />} />
|
||||
<Route path="/workbench" element={<Workbench />} />
|
||||
<Route path="/topics/:slug" element={<TopicLanding />} />
|
||||
<Route path="/skill/:id" element={<SkillDetail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('App route loading', () => {
|
||||
|
||||
expect(source).toMatch(/lazy\(\(\) => import\('\.\/pages\/Home'\)\)/);
|
||||
expect(source).toMatch(/lazy\(\(\) => import\('\.\/pages\/SkillDetail'\)\)/);
|
||||
expect(source).toMatch(/lazy\(\(\) => import\('\.\/pages\/Workbench'\)\)/);
|
||||
expect(source).toMatch(/<Suspense/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,14 +66,6 @@ export const specializedPlugins: SpecializedPlugin[] = [
|
||||
why: 'Maps directly to plugin-based agent workflows because it can grow from skills into MCP server configuration and app/tool integrations.',
|
||||
skills: ['ai-agents-architect', 'agent-evaluation', 'mcp-builder', 'mcp-tool-developer', 'llm-app-patterns', 'rag-engineer', 'langgraph', 'langfuse', 'context-window-management'],
|
||||
},
|
||||
{
|
||||
id: 'aas-oss-maintainer',
|
||||
name: 'AAS OSS Maintainer',
|
||||
priority: 'tier-1',
|
||||
audience: 'Open-source maintainers managing PRs, releases, reviews, and contributor handoffs.',
|
||||
why: 'Packages repeatable repo work around review, release, changelog, GitHub workflows, and contributor handoffs.',
|
||||
skills: ['agents-md', 'commit', 'create-pr', 'requesting-code-review', 'receiving-code-review', 'changelog-automation', 'git-advanced-workflows', 'github-actions-advanced', 'address-github-comments', 'lint-and-validate'],
|
||||
},
|
||||
{
|
||||
id: 'aas-qa-test-automation',
|
||||
name: 'AAS QA & Test Automation',
|
||||
|
||||
@@ -73,8 +73,6 @@ export function Home(): React.ReactElement {
|
||||
const [sortBy, setSortBy] = useState('default');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<SyncMessage | null>(null);
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
const installCommand = 'npx agentic-awesome-skills';
|
||||
const repositoryLink = 'https://github.com/sickn33/agentic-awesome-skills';
|
||||
const docsLink = 'https://github.com/sickn33/agentic-awesome-skills/blob/main/docs/users/usage.md';
|
||||
const installLink = 'https://www.npmjs.com/package/agentic-awesome-skills';
|
||||
@@ -83,12 +81,6 @@ export function Home(): React.ReactElement {
|
||||
|
||||
usePageMeta(buildHomeMeta(skills.length));
|
||||
|
||||
const copyInstallCommand = async () => {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCommandCopied(true);
|
||||
window.setTimeout(() => setCommandCopied(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
@@ -197,12 +189,12 @@ export function Home(): React.ReactElement {
|
||||
>
|
||||
Open the GitHub repository
|
||||
</a>
|
||||
<button
|
||||
onClick={copyInstallCommand}
|
||||
<Link
|
||||
to="/workbench"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white"
|
||||
>
|
||||
{commandCopied ? 'Copied install command' : 'Copy install command'}
|
||||
</button>
|
||||
Compose an exact install
|
||||
</Link>
|
||||
<a
|
||||
href={installLink}
|
||||
target="_blank"
|
||||
@@ -234,10 +226,10 @@ export function Home(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="font-medium">Recommended command</span>
|
||||
<code className="rounded-md border border-slate-200 bg-slate-100 px-2 py-1 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
||||
{installCommand}
|
||||
</code>
|
||||
<span className="font-medium">Large catalog safety</span>
|
||||
<Link to="/workbench" className="font-semibold text-slate-800 underline underline-offset-4 hover:text-teal-700 dark:text-slate-200 dark:hover:text-teal-300">
|
||||
Inspect evidence, select exact IDs, and preview before writing.
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -67,9 +67,7 @@ export function SkillDetail(): React.ReactElement {
|
||||
const [copiedFull, setCopiedFull] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customContext, setCustomContext] = useState('');
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const installCommand = 'npx agentic-awesome-skills';
|
||||
const skill = useMemo(() => skills.find(s => s.id === id), [skills, id]);
|
||||
|
||||
const topPrioritySkills = useMemo(() => selectTopSkills(skills), [skills]);
|
||||
@@ -176,12 +174,6 @@ export function SkillDetail(): React.ReactElement {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyInstallCommand = async () => {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCommandCopied(true);
|
||||
window.setTimeout(() => setCommandCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyFullToClipboard = () => {
|
||||
const finalPrompt = customContext.trim()
|
||||
? `${content}\n\nContext:\n${customContext}`
|
||||
@@ -305,23 +297,23 @@ export function SkillDetail(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200/80 bg-white/95 p-6 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="mb-4 rounded-xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="mb-4 border border-slate-300 bg-slate-50/70 p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Use it now
|
||||
Exact install input
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Start quickly: install the package, open your workspace, and run this skill prompt directly.
|
||||
Add only this canonical skill to the Workbench, inspect its recorded evidence, then generate a release-pinned dry run.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<code className="inline-block rounded-md border border-slate-800 bg-slate-900 px-3 py-2 font-mono text-sm text-slate-50">
|
||||
{installCommand}
|
||||
<code className="inline-block border border-slate-800 bg-slate-900 px-3 py-2 font-mono text-sm text-slate-50">
|
||||
{skill.id}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyInstallCommand}
|
||||
className="inline-flex items-center text-sm font-medium text-teal-700 transition-colors hover:text-teal-600 dark:text-teal-300 dark:hover:text-teal-200"
|
||||
<Link
|
||||
to={`/workbench?selected=${encodeURIComponent(skill.id)}&host=codex`}
|
||||
className="inline-flex items-center border border-teal-700 px-3 py-2 text-sm font-semibold text-teal-800 transition-colors hover:bg-teal-50 dark:border-teal-400 dark:text-teal-200 dark:hover:bg-teal-950/40"
|
||||
>
|
||||
{commandCopied ? 'Copied' : 'Copy command'}
|
||||
</button>
|
||||
Compose exact install
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import packageMetadata from '../../../../package.json';
|
||||
import { Icon } from '../components/ui/Icon';
|
||||
import { useSkills } from '../context/SkillContext';
|
||||
import { usePageMeta } from '../hooks/usePageMeta';
|
||||
import type { RiskLevel, Skill } from '../types';
|
||||
import {
|
||||
WORKBENCH_HOSTS,
|
||||
buildInstallerCommand,
|
||||
collectSelectionEvidence,
|
||||
getHostCompatibility,
|
||||
getRecordedProvenance,
|
||||
getSetupType,
|
||||
isWorkbenchHost,
|
||||
matchesWorkbenchFilters,
|
||||
normalizeSelectedIds,
|
||||
parseSelectedIds,
|
||||
type CompatibilityFacet,
|
||||
type ProvenanceFacet,
|
||||
type RiskFacet,
|
||||
type SetupFacet,
|
||||
type WorkbenchHost,
|
||||
} from '../utils/skillWorkbench';
|
||||
|
||||
const riskStyles: Record<RiskLevel, string> = {
|
||||
none: 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||
safe: 'border-teal-300 bg-teal-50 text-teal-800 dark:border-teal-800 dark:bg-teal-950/50 dark:text-teal-300',
|
||||
unknown: 'border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300',
|
||||
critical: 'border-orange-300 bg-orange-50 text-orange-900 dark:border-orange-800 dark:bg-orange-950/50 dark:text-orange-300',
|
||||
offensive: 'border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300',
|
||||
};
|
||||
|
||||
const evidenceToneStyles = {
|
||||
rose: 'border-rose-900/70 bg-rose-950/35 text-rose-200',
|
||||
orange: 'border-orange-900/70 bg-orange-950/30 text-orange-200',
|
||||
amber: 'border-amber-900/70 bg-amber-950/25 text-amber-200',
|
||||
violet: 'border-violet-900/70 bg-violet-950/25 text-violet-200',
|
||||
slate: 'border-slate-700 bg-slate-900 text-slate-300',
|
||||
} as const;
|
||||
|
||||
function recordedRisk(skill: Skill): RiskLevel {
|
||||
return skill.risk ?? 'unknown';
|
||||
}
|
||||
|
||||
function EvidenceBucket({
|
||||
summary,
|
||||
entries,
|
||||
tone,
|
||||
}: {
|
||||
summary: string;
|
||||
entries: string[];
|
||||
tone: keyof typeof evidenceToneStyles;
|
||||
}): React.ReactElement | null {
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className={`border px-2.5 py-2 ${evidenceToneStyles[tone]}`}>
|
||||
<p>{entries.length} {summary}.</p>
|
||||
<ul className="mt-1 space-y-1 font-mono text-[10px] leading-relaxed opacity-90">
|
||||
{entries.map((entry) => <li key={entry} className="break-words">{entry}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkbenchQuery(selectedIds: Iterable<string>, host: WorkbenchHost): URLSearchParams {
|
||||
const query = new URLSearchParams();
|
||||
const normalized = normalizeSelectedIds(selectedIds);
|
||||
if (normalized.length > 0) query.set('selected', normalized.join(','));
|
||||
query.set('host', host);
|
||||
return query;
|
||||
}
|
||||
|
||||
function WorkbenchCard({
|
||||
skill,
|
||||
selected,
|
||||
host,
|
||||
onToggle,
|
||||
}: {
|
||||
skill: Skill;
|
||||
selected: boolean;
|
||||
host: WorkbenchHost;
|
||||
onToggle: (skillId: string) => void;
|
||||
}): React.ReactElement {
|
||||
const compatibility = getHostCompatibility(skill, host);
|
||||
const provenance = getRecordedProvenance(skill);
|
||||
const setup = getSetupType(skill);
|
||||
const risk = recordedRisk(skill);
|
||||
const recordedOrigin = skill.source_repo || skill.source || 'not recorded';
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`relative flex h-full flex-col border bg-white p-4 transition-colors dark:bg-slate-950 ${selected
|
||||
? 'border-teal-500 shadow-[inset_4px_0_0_0_rgb(13_148_136)] dark:border-teal-500'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-800 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-[10px] uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{skill.category}
|
||||
</p>
|
||||
<h2 className="mt-1 truncate text-base font-semibold text-slate-950 dark:text-slate-100">
|
||||
{skill.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${selected ? 'Remove' : 'Select'} ${skill.name}`}
|
||||
onClick={() => onToggle(skill.id)}
|
||||
className={`shrink-0 border px-2.5 py-1.5 text-xs font-semibold transition-colors ${selected
|
||||
? 'border-teal-700 bg-teal-700 text-white hover:bg-teal-800'
|
||||
: 'border-slate-300 bg-slate-50 text-slate-800 hover:border-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{selected ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 line-clamp-3 flex-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-1.5 text-[11px]">
|
||||
<span className={`border px-2 py-1 font-semibold ${riskStyles[risk]}`}>risk: {risk}</span>
|
||||
<span className="border border-slate-300 bg-slate-50 px-2 py-1 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||
source: {provenance}
|
||||
</span>
|
||||
<span className={`border px-2 py-1 ${compatibility === 'blocked'
|
||||
? 'border-rose-300 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300'
|
||||
: 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'
|
||||
}`}>
|
||||
host: {compatibility}
|
||||
</span>
|
||||
{setup === 'manual' && (
|
||||
<span className="border border-violet-300 bg-violet-50 px-2 py-1 text-violet-800 dark:border-violet-800 dark:bg-violet-950/50 dark:text-violet-300">
|
||||
manual setup
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<p className="mt-3 line-clamp-1 font-mono text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{skill.tags.map((tag) => `#${tag}`).join(' ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p title={recordedOrigin} className="mt-3 truncate font-mono text-[11px] text-slate-500 dark:text-slate-400">
|
||||
origin: {recordedOrigin}
|
||||
</p>
|
||||
{setup === 'manual' && skill.plugin?.setup?.summary && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-violet-800 dark:text-violet-300">
|
||||
setup: {skill.plugin.setup.summary}
|
||||
</p>
|
||||
)}
|
||||
{compatibility === 'blocked' && skill.plugin?.reasons?.length ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-rose-800 dark:text-rose-300">
|
||||
recorded note: {skill.plugin.reasons.join(' ')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Link
|
||||
to={`/skill/${encodeURIComponent(skill.id)}`}
|
||||
className="mt-4 inline-flex items-center gap-1 text-xs font-semibold text-slate-700 underline decoration-slate-300 underline-offset-4 hover:text-teal-700 dark:text-slate-300 dark:hover:text-teal-300"
|
||||
>
|
||||
Inspect canonical skill
|
||||
<Icon name="arrowRight" size={13} />
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Workbench(): React.ReactElement {
|
||||
const { skills, loading, error, refreshSkills } = useSkills();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [risk, setRisk] = useState<RiskFacet>('all');
|
||||
const [provenance, setProvenance] = useState<ProvenanceFacet>('all');
|
||||
const [compatibility, setCompatibility] = useState<CompatibilityFacet>('all');
|
||||
const [setup, setSetup] = useState<SetupFacet>('all');
|
||||
const [copied, setCopied] = useState<'preview' | 'install' | 'share' | null>(null);
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
usePageMeta(useMemo(() => ({
|
||||
title: 'Skill Workbench | Agentic Awesome Skills',
|
||||
description: 'Filter canonical skill evidence, compose an exact host-aware set, and preview a version-pinned install without filesystem writes.',
|
||||
canonicalPath: '/workbench',
|
||||
}), []));
|
||||
|
||||
const hostParam = searchParams.get('host');
|
||||
const host: WorkbenchHost = isWorkbenchHost(hostParam) ? hostParam : 'codex';
|
||||
const selectedIds = useMemo(
|
||||
() => parseSelectedIds(searchParams.get('selected')),
|
||||
[searchParams],
|
||||
);
|
||||
const skillsById = useMemo(() => new Map(skills.map((skill) => [skill.id, skill])), [skills]);
|
||||
const selectedSkills = useMemo(
|
||||
() => selectedIds.flatMap((skillId) => {
|
||||
const skill = skillsById.get(skillId);
|
||||
return skill ? [skill] : [];
|
||||
}),
|
||||
[selectedIds, skillsById],
|
||||
);
|
||||
const missingSelectedIds = useMemo(
|
||||
() => selectedIds.filter((skillId) => !skillsById.has(skillId)),
|
||||
[selectedIds, skillsById],
|
||||
);
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const categories = useMemo(
|
||||
() => ['all', ...new Set(skills.map((skill) => skill.category).filter(Boolean))].sort((a, b) => {
|
||||
if (a === 'all') return -1;
|
||||
if (b === 'all') return 1;
|
||||
return a.localeCompare(b);
|
||||
}),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => matchesWorkbenchFilters(skill, {
|
||||
search: deferredSearch,
|
||||
category,
|
||||
risk,
|
||||
provenance,
|
||||
compatibility,
|
||||
setup,
|
||||
}, host)),
|
||||
[category, compatibility, deferredSearch, host, provenance, risk, setup, skills],
|
||||
);
|
||||
|
||||
const evidence = useMemo(
|
||||
() => collectSelectionEvidence(selectedSkills, host),
|
||||
[host, selectedSkills],
|
||||
);
|
||||
|
||||
const commands = useMemo(() => {
|
||||
if (selectedIds.length === 0 || missingSelectedIds.length > 0) return null;
|
||||
return {
|
||||
preview: buildInstallerCommand({ skillIds: selectedIds, host, version: packageMetadata.version, dryRun: true }),
|
||||
install: buildInstallerCommand({ skillIds: selectedIds, host, version: packageMetadata.version, dryRun: false }),
|
||||
};
|
||||
}, [host, missingSelectedIds.length, selectedIds]);
|
||||
|
||||
const installBlocked = missingSelectedIds.length > 0 || evidence.blockedForHost.length > 0;
|
||||
|
||||
const updateUrlState = (nextSelectedIds: string[], nextHost = host) => {
|
||||
setSearchParams(buildWorkbenchQuery(nextSelectedIds, nextHost), { replace: true });
|
||||
};
|
||||
|
||||
const toggleSkill = (skillId: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(skillId)) next.delete(skillId);
|
||||
else next.add(skillId);
|
||||
updateUrlState([...next]);
|
||||
};
|
||||
|
||||
const changeHost = (nextHost: WorkbenchHost) => {
|
||||
updateUrlState(selectedIds, nextHost);
|
||||
setCompatibility('all');
|
||||
};
|
||||
|
||||
const copyText = async (kind: 'preview' | 'install' | 'share', value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(kind);
|
||||
window.setTimeout(() => setCopied(null), 1800);
|
||||
};
|
||||
|
||||
const copyShareLink = async () => {
|
||||
const query = buildWorkbenchQuery(selectedIds, host).toString();
|
||||
const relative = `${import.meta.env.BASE_URL}workbench${query ? `?${query}` : ''}`;
|
||||
await copyText('share', new URL(relative, window.location.origin).toString());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearch('');
|
||||
setCategory('all');
|
||||
setRisk('all');
|
||||
setProvenance('all');
|
||||
setCompatibility('all');
|
||||
setSetup('all');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-8rem)] bg-slate-100/70 p-4 sm:p-6 dark:bg-slate-950">
|
||||
<header className="relative overflow-hidden border border-slate-300 bg-slate-950 px-5 py-6 text-slate-100 shadow-[0_24px_60px_-38px_rgba(15,23,42,0.9)] sm:px-7 dark:border-slate-700">
|
||||
<div className="absolute inset-y-0 left-0 w-1.5 bg-teal-400" />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.24em] text-teal-300">Expert registry control bench</p>
|
||||
<h1 className="mt-3 max-w-4xl text-3xl font-bold tracking-tight sm:text-5xl">
|
||||
Build a precise, inspectable skill set
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 sm:text-base">
|
||||
Query recorded catalog evidence, select exact canonical IDs, expose host and risk conflicts, then preview a pinned install before it touches a target directory.
|
||||
</p>
|
||||
</div>
|
||||
<dl className="grid grid-cols-3 border border-slate-700 bg-slate-900/80 font-mono text-xs">
|
||||
<div className="border-r border-slate-700 px-4 py-3">
|
||||
<dt className="text-slate-500">catalog</dt>
|
||||
<dd className="mt-1 text-lg text-slate-100">{skills.length.toLocaleString('en-US')}</dd>
|
||||
</div>
|
||||
<div className="border-r border-slate-700 px-4 py-3">
|
||||
<dt className="text-slate-500">visible</dt>
|
||||
<dd className="mt-1 text-lg text-slate-100">{filteredSkills.length.toLocaleString('en-US')}</dd>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<dt className="text-slate-500">selected</dt>
|
||||
<dd className="mt-1 text-lg text-teal-300">{selectedSkills.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-5 grid items-start gap-5 xl:grid-cols-[17rem_minmax(0,1fr)_23rem]">
|
||||
<aside className="order-1 border border-slate-300 bg-white p-4 xl:sticky xl:top-20 dark:border-slate-800 dark:bg-slate-900" aria-label="Workbench filters">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-mono text-xs font-semibold uppercase tracking-[0.16em] text-slate-800 dark:text-slate-200">Query</h2>
|
||||
<button type="button" onClick={resetFilters} className="text-xs font-semibold text-slate-500 underline underline-offset-4 hover:text-slate-900 dark:hover:text-slate-100">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Search recorded fields
|
||||
<span className="relative mt-1.5 block">
|
||||
<Icon name="search" size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
aria-label="Search recorded fields"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="name, tag, source…"
|
||||
className="h-10 w-full border border-slate-300 bg-white pl-9 pr-3 text-sm font-normal text-slate-950 outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-100 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-teal-950"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Category
|
||||
<select aria-label="Filter workbench by category" value={category} onChange={(event) => setCategory(event.target.value)} className="mt-1.5 h-10 w-full border border-slate-300 bg-white px-2 text-sm font-normal dark:border-slate-700 dark:bg-slate-950">
|
||||
{categories.map((value) => <option key={value} value={value}>{value === 'all' ? 'All categories' : value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Risk label
|
||||
<select aria-label="Filter workbench by risk" value={risk} onChange={(event) => setRisk(event.target.value as RiskFacet)} className="mt-1.5 h-10 w-full border border-slate-300 bg-white px-2 text-sm font-normal dark:border-slate-700 dark:bg-slate-950">
|
||||
<option value="all">All risk labels</option>
|
||||
<option value="none">none</option>
|
||||
<option value="safe">safe</option>
|
||||
<option value="unknown">unknown</option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="offensive">offensive</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Recorded provenance
|
||||
<select aria-label="Filter workbench by provenance" value={provenance} onChange={(event) => setProvenance(event.target.value as ProvenanceFacet)} className="mt-1.5 h-10 w-full border border-slate-300 bg-white px-2 text-sm font-normal dark:border-slate-700 dark:bg-slate-950">
|
||||
<option value="all">All provenance</option>
|
||||
<option value="official">official</option>
|
||||
<option value="community">community</option>
|
||||
<option value="self">self</option>
|
||||
<option value="unknown">unknown / not recorded</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Selected host
|
||||
<select aria-label="Select workbench host" value={host} onChange={(event) => changeHost(event.target.value as WorkbenchHost)} className="mt-1.5 h-10 w-full border border-slate-300 bg-white px-2 text-sm font-normal dark:border-slate-700 dark:bg-slate-950">
|
||||
{WORKBENCH_HOSTS.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Host evidence
|
||||
<select aria-label="Filter workbench by compatibility" value={compatibility} onChange={(event) => setCompatibility(event.target.value as CompatibilityFacet)} className="mt-1.5 h-10 w-full border border-slate-300 bg-white px-2 text-sm font-normal dark:border-slate-700 dark:bg-slate-950">
|
||||
<option value="all">All host evidence</option>
|
||||
<option value="supported">supported</option>
|
||||
<option value="blocked">blocked</option>
|
||||
<option value="unrecorded">unrecorded</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Setup burden
|
||||
<select aria-label="Filter workbench by setup" value={setup} onChange={(event) => setSetup(event.target.value as SetupFacet)} className="mt-1.5 h-10 w-full border border-slate-300 bg-white px-2 text-sm font-normal dark:border-slate-700 dark:bg-slate-950">
|
||||
<option value="all">All setup states</option>
|
||||
<option value="none">none recorded</option>
|
||||
<option value="manual">manual setup</option>
|
||||
<option value="unknown">unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside className="order-2 border border-slate-300 bg-slate-950 text-slate-100 xl:order-3 xl:sticky xl:top-20" aria-label="Selected skill ledger">
|
||||
<div className="border-b border-slate-700 px-4 py-4">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-teal-300">Selection ledger</p>
|
||||
<div className="mt-2 flex items-end justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">{selectedSkills.length} exact skills</h2>
|
||||
<span className="font-mono text-xs text-slate-400">v{packageMetadata.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto border-b border-slate-800 px-4 py-3">
|
||||
{selectedIds.length === 0 ? (
|
||||
<p className="text-sm leading-relaxed text-slate-400">Select canonical IDs from the results. Nothing broad is added automatically.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{selectedIds.map((skillId) => (
|
||||
<li key={skillId} className="flex items-center justify-between gap-2 font-mono text-xs">
|
||||
<span className={`truncate ${skillsById.has(skillId) ? 'text-slate-200' : 'text-rose-300'}`}>{skillId}</span>
|
||||
<button type="button" aria-label={`Remove ${skillId}`} onClick={() => toggleSkill(skillId)} className="shrink-0 text-slate-500 hover:text-rose-300">×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div aria-live="polite" className="space-y-2 border-b border-slate-800 px-4 py-3 text-xs">
|
||||
<EvidenceBucket summary="selected IDs do not resolve in this catalog" entries={missingSelectedIds} tone="rose" />
|
||||
<EvidenceBucket
|
||||
summary="explicitly blocked for this host"
|
||||
entries={evidence.blockedForHost.map((skill) => [
|
||||
skill.id,
|
||||
skill.plugin?.reasons?.join(' '),
|
||||
].filter(Boolean).join(' — '))}
|
||||
tone="rose"
|
||||
/>
|
||||
<EvidenceBucket
|
||||
summary="critical/offensive risk labels"
|
||||
entries={evidence.elevatedRisk.map((skill) => `${skill.id} — risk: ${recordedRisk(skill)}`)}
|
||||
tone="orange"
|
||||
/>
|
||||
<EvidenceBucket summary="unknown risk labels" entries={evidence.unknownRisk.map((skill) => skill.id)} tone="amber" />
|
||||
<EvidenceBucket summary="without recorded provenance type" entries={evidence.missingProvenance.map((skill) => skill.id)} tone="amber" />
|
||||
<EvidenceBucket
|
||||
summary="require manual setup"
|
||||
entries={evidence.manualSetup.map((skill) => [
|
||||
skill.id,
|
||||
skill.plugin?.setup?.summary,
|
||||
].filter(Boolean).join(' — '))}
|
||||
tone="violet"
|
||||
/>
|
||||
<EvidenceBucket summary="have no recorded compatibility for this host" entries={evidence.unrecordedForHost.map((skill) => skill.id)} tone="slate" />
|
||||
{!installBlocked && evidence.elevatedRisk.length === 0 && evidence.unknownRisk.length === 0 && evidence.missingProvenance.length === 0 && evidence.manualSetup.length === 0 && evidence.unrecordedForHost.length === 0 && (
|
||||
<p className="text-emerald-300">No recorded conflicts in the selected fields.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-slate-500">1 / Preview without writes</p>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-slate-400">
|
||||
Exact selection is desired state for installer-managed entries. The dry run lists stale managed entries it would remove.
|
||||
</p>
|
||||
<code className="mt-1.5 block max-h-28 overflow-auto whitespace-pre-wrap break-all border border-slate-700 bg-black/40 p-2.5 font-mono text-[11px] leading-relaxed text-slate-300">
|
||||
{commands?.preview ?? 'Select valid canonical IDs to generate a preview.'}
|
||||
</code>
|
||||
<button type="button" disabled={!commands} onClick={() => commands && void copyText('preview', commands.preview)} className="mt-2 w-full border border-slate-600 px-3 py-2 text-xs font-semibold transition-colors hover:border-teal-400 hover:text-teal-300 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{copied === 'preview' ? 'Preview command copied' : 'Copy dry-run command'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-slate-500">2 / Apply exact set</p>
|
||||
<code className="mt-1.5 block max-h-28 overflow-auto whitespace-pre-wrap break-all border border-slate-700 bg-black/40 p-2.5 font-mono text-[11px] leading-relaxed text-slate-300">
|
||||
{commands?.install ?? 'The install command appears only after exact resolution.'}
|
||||
</code>
|
||||
<button type="button" disabled={!commands || installBlocked} onClick={() => commands && void copyText('install', commands.install)} className="mt-2 w-full bg-teal-400 px-3 py-2 text-xs font-bold text-slate-950 transition-colors hover:bg-teal-300 disabled:cursor-not-allowed disabled:bg-slate-700 disabled:text-slate-400">
|
||||
{copied === 'install' ? 'Install command copied' : installBlocked ? 'Resolve blocked IDs first' : 'Copy pinned install command'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" disabled={selectedIds.length === 0} onClick={() => void copyShareLink()} className="w-full border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-300 hover:border-slate-500 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{copied === 'share' ? 'Share URL copied' : 'Copy review URL'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="order-3 min-w-0 xl:order-2" aria-label="Workbench results">
|
||||
<div className="mb-3 flex items-center justify-between gap-4 border-b border-slate-300 pb-3 dark:border-slate-800">
|
||||
<p aria-live="polite" className="font-mono text-xs text-slate-600 dark:text-slate-400">
|
||||
{filteredSkills.length.toLocaleString('en-US')} matching canonical skills
|
||||
</p>
|
||||
{selectedIds.length > 0 && (
|
||||
<button type="button" onClick={() => updateUrlState([])} className="text-xs font-semibold text-slate-500 underline underline-offset-4 hover:text-rose-700 dark:hover:text-rose-300">
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div data-testid="workbench-loader" className="grid gap-3 md:grid-cols-2">
|
||||
{[...Array(8)].map((_, index) => <div key={index} className="h-56 animate-pulse border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900" />)}
|
||||
</div>
|
||||
) : error && skills.length === 0 ? (
|
||||
<div className="border border-rose-300 bg-rose-50 p-6 text-sm text-rose-900 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-200">
|
||||
<h2 className="font-semibold">Unable to load the canonical catalog</h2>
|
||||
<p className="mt-2">{error}</p>
|
||||
<button type="button" onClick={() => void refreshSkills()} className="mt-4 border border-rose-500 px-3 py-2 font-semibold">Retry</button>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="border border-slate-300 bg-white p-8 text-center dark:border-slate-800 dark:bg-slate-900">
|
||||
<h2 className="font-semibold text-slate-950 dark:text-slate-100">No exact matches</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">Reset one or more recorded-data filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
useWindowScroll
|
||||
totalCount={filteredSkills.length}
|
||||
listClassName="grid gap-3 md:grid-cols-2 2xl:grid-cols-3"
|
||||
itemContent={(index) => {
|
||||
const skill = filteredSkills[index];
|
||||
return <WorkbenchCard key={skill.id} skill={skill} selected={selectedSet.has(skill.id)} host={host} onToggle={toggleSkill} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Workbench;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { Home } from '../Home';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
@@ -88,8 +88,7 @@ describe('Home', () => {
|
||||
expect(document.title).toContain('Agentic Awesome Skills');
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /Copy install command/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/npx agentic-awesome-skills/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('link', { name: /Compose an exact install/i })).toHaveAttribute('href', '/workbench');
|
||||
expect(screen.getByText(/What is the difference between skills and MCP tools/i)).toBeInTheDocument();
|
||||
expect(document.querySelector('meta[property="og:title"]')).toHaveAttribute(
|
||||
'content',
|
||||
@@ -97,7 +96,7 @@ describe('Home', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy install command from hero CTA', async () => {
|
||||
it('routes the primary catalog action to exact composition', async () => {
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills: [],
|
||||
stars: {},
|
||||
@@ -108,20 +107,10 @@ describe('Home', () => {
|
||||
renderWithRouter(<Home />, { useProvider: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy install command/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Compose an exact install/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy install command/i }));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('npx agentic-awesome-skills');
|
||||
expect(screen.getByText(/Inspect evidence, select exact IDs/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { SkillDetail } from '../SkillDetail';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
@@ -268,7 +268,7 @@ describe('SkillDetail', () => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Use @click-test');
|
||||
});
|
||||
|
||||
it('should copy install command when copy command CTA is clicked', async () => {
|
||||
it('should send the exact skill to the Workbench', async () => {
|
||||
const mockSkill = createMockSkill({ id: 'click-install', name: 'click-install' });
|
||||
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
@@ -289,20 +289,13 @@ describe('SkillDetail', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Copy command/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Compose exact install/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy command/i }));
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('npx agentic-awesome-skills');
|
||||
expect(screen.getByRole('link', { name: /Compose exact install/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/workbench?selected=click-install&host=codex',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import packageMetadata from '../../../../../package.json';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
import { useSkills } from '../../context/SkillContext';
|
||||
import { renderWithRouter } from '../../utils/testUtils';
|
||||
import { Workbench } from '../Workbench';
|
||||
|
||||
vi.mock('../../context/SkillContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../context/SkillContext')>();
|
||||
return { ...actual, useSkills: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({ totalCount, itemContent }: { totalCount: number; itemContent: (index: number) => React.ReactNode }) => (
|
||||
<div data-testid="workbench-grid">
|
||||
{Array.from({ length: totalCount }, (_, index) => <div key={index}>{itemContent(index)}</div>)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const skills = [
|
||||
createMockSkill({
|
||||
id: 'safe-skill',
|
||||
name: 'Safe Skill',
|
||||
category: 'development',
|
||||
tags: ['typescript', 'review'],
|
||||
risk: 'safe',
|
||||
source: 'official',
|
||||
source_type: 'official',
|
||||
source_repo: 'example/official-skills',
|
||||
plugin: {
|
||||
targets: { codex: 'supported', claude: 'supported' },
|
||||
setup: { type: 'none', summary: '', docs: null },
|
||||
reasons: [],
|
||||
},
|
||||
}),
|
||||
createMockSkill({
|
||||
id: 'data-pipeline',
|
||||
name: 'Data Pipeline',
|
||||
category: 'data',
|
||||
tags: ['python', 'etl'],
|
||||
risk: 'unknown',
|
||||
source: 'community',
|
||||
source_type: 'community',
|
||||
plugin: {
|
||||
targets: { codex: 'supported', claude: 'supported' },
|
||||
setup: { type: 'manual', summary: 'Install Python.', docs: 'SKILL.md' },
|
||||
reasons: [],
|
||||
},
|
||||
}),
|
||||
createMockSkill({
|
||||
id: 'blocked-skill',
|
||||
name: 'Blocked Skill',
|
||||
category: 'security',
|
||||
risk: 'critical',
|
||||
source: 'community',
|
||||
source_type: 'community',
|
||||
plugin: {
|
||||
targets: { codex: 'blocked', claude: 'supported' },
|
||||
setup: { type: 'none', summary: '', docs: null },
|
||||
reasons: ['Requires Claude-specific tools.'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
function LocationProbe(): React.ReactElement {
|
||||
const location = useLocation();
|
||||
return <output data-testid="workbench-location">{location.pathname}{location.search}</output>;
|
||||
}
|
||||
|
||||
function currentLocationParams(): URLSearchParams {
|
||||
const renderedLocation = screen.getByTestId('workbench-location').textContent ?? '';
|
||||
return new URLSearchParams(renderedLocation.split('?')[1] ?? '');
|
||||
}
|
||||
|
||||
describe('Workbench', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useSkills as Mock).mockReturnValue({
|
||||
skills,
|
||||
stars: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
refreshSkills: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
it('loads URL selection and builds deterministic pinned preview/install commands', async () => {
|
||||
renderWithRouter(<Workbench />, {
|
||||
route: '/workbench?selected=safe-skill&host=codex',
|
||||
path: '/workbench',
|
||||
useProvider: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText('1 exact skills')).toBeInTheDocument();
|
||||
expect(screen.getByText(/--skills safe-skill --dry-run/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/desired state for installer-managed entries/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select Data Pipeline' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2 exact skills')).toBeInTheDocument();
|
||||
expect(screen.getByText(/--skills data-pipeline,safe-skill --dry-run/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy dry-run command' }));
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
`npx agentic-awesome-skills@${packageMetadata.version} --codex --release ${packageMetadata.version} --skills data-pipeline,safe-skill --dry-run`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips the exact selection and host through reviewable URL state', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/workbench?selected=safe-skill,safe-skill&host=codex&noise=drop']}>
|
||||
<Routes>
|
||||
<Route path="/workbench" element={<><Workbench /><LocationProbe /></>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy review URL' }));
|
||||
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalled());
|
||||
const copiedReviewUrl = new URL(vi.mocked(navigator.clipboard.writeText).mock.calls.at(-1)?.[0] ?? '');
|
||||
expect(copiedReviewUrl.searchParams.get('selected')).toBe('safe-skill');
|
||||
expect(copiedReviewUrl.searchParams.get('host')).toBe('codex');
|
||||
expect(copiedReviewUrl.searchParams.has('noise')).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select Data Pipeline' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(currentLocationParams().get('selected')).toBe('data-pipeline,safe-skill');
|
||||
expect(currentLocationParams().get('host')).toBe('codex');
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Select workbench host'), { target: { value: 'claude' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(currentLocationParams().get('selected')).toBe('data-pipeline,safe-skill');
|
||||
expect(currentLocationParams().get('host')).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
it('queries tags and filters raw provenance without recommendation logic', async () => {
|
||||
renderWithRouter(<Workbench />, { route: '/workbench', path: '/workbench', useProvider: false });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Search recorded fields'), { target: { value: 'etl' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Data Pipeline')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Safe Skill')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Search recorded fields'), { target: { value: '' } });
|
||||
fireEvent.change(screen.getByLabelText('Filter workbench by provenance'), { target: { value: 'official' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Safe Skill')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Data Pipeline')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks install command copying for an explicitly incompatible host', async () => {
|
||||
renderWithRouter(<Workbench />, {
|
||||
route: '/workbench?selected=blocked-skill&host=codex',
|
||||
path: '/workbench',
|
||||
useProvider: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/explicitly blocked for this host/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('blocked-skill — Requires Claude-specific tools.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Resolve blocked IDs first/i })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Select workbench host'), { target: { value: 'claude' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/explicitly blocked for this host/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Copy pinned install command/i })).toBeEnabled();
|
||||
expect(
|
||||
screen.getAllByText(`--claude --release ${packageMetadata.version}`, { exact: false }),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps invalid shared IDs visible and fail-closed', () => {
|
||||
renderWithRouter(<Workbench />, {
|
||||
route: '/workbench?selected=missing-skill,safe-skill&host=codex',
|
||||
path: '/workbench',
|
||||
useProvider: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/1 selected IDs do not resolve/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText('missing-skill').some((element) => element.classList.contains('text-rose-300'))).toBe(true);
|
||||
expect(screen.getByRole('button', { name: /Resolve blocked IDs first/i })).toBeDisabled();
|
||||
expect(screen.getByText(/install command appears only after exact resolution/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes named controls, live evidence, and semantic mobile reading order', () => {
|
||||
const { container } = renderWithRouter(<Workbench />, {
|
||||
route: '/workbench?selected=data-pipeline&host=codex',
|
||||
path: '/workbench',
|
||||
useProvider: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Build a precise, inspectable skill set' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Search recorded fields' })).toBeInTheDocument();
|
||||
for (const name of [
|
||||
'Filter workbench by category',
|
||||
'Filter workbench by risk',
|
||||
'Filter workbench by provenance',
|
||||
'Select workbench host',
|
||||
'Filter workbench by compatibility',
|
||||
'Filter workbench by setup',
|
||||
]) {
|
||||
expect(screen.getByRole('combobox', { name })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(
|
||||
[...container.querySelectorAll('aside[aria-label], main[aria-label]')]
|
||||
.map((element) => element.getAttribute('aria-label')),
|
||||
).toEqual(['Workbench filters', 'Selected skill ledger', 'Workbench results']);
|
||||
expect(screen.getByText(/unknown risk labels/i).closest('[aria-live="polite"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('data-pipeline — Install Python.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove Data Pipeline' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,19 @@
|
||||
export type RiskLevel = 'none' | 'safe' | 'critical' | 'offensive' | 'unknown';
|
||||
export type PluginCompatibility = 'supported' | 'blocked';
|
||||
export type SkillSetupType = 'none' | 'manual';
|
||||
|
||||
export interface SkillPluginMetadata {
|
||||
targets: {
|
||||
codex: PluginCompatibility;
|
||||
claude: PluginCompatibility;
|
||||
};
|
||||
setup: {
|
||||
type: SkillSetupType;
|
||||
summary: string;
|
||||
docs: string | null;
|
||||
};
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill data type from skills.json
|
||||
@@ -10,8 +25,14 @@ export interface Skill {
|
||||
category: string;
|
||||
risk?: RiskLevel;
|
||||
source?: string;
|
||||
source_type?: 'official' | 'community' | 'self';
|
||||
source_repo?: string;
|
||||
license?: string;
|
||||
license_source?: string;
|
||||
tags?: string[];
|
||||
date_added?: string;
|
||||
path: string;
|
||||
plugin?: SkillPluginMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import canonicalCatalog from '../../../../../skills_index.json';
|
||||
import packageMetadata from '../../../../../package.json';
|
||||
import { createMockSkill } from '../../factories/skill';
|
||||
import {
|
||||
buildInstallerCommand,
|
||||
collectSelectionEvidence,
|
||||
matchesWorkbenchFilters,
|
||||
parseSelectedIds,
|
||||
type WorkbenchFilters,
|
||||
} from '../skillWorkbench';
|
||||
|
||||
const baseFilters: WorkbenchFilters = {
|
||||
search: '',
|
||||
category: 'all',
|
||||
risk: 'all',
|
||||
provenance: 'all',
|
||||
compatibility: 'all',
|
||||
setup: 'all',
|
||||
};
|
||||
|
||||
describe('skill workbench domain', () => {
|
||||
it('searches every recorded field promised by the workbench', () => {
|
||||
const skill = createMockSkill({
|
||||
id: 'service-audit',
|
||||
name: 'Service Audit',
|
||||
description: 'Reviews Kubernetes deployment supply chains.',
|
||||
category: 'security',
|
||||
tags: ['kubernetes', 'supply-chain'],
|
||||
source: 'community',
|
||||
source_type: 'community',
|
||||
source_repo: 'example/security-skills',
|
||||
});
|
||||
|
||||
for (const query of [
|
||||
'service-audit',
|
||||
'Service Audit',
|
||||
'deployment supply',
|
||||
'security',
|
||||
'supply-chain',
|
||||
'community',
|
||||
'example/security',
|
||||
]) {
|
||||
expect(matchesWorkbenchFilters(skill, { ...baseFilters, search: query }, 'codex')).toBe(true);
|
||||
}
|
||||
expect(matchesWorkbenchFilters(skill, { ...baseFilters, search: 'browser-design' }, 'codex')).toBe(false);
|
||||
});
|
||||
|
||||
it('filters category and recorded-or-unknown risk without inventing defaults', () => {
|
||||
const skill = createMockSkill({
|
||||
category: 'operations',
|
||||
risk: undefined,
|
||||
});
|
||||
|
||||
expect(matchesWorkbenchFilters(skill, {
|
||||
...baseFilters,
|
||||
category: 'operations',
|
||||
risk: 'unknown',
|
||||
}, 'codex')).toBe(true);
|
||||
expect(matchesWorkbenchFilters(skill, { ...baseFilters, category: 'security' }, 'codex')).toBe(false);
|
||||
expect(matchesWorkbenchFilters(skill, { ...baseFilters, risk: 'safe' }, 'codex')).toBe(false);
|
||||
});
|
||||
|
||||
it('filters raw unknown provenance and recorded host compatibility independently', () => {
|
||||
const skill = createMockSkill({
|
||||
source: 'https://example.com/source',
|
||||
source_type: undefined,
|
||||
plugin: {
|
||||
targets: { codex: 'blocked', claude: 'supported' },
|
||||
setup: { type: 'manual', summary: 'Configure a token.', docs: 'SKILL.md' },
|
||||
reasons: ['Codex-only tool is unavailable.'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(matchesWorkbenchFilters(skill, {
|
||||
...baseFilters,
|
||||
provenance: 'unknown',
|
||||
compatibility: 'blocked',
|
||||
setup: 'manual',
|
||||
}, 'codex')).toBe(true);
|
||||
expect(matchesWorkbenchFilters(skill, { ...baseFilters, compatibility: 'blocked' }, 'claude')).toBe(false);
|
||||
expect(matchesWorkbenchFilters(skill, { ...baseFilters, compatibility: 'unrecorded' }, 'cursor')).toBe(true);
|
||||
|
||||
const unrecorded = createMockSkill({ source: 'personal', source_type: undefined, plugin: undefined });
|
||||
expect(matchesWorkbenchFilters(unrecorded, {
|
||||
...baseFilters,
|
||||
provenance: 'unknown',
|
||||
compatibility: 'unrecorded',
|
||||
setup: 'unknown',
|
||||
}, 'codex')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps evidence dimensions separate instead of manufacturing a score', () => {
|
||||
const skills = [
|
||||
createMockSkill({
|
||||
id: 'unknown-source',
|
||||
risk: 'unknown',
|
||||
source: 'external',
|
||||
source_type: undefined,
|
||||
}),
|
||||
createMockSkill({
|
||||
id: 'blocked-offensive',
|
||||
risk: 'offensive',
|
||||
source_type: 'community',
|
||||
plugin: {
|
||||
targets: { codex: 'blocked', claude: 'supported' },
|
||||
setup: { type: 'manual', summary: 'Manual setup', docs: null },
|
||||
reasons: ['Blocked'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const evidence = collectSelectionEvidence(skills, 'codex');
|
||||
expect(evidence.unknownRisk.map((skill) => skill.id)).toEqual(['unknown-source']);
|
||||
expect(evidence.missingProvenance.map((skill) => skill.id)).toEqual(['unknown-source']);
|
||||
expect(evidence.elevatedRisk.map((skill) => skill.id)).toEqual(['blocked-offensive']);
|
||||
expect(evidence.blockedForHost.map((skill) => skill.id)).toEqual(['blocked-offensive']);
|
||||
expect(evidence.manualSetup.map((skill) => skill.id)).toEqual(['blocked-offensive']);
|
||||
});
|
||||
|
||||
it('round-trips a deterministic exact selection and pins package and source release', () => {
|
||||
expect(parseSelectedIds('zeta,alpha,zeta')).toEqual(['alpha', 'zeta']);
|
||||
expect(buildInstallerCommand({
|
||||
skillIds: ['zeta', 'alpha'],
|
||||
host: 'codex',
|
||||
version: packageMetadata.version,
|
||||
dryRun: true,
|
||||
})).toBe(
|
||||
`npx agentic-awesome-skills@${packageMetadata.version} --codex --release ${packageMetadata.version} --skills alpha,zeta --dry-run`,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses malformed IDs instead of shell-quoting untrusted selection state', () => {
|
||||
expect(() => buildInstallerCommand({
|
||||
skillIds: ['safe-skill', 'bad;command'],
|
||||
host: 'claude',
|
||||
version: packageMetadata.version,
|
||||
dryRun: false,
|
||||
})).toThrow(/Invalid skill IDs/i);
|
||||
});
|
||||
|
||||
it('accepts the legacy underscore used by a canonical catalog id', () => {
|
||||
expect(buildInstallerCommand({
|
||||
skillIds: ['android_ui_verification'],
|
||||
host: 'codex',
|
||||
version: packageMetadata.version,
|
||||
dryRun: true,
|
||||
})).toContain('--skills android_ui_verification --dry-run');
|
||||
});
|
||||
|
||||
it('proves every current canonical catalog id is unique and command-safe', () => {
|
||||
const ids = canonicalCatalog.map((skill) => skill.id);
|
||||
|
||||
expect(ids.length).toBeGreaterThan(1_000);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(parseSelectedIds(ids.join(','))).toEqual([...ids].sort());
|
||||
expect(() => buildInstallerCommand({
|
||||
skillIds: ids,
|
||||
host: 'codex',
|
||||
version: packageMetadata.version,
|
||||
dryRun: true,
|
||||
})).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { PluginCompatibility, RiskLevel, Skill, SkillSetupType } from '../types';
|
||||
|
||||
export const WORKBENCH_HOSTS = [
|
||||
{ id: 'codex', label: 'Codex CLI', flag: '--codex', compatibilityRecorded: true },
|
||||
{ id: 'claude', label: 'Claude Code', flag: '--claude', compatibilityRecorded: true },
|
||||
{ id: 'cursor', label: 'Cursor', flag: '--cursor', compatibilityRecorded: false },
|
||||
{ id: 'gemini', label: 'Gemini CLI', flag: '--gemini', compatibilityRecorded: false },
|
||||
{ id: 'antigravity', label: 'Antigravity', flag: '--antigravity', compatibilityRecorded: false },
|
||||
{ id: 'kiro', label: 'Kiro', flag: '--kiro', compatibilityRecorded: false },
|
||||
] as const;
|
||||
|
||||
export type WorkbenchHost = typeof WORKBENCH_HOSTS[number]['id'];
|
||||
export type RiskFacet = RiskLevel | 'all';
|
||||
export type ProvenanceFacet = 'all' | 'official' | 'community' | 'self' | 'unknown';
|
||||
export type CompatibilityFacet = 'all' | 'supported' | 'blocked' | 'unrecorded';
|
||||
export type SetupFacet = SkillSetupType | 'all' | 'unknown';
|
||||
|
||||
export interface WorkbenchFilters {
|
||||
search: string;
|
||||
category: string;
|
||||
risk: RiskFacet;
|
||||
provenance: ProvenanceFacet;
|
||||
compatibility: CompatibilityFacet;
|
||||
setup: SetupFacet;
|
||||
}
|
||||
|
||||
export interface SelectionEvidence {
|
||||
blockedForHost: Skill[];
|
||||
unrecordedForHost: Skill[];
|
||||
elevatedRisk: Skill[];
|
||||
unknownRisk: Skill[];
|
||||
missingProvenance: Skill[];
|
||||
manualSetup: Skill[];
|
||||
}
|
||||
|
||||
// Canonical catalog ids are shell-safe lowercase directory basenames. One
|
||||
// legacy id contains an underscore, so preserve that real registry value.
|
||||
const SKILL_ID_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
|
||||
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||
|
||||
export function isWorkbenchHost(value: string | null): value is WorkbenchHost {
|
||||
return WORKBENCH_HOSTS.some((host) => host.id === value);
|
||||
}
|
||||
|
||||
export function getRecordedProvenance(skill: Skill): Exclude<ProvenanceFacet, 'all'> {
|
||||
if (skill.source_type) return skill.source_type;
|
||||
if (skill.source === 'official' || skill.source === 'community' || skill.source === 'self') {
|
||||
return skill.source;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function getHostCompatibility(
|
||||
skill: Skill,
|
||||
host: WorkbenchHost,
|
||||
): PluginCompatibility | 'unrecorded' {
|
||||
if (host === 'codex' || host === 'claude') {
|
||||
return skill.plugin?.targets?.[host] ?? 'unrecorded';
|
||||
}
|
||||
return 'unrecorded';
|
||||
}
|
||||
|
||||
export function getSetupType(skill: Skill): SkillSetupType | 'unknown' {
|
||||
return skill.plugin?.setup?.type ?? 'unknown';
|
||||
}
|
||||
|
||||
function searchableText(skill: Skill): string {
|
||||
return [
|
||||
skill.id,
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.category,
|
||||
skill.source,
|
||||
skill.source_repo,
|
||||
skill.source_type,
|
||||
...(skill.tags ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesWorkbenchFilters(
|
||||
skill: Skill,
|
||||
filters: WorkbenchFilters,
|
||||
host: WorkbenchHost,
|
||||
): boolean {
|
||||
const query = filters.search.trim().toLowerCase();
|
||||
if (query && !searchableText(skill).includes(query)) return false;
|
||||
if (filters.category !== 'all' && skill.category !== filters.category) return false;
|
||||
if (filters.risk !== 'all' && (skill.risk ?? 'unknown') !== filters.risk) return false;
|
||||
if (filters.provenance !== 'all' && getRecordedProvenance(skill) !== filters.provenance) return false;
|
||||
if (filters.compatibility !== 'all' && getHostCompatibility(skill, host) !== filters.compatibility) return false;
|
||||
if (filters.setup !== 'all' && getSetupType(skill) !== filters.setup) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function collectSelectionEvidence(
|
||||
skills: Skill[],
|
||||
host: WorkbenchHost,
|
||||
): SelectionEvidence {
|
||||
return {
|
||||
blockedForHost: skills.filter((skill) => getHostCompatibility(skill, host) === 'blocked'),
|
||||
unrecordedForHost: skills.filter((skill) => getHostCompatibility(skill, host) === 'unrecorded'),
|
||||
elevatedRisk: skills.filter((skill) => skill.risk === 'critical' || skill.risk === 'offensive'),
|
||||
unknownRisk: skills.filter((skill) => (skill.risk ?? 'unknown') === 'unknown'),
|
||||
missingProvenance: skills.filter((skill) => getRecordedProvenance(skill) === 'unknown'),
|
||||
manualSetup: skills.filter((skill) => getSetupType(skill) === 'manual'),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSelectedIds(values: Iterable<string>): string[] {
|
||||
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function parseSelectedIds(value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return normalizeSelectedIds(value.split(','));
|
||||
}
|
||||
|
||||
export function buildInstallerCommand({
|
||||
skillIds,
|
||||
host,
|
||||
version,
|
||||
dryRun,
|
||||
}: {
|
||||
skillIds: Iterable<string>;
|
||||
host: WorkbenchHost;
|
||||
version: string;
|
||||
dryRun: boolean;
|
||||
}): string {
|
||||
const selected = normalizeSelectedIds(skillIds);
|
||||
if (selected.length === 0) throw new Error('Select at least one skill.');
|
||||
if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid release version: ${version}`);
|
||||
const invalid = selected.filter((skillId) => !SKILL_ID_PATTERN.test(skillId));
|
||||
if (invalid.length > 0) throw new Error(`Invalid skill IDs: ${invalid.join(', ')}`);
|
||||
const hostDefinition = WORKBENCH_HOSTS.find((candidate) => candidate.id === host);
|
||||
if (!hostDefinition) throw new Error(`Unsupported host: ${host}`);
|
||||
|
||||
return [
|
||||
`npx agentic-awesome-skills@${version}`,
|
||||
hostDefinition.flag,
|
||||
`--release ${version}`,
|
||||
`--skills ${selected.join(',')}`,
|
||||
dryRun ? '--dry-run' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user