chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover plugins in marketplace.json whose upstream repo has moved past
|
||||
their pinned SHA, update the file in place, and emit a summary.
|
||||
|
||||
Adapted from claude-plugins-community-internal's discover_bumps.py for the
|
||||
single-file marketplace.json format used by claude-plugins-official.
|
||||
|
||||
Usage: discover_bumps.py [--plugin NAME] [--max N] [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
MARKETPLACE_PATH = ".claude-plugin/marketplace.json"
|
||||
|
||||
|
||||
def gh_api(path: str) -> Any:
|
||||
"""GET from the GitHub API. None on not-found; raises on other errors.
|
||||
|
||||
"Not found" covers both 404 (resource gone) and 422 "No commit found
|
||||
for SHA" (force-pushed away). Both mean the thing we asked for isn't
|
||||
there — treating them the same lets callers handle dead refs uniformly.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["gh", "api", path], capture_output=True, text=True
|
||||
)
|
||||
if r.returncode != 0:
|
||||
combined = r.stdout + r.stderr
|
||||
if any(s in combined for s in ("404", "Not Found", "No commit found")):
|
||||
return None
|
||||
raise RuntimeError(f"gh api {path}: {r.stderr.strip() or r.stdout.strip()}")
|
||||
return json.loads(r.stdout)
|
||||
|
||||
|
||||
def parse_github_repo(url: str) -> tuple[str, str] | None:
|
||||
"""Extract (owner, repo) from a URL or owner/repo shorthand."""
|
||||
# Full URL: https://github.com/owner/repo(.git)(/...)
|
||||
m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/|$)", url)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
# Shorthand: owner/repo
|
||||
m = re.match(r"^([\w.-]+)/([\w.-]+)$", url)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
return None
|
||||
|
||||
|
||||
def latest_sha(owner: str, repo: str, *, ref: str | None, path: str | None) -> str | None:
|
||||
"""Latest commit SHA for the repo, optionally scoped to a ref and/or path."""
|
||||
if path:
|
||||
# Scoped to a subdirectory — use the commits list endpoint with path filter.
|
||||
q = f"repos/{owner}/{repo}/commits?per_page=1&path={path}"
|
||||
if ref:
|
||||
q += f"&sha={ref}"
|
||||
commits = gh_api(q)
|
||||
if not commits:
|
||||
return None
|
||||
return commits[0]["sha"]
|
||||
# Whole repo — the single-ref endpoint is cheaper.
|
||||
if not ref:
|
||||
meta = gh_api(f"repos/{owner}/{repo}")
|
||||
if not meta:
|
||||
return None
|
||||
ref = meta["default_branch"]
|
||||
c = gh_api(f"repos/{owner}/{repo}/commits/{ref}")
|
||||
return c["sha"] if c else None
|
||||
|
||||
|
||||
def pinned_age_days(owner: str, repo: str, sha: str) -> int | None:
|
||||
"""Days since the pinned commit was authored. Used for oldest-first rotation."""
|
||||
c = gh_api(f"repos/{owner}/{repo}/commits/{sha}")
|
||||
if not c:
|
||||
return None
|
||||
dt = datetime.fromisoformat(
|
||||
c["commit"]["committer"]["date"].replace("Z", "+00:00")
|
||||
)
|
||||
return (datetime.now(timezone.utc) - dt).days
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--plugin", help="only check this plugin")
|
||||
ap.add_argument("--max", type=int, default=20, help="cap bumps emitted")
|
||||
ap.add_argument("--dry-run", action="store_true", help="don't write marketplace.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(MARKETPLACE_PATH) as f:
|
||||
marketplace = json.load(f)
|
||||
|
||||
plugins = marketplace.get("plugins", [])
|
||||
bumps: list[dict] = []
|
||||
dead: list[str] = []
|
||||
skipped_non_github = 0
|
||||
checked = 0
|
||||
|
||||
for plugin in plugins:
|
||||
name = plugin.get("name", "?")
|
||||
src = plugin.get("source")
|
||||
|
||||
# Only process object sources with a sha field
|
||||
if not isinstance(src, dict) or "sha" not in src:
|
||||
continue
|
||||
|
||||
# Filter to specific plugin if requested
|
||||
if args.plugin and name != args.plugin:
|
||||
continue
|
||||
|
||||
checked += 1
|
||||
kind = src.get("source")
|
||||
url = src.get("url", "")
|
||||
path = src.get("path")
|
||||
ref = src.get("ref")
|
||||
pinned = src.get("sha")
|
||||
|
||||
slug = parse_github_repo(url)
|
||||
if not slug:
|
||||
skipped_non_github += 1
|
||||
continue
|
||||
owner, repo = slug
|
||||
|
||||
try:
|
||||
latest = latest_sha(owner, repo, ref=ref, path=path)
|
||||
except RuntimeError as e:
|
||||
print(f"::warning::{name}: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
if latest is None:
|
||||
dead.append(f"{name} ({owner}/{repo})")
|
||||
continue
|
||||
|
||||
if latest == pinned:
|
||||
continue # up to date
|
||||
|
||||
# Age lookup for rotation — oldest-pinned first prevents starvation.
|
||||
try:
|
||||
age = pinned_age_days(owner, repo, pinned) if pinned else None
|
||||
except RuntimeError as e:
|
||||
print(f"::warning::{name}: age lookup failed: {e}", file=sys.stderr)
|
||||
age = None
|
||||
|
||||
bumps.append({
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"url": url,
|
||||
"path": path or "",
|
||||
"ref": ref or "",
|
||||
"old_sha": pinned or "",
|
||||
"new_sha": latest,
|
||||
"age_days": age if age is not None else 10**6,
|
||||
})
|
||||
|
||||
# Oldest-pinned first so nothing starves under the cap.
|
||||
bumps.sort(key=lambda b: -b["age_days"])
|
||||
emitted = bumps[: args.max]
|
||||
|
||||
# Apply bumps to marketplace data
|
||||
if emitted and not args.dry_run:
|
||||
bump_map = {b["name"]: b["new_sha"] for b in emitted}
|
||||
for plugin in plugins:
|
||||
name = plugin.get("name")
|
||||
src = plugin.get("source")
|
||||
if isinstance(src, dict) and name in bump_map:
|
||||
src["sha"] = bump_map[name]
|
||||
|
||||
with open(MARKETPLACE_PATH, "w") as f:
|
||||
json.dump(marketplace, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
# Write GitHub outputs
|
||||
out = os.environ.get("GITHUB_OUTPUT")
|
||||
if out:
|
||||
bumped_names = ",".join(b["name"] for b in emitted)
|
||||
with open(out, "a") as fh:
|
||||
fh.write(f"count={len(emitted)}\n")
|
||||
fh.write(f"bumped_names={bumped_names}\n")
|
||||
|
||||
# Write GitHub step summary
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary:
|
||||
with open(summary, "a") as fh:
|
||||
fh.write("## SHA Bump Discovery\n\n")
|
||||
fh.write(f"- Checked: {checked} SHA-pinned entries\n")
|
||||
fh.write(f"- Stale: {len(bumps)} (applying {len(emitted)}, cap {args.max})\n")
|
||||
if skipped_non_github:
|
||||
fh.write(f"- Skipped non-GitHub: {skipped_non_github}\n")
|
||||
if dead:
|
||||
fh.write(f"- **Dead upstream** ({len(dead)}): {', '.join(dead)}\n")
|
||||
if emitted:
|
||||
fh.write("\n| Plugin | Old | New | Age |\n|---|---|---|---|\n")
|
||||
for b in emitted:
|
||||
old = b["old_sha"][:8] if b["old_sha"] else "(unpinned)"
|
||||
fh.write(f"| {b['name']} | `{old}` | `{b['new_sha'][:8]}` | {b['age_days']}d |\n")
|
||||
|
||||
# Write PR body for the workflow to use
|
||||
pr_body_path = os.environ.get("PR_BODY_PATH", "/tmp/bump-pr-body.md")
|
||||
if emitted:
|
||||
with open(pr_body_path, "w") as fh:
|
||||
fh.write("Upstream repos moved. Bumping pinned SHAs so plugins track latest.\n\n")
|
||||
fh.write("| Plugin | Old | New | Upstream |\n")
|
||||
fh.write("|--------|-----|-----|----------|\n")
|
||||
for b in emitted:
|
||||
old = b["old_sha"][:8] if b["old_sha"] else "(unpinned)"
|
||||
slug_str = re.sub(r"https?://github\.com/", "", b["url"])
|
||||
slug_str = re.sub(r"\.git$", "", slug_str)
|
||||
compare = f"https://github.com/{slug_str}/compare/{b['old_sha'][:12]}...{b['new_sha'][:12]}"
|
||||
fh.write(f"| `{b['name']}` | `{old}` | `{b['new_sha'][:8]}` | [diff]({compare}) |\n")
|
||||
fh.write(f"\n---\n_Auto-generated by `bump-plugin-shas.yml` on {datetime.now(timezone.utc).strftime('%Y-%m-%d')}_\n")
|
||||
|
||||
# Console summary
|
||||
print(f"Checked {checked} SHA-pinned plugins", file=sys.stderr)
|
||||
print(f"Stale: {len(bumps)}, applying: {len(emitted)}", file=sys.stderr)
|
||||
if dead:
|
||||
print(f"Dead upstream: {', '.join(dead)}", file=sys.stderr)
|
||||
for b in emitted:
|
||||
old = b["old_sha"][:8] if b["old_sha"] else "unpinned"
|
||||
print(f" {b['name']}: {old} -> {b['new_sha'][:8]} ({b['age_days']}d)", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
// Shared logic for letting a NON-MEMBER pull request stay open and be reviewed, scoped to
|
||||
// the contributor's own already-listed plugin repo. No maintained allowlist, no individuals.
|
||||
//
|
||||
// Trust model: we do NOT verify the submitter's identity. We trust the SOURCE REPO. A PR is
|
||||
// in scope only if it ADDS marketplace.json entries whose source.url is a repo that ALREADY
|
||||
// backs a live entry in this marketplace (derived from the base marketplace.json), pinned to
|
||||
// a commit in that repo. Because the repo is org-controlled and the SHA pins to a real commit
|
||||
// there, the shipped code is the org's code regardless of who opened the PR. Merge still
|
||||
// requires CI + a maintainer approval.
|
||||
//
|
||||
// Used by:
|
||||
// - close-external-prs.yml (skip the auto-close when in scope)
|
||||
// - external-pr-scope-guard.yml (required status check: fail a non-member PR that is out of scope)
|
||||
//
|
||||
// Security: evaluate() reads base + head marketplace.json as DATA via the API and parses them;
|
||||
// it never checks out or executes head code.
|
||||
|
||||
const MARKETPLACE = '.claude-plugin/marketplace.json';
|
||||
|
||||
function normalizeRepo(u) {
|
||||
return String(u || '').trim().toLowerCase()
|
||||
.replace(/^git\+/, '')
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function pluginsByName(json) {
|
||||
const map = {};
|
||||
for (const p of (json && json.plugins) || []) { if (p && p.name) map[p.name] = p; }
|
||||
return map;
|
||||
}
|
||||
|
||||
// Repos that already back a live entry, derived from the base marketplace.json.
|
||||
function liveReposOf(base) {
|
||||
const s = new Set();
|
||||
for (const name of Object.keys(base)) {
|
||||
const u = base[name] && base[name].source && base[name].source.url;
|
||||
if (!u) continue;
|
||||
const r = normalizeRepo(u);
|
||||
if (r.split('/').length >= 3) s.add(r); // host/org/repo
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Pure decision over an already-computed diff. Returns { ok, problems, added, removed, modified }.
|
||||
// before = plugins at the MERGE-BASE (what head forked from), after = plugins at HEAD,
|
||||
// liveRepos = repos already live on the current base branch. Diffing before->after (not
|
||||
// base-tip->head) isolates THIS PR's changes; a stale fork no longer shows main's later
|
||||
// additions as phantom removals.
|
||||
function analyze({ changedFiles, before, after, liveRepos }) {
|
||||
const problems = [];
|
||||
|
||||
const off = changedFiles.filter(n => n !== MARKETPLACE);
|
||||
if (off.length) problems.push(`changes files other than ${MARKETPLACE}: ${off.join(', ')}`);
|
||||
|
||||
const baseNames = new Set(Object.keys(before));
|
||||
const headNames = new Set(Object.keys(after));
|
||||
const removed = [...baseNames].filter(n => !headNames.has(n));
|
||||
const added = [...headNames].filter(n => !baseNames.has(n));
|
||||
const modified = [...headNames].filter(
|
||||
n => baseNames.has(n) && JSON.stringify(before[n]) !== JSON.stringify(after[n])
|
||||
);
|
||||
|
||||
if (removed.length) problems.push(`removes existing entr${removed.length > 1 ? 'ies' : 'y'}: ${removed.join(', ')}`);
|
||||
if (modified.length) problems.push(`modifies existing entr${modified.length > 1 ? 'ies' : 'y'}: ${modified.join(', ')}`);
|
||||
if (!off.length && !added.length && !removed.length && !modified.length) {
|
||||
problems.push('makes no in-scope change (expected additions to marketplace.json)');
|
||||
}
|
||||
|
||||
for (const name of added) {
|
||||
const u = after[name] && after[name].source && after[name].source.url;
|
||||
if (!u) { problems.push(`added "${name}" has no source.url to validate`); continue; }
|
||||
const r = normalizeRepo(u);
|
||||
if (r.split('/').length < 3) { problems.push(`added "${name}" source.url ${u} is not a valid repo URL`); continue; }
|
||||
if (!liveRepos.has(r)) {
|
||||
problems.push(`added "${name}" points at ${u}, a repo with no existing live plugin in this marketplace`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: problems.length === 0, problems, added, removed, modified, liveRepoCount: liveRepos.size };
|
||||
}
|
||||
|
||||
async function readPlugins(github, owner, repo, ref) {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({ owner, repo, ref, path: MARKETPLACE });
|
||||
return pluginsByName(JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// API wrapper used by both workflows. Fetches the diff + base/head marketplace.json, delegates to analyze().
|
||||
async function evaluate({ github, context }) {
|
||||
const pr = context.payload.pull_request;
|
||||
const owner = context.repo.owner, repo = context.repo.repo;
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: pr.number, per_page: 100,
|
||||
});
|
||||
const changedFiles = files.map(f => f.filename);
|
||||
|
||||
// Diff THIS PR's changes (merge-base -> head), not base-tip -> head, so a fork that is
|
||||
// behind main doesn't show main's later additions as phantom removals.
|
||||
let mergeBaseSha = pr.base.sha;
|
||||
try {
|
||||
const cmp = await github.rest.repos.compareCommits({ owner, repo, base: pr.base.sha, head: pr.head.sha });
|
||||
if (cmp && cmp.data && cmp.data.merge_base_commit && cmp.data.merge_base_commit.sha) {
|
||||
mergeBaseSha = cmp.data.merge_base_commit.sha;
|
||||
}
|
||||
} catch (e) { /* fall back to base.sha */ }
|
||||
|
||||
const liveBase = await readPlugins(github, owner, repo, pr.base.sha); // current base branch (for "already live")
|
||||
const before = await readPlugins(github, owner, repo, mergeBaseSha); // what head forked from
|
||||
const after = await readPlugins(github, pr.head.repo.owner.login, pr.head.repo.name, pr.head.sha);
|
||||
if (liveBase === null || before === null || after === null) {
|
||||
return { ok: false, problems: ['could not read marketplace.json at base, merge-base, and/or head'], added: [], removed: [], modified: [] };
|
||||
}
|
||||
|
||||
return analyze({ changedFiles, before, after, liveRepos: liveReposOf(liveBase) });
|
||||
}
|
||||
|
||||
// Authors that are NOT subject to the external-contributor scope rules:
|
||||
// - the repo's own automation bot — its bump PRs legitimately MODIFY existing entries
|
||||
// (SHA bumps), which the additions-only external-contributor rule forbids; AND
|
||||
// - org members (write/admin).
|
||||
// Safe under pull_request_target: a fork PR cannot set its author to github-actions[bot]
|
||||
// (that login is only ever the org's own GITHUB_TOKEN workflow), and the member path is a
|
||||
// real permission lookup. Wrapped in try/catch because getCollaboratorPermissionLevel throws
|
||||
// for a non-collaborator/unknown user — without this, both callers would error the job rather
|
||||
// than fall through to scope evaluation.
|
||||
const EXEMPT_BOTS = new Set(['github-actions[bot]']);
|
||||
|
||||
async function isExemptAuthor({ github, context }) {
|
||||
const author = context.payload.pull_request.user.login;
|
||||
if (EXEMPT_BOTS.has(author)) {
|
||||
return { exempt: true, reason: `${author} is the trusted automation bot` };
|
||||
}
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, username: author,
|
||||
});
|
||||
if (['admin', 'write'].includes(data.permission)) {
|
||||
return { exempt: true, reason: `${author} is ${data.permission} (member)` };
|
||||
}
|
||||
} catch (e) {
|
||||
// not a collaborator / lookup failed → not exempt; fall through to scope evaluation
|
||||
}
|
||||
return { exempt: false };
|
||||
}
|
||||
|
||||
module.exports = { normalizeRepo, liveReposOf, analyze, readPlugins, evaluate, isExemptAuthor, MARKETPLACE };
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Validates YAML frontmatter in agent, skill, and command .md files.
|
||||
*
|
||||
* Usage:
|
||||
* bun validate-frontmatter.ts # scan current directory
|
||||
* bun validate-frontmatter.ts /path/to/dir # scan specific directory
|
||||
* bun validate-frontmatter.ts file1.md file2.md # validate specific files
|
||||
*/
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { basename, join, relative, resolve } from "path";
|
||||
|
||||
// Characters that require quoting in YAML values when unquoted:
|
||||
// {} [] flow indicators, * anchor/alias, & anchor, # comment,
|
||||
// ! tag, | > block scalars, % directive, @ ` reserved
|
||||
const YAML_SPECIAL_CHARS = /[{}[\]*&#!|>%@`]/;
|
||||
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/;
|
||||
|
||||
/**
|
||||
* Pre-process frontmatter text to quote values containing special YAML
|
||||
* characters. This allows glob patterns like **\/*.{ts,tsx} to parse.
|
||||
*/
|
||||
function quoteSpecialValues(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([a-zA-Z_-]+):\s+(.+)$/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
if (!key || !value) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
// Skip already-quoted values
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
if (YAML_SPECIAL_CHARS.test(value)) {
|
||||
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
result.push(`${key}: "${escaped}"`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
frontmatter: Record<string, unknown>;
|
||||
content: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown: string): ParseResult {
|
||||
const match = markdown.match(FRONTMATTER_REGEX);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
content: markdown,
|
||||
error: "No frontmatter found",
|
||||
};
|
||||
}
|
||||
|
||||
const frontmatterText = quoteSpecialValues(match[1] || "");
|
||||
const content = markdown.slice(match[0].length);
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(frontmatterText);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return { frontmatter: parsed as Record<string, unknown>, content };
|
||||
}
|
||||
return {
|
||||
frontmatter: {},
|
||||
content,
|
||||
error: `YAML parsed but result is not an object (got ${typeof parsed}${Array.isArray(parsed) ? " array" : ""})`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
content,
|
||||
error: `YAML parse failed: ${err instanceof Error ? err.message : err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
type FileType = "agent" | "skill" | "command";
|
||||
|
||||
interface ValidationIssue {
|
||||
level: "error" | "warning";
|
||||
message: string;
|
||||
}
|
||||
|
||||
function validateAgent(
|
||||
frontmatter: Record<string, unknown>
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (!frontmatter["name"] || typeof frontmatter["name"] !== "string") {
|
||||
issues.push({ level: "error", message: 'Missing required "name" field' });
|
||||
}
|
||||
if (
|
||||
!frontmatter["description"] ||
|
||||
typeof frontmatter["description"] !== "string"
|
||||
) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: 'Missing required "description" field',
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateSkill(
|
||||
frontmatter: Record<string, unknown>
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (!frontmatter["description"] && !frontmatter["when_to_use"]) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: 'Missing required "description" field',
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateCommand(
|
||||
frontmatter: Record<string, unknown>
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (
|
||||
!frontmatter["description"] ||
|
||||
typeof frontmatter["description"] !== "string"
|
||||
) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: 'Missing required "description" field',
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// --- File type detection ---
|
||||
|
||||
function detectFileType(filePath: string): FileType | null {
|
||||
// Only match agents/ and commands/ at the plugin root level, not nested
|
||||
// inside skill content (e.g. plugins/foo/skills/bar/agents/ is skill content,
|
||||
// not an agent definition).
|
||||
const inSkillContent = /\/skills\/[^/]+\//.test(filePath);
|
||||
if (filePath.includes("/agents/") && !inSkillContent) return "agent";
|
||||
if (filePath.includes("/skills/") && basename(filePath) === "SKILL.md")
|
||||
return "skill";
|
||||
if (filePath.includes("/commands/") && !inSkillContent) return "command";
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- File discovery ---
|
||||
|
||||
async function findMdFiles(
|
||||
baseDir: string
|
||||
): Promise<{ path: string; type: FileType }[]> {
|
||||
const results: { path: string; type: FileType }[] = [];
|
||||
|
||||
async function walk(dir: string) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else if (entry.name.endsWith(".md")) {
|
||||
const type = detectFileType(fullPath);
|
||||
if (type) {
|
||||
results.push({ path: fullPath, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(baseDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
let files: { path: string; type: FileType }[];
|
||||
let baseDir: string;
|
||||
|
||||
if (args.length > 0 && args.every((a) => a.endsWith(".md"))) {
|
||||
baseDir = process.cwd();
|
||||
files = [];
|
||||
for (const arg of args) {
|
||||
const fullPath = resolve(arg);
|
||||
const type = detectFileType(fullPath);
|
||||
if (type) {
|
||||
files.push({ path: fullPath, type });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
baseDir = args[0] || process.cwd();
|
||||
files = await findMdFiles(baseDir);
|
||||
}
|
||||
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
|
||||
console.log(`Validating ${files.length} frontmatter files...\n`);
|
||||
|
||||
for (const { path: filePath, type } of files) {
|
||||
const rel = relative(baseDir, filePath);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const result = parseFrontmatter(content);
|
||||
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (result.error) {
|
||||
issues.push({ level: "error", message: result.error });
|
||||
}
|
||||
|
||||
if (!result.error) {
|
||||
switch (type) {
|
||||
case "agent":
|
||||
issues.push(...validateAgent(result.frontmatter));
|
||||
break;
|
||||
case "skill":
|
||||
issues.push(...validateSkill(result.frontmatter));
|
||||
break;
|
||||
case "command":
|
||||
issues.push(...validateCommand(result.frontmatter));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.log(`${rel} (${type})`);
|
||||
for (const issue of issues) {
|
||||
const prefix = issue.level === "error" ? " ERROR" : " WARN ";
|
||||
console.log(`${prefix}: ${issue.message}`);
|
||||
if (issue.level === "error") totalErrors++;
|
||||
else totalWarnings++;
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("---");
|
||||
console.log(
|
||||
`Validated ${files.length} files: ${totalErrors} errors, ${totalWarnings} warnings`
|
||||
);
|
||||
|
||||
if (totalErrors > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user