mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-01 05:55:18 +02:00
Adds a curated, Pi-native, skills+prompts-only profile at pi/core/ for downstream packagers that mirror GitHub Releases. - manifests/pi-core.json: explicit include lists, per-item exclusion reasons, curation rules, safety allowlists; every root skill and command must be classified. - scripts/build-pi-core.js regenerates pi/core deterministically (package.json from VERSION, LICENSE, README.md, CURATION.md, skills/, commands/); --check fails CI on drift. council is renamed ecc-council inside pi/core only. - Safety checks: no callable endpoints outside the allowlist, no npx/curl|sh/pip install, no secrets, no absolute home paths, no symlinks, valid frontmatter, no duplicate names. - CI: build + drift check and an offline Pi CLI load test of pi/core. - Release: VERSION, package.json and pi/core/package.json at 2.2.2, CHANGELOG, tag-triggered release verification, and a two-week cadence in CONTRIBUTING.md. pi/core: 123 of 293 skills and 24 of 94 commands; 35,006 characters of skill description text.
391 lines
16 KiB
JavaScript
Executable File
391 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Regenerate pi/core/ deterministically from manifests/pi-core.json.
|
|
*
|
|
* The profile is a curated, Pi-native, skills+prompts-only package:
|
|
* pi/core/package.json - { name: "ecc-pi-core", version: <root VERSION>, license: MIT,
|
|
* keywords: ["pi-package", "skills"],
|
|
* pi: { skills: ["./skills"], prompts: ["./commands"] } }
|
|
* pi/core/LICENSE - copy of the root LICENSE
|
|
* pi/core/README.md - short generated overview
|
|
* pi/core/CURATION.md - every excluded skill/command with its reason
|
|
* pi/core/skills/ - curated skill directories (council renamed to ecc-council)
|
|
* pi/core/commands/ - curated prompt command files
|
|
*
|
|
* Safety checks (build fails if violated; semantics documented in
|
|
* manifests/pi-core.json safety.semantics):
|
|
* - no callable http(s) endpoints (documentation links allowed via the host
|
|
* allowlist in manifests/pi-core.json, plus localhost/example placeholders
|
|
* and non-FQDN internal hostnames)
|
|
* - no runtime download-and-run forms: pipe-to-shell (curl|sh, wget|sh) or
|
|
* fetch-and-run npx (-y/--yes, pkg@version, create-*, degit, skills add);
|
|
* skills whose own operation downloads tooling are excluded in the manifest
|
|
* - no secrets or tokens
|
|
* - no absolute per-user home paths (/Users/..., /home/..., C:\Users\...)
|
|
* - no symlinks
|
|
* - every SKILL.md has frontmatter with name == directory name and a description
|
|
* - no duplicate skill names
|
|
*
|
|
* Scoped exceptions for incidental mentions (anti-pattern warnings, detection
|
|
* examples) live in manifests/pi-core.json safety.scanAllowlist with a reason.
|
|
*
|
|
* Usage: node scripts/build-pi-core.js [--check]
|
|
* --check rebuild and verify pi/core is already up to date (exit 1 on drift)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const MANIFEST_PATH = path.join(ROOT, 'manifests', 'pi-core.json');
|
|
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
|
|
const PROFILE_DIR = path.join(ROOT, manifest.profile.dir);
|
|
const SKILLS_SRC = path.join(ROOT, 'skills');
|
|
const COMMANDS_SRC = path.join(ROOT, 'commands');
|
|
const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
|
|
const CHECK_MODE = process.argv.includes('--check');
|
|
|
|
const violations = [];
|
|
function fail(msg) {
|
|
violations.push(msg);
|
|
}
|
|
|
|
// ---------- partition completeness -----------------------------------------
|
|
// Every on-disk skill dir and command file must be classified exactly once in
|
|
// the manifest, so new content cannot silently bypass curation.
|
|
{
|
|
const onDiskSkills = fs.readdirSync(SKILLS_SRC, { withFileTypes: true })
|
|
.filter(e => e.isDirectory()).map(e => e.name);
|
|
const known = new Set([...manifest.skills.include, ...Object.keys(manifest.skills.exclude)]);
|
|
for (const dir of onDiskSkills) {
|
|
if (!known.has(dir)) fail(`skills/${dir} is not classified in manifests/pi-core.json`);
|
|
}
|
|
for (const name of known) {
|
|
if (!onDiskSkills.includes(name)) fail(`manifests/pi-core.json references missing skills/${name}`);
|
|
}
|
|
const onDiskCommands = fs.readdirSync(COMMANDS_SRC).filter(f => f.endsWith('.md'));
|
|
const knownCmd = new Set([...manifest.commands.include, ...Object.keys(manifest.commands.exclude)]);
|
|
for (const f of onDiskCommands) {
|
|
if (!knownCmd.has(f)) fail(`commands/${f} is not classified in manifests/pi-core.json`);
|
|
}
|
|
for (const f of knownCmd) {
|
|
if (!onDiskCommands.includes(f)) fail(`manifests/pi-core.json references missing commands/${f}`);
|
|
}
|
|
}
|
|
|
|
// ---------- deterministic copy ----------------------------------------------
|
|
/** Recursively list files under dir, sorted; reject symlinks. */
|
|
function listFiles(dir, base) {
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const full = path.join(dir, entry.name);
|
|
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
if (entry.isSymbolicLink()) {
|
|
fail(`symlink found in profile source: ${rel}`);
|
|
} else if (entry.isDirectory()) {
|
|
out.push(...listFiles(full, rel));
|
|
} else if (entry.isFile()) {
|
|
out.push({ full, rel });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function copyFile(src, dest) {
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.copyFileSync(src, dest);
|
|
}
|
|
|
|
/** Parse YAML frontmatter minimally: returns { name, description } or null. */
|
|
function parseFrontmatter(text) {
|
|
const m = text.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
if (!m) return null;
|
|
const name = (m[1].match(/^name:\s*["']?(.+?)["']?\s*$/m) || [])[1];
|
|
const description = (m[1].match(/^description:\s*["']?([\s\S]+?)["']?\s*$/m) || [])[1];
|
|
return { name: name || '', description: description || '', raw: m[1] };
|
|
}
|
|
|
|
// ---------- rebuild pi/core --------------------------------------------------
|
|
const tmpDir = path.join(ROOT, 'pi', '.core-build-tmp');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
const rename = manifest.skills.rename || {};
|
|
const skillDescriptions = [];
|
|
const seenNames = new Map();
|
|
|
|
for (const dirName of manifest.skills.include) {
|
|
const srcDir = path.join(SKILLS_SRC, dirName);
|
|
const outName = rename[dirName] || dirName;
|
|
const destDir = path.join(tmpDir, 'skills', outName);
|
|
const skillMd = path.join(srcDir, 'SKILL.md');
|
|
if (!fs.existsSync(skillMd)) {
|
|
fail(`skills/${dirName}/SKILL.md is missing`);
|
|
continue;
|
|
}
|
|
const text = fs.readFileSync(skillMd, 'utf8');
|
|
const fm = parseFrontmatter(text);
|
|
if (!fm) {
|
|
fail(`skills/${dirName}/SKILL.md has no parseable frontmatter`);
|
|
} else {
|
|
if (fm.name !== dirName) fail(`skills/${dirName}/SKILL.md frontmatter name "${fm.name}" != directory name`);
|
|
if (!fm.description) fail(`skills/${dirName}/SKILL.md frontmatter has no description`);
|
|
skillDescriptions.push(fm.description);
|
|
if (seenNames.has(outName)) fail(`duplicate skill name in profile: ${outName} (skills/${dirName} and skills/${seenNames.get(outName)})`);
|
|
seenNames.set(outName, dirName);
|
|
}
|
|
for (const f of listFiles(srcDir, '')) {
|
|
let content = null;
|
|
if (rename[dirName] && f.rel === 'SKILL.md') {
|
|
// Rename the skill inside pi/core only; the root skill keeps its name.
|
|
content = fs.readFileSync(f.full, 'utf8')
|
|
.replace(/^name:\s*["']?[^\n"']+["']?\s*$/m, `name: ${outName}`);
|
|
fs.mkdirSync(path.dirname(path.join(destDir, f.rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(destDir, f.rel), content);
|
|
} else {
|
|
copyFile(f.full, path.join(destDir, f.rel));
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const file of manifest.commands.include) {
|
|
copyFile(path.join(COMMANDS_SRC, file), path.join(tmpDir, 'commands', file));
|
|
}
|
|
|
|
copyFile(path.join(ROOT, 'LICENSE'), path.join(tmpDir, 'LICENSE'));
|
|
|
|
const profilePackage = {
|
|
name: manifest.profile.packageName,
|
|
version: VERSION,
|
|
license: manifest.profile.license,
|
|
keywords: manifest.profile.keywords,
|
|
pi: { skills: ['./skills'], prompts: ['./commands'] },
|
|
};
|
|
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(profilePackage, null, 2) + '\n');
|
|
|
|
const skillCount = manifest.skills.include.length;
|
|
const commandCount = manifest.commands.include.length;
|
|
const descChars = skillDescriptions.reduce((n, d) => n + d.length, 0);
|
|
|
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), `# ecc-pi-core
|
|
|
|
A curated, Pi-native profile of ECC (Everything Claude Code): ${skillCount} portable
|
|
engineering skills and ${commandCount} pure prompt-workflow commands, with no extensions,
|
|
no hooks, no runtime downloads, and no network or SaaS dependencies.
|
|
|
|
## Contents
|
|
|
|
- \`skills/\` - language, framework, testing/TDD, code review, security review,
|
|
planning, refactoring, docs, and git/PR workflow skills.
|
|
- \`commands/\` - prompt commands that are pure prompt workflows.
|
|
- \`CURATION.md\` - every excluded skill and command with its reason.
|
|
|
|
## Use
|
|
|
|
Copy this directory into your project (or pin a release tarball) and load it with the
|
|
Pi coding agent:
|
|
|
|
\`\`\`sh
|
|
pi --no-extensions --extension pi/core
|
|
\`\`\`
|
|
|
|
Offline load test (as run in CI):
|
|
|
|
\`\`\`sh
|
|
PI_OFFLINE=1 pi --offline --mode rpc --no-session --no-context-files --no-extensions \\
|
|
--extension pi/core </dev/null >/dev/null
|
|
\`\`\`
|
|
|
|
## Regenerate
|
|
|
|
\`pi/core\` is generated from \`manifests/pi-core.json\` and committed so release
|
|
tarballs contain it verbatim. After changing the manifest or any included source
|
|
content, run:
|
|
|
|
\`\`\`sh
|
|
node scripts/build-pi-core.js
|
|
\`\`\`
|
|
|
|
and commit the result. CI verifies the committed profile is up to date.
|
|
`);
|
|
|
|
{
|
|
const lines = [];
|
|
lines.push('# Curation');
|
|
lines.push('');
|
|
lines.push(`pi/core includes ${skillCount} of ${Object.keys(manifest.skills.exclude).length + skillCount} skills ` +
|
|
`and ${commandCount} of ${Object.keys(manifest.commands.exclude).length + commandCount} commands from the root of ECC.`);
|
|
lines.push('Everything excluded is listed here with its reason.');
|
|
lines.push('');
|
|
lines.push('## Rules');
|
|
lines.push('');
|
|
lines.push('Include: ' + manifest.curationRules.include.join('; ') + '.');
|
|
lines.push('');
|
|
lines.push('Exclude anything that:');
|
|
for (const rule of manifest.curationRules.exclude) lines.push(`- ${rule}`);
|
|
lines.push('');
|
|
lines.push('## Excluded skills');
|
|
lines.push('');
|
|
lines.push('| Skill | Reason |');
|
|
lines.push('|---|---|');
|
|
for (const [name, reason] of Object.entries(manifest.skills.exclude)) {
|
|
lines.push(`| \`${name}\` | ${reason.replace(/\|/g, '\\|')} |`);
|
|
}
|
|
lines.push('');
|
|
lines.push('## Excluded commands');
|
|
lines.push('');
|
|
lines.push('| Command | Reason |');
|
|
lines.push('|---|---|');
|
|
for (const [file, reason] of Object.entries(manifest.commands.exclude)) {
|
|
lines.push(`| \`${file.replace(/\.md$/, '')}\` | ${reason.replace(/\|/g, '\\|')} |`);
|
|
}
|
|
lines.push('');
|
|
lines.push('## Renames');
|
|
lines.push('');
|
|
for (const [from, to] of Object.entries(rename)) {
|
|
lines.push(`- \`${from}\` is shipped as \`${to}\` inside pi/core (the root skill keeps its original name).`);
|
|
}
|
|
lines.push('');
|
|
fs.writeFileSync(path.join(tmpDir, 'CURATION.md'), lines.join('\n'));
|
|
}
|
|
|
|
// ---------- safety scans -----------------------------------------------------
|
|
//
|
|
// Scanner semantics (mirrors the curation rules in manifests/pi-core.json):
|
|
// - URLs: documentation links are allowed via safety.urlAllowlistHosts.
|
|
// Placeholder hosts (example.com/org/net and subdomains) and non-FQDN
|
|
// internal hostnames (localhost, docker service names like "api" or "db")
|
|
// are always allowed; they are not callable endpoints.
|
|
// - Runtime downloads: pipe-to-shell (curl|sh, wget|sh) and fetch-and-run
|
|
// npx forms (-y/--yes, pkg@version, create-*, degit, "skills add") fail.
|
|
// Local-first `npx <tool>` (jest, tsc, playwright, prisma, ...) and
|
|
// standard project dependency installation (pip install <dep>, npm i) are
|
|
// the reader's own project workflow, not the profile downloading code to
|
|
// run itself, so they are allowed; skills whose own operation downloads
|
|
// tooling are excluded in the manifest instead (see CURATION.md).
|
|
// - Home paths: absolute per-user paths (/Users/..., /home/..., C:\Users\...)
|
|
// fail. Portable tilde references (~/.cache/...) are user-agnostic and OK.
|
|
const defaultHosts = new Set(manifest.safety.defaultAllowedHosts || []);
|
|
const allowHosts = new Set([...(manifest.safety.urlAllowlistHosts || []), ...defaultHosts]);
|
|
const scanAllowlist = manifest.safety.scanAllowlist || [];
|
|
const PLACEHOLDER_SUFFIXES = ['.example.com', '.example.org', '.example.net'];
|
|
|
|
function isAllowlisted(relPath, line) {
|
|
return scanAllowlist.some(e => relPath === e.path && line.includes(e.contains));
|
|
}
|
|
|
|
function hostAllowed(hostname) {
|
|
if (!hostname) return false;
|
|
if (allowHosts.has(hostname)) return true;
|
|
if (!hostname.includes('.')) return true; // localhost, docker service names, placeholders
|
|
return PLACEHOLDER_SUFFIXES.some(s => hostname.endsWith(s));
|
|
}
|
|
|
|
const URL_RE = /https?:\/\/([A-Za-z0-9._-]+)(?::\d+)?[^\s)\]"'`<>}]*/g;
|
|
const SECRET_RES = [
|
|
{ re: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' },
|
|
{ re: /ghp_[A-Za-z0-9]{20,}/, label: 'GitHub PAT' },
|
|
{ re: /github_pat_[A-Za-z0-9_]{22,}/, label: 'GitHub fine-grained PAT' },
|
|
{ re: /sk-[A-Za-z0-9_-]{20,}/, label: 'OpenAI-style key' },
|
|
{ re: /xox[baprs]-[A-Za-z0-9-]{10,}/, label: 'Slack token' },
|
|
{ re: /AIza[0-9A-Za-z_-]{35}/, label: 'Google API key' },
|
|
{ re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, label: 'private key block' },
|
|
{ re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, label: 'JWT' },
|
|
];
|
|
const INSTALL_RES = [
|
|
{ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|fi)?sh\b/, label: 'pipe-to-shell (curl|sh / wget|sh)' },
|
|
{ re: /\bnpx\s+(?:-y\b|--yes\b)/, label: 'npx -y/--yes (fetch-and-run)' },
|
|
{ re: /\bnpx\s+(?:--\S+\s+)*[A-Za-z@][^\s]*@\d/, label: 'npx pkg@version (fetch-and-run)' },
|
|
{ re: /\bnpx\s+(?:--\S+\s+)*(?:create-[a-z-]+|degit\b|skills\s+add\b)/, label: 'npx scaffold/fetch form' },
|
|
{ re: /\bpipx\s+install\b/, label: 'pipx install' },
|
|
];
|
|
const HOME_RES = [
|
|
{ re: /\/Users\/[^\s)"'`\]<>|]+/, label: 'macOS home path' },
|
|
{ re: /\/home\/[^\s)"'`\]<>|]+/, label: 'Linux home path' },
|
|
{ re: /[A-Za-z]:\\Users\\[^\s)"'`\]<>|]+/, label: 'Windows home path' },
|
|
];
|
|
const GENERATED_FILES = new Set(['package.json', 'README.md', 'CURATION.md']);
|
|
|
|
function scanTextFile(relPath, text) {
|
|
if (GENERATED_FILES.has(relPath)) return; // generated from the manifest itself
|
|
const lines = text.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const at = `${relPath}:${i + 1}`;
|
|
const allowlisted = isAllowlisted(relPath, line);
|
|
for (const m of line.matchAll(URL_RE)) {
|
|
if (!hostAllowed(m[1]) && !allowlisted) {
|
|
fail(`${at}: non-allowlisted URL host ${m[1]} (${m[0].slice(0, 100)})`);
|
|
}
|
|
}
|
|
if (allowlisted) continue;
|
|
for (const { re, label } of SECRET_RES) {
|
|
if (re.test(line)) fail(`${at}: possible secret (${label})`);
|
|
}
|
|
for (const { re, label } of INSTALL_RES) {
|
|
if (re.test(line)) fail(`${at}: runtime download pattern (${label})`);
|
|
}
|
|
for (const { re, label } of HOME_RES) {
|
|
if (re.test(line)) fail(`${at}: absolute home path (${label})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const f of listFiles(tmpDir, '')) {
|
|
const rel = f.rel;
|
|
const buf = fs.readFileSync(f.full);
|
|
if (buf.includes(0)) continue; // binary file: nothing textual to scan
|
|
scanTextFile(rel, buf.toString('utf8'));
|
|
}
|
|
|
|
// ---------- emit report -------------------------------------------------------
|
|
const report = [
|
|
`pi/core: ${skillCount} skills, ${commandCount} commands`,
|
|
`skill description text: ${descChars} characters (target: under ~40000)`,
|
|
];
|
|
if (descChars > 42000) fail(`skill description text too large: ${descChars} characters`);
|
|
|
|
if (violations.length) {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
console.error('pi/core build FAILED:');
|
|
for (const v of violations) console.error(' - ' + v);
|
|
console.error(report.join('\n'));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (CHECK_MODE) {
|
|
// Compare tmpDir against the committed profile without touching it.
|
|
const committed = listFiles(PROFILE_DIR, '').map(f => f.rel).sort();
|
|
const built = listFiles(tmpDir, '').map(f => f.rel).sort();
|
|
let drift = false;
|
|
if (committed.join('\n') !== built.join('\n')) {
|
|
console.error('pi/core file list drift:');
|
|
const cSet = new Set(committed), bSet = new Set(built);
|
|
for (const f of committed) if (!bSet.has(f)) console.error(' only committed: ' + f);
|
|
for (const f of built) if (!cSet.has(f)) console.error(' only built: ' + f);
|
|
drift = true;
|
|
} else {
|
|
for (const rel of committed) {
|
|
const a = fs.readFileSync(path.join(PROFILE_DIR, rel));
|
|
const b = fs.readFileSync(path.join(tmpDir, rel));
|
|
if (!a.equals(b)) { console.error('pi/core content drift: ' + rel); drift = true; }
|
|
}
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
if (drift) {
|
|
console.error('Run: node scripts/build-pi-core.js');
|
|
process.exit(1);
|
|
}
|
|
console.log('pi/core is up to date.');
|
|
console.log(report.join('\n'));
|
|
process.exit(0);
|
|
}
|
|
|
|
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
|
fs.renameSync(tmpDir, PROFILE_DIR);
|
|
console.log(report.join('\n'));
|
|
console.log(`wrote ${manifest.profile.dir}/`);
|