Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ jobs:
# Upload skill-menu.json (used by the wizard to discover available skills)
echo "Uploading skill-menu.json..."
gh release upload "$RELEASE_TAG" dist/skills/skill-menu.json --clobber
# Upload each bundled group (one JSON of every variant, in place of per-variant ZIPs)
for file in dist/skills/*.json; do
filename=$(basename "$file")
case "$filename" in manifest.json|skill-menu.json) continue ;; esac
echo "Uploading $filename..."
gh release upload "$RELEASE_TAG" "$file" --clobber
done
# Upload reference docs (used by the wizard for runtime-specific overrides)
for file in dist/skills/*.md; do
[ -f "$file" ] || continue
Expand Down
1 change: 1 addition & 0 deletions context/skills/integration-v2/capture/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ tags: [orchestrator, capture]
cli:
role: internal
variants_from: integration
packaging: bundle
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ tags: [orchestrator, error-tracking]
cli:
role: internal
variants_from: integration
packaging: bundle
1 change: 1 addition & 0 deletions context/skills/integration-v2/init/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ tags: [orchestrator, init]
cli:
role: internal
variants_from: integration
packaging: bundle
1 change: 1 addition & 0 deletions context/skills/integration-v2/install/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ tags: [orchestrator, install]
cli:
role: internal
variants_from: integration
packaging: bundle
17 changes: 15 additions & 2 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
loadDocsConfig,
zipSkillToBuffer,
createBundledArchive,
writeBundles,
writeManifestAndMenu,
} from './lib/build-phases.js';

Expand Down Expand Up @@ -62,6 +63,8 @@ async function main() {
console.log('\nCreating skill ZIPs...');
const skillZips = {};
for (const skill of skills) {
// A bundled variant ships inside its group's JSON, not as its own zip.
if (skill.bundle) continue;
const skillDir = path.join(tempDir, skill.id);
const buffer = await zipSkillToBuffer(skillDir);
const filename = `${skill.id}.zip`;
Expand All @@ -70,6 +73,12 @@ async function main() {
fs.writeFileSync(path.join(skillsDir, filename), buffer);
console.log(` ✓ ${filename} (${(buffer.length / 1024).toFixed(1)} KB)`);
}
const bundleFiles = writeBundles({
skills,
sourceDir: tempDir,
skillsDir,
log: console.log,
});

console.log('\nGenerating marketplace plugins...');
const marketplaceResult = generateMarketplace({
Expand Down Expand Up @@ -102,7 +111,8 @@ async function main() {
console.log(`\n ✓ manifest.json`);

const skillMenu = JSON.parse(fs.readFileSync(path.join(skillsDir, 'skill-menu.json'), 'utf8'));
console.log(` ✓ skill-menu.json (${Object.keys(skillMenu.categories).length} categories, ${skills.length} skills)`);
const menuEntries = Object.values(skillMenu.categories).flat().length;
console.log(` ✓ skill-menu.json (${Object.keys(skillMenu.categories).length} categories, ${menuEntries} entries)`);

console.log('\nBuilding agent prompts...');
const agentsResult = buildAgents({
Expand All @@ -128,7 +138,10 @@ async function main() {

console.log('\nCreating bundled archive...');
const bundlePath = path.join(distDir, 'skills-mcp-resources.zip');
const bundleSize = await createBundledArchive(bundlePath, manifest, skillZips);
const bundleSize = await createBundledArchive(bundlePath, manifest, {
...skillZips,
...bundleFiles,
});
console.log(` ✓ skills-mcp-resources.zip (${(bundleSize / 1024).toFixed(1)} KB)`);

console.log('\n' + '='.repeat(50));
Expand Down
16 changes: 12 additions & 4 deletions scripts/dev-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,17 @@ function serveFile(res, filePath, contentType, attachmentName = null) {

function createServer() {
const server = http.createServer((req, res) => {
const skillMatch = req.url?.match(/^\/skills\/(.+\.zip)$/);
// A skill is served as its own zip, or — for a bundled group — as one JSON of every variant.
const skillMatch = req.url?.match(/^\/skills\/(.+\.(zip|json))$/);
if (skillMatch) {
const skillFile = skillMatch[1];
serveFile(res, path.join(skillsDir, skillFile), 'application/zip', skillFile);
const [skillFile, ext] = [skillMatch[1], skillMatch[2]];
const isZip = ext === 'zip';
serveFile(
res,
path.join(skillsDir, skillFile),
isZip ? 'application/zip' : 'application/json',
isZip ? skillFile : undefined,
);
return;
}

Expand Down Expand Up @@ -316,13 +323,14 @@ function createServer() {
}

res.writeHead(404, { 'Content-Type': 'text/plain', ...NO_CACHE_HEADERS });
res.end('Not found. Available endpoints:\n /skill-menu.json\n /skills-mcp-resources.zip\n /skills/{id}.zip\n /agent-menu.json\n /agents/{flow}/{type}.md');
res.end('Not found. Available endpoints:\n /skill-menu.json\n /skills-mcp-resources.zip\n /skills/{id}.zip\n /skills/{group}.json\n /agent-menu.json\n /agents/{flow}/{type}.md');
});

server.listen(PORT, () => {
console.log('\n🚀 Development server started!');
console.log(`\n📍 Skills bundle: http://localhost:${PORT}/skills-mcp-resources.zip`);
console.log(`📍 Individual skill: http://localhost:${PORT}/skills/{id}.zip`);
console.log(`📦 Bundled group: http://localhost:${PORT}/skills/{group}.json`);
console.log(`📋 Skills menu: http://localhost:${PORT}/skill-menu.json`);
console.log(`🤖 Agent prompt: http://localhost:${PORT}/agents/{flow}/{type}.md`);
console.log(`📋 Agents menu: http://localhost:${PORT}/agent-menu.json`);
Expand Down
94 changes: 87 additions & 7 deletions scripts/lib/build-phases.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,36 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers
fs.writeFileSync(path.join(skillsDir, 'manifest.json'), JSON.stringify(manifest, null, 2));

const skillsByCategory = {};
const bundleEntries = new Map();
for (const skill of allSkills) {
const cat = skill.group;
if (!skillsByCategory[cat]) skillsByCategory[cat] = [];
const group = skill.group.replace(/\//g, '-');
const url = manifest.resources.find(r => r.id === skill.id)?.downloadUrl;
// A bundled group publishes one entry; the consumer picks the variant inside.
if (skill.bundle) {
let entry = bundleEntries.get(group);
if (!entry) {
entry = {
id: group,
name: skill.name,
group,
bundle: true,
variants: [],
downloadUrl: url?.replace(/\/[^/]+\.zip$/, `/${group}.json`),
};
bundleEntries.set(group, entry);
skillsByCategory[cat].push(entry);
}
// Each variant keeps its own id/framework/default so consumers resolve it exactly as they would a per-skill zip.
const variant = { id: skill.id };
if (skill.framework) variant.framework = skill.framework;
if (skill.default) variant.default = true;
entry.variants.push(variant);
continue;
}
// group/framework/default let consumers resolve a bare skill id + framework by exact match.
const entry = {
id: skill.id,
name: skill.name,
group: skill.group.replace(/\//g, '-'),
downloadUrl: manifest.resources.find(r => r.id === skill.id)?.downloadUrl,
};
const entry = { id: skill.id, name: skill.name, group, downloadUrl: url };
if (skill.framework) entry.framework = skill.framework;
if (skill.default) entry.default = true;
skillsByCategory[cat].push(entry);
Expand Down Expand Up @@ -295,11 +315,67 @@ function validateDefault(entries) {
* Delete `dist/skills/<id>.zip` files whose IDs are no longer in `allSkills`.
* Returns the array of removed filenames.
*/
/** Read a built skill dir into { relativePath: contents }. */
function readSkillFiles(dir) {
const files = {};
for (const entry of fs.readdirSync(dir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile()) continue;
const abs = path.join(entry.parentPath ?? entry.path, entry.name);
files[path.relative(dir, abs)] = fs.readFileSync(abs, 'utf8');
}
return files;
}

/**
* Write each bundled group as one `<group>.json` holding every variant's files,
* read from the built skill dirs under `sourceDir`.
*
* `merge` keeps the variants already on disk and replaces only the ones passed
* in — the dev server rebuilds a single variant at a time and must not drop the
* other 36. A full build writes fresh so a deleted variant cannot survive.
*
* Returns { filename: Buffer } so the caller can add the bundles to the archive.
*/
function writeBundles({ skills, sourceDir, skillsDir, merge = false, log = () => {} }) {
const groups = {};
for (const skill of skills) {
if (!skill.bundle) continue;
(groups[skill.group.replace(/\//g, '-')] ??= []).push(skill);
}

const artifacts = {};
for (const [group, variants] of Object.entries(groups)) {
const file = path.join(skillsDir, `${group}.json`);
const existing =
merge && fs.existsSync(file)
? JSON.parse(fs.readFileSync(file, 'utf8')).variants
: {};
const bundle = { id: group, variants: { ...existing } };
const seen = new Set();
for (const skill of variants) {
// Two variants resolving to one short id would silently overwrite each other.
if (seen.has(skill.shortId)) {
throw new Error(`Bundle "${group}" has duplicate variant id "${skill.shortId}"`);
}
seen.add(skill.shortId);
bundle.variants[skill.shortId] = readSkillFiles(path.join(sourceDir, skill.id));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we validate IDs per bundle? I think if any duplicates slip through they could overwrite

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh good call, there should be

}
const json = JSON.stringify(bundle);
fs.writeFileSync(file, json);
artifacts[`${group}.json`] = Buffer.from(json);
log(
` ✓ ${group}.json (${Object.keys(bundle.variants).length} variants, ${(json.length / 1024).toFixed(1)} KB)`,
);
}
return artifacts;
}

function reconcileOrphans({ allSkills, distDir, log = () => {} }) {
const skillsDir = path.join(distDir, 'skills');
if (!fs.existsSync(skillsDir)) return [];

const validIds = new Set(allSkills.map(s => s.id));
// A bundled skill has no zip of its own, so a leftover one from an earlier build is an orphan.
const validIds = new Set(allSkills.filter(s => !s.bundle).map(s => s.id));
const removed = [];

for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
Expand Down Expand Up @@ -352,12 +428,15 @@ async function partialRebuild({
});

for (const skill of rebuiltSkills) {
if (skill.bundle) continue;
const skillTempDir = path.join(tempDir, skill.id);
const buffer = await zipSkillToBuffer(skillTempDir);
const filename = `${skill.id}.zip`;
fs.writeFileSync(path.join(skillsDir, filename), buffer);
log(` ✓ ${filename} (${(buffer.length / 1024).toFixed(1)} KB)`);
}
// Patch the rebuilt variants into their bundle, leaving the group's untouched variants alone.
writeBundles({ skills: rebuiltSkills, sourceDir: tempDir, skillsDir, merge: true, log });

writeManifestAndMenu({ allSkills, docContents, distDir, configDir, version });
reconcileOrphans({ allSkills, distDir, log });
Expand All @@ -374,6 +453,7 @@ export {
loadDocContentsFromManifest,
zipSkillToBuffer,
createBundledArchive,
writeBundles,
generateManifest,
generateCliEntries,
writeManifestAndMenu,
Expand Down
6 changes: 6 additions & 0 deletions scripts/lib/skill-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ function expandSkillGroups(config, configDir) {
const baseSharedDocs = group.shared_docs || [];
const baseExamplePaths = normalizeExamplePaths(group.example_paths);
const baseCli = parseCliBlock(group.cli, `Skill group "${key}"`);
// `packaging: bundle` ships the group as one JSON whose variants live inside.
const bundled = group.packaging === 'bundle';

// Category is the first segment of the composite key, or an explicit override
const category = group.category || key.split('/')[0];
Expand Down Expand Up @@ -334,6 +336,7 @@ function expandSkillGroups(config, configDir) {
_examplePaths: [...baseExamplePaths, ...normalizeExamplePaths(variation.example_paths)],
_references: group.references || null,
_group: key,
_bundle: bundled,
_cli: cli,
});
}
Expand Down Expand Up @@ -811,6 +814,9 @@ function serializeSkill(s) {
if (s.default) {
result.default = true;
}
if (s._bundle) {
result.bundle = true;
}
if (s._cli) {
result.cli = s._cli;
}
Expand Down
Loading
Loading