#!/usr/bin/env npx tsx /** * scripts/skill-add.ts — Add a skill to the library catalog. * * Usage: * just skill-add local:.agent/skills/my-skill/SKILL.md * just skill-add codeberg:Clawdie/Skills/some-skill/SKILL.md * just skill-add github:disler/the-library/some-skill/SKILL.md * just skill-add raw:https://example.com/SKILL.md * * Fetches the SKILL.md, extracts id/description/tags from frontmatter, * and appends an entry to agent/library.yaml. */ import fs from 'fs'; import path from 'path'; import https from 'https'; import { parse, stringify } from 'yaml'; import { PROJECT_ROOT } from '../src/config.js'; import { reloadLibrary } from '../src/skill-library.js'; const LIBRARY_PATH = path.join(PROJECT_ROOT, 'agent', 'library.yaml'); const CACHE_DIR = path.join(PROJECT_ROOT, '.agent', 'library-cache'); const source = process.argv[2]; if (!source) { console.error('Usage: just skill-add '); console.error(' source: local:, codeberg://, github://, raw:'); process.exit(1); } // --------------------------------------------------------------------------- // Resolve source to a URL or local path // --------------------------------------------------------------------------- function sourceToUrl(src: string): string | null { if (src.startsWith('local:')) return null; if (src.startsWith('raw:')) return src.slice('raw:'.length); if (src.startsWith('codeberg:')) { const rest = src.slice('codeberg:'.length); const [owner, repo, ...fileParts] = rest.split('/'); return `https://codeberg.org/${owner}/${repo}/raw/branch/main/${fileParts.join('/')}`; } if (src.startsWith('github:')) { const rest = src.slice('github:'.length); const [owner, repo, ...fileParts] = rest.split('/'); return `https://raw.githubusercontent.com/${owner}/${repo}/main/${fileParts.join('/')}`; } throw new Error(`Unknown source prefix: ${src}`); } function fetchUrl(url: string): Promise { return new Promise((resolve, reject) => { https.get(url, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { fetchUrl(res.headers.location!).then(resolve).catch(reject); return; } if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); return; } const chunks: Buffer[] = []; res.on('data', (c: Buffer) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); res.on('error', reject); }).on('error', reject); }); } // --------------------------------------------------------------------------- // Parse SKILL.md frontmatter // --------------------------------------------------------------------------- interface SkillFrontmatter { name?: string; description?: string; tags?: string[]; } function parseFrontmatter(content: string): SkillFrontmatter { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return {}; try { return parse(match[1]) as SkillFrontmatter; } catch { return {}; } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main(): Promise { let content: string; const url = sourceToUrl(source); if (url) { console.log(`Fetching: ${url}`); content = await fetchUrl(url); // Cache it if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true }); const safe = source.replace(/[^a-zA-Z0-9._-]/g, '_'); fs.writeFileSync(path.join(CACHE_DIR, safe), content, 'utf-8'); console.log('Cached.'); } else { // local: const localPath = path.join(PROJECT_ROOT, source.slice('local:'.length)); const skillMd = fs.statSync(localPath).isDirectory() ? path.join(localPath, 'SKILL.md') : localPath; if (!fs.existsSync(skillMd)) { console.error(`Not found: ${skillMd}`); process.exit(1); } content = fs.readFileSync(skillMd, 'utf-8'); } const fm = parseFrontmatter(content); // Derive id from source path const parts = source.replace(/^(local:|codeberg:|github:|raw:)/, '').split('/'); const fileName = parts[parts.length - 1].replace(/\.md$/, ''); const dirName = parts[parts.length - 2] ?? fileName; const id = dirName === 'SKILL' ? fileName : dirName; const description = fm.description ?? '(no description)'; const tags = fm.tags ?? []; // Load existing library and append if (!fs.existsSync(LIBRARY_PATH)) { console.error(`Library not found: ${LIBRARY_PATH}`); process.exit(1); } const lib = parse(fs.readFileSync(LIBRARY_PATH, 'utf-8')) as { skills?: unknown[] }; lib.skills = lib.skills ?? []; // Check for duplicate const existing = (lib.skills as Array<{ id: string }>).find((s) => s.id === id); if (existing) { console.log(`Skill "${id}" already in library. Update library.yaml manually if needed.`); process.exit(0); } (lib.skills as unknown[]).push({ id, source, description, tags }); fs.writeFileSync(LIBRARY_PATH, stringify(lib), 'utf-8'); reloadLibrary(); console.log(`Added to library:`); console.log(` id: ${id}`); console.log(` source: ${source}`); console.log(` description: ${description}`); console.log(` tags: ${tags.join(', ') || '(none)'}`); console.log('\nEdit agent/library.yaml to adjust tags or description if needed.'); } main().catch((err: Error) => { console.error(err.message); process.exit(1); });