diff --git a/documentation/.gitignore b/documentation/.gitignore index 805031f2fa18..113ba2622414 100644 --- a/documentation/.gitignore +++ b/documentation/.gitignore @@ -8,6 +8,8 @@ .docusaurus .cache-loader static/goose-docs-map.md +static/skills-manifest.json +static/skills-data-zips/ # Misc .DS_Store @@ -17,3 +19,4 @@ static/goose-docs-map.md .env.production.local npm-debug.log* +.tmp/ diff --git a/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts index a9abd332df13..3e9916da7186 100644 --- a/documentation/docusaurus.config.ts +++ b/documentation/docusaurus.config.ts @@ -391,6 +391,10 @@ const config: Config = { to: '/extensions', label: 'Extensions', }, + { + to: '/skills', + label: 'Skills Marketplace', + }, { to: '/recipe-generator', label: 'Recipe Generator', diff --git a/documentation/package-lock.json b/documentation/package-lock.json index 61e6de1a2e0c..090d882e651f 100644 --- a/documentation/package-lock.json +++ b/documentation/package-lock.json @@ -250,6 +250,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.42.0.tgz", "integrity": "sha512-NZR7yyHj2WzK6D5X8gn+/KOxPdzYEXOqVdSaK/biU8QfYUpUuEA0sCWg/XlO05tPVEcJelF/oLrrNY3UjRbOww==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -387,6 +388,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2176,6 +2178,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2198,6 +2201,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2307,6 +2311,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2728,6 +2733,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3688,6 +3694,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -5032,6 +5039,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -5366,6 +5374,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5737,6 +5746,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6087,6 +6097,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6172,6 +6183,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6217,6 +6229,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.42.0.tgz", "integrity": "sha512-X5+PtWc9EJIPafT/cj8ZG+6IU3cjRRnlHGtqMHK/9gsiupQbAyYlH5y7qt/FtsAhfX5AICHffZy69ZAsVrxWkQ==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.8.0", "@algolia/client-abtesting": "5.42.0", @@ -6715,6 +6728,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7643,6 +7657,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7955,7 +7970,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debounce": { "version": "1.2.1", @@ -9040,6 +9056,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13690,6 +13707,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14260,6 +14278,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15272,6 +15291,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16089,6 +16109,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16098,6 +16119,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16170,6 +16192,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -16225,6 +16248,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -18196,7 +18220,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/turndown": { "version": "7.2.2", @@ -18268,6 +18293,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18609,6 +18635,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -18816,6 +18843,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19429,6 +19457,7 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "devOptional": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -19468,6 +19497,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/documentation/package.json b/documentation/package.json index c0f430528839..a9443aafbb2d 100644 --- a/documentation/package.json +++ b/documentation/package.json @@ -5,7 +5,7 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "build": "node scripts/generate-docs-map.js && docusaurus build", + "build": "node scripts/generate-docs-map.js && node scripts/generate-skills-manifest.js && node scripts/generate-skills-zips.js && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/documentation/plugins/custom-webpack.cjs b/documentation/plugins/custom-webpack.cjs index 3f1a6b36e2c2..8f4193599b9e 100644 --- a/documentation/plugins/custom-webpack.cjs +++ b/documentation/plugins/custom-webpack.cjs @@ -1,15 +1,19 @@ module.exports = function () { return { name: 'custom-webpack-loaders', - configureWebpack(config) { + configureWebpack(config, isServer, utils) { + // Add YAML loader config.module.rules.push({ test: /\.ya?ml$/, use: 'yaml-loader', }); + + // Add .raw file loader config.module.rules.push({ test: /\.raw$/, type: 'asset/source', }); + return {}; }, }; diff --git a/documentation/scripts/generate-skills-manifest.js b/documentation/scripts/generate-skills-manifest.js new file mode 100644 index 000000000000..5a28413934ff --- /dev/null +++ b/documentation/scripts/generate-skills-manifest.js @@ -0,0 +1,360 @@ +/** + * Generate skills manifest from Agent-Skills repository + * + * This script clones the block/Agent-Skills repository and reads all SKILL.md files + * to generate a skills-manifest.json file that the frontend can fetch. + * + * It also supports external skills defined in a local external-skills.json file. + * + * Run this before building the documentation site. + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const matter = require('gray-matter'); + +// Configuration +const AGENT_SKILLS_REPO = 'https://github.com/block/Agent-Skills.git'; +const AGENT_SKILLS_REPO_URL = 'https://github.com/block/Agent-Skills'; +const TEMP_DIR = path.join(__dirname, '..', '.tmp'); +const CLONED_REPO_DIR = path.join(TEMP_DIR, 'agent-skills'); +const MANIFEST_OUTPUT = path.join(__dirname, '..', 'static', 'skills-manifest.json'); +const EXTERNAL_SKILLS_FILE = path.join(__dirname, '..', 'static', 'external-skills.json'); + +// Directories to skip when scanning for skills (not skill folders) +const SKIP_DIRS = ['.github', 'node_modules', '.git']; + +/** + * Clone or update the Agent-Skills repository + */ +function cloneAgentSkillsRepo() { + console.log('[generate-skills-manifest] Fetching Agent-Skills repository...'); + + // Create temp directory if it doesn't exist + if (!fs.existsSync(TEMP_DIR)) { + fs.mkdirSync(TEMP_DIR, { recursive: true }); + } + + // Remove existing clone if present + if (fs.existsSync(CLONED_REPO_DIR)) { + console.log('[generate-skills-manifest] Removing existing clone...'); + fs.rmSync(CLONED_REPO_DIR, { recursive: true, force: true }); + } + + // Shallow clone the repository + try { + execSync(`git clone --depth 1 ${AGENT_SKILLS_REPO} ${CLONED_REPO_DIR}`, { + stdio: 'pipe', + timeout: 60000 // 60 second timeout + }); + console.log('[generate-skills-manifest] Successfully cloned Agent-Skills repository'); + } catch (error) { + console.error('[generate-skills-manifest] ERROR: Failed to clone Agent-Skills repository'); + console.error('[generate-skills-manifest] Error:', error.message); + throw new Error('Failed to fetch Agent-Skills repository. Build cannot continue.'); + } +} + +/** + * Clean up temporary files + */ +function cleanup() { + if (fs.existsSync(CLONED_REPO_DIR)) { + console.log('[generate-skills-manifest] Cleaning up temporary files...'); + fs.rmSync(CLONED_REPO_DIR, { recursive: true, force: true }); + } +} + +/** + * Determine install method based on source configuration + */ +function determineInstallMethod(isExternal, sourceUrl) { + if (isExternal && sourceUrl) { + // External skill with a source URL + const simpleRepoPattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/; + if (simpleRepoPattern.test(sourceUrl)) { + return 'npx-single'; + } + return 'npx-multi'; + } + // Official skill from Agent-Skills repo + return 'npx-multi'; +} + +/** + * Generate install command based on method and source + */ +function generateInstallCommand(skillId, isExternal, sourceUrl) { + if (isExternal && sourceUrl) { + const simpleRepoPattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/; + if (simpleRepoPattern.test(sourceUrl)) { + const match = sourceUrl.match(/github\.com\/([^\/]+\/[^\/]+)/); + if (match) { + return `npx skills add ${match[1]}`; + } + } + return `npx skills add ${sourceUrl} --skill ${skillId}`; + } + // Official skill from Agent-Skills repo + return `npx skills add ${AGENT_SKILLS_REPO_URL} --skill ${skillId}`; +} + +/** + * Generate view source URL for a skill + */ +function generateViewSourceUrl(skillId, isExternal, sourceUrl) { + if (isExternal && sourceUrl) { + return sourceUrl; + } + // Official skill from Agent-Skills repo + return `${AGENT_SKILLS_REPO_URL}/tree/main/${skillId}`; +} + +/** + * Get supporting files in a skill directory (excluding SKILL.md) + */ +function getSupportingFiles(skillDir) { + const files = []; + + function walkDir(dir, prefix = '') { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + walkDir(fullPath, relativePath); + } else if (entry.name !== 'SKILL.md') { + files.push(relativePath); + } + } + } + + walkDir(skillDir); + return files; +} + +/** + * Determine the supporting files type based on file contents + * Returns: 'scripts' | 'templates' | 'multi-file' | 'none' + */ +function determineSupportingFilesType(supportingFiles) { + 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 (file names or extensions) + 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 = path.extname(file).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'; +} + +/** + * Check if a directory contains a SKILL.md file (i.e., is a skill folder) + */ +function isSkillDirectory(dirPath) { + const skillMdPath = path.join(dirPath, 'SKILL.md'); + return fs.existsSync(skillMdPath); +} + +/** + * Process official skills from the cloned Agent-Skills repo + */ +function processOfficialSkills() { + const skills = []; + + // Get all directories in the cloned repo + const entries = fs.readdirSync(CLONED_REPO_DIR, { withFileTypes: true }); + + for (const entry of entries) { + // Skip non-directories and special directories + if (!entry.isDirectory() || SKIP_DIRS.includes(entry.name)) { + continue; + } + + const skillId = entry.name; + const skillDir = path.join(CLONED_REPO_DIR, skillId); + + // Skip if not a skill directory (no SKILL.md) + if (!isSkillDirectory(skillDir)) { + continue; + } + + const skillMdPath = path.join(skillDir, 'SKILL.md'); + + try { + const rawContent = fs.readFileSync(skillMdPath, 'utf8'); + const parsed = matter(rawContent); + const frontmatter = parsed.data || {}; + const content = parsed.content || ''; + + const supportingFiles = getSupportingFiles(skillDir); + const sourceUrl = frontmatter.source_url || frontmatter.sourceUrl; + const author = frontmatter.author; + const isCommunity = author && author.toLowerCase() !== 'goose'; + + const supportingFilesType = determineSupportingFilesType(supportingFiles); + + const skill = { + id: skillId, + name: frontmatter.name || skillId, + description: frontmatter.description || 'No description provided.', + author, + version: frontmatter.version, + tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [], + sourceUrl, // Optional external source if skill references another repo + content, + hasSupporting: supportingFiles.length > 0, + supportingFiles, + supportingFilesType, + installMethod: determineInstallMethod(false, sourceUrl), + installCommand: generateInstallCommand(skillId, false, sourceUrl), + viewSourceUrl: generateViewSourceUrl(skillId, false, sourceUrl), + repoUrl: AGENT_SKILLS_REPO_URL, + isCommunity, + }; + + skills.push(skill); + console.log(`[generate-skills-manifest] Processed official skill: ${skillId}`); + } catch (error) { + console.error(`[generate-skills-manifest] Error processing ${skillId}:`, error.message); + } + } + + return skills; +} + +/** + * Process external skills from external-skills.json + */ +function processExternalSkills() { + const skills = []; + + if (!fs.existsSync(EXTERNAL_SKILLS_FILE)) { + console.log('[generate-skills-manifest] No external-skills.json found, skipping external skills'); + return skills; + } + + try { + const externalData = JSON.parse(fs.readFileSync(EXTERNAL_SKILLS_FILE, 'utf8')); + const externalSkills = externalData.skills || []; + + for (const extSkill of externalSkills) { + const skillId = extSkill.id; + const sourceUrl = extSkill.sourceUrl || extSkill.source_url; + const author = extSkill.author; + const isCommunity = author && author.toLowerCase() !== 'goose'; + + const skill = { + id: skillId, + name: extSkill.name || skillId, + description: extSkill.description || 'No description provided.', + author, + version: extSkill.version, + tags: Array.isArray(extSkill.tags) ? extSkill.tags : [], + sourceUrl, + content: extSkill.content || '', // External skills may not have content + hasSupporting: false, + supportingFiles: [], + supportingFilesType: 'none', + installMethod: determineInstallMethod(true, sourceUrl), + installCommand: generateInstallCommand(skillId, true, sourceUrl), + viewSourceUrl: generateViewSourceUrl(skillId, true, sourceUrl), + repoUrl: sourceUrl, + isCommunity, + }; + + skills.push(skill); + console.log(`[generate-skills-manifest] Processed external skill: ${skillId}`); + } + } catch (error) { + console.error('[generate-skills-manifest] Error processing external skills:', error.message); + } + + return skills; +} + +/** + * Main function to generate the manifest + */ +function generateManifest() { + console.log('[generate-skills-manifest] Starting...'); + + try { + // Clone the Agent-Skills repository + cloneAgentSkillsRepo(); + + // Process official skills from the cloned repo + const officialSkills = processOfficialSkills(); + + // Process external skills from local JSON file + const externalSkills = processExternalSkills(); + + // Combine all skills + const allSkills = [...officialSkills, ...externalSkills]; + + // Check if we have any skills + if (allSkills.length === 0) { + console.error('[generate-skills-manifest] ERROR: No skills found. Build cannot continue.'); + throw new Error('No skills found in Agent-Skills repository.'); + } + + // Generate manifest + const manifest = { + skills: allSkills, + generatedAt: new Date().toISOString(), + count: allSkills.length, + officialCount: officialSkills.length, + externalCount: externalSkills.length, + sourceRepo: AGENT_SKILLS_REPO_URL, + }; + + // Write manifest + fs.writeFileSync(MANIFEST_OUTPUT, JSON.stringify(manifest, null, 2)); + console.log(`[generate-skills-manifest] Generated manifest with ${allSkills.length} skills (${officialSkills.length} official, ${externalSkills.length} external): ${MANIFEST_OUTPUT}`); + + } finally { + // Always clean up + cleanup(); + } +} + +// Run the script +generateManifest(); diff --git a/documentation/scripts/generate-skills-zips.js b/documentation/scripts/generate-skills-zips.js new file mode 100644 index 000000000000..e27a70b9fafd --- /dev/null +++ b/documentation/scripts/generate-skills-zips.js @@ -0,0 +1,130 @@ +/** + * Generate ZIP files for skills from Agent-Skills repository + * + * This script creates ZIP files for each skill in the Agent-Skills repo + * and outputs them to static/skills-data-zips/.zip + * + * Note: This script should run AFTER generate-skills-manifest.js + * because it relies on the cloned repo being present in .tmp/agent-skills + * + * Run this before building the documentation site. + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Configuration - must match generate-skills-manifest.js +const AGENT_SKILLS_REPO = 'https://github.com/block/Agent-Skills.git'; +const TEMP_DIR = path.join(__dirname, '..', '.tmp'); +const CLONED_REPO_DIR = path.join(TEMP_DIR, 'agent-skills'); +const ZIPS_OUTPUT_DIR = path.join(__dirname, '..', 'static', 'skills-data-zips'); + +// Directories to skip when scanning for skills (not skill folders) +const SKIP_DIRS = ['.github', 'node_modules', '.git']; + +/** + * Clone the Agent-Skills repository if not already present + */ +function ensureRepoCloned() { + if (fs.existsSync(CLONED_REPO_DIR)) { + console.log('[generate-skills-zips] Agent-Skills repo already cloned'); + return true; + } + + console.log('[generate-skills-zips] Cloning Agent-Skills repository...'); + + // Create temp directory if it doesn't exist + if (!fs.existsSync(TEMP_DIR)) { + fs.mkdirSync(TEMP_DIR, { recursive: true }); + } + + try { + execSync(`git clone --depth 1 ${AGENT_SKILLS_REPO} ${CLONED_REPO_DIR}`, { + stdio: 'pipe', + timeout: 60000 + }); + console.log('[generate-skills-zips] Successfully cloned Agent-Skills repository'); + return true; + } catch (error) { + console.error('[generate-skills-zips] ERROR: Failed to clone Agent-Skills repository'); + console.error('[generate-skills-zips] Error:', error.message); + return false; + } +} + +/** + * Check if a directory contains a SKILL.md file (i.e., is a skill folder) + */ +function isSkillDirectory(dirPath) { + const skillMdPath = path.join(dirPath, 'SKILL.md'); + return fs.existsSync(skillMdPath); +} + +/** + * Clean up temporary files + */ +function cleanup() { + if (fs.existsSync(CLONED_REPO_DIR)) { + console.log('[generate-skills-zips] Cleaning up temporary files...'); + fs.rmSync(CLONED_REPO_DIR, { recursive: true, force: true }); + } +} + +function generateSkillZips() { + console.log('[generate-skills-zips] Starting...'); + + // Ensure repo is cloned + if (!ensureRepoCloned()) { + console.error('[generate-skills-zips] Cannot generate ZIPs without Agent-Skills repo'); + process.exit(1); + } + + // Create output directory if it doesn't exist + if (!fs.existsSync(ZIPS_OUTPUT_DIR)) { + fs.mkdirSync(ZIPS_OUTPUT_DIR, { recursive: true }); + console.log(`[generate-skills-zips] Created output directory: ${ZIPS_OUTPUT_DIR}`); + } + + // Clean existing ZIPs + const existingZips = fs.readdirSync(ZIPS_OUTPUT_DIR).filter(f => f.endsWith('.zip')); + for (const zip of existingZips) { + fs.unlinkSync(path.join(ZIPS_OUTPUT_DIR, zip)); + } + console.log(`[generate-skills-zips] Cleaned ${existingZips.length} existing ZIP files`); + + // Get all skill directories from the cloned repo + const entries = fs.readdirSync(CLONED_REPO_DIR, { withFileTypes: true }); + const skillDirs = entries + .filter(d => d.isDirectory() && !SKIP_DIRS.includes(d.name)) + .map(d => d.name) + .filter(name => isSkillDirectory(path.join(CLONED_REPO_DIR, name))); + + let generatedCount = 0; + + for (const skillId of skillDirs) { + const skillDir = path.join(CLONED_REPO_DIR, skillId); + const zipPath = path.join(ZIPS_OUTPUT_DIR, `${skillId}.zip`); + + try { + // Use the system zip command to create the archive + // cd into the cloned repo and zip the skill folder to preserve the folder name + execSync(`cd "${CLONED_REPO_DIR}" && zip -r "${zipPath}" "${skillId}"`, { + stdio: 'pipe' + }); + + const stats = fs.statSync(zipPath); + console.log(`[generate-skills-zips] Created: ${skillId}.zip (${(stats.size / 1024).toFixed(1)} KB)`); + generatedCount++; + } catch (error) { + console.error(`[generate-skills-zips] Error creating ZIP for ${skillId}:`, error.message); + } + } + + console.log(`[generate-skills-zips] Generated ${generatedCount} ZIP files in ${ZIPS_OUTPUT_DIR}`); + + // Clean up the cloned repo + cleanup(); +} + +generateSkillZips(); diff --git a/documentation/src/components/skill-card.tsx b/documentation/src/components/skill-card.tsx new file mode 100644 index 000000000000..80898a443be0 --- /dev/null +++ b/documentation/src/components/skill-card.tsx @@ -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 ( +
+ +
+ +
+
+ {/* Header with name and badges */} +
+

