[docs] Add OSS Skills Marketplace (#6752)
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { Check } from "lucide-react";
|
||||
import type { Skill } from "@site/src/pages/skills/types";
|
||||
|
||||
function generateInstallCommand(repoUrl: string, skillId: string): string {
|
||||
return `npx skills add ${repoUrl} --skill ${skillId}`;
|
||||
}
|
||||
|
||||
export function SkillCard({ skill }: { skill: Skill }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyInstall = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const command = generateInstallCommand(skill.repoUrl, skill.id);
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<Link
|
||||
to={`/skills/detail?id=${skill.id}`}
|
||||
className="block no-underline hover:no-underline h-full"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-2xl bg-purple-500 opacity-10 blur-2xl" />
|
||||
|
||||
<div className="relative z-10 w-full h-full rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#1A1A1A] flex flex-col justify-between p-6 transition-shadow duration-200 ease-in-out hover:shadow-[0_0_0_2px_rgba(99,102,241,0.4),_0_4px_20px_rgba(99,102,241,0.1)]">
|
||||
<div className="space-y-4">
|
||||
{/* Header with name and badges */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-base text-zinc-900 dark:text-white leading-snug">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{skill.isCommunity && (
|
||||
<span className="inline-flex items-center h-6 px-2 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 text-xs font-medium border border-yellow-200 dark:border-yellow-800">
|
||||
Community
|
||||
</span>
|
||||
)}
|
||||
{skill.version && (
|
||||
<span className="inline-flex items-center h-6 px-2 rounded-full bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 text-xs font-medium">
|
||||
v{skill.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skill.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center h-7 px-3 rounded-full border border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 text-xs font-medium"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supporting files indicator */}
|
||||
{skill.supportingFilesType === 'scripts' && (
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-500">
|
||||
⚙️ Runs scripts
|
||||
</div>
|
||||
)}
|
||||
{skill.supportingFilesType === 'templates' && (
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-500">
|
||||
📄 Includes templates
|
||||
</div>
|
||||
)}
|
||||
{skill.supportingFilesType === 'multi-file' && (
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-500">
|
||||
📁 Multi-file skill
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with actions */}
|
||||
<div className="flex justify-between items-center pt-6 mt-2 border-t border-zinc-100 dark:border-zinc-800">
|
||||
{/* Install button */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={handleCopyInstall}
|
||||
className={`text-sm font-medium px-3 py-1 rounded cursor-pointer flex items-center gap-1.5 transition-colors ${
|
||||
copied
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: "text-zinc-700 bg-zinc-200 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 hover:bg-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
"Copy Install"
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* View Source link - always show, links to Agent-Skills repo */}
|
||||
<a
|
||||
href={skill.viewSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-purple-600 hover:underline dark:text-purple-400"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View Source →
|
||||
</a>
|
||||
|
||||
{/* Author */}
|
||||
{skill.author && (
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
by {skill.author}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { Skill };
|
||||
@@ -0,0 +1,316 @@
|
||||
import Layout from "@theme/Layout";
|
||||
import { ArrowLeft, Download, Copy, ExternalLink, FileText, Check } from "lucide-react";
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import CodeBlock from "@theme/CodeBlock";
|
||||
import { Button } from "@site/src/components/ui/button";
|
||||
import { getSkillById } from "@site/src/utils/skills";
|
||||
import type { Skill } from "@site/src/pages/skills/types";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
type PackageManager = 'npx' | 'pnpm' | 'bun';
|
||||
|
||||
const PACKAGE_MANAGERS: { id: PackageManager; label: string; prefix: string }[] = [
|
||||
{ id: 'npx', label: 'npx', prefix: 'npx' },
|
||||
{ id: 'pnpm', label: 'pnpm', prefix: 'pnpm dlx' },
|
||||
{ id: 'bun', label: 'bun', prefix: 'bunx' },
|
||||
];
|
||||
|
||||
function generateInstallCommand(repoUrl: string, skillId: string, packageManager: PackageManager): string {
|
||||
const prefix = PACKAGE_MANAGERS.find(pm => pm.id === packageManager)?.prefix || 'npx';
|
||||
return `${prefix} skills add ${repoUrl} --skill ${skillId}`;
|
||||
}
|
||||
|
||||
export default function SkillDetailPage(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const [skill, setSkill] = useState<Skill | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPM, setSelectedPM] = useState<PackageManager>('npx');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSkill = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get("id");
|
||||
if (!id) {
|
||||
setError("No skill ID provided");
|
||||
return;
|
||||
}
|
||||
|
||||
const skillData = getSkillById(id);
|
||||
if (skillData) {
|
||||
setSkill(skillData);
|
||||
} else {
|
||||
setError("Skill not found");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load skill details");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSkill();
|
||||
}, [location]);
|
||||
|
||||
const handleCopyInstall = () => {
|
||||
if (skill) {
|
||||
const command = generateInstallCommand(skill.repoUrl, skill.id, selectedPM);
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (skill) {
|
||||
const zipUrl = `/goose/skills-data-zips/${skill.id}.zip`;
|
||||
const link = document.createElement('a');
|
||||
link.href = zipUrl;
|
||||
link.download = `${skill.id}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen flex items-start justify-center py-16">
|
||||
<div className="container max-w-5xl mx-auto px-4 animate-pulse">
|
||||
<div className="h-12 w-48 bg-bgSubtle dark:bg-zinc-800 rounded-lg mb-4"></div>
|
||||
<div className="h-6 w-full bg-bgSubtle dark:bg-zinc-800 rounded-lg mb-2"></div>
|
||||
<div className="h-6 w-2/3 bg-bgSubtle dark:bg-zinc-800 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !skill) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen flex items-start justify-center py-16">
|
||||
<div className="container max-w-5xl mx-auto px-4 text-red-500">
|
||||
{error || "Skill not found"}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const currentCommand = generateInstallCommand(skill.repoUrl, skill.id, selectedPM);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title={skill.name}
|
||||
description={skill.description}
|
||||
>
|
||||
<div className="min-h-screen py-12">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex justify-between items-start">
|
||||
<Link to="/skills">
|
||||
<Button className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Skills
|
||||
</Button>
|
||||
</Link>
|
||||
{skill.author && (
|
||||
<span className="text-sm text-textSubtle">
|
||||
by {skill.author}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-[#1A1A1A] border border-borderSubtle dark:border-zinc-700 rounded-xl p-8 shadow-md">
|
||||
{/* Title and badges */}
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<h1 className="text-4xl font-semibold text-textProminent dark:text-white">
|
||||
{skill.name}
|
||||
</h1>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{skill.isCommunity && (
|
||||
<span className="inline-flex items-center h-7 px-3 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 text-sm font-medium border border-yellow-200 dark:border-yellow-800">
|
||||
Community
|
||||
</span>
|
||||
)}
|
||||
{skill.version && (
|
||||
<span className="inline-flex items-center h-7 px-3 rounded-full bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 text-sm font-medium">
|
||||
v{skill.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-textSubtle dark:text-zinc-400 text-lg mb-6">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skill.tags.map((tag, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
to={`/skills?tag=${tag}`}
|
||||
className="inline-flex items-center h-7 px-3 rounded-full border border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 text-xs font-medium hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install section with tabs */}
|
||||
<div className="mb-6 p-4 bg-zinc-50 dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-lg font-medium mb-3 text-textProminent dark:text-white flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Install
|
||||
</h2>
|
||||
|
||||
{/* Package manager tabs */}
|
||||
<div className="flex gap-1 mb-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
{PACKAGE_MANAGERS.map((pm) => (
|
||||
<button
|
||||
key={pm.id}
|
||||
onClick={() => setSelectedPM(pm.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
|
||||
selectedPM === pm.id
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{pm.label}
|
||||
{selectedPM === pm.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-purple-600 dark:bg-purple-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Install command */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className="flex-1 bg-zinc-200 dark:bg-zinc-800 px-3 py-2 rounded text-sm font-mono text-zinc-800 dark:text-zinc-200 overflow-x-auto">
|
||||
{currentCommand}
|
||||
</code>
|
||||
<Button
|
||||
onClick={handleCopyInstall}
|
||||
className={`flex items-center gap-2 flex-shrink-0 transition-colors ${
|
||||
copied
|
||||
? "bg-green-600 hover:bg-green-700 text-white"
|
||||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500">
|
||||
Requires <a href="/docs/guides/using-extensions#goose-skills" className="text-purple-600 hover:underline">Goose Skills extension</a> enabled
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ZIP Download - secondary option */}
|
||||
<div className="mb-6 flex items-center gap-3 text-sm">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Prefer manual install?</span>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-1.5 text-zinc-600 dark:text-zinc-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Source - always show, links to Agent-Skills repo */}
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href={skill.viewSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-purple-600 hover:underline dark:text-purple-400"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View Source on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Supporting files */}
|
||||
{skill.hasSupporting && skill.supportingFiles.length > 0 && (
|
||||
<div className="mb-6 border-t border-borderSubtle dark:border-zinc-700 pt-6">
|
||||
<h2 className="text-xl font-medium mb-3 text-textProminent dark:text-white flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Supporting Files
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-3">
|
||||
This skill includes additional files that will be installed with it:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-zinc-600 dark:text-zinc-400 space-y-1">
|
||||
{skill.supportingFiles.map((file, index) => (
|
||||
<li key={index}>
|
||||
<code className="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">{file}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill content (markdown) */}
|
||||
<div className="border-t border-borderSubtle dark:border-zinc-700 pt-6">
|
||||
<h2 className="text-2xl font-medium mb-4 text-textProminent dark:text-white">
|
||||
Skill Instructions
|
||||
</h2>
|
||||
<div className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<CodeBlock language={match[1]}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h2 className="text-2xl font-semibold mt-6 mb-4">{children}</h2>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{skill.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { SkillCard } from "@site/src/components/skill-card";
|
||||
import { searchSkills, getAllTags } from "@site/src/utils/skills";
|
||||
import type { Skill } from "@site/src/pages/skills/types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Layout from "@theme/Layout";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import { Button } from "@site/src/components/ui/button";
|
||||
import { SidebarFilter, type SidebarFilterGroup } from "@site/src/components/ui/sidebar-filter";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Link from '@docusaurus/Link';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedFilters, setSelectedFilters] = useState<Record<string, string[]>>({});
|
||||
const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const skillsPerPage = 10;
|
||||
|
||||
// Build tag filter options from loaded skills
|
||||
const uniqueTags = Array.from(
|
||||
new Set(
|
||||
skills.flatMap((s) => s.tags || [])
|
||||
)
|
||||
).sort().map((tag) => ({
|
||||
label: tag.charAt(0).toUpperCase() + tag.slice(1),
|
||||
value: tag
|
||||
}));
|
||||
|
||||
// Build source filter options (Community only - official is the default)
|
||||
const sourceOptions = [
|
||||
{ label: "Community", value: "community" }
|
||||
];
|
||||
|
||||
const sidebarFilterGroups: SidebarFilterGroup[] = [
|
||||
{
|
||||
title: "Tags",
|
||||
options: uniqueTags
|
||||
},
|
||||
{
|
||||
title: "Source",
|
||||
options: sourceOptions
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const loadSkills = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const results = await searchSkills(searchQuery);
|
||||
setSkills(results);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(`Failed to load skills: ${errorMessage}`);
|
||||
console.error("Error loading skills:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(loadSkills, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Apply filters
|
||||
let filteredSkills = skills;
|
||||
|
||||
Object.entries(selectedFilters).forEach(([group, values]) => {
|
||||
if (values.length > 0) {
|
||||
filteredSkills = filteredSkills.filter((skill) => {
|
||||
if (group === "Tags") {
|
||||
return skill.tags?.some((tag) => values.includes(tag)) ?? false;
|
||||
}
|
||||
if (group === "Source") {
|
||||
// Use isCommunity field from manifest (true if author is not "goose")
|
||||
const isCommunity = skill.isCommunity ?? false;
|
||||
if (values.includes("community")) return isCommunity;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Skills Marketplace"
|
||||
description="Browse and install community-contributed skills for goose"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-8 md:p-24">
|
||||
<div className="pb-8 md:pb-16">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h1 className="text-4xl md:text-[64px] font-medium text-textProminent">
|
||||
Skills Marketplace
|
||||
</h1>
|
||||
<Button
|
||||
onClick={() => window.open('https://github.com/block/Agent-Skills?tab=readme-ov-file#contributing-a-skill', '_blank')}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Submit Skill
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-textProminent">
|
||||
Browse community-contributed{" "}
|
||||
<Link to="/docs/guides/context-engineering/using-skills" className="text-purple-600 hover:underline">
|
||||
skills
|
||||
</Link>{" "}
|
||||
that teach goose how to perform specific tasks. Skills are reusable instruction sets with optional supporting files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="search-container mb-6 md:mb-8">
|
||||
<input
|
||||
className="bg-bgApp font-light text-textProminent placeholder-textPlaceholder w-full px-3 py-2 md:py-3 text-2xl md:text-[40px] leading-tight md:leading-[52px] border-b border-borderSubtle focus:outline-none focus:ring-purple-500 focus:border-borderProminent caret-[#FF4F00] pl-0"
|
||||
placeholder="Search skills by name, description, or tag"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden mb-4">
|
||||
<Button onClick={() => setIsMobileFilterOpen(!isMobileFilterOpen)}>
|
||||
{isMobileFilterOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
{isMobileFilterOpen ? "Close Filters" : "Show Filters"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className={`${isMobileFilterOpen ? "block" : "hidden"} md:block md:w-64 mt-6`}>
|
||||
<SidebarFilter
|
||||
groups={sidebarFilterGroups}
|
||||
selectedValues={selectedFilters}
|
||||
onChange={(group, values) => {
|
||||
setSelectedFilters(prev => ({ ...prev, [group]: values }));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className={`${searchQuery ? "pb-2" : "pb-4 md:pb-8"}`}>
|
||||
<p className="text-gray-600">
|
||||
{searchQuery
|
||||
? `${filteredSkills.length} result${filteredSkills.length !== 1 ? "s" : ""} for "${searchQuery}"`
|
||||
: `${filteredSkills.length} skill${filteredSkills.length !== 1 ? "s" : ""} available`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Admonition type="danger" title="Error">
|
||||
<p>{error}</p>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-xl text-gray-600">Loading skills...</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<Admonition type="info">
|
||||
<p>
|
||||
{searchQuery
|
||||
? "No skills found matching your search."
|
||||
: "No skills have been submitted yet."}
|
||||
</p>
|
||||
</Admonition>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
{filteredSkills
|
||||
.slice((currentPage - 1) * skillsPerPage, currentPage * skillsPerPage)
|
||||
.map((skill) => (
|
||||
<motion.div
|
||||
key={skill.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<SkillCard skill={skill} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredSkills.length > skillsPerPage && (
|
||||
<div className="flex justify-center items-center gap-2 md:gap-4 mt-6 md:mt-8">
|
||||
<Button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 md:px-4 py-2 rounded-md border border-border bg-surfaceHighlight hover:bg-surface text-textProminent disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm md:text-base"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-textProminent text-sm md:text-base">
|
||||
Page {currentPage} of {Math.ceil(filteredSkills.length / skillsPerPage)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={() => setCurrentPage(prev => Math.min(Math.ceil(filteredSkills.length / skillsPerPage), prev + 1))}
|
||||
disabled={currentPage >= Math.ceil(filteredSkills.length / skillsPerPage)}
|
||||
className="px-3 md:px-4 py-2 rounded-md border border-border bg-surfaceHighlight hover:bg-surface text-textProminent disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm md:text-base"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import Layout from '@docusaurus/theme-classic/lib/theme/Layout';
|
||||
import CodeBlock from '@docusaurus/theme-classic/lib/theme/CodeBlock';
|
||||
|
||||
/**
|
||||
* Skill status indicator
|
||||
*/
|
||||
export type SkillStatus = 'experimental' | 'stable';
|
||||
|
||||
/**
|
||||
* Install method for a skill
|
||||
* - 'npx-single': npx skills add <owner>/<repo>
|
||||
* - 'npx-multi': npx skills add <url> --skill <name>
|
||||
* - 'download': No repo, show download button
|
||||
*/
|
||||
export type SkillInstallMethod = 'npx-single' | 'npx-multi' | 'download';
|
||||
|
||||
/**
|
||||
* Supporting files type - indicates what kind of extra files the skill includes
|
||||
* - 'scripts': Contains executable files (.sh, .py, .js, etc.)
|
||||
* - 'templates': Contains template files (.template., .example., etc.)
|
||||
* - 'multi-file': Contains other supporting files
|
||||
* - 'none': No supporting files
|
||||
*/
|
||||
export type SupportingFilesType = 'scripts' | 'templates' | 'multi-file' | 'none';
|
||||
|
||||
/**
|
||||
* Skill type definition
|
||||
*/
|
||||
export type Skill = {
|
||||
id: string; // Derived from directory name
|
||||
name: string; // From frontmatter (required)
|
||||
description: string; // From frontmatter (required)
|
||||
author?: string; // From frontmatter
|
||||
version?: string; // From frontmatter
|
||||
status: SkillStatus; // From frontmatter (default: 'stable')
|
||||
tags: string[]; // From frontmatter (default: [])
|
||||
sourceUrl?: string; // From frontmatter - optional external source URL
|
||||
content: string; // Markdown content after frontmatter
|
||||
hasSupporting: boolean; // Computed: has files beyond SKILL.md
|
||||
supportingFiles: string[]; // Computed: list of supporting file paths
|
||||
supportingFilesType: SupportingFilesType; // Computed: type of supporting files
|
||||
installMethod: SkillInstallMethod; // Computed based on source
|
||||
installCommand?: string; // Computed: npx command
|
||||
viewSourceUrl: string; // Computed: GitHub link to skill source
|
||||
repoUrl: string; // Repository URL (Agent-Skills for official, sourceUrl for external)
|
||||
isCommunity: boolean; // True if author is not "goose" (community-contributed)
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter group for sidebar
|
||||
*/
|
||||
export type SkillFilterGroup = {
|
||||
title: string;
|
||||
options: { label: string; value: string; count?: number }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Types documentation page
|
||||
*/
|
||||
const SkillTypes: React.FC = () => {
|
||||
return (
|
||||
<Layout title="Skill Types" description="Type definitions for the Skills Marketplace">
|
||||
<div className="container margin-vert--lg">
|
||||
<h1>Skill Type Definitions</h1>
|
||||
<p>This page contains the type definitions used in the Skills Marketplace.</p>
|
||||
|
||||
<h2>Skill Status</h2>
|
||||
<CodeBlock language="typescript">
|
||||
{`type SkillStatus = 'experimental' | 'stable';`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Skill Install Method</h2>
|
||||
<CodeBlock language="typescript">
|
||||
{`// Install method for a skill
|
||||
// - 'npx-single': npx skills add <owner>/<repo>
|
||||
// - 'npx-multi': npx skills add <url> --skill <name>
|
||||
// - 'download': No repo, show download button
|
||||
type SkillInstallMethod = 'npx-single' | 'npx-multi' | 'download';`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Skill</h2>
|
||||
<CodeBlock language="typescript">
|
||||
{`type Skill = {
|
||||
id: string; // Derived from directory name
|
||||
name: string; // From frontmatter (required)
|
||||
description: string; // From frontmatter (required)
|
||||
author?: string; // From frontmatter
|
||||
version?: string; // From frontmatter
|
||||
status: SkillStatus; // From frontmatter (default: 'stable')
|
||||
tags: string[]; // From frontmatter (default: [])
|
||||
sourceUrl?: string; // From frontmatter - optional external source URL
|
||||
content: string; // Markdown content after frontmatter
|
||||
hasSupporting: boolean; // Computed: has files beyond SKILL.md
|
||||
supportingFiles: string[]; // Computed: list of supporting file paths
|
||||
installMethod: SkillInstallMethod; // Computed based on source
|
||||
installCommand?: string; // Computed: npx command
|
||||
viewSourceUrl: string; // Computed: GitHub link to skill source
|
||||
repoUrl: string; // Repository URL (Agent-Skills for official, sourceUrl for external)
|
||||
isCommunity: boolean; // True if author is not "goose" (community-contributed)
|
||||
};`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SKILL.md Frontmatter Schema</h2>
|
||||
<CodeBlock language="yaml">
|
||||
{`---
|
||||
# Required fields
|
||||
name: string # Skill identifier
|
||||
description: string # Brief description (1-2 sentences)
|
||||
|
||||
# Optional fields
|
||||
author: string # Author name or GitHub handle
|
||||
version: string # Semantic version (e.g., "1.0")
|
||||
status: experimental | stable # Development status (default: stable)
|
||||
tags: # Array of category tags
|
||||
- string
|
||||
source_url: string # GitHub repo URL for npx install
|
||||
---`}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillTypes;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +8,13 @@ interface Props {
|
||||
const SHOW_BANNER = false;
|
||||
|
||||
export default function Root({ children }: Props): JSX.Element {
|
||||
// Initialize gtag as no-op if not present (prevents errors in development)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !window.gtag) {
|
||||
(window as any).gtag = function() {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHOW_BANNER && (
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { Skill, SkillStatus, SkillInstallMethod, SupportingFilesType } from "@site/src/pages/skills/types";
|
||||
import siteConfig from "@generated/docusaurus.config";
|
||||
|
||||
|
||||
// Skills data is loaded from a generated JSON manifest at build time
|
||||
// Generated at: documentation/static/skills-manifest.json
|
||||
|
||||
// Cache for loaded skills
|
||||
let skillsCache: Skill[] | null = null;
|
||||
let skillsPromise: Promise<Skill[]> | null = null;
|
||||
|
||||
/**
|
||||
* Get a skill by its ID
|
||||
*/
|
||||
export function getSkillById(id: string): Skill | null {
|
||||
const allSkills = loadAllSkillsSync();
|
||||
return allSkills.find((skill) => skill.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search skills by query string
|
||||
* Searches name, description, and tags
|
||||
*/
|
||||
export async function searchSkills(query: string): Promise<Skill[]> {
|
||||
const allSkills = await loadAllSkills();
|
||||
if (!query) return allSkills;
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allSkills.filter(
|
||||
(skill) =>
|
||||
skill.name?.toLowerCase().includes(lowerQuery) ||
|
||||
skill.description?.toLowerCase().includes(lowerQuery) ||
|
||||
skill.tags?.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all skills - async version that fetches from manifest
|
||||
*/
|
||||
export async function loadAllSkills(): Promise<Skill[]> {
|
||||
// Never fetch/cache during SSR (prevents "empty list" getting locked in on preview)
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
if (skillsCache) return skillsCache;
|
||||
if (skillsPromise) return skillsPromise;
|
||||
|
||||
skillsPromise = fetchSkillsManifest();
|
||||
|
||||
const skills = await skillsPromise;
|
||||
|
||||
// Only cache if we actually got data (avoid caching [] due to a transient 404)
|
||||
if (skills.length > 0) skillsCache = skills;
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all skills synchronously (uses cache, returns empty if not loaded)
|
||||
*/
|
||||
export function loadAllSkillsSync(): Skill[] {
|
||||
if (skillsCache) return skillsCache;
|
||||
|
||||
// Trigger async load on client
|
||||
if (typeof window !== "undefined") {
|
||||
void loadAllSkills();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch skills manifest from static files
|
||||
*/
|
||||
async function fetchSkillsManifest(): Promise<Skill[]> {
|
||||
try {
|
||||
// In Docusaurus, baseUrl changes automatically for PR previews.
|
||||
// Example:
|
||||
// prod: /goose/
|
||||
// PR preview: /goose/pr-preview/pr-6752/
|
||||
const baseUrl = siteConfig.baseUrl.endsWith("/")
|
||||
? siteConfig.baseUrl
|
||||
: `${siteConfig.baseUrl}/`;
|
||||
|
||||
const manifestUrl = `${baseUrl}skills-manifest.json`;
|
||||
|
||||
const response = await fetch(manifestUrl);
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch skills manifest:", response.status, manifestUrl);
|
||||
return [];
|
||||
}
|
||||
|
||||
const manifest = await response.json();
|
||||
return manifest.skills || [];
|
||||
} catch (error) {
|
||||
console.error("Error loading skills manifest:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Normalize raw frontmatter-like data to Skill type
|
||||
* (kept here in case you reuse it elsewhere)
|
||||
*/
|
||||
export function normalizeSkill(
|
||||
parsed: { frontmatter: Record<string, any>; content: string },
|
||||
id: string,
|
||||
supportingFiles: string[]
|
||||
): Skill {
|
||||
const { frontmatter, content } = parsed;
|
||||
|
||||
const sourceUrl = frontmatter.source_url || frontmatter.sourceUrl;
|
||||
const repoUrl = frontmatter.repo_url || frontmatter.repoUrl || sourceUrl;
|
||||
const author = frontmatter.author;
|
||||
const isCommunity = !!author && author.toLowerCase() !== "goose";
|
||||
|
||||
const installMethod = determineInstallMethod(sourceUrl, id);
|
||||
const installCommand = generateInstallCommand(sourceUrl, id, installMethod);
|
||||
const supportingFilesType = determineSupportingFilesType(supportingFiles);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: frontmatter.name || id,
|
||||
description: frontmatter.description || "No description provided.",
|
||||
author,
|
||||
version: frontmatter.version,
|
||||
status: (frontmatter.status as SkillStatus) || "stable",
|
||||
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [],
|
||||
sourceUrl,
|
||||
repoUrl,
|
||||
isCommunity,
|
||||
content,
|
||||
hasSupporting: supportingFiles.length > 0,
|
||||
supportingFiles,
|
||||
supportingFilesType,
|
||||
installMethod,
|
||||
installCommand,
|
||||
viewSourceUrl: generateViewSourceUrl(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the supporting files type based on file contents
|
||||
*/
|
||||
function determineSupportingFilesType(supportingFiles: string[]): SupportingFilesType {
|
||||
if (supportingFiles.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Executable file extensions
|
||||
const executableExtensions = ['.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', '.py', '.rb', '.js', '.mjs', '.ts'];
|
||||
|
||||
// Template-like patterns
|
||||
const templatePatterns = [
|
||||
/\.template\./i,
|
||||
/\.tmpl\./i,
|
||||
/\.tpl\./i,
|
||||
/template\./i,
|
||||
/\.example\./i,
|
||||
/\.sample\./i,
|
||||
/\.skeleton\./i,
|
||||
/\.stub\./i,
|
||||
/\.j2$/i,
|
||||
/\.jinja2?$/i,
|
||||
/\.mustache$/i,
|
||||
/\.hbs$/i,
|
||||
/\.handlebars$/i,
|
||||
/\.ejs$/i,
|
||||
/\.erb$/i,
|
||||
];
|
||||
|
||||
const hasExecutable = supportingFiles.some(file => {
|
||||
const ext = file.substring(file.lastIndexOf('.')).toLowerCase();
|
||||
return executableExtensions.includes(ext);
|
||||
});
|
||||
|
||||
if (hasExecutable) {
|
||||
return 'scripts';
|
||||
}
|
||||
|
||||
const hasTemplates = supportingFiles.some(file => {
|
||||
return templatePatterns.some(pattern => pattern.test(file));
|
||||
});
|
||||
|
||||
if (hasTemplates) {
|
||||
return 'templates';
|
||||
}
|
||||
|
||||
return 'multi-file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the install method based on source URL
|
||||
*/
|
||||
function determineInstallMethod(sourceUrl: string | undefined, skillId: string): SkillInstallMethod {
|
||||
if (!sourceUrl) return "download";
|
||||
if (sourceUrl.includes("block/goose")) return "npx-multi";
|
||||
|
||||
const simpleRepoPattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/;
|
||||
if (simpleRepoPattern.test(sourceUrl)) return "npx-single";
|
||||
|
||||
return "npx-multi";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the install command based on method
|
||||
*/
|
||||
function generateInstallCommand(
|
||||
sourceUrl: string | undefined,
|
||||
skillId: string,
|
||||
method: SkillInstallMethod
|
||||
): string | undefined {
|
||||
if (method === "download" || !sourceUrl) return undefined;
|
||||
|
||||
if (method === "npx-single") {
|
||||
const match = sourceUrl.match(/github\.com\/([^\/]+\/[^\/]+)/);
|
||||
if (match) return `npx skills add ${match[1]}`;
|
||||
}
|
||||
|
||||
if (method === "npx-multi") {
|
||||
return `npx skills add ${sourceUrl} --skill ${skillId}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the view source URL for a skill in the Agent-Skills repo
|
||||
*/
|
||||
function generateViewSourceUrl(skillId: string): string {
|
||||
return `https://github.com/block/Agent-Skills/tree/main/${skillId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique tags from all skills (async)
|
||||
*/
|
||||
export async function getAllTags(): Promise<string[]> {
|
||||
const allSkills = await loadAllSkills();
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
allSkills.forEach((skill) => {
|
||||
skill.tags.forEach((tag) => tagSet.add(tag));
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort();
|
||||
}
|
||||
Reference in New Issue
Block a user