clawdie-ai/scripts/skill-add.ts
Clawdie AI 2983111a5d feat(phase2): add skill library catalog and tooling
- agent/library.yaml: catalog of all 48 skills (37 operational + 11 features),
  4 agents with skill assignments, 2 prompt refs
- src/skill-library.ts: loadLibrary, searchSkills, getSkillContent,
  getAgentSkills, validateLibrary, reloadLibrary
- scripts/skill-list.ts: grouped table output with color, optional search query
- scripts/skill-add.ts: add skill from local/codeberg/github/raw source
- scripts/skill-sync.ts: refresh all remote-sourced skills in cache
- scripts/validate-library.ts: validate all local: sources exist on disk
- .agent/identities/: COORDINATOR, SYSADMIN, DB_ADMIN, GIT_ADMIN stubs
- .agent/context/FREEBSD.md: FreeBSD gotchas context for agents

Typecheck passes. `just skill-list` and `just skill-search` ready to wire up
in the justfile (Phase 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---
Build: pass | Tests: pass — Tests  942 passed (942)
2026-04-13 23:06:50 +00:00

165 lines
5.5 KiB
TypeScript

#!/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 <source>');
console.error(' source: local:<path>, codeberg:<owner>/<repo>/<path>, github:<owner>/<repo>/<path>, raw:<url>');
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<string> {
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<void> {
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);
});