|
#!/usr/bin/env node |
|
|
|
/** |
|
* Claude Code Skills Installer |
|
* Discovers, installs, and updates Claude Code skills from GitHub Gists |
|
* Created by Robin van Baalen (https://robinvanbaalen.nl) |
|
*/ |
|
|
|
import { execSync } from 'child_process'; |
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs'; |
|
import { createHash } from 'crypto'; |
|
import { homedir } from 'os'; |
|
import { join } from 'path'; |
|
import { fileURLToPath } from 'url'; |
|
import * as readline from 'readline'; |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Self-update check |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
const SCRIPT_GIST_URL = 'https://gist.githubusercontent.com/rvanbaalen/2e4c2840d06de810f771a4514f97c6da/raw/claude-skills.js'; |
|
|
|
async function checkForScriptUpdate() { |
|
// Get current script path |
|
const currentPath = fileURLToPath(import.meta.url); |
|
|
|
// Skip if running from /tmp (one-time run) |
|
if (currentPath.startsWith('/tmp') || currentPath.startsWith('/var')) { |
|
return; |
|
} |
|
|
|
try { |
|
const localContent = readFileSync(currentPath, 'utf-8'); |
|
const localHash = createHash('md5').update(localContent).digest('hex'); |
|
|
|
const remoteContent = execSync(`curl -fsSL "${SCRIPT_GIST_URL}"`, { |
|
encoding: 'utf-8', |
|
stdio: ['pipe', 'pipe', 'pipe'], |
|
}); |
|
const remoteHash = createHash('md5').update(remoteContent).digest('hex'); |
|
|
|
if (localHash !== remoteHash) { |
|
console.log(); |
|
console.log(`${c.yellow}⚠${c.reset} A newer version of claude-skill-gists is available.`); |
|
console.log(); |
|
|
|
const answer = await promptYesNo('Would you like to update now?'); |
|
|
|
if (answer) { |
|
writeFileSync(currentPath, remoteContent); |
|
console.log(`${c.green}✓${c.reset} Updated successfully. Please run the command again.`); |
|
process.exit(0); |
|
} |
|
console.log(); |
|
} |
|
} catch (e) { |
|
// Silently ignore update check failures |
|
} |
|
} |
|
|
|
async function promptYesNo(question) { |
|
const rl = readline.createInterface({ |
|
input: process.stdin, |
|
output: process.stdout, |
|
}); |
|
|
|
return new Promise((resolve) => { |
|
rl.question(`${c.magenta}▸${c.reset} ${question} [y/N] `, (answer) => { |
|
rl.close(); |
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); |
|
}); |
|
}); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Config |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
let githubUser = 'rvanbaalen'; |
|
const SKILLS_DIR = join(homedir(), '.claude', 'skills'); |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Colors |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
const c = { |
|
reset: '\x1b[0m', |
|
bold: '\x1b[1m', |
|
dim: '\x1b[2m', |
|
red: '\x1b[31m', |
|
green: '\x1b[32m', |
|
yellow: '\x1b[33m', |
|
blue: '\x1b[34m', |
|
magenta: '\x1b[35m', |
|
cyan: '\x1b[36m', |
|
white: '\x1b[37m', |
|
bgBlue: '\x1b[44m', |
|
bgGray: '\x1b[100m', |
|
}; |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// UI Helpers |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
function clearScreen() { |
|
process.stdout.write('\x1b[2J\x1b[H'); |
|
} |
|
|
|
function hideCursor() { |
|
process.stdout.write('\x1b[?25l'); |
|
} |
|
|
|
function showCursor() { |
|
process.stdout.write('\x1b[?25h'); |
|
} |
|
|
|
function printHeader() { |
|
console.log(`${c.cyan}`); |
|
console.log(' ┌───────────────────────────────────────────────────────┐'); |
|
console.log(' │ │'); |
|
console.log(` │ ${c.bold}Claude Code Skills Installer${c.reset}${c.cyan} │`); |
|
console.log(` │ ${c.dim}Discover and install skills from GitHub Gists${c.reset}${c.cyan} │`); |
|
console.log(' │ │'); |
|
console.log(' └───────────────────────────────────────────────────────┘'); |
|
console.log(`${c.reset}`); |
|
} |
|
|
|
function printFooter() { |
|
console.log(); |
|
console.log(`${c.dim}─────────────────────────────────────────────────────────${c.reset}`); |
|
console.log(`${c.dim}Created by Robin van Baalen • robinvanbaalen.nl${c.reset}`); |
|
console.log(); |
|
} |
|
|
|
const log = { |
|
info: (msg) => console.log(`${c.blue}ℹ${c.reset} ${msg}`), |
|
success: (msg) => console.log(`${c.green}✓${c.reset} ${msg}`), |
|
warn: (msg) => console.log(`${c.yellow}⚠${c.reset} ${msg}`), |
|
error: (msg) => console.log(`${c.red}✗${c.reset} ${msg}`), |
|
step: (msg) => console.log(`${c.magenta}▸${c.reset} ${msg}`), |
|
}; |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Utilities |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
function md5(content) { |
|
return createHash('md5').update(content).digest('hex'); |
|
} |
|
|
|
function ghApi(endpoint) { |
|
try { |
|
const result = execSync(`gh api "${endpoint}" --paginate`, { |
|
encoding: 'utf-8', |
|
stdio: ['pipe', 'pipe', 'pipe'], |
|
}); |
|
return JSON.parse(result); |
|
} catch (e) { |
|
return null; |
|
} |
|
} |
|
|
|
function fetchUrl(url) { |
|
try { |
|
return execSync(`curl -fsSL "${url}"`, { |
|
encoding: 'utf-8', |
|
stdio: ['pipe', 'pipe', 'pipe'], |
|
}); |
|
} catch (e) { |
|
return null; |
|
} |
|
} |
|
|
|
function checkDependencies() { |
|
const missing = []; |
|
|
|
try { |
|
execSync('gh --version', { stdio: 'pipe' }); |
|
} catch { |
|
missing.push('gh (GitHub CLI)'); |
|
} |
|
|
|
try { |
|
execSync('curl --version', { stdio: 'pipe' }); |
|
} catch { |
|
missing.push('curl'); |
|
} |
|
|
|
if (missing.length > 0) { |
|
log.error(`Missing dependencies: ${missing.join(', ')}`); |
|
console.log(); |
|
log.info('Please install the missing dependencies and try again.'); |
|
process.exit(1); |
|
} |
|
|
|
// Check gh auth |
|
try { |
|
execSync('gh auth status', { stdio: 'pipe' }); |
|
} catch { |
|
log.error('GitHub CLI is not authenticated'); |
|
console.log(); |
|
log.info(`Please run: ${c.cyan}gh auth login${c.reset}`); |
|
process.exit(1); |
|
} |
|
} |
|
|
|
function parseYamlFrontmatter(content) { |
|
const lines = content.split('\n'); |
|
if (lines[0] !== '---') return null; |
|
|
|
const endIndex = lines.indexOf('---', 1); |
|
if (endIndex === -1) return null; |
|
|
|
const frontmatter = {}; |
|
for (let i = 1; i < endIndex; i++) { |
|
const match = lines[i].match(/^(\w+):\s*(.+)$/); |
|
if (match) { |
|
frontmatter[match[1]] = match[2].replace(/^["']|["']$/g, ''); |
|
} |
|
} |
|
return frontmatter; |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Core Functions |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
async function fetchSkills() { |
|
log.step(`Fetching gists from ${c.cyan}@${githubUser}${c.reset}...`); |
|
|
|
const gists = ghApi(`users/${githubUser}/gists`); |
|
if (!gists) { |
|
log.error(`Failed to fetch gists from @${githubUser}`); |
|
process.exit(1); |
|
} |
|
|
|
const skills = []; |
|
let updates = 0; |
|
|
|
for (const gist of gists) { |
|
const files = Object.keys(gist.files); |
|
if (!files.includes('SKILL.md')) continue; |
|
|
|
const rawUrl = gist.files['SKILL.md'].raw_url; |
|
const content = fetchUrl(rawUrl); |
|
if (!content) continue; |
|
|
|
const frontmatter = parseYamlFrontmatter(content); |
|
if (!frontmatter?.name) continue; |
|
|
|
const name = frontmatter.name; |
|
const description = frontmatter.description || 'No description'; |
|
const installPath = join(SKILLS_DIR, name, 'SKILL.md'); |
|
|
|
let status = 'new'; |
|
if (existsSync(installPath)) { |
|
const localContent = readFileSync(installPath, 'utf-8'); |
|
if (md5(localContent) !== md5(content)) { |
|
status = 'update'; |
|
updates++; |
|
} else { |
|
status = 'installed'; |
|
} |
|
} |
|
|
|
skills.push({ |
|
name, |
|
description, |
|
gistId: gist.id, |
|
rawUrl, |
|
content, |
|
status, |
|
selected: status === 'update', |
|
}); |
|
} |
|
|
|
if (skills.length === 0) { |
|
log.warn(`No Claude Code skills found in @${githubUser}'s gists`); |
|
process.exit(0); |
|
} |
|
|
|
log.success(`Found ${c.white}${c.bold}${skills.length}${c.reset} skill(s)`); |
|
if (updates > 0) { |
|
log.info(`${c.yellow}${updates}${c.reset} update(s) available`); |
|
} |
|
console.log(); |
|
|
|
return skills; |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Direct Install from Gist ID |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
async function installFromGistId(gistId) { |
|
printHeader(); |
|
checkDependencies(); |
|
|
|
log.step(`Fetching gist ${c.cyan}${gistId}${c.reset}...`); |
|
|
|
const gist = ghApi(`gists/${gistId}`); |
|
if (!gist) { |
|
log.error('Failed to fetch gist. Check if the gist ID is correct.'); |
|
process.exit(1); |
|
} |
|
|
|
const files = Object.keys(gist.files); |
|
|
|
// Check for SKILL.md (case-insensitive) |
|
const skillFile = files.find(f => f.toLowerCase() === 'skill.md'); |
|
if (!skillFile) { |
|
log.error('This gist does not contain a SKILL.md file.'); |
|
console.log(); |
|
log.info('A valid Claude Code skill gist must contain a SKILL.md file.'); |
|
process.exit(1); |
|
} |
|
|
|
const rawUrl = gist.files[skillFile].raw_url; |
|
const content = fetchUrl(rawUrl); |
|
if (!content) { |
|
log.error('Failed to fetch skill content.'); |
|
process.exit(1); |
|
} |
|
|
|
const frontmatter = parseYamlFrontmatter(content); |
|
if (!frontmatter?.name) { |
|
log.error('SKILL.md is missing required "name" field in frontmatter.'); |
|
console.log(); |
|
log.info('The skill file must have YAML frontmatter with at least a "name" field.'); |
|
process.exit(1); |
|
} |
|
|
|
const name = frontmatter.name; |
|
const description = frontmatter.description || 'No description'; |
|
const installDir = join(SKILLS_DIR, name); |
|
const installPath = join(installDir, 'SKILL.md'); |
|
|
|
// Check if already installed |
|
let isUpdate = false; |
|
if (existsSync(installPath)) { |
|
const localContent = readFileSync(installPath, 'utf-8'); |
|
if (md5(localContent) === md5(content)) { |
|
log.success(`Skill ${c.cyan}${name}${c.reset} is already installed and up to date.`); |
|
printFooter(); |
|
return; |
|
} |
|
isUpdate = true; |
|
} |
|
|
|
console.log(); |
|
console.log(` ${c.bold}Skill:${c.reset} ${c.cyan}${name}${c.reset}`); |
|
console.log(` ${c.bold}Description:${c.reset} ${c.dim}${description.slice(0, 50)}${c.reset}`); |
|
console.log(` ${c.bold}Action:${c.reset} ${isUpdate ? 'Update' : 'Install'}`); |
|
console.log(); |
|
|
|
const confirm = await promptYesNo(`${isUpdate ? 'Update' : 'Install'} this skill?`); |
|
|
|
if (!confirm) { |
|
console.log(); |
|
log.info('Installation cancelled.'); |
|
printFooter(); |
|
return; |
|
} |
|
|
|
try { |
|
mkdirSync(installDir, { recursive: true }); |
|
|
|
if (existsSync(installPath)) { |
|
writeFileSync(`${installPath}.bak`, readFileSync(installPath)); |
|
} |
|
|
|
writeFileSync(installPath, content); |
|
|
|
console.log(); |
|
if (isUpdate) { |
|
log.success(`Updated ${c.cyan}${name}${c.reset}`); |
|
} else { |
|
log.success(`Installed ${c.cyan}${name}${c.reset}`); |
|
} |
|
console.log(); |
|
console.log(` ${c.dim}Location:${c.reset} ${installPath}`); |
|
console.log(); |
|
console.log(`${c.bold}Usage:${c.reset}`); |
|
console.log(` In Claude Code, run ${c.cyan}/${name}${c.reset} to invoke this skill`); |
|
|
|
} catch (e) { |
|
log.error(`Failed to install: ${e.message}`); |
|
process.exit(1); |
|
} |
|
|
|
printFooter(); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Direct Uninstall by Skill Name |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
async function uninstallByName(skillName) { |
|
printHeader(); |
|
|
|
const skillDir = join(SKILLS_DIR, skillName); |
|
const skillPath = join(skillDir, 'SKILL.md'); |
|
|
|
if (!existsSync(skillPath)) { |
|
log.error(`Skill ${c.cyan}${skillName}${c.reset} is not installed.`); |
|
console.log(); |
|
log.info(`Run ${c.cyan}claude-skill-gists list${c.reset} to see installed skills.`); |
|
printFooter(); |
|
process.exit(1); |
|
} |
|
|
|
// Read skill info for display |
|
const content = readFileSync(skillPath, 'utf-8'); |
|
const frontmatter = parseYamlFrontmatter(content); |
|
const description = frontmatter?.description || 'No description'; |
|
|
|
console.log(` ${c.bold}Skill:${c.reset} ${c.cyan}${skillName}${c.reset}`); |
|
console.log(` ${c.bold}Description:${c.reset} ${c.dim}${description.slice(0, 50)}${c.reset}`); |
|
console.log(` ${c.bold}Location:${c.reset} ${skillDir}`); |
|
console.log(); |
|
|
|
const confirm = await promptYesNo(`Uninstall this skill?`); |
|
|
|
if (!confirm) { |
|
console.log(); |
|
log.info('Uninstall cancelled.'); |
|
printFooter(); |
|
return; |
|
} |
|
|
|
try { |
|
rmSync(skillDir, { recursive: true, force: true }); |
|
console.log(); |
|
log.success(`Removed ${c.cyan}${skillName}${c.reset}`); |
|
} catch (e) { |
|
log.error(`Failed to uninstall: ${e.message}`); |
|
process.exit(1); |
|
} |
|
|
|
printFooter(); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// List Skills from GitHub User |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
async function listFromUser(username) { |
|
clearScreen(); |
|
printHeader(); |
|
checkDependencies(); |
|
|
|
githubUser = username; |
|
|
|
log.step(`Fetching skills from ${c.cyan}@${username}${c.reset}...`); |
|
|
|
const gists = ghApi(`users/${username}/gists`); |
|
if (!gists) { |
|
log.error(`Failed to fetch gists from @${username}`); |
|
console.log(); |
|
log.info('Check if the username is correct and the user has public gists.'); |
|
printFooter(); |
|
process.exit(1); |
|
} |
|
|
|
const skills = []; |
|
|
|
for (const gist of gists) { |
|
const files = Object.keys(gist.files); |
|
if (!files.includes('SKILL.md')) continue; |
|
|
|
const rawUrl = gist.files['SKILL.md'].raw_url; |
|
const content = fetchUrl(rawUrl); |
|
if (!content) continue; |
|
|
|
const frontmatter = parseYamlFrontmatter(content); |
|
if (!frontmatter?.name) continue; |
|
|
|
const name = frontmatter.name; |
|
const description = frontmatter.description || 'No description'; |
|
const installPath = join(SKILLS_DIR, name, 'SKILL.md'); |
|
|
|
let status = 'available'; |
|
if (existsSync(installPath)) { |
|
const localContent = readFileSync(installPath, 'utf-8'); |
|
if (md5(localContent) !== md5(content)) { |
|
status = 'update'; |
|
} else { |
|
status = 'installed'; |
|
} |
|
} |
|
|
|
skills.push({ name, description, gistId: gist.id, status }); |
|
} |
|
|
|
console.log(); |
|
console.log(`${c.bold}Available Skills from @${username}:${c.reset}`); |
|
console.log(); |
|
|
|
if (skills.length === 0) { |
|
log.warn(`No Claude Code skills found in @${username}'s gists`); |
|
printFooter(); |
|
return; |
|
} |
|
|
|
for (const skill of skills) { |
|
let statusLabel = ''; |
|
if (skill.status === 'installed') { |
|
statusLabel = ` ${c.green}(installed)${c.reset}`; |
|
} else if (skill.status === 'update') { |
|
statusLabel = ` ${c.yellow}(update available)${c.reset}`; |
|
} |
|
|
|
console.log(` ${c.cyan}${skill.name}${c.reset}${statusLabel}`); |
|
console.log(` ${c.dim}${skill.description.slice(0, 60)}${c.reset}`); |
|
console.log(` ${c.dim}gist: ${skill.gistId}${c.reset}`); |
|
console.log(); |
|
} |
|
|
|
log.info(`To install: ${c.cyan}claude-skill-gists install <gist-id>${c.reset}`); |
|
|
|
printFooter(); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Interactive Selection UI |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
async function interactiveSelect(skills, updateMode = false) { |
|
// Filter skills for update mode |
|
const displaySkills = updateMode |
|
? skills.filter((s) => s.status === 'update') |
|
: skills; |
|
|
|
if (displaySkills.length === 0) { |
|
log.success('All skills are up to date!'); |
|
return []; |
|
} |
|
|
|
let cursor = 0; |
|
const selected = new Set( |
|
displaySkills |
|
.map((s, i) => (s.selected ? i : -1)) |
|
.filter((i) => i >= 0) |
|
); |
|
|
|
// Add "Install" button as last item |
|
const INSTALL_BUTTON = displaySkills.length; |
|
|
|
function render() { |
|
clearScreen(); |
|
printHeader(); |
|
|
|
console.log(`${c.bold}Select skills to install:${c.reset} ${c.dim}(from @${githubUser})${c.reset}`); |
|
console.log(`${c.dim}Use ↑↓ to navigate, Space to select, Enter to install${c.reset}`); |
|
console.log(); |
|
|
|
displaySkills.forEach((skill, i) => { |
|
const isHighlighted = cursor === i; |
|
const isSelected = selected.has(i); |
|
|
|
const checkbox = isSelected |
|
? `${c.green}[✓]${c.reset}` |
|
: `${c.dim}[ ]${c.reset}`; |
|
|
|
let statusLabel = ''; |
|
if (skill.status === 'installed') { |
|
statusLabel = ` ${c.green}(up to date)${c.reset}`; |
|
} else if (skill.status === 'update') { |
|
statusLabel = ` ${c.yellow}(update available)${c.reset}`; |
|
} |
|
|
|
const prefix = isHighlighted ? `${c.cyan}❯${c.reset}` : ' '; |
|
const name = isHighlighted |
|
? `${c.cyan}${c.bold}${skill.name}${c.reset}` |
|
: `${c.white}${skill.name}${c.reset}`; |
|
|
|
console.log(` ${prefix} ${checkbox} ${name}${statusLabel}`); |
|
console.log(` ${c.dim}${skill.description.slice(0, 50)}${c.reset}`); |
|
}); |
|
|
|
console.log(); |
|
|
|
// Install button |
|
const isInstallHighlighted = cursor === INSTALL_BUTTON; |
|
const selectedCount = selected.size; |
|
const buttonText = selectedCount > 0 |
|
? `Install ${selectedCount} skill${selectedCount > 1 ? 's' : ''}` |
|
: 'Install'; |
|
|
|
if (isInstallHighlighted) { |
|
console.log(` ${c.cyan}❯${c.reset} ${c.bgBlue}${c.white}${c.bold} ${buttonText} ${c.reset}`); |
|
} else { |
|
console.log(` ${c.dim}[ ${buttonText} ]${c.reset}`); |
|
} |
|
|
|
console.log(); |
|
console.log(`${c.dim} a = select all · n = select none · g = change user${c.reset}`); |
|
console.log(`${c.dim} s = install this script locally · q = quit${c.reset}`); |
|
} |
|
|
|
return new Promise((resolve) => { |
|
hideCursor(); |
|
render(); |
|
|
|
process.stdin.setRawMode(true); |
|
process.stdin.resume(); |
|
process.stdin.setEncoding('utf8'); |
|
|
|
const cleanup = () => { |
|
process.stdin.setRawMode(false); |
|
process.stdin.pause(); |
|
showCursor(); |
|
}; |
|
|
|
process.stdin.on('data', async (key) => { |
|
// Ctrl+C |
|
if (key === '\^C') { |
|
cleanup(); |
|
clearScreen(); |
|
log.info('Cancelled'); |
|
process.exit(0); |
|
} |
|
|
|
// q to quit |
|
if (key === 'q' || key === 'Q') { |
|
cleanup(); |
|
clearScreen(); |
|
log.info('Cancelled'); |
|
process.exit(0); |
|
} |
|
|
|
// Arrow up |
|
if (key === '\^[[A') { |
|
cursor = cursor > 0 ? cursor - 1 : INSTALL_BUTTON; |
|
render(); |
|
return; |
|
} |
|
|
|
// Arrow down |
|
if (key === '\^[[B') { |
|
cursor = cursor < INSTALL_BUTTON ? cursor + 1 : 0; |
|
render(); |
|
return; |
|
} |
|
|
|
// Space to toggle |
|
if (key === ' ') { |
|
if (cursor < displaySkills.length) { |
|
if (selected.has(cursor)) { |
|
selected.delete(cursor); |
|
} else { |
|
selected.add(cursor); |
|
} |
|
render(); |
|
} |
|
return; |
|
} |
|
|
|
// Enter to confirm |
|
if (key === '\r' || key === '\n') { |
|
if (cursor === INSTALL_BUTTON || selected.size > 0) { |
|
cleanup(); |
|
const result = displaySkills.filter((_, i) => selected.has(i)); |
|
resolve(result); |
|
} |
|
return; |
|
} |
|
|
|
// 'a' to select all |
|
if (key === 'a' || key === 'A') { |
|
for (let i = 0; i < displaySkills.length; i++) { |
|
selected.add(i); |
|
} |
|
render(); |
|
return; |
|
} |
|
|
|
// 'n' to select none |
|
if (key === 'n' || key === 'N') { |
|
selected.clear(); |
|
render(); |
|
return; |
|
} |
|
|
|
// 'g' to change GitHub user |
|
if (key === 'g' || key === 'G') { |
|
cleanup(); |
|
const newUser = await promptForUsername(); |
|
if (newUser && newUser.trim()) { |
|
githubUser = newUser.trim(); |
|
resolve({ changeUser: true }); |
|
} else { |
|
// Re-enable raw mode and continue |
|
process.stdin.setRawMode(true); |
|
process.stdin.resume(); |
|
render(); |
|
} |
|
return; |
|
} |
|
|
|
// 's' to install script locally |
|
if (key === 's' || key === 'S') { |
|
cleanup(); |
|
await installScriptLocally(); |
|
process.stdin.setRawMode(true); |
|
process.stdin.resume(); |
|
render(); |
|
return; |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
async function promptForUsername() { |
|
showCursor(); |
|
const rl = readline.createInterface({ |
|
input: process.stdin, |
|
output: process.stdout, |
|
}); |
|
|
|
return new Promise((resolve) => { |
|
rl.question(`${c.magenta}▸${c.reset} Enter GitHub username: `, (answer) => { |
|
rl.close(); |
|
resolve(answer); |
|
}); |
|
}); |
|
} |
|
|
|
async function installScriptLocally() { |
|
showCursor(); |
|
const installPath = '/usr/local/bin/claude-skill-gists'; |
|
const currentPath = fileURLToPath(import.meta.url); |
|
|
|
// Check if already installed at target location |
|
if (currentPath === installPath) { |
|
console.log(); |
|
log.info('Already running from installed location.'); |
|
console.log(); |
|
await pressAnyKey(); |
|
return; |
|
} |
|
|
|
console.log(); |
|
console.log(`${c.bold}Install claude-skill-gists locally${c.reset}`); |
|
console.log(); |
|
console.log(` ${c.dim}Location:${c.reset} ${installPath}`); |
|
console.log(); |
|
|
|
const confirm = await promptYesNo('Proceed with installation?'); |
|
|
|
if (!confirm) { |
|
console.log(); |
|
log.info('Installation cancelled.'); |
|
console.log(); |
|
await pressAnyKey(); |
|
return; |
|
} |
|
|
|
try { |
|
// Fetch latest version from gist |
|
const content = execSync(`curl -fsSL "${SCRIPT_GIST_URL}"`, { |
|
encoding: 'utf-8', |
|
stdio: ['pipe', 'pipe', 'pipe'], |
|
}); |
|
|
|
// Try to write directly, fall back to sudo |
|
try { |
|
writeFileSync(installPath, content, { mode: 0o755 }); |
|
} catch (e) { |
|
// Need sudo |
|
console.log(); |
|
log.info('Requesting sudo access to write to /usr/local/bin...'); |
|
execSync(`sudo tee "${installPath}" > /dev/null && sudo chmod +x "${installPath}"`, { |
|
input: content, |
|
stdio: ['pipe', 'inherit', 'inherit'], |
|
}); |
|
} |
|
|
|
console.log(); |
|
log.success(`Installed successfully!`); |
|
console.log(); |
|
console.log(` ${c.dim}Run with:${c.reset} ${c.cyan}claude-skill-gists${c.reset}`); |
|
console.log(); |
|
} catch (e) { |
|
console.log(); |
|
log.error(`Installation failed: ${e.message}`); |
|
console.log(); |
|
} |
|
|
|
await pressAnyKey(); |
|
} |
|
|
|
async function pressAnyKey() { |
|
return new Promise((resolve) => { |
|
console.log(`${c.dim}Press any key to continue...${c.reset}`); |
|
process.stdin.setRawMode(true); |
|
process.stdin.resume(); |
|
process.stdin.once('data', () => { |
|
process.stdin.setRawMode(false); |
|
resolve(); |
|
}); |
|
}); |
|
} |
|
|
|
function installSkills(skills) { |
|
clearScreen(); |
|
printHeader(); |
|
console.log(`${c.bold}Installing skills...${c.reset}`); |
|
console.log(); |
|
|
|
let installed = 0; |
|
let updated = 0; |
|
let failed = 0; |
|
|
|
for (const skill of skills) { |
|
const actionVerb = skill.status === 'update' ? 'Updating' : 'Installing'; |
|
log.step(`${actionVerb} ${c.cyan}${skill.name}${c.reset}...`); |
|
|
|
const installDir = join(SKILLS_DIR, skill.name); |
|
const installPath = join(installDir, 'SKILL.md'); |
|
|
|
try { |
|
mkdirSync(installDir, { recursive: true }); |
|
|
|
if (existsSync(installPath)) { |
|
writeFileSync(`${installPath}.bak`, readFileSync(installPath)); |
|
} |
|
|
|
writeFileSync(installPath, skill.content); |
|
|
|
if (skill.status === 'update') { |
|
log.success(`Updated ${c.cyan}${skill.name}${c.reset}`); |
|
updated++; |
|
} else { |
|
log.success(`Installed ${c.cyan}${skill.name}${c.reset}`); |
|
installed++; |
|
} |
|
} catch (e) { |
|
log.error(`Failed to install ${skill.name}`); |
|
failed++; |
|
} |
|
} |
|
|
|
console.log(); |
|
console.log(`${c.green}${c.bold}Complete!${c.reset}`); |
|
console.log(); |
|
if (installed > 0) { |
|
console.log(` ${c.dim}Installed:${c.reset} ${c.green}${installed}${c.reset} skill(s)`); |
|
} |
|
if (updated > 0) { |
|
console.log(` ${c.dim}Updated:${c.reset} ${c.blue}${updated}${c.reset} skill(s)`); |
|
} |
|
if (failed > 0) { |
|
console.log(` ${c.dim}Failed:${c.reset} ${c.red}${failed}${c.reset} skill(s)`); |
|
} |
|
console.log(); |
|
console.log(` ${c.dim}Skills directory:${c.reset} ${SKILLS_DIR}`); |
|
console.log(); |
|
console.log(`${c.bold}Usage:${c.reset}`); |
|
console.log(` In Claude Code, run ${c.cyan}/<skill-name>${c.reset} to invoke a skill`); |
|
|
|
printFooter(); |
|
} |
|
|
|
async function checkUpdates() { |
|
clearScreen(); |
|
printHeader(); |
|
checkDependencies(); |
|
log.step('Checking for updates...'); |
|
|
|
const gists = ghApi(`users/${githubUser}/gists`); |
|
if (!gists) { |
|
log.error('Failed to fetch gists'); |
|
process.exit(1); |
|
} |
|
|
|
const updateList = []; |
|
|
|
for (const gist of gists) { |
|
const files = Object.keys(gist.files); |
|
if (!files.includes('SKILL.md')) continue; |
|
|
|
const rawUrl = gist.files['SKILL.md'].raw_url; |
|
const content = fetchUrl(rawUrl); |
|
if (!content) continue; |
|
|
|
const frontmatter = parseYamlFrontmatter(content); |
|
if (!frontmatter?.name) continue; |
|
|
|
const installPath = join(SKILLS_DIR, frontmatter.name, 'SKILL.md'); |
|
if (existsSync(installPath)) { |
|
const localContent = readFileSync(installPath, 'utf-8'); |
|
if (md5(localContent) !== md5(content)) { |
|
updateList.push(frontmatter.name); |
|
} |
|
} |
|
} |
|
|
|
console.log(); |
|
|
|
if (updateList.length === 0) { |
|
log.success('All installed skills are up to date!'); |
|
} else { |
|
log.warn(`${updateList.length} update(s) available:`); |
|
console.log(); |
|
for (const name of updateList) { |
|
console.log(` ${c.dim}•${c.reset} ${c.cyan}${name}${c.reset}`); |
|
} |
|
console.log(); |
|
log.info(`Run ${c.cyan}claude-skill-gists update${c.reset} to update`); |
|
} |
|
|
|
printFooter(); |
|
} |
|
|
|
function listInstalled() { |
|
clearScreen(); |
|
printHeader(); |
|
console.log(`${c.bold}Installed Skills:${c.reset}`); |
|
console.log(); |
|
|
|
let found = 0; |
|
|
|
if (existsSync(SKILLS_DIR)) { |
|
const dirs = readdirSync(SKILLS_DIR, { withFileTypes: true }); |
|
for (const dir of dirs) { |
|
if (!dir.isDirectory()) continue; |
|
|
|
const skillPath = join(SKILLS_DIR, dir.name, 'SKILL.md'); |
|
if (!existsSync(skillPath)) continue; |
|
|
|
const content = readFileSync(skillPath, 'utf-8'); |
|
const frontmatter = parseYamlFrontmatter(content); |
|
const description = frontmatter?.description || ''; |
|
|
|
console.log(` ${c.cyan}${dir.name}${c.reset}`); |
|
console.log(` ${c.dim}${description.slice(0, 60)}${c.reset}`); |
|
console.log(); |
|
found++; |
|
} |
|
} |
|
|
|
if (found === 0) { |
|
log.info('No skills installed'); |
|
console.log(); |
|
log.info(`Run ${c.cyan}claude-skill-gists${c.reset} to install skills`); |
|
} |
|
|
|
printFooter(); |
|
} |
|
|
|
async function uninstallMenu() { |
|
const installedSkills = []; |
|
|
|
if (existsSync(SKILLS_DIR)) { |
|
const dirs = readdirSync(SKILLS_DIR, { withFileTypes: true }); |
|
for (const dir of dirs) { |
|
if (!dir.isDirectory()) continue; |
|
const skillPath = join(SKILLS_DIR, dir.name, 'SKILL.md'); |
|
if (existsSync(skillPath)) { |
|
const content = readFileSync(skillPath, 'utf-8'); |
|
const frontmatter = parseYamlFrontmatter(content); |
|
installedSkills.push({ |
|
name: dir.name, |
|
description: frontmatter?.description || '', |
|
selected: false, |
|
}); |
|
} |
|
} |
|
} |
|
|
|
if (installedSkills.length === 0) { |
|
clearScreen(); |
|
printHeader(); |
|
log.warn('No skills installed'); |
|
printFooter(); |
|
process.exit(0); |
|
} |
|
|
|
let cursor = 0; |
|
const selected = new Set(); |
|
const UNINSTALL_BUTTON = installedSkills.length; |
|
|
|
function render() { |
|
clearScreen(); |
|
printHeader(); |
|
|
|
console.log(`${c.bold}Select skills to uninstall:${c.reset}`); |
|
console.log(`${c.dim}Use ↑↓ to navigate, Space to select, Enter to uninstall, q to quit${c.reset}`); |
|
console.log(); |
|
|
|
installedSkills.forEach((skill, i) => { |
|
const isHighlighted = cursor === i; |
|
const isSelected = selected.has(i); |
|
|
|
const checkbox = isSelected |
|
? `${c.red}[✓]${c.reset}` |
|
: `${c.dim}[ ]${c.reset}`; |
|
|
|
const prefix = isHighlighted ? `${c.cyan}❯${c.reset}` : ' '; |
|
const name = isHighlighted |
|
? `${c.cyan}${c.bold}${skill.name}${c.reset}` |
|
: `${c.white}${skill.name}${c.reset}`; |
|
|
|
console.log(` ${prefix} ${checkbox} ${name}`); |
|
console.log(` ${c.dim}${skill.description.slice(0, 50)}${c.reset}`); |
|
}); |
|
|
|
console.log(); |
|
|
|
const isButtonHighlighted = cursor === UNINSTALL_BUTTON; |
|
const selectedCount = selected.size; |
|
const buttonText = selectedCount > 0 |
|
? `Uninstall ${selectedCount} skill${selectedCount > 1 ? 's' : ''}` |
|
: 'Uninstall'; |
|
|
|
if (isButtonHighlighted) { |
|
console.log(` ${c.cyan}❯${c.reset} ${c.bgGray}${c.red}${c.bold} ${buttonText} ${c.reset}`); |
|
} else { |
|
console.log(` ${c.dim}[ ${buttonText} ]${c.reset}`); |
|
} |
|
|
|
console.log(); |
|
console.log(`${c.dim} a = select all · n = select none · q = quit${c.reset}`); |
|
} |
|
|
|
return new Promise((resolve) => { |
|
hideCursor(); |
|
render(); |
|
|
|
process.stdin.setRawMode(true); |
|
process.stdin.resume(); |
|
process.stdin.setEncoding('utf8'); |
|
|
|
const cleanup = () => { |
|
process.stdin.setRawMode(false); |
|
process.stdin.pause(); |
|
showCursor(); |
|
}; |
|
|
|
process.stdin.on('data', async (key) => { |
|
if (key === '\^C' || key === 'q' || key === 'Q') { |
|
cleanup(); |
|
clearScreen(); |
|
log.info('Cancelled'); |
|
process.exit(0); |
|
} |
|
|
|
if (key === '\^[[A') { |
|
cursor = cursor > 0 ? cursor - 1 : UNINSTALL_BUTTON; |
|
render(); |
|
return; |
|
} |
|
|
|
if (key === '\^[[B') { |
|
cursor = cursor < UNINSTALL_BUTTON ? cursor + 1 : 0; |
|
render(); |
|
return; |
|
} |
|
|
|
if (key === ' ') { |
|
if (cursor < installedSkills.length) { |
|
if (selected.has(cursor)) { |
|
selected.delete(cursor); |
|
} else { |
|
selected.add(cursor); |
|
} |
|
render(); |
|
} |
|
return; |
|
} |
|
|
|
if (key === '\r' || key === '\n') { |
|
if (cursor === UNINSTALL_BUTTON || selected.size > 0) { |
|
cleanup(); |
|
clearScreen(); |
|
printHeader(); |
|
|
|
const toRemove = installedSkills.filter((_, i) => selected.has(i)); |
|
for (const skill of toRemove) { |
|
rmSync(join(SKILLS_DIR, skill.name), { recursive: true, force: true }); |
|
log.success(`Removed ${c.cyan}${skill.name}${c.reset}`); |
|
} |
|
|
|
printFooter(); |
|
resolve(); |
|
} |
|
return; |
|
} |
|
|
|
if (key === 'a' || key === 'A') { |
|
for (let i = 0; i < installedSkills.length; i++) { |
|
selected.add(i); |
|
} |
|
render(); |
|
return; |
|
} |
|
|
|
if (key === 'n' || key === 'N') { |
|
selected.clear(); |
|
render(); |
|
return; |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
function showHelp() { |
|
clearScreen(); |
|
printHeader(); |
|
|
|
console.log(`${c.bold}Usage:${c.reset}`); |
|
console.log(' claude-skill-gists Interactive skill browser'); |
|
console.log(' claude-skill-gists <username> Browse skills from GitHub user'); |
|
console.log(' claude-skill-gists install <gist-id> Install skill from gist ID'); |
|
console.log(' claude-skill-gists uninstall <name> Uninstall skill by name'); |
|
console.log(' claude-skill-gists uninstall Interactive uninstall menu'); |
|
console.log(' claude-skill-gists update Update outdated skills'); |
|
console.log(' claude-skill-gists check Check for available updates'); |
|
console.log(' claude-skill-gists list List installed skills'); |
|
console.log(' claude-skill-gists list <username> List skills from GitHub user'); |
|
console.log(' claude-skill-gists help Show this help'); |
|
console.log(); |
|
console.log(`${c.bold}Examples:${c.reset}`); |
|
console.log(` ${c.dim}# Browse skills from a user${c.reset}`); |
|
console.log(` claude-skill-gists anthropic`); |
|
console.log(); |
|
console.log(` ${c.dim}# List available skills from a user${c.reset}`); |
|
console.log(` claude-skill-gists list octocat`); |
|
console.log(); |
|
console.log(` ${c.dim}# Install a skill from any gist${c.reset}`); |
|
console.log(` claude-skill-gists install abc123def456`); |
|
console.log(); |
|
console.log(` ${c.dim}# Install from full gist URL${c.reset}`); |
|
console.log(` claude-skill-gists install https://gist.github.com/user/abc123`); |
|
console.log(); |
|
console.log(` ${c.dim}# Uninstall a specific skill${c.reset}`); |
|
console.log(` claude-skill-gists uninstall my-skill`); |
|
|
|
printFooter(); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────── |
|
// Main |
|
// ───────────────────────────────────────────────────────────── |
|
|
|
async function main() { |
|
// Check for script updates (only if installed locally) |
|
await checkForScriptUpdate(); |
|
|
|
const args = process.argv.slice(2); |
|
const command = args[0]; |
|
|
|
// Handle subcommands |
|
switch (command) { |
|
case 'install': { |
|
let gistId = args[1]; |
|
|
|
if (!gistId) { |
|
log.error('Missing gist ID'); |
|
console.log(); |
|
log.info(`Usage: ${c.cyan}claude-skill-gists install <gist-id>${c.reset}`); |
|
process.exit(1); |
|
} |
|
|
|
// Extract gist ID from URL if provided |
|
const urlMatch = gistId.match(/gist\.github\.com\/[\w-]+\/([a-f0-9]+)/i); |
|
if (urlMatch) { |
|
gistId = urlMatch[1]; |
|
} |
|
|
|
// Validate gist ID format (should be hex string) |
|
if (!/^[a-f0-9]+$/i.test(gistId)) { |
|
log.error('Invalid gist ID format'); |
|
console.log(); |
|
log.info('Gist ID should be a hexadecimal string (e.g., abc123def456)'); |
|
process.exit(1); |
|
} |
|
|
|
await installFromGistId(gistId); |
|
break; |
|
} |
|
|
|
case 'uninstall': { |
|
const skillName = args[1]; |
|
|
|
if (skillName) { |
|
// Direct uninstall by name |
|
await uninstallByName(skillName); |
|
} else { |
|
// Interactive uninstall menu |
|
await uninstallMenu(); |
|
} |
|
break; |
|
} |
|
|
|
case 'update': |
|
clearScreen(); |
|
printHeader(); |
|
checkDependencies(); |
|
const skillsToUpdate = await fetchSkills(); |
|
const hasUpdates = skillsToUpdate.some((s) => s.status === 'update'); |
|
if (!hasUpdates) { |
|
log.success('All skills are up to date!'); |
|
printFooter(); |
|
process.exit(0); |
|
} |
|
const toUpdate = await interactiveSelect(skillsToUpdate, true); |
|
if (toUpdate.length > 0) { |
|
installSkills(toUpdate); |
|
} |
|
break; |
|
|
|
case 'check': |
|
await checkUpdates(); |
|
break; |
|
|
|
case 'list': { |
|
const username = args[1]; |
|
if (username) { |
|
// List skills from a specific GitHub user |
|
await listFromUser(username); |
|
} else { |
|
// List installed skills |
|
listInstalled(); |
|
} |
|
break; |
|
} |
|
|
|
case 'help': |
|
case '--help': |
|
case '-h': |
|
showHelp(); |
|
break; |
|
|
|
// Legacy flag support |
|
case '--update': |
|
case '-U': |
|
clearScreen(); |
|
printHeader(); |
|
checkDependencies(); |
|
const legacySkillsToUpdate = await fetchSkills(); |
|
const legacyHasUpdates = legacySkillsToUpdate.some((s) => s.status === 'update'); |
|
if (!legacyHasUpdates) { |
|
log.success('All skills are up to date!'); |
|
printFooter(); |
|
process.exit(0); |
|
} |
|
const legacyToUpdate = await interactiveSelect(legacySkillsToUpdate, true); |
|
if (legacyToUpdate.length > 0) { |
|
installSkills(legacyToUpdate); |
|
} |
|
break; |
|
|
|
case '--check': |
|
case '-c': |
|
await checkUpdates(); |
|
break; |
|
|
|
case '--list': |
|
case '-l': |
|
listInstalled(); |
|
break; |
|
|
|
case '--uninstall': |
|
case '-u': |
|
await uninstallMenu(); |
|
break; |
|
|
|
default: |
|
// Check if command looks like a username (no special chars, not a flag) |
|
if (command && !command.startsWith('-') && /^[\w-]+$/.test(command)) { |
|
// Treat as username - open interactive browser for that user |
|
githubUser = command; |
|
clearScreen(); |
|
printHeader(); |
|
checkDependencies(); |
|
|
|
let continueLoop = true; |
|
while (continueLoop) { |
|
const skills = await fetchSkills(); |
|
const result = await interactiveSelect(skills); |
|
|
|
if (result && result.changeUser) { |
|
clearScreen(); |
|
printHeader(); |
|
continue; |
|
} |
|
|
|
if (Array.isArray(result) && result.length > 0) { |
|
installSkills(result); |
|
} |
|
continueLoop = false; |
|
} |
|
} else if (!command) { |
|
// No command - default interactive mode |
|
clearScreen(); |
|
printHeader(); |
|
checkDependencies(); |
|
|
|
let continueLoop = true; |
|
while (continueLoop) { |
|
const skills = await fetchSkills(); |
|
const result = await interactiveSelect(skills); |
|
|
|
if (result && result.changeUser) { |
|
clearScreen(); |
|
printHeader(); |
|
continue; |
|
} |
|
|
|
if (Array.isArray(result) && result.length > 0) { |
|
installSkills(result); |
|
} |
|
continueLoop = false; |
|
} |
|
} else { |
|
log.error(`Unknown command: ${command}`); |
|
console.log(); |
|
log.info(`Run ${c.cyan}claude-skill-gists help${c.reset} for usage`); |
|
process.exit(1); |
|
} |
|
break; |
|
} |
|
} |
|
|
|
main().catch((e) => { |
|
showCursor(); |
|
log.error(e.message); |
|
process.exit(1); |
|
}); |