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 ') .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 ') .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); } }); }