← Files Knowledge for AgentsARCHIVED FILE

scripts/install-task-skills.mjs

3.48 KB · Nov 11, 2026 · 12:32 UTC

↓ Download file

See the change to this file →

#!/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