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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ jobs:
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.11"
- uses: actions/setup-node@v6.4.0
with:
node-version: "20"
- name: Install package
run: python -m pip install -e . pytest
- name: Run Node installer tests
run: npm test
- name: Run tests
# Node must be on PATH first: the slug-parity test shells out to it.
run: pytest -q
93 changes: 65 additions & 28 deletions bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,11 @@ const child_process = require('child_process');
const REPO = 'derek-palmer/codeforerunner';
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`;

// All skill slugs written during a local install.
const TASK_SKILL_SLUGS = [
'codeforerunner',
'forerunner-api-docs',
'forerunner-arch-review',
'forerunner-audit',
'forerunner-changelog',
'forerunner-check',
'forerunner-diagrams',
'forerunner-flows',
'forerunner-init',
'forerunner-readme',
'forerunner-review',
'forerunner-scan',
'forerunner-stack-docs',
'forerunner-version-audit',
'forerunner-refresh',
];
// Task Registry — single source of truth for installable skill slugs, shared
// with the Python installer (src/codeforerunner/tasks.py). The slug list is
// loaded from this file at install time via loadTaskSkillSlugs() rather than
// duplicated here.
const TASKS_JSON_REL = path.join('src', 'codeforerunner', 'tasks.json');

// ── Argv ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -332,14 +319,12 @@ async function promptGlobalOrLocal(c) {

// ── Local install helpers ─────────────────────────────────────────────────

async function fetchSkill(slug, repoRoot) {
if (repoRoot) {
const p = path.join(repoRoot, 'skills', slug, 'SKILL.md');
try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
}
// GET a raw text resource over HTTPS, following one level of redirects.
// Resolves to the body string, or null on any non-200/error/timeout.
function fetchRawText(url) {
return new Promise((resolve) => {
const get = (url) => {
const req = https.get(url, (res) => {
const get = (u) => {
const req = https.get(u, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
get(res.headers.location);
res.resume();
Expand All @@ -353,15 +338,62 @@ async function fetchSkill(slug, repoRoot) {
req.on('error', () => resolve(null));
req.setTimeout(10000, () => { req.destroy(new Error('timeout')); });
};
get(`${RAW_BASE}/skills/${slug}/SKILL.md`);
get(url);
});
}

async function fetchSkill(slug, repoRoot) {
if (repoRoot) {
const p = path.join(repoRoot, 'skills', slug, 'SKILL.md');
try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
}
return fetchRawText(`${RAW_BASE}/skills/${slug}/SKILL.md`);
}

// Derive the install slug list from tasks.json, mirroring Python's
// tasks.installable_slugs(): the canonical skill slug followed by each task's
// skill_slug in registry order, deduped against the canonical slug.
function slugsFromTasksJson(text) {
const data = JSON.parse(text);
const canonical = data.canonical_skill_slug;
if (typeof canonical !== 'string' || !canonical) {
throw new Error('tasks.json missing canonical_skill_slug');
}
const slugs = [canonical];
for (const t of data.tasks || []) {
if (t.skill_slug && t.skill_slug !== canonical) slugs.push(t.skill_slug);
}
return slugs;
}

// Load installable skill slugs from the Task Registry. Reads tasks.json from a
// local checkout when available (local clone or the packaged file beside this
// script), else fetches it over HTTPS — the same local-or-remote strategy
// fetchSkill() uses. Returns null if no source could be read or parsed.
async function loadTaskSkillSlugs(repoRoot) {
const localCandidates = [];
if (repoRoot) localCandidates.push(path.join(repoRoot, TASKS_JSON_REL));
localCandidates.push(path.resolve(__dirname, '..', TASKS_JSON_REL));
for (const p of localCandidates) {
try { return slugsFromTasksJson(fs.readFileSync(p, 'utf8')); } catch (_) { /* try next */ }
}
const remoteUrl = `${RAW_BASE}/${TASKS_JSON_REL.split(path.sep).join('/')}`;
const text = await fetchRawText(remoteUrl);
if (text) {
try { return slugsFromTasksJson(text); } catch (_) { /* fall through to null */ }
}
return null;
}

async function writeSkillsLocal(opts, results, c) {
if (opts.only.length) {
die('error: --only cannot be used with --local (local install writes all skills)');
}
const repoRoot = detectRepoRoot();
const taskSkillSlugs = await loadTaskSkillSlugs(repoRoot);
if (!taskSkillSlugs || !taskSkillSlugs.length) {
die('error: could not load installable skill slugs from tasks.json (Task Registry)');
}
const cwd = process.cwd();

// Claude Code natively recognises .claude/skills/<slug>/SKILL.md.
Expand All @@ -373,7 +405,7 @@ async function writeSkillsLocal(opts, results, c) {
process.stdout.write(c.dim(` target: ${cwd}\n`));
if (opts.dryRun) process.stdout.write(c.yellow(' (dry-run — no files written)\n'));

for (const slug of TASK_SKILL_SLUGS) {
for (const slug of taskSkillSlugs) {
process.stdout.write(`\n${c.bold(`→ ${slug}`)}\n`);
const content = await fetchSkill(slug, repoRoot);
if (!content) {
Expand Down Expand Up @@ -604,4 +636,9 @@ async function main() {
if (results.failed.length) process.exit(1);
}

main().catch(err => { process.stderr.write(err.stack + '\n'); process.exit(2); });
if (require.main === module) {
main().catch(err => { process.stderr.write(err.stack + '\n'); process.exit(2); });
}

// Exported for tests (tests/install.test.js, tests/test_installer.py) — kept minimal.
module.exports = { loadTaskSkillSlugs, slugsFromTasksJson };
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
"files": [
"bin/",
"skills/",
"src/codeforerunner/tasks.json",
"install.sh",
"install.ps1",
"skills-lock.json"
],
"scripts": {
"test": "node --test tests/install.test.js"
},
"engines": {
"node": ">=18"
},
Expand Down
121 changes: 121 additions & 0 deletions tests/install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use strict';

// Behavior tests for the installer's Task Registry slug loading.
// Run: node --test tests/install.test.js
//
// Scope is deliberately the public, exported surface of bin/install.js
// (slugsFromTasksJson, loadTaskSkillSlugs) plus the observable CLI behavior
// when the registry cannot be read. No internal/private functions are touched,
// so these survive refactors of the install machinery.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { spawnSync } = require('node:child_process');

const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_JS = path.join(REPO_ROOT, 'bin', 'install.js');

const { slugsFromTasksJson, loadTaskSkillSlugs } = require(INSTALL_JS);

test('slugsFromTasksJson lists the canonical skill slug first', () => {
const slugs = slugsFromTasksJson(JSON.stringify({
canonical_skill_slug: 'codeforerunner',
tasks: [{ name: 'scan', skill_slug: 'forerunner-scan' }],
}));
assert.equal(slugs[0], 'codeforerunner');
});

test('slugsFromTasksJson does not repeat a task slug equal to the canonical', () => {
const slugs = slugsFromTasksJson(JSON.stringify({
canonical_skill_slug: 'codeforerunner',
tasks: [
{ name: 'self', skill_slug: 'codeforerunner' },
{ name: 'scan', skill_slug: 'forerunner-scan' },
],
}));
assert.deepEqual(slugs, ['codeforerunner', 'forerunner-scan']);
});

test('slugsFromTasksJson skips tasks with no skill_slug', () => {
const slugs = slugsFromTasksJson(JSON.stringify({
canonical_skill_slug: 'codeforerunner',
tasks: [
{ name: 'scan', skill_slug: 'forerunner-scan' },
{ name: 'internal', skill_slug: null },
{ name: 'also-internal' },
],
}));
assert.deepEqual(slugs, ['codeforerunner', 'forerunner-scan']);
});

test('slugsFromTasksJson preserves registry (tasks array) order', () => {
const slugs = slugsFromTasksJson(JSON.stringify({
canonical_skill_slug: 'codeforerunner',
tasks: [
{ name: 'b', skill_slug: 'forerunner-b' },
{ name: 'a', skill_slug: 'forerunner-a' },
{ name: 'c', skill_slug: 'forerunner-c' },
],
}));
assert.deepEqual(slugs, ['codeforerunner', 'forerunner-b', 'forerunner-a', 'forerunner-c']);
});

test('slugsFromTasksJson throws on malformed JSON', () => {
assert.throws(() => slugsFromTasksJson('{ not json'));
});

test('slugsFromTasksJson throws when canonical_skill_slug is missing', () => {
// Mirrors Python's KeyError — a registry with no canonical slug is invalid,
// not a slug list of [undefined].
assert.throws(() => slugsFromTasksJson(JSON.stringify({
tasks: [{ name: 'scan', skill_slug: 'forerunner-scan' }],
})));
});

test('loadTaskSkillSlugs reads the real tasks.json from a local checkout', async () => {
const slugs = await loadTaskSkillSlugs(REPO_ROOT);
assert.ok(Array.isArray(slugs) && slugs.length > 0, 'expected a non-empty slug list');
assert.equal(slugs[0], 'codeforerunner');
assert.ok(slugs.includes('forerunner-scan'));
});

test('local install exits non-zero with a clear error when the registry is unreadable', () => {
// Preload stub: poison every tasks.json read path (local file + HTTPS) so
// loadTaskSkillSlugs() resolves null, exercising writeSkillsLocal's guard.
// readFileSync throws (local candidates fail); https.get errors (remote fails).
const stub = path.join(os.tmpdir(), `cfr-stub-${process.pid}-${Date.now()}.js`);
fs.writeFileSync(stub, `
const fs = require('fs');
// Poison only tasks.json reads — Node's module loader uses readFileSync to
// load install.js itself, so a blanket stub would crash before our guard runs.
const realReadFileSync = fs.readFileSync;
fs.readFileSync = function (p, ...rest) {
if (typeof p === 'string' && p.includes('tasks.json')) throw new Error('blocked by test stub');
return realReadFileSync.call(this, p, ...rest);
};
const https = require('https');
https.get = function () {
const req = {
on(ev, cb) { if (ev === 'error') process.nextTick(() => cb(new Error('blocked'))); return req; },
setTimeout() { return req; },
destroy() {},
};
return req;
};
`, 'utf8');

try {
const res = spawnSync(
process.execPath,
['--require', stub, INSTALL_JS, '--local', '--non-interactive', '--no-color'],
{ encoding: 'utf8', cwd: os.tmpdir() },
);
assert.notEqual(res.status, 0, 'expected a non-zero exit');
assert.match((res.stderr || '') + (res.stdout || ''), /tasks\.json/);
} finally {
fs.rmSync(stub, { force: true });
}
});
33 changes: 33 additions & 0 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,39 @@ def test_task_skill_slugs_include_arch_review():
assert (REPO / "skills/forerunner-arch-review/SKILL.md").is_file()


def test_node_installer_slug_list_matches_registry():
"""bin/install.js must derive the same slug list as tasks.installable_slugs().

The Node installer reads tasks.json at install time (no hardcoded list), so
this guards against the two installers drifting apart.
"""
import shutil
import subprocess

from codeforerunner.tasks import installable_slugs

node = shutil.which("node")
if node is None:
pytest.skip("node not available")

install_js = REPO / "bin" / "install.js"
script = (
"require(process.argv[1])"
".loadTaskSkillSlugs(process.argv[2])"
".then(s => process.stdout.write(JSON.stringify(s)))"
".catch(e => { process.stderr.write(String(e)); process.exit(1); });"
)
proc = subprocess.run(
[node, "-e", script, str(install_js), str(REPO)],
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
node_slugs = json.loads(proc.stdout)
assert node_slugs == list(installable_slugs())


# ── install_all_skills ────────────────────────────────────────────────────────

def test_install_all_skills_check_only(tmp_path):
Expand Down