+ {skill.name} +

+
+ {skill.isCommunity && ( + + Community + + )} + {skill.version && ( + + v{skill.version} + + )} +
+
+ + {/* Description */} +

+ {skill.description} +

+ + {/* Tags */} + {skill.tags.length > 0 && ( +
+ {skill.tags.map((tag, index) => ( + + {tag} + + ))} +
+ )} + + {/* Supporting files indicator */} + {skill.supportingFilesType === 'scripts' && ( +
+ ⚙️ Runs scripts +
+ )} + {skill.supportingFilesType === 'templates' && ( +
+ 📄 Includes templates +
+ )} + {skill.supportingFilesType === 'multi-file' && ( +
+ 📁 Multi-file skill +
+ )} +
+ + {/* Footer with actions */} +
+ {/* Install button */} +
+ + +
+ + {/* View Source link - always show, links to Agent-Skills repo */} + e.stopPropagation()} + > + View Source → + + + {/* Author */} + {skill.author && ( + + by {skill.author} + + )} +
+
+ +
+ ); +} + +export type { Skill }; diff --git a/documentation/src/pages/skills/detail.tsx b/documentation/src/pages/skills/detail.tsx new file mode 100644 index 000000000000..966c710051b1 --- /dev/null +++ b/documentation/src/pages/skills/detail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedPM, setSelectedPM] = useState('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 ( + +
+
+
+
+
+
+
+
+ ); + } + + if (error || !skill) { + return ( + +
+
+ {error || "Skill not found"} +
+
+
+ ); + } + + const currentCommand = generateInstallCommand(skill.repoUrl, skill.id, selectedPM); + + return ( + +
+
+ {/* Header */} +
+ + + + {skill.author && ( + + by {skill.author} + + )} +
+ +
+ {/* Title and badges */} +
+

+ {skill.name} +

+
+ {skill.isCommunity && ( + + Community + + )} + {skill.version && ( + + v{skill.version} + + )} +
+
+ + {/* Description */} +

+ {skill.description} +

+ + {/* Tags */} + {skill.tags.length > 0 && ( +
+
+ {skill.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} + + {/* Install section with tabs */} +
+

+ + Install +

+ + {/* Package manager tabs */} +
+ {PACKAGE_MANAGERS.map((pm) => ( + + ))} +
+ + {/* Install command */} +
+ + {currentCommand} + + +
+

+ Requires Goose Skills extension enabled +

+
+ + {/* ZIP Download - secondary option */} +
+ Prefer manual install? + +
+ + {/* View Source - always show, links to Agent-Skills repo */} + + + {/* Supporting files */} + {skill.hasSupporting && skill.supportingFiles.length > 0 && ( +
+

+ + Supporting Files +

+

+ This skill includes additional files that will be installed with it: +

+
    + {skill.supportingFiles.map((file, index) => ( +
  • + {file} +
  • + ))} +
+
+ )} + + {/* Skill content (markdown) */} +
+

+ Skill Instructions +

+
+ + {String(children).replace(/\n$/, '')} + + ); + } + return ( + + {children} + + ); + }, + h1({ children }) { + return

{children}

; + }, + }} + > + {skill.content} +
+
+
+
+
+
+
+ ); +} diff --git a/documentation/src/pages/skills/index.tsx b/documentation/src/pages/skills/index.tsx new file mode 100644 index 000000000000..ff8734323f07 --- /dev/null +++ b/documentation/src/pages/skills/index.tsx @@ -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([]); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedFilters, setSelectedFilters] = useState>({}); + const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+
+
+

