Files
lettabot/src/skills/sync.ts

304 lines
9.7 KiB
TypeScript

/**
* Skills Sync - Interactive checklist to manage skills in working directory
*/
import { existsSync, readdirSync, cpSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import * as p from '@clack/prompts';
import { PROJECT_SKILLS_DIR, BUNDLED_SKILLS_DIR, GLOBAL_SKILLS_DIR, SKILLS_SH_DIR, WORKING_SKILLS_DIR, parseSkillFile } from './loader.js';
const HOME = process.env.HOME || process.env.USERPROFILE || '';
const TARGET_DIR = WORKING_SKILLS_DIR;
// Skill source directories
const CLAWDHUB_DIR = join(HOME, 'clawd', 'skills'); // ~/clawd/skills (ClawdHub)
const VERCEL_DIR = join(HOME, '.agents', 'skills'); // ~/.agents/skills (Vercel)
interface SkillInfo {
name: string;
description: string;
source: 'builtin' | 'global' | 'clawdhub' | 'vercel';
sourcePath: string;
installed: boolean;
}
/**
* Discover all available skills from all sources
*/
function discoverSkills(): SkillInfo[] {
const skills: SkillInfo[] = [];
const seen = new Set<string>();
// Get existing skills in target
const installedSkills = new Set<string>();
if (existsSync(TARGET_DIR)) {
for (const entry of readdirSync(TARGET_DIR, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
installedSkills.add(entry.name);
}
}
}
// Helper to add skills from a directory
const addFromDir = (dir: string, source: SkillInfo['source']) => {
if (!existsSync(dir)) return;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
if (seen.has(entry.name)) continue;
const skillPath = join(dir, entry.name, 'SKILL.md');
const skill = parseSkillFile(skillPath);
if (skill) {
seen.add(entry.name);
skills.push({
name: entry.name,
description: skill.description || '',
source,
sourcePath: join(dir, entry.name),
installed: installedSkills.has(entry.name),
});
}
}
} catch (e) {
// Ignore errors reading directories
}
};
// Discover from all sources (order matters - first source wins for duplicates).
// Priority matches the loader hierarchy: project (.skills/) > bundled (skills/) > external.
addFromDir(PROJECT_SKILLS_DIR, 'builtin'); // .skills/ project overrides
addFromDir(BUNDLED_SKILLS_DIR, 'builtin'); // skills/ bundled with repo
// ~/.letta/skills/ uses a category/skill-name nested structure (tools/, meta/, letta/, etc.)
if (existsSync(GLOBAL_SKILLS_DIR)) {
try {
for (const cat of readdirSync(GLOBAL_SKILLS_DIR, { withFileTypes: true })) {
if (cat.isDirectory() && !cat.name.startsWith('.')) {
addFromDir(join(GLOBAL_SKILLS_DIR, cat.name), 'global');
}
}
} catch { /* ignore */ }
}
addFromDir(CLAWDHUB_DIR, 'clawdhub');
addFromDir(VERCEL_DIR, 'vercel');
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Interactive skills sync with checklist
*/
export async function runSkillsSync(): Promise<void> {
p.intro('🔄 Skills Sync');
const skills = discoverSkills();
if (skills.length === 0) {
p.note(
'No skills found.\n\n' +
'Install skills with:\n' +
' npm run skill:install <name> (ClawdHub)\n' +
' npm run skills:add <owner/repo> (skills.sh)',
'No skills available'
);
p.outro('');
return;
}
const installedCount = skills.filter(s => s.installed).length;
p.log.info(`Target: ${TARGET_DIR}`);
p.log.info(`Found ${skills.length} skills (${installedCount} installed)`);
// Check which sources exist
const hasBuiltin = skills.some(s => s.source === 'builtin');
const hasGlobal = skills.some(s => s.source === 'global');
const hasClawdhub = existsSync(CLAWDHUB_DIR) && skills.some(s => s.source === 'clawdhub');
const hasVercel = existsSync(VERCEL_DIR) && skills.some(s => s.source === 'vercel');
// Build options grouped by source with headers
const options: Array<{ value: string; label: string; hint: string }> = [];
// Add Letta global skills section (~/.letta/skills/)
if (hasGlobal) {
options.push({ value: '__header_global__', label: `── Letta Skills ── (~/.letta/skills)`, hint: '' });
for (const skill of skills.filter(s => s.source === 'global')) {
const desc = skill.description || '';
options.push({
value: skill.name,
label: `${skill.name}`,
hint: desc.length > 60 ? desc.slice(0, 57) + '...' : desc,
});
}
}
// Add ClawdHub skills section
if (hasClawdhub) {
options.push({ value: '__header_clawdhub__', label: '── ClawdHub Skills ── (~/clawd/skills)', hint: '' });
for (const skill of skills.filter(s => s.source === 'clawdhub')) {
const desc = skill.description || '';
options.push({
value: skill.name,
label: `🦞 ${skill.name}`,
hint: desc.length > 60 ? desc.slice(0, 57) + '...' : desc,
});
}
}
// Add Vercel skills section
if (hasVercel) {
options.push({ value: '__header_vercel__', label: '── Vercel Skills ── (~/.agents/skills)', hint: '' });
for (const skill of skills.filter(s => s.source === 'vercel')) {
const desc = skill.description || '';
options.push({
value: skill.name,
label: `🔼 ${skill.name}`,
hint: desc.length > 60 ? desc.slice(0, 57) + '...' : desc,
});
}
}
// Add built-in skills section
if (hasBuiltin) {
options.push({ value: '__header_builtin__', label: '── Built-in Skills ──', hint: '' });
for (const skill of skills.filter(s => s.source === 'builtin')) {
const desc = skill.description || '';
options.push({
value: skill.name,
label: `📦 ${skill.name}`,
hint: desc.length > 60 ? desc.slice(0, 57) + '...' : desc,
});
}
}
// Pre-check currently installed skills so the user edits from current state
const currentlyInstalled = skills.filter(s => s.installed).map(s => s.name);
const selected = await p.multiselect({
message: 'Enable skills (space=toggle, enter=confirm):',
options,
initialValues: currentlyInstalled,
required: false,
});
if (p.isCancel(selected)) {
p.cancel('Cancelled');
return;
}
// Filter out header items
const selectedSkills = (selected as string[]).filter(s => !s.startsWith('__header_'));
const selectedSet = new Set(selectedSkills);
// Determine what to add and remove
const toAdd = skills.filter(s => selectedSet.has(s.name) && !s.installed);
const toRemove = skills.filter(s => !selectedSet.has(s.name) && s.installed);
if (toAdd.length === 0 && toRemove.length === 0) {
p.log.info('No changes needed');
p.log.info(`Skills directory: ${TARGET_DIR}`);
p.outro('✨ Done!');
return;
}
// Confirm changes
const confirmMsg = [];
if (toAdd.length > 0) confirmMsg.push(`Add ${toAdd.length} skill(s)`);
if (toRemove.length > 0) confirmMsg.push(`Remove ${toRemove.length} skill(s)`);
const confirmed = await p.confirm({
message: `${confirmMsg.join(', ')}?`,
});
if (!confirmed || p.isCancel(confirmed)) {
p.cancel('Cancelled');
return;
}
// Ensure target directory exists
mkdirSync(TARGET_DIR, { recursive: true });
// Add new skills
for (const skill of toAdd) {
const dest = join(TARGET_DIR, skill.name);
try {
cpSync(skill.sourcePath, dest, { recursive: true });
p.log.success(`Added: ${skill.name}`);
} catch (e) {
p.log.error(`Failed to add ${skill.name}: ${e}`);
}
}
// Remove skills
for (const skill of toRemove) {
const dest = join(TARGET_DIR, skill.name);
try {
rmSync(dest, { recursive: true, force: true });
p.log.warn(`Removed: ${skill.name}`);
} catch (e) {
p.log.error(`Failed to remove ${skill.name}: ${e}`);
}
}
p.log.info(`Skills directory: ${TARGET_DIR}`);
p.outro(`✨ Added ${toAdd.length}, removed ${toRemove.length} skill(s)`);
}
/**
* Non-interactively disable a single skill by name.
*/
export function disableSkill(name: string): void {
const dest = join(TARGET_DIR, name);
if (!existsSync(dest)) {
p.log.warn(`Skill '${name}' is not enabled.`);
return;
}
try {
rmSync(dest, { recursive: true, force: true });
p.log.success(`Disabled skill '${name}'.`);
} catch (e) {
p.log.error(`Failed to disable '${name}': ${e}`);
process.exit(1);
}
}
/**
* Non-interactively enable a single skill by name.
* Searches BUNDLED_SKILLS_DIR, then GLOBAL_SKILLS_DIR, then SKILLS_SH_DIR.
*/
export function enableSkill(name: string): void {
mkdirSync(TARGET_DIR, { recursive: true });
const dest = join(TARGET_DIR, name);
if (existsSync(dest)) {
p.log.warn(`Skill '${name}' is already enabled.`);
return;
}
// Build search list: flat dirs first, then nested GLOBAL_SKILLS_DIR categories
const flatDirs = [PROJECT_SKILLS_DIR, BUNDLED_SKILLS_DIR, SKILLS_SH_DIR];
const searchDirs: string[] = [...flatDirs];
if (existsSync(GLOBAL_SKILLS_DIR)) {
try {
for (const cat of readdirSync(GLOBAL_SKILLS_DIR, { withFileTypes: true })) {
if (cat.isDirectory() && !cat.name.startsWith('.')) {
searchDirs.push(join(GLOBAL_SKILLS_DIR, cat.name));
}
}
} catch { /* ignore */ }
}
for (const dir of searchDirs) {
const src = join(dir, name);
if (existsSync(src) && existsSync(join(src, 'SKILL.md'))) {
cpSync(src, dest, { recursive: true });
p.log.success(`Enabled skill '${name}'.`);
return;
}
}
p.log.error(`Skill '${name}' not found. Run 'lettabot skills status' to see available skills.`);
process.exit(1);
}