Files

567 lines
19 KiB
JavaScript
Executable File

#!/usr/bin/env node
const { Command } = require('commander');
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const os = require('os');
const { listSkillIds, readSkill, tokenize, unique } = require('../lib/skill-utils');
const { version } = require('../package.json');
const program = new Command();
// Resolve paths
const PACKAGE_ROOT = path.resolve(__dirname, '..');
const SKILLS_SOURCE_DIR = path.join(PACKAGE_ROOT, 'skills');
const CATALOG_PATH = path.join(PACKAGE_ROOT, 'catalog.json');
const BUNDLES_PATH = path.join(PACKAGE_ROOT, 'bundles.json');
const ALIASES_PATH = path.join(PACKAGE_ROOT, 'aliases.json');
// Define destinations
const GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.gemini', 'antigravity', 'skills');
const LOCAL_SKILLS_DIR = path.join(process.cwd(), '.agent', 'skills');
// Allow overriding the install location for both scopes via an env var.
// The override is a destination only; the skill source stays guarded under SKILLS_SOURCE_DIR.
function resolveTargetDir(global) {
if (process.env.AG_SKILLS_DIR) {
const raw = process.env.AG_SKILLS_DIR.replace(/^~(?=$|[/\\])/, os.homedir());
return path.resolve(raw);
}
return global ? GLOBAL_SKILLS_DIR : LOCAL_SKILLS_DIR;
}
program
.name('ag-skills')
.description('Manage Antigravity Skills')
.version(version);
function loadJson(filePath, label) {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (err) {
if (label) {
console.warn(chalk.yellow(`Warning: ${label} is present but could not be parsed (${err.message}).`));
}
return null;
}
}
function loadCatalog() {
// No label: a corrupt catalog.json already warns via the _fallback path below; avoid a double warning.
const catalog = loadJson(CATALOG_PATH);
if (catalog && Array.isArray(catalog.skills)) {
return catalog;
}
const skillIds = listSkillIds(SKILLS_SOURCE_DIR);
const skills = skillIds.map(skillId => {
const skill = readSkill(SKILLS_SOURCE_DIR, skillId);
const tags = unique([...(skill.tags || []), ...tokenize(skillId)]);
return {
id: skill.id,
name: skill.name,
description: skill.description,
tags,
category: 'general',
};
});
return {
skills,
total: skills.length,
_fallback: true,
};
}
function loadBundles() {
const bundles = loadJson(BUNDLES_PATH, 'bundles.json');
if (bundles && bundles.bundles) return bundles;
return { bundles: {}, common: [] };
}
function loadAliases() {
const aliases = loadJson(ALIASES_PATH, 'aliases.json');
if (aliases && aliases.aliases) return aliases.aliases;
return {};
}
function sanitizeSkillId(value) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized || !/^[a-z0-9-]+$/.test(normalized)) return null;
return normalized;
}
function resolveSkillId(input, aliases) {
const sanitized = sanitizeSkillId(input);
if (!sanitized) return null;
return aliases[sanitized] || sanitized;
}
function resolveSkillPath(skillId) {
const resolved = path.resolve(SKILLS_SOURCE_DIR, skillId);
if (!resolved.startsWith(SKILLS_SOURCE_DIR + path.sep)) return null;
return resolved;
}
function truncate(value, limit) {
if (!value) return '';
if (value.length <= limit) return value;
return `${value.slice(0, limit - 3)}...`;
}
function scoreSkill(skill, query, queryTokens) {
const haystack = `${skill.id} ${skill.name || ''} ${skill.description || ''} ${(skill.tags || []).join(' ')} ${(skill.triggers || []).join(' ')}`.toLowerCase();
let score = haystack.includes(query) ? 5 : 0;
for (const token of queryTokens) {
if (skill.id.toLowerCase().includes(token)) score += 3;
if (haystack.includes(token)) score += 2;
}
return score;
}
function collectOption(value, previous) {
const items = Array.isArray(previous) ? previous : [];
const parts = String(value)
.split(',')
.map(part => part.trim())
.filter(Boolean);
return items.concat(parts);
}
function parseLimit(value) {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) return 20;
return parsed;
}
function checkDir(dirPath) {
if (!fs.existsSync(dirPath)) {
return { exists: false, isDir: false, writable: false };
}
try {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
return { exists: true, isDir: false, writable: false };
}
fs.accessSync(dirPath, fs.constants.W_OK);
return { exists: true, isDir: true, writable: true };
} catch (err) {
return { exists: true, isDir: true, writable: false };
}
}
program
.command('list')
.description('List all available skills in the vault')
.action(() => {
try {
const skills = listSkillIds(SKILLS_SOURCE_DIR);
console.log(chalk.bold('\nAvailable Skills:\n'));
skills.forEach(skill => {
console.log(`- ${chalk.cyan(skill)}`);
});
console.log(chalk.green(`\nTotal: ${skills.length} skills`));
} catch (err) {
console.error(chalk.red('Error listing skills:'), err.message);
}
});
program
.command('search <query>')
.description('Search skills by name, description, and tags')
.option('-l, --limit <number>', 'Limit results', parseLimit, 20)
.action((query, options) => {
const catalog = loadCatalog();
const queryText = query.toLowerCase().trim();
if (!queryText) {
console.error(chalk.red('Error: Please provide a search query.'));
process.exit(1);
}
if (catalog._fallback) {
console.warn(chalk.yellow('Warning: catalog.json not found; using fallback metadata.'));
}
const queryTokens = unique(tokenize(queryText));
const results = (catalog.skills || [])
.map(skill => ({
skill,
score: scoreSkill(skill, queryText, queryTokens),
}))
.filter(result => result.score > 0)
.sort((a, b) => b.score - a.score || a.skill.id.localeCompare(b.skill.id))
.slice(0, options.limit || 20);
if (!results.length) {
console.log(chalk.yellow('No matching skills found.'));
return;
}
console.log(chalk.bold(`\nSearch results (${results.length}):\n`));
for (const result of results) {
const description = truncate(result.skill.description || '', 100);
const tags = (result.skill.tags || []).slice(0, 6).join(', ');
console.log(`- ${chalk.cyan(result.skill.id)}${description ? ` - ${description}` : ''}`);
if (tags) {
console.log(` ${chalk.gray(`tags: ${tags}`)}`);
}
}
});
program
.command('install [skillName]')
.description('Install a skill to your workspace or globally')
.option('-g, --global', 'Install to global workspace (~/.gemini/antigravity/skills)')
.option('-a, --all', 'Install ALL skills')
.option('-t, --tag <tag>', 'Install skills by tag (repeatable)', collectOption, [])
.option('-b, --bundle <bundle>', 'Install a curated bundle')
.option('-f, --force', 'Overwrite skills that are already installed')
.action(async (skillName, options) => {
const targetDir = resolveTargetDir(options.global);
const aliases = loadAliases();
const hasSkillName = typeof skillName === 'string' && skillName.trim().length > 0;
const bundleName = options.bundle ? options.bundle.toLowerCase().trim() : '';
const hasBundle = Boolean(bundleName);
const hasTags = Array.isArray(options.tag) && options.tag.length > 0;
const hasAll = Boolean(options.all);
const selectedInputs = [hasSkillName, hasAll, hasBundle, hasTags].filter(Boolean);
if (selectedInputs.length === 0) {
console.error(chalk.red('Error: Please specify a skill name or use --all/--tag/--bundle'));
process.exit(1);
}
if (selectedInputs.length > 1) {
console.error(chalk.red('Error: Choose only one of skill name, --all, --tag, or --bundle'));
process.exit(1);
}
try {
await fs.ensureDir(targetDir);
console.log(chalk.gray(`Target directory: ${targetDir}`));
let skillsToInstall = [];
if (hasAll) {
console.warn(chalk.yellow('Warning: Installing all skills increases token usage and activation noise.'));
skillsToInstall = listSkillIds(SKILLS_SOURCE_DIR);
} else if (hasBundle) {
const bundles = loadBundles();
const bundle = bundles.bundles[bundleName];
if (!bundle) {
console.error(chalk.red(`Bundle '${bundleName}' not found.`));
const available = Object.keys(bundles.bundles).sort();
if (available.length) {
console.log(chalk.gray(`Available bundles: ${available.join(', ')}`));
} else {
console.log(chalk.gray('Run npm run build:catalog to generate bundles.'));
}
process.exit(1);
}
if (!Array.isArray(bundle.skills) || bundle.skills.length === 0) {
console.error(chalk.red(`Bundle '${bundleName}' has no skills.`));
process.exit(1);
}
skillsToInstall = bundle.skills;
} else if (hasTags) {
const catalog = loadCatalog();
const tagSet = new Set(options.tag.map(tag => tag.toLowerCase().trim()).filter(Boolean));
skillsToInstall = (catalog.skills || [])
.filter(skill => (skill.tags || []).some(tag => tagSet.has(String(tag).toLowerCase())))
.map(skill => skill.id);
if (!skillsToInstall.length) {
console.error(chalk.red(`No skills found for tags: ${Array.from(tagSet).join(', ')}`));
process.exit(1);
}
} else if (hasSkillName) {
const resolved = resolveSkillId(skillName.trim(), aliases);
if (!resolved) {
console.error(chalk.red(`Invalid skill name: '${skillName}'`));
process.exit(1);
}
skillsToInstall = [resolved];
}
skillsToInstall = unique(skillsToInstall);
if (!skillsToInstall.length) {
console.log(chalk.yellow('No skills to install.'));
return;
}
let installedCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (const skill of skillsToInstall) {
const safeSkill = sanitizeSkillId(skill);
if (!safeSkill) {
console.error(chalk.red(`Invalid skill name: '${skill}'`));
failedCount += 1;
continue;
}
const sourcePath = resolveSkillPath(safeSkill);
if (!sourcePath || !await fs.pathExists(sourcePath)) {
console.error(chalk.red(`Skill '${safeSkill}' not found in vault.`));
failedCount += 1;
continue;
}
const destPath = path.join(targetDir, safeSkill);
if (await fs.pathExists(destPath)) {
if (!options.force) {
console.warn(chalk.yellow(`• Skipped (already installed): ${safeSkill}`));
skippedCount += 1;
continue;
}
await fs.remove(destPath);
}
await fs.copy(sourcePath, destPath, { overwrite: true });
console.log(`${chalk.green('✔ Installed:')} ${safeSkill}`);
installedCount += 1;
}
console.log('');
console.log(`Installed: ${installedCount}, skipped: ${skippedCount}, not found/invalid: ${failedCount}`);
if (installedCount > 0) {
console.log(chalk.bold.green('Installation complete!'));
console.log('Restart your agent session to see changes.');
if (skippedCount > 0) {
console.log(chalk.gray('Use --force to overwrite already-installed skills.'));
}
if (failedCount > 0) {
console.error(chalk.red(`${failedCount} requested skill(s) could not be installed.`));
process.exitCode = 1;
}
} else if (failedCount > 0) {
console.error(chalk.red('No skills were installed.'));
process.exitCode = 1;
} else {
console.log(chalk.yellow('Nothing to do — all requested skills are already installed.'));
}
} catch (err) {
console.error(chalk.red('Installation failed:'), err.message);
process.exit(1);
}
});
program
.command('installed')
.description('List skills installed in your workspace')
.option('-g, --global', 'List globally installed skills')
.action(async (options) => {
const targetDir = resolveTargetDir(options.global);
try {
if (!await fs.pathExists(targetDir)) {
console.log(chalk.yellow(`No skills directory found at: ${targetDir}`));
return;
}
const installedSkills = await fs.readdir(targetDir);
const filteredSkills = installedSkills.filter(f => !f.startsWith('.'));
if (filteredSkills.length === 0) {
console.log(chalk.yellow('No skills installed.'));
return;
}
const scopeLabel = process.env.AG_SKILLS_DIR
? 'AG_SKILLS_DIR override'
: (options.global ? 'Global' : 'Local');
console.log(chalk.bold(`\nInstalled Skills (${scopeLabel}):\n`));
filteredSkills.forEach(skill => {
console.log(`- ${chalk.green(skill)}`);
});
console.log(chalk.gray(`\nLocation: ${targetDir}`));
} catch (err) {
console.error(chalk.red('Error listing installed skills:'), err.message);
}
});
program
.command('update [skillName]')
.description('Update installed skills from the vault')
.option('-g, --global', 'Update globally installed skills')
.action(async (skillName, options) => {
const targetDir = resolveTargetDir(options.global);
const aliases = loadAliases();
try {
if (!await fs.pathExists(targetDir)) {
console.error(chalk.red(`No installation found at: ${targetDir}`));
process.exitCode = 1;
return;
}
let skillsToUpdate = [];
if (skillName) {
const resolved = resolveSkillId(skillName, aliases);
if (!resolved) {
console.error(chalk.red(`Invalid skill name: '${skillName}'`));
process.exitCode = 1;
return;
}
if (await fs.pathExists(path.join(targetDir, resolved))) {
skillsToUpdate.push(resolved);
} else {
console.error(chalk.red(`Skill '${skillName}' is not installed.`));
process.exitCode = 1;
return;
}
} else {
const installed = await fs.readdir(targetDir);
skillsToUpdate = installed.filter(f => !f.startsWith('.'));
}
if (skillsToUpdate.length === 0) {
console.log(chalk.yellow('No skills to update.'));
return;
}
console.log(chalk.bold(`Updating ${skillsToUpdate.length} skills...\n`));
for (const skill of skillsToUpdate) {
const safeSkill = sanitizeSkillId(skill);
if (!safeSkill) {
console.warn(chalk.yellow(`Warning: invalid skill name '${skill}'. Skipping.`));
continue;
}
const sourcePath = resolveSkillPath(safeSkill);
const destPath = path.join(targetDir, safeSkill);
if (!sourcePath || !await fs.pathExists(sourcePath)) {
console.warn(chalk.yellow(`⚠ Warning: Skill '${safeSkill}' no longer exists in vault. Skipping.`));
continue;
}
await fs.copy(sourcePath, destPath, { overwrite: true });
console.log(`${chalk.green('✔ Updated:')} ${safeSkill}`);
}
console.log(chalk.bold.green('\nUpdate complete!'));
} catch (err) {
console.error(chalk.red('Update failed:'), err.message);
process.exit(1);
}
});
program
.command('doctor')
.description('Check install paths and catalog metadata')
.action(() => {
const localStatus = checkDir(LOCAL_SKILLS_DIR);
const globalStatus = checkDir(GLOBAL_SKILLS_DIR);
console.log(chalk.bold('\nEnvironment Check:\n'));
if (fs.existsSync(SKILLS_SOURCE_DIR)) {
const count = listSkillIds(SKILLS_SOURCE_DIR).length;
console.log(`Vault directory: ${SKILLS_SOURCE_DIR} (${chalk.green('OK')}, ${count} skills)`);
} else {
console.log(`Vault directory: ${SKILLS_SOURCE_DIR} (${chalk.red('MISSING')})`);
}
const catalogExists = fs.existsSync(CATALOG_PATH);
console.log(`catalog.json: ${catalogExists ? chalk.green('OK') : chalk.red('MISSING')}`);
const bundlesExists = fs.existsSync(BUNDLES_PATH);
console.log(`bundles.json: ${bundlesExists ? chalk.green('OK') : chalk.red('MISSING')}`);
const aliasesExists = fs.existsSync(ALIASES_PATH);
console.log(`aliases.json: ${aliasesExists ? chalk.green('OK') : chalk.red('MISSING')}`);
console.log('');
console.log(`Local skills dir: ${LOCAL_SKILLS_DIR} (${localStatus.exists && localStatus.isDir ? chalk.green(localStatus.writable ? 'OK' : 'NOT WRITABLE') : chalk.red('MISSING')})`);
if (!localStatus.exists) {
console.log(chalk.gray(`Create with: mkdir -p ${LOCAL_SKILLS_DIR}`));
}
console.log(`Global skills dir: ${GLOBAL_SKILLS_DIR} (${globalStatus.exists && globalStatus.isDir ? chalk.green(globalStatus.writable ? 'OK' : 'NOT WRITABLE') : chalk.red('MISSING')})`);
if (!globalStatus.exists) {
console.log(chalk.gray(`Create with: mkdir -p ${GLOBAL_SKILLS_DIR}`));
}
if (process.env.AG_SKILLS_DIR) {
const overrideDir = resolveTargetDir(false);
const overrideStatus = checkDir(overrideDir);
const label = overrideStatus.exists && overrideStatus.isDir
? chalk.green(overrideStatus.writable ? 'OK' : 'NOT WRITABLE')
: chalk.red('MISSING');
console.log(`AG_SKILLS_DIR override: ${overrideDir} (${label})`);
console.log(chalk.gray('Active install/update target (overrides local/global).'));
}
if (!catalogExists || !bundlesExists || !aliasesExists) {
console.log('');
console.log(chalk.gray('Run npm run build:catalog to regenerate catalog files.'));
}
});
program
.command('stats')
.description('Show catalog statistics')
.action(() => {
const catalog = loadCatalog();
const bundles = loadBundles();
const total = catalog.total || (catalog.skills || []).length;
if (catalog._fallback) {
console.warn(chalk.yellow('Warning: catalog.json not found; stats are based on minimal metadata.'));
}
const categoryCounts = new Map();
for (const skill of catalog.skills || []) {
const category = skill.category || 'general';
categoryCounts.set(category, (categoryCounts.get(category) || 0) + 1);
}
const sortedCategories = Array.from(categoryCounts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
console.log(chalk.bold('\nCatalog Stats:\n'));
console.log(`Total skills: ${total}`);
if (catalog.generatedAt) {
console.log(`Catalog generated at: ${catalog.generatedAt}`);
}
console.log('');
console.log('Category counts:');
sortedCategories.forEach(([category, count]) => {
console.log(`- ${category}: ${count}`);
});
if (bundles.common && bundles.common.length) {
console.log('');
console.log(`Common skills (curated): ${bundles.common.join(', ')}`);
}
});
function main() {
program.parse(process.argv);
}
if (require.main === module) {
main();
} else {
module.exports = {
loadJson,
sanitizeSkillId,
resolveSkillId,
resolveSkillPath,
resolveTargetDir,
scoreSkill,
truncate,
parseLimit,
collectOption,
};
}