← Files Knowledge for AgentsARCHIVED FILE
scripts/install-task-skills.mjs
3.48 KB · Nov 11, 2026 · 12:32 UTC
#!/usr/bin/env node
// Install the canonical task Skills locally, without changing MCP or auth settings.
import { createHash } from "node:crypto";
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, parse, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const publicRoot = await lstat(join(root, "public", "skills"))
.then(() => join(root, "public"))
.catch(() => root);
const catalog = JSON.parse(
await readFile(join(publicRoot, "task-skills.json"), "utf8"),
);
const clients = {
codex: ".agents/skills",
cursor: ".cursor/skills",
claude: ".claude/skills",
gemini: ".gemini/skills",
copilot: ".github/skills",
};
const args = process.argv.slice(2);
const flag = (name) => {
const at = args.indexOf(name);
return at < 0 ? undefined : args[at + 1];
};
const client = flag("--client");
if (!Object.hasOwn(clients, client ?? ""))
throw new Error(
"Use --client codex|cursor|claude|gemini|copilot [--dest DIR] [--check|--list]",
);
if (!Array.isArray(catalog.skills) || catalog.skills.length !== 7)
throw new Error("Expected seven canonical task Skills");
const dest = resolve(flag("--dest") ?? join(process.cwd(), clients[client]));
async function noSymlinks(path) {
const relative = path.slice(parse(path).root.length).split(/[\\/]/);
let current = parse(path).root;
for (const part of relative) {
current = join(current, part);
const info = await lstat(current).catch((e) => {
if (e.code === "ENOENT") return null;
throw e;
});
if (info?.isSymbolicLink())
throw new Error("Refusing a symbolic-link installation path");
}
}
const names = new Set();
const planned = [];
for (const skill of catalog.skills) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.name) || names.has(skill.name))
throw new Error("Invalid skill identity");
names.add(skill.name);
const source = join(publicRoot, "skills", skill.name, "SKILL.md");
await noSymlinks(source);
const content = await readFile(source, "utf8");
const digest = "sha256:" + createHash("sha256").update(content).digest("hex");
if (
digest !== skill.digest ||
!content.startsWith(`---\nname: ${skill.name}\n`)
)
throw new Error("Skill content does not match the catalog");
const target = join(dest, skill.name, "SKILL.md");
await noSymlinks(target);
const existing = await readFile(target, "utf8").catch((e) => {
if (e.code === "ENOENT") return null;
throw e;
});
if (existing !== null && existing !== content)
throw new Error(
"Existing skill differs; inspect it before replacing manually",
);
planned.push({ target, content, current: existing === content });
}
if (args.includes("--list")) {
console.log(JSON.stringify({ client, skills: [...names] }));
} else if (args.includes("--check")) {
const current = planned.every((item) => item.current);
console.log(
JSON.stringify({
status: current ? "current" : "missing",
client,
skills: planned.length,
}),
);
process.exitCode = current ? 0 : 1;
} else {
for (const item of planned) {
if (item.current) continue;
await mkdir(dirname(item.target), { recursive: true });
await noSymlinks(item.target);
await writeFile(item.target, item.content, { flag: "wx" });
}
console.log(
JSON.stringify({
status: "installed",
client,
skills: planned.length,
auth: "unchanged",
default_access: "read_only",
}),
);
}
SHA-256: 4491873d79701e6651181574c6c6cdc986d57823dc9067d9821e9135869ddb9d