+ Skills Marketplace +

+ +
+

+ Browse community-contributed{" "} + + skills + {" "} + that teach goose how to perform specific tasks. Skills are reusable instruction sets with optional supporting files. +

+
+ +
+ { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + /> +
+ +
+ +
+ +
+
+ { + setSelectedFilters(prev => ({ ...prev, [group]: values })); + setCurrentPage(1); + }} + /> +
+ +
+
+

+ {searchQuery + ? `${filteredSkills.length} result${filteredSkills.length !== 1 ? "s" : ""} for "${searchQuery}"` + : `${filteredSkills.length} skill${filteredSkills.length !== 1 ? "s" : ""} available`} +

+
+ + {error && ( + +

{error}

+
+ )} + + {isLoading ? ( +
Loading skills...
+ ) : filteredSkills.length === 0 ? ( + +

+ {searchQuery + ? "No skills found matching your search." + : "No skills have been submitted yet."} +

+
+ ) : ( + <> +
+ {filteredSkills + .slice((currentPage - 1) * skillsPerPage, currentPage * skillsPerPage) + .map((skill) => ( + + + + ))} +
+ + {filteredSkills.length > skillsPerPage && ( +
+ + + + Page {currentPage} of {Math.ceil(filteredSkills.length / skillsPerPage)} + + + +
+ )} + + )} +
+
+
+
+ ); +} diff --git a/documentation/src/pages/skills/types/index.tsx b/documentation/src/pages/skills/types/index.tsx new file mode 100644 index 000000000000..0995b32ba8ef --- /dev/null +++ b/documentation/src/pages/skills/types/index.tsx @@ -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 / + * - 'npx-multi': npx skills add --skill + * - '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 ( + +
+

