420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readlinkSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
type Stats,
|
|
} from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
import type { Command } from 'commander';
|
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
|
|
|
export interface SkillPaths {
|
|
mosaicSkillsDir: string;
|
|
claudeSkillsDir: string;
|
|
}
|
|
|
|
export type SkillRegistrationStatus = 'registered' | 'already-registered' | 'repaired';
|
|
export type SkillUnregistrationStatus = 'unregistered' | 'already-unregistered';
|
|
export type SkillListStatus =
|
|
| 'registered'
|
|
| 'unregistered'
|
|
| 'dangling'
|
|
| 'foreign'
|
|
| 'foreign-dangling'
|
|
| 'misdirected';
|
|
|
|
export interface SkillRegistrationResult {
|
|
name: string;
|
|
status: SkillRegistrationStatus;
|
|
sourcePath: string;
|
|
linkPath: string;
|
|
}
|
|
|
|
export interface SkillUnregistrationResult {
|
|
name: string;
|
|
status: SkillUnregistrationStatus;
|
|
linkPath: string;
|
|
}
|
|
|
|
export interface SkillListEntry {
|
|
name: string;
|
|
status: SkillListStatus;
|
|
sourcePath?: string;
|
|
linkPath: string;
|
|
targetPath?: string;
|
|
}
|
|
|
|
export interface SkillSyncConflict {
|
|
name: string;
|
|
reason: string;
|
|
}
|
|
|
|
export interface SkillSyncResult {
|
|
registered: string[];
|
|
repaired: string[];
|
|
unchanged: string[];
|
|
conflicts: SkillSyncConflict[];
|
|
}
|
|
|
|
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
|
export class SkillBridgeError extends Error {
|
|
public constructor(message: string) {
|
|
super(message);
|
|
this.name = 'SkillBridgeError';
|
|
}
|
|
}
|
|
|
|
/** Resolve the production bridge paths while keeping tests injectable. */
|
|
export function getDefaultSkillPaths(): SkillPaths {
|
|
const mosaicHome = process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
|
const claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
|
|
return {
|
|
mosaicSkillsDir: join(mosaicHome, 'skills'),
|
|
claudeSkillsDir: join(claudeHome, 'skills'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reject a user-supplied name before any filesystem operation.
|
|
* A skill name must identify one direct child in both managed roots.
|
|
*/
|
|
export function validateSkillName(name: string): void {
|
|
if (
|
|
name.length === 0 ||
|
|
name.startsWith('-') ||
|
|
name.endsWith('.') ||
|
|
name.includes('..') ||
|
|
name.includes('/') ||
|
|
name.includes('\\') ||
|
|
isAbsolute(name) ||
|
|
!SAFE_SKILL_NAME.test(name)
|
|
) {
|
|
throw new SkillBridgeError(
|
|
`Invalid skill name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function displaySkillName(name: string): string {
|
|
return SAFE_SKILL_NAME.test(name) ? name : JSON.stringify(name);
|
|
}
|
|
|
|
function lstatIfPresent(path: string): Stats | undefined {
|
|
try {
|
|
return lstatSync(path);
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function assertNoSymlinkAncestors(path: string): void {
|
|
const absolute = resolve(path);
|
|
const pathRoot = parse(absolute).root;
|
|
let current = pathRoot;
|
|
|
|
for (const segment of relative(pathRoot, absolute).split(sep)) {
|
|
if (segment.length === 0) continue;
|
|
current = join(current, segment);
|
|
const entry = lstatIfPresent(current);
|
|
if (!entry) break;
|
|
if (entry.isSymbolicLink()) {
|
|
throw new SkillBridgeError(
|
|
`Refusing symlink ancestor at ${current}; managed skill roots must resolve without symlink traversal.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertManagedRoots(paths: SkillPaths): void {
|
|
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
|
|
assertNoSymlinkAncestors(paths.claudeSkillsDir);
|
|
}
|
|
|
|
function directChild(root: string, name: string): string {
|
|
const resolvedRoot = resolve(root);
|
|
const child = resolve(resolvedRoot, name);
|
|
if (dirname(child) !== resolvedRoot) {
|
|
throw new SkillBridgeError(`Invalid skill name "${name}": resolved path escapes its root.`);
|
|
}
|
|
return child;
|
|
}
|
|
|
|
function resolveLinkTarget(linkPath: string): string {
|
|
return resolve(dirname(linkPath), readlinkSync(linkPath));
|
|
}
|
|
|
|
function isInsideSkillsRoot(targetPath: string, skillsRoot: string): boolean {
|
|
const rel = relative(resolve(skillsRoot), resolve(targetPath));
|
|
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
}
|
|
|
|
function isDangling(linkPath: string): boolean {
|
|
return !existsSync(linkPath);
|
|
}
|
|
|
|
function assertSourceSkill(name: string, paths: SkillPaths): string {
|
|
const sourcePath = directChild(paths.mosaicSkillsDir, name);
|
|
const source = lstatIfPresent(sourcePath);
|
|
if (!source?.isDirectory()) {
|
|
throw new SkillBridgeError(
|
|
`Canonical skill directory not found: ${sourcePath}. Add the skill under the Mosaic skills directory before registering it.`,
|
|
);
|
|
}
|
|
return sourcePath;
|
|
}
|
|
|
|
function foreignTargetError(linkPath: string): SkillBridgeError {
|
|
return new SkillBridgeError(
|
|
`Refusing to modify foreign entry at ${linkPath}; only symlinks pointing inside the Mosaic skills directory are managed.`,
|
|
);
|
|
}
|
|
|
|
/** Register one canonical skill with Claude Code without clobbering foreign entries. */
|
|
export function registerSkill(
|
|
name: string,
|
|
paths: SkillPaths = getDefaultSkillPaths(),
|
|
): SkillRegistrationResult {
|
|
validateSkillName(name);
|
|
assertManagedRoots(paths);
|
|
const sourcePath = assertSourceSkill(name, paths);
|
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
|
const existing = lstatIfPresent(linkPath);
|
|
|
|
if (!existing) {
|
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
|
symlinkSync(sourcePath, linkPath);
|
|
return { name, status: 'registered', sourcePath, linkPath };
|
|
}
|
|
|
|
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
|
|
|
|
const existingTarget = resolveLinkTarget(linkPath);
|
|
if (!isInsideSkillsRoot(existingTarget, paths.mosaicSkillsDir)) {
|
|
throw foreignTargetError(linkPath);
|
|
}
|
|
|
|
if (existingTarget === resolve(sourcePath) && !isDangling(linkPath)) {
|
|
return { name, status: 'already-registered', sourcePath, linkPath };
|
|
}
|
|
|
|
if (!isDangling(linkPath)) {
|
|
throw new SkillBridgeError(
|
|
`Refusing to replace live Mosaic skill symlink at ${linkPath}; it points to ${existingTarget}, not ${sourcePath}.`,
|
|
);
|
|
}
|
|
|
|
unlinkSync(linkPath);
|
|
symlinkSync(sourcePath, linkPath);
|
|
return { name, status: 'repaired', sourcePath, linkPath };
|
|
}
|
|
|
|
/** Unregister only a symlink owned by the canonical Mosaic skills root. */
|
|
export function unregisterSkill(
|
|
name: string,
|
|
paths: SkillPaths = getDefaultSkillPaths(),
|
|
): SkillUnregistrationResult {
|
|
validateSkillName(name);
|
|
assertManagedRoots(paths);
|
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
|
const existing = lstatIfPresent(linkPath);
|
|
|
|
if (!existing) return { name, status: 'already-unregistered', linkPath };
|
|
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
|
|
|
|
const targetPath = resolveLinkTarget(linkPath);
|
|
if (!isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir)) throw foreignTargetError(linkPath);
|
|
|
|
const expectedTarget = resolve(directChild(paths.mosaicSkillsDir, name));
|
|
if (targetPath !== expectedTarget) {
|
|
throw new SkillBridgeError(
|
|
`Refusing to unregister misdirected Mosaic skill symlink at ${linkPath}; it points to ${targetPath}, not ${expectedTarget}.`,
|
|
);
|
|
}
|
|
|
|
unlinkSync(linkPath);
|
|
return { name, status: 'unregistered', linkPath };
|
|
}
|
|
|
|
function canonicalSkillNames(paths: SkillPaths): string[] {
|
|
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
|
|
const root = lstatIfPresent(paths.mosaicSkillsDir);
|
|
if (!root) return [];
|
|
if (!root.isDirectory()) {
|
|
throw new SkillBridgeError(
|
|
`Canonical skills path is not a directory: ${paths.mosaicSkillsDir}`,
|
|
);
|
|
}
|
|
return readdirSync(paths.mosaicSkillsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
}
|
|
|
|
function claudeEntryNames(paths: SkillPaths): string[] {
|
|
assertNoSymlinkAncestors(paths.claudeSkillsDir);
|
|
const root = lstatIfPresent(paths.claudeSkillsDir);
|
|
if (!root) return [];
|
|
if (!root.isDirectory()) {
|
|
throw new SkillBridgeError(`Claude skills path is not a directory: ${paths.claudeSkillsDir}`);
|
|
}
|
|
return readdirSync(paths.claudeSkillsDir)
|
|
.filter((name) => name.length > 0)
|
|
.sort();
|
|
}
|
|
|
|
/** Return a deterministic union of canonical skills and Claude bridge entries. */
|
|
export function listSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillListEntry[] {
|
|
const canonicalNames = new Set(canonicalSkillNames(paths));
|
|
const names = new Set([...canonicalNames, ...claudeEntryNames(paths)]);
|
|
const entries: SkillListEntry[] = [];
|
|
|
|
for (const name of [...names].sort()) {
|
|
const sourcePath = canonicalNames.has(name)
|
|
? directChild(paths.mosaicSkillsDir, name)
|
|
: undefined;
|
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
|
const installed = lstatIfPresent(linkPath);
|
|
|
|
if (!installed) {
|
|
if (sourcePath) entries.push({ name, status: 'unregistered', sourcePath, linkPath });
|
|
continue;
|
|
}
|
|
|
|
if (!installed.isSymbolicLink()) {
|
|
entries.push({ name, status: 'foreign', sourcePath, linkPath });
|
|
continue;
|
|
}
|
|
|
|
const targetPath = resolveLinkTarget(linkPath);
|
|
const owned = isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir);
|
|
const dangling = isDangling(linkPath);
|
|
|
|
if (!owned) {
|
|
entries.push({
|
|
name,
|
|
status: dangling ? 'foreign-dangling' : 'foreign',
|
|
sourcePath,
|
|
linkPath,
|
|
targetPath,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (dangling) {
|
|
entries.push({ name, status: 'dangling', sourcePath, linkPath, targetPath });
|
|
continue;
|
|
}
|
|
|
|
entries.push({
|
|
name,
|
|
status: sourcePath && targetPath === resolve(sourcePath) ? 'registered' : 'misdirected',
|
|
sourcePath,
|
|
linkPath,
|
|
targetPath,
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/** Reconcile every canonical skill directory while preserving all foreign entries. */
|
|
export function syncClaudeSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillSyncResult {
|
|
const result: SkillSyncResult = {
|
|
registered: [],
|
|
repaired: [],
|
|
unchanged: [],
|
|
conflicts: [],
|
|
};
|
|
|
|
for (const name of canonicalSkillNames(paths)) {
|
|
try {
|
|
const registration = registerSkill(name, paths);
|
|
if (registration.status === 'registered') result.registered.push(name);
|
|
if (registration.status === 'repaired') result.repaired.push(name);
|
|
if (registration.status === 'already-registered') result.unchanged.push(name);
|
|
} catch (error: unknown) {
|
|
result.conflicts.push({
|
|
name: displaySkillName(name),
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function reportCommandError(error: unknown): void {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
/** Register the `mosaic skill` command group. */
|
|
export function registerSkillCommand(
|
|
program: Command,
|
|
paths: SkillPaths = getDefaultSkillPaths(),
|
|
): void {
|
|
const skill = program
|
|
.command('skill')
|
|
.description('Manage Claude Code skill registrations')
|
|
.configureHelp({ sortSubcommands: true });
|
|
|
|
skill
|
|
.command('register <name>')
|
|
.description('Register a Mosaic skill with Claude Code')
|
|
.action((name: string) => {
|
|
try {
|
|
const result = registerSkill(name, paths);
|
|
if (result.status === 'already-registered') {
|
|
console.log(`${name}: already registered`);
|
|
} else if (result.status === 'repaired') {
|
|
console.log(`${name}: repaired dangling registration`);
|
|
} else {
|
|
console.log(`${name}: registered`);
|
|
}
|
|
} catch (error: unknown) {
|
|
reportCommandError(error);
|
|
}
|
|
});
|
|
|
|
skill
|
|
.command('unregister <name>')
|
|
.description('Unregister a Mosaic skill from Claude Code')
|
|
.action((name: string) => {
|
|
try {
|
|
const result = unregisterSkill(name, paths);
|
|
console.log(
|
|
result.status === 'already-unregistered'
|
|
? `${name}: already unregistered`
|
|
: `${name}: unregistered`,
|
|
);
|
|
} catch (error: unknown) {
|
|
reportCommandError(error);
|
|
}
|
|
});
|
|
|
|
skill
|
|
.command('list')
|
|
.description('List registered, dangling, foreign, and unregistered skills')
|
|
.action(() => {
|
|
try {
|
|
const entries = listSkills(paths);
|
|
if (entries.length === 0) {
|
|
console.log('No Mosaic or Claude Code skills found.');
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
console.log(`${entry.status.padEnd(17)} ${displaySkillName(entry.name)}`);
|
|
}
|
|
} catch (error: unknown) {
|
|
reportCommandError(error);
|
|
}
|
|
});
|
|
}
|