diff --git a/CHANGELOG.md b/CHANGELOG.md index 19db85b..7062bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.0] - 2025-03-04 +## [0.3.0] - 2026-03-12 + +### Added +- **skills.sh Integration**: New CLI tools for managing external skills from the skills.sh database + - `opencode-link external-skills:find [query]` - Search the skills.sh database for available skills + - `opencode-link external-skills:add ` - Add skill to config and install + - `opencode-link external-skills:install` - Install/update external skills + - `opencode-link external-skills:update` - Update external skills to latest version + - `opencode-link external-skills:list` - Show installed external skills +- New modules: + - `lib/external-skills.js` - Core logic for external skills management + - `lib/skills-registry.js` - Skills registry management and search functionality +- JSON schema generation for external skills validation + +### Changed +- `bin/opencode-link.js` - Extended with new CLI commands for skills.sh integration +- `README.md` - Updated documentation with new functions +- `package.json` - Updated dependencies and version + +### Details +This release introduces full integration with the skills.sh database, enabling users to discover, install, and manage custom skills. The implementation provides seamless interoperability with the OpenCode plugin system. + +## [0.2.0] - 2026-03-04 ### Added - GitHub Actions workflow for automatic npm publishing on version tags - `files` array in package.json for explicit publish contents - npm provenance support for supply chain security -## [0.1.0] - 2025-03-04 +## [0.1.0] - 2026-03-04 ### Added - User config directory support (`~/.config/opencode/node_modules/`) diff --git a/README.md b/README.md index be672d5..018d5d3 100644 --- a/README.md +++ b/README.md @@ -188,17 +188,107 @@ opencode-link time-tracking **Note:** Commands are prefixed with the plugin name to avoid conflicts between plugins. +## External Skills + +External skills are agent skills sourced directly from GitHub repositories, curated via [skills.sh](https://skills.sh). They are downloaded and symlinked automatically — no npm install required. + +### Quick Start + +```bash +# Search for skills +opencode-link external-skills:find react + +# Add a skill to your project (updates opencode-project.json + installs immediately) +opencode-link external-skills:add vercel-labs/agent-skills vercel-react-best-practices + +# Show installed external skills +opencode-link external-skills:list +``` + +### How It Works + +1. Skills are configured in `.opencode/opencode-project.json` under `externalSkills` +2. `opencode-link` (default run) detects the config and downloads/links them automatically +3. Files land in `.opencode/.external-skills//SKILL.md` (gitignored) +4. Symlinks are created in `.opencode/skills//` + +### Configuration (`opencode-project.json`) + +```json +{ + "externalSkills": [ + { + "source": "vercel-labs/agent-skills", + "skills": ["vercel-react-best-practices", "vercel-nextjs-app-router"], + "branch": "main", + "category": "optional" + }, + { + "source": "some-org/all-their-skills" + } + ] +} +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `source` | yes | — | GitHub `owner/repo` | +| `skills` | no | all | Skill names to install. Omit to install all skills in the repo. | +| `branch` | no | `main` | Git branch to download from | +| `category` | no | `optional` | `standard` or `optional` | + +### Security Checks + +Before downloading, each skill is audited via three security partners (ATH, Socket, Snyk): + +| Risk level | Behaviour | +|------------|-----------| +| `safe` / `low` | Installed automatically | +| `medium` | Installed with a warning | +| `high` | Interactive confirmation required (non-interactive: aborted) | +| `critical` | Always skipped | + +Use `--force` to bypass high-risk prompts or to force reinstall an up-to-date skill. + +> **Note:** Security data is fetched from `add-skill.vercel.sh`. If the service is unavailable, installation continues with a warning — it never blocks. + +### Update Detection + +Skills store a SHA fingerprint (`.skill-meta.json`) after download. Subsequent runs skip skills whose SHA hasn't changed. Use `external-skills:update` to explicitly pull the latest version. + +```bash +# Re-download skills where the upstream SHA changed +opencode-link external-skills:update + +# Force-reinstall all skills regardless of SHA +opencode-link --force external-skills:install +``` + +### GitHub Rate Limits + +The GitHub API is used to fetch file SHAs. Set `GITHUB_TOKEN` to increase the rate limit: + +```bash +export GITHUB_TOKEN=ghp_... +opencode-link external-skills:install +``` + ## Commands | Command | Description | |---------|-------------| -| `opencode-link` | Link all standard plugins | +| `opencode-link` | Link all standard plugins + install configured external skills | | `opencode-link all` | Link ALL plugins (standard + optional) | | `opencode-link ` | Link a specific plugin | -| `opencode-link list` | List available plugins | +| `opencode-link list` | List available plugins and configured external skills | | `opencode-link status` | Show current links | -| `opencode-link clean` | Remove all symlinks | +| `opencode-link clean` | Remove all symlinks and `.external-skills/` directory | | `opencode-link schema` | Regenerate schema | +| `opencode-link external-skills:find ` | Search skills.sh for skills | +| `opencode-link external-skills:add ` | Add skill to config and install | +| `opencode-link external-skills:install` | Install/update all configured external skills | +| `opencode-link external-skills:update` | Re-download skills with upstream changes | +| `opencode-link external-skills:list` | Show installed external skills with security status | ## Options @@ -206,6 +296,7 @@ opencode-link time-tracking |------|-------------| | `--target-dir=` | Override target directory | | `--singular` | Use singular directory names (agent/ instead of agents/) | +| `--force` | Force reinstall even if up to date; bypass high-risk security prompt | ## Programmatic Usage @@ -215,14 +306,21 @@ You can also use the CLI modules programmatically: import { discoverPlugins, getContentTypes } from '@techdivision/opencode-cli/discovery'; import { createSymlink, getItemsToLink } from '@techdivision/opencode-cli/linker'; import { generateCombinedSchema } from '@techdivision/opencode-cli/schema'; +import { searchSkills, fetchAuditData } from '@techdivision/opencode-cli/skills-registry'; +import { installExternalSkills, listInstalledExternalSkills } from '@techdivision/opencode-cli/external-skills'; // Discover plugins const plugins = discoverPlugins('/path/to/.opencode'); console.log(`Found ${plugins.size} plugins`); -// Get all content types -const types = getContentTypes(plugins); -console.log(`Content types: ${types.join(', ')}`); +// Search skills.sh +const results = await searchSkills('react'); + +// Install configured external skills +await installExternalSkills('/path/to/.opencode', { force: false }); + +// List installed external skills +const installed = listInstalledExternalSkills('/path/to/.opencode'); ``` ## License diff --git a/bin/opencode-link.js b/bin/opencode-link.js index 333851e..42aed94 100755 --- a/bin/opencode-link.js +++ b/bin/opencode-link.js @@ -22,10 +22,16 @@ * opencode-link clean # Removes all symlinks * opencode-link list # Lists available plugins * opencode-link schema # Regenerates combined schema + * opencode-link external-skills:find [query] # Search skills.sh + * opencode-link external-skills:add # Add skill to config + install + * opencode-link external-skills:install # Install/update external skills + * opencode-link external-skills:update # Update external skills to latest version + * opencode-link external-skills:list # Show installed external skills * * Options: * --target-dir= # Override target directory * --singular # Use singular dir names (agent/, skill/ instead of agents/, skills/) + * --force # Force reinstall even if up to date; bypass high-risk prompt * * Examples: * opencode-link magento # Links magento plugin from node_modules @@ -43,6 +49,15 @@ import { fileURLToPath } from 'url'; import { discoverPlugins, getContentTypes } from '../lib/discovery.js'; import { getSourceDir, getItemsToLink, createSymlink, generateCombinedAgentsMd, isAutoGeneratedAgentsMd, AGENTS_MD_MARKER, normalizeContentType, mapContentType, setUseSingularDirs } from '../lib/linker.js'; import { generateCombinedSchema, ensureProjectConfigSchema } from '../lib/schema.js'; +import { searchSkills, formatInstalls, getHighestRisk } from '../lib/skills-registry.js'; +import { + readExternalSkillsConfig, + installExternalSkills, + linkExternalSkills, + updateExternalSkills, + listInstalledExternalSkills, + addExternalSkill, +} from '../lib/external-skills.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -84,6 +99,10 @@ function logInfo(message) { console.log(`${colors.cyan} ℹ ${message}${colors.reset}`); } +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + /** * Formats a plugin label showing location and type. * Format: [location/type] @@ -105,6 +124,7 @@ let TARGET_DIR = null; let TARGET_SOURCE = null; let PLUGINS = null; let CONTENT_TYPES = null; +let FORCE = false; /** * Gets the OpenCode user config directory path. @@ -193,6 +213,7 @@ function resolveTargetDir(cliTargetDir) { function parseArgs(args) { let targetDir = null; let singular = false; + let force = false; const restArgs = []; for (const arg of args) { @@ -203,6 +224,8 @@ function parseArgs(args) { process.exit(1); } else if (arg === '--singular') { singular = true; + } else if (arg === '--force') { + force = true; } else { restArgs.push(arg); } @@ -212,6 +235,7 @@ function parseArgs(args) { commands: restArgs, targetDir, singular, + force, }; } @@ -576,6 +600,18 @@ function cleanLinks() { } } + // Remove .external-skills/ directory + const externalSkillsDir = path.join(TARGET_DIR, '.external-skills'); + if (fs.existsSync(externalSkillsDir)) { + try { + fs.rmSync(externalSkillsDir, { recursive: true, force: true }); + log(` - .external-skills/ (removed)`, 'gray'); + removedDirs++; + } catch { + logError('Failed to remove .external-skills/'); + } + } + log(`\nRemoved ${removedCount} items${removedDirs > 0 ? ` and ${removedDirs} empty directories` : ''}`, 'green'); } @@ -608,6 +644,27 @@ function listPlugins() { showPlugins(standardPlugins, 'Standard Plugins', 'green'); showPlugins(optionalPlugins, 'Optional Plugins', 'yellow'); + // External Skills section + const externalSkillsConfig = readExternalSkillsConfig(TARGET_DIR); + if (externalSkillsConfig.length > 0) { + const installed = listInstalledExternalSkills(TARGET_DIR); + + log('\nEXTERNAL SKILLS (configured in opencode-project.json)', 'bright'); + log('─'.repeat(57), 'gray'); + + renderExternalSkillsTable(externalSkillsConfig, installed); + + // Show hint for "install all" entries with nothing installed yet + for (const config of externalSkillsConfig) { + if (!config.skills || config.skills.length === 0) { + const fromSource = installed.filter((m) => m.source === config.source); + if (fromSource.length === 0) { + console.log(` ${colors.gray}(all skills from ${config.source} — run external-skills:install to install)${colors.reset}`); + } + } + } + } + log(''); } @@ -648,18 +705,163 @@ function regenerateArtifacts() { log('\nDone!', 'green'); } +/** + * Returns a formatted security label string for a skill meta entry. + * + * @param {ExternalSkillMeta|undefined} meta + * @returns {string} + */ +function formatSecurityLabel(meta) { + if (!meta || !meta.security) return ''; + const risk = getHighestRisk(meta.security); + if (risk === 'safe' || risk === 'low') return `${colors.green}✓ Safe${colors.reset} `; + if (risk === 'medium') return `${colors.yellow}⚠ Medium${colors.reset} `; + if (risk === 'high' || risk === 'critical') return `${colors.red}✗ ${capitalize(risk)}${colors.reset} `; + return ''; +} + +/** + * Builds a deduplicated list of {name, source} entries from config + installed skills. + * + * @param {ExternalSkillConfig[]} configured + * @param {ExternalSkillMeta[]} installed + * @returns {Array<{name: string, source: string}>} + */ +function buildExternalSkillsRows(configured, installed) { + const seen = new Set(); + const rows = []; + + for (const config of configured) { + const names = config.skills && config.skills.length > 0 + ? config.skills + : installed.filter((m) => m.source === config.source).map((m) => m.name); + + for (const name of names) { + if (!seen.has(name)) { + seen.add(name); + rows.push({ name, source: config.source }); + } + } + } + + // Add skills that are installed but no longer in config + for (const meta of installed) { + if (!seen.has(meta.name)) { + seen.add(meta.name); + rows.push({ name: meta.name, source: meta.source }); + } + } + + return rows; +} + +/** + * Renders a table of external skills to stdout. + * + * @param {ExternalSkillConfig[]} configured + * @param {ExternalSkillMeta[]} installed + */ +function renderExternalSkillsTable(configured, installed) { + const installedNames = new Set(installed.map((m) => m.name)); + const rows = buildExternalSkillsRows(configured, installed); + + const COL_NAME = 32; + const COL_SOURCE = 28; + + for (const row of rows) { + const isInstalled = installedNames.has(row.name); + const meta = installed.find((m) => m.name === row.name); + const name = row.name.substring(0, COL_NAME - 1).padEnd(COL_NAME); + const source = (row.source || '').substring(0, COL_SOURCE - 1).padEnd(COL_SOURCE); + const securityLabel = formatSecurityLabel(meta); + const statusLabel = isInstalled + ? `${colors.green}installed${colors.reset}` + : `${colors.gray}not installed${colors.reset}`; + + console.log(` ${colors.cyan}${name}${colors.reset} ${colors.gray}${source}${colors.reset} ${securityLabel}${statusLabel}`); + } +} + +/** + * Searches skills.sh and displays results in a formatted table. + * + * @param {string} query + */ +async function runExternalSkillsFind(query) { + if (!query || !query.trim()) { + logError('Usage: opencode-link external-skills:find '); + process.exit(1); + } + + log(`\nExternal Skills from skills.sh — "${query.trim()}"`, 'bright'); + log('─'.repeat(70), 'gray'); + + const skills = await searchSkills(query.trim()); + + if (skills.length === 0) { + logInfo('No skills found.'); + return; + } + + // Header + const COL_SKILL = 28; + const COL_REPO = 26; + const COL_INST = 10; + const header = ` ${'SKILL'.padEnd(COL_SKILL)} ${'REPOSITORY'.padEnd(COL_REPO)} INSTALLS`; + log(header, 'gray'); + + for (const skill of skills) { + const name = (skill.name || skill.slug || '').substring(0, COL_SKILL - 1).padEnd(COL_SKILL); + const source = (skill.source || '').substring(0, COL_REPO - 1).padEnd(COL_REPO); + const installs = formatInstalls(skill.installs || 0).padEnd(COL_INST); + console.log(` ${colors.cyan}${name}${colors.reset} ${colors.gray}${source}${colors.reset} ${installs}`); + } + + // Show install hint using the :add command + const firstSource = skills[0]?.source || ''; + const firstName = skills[0]?.name || skills[0]?.slug || ''; + + log(`\nFor the complete list see https://skills.sh/?q=${encodeURIComponent(query.trim())}`, 'gray'); + log('\nTo install:', 'gray'); + console.log(` ${colors.cyan}opencode-link external-skills:add ${firstSource} ${firstName}${colors.reset}`); +} + +/** + * Displays a list of installed external skills. + * + * @param {string} targetDir + */ +function showExternalSkillsList(targetDir) { + const installed = listInstalledExternalSkills(targetDir); + const configured = readExternalSkillsConfig(targetDir); + + log('\nExternal Skills', 'bright'); + log('─'.repeat(70), 'gray'); + + if (installed.length === 0 && configured.length === 0) { + logInfo('No external skills configured. Add "externalSkills" to .opencode/opencode-project.json.'); + return; + } + + renderExternalSkillsTable(configured, installed); + + log(''); +} + /** * Main entry point. */ async function main() { const args = process.argv.slice(2); - const { commands, targetDir: cliTargetDir, singular } = parseArgs(args); + const { commands, targetDir: cliTargetDir, singular, force } = parseArgs(args); // Enable singular directory mode if --singular flag is passed if (singular) { setUseSingularDirs(true); } + FORCE = force; + // Resolve target directory const { targetDir, source } = resolveTargetDir(cliTargetDir); TARGET_DIR = targetDir; @@ -699,11 +901,37 @@ async function main() { // Generate artifacts log(''); regenerateArtifacts(); + + // Install and link external skills if configured + const externalSkillsConfig = readExternalSkillsConfig(TARGET_DIR); + if (externalSkillsConfig.length > 0) { + await installExternalSkills(TARGET_DIR, { force: FORCE }); + linkExternalSkills(TARGET_DIR); + } log(`\nLinked ${standardPlugins.length} standard plugins. Use 'npx opencode-link all' for all plugins.`, 'gray'); return; } + // external-skills:find consumes all remaining args as the search query + if (commands[0] === 'external-skills:find') { + await runExternalSkillsFind(commands.slice(1).join(' ')); + return; + } + + // external-skills:add + if (commands[0] === 'external-skills:add') { + const ownerRepo = commands[1]; + const skillName = commands[2]; + if (!ownerRepo || !skillName) { + logError('Usage: opencode-link external-skills:add '); + logError('Example: opencode-link external-skills:add vercel-labs/agent-skills vercel-react-best-practices'); + process.exit(1); + } + await addExternalSkill(TARGET_DIR, ownerRepo, skillName, { force: FORCE }); + return; + } + // Process commands for (const command of commands) { switch (command) { @@ -734,6 +962,20 @@ async function main() { showStatus(); break; + case 'external-skills:install': + await installExternalSkills(TARGET_DIR, { force: FORCE }); + linkExternalSkills(TARGET_DIR); + break; + + case 'external-skills:update': + await updateExternalSkills(TARGET_DIR); + linkExternalSkills(TARGET_DIR); + break; + + case 'external-skills:list': + showExternalSkillsList(TARGET_DIR); + break; + default: ensureDirectoryStructure(); if (await linkPlugin(command)) { diff --git a/lib/external-skills.js b/lib/external-skills.js new file mode 100644 index 0000000..b0a80b6 --- /dev/null +++ b/lib/external-skills.js @@ -0,0 +1,599 @@ +/** + * External Skills Module + * + * Handles config reading, download, security checks, symlinking and update detection + * for external skills configured in opencode-project.json. + */ + +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import { + fetchAuditData, + downloadSkillFile, + getSkillSha, + listRepoSkills, + getHighestRisk, + formatAuditDisplay, +} from './skills-registry.js'; +import {createSymlink} from './linker.js'; + +const EXTERNAL_SKILLS_DIR_NAME = '.external-skills'; + +// Colors for terminal output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +}; + +/** + * Reads the externalSkills array from opencode-project.json. + * Returns [] if not configured or file doesn't exist. + * + * @param {string} targetDir path to .opencode directory + * @returns {ExternalSkillConfig[]} + */ +export function readExternalSkillsConfig(targetDir) { + const configPath = path.join(targetDir, 'opencode-project.json'); + if (!fs.existsSync(configPath)) return []; + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const externalSkills = config.externalSkills; + if (!Array.isArray(externalSkills)) return []; + return externalSkills; + } catch { + return []; + } +} + +/** + * Returns the path to the .external-skills directory. + * + * @param {string} targetDir + * @returns {string} + */ +function getExternalSkillsDir(targetDir) { + return path.join(targetDir, EXTERNAL_SKILLS_DIR_NAME); +} + +/** + * Ensures the .external-skills directory exists and is gitignored. + * + * @param {string} targetDir + */ +function ensureExternalSkillsDir(targetDir) { + const dir = getExternalSkillsDir(targetDir); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}); + } + + // Ensure .gitignore in targetDir covers .external-skills/ + const gitignorePath = path.join(targetDir, '.gitignore'); + const entry = `${EXTERNAL_SKILLS_DIR_NAME}/\n`; + + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync(gitignorePath, entry); + } else { + const content = fs.readFileSync(gitignorePath, 'utf-8'); + if (!content.includes(EXTERNAL_SKILLS_DIR_NAME)) { + fs.appendFileSync(gitignorePath, (content.endsWith('\n') ? '' : '\n') + entry); + } + } +} + +/** + * Reads .skill-meta.json for an installed external skill. + * + * @param {string} externalSkillsDir + * @param {string} skillName + * @returns {ExternalSkillMeta|null} + */ +export function readSkillMeta(externalSkillsDir, skillName) { + const metaPath = path.join(externalSkillsDir, skillName, '.skill-meta.json'); + if (!fs.existsSync(metaPath)) return null; + try { + return JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + } catch { + return null; + } +} + +/** + * Writes .skill-meta.json for an installed external skill. + * + * @param {string} externalSkillsDir + * @param {string} skillName + * @param {ExternalSkillMeta} meta + */ +function writeSkillMeta(externalSkillsDir, skillName, meta) { + const skillDir = path.join(externalSkillsDir, skillName); + if (!fs.existsSync(skillDir)) { + fs.mkdirSync(skillDir, {recursive: true}); + } + const metaPath = path.join(skillDir, '.skill-meta.json'); + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n'); +} + +/** + * Asks the user a yes/no question in an interactive TTY. + * Returns the answer (true = yes, false = no). + * + * @param {string} question + * @returns {Promise} + */ +function askConfirmation(question) { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); +} + +/** + * Installs a single skill: + * 1. Checks security (never blocks if unavailable) + * 2. Downloads SKILL.md + * 3. Writes .skill-meta.json + * + * Returns 'installed', 'skipped', 'aborted', or 'failed'. + * + * @param {string} externalSkillsDir + * @param {string} ownerRepo + * @param {string} skillName + * @param {string} skillPath + * @param {string} branch + * @param {boolean} force + * @param {AuditResponse|null} auditData pre-fetched audit data for entire repo + * @returns {Promise<'installed'|'skipped'|'aborted'|'failed'>} + */ +async function installSingleSkill(externalSkillsDir, ownerRepo, skillName, skillPath, branch, force, auditData) { + const existingMeta = readSkillMeta(externalSkillsDir, skillName); + + // Check current SHA to detect if already up to date + const latestSha = await getSkillSha(ownerRepo, skillPath, branch); + + if (!force && existingMeta && existingMeta.sha && latestSha && existingMeta.sha === latestSha) { + console.log(`${colors.gray} - ${skillName} (up to date, skipped)${colors.reset}`); + return 'skipped'; + } + + // Security audit for this specific skill + const skillAuditData = auditData ? auditData[skillName] : null; + const risk = getHighestRisk(skillAuditData); + + if (skillAuditData) { + const display = formatAuditDisplay(skillAuditData); + console.log(`${colors.cyan} ${skillName} Security Audit: ${display}${colors.reset}`); + } else { + console.log(`${colors.yellow} ${skillName} (security check unavailable)${colors.reset}`); + } + + // Enforce security policy + const skillUrl = `https://skills.sh/${ownerRepo}/${skillName}`; + + if (risk === 'critical' && !force) { + console.log(`${colors.red} ! ${skillName}: Risk level CRITICAL — skipping. Review: ${skillUrl} — Use --force to override.${colors.reset}`); + return 'aborted'; + } + + if (risk === 'high' && !force) { + const isInteractive = process.stdin.isTTY; + if (!isInteractive) { + console.log(`${colors.red} ! ${skillName}: Risk level HIGH — aborted. Review: ${skillUrl} — Use --force to override.${colors.reset}`); + return 'aborted'; + } + const confirmed = await askConfirmation(` ${colors.yellow}Risk level HIGH for "${skillName}". Review: ${skillUrl}\n Install anyway? [y/N] ${colors.reset}`); + if (!confirmed) { + console.log(`${colors.yellow} - ${skillName}: Skipped by user.${colors.reset}`); + return 'aborted'; + } + } + + if (risk === 'medium') { + console.log(`${colors.yellow} ⚠ ${skillName}: Risk level MEDIUM — review skill before using it: ${skillUrl}${colors.reset}`); + } + + // Download SKILL.md + const result = await downloadSkillFile(ownerRepo, skillPath, branch); + if (!result) { + console.log(`${colors.red} ! ${skillName}: Failed to download SKILL.md${colors.reset}`); + return 'failed'; + } + + const {content, sha} = result; + + // Write SKILL.md + const skillDir = path.join(externalSkillsDir, skillName); + if (!fs.existsSync(skillDir)) { + fs.mkdirSync(skillDir, {recursive: true}); + } + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + + // Write metadata + /** @type {ExternalSkillMeta} */ + const meta = { + name: skillName, + source: ownerRepo, + skillPath, + branch, + sha, + downloadedAt: new Date().toISOString(), + security: skillAuditData || null, + }; + writeSkillMeta(externalSkillsDir, skillName, meta); + + console.log(`${colors.green} + Downloaded SKILL.md (sha: ${sha ? sha.substring(0, 7) : 'unknown'})${colors.reset}`); + return 'installed'; +} + +/** + * Downloads all configured external skills to .external-skills/. + * Runs security check before each download. + * Skips skills where SHA matches cached value. + * + * @param {string} targetDir + * @param {{ force?: boolean }} options + * @returns {Promise<{installed: string[], skipped: string[], failed: string[]}>} + */ +export async function installExternalSkills(targetDir, options = {}) { + const force = options.force || false; + const configs = readExternalSkillsConfig(targetDir); + const externalSkillsDir = getExternalSkillsDir(targetDir); + + if (configs.length === 0) { + return {installed: [], skipped: [], failed: []}; + } + + ensureExternalSkillsDir(targetDir); + + const installed = []; + const skipped = []; + const failed = []; + + console.log(`\n${colors.cyan}External skills (from opencode-project.json)...${colors.reset}`); + + for (const config of configs) { + const {source: ownerRepo, branch = 'main', skills: requestedSkills} = config; + + // Determine which skills to install + let skillsToInstall; + if (requestedSkills && requestedSkills.length > 0) { + skillsToInstall = requestedSkills.map((name) => ({ + name, + path: `skills/${name}`, + })); + } else { + // Discover all skills in the repo + const discovered = await listRepoSkills(ownerRepo, branch); + if (discovered.length === 0) { + console.log(`${colors.yellow} ⚠ No skills found in ${ownerRepo}${colors.reset}`); + continue; + } + skillsToInstall = discovered.map((name) => ({ + name: name || path.basename(ownerRepo), + path: name ? `skills/${name}` : '', + })); + } + + // Fetch audit data for entire repo at once (one API call) + const skillNames = skillsToInstall.map((s) => s.name); + const auditData = await fetchAuditData(ownerRepo, skillNames); + + for (const skill of skillsToInstall) { + const result = await installSingleSkill( + externalSkillsDir, + ownerRepo, + skill.name, + skill.path, + branch, + force, + auditData, + ); + + if (result === 'installed') installed.push(skill.name); + else if (result === 'skipped') skipped.push(skill.name); + else failed.push(skill.name); + } + } + + return {installed, skipped, failed}; +} + +/** + * Creates symlinks in .opencode/skills/ pointing to .external-skills//. + * Reuses createSymlink() from linker.js (last-wins strategy). + * + * @param {string} targetDir + */ +export function linkExternalSkills(targetDir) { + const externalSkillsDir = getExternalSkillsDir(targetDir); + if (!fs.existsSync(externalSkillsDir)) return; + + // Ensure skills/ directory exists in targetDir + const skillsDir = path.join(targetDir, 'skills'); + if (!fs.existsSync(skillsDir)) { + fs.mkdirSync(skillsDir, {recursive: true}); + } + + const skillDirs = fs.readdirSync(externalSkillsDir).filter((name) => { + const fullPath = path.join(externalSkillsDir, name); + return fs.statSync(fullPath).isDirectory() && !name.startsWith('.'); + }); + + for (const skillName of skillDirs) { + const sourcePath = path.join(externalSkillsDir, skillName); + const targetPath = path.join(skillsDir, skillName); + const result = createSymlink(sourcePath, targetPath); + + if (result === 'created' || result === 'overridden') { + console.log(`${colors.green} + Linked → skills/${skillName}${colors.reset}`); + } + // skipped-same: already correct, no output needed + // skipped-real: real directory exists, warn + if (result === 'skipped-real') { + console.log(`${colors.yellow} ~ skills/${skillName} (real directory exists, not overriding)${colors.reset}`); + } + if (result === 'error') { + console.log(`${colors.red} ! Failed to link skills/${skillName}${colors.reset}`); + } + } +} + +/** + * Checks GitHub SHAs for all installed external skills. + * Returns list of skills that have updates available. + * + * @param {string} targetDir + * @returns {Promise>} + */ +export async function checkExternalSkillUpdates(targetDir) { + const externalSkillsDir = getExternalSkillsDir(targetDir); + if (!fs.existsSync(externalSkillsDir)) return []; + + const updates = []; + const installed = listInstalledExternalSkills(targetDir); + + for (const meta of installed) { + const latestSha = await getSkillSha(meta.source, meta.skillPath, meta.branch); + if (latestSha && meta.sha && latestSha !== meta.sha) { + updates.push({ + name: meta.name, + currentSha: meta.sha, + latestSha, + }); + } + } + + return updates; +} + +/** + * Re-downloads all external skills where updates are available. + * + * @param {string} targetDir + */ +export async function updateExternalSkills(targetDir) { + const externalSkillsDir = getExternalSkillsDir(targetDir); + + console.log(`\n${colors.cyan}Checking for external skill updates...${colors.reset}`); + const updates = await checkExternalSkillUpdates(targetDir); + + if (updates.length === 0) { + console.log(`${colors.gray} All external skills are up to date.${colors.reset}`); + return; + } + + console.log(`${colors.yellow} ${updates.length} update(s) available.${colors.reset}`); + + // Force-reinstall outdated skills + const installed = listInstalledExternalSkills(targetDir); + const updateNames = new Set(updates.map((u) => u.name)); + + for (const meta of installed) { + if (!updateNames.has(meta.name)) continue; + + const auditData = await fetchAuditData(meta.source, [meta.name]); + + const result = await installSingleSkill( + externalSkillsDir, + meta.source, + meta.name, + meta.skillPath, + meta.branch, + true, // force=true for updates + auditData, + ); + + if (result === 'installed') { + console.log(`${colors.green} + Updated ${meta.name}${colors.reset}`); + } else { + console.log(`${colors.red} ! Failed to update ${meta.name}${colors.reset}`); + } + } +} + +/** + * Lists all installed external skills with their meta info. + * + * @param {string} targetDir + * @returns {ExternalSkillMeta[]} + */ +export function listInstalledExternalSkills(targetDir) { + const externalSkillsDir = getExternalSkillsDir(targetDir); + if (!fs.existsSync(externalSkillsDir)) return []; + + const skillsDir = path.join(targetDir, 'skills'); + + const result = []; + const entries = fs.readdirSync(externalSkillsDir).filter((name) => { + const fullPath = path.join(externalSkillsDir, name); + return fs.statSync(fullPath).isDirectory() && !name.startsWith('.'); + }); + + for (const name of entries) { + // Only count as installed if the symlink in skills/ exists + const symlinkPath = path.join(skillsDir, name); + try { + if (!fs.lstatSync(symlinkPath).isSymbolicLink()) continue; + } catch { + continue; + } + + const meta = readSkillMeta(externalSkillsDir, name); + if (meta) result.push(meta); + } + + return result; +} + +/** + * Adds a skill to opencode-project.json and installs it immediately. + * + * - Creates opencode-project.json if it does not exist. + * - If the source repo is already configured, adds the skill name to the existing entry. + * - Errors if the skill name is already listed under that source. + * + * @param {string} targetDir + * @param {string} ownerRepo e.g. "vercel-labs/agent-skills" + * @param {string} skillName e.g. "vercel-react-best-practices" + * @param {{ branch?: string, category?: string, force?: boolean }} options + * @returns {Promise} + */ +export async function addExternalSkill(targetDir, ownerRepo, skillName, options = {}) { + const {branch = 'main', category = 'optional', force = false} = options; + const configPath = path.join(targetDir, 'opencode-project.json'); + + // Read existing config (or start fresh) + let config = {}; + if (fs.existsSync(configPath)) { + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + console.log(`${colors.red} ! opencode-project.json is not valid JSON — cannot update.${colors.reset}`); + process.exit(1); + } + } + + if (!Array.isArray(config.externalSkills)) { + config.externalSkills = []; + } + + // Find existing entry for this source + const existing = config.externalSkills.find((e) => e.source === ownerRepo); + + // Check if skill is already configured (explicitly or via "install all") + const alreadyConfigured = existing && ( + !Array.isArray(existing.skills) || + existing.skills.includes(skillName) + ); + + if (alreadyConfigured) { + const isInteractive = process.stdin.isTTY; + if (!isInteractive) { + console.log(`${colors.yellow} - "${skillName}" is already configured — skipping (non-interactive mode).${colors.reset}`); + return; + } + const confirmed = await askConfirmation(` ${colors.yellow}"${skillName}" is already configured. Update to latest version? [y/N] ${colors.reset}`); + if (!confirmed) { + console.log(`${colors.gray} - Skipped.${colors.reset}`); + return; + } + // Re-install with force + const externalSkillsDir = getExternalSkillsDir(targetDir); + ensureExternalSkillsDir(targetDir); + const auditData = await fetchAuditData(ownerRepo, [skillName]); + const existingBranch = existing.branch || branch; + const result = await installSingleSkill( + externalSkillsDir, + ownerRepo, + skillName, + `skills/${skillName}`, + existingBranch, + true, + auditData, + ); + if (result === 'installed') linkExternalSkills(targetDir); + return; + } + + if (existing) { + existing.skills.push(skillName); + } else { + config.externalSkills.push({ + source: ownerRepo, + skills: [skillName], + branch, + category, + }); + } + + // Write updated config + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, {recursive: true}); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + console.log(`${colors.green} + Added "${skillName}" from "${ownerRepo}" to opencode-project.json${colors.reset}`); + + // Install immediately + const externalSkillsDir = getExternalSkillsDir(targetDir); + ensureExternalSkillsDir(targetDir); + + const skillPath = `skills/${skillName}`; + const auditData = await fetchAuditData(ownerRepo, [skillName]); + + const result = await installSingleSkill( + externalSkillsDir, + ownerRepo, + skillName, + skillPath, + branch, + force, + auditData, + ); + + if (result === 'installed') { + linkExternalSkills(targetDir); + } else if (result === 'aborted' || result === 'failed') { + // Roll back config change on abort or failure + if (existing) { + existing.skills = existing.skills.filter((s) => s !== skillName); + if (existing.skills.length === 0) { + config.externalSkills = config.externalSkills.filter((e) => e.source !== ownerRepo); + } + } else { + config.externalSkills = config.externalSkills.filter((e) => e.source !== ownerRepo); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + console.log(`${colors.yellow} ~ Reverted opencode-project.json (${result}).${colors.reset}`); + process.exit(1); + } +} + +/** + * @typedef {{ + * source: string, + * skills?: string[], + * category?: string, + * branch?: string + * }} ExternalSkillConfig + * + * @typedef {{ + * name: string, + * source: string, + * skillPath: string, + * branch: string, + * sha: string, + * downloadedAt: string, + * security: import('./skills-registry.js').AuditResponse|null + * }} ExternalSkillMeta + */ diff --git a/lib/index.js b/lib/index.js index 30034ad..9b5dde4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,3 +7,5 @@ export * from './discovery.js'; export * from './linker.js'; export * from './schema.js'; +export * from './skills-registry.js'; +export * from './external-skills.js'; diff --git a/lib/schema.js b/lib/schema.js index 48160f2..9661fc9 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -40,6 +40,9 @@ export function generateCombinedSchema(plugins, targetDir) { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "OpenCode Project Configuration (Generated)", "description": `Automatically generated from installed plugins: ${Array.from(plugins.keys()).join(', ')}`, + "properties": { + "externalSkills": buildExternalSkillsSchema() + }, "allOf": schemaRefs.map(ref => ({ "$ref": ref["$ref"] })) }; @@ -86,3 +89,41 @@ export function ensureProjectConfigSchema(targetDir) { // Ignore parse errors } } + +/** + * Returns the JSON Schema definition for the externalSkills property. + * Defined inline (not via $ref) since it is part of the core schema. + * + * @returns {object} + */ +export function buildExternalSkillsSchema() { + return { + "type": "array", + "description": "External skills from skills.sh to include in this project", + "items": { + "type": "object", + "required": ["source"], + "properties": { + "source": { + "type": "string", + "description": "GitHub owner/repo (e.g. 'vercel-labs/agent-skills')" + }, + "skills": { + "type": "array", + "items": { "type": "string" }, + "description": "Skill names to install. Omit to install all skills in the repo." + }, + "category": { + "type": "string", + "enum": ["standard", "optional"], + "default": "optional" + }, + "branch": { + "type": "string", + "default": "main", + "description": "Git branch to download from" + } + } + } + }; +} diff --git a/lib/skills-registry.js b/lib/skills-registry.js new file mode 100644 index 0000000..763e32d --- /dev/null +++ b/lib/skills-registry.js @@ -0,0 +1,262 @@ +/** + * Skills Registry Module + * + * Handles all communication with skills.sh and the GitHub API. + * No external dependencies — Node.js built-ins only (fetch, https). + */ + +const SKILLS_API_BASE = 'https://skills.sh/api'; +const AUDIT_API_BASE = 'https://add-skill.vercel.sh'; // This is an undocumented API endpoint. It may change. +const GITHUB_API_BASE = 'https://api.github.com'; +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com'; + +/** + * Returns headers for GitHub API requests. + * Includes Authorization header if GITHUB_TOKEN env var is set. + * + * @returns {Record} + */ +function getGitHubHeaders() { + const headers = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'opencode-link', + }; + if (process.env.GITHUB_TOKEN) { + headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`; + } + return headers; +} + +/** + * Performs a fetch with an optional timeout. + * + * @param {string} url + * @param {RequestInit} options + * @param {number} timeoutMs + * @returns {Promise} + */ +async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, {...options, signal: controller.signal}); + } finally { + clearTimeout(timer); + } +} + +/** + * Searches skills.sh for matching skills. + * + * @param {string} query + * @param {number} limit + * @returns {Promise>} + */ +export async function searchSkills(query, limit = 10) { + const url = `${SKILLS_API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`; + try { + const res = await fetchWithTimeout(url, {}, 10000); + if (!res.ok) return []; + const data = await res.json(); + const skills = data.skills || []; + return skills.map((s) => ({ + name: s.name || s.id || '', + slug: s.id || s.name || '', + source: s.source || '', + installs: s.installs || 0, + })); + } catch { + return []; + } +} + +/** + * Fetches security audit data from add-skill.vercel.sh. + * Returns null on any error — never blocks installation. + * + * @param {string} ownerRepo e.g. "vercel-labs/agent-skills" + * @param {string[]} skillNames + * @param {number} timeoutMs + * @returns {Promise} + */ +export async function fetchAuditData(ownerRepo, skillNames, timeoutMs = 3000) { + if (!skillNames || skillNames.length === 0) return null; + const url = `${AUDIT_API_BASE}/audit?source=${encodeURIComponent(ownerRepo)}&skills=${skillNames.map(encodeURIComponent).join(',')}`; + try { + const res = await fetchWithTimeout(url, {}, timeoutMs); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +/** + * Lists available skills in a GitHub repo via GitHub Contents API. + * Checks for /skills/ subdirectory; falls back to root SKILL.md. + * + * @param {string} ownerRepo e.g. "vercel-labs/agent-skills" + * @param {string} branch default "main" + * @returns {Promise} skill directory names (or [''] for root SKILL.md) + */ +export async function listRepoSkills(ownerRepo, branch = 'main') { + // Try /skills/ subdirectory first + const skillsDirUrl = `${GITHUB_API_BASE}/repos/${ownerRepo}/contents/skills?ref=${encodeURIComponent(branch)}`; + try { + const res = await fetchWithTimeout(skillsDirUrl, {headers: getGitHubHeaders()}, 10000); + if (res.ok) { + const entries = await res.json(); + if (Array.isArray(entries)) { + return entries + .filter((e) => e.type === 'dir') + .map((e) => e.name); + } + } + } catch { + // fall through + } + + // Fallback: check if root has SKILL.md + const rootSkillUrl = `${GITHUB_API_BASE}/repos/${ownerRepo}/contents/SKILL.md?ref=${encodeURIComponent(branch)}`; + try { + const res = await fetchWithTimeout(rootSkillUrl, {headers: getGitHubHeaders()}, 10000); + if (res.ok) { + return ['']; // Root-level SKILL.md — skillPath is repo root + } + } catch { + // fall through + } + + return []; +} + +/** + * Downloads a SKILL.md from GitHub and returns content + SHA. + * + * @param {string} ownerRepo + * @param {string} skillPath path within repo, e.g. "skills/vercel-react-best-practices" + * @param {string} branch + * @returns {Promise<{content: string, sha: string}|null>} + */ +export async function downloadSkillFile(ownerRepo, skillPath, branch = 'main') { + // Build the raw URL for file content + const filePath = skillPath ? `${skillPath}/SKILL.md` : 'SKILL.md'; + const rawUrl = `${GITHUB_RAW_BASE}/${ownerRepo}/${branch}/${filePath}`; + + // Fetch SHA via GitHub API + const sha = await getSkillSha(ownerRepo, skillPath, branch); + + try { + const res = await fetchWithTimeout(rawUrl, {}, 15000); + if (!res.ok) return null; + const content = await res.text(); + return {content, sha: sha || ''}; + } catch { + return null; + } +} + +/** + * Fetches the current SHA of a skill directory/file from GitHub API. + * Used to detect if an installed skill needs updating. + * + * @param {string} ownerRepo + * @param {string} skillPath path within repo + * @param {string} branch + * @returns {Promise} + */ +export async function getSkillSha(ownerRepo, skillPath, branch = 'main') { + const filePath = skillPath ? `${skillPath}/SKILL.md` : 'SKILL.md'; + const url = `${GITHUB_API_BASE}/repos/${ownerRepo}/contents/${filePath}?ref=${encodeURIComponent(branch)}`; + try { + const res = await fetchWithTimeout(url, {headers: getGitHubHeaders()}, 10000); + if (!res.ok) return null; + const data = await res.json(); + return data.sha || null; + } catch { + return null; + } +} + +/** + * Formats an installs count as a human-readable string (e.g. 194.8K). + * + * @param {number} installs + * @returns {string} + */ +export function formatInstalls(installs) { + if (installs >= 1_000_000) return `${(installs / 1_000_000).toFixed(1)}M`; + if (installs >= 1_000) return `${(installs / 1_000).toFixed(1)}K`; + return String(installs); +} + +/** + * Determines the highest risk level across all audit partners. + * + * @param {SkillAuditData|null|undefined} auditData + * @returns {'safe'|'low'|'medium'|'high'|'critical'|null} + */ +export function getHighestRisk(auditData) { + if (!auditData) return null; + + const RISK_ORDER = ['safe', 'low', 'medium', 'high', 'critical']; + let highest = -1; + + for (const partner of ['ath', 'socket', 'snyk']) { + const partnerData = auditData[partner]; + if (partnerData && partnerData.risk) { + const idx = RISK_ORDER.indexOf(partnerData.risk); + if (idx > highest) highest = idx; + } + } + + return highest >= 0 ? RISK_ORDER[highest] : null; +} + +// ANSI colors for risk levels +const RESET = '\x1b[0m'; +const RISK_COLORS = { + safe: '\x1b[32m', // green + low: '\x1b[32m', // green + medium: '\x1b[33m', // yellow + high: '\x1b[31m', // red + critical: '\x1b[31m', // red +}; + +/** + * Formats audit data for display in the console. + * Returns a short string like "Gen ✓ Safe · Socket ✓ 0 alerts · Snyk ✓ Low" + * Risk levels are color-coded: green (safe/low), yellow (medium), red (high/critical). + * + * @param {SkillAuditData|null|undefined} auditData + * @returns {string} + */ +export function formatAuditDisplay(auditData) { + if (!auditData) return '\x1b[90m(security unavailable)\x1b[0m'; + + const parts = []; + + if (auditData.ath) { + const risk = auditData.ath.risk || 'unknown'; + parts.push(`${RESET}Gen ${RISK_COLORS[risk] || '\x1b[90m'}[${risk}]${RESET}`); + } + if (auditData.socket) { + const risk = auditData.socket.risk || 'unknown'; + const alerts = auditData.socket.alerts != null + ? `${RISK_COLORS[risk] || '\x1b[90m'}[${auditData.socket.alerts} alerts]${RESET}` + : `${RISK_COLORS[risk] || '\x1b[90m'}${risk}${RESET}`; + parts.push(`Socket ${alerts}`); + } + if (auditData.snyk) { + const risk = auditData.snyk.risk || 'unknown'; + parts.push(`Snyk ${RISK_COLORS[risk] || '\x1b[90m'}[${risk}]${RESET}`); + } + + return parts.length > 0 ? parts.join(' · ') : '\x1b[90m(no audit data)\x1b[0m'; +} + +/** + * @typedef {{ risk: 'safe'|'low'|'medium'|'high'|'critical', alerts?: number, analyzedAt: string }} PartnerAudit + * @typedef {{ ath?: PartnerAudit, socket?: PartnerAudit, snyk?: PartnerAudit }} SkillAuditData + * @typedef {Record} AuditResponse + */ diff --git a/package.json b/package.json index bb8a204..30db89d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techdivision/opencode-cli", - "version": "0.2.1", + "version": "0.3.0", "description": "CLI tools for OpenCode plugin management", "type": "module", "bin": { @@ -18,6 +18,12 @@ }, "./schema": { "import": "./lib/schema.js" + }, + "./skills-registry": { + "import": "./lib/skills-registry.js" + }, + "./external-skills": { + "import": "./lib/external-skills.js" } }, "files": [