Skill Type Definitions

+

This page contains the type definitions used in the Skills Marketplace.

+ +

Skill Status

+ +{`type SkillStatus = 'experimental' | 'stable';`} + + +

Skill Install Method

+ +{`// Install method for a skill +// - 'npx-single': npx skills add / +// - 'npx-multi': npx skills add --skill +// - 'download': No repo, show download button +type SkillInstallMethod = 'npx-single' | 'npx-multi' | 'download';`} + + +

Skill

+ +{`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) +};`} + + +

SKILL.md Frontmatter Schema

+ +{`--- +# 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 +---`} + +
+
+ ); +}; + +export default SkillTypes; diff --git a/documentation/src/theme/Root.tsx b/documentation/src/theme/Root.tsx index fd240f186800..a25841532075 100644 --- a/documentation/src/theme/Root.tsx +++ b/documentation/src/theme/Root.tsx @@ -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 && ( diff --git a/documentation/src/utils/skills.ts b/documentation/src/utils/skills.ts new file mode 100644 index 000000000000..731fc7aeacef --- /dev/null +++ b/documentation/src/utils/skills.ts @@ -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 | 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 { + 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 { + // 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 { + 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; 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 { + const allSkills = await loadAllSkills(); + const tagSet = new Set(); + + allSkills.forEach((skill) => { + skill.tags.forEach((tag) => tagSet.add(tag)); + }); + + return Array.from(tagSet).sort(); +}