[docs] Add OSS Skills Marketplace (#6752)

This commit is contained in:
Ebony Louis
2026-01-28 22:46:18 -05:00
committed by GitHub
parent 7ea38402a3
commit 4d49fd5423
13 changed files with 1590 additions and 5 deletions
+137
View File
@@ -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 };
+316
View File
@@ -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>
);
}
+223
View File
@@ -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;
+8 -1
View File
@@ -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 && (
+246
View File
@@ -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();
}