diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 11960c88..d3a6d794 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,7 +14,9 @@ "description": "Create and maintain human-readable changelogs for software projects using Keep a Changelog standards", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-changelog"], + "skills": [ + "./skills/slim-changelog" + ], "keywords": [ "changelog", "documentation", @@ -28,7 +30,9 @@ "description": "Create and implement code of conduct and collaboration policies using Contributor Covenant standards, with specialized templates for scientific research environments and academic citation", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-code-of-conduct"], + "skills": [ + "./skills/slim-code-of-conduct" + ], "keywords": [ "code-of-conduct", "governance", @@ -43,7 +47,9 @@ "description": "Implement Grype-based container and dependency vulnerability scanning with automated pre-commit hooks", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-container-vulnerability-scanning"], + "skills": [ + "./skills/slim-container-vulnerability-scanning" + ], "keywords": [ "vulnerability-scanning", "container", @@ -58,7 +64,9 @@ "description": "Implement comprehensive testing strategies with documentation and automation templates for static analysis, security scanning, and code quality checks", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-continuous-testing"], + "skills": [ + "./skills/slim-continuous-testing" + ], "keywords": [ "testing", "continuous-testing", @@ -73,7 +81,9 @@ "description": "Create comprehensive contributing guides for open source projects with workflow guidance and customizable templates", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-contributing-guide"], + "skills": [ + "./skills/slim-contributing-guide" + ], "keywords": [ "contributing", "open-source", @@ -88,7 +98,9 @@ "description": "Generate comprehensive executive summaries from workspace communication files using AI analysis with automatic context discovery", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-executive-summary"], + "skills": [ + "./skills/slim-executive-summary" + ], "keywords": [ "executive-summary", "ai-powered", @@ -103,7 +115,9 @@ "description": "Team governance templates for government-sponsored open source projects (small, medium, large teams)", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-governance"], + "skills": [ + "./skills/slim-governance" + ], "keywords": [ "governance", "templates", @@ -118,7 +132,9 @@ "description": "Add standardized GitHub issue templates for bug reports and feature requests in both Markdown and Form formats", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-issue-templates"], + "skills": [ + "./skills/slim-issue-templates" + ], "keywords": [ "issue-templates", "github", @@ -133,7 +149,9 @@ "description": "Detect project type and add appropriate NASA/Open Source licenses (Apache 2.0 or MIT)", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-license"], + "skills": [ + "./skills/slim-license" + ], "keywords": [ "license", "open-source", @@ -149,7 +167,9 @@ "description": "Generate comprehensive meeting agendas by orchestrating context gathering from previous summaries, querying external services (GitHub, Slack, etc. via TECH.md), and matching custom format templates. Automatically identifies and suggests generation of missing meeting summaries.", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-meeting-agenda"], + "skills": [ + "./skills/slim-meeting-agenda" + ], "keywords": [ "meeting-agenda", "collaboration", @@ -165,7 +185,9 @@ "description": "Generate comprehensive meeting summaries with action items from meeting notes, transcripts, or agendas. Automatically fulfills actionable tasks using tools discovered from TECH.md (MCP servers, APIs, local scripts) and provides guidance for manual tasks.", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-meeting-summary"], + "skills": [ + "./skills/slim-meeting-summary" + ], "keywords": [ "meeting-summary", "collaboration", @@ -180,7 +202,9 @@ "description": "Set up a project-aware workspace with date-organized dynamic folders for project-aware skill execution, a dedicated assets directory for code repositories, tool integration tracking (MCP servers, APIs, scripts), and daily work organization. This is an essential skill for building advanced AI-driven workflows customized for your project. A number of other skills depend on this as a prerequisite.", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-project-aware-workspace"], + "skills": [ + "./skills/slim-project-aware-workspace" + ], "keywords": [ "workspace", "project-aware", @@ -196,7 +220,9 @@ "description": "Create a comprehensive README.md template to help developers and users understand your project", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-readme"], + "skills": [ + "./skills/slim-readme" + ], "keywords": [ "readme", "documentation", @@ -210,7 +236,9 @@ "description": "Meta-skill for creating and integrating new best practices (skills, agents, MCP servers) into the SLIM marketplace with dependency tracking and automated registry updates", "source": "./static/marketplace", "strict": false, - "skills": ["./skills/slim-skill-creator"], + "skills": [ + "./skills/slim-skill-creator" + ], "keywords": [ "skill-creator", "marketplace", @@ -221,43 +249,14 @@ "mcp-servers" ] }, - { - "name": "github-mcp-server", - "source": { - "source": "github", - "repo": "https://github.com/github/github-mcp-server" - }, - "description": "MCP server providing GitHub repository access and management capabilities including issues, PRs, and repository operations", - "version": "1.0.0", - "author": { - "name": "GitHub", - "url": "https://github.com/github/github-mcp-server" - }, - "category": "integrations", - "homepage": "https://github.com/github/github-mcp-server", - "keywords": [ - "github", - "git", - "integration", - "external", - "third-party", - "mcp-server", - "repository" - ] - }, { "name": "slim-rebranding", - "version": "1.0.2", "description": "Rebranding agent for branding the slim framework for a new project customized for a specific domain. main. Use this skill whenever you'd like to leverage the Slim Marketplace framework for a customized project, but you don't want to keep the slim branding.", "source": "./static/marketplace", - "author": { - "name": "NASA AMMOS", - "email": "slim@jpl.nasa.gov", - "url": "https://github.com/NASA-AMMOS" - }, - "homepage": "https://github.com/NASA-AMMOS/slim/tree/main/static/marketplace/skills/slim-rebranding", - "repository": "https://github.com/NASA-AMMOS/slim", - "license": "Apache-2.0", + "strict": false, + "skills": [ + "./skills/slim-rebranding" + ], "keywords": [ "rebranding", "branding", @@ -268,21 +267,24 @@ "code-quality", "workflow" ], - "skills": ["./skills/slim-rebranding"] - }, - { - "name": "slim-website-maker", - "version": "1.0.0", - "description": "Autonomous website generator skill for creating customized Docusaurus documentation websites. Analyzes project content, generates intelligent website structure, and validates changes with iterative build testing until successful. Creates git branch and commits with clear messages. Auto-detects project type and customizes accordingly. Use when generating project documentation websites, creating content-driven sites, or automating website creation with build validation.", - "source": "./static/marketplace", + "version": "1.0.2", "author": { "name": "NASA AMMOS", "email": "slim@jpl.nasa.gov", "url": "https://github.com/NASA-AMMOS" }, - "homepage": "https://github.com/NASA-AMMOS/slim/tree/main/static/marketplace/agents/slim-website-maker", + "homepage": "https://github.com/NASA-AMMOS/slim/tree/main/static/marketplace/skills/slim-rebranding", "repository": "https://github.com/NASA-AMMOS/slim", - "license": "Apache-2.0", + "license": "Apache-2.0" + }, + { + "name": "slim-website-maker", + "description": "Autonomous website generator skill for creating customized Docusaurus documentation websites. Analyzes project content, generates intelligent website structure, and validates changes with iterative build testing until successful. Creates git branch and commits with clear messages. Auto-detects project type and customizes accordingly. Use when generating project documentation websites, creating content-driven sites, or automating website creation with build validation.", + "source": "./static/marketplace", + "strict": false, + "skills": [ + "./skills/slim-website-maker" + ], "keywords": [ "website-maker", "docusaurus", @@ -294,7 +296,39 @@ "workflow", "deployment" ], - "skills": ["./skills/slim-website-maker"] + "version": "1.0.0", + "author": { + "name": "NASA AMMOS", + "email": "slim@jpl.nasa.gov", + "url": "https://github.com/NASA-AMMOS" + }, + "homepage": "https://github.com/NASA-AMMOS/slim/tree/main/static/marketplace/agents/slim-website-maker", + "repository": "https://github.com/NASA-AMMOS/slim", + "license": "Apache-2.0" + }, + { + "name": "github-mcp-server", + "description": "MCP server providing GitHub repository access and management capabilities including issues, PRs, and repository operations", + "source": { + "source": "github", + "repo": "https://github.com/github/github-mcp-server" + }, + "external_only": true, + "keywords": [ + "github", + "git", + "integration", + "external", + "third-party", + "mcp-server", + "repository" + ], + "version": "1.0.0", + "author": { + "name": "GitHub", + "url": "https://github.com/github/github-mcp-server" + }, + "homepage": "https://github.com/github/github-mcp-server" } ] } diff --git a/.github/workflows/docusaurus.yml b/.github/workflows/docusaurus.yml index 1a10cf85..47ecef84 100644 --- a/.github/workflows/docusaurus.yml +++ b/.github/workflows/docusaurus.yml @@ -11,6 +11,8 @@ jobs: deploy: name: Deploy to GitHub Pages runs-on: ubuntu-latest + permissions: + contents: write # required for peaceiris/actions-gh-pages to publish to gh-pages steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 diff --git a/.github/workflows/registry-check.yml b/.github/workflows/registry-check.yml new file mode 100644 index 00000000..0e50e227 --- /dev/null +++ b/.github/workflows/registry-check.yml @@ -0,0 +1,29 @@ +name: Registry sync check + +# registry.json is the hand-authored source of truth; marketplace.json is +# generated from it. This check fails if the committed marketplace.json is +# stale — contributors must run `npm run prebuild` and commit the result. + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + name: Verify marketplace.json is in sync with registry.json + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Regenerate marketplace.json from registry.json + run: node src/conf/generate-marketplace.js + + - name: Fail if marketplace.json / registry.json are out of sync + run: | + git diff --exit-code .claude-plugin/marketplace.json static/data/registry.json \ + || (echo "::error::marketplace.json / registry.json out of sync — run 'npm run prebuild' and commit the result." && exit 1) diff --git a/README.md b/README.md index 7d2b7d67..8d076feb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ An AI-native platform where DevOps best practices are discoverable and executabl This repository follows a "Single Source of Truth" philosophy: - **static/marketplace/**: Complete skills, agents, and MCP servers with content only (no metadata files) -- **static/data/registry.json**: Manually curated marketplace metadata registry +- **static/data/registry.json**: Hand-authored **source of truth** for all marketplace metadata +- **.claude-plugin/marketplace.json**: Claude Code plugin manifest, **generated** from `registry.json` by `src/conf/generate-marketplace.js` (runs during `npm run prebuild`) - **website/**: Docusaurus-based website for browsing and discovering best practices ## Getting Started @@ -29,11 +30,12 @@ gem install slim- ### For Contributors 1. Add your skill content to `static/marketplace/skills//` (SKILL.md, assets, scripts, etc.) -2. Manually add/update the skill entry in `static/data/registry.json` -3. Test the website: `cd website && npm run serve` -4. Submit a pull request +2. Add/update the skill entry in `static/data/registry.json` +3. Run `npm run prebuild` to regenerate `.claude-plugin/marketplace.json` +4. Test the website: `npm run serve` +5. Commit both `registry.json` and `marketplace.json`, then submit a pull request -**Note:** Skills contain only content files (SKILL.md, assets, scripts, references). All metadata is maintained in registry.json. +**Note:** Skills contain only content files (SKILL.md, assets, scripts, references). All metadata is hand-authored in `registry.json`; `marketplace.json` is generated from it. ## Available Skills diff --git a/docs/contribute/submit-ai-plugin.md b/docs/contribute/submit-ai-plugin.md index de645bf4..d7082f3b 100644 --- a/docs/contribute/submit-ai-plugin.md +++ b/docs/contribute/submit-ai-plugin.md @@ -58,25 +58,29 @@ marketplace/skills/your-skill-name/ ``` **Registry entry:** -Add to `.claude-plugin/marketplace.json`: +Add an entry to the `skills` array in `static/data/registry.json` — the +hand-authored source of truth: ```json { "name": "your-skill-name", + "displayName": "Your Skill Display Name", "description": "What it does and when to use it", - "source": "./static/marketplace", - "strict": false, - "skills": ["./skills/your-skill-name"], - "keywords": [ + "category": "documentation", + "tags": [ "readme", "documentation", "project-setup", "templates", "onboarding" - ] + ], + "example": "Generate a README for this project", + "lastUpdated": "2026-01-01" } ``` -NOTE: make sure to add your entry to the marketplace JSON - that will ensure it gets properly detected and then inserted or updated into the `/static/data/registry.json` file. +NOTE: `static/data/registry.json` is the single source of truth. The Claude Code +manifest `.claude-plugin/marketplace.json` is generated from it — run +`npm run prebuild` after editing the registry, and commit both files. **Need help?** See [skill development best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) diff --git a/docs/contribute/submit-best-practice.md b/docs/contribute/submit-best-practice.md index c4efde2b..794e751b 100644 --- a/docs/contribute/submit-best-practice.md +++ b/docs/contribute/submit-best-practice.md @@ -59,25 +59,30 @@ marketplace/skills/your-skill-name/ ``` **Registry entry:** -Add to `.claude-plugin/marketplace.json`: +Add an entry to the `skills` array in `static/data/registry.json` — the +hand-authored source of truth: ```json { "name": "your-skill-name", + "displayName": "Your Skill Display Name", "description": "What it does and when to use it", - "source": "./static/marketplace", - "strict": false, - "skills": ["./skills/your-skill-name"], - "keywords": [ + "category": "documentation", + "tags": [ "readme", "documentation", "project-setup", "templates", "onboarding" - ] + ], + "example": "Generate a README for this project", + "lastUpdated": "2026-01-01" } ``` -NOTE: make sure to add your entry to the marketplace place JSON - that will ensure it gets properly detected and then inserted or updated into the `/maap-ai/static/data/registry.json` file. +NOTE: `static/data/registry.json` is the single source of truth. The Claude Code +manifest `.claude-plugin/marketplace.json` is generated from it — run +`npm run prebuild` after editing the registry, and commit both files. Derived +fields (`type`, `skill_file_url`, `zip_file_path`) are filled in by the build. **Need help?** See [skill development best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) diff --git a/docusaurus.config.js b/docusaurus.config.js index 5a6a1e69..9bec90f3 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -214,16 +214,8 @@ const config = { }, slimConfig: { - localRegistrySources: [ - { - type: "marketplace-json", - path: "./.claude-plugin/marketplace.json", - enabled: true, - }, - // Future: Support for other types and URLs - ], registries: [ - "./static/data/registry.json", // Local registry (generated from marketplace.json) + "./static/data/registry.json", // Local registry (hand-authored source of truth) // Future: Add remote registries like 'https://example.com/registry.json' ], }, diff --git a/package.json b/package.json index bbb11227..7d8e106b 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "prebuild": "node src/conf/generate-registry.js && node src/conf/generate-file-manifests.js && node src/conf/create-marketplace-zips.js", + "prebuild": "node src/conf/generate-marketplace.js && node src/conf/generate-file-manifests.js && node src/conf/create-marketplace-zips.js", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", - "clear": "docusaurus clear && rm -rf static/assets/marketplace static/data/registry.json static/data/marketplace-files.json", + "clear": "docusaurus clear && rm -rf static/assets/zip static/data/marketplace-files.json", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids" diff --git a/src/components/SkillBrowser.js b/src/components/SkillBrowser.js index 7dbf6ae2..8d1ec909 100644 --- a/src/components/SkillBrowser.js +++ b/src/components/SkillBrowser.js @@ -373,7 +373,7 @@ const SkillCard = ({ text="dark" style={{ fontSize: "0.7rem", fontWeight: "normal" }} > - {new Date(skill.lastUpdated).toLocaleDateString("en-US", { + {new Date(`${skill.lastUpdated}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", diff --git a/src/conf/generate-marketplace.js b/src/conf/generate-marketplace.js new file mode 100644 index 00000000..8a36f8d9 --- /dev/null +++ b/src/conf/generate-marketplace.js @@ -0,0 +1,290 @@ +/** + * Generate .claude-plugin/marketplace.json from static/data/registry.json + * + * `registry.json` is the single, hand-authored SOURCE OF TRUTH, and the + * Claude Code plugin marketplace manifest is GENERATED from it. + * + * The script also "hydrates" registry.json — it fills in the derived fields + * (`type`, `skill_file_url`, `zip_file_path`) so authors never have to write + * them by hand. Hydration is deterministic and idempotent. + * + * Run via `npm run prebuild` (or directly: `node src/conf/generate-marketplace.js`). + */ + +const fs = require("fs"); +const path = require("path"); + +// Paths +const configPath = path.join(__dirname, "../../docusaurus.config.js"); +const registryPath = path.join(__dirname, "../../static/data/registry.json"); +const marketplacePath = path.join( + __dirname, + "../../.claude-plugin/marketplace.json", +); + +// Registry-only fields that must never be copied into marketplace.json. +const REGISTRY_ONLY_FIELDS = [ + "type", + "displayName", + "category", + "example", + "lastUpdated", + "dependencies", + "skill_file_url", + "zip_file_path", +]; + +// Optional metadata fields copied through to the marketplace plugin as-is. +const PASSTHROUGH_FIELDS = [ + "version", + "author", + "homepage", + "repository", + "license", +]; + +/** + * Read the docusaurus baseUrl (used only to build skill_file_url paths). + * + * The config file is read as text and scanned with a regex rather than + * `require()`-d, so this generator runs even before `npm install`. + * + * @returns {string} baseUrl, guaranteed to end with a single "/" + */ +function getBaseUrl() { + let baseUrl = "/"; + try { + const text = fs.readFileSync(configPath, "utf8"); + const match = text.match(/baseUrl:\s*["'`]([^"'`]+)["'`]/); + if (match) baseUrl = match[1]; + } catch (err) { + console.warn(`⚠️ Could not read baseUrl from config: ${err.message}`); + } + return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; +} + +/** + * Determine whether an MCP entry is hosted externally. + * @param {Object} entry - Registry entry + * @returns {boolean} + */ +function isExternal(entry) { + return Boolean( + entry.external_only || + entry.npm_package || + (entry.source && typeof entry.source === "object"), + ); +} + +/** + * Build the website-facing `skill_file_url` for a local entry. + * @param {string} type - "skill" | "agent" | "mcp" + * @param {string} name - Entry name + * @param {string} baseUrl - Docusaurus baseUrl (ends with "/") + * @returns {string} + */ +function deriveFileUrl(type, name, baseUrl) { + if (type === "agent") { + return `${baseUrl}marketplace/agents/${name}/AGENT.md`; + } + if (type === "mcp") { + return `${baseUrl}marketplace/mcp-servers/${name}/MCP.md`; + } + return `${baseUrl}marketplace/skills/${name}/SKILL.md`; +} + +/** + * Hydrate registry entries in place: set `type`, and derive `skill_file_url` + * and `zip_file_path` for every local (non-external) entry. + * @param {Object} registry - Parsed registry object + * @param {string} baseUrl - Docusaurus baseUrl + */ +function hydrateRegistry(registry, baseUrl) { + const groups = [ + ["skills", "skill"], + ["agents", "agent"], + ["mcp", "mcp"], + ]; + + for (const [key, type] of groups) { + if (!Array.isArray(registry[key])) continue; + for (const entry of registry[key]) { + entry.type = type; + if (type === "mcp" && isExternal(entry)) { + // External MCP: skill_file_url is hand-authored; no local zip. + delete entry.zip_file_path; + } else { + entry.skill_file_url = deriveFileUrl(type, entry.name, baseUrl); + entry.zip_file_path = `assets/zip/${entry.name}.zip`; + } + } + } +} + +/** + * Transform a single registry entry into a marketplace plugin object. + * @param {Object} entry - Hydrated registry entry + * @param {string} marketplaceSource - The marketplace.source string + * @returns {Object} Marketplace plugin + */ +function toPlugin(entry, marketplaceSource) { + const plugin = { + name: entry.name, + description: entry.description || "", + }; + + if (entry.type === "skill") { + plugin.source = marketplaceSource; + plugin.strict = false; + plugin.skills = [`./skills/${entry.name}`]; + } else if (entry.type === "agent") { + plugin.source = marketplaceSource; + plugin.strict = false; + plugin.agents = `./agents/${entry.name}`; + } else if (entry.type === "mcp") { + if (isExternal(entry)) { + // External MCP server — carry through its source/package descriptors. + if (entry.source) plugin.source = entry.source; + if (entry.npm_package) plugin.npm_package = entry.npm_package; + if (entry.external_only) plugin.external_only = true; + } else { + plugin.source = marketplaceSource; + plugin.strict = false; + } + } + + plugin.keywords = Array.isArray(entry.tags) ? entry.tags : []; + + for (const field of PASSTHROUGH_FIELDS) { + if (entry[field] !== undefined) plugin[field] = entry[field]; + } + + // Defensive: never leak registry-only fields. + for (const field of REGISTRY_ONLY_FIELDS) { + delete plugin[field]; + } + + return plugin; +} + +/** + * Warn about incomplete website metadata; hard-fail on missing required fields. + * @param {Object} registry - Hydrated registry + * @returns {boolean} true if valid (no hard errors) + */ +function validateRegistry(registry) { + const userFields = ["displayName", "category", "example", "lastUpdated"]; + let hardErrors = 0; + let warnings = 0; + + for (const key of ["skills", "agents", "mcp"]) { + if (!Array.isArray(registry[key])) continue; + for (const entry of registry[key]) { + if (!entry.name || !entry.description) { + console.error( + `❌ CRITICAL: ${key} entry missing required field 'name' or 'description': ${JSON.stringify(entry.name || entry)}`, + ); + hardErrors++; + continue; + } + const missing = userFields.filter((f) => !entry[f]); + if (missing.length > 0) { + console.warn( + `⚠️ '${entry.name}' is missing recommended fields: ${missing.join(", ")}`, + ); + warnings++; + } + } + } + + if (warnings > 0) { + console.log( + `\n💡 ${warnings} entr${warnings === 1 ? "y" : "ies"} have incomplete website metadata (see warnings above).`, + ); + } + return hardErrors === 0; +} + +/** + * Main generator. + */ +function generateMarketplace() { + console.log("🚀 Generating marketplace.json from registry.json...\n"); + + // 1. Load registry (required). + if (!fs.existsSync(registryPath)) { + console.error(`❌ registry.json not found at ${registryPath}`); + process.exit(1); + } + let registry; + try { + registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); + } catch (err) { + console.error(`❌ Invalid JSON in registry.json: ${err.message}`); + process.exit(1); + } + + // 2. Require the top-level `marketplace` identity block. + const mp = registry.marketplace; + if (!mp || !mp.name || !mp.owner || !mp.metadata) { + console.error( + "❌ registry.json must contain a top-level `marketplace` block with " + + "`name`, `owner`, and `metadata`.", + ); + process.exit(1); + } + const marketplaceSource = mp.source || "./static/marketplace"; + + // 3. Hydrate derived fields and write the registry back. + const baseUrl = getBaseUrl(); + hydrateRegistry(registry, baseUrl); + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + "\n"); + console.log(`✓ Hydrated registry.json (derived type / urls / zip paths)`); + + // 4. Validate. + const isValid = validateRegistry(registry); + + // 5. Build the marketplace manifest. + const plugins = []; + for (const key of ["skills", "agents", "mcp"]) { + if (!Array.isArray(registry[key])) continue; + for (const entry of registry[key]) { + plugins.push(toPlugin(entry, marketplaceSource)); + } + } + + const marketplace = { + name: mp.name, + owner: mp.owner, + metadata: mp.metadata, + plugins, + }; + + // 6. Write .claude-plugin/marketplace.json. + fs.mkdirSync(path.dirname(marketplacePath), { recursive: true }); + fs.writeFileSync( + marketplacePath, + JSON.stringify(marketplace, null, 2) + "\n", + ); + console.log( + `✓ Wrote .claude-plugin/marketplace.json (${plugins.length} plugin${plugins.length === 1 ? "" : "s"})`, + ); + + if (!isValid) { + console.error("\n❌ Registry has critical errors — see above."); + process.exit(1); + } + console.log("\n✅ Done."); +} + +// Run. +if (require.main === module) { + generateMarketplace(); +} + +module.exports = { + generateMarketplace, + hydrateRegistry, + toPlugin, + validateRegistry, +}; diff --git a/src/conf/generate-registry.js b/src/conf/generate-registry.js deleted file mode 100644 index f89eafb4..00000000 --- a/src/conf/generate-registry.js +++ /dev/null @@ -1,618 +0,0 @@ -/** - * Generate registry.json from marketplace.json sources - * This script reverses the data flow - generating registry.json FROM marketplace.json - * while preserving user-managed fields (displayName, example, category, dependencies, lastUpdated) - */ - -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const http = require('http'); - -// Paths -const configPath = path.join(__dirname, '../../docusaurus.config.js'); -const registryPath = path.join(__dirname, '../../static/data/registry.json'); - -/** - * Load registry sources from configuration - * @param {Object} config - Configuration object with slimConfig.localRegistrySources - * @returns {Promise} Array of parsed marketplace objects with metadata - */ -async function loadRegistrySources(config) { - const sources = []; - const registrySources = config.slimConfig?.localRegistrySources || []; - - console.log(`📂 Loading ${registrySources.length} registry source(s)...\n`); - - for (const source of registrySources) { - if (!source.enabled) { - console.log(`⏭️ Skipping disabled source: ${source.path}`); - continue; - } - - try { - let marketplace; - let sourceMetadata = { ...source }; - - if (source.type === 'marketplace-json') { - if (source.path.startsWith('http://') || source.path.startsWith('https://')) { - // Fetch from URL - console.log(`🌐 Fetching marketplace from URL: ${source.path}`); - marketplace = await fetchFromUrl(source.path); - sourceMetadata.isRemote = true; - } else { - // Read from local file - const fullPath = path.resolve(source.path); - console.log(`📄 Reading marketplace from local file: ${fullPath}`); - - if (!fs.existsSync(fullPath)) { - console.warn(`⚠️ Source file not found: ${fullPath} - skipping`); - continue; - } - - marketplace = JSON.parse(fs.readFileSync(fullPath, 'utf8')); - sourceMetadata.isRemote = false; - sourceMetadata.resolvedPath = fullPath; - } - - sources.push({ - marketplace, - metadata: sourceMetadata - }); - - console.log(`✓ Loaded marketplace with ${marketplace.plugins?.length || 0} plugins`); - } else { - console.warn(`⚠️ Unknown source type: ${source.type} - skipping`); - } - } catch (error) { - console.error(`❌ Error loading source ${source.path}:`, error.message); - console.log(` Continuing with other sources...\n`); - } - } - - if (sources.length === 0) { - console.warn(`⚠️ No sources loaded successfully`); - } - - return sources; -} - -/** - * Fetch marketplace JSON from URL - * @param {string} url - URL to fetch from - * @returns {Promise} Parsed marketplace JSON - */ -function fetchFromUrl(url) { - return new Promise((resolve, reject) => { - const client = url.startsWith('https://') ? https : http; - - client.get(url, (res) => { - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); - return; - } - - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - const parsed = JSON.parse(data); - resolve(parsed); - } catch (error) { - reject(new Error(`Invalid JSON: ${error.message}`)); - } - }); - }).on('error', (error) => { - reject(error); - }); - }); -} - -/** - * Transform marketplace.json to registry.json format - * @param {Object} marketplace - Marketplace object - * @param {Object} sourceMetadata - Source metadata - * @returns {Object} Registry object with { skills: [], agents: [], mcp: [] } - */ -function transformMarketplaceToRegistry(marketplace, sourceMetadata) { - const registry = { - skills: [], - agents: [], - mcp: [] - }; - - if (!marketplace.plugins || !Array.isArray(marketplace.plugins)) { - console.warn(`⚠️ No plugins array found in marketplace`); - return registry; - } - - for (const plugin of marketplace.plugins) { - try { - // Determine plugin type and extract entries - if (plugin.skills && Array.isArray(plugin.skills)) { - // Skills plugin - for (const skillPath of plugin.skills) { - const skill = createRegistryEntry(plugin, 'skill', skillPath, sourceMetadata); - registry.skills.push(skill); - } - } else if (plugin.agents) { - // Agents plugin - const agent = createRegistryEntry(plugin, 'agent', plugin.agents, sourceMetadata); - registry.agents.push(agent); - } else if (plugin.source && (typeof plugin.source === 'object')) { - // MCP server (external) - const mcp = createRegistryEntry(plugin, 'mcp', null, sourceMetadata); - registry.mcp.push(mcp); - } else if (plugin.external_only || plugin.npm_package) { - // External MCP server - const mcp = createRegistryEntry(plugin, 'mcp', null, sourceMetadata); - registry.mcp.push(mcp); - } else { - console.warn(`⚠️ Unknown plugin type for: ${plugin.name}`); - } - } catch (error) { - console.error(`❌ Error processing plugin ${plugin.name}:`, error.message); - } - } - - return registry; -} - -/** - * Create a registry entry from a marketplace plugin - * @param {Object} plugin - Marketplace plugin object - * @param {string} type - Type: 'skill', 'agent', or 'mcp' - * @param {string|null} itemPath - Path to skill/agent file (null for MCP) - * @param {Object} sourceMetadata - Source metadata - * @returns {Object} Registry entry - */ -function createRegistryEntry(plugin, type, itemPath, sourceMetadata) { - const entry = { - name: plugin.name, - description: plugin.description || '', - tags: plugin.keywords || [], - type: type, - skill_file_url: constructSkillFileUrl(plugin, type, itemPath, sourceMetadata), - }; - - // Add zip file path for local entries - if (!plugin.external_only) { - entry.zip_file_path = `assets/zip/${plugin.name}.zip`; - } - - // Copy additional marketplace fields - if (plugin.version) entry.version = plugin.version; - if (plugin.author) entry.author = plugin.author; - if (plugin.homepage) entry.homepage = plugin.homepage; - if (plugin.repository) entry.repository = plugin.repository; - if (plugin.license) entry.license = plugin.license; - - // MCP-specific fields - if (type === 'mcp') { - if (plugin.external_only) entry.external_only = true; - if (plugin.npm_package) entry.npm_package = plugin.npm_package; - } - - return entry; -} - -/** - * Construct skill_file_url from plugin info - * @param {Object} plugin - Marketplace plugin - * @param {string} type - Entry type - * @param {string|null} itemPath - Path to item file - * @param {Object} sourceMetadata - Source metadata - * @returns {string} Constructed URL - */ -function constructSkillFileUrl(plugin, type, itemPath, sourceMetadata) { - if (type === 'mcp') { - // For MCP, use repository URL or npm package URL - if (plugin.repository?.url) { - return plugin.repository.url.includes('github.com') - ? `https://raw.githubusercontent.com/${plugin.repository.url.split('github.com/')[1]}/main/README.md` - : plugin.repository.url; - } - if (plugin.npm_package) { - return `https://www.npmjs.com/package/${plugin.npm_package}`; - } - return plugin.homepage || '#'; - } - - if (sourceMetadata.isRemote) { - // Remote source - construct external URL - const baseUrl = plugin.source; - if (type === 'agent') { - return itemPath; // Agent path is already complete - } else { - return `${baseUrl}/${itemPath}/SKILL.md`; - } - } else { - // Local source - construct local URL path - if (type === 'agent') { - // Agent path points directly to .md file - return `/slim/marketplace/${itemPath}`; - } else { - // Skill path needs SKILL.md appended, remove leading "./" - const cleanPath = itemPath.startsWith('./') ? itemPath.substring(2) : itemPath; - return `/slim/marketplace/${cleanPath}/SKILL.md`; - } - } -} - -/** - * Merge generated registry with existing registry - * Only updates marketplace-sourced fields, preserves user-managed fields - * @param {Object} generated - Generated registry from marketplace - * @param {Object} existing - Existing registry - * @returns {Object} Merged registry - */ -function mergeWithExisting(generated, existing) { - console.log(`\n🔄 Merging with existing registry...`); - - const merged = { - skills: [], - agents: [], - mcp: [] - }; - - // Preserve local_marketplace_path if it exists - if (existing.local_marketplace_path) { - merged.local_marketplace_path = existing.local_marketplace_path; - } - - // Preserve metadata if it exists (for category icons, etc.) - if (existing.metadata) { - merged.metadata = existing.metadata; - console.log(`✓ Preserved existing metadata section`); - } - - // Fields that are marketplace-managed (always update) - const marketplaceManagedFields = [ - 'name', 'description', 'tags', 'type', 'skill_file_url', 'zip_file_path', - 'version', 'author', 'homepage', 'repository', 'license', - 'external_only', 'npm_package' - ]; - - // Fields that are user-managed (never update, preserve existing) - const userManagedFields = [ - 'displayName', 'example', 'category', 'dependencies', 'lastUpdated' - ]; - - let addedCount = 0; - let updatedCount = 0; - let unchangedCount = 0; - - for (const category of ['skills', 'agents', 'mcp']) { - const existingMap = new Map(); - - // Build map of existing entries by name - if (existing[category] && Array.isArray(existing[category])) { - for (const item of existing[category]) { - existingMap.set(item.name, item); - } - } - - // Process generated entries - for (const generatedItem of generated[category]) { - const existingItem = existingMap.get(generatedItem.name); - - if (existingItem) { - // Merge: update marketplace fields, preserve user fields - const mergedItem = { ...existingItem }; - let hasChanges = false; - - for (const field of marketplaceManagedFields) { - if (generatedItem[field] !== undefined) { - // Special handling for tags: preserve existing tags if marketplace has no keywords - if (field === 'tags') { - if (Array.isArray(generatedItem[field]) && generatedItem[field].length > 0) { - // Marketplace has keywords, use them - if (JSON.stringify(mergedItem[field]) !== JSON.stringify(generatedItem[field])) { - mergedItem[field] = generatedItem[field]; - hasChanges = true; - } - } - // If marketplace has empty tags but registry has existing tags, preserve them - else if (Array.isArray(existingItem[field]) && existingItem[field].length > 0) { - // Keep existing tags, no change needed - console.log(` Preserved existing tags for ${generatedItem.name}: [${existingItem[field].join(', ')}]`); - } else { - // Both are empty, set empty array - if (!Array.isArray(mergedItem[field])) { - mergedItem[field] = []; - hasChanges = true; - } - } - } else { - // Standard field update for non-tags fields - if (mergedItem[field] !== generatedItem[field]) { - mergedItem[field] = generatedItem[field]; - hasChanges = true; - } - } - } - } - - // Ensure user-managed fields exist with defaults if missing - for (const field of userManagedFields) { - if (mergedItem[field] === undefined) { - switch (field) { - case 'displayName': - mergedItem[field] = ''; - break; - case 'example': - mergedItem[field] = ''; - break; - case 'category': - mergedItem[field] = ''; - break; - case 'lastUpdated': - mergedItem[field] = ''; - break; - case 'dependencies': - // Don't set a default, leave undefined - break; - } - } - } - - merged[category].push(mergedItem); - existingMap.delete(generatedItem.name); // Mark as processed - - if (hasChanges) { - updatedCount++; - console.log(`✓ Updated ${category.slice(0, -1)}: ${generatedItem.name}`); - } else { - unchangedCount++; - } - } else { - // New entry: add with marketplace fields + empty user fields - const newItem = { ...generatedItem }; - - for (const field of userManagedFields) { - switch (field) { - case 'displayName': - newItem[field] = ''; - break; - case 'example': - newItem[field] = ''; - break; - case 'category': - newItem[field] = ''; - break; - case 'lastUpdated': - newItem[field] = ''; - break; - case 'dependencies': - // Don't set a default, leave undefined - break; - } - } - - merged[category].push(newItem); - addedCount++; - console.log(`➕ Added new ${category.slice(0, -1)}: ${generatedItem.name}`); - } - } - - // Add remaining existing entries (not in generated) - for (const [name, existingItem] of existingMap) { - merged[category].push(existingItem); - console.log(`📋 Preserved existing ${category.slice(0, -1)}: ${name}`); - } - } - - console.log(`\n📊 Merge Summary:`); - console.log(` ➕ Added: ${addedCount}`); - console.log(` ✓ Updated: ${updatedCount}`); - console.log(` 📋 Unchanged: ${unchangedCount}`); - - return merged; -} - -/** - * Validate registry and report missing required fields - * @param {Object} registry - Registry to validate - * @returns {Object} Validation report - */ -function validateRegistry(registry) { - console.log(`\n🔍 Validating registry...`); - - const requiredFields = { - marketplace: ['name', 'description', 'tags', 'type', 'skill_file_url'], - user: ['displayName', 'category', 'example', 'lastUpdated'] - }; - - const warnings = []; - let totalEntries = 0; - let entriesWithMissingFields = 0; - - for (const category of ['skills', 'agents', 'mcp']) { - if (!registry[category] || !Array.isArray(registry[category])) continue; - - for (const entry of registry[category]) { - totalEntries++; - const missingFields = []; - - // Check marketplace fields (errors) - for (const field of requiredFields.marketplace) { - if (field === 'tags') { - // For tags, check if it exists and is an array (empty array is valid) - if (!entry[field] || !Array.isArray(entry[field])) { - missingFields.push(`${field} (marketplace-managed)`); - } - } else if (Array.isArray(entry[field])) { - // Other array fields must not be empty - if (entry[field].length === 0) { - missingFields.push(`${field} (marketplace-managed)`); - } - } else { - // Non-array fields must exist and not be empty - if (!entry[field] || entry[field] === '') { - missingFields.push(`${field} (marketplace-managed)`); - } - } - } - - // Check user fields (warnings) - for (const field of requiredFields.user) { - if (!entry[field] || entry[field] === '') { - missingFields.push(`${field} (user-managed)`); - } - } - - if (missingFields.length > 0) { - entriesWithMissingFields++; - warnings.push({ - name: entry.name, - type: entry.type, - missingFields - }); - } - } - } - - const report = { - totalEntries, - entriesWithMissingFields, - warnings, - isValid: warnings.filter(w => w.missingFields.some(f => f.includes('marketplace-managed'))).length === 0 - }; - - // Print validation results - if (report.warnings.length === 0) { - console.log(`✅ All ${totalEntries} entries are valid!`); - } else { - console.log(`⚠️ Validation Warnings:\n`); - - for (const warning of report.warnings) { - const userMissing = warning.missingFields.filter(f => f.includes('user-managed')); - const marketplaceMissing = warning.missingFields.filter(f => f.includes('marketplace-managed')); - - if (marketplaceMissing.length > 0) { - console.log(`❌ CRITICAL - Missing marketplace fields for ${warning.type} '${warning.name}':`); - for (const field of marketplaceMissing) { - console.log(` - ${field}`); - } - } - - if (userMissing.length > 0) { - console.log(`⚠️ Missing user fields for ${warning.type} '${warning.name}':`); - for (const field of userMissing) { - const fieldName = field.split(' ')[0]; - let suggestion = ''; - switch (fieldName) { - case 'displayName': - suggestion = ' - Please add a human-readable display name'; - break; - case 'category': - suggestion = ' - Please add a category (documentation, governance, testing, etc.)'; - break; - case 'example': - suggestion = ' - Please add an example usage string'; - break; - case 'lastUpdated': - suggestion = ' - Please add last updated date (YYYY-MM-DD)'; - break; - } - console.log(` - ${fieldName}${suggestion}`); - } - } - console.log(''); - } - - console.log(`Build completed with ${entriesWithMissingFields} entries needing manual updates.`); - } - - return report; -} - -/** - * Main generator function - */ -async function generateRegistry() { - console.log('🚀 Generating registry.json from marketplace sources...\n'); - - try { - // 1. Load configuration - if (!fs.existsSync(configPath)) { - throw new Error(`Configuration file not found: ${configPath}`); - } - - const config = require(configPath); - if (!config.customFields?.slimConfig?.localRegistrySources) { - throw new Error('No customFields.slimConfig.localRegistrySources found in docusaurus.config.js'); - } - - // 2. Load existing registry (required) - if (!fs.existsSync(registryPath)) { - throw new Error(`Existing registry.json not found at ${registryPath}`); - } - - const existingRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - console.log(`📖 Loaded existing registry with ${(existingRegistry.skills?.length || 0) + (existingRegistry.agents?.length || 0) + (existingRegistry.mcp?.length || 0)} entries\n`); - - // 3. Load marketplace sources - const sources = await loadRegistrySources(config.customFields); - if (sources.length === 0) { - throw new Error('No marketplace sources loaded successfully'); - } - - // 4. Transform all sources and merge - let combinedGenerated = { skills: [], agents: [], mcp: [] }; - - for (const source of sources) { - console.log(`\n🔄 Processing source: ${source.metadata.path}`); - const generated = transformMarketplaceToRegistry(source.marketplace, source.metadata); - - // Combine all generated entries - combinedGenerated.skills.push(...generated.skills); - combinedGenerated.agents.push(...generated.agents); - combinedGenerated.mcp.push(...generated.mcp); - } - - console.log(`\n📋 Generated ${combinedGenerated.skills.length} skills, ${combinedGenerated.agents.length} agents, ${combinedGenerated.mcp.length} MCP servers`); - - // 5. Merge with existing registry - const mergedRegistry = mergeWithExisting(combinedGenerated, existingRegistry); - - // 6. Validate merged registry - const validationReport = validateRegistry(mergedRegistry); - - // 7. Write updated registry - fs.writeFileSync(registryPath, JSON.stringify(mergedRegistry, null, 2)); - - console.log(`\n✅ Successfully updated registry.json at: ${registryPath}`); - console.log(`📊 Total entries: ${(mergedRegistry.skills?.length || 0) + (mergedRegistry.agents?.length || 0) + (mergedRegistry.mcp?.length || 0)}`); - - if (!validationReport.isValid) { - console.log(`\n❌ Registry has critical validation errors - please fix marketplace-managed fields`); - process.exit(1); - } - - if (validationReport.warnings.length > 0) { - console.log(`\n⚠️ Registry generated successfully but has missing user-managed fields`); - console.log(`💡 Please update the missing fields listed above in registry.json`); - } - - } catch (error) { - console.error(`\n❌ Error generating registry:`, error.message); - process.exit(1); - } -} - -// Run the generator -if (require.main === module) { - generateRegistry(); -} - -module.exports = { - generateRegistry, - loadRegistrySources, - transformMarketplaceToRegistry, - mergeWithExisting, - validateRegistry -}; \ No newline at end of file diff --git a/static/data/registry.json b/static/data/registry.json index 93bed8ca..e83f543b 100644 --- a/static/data/registry.json +++ b/static/data/registry.json @@ -1,4 +1,16 @@ { + "marketplace": { + "name": "slim-marketplace", + "owner": { + "name": "NASA AMMOS", + "email": "slim@jpl.nasa.gov" + }, + "metadata": { + "description": "SLIM - Software Lifecycle Improvement & Modernization best practices", + "version": "1.0.0" + }, + "source": "./static/marketplace" + }, "skills": [ { "name": "slim-changelog", @@ -351,6 +363,11 @@ { "name": "github-mcp-server", "description": "MCP server providing GitHub repository access and management capabilities including issues, PRs, and repository operations", + "external_only": true, + "source": { + "source": "github", + "repo": "https://github.com/github/github-mcp-server" + }, "tags": [ "github", "git", @@ -371,8 +388,7 @@ "displayName": "GitHub MCP Server", "example": "Give me information about this repository from GitHub. ", "category": "infrastructure", - "lastUpdated": "2026-01-05", - "zip_file_path": "assets/zip/github-mcp-server.zip" + "lastUpdated": "2026-01-05" } ], "metadata": { @@ -389,4 +405,4 @@ "user-interface": "🎨" } } -} \ No newline at end of file +}