Files
marketingskills/.github/scripts/sync-skills.js
T
Corey Haines e40125a746 fix: keep plugin.json version in sync with marketplace.json (closes #323) (#326)
Plugin.json's version field had been stuck at 1.9.0 across three releases
(v2.0, v2.0.1, v2.1.0) while marketplace.json was bumped each time. Claude
Code uses plugin.json's version for the update check, so
`claude plugin update marketing-skills@marketingskills` reported "already
at the latest version (1.9.0)" and refused to pull anything new — even
when main had real new content (sms skill, refreshed ai-seo / image / video).

Three changes:

1. .claude-plugin/plugin.json: bumped 1.9.0 → 2.1.0 to clear the current
   drift.

2. .github/scripts/sync-skills.js: added updatePluginVersion() that reads
   marketplace.json's metadata.version and copies it into plugin.json's
   version field. Idempotent — no-op when they already match.

3. .github/workflows/sync-skills.yml: added marketplace.json to the
   `paths` filter so the sync workflow runs whenever marketplace.json
   changes (previously it only ran on skills/** changes, which is why
   release commits that only bumped marketplace.json never triggered the
   sync). Also added .claude-plugin/plugin.json to the file_pattern so
   the bot commit captures the auto-bump.

Verified locally: script bumped 1.9.0 → 2.1.0, then a second run reported
"Everything is already in sync."

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:12:39 -07:00

212 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Sync marketplace.json and README.md with skills directory.
*
* Scans the skills/ directory for valid skills (directories containing SKILL.md)
* and updates marketplace.json and the README skills table to match.
*/
const fs = require("fs");
const path = require("path");
const SKILLS_DIR = "skills";
const MARKETPLACE_FILE = ".claude-plugin/marketplace.json";
const PLUGIN_FILE = ".claude-plugin/plugin.json";
const README_FILE = "README.md";
/**
* Parse YAML frontmatter from a SKILL.md file
*/
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const frontmatter = {};
const lines = match[1].split("\n");
for (const line of lines) {
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim();
let value = line.slice(colonIndex + 1).trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
return frontmatter;
}
/**
* Get all skills with their metadata
*/
function getSkillsWithMetadata() {
if (!fs.existsSync(SKILLS_DIR)) {
return [];
}
return fs
.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((entry) => {
if (!entry.isDirectory()) return false;
const skillFile = path.join(SKILLS_DIR, entry.name, "SKILL.md");
return fs.existsSync(skillFile);
})
.map((entry) => {
const skillFile = path.join(SKILLS_DIR, entry.name, "SKILL.md");
const content = fs.readFileSync(skillFile, "utf8");
const frontmatter = parseFrontmatter(content);
return {
dir: entry.name,
path: `./${SKILLS_DIR}/${entry.name}`,
name: frontmatter.name || entry.name,
description: frontmatter.description || "",
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Update skill count in description
*/
function updateSkillCount(description, count) {
return description.replace(/\d+ marketing skills/, `${count} marketing skills`);
}
/**
* Truncate description to a maximum length
*/
function truncateDescription(description, maxLength = 120) {
if (description.length <= maxLength) return description;
// Find last space before maxLength to avoid cutting words
const truncated = description.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(" ");
return truncated.slice(0, lastSpace) + "...";
}
/**
* Generate the skills table for README
*/
function generateSkillsTable(skills) {
const header = "| Skill | Description |\n|-------|-------------|";
const rows = skills.map((skill) => {
const link = `[${skill.name}](skills/${skill.dir}/)`;
const description = truncateDescription(skill.description);
return `| ${link} | ${description} |`;
});
return [header, ...rows].join("\n");
}
/**
* Update README.md with new skills table
*/
function updateReadme(skills) {
const content = fs.readFileSync(README_FILE, "utf8");
// Match content between skill list markers
const tableRegex = /(<!-- SKILLS:START -->\n)[\s\S]*?(\n<!-- SKILLS:END -->)/;
const newTable = generateSkillsTable(skills);
if (!tableRegex.test(content)) {
console.log("WARNING: Could not find skill markers in README.md");
return false;
}
const newContent = content.replace(tableRegex, `$1${newTable}$2`);
if (newContent === content) {
return false;
}
fs.writeFileSync(README_FILE, newContent);
return true;
}
/**
* Update marketplace.json — refresh the skill count in the plugin description
* and strip any `skills` array if present. Claude Code's plugin schema discovers
* skills via the `skills/` directory; the explicit array fails validation, so
* this script must never (re-)introduce it.
*/
function updateMarketplace(skills) {
const marketplace = JSON.parse(fs.readFileSync(MARKETPLACE_FILE, "utf8"));
const plugin = marketplace.plugins[0];
const oldDescription = plugin.description;
const newDescription = updateSkillCount(plugin.description, skills.length);
const hadStaleSkillsArray = "skills" in plugin;
if (newDescription === oldDescription && !hadStaleSkillsArray) {
return { updated: false };
}
plugin.description = newDescription;
delete plugin.skills;
fs.writeFileSync(MARKETPLACE_FILE, JSON.stringify(marketplace, null, 2) + "\n");
return { updated: true, removedSkillsArray: hadStaleSkillsArray };
}
/**
* Update plugin.json's `version` field to match marketplace.json's
* `metadata.version`. Claude Code uses plugin.json's version for the update
* check (`claude plugin update`); if it drifts from marketplace.json the
* update path silently breaks.
*/
function updatePluginVersion() {
if (!fs.existsSync(PLUGIN_FILE)) return { updated: false };
const marketplace = JSON.parse(fs.readFileSync(MARKETPLACE_FILE, "utf8"));
const plugin = JSON.parse(fs.readFileSync(PLUGIN_FILE, "utf8"));
const marketplaceVersion = marketplace.metadata && marketplace.metadata.version;
if (!marketplaceVersion) return { updated: false };
if (plugin.version === marketplaceVersion) return { updated: false };
const oldVersion = plugin.version;
plugin.version = marketplaceVersion;
fs.writeFileSync(PLUGIN_FILE, JSON.stringify(plugin, null, 2) + "\n");
return { updated: true, oldVersion, newVersion: marketplaceVersion };
}
function main() {
const skills = getSkillsWithMetadata();
const marketplaceResult = updateMarketplace(skills);
const readmeUpdated = updateReadme(skills);
const pluginResult = updatePluginVersion();
if (!marketplaceResult.updated && !readmeUpdated && !pluginResult.updated) {
console.log("Everything is already in sync");
return;
}
if (marketplaceResult.updated) {
if (marketplaceResult.removedSkillsArray) {
console.log("Stripped stale `skills` array from marketplace.json");
}
console.log(`Updated marketplace.json (${skills.length} skills)`);
}
if (pluginResult.updated) {
console.log(`Bumped plugin.json version: ${pluginResult.oldVersion}${pluginResult.newVersion}`);
}
if (readmeUpdated) {
console.log("Updated README.md skills table");
}
}
main();