304 lines
9.7 KiB
TypeScript
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);
|
|
}
|