#!/usr/bin/env node // Installs skills from skills/manifest.json into a target directory. // // This is the only place skill content is handled. install.sh and install.ps1 // call it and contain no skill text of their own, which is what keeps the two // platforms provably identical (see tests/skills-manifest.test.mjs) and keeps // non-ASCII bytes out of .bat launchers (see tests/windows-launchers.test.mjs). // // node lib/install-skills.mjs --target [--locale en] [--persona general] // [--source ] [--ref ] // [--list] [--dry-run] [--json] import { mkdirSync, readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // Where to fetch from lives in origin.json so a move to our own host is one // edit. Falling back to literals keeps this working when the file is absent — // the remote installers download this script on its own, without the repo. const ORIGIN = (() => { for (const dir of [join(scriptDirOf(), '..'), scriptDirOf()]) { try { return JSON.parse(readFileSync(join(dir, 'origin.json'), 'utf8')); } catch { /* try next */ } } return null; })(); // A template rather than a base URL: Gitea and GitHub lay raw paths out // differently, and hardcoding either shape makes the other impossible. const RAW_TEMPLATE = ORIGIN?.urls?.rawTemplate ?? 'https://gitea.fanghe.it.com/zhenghy/u-claw/raw/branch/{ref}/{path}'; const DEFAULT_REF = ORIGIN?.repo?.ref ?? 'main'; function scriptDirOf() { return dirname(fileURLToPath(import.meta.url)); } const scriptDir = scriptDirOf(); function parseArgs(argv) { const opts = { locale: 'en', personas: [], list: false, dryRun: false, json: false, ref: DEFAULT_REF }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const next = () => { const value = argv[++i]; if (value === undefined) fail(`${arg} needs a value`); return value; }; switch (arg) { case '--target': opts.target = next(); break; case '--source': opts.source = next(); break; case '--locale': opts.locale = next(); break; case '--persona': opts.personas.push(next()); break; case '--ref': opts.ref = next(); break; case '--list': opts.list = true; break; case '--dry-run': opts.dryRun = true; break; case '--json': opts.json = true; break; case '--help': case '-h': usage(); process.exit(0); break; default: fail(`unknown argument: ${arg}`); } } return opts; } function usage() { process.stdout.write( 'Usage: install-skills.mjs --target [--locale en] [--persona ]...\n' + ' [--source ] [--ref ] [--list] [--dry-run] [--json]\n' ); } function fail(message) { process.stderr.write(`install-skills: ${message}\n`); process.exit(1); } // Content lives in the repo checkout or on the USB. The one-line installers have // neither, so they fall through to fetching from GitHub at a pinned ref. function resolveLocalSource(explicit) { const candidates = [explicit, join(scriptDir, '..', 'skills'), join(scriptDir, 'skills')]; for (const dir of candidates) { if (dir && existsSync(join(dir, 'manifest.json'))) return resolve(dir); } return null; } async function fetchText(url) { const response = await fetch(url); if (!response.ok) throw new Error(`${response.status} ${response.statusText} for ${url}`); return response.text(); } function rawUrl(ref, path) { return RAW_TEMPLATE.replace('{ref}', ref).replace('{path}', `skills/${path}`); } function selectSkills(manifest, { locale, personas }) { return manifest.skills .filter((skill) => skill.status === 'shipping') .filter((skill) => (skill.locales ?? []).includes(locale)) // No --persona means install everything available for the locale. .filter((skill) => personas.length === 0 || personas.some((p) => (skill.personas ?? []).includes(p))) .sort((a, b) => a.id.localeCompare(b.id)); } async function main() { const opts = parseArgs(process.argv.slice(2)); if (!opts.target && !opts.list) fail('--target is required (or use --list)'); const localSource = resolveLocalSource(opts.source); const remote = localSource === null; let manifest; try { manifest = JSON.parse( remote ? await fetchText(rawUrl(opts.ref, 'manifest.json')) : readFileSync(join(localSource, 'manifest.json'), 'utf8') ); } catch (error) { fail(`could not read the skill manifest: ${error.message}`); } const selected = selectSkills(manifest, opts); if (opts.list) { process.stdout.write( opts.json ? `${JSON.stringify(selected.map((s) => s.id))}\n` : `${selected.map((s) => s.id).join('\n')}\n` ); return; } let installed = 0; let skipped = 0; const failures = []; for (const skill of selected) { const destDir = join(opts.target, skill.id); const destFile = join(destDir, 'SKILL.md'); if (existsSync(destFile)) { skipped++; continue; } if (opts.dryRun) { installed++; continue; } try { mkdirSync(destDir, { recursive: true }); if (remote) { writeFileSync(destFile, await fetchText(rawUrl(opts.ref, `${opts.locale}/${skill.id}/SKILL.md`)), 'utf8'); } else { const src = join(localSource, opts.locale, skill.id, 'SKILL.md'); if (!existsSync(src)) throw new Error(`missing ${src}`); copyFileSync(src, destFile); } installed++; } catch (error) { // One bad skill should not abort the install — report at the end instead. failures.push(`${skill.id}: ${error.message}`); } } if (opts.json) { process.stdout.write(`${JSON.stringify({ installed, skipped, failed: failures })}\n`); } else { process.stdout.write(`skills installed: ${installed}, already present: ${skipped}\n`); for (const failure of failures) process.stderr.write(` failed: ${failure}\n`); } if (failures.length > 0 && installed === 0) process.exit(1); } main().catch((error) => fail(error.message));