Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Run `npm run build` to generate the release artifacts:
|--------|-------------|
| `dist/skills/<id>.zip` | Per-skill bundles (SKILL.md + references + shared docs) |
| `dist/skills/manifest.json` | Versioned manifest of every bundled skill and its download URL. It ships inside `skills-mcp-resources.zip`, not as its own release asset |
| `dist/skills/skill-menu.json` | Category groupings and `cliEntries` — the wizard's command lookup table |
| `dist/skills/skill-menu.json` | Category groupings and `cliEntries` — the wizard's command lookup table. Each entry and bundle variant carries `title`, `description` and `tags`; the tags add `framework:<framework>` for the framework it serves, its `group` and (when set) `default` to the skill's own |
| `dist/agents/agent-menu.json` | Orchestrator flows and the task prompts in each one |
| `dist/skills-mcp-resources.zip` | One bundle of `manifest.json` and every skill ZIP, for the MCP server |

Expand Down
4 changes: 4 additions & 0 deletions context/agents/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Agent prompts

> **The wizard has its own copy.** It bundles these prompts in its own repo,
> at `src/programs/<id>/agent/`. This copy is published for wizard versions
> that fetch their prompts. Make a prompt change in both places.

One `<flow>/<type>.md` per orchestrator task — the WHAT of the task. The folder
is the flow: the wizard's registry is scoped per flow, so audit or migration
agent sets get sibling folders. The frontmatter carries the artifacts the
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:skills": "vitest run scripts/lib/tests",
"test:skills:watch": "vitest scripts/lib/tests",
"test": "vitest run scripts/plugins/tests scripts/lib/tests",
"check:bundle-diff": "node scripts/check-bundle-diff.js",
"security-scan": "node scripts/scan-warlock.js",
"security-scan:skills": "node scripts/scan-warlock.js dist/skills"
},
Expand Down
5 changes: 5 additions & 0 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ async function main() {
version: BUILD_VERSION,
});

// Non-blocking: the wizard selects skills by tag, so an untagged skill is only reachable by id.
for (const skill of skills) {
if (!skill.tags?.length) console.warn(` [WARN] Skill "${skill.id}" has no tags`);
}

const docEntries = loadDocsConfig(configDir);

console.log('\nCreating skill ZIPs...');
Expand Down
114 changes: 114 additions & 0 deletions scripts/check-bundle-diff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env node

/**
* Fail when a build's output differs from a baseline build in anything but the skill-menu's title/description/tags.
* Baseline from main: `git worktree add /tmp/cm-main main && (cd /tmp/cm-main && npm install && npm run build)`,
* then `npm run build && npm run check:bundle-diff -- /tmp/cm-main/dist [dist]`.
*/

import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';

const MENU_METADATA = ['title', 'description', 'tags'];

const sha = buf => crypto.createHash('sha256').update(buf).digest('hex');

/** Entry name -> content buffer for a zip, read from its central directory (zip mtimes are ignored). */
function readZip(buf) {
let eocd = buf.length - 22;
while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('not a zip archive');
const count = buf.readUInt16LE(eocd + 10);
let offset = buf.readUInt32LE(eocd + 16);
const entries = {};
for (let i = 0; i < count; i++) {
const method = buf.readUInt16LE(offset + 10);
const compressedSize = buf.readUInt32LE(offset + 20);
const nameLength = buf.readUInt16LE(offset + 28);
const extraLength = buf.readUInt16LE(offset + 30);
const commentLength = buf.readUInt16LE(offset + 32);
const localOffset = buf.readUInt32LE(offset + 42);
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLength);
offset += 46 + nameLength + extraLength + commentLength;
if (name.endsWith('/')) continue;
const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
const data = buf.subarray(dataStart, dataStart + compressedSize);
entries[name] = method === 8 ? zlib.inflateRawSync(data) : Buffer.from(data);
}
return entries;
}

function stripMenuMetadata(node) {
if (Array.isArray(node)) return node.map(stripMenuMetadata);
if (!node || typeof node !== 'object') return node;
return Object.fromEntries(
Object.entries(node).filter(([k]) => !MENU_METADATA.includes(k)).map(([k, v]) => [k, stripMenuMetadata(v)]),
);
}

/** Comparable digest of one file: zips by their entries, manifests without the build timestamp, the menu without its metadata fields. */
function digest(name, buf) {
const base = path.basename(name);
if (base.endsWith('.zip')) {
const entries = readZip(buf);
return Object.fromEntries(Object.keys(entries).sort().map(n => [n, digest(n, entries[n])]));
}
if (base === 'manifest.json') {
const { buildTimestamp: _ts, ...rest } = JSON.parse(buf.toString('utf8'));
return sha(JSON.stringify(rest));
}
if (base === 'skill-menu.json') {
const menu = JSON.parse(buf.toString('utf8'));
menu.categories = stripMenuMetadata(menu.categories);
return sha(JSON.stringify(menu));
}
return sha(buf);
}

