Files
nocobase/scripts/sync-skills.mjs
T

161 lines
6.2 KiB
JavaScript

// Link every Skill committed under `skills/` into `.agents/skills/` and `.claude/skills/`, so the agents working in
// this repository see them.
//
// `skills/` is the one committed source. It is also where `npx skills add` reads from the repository's v3 branch, so a Skill
// written there reaches a user's global installation and this checkout alike. Agents do not look there on their own:
// most read `.agents/skills/`, and Claude Code discovers Skills only under `~/.claude/skills/` and
// `<project>/.claude/skills/`. Without these links a contributor working in this repository gets none of its Skills,
// while the globally installed NocoBase 2 Skills that the root `AGENTS.md` tells agents to ignore stay available —
// exactly backwards.
//
// This is the same arrangement `nocobase skills sync` sets up inside a generated application: both directories are
// generated and ignored. The difference is where the Skills come from — an application's are copied from its installed
// packages, while this repository's are written by hand under `skills/`.
//
// Links rather than copies, so editing a Skill through any path edits the one committed file. Relative links, so the
// checkout stays movable.
import { lstat, mkdir, readdir, symlink, unlink } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
/** Directory, relative to the repository root, that holds the committed Skills. */
export const SKILLS_DIRECTORY = 'skills';
/** Directories, relative to the repository root, that agents read Skills from. */
export const AGENT_SKILLS_DIRECTORIES = [
path.join('.agents', 'skills'),
path.join('.claude', 'skills'),
];
/**
* Brings every directory in `AGENT_SKILLS_DIRECTORIES` in line with `skills/`, returning what changed in each.
*
* A path that is not a symbolic link is never replaced: it is something this script did not write, and removing it to
* make room would delete work it cannot restore. It is reported and skipped instead.
*/
export async function syncSkills(root) {
const names = await readDirectoryNames(path.join(root, SKILLS_DIRECTORY));
const results = {};
for (const directory of AGENT_SKILLS_DIRECTORIES) {
results[directory] = await linkInto(root, directory, names);
}
return results;
}
async function linkInto(root, directory, names) {
const target = path.join(root, directory);
const linked = [];
const removed = [];
const skipped = [];
for (const name of await readDirectoryNames(target, { links: true })) {
if (names.includes(name)) continue;
if (await removeSymbolicLink(path.join(target, name))) removed.push(name);
}
if (names.length > 0) await mkdir(target, { recursive: true });
for (const name of names) {
const linkPath = path.join(target, name);
if (!(await removeSymbolicLink(linkPath))) {
skipped.push(name);
continue;
}
await createLink(root, directory, name, linkPath);
linked.push(name);
}
return { linked, removed, skipped };
}
async function createLink(root, directory, name, linkPath) {
const relativeTarget = path.relative(
path.join(root, directory),
path.join(root, SKILLS_DIRECTORY, name),
);
try {
await symlink(relativeTarget, linkPath, 'dir');
} catch (error) {
if (process.platform !== 'win32') throw error;
// Windows refuses symbolic links without developer mode or elevation. A junction needs neither, but only accepts
// an absolute target, so on that platform alone the checkout stops being movable.
await symlink(
path.join(root, SKILLS_DIRECTORY, name),
linkPath,
'junction',
);
}
}
// `withFileTypes` reports a symbolic link as a link rather than as the directory it points at. `skills/` holds real
// directories only. The agent directories accept links outright: their entries are the links this script writes, and
// a link whose target is already gone still has to be found in order to be pruned.
async function readDirectoryNames(directory, { links = false } = {}) {
try {
const entries = await readdir(directory, { withFileTypes: true });
return entries
.filter(
(entry) => entry.isDirectory() || (links && entry.isSymbolicLink()),
)
.map((entry) => entry.name)
.sort();
} catch (error) {
if (isNodeError(error, 'ENOENT') || isNodeError(error, 'ENOTDIR'))
return [];
throw error;
}
}
/**
* Removes `linkPath` when it is a symbolic link, reporting whether the path is now free.
*
* `lstat` rather than `stat` so a link to a missing target still reads as a link, and `unlink` rather than `rm` so a
* link to a directory is removed without following it.
*/
async function removeSymbolicLink(linkPath) {
let entry;
try {
entry = await lstat(linkPath);
} catch (error) {
if (isNodeError(error, 'ENOENT') || isNodeError(error, 'ENOTDIR'))
return true;
throw error;
}
if (!entry.isSymbolicLink()) return false;
await unlink(linkPath);
return true;
}
function isNodeError(error, code) {
return error !== null && typeof error === 'object' && error.code === code;
}
/** Runs the sync without ever failing the install that invoked it. */
export async function main(root = path.resolve(import.meta.dirname, '..')) {
try {
const results = await syncSkills(root);
for (const [directory, { linked, removed, skipped }] of Object.entries(
results,
)) {
if (skipped.length > 0) {
console.warn(
`[nocobase3] Left ${skipped.join(', ')} in ${directory}: not a symbolic link. Remove it to have it linked to ${SKILLS_DIRECTORY}.`,
);
}
if (linked.length > 0 || removed.length > 0) {
console.log(
`Linked ${linked.length} skill(s) into ${directory}${removed.length > 0 ? `, removed ${removed.length} stale link(s)` : ''}.`,
);
}
}
} catch (error) {
// An unwritable or unusual working tree must not break `pnpm install`; the Skills are still readable where they
// are committed.
console.warn(
`[nocobase3] Could not link ${SKILLS_DIRECTORY} into ${AGENT_SKILLS_DIRECTORIES.join(' and ')}: ${error.message}`,
);
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();