From cec499fbb688bf032a1505be1c0f31711bd5f2fd Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sat, 24 Jan 2026 23:28:43 -0700 Subject: [PATCH] added scripts --- cli.js | 265 ++++++++++++++++++++++++++++++++++++++ install.sh | 168 ++++++++++++++++++++++++ package.json | 31 +++++ scripts/generate_index.py | 60 +++++++++ scripts/validate.py | 104 +++++++++++++++ 5 files changed, 628 insertions(+) create mode 100644 cli.js create mode 100644 install.sh create mode 100644 package.json create mode 100644 scripts/generate_index.py create mode 100644 scripts/validate.py diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..80f90c3 --- /dev/null +++ b/cli.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const SKILLS_DIR = path.join(__dirname, 'skills'); + +// Agent configurations +const AGENTS = { + 'claude-code': { + name: 'Claude Code', + global: path.join(os.homedir(), '.claude', 'skills'), + local: '.claude/skills', + detect: () => fs.existsSync(path.join(os.homedir(), '.claude')) + }, + 'opencode': { + name: 'OpenCode', + global: path.join(os.homedir(), '.config', 'opencode', 'skills'), + local: '.opencode/skills', + detect: () => fs.existsSync(path.join(os.homedir(), '.config', 'opencode')) + }, + 'cursor': { + name: 'Cursor', + global: path.join(os.homedir(), '.cursor', 'skills'), + local: '.cursor/skills', + detect: () => fs.existsSync(path.join(os.homedir(), '.cursor')) + }, + 'gemini': { + name: 'Gemini CLI', + global: path.join(os.homedir(), '.gemini', 'skills'), + local: '.gemini/skills', + detect: () => fs.existsSync(path.join(os.homedir(), '.gemini')) + } +}; + +function getAvailableSkills() { + if (!fs.existsSync(SKILLS_DIR)) return []; + return fs.readdirSync(SKILLS_DIR).filter(name => { + const skillPath = path.join(SKILLS_DIR, name, 'SKILL.md'); + return fs.existsSync(skillPath); + }); +} + +function detectAgents() { + return Object.entries(AGENTS) + .filter(([_, config]) => config.detect()) + .map(([id, config]) => ({ id, ...config })); +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function createSymlink(source, target) { + if (fs.existsSync(target)) { + const stats = fs.lstatSync(target); + if (stats.isSymbolicLink()) { + fs.unlinkSync(target); + } else { + console.log(` skip: ${path.basename(target)} (already exists, not a symlink)`); + return false; + } + } + fs.symlinkSync(source, target); + return true; +} + +function install(args) { + const skills = getAvailableSkills(); + const detectedAgents = detectAgents(); + + // Parse arguments + let targetSkills = []; + let targetAgent = null; + let useGlobal = true; + + for (const arg of args) { + if (arg === '--all') { + targetSkills = skills; + } else if (arg === '--local') { + useGlobal = false; + } else if (arg.startsWith('--agent=')) { + targetAgent = arg.split('=')[1]; + } else if (!arg.startsWith('-')) { + if (skills.includes(arg)) { + targetSkills.push(arg); + } else { + console.log(`warning: skill '${arg}' not found, skipping`); + } + } + } + + // Default to all skills if none specified + if (targetSkills.length === 0) { + targetSkills = skills; + } + + // Determine which agents to install to + let agents = []; + if (targetAgent) { + if (AGENTS[targetAgent]) { + agents = [{ id: targetAgent, ...AGENTS[targetAgent] }]; + } else { + console.log(`error: unknown agent '${targetAgent}'`); + console.log(`available agents: ${Object.keys(AGENTS).join(', ')}`); + process.exit(1); + } + } else if (detectedAgents.length > 0) { + agents = detectedAgents; + } else { + // Default to claude-code if nothing detected + agents = [{ id: 'claude-code', ...AGENTS['claude-code'] }]; + } + + console.log(`\nvfx-skills installer\n`); + console.log(`skills to install: ${targetSkills.length}`); + console.log(`target agents: ${agents.map(a => a.name).join(', ')}`); + console.log(`install type: ${useGlobal ? 'global' : 'local'}\n`); + + for (const agent of agents) { + const targetDir = useGlobal ? agent.global : path.join(process.cwd(), agent.local); + console.log(`installing to ${agent.name} (${targetDir})...`); + + ensureDir(targetDir); + + let installed = 0; + let skipped = 0; + + for (const skill of targetSkills) { + const source = path.join(SKILLS_DIR, skill); + const target = path.join(targetDir, skill); + + if (createSymlink(source, target)) { + installed++; + } else { + skipped++; + } + } + + console.log(` done: ${installed} installed, ${skipped} skipped\n`); + } + + console.log('installation complete!'); + console.log('restart your agent to load the new skills.\n'); +} + +function list() { + const skills = getAvailableSkills(); + console.log(`\navailable skills (${skills.length}):\n`); + + for (const skill of skills) { + const skillPath = path.join(SKILLS_DIR, skill, 'SKILL.md'); + const content = fs.readFileSync(skillPath, 'utf-8'); + + // Extract description from frontmatter + const match = content.match(/^---\s*\n[\s\S]*?description:\s*(.+)\n[\s\S]*?---/); + const desc = match ? match[1].trim() : '(no description)'; + + console.log(` ${skill}`); + console.log(` ${desc}\n`); + } +} + +function uninstall(args) { + const detectedAgents = detectAgents(); + const skills = args.filter(a => !a.startsWith('-')); + + if (skills.length === 0) { + console.log('usage: vfx-skills uninstall [skill-name...]'); + process.exit(1); + } + + let targetAgent = null; + for (const arg of args) { + if (arg.startsWith('--agent=')) { + targetAgent = arg.split('=')[1]; + } + } + + let agents = targetAgent + ? [{ id: targetAgent, ...AGENTS[targetAgent] }] + : detectedAgents.length > 0 + ? detectedAgents + : [{ id: 'claude-code', ...AGENTS['claude-code'] }]; + + for (const agent of agents) { + console.log(`removing from ${agent.name}...`); + + for (const skill of skills) { + const target = path.join(agent.global, skill); + if (fs.existsSync(target)) { + const stats = fs.lstatSync(target); + if (stats.isSymbolicLink()) { + fs.unlinkSync(target); + console.log(` removed: ${skill}`); + } else { + console.log(` skip: ${skill} (not a symlink)`); + } + } else { + console.log(` skip: ${skill} (not found)`); + } + } + } + + console.log('\ndone!'); +} + +function help() { + console.log(` +vfx-skills - VFX skills for Claude Code and other AI agents + +usage: + npx vfx-skills install [skills...] [options] + npx vfx-skills uninstall + npx vfx-skills list + +commands: + install Install skills (default: all skills, global install) + uninstall Remove installed skills + list List available skills + +install options: + --all Install all skills (default if no skills specified) + --local Install to current project instead of global + --agent= Target specific agent (claude-code, opencode, cursor, gemini) + +examples: + npx vfx-skills install # Install all skills globally + npx vfx-skills install nuke-scripting # Install specific skill + npx vfx-skills install --local # Install to current project + npx vfx-skills install --agent=opencode # Install for OpenCode only + npx vfx-skills list # Show available skills + npx vfx-skills uninstall aces-vfx # Remove a skill +`); +} + +// Main +const [,, command, ...args] = process.argv; + +switch (command) { + case 'install': + install(args); + break; + case 'uninstall': + case 'remove': + uninstall(args); + break; + case 'list': + case 'ls': + list(); + break; + case 'help': + case '--help': + case '-h': + case undefined: + help(); + break; + default: + console.log(`unknown command: ${command}`); + help(); + process.exit(1); +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..677bf9c --- /dev/null +++ b/install.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# vfx-skills installer +# Symlinks VFX skills to your AI agent's skills directory + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILLS_DIR="$SCRIPT_DIR/skills" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +print_header() { + echo "" + echo "vfx-skills installer" + echo "====================" + echo "" +} + +detect_agents() { + local agents=() + + if [ -d "$HOME/.claude" ]; then + agents+=("claude-code:$HOME/.claude/skills") + fi + + if [ -d "$HOME/.config/opencode" ]; then + agents+=("opencode:$HOME/.config/opencode/skills") + fi + + if [ -d "$HOME/.cursor" ]; then + agents+=("cursor:$HOME/.cursor/skills") + fi + + if [ -d "$HOME/.gemini" ]; then + agents+=("gemini:$HOME/.gemini/skills") + fi + + echo "${agents[@]}" +} + +install_skills() { + local target_dir="$1" + local agent_name="$2" + + echo -e "installing to ${GREEN}$agent_name${NC} ($target_dir)..." + + # Create target directory if it doesn't exist + mkdir -p "$target_dir" + + local installed=0 + local skipped=0 + + for skill_dir in "$SKILLS_DIR"/*/; do + if [ -f "$skill_dir/SKILL.md" ]; then + skill_name=$(basename "$skill_dir") + target="$target_dir/$skill_name" + + if [ -L "$target" ]; then + # Remove existing symlink + rm "$target" + elif [ -e "$target" ]; then + echo -e " ${YELLOW}skip${NC}: $skill_name (already exists, not a symlink)" + ((skipped++)) + continue + fi + + ln -s "$skill_dir" "$target" + ((installed++)) + fi + done + + echo -e " done: ${GREEN}$installed installed${NC}, $skipped skipped" + echo "" +} + +# Parse arguments +TARGET_AGENT="" +LOCAL_INSTALL=false + +while [[ $# -gt 0 ]]; do + case $1 in + --agent=*) + TARGET_AGENT="${1#*=}" + shift + ;; + --local) + LOCAL_INSTALL=true + shift + ;; + --help|-h) + echo "usage: ./install.sh [options]" + echo "" + echo "options:" + echo " --agent=NAME Install for specific agent (claude-code, opencode, cursor, gemini)" + echo " --local Install to current directory instead of global" + echo " --help Show this help" + exit 0 + ;; + *) + echo "unknown option: $1" + exit 1 + ;; + esac +done + +print_header + +# Check skills directory exists +if [ ! -d "$SKILLS_DIR" ]; then + echo -e "${RED}error${NC}: skills directory not found at $SKILLS_DIR" + exit 1 +fi + +# Count available skills +skill_count=$(find "$SKILLS_DIR" -maxdepth 2 -name "SKILL.md" | wc -l) +echo "found $skill_count skills" + +if $LOCAL_INSTALL; then + # Install to current directory + echo "installing to current project..." + install_skills ".claude/skills" "local project" +else + # Detect and install to agents + if [ -n "$TARGET_AGENT" ]; then + case $TARGET_AGENT in + claude-code) + install_skills "$HOME/.claude/skills" "Claude Code" + ;; + opencode) + install_skills "$HOME/.config/opencode/skills" "OpenCode" + ;; + cursor) + install_skills "$HOME/.cursor/skills" "Cursor" + ;; + gemini) + install_skills "$HOME/.gemini/skills" "Gemini CLI" + ;; + *) + echo -e "${RED}error${NC}: unknown agent '$TARGET_AGENT'" + echo "available agents: claude-code, opencode, cursor, gemini" + exit 1 + ;; + esac + else + # Auto-detect agents + agents=$(detect_agents) + + if [ -z "$agents" ]; then + # Default to claude-code if nothing detected + echo "no agents detected, defaulting to Claude Code..." + install_skills "$HOME/.claude/skills" "Claude Code" + else + for agent in $agents; do + name="${agent%%:*}" + path="${agent#*:}" + install_skills "$path" "$name" + done + fi + fi +fi + +echo -e "${GREEN}installation complete!${NC}" +echo "restart your agent to load the new skills." +echo "" diff --git a/package.json b/package.json new file mode 100644 index 0000000..67d8c46 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "vfx-skills", + "version": "1.0.0", + "description": "Claude Code skills for VFX workflows - Nuke, ACES, ffmpeg, EXR wrangling", + "keywords": [ + "vfx", + "nuke", + "compositing", + "claude-code", + "ai-skills", + "aces", + "ffmpeg", + "exr" + ], + "author": "Biohazard VFX", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/biohazard-vfx/vfx-skills.git" + }, + "bin": { + "vfx-skills": "./cli.js" + }, + "files": [ + "cli.js", + "skills/**/*" + ], + "engines": { + "node": ">=18" + } +} diff --git a/scripts/generate_index.py b/scripts/generate_index.py new file mode 100644 index 0000000..51bf121 --- /dev/null +++ b/scripts/generate_index.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Generate skills_index.json from all skills in the repository. +""" + +import os +import json +import re + +def generate_index(skills_dir, output_file): + print(f"generating index from: {skills_dir}\n") + skills = [] + + for entry in sorted(os.listdir(skills_dir)): + skill_path = os.path.join(skills_dir, entry) + skill_file = os.path.join(skill_path, "SKILL.md") + + if not os.path.isdir(skill_path) or not os.path.exists(skill_file): + continue + + with open(skill_file, 'r', encoding='utf-8') as f: + content = f.read() + + skill_info = { + "id": entry, + "name": entry.replace("-", " ").title(), + "description": "", + "path": f"skills/{entry}" + } + + # Extract from frontmatter + fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL) + if fm_match: + fm_content = fm_match.group(1) + + name_match = re.search(r'^name:\s*(.+)$', fm_content, re.MULTILINE) + if name_match: + skill_info["name"] = name_match.group(1).strip() + + desc_match = re.search(r'^description:\s*(.+)$', fm_content, re.MULTILINE) + if desc_match: + skill_info["description"] = desc_match.group(1).strip() + + skills.append(skill_info) + print(f" {skill_info['id']}: {skill_info['description'][:60]}...") + + # Write index + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(skills, f, indent=2) + + print(f"\ngenerated index with {len(skills)} skills at: {output_file}") + return skills + + +if __name__ == "__main__": + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_path = os.path.join(base_dir, "skills") + output_path = os.path.join(base_dir, "skills_index.json") + + generate_index(skills_path, output_path) diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100644 index 0000000..f09b097 --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Validate all skills in the repository. +Checks for required frontmatter fields and proper structure. +""" + +import os +import re +import sys + +def validate_skills(skills_dir): + print(f"validating skills in: {skills_dir}\n") + errors = [] + warnings = [] + skill_count = 0 + + for entry in sorted(os.listdir(skills_dir)): + skill_path = os.path.join(skills_dir, entry) + skill_file = os.path.join(skill_path, "SKILL.md") + + if not os.path.isdir(skill_path): + continue + + if not os.path.exists(skill_file): + errors.append(f"{entry}: missing SKILL.md") + continue + + skill_count += 1 + + with open(skill_file, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for frontmatter + if not content.strip().startswith("---"): + errors.append(f"{entry}: missing frontmatter (must start with ---)") + continue + + # Extract frontmatter + fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL) + if not fm_match: + errors.append(f"{entry}: malformed frontmatter") + continue + + fm_content = fm_match.group(1) + + # Check required fields + name_match = re.search(r'^name:\s*(.+)$', fm_content, re.MULTILINE) + desc_match = re.search(r'^description:\s*(.+)$', fm_content, re.MULTILINE) + + if not name_match: + errors.append(f"{entry}: frontmatter missing 'name' field") + else: + name = name_match.group(1).strip() + # Validate name format + if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name): + errors.append(f"{entry}: name must be lowercase alphanumeric with hyphens") + if name != entry: + warnings.append(f"{entry}: name '{name}' doesn't match directory name") + + if not desc_match: + errors.append(f"{entry}: frontmatter missing 'description' field") + else: + desc = desc_match.group(1).strip() + if len(desc) < 10: + warnings.append(f"{entry}: description seems too short") + if len(desc) > 1024: + errors.append(f"{entry}: description exceeds 1024 characters") + + # Check for content after frontmatter + body = content[fm_match.end():].strip() + if len(body) < 50: + warnings.append(f"{entry}: skill content seems too short") + + # Print results + print(f"checked {skill_count} skills\n") + + if warnings: + print("warnings:") + for w in warnings: + print(f" {w}") + print() + + if errors: + print("errors:") + for e in errors: + print(f" {e}") + print() + print(f"validation failed with {len(errors)} errors") + return False + + print("all skills passed validation!") + return True + + +if __name__ == "__main__": + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_path = os.path.join(base_dir, "skills") + + if not os.path.exists(skills_path): + print(f"error: skills directory not found at {skills_path}") + sys.exit(1) + + success = validate_skills(skills_path) + sys.exit(0 if success else 1)