function listFiles(root) {
return fs.readdirSync(root, { recursive: true })
.filter(rel => fs.statSync(path.join(root, rel)).isFile())
.map(rel => rel.split(path.sep).join('/'))
.sort();
}

function diff(a, b, at, out) {
if (typeof a === 'string' || typeof b === 'string') {
if (a !== b) out.push(at);
return;
}
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
if (!(key in a)) out.push(`${at}!${key} (added)`);
else if (!(key in b)) out.push(`${at}!${key} (removed)`);
else diff(a[key], b[key], `${at}!${key}`, out);
}
}

const [baselineDir, candidateDir = 'dist'] = process.argv.slice(2);
if (!baselineDir) {
console.error('Usage: node scripts/check-bundle-diff.js <baseline-dist> [candidate-dist]');
process.exit(2);
}

const baseline = listFiles(baselineDir);
const candidate = listFiles(candidateDir);
const changes = [];
for (const rel of new Set([...baseline, ...candidate])) {
if (!baseline.includes(rel)) { changes.push(`${rel} (added)`); continue; }
if (!candidate.includes(rel)) { changes.push(`${rel} (removed)`); continue; }
diff(
digest(rel, fs.readFileSync(path.join(baselineDir, rel))),
digest(rel, fs.readFileSync(path.join(candidateDir, rel))),
rel,
changes,
);
}

if (changes.length > 0) {
console.error(`Build output differs from ${baselineDir} beyond the skill-menu metadata fields:`);
for (const change of changes) console.error(` - ${change}`);
process.exit(1);
}
console.log(`No differences beyond skill-menu ${MENU_METADATA.join('/')} (${candidate.length} files compared).`);
34 changes: 34 additions & 0 deletions scripts/lib/build-phases.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,35 @@ function generateManifest({ resources, uriSchema, version, docContents = {} }) {
};
}

/** The tag naming the framework a variant serves. Prefixed, because content tags such as `python` describe a skill, not what it serves. */
const FRAMEWORK_TAG_PREFIX = 'framework:';

/**
* Menu tags: the skill's own tags plus its served framework, group and `default`.
* Added here only, so the tags commandments are selected by stay untouched.
*/
function menuTags(tags, { framework, group, isDefault }) {
return [
...new Set(
[
...(tags || []),
framework ? `${FRAMEWORK_TAG_PREFIX}${framework}` : null,
group,
isDefault ? 'default' : null,
].filter(Boolean),
),
];
}

/** The title, description and tags a menu entry or bundle variant publishes, matching manifest.json's name/description. */
function menuMetadata(skill, group) {
return {
title: skill.displayName ?? skill.name,
description: skill.description,
tags: menuTags(skill.tags, { framework: skill.framework, group, isDefault: skill.default }),
};
}

/**
* Compose the manifest + skill-menu and write both to dist/skills/.
* Returns the manifest object so callers (notably full builds) can pass it
Expand Down Expand Up @@ -213,6 +242,9 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers
variants: [],
// Bundled variants are absent from the manifest, so the group's URL is built from the base.
downloadUrl: `${resolveBaseDownloadUrl(version)}/${group}.json`,
title: skill.name,
description: skill.name,
tags: menuTags(skill.groupTags, { group }),
};
bundleEntries.set(group, entry);
skillsByCategory[cat].push(entry);
Expand All @@ -221,13 +253,15 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers
const variant = { id: skill.id };
if (skill.framework) variant.framework = skill.framework;
if (skill.default) variant.default = true;
Object.assign(variant, menuMetadata(skill, group));
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, downloadUrl: url };
if (skill.framework) entry.framework = skill.framework;
if (skill.default) entry.default = true;
Object.assign(entry, menuMetadata(skill, group));
skillsByCategory[cat].push(entry);
}

Expand Down
2 changes: 2 additions & 0 deletions scripts/lib/skill-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ function expandSkillGroups(config, configDir) {
_references: group.references || null,
_group: key,
_bundle: bundled,
_groupTags: baseTags,
_cli: cli,
});
}
Expand Down Expand Up @@ -821,6 +822,7 @@ function serializeSkill(s) {
}
if (s._bundle) {
result.bundle = true;
result.groupTags = s._groupTags || [];
}
if (s._cli) {
result.cli = s._cli;
Expand Down
Loading
Loading