feat(schema): add kind, topics, compat, npm and path; reject unknown fields and non-root repo URLs

Entries gain a fixed kind and 1-3 topics (replacing 374 one-off tags as the categorization axis), an OpenCode API compat field (required for plugins), and npm/path for packages and monorepo folders. repo must be a repository root; unknown fields such as install: or repository: are rejected. Cross-entry rules (one entry per repo+path, kind must fit the category, no placeholder owners or mojibake) live in scripts/utils/catalog.js, and validate.js now always loads the whole catalog and fails on unparseable YAML. Adds npm run fmt for canonical formatting and drops the unused glob dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
John SmithandClaude Opus 5.5 committed 2026-10-17 13:00:22 -04:00
1 parent 3e58e7098c
commit 56fad94c34
15 files changed
+527 -624

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ When adding a new entry to `data/{category}/`, use the corresponding example as
| `data/agents/` | [agent.yaml](agent.yaml) |
| `data/projects/` | [project.yaml](project.yaml) |
| `data/resources/` | [resource.yaml](resource.yaml) |
| `data/forks/` | [fork.yaml](fork.yaml) |
| `data/projects/` (`kind: fork`) | [fork.yaml](fork.yaml) |
## Required vs Optional Fields
+4
View File
@@ -16,6 +16,10 @@ description: A custom agent configuration for security-focused code review.
- Predefined tools for vulnerability scanning
- Skills for OWASP Top 10 detection
kind: agent # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork
topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping
- orchestration
# ============================================================================
# OPTIONAL FIELDS
# ============================================================================
+5 -1
View File
@@ -1,6 +1,6 @@
# Example Fork Entry
# Forks are modified builds of OpenCode with custom features or patches.
# Place actual entries in: data/forks/your-fork-name.yaml
# Place actual entries in: data/projects/your-fork-name.yaml
# ============================================================================
# REQUIRED FIELDS
@@ -17,6 +17,10 @@ description: A fork of OpenCode designed for enterprise environments.
- Custom model routing
- Air-gapped deployment support
kind: fork # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork
topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping
- environment
# ============================================================================
# OPTIONAL FIELDS
# ============================================================================
+5
View File
@@ -15,6 +15,11 @@ description: A longer description explaining the plugin in detail.
- Use cases
- Key benefits
kind: plugin # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork
topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping
- workflow
compat: v2 # plugins only: v2, v1 or unknown (CI verifies it)
# ============================================================================
# OPTIONAL FIELDS
# ============================================================================
+4
View File
@@ -18,6 +18,10 @@ description: A standalone web application for monitoring and managing
- Token usage analytics
- Multi-project overview
kind: client # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork
topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping
- sessions
# ============================================================================
# OPTIONAL FIELDS
# ============================================================================
+4
View File
@@ -17,6 +17,10 @@ description: A Model Context Protocol (MCP) server that provides
- Read-only query execution
- Schema introspection
kind: guide # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork
topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping
- docs
# ============================================================================
# OPTIONAL FIELDS
# ============================================================================
+4
View File
@@ -13,6 +13,10 @@ description: A dark theme designed for extended coding sessions.
Features high contrast syntax highlighting and
carefully chosen colors to reduce eye strain.
kind: theme # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork
topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping
- ui
# ============================================================================
# OPTIONAL FIELDS
# ============================================================================
+117 -12
View File
@@ -2,18 +2,35 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/Heretek-AI/awesome-opencode/data/schema.json",
"title": "Awesome Opencode Entry",
"description": "Schema for validating YAML entries in the Heretek-AI/awesome-opencode list",
"description": "Schema for validating YAML entries in the Heretek-AI/awesome-opencode list. Cross-entry rules (unique repos, kind per category, compat for plugins) live in scripts/utils/catalog.js.",
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80,
"description": "Display name of the project or tool"
},
"repo": {
"type": "string",
"format": "uri",
"description": "URL to the repository (https://github.com/...)"
"pattern": "^https://((github\\.com|gitlab\\.com|codeberg\\.org|gitee\\.com)/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+|github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/discussions/[0-9]+|gist\\.github\\.com/[A-Za-z0-9_-]+/[0-9a-f]+)/?$",
"description": "Repository root URL (no /tree/ or /blob/ paths, no npm or marketing pages). Gists and GitHub discussions (for guides) are also accepted. Use `path` for a package inside a monorepo."
},
"path": {
"type": "string",
"pattern": "^(?!/)(?!.*\\.\\.)(?!.*/$)[A-Za-z0-9_.@/-]+$",
"description": "Subdirectory of `repo` that holds this entry, for monorepos (e.g. packages/my-plugin)"
},
"npm": {
"type": "string",
"pattern": "^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$",
"description": "npm package name, for entries installed from npm. Used for the V2 compatibility check and health checks."
},
"homepage": {
"type": "string",
"format": "uri",
"description": "Documentation URL if different from repository"
},
"tagline": {
"type": "string",
@@ -24,13 +41,99 @@
"description": {
"type": "string",
"minLength": 1,
"maxLength": 1500,
"description": "Full description of the project (shown when expanded)"
},
"kind": {
"type": "string",
"enum": [
"plugin",
"theme",
"agent",
"skill",
"mcp-server",
"client",
"tool",
"config",
"guide",
"fork"
],
"description": "What the entry is. plugin: OpenCode plugin package. theme: color theme. agent: agent/subagent configuration. skill: SKILL.md pack. mcp-server: Model Context Protocol server. client: GUI, TUI, web, mobile, editor or chat front-end for OpenCode. tool: CLI, proxy, library or other utility. config: starter config, commands or templates. guide: docs, tutorials or articles. fork: a modified build of OpenCode."
},
"also": {
"type": "array",
"items": {
"type": "string",
"enum": [
"plugin",
"theme",
"agent",
"skill",
"mcp-server",
"client",
"tool",
"config",
"guide",
"fork"
]
},
"uniqueItems": true,
"maxItems": 3,
"description": "Secondary kinds, e.g. a plugin that also ships an MCP server. Cross-listed in the README and site."
},
"topics": {
"type": "array",
"items": {
"type": "string",
"enum": [
"memory",
"context",
"orchestration",
"workflow",
"providers",
"cost",
"notifications",
"sessions",
"code-quality",
"security",
"git",
"remote",
"web",
"ui",
"voice",
"media",
"docs",
"environment",
"observability",
"integrations",
"code-intel",
"prompting",
"domain"
]
},
"minItems": 1,
"maxItems": 3,
"uniqueItems": true,
"description": "1-3 use-case topics from a fixed list. The first one is the primary topic used for README grouping."
},
"compat": {
"type": "string",
"enum": [
"v2",
"v1",
"n/a",
"unknown"
],
"description": "OpenCode API generation. v2: depends on @opencode/* (e.g. @opencode/plugin with Plugin.define). v1: legacy @opencode-ai/* only. n/a: does not use the plugin/SDK API. unknown: not yet verified. Required for plugins; CI verifies it."
},
"scope": {
"type": "array",
"items": {
"type": "string",
"enum": ["global", "project"]
"enum": [
"global",
"project"
]
},
"minItems": 1,
"uniqueItems": true,
@@ -41,23 +144,25 @@
"items": {
"type": "string"
},
"description": "Tags for filtering and discoverability"
"description": "Free-form search keywords. Not rendered; use `topics` for categorization."
},
"min_version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+",
"description": "Minimum OpenCode version required (semver format, e.g. '1.0.0')"
},
"homepage": {
"type": "string",
"format": "uri",
"description": "Documentation URL if different from repository"
"description": "Minimum OpenCode version required (semver format, e.g. '2.0.0')"
},
"installation": {
"type": "string",
"description": "Detailed installation instructions in markdown format"
}
},
"required": ["name", "repo", "tagline", "description"],
"additionalProperties": true
"required": [
"name",
"repo",
"tagline",
"description",
"kind",
"topics"
],
"additionalProperties": false
}
-475
View File
@@ -11,27 +11,9 @@
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"glob": "^11.0.0",
"js-yaml": "^4.3.2"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -65,101 +47,12 @@
}
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -182,75 +75,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@@ -279,70 +103,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -351,241 +111,6 @@
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
}
}
}
+21 -5
View File
@@ -1,19 +1,35 @@
{
"name": "awesome-opencode",
"version": "1.0.0",
"description": "A curated list of plugins, themes, agents, and resources for Opencode",
"description": "A curated, validated catalog of OpenCode plugins, themes, agents, MCP servers, clients and resources",
"private": true,
"scripts": {
"generate": "node scripts/generate-readme.js",
"validate": "node scripts/validate.js",
"bootstrap": "node scripts/bootstrap.js"
"generate": "node scripts/generate-readme.js",
"export": "node scripts/export-json.js --pretty --output dist/registry.json",
"new": "node scripts/new-entry.js",
"fmt": "node scripts/fmt.js",
"check-pr": "node scripts/check-pr.js --base origin/main",
"health": "node scripts/health-check.js --dry-run",
"scout": "node scripts/scout.js run --dry-run",
"site": "node scripts/build-site.js",
"test": "node --test test/"
},
"keywords": ["opencode", "awesome-list", "plugins", "themes", "agents"],
"keywords": [
"opencode",
"awesome-list",
"plugins",
"themes",
"agents",
"mcp"
],
"license": "CC0-1.0",
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"glob": "^11.0.0",
"js-yaml": "^4.3.2"
},
"engines": {
"node": ">=20"
}
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Rewrite catalog entries in canonical format (key order, safe quoting).
* Usage: node scripts/fmt.js [--check] [file.yaml ...]
*
* --check lists files that would change and exits 1 without writing.
*/
const fs = require('fs');
const path = require('path');
const { loadCatalog, formatProblems } = require('./utils/catalog');
const { dumpEntry } = require('./utils/format');
const ROOT = path.join(__dirname, '..');
function main() {
const argv = process.argv.slice(2);
const check = argv.includes('--check');
const only = argv.filter(a => a.endsWith('.yaml')).map(f => path.relative(ROOT, path.resolve(f)));
const { entries, errors } = loadCatalog();
if (errors.length) {
console.error(formatProblems(errors));
console.error('\nFix the YAML syntax errors above first.');
process.exit(1);
}
const changed = [];
for (const entry of entries) {
if (only.length && !only.includes(entry.file)) continue;
const target = path.join(ROOT, entry.file);
const formatted = dumpEntry(entry.data);
if (fs.readFileSync(target, 'utf8') !== formatted) {
changed.push(entry.file);
if (!check) fs.writeFileSync(target, formatted);
}
}
if (check && changed.length) {
console.error(`${changed.length} file(s) are not in canonical format:\n${changed.map(f => ` ${f}`).join('\n')}\nRun: npm run fmt`);
process.exit(1);
}
console.log(check ? '✓ All entries are formatted.' : `✓ Formatted ${changed.length} file(s).`);
}
main();
+250
View File
@@ -0,0 +1,250 @@
/**
* Catalog loading and cross-entry rules for awesome-opencode.
*
* Every consumer (validate, generate-readme, export-json, CI checks) loads entries through
* loadCatalog() so a single unparseable file is reported as an error instead of silently
* dropping a whole category.
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { validateEntry } = require('./validation');
const schema = require('../../data/schema.json');
const DATA_DIR = path.join(__dirname, '../../data');
// Category processing order (README section order)
const CATEGORIES = ['plugins', 'themes', 'agents', 'projects', 'resources'];
// Plugins still on the V1 API (compat: v1) are removed from the catalog after this date
const V1_SUNSET = '2027-01-01';
const KINDS = schema.properties.kind.enum;
const TOPICS = schema.properties.topics.items.enum;
// Which kinds may live in which category directory
const CATEGORY_KINDS = {
plugins: ['plugin'],
themes: ['theme'],
agents: ['agent', 'skill'],
projects: ['client', 'tool', 'mcp-server', 'skill', 'agent', 'fork'],
resources: ['guide', 'config', 'mcp-server', 'skill', 'tool']
};
const TOPIC_LABELS = {
memory: 'Memory',
context: 'Context & Tokens',
orchestration: 'Multi-Agent & Orchestration',
workflow: 'Workflows & Planning',
providers: 'Providers, Auth & Routing',
cost: 'Cost & Usage',
notifications: 'Notifications',
sessions: 'Sessions & History',
'code-quality': 'Code Review & Quality',
security: 'Security & Sandboxing',
git: 'Git & GitHub',
remote: 'Remote & Mobile',
web: 'Web & Browser',
ui: 'UI & TUI',
voice: 'Voice',
media: 'Media & Documents',
docs: 'Docs & Research',
environment: 'Environment & Setup',
observability: 'Observability',
integrations: 'Integrations',
'code-intel': 'Code Intelligence',
prompting: 'Prompting & Skills',
domain: 'Domain-Specific'
};
const KIND_LABELS = {
plugin: 'Plugins',
theme: 'Themes',
agent: 'Agents',
skill: 'Skills',
'mcp-server': 'MCP Servers',
client: 'Clients & GUIs',
tool: 'Tools & Utilities',
config: 'Configs & Starters',
guide: 'Guides',
fork: 'Forks'
};
const FILENAME_RE = /^[a-z0-9]+(?:[-.][a-z0-9]+)*\.yaml$/;
// Contributor-facing wording for schema pattern failures
const PATTERN_HINTS = {
'/repo': 'repo must be the repository root, e.g. https://github.com/owner/repo (no /tree/ or /blob/ paths, npm pages or websites; use `path` for a folder inside a monorepo)',
'/path': 'path must be a relative folder inside the repo, e.g. packages/my-plugin',
'/npm': 'npm must be a package name, e.g. opencode-my-plugin or @scope/opencode-my-plugin',
'/min_version': 'min_version must be semver, e.g. "2.0.0"'
};
const MOJIBAKE_RE = /鈥|â€|Ã[\u0080-¿]/;
const PLACEHOLDER_OWNER_RE =/^(your[-_]?(user(name)?|org|name)|user(name)?|owner|example|org|me)$/i;
/**
* Parse a repo URL into { host, owner, name } (gists return owner/id).
* @param {string} url
* @returns {{host: string, owner: string, name: string}|null}
*/
function parseRepoUrl(url) {
const match = String(url || '').match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/#?]+)/);
if (!match) return null;
return { host: match[1].toLowerCase(), owner: match[2], name: match[3].replace(/\.git$/, '') };
}
/**
* Canonical identity of an entry's target: lowercased host/owner/repo plus optional path.
* @param {object} data - Entry data
* @returns {string}
*/
function canonicalKey(data) {
const parsed = parseRepoUrl(data.repo);
const base = parsed
? `${parsed.host}/${parsed.owner}/${parsed.name}`.toLowerCase()
: String(data.repo || '').toLowerCase().replace(/\/+$/, '');
const sub = (data.path || '').replace(/^\/+|\/+$/g, '').toLowerCase();
return sub ? `${base}#${sub}` : base;
}
/**
* Load every entry in data/<category>/*.yaml.
* @param {object} [options]
* @param {string} [options.dataDir] - Override the data directory
* @returns {{entries: object[], errors: {file: string, message: string}[]}}
*/
function loadCatalog({ dataDir = DATA_DIR } = {}) {
const entries = [];
const errors = [];
for (const category of CATEGORIES) {
const dir = path.join(dataDir, category);
if (!fs.existsSync(dir)) continue;
for (const fileName of fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort()) {
const file = path.join('data', category, fileName);
try {
const data = yaml.load(fs.readFileSync(path.join(dir, fileName), 'utf8'));
if (!data || typeof data !== 'object' || Array.isArray(data)) {
errors.push({ file, message: 'file is empty or not a YAML mapping' });
continue;
}
entries.push({ category, id: fileName.replace(/\.yaml$/, ''), file, data });
} catch (err) {
errors.push({ file, message: `invalid YAML: ${err.reason || err.message}` });
}
}
}
return { entries, errors };
}
/**
* Rules for a single entry: schema, kind/category fit, filename, placeholder repos.
* @param {object} entry - { category, id, file, data }
* @returns {string[]} Error messages
*/
function checkEntry(entry) {
const messages = [];
const result = validateEntry(entry.data, entry.file);
if (!result.valid) {
for (const err of result.errors) {
if (err.keyword === 'pattern' && PATTERN_HINTS[err.path]) {
messages.push(PATTERN_HINTS[err.path]);
continue;
}
const detail = err.keyword === 'additionalProperties'
? `unknown field "${err.params.additionalProperty}"`
: err.keyword === 'enum'
? `${err.message}: ${err.params.allowedValues.join(', ')}`
: err.message;
messages.push(`${err.path === '/' ? '' : err.path + ': '}${detail}`);
}
}
if (!FILENAME_RE.test(path.basename(entry.file))) {
messages.push('filename must be kebab-case, e.g. my-plugin.yaml');
}
const allowed = CATEGORY_KINDS[entry.category];
if (entry.data.kind && allowed && !allowed.includes(entry.data.kind)) {
messages.push(`kind "${entry.data.kind}" does not belong in data/${entry.category}/ (allowed: ${allowed.join(', ')})`);
}
if (entry.category === 'plugins' && !entry.data.compat) {
messages.push('plugins must declare compat (v2, v1 or unknown); CI verifies it');
}
for (const field of ['name', 'tagline', 'description']) {
if (MOJIBAKE_RE.test(entry.data[field] || '')) {
messages.push(`${field} contains mis-encoded characters (e.g. "鈥?" or "—" instead of "—"); re-save the file as UTF-8`);
}
}
const parsed = parseRepoUrl(entry.data.repo);
if (parsed && PLACEHOLDER_OWNER_RE.test(parsed.owner)) {
messages.push(`repo owner "${parsed.owner}" looks like a placeholder`);
}
return messages;
}
/**
* Validate the whole catalog: per-entry rules plus cross-entry uniqueness.
* @param {object[]} entries
* @returns {{file: string, message: string}[]}
*/
function checkCatalog(entries) {
const problems = [];
const byKey = new Map();
for (const entry of entries) {
for (const message of checkEntry(entry)) {
problems.push({ file: entry.file, message });
}
if (!entry.data.repo) continue;
const key = canonicalKey(entry.data);
if (byKey.has(key)) {
problems.push({
file: entry.file,
message: `duplicate of ${byKey.get(key)} (same repo${entry.data.path ? ' and path' : ''}); use \`path\` for separate packages in one monorepo`
});
} else {
byKey.set(key, entry.file);
}
}
return problems;
}
/**
* Format problems for terminal output.
* @param {{file: string, message: string}[]} problems
* @returns {string}
*/
function formatProblems(problems) {
const byFile = new Map();
for (const { file, message } of problems) {
if (!byFile.has(file)) byFile.set(file, []);
byFile.get(file).push(message);
}
return [...byFile].map(([file, messages]) => `✗ ${file}\n${messages.map(m => ` - ${m}`).join('\n')}`).join('\n');
}
module.exports = {
CATEGORIES,
CATEGORY_KINDS,
DATA_DIR,
KINDS,
KIND_LABELS,
TOPICS,
TOPIC_LABELS,
V1_SUNSET,
canonicalKey,
checkCatalog,
checkEntry,
formatProblems,
loadCatalog,
parseRepoUrl
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Canonical YAML formatting for catalog entries.
*
* All bot-written and scaffolded entries go through dumpEntry() so quoting is always valid YAML
* (unquoted ": " in a tagline broke README generation for every entry before this existed).
*/
const yaml = require('js-yaml');
const KEY_ORDER = [
'name', 'repo', 'path', 'npm', 'homepage', 'tagline', 'description',
'kind', 'also', 'topics', 'compat', 'scope', 'tags', 'min_version', 'installation'
];
/**
* Order keys canonically; unknown keys are kept at the end so validation can report them.
* @param {object} data
* @returns {object}
*/
function orderEntry(data) {
const ordered = {};
for (const key of KEY_ORDER) {
if (data[key] !== undefined && data[key] !== null && !(Array.isArray(data[key]) && data[key].length === 0)) {
ordered[key] = data[key];
}
}
for (const key of Object.keys(data)) {
if (!(key in ordered) && !KEY_ORDER.includes(key) && !key.startsWith('_')) ordered[key] = data[key];
}
return ordered;
}
/**
* Serialize an entry to YAML text.
* @param {object} data
* @returns {string}
*/
function dumpEntry(data) {
const clean = orderEntry(data);
for (const key of ['name', 'tagline', 'description']) {
if (typeof clean[key] === 'string') clean[key] = clean[key].trim();
}
return yaml.dump(clean, { lineWidth: -1, noRefs: true, quotingType: '"' });
}
module.exports = { KEY_ORDER, dumpEntry, orderEntry };
+3 -37
View File
@@ -1,42 +1,8 @@
/**
* YAML file reading utilities for awesome-opencode
* Filename helpers for awesome-opencode entries.
* (Catalog loading lives in ./catalog.js.)
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { glob } = require('glob');
/**
* Read and parse a single YAML file
* @param {string} filePath - Path to YAML file
* @returns {object} Parsed YAML object
*/
function readYamlFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return yaml.load(content, { filename: filePath });
} catch (e) {
throw new Error(`Failed to parse ${filePath}: ${e.message}`);
}
}
/**
* Read all YAML files from a directory
* @param {string} dirPath - Directory containing YAML files
* @returns {Promise<object[]>} Array of parsed YAML objects with _filePath metadata
*/
async function readYamlDir(dirPath) {
const pattern = path.join(dirPath, '*.yaml').replace(/\\/g, '/');
const files = await glob(pattern);
return files.map(file => ({
...readYamlFile(file),
_filePath: file,
_fileName: path.basename(file, '.yaml')
}));
}
/**
* Convert a name to a filename-safe slug
* @param {string} name - Entry name
@@ -49,4 +15,4 @@ function slugify(name) {
.replace(/^-+|-+$/g, '');
}
module.exports = { readYamlFile, readYamlDir, slugify };
module.exports = { slugify };
+17 -93
View File
@@ -1,109 +1,33 @@
#!/usr/bin/env node
/**
* Validate YAML files against the schema
* Validate catalog entries.
* Usage: node scripts/validate.js [file1.yaml] [file2.yaml] ...
* If no files specified, validates all YAML files in data/
*
* Always loads the whole catalog so cross-entry rules (duplicate repos) are enforced; when files are
* given, only problems in those files are reported. Exits 1 on any problem, including unparseable YAML.
*/
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const yaml = require('js-yaml');
const { validateEntry, formatValidationErrors } = require('./utils/validation');
const { checkCatalog, formatProblems, loadCatalog } = require('./utils/catalog');
const DATA_DIR = path.join(__dirname, '../data');
const ROOT = path.join(__dirname, '..');
/**
* Get all YAML files in the data directory
* @returns {string[]} Array of file paths
*/
function getAllYamlFiles() {
const pattern = path.join(DATA_DIR, '**/*.yaml');
return glob.sync(pattern);
}
/**
* Load and parse a YAML file
* @param {string} filePath - Path to the YAML file
* @returns {object|null} Parsed YAML object or null on error
*/
function loadYamlFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return yaml.load(content);
} catch (err) {
console.error(`Error reading ${filePath}: ${err.message}`);
return null;
}
}
/**
* Validate a single file
* @param {string} filePath - Path to the YAML file
* @returns {boolean} True if valid, false otherwise
*/
function validateFile(filePath) {
const data = loadYamlFile(filePath);
if (!data) {
return false;
}
const result = validateEntry(data, filePath);
if (result.valid) {
console.log(`✓ ${filePath}`);
} else {
console.error(formatValidationErrors(result));
}
return result.valid;
}
/**
* Main validation function
*/
function main() {
const args = process.argv.slice(2);
let files = [];
const only = process.argv.slice(2)
.filter(f => f.endsWith('.yaml'))
.map(f => path.relative(ROOT, path.resolve(f)));
if (args.length > 0) {
// Validate specified files
files = args;
} else {
// Validate all YAML files in data/
files = getAllYamlFiles();
}
const { entries, errors } = loadCatalog();
const problems = [...errors, ...checkCatalog(entries)]
.filter(p => only.length === 0 || only.includes(p.file));
if (files.length === 0) {
console.log('No YAML files to validate.');
process.exit(0);
}
console.log(`Validating ${files.length} YAML file(s)...\n`);
let allValid = true;
let validatedCount = 0;
for (const file of files) {
if (fs.existsSync(file)) {
if (!validateFile(file)) {
allValid = false;
}
validatedCount++;
} else {
console.warn(`⚠ File not found: ${file}`);
allValid = false;
}
}
console.log('');
if (allValid) {
console.log(`✓ All ${validatedCount} file(s) passed validation.`);
process.exit(0);
} else {
console.error(`✗ Validation failed for ${validatedCount} file(s).`);
const checked = only.length || entries.length + errors.length;
if (problems.length > 0) {
console.error(formatProblems(problems));
console.error(`\n✗ ${new Set(problems.map(p => p.file)).size} of ${checked} file(s) failed validation.`);
process.exit(1);
}
console.log(`✓ All ${checked} file(s) passed validation.`);
}
main();