From c8dd02598af952bcbc22f008a16cf42cdd974d6f Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 8 Mar 2026 13:46:24 +0700 Subject: [PATCH 1/6] feat(cli): add `skillx add` command for installing skills from GitHub Implement skill installation from GitHub repositories with interactive prompts, agent detection, SKILL.md discovery, and lock file tracking. --- packages/cli/package.json | 8 +- packages/cli/src/commands/add-display.ts | 103 ++++ packages/cli/src/commands/add-prompts.ts | 128 ++++ packages/cli/src/commands/add.ts | 307 ++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/lib/add-types.ts | 78 +++ packages/cli/src/lib/agent-registry.ts | 237 +++++++ packages/cli/src/lib/git-clone.ts | 73 +++ packages/cli/src/lib/skill-discovery.test.ts | 141 +++++ packages/cli/src/lib/skill-discovery.ts | 163 +++++ packages/cli/src/lib/skill-installer.ts | 183 ++++++ packages/cli/src/lib/skill-lock.ts | 84 +++ packages/cli/src/lib/source-parser.test.ts | 117 ++++ packages/cli/src/lib/source-parser.ts | 152 +++++ ...r-260308-1226-vercel-skills-add-command.md | 578 ++++++++++++++++++ pnpm-lock.yaml | 169 +++++ 16 files changed, 2521 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/add-display.ts create mode 100644 packages/cli/src/commands/add-prompts.ts create mode 100644 packages/cli/src/commands/add.ts create mode 100644 packages/cli/src/lib/add-types.ts create mode 100644 packages/cli/src/lib/agent-registry.ts create mode 100644 packages/cli/src/lib/git-clone.ts create mode 100644 packages/cli/src/lib/skill-discovery.test.ts create mode 100644 packages/cli/src/lib/skill-discovery.ts create mode 100644 packages/cli/src/lib/skill-installer.ts create mode 100644 packages/cli/src/lib/skill-lock.ts create mode 100644 packages/cli/src/lib/source-parser.test.ts create mode 100644 packages/cli/src/lib/source-parser.ts create mode 100644 plans/reports/researcher-260308-1226-vercel-skills-add-command.md diff --git a/packages/cli/package.json b/packages/cli/package.json index 330c5d2..c027897 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,18 +18,22 @@ "dev": "tsup --watch" }, "dependencies": { + "@clack/prompts": "^1.1.0", "chalk": "^5.3.0", "commander": "^12.1.0", "conf": "^13.0.0", + "gray-matter": "^4.0.3", "ora": "^8.1.0", + "picocolors": "^1.1.1", + "simple-git": "^3.32.3", "yaml": "^2.9.1" }, "devDependencies": { "@skillx/contracts": "workspace:*", "@skillx/skill-tree": "workspace:*", + "@types/node": "^22.0.0", "tsup": "^8.0.0", - "typescript": "^5.6.0", - "@types/node": "^22.0.0" + "typescript": "^5.6.0" }, "engines": { "node": ">=22" diff --git a/packages/cli/src/commands/add-display.ts b/packages/cli/src/commands/add-display.ts new file mode 100644 index 0000000..5a8b4bf --- /dev/null +++ b/packages/cli/src/commands/add-display.ts @@ -0,0 +1,103 @@ +import pc from 'picocolors'; +import { homedir } from 'os'; +import { sep } from 'path'; +import type { AgentType, InstallMode } from '../lib/add-types.js'; +import { agents, isUniversalAgent } from '../lib/agent-registry.js'; + +/** Shorten a path for display: replace homedir with ~ and cwd with . */ +export function shortenPath(fullPath: string, cwd: string): string { + const home = homedir(); + if (fullPath === home || fullPath.startsWith(home + sep)) { + return '~' + fullPath.slice(home.length); + } + if (fullPath === cwd || fullPath.startsWith(cwd + sep)) { + return '.' + fullPath.slice(cwd.length); + } + return fullPath; +} + +/** Format a list of items, truncating if too many */ +export function formatList(items: string[], maxShow = 5): string { + if (items.length <= maxShow) return items.join(', '); + const shown = items.slice(0, maxShow); + return `${shown.join(', ')} +${items.length - maxShow} more`; +} + +/** Pad a string to visible width (ignoring ANSI codes) */ +function padEnd(str: string, width: number): string { + const visible = str.replace(/\x1b\[[0-9;]*m/g, ''); + return str + ' '.repeat(Math.max(0, width - visible.length)); +} + +/** Split agents into universal and non-universal groups */ +function splitAgentsByType(agentTypes: AgentType[]): { + universal: string[]; + symlinked: string[]; +} { + const universal: string[] = []; + const symlinked: string[] = []; + for (const a of agentTypes) { + if (isUniversalAgent(a)) { + universal.push(agents[a].displayName); + } else { + symlinked.push(agents[a].displayName); + } + } + return { universal, symlinked }; +} + +/** Build summary lines showing universal vs symlinked agents */ +export function buildAgentSummaryLines( + targetAgents: AgentType[], + installMode: InstallMode +): string[] { + const lines: string[] = []; + const { universal, symlinked } = splitAgentsByType(targetAgents); + + if (installMode === 'symlink') { + if (universal.length > 0) { + lines.push(` ${pc.green('universal:')} ${formatList(universal)}`); + } + if (symlinked.length > 0) { + lines.push(` ${pc.dim('symlink →')} ${formatList(symlinked)}`); + } + } else { + const allNames = targetAgents.map((a) => agents[a].displayName); + lines.push(` ${pc.dim('copy →')} ${formatList(allNames)}`); + } + return lines; +} + +/** Build result lines from installation results */ +export function buildResultLines( + results: Array<{ agent: string; symlinkFailed?: boolean }>, + targetAgents: AgentType[] +): string[] { + const lines: string[] = []; + const { universal } = splitAgentsByType(targetAgents); + + const successSymlinks = results + .filter((r) => !r.symlinkFailed && !universal.includes(r.agent)) + .map((r) => r.agent); + const failedSymlinks = results.filter((r) => r.symlinkFailed).map((r) => r.agent); + + if (universal.length > 0) { + lines.push(` ${pc.green('universal:')} ${formatList(universal)}`); + } + if (successSymlinks.length > 0) { + lines.push(` ${pc.dim('symlinked:')} ${formatList(successSymlinks)}`); + } + if (failedSymlinks.length > 0) { + lines.push(` ${pc.yellow('copied:')} ${formatList(failedSymlinks)}`); + } + return lines; +} + +/** Ensure universal agents are always included in target list */ +export function ensureUniversalAgents(targetAgents: AgentType[]): AgentType[] { + const result = [...targetAgents]; + if (!result.includes('universal')) { + result.push('universal'); + } + return result; +} diff --git a/packages/cli/src/commands/add-prompts.ts b/packages/cli/src/commands/add-prompts.ts new file mode 100644 index 0000000..1d826bb --- /dev/null +++ b/packages/cli/src/commands/add-prompts.ts @@ -0,0 +1,128 @@ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import type { AgentType, Skill, InstallMode } from '../lib/add-types.js'; +import { agents, getNonUniversalAgents, getUniversalAgents } from '../lib/agent-registry.js'; +import { getSkillDisplayName } from '../lib/skill-discovery.js'; +import { getLastSelectedAgents, saveSelectedAgents } from '../lib/skill-lock.js'; + +const isCancelled = (value: unknown): value is symbol => typeof value === 'symbol'; + +/** Prompt user to select skills from a list */ +export async function selectSkillsInteractive(skills: Skill[]): Promise { + const sorted = [...skills].sort((a, b) => + getSkillDisplayName(a).localeCompare(getSkillDisplayName(b)) + ); + + const choices = sorted.map((s) => ({ + value: s, + label: getSkillDisplayName(s), + hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description, + })); + + const selected = await p.multiselect({ + message: `Select skills to install ${pc.dim('(space to toggle)')}`, + options: choices as p.Option[], + required: true, + }); + + if (isCancelled(selected)) { + p.cancel('Installation cancelled'); + return null; + } + return selected as Skill[]; +} + +/** Interactive agent selection with universal agents always included */ +export async function selectAgentsInteractive(options: { + global?: boolean; +}): Promise { + const supportsGlobal = (a: AgentType) => !options.global || agents[a].globalSkillsDir; + const otherAgents = getNonUniversalAgents().filter(supportsGlobal); + + const choices = otherAgents.map((a) => ({ + value: a, + label: agents[a].displayName, + hint: options.global ? agents[a].globalSkillsDir! : agents[a].skillsDir, + })); + + // Pre-select from last session + let lastSelected: string[] | undefined; + try { + lastSelected = await getLastSelectedAgents(); + } catch { /* ignore */ } + + const initialValues = lastSelected + ? (lastSelected.filter((a) => otherAgents.includes(a as AgentType)) as AgentType[]) + : []; + + const selected = await p.multiselect({ + message: `Which agents do you want to install to? ${pc.dim('(space to toggle)')}`, + options: choices as p.Option[], + initialValues, + required: true, + }); + + if (isCancelled(selected)) { + p.cancel('Installation cancelled'); + return null; + } + + const agents_selected = selected as AgentType[]; + + // Save selection for next time + try { + await saveSelectedAgents(agents_selected as string[]); + } catch { /* ignore */ } + + // Always include universal agents + const universals = getUniversalAgents(); + for (const ua of universals) { + if (!agents_selected.includes(ua)) agents_selected.push(ua); + } + + return agents_selected; +} + +/** Prompt for installation scope (project vs global) */ +export async function selectScope(): Promise { + const scope = await p.select({ + message: 'Installation scope', + options: [ + { value: false, label: 'Project', hint: 'Install in current directory' }, + { value: true, label: 'Global', hint: 'Install in home directory (all projects)' }, + ], + }); + + if (isCancelled(scope)) { + p.cancel('Installation cancelled'); + return null; + } + return scope as boolean; +} + +/** Prompt for installation method (symlink vs copy) */ +export async function selectInstallMode(): Promise { + const mode = await p.select({ + message: 'Installation method', + options: [ + { value: 'symlink', label: 'Symlink (Recommended)', hint: 'Single source of truth' }, + { value: 'copy', label: 'Copy to all agents', hint: 'Independent copies' }, + ], + }); + + if (isCancelled(mode)) { + p.cancel('Installation cancelled'); + return null; + } + return mode as InstallMode; +} + +/** Confirm installation */ +export async function confirmInstall(): Promise { + const confirmed = await p.confirm({ message: 'Proceed with installation?' }); + if (isCancelled(confirmed) || !confirmed) { + p.cancel('Installation cancelled'); + return false; + } + return true; +} diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts new file mode 100644 index 0000000..c41ea38 --- /dev/null +++ b/packages/cli/src/commands/add.ts @@ -0,0 +1,307 @@ +import { Command } from 'commander'; +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { existsSync } from 'fs'; +import type { AddOptions, AgentType, InstallMode, Skill } from '../lib/add-types.js'; +import { parseSource, getOwnerRepo } from '../lib/source-parser.js'; +import { cloneRepo, cleanupTempDir, GitCloneError } from '../lib/git-clone.js'; +import { discoverSkills, getSkillDisplayName, filterSkills } from '../lib/skill-discovery.js'; +import { agents, detectInstalledAgents } from '../lib/agent-registry.js'; +import { installSkillForAgent, isSkillInstalled, getCanonicalPath } from '../lib/skill-installer.js'; +import { addSkillToLock } from '../lib/skill-lock.js'; +import { + shortenPath, buildAgentSummaryLines, buildResultLines, + formatList, ensureUniversalAgents, +} from './add-display.js'; +import { + selectSkillsInteractive, selectAgentsInteractive, + selectScope, selectInstallMode, confirmInstall, +} from './add-prompts.js'; + +/** Parse CLI options for the add command */ +function parseAddOptions(opts: Record): AddOptions { + const options: AddOptions = { + global: opts.global as boolean | undefined, + yes: opts.yes as boolean | undefined, + list: opts.list as boolean | undefined, + copy: opts.copy as boolean | undefined, + all: opts.all as boolean | undefined, + fullDepth: opts.fullDepth as boolean | undefined, + }; + // Commander passes repeated options as arrays + if (opts.agent) options.agent = Array.isArray(opts.agent) ? opts.agent : [opts.agent as string]; + if (opts.skill) options.skill = Array.isArray(opts.skill) ? opts.skill : [opts.skill as string]; + return options; +} + +async function cleanup(tempDir: string | null) { + if (tempDir) { + try { await cleanupTempDir(tempDir); } catch { /* ignore */ } + } +} + +/** Main add command logic */ +async function runAdd(source: string, rawOpts: Record): Promise { + const options = parseAddOptions(rawOpts); + + // --all implies --skill '*' --agent '*' -y + if (options.all) { + options.skill = ['*']; + options.agent = ['*']; + options.yes = true; + } + + console.log(); + p.intro(pc.bgCyan(pc.black(' skillx add '))); + + let tempDir: string | null = null; + + try { + const spinner = p.spinner(); + + // 1. Parse source + spinner.start('Parsing source...'); + const parsed = parseSource(source); + spinner.stop( + `Source: ${parsed.type === 'local' ? parsed.localPath! : parsed.url}` + + `${parsed.ref ? ` @ ${pc.yellow(parsed.ref)}` : ''}` + + `${parsed.subpath ? ` (${parsed.subpath})` : ''}` + + `${parsed.skillFilter ? ` ${pc.dim('@')}${pc.cyan(parsed.skillFilter)}` : ''}` + ); + + // 2. Clone or validate local path + let skillsDir: string; + if (parsed.type === 'local') { + if (!existsSync(parsed.localPath!)) { + p.outro(pc.red(`Local path does not exist: ${parsed.localPath}`)); + process.exit(1); + } + skillsDir = parsed.localPath!; + } else { + spinner.start('Cloning repository...'); + tempDir = await cloneRepo(parsed.url, parsed.ref); + spinner.stop('Repository cloned'); + } + + // Merge @skill filter into options.skill + if (parsed.skillFilter) { + options.skill = options.skill || []; + if (!options.skill.includes(parsed.skillFilter)) options.skill.push(parsed.skillFilter); + } + + // 3. Discover skills + spinner.start('Discovering skills...'); + const skills = await discoverSkills(skillsDir!, parsed.subpath, { + fullDepth: options.fullDepth, + }); + + if (skills.length === 0) { + spinner.stop(pc.red('No skills found')); + p.outro(pc.red('No valid skills found. Skills require a SKILL.md with name and description.')); + await cleanup(tempDir); + process.exit(1); + } + spinner.stop(`Found ${pc.green(String(skills.length))} skill${skills.length > 1 ? 's' : ''}`); + + // 4. --list: just show available skills and exit + if (options.list) { + console.log(); + p.log.step(pc.bold('Available Skills')); + for (const skill of skills) { + p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`); + p.log.message(` ${pc.dim(skill.description)}`); + } + console.log(); + p.outro('Use --skill to install specific skills'); + await cleanup(tempDir); + process.exit(0); + } + + // 5. Select skills + let selectedSkills: Skill[]; + if (options.skill?.includes('*')) { + selectedSkills = skills; + p.log.info(`Installing all ${skills.length} skills`); + } else if (options.skill && options.skill.length > 0) { + selectedSkills = filterSkills(skills, options.skill); + if (selectedSkills.length === 0) { + p.log.error(`No matching skills for: ${options.skill.join(', ')}`); + p.log.info('Available: ' + skills.map((s) => getSkillDisplayName(s)).join(', ')); + await cleanup(tempDir); + process.exit(1); + } + } else if (skills.length === 1) { + selectedSkills = skills; + p.log.info(`Skill: ${pc.cyan(getSkillDisplayName(skills[0]!))}`); + } else if (options.yes) { + selectedSkills = skills; + p.log.info(`Installing all ${skills.length} skills`); + } else { + const result = await selectSkillsInteractive(skills); + if (!result) { await cleanup(tempDir); process.exit(0); } + selectedSkills = result; + } + + // 6. Select target agents + let targetAgents: AgentType[]; + const validAgents = Object.keys(agents) as AgentType[]; + + if (options.agent?.includes('*')) { + targetAgents = validAgents; + } else if (options.agent && options.agent.length > 0) { + const invalid = options.agent.filter((a) => !validAgents.includes(a as AgentType)); + if (invalid.length > 0) { + p.log.error(`Invalid agents: ${invalid.join(', ')}`); + await cleanup(tempDir); + process.exit(1); + } + targetAgents = options.agent as AgentType[]; + } else { + spinner.start('Detecting agents...'); + const installed = await detectInstalledAgents(); + spinner.stop(`${validAgents.length} agents available`); + + if (installed.length <= 1 || options.yes) { + targetAgents = ensureUniversalAgents(installed.length > 0 ? installed : validAgents); + } else { + const result = await selectAgentsInteractive({ global: options.global }); + if (!result) { await cleanup(tempDir); process.exit(0); } + targetAgents = result; + } + } + + // 7. Select scope (global vs project) + let installGlobally = options.global ?? false; + if (options.global === undefined && !options.yes) { + const scope = await selectScope(); + if (scope === null) { await cleanup(tempDir); process.exit(0); } + installGlobally = scope; + } + + // 8. Select install mode (symlink vs copy) + let installMode: InstallMode = options.copy ? 'copy' : 'symlink'; + if (!options.copy && !options.yes) { + const mode = await selectInstallMode(); + if (!mode) { await cleanup(tempDir); process.exit(0); } + installMode = mode; + } + + // 9. Summary + const cwd = process.cwd(); + const summaryLines: string[] = []; + for (const skill of selectedSkills) { + if (summaryLines.length > 0) summaryLines.push(''); + const canonical = getCanonicalPath(skill.name, { global: installGlobally }); + summaryLines.push(pc.cyan(shortenPath(canonical, cwd))); + summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); + } + console.log(); + p.note(summaryLines.join('\n'), 'Installation Summary'); + + // 10. Confirm + if (!options.yes) { + const ok = await confirmInstall(); + if (!ok) { await cleanup(tempDir); process.exit(0); } + } + + // 11. Install + spinner.start('Installing skills...'); + const results: Array<{ + skill: string; agent: string; success: boolean; path: string; + canonicalPath?: string; mode: InstallMode; symlinkFailed?: boolean; error?: string; + }> = []; + + for (const skill of selectedSkills) { + for (const agent of targetAgents) { + const result = await installSkillForAgent(skill, agent, { + global: installGlobally, + mode: installMode, + }); + results.push({ skill: getSkillDisplayName(skill), agent: agents[agent].displayName, ...result }); + } + } + spinner.stop('Installation complete'); + + // 12. Show results + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + if (successful.length > 0) { + const bySkill = new Map(); + for (const r of successful) { + const arr = bySkill.get(r.skill) || []; + arr.push(r); + bySkill.set(r.skill, arr); + } + + const resultLines: string[] = []; + for (const [skillName, skillResults] of bySkill) { + const first = skillResults[0]!; + if (first.mode === 'copy') { + resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim('(copied)')}`); + for (const r of skillResults) resultLines.push(` ${pc.dim('→')} ${shortenPath(r.path, cwd)}`); + } else { + const label = first.canonicalPath ? shortenPath(first.canonicalPath, cwd) : skillName; + resultLines.push(`${pc.green('✓')} ${label}`); + resultLines.push(...buildResultLines(skillResults, targetAgents)); + } + } + + p.note(resultLines.join('\n'), pc.green(`Installed ${bySkill.size} skill${bySkill.size !== 1 ? 's' : ''}`)); + + const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed); + if (symlinkFailures.length > 0) { + p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(symlinkFailures.map((r) => r.agent))}`)); + p.log.message(pc.dim(' Files were copied instead. On Windows, enable Developer Mode for symlink support.')); + } + } + + if (failed.length > 0) { + p.log.error(pc.red(`Failed to install ${failed.length}`)); + for (const r of failed) p.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`); + } + + // 13. Update lock file (global installs) + if (successful.length > 0 && installGlobally) { + const normalizedSource = getOwnerRepo(parsed); + if (normalizedSource) { + for (const skill of selectedSkills) { + try { + await addSkillToLock(skill.name, { + source: normalizedSource, + sourceType: parsed.type, + sourceUrl: parsed.url, + }); + } catch { /* ignore lock file errors */ } + } + } + } + + console.log(); + p.outro(pc.green('Done!') + pc.dim(' Review skills before use; they run with full agent permissions.')); + } catch (error) { + if (error instanceof GitCloneError) { + p.log.error(pc.red('Failed to clone repository')); + for (const line of error.message.split('\n')) p.log.message(pc.dim(line)); + } else { + p.log.error(error instanceof Error ? error.message : 'Unknown error occurred'); + } + p.outro(pc.red('Installation failed')); + process.exit(1); + } finally { + await cleanup(tempDir); + } +} + +export const addCommand = new Command('add') + .description('Add skills from a GitHub repo, local path, or git URL') + .argument('', 'owner/repo, URL, or local path') + .option('-g, --global', 'Install globally (~/) instead of project-level') + .option('-a, --agent ', 'Target specific agents (use * for all)') + .option('-s, --skill ', 'Select specific skills (use * for all)') + .option('-l, --list', 'List available skills without installing') + .option('-y, --yes', 'Skip confirmation prompts') + .option('--copy', 'Copy files instead of symlinking') + .option('--all', 'Shorthand for --skill * --agent * -y') + .option('--full-depth', 'Search all subdirs even when root SKILL.md exists') + .action((source: string, opts: Record) => runAdd(source, opts)); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dcb0f08..c1e91d8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { promoteCommand } from './commands/promote.js'; import { releaseCommand } from './commands/release-publish.js'; import { installCommand, listCommand, syncCommand, uninstallCommand, updateCommand } from './commands/install.js'; import { mcpCommand } from './commands/mcp.js'; +import { addCommand } from './commands/add.js'; import { routeUsageErrors } from './lib/run-command.js'; /** Injected at build time from packages/cli/package.json by tsup (see tsup.config.ts). */ @@ -41,6 +42,7 @@ program.addCommand(publishCommand); program.addCommand(promoteCommand); program.addCommand(releaseCommand); for (const command of [installCommand, updateCommand, uninstallCommand, listCommand, syncCommand, mcpCommand]) program.addCommand(command); +program.addCommand(addCommand); // Parse errors exit 2 (and print a usage_error envelope under --json); see docs/cli-reference.md. routeUsageErrors(program); diff --git a/packages/cli/src/lib/add-types.ts b/packages/cli/src/lib/add-types.ts new file mode 100644 index 0000000..2b7a785 --- /dev/null +++ b/packages/cli/src/lib/add-types.ts @@ -0,0 +1,78 @@ +/** Shared types for the `skillx add` command */ + +export type AgentType = + | 'amp' + | 'augment' + | 'claude-code' + | 'cline' + | 'codebuddy' + | 'codex' + | 'command-code' + | 'continue' + | 'cursor' + | 'gemini-cli' + | 'github-copilot' + | 'goose' + | 'junie' + | 'kilo' + | 'kiro-cli' + | 'kode' + | 'mux' + | 'neovate' + | 'opencode' + | 'openhands' + | 'roo' + | 'trae' + | 'windsurf' + | 'zencoder' + | 'universal'; + +export interface AgentConfig { + name: string; + displayName: string; + skillsDir: string; + /** Global skills directory. undefined = doesn't support global install. */ + globalSkillsDir: string | undefined; + detectInstalled: () => Promise; +} + +export interface Skill { + name: string; + description: string; + path: string; + rawContent?: string; + pluginName?: string; + metadata?: Record; +} + +export interface ParsedSource { + type: 'github' | 'gitlab' | 'git' | 'local'; + url: string; + subpath?: string; + localPath?: string; + ref?: string; + /** Skill name from @skill syntax (e.g., owner/repo@skill-name) */ + skillFilter?: string; +} + +export interface AddOptions { + global?: boolean; + agent?: string[]; + yes?: boolean; + skill?: string[]; + list?: boolean; + all?: boolean; + fullDepth?: boolean; + copy?: boolean; +} + +export type InstallMode = 'symlink' | 'copy'; + +export interface InstallResult { + success: boolean; + path: string; + canonicalPath?: string; + mode: InstallMode; + symlinkFailed?: boolean; + error?: string; +} diff --git a/packages/cli/src/lib/agent-registry.ts b/packages/cli/src/lib/agent-registry.ts new file mode 100644 index 0000000..8bea309 --- /dev/null +++ b/packages/cli/src/lib/agent-registry.ts @@ -0,0 +1,237 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import type { AgentConfig, AgentType } from './add-types.js'; + +const home = homedir(); +const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude'); + +// Canonical directory for universal agents (.agents/skills) +const AGENTS_DIR = '.agents'; +const SKILLS_SUBDIR = 'skills'; + +/** All supported agent configurations */ +export const agents: Record = { + amp: { + name: 'amp', + displayName: 'Amp', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.config/agents/skills'), + detectInstalled: async () => existsSync(join(home, '.config/amp')), + }, + augment: { + name: 'augment', + displayName: 'Augment', + skillsDir: '.augment/skills', + globalSkillsDir: join(home, '.augment/skills'), + detectInstalled: async () => existsSync(join(home, '.augment')), + }, + 'claude-code': { + name: 'claude-code', + displayName: 'Claude Code', + skillsDir: '.claude/skills', + globalSkillsDir: join(claudeHome, 'skills'), + detectInstalled: async () => existsSync(claudeHome), + }, + cline: { + name: 'cline', + displayName: 'Cline', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.agents', 'skills'), + detectInstalled: async () => existsSync(join(home, '.cline')), + }, + codebuddy: { + name: 'codebuddy', + displayName: 'CodeBuddy', + skillsDir: '.codebuddy/skills', + globalSkillsDir: join(home, '.codebuddy/skills'), + detectInstalled: async () => existsSync(join(home, '.codebuddy')), + }, + codex: { + name: 'codex', + displayName: 'Codex', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.codex/skills'), + detectInstalled: async () => existsSync(join(home, '.codex')), + }, + 'command-code': { + name: 'command-code', + displayName: 'Command Code', + skillsDir: '.commandcode/skills', + globalSkillsDir: join(home, '.commandcode/skills'), + detectInstalled: async () => existsSync(join(home, '.commandcode')), + }, + continue: { + name: 'continue', + displayName: 'Continue', + skillsDir: '.continue/skills', + globalSkillsDir: join(home, '.continue/skills'), + detectInstalled: async () => existsSync(join(home, '.continue')), + }, + cursor: { + name: 'cursor', + displayName: 'Cursor', + skillsDir: '.cursor/skills', + globalSkillsDir: join(home, '.cursor/skills'), + detectInstalled: async () => existsSync(join(home, '.cursor')), + }, + 'gemini-cli': { + name: 'gemini-cli', + displayName: 'Gemini CLI', + skillsDir: '.gemini/skills', + globalSkillsDir: join(home, '.gemini/skills'), + detectInstalled: async () => existsSync(join(home, '.gemini')), + }, + 'github-copilot': { + name: 'github-copilot', + displayName: 'GitHub Copilot', + skillsDir: '.github/skills', + globalSkillsDir: undefined, + detectInstalled: async () => existsSync(join(process.cwd(), '.github')), + }, + goose: { + name: 'goose', + displayName: 'Goose', + skillsDir: '.goose/skills', + globalSkillsDir: join(home, '.config/goose/skills'), + detectInstalled: async () => existsSync(join(home, '.config/goose')), + }, + junie: { + name: 'junie', + displayName: 'Junie', + skillsDir: '.junie/skills', + globalSkillsDir: join(home, '.junie/skills'), + detectInstalled: async () => existsSync(join(home, '.junie')), + }, + kilo: { + name: 'kilo', + displayName: 'Kilo', + skillsDir: '.kilocode/skills', + globalSkillsDir: join(home, '.kilocode/skills'), + detectInstalled: async () => existsSync(join(home, '.kilocode')), + }, + 'kiro-cli': { + name: 'kiro-cli', + displayName: 'Kiro', + skillsDir: '.kiro/skills', + globalSkillsDir: join(home, '.kiro/skills'), + detectInstalled: async () => existsSync(join(home, '.kiro')), + }, + kode: { + name: 'kode', + displayName: 'Kode', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.agents/skills'), + detectInstalled: async () => existsSync(join(home, '.kode')), + }, + mux: { + name: 'mux', + displayName: 'Mux', + skillsDir: '.mux/skills', + globalSkillsDir: join(home, '.mux/skills'), + detectInstalled: async () => existsSync(join(home, '.mux')), + }, + neovate: { + name: 'neovate', + displayName: 'Neovate', + skillsDir: '.neovate/skills', + globalSkillsDir: join(home, '.neovate/skills'), + detectInstalled: async () => existsSync(join(home, '.neovate')), + }, + opencode: { + name: 'opencode', + displayName: 'OpenCode', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.config/agents/skills'), + detectInstalled: async () => existsSync(join(home, '.config/opencode')), + }, + openhands: { + name: 'openhands', + displayName: 'OpenHands', + skillsDir: '.openhands/skills', + globalSkillsDir: join(home, '.openhands/skills'), + detectInstalled: async () => existsSync(join(home, '.openhands')), + }, + roo: { + name: 'roo', + displayName: 'Roo', + skillsDir: '.roo/skills', + globalSkillsDir: join(home, '.roo/skills'), + detectInstalled: async () => existsSync(join(home, '.roo')), + }, + trae: { + name: 'trae', + displayName: 'Trae', + skillsDir: '.trae/skills', + globalSkillsDir: join(home, '.trae/skills'), + detectInstalled: async () => existsSync(join(home, '.trae')), + }, + windsurf: { + name: 'windsurf', + displayName: 'Windsurf', + skillsDir: '.windsurf/skills', + globalSkillsDir: join(home, '.windsurf/skills'), + detectInstalled: async () => existsSync(join(home, '.windsurf')), + }, + zencoder: { + name: 'zencoder', + displayName: 'Zencoder', + skillsDir: '.zencoder/skills', + globalSkillsDir: join(home, '.zencoder/skills'), + detectInstalled: async () => existsSync(join(home, '.zencoder')), + }, + universal: { + name: 'universal', + displayName: 'Universal (.agents/skills)', + skillsDir: '.agents/skills', + globalSkillsDir: join(home, '.agents/skills'), + detectInstalled: async () => true, + }, +}; + +// Agents that share the canonical .agents/skills directory +const UNIVERSAL_AGENTS: AgentType[] = ['universal']; + +export function isUniversalAgent(agent: AgentType): boolean { + return UNIVERSAL_AGENTS.includes(agent); +} + +export function getUniversalAgents(): AgentType[] { + return UNIVERSAL_AGENTS; +} + +export function getNonUniversalAgents(): AgentType[] { + return (Object.keys(agents) as AgentType[]).filter((a) => !isUniversalAgent(a)); +} + +/** Detect which agents are installed on the system */ +export async function detectInstalledAgents(): Promise { + const results = await Promise.all( + (Object.keys(agents) as AgentType[]).map(async (key) => ({ + key, + installed: await agents[key].detectInstalled(), + })) + ); + return results.filter((r) => r.installed).map((r) => r.key); +} + +/** Get canonical skills directory (.agents/skills) */ +export function getCanonicalSkillsDir(global: boolean, cwd?: string): string { + const baseDir = global ? homedir() : cwd || process.cwd(); + return join(baseDir, AGENTS_DIR, SKILLS_SUBDIR); +} + +/** Get agent-specific base directory for skills */ +export function getAgentBaseDir(agentType: AgentType, global: boolean, cwd?: string): string { + if (isUniversalAgent(agentType)) { + return getCanonicalSkillsDir(global, cwd); + } + + const agent = agents[agentType]; + const baseDir = global ? homedir() : cwd || process.cwd(); + + if (global) { + return agent.globalSkillsDir ?? join(baseDir, agent.skillsDir); + } + return join(baseDir, agent.skillsDir); +} diff --git a/packages/cli/src/lib/git-clone.ts b/packages/cli/src/lib/git-clone.ts new file mode 100644 index 0000000..45a834c --- /dev/null +++ b/packages/cli/src/lib/git-clone.ts @@ -0,0 +1,73 @@ +import simpleGit from 'simple-git'; +import { join, normalize, resolve, sep } from 'path'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; + +const CLONE_TIMEOUT_MS = 60000; + +export class GitCloneError extends Error { + readonly url: string; + readonly isTimeout: boolean; + readonly isAuthError: boolean; + + constructor(message: string, url: string, isTimeout = false, isAuthError = false) { + super(message); + this.name = 'GitCloneError'; + this.url = url; + this.isTimeout = isTimeout; + this.isAuthError = isAuthError; + } +} + +/** Shallow clone a git repository to a temp directory */ +export async function cloneRepo(url: string, ref?: string): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'skillx-')); + const git = simpleGit({ + timeout: { block: CLONE_TIMEOUT_MS }, + // Disable interactive credential prompts + config: ['core.askPass=', 'credential.helper='], + }); + const cloneOptions = ref ? ['--depth', '1', '--branch', ref] : ['--depth', '1']; + + try { + await git.clone(url, tempDir, cloneOptions); + return tempDir; + } catch (error) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + + const msg = error instanceof Error ? error.message : String(error); + const isTimeout = msg.includes('block timeout') || msg.includes('timed out'); + const isAuth = + msg.includes('Authentication failed') || + msg.includes('could not read Username') || + msg.includes('Permission denied') || + msg.includes('Repository not found'); + + if (isTimeout) { + throw new GitCloneError( + 'Clone timed out after 60s. Ensure you have access and credentials configured.\n' + + ' SSH: ssh-add -l | HTTPS: gh auth status', + url, true, false + ); + } + if (isAuth) { + throw new GitCloneError( + `Authentication failed for ${url}.\n` + + ' SSH: ssh -T git@github.com | HTTPS: gh auth login', + url, false, true + ); + } + throw new GitCloneError(`Failed to clone ${url}: ${msg}`, url); + } +} + +/** Safe cleanup — validates path is within tmpdir before deleting */ +export async function cleanupTempDir(dir: string): Promise { + const normalizedDir = normalize(resolve(dir)); + const normalizedTmp = normalize(resolve(tmpdir())); + + if (!normalizedDir.startsWith(normalizedTmp + sep) && normalizedDir !== normalizedTmp) { + throw new Error('Attempted to clean up directory outside of temp directory'); + } + await rm(dir, { recursive: true, force: true }); +} diff --git a/packages/cli/src/lib/skill-discovery.test.ts b/packages/cli/src/lib/skill-discovery.test.ts new file mode 100644 index 0000000..f731cd9 --- /dev/null +++ b/packages/cli/src/lib/skill-discovery.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, writeFile, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { discoverSkills, filterSkills, getSkillDisplayName, parseSkillMd } from './skill-discovery.js'; + +let testDir: string; + +beforeEach(async () => { + testDir = join(tmpdir(), `skillx-test-${Date.now()}`); + await mkdir(testDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); +}); + +async function createSkill(dir: string, name: string, description: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nContent here.` + ); +} + +describe('parseSkillMd', () => { + it('parses valid SKILL.md with frontmatter', async () => { + await createSkill(testDir, 'test-skill', 'A test skill'); + const skill = await parseSkillMd(join(testDir, 'SKILL.md')); + expect(skill).not.toBeNull(); + expect(skill!.name).toBe('test-skill'); + expect(skill!.description).toBe('A test skill'); + }); + + it('returns null for missing name', async () => { + await mkdir(testDir, { recursive: true }); + await writeFile(join(testDir, 'SKILL.md'), '---\ndescription: no name\n---\n'); + const skill = await parseSkillMd(join(testDir, 'SKILL.md')); + expect(skill).toBeNull(); + }); + + it('returns null for non-existent file', async () => { + const skill = await parseSkillMd(join(testDir, 'nonexistent', 'SKILL.md')); + expect(skill).toBeNull(); + }); +}); + +describe('discoverSkills', () => { + it('finds root-level SKILL.md', async () => { + await createSkill(testDir, 'root-skill', 'Root skill'); + const skills = await discoverSkills(testDir); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('root-skill'); + }); + + it('finds skills in skills/ subdirectory', async () => { + await createSkill(join(testDir, 'skills', 'alpha'), 'alpha', 'Alpha skill'); + await createSkill(join(testDir, 'skills', 'beta'), 'beta', 'Beta skill'); + const skills = await discoverSkills(testDir); + expect(skills).toHaveLength(2); + const names = skills.map((s) => s.name).sort(); + expect(names).toEqual(['alpha', 'beta']); + }); + + it('finds skills in .claude/skills/ subdirectory', async () => { + await createSkill(join(testDir, '.claude', 'skills', 'my-skill'), 'my-skill', 'Claude skill'); + const skills = await discoverSkills(testDir); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('my-skill'); + }); + + it('deduplicates skills by name', async () => { + await createSkill(join(testDir, 'skills', 'dupe'), 'same-name', 'First'); + await createSkill(join(testDir, '.claude', 'skills', 'dupe'), 'same-name', 'Second'); + const skills = await discoverSkills(testDir); + expect(skills).toHaveLength(1); + }); + + it('respects subpath parameter', async () => { + await createSkill(join(testDir, 'sub', 'skills', 'nested'), 'nested', 'Nested'); + const skills = await discoverSkills(testDir, 'sub'); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('nested'); + }); + + it('rejects path traversal in subpath', async () => { + await expect(discoverSkills(testDir, '../../etc')).rejects.toThrow('Invalid subpath'); + }); + + it('returns empty for directory with no skills', async () => { + const skills = await discoverSkills(testDir); + expect(skills).toHaveLength(0); + }); + + it('recursive fallback finds deeply nested skills', async () => { + await createSkill(join(testDir, 'deep', 'nested', 'skill'), 'deep-skill', 'Deep'); + const skills = await discoverSkills(testDir); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('deep-skill'); + }); +}); + +describe('filterSkills', () => { + const skills = [ + { name: 'alpha', description: 'A', path: '/a' }, + { name: 'Beta', description: 'B', path: '/b' }, + { name: 'gamma', description: 'G', path: '/g' }, + ]; + + it('filters by exact name (case-insensitive)', () => { + const result = filterSkills(skills, ['alpha']); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('alpha'); + }); + + it('filters case-insensitively', () => { + const result = filterSkills(skills, ['BETA']); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('Beta'); + }); + + it('filters multiple names', () => { + const result = filterSkills(skills, ['alpha', 'gamma']); + expect(result).toHaveLength(2); + }); + + it('returns empty for no matches', () => { + const result = filterSkills(skills, ['nonexistent']); + expect(result).toHaveLength(0); + }); +}); + +describe('getSkillDisplayName', () => { + it('returns skill name when available', () => { + expect(getSkillDisplayName({ name: 'my-skill', description: '', path: '/x/my-skill' })).toBe('my-skill'); + }); + + it('falls back to basename when name is empty', () => { + expect(getSkillDisplayName({ name: '', description: '', path: '/x/fallback-dir' })).toBe('fallback-dir'); + }); +}); diff --git a/packages/cli/src/lib/skill-discovery.ts b/packages/cli/src/lib/skill-discovery.ts new file mode 100644 index 0000000..19bba68 --- /dev/null +++ b/packages/cli/src/lib/skill-discovery.ts @@ -0,0 +1,163 @@ +import { readdir, readFile, stat } from 'fs/promises'; +import { join, basename, dirname, normalize, resolve, sep } from 'path'; +import matter from 'gray-matter'; +import type { Skill } from './add-types.js'; + +const SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__']; + +async function hasSkillMd(dir: string): Promise { + try { + const stats = await stat(join(dir, 'SKILL.md')); + return stats.isFile(); + } catch { + return false; + } +} + +/** Parse a SKILL.md file into a Skill object. Returns null if invalid. */ +export async function parseSkillMd(skillMdPath: string): Promise { + try { + const content = await readFile(skillMdPath, 'utf-8'); + const { data } = matter(content); + + if (!data.name || !data.description) return null; + if (typeof data.name !== 'string' || typeof data.description !== 'string') return null; + + return { + name: data.name, + description: data.description, + path: dirname(skillMdPath), + rawContent: content, + metadata: data.metadata, + }; + } catch { + return null; + } +} + +/** Recursively find directories containing SKILL.md */ +async function findSkillDirs(dir: string, depth = 0, maxDepth = 5): Promise { + if (depth > maxDepth) return []; + + try { + const [hasSkill, entries] = await Promise.all([ + hasSkillMd(dir), + readdir(dir, { withFileTypes: true }).catch(() => []), + ]); + + const currentDir = hasSkill ? [dir] : []; + const subResults = await Promise.all( + entries + .filter((e) => e.isDirectory() && !SKIP_DIRS.includes(e.name)) + .map((e) => findSkillDirs(join(dir, e.name), depth + 1, maxDepth)) + ); + + return [...currentDir, ...subResults.flat()]; + } catch { + return []; + } +} + +export interface DiscoverOptions { + fullDepth?: boolean; +} + +/** Validate subpath stays within base directory */ +function isSubpathSafe(basePath: string, subpath: string): boolean { + const normalizedBase = normalize(resolve(basePath)); + const normalizedTarget = normalize(resolve(join(basePath, subpath))); + return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase; +} + +/** + * Discover all skills in a directory by searching for SKILL.md files. + * Priority: root → common skill directories → recursive fallback. + */ +export async function discoverSkills( + basePath: string, + subpath?: string, + options?: DiscoverOptions +): Promise { + const skills: Skill[] = []; + const seenNames = new Set(); + + if (subpath && !isSubpathSafe(basePath, subpath)) { + throw new Error(`Invalid subpath: "${subpath}" resolves outside the repository directory.`); + } + + const searchPath = subpath ? join(basePath, subpath) : basePath; + + // If pointing directly at a skill, add it + if (await hasSkillMd(searchPath)) { + const skill = await parseSkillMd(join(searchPath, 'SKILL.md')); + if (skill) { + skills.push(skill); + seenNames.add(skill.name); + if (!options?.fullDepth) return skills; + } + } + + // Search common skill locations first + const priorityDirs = [ + searchPath, + join(searchPath, 'skills'), + join(searchPath, '.agent/skills'), + join(searchPath, '.agents/skills'), + join(searchPath, '.claude/skills'), + join(searchPath, '.cline/skills'), + join(searchPath, '.github/skills'), + join(searchPath, '.goose/skills'), + join(searchPath, '.kiro/skills'), + join(searchPath, '.opencode/skills'), + join(searchPath, '.roo/skills'), + join(searchPath, '.trae/skills'), + join(searchPath, '.windsurf/skills'), + ]; + + for (const dir of priorityDirs) { + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = join(dir, entry.name); + if (await hasSkillMd(skillDir)) { + const skill = await parseSkillMd(join(skillDir, 'SKILL.md')); + if (skill && !seenNames.has(skill.name)) { + skills.push(skill); + seenNames.add(skill.name); + } + } + } + } catch { + // Directory doesn't exist — skip + } + } + + // Recursive fallback if nothing found, or fullDepth requested + if (skills.length === 0 || options?.fullDepth) { + const allDirs = await findSkillDirs(searchPath); + for (const skillDir of allDirs) { + const skill = await parseSkillMd(join(skillDir, 'SKILL.md')); + if (skill && !seenNames.has(skill.name)) { + skills.push(skill); + seenNames.add(skill.name); + } + } + } + + return skills; +} + +export function getSkillDisplayName(skill: Skill): string { + return skill.name || basename(skill.path); +} + +/** Filter skills by name (case-insensitive) */ +export function filterSkills(skills: Skill[], inputNames: string[]): Skill[] { + const normalized = inputNames.map((n) => n.toLowerCase()); + return skills.filter((skill) => { + const name = skill.name.toLowerCase(); + const display = getSkillDisplayName(skill).toLowerCase(); + return normalized.some((input) => input === name || input === display); + }); +} diff --git a/packages/cli/src/lib/skill-installer.ts b/packages/cli/src/lib/skill-installer.ts new file mode 100644 index 0000000..043154b --- /dev/null +++ b/packages/cli/src/lib/skill-installer.ts @@ -0,0 +1,183 @@ +import { mkdir, cp, lstat, rm, symlink, readlink, realpath } from 'fs/promises'; +import { join, basename, normalize, resolve, sep, relative, dirname } from 'path'; +import { homedir } from 'os'; +import type { Skill, AgentType, InstallMode, InstallResult } from './add-types.js'; +import { agents, isUniversalAgent, getCanonicalSkillsDir, getAgentBaseDir } from './agent-registry.js'; + +/** + * Sanitize a name for safe use in file paths. + * Lowercase, replace special chars with hyphens, trim dots/hyphens. + */ +export function sanitizeName(name: string): string { + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9._]+/g, '-') + .replace(/^[.\-]+|[.\-]+$/g, ''); + return sanitized.substring(0, 255) || 'unnamed-skill'; +} + +/** Check if a skill is already installed for an agent */ +export async function isSkillInstalled( + skillName: string, + agent: AgentType, + options: { global?: boolean } +): Promise { + const baseDir = getAgentBaseDir(agent, options.global ?? false); + const skillDir = join(baseDir, sanitizeName(skillName)); + try { + await lstat(skillDir); + return true; + } catch { + return false; + } +} + +/** Get the canonical install path for a skill */ +export function getCanonicalPath( + skillName: string, + options: { global?: boolean } +): string { + return join(getCanonicalSkillsDir(options.global ?? false), sanitizeName(skillName)); +} + +/** Get the install path for a specific agent */ +export function getInstallPath( + skillName: string, + agent: AgentType, + options: { global?: boolean } +): string { + return join(getAgentBaseDir(agent, options.global ?? false), sanitizeName(skillName)); +} + +/** Clean and recreate a directory */ +async function cleanAndCreateDir(path: string): Promise { + try { + await rm(path, { recursive: true, force: true }); + } catch { + // Ignore — mkdir will fail if real problem + } + await mkdir(path, { recursive: true }); +} + +/** Resolve parent symlinks while keeping final component */ +async function resolveParentSymlinks(path: string): Promise { + const resolved = resolve(path); + const dir = dirname(resolved); + const base = basename(resolved); + try { + const realDir = await realpath(dir); + return join(realDir, base); + } catch { + return resolved; + } +} + +/** Create symlink with cross-platform handling. Returns false if fallback to copy needed. */ +async function createSymlink(target: string, linkPath: string): Promise { + try { + const resolvedTarget = resolve(target); + const resolvedLinkPath = resolve(linkPath); + + // Check if paths resolve to same location + const [realTarget, realLinkPath] = await Promise.all([ + realpath(resolvedTarget).catch(() => resolvedTarget), + realpath(resolvedLinkPath).catch(() => resolvedLinkPath), + ]); + + if (realTarget === realLinkPath) return true; + + // Check with parent symlinks resolved + const realTargetParent = await resolveParentSymlinks(target); + const realLinkParent = await resolveParentSymlinks(linkPath); + if (realTargetParent === realLinkParent) return true; + + // Remove existing link/dir if present + try { + const stats = await lstat(linkPath); + if (stats.isSymbolicLink()) { + const existing = await readlink(linkPath); + if (resolve(dirname(linkPath), existing) === resolvedTarget) return true; + await rm(linkPath); + } else { + await rm(linkPath, { recursive: true }); + } + } catch (err: unknown) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ELOOP') { + try { await rm(linkPath, { force: true }); } catch { /* noop */ } + } + } + + const linkDir = dirname(linkPath); + await mkdir(linkDir, { recursive: true }); + + const realLinkDir = await resolveParentSymlinks(linkDir); + const relTarget = relative(realLinkDir, resolvedTarget); + + // Use 'junction' on Windows for directory symlinks (no admin rights needed) + const symlinkType = process.platform === 'win32' ? 'junction' : undefined; + await symlink( + symlinkType === 'junction' ? resolvedTarget : relTarget, + linkPath, + symlinkType + ); + return true; + } catch { + return false; + } +} + +/** Install a skill for a specific agent (copy or symlink) */ +export async function installSkillForAgent( + skill: Skill, + agent: AgentType, + options: { global?: boolean; mode?: InstallMode } +): Promise { + const global = options.global ?? false; + const mode = options.mode ?? 'symlink'; + const safeName = sanitizeName(skill.name); + + const canonicalDir = join(getCanonicalSkillsDir(global), safeName); + const agentDir = join(getAgentBaseDir(agent, global), safeName); + + try { + // Always copy to canonical directory first + await cleanAndCreateDir(canonicalDir); + await cp(skill.path, canonicalDir, { recursive: true }); + + // For universal agents, canonical IS the agent dir + if (isUniversalAgent(agent)) { + return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode }; + } + + if (mode === 'copy') { + await cleanAndCreateDir(agentDir); + await cp(skill.path, agentDir, { recursive: true }); + return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'copy' }; + } + + // Symlink mode: link agent dir → canonical dir + const linked = await createSymlink(canonicalDir, agentDir); + if (linked) { + return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink' }; + } + + // Fallback to copy if symlink fails (e.g., Windows without Developer Mode) + await cleanAndCreateDir(agentDir); + await cp(skill.path, agentDir, { recursive: true }); + return { + success: true, + path: agentDir, + canonicalPath: canonicalDir, + mode: 'symlink', + symlinkFailed: true, + }; + } catch (error) { + return { + success: false, + path: agentDir, + canonicalPath: canonicalDir, + mode, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/cli/src/lib/skill-lock.ts b/packages/cli/src/lib/skill-lock.ts new file mode 100644 index 0000000..ca9f2fa --- /dev/null +++ b/packages/cli/src/lib/skill-lock.ts @@ -0,0 +1,84 @@ +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +const LOCK_FILE = '.skills.lock.json'; + +interface LockEntry { + source: string; + sourceType: string; + sourceUrl?: string; + skillPath?: string; + skillFolderHash?: string; + pluginName?: string; + installedAt: string; +} + +interface LockFile { + version: number; + skills: Record; + preferences?: { + lastSelectedAgents?: string[]; + dismissedPrompts?: string[]; + }; +} + +function getLockFilePath(): string { + return join(homedir(), '.agents', LOCK_FILE); +} + +async function readLockFile(): Promise { + try { + const content = await readFile(getLockFilePath(), 'utf-8'); + return JSON.parse(content) as LockFile; + } catch { + return { version: 1, skills: {} }; + } +} + +async function writeLockFile(data: LockFile): Promise { + const path = getLockFilePath(); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** Add or update a skill entry in the lock file */ +export async function addSkillToLock( + skillName: string, + entry: Omit +): Promise { + const lock = await readLockFile(); + lock.skills[skillName] = { ...entry, installedAt: new Date().toISOString() }; + await writeLockFile(lock); +} + +/** Get the list of last selected agents */ +export async function getLastSelectedAgents(): Promise { + const lock = await readLockFile(); + return lock.preferences?.lastSelectedAgents; +} + +/** Save the list of selected agents for next time */ +export async function saveSelectedAgents(agents: string[]): Promise { + const lock = await readLockFile(); + if (!lock.preferences) lock.preferences = {}; + lock.preferences.lastSelectedAgents = agents; + await writeLockFile(lock); +} + +/** Check if a prompt has been dismissed */ +export async function isPromptDismissed(key: string): Promise { + const lock = await readLockFile(); + return lock.preferences?.dismissedPrompts?.includes(key) ?? false; +} + +/** Dismiss a prompt so it won't show again */ +export async function dismissPrompt(key: string): Promise { + const lock = await readLockFile(); + if (!lock.preferences) lock.preferences = {}; + if (!lock.preferences.dismissedPrompts) lock.preferences.dismissedPrompts = []; + if (!lock.preferences.dismissedPrompts.includes(key)) { + lock.preferences.dismissedPrompts.push(key); + } + await writeLockFile(lock); +} diff --git a/packages/cli/src/lib/source-parser.test.ts b/packages/cli/src/lib/source-parser.test.ts new file mode 100644 index 0000000..7ebef04 --- /dev/null +++ b/packages/cli/src/lib/source-parser.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { parseSource, getOwnerRepo, parseOwnerRepo, isSubpathSafe } from './source-parser.js'; + +describe('parseSource', () => { + it('parses GitHub shorthand: owner/repo', () => { + const result = parseSource('vercel-labs/agent-skills'); + expect(result.type).toBe('github'); + expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); + expect(result.subpath).toBeUndefined(); + }); + + it('parses GitHub shorthand with subpath: owner/repo/path', () => { + const result = parseSource('vercel-labs/agent-skills/skills/pr-review'); + expect(result.type).toBe('github'); + expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); + expect(result.subpath).toBe('skills/pr-review'); + }); + + it('parses GitHub shorthand with @skill filter', () => { + const result = parseSource('vercel-labs/agent-skills@find-skills'); + expect(result.type).toBe('github'); + expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); + expect(result.skillFilter).toBe('find-skills'); + }); + + it('parses GitHub URL: https://github.com/owner/repo', () => { + const result = parseSource('https://github.com/vercel-labs/agent-skills'); + expect(result.type).toBe('github'); + expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); + }); + + it('parses GitHub URL with tree/branch/path', () => { + const result = parseSource('https://github.com/owner/repo/tree/main/skills/web'); + expect(result.type).toBe('github'); + expect(result.url).toBe('https://github.com/owner/repo.git'); + expect(result.ref).toBe('main'); + expect(result.subpath).toBe('skills/web'); + }); + + it('parses GitHub URL with tree/branch only', () => { + const result = parseSource('https://github.com/owner/repo/tree/develop'); + expect(result.type).toBe('github'); + expect(result.ref).toBe('develop'); + }); + + it('parses GitLab URL', () => { + const result = parseSource('https://gitlab.com/myorg/myrepo'); + expect(result.type).toBe('gitlab'); + expect(result.url).toBe('https://gitlab.com/myorg/myrepo.git'); + }); + + it('parses local absolute path', () => { + const result = parseSource('/home/user/my-skills'); + expect(result.type).toBe('local'); + expect(result.localPath).toBeTruthy(); + }); + + it('parses local relative path ./', () => { + const result = parseSource('./my-skills'); + expect(result.type).toBe('local'); + expect(result.localPath).toBeTruthy(); + }); + + it('parses github: prefix shorthand', () => { + const result = parseSource('github:owner/repo'); + expect(result.type).toBe('github'); + expect(result.url).toBe('https://github.com/owner/repo.git'); + }); + + it('parses gitlab: prefix shorthand', () => { + const result = parseSource('gitlab:myorg/myrepo'); + expect(result.type).toBe('gitlab'); + expect(result.url).toBe('https://gitlab.com/myorg/myrepo.git'); + }); + + it('falls back to git type for unknown URLs', () => { + const result = parseSource('git@custom-host.com:org/repo.git'); + expect(result.type).toBe('git'); + }); + + it('rejects path traversal in subpath', () => { + expect(() => parseSource('owner/repo/../../../etc/passwd')).toThrow('Unsafe subpath'); + }); +}); + +describe('getOwnerRepo', () => { + it('extracts owner/repo from github parsed source', () => { + const parsed = parseSource('vercel-labs/agent-skills'); + expect(getOwnerRepo(parsed)).toBe('vercel-labs/agent-skills'); + }); + + it('returns null for local sources', () => { + const parsed = parseSource('./local-path'); + expect(getOwnerRepo(parsed)).toBeNull(); + }); +}); + +describe('parseOwnerRepo', () => { + it('parses valid owner/repo', () => { + expect(parseOwnerRepo('vercel/next.js')).toEqual({ owner: 'vercel', repo: 'next.js' }); + }); + + it('returns null for invalid format', () => { + expect(parseOwnerRepo('just-a-name')).toBeNull(); + expect(parseOwnerRepo('a/b/c')).toBeNull(); + }); +}); + +describe('isSubpathSafe', () => { + it('returns true for valid subpath', () => { + expect(isSubpathSafe('/base', 'skills/web')).toBe(true); + }); + + it('returns false for path traversal', () => { + expect(isSubpathSafe('/base', '../../etc')).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/source-parser.ts b/packages/cli/src/lib/source-parser.ts new file mode 100644 index 0000000..777874c --- /dev/null +++ b/packages/cli/src/lib/source-parser.ts @@ -0,0 +1,152 @@ +import { isAbsolute, resolve, normalize, sep } from 'path'; +import type { ParsedSource } from './add-types.js'; + +/** + * Extract owner/repo from a parsed source for tracking. + * Returns null for local paths or unparseable sources. + */ +export function getOwnerRepo(parsed: ParsedSource): string | null { + if (parsed.type === 'local') return null; + + // SSH URLs: git@github.com:owner/repo.git + const sshMatch = parsed.url.match(/^git@[^:]+:(.+)$/); + if (sshMatch) { + const path = sshMatch[1]!.replace(/\.git$/, ''); + return path.includes('/') ? path : null; + } + + if (!parsed.url.startsWith('http://') && !parsed.url.startsWith('https://')) { + return null; + } + + try { + const url = new URL(parsed.url); + const path = url.pathname.slice(1).replace(/\.git$/, ''); + return path.includes('/') ? path : null; + } catch { + return null; + } +} + +/** Extract owner and repo from "owner/repo" string */ +export function parseOwnerRepo(ownerRepo: string): { owner: string; repo: string } | null { + const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/); + return match ? { owner: match[1]!, repo: match[2]! } : null; +} + +/** Sanitize subpath to prevent path traversal */ +function sanitizeSubpath(subpath: string): string { + const normalized = subpath.replace(/\\/g, '/'); + for (const segment of normalized.split('/')) { + if (segment === '..') { + throw new Error( + `Unsafe subpath: "${subpath}" contains path traversal segments.` + ); + } + } + return subpath; +} + +/** Validate subpath stays within base directory */ +export function isSubpathSafe(basePath: string, subpath: string): boolean { + const normalizedBase = normalize(resolve(basePath)); + const normalizedTarget = normalize(resolve(basePath, subpath)); + return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase; +} + +function isLocalPath(input: string): boolean { + return ( + isAbsolute(input) || + input.startsWith('./') || + input.startsWith('../') || + input === '.' || + input === '..' || + /^[a-zA-Z]:[/\\]/.test(input) // Windows absolute paths + ); +} + +/** + * Parse a source string into a structured format. + * Supports: local paths, GitHub URLs, GitLab URLs, GitHub shorthand, git URLs. + */ +export function parseSource(input: string): ParsedSource { + // github: prefix shorthand + const githubPrefix = input.match(/^github:(.+)$/); + if (githubPrefix) return parseSource(githubPrefix[1]!); + + // gitlab: prefix shorthand + const gitlabPrefix = input.match(/^gitlab:(.+)$/); + if (gitlabPrefix) return parseSource(`https://gitlab.com/${gitlabPrefix[1]!}`); + + // Local path + if (isLocalPath(input)) { + const resolvedPath = resolve(input); + return { type: 'local', url: resolvedPath, localPath: resolvedPath }; + } + + // GitHub URL with path: .../tree/branch/path + const ghTreePath = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/); + if (ghTreePath) { + const [, owner, repo, ref, subpath] = ghTreePath; + return { + type: 'github', + url: `https://github.com/${owner}/${repo}.git`, + ref, + subpath: subpath ? sanitizeSubpath(subpath) : subpath, + }; + } + + // GitHub URL with branch only: .../tree/branch + const ghTree = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)$/); + if (ghTree) { + const [, owner, repo, ref] = ghTree; + return { type: 'github', url: `https://github.com/${owner}/${repo}.git`, ref }; + } + + // GitHub URL: https://github.com/owner/repo + const ghRepo = input.match(/github\.com\/([^/]+)\/([^/]+)/); + if (ghRepo) { + const [, owner, repo] = ghRepo; + const cleanRepo = repo!.replace(/\.git$/, ''); + return { type: 'github', url: `https://github.com/${owner}/${cleanRepo}.git` }; + } + + // GitLab URL with path + const glTreePath = input.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)\/(.+)/); + if (glTreePath && glTreePath[2] !== 'github.com') { + const [, protocol, hostname, repoPath, ref, subpath] = glTreePath; + return { + type: 'gitlab', + url: `${protocol}://${hostname}/${repoPath!.replace(/\.git$/, '')}.git`, + ref, + subpath: subpath ? sanitizeSubpath(subpath) : subpath, + }; + } + + // GitLab URL + const glRepo = input.match(/gitlab\.com\/(.+?)(?:\.git)?\/?$/); + if (glRepo && glRepo[1]!.includes('/')) { + return { type: 'gitlab', url: `https://gitlab.com/${glRepo[1]!}.git` }; + } + + // GitHub shorthand with @skill filter: owner/repo@skill-name + const atSkill = input.match(/^([^/]+)\/([^/@]+)@(.+)$/); + if (atSkill && !input.includes(':') && !input.startsWith('.')) { + const [, owner, repo, skillFilter] = atSkill; + return { type: 'github', url: `https://github.com/${owner}/${repo}.git`, skillFilter }; + } + + // GitHub shorthand: owner/repo or owner/repo/subpath + const shorthand = input.match(/^([^/]+)\/([^/]+)(?:\/(.+))?$/); + if (shorthand && !input.includes(':') && !input.startsWith('.')) { + const [, owner, repo, subpath] = shorthand; + return { + type: 'github', + url: `https://github.com/${owner}/${repo}.git`, + subpath: subpath ? sanitizeSubpath(subpath) : subpath, + }; + } + + // Fallback: direct git URL + return { type: 'git', url: input }; +} diff --git a/plans/reports/researcher-260308-1226-vercel-skills-add-command.md b/plans/reports/researcher-260308-1226-vercel-skills-add-command.md new file mode 100644 index 0000000..97b7f16 --- /dev/null +++ b/plans/reports/researcher-260308-1226-vercel-skills-add-command.md @@ -0,0 +1,578 @@ +# Research Report: Vercel Labs `skills add` Command + +**Date:** 2026-03-08 +**Source:** https://github.com/vercel-labs/skills +**Focus:** Complete reverse engineering of `skills add` command + +--- + +## 1. What `skills add` Does (Full User Flow) + +### High-Level Purpose +The `skills add` command installs reusable AI agent skills from various sources (GitHub repos, local paths, or well-known endpoints) into agent-specific or global installation directories. + +### Complete User Flow + +``` +User runs: npx skills add [options] + ↓ +Parse source (GitHub shorthand, URLs, local paths) + ↓ +Clone/validate repository (or use local path) + ↓ +Discover available SKILL.md files in repository + ↓ +Select skills (interactive multiselect or via --skill flag) + ↓ +Detect installed agents on system + ↓ +Select target agents (interactive or via --agent flag) + ↓ +Choose installation scope (global vs project-local, unless -g specified) + ↓ +Choose installation method (symlink vs copy, unless --copy specified) + ↓ +Display installation summary + security audit results + ↓ +Confirm installation (unless -y flag) + ↓ +Install skills for each agent (create dirs, symlink/copy files) + ↓ +Update skill lock files (.claude/skills.lock.json, .skills.lock.json) + ↓ +Prompt to install find-skills skill (one-time only) + ↓ +Display results summary +``` + +--- + +## 2. Arguments and Flags + +### Required Argument +- **``** (positional) - Skill source identifier (required, no default) + - Examples: `vercel-labs/skills`, `/path/to/local`, `https://github.com/owner/repo` + +### Optional Flags + +| Flag | Long Form | Type | Default | Purpose | +|------|-----------|------|---------|---------| +| `-g` | `--global` | boolean | false | Install to home directory instead of project | +| `-y` | `--yes` | boolean | false | Skip all confirmation prompts | +| `-l` | `--list` | boolean | false | List available skills without installing | +| `-a` | `--agent` | string[] | (detect) | Target specific agents; can repeat: `-a claude-code -a cursor` | +| `-s` | `--skill` | string[] | (interactive) | Install specific skills; can repeat: `-s frontend-design -s utils` | +| `--all` | `--all` | boolean | false | Install all skills to all agents (implies `-y`) | +| `--copy` | `--copy` | boolean | false | Copy files instead of symlinking | +| `--full-depth` | `--full-depth` | boolean | false | Search all subdirectories even if root has SKILL.md | + +### Flag Combinations + +Special handling: +- `--all` implies `--skill '*'` + `--agent '*'` + `-y` +- `--skill '*'` installs all discovered skills +- `--agent '*'` installs to all available agents +- If source contains `@skill-name` syntax (e.g., `owner/repo@find-skills`), it sets a skill filter + +### Example Commands +```bash +# Interactive mode +npx skills add vercel-labs/skills + +# Specific skill & agent, skip prompts +npx skills add vercel-labs/skills -s find-skills -a claude-code -y -g + +# All skills, all agents, global +npx skills add vercel-labs/agent-skills --all -g + +# List available skills +npx skills add /path/to/repo --list + +# Using @skill syntax +npx skills add vercel-labs/skills@find-skills -y -g -a claude-code +``` + +--- + +## 3. Skill Name Resolution + +### Source Parsing Priority (parseSource function) + +The `parseSource()` function accepts multiple input formats: + +1. **Source Aliases** + - `coinbase/agentWallet` → `coinbase/agentic-wallet-skills` + +2. **GitHub Prefix** + - `github:owner/repo` → parses as GitHub shorthand + +3. **GitLab Prefix** + - `gitlab:owner/repo` → `https://gitlab.com/owner/repo` + +4. **Local Paths** (Absolute, relative, or current directory) + - `/path/to/skills`, `./skills`, `../repo`, `.`, `..`, `C:\Users\...` (Windows) + - Returns: `ParsedSource` with `type: 'local'`, `localPath` set + +5. **GitHub URLs with Path + Branch** + - `https://github.com/owner/repo/tree/main/path/to/skills` + - Returns: `type: 'github'`, `ref: 'main'`, `subpath: 'path/to/skills'` + +6. **GitHub Shorthand** (Most Common) + - `owner/repo` → `https://github.com/owner/repo.git` + - `owner/repo/path/to/skills` → adds `subpath` + - `owner/repo@skill-name` → adds `skillFilter` (extracts single skill) + +7. **GitHub URLs (Full)** + - `https://github.com/owner/repo` + - Automatically strips `.git` suffix + +8. **GitLab URLs** (with group/subgroup support) + - `https://gitlab.com/owner/repo` or `https://gitlab.com/group/subgroup/repo` + - Also supports path + branch: `/tree/main/path` + +9. **Well-Known Endpoints** (RFC 8615) + - Any `http(s)://` URL not matching GitHub/GitLab + - Expects `/.well-known/skills/index.json` endpoint + +10. **Direct Git URLs** (Fallback) + - Any other git URL (e.g., private git servers) + +### Returned ParsedSource Structure +```typescript +interface ParsedSource { + type: 'github' | 'gitlab' | 'git' | 'local' | 'well-known'; + url: string; // Full URL or resolved local path + subpath?: string; // Path within repository + localPath?: string; // Set only for local type + ref?: string; // Git branch/tag + skillFilter?: string; // Skill name extracted from @syntax +} +``` + +--- + +## 4. Files Created/Modified on User's System + +### Installation Directory Structure + +**Global Installation:** +- Base: `~/.agent/skills/` (canonical directory for all agents) +- Per-agent (symlink): + - `~/.agent//skills/` → symlinks to canonical + - Examples: `~/.claude-code/skills/`, `~/.cline/skills/`, `~/.cursor/skills/` + +**Project-Local Installation:** +- Base: `./.agent/skills/` (in current working directory) +- Per-agent: `./.agent//skills/` + +### Directory Creation Pattern + +For global install of skill `find-skills` to `claude-code`: + +``` +~/.agent/ +├── skills/ # Canonical dir (universal agents + symlink targets) +│ └── find-skills/ # Sanitized skill name +│ ├── SKILL.md # Skill metadata + instructions +│ └── [other files from repo] +├── .claude-code/ # Agent-specific dir +│ └── skills/ +│ └── find-skills → symlink to ~/.agent/skills/find-skills +└── .universal/ # Special agent type + └── skills/ + └── find-skills → symlink or copy +``` + +### Lock Files Updated + +**Global Skills Lock (for update tracking):** +- Path: `~/.agent/.skills.lock.json` +- Version: 3 +- Contains: Map of installed skills with source, branch, folder hash, plugin name + +Example: +```json +{ + "version": 3, + "skills": { + "find-skills": { + "source": "vercel-labs/skills", + "sourceType": "github", + "sourceUrl": "https://github.com/vercel-labs/skills", + "skillPath": "skills/find-skills/SKILL.md", + "skillFolderHash": "a1b2c3d...", + "pluginName": "curated" + } + } +} +``` + +**Project-Local Skills Lock:** +- Path: `.skills.lock.json` (in project root) +- Contains: Similar structure but tracks project-scoped installs +- Stores: `computedHash` instead of `skillFolderHash` (file-based hash, not GitHub tree hash) + +**Telemetry/Tracking:** (Opt-out) +- Only sent for public repos (private repos never tracked) +- Tracks: event type, source, skill names, agents, installation mode, skill file paths + +--- + +## 5. SKILL.md File Handling + +### Structure Expected + +**Minimal SKILL.md:** +```yaml +--- +name: "Find Skills" +description: "Help your agent discover and suggest skills" +# Optional metadata +metadata: + internal: false + category: "discovery" +--- + +# Skill Implementation + +[Markdown content with agent instructions and code] +``` + +### Parsing (parseSkillMd function) + +```typescript +async function parseSkillMd(skillMdPath: string): Promise +``` + +1. **Reads file** from `SKILL.md` (must be exact filename) +2. **Parses frontmatter** (YAML) using gray-matter library +3. **Validates:** + - `data.name` exists and is string + - `data.description` exists and is string + - Skips internal skills unless `INSTALL_INTERNAL_SKILLS=1` or `includeInternal=true` +4. **Returns:** Skill object with name, description, path, rawContent, metadata +5. **Returns null** if parsing fails or validation fails (skipped silently) + +### Returned Skill Object +```typescript +interface Skill { + name: string; // From frontmatter + description: string; // From frontmatter + path: string; // Directory containing SKILL.md + rawContent?: string; // Full file content (for hashing) + metadata?: Record; // Additional frontmatter fields + pluginName?: string; // Assigned during discovery (optional) +} +``` + +### Internal Skills + +Hidden by default. Revealed only if: +1. Environment variable `INSTALL_INTERNAL_SKILLS=1` is set, OR +2. User explicitly requests the skill (via `--skill` or `@skill` syntax) + +--- + +## 6. Directory Structure & Discovery Algorithm + +### Skill Discovery (discoverSkills function) + +**Search Order (Priority Lookup):** + +1. **Direct path check:** If `basePath/SKILL.md` exists, return it (unless `--full-depth`) +2. **Priority directories** (checked in order): + ``` + skills/ + skills/.curated/ + skills/.experimental/ + skills/.system/ + .agent/skills/ + .agents/skills/ + .claude/skills/ + .cline/skills/ + .codebuddy/skills/ + .codex/skills/ + .commandcode/skills/ + .continue/skills/ + [30+ more agent-specific dirs...] + ``` +3. **Plugin-declared paths** (from plugin manifests) +4. **Recursive fallback:** If no skills found in priority dirs, recursively search up to 5 levels deep + - Skips: `node_modules`, `.git`, `dist`, `build`, `__pycache__` + +### De-duplication +- Tracks seen skill names to prevent duplicates +- First match wins in priority order + +### Plugin Grouping +Skills discovered within a plugin are grouped together in UI (e.g., "Curated Skills", "Experimental Skills") + +--- + +## 7. Interactive Prompts and Confirmations + +### Prompt Sequence (Unless -y flag) + +**Step 1: Skill Selection** +- If single skill: Auto-selected, shown for confirmation +- If multiple skills: Grouped multiselect (by plugin if available) + - Space to toggle, Enter to confirm + - Shows description hints (truncated to 60 chars) + +**Step 2: Agent Detection** +- Auto-detects installed agents on system +- If none detected: Prompts to select from all available agents + - Can select multiple agents + - Uses searchable multiselect if many agents +- If detected: Shows detected agents, optionally prompts if multiple + +**Step 3: Installation Scope** (only if agent supports global) +- Options: "Project" (local ./.agent) or "Global" (~/.agent) +- Only shown if `-g` not specified and `--global` not implied + +**Step 4: Installation Method** +- Options: "Symlink (Recommended)" or "Copy to all agents" +- Symlink = single source of truth, easy updates +- Copy = independent copies per agent (fallback if symlink fails on Windows) +- Only shown if `--copy` not specified + +**Step 5: Installation Summary** (p.note display) +- Shows: Skill names → installation paths → target agents → overwrite status + +**Step 6: Security Audit Results** (if available) +- Fetched in parallel from telemetry service +- Shows risk levels: Critical/High/Medium/Low/Safe +- Shows partner audit results (Socket, Snyk, ATH) + +**Step 7: Final Confirmation** +- Message: "Proceed with installation?" +- Yes/No prompt (unless -y flag) + +**Step 8: Post-Installation Prompts** +- "Install find-skills skill?" (one-time only, after first install) + - If dismissed, won't ask again + +### Cancellation +- `Ctrl+C` at any prompt cancels and cleans up +- All prompts allow `p.cancel(symbol)` to exit gracefully + +--- + +## 8. Error Handling Patterns + +### Source Validation +```typescript +if (!source) { + console.error('ERROR: Missing required argument: source'); + console.error('Usage: npx skills add [options]'); + process.exit(1); +} +``` + +### Repository Cloning +```typescript +try { + tempDir = await cloneRepo(parsed.url, parsed.ref); +} catch (error) { + if (error instanceof GitCloneError) { + p.log.error('Failed to clone repository'); + // Print each error line + } + process.exit(1); +} +``` + +### Skill Discovery +```typescript +if (skills.length === 0) { + p.outro(pc.red('No valid skills found. Skills require a SKILL.md with name and description.')); + process.exit(1); +} +``` + +### Skill Selection +```typescript +if (selectedSkills.length === 0) { + p.log.error(`No matching skills found for: ${options.skill.join(', ')}`); + p.log.info('Available skills:'); + for (const s of skills) { + p.log.message(` - ${getSkillDisplayName(s)}`); + } + process.exit(1); +} +``` + +### Agent Validation +```typescript +const invalidAgents = options.agent.filter((a) => !validAgents.includes(a)); +if (invalidAgents.length > 0) { + p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`); + p.log.info(`Valid agents: ${validAgents.join(', ')}`); + process.exit(1); +} +``` + +### Installation Failures +- Per-skill failures don't stop other installations +- Results collected in array: `{ success, path, error, symlinkFailed, mode }` +- Symlink failures fallback to copy (Windows developer mode issue) +- Final summary shows successes and failures separately + +### Path Safety +- Validates subpaths don't contain `..` segments (path traversal protection) +- Checks symlink targets are within expected directories +- Handles ELOOP (circular symlinks) gracefully + +### Cleanup +- Temporary cloned directories cleaned up on exit (finally block) +- Errors in cleanup don't fail the main process + +--- + +## 9. Agent Configuration + +### Supported Agents (40+) +```typescript +type AgentType = + 'amp' | 'antigravity' | 'augment' | 'claude-code' | 'cline' | 'cursor' + | 'continue' | 'vscode' | 'github-copilot' | 'windsurf' + | 'replit' | 'openhands' | 'goose' | 'command-code' + // ... 28 more +``` + +### Universal vs Symlinked Agents + +**Universal Agents** (shared canonical directory): +- `claude-code` (Claude Code), `cline`, `copilot`, `windsurf`, `cursor` (partial list) +- Installed to: `~/.agent/skills/` +- No symlinks needed (prevent redundancy) + +**Symlinked Agents** (per-agent symlink): +- Most others: `~/.claude-code/skills/` → `~/.agent/skills/` +- Use symlinks to save space and enable easy updates + +### Agent Detection +```typescript +const installedAgents = await detectInstalledAgents(); +``` +Checks for agent presence on system (config files, env vars, executables) + +### Install Mode Impact + +**Symlink mode (recommended):** +- Universal agents: Installed to canonical dir +- Symlinked agents: Dir created with symlinks to canonical +- Benefit: Single source of truth, easy updates, no duplication + +**Copy mode:** +- All agents: Independent full copies +- Benefit: Works on Windows without Developer Mode +- Cost: Duplicated files, harder updates + +--- + +## 10. Key Implementation Details + +### Sanitization (sanitizeName function) +```typescript +name + .toLowerCase() + .replace(/[^a-z0-9._]+/g, '-') // Spaces & special chars → hyphens + .replace(/^[.\-]+|[.\-]+$/g, '') // Trim leading/trailing dots & hyphens + .substring(0, 255) // Limit to 255 chars + || 'unnamed-skill' // Fallback if empty +``` + +### Hashing for Updates +- **Global installs:** GitHub Trees API hash (folder-level, tracks updates) +- **Local installs:** File-based hash (computeSkillFolderHash, for integrity check) +- Used in `check` and `update` commands + +### Telemetry +- Event type: `'install'` +- Tracked fields: source, skill names, agents, global flag, skill files map +- **Only for public repos** (private repos excluded) +- Can be opted out at system level + +### Skill Filtering with @syntax +``` +Source: owner/repo@find-skills + ↓ +Parser extracts: skillFilter: 'find-skills' + ↓ +Merged into options.skill array + ↓ +Only 'find-skills' displayed/installed +``` + +--- + +## 11. File System Operations Summary + +### Operations per Skill Installation + +For each skill × agent combination: + +1. **Create directory:** `getAgentBaseDir(agent) / skill-name/` +2. **Symlink mode (non-universal):** + - Compute relative path from agent dir to canonical dir + - Create symlink at agent dir pointing to canonical + - Fallback to copy on ENOENT, ELOOP, or permission errors (Windows) +3. **Copy mode:** + - Recursively copy skill files to agent dir + - Preserve permissions and structure +4. **Write lock file:** + - Update global or local `.skills.lock.json` + - Record source, hash, plugin name for future updates + +### Cleanup +- Temporary clone directory deleted on process exit +- Partial failures don't prevent other installations +- Lock file updates wrapped in try-catch (don't fail install) + +--- + +## 12. Dependencies & Libraries + +**Key packages:** +- `@clack/prompts` - Interactive prompts (multiselect, confirm, etc.) +- `picocolors` - Terminal colors (no dependencies) +- `gray-matter` - YAML frontmatter parsing +- `fs/promises` - Async file operations +- Built-in: `path`, `os`, `child_process` + +**Custom modules:** +- `parseSource()` - Source string → ParsedSource +- `discoverSkills()` - Directory → Skill[] array +- `installSkillForAgent()` - Skill × Agent → install result +- `fetchSkillFolderHash()` - GitHub Trees API lookup +- `addSkillToLock()` / `addSkillToLocalLock()` - Lock file management + +--- + +## Unresolved Questions + +1. **Well-known endpoint format** - Exact JSON structure of `/.well-known/skills/index.json` (assumed from code but not fully documented in repo) +2. **Plugin manifest format** - Exact structure of `plugin-manifest.ts` (mentioned but not fully explored) +3. **Private repo handling** - How are credentials passed for private GitHub repos? +4. **Symlink fallback mechanics** - Exact conditions that trigger copy fallback on Windows +5. **Agent detection details** - How each agent type is detected (env vars? Config file locations?) +6. **Rate limiting** - GitHub API rate limits and behavior when exceeded + +--- + +## Summary for SkillX Implementation + +### Key Learnings for `skillx add` + +1. **Source parsing is complex but handles many cases** - Shorthand, URLs, local paths, well-known endpoints +2. **Skill discovery uses priority search** - Common dirs first, then recursive fallback, prevents duplicates +3. **Installation is agent-aware** - Universal vs symlinked requires different handling; per-agent discovery needed +4. **Lock files enable updates** - Track source, path, folder hash for checking updates later +5. **Prompts are ergonomic** - Grouped multiselect, search, hints; respect -y flag +6. **Error handling is graceful** - Per-skill failures don't block; cleanup happens regardless +7. **Symlink vs copy trade-off** - Symlink preferred (space, updates) but copy fallback for Windows +8. **Telemetry respects privacy** - Only public repos; can be disabled +9. **Security scanning is parallel** - Fetched while user selects agents (non-blocking) +10. **Sanitization is strict** - File names sanitized; paths validated against traversal attacks + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b7576f..601d83a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: packages/cli: dependencies: + '@clack/prompts': + specifier: ^1.1.0 + version: 1.8.1 chalk: specifier: ^5.3.0 version: 5.6.2 @@ -99,9 +102,18 @@ importers: conf: specifier: ^13.0.0 version: 13.1.0 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 ora: specifier: ^8.1.0 version: 8.2.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + simple-git: + specifier: ^3.32.3 + version: 3.36.0 yaml: specifier: ^2.9.1 version: 2.9.1 @@ -302,6 +314,14 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -1128,6 +1148,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} @@ -1313,6 +1339,12 @@ packages: cpu: [x64] os: [win32] + '@simple-git/args-pathspec@1.0.4': + resolution: {integrity: sha512-EtMX6XjRWSastG2SdkmSQByPtJ9dx/NIjnHbpQPCYt62j4RmSx5rgLTGpw0YCjF5h191gZOmBfheOT23cRSFdw==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1512,6 +1544,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1877,6 +1912,11 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -1894,15 +1934,28 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1942,6 +1995,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -1963,6 +2020,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -2004,6 +2065,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -2020,6 +2085,10 @@ packages: engines: {node: '>=6'} hasBin: true + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -2390,6 +2459,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2417,6 +2490,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2435,6 +2514,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2456,6 +2538,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + stubborn-fs@2.0.0: resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} @@ -2974,6 +3060,18 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0)': @@ -3453,6 +3551,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@mjackson/node-fetch-server@0.2.0': {} '@noble/ciphers@2.1.1': {} @@ -3608,6 +3714,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@simple-git/args-pathspec@1.0.4': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.4 + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.14': {} @@ -3785,6 +3897,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + assertion-error@2.0.1: {} atomically@2.1.1: @@ -4089,6 +4205,8 @@ snapshots: escalade@3.2.0: {} + esprima@4.0.1: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@3.0.3: @@ -4101,12 +4219,26 @@ snapshots: exsolve@1.0.8: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} fast-deep-equal@3.1.3: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4144,6 +4276,13 @@ snapshots: graceful-fs@4.2.11: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.15.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -4181,6 +4320,8 @@ snapshots: is-decimal@2.0.1: {} + is-extendable@0.1.1: {} + is-hexadecimal@2.0.1: {} is-interactive@2.0.0: {} @@ -4204,6 +4345,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + jsesc@3.0.2: {} json-schema-traverse@1.0.0: {} @@ -4212,6 +4358,8 @@ snapshots: json5@2.2.3: {} + kind-of@6.0.3: {} + kleur@4.1.5: {} kysely@0.28.11: {} @@ -4723,6 +4871,11 @@ snapshots: scheduler@0.27.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@6.3.1: {} semver@7.7.4: {} @@ -4767,6 +4920,18 @@ snapshots: signal-exit@4.1.0: {} + simple-git@3.36.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.4 + '@simple-git/argv-parser': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -4780,6 +4945,8 @@ snapshots: space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -4801,6 +4968,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-bom-string@1.0.0: {} + stubborn-fs@2.0.0: dependencies: stubborn-utils: 1.0.2 From 3a241ef62bf587675909975235e234832a2690ee Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 8 Mar 2026 13:51:23 +0700 Subject: [PATCH 2/6] fix(cli): assign skillsDir after git clone in add command Remote repo clones left skillsDir undefined, causing "path argument must be of type string" error during skill discovery. --- packages/cli/src/commands/add.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index c41ea38..a9d7f79 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -80,6 +80,7 @@ async function runAdd(source: string, rawOpts: Record): Promise } else { spinner.start('Cloning repository...'); tempDir = await cloneRepo(parsed.url, parsed.ref); + skillsDir = tempDir; spinner.stop('Repository cloned'); } From ceaa173e341fe71271ef72749c131e2147a4cb4a Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 25 Oct 2026 10:21:54 +0700 Subject: [PATCH 3/6] feat(cli): add --json envelope and exit codes to skillx add Every leaf command must offer --json (built-cli contract), so add now reports through runCommand: one envelope on stdout, silent clack output, and failures as classified CliErrors instead of process.exit. --json implies --yes because an envelope caller cannot answer prompts. Document the command in docs/cli-reference.md. Co-Authored-By: Claude Opus 5.5 --- docs/cli-reference.md | 42 ++++++++ packages/cli/src/built-cli.test.ts | 20 ++++ packages/cli/src/commands/add.ts | 163 ++++++++++++++++++----------- 3 files changed, 162 insertions(+), 63 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1de680b..cf711a4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -192,6 +192,48 @@ skillx import --dry-run --- +### `skillx add ` + +Copy or symlink skills straight from a GitHub repo, a git URL or a local path +into agent skill directories. This does not go through the registry; use the +[installer](#installer) for signed releases, kits and collections. + +Skills are written once to a canonical `.agents/skills/` directory and +linked (or, with `--copy`, copied) into each target agent's skills directory. +With `--global` they go under your home directory, and the source is recorded in +the global skill lock. + +**Arguments:** +- ``: `owner/repo`, `owner/repo@skill`, a git or GitHub URL (a `tree//` URL selects a ref and subpath), or a local path. + +**Options:** +| Flag | Description | Default | +|------|-------------|---------| +| `-g, --global` | Install under the home directory instead of the project | asks, or project with `-y` | +| `-a, --agent ` | Target agents (`*` for all) | detected agents | +| `-s, --skill ` | Skills to install (`*` for all) | asks, or all with `-y` | +| `-l, --list` | List the skills in the source without installing | `false` | +| `-y, --yes` | Skip every prompt | `false` | +| `--copy` | Copy files instead of symlinking | symlink | +| `--all` | Shorthand for `--skill '*' --agent '*' -y` | `false` | +| `--full-depth` | Search all subdirectories even when a root SKILL.md exists | `false` | +| `--json` | Envelope with `status` (`listed`, `installed` or `cancelled`) and per-agent results; implies `--yes` | `false` | + +A missing local path or an unknown agent is a `usage_error` (exit 2), a source +with no matching skills is `not_found` (exit 3), and a failed clone is +`network_error` (exit 6). Review skills before use; they run with full agent +permissions. + +**Examples:** +```bash +skillx add owner/repo --list +skillx add owner/repo --skill my-skill --agent claude-code codex +skillx add https://github.com/owner/repo/tree/main/skills/my-skill -y +skillx add ./my-skills --all --copy --json +``` + +--- + ### Release commands: `validate`, `pack`, `publish`, `promote` These commands work on immutable, digest-pinned releases. Like every other diff --git a/packages/cli/src/built-cli.test.ts b/packages/cli/src/built-cli.test.ts index 68d0925..860a6d9 100644 --- a/packages/cli/src/built-cli.test.ts +++ b/packages/cli/src/built-cli.test.ts @@ -266,6 +266,26 @@ describe("built cli", { timeout: 30_000 }, () => { return (await Promise.all(names.map((name) => leafCommands([...path, name])))).flat(); } + it("add lists and installs a local skill as one envelope, and fails a missing path as a usage error", async () => { + const listed = JSON.parse((await cli("add", skillDir, "--list", "--json")).stdout) as { ok: true; data: { status: string; skills: Array<{ name: string }> } }; + expect(listed.data.status).toBe("listed"); + expect(listed.data.skills.map((s) => s.name)).toEqual(["smoke-skill"]); + + const installed = JSON.parse((await cli("add", skillDir, "--agent", "claude-code", "--copy", "--json")).stdout) as { + ok: true; + data: { status: string; global: boolean; results: Array<{ success: boolean; path: string }> }; + }; + expect(installed.data.status).toBe("installed"); + expect(installed.data.global).toBe(false); + expect(installed.data.results).toHaveLength(1); + expect(installed.data.results[0]!.success).toBe(true); + expect(readFileSync(join(installed.data.results[0]!.path, "SKILL.md"), "utf8")).toContain("name: smoke-skill"); + + const missing = await cliExit("add", join(root, "no-such-dir"), "--json"); + expect(missing.code).toBe(2); + expect((JSON.parse(missing.stdout) as { error: { code: string } }).error.code).toBe("usage_error"); + }); + it("offers --json on every command but the stdio MCP server", async () => { const leaves = await leafCommands(); expect(leaves.length).toBeGreaterThan(20); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index a9d7f79..76a5e95 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -7,8 +7,10 @@ import { parseSource, getOwnerRepo } from '../lib/source-parser.js'; import { cloneRepo, cleanupTempDir, GitCloneError } from '../lib/git-clone.js'; import { discoverSkills, getSkillDisplayName, filterSkills } from '../lib/skill-discovery.js'; import { agents, detectInstalledAgents } from '../lib/agent-registry.js'; -import { installSkillForAgent, isSkillInstalled, getCanonicalPath } from '../lib/skill-installer.js'; +import { installSkillForAgent, getCanonicalPath } from '../lib/skill-installer.js'; import { addSkillToLock } from '../lib/skill-lock.js'; +import { CliError } from '../lib/output.js'; +import { runCommand } from '../lib/run-command.js'; import { shortenPath, buildAgentSummaryLines, buildResultLines, formatList, ensureUniversalAgents, @@ -18,6 +20,32 @@ import { selectScope, selectInstallMode, confirmInstall, } from './add-prompts.js'; +interface InstallResult { + skill: string; agent: string; success: boolean; path: string; + canonicalPath?: string; mode: InstallMode; symlinkFailed?: boolean; error?: string; +} + +/** What `skillx add` reports; the same data backs the `--json` envelope. */ +export type AddResult = + | { status: 'cancelled' } + | { status: 'listed'; source: string; skills: Array<{ name: string; description: string }> } + | { + status: 'installed'; source: string; global: boolean; mode: InstallMode; + agents: AgentType[]; results: InstallResult[]; + }; + +/** Clack output in human mode; silent under `--json` so stdout carries one envelope. */ +type Ui = Pick & { log: Pick }; + +const noop = () => {}; +const silentUi: Ui = { + intro: noop, + outro: noop, + note: noop, + spinner: () => ({ start: noop, stop: noop, message: noop }) as unknown as ReturnType, + log: { info: noop, step: noop, message: noop, warn: noop, error: noop }, +}; + /** Parse CLI options for the add command */ function parseAddOptions(opts: Record): AddOptions { const options: AddOptions = { @@ -40,10 +68,8 @@ async function cleanup(tempDir: string | null) { } } -/** Main add command logic */ -async function runAdd(source: string, rawOpts: Record): Promise { - const options = parseAddOptions(rawOpts); - +/** Main add command logic. Throws `CliError` on failure; never exits the process. */ +async function runAdd(source: string, options: AddOptions, ui: Ui): Promise { // --all implies --skill '*' --agent '*' -y if (options.all) { options.skill = ['*']; @@ -51,19 +77,19 @@ async function runAdd(source: string, rawOpts: Record): Promise options.yes = true; } - console.log(); - p.intro(pc.bgCyan(pc.black(' skillx add '))); + ui.intro(pc.bgCyan(pc.black(' skillx add '))); let tempDir: string | null = null; try { - const spinner = p.spinner(); + const spinner = ui.spinner(); // 1. Parse source spinner.start('Parsing source...'); const parsed = parseSource(source); + const sourceLabel = parsed.type === 'local' ? parsed.localPath! : parsed.url; spinner.stop( - `Source: ${parsed.type === 'local' ? parsed.localPath! : parsed.url}` + + `Source: ${sourceLabel}` + `${parsed.ref ? ` @ ${pc.yellow(parsed.ref)}` : ''}` + `${parsed.subpath ? ` (${parsed.subpath})` : ''}` + `${parsed.skillFilter ? ` ${pc.dim('@')}${pc.cyan(parsed.skillFilter)}` : ''}` @@ -73,13 +99,19 @@ async function runAdd(source: string, rawOpts: Record): Promise let skillsDir: string; if (parsed.type === 'local') { if (!existsSync(parsed.localPath!)) { - p.outro(pc.red(`Local path does not exist: ${parsed.localPath}`)); - process.exit(1); + throw new CliError('usage_error', `Local path does not exist: ${parsed.localPath}`); } skillsDir = parsed.localPath!; } else { spinner.start('Cloning repository...'); - tempDir = await cloneRepo(parsed.url, parsed.ref); + try { + tempDir = await cloneRepo(parsed.url, parsed.ref); + } catch (error) { + if (error instanceof GitCloneError) { + throw new CliError('network_error', 'Failed to clone repository', { hint: error.message }); + } + throw error; + } skillsDir = tempDir; spinner.stop('Repository cloned'); } @@ -92,54 +124,52 @@ async function runAdd(source: string, rawOpts: Record): Promise // 3. Discover skills spinner.start('Discovering skills...'); - const skills = await discoverSkills(skillsDir!, parsed.subpath, { + const skills = await discoverSkills(skillsDir, parsed.subpath, { fullDepth: options.fullDepth, }); if (skills.length === 0) { spinner.stop(pc.red('No skills found')); - p.outro(pc.red('No valid skills found. Skills require a SKILL.md with name and description.')); - await cleanup(tempDir); - process.exit(1); + throw new CliError('not_found', 'No valid skills found. Skills require a SKILL.md with name and description.'); } spinner.stop(`Found ${pc.green(String(skills.length))} skill${skills.length > 1 ? 's' : ''}`); - // 4. --list: just show available skills and exit + // 4. --list: just show available skills if (options.list) { - console.log(); - p.log.step(pc.bold('Available Skills')); + ui.log.step(pc.bold('Available Skills')); for (const skill of skills) { - p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`); - p.log.message(` ${pc.dim(skill.description)}`); + ui.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`); + ui.log.message(` ${pc.dim(skill.description)}`); } - console.log(); - p.outro('Use --skill to install specific skills'); - await cleanup(tempDir); - process.exit(0); + ui.outro('Use --skill to install specific skills'); + return { + status: 'listed', + source: sourceLabel, + skills: skills.map((s) => ({ name: getSkillDisplayName(s), description: s.description })), + }; } // 5. Select skills let selectedSkills: Skill[]; if (options.skill?.includes('*')) { selectedSkills = skills; - p.log.info(`Installing all ${skills.length} skills`); + ui.log.info(`Installing all ${skills.length} skills`); } else if (options.skill && options.skill.length > 0) { selectedSkills = filterSkills(skills, options.skill); if (selectedSkills.length === 0) { - p.log.error(`No matching skills for: ${options.skill.join(', ')}`); - p.log.info('Available: ' + skills.map((s) => getSkillDisplayName(s)).join(', ')); - await cleanup(tempDir); - process.exit(1); + throw new CliError('not_found', `No matching skills for: ${options.skill.join(', ')}`, { + hint: 'Available: ' + skills.map((s) => getSkillDisplayName(s)).join(', '), + }); } } else if (skills.length === 1) { selectedSkills = skills; - p.log.info(`Skill: ${pc.cyan(getSkillDisplayName(skills[0]!))}`); + ui.log.info(`Skill: ${pc.cyan(getSkillDisplayName(skills[0]!))}`); } else if (options.yes) { selectedSkills = skills; - p.log.info(`Installing all ${skills.length} skills`); + ui.log.info(`Installing all ${skills.length} skills`); } else { const result = await selectSkillsInteractive(skills); - if (!result) { await cleanup(tempDir); process.exit(0); } + if (!result) return { status: 'cancelled' }; selectedSkills = result; } @@ -152,9 +182,9 @@ async function runAdd(source: string, rawOpts: Record): Promise } else if (options.agent && options.agent.length > 0) { const invalid = options.agent.filter((a) => !validAgents.includes(a as AgentType)); if (invalid.length > 0) { - p.log.error(`Invalid agents: ${invalid.join(', ')}`); - await cleanup(tempDir); - process.exit(1); + throw new CliError('usage_error', `Invalid agents: ${invalid.join(', ')}`, { + hint: `Valid agents: ${validAgents.join(', ')}`, + }); } targetAgents = options.agent as AgentType[]; } else { @@ -166,7 +196,7 @@ async function runAdd(source: string, rawOpts: Record): Promise targetAgents = ensureUniversalAgents(installed.length > 0 ? installed : validAgents); } else { const result = await selectAgentsInteractive({ global: options.global }); - if (!result) { await cleanup(tempDir); process.exit(0); } + if (!result) return { status: 'cancelled' }; targetAgents = result; } } @@ -175,7 +205,7 @@ async function runAdd(source: string, rawOpts: Record): Promise let installGlobally = options.global ?? false; if (options.global === undefined && !options.yes) { const scope = await selectScope(); - if (scope === null) { await cleanup(tempDir); process.exit(0); } + if (scope === null) return { status: 'cancelled' }; installGlobally = scope; } @@ -183,7 +213,7 @@ async function runAdd(source: string, rawOpts: Record): Promise let installMode: InstallMode = options.copy ? 'copy' : 'symlink'; if (!options.copy && !options.yes) { const mode = await selectInstallMode(); - if (!mode) { await cleanup(tempDir); process.exit(0); } + if (!mode) return { status: 'cancelled' }; installMode = mode; } @@ -196,21 +226,17 @@ async function runAdd(source: string, rawOpts: Record): Promise summaryLines.push(pc.cyan(shortenPath(canonical, cwd))); summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); } - console.log(); - p.note(summaryLines.join('\n'), 'Installation Summary'); + ui.note(summaryLines.join('\n'), 'Installation Summary'); // 10. Confirm if (!options.yes) { const ok = await confirmInstall(); - if (!ok) { await cleanup(tempDir); process.exit(0); } + if (!ok) return { status: 'cancelled' }; } // 11. Install spinner.start('Installing skills...'); - const results: Array<{ - skill: string; agent: string; success: boolean; path: string; - canonicalPath?: string; mode: InstallMode; symlinkFailed?: boolean; error?: string; - }> = []; + const results: InstallResult[] = []; for (const skill of selectedSkills) { for (const agent of targetAgents) { @@ -228,7 +254,7 @@ async function runAdd(source: string, rawOpts: Record): Promise const failed = results.filter((r) => !r.success); if (successful.length > 0) { - const bySkill = new Map(); + const bySkill = new Map(); for (const r of successful) { const arr = bySkill.get(r.skill) || []; arr.push(r); @@ -248,18 +274,18 @@ async function runAdd(source: string, rawOpts: Record): Promise } } - p.note(resultLines.join('\n'), pc.green(`Installed ${bySkill.size} skill${bySkill.size !== 1 ? 's' : ''}`)); + ui.note(resultLines.join('\n'), pc.green(`Installed ${bySkill.size} skill${bySkill.size !== 1 ? 's' : ''}`)); const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed); if (symlinkFailures.length > 0) { - p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(symlinkFailures.map((r) => r.agent))}`)); - p.log.message(pc.dim(' Files were copied instead. On Windows, enable Developer Mode for symlink support.')); + ui.log.warn(pc.yellow(`Symlinks failed for: ${formatList(symlinkFailures.map((r) => r.agent))}`)); + ui.log.message(pc.dim(' Files were copied instead. On Windows, enable Developer Mode for symlink support.')); } } if (failed.length > 0) { - p.log.error(pc.red(`Failed to install ${failed.length}`)); - for (const r of failed) p.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`); + ui.log.error(pc.red(`Failed to install ${failed.length}`)); + for (const r of failed) ui.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`); } // 13. Update lock file (global installs) @@ -278,17 +304,17 @@ async function runAdd(source: string, rawOpts: Record): Promise } } - console.log(); - p.outro(pc.green('Done!') + pc.dim(' Review skills before use; they run with full agent permissions.')); - } catch (error) { - if (error instanceof GitCloneError) { - p.log.error(pc.red('Failed to clone repository')); - for (const line of error.message.split('\n')) p.log.message(pc.dim(line)); - } else { - p.log.error(error instanceof Error ? error.message : 'Unknown error occurred'); + if (successful.length === 0) { + throw new CliError('unexpected_error', `Failed to install ${failed.length} skill target${failed.length !== 1 ? 's' : ''}`, { + details: { results }, + }); } - p.outro(pc.red('Installation failed')); - process.exit(1); + + ui.outro(pc.green('Done!') + pc.dim(' Review skills before use; they run with full agent permissions.')); + return { status: 'installed', source: sourceLabel, global: installGlobally, mode: installMode, agents: targetAgents, results }; + } catch (error) { + ui.outro(pc.red('Installation failed')); + throw error; } finally { await cleanup(tempDir); } @@ -305,4 +331,15 @@ export const addCommand = new Command('add') .option('--copy', 'Copy files instead of symlinking') .option('--all', 'Shorthand for --skill * --agent * -y') .option('--full-depth', 'Search all subdirs even when root SKILL.md exists') - .action((source: string, opts: Record) => runAdd(source, opts)); + .option('--json', 'emit a machine-readable envelope instead of text (implies --yes)') + .action((source: string, opts: Record) => { + const json = opts.json === true; + const options = parseAddOptions(opts); + // An envelope caller cannot answer prompts, so --json never asks. + if (json) options.yes = true; + return runCommand( + { json }, + () => runAdd(source, options, json ? silentUi : p), + () => {}, + ); + }); From 09a705afae8e8224c37ab6c551dbec0cd2077637 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Fri, 30 Oct 2026 01:33:22 +0700 Subject: [PATCH 4/6] feat(cli): rebuild skillx add over the generic installer Install skills from GitHub, an https git URL or a local path through the installer's receipts, owners, lock and conflict checks instead of a parallel .agents/skills install path, skill lock and agent registry. - Parse front matter as YAML data only (core schema, no tags or engines). - Clone shallow without checkout or prompts, https only, argv with -- before the URL; read files from git objects. - Refuse symlinks, hardlinks, submodules, unsafe paths and reserved ak- names; never replace an installed skill without --force. - Redact URL credentials in every message and envelope; never store them. - Record each skill as an add: owner that uninstall removes and update skips; drop the research report and the extra dependencies. Co-Authored-By: Claude Opus 5.5 --- packages/cli/package.json | 8 +- packages/cli/src/built-cli.test.ts | 56 +- packages/cli/src/commands/add-display.ts | 146 ++--- packages/cli/src/commands/add-prompts.ts | 180 ++---- packages/cli/src/commands/add.ts | 429 +++---------- packages/cli/src/commands/install.ts | 2 +- packages/cli/src/lib/add-types.ts | 78 --- packages/cli/src/lib/add/add-install.test.ts | 154 +++++ packages/cli/src/lib/add/add-install.ts | 83 +++ packages/cli/src/lib/add/add-source.test.ts | 108 ++++ packages/cli/src/lib/add/add-source.ts | 129 ++++ packages/cli/src/lib/add/git-source.test.ts | 98 +++ packages/cli/src/lib/add/git-source.ts | 137 +++++ packages/cli/src/lib/add/local-source.ts | 90 +++ packages/cli/src/lib/add/skill-discovery.ts | 127 ++++ packages/cli/src/lib/add/skill-frontmatter.ts | 56 ++ packages/cli/src/lib/add/skill-safety.test.ts | 157 +++++ packages/cli/src/lib/agent-registry.ts | 237 ------- packages/cli/src/lib/git-clone.ts | 73 --- .../cli/src/lib/install/install-engine.ts | 14 +- .../cli/src/lib/install/install-operations.ts | 28 +- packages/cli/src/lib/skill-discovery.test.ts | 141 ----- packages/cli/src/lib/skill-discovery.ts | 163 ----- packages/cli/src/lib/skill-installer.ts | 183 ------ packages/cli/src/lib/skill-lock.ts | 84 --- packages/cli/src/lib/source-parser.test.ts | 117 ---- packages/cli/src/lib/source-parser.ts | 152 ----- ...r-260308-1226-vercel-skills-add-command.md | 578 ------------------ pnpm-lock.yaml | 169 ----- 29 files changed, 1430 insertions(+), 2547 deletions(-) delete mode 100644 packages/cli/src/lib/add-types.ts create mode 100644 packages/cli/src/lib/add/add-install.test.ts create mode 100644 packages/cli/src/lib/add/add-install.ts create mode 100644 packages/cli/src/lib/add/add-source.test.ts create mode 100644 packages/cli/src/lib/add/add-source.ts create mode 100644 packages/cli/src/lib/add/git-source.test.ts create mode 100644 packages/cli/src/lib/add/git-source.ts create mode 100644 packages/cli/src/lib/add/local-source.ts create mode 100644 packages/cli/src/lib/add/skill-discovery.ts create mode 100644 packages/cli/src/lib/add/skill-frontmatter.ts create mode 100644 packages/cli/src/lib/add/skill-safety.test.ts delete mode 100644 packages/cli/src/lib/agent-registry.ts delete mode 100644 packages/cli/src/lib/git-clone.ts delete mode 100644 packages/cli/src/lib/skill-discovery.test.ts delete mode 100644 packages/cli/src/lib/skill-discovery.ts delete mode 100644 packages/cli/src/lib/skill-installer.ts delete mode 100644 packages/cli/src/lib/skill-lock.ts delete mode 100644 packages/cli/src/lib/source-parser.test.ts delete mode 100644 packages/cli/src/lib/source-parser.ts delete mode 100644 plans/reports/researcher-260308-1226-vercel-skills-add-command.md diff --git a/packages/cli/package.json b/packages/cli/package.json index e21c98d..e608585 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,22 +18,18 @@ "dev": "tsup --watch" }, "dependencies": { - "@clack/prompts": "^1.1.0", "chalk": "^5.3.0", "commander": "^12.1.0", "conf": "^13.0.0", - "gray-matter": "^4.0.3", "ora": "^8.1.0", - "picocolors": "^1.1.1", - "simple-git": "^3.32.3", "yaml": "^2.9.1" }, "devDependencies": { "@skillx/contracts": "workspace:*", "@skillx/skill-tree": "workspace:*", - "@types/node": "^22.0.0", "tsup": "^8.0.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "@types/node": "^22.0.0" }, "engines": { "node": ">=22" diff --git a/packages/cli/src/built-cli.test.ts b/packages/cli/src/built-cli.test.ts index 9043618..b185b01 100644 --- a/packages/cli/src/built-cli.test.ts +++ b/packages/cli/src/built-cli.test.ts @@ -357,24 +357,48 @@ describe("built cli", { timeout: 30_000 }, () => { return (await Promise.all(names.map((name) => leafCommands([...path, name])))).flat(); } - it("add lists and installs a local skill as one envelope, and fails a missing path as a usage error", async () => { - const listed = JSON.parse((await cli("add", skillDir, "--list", "--json")).stdout) as { ok: true; data: { status: string; skills: Array<{ name: string }> } }; - expect(listed.data.status).toBe("listed"); - expect(listed.data.skills.map((s) => s.name)).toEqual(["smoke-skill"]); - - const installed = JSON.parse((await cli("add", skillDir, "--agent", "claude-code", "--copy", "--json")).stdout) as { - ok: true; - data: { status: string; global: boolean; results: Array<{ success: boolean; path: string }> }; + it("add installs a local skill through the installer as one envelope, with installer exit codes", async () => { + const home = join(root, "add-home"); + const env = { ...childEnv, HOME: home, USERPROFILE: home, SKILLX_HOME: join(home, ".skillx"), CLAUDE_CONFIG_DIR: join(home, ".claude") }; + const run = async (...args: string[]) => { + try { + return { code: 0, stdout: (await execFileAsync(process.execPath, [BIN, ...args], { cwd: root, env, encoding: "utf8" })).stdout }; + } catch (error) { + const failed = error as { code?: number; stdout?: string }; + return { code: failed.code ?? -1, stdout: failed.stdout ?? "" }; + } }; - expect(installed.data.status).toBe("installed"); - expect(installed.data.global).toBe(false); - expect(installed.data.results).toHaveLength(1); - expect(installed.data.results[0]!.success).toBe(true); - expect(readFileSync(join(installed.data.results[0]!.path, "SKILL.md"), "utf8")).toContain("name: smoke-skill"); - - const missing = await cliExit("add", join(root, "no-such-dir"), "--json"); + const src = join(root, "add-src"); + mkdirSync(join(src, "references"), { recursive: true }); + writeFileSync(join(src, "SKILL.md"), "---\nname: smoke-skill\ndescription: Built CLI smoke\n---\n# Smoke\n"); + writeFileSync(join(src, "references", "notes.md"), "notes\n"); + const listed = JSON.parse((await run("add", src, "--list", "--json")).stdout) as { ok: true; data: { status: string; skills: Array<{ name: string }> } }; + expect(listed.data).toMatchObject({ status: "listed", skills: [{ name: "smoke-skill" }] }); + + const installed = await run("add", src, "--json"); + expect(installed.code).toBe(0); + expect(JSON.parse(installed.stdout)).toMatchObject({ ok: true, data: { status: "installed", skills: [{ alias: "smoke-skill" }] } }); + expect(readFileSync(join(home, ".claude", "skills", "smoke-skill", "references", "notes.md"), "utf8")).toBe("notes\n"); + const owners = JSON.parse((await run("list", "--json")).stdout) as { data: { sources: Array<{ owner: string }> } }; + expect(owners.data.sources.map((s) => s.owner)).toEqual(["add:smoke-skill"]); + + // The same name from a changed tree is a conflict (exit 8) until --force. + writeFileSync(join(src, "SKILL.md"), "---\nname: smoke-skill\ndescription: Built CLI smoke v2\n---\n"); + const conflict = await run("add", src, "--json"); + expect(conflict.code).toBe(8); + expect(JSON.parse(conflict.stdout)).toMatchObject({ ok: false, error: { code: "conflict" } }); + expect((await run("add", src, "--force", "--json")).code).toBe(0); + expect((await run("uninstall", "add:smoke-skill", "--json")).code).toBe(0); + + const missing = await run("add", join(root, "no-such-dir"), "--json"); expect(missing.code).toBe(2); - expect((JSON.parse(missing.stdout) as { error: { code: string } }).error.code).toBe("usage_error"); + expect(JSON.parse(missing.stdout)).toMatchObject({ ok: false, error: { code: "usage_error" } }); + // Human mode fails and exits too; nothing keeps the process alive. + expect((await run("add", "ext::sh -c id")).code).toBe(2); + const token = "ghp_SECRETTOKEN123"; + const leaked = await run("add", `http://user:${token}@127.0.0.1:9/x.git`, "--json"); + expect(leaked.code).toBe(2); + expect(leaked.stdout).not.toContain(token); }); it("offers --json on every command but the stdio MCP server", async () => { diff --git a/packages/cli/src/commands/add-display.ts b/packages/cli/src/commands/add-display.ts index 5a8b4bf..e3e6427 100644 --- a/packages/cli/src/commands/add-display.ts +++ b/packages/cli/src/commands/add-display.ts @@ -1,103 +1,53 @@ -import pc from 'picocolors'; -import { homedir } from 'os'; -import { sep } from 'path'; -import type { AgentType, InstallMode } from '../lib/add-types.js'; -import { agents, isUniversalAgent } from '../lib/agent-registry.js'; - -/** Shorten a path for display: replace homedir with ~ and cwd with . */ -export function shortenPath(fullPath: string, cwd: string): string { - const home = homedir(); - if (fullPath === home || fullPath.startsWith(home + sep)) { - return '~' + fullPath.slice(home.length); - } - if (fullPath === cwd || fullPath.startsWith(cwd + sep)) { - return '.' + fullPath.slice(cwd.length); - } - return fullPath; -} - -/** Format a list of items, truncating if too many */ -export function formatList(items: string[], maxShow = 5): string { - if (items.length <= maxShow) return items.join(', '); - const shown = items.slice(0, maxShow); - return `${shown.join(', ')} +${items.length - maxShow} more`; -} - -/** Pad a string to visible width (ignoring ANSI codes) */ -function padEnd(str: string, width: number): string { - const visible = str.replace(/\x1b\[[0-9;]*m/g, ''); - return str + ' '.repeat(Math.max(0, width - visible.length)); -} - -/** Split agents into universal and non-universal groups */ -function splitAgentsByType(agentTypes: AgentType[]): { - universal: string[]; - symlinked: string[]; -} { - const universal: string[] = []; - const symlinked: string[] = []; - for (const a of agentTypes) { - if (isUniversalAgent(a)) { - universal.push(agents[a].displayName); - } else { - symlinked.push(agents[a].displayName); - } - } - return { universal, symlinked }; +import chalk from 'chalk'; +import type { ApplyResult } from '../lib/install/install-engine.js'; +import type { FoundSkill, SkippedSkill } from '../lib/add/skill-discovery.js'; + +/** What `skillx add` reports; the same data backs the `--json` envelope. */ +export type AddResult = + | { status: 'cancelled' } + | { status: 'listed'; source: string; commit?: string; skills: FoundSkill[]; skipped: SkippedSkill[] } + | { + status: 'installed'; + source: string; + commit?: string; + runtime: string; + skillsDir: string; + skills: Array<{ alias: string; origin: string; digest: string }>; + skipped: SkippedSkill[]; + planDigest: string; + applied: ApplyResult[]; + recovered: { rolledBack: string[]; rolledForward: string[] }; + }; + +/** Refused skills go to stderr, so piped stdout stays clean. */ +export function printSkipped(skipped: readonly SkippedSkill[]): void { + for (const s of skipped) console.error(chalk.yellow(` ! skipped ${s.dir || '(root)'}: ${s.reason}`)); } -/** Build summary lines showing universal vs symlinked agents */ -export function buildAgentSummaryLines( - targetAgents: AgentType[], - installMode: InstallMode -): string[] { - const lines: string[] = []; - const { universal, symlinked } = splitAgentsByType(targetAgents); - - if (installMode === 'symlink') { - if (universal.length > 0) { - lines.push(` ${pc.green('universal:')} ${formatList(universal)}`); +export function printAdd(result: AddResult): void { + if (result.status === 'cancelled') { + console.log(chalk.dim('Cancelled; nothing was installed.')); + return; + } + const at = result.commit ? chalk.dim(` @ ${result.commit.slice(0, 12)}`) : ''; + if (result.status === 'listed') { + console.log(chalk.bold(`\n${result.source}`) + at); + for (const s of result.skills) console.log(` ${chalk.cyan(s.name.padEnd(28))} ${chalk.dim(s.dir || '.')} ${s.description.slice(0, 80)}`); + if (result.skills.length === 0) console.log(chalk.dim(' no installable skill')); + printSkipped(result.skipped); + return; + } + console.log(chalk.bold(`\n${result.source}`) + at + chalk.dim(` → ${result.skillsDir}`)); + for (const a of result.applied) { + for (const c of a.changed) { + console.log(` ${chalk.green(c.action.padEnd(9))} ${c.alias}`); + if (c.backupDir) console.log(chalk.yellow(` local changes saved to ${c.backupDir}`)); + if (c.preserved.length > 0) console.log(chalk.yellow(` kept files not in the skill: ${c.preserved.slice(0, 5).join(', ')}`)); + // Stderr, like the installer: an executable file is reported, never run. + if (c.executable) console.error(chalk.yellow(` ! ${c.alias}: ${c.executable.length} executable file(s) written (${c.executable.slice(0, 5).join(', ')}); skillx never runs them, review before use`)); } - if (symlinked.length > 0) { - lines.push(` ${pc.dim('symlink →')} ${formatList(symlinked)}`); - } - } else { - const allNames = targetAgents.map((a) => agents[a].displayName); - lines.push(` ${pc.dim('copy →')} ${formatList(allNames)}`); - } - return lines; -} - -/** Build result lines from installation results */ -export function buildResultLines( - results: Array<{ agent: string; symlinkFailed?: boolean }>, - targetAgents: AgentType[] -): string[] { - const lines: string[] = []; - const { universal } = splitAgentsByType(targetAgents); - - const successSymlinks = results - .filter((r) => !r.symlinkFailed && !universal.includes(r.agent)) - .map((r) => r.agent); - const failedSymlinks = results.filter((r) => r.symlinkFailed).map((r) => r.agent); - - if (universal.length > 0) { - lines.push(` ${pc.green('universal:')} ${formatList(universal)}`); - } - if (successSymlinks.length > 0) { - lines.push(` ${pc.dim('symlinked:')} ${formatList(successSymlinks)}`); - } - if (failedSymlinks.length > 0) { - lines.push(` ${pc.yellow('copied:')} ${formatList(failedSymlinks)}`); - } - return lines; -} - -/** Ensure universal agents are always included in target list */ -export function ensureUniversalAgents(targetAgents: AgentType[]): AgentType[] { - const result = [...targetAgents]; - if (!result.includes('universal')) { - result.push('universal'); + if (a.changed.length === 0) console.log(` ${chalk.dim('unchanged'.padEnd(9))} ${a.owner.slice(a.owner.indexOf(':') + 1)}`); } - return result; + printSkipped(result.skipped); + console.log(chalk.green('\n✓ done') + chalk.dim(' Skills from git or a local path are not scanned or signed; review them before use.')); } diff --git a/packages/cli/src/commands/add-prompts.ts b/packages/cli/src/commands/add-prompts.ts index 1d826bb..f9dc1c3 100644 --- a/packages/cli/src/commands/add-prompts.ts +++ b/packages/cli/src/commands/add-prompts.ts @@ -1,128 +1,76 @@ -import * as p from '@clack/prompts'; -import pc from 'picocolors'; -import type { AgentType, Skill, InstallMode } from '../lib/add-types.js'; -import { agents, getNonUniversalAgents, getUniversalAgents } from '../lib/agent-registry.js'; -import { getSkillDisplayName } from '../lib/skill-discovery.js'; -import { getLastSelectedAgents, saveSelectedAgents } from '../lib/skill-lock.js'; - -const isCancelled = (value: unknown): value is symbol => typeof value === 'symbol'; - -/** Prompt user to select skills from a list */ -export async function selectSkillsInteractive(skills: Skill[]): Promise { - const sorted = [...skills].sort((a, b) => - getSkillDisplayName(a).localeCompare(getSkillDisplayName(b)) - ); - - const choices = sorted.map((s) => ({ - value: s, - label: getSkillDisplayName(s), - hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description, - })); - - const selected = await p.multiselect({ - message: `Select skills to install ${pc.dim('(space to toggle)')}`, - options: choices as p.Option[], - required: true, - }); - - if (isCancelled(selected)) { - p.cancel('Installation cancelled'); - return null; +import chalk from 'chalk'; +import { InstallError, usageError } from '../lib/install/install-errors.js'; +import type { FoundSkill, SkippedSkill } from '../lib/add/skill-discovery.js'; +import { promptLine } from '../lib/prompt-line.js'; + +/** + * Which skills `skillx add` installs. Names match a skill's front matter name + * or its directory name, case-insensitively; `*` takes every installable + * skill. A name that matches a refused skill (reserved, invalid front matter) + * fails with the reason instead of being skipped quietly. Without names, one + * skill is taken as is; several are all taken with `--yes`, chosen at a + * prompt in human mode, and must be named under `--json`. + */ + +const baseName = (dir: string) => dir.slice(dir.lastIndexOf('/') + 1); +const matches = (name: string, skill: { name?: string; dir: string }) => { + const n = name.toLowerCase(); + return skill.name?.toLowerCase() === n || (skill.dir !== '' && baseName(skill.dir).toLowerCase() === n); +}; + +export function pickByName(found: readonly FoundSkill[], skipped: readonly SkippedSkill[], names: readonly string[]): FoundSkill[] { + const picked = new Set(); + for (const name of names) { + const hits = found.filter((s) => matches(name, s)); + if (hits.length > 0) { + hits.forEach((s) => picked.add(s)); + continue; + } + const refused = skipped.find((s) => matches(name, s)); + if (refused) throw usageError(`cannot install ${name}: ${refused.reason}`, { skill: name, dir: refused.dir }); + throw new InstallError('not_found', `no skill named ${name} in this source`, { skill: name, available: found.map((s) => s.name) }); } - return selected as Skill[]; + return [...picked]; } -/** Interactive agent selection with universal agents always included */ -export async function selectAgentsInteractive(options: { - global?: boolean; -}): Promise { - const supportsGlobal = (a: AgentType) => !options.global || agents[a].globalSkillsDir; - const otherAgents = getNonUniversalAgents().filter(supportsGlobal); - - const choices = otherAgents.map((a) => ({ - value: a, - label: agents[a].displayName, - hint: options.global ? agents[a].globalSkillsDir! : agents[a].skillsDir, - })); - - // Pre-select from last session - let lastSelected: string[] | undefined; - try { - lastSelected = await getLastSelectedAgents(); - } catch { /* ignore */ } - - const initialValues = lastSelected - ? (lastSelected.filter((a) => otherAgents.includes(a as AgentType)) as AgentType[]) - : []; - - const selected = await p.multiselect({ - message: `Which agents do you want to install to? ${pc.dim('(space to toggle)')}`, - options: choices as p.Option[], - initialValues, - required: true, - }); - - if (isCancelled(selected)) { - p.cancel('Installation cancelled'); - return null; +/** Parses `1,3`, `2-4` or `all` against a list of `count` items; null when the answer is not one of those. */ +export function parseChoice(answer: string, count: number): number[] | null { + const text = answer.trim().toLowerCase(); + if (text === 'all' || text === '*') return Array.from({ length: count }, (_, i) => i); + const picked = new Set(); + for (const part of text.split(/[\s,]+/).filter(Boolean)) { + const m = /^(\d+)(?:-(\d+))?$/.exec(part); + if (!m) return null; + const [from, to] = [Number(m[1]), Number(m[2] ?? m[1])]; + if (from < 1 || to > count || from > to) return null; + for (let i = from; i <= to; i++) picked.add(i - 1); } - - const agents_selected = selected as AgentType[]; - - // Save selection for next time - try { - await saveSelectedAgents(agents_selected as string[]); - } catch { /* ignore */ } - - // Always include universal agents - const universals = getUniversalAgents(); - for (const ua of universals) { - if (!agents_selected.includes(ua)) agents_selected.push(ua); - } - - return agents_selected; + return picked.size > 0 ? [...picked].sort((a, b) => a - b) : null; } -/** Prompt for installation scope (project vs global) */ -export async function selectScope(): Promise { - const scope = await p.select({ - message: 'Installation scope', - options: [ - { value: false, label: 'Project', hint: 'Install in current directory' }, - { value: true, label: 'Global', hint: 'Install in home directory (all projects)' }, - ], - }); - - if (isCancelled(scope)) { - p.cancel('Installation cancelled'); - return null; +export async function selectSkills( + found: readonly FoundSkill[], + skipped: readonly SkippedSkill[], + names: readonly string[], + mode: { yes?: boolean; json?: boolean }, +): Promise { + if (names.length > 0 && !names.includes('*')) return pickByName(found, skipped, names); + if (found.length === 0) { + throw new InstallError('not_found', 'no installable skill found (a skill is a directory with a SKILL.md that has a name and a description)', { skipped }); } - return scope as boolean; -} - -/** Prompt for installation method (symlink vs copy) */ -export async function selectInstallMode(): Promise { - const mode = await p.select({ - message: 'Installation method', - options: [ - { value: 'symlink', label: 'Symlink (Recommended)', hint: 'Single source of truth' }, - { value: 'copy', label: 'Copy to all agents', hint: 'Independent copies' }, - ], - }); - - if (isCancelled(mode)) { - p.cancel('Installation cancelled'); - return null; + if (names.length > 0 || found.length === 1 || mode.yes) return [...found]; + if (mode.json) throw usageError(`${found.length} skills found; name them with --skill or --skill '*'`, { available: found.map((s) => s.name) }); + found.forEach((s, i) => console.log(` ${chalk.dim(String(i + 1).padStart(2))} ${chalk.cyan(s.name)} ${chalk.dim(s.description.slice(0, 70))}`)); + for (;;) { + const answer = await promptLine('Install which skills? (e.g. 1,3 or 2-4 or all; empty to cancel) ', process.stdout); + if (answer === null || answer.trim() === '') return null; + const picked = parseChoice(answer, found.length); + if (picked) return picked.map((i) => found[i]!); + console.log(chalk.yellow(` answer with numbers from 1 to ${found.length}, ranges, or all`)); } - return mode as InstallMode; } -/** Confirm installation */ export async function confirmInstall(): Promise { - const confirmed = await p.confirm({ message: 'Proceed with installation?' }); - if (isCancelled(confirmed) || !confirmed) { - p.cancel('Installation cancelled'); - return false; - } - return true; + const answer = await promptLine('Install? [y/N] ', process.stdout); + return answer !== null && /^y(es)?$/i.test(answer.trim()); } diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 76a5e95..02e84c2 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -1,345 +1,116 @@ +import { join } from 'node:path'; import { Command } from 'commander'; -import * as p from '@clack/prompts'; -import pc from 'picocolors'; -import { existsSync } from 'fs'; -import type { AddOptions, AgentType, InstallMode, Skill } from '../lib/add-types.js'; -import { parseSource, getOwnerRepo } from '../lib/source-parser.js'; -import { cloneRepo, cleanupTempDir, GitCloneError } from '../lib/git-clone.js'; -import { discoverSkills, getSkillDisplayName, filterSkills } from '../lib/skill-discovery.js'; -import { agents, detectInstalledAgents } from '../lib/agent-registry.js'; -import { installSkillForAgent, getCanonicalPath } from '../lib/skill-installer.js'; -import { addSkillToLock } from '../lib/skill-lock.js'; +import chalk from 'chalk'; +import { applyAdd, planAdd } from '../lib/add/add-install.js'; +import { parseAddSource, redactCredentials, searchPathOf } from '../lib/add/add-source.js'; +import type { AddSource } from '../lib/add/add-source.js'; +import { openGitSource } from '../lib/add/git-source.js'; +import { openLocalSource } from '../lib/add/local-source.js'; +import { discoverSkills, prepareSkill } from '../lib/add/skill-discovery.js'; +import type { SourceSnapshot } from '../lib/add/skill-discovery.js'; import { CliError } from '../lib/output.js'; -import { runCommand } from '../lib/run-command.js'; -import { - shortenPath, buildAgentSummaryLines, buildResultLines, - formatList, ensureUniversalAgents, -} from './add-display.js'; -import { - selectSkillsInteractive, selectAgentsInteractive, - selectScope, selectInstallMode, confirmInstall, -} from './add-prompts.js'; - -interface InstallResult { - skill: string; agent: string; success: boolean; path: string; - canonicalPath?: string; mode: InstallMode; symlinkFailed?: boolean; error?: string; +import { confirmInstall, selectSkills } from './add-prompts.js'; +import { printAdd, printSkipped } from './add-display.js'; +import type { AddResult } from './add-display.js'; +import { contextFrom, printPlan, runInstaller } from './install-runner.js'; +import type { InstallerOptions } from './install-runner.js'; + +/** + * `skillx add `: install skills straight from GitHub, an https git + * URL or a local directory, through the generic installer (same skills + * directory, receipts, owners, install lock, conflicts and backups as + * `skillx install`). Sources are parsed and refused in `lib/add/add-source.ts`, + * read without following links in `git-source.ts`/`local-source.ts`, and each + * chosen skill is checked by the release tree rules before the installer sees + * it. Every failure message is redacted, so a token in a URL never prints. + */ + +export interface AddOptions extends InstallerOptions { + skill?: string[]; + list?: boolean; + yes?: boolean; } -/** What `skillx add` reports; the same data backs the `--json` envelope. */ -export type AddResult = - | { status: 'cancelled' } - | { status: 'listed'; source: string; skills: Array<{ name: string; description: string }> } - | { - status: 'installed'; source: string; global: boolean; mode: InstallMode; - agents: AgentType[]; results: InstallResult[]; - }; - -/** Clack output in human mode; silent under `--json` so stdout carries one envelope. */ -type Ui = Pick & { log: Pick }; - -const noop = () => {}; -const silentUi: Ui = { - intro: noop, - outro: noop, - note: noop, - spinner: () => ({ start: noop, stop: noop, message: noop }) as unknown as ReturnType, - log: { info: noop, step: noop, message: noop, warn: noop, error: noop }, -}; - -/** Parse CLI options for the add command */ -function parseAddOptions(opts: Record): AddOptions { - const options: AddOptions = { - global: opts.global as boolean | undefined, - yes: opts.yes as boolean | undefined, - list: opts.list as boolean | undefined, - copy: opts.copy as boolean | undefined, - all: opts.all as boolean | undefined, - fullDepth: opts.fullDepth as boolean | undefined, - }; - // Commander passes repeated options as arrays - if (opts.agent) options.agent = Array.isArray(opts.agent) ? opts.agent : [opts.agent as string]; - if (opts.skill) options.skill = Array.isArray(opts.skill) ? opts.skill : [opts.skill as string]; - return options; +export function openSource(source: AddSource): SourceSnapshot { + if (source.kind === 'local') return openLocalSource(source.dir); + return openGitSource(source.cloneUrl, source.label, source.kind === 'github' ? source.ref : undefined); } -async function cleanup(tempDir: string | null) { - if (tempDir) { - try { await cleanupTempDir(tempDir); } catch { /* ignore */ } - } +/** Where a skill came from: the source plus the skill's directory in it, never credentials. */ +export function originOf(source: AddSource, dir: string): string { + if (source.kind === 'local') return dir === '' ? source.dir : join(source.dir, ...dir.split('/')); + if (source.kind === 'github') return `github:${source.owner}/${source.repo}${dir ? `/${dir}` : ''}${source.ref ? `@${source.ref}` : ''}`; + return dir === '' ? source.label : `${source.label}//${dir}`; } -/** Main add command logic. Throws `CliError` on failure; never exits the process. */ -async function runAdd(source: string, options: AddOptions, ui: Ui): Promise { - // --all implies --skill '*' --agent '*' -y - if (options.all) { - options.skill = ['*']; - options.agent = ['*']; - options.yes = true; - } - - ui.intro(pc.bgCyan(pc.black(' skillx add '))); - - let tempDir: string | null = null; - +export async function runAdd(input: string, options: AddOptions): Promise { + const source = parseAddSource(input); + // Resolve the runtime before any clone, so a bad --runtime fails fast. + const ctx = options.list ? null : contextFrom(options); + if (!options.json && source.kind !== 'local') console.error(chalk.dim(`cloning ${source.label} ...`)); + const snapshot = openSource(source); try { - const spinner = ui.spinner(); - - // 1. Parse source - spinner.start('Parsing source...'); - const parsed = parseSource(source); - const sourceLabel = parsed.type === 'local' ? parsed.localPath! : parsed.url; - spinner.stop( - `Source: ${sourceLabel}` + - `${parsed.ref ? ` @ ${pc.yellow(parsed.ref)}` : ''}` + - `${parsed.subpath ? ` (${parsed.subpath})` : ''}` + - `${parsed.skillFilter ? ` ${pc.dim('@')}${pc.cyan(parsed.skillFilter)}` : ''}` - ); - - // 2. Clone or validate local path - let skillsDir: string; - if (parsed.type === 'local') { - if (!existsSync(parsed.localPath!)) { - throw new CliError('usage_error', `Local path does not exist: ${parsed.localPath}`); - } - skillsDir = parsed.localPath!; - } else { - spinner.start('Cloning repository...'); - try { - tempDir = await cloneRepo(parsed.url, parsed.ref); - } catch (error) { - if (error instanceof GitCloneError) { - throw new CliError('network_error', 'Failed to clone repository', { hint: error.message }); - } - throw error; - } - skillsDir = tempDir; - spinner.stop('Repository cloned'); - } - - // Merge @skill filter into options.skill - if (parsed.skillFilter) { - options.skill = options.skill || []; - if (!options.skill.includes(parsed.skillFilter)) options.skill.push(parsed.skillFilter); + const { skills, skipped } = discoverSkills(snapshot, searchPathOf(source)); + const base = { source: source.label, ...(snapshot.commit ? { commit: snapshot.commit } : {}), skipped }; + if (ctx === null) return { status: 'listed', ...base, skills }; + const names = [...(options.skill ?? []), ...(source.kind === 'github' && source.skill ? [source.skill] : [])]; + const chosen = await selectSkills(skills, skipped, names, options); + if (chosen === null) return { status: 'cancelled' }; + const added = chosen.map((s) => ({ alias: s.name, origin: originOf(source, s.dir), tree: prepareSkill(snapshot, s) })); + const info = { ref: source.label, version: snapshot.commit ?? 'local' }; + let expectDigest: string | undefined; + if (!options.json && !options.yes) { + const plan = await planAdd(ctx, info, added); + printPlan(plan); + printSkipped(skipped); + if (!(await confirmInstall())) return { status: 'cancelled' }; + expectDigest = plan.planDigest; } + const outcome = await applyAdd(ctx, info, added, expectDigest); + return { + status: 'installed', + ...base, + runtime: ctx.runtime, + skillsDir: ctx.skillsDir, + skills: added.map((a) => ({ alias: a.alias, origin: a.origin, digest: a.tree.digest })), + planDigest: outcome.planDigest, + applied: outcome.applied, + recovered: outcome.recovered, + }; + } finally { + snapshot.dispose(); + } +} - // 3. Discover skills - spinner.start('Discovering skills...'); - const skills = await discoverSkills(skillsDir, parsed.subpath, { - fullDepth: options.fullDepth, +/** The same failure with every URL credential masked in its message, hint and details. */ +export function redactError(error: unknown): unknown { + if (error instanceof CliError) { + const { details, hint } = error.extra; + return new CliError(error.code, redactCredentials(error.message), { + ...(details === undefined ? {} : { details: JSON.parse(redactCredentials(JSON.stringify(details))) as unknown }), + ...(hint ? { hint: redactCredentials(hint) } : {}), }); - - if (skills.length === 0) { - spinner.stop(pc.red('No skills found')); - throw new CliError('not_found', 'No valid skills found. Skills require a SKILL.md with name and description.'); - } - spinner.stop(`Found ${pc.green(String(skills.length))} skill${skills.length > 1 ? 's' : ''}`); - - // 4. --list: just show available skills - if (options.list) { - ui.log.step(pc.bold('Available Skills')); - for (const skill of skills) { - ui.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`); - ui.log.message(` ${pc.dim(skill.description)}`); - } - ui.outro('Use --skill to install specific skills'); - return { - status: 'listed', - source: sourceLabel, - skills: skills.map((s) => ({ name: getSkillDisplayName(s), description: s.description })), - }; - } - - // 5. Select skills - let selectedSkills: Skill[]; - if (options.skill?.includes('*')) { - selectedSkills = skills; - ui.log.info(`Installing all ${skills.length} skills`); - } else if (options.skill && options.skill.length > 0) { - selectedSkills = filterSkills(skills, options.skill); - if (selectedSkills.length === 0) { - throw new CliError('not_found', `No matching skills for: ${options.skill.join(', ')}`, { - hint: 'Available: ' + skills.map((s) => getSkillDisplayName(s)).join(', '), - }); - } - } else if (skills.length === 1) { - selectedSkills = skills; - ui.log.info(`Skill: ${pc.cyan(getSkillDisplayName(skills[0]!))}`); - } else if (options.yes) { - selectedSkills = skills; - ui.log.info(`Installing all ${skills.length} skills`); - } else { - const result = await selectSkillsInteractive(skills); - if (!result) return { status: 'cancelled' }; - selectedSkills = result; - } - - // 6. Select target agents - let targetAgents: AgentType[]; - const validAgents = Object.keys(agents) as AgentType[]; - - if (options.agent?.includes('*')) { - targetAgents = validAgents; - } else if (options.agent && options.agent.length > 0) { - const invalid = options.agent.filter((a) => !validAgents.includes(a as AgentType)); - if (invalid.length > 0) { - throw new CliError('usage_error', `Invalid agents: ${invalid.join(', ')}`, { - hint: `Valid agents: ${validAgents.join(', ')}`, - }); - } - targetAgents = options.agent as AgentType[]; - } else { - spinner.start('Detecting agents...'); - const installed = await detectInstalledAgents(); - spinner.stop(`${validAgents.length} agents available`); - - if (installed.length <= 1 || options.yes) { - targetAgents = ensureUniversalAgents(installed.length > 0 ? installed : validAgents); - } else { - const result = await selectAgentsInteractive({ global: options.global }); - if (!result) return { status: 'cancelled' }; - targetAgents = result; - } - } - - // 7. Select scope (global vs project) - let installGlobally = options.global ?? false; - if (options.global === undefined && !options.yes) { - const scope = await selectScope(); - if (scope === null) return { status: 'cancelled' }; - installGlobally = scope; - } - - // 8. Select install mode (symlink vs copy) - let installMode: InstallMode = options.copy ? 'copy' : 'symlink'; - if (!options.copy && !options.yes) { - const mode = await selectInstallMode(); - if (!mode) return { status: 'cancelled' }; - installMode = mode; - } - - // 9. Summary - const cwd = process.cwd(); - const summaryLines: string[] = []; - for (const skill of selectedSkills) { - if (summaryLines.length > 0) summaryLines.push(''); - const canonical = getCanonicalPath(skill.name, { global: installGlobally }); - summaryLines.push(pc.cyan(shortenPath(canonical, cwd))); - summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); - } - ui.note(summaryLines.join('\n'), 'Installation Summary'); - - // 10. Confirm - if (!options.yes) { - const ok = await confirmInstall(); - if (!ok) return { status: 'cancelled' }; - } - - // 11. Install - spinner.start('Installing skills...'); - const results: InstallResult[] = []; - - for (const skill of selectedSkills) { - for (const agent of targetAgents) { - const result = await installSkillForAgent(skill, agent, { - global: installGlobally, - mode: installMode, - }); - results.push({ skill: getSkillDisplayName(skill), agent: agents[agent].displayName, ...result }); - } - } - spinner.stop('Installation complete'); - - // 12. Show results - const successful = results.filter((r) => r.success); - const failed = results.filter((r) => !r.success); - - if (successful.length > 0) { - const bySkill = new Map(); - for (const r of successful) { - const arr = bySkill.get(r.skill) || []; - arr.push(r); - bySkill.set(r.skill, arr); - } - - const resultLines: string[] = []; - for (const [skillName, skillResults] of bySkill) { - const first = skillResults[0]!; - if (first.mode === 'copy') { - resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim('(copied)')}`); - for (const r of skillResults) resultLines.push(` ${pc.dim('→')} ${shortenPath(r.path, cwd)}`); - } else { - const label = first.canonicalPath ? shortenPath(first.canonicalPath, cwd) : skillName; - resultLines.push(`${pc.green('✓')} ${label}`); - resultLines.push(...buildResultLines(skillResults, targetAgents)); - } - } - - ui.note(resultLines.join('\n'), pc.green(`Installed ${bySkill.size} skill${bySkill.size !== 1 ? 's' : ''}`)); - - const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed); - if (symlinkFailures.length > 0) { - ui.log.warn(pc.yellow(`Symlinks failed for: ${formatList(symlinkFailures.map((r) => r.agent))}`)); - ui.log.message(pc.dim(' Files were copied instead. On Windows, enable Developer Mode for symlink support.')); - } - } - - if (failed.length > 0) { - ui.log.error(pc.red(`Failed to install ${failed.length}`)); - for (const r of failed) ui.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`); - } - - // 13. Update lock file (global installs) - if (successful.length > 0 && installGlobally) { - const normalizedSource = getOwnerRepo(parsed); - if (normalizedSource) { - for (const skill of selectedSkills) { - try { - await addSkillToLock(skill.name, { - source: normalizedSource, - sourceType: parsed.type, - sourceUrl: parsed.url, - }); - } catch { /* ignore lock file errors */ } - } - } - } - - if (successful.length === 0) { - throw new CliError('unexpected_error', `Failed to install ${failed.length} skill target${failed.length !== 1 ? 's' : ''}`, { - details: { results }, - }); - } - - ui.outro(pc.green('Done!') + pc.dim(' Review skills before use; they run with full agent permissions.')); - return { status: 'installed', source: sourceLabel, global: installGlobally, mode: installMode, agents: targetAgents, results }; - } catch (error) { - ui.outro(pc.red('Installation failed')); - throw error; - } finally { - await cleanup(tempDir); } + if (error instanceof Error) return new Error(redactCredentials(error.message)); + return error; } export const addCommand = new Command('add') - .description('Add skills from a GitHub repo, local path, or git URL') - .argument('', 'owner/repo, URL, or local path') - .option('-g, --global', 'Install globally (~/) instead of project-level') - .option('-a, --agent ', 'Target specific agents (use * for all)') - .option('-s, --skill ', 'Select specific skills (use * for all)') - .option('-l, --list', 'List available skills without installing') - .option('-y, --yes', 'Skip confirmation prompts') - .option('--copy', 'Copy files instead of symlinking') - .option('--all', 'Shorthand for --skill * --agent * -y') - .option('--full-depth', 'Search all subdirs even when root SKILL.md exists') - .option('--json', 'emit a machine-readable envelope instead of text (implies --yes)') - .action((source: string, opts: Record) => { - const json = opts.json === true; - const options = parseAddOptions(opts); - // An envelope caller cannot answer prompts, so --json never asks. - if (json) options.yes = true; - return runCommand( - { json }, - () => runAdd(source, options, json ? silentUi : p), - () => {}, - ); - }); + .description('Install skills from GitHub, an https git URL or a local directory through the installer') + .argument('', 'owner/repo[/skill] | github:owner/repo[/path][@ref] | https://host/repo.git | ./local/dir') + .option('-s, --skill ', "skills to install, by name or directory name ('*' for all)") + .option('-l, --list', 'list the skills in the source; install nothing') + .option('-y, --yes', 'do not ask; without --skill, install every skill found') + .option('--force', 'replace a skill added before or an unowned directory (edits backed up, unknown files kept)') + .option('--runtime ', 'target runtime: claude-code or codex', 'claude-code') + .option('--json', 'emit a machine-readable envelope instead of text; never asks') + .action((source: string, options: AddOptions) => + runInstaller( + options, + () => + runAdd(source, options).catch((error: unknown) => { + throw redactError(error); + }), + printAdd, + ), + ); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index f56dca1..0ea89ea 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -94,7 +94,7 @@ export const updateCommand = withCommon(new Command('update')) export const uninstallCommand = withCommon(new Command('uninstall')) .description('Remove a source; skills another source still needs stay, edited and unknown files are kept') - .argument('', 'the source as installed: kit:@handle/kit, collection: or @publisher/package') + .argument('', 'the source as installed: kit:@handle/kit, collection:, @publisher/package or add:') .option('--lock ', 'packages lock file to drop the package from', INSTALL_LOCK_FILENAME) .option('--no-lock', 'leave the lock file alone') .action((refs: string[], options: ApplyOptions) => diff --git a/packages/cli/src/lib/add-types.ts b/packages/cli/src/lib/add-types.ts deleted file mode 100644 index 2b7a785..0000000 --- a/packages/cli/src/lib/add-types.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** Shared types for the `skillx add` command */ - -export type AgentType = - | 'amp' - | 'augment' - | 'claude-code' - | 'cline' - | 'codebuddy' - | 'codex' - | 'command-code' - | 'continue' - | 'cursor' - | 'gemini-cli' - | 'github-copilot' - | 'goose' - | 'junie' - | 'kilo' - | 'kiro-cli' - | 'kode' - | 'mux' - | 'neovate' - | 'opencode' - | 'openhands' - | 'roo' - | 'trae' - | 'windsurf' - | 'zencoder' - | 'universal'; - -export interface AgentConfig { - name: string; - displayName: string; - skillsDir: string; - /** Global skills directory. undefined = doesn't support global install. */ - globalSkillsDir: string | undefined; - detectInstalled: () => Promise; -} - -export interface Skill { - name: string; - description: string; - path: string; - rawContent?: string; - pluginName?: string; - metadata?: Record; -} - -export interface ParsedSource { - type: 'github' | 'gitlab' | 'git' | 'local'; - url: string; - subpath?: string; - localPath?: string; - ref?: string; - /** Skill name from @skill syntax (e.g., owner/repo@skill-name) */ - skillFilter?: string; -} - -export interface AddOptions { - global?: boolean; - agent?: string[]; - yes?: boolean; - skill?: string[]; - list?: boolean; - all?: boolean; - fullDepth?: boolean; - copy?: boolean; -} - -export type InstallMode = 'symlink' | 'copy'; - -export interface InstallResult { - success: boolean; - path: string; - canonicalPath?: string; - mode: InstallMode; - symlinkFailed?: boolean; - error?: string; -} diff --git a/packages/cli/src/lib/add/add-install.test.ts b/packages/cli/src/lib/add/add-install.test.ts new file mode 100644 index 0000000..7918b9a --- /dev/null +++ b/packages/cli/src/lib/add/add-install.test.ts @@ -0,0 +1,154 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runAdd } from '../../commands/add.js'; +import type { AddResult } from '../../commands/add-display.js'; +import { createInstallContext } from '../install/install-context.js'; +import type { InstallContext } from '../install/install-context.js'; +import { listInstalled, uninstallRef, updateInstalled } from '../install/install-operations.js'; +import { readOwners, readReceipt } from '../install/install-state.js'; +import { createRegistry } from '../install/registry-client.js'; +import { CliError } from '../output.js'; +import { applyAdd } from './add-install.js'; +import { openLocalSource } from './local-source.js'; +import { discoverSkills, prepareSkill } from './skill-discovery.js'; + +/** + * `skillx add` from a local path, end to end into a temporary home: the + * skills directory, receipts and owners all live under `root`, and the + * registry is never contacted. + */ + +let root: string; +let src: string; +let ctx: (force?: boolean) => InstallContext; +const saved = { ...process.env }; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'skillx-add-e2e-')); + src = join(root, 'src'); + const env = { HOME: join(root, 'home'), USERPROFILE: join(root, 'home'), SKILLX_HOME: join(root, 'skillx'), CLAUDE_CONFIG_DIR: join(root, 'claude') }; + // runAdd builds its context from process.env, like the CLI. + Object.assign(process.env, env); + ctx = (force = false) => createInstallContext({ registry: createRegistry({ baseUrl: 'http://127.0.0.1:9' }), env, force }); +}); +afterEach(() => { + process.env = { ...saved }; + rmSync(root, { recursive: true, force: true }); +}); + +function write(rel: string, text: string): void { + const path = join(src, ...rel.split('/')); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +} +const skillMd = (name: string, body = '') => `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n${body}`; +const skillsDir = () => join(root, 'claude', 'skills'); + +async function add(names: string[], force = false): Promise { + return runAdd(src, { json: true, skill: names, ...(force ? { force } : {}) }); +} + +async function refusal(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + return error as CliError; + } + throw new Error('expected a refusal'); +} + +describe('skillx add from a local path', () => { + it('installs through the installer: files, receipt and an add: owner', async () => { + write('skills/demo/SKILL.md', skillMd('demo')); + write('skills/demo/references/guide.md', 'guide\n'); + write('skills/other/SKILL.md', skillMd('other')); + const result = await add(['demo']); + expect(result).toMatchObject({ status: 'installed', source: src, runtime: 'claude-code', skillsDir: skillsDir(), skills: [{ alias: 'demo', origin: join(src, 'skills', 'demo') }] }); + expect(readFileSync(join(skillsDir(), 'demo', 'references', 'guide.md'), 'utf8')).toBe('guide\n'); + expect(existsSync(join(skillsDir(), 'other'))).toBe(false); + + const receipt = readReceipt(join(root, 'skillx'), 'claude-code', 'demo')!; + expect(receipt).toMatchObject({ package: join(src, 'skills', 'demo'), version: 'local', requiredBy: ['add:demo'] }); + expect(receipt.files.map((f) => f.path)).toEqual(['SKILL.md', 'references/guide.md']); + expect(readOwners(join(root, 'skillx'), 'claude-code').owners['add:demo']?.ref).toBe(src); + expect(listInstalled(ctx()).skills.map((s) => s.alias)).toEqual(['demo']); + }); + + it('is a no-op when the same tree is added again, and refuses a changed one without --force', async () => { + write('demo/SKILL.md', skillMd('demo')); + await add([]); + const again = await add([]); + expect(again.status === 'installed' && again.applied[0]!.changed).toEqual([]); + + write('demo/SKILL.md', skillMd('demo', 'v2\n')); + const refused = await refusal(() => add([])); + expect(refused.code).toBe('conflict'); + expect(refused.message).toContain('--force'); + expect(readFileSync(join(skillsDir(), 'demo', 'SKILL.md'), 'utf8')).not.toContain('v2'); + + await add([], true); + expect(readFileSync(join(skillsDir(), 'demo', 'SKILL.md'), 'utf8')).toContain('v2'); + }); + + it('never installs over an existing directory it does not own without --force, and keeps unknown files with it', async () => { + write('demo/SKILL.md', skillMd('demo')); + mkdirSync(join(skillsDir(), 'demo'), { recursive: true }); + writeFileSync(join(skillsDir(), 'demo', 'MY-EDITS.md'), 'mine\n'); + const refused = await refusal(() => add([])); + expect(refused.code).toBe('conflict'); + expect(refused.extra.details).toMatchObject({ conflicts: [{ alias: 'demo', reason: 'unowned_directory' }] }); + expect(existsSync(join(skillsDir(), 'demo', 'SKILL.md'))).toBe(false); + + await add([], true); + expect(readFileSync(join(skillsDir(), 'demo', 'MY-EDITS.md'), 'utf8')).toBe('mine\n'); + expect(existsSync(join(skillsDir(), 'demo', 'SKILL.md'))).toBe(true); + }); + + it('refuses reserved names when asked for one, and never installs them with *', async () => { + write('skills/ak-plan/SKILL.md', skillMd('ak-plan')); + write('skills/fine/SKILL.md', skillMd('fine')); + expect((await refusal(() => add(['ak-plan']))).code).toBe('usage_error'); + const all = await add(['*']); + expect(all.status === 'installed' && all.skills.map((s) => s.alias)).toEqual(['fine']); + expect(all.status === 'installed' && all.skipped).toEqual([{ dir: 'skills/ak-plan', reason: expect.stringContaining('reserved') }]); + expect(existsSync(join(skillsDir(), 'ak-plan'))).toBe(false); + }); + + it('asks for names under --json when the source holds several skills', async () => { + write('a/SKILL.md', skillMd('a')); + write('b/SKILL.md', skillMd('b')); + expect((await refusal(() => runAdd(src, { json: true }))).code).toBe('usage_error'); + expect((await refusal(() => add(['nope']))).code).toBe('not_found'); + }); + + it('lists without installing anything', async () => { + write('a/SKILL.md', skillMd('a')); + const listed = await runAdd(src, { json: true, list: true }); + expect(listed).toMatchObject({ status: 'listed', skills: [{ name: 'a', dir: 'a', description: 'a skill' }], skipped: [] }); + expect(existsSync(skillsDir())).toBe(false); + }); + + it('uninstalls add: with ownership proof, and update leaves added skills alone', async () => { + write('demo/SKILL.md', skillMd('demo')); + await add([]); + expect(await updateInstalled(ctx(), [])).toEqual([]); + expect((await refusal(() => updateInstalled(ctx(), ['add:demo']))).code).toBe('usage_error'); + + writeFileSync(join(skillsDir(), 'demo', 'notes.md'), 'my notes\n'); + const [removed] = await uninstallRef(ctx(), ['add:demo']); + expect(removed!.removed[0]).toMatchObject({ alias: 'demo', removed: ['SKILL.md'], keptUnknown: ['notes.md'] }); + expect(readReceipt(join(root, 'skillx'), 'claude-code', 'demo')).toBeNull(); + expect(readOwners(join(root, 'skillx'), 'claude-code').owners['add:demo']).toBeUndefined(); + }); + + it('refuses two selected skills that install under one name', async () => { + write('one/SKILL.md', skillMd('same')); + write('two/SKILL.md', skillMd('same')); + const snapshot = openLocalSource(src); + const added = discoverSkills(snapshot, '').skills.map((s) => ({ alias: s.name, origin: s.dir, tree: prepareSkill(snapshot, s) })); + expect((await refusal(() => applyAdd(ctx(), { ref: src, version: 'local' }, added))).code).toBe('usage_error'); + }); +}); diff --git a/packages/cli/src/lib/add/add-install.ts b/packages/cli/src/lib/add/add-install.ts new file mode 100644 index 0000000..2dfd143 --- /dev/null +++ b/packages/cli/src/lib/add/add-install.ts @@ -0,0 +1,83 @@ +import { INSTALL_LOCK_SCHEMA, INSTALL_RESOLVER_VERSION, canonicalJsonBytes, lockDigest, sortLockPackages } from '@skillx/contracts'; +import type { InstallLock, InstallLockPackage } from '@skillx/contracts'; +import type { VerifiedTree } from '../install/fetch-verify.js'; +import { sha256Hex } from '../install/fetch-verify.js'; +import type { InstallContext } from '../install/install-context.js'; +import { planOwned } from '../install/install-engine.js'; +import type { OwnedLock } from '../install/install-engine.js'; +import { conflictError, usageError } from '../install/install-errors.js'; +import { ADD_OWNER_PREFIX, applyPlanned } from '../install/install-operations.js'; +import type { ApplyOutcome, PlannedItem, SourcePlan } from '../install/install-operations.js'; +import { readReceipt } from '../install/install-state.js'; + +/** + * `skillx add` through the generic installer. Each added skill is its own + * owner, `add:`, recorded in the owners file with the source it came + * from, so `list` shows it and `uninstall add:` removes it with + * ownership proof. Trees were read and checked by the caller; the installer + * plans, stages, journals and swaps them like any release, under the same + * install lock, conflicts and backups. + * + * On top of the installer's rules (an unowned directory needs `--force`, a + * skill another source needs at another digest is never replaced), `add` + * never replaces a skill it added before without `--force`. Nothing goes to + * `skillx.lock.json`: a git or local tree has no signed release to pin. + */ + +export interface AddedSkill { + alias: string; + /** Where the skill came from, e.g. `github:owner/repo/skills/demo`; no credentials. */ + origin: string; + tree: VerifiedTree; +} + +export interface AddSourceInfo { + /** The source as the user can give it again; no credentials. */ + ref: string; + /** The commit read, or `local`. */ + version: string; +} + +export const addOwnerOf = (alias: string) => `${ADD_OWNER_PREFIX}${alias}`; + +function owned(source: AddSourceInfo, skill: AddedSkill): OwnedLock { + const pkg: InstallLockPackage = { alias: skill.alias, package: skill.origin, version: source.version, releaseDigest: skill.tree.digest, compositionDigest: skill.tree.digest, overlayDigests: [] }; + const lock: InstallLock = { schema: INSTALL_LOCK_SCHEMA, resolver: INSTALL_RESOLVER_VERSION, source: { kind: 'packages' }, runtimes: {}, packages: [pkg] }; + return { owner: addOwnerOf(skill.alias), ref: source.ref, lock, overlays: new Map(), trees: new Map([[skill.alias, skill.tree]]) }; +} + +/** Plans the skills; refuses duplicates and, without `--force`, replacing a skill added before. */ +export async function planAdd(ctx: InstallContext, source: AddSourceInfo, skills: readonly AddedSkill[]): Promise { + if (skills.length === 0) throw usageError('no skill selected'); + const seen = new Set(); + for (const s of skills) { + if (seen.has(s.alias)) throw usageError(`two selected skills install as '${s.alias}'; pick one with --skill`, { alias: s.alias }); + seen.add(s.alias); + } + const items: PlannedItem[] = skills.map((s) => { + const o = owned(source, s); + return { ...o, plan: planOwned(ctx, o) }; + }); + if (!ctx.force) { + const replaced = items.flatMap((i) => i.plan.actions).filter((a) => a.action === 'update'); + if (replaced.length > 0) { + const from = replaced.map((a) => `${a.alias} (from ${readReceipt(ctx.home, ctx.runtime, a.alias)?.package ?? 'an earlier add'})`); + throw conflictError(`${from.join(', ')} already installed; pass --force to replace`, { + conflicts: replaced.map((a) => ({ alias: a.alias, reason: 'installed', files: [] })), + }); + } + } + const lock: InstallLock = { ...items[0]!.lock, packages: sortLockPackages(items.map((i) => i.lock.packages[0]!)) }; + return { + lock, + lockDigest: await lockDigest(lock), + items, + planDigest: `sha256:${sha256Hex(canonicalJsonBytes(items.map((i) => i.plan.planDigest)))}`, + warnings: [], + }; +} + +/** Plans afresh under the install lock and applies; `expectDigest` pins the plan the user confirmed. */ +export function applyAdd(ctx: InstallContext, source: AddSourceInfo, skills: readonly AddedSkill[], expectDigest?: string): Promise { + return applyPlanned(ctx, () => planAdd(ctx, source, skills), expectDigest); +} diff --git a/packages/cli/src/lib/add/add-source.test.ts b/packages/cli/src/lib/add/add-source.test.ts new file mode 100644 index 0000000..1c82989 --- /dev/null +++ b/packages/cli/src/lib/add/add-source.test.ts @@ -0,0 +1,108 @@ +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { CliError } from '../output.js'; +import { parseAddSource, redactCredentials } from './add-source.js'; +import { redactError } from '../../commands/add.js'; + +const refusal = (input: string) => { + try { + parseAddSource(input); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + return error as CliError; + } + throw new Error(`expected '${input}' to be refused`); +}; + +describe('parseAddSource', () => { + it('reads github: sources with a path and a ref', () => { + expect(parseAddSource('github:acme/skills')).toMatchObject({ kind: 'github', owner: 'acme', repo: 'skills', path: '', cloneUrl: 'https://github.com/acme/skills.git', label: 'github:acme/skills' }); + expect(parseAddSource('github:acme/skills/tools/lint@v1.2')).toMatchObject({ path: 'tools/lint', ref: 'v1.2', label: 'github:acme/skills/tools/lint@v1.2' }); + expect(parseAddSource('github:acme/skills@release/2026')).toMatchObject({ path: '', ref: 'release/2026' }); + }); + + it('reads the owner/repo[/skill] shorthand as a GitHub source with a skill name', () => { + expect(parseAddSource('acme/skills')).toMatchObject({ kind: 'github', owner: 'acme', repo: 'skills', path: '' }); + expect(parseAddSource('acme/skills/pdf')).toMatchObject({ kind: 'github', skill: 'pdf', path: '' }); + }); + + it('maps github.com web URLs to github: and keeps other https URLs as git sources', () => { + expect(parseAddSource('https://github.com/acme/skills.git')).toMatchObject({ kind: 'github', owner: 'acme', repo: 'skills', path: '' }); + expect(parseAddSource('https://github.com/acme/skills/tree/main/skills/pdf')).toMatchObject({ kind: 'github', ref: 'main', path: 'skills/pdf' }); + expect(parseAddSource('https://git.example.com/team/skills.git')).toEqual({ kind: 'git', cloneUrl: 'https://git.example.com/team/skills.git', label: 'https://git.example.com/team/skills.git' }); + }); + + it('reads local directories relative to the working directory', () => { + const cwd = resolve('/work/project'); + expect(parseAddSource('./skills', cwd)).toEqual({ kind: 'local', dir: resolve(cwd, 'skills'), label: resolve(cwd, 'skills') }); + expect(parseAddSource('..', cwd)).toMatchObject({ kind: 'local', dir: resolve(cwd, '..') }); + expect(parseAddSource(resolve('/abs/skills'), cwd)).toMatchObject({ kind: 'local', dir: resolve('/abs/skills') }); + }); + + it.each([ + ['ext::sh -c touch% /tmp/pwned', 'transport helpers'], + ['fd::17', 'transport helpers'], + ['file:///etc/passwd', 'only https'], + ['http://git.example.com/x.git', 'only https'], + ['ssh://git@github.com/acme/skills.git', 'only https'], + ['git@github.com:acme/skills.git', 'scp-style'], + ['--upload-pack=touch /tmp/pwned', 'option'], + ['-u touch /tmp/pwned', 'option'], + ['https://git.example.com/x.git?private_token=abc', 'no query'], + ])('refuses %s as a usage error', (input, why) => { + const error = refusal(input); + expect(error.code).toBe('usage_error'); + expect(error.message).toContain(why); + }); + + it.each(['github:acme/skills/../../etc', 'github:acme/skills/a/./b', 'github:acme/skills/a\\b', 'github:acme/..', 'https://github.com/acme/skills/tree/main/..%2F..'])( + 'refuses the traversal or unsafe path in %s', + (input) => { + expect(refusal(input).code).toBe('usage_error'); + }, + ); + + it.each(['github:acme/skills@-oops', 'github:acme/skills@a..b', 'github:acme/skills@x@{1}', 'github:acme/skills@main.lock'])('refuses the ref in %s', (input) => { + expect(refusal(input).code).toBe('usage_error'); + }); + + it('sends registry sources to skillx install and refuses @agentkit', () => { + expect(refusal('@agentkit/plan').message).toContain('reserved'); + expect(refusal('@acme/pdf').message).toContain('skillx install'); + expect(refusal('kit:@acme/core').message).toContain('skillx install'); + }); +}); + +describe('credentials in URLs', () => { + const token = 'ghp_SECRETTOKEN123'; + + it('clones with the credentials but never puts them in the label', () => { + const source = parseAddSource(`https://${token}@git.example.com/team/skills.git`); + expect(source).toMatchObject({ kind: 'git', cloneUrl: `https://${token}@git.example.com/team/skills.git`, label: 'https://git.example.com/team/skills.git' }); + // github.com with credentials stays a plain git source, so the token is never dropped silently either. + const gh = parseAddSource(`https://user:${token}@github.com/acme/private.git`); + expect(gh.kind).toBe('git'); + expect(gh.label).not.toContain(token); + }); + + it('redacts userinfo anywhere in a text, also inside JSON', () => { + expect(redactCredentials(`fatal: could not read from https://user:${token}@host/x.git and http://${token}@h`)).toBe('fatal: could not read from https://***@host/x.git and http://***@h'); + const json = JSON.stringify({ hint: `https://a:${token}@h/x`, other: 'mail me@example.com' }); + expect(JSON.parse(redactCredentials(json))).toEqual({ hint: 'https://***@h/x', other: 'mail me@example.com' }); + expect(redactCredentials('https://github.com/acme/skills')).toBe('https://github.com/acme/skills'); + }); + + it('never echoes a token in a refusal', () => { + for (const input of [`http://${token}@host/x.git`, `https://${token}@host/x.git#main`, `ssh://${token}@host/x`]) { + expect(refusal(input).message).not.toContain(token); + } + }); + + it('redactError masks message, hint and details of any failure', () => { + const error = new CliError('network_error', `cannot clone https://u:${token}@h/x`, { hint: `retry https://${token}@h`, details: { url: `https://${token}@h/x` } }); + const out = redactError(error) as CliError; + expect(JSON.stringify({ m: out.message, e: out.extra })).not.toContain(token); + expect(out.code).toBe('network_error'); + expect((redactError(new Error(`boom https://${token}@h`)) as Error).message).toBe('boom https://***@h'); + }); +}); diff --git a/packages/cli/src/lib/add/add-source.ts b/packages/cli/src/lib/add/add-source.ts new file mode 100644 index 0000000..8fc28f9 --- /dev/null +++ b/packages/cli/src/lib/add/add-source.ts @@ -0,0 +1,129 @@ +import { isAbsolute, resolve } from 'node:path'; +import { usageError } from '../install/install-errors.js'; + +/** + * Sources `skillx add` accepts, parsed before anything touches the network or + * the disk: + * + * - `github:owner/repo[/path][@ref]`: a GitHub repository, optionally a + * directory in it and a branch or tag; + * - `owner/repo[/skill]`: GitHub shorthand, optionally one skill by name; + * - `https://github.com/owner/repo[/tree/[/]]` maps to `github:`; + * - any other `https://` git URL; every other transport (`http`, `ssh`, + * `git@host:`, `file://`, `ext::` and other `::` helpers) is refused, and so + * is anything that starts with `-`, so a source never becomes a git option; + * - a local directory: an absolute path, or one starting with `.`/`..`. + * + * Credentials in a URL may be used to clone but never leave this module in a + * label: `label` strips them, and `redactCredentials` masks them in any text. + */ + +export type AddSource = + | { kind: 'github'; owner: string; repo: string; path: string; ref?: string; skill?: string; cloneUrl: string; label: string } + | { kind: 'git'; cloneUrl: string; label: string } + | { kind: 'local'; dir: string; label: string }; + +const OWNER = '[A-Za-z0-9][A-Za-z0-9-]{0,38}'; +const REPO = '[A-Za-z0-9._-]{1,100}'; +const SHORTHAND = new RegExp(`^(${OWNER})/(${REPO})(?:/([A-Za-z0-9._-]{1,128}))?$`); +const GITHUB_REF = new RegExp(`^(${OWNER})/(${REPO})(/[^@]*)?(?:@(.+))?$`); +const GITHUB_URL = new RegExp(`^/(${OWNER})/(${REPO}?)(?:\\.git)?(?:/tree/([^/]+)((?:/[^/]+)*))?/?$`); +/** A branch or tag name git accepts and that cannot read as an option or a revision expression. */ +const REF = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)(?!.*\.lock$)(?!.*\/$)[A-Za-z0-9._/-]{1,200}$/; +const USAGE = 'owner/repo[/skill], github:owner/repo[/path][@ref], an https git URL or a local directory'; + +/** + * Masks the userinfo of every `scheme://user:secret@host` in `text`. Quotes + * end a match, so the result of redacting serialized JSON is still JSON. + */ +export function redactCredentials(text: string): string { + return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/?#@"'<>]*(?:@[^\s/?#@"'<>]*)*@/gi, '$1***@'); +} + +const refuse = (input: string, why: string) => usageError(`refusing source '${redactCredentials(input)}': ${why}`); + +function checkRepo(repo: string, input: string): void { + if (repo === '.' || repo === '..') throw refuse(input, 'the repository name is not a name'); +} + +/** A repository path: plain `/`-separated segments, never `.`, `..`, empty, `\` or `:`. */ +export function checkRepoPath(path: string, input: string): string { + const trimmed = path.replace(/^\/+|\/+$/g, ''); + if (trimmed === '') return ''; + for (const segment of trimmed.split('/')) { + if (segment === '' || segment === '.' || segment === '..' || /[\\:\0-\x1f]/.test(segment)) throw refuse(input, `unsafe path '${trimmed}'`); + } + return trimmed; +} + +function checkRef(ref: string, input: string): string { + if (!REF.test(ref)) throw refuse(input, `'${ref}' is not a branch or tag name`); + return ref; +} + +function github(input: string, owner: string, repo: string, path: string, ref: string | undefined, skill?: string): AddSource { + checkRepo(repo, input); + const label = `github:${owner}/${repo}${path ? `/${path}` : ''}${ref ? `@${ref}` : ''}`; + return { kind: 'github', owner, repo, path, ...(ref ? { ref } : {}), ...(skill ? { skill } : {}), cloneUrl: `https://github.com/${owner}/${repo}.git`, label }; +} + +function fromUrl(input: string): AddSource { + if (input.includes('::')) throw refuse(input, 'git transport helpers (ext::, fd:: and the like) are not accepted'); + if (/^[^/:]+@[^/:]+:/.test(input)) throw refuse(input, 'scp-style ssh sources are not accepted; use an https URL'); + let url: URL; + try { + url = new URL(input); + } catch { + throw refuse(input, 'not a valid URL'); + } + if (url.protocol !== 'https:') throw refuse(input, `only https git URLs are accepted, not ${url.protocol.replace(/:$/, '')}`); + if (url.hostname === '') throw refuse(input, 'the URL has no host'); + if (url.search !== '' || url.hash !== '') throw refuse(input, 'a git URL takes no query or fragment'); + const credentials = url.username !== '' || url.password !== ''; + const gh = url.hostname === 'github.com' && url.port === '' && !credentials ? GITHUB_URL.exec(url.pathname) : null; + if (gh) { + // Decoded first, so an encoded `..%2F..` is checked as the `../..` GitHub would see. + const decode = (part: string) => { + try { + return decodeURIComponent(part); + } catch { + throw refuse(input, 'the URL path is not valid percent-encoding'); + } + }; + return github(input, gh[1]!, gh[2]!, checkRepoPath(decode(gh[4] ?? ''), input), gh[3] ? checkRef(decode(gh[3]), input) : undefined); + } + const bare = new URL(url.href); + bare.username = ''; + bare.password = ''; + return { kind: 'git', cloneUrl: url.href, label: bare.href }; +} + +function isLocalPath(input: string): boolean { + return isAbsolute(input) || /^\.\.?(?:[/\\]|$)/.test(input) || /^[A-Za-z]:[/\\]/.test(input); +} + +/** Parses a source string; every refusal is a `usage_error` whose message never carries credentials. */ +export function parseAddSource(input: string, cwd: string = process.cwd()): AddSource { + const raw = input.trim(); + if (raw === '') throw usageError(`name a source: ${USAGE}`); + if (/[\0-\x1f\x7f]/.test(raw)) throw refuse(raw, 'it contains control characters'); + if (raw.startsWith('-')) throw refuse(raw, 'it reads as an option'); + if (raw.startsWith('@agentkit/')) throw refuse(raw, '@agentkit is reserved for AgentKit kit releases'); + if (raw.startsWith('@') || /^(?:kit|collection):/.test(raw)) throw refuse(raw, 'registry sources are installed with skillx install'); + if (isLocalPath(raw)) { + const dir = resolve(cwd, raw); + return { kind: 'local', dir, label: dir }; + } + if (raw.startsWith('github:')) { + const m = GITHUB_REF.exec(raw.slice('github:'.length)); + if (!m) throw refuse(raw, 'expected github:owner/repo[/path][@ref]'); + return github(raw, m[1]!, m[2]!, checkRepoPath(m[3] ?? '', raw), m[4] === undefined ? undefined : checkRef(m[4], raw)); + } + if (raw.includes('://') || raw.includes('::') || /^[^/:]+@[^/:]+:/.test(raw)) return fromUrl(raw); + const m = SHORTHAND.exec(raw); + if (m) return github(raw, m[1]!, m[2]!, '', undefined, m[3]); + throw usageError(`'${redactCredentials(raw)}' is not a source: ${USAGE}`); +} + +/** Where discovery starts inside the source: the `github:` path, else the root. */ +export const searchPathOf = (source: AddSource) => (source.kind === 'github' ? source.path : ''); diff --git a/packages/cli/src/lib/add/git-source.test.ts b/packages/cli/src/lib/add/git-source.test.ts new file mode 100644 index 0000000..7ce8692 --- /dev/null +++ b/packages/cli/src/lib/add/git-source.test.ts @@ -0,0 +1,98 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CliError } from '../output.js'; +import { cloneArgs, gitEnv, openGitSource } from './git-source.js'; +import { discoverSkills, prepareSkill } from './skill-discovery.js'; + +/** + * Clones a local repository through the `file` transport, which only tests + * may allow; the CLI always passes `https`. The repository holds a skill and + * a skill with a symlink entry that points out of the repository. + */ + +let root: string; +let repoUrl: string; + +function git(cwd: string, args: string[], input?: string): string { + return execFileSync('git', ['-c', 'core.autocrlf=false', '-c', 'commit.gpgsign=false', '-c', 'user.name=Test', '-c', 'user.email=test@example.com', ...args], { cwd, input, encoding: 'utf8' }).trim(); +} + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'skillx-add-git-')); + const repo = join(root, 'repo'); + const write = (rel: string, text: string) => { + mkdirSync(dirname(join(repo, rel)), { recursive: true }); + writeFileSync(join(repo, rel), text); + }; + mkdirSync(repo); + git(repo, ['init', '-q', '-b', 'main']); + write('skills/demo/SKILL.md', '---\nname: demo\ndescription: Demo skill\n---\n# Demo\n'); + write('skills/demo/references/guide.md', 'guide\n'); + write('skills/leaky/SKILL.md', '---\nname: leaky\ndescription: Leaks\n---\n'); + git(repo, ['add', '-A']); + // A symlink entry made without the filesystem, so the test runs where symlinks need privileges. + const oid = git(repo, ['hash-object', '-w', '--stdin'], '../../../../home/user/.ssh/id_rsa'); + git(repo, ['update-index', '--add', '--cacheinfo', `120000,${oid},skills/leaky/leak`]); + git(repo, ['commit', '-q', '-m', 'skills']); + repoUrl = pathToFileURL(repo).href; +}, 60_000); + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +function failure(run: () => unknown): CliError { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + return error as CliError; + } + throw new Error('expected a failure'); +} + +describe('git sources', () => { + it('clones with no prompt, no checkout and the URL after --', () => { + const args = cloneArgs('https://host/x.git', '/tmp/dest', 'main'); + expect(args.slice(0, 6)).toEqual(['-c', 'core.askPass=', '-c', 'protocol.allow=never', '-c', 'protocol.https.allow=always']); + expect(args).toEqual(expect.arrayContaining(['--depth', '1', '--no-checkout', '--branch', 'main'])); + expect(args.slice(-3)).toEqual(['--', 'https://host/x.git', '/tmp/dest']); + expect(gitEnv({})).toMatchObject({ GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '' }); + }); + + it('reads skills from git objects and refuses a skill holding a symlink entry', () => { + const source = openGitSource(repoUrl, 'test-repo', 'main', 'file'); + try { + expect(source.commit).toMatch(/^[0-9a-f]{40}$/); + const { skills } = discoverSkills(source, ''); + expect(skills.map((s) => s.name)).toEqual(['demo', 'leaky']); + expect(prepareSkill(source, skills[0]!).files.map((f) => f.path)).toEqual(['SKILL.md', 'references/guide.md']); + const refused = failure(() => prepareSkill(source, skills[1]!)); + expect(refused.code).toBe('incompatible'); + expect(refused.extra.details).toMatchObject({ code: 'tree.unsupported_entry', path: 'leak' }); + expect(discoverSkills(source, 'skills/demo').skills.map((s) => s.name)).toEqual(['demo']); + expect(failure(() => discoverSkills(source, 'skills/leaky/leak')).code).toBe('not_found'); + } finally { + source.dispose(); + } + }); + + it('lets git itself refuse every transport but https', () => { + const refused = failure(() => openGitSource(repoUrl, 'test-repo')); + expect(refused.code).toBe('usage_error'); + expect(refused.message).toContain('not allowed'); + }); + + it('reports a missing branch as not found', () => { + expect(failure(() => openGitSource(repoUrl, 'test-repo', 'no-such-branch', 'file')).code).toBe('not_found'); + }); + + it('never prints the credentials of a failing clone', () => { + const token = 'ghp_SECRETTOKEN123'; + const refused = failure(() => openGitSource(`https://user:${token}@127.0.0.1:9/x.git`, 'https://127.0.0.1:9/x.git')); + expect(refused.code).toBe('network_error'); + expect(JSON.stringify({ m: refused.message, e: refused.extra })).not.toContain(token); + }, 60_000); +}); diff --git a/packages/cli/src/lib/add/git-source.ts b/packages/cli/src/lib/add/git-source.ts new file mode 100644 index 0000000..bfcc96e --- /dev/null +++ b/packages/cli/src/lib/add/git-source.ts @@ -0,0 +1,137 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { InstallError } from '../install/install-errors.js'; +import { redactCredentials } from './add-source.js'; +import type { ListedEntry, SourceSnapshot } from './skill-discovery.js'; +import { isDiscoverable } from './skill-discovery.js'; + +/** + * A git source, cloned into a private temporary directory and read from git + * objects only. The clone is shallow, single-branch and has no checkout, so + * no file of the repository is ever written to disk, no checkout filter, LFS + * smudge or hook runs, and a symlink stays a `120000` tree entry that the + * tree rules refuse. git runs with argument arrays (no shell), `--` before the + * URL, every credential prompt off (`core.askPass=` plus an empty + * `GIT_ASKPASS`, `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`), and only + * the https transport allowed, so `ext::`, `file://` and `ssh` are refused by + * git itself too, also on a redirect. Every message is redacted. + */ + +const CLONE_TIMEOUT_MS = 120_000; +const MAX_OUTPUT = 1024 * 1024 * 1024; + +export function gitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...base, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_LFS_SKIP_SMUDGE: '1' }; +} + +/** The clone command line. `transport` is `https` outside tests. */ +export function cloneArgs(url: string, dest: string, ref?: string, transport = 'https'): string[] { + return [ + '-c', 'core.askPass=', + '-c', 'protocol.allow=never', + '-c', `protocol.${transport}.allow=always`, + 'clone', '--depth', '1', '--no-checkout', '--single-branch', '--no-tags', + ...(ref === undefined ? [] : ['--branch', ref]), + '--', url, dest, + ]; +} + +function git(args: string[], what: string, input?: string): Buffer { + const r = spawnSync('git', args, { env: gitEnv(), input, maxBuffer: MAX_OUTPUT, timeout: CLONE_TIMEOUT_MS, windowsHide: true }); + if (r.error) { + const code = (r.error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') throw new InstallError('unexpected_error', 'git is not installed or not on PATH'); + if (code === 'ETIMEDOUT') throw new InstallError('network_error', `${what} timed out after ${CLONE_TIMEOUT_MS / 1000}s`); + throw new InstallError('unexpected_error', `${what} failed: ${redactCredentials(r.error.message)}`); + } + if (r.status !== 0) { + const stderr = redactCredentials(r.stderr.toString('utf8')).trim(); + const line = stderr.split('\n').filter((l) => !/^\s*$/.test(l)).pop() ?? `exit ${r.status}`; + throw new InstallError(classify(stderr), `${what} failed: ${line}`); + } + return r.stdout; +} + +function classify(stderr: string): 'auth_error' | 'not_found' | 'usage_error' | 'network_error' { + if (/authentication failed|could not read (username|password)|terminal prompts disabled|returned error: 40[13]/i.test(stderr)) return 'auth_error'; + if (/repository .*not found|not found|remote branch .* not found|returned error: 404/i.test(stderr)) return 'not_found'; + if (/transport .* not allowed|unsupported protocol/i.test(stderr)) return 'usage_error'; + return 'network_error'; +} + +/** `ls-tree -r -l -z` records: every blob and submodule of the commit, with its mode and size. */ +function listTree(dir: string): Array { + const out = git(['-C', dir, 'ls-tree', '-r', '-l', '-z', '--full-tree', 'HEAD'], 'git ls-tree'); + const decoder = new TextDecoder('utf-8', { fatal: true }); + const entries: Array = []; + for (const record of out.toString('latin1').split('\0')) { + if (record === '') continue; + const tab = record.indexOf('\t'); + const m = /^(\d{6}) \w+ ([0-9a-f]{40,64}) +(\S+)$/.exec(record.slice(0, tab)); + if (tab < 0 || !m) throw new InstallError('unexpected_error', 'git ls-tree printed an unexpected record'); + let path: string; + try { + path = decoder.decode(Buffer.from(record.slice(tab + 1), 'latin1')); + } catch { + throw new InstallError('incompatible', 'a path in the repository is not valid UTF-8'); + } + const [mode, oid, size] = [m[1]!, m[2]!, m[3]!]; + const kind = mode === '120000' ? 'symlink' : mode === '160000' ? 'special' : 'regular'; + entries.push({ path, kind, mode: mode === '100664' ? '100644' : mode, size: size === '-' ? 0 : Number(size), oid }); + } + return entries; +} + +/** Splits `git cat-file --batch` output into blob contents, in request order. */ +function readBlobs(dir: string, oids: readonly string[]): Uint8Array[] { + if (oids.length === 0) return []; + const out = git(['-C', dir, 'cat-file', '--batch'], 'git cat-file', `${oids.join('\n')}\n`); + const blobs: Uint8Array[] = []; + let at = 0; + for (const oid of oids) { + const newline = out.indexOf(0x0a, at); + const [id, type, size] = out.subarray(at, newline).toString('utf8').split(' '); + if (id !== oid || type !== 'blob' || size === undefined) throw new InstallError('unexpected_error', `git returned an unexpected object for ${oid}`); + const start = newline + 1; + blobs.push(new Uint8Array(out.subarray(start, start + Number(size)))); + at = start + Number(size) + 1; + } + return blobs; +} + +/** Clones `url` and returns a snapshot of its HEAD; the clone is removed by `dispose`. */ +export function openGitSource(url: string, label: string, ref?: string, transport = 'https'): SourceSnapshot { + const dir = mkdtempSync(join(tmpdir(), 'skillx-add-')); + const dispose = () => rmSync(dir, { recursive: true, force: true }); + try { + git(cloneArgs(url, join(dir, 'repo'), ref, transport), `cloning ${label}`); + const repo = join(dir, 'repo'); + let commit: string; + try { + commit = git(['-C', repo, 'rev-parse', '--verify', 'HEAD^{commit}'], 'git rev-parse').toString('utf8').trim(); + } catch { + throw new InstallError('not_found', `${label} has no commit to read`); + } + const entries = listTree(repo); + const byPath = new Map(entries.map((e) => [e.path, e])); + const under = (d: string) => (p: string) => d === '' || p.startsWith(`${d}/`); + return { + label, + commit, + skillFiles: (searchPath) => + entries.filter((e) => e.kind === 'regular' && under(searchPath)(e.path) && (e.path === 'SKILL.md' || e.path.endsWith('/SKILL.md')) && isDiscoverable(e.path, searchPath)).map((e) => e.path), + hasDir: (path) => entries.some((e) => e.path.startsWith(`${path}/`)), + entriesUnder: (d) => entries.filter((e) => under(d)(e.path)).map(({ path, kind, mode, size }) => ({ path: d === '' ? path : path.slice(d.length + 1), kind, mode, size })), + read: (paths) => { + const blobs = readBlobs(repo, paths.map((p) => byPath.get(p)!.oid)); + return new Map(paths.map((p, i) => [p, blobs[i]!])); + }, + dispose, + }; + } catch (error) { + dispose(); + throw error; + } +} diff --git a/packages/cli/src/lib/add/local-source.ts b/packages/cli/src/lib/add/local-source.ts new file mode 100644 index 0000000..e29ae72 --- /dev/null +++ b/packages/cli/src/lib/add/local-source.ts @@ -0,0 +1,90 @@ +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import type { Stats } from 'node:fs'; +import { join } from 'node:path'; +import { InstallError, usageError } from '../install/install-errors.js'; +import { containedPath, linkFreeParents } from '../install/safe-fs.js'; +import { LIMITS } from '@skillx/skill-tree'; +import type { EntryKind } from '@skillx/skill-tree'; +import type { ListedEntry, SourceSnapshot } from './skill-discovery.js'; +import { isDiscoverable } from './skill-discovery.js'; + +/** + * A local directory source. Everything is listed with `lstat` and links are + * never followed: a symlink or junction is listed as `symlink` (and refused + * by the tree rules if it sits inside a chosen skill), a regular file with + * more than one hard link as `hardlink` (refused too), anything else that is + * not a file or directory as `special`. A file is read only when it is still a + * regular, single-link file reached through real directories, so a swap after + * the listing is refused, not followed. + */ + +/** Discovery stops listing past this many entries: point at a smaller directory. */ +const MAX_DISCOVERY_ENTRIES = 100_000; + +function kindOf(stat: Stats): EntryKind { + if (stat.isSymbolicLink()) return 'symlink'; + if (stat.isDirectory()) return 'directory'; + if (!stat.isFile()) return 'special'; + return stat.nlink > 1 ? 'hardlink' : 'regular'; +} + +const modeOf = (stat: Stats) => ((stat.mode & 0o111) !== 0 ? '100755' : '100644'); + +/** Lists `root/rel` recursively; `enter` decides which directories to walk into. */ +function walk(root: string, rel: string, enter: (rel: string) => boolean, limit: number, tooMany: () => Error): ListedEntry[] { + const out: ListedEntry[] = []; + const visit = (dirRel: string) => { + const abs = dirRel === '' ? root : containedPath(root, dirRel); + for (const name of readdirSync(abs).sort()) { + const path = dirRel === '' ? name : `${dirRel}/${name}`; + const stat = lstatSync(join(abs, name)); + const kind = kindOf(stat); + if (kind === 'directory') { + if (enter(path)) visit(path); + continue; + } + out.push({ path, kind, mode: modeOf(stat), size: stat.size }); + if (out.length > limit) throw tooMany(); + } + }; + visit(rel); + return out; +} + +export function openLocalSource(dir: string): SourceSnapshot { + let root: string; + try { + root = realpathSync(dir); + if (!lstatSync(root).isDirectory()) throw new Error('not a directory'); + } catch { + throw usageError(`${dir} is not a directory`); + } + const strip = (d: string) => (e: ListedEntry) => ({ ...e, path: d === '' ? e.path : e.path.slice(d.length + 1) }); + return { + label: dir, + skillFiles: (searchPath) => + walk(root, searchPath, (rel) => isDiscoverable(`${rel}/SKILL.md`, searchPath), MAX_DISCOVERY_ENTRIES, () => + usageError(`more than ${MAX_DISCOVERY_ENTRIES} files under ${dir}; point at the skill directory or a smaller tree`), + ) + .filter((e) => e.kind === 'regular' && (e.path === 'SKILL.md' || e.path.endsWith('/SKILL.md'))) + .map((e) => e.path), + hasDir: (path) => linkFreeParents(root, `${path}/x`), + entriesUnder: (d) => + walk(root, d, () => true, LIMITS.maxEntries, () => + new InstallError('incompatible', `${d || dir} has more than ${LIMITS.maxEntries} entries`, { code: 'tree.limit_exceeded', dir: d }), + ).map(strip(d)), + read: (paths) => { + const out = new Map(); + for (const path of paths) { + const abs = containedPath(root, path); + const stat = lstatSync(abs); + if (!linkFreeParents(root, path) || kindOf(stat) !== 'regular') { + throw new InstallError('incompatible', `${path} changed into a link or special file while it was read`, { path }); + } + out.set(path, new Uint8Array(readFileSync(abs))); + } + return out; + }, + dispose: () => {}, + }; +} diff --git a/packages/cli/src/lib/add/skill-discovery.ts b/packages/cli/src/lib/add/skill-discovery.ts new file mode 100644 index 0000000..4bd25fa --- /dev/null +++ b/packages/cli/src/lib/add/skill-discovery.ts @@ -0,0 +1,127 @@ +import { LIMITS, buildTree, isNoisePath, isSkillTreeError } from '@skillx/skill-tree'; +import type { EntryKind, TreeEntry } from '@skillx/skill-tree'; +import type { VerifiedTree } from '../install/fetch-verify.js'; +import { sha256Hex } from '../install/fetch-verify.js'; +import { InstallError } from '../install/install-errors.js'; +import { readFrontmatter } from './skill-frontmatter.js'; + +/** + * Finds skills in a source and turns a chosen one into a checked tree. + * + * A source is a snapshot: a listing of entries (from git objects or an + * `lstat` walk, links never followed) and a reader for regular files. A skill + * is a directory holding a regular `SKILL.md`; when the search path itself is + * one, it is the only skill. Otherwise every such directory up to + * `MAX_DEPTH` below it counts, outside noise directories and `node_modules`, + * and a skill's own subdirectories are its content, not more skills. + * + * `prepareSkill` builds the tree with the registry's `agentkit.skill-tree/v1` + * rules: a symlink, junction, hardlink, submodule or other special entry, + * an unsafe or colliding path, or a tree over the limits refuses the skill + * (`incompatible`); nothing is followed or copied from outside it. + */ + +export interface ListedEntry { + /** POSIX path relative to the directory it was listed under. */ + path: string; + kind: EntryKind; + mode: string; + size: number; +} + +export interface SourceSnapshot { + /** Display name, never carrying credentials. */ + label: string; + /** Commit read, for a git source. */ + commit?: string; + /** Regular `SKILL.md` files under `searchPath` that discovery may consider, relative to the source root. */ + skillFiles(searchPath: string): string[]; + /** True when `path` names a directory of the source. */ + hasDir(path: string): boolean; + /** Every entry under directory `dir` ('' for the root), relative to it. */ + entriesUnder(dir: string): ListedEntry[]; + /** Bytes of regular files, by path relative to the source root. */ + read(paths: readonly string[]): Map; + dispose(): void; +} + +export interface FoundSkill { + name: string; + description: string; + /** Directory relative to the source root ('' for the root). */ + dir: string; +} + +export interface SkippedSkill { + dir: string; + reason: string; +} + +export const MAX_DEPTH = 6; +const SKIPPED_DIRS = new Set(['node_modules']); + +/** True when discovery may look at this `SKILL.md` path (depth, noise and skipped directories). */ +export function isDiscoverable(path: string, searchPath: string): boolean { + const rel = searchPath === '' ? path : path.slice(searchPath.length + 1); + const segments = rel.split('/'); + return segments.length - 1 <= MAX_DEPTH && !isNoisePath(rel) && !segments.slice(0, -1).some((s) => SKIPPED_DIRS.has(s)); +} + +const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''); +const join = (dir: string, name: string) => (dir === '' ? name : `${dir}/${name}`); + +export function discoverSkills(source: SourceSnapshot, searchPath: string): { skills: FoundSkill[]; skipped: SkippedSkill[] } { + if (searchPath !== '' && !source.hasDir(searchPath)) { + throw new InstallError('not_found', `${searchPath} is not a directory in ${source.label}`, { path: searchPath }); + } + const files = source.skillFiles(searchPath); + const root = join(searchPath, 'SKILL.md'); + const dirs = files.includes(root) ? [searchPath] : files.map(parentOf).sort(); + // A skill's subdirectories are its content: drop any skill nested inside another one. + const outer = dirs.filter((d) => !dirs.some((o) => o !== d && (o === '' || d.startsWith(`${o}/`)))); + const bytes = source.read(outer.map((d) => join(d, 'SKILL.md'))); + const skills: FoundSkill[] = []; + const skipped: SkippedSkill[] = []; + for (const dir of outer) { + const fm = readFrontmatter(bytes.get(join(dir, 'SKILL.md'))!); + if (fm.ok) skills.push({ name: fm.name, description: fm.description, dir }); + else skipped.push({ dir, reason: fm.reason }); + } + return { skills, skipped }; +} + +const refused = (skill: FoundSkill, detail: string, extra: Record = {}) => + new InstallError('incompatible', `cannot install ${skill.name} from ${skill.dir || 'the source root'}: ${detail}`, { skill: skill.name, dir: skill.dir, ...extra }); + +/** Checks the limits from the listing before any byte is read. */ +function checkListedLimits(skill: FoundSkill, entries: readonly ListedEntry[]): void { + if (entries.length > LIMITS.maxEntries) throw refused(skill, `more than ${LIMITS.maxEntries} entries`, { code: 'tree.limit_exceeded' }); + const files = entries.filter((e) => e.kind === 'regular'); + if (files.length > LIMITS.maxFiles) throw refused(skill, `more than ${LIMITS.maxFiles} files`, { code: 'tree.limit_exceeded' }); + const big = files.find((e) => e.size > LIMITS.maxFileSize); + if (big) throw refused(skill, `${big.path} is larger than ${LIMITS.maxFileSize} bytes`, { code: 'tree.limit_exceeded', path: big.path }); + if (files.reduce((sum, e) => sum + e.size, 0) > LIMITS.maxTreeSize) throw refused(skill, `more than ${LIMITS.maxTreeSize} bytes`, { code: 'tree.limit_exceeded' }); +} + +/** The skill's files as a checked tree, ready for the installer. */ +export function prepareSkill(source: SourceSnapshot, skill: FoundSkill): VerifiedTree { + const listed = source.entriesUnder(skill.dir); + checkListedLimits(skill, listed); + const regular = listed.filter((e) => e.kind === 'regular' && !isNoisePath(e.path)); + const bytes = source.read(regular.map((e) => join(skill.dir, e.path))); + const byPath = new Map(regular.map((e) => [e.path, bytes.get(join(skill.dir, e.path))!])); + const entries: TreeEntry[] = listed.map((e) => { + const data = byPath.get(e.path); + return { path: e.path, kind: e.kind, mode: e.mode, size: data?.length ?? e.size, sha256: data ? sha256Hex(data) : '0'.repeat(64) }; + }); + try { + const manifest = buildTree(entries); + return { + digest: manifest.digest, + files: manifest.files.map((f) => ({ path: f.path, mode: f.mode as '100644' | '100755', sha256: f.sha256, size: f.size, bytes: byPath.get(f.path)! })), + }; + } catch (error) { + if (isSkillTreeError(error)) throw refused(skill, error.message, { code: error.code, path: error.path }); + throw error; + } +} diff --git a/packages/cli/src/lib/add/skill-frontmatter.ts b/packages/cli/src/lib/add/skill-frontmatter.ts new file mode 100644 index 0000000..5b08268 --- /dev/null +++ b/packages/cli/src/lib/add/skill-frontmatter.ts @@ -0,0 +1,56 @@ +import { isInstallAlias } from '@skillx/contracts'; +import { parseDocument } from 'yaml'; + +/** + * SKILL.md front matter, read as data only. The block is the text between a + * first line `---` and the next `---` line (a UTF-8 BOM and CRLF are + * tolerated), parsed by `yaml` with the YAML 1.2 core schema. No engine is + * ever picked from the fence: `---js`, `---javascript` or `---coffee` is not + * a fence, so such a file has no front matter and is skipped, never run. A + * tag, a duplicate key or any parse problem rejects the block. + * + * The skill's `name` becomes its installed directory, so it must already be a + * valid install alias; it is never rewritten, so two names never merge. Names + * starting with `ak-` or `ak:` belong to AgentKit and are refused. + */ + +export type Frontmatter = { ok: true; name: string; description: string } | { ok: false; reason: string }; + +const isFence = (line: string | undefined) => line !== undefined && line.replace(/[ \t]+$/, '') === '---'; +const fail = (reason: string): Frontmatter => ({ ok: false, reason }); + +/** True for names AgentKit reserves for its kit skills, in any case. */ +export const isReservedSkillName = (name: string) => /^ak[-:]/i.test(name); + +export function readFrontmatter(bytes: Uint8Array): Frontmatter { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return fail('SKILL.md is not valid UTF-8'); + } + const lines = text.replace(/^/, '').replace(/\r\n/g, '\n').split('\n'); + if (!isFence(lines[0])) return fail('SKILL.md has no front matter (the first line must be ---)'); + const end = lines.findIndex((line, i) => i > 0 && isFence(line)); + if (end < 0) return fail('SKILL.md front matter is not closed by a --- line'); + const doc = parseDocument(lines.slice(1, end).join('\n'), { version: '1.2', schema: 'core', uniqueKeys: true }); + if (doc.errors.length > 0 || doc.warnings.length > 0) return fail('SKILL.md front matter is not plain YAML'); + if (hasTags(doc.contents)) return fail('SKILL.md front matter uses YAML tags'); + const data: unknown = doc.toJS({ maxAliasCount: 10 }); + if (data === null || typeof data !== 'object' || Array.isArray(data)) return fail('SKILL.md front matter is not a mapping'); + const { name, description } = data as Record; + if (typeof name !== 'string' || name.trim() === '') return fail('SKILL.md front matter needs a non-empty name'); + if (typeof description !== 'string' || description.trim() === '') return fail('SKILL.md front matter needs a non-empty description'); + if (isReservedSkillName(name)) return fail(`'${name}' is reserved: ak- and ak: names belong to AgentKit`); + if (!isInstallAlias(name)) return fail(`'${name}' is not a valid skill directory name (lowercase letters, digits, '.', '_' and '-')`); + return { ok: true, name, description: description.trim() }; +} + +/** True when any node of the document carries an explicit tag. */ +function hasTags(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const n = node as { tag?: string; items?: unknown[]; key?: unknown; value?: unknown }; + if (typeof n.tag === 'string' && n.tag !== '') return true; + if (Array.isArray(n.items)) return n.items.some(hasTags); + return hasTags(n.key) || hasTags(n.value); +} diff --git a/packages/cli/src/lib/add/skill-safety.test.ts b/packages/cli/src/lib/add/skill-safety.test.ts new file mode 100644 index 0000000..3d8cddf --- /dev/null +++ b/packages/cli/src/lib/add/skill-safety.test.ts @@ -0,0 +1,157 @@ +import { linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CliError } from '../output.js'; +import { openLocalSource } from './local-source.js'; +import { discoverSkills, prepareSkill } from './skill-discovery.js'; +import { readFrontmatter } from './skill-frontmatter.js'; + +const enc = (text: string) => new TextEncoder().encode(text); +const skillMd = (name: string, extra = '') => `---\nname: ${name}\ndescription: ${name} does things\n${extra}---\n# ${name}\n`; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'skillx-add-safety-')); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function write(rel: string, text: string): string { + const path = join(root, ...rel.split('/')); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); + return path; +} + +const fileLinksWork = (() => { + const dir = mkdtempSync(join(tmpdir(), 'skillx-probe-')); + try { + writeFileSync(join(dir, 'a'), ''); + symlinkSync(join(dir, 'a'), join(dir, 'b'), 'file'); + return true; + } catch { + return false; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +})(); + +function refusedWith(run: () => unknown): CliError { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + return error as CliError; + } + throw new Error('expected a refusal'); +} + +describe('front matter is data, never code', () => { + it.each(['---js', '---javascript', '---coffee'])('treats a %s fence as no front matter and runs nothing', (fence) => { + const marker = '__skillx_frontmatter_ran__'; + const text = `${fence}\n(globalThis['${marker}'] = true, { name: 'evil', description: 'x' })\n---\n# body\n`; + const result = readFrontmatter(enc(text)); + expect(result).toEqual({ ok: false, reason: expect.stringContaining('no front matter') }); + expect((globalThis as Record)[marker]).toBeUndefined(); + }); + + it('refuses YAML tags, duplicate keys and non-mappings', () => { + expect(readFrontmatter(enc(skillMd('demo', 'x: !!js/function "function () {}"\n'))).ok).toBe(false); + expect(readFrontmatter(enc('---\nname: !!str demo\ndescription: d\n---\n')).ok).toBe(false); + expect(readFrontmatter(enc('---\nname: a\nname: b\ndescription: d\n---\n')).ok).toBe(false); + expect(readFrontmatter(enc('---\n- a\n---\n')).ok).toBe(false); + }); + + it('accepts plain front matter with a BOM and CRLF', () => { + expect(readFrontmatter(enc('---\r\nname: demo\r\ndescription: Does a thing\r\n---\r\n'))).toEqual({ ok: true, name: 'demo', description: 'Does a thing' }); + }); + + it.each(['ak-plan', 'AK-plan', 'ak:plan'])('refuses the AgentKit-reserved name %s', (name) => { + expect(readFrontmatter(enc(skillMd(name)))).toEqual({ ok: false, reason: expect.stringContaining('reserved') }); + }); + + it.each(['Foo Bar', 'con', 'trailing.', '../up', 'a/b'])('refuses %s as a directory name instead of rewriting it', (name) => { + expect(readFrontmatter(enc(skillMd(JSON.stringify(name)))).ok).toBe(false); + }); +}); + +describe('discovery', () => { + it('takes the search root alone when it is a skill', () => { + write('SKILL.md', skillMd('root-skill')); + write('skills/inner/SKILL.md', skillMd('inner')); + const { skills } = discoverSkills(openLocalSource(root), ''); + expect(skills.map((s) => s.name)).toEqual(['root-skill']); + }); + + it('finds nested skills, skips node_modules and noise, and reports refused ones', () => { + write('skills/a/SKILL.md', skillMd('a')); + write('skills/a/examples/sub/SKILL.md', skillMd('a-example')); + write('skills/b/SKILL.md', skillMd('ak-b')); + write('node_modules/pkg/SKILL.md', skillMd('vendored')); + write('.git/x/SKILL.md', skillMd('git-internal')); + const { skills, skipped } = discoverSkills(openLocalSource(root), ''); + expect(skills.map((s) => [s.name, s.dir])).toEqual([['a', 'skills/a']]); + expect(skipped).toEqual([{ dir: 'skills/b', reason: expect.stringContaining('reserved') }]); + }); + + it('never enters a linked directory', () => { + const outside = mkdtempSync(join(tmpdir(), 'skillx-outside-')); + try { + write('skills/real/SKILL.md', skillMd('real')); + mkdirSync(join(outside, 'secret'), { recursive: true }); + writeFileSync(join(outside, 'secret', 'SKILL.md'), skillMd('secret')); + symlinkSync(join(outside, 'secret'), join(root, 'skills', 'linked'), 'junction'); + const { skills } = discoverSkills(openLocalSource(root), ''); + expect(skills.map((s) => s.name)).toEqual(['real']); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); +}); + +describe('a chosen skill is refused, never copied, when it holds links', () => { + const found = { name: 'leaky', description: 'd', dir: 'leaky' }; + + it('refuses a junction inside the skill', () => { + write('leaky/SKILL.md', skillMd('leaky')); + mkdirSync(join(root, 'secret')); + writeFileSync(join(root, 'secret', 'id_rsa'), 'PRIVATE'); + symlinkSync(join(root, 'secret'), join(root, 'leaky', 'dirleak'), 'junction'); + const error = refusedWith(() => prepareSkill(openLocalSource(root), found)); + expect(error.code).toBe('incompatible'); + expect(error.extra.details).toMatchObject({ code: 'tree.unsupported_entry', path: 'dirleak' }); + }); + + it.skipIf(!fileLinksWork)('refuses a file symlink inside the skill', () => { + write('leaky/SKILL.md', skillMd('leaky')); + const secret = write('secret.txt', 'PRIVATE'); + symlinkSync(secret, join(root, 'leaky', 'leak'), 'file'); + expect(refusedWith(() => prepareSkill(openLocalSource(root), found)).extra.details).toMatchObject({ code: 'tree.unsupported_entry', path: 'leak' }); + }); + + it('refuses a hardlink inside the skill', () => { + write('leaky/SKILL.md', skillMd('leaky')); + const secret = write('secret.txt', 'PRIVATE'); + linkSync(secret, join(root, 'leaky', 'hard')); + expect(refusedWith(() => prepareSkill(openLocalSource(root), found)).extra.details).toMatchObject({ code: 'tree.unsupported_entry', path: 'hard' }); + }); + + it('builds a tree of regular files only, leaving out noise', () => { + write('leaky/SKILL.md', skillMd('leaky')); + write('leaky/references/guide.md', 'guide\n'); + write('leaky/.git/config', '[remote "origin"]\n\turl = https://token@host/x\n'); + const tree = prepareSkill(openLocalSource(root), found); + expect(tree.files.map((f) => f.path)).toEqual(['SKILL.md', 'references/guide.md']); + expect(tree.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it('refuses a missing search path as not found', () => { + write('a/SKILL.md', skillMd('a')); + expect(refusedWith(() => discoverSkills(openLocalSource(root), 'nope')).code).toBe('not_found'); + }); + + it('refuses a local source that is not a directory', () => { + const file = write('file.txt', 'x'); + expect(refusedWith(() => openLocalSource(file)).code).toBe('usage_error'); + }); +}); diff --git a/packages/cli/src/lib/agent-registry.ts b/packages/cli/src/lib/agent-registry.ts deleted file mode 100644 index 8bea309..0000000 --- a/packages/cli/src/lib/agent-registry.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync } from 'fs'; -import type { AgentConfig, AgentType } from './add-types.js'; - -const home = homedir(); -const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude'); - -// Canonical directory for universal agents (.agents/skills) -const AGENTS_DIR = '.agents'; -const SKILLS_SUBDIR = 'skills'; - -/** All supported agent configurations */ -export const agents: Record = { - amp: { - name: 'amp', - displayName: 'Amp', - skillsDir: '.agents/skills', - globalSkillsDir: join(home, '.config/agents/skills'), - detectInstalled: async () => existsSync(join(home, '.config/amp')), - }, - augment: { - name: 'augment', - displayName: 'Augment', - skillsDir: '.augment/skills', - globalSkillsDir: join(home, '.augment/skills'), - detectInstalled: async () => existsSync(join(home, '.augment')), - }, - 'claude-code': { - name: 'claude-code', - displayName: 'Claude Code', - skillsDir: '.claude/skills', - globalSkillsDir: join(claudeHome, 'skills'), - detectInstalled: async () => existsSync(claudeHome), - }, - cline: { - name: 'cline', - displayName: 'Cline', - skillsDir: '.agents/skills', - globalSkillsDir: join(home, '.agents', 'skills'), - detectInstalled: async () => existsSync(join(home, '.cline')), - }, - codebuddy: { - name: 'codebuddy', - displayName: 'CodeBuddy', - skillsDir: '.codebuddy/skills', - globalSkillsDir: join(home, '.codebuddy/skills'), - detectInstalled: async () => existsSync(join(home, '.codebuddy')), - }, - codex: { - name: 'codex', - displayName: 'Codex', - skillsDir: '.agents/skills', - globalSkillsDir: join(home, '.codex/skills'), - detectInstalled: async () => existsSync(join(home, '.codex')), - }, - 'command-code': { - name: 'command-code', - displayName: 'Command Code', - skillsDir: '.commandcode/skills', - globalSkillsDir: join(home, '.commandcode/skills'), - detectInstalled: async () => existsSync(join(home, '.commandcode')), - }, - continue: { - name: 'continue', - displayName: 'Continue', - skillsDir: '.continue/skills', - globalSkillsDir: join(home, '.continue/skills'), - detectInstalled: async () => existsSync(join(home, '.continue')), - }, - cursor: { - name: 'cursor', - displayName: 'Cursor', - skillsDir: '.cursor/skills', - globalSkillsDir: join(home, '.cursor/skills'), - detectInstalled: async () => existsSync(join(home, '.cursor')), - }, - 'gemini-cli': { - name: 'gemini-cli', - displayName: 'Gemini CLI', - skillsDir: '.gemini/skills', - globalSkillsDir: join(home, '.gemini/skills'), - detectInstalled: async () => existsSync(join(home, '.gemini')), - }, - 'github-copilot': { - name: 'github-copilot', - displayName: 'GitHub Copilot', - skillsDir: '.github/skills', - globalSkillsDir: undefined, - detectInstalled: async () => existsSync(join(process.cwd(), '.github')), - }, - goose: { - name: 'goose', - displayName: 'Goose', - skillsDir: '.goose/skills', - globalSkillsDir: join(home, '.config/goose/skills'), - detectInstalled: async () => existsSync(join(home, '.config/goose')), - }, - junie: { - name: 'junie', - displayName: 'Junie', - skillsDir: '.junie/skills', - globalSkillsDir: join(home, '.junie/skills'), - detectInstalled: async () => existsSync(join(home, '.junie')), - }, - kilo: { - name: 'kilo', - displayName: 'Kilo', - skillsDir: '.kilocode/skills', - globalSkillsDir: join(home, '.kilocode/skills'), - detectInstalled: async () => existsSync(join(home, '.kilocode')), - }, - 'kiro-cli': { - name: 'kiro-cli', - displayName: 'Kiro', - skillsDir: '.kiro/skills', - globalSkillsDir: join(home, '.kiro/skills'), - detectInstalled: async () => existsSync(join(home, '.kiro')), - }, - kode: { - name: 'kode', - displayName: 'Kode', - skillsDir: '.agents/skills', - globalSkillsDir: join(home, '.agents/skills'), - detectInstalled: async () => existsSync(join(home, '.kode')), - }, - mux: { - name: 'mux', - displayName: 'Mux', - skillsDir: '.mux/skills', - globalSkillsDir: join(home, '.mux/skills'), - detectInstalled: async () => existsSync(join(home, '.mux')), - }, - neovate: { - name: 'neovate', - displayName: 'Neovate', - skillsDir: '.neovate/skills', - globalSkillsDir: join(home, '.neovate/skills'), - detectInstalled: async () => existsSync(join(home, '.neovate')), - }, - opencode: { - name: 'opencode', - displayName: 'OpenCode', - skillsDir: '.agents/skills', - globalSkillsDir: join(home, '.config/agents/skills'), - detectInstalled: async () => existsSync(join(home, '.config/opencode')), - }, - openhands: { - name: 'openhands', - displayName: 'OpenHands', - skillsDir: '.openhands/skills', - globalSkillsDir: join(home, '.openhands/skills'), - detectInstalled: async () => existsSync(join(home, '.openhands')), - }, - roo: { - name: 'roo', - displayName: 'Roo', - skillsDir: '.roo/skills', - globalSkillsDir: join(home, '.roo/skills'), - detectInstalled: async () => existsSync(join(home, '.roo')), - }, - trae: { - name: 'trae', - displayName: 'Trae', - skillsDir: '.trae/skills', - globalSkillsDir: join(home, '.trae/skills'), - detectInstalled: async () => existsSync(join(home, '.trae')), - }, - windsurf: { - name: 'windsurf', - displayName: 'Windsurf', - skillsDir: '.windsurf/skills', - globalSkillsDir: join(home, '.windsurf/skills'), - detectInstalled: async () => existsSync(join(home, '.windsurf')), - }, - zencoder: { - name: 'zencoder', - displayName: 'Zencoder', - skillsDir: '.zencoder/skills', - globalSkillsDir: join(home, '.zencoder/skills'), - detectInstalled: async () => existsSync(join(home, '.zencoder')), - }, - universal: { - name: 'universal', - displayName: 'Universal (.agents/skills)', - skillsDir: '.agents/skills', - globalSkillsDir: join(home, '.agents/skills'), - detectInstalled: async () => true, - }, -}; - -// Agents that share the canonical .agents/skills directory -const UNIVERSAL_AGENTS: AgentType[] = ['universal']; - -export function isUniversalAgent(agent: AgentType): boolean { - return UNIVERSAL_AGENTS.includes(agent); -} - -export function getUniversalAgents(): AgentType[] { - return UNIVERSAL_AGENTS; -} - -export function getNonUniversalAgents(): AgentType[] { - return (Object.keys(agents) as AgentType[]).filter((a) => !isUniversalAgent(a)); -} - -/** Detect which agents are installed on the system */ -export async function detectInstalledAgents(): Promise { - const results = await Promise.all( - (Object.keys(agents) as AgentType[]).map(async (key) => ({ - key, - installed: await agents[key].detectInstalled(), - })) - ); - return results.filter((r) => r.installed).map((r) => r.key); -} - -/** Get canonical skills directory (.agents/skills) */ -export function getCanonicalSkillsDir(global: boolean, cwd?: string): string { - const baseDir = global ? homedir() : cwd || process.cwd(); - return join(baseDir, AGENTS_DIR, SKILLS_SUBDIR); -} - -/** Get agent-specific base directory for skills */ -export function getAgentBaseDir(agentType: AgentType, global: boolean, cwd?: string): string { - if (isUniversalAgent(agentType)) { - return getCanonicalSkillsDir(global, cwd); - } - - const agent = agents[agentType]; - const baseDir = global ? homedir() : cwd || process.cwd(); - - if (global) { - return agent.globalSkillsDir ?? join(baseDir, agent.skillsDir); - } - return join(baseDir, agent.skillsDir); -} diff --git a/packages/cli/src/lib/git-clone.ts b/packages/cli/src/lib/git-clone.ts deleted file mode 100644 index 45a834c..0000000 --- a/packages/cli/src/lib/git-clone.ts +++ /dev/null @@ -1,73 +0,0 @@ -import simpleGit from 'simple-git'; -import { join, normalize, resolve, sep } from 'path'; -import { mkdtemp, rm } from 'fs/promises'; -import { tmpdir } from 'os'; - -const CLONE_TIMEOUT_MS = 60000; - -export class GitCloneError extends Error { - readonly url: string; - readonly isTimeout: boolean; - readonly isAuthError: boolean; - - constructor(message: string, url: string, isTimeout = false, isAuthError = false) { - super(message); - this.name = 'GitCloneError'; - this.url = url; - this.isTimeout = isTimeout; - this.isAuthError = isAuthError; - } -} - -/** Shallow clone a git repository to a temp directory */ -export async function cloneRepo(url: string, ref?: string): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'skillx-')); - const git = simpleGit({ - timeout: { block: CLONE_TIMEOUT_MS }, - // Disable interactive credential prompts - config: ['core.askPass=', 'credential.helper='], - }); - const cloneOptions = ref ? ['--depth', '1', '--branch', ref] : ['--depth', '1']; - - try { - await git.clone(url, tempDir, cloneOptions); - return tempDir; - } catch (error) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}); - - const msg = error instanceof Error ? error.message : String(error); - const isTimeout = msg.includes('block timeout') || msg.includes('timed out'); - const isAuth = - msg.includes('Authentication failed') || - msg.includes('could not read Username') || - msg.includes('Permission denied') || - msg.includes('Repository not found'); - - if (isTimeout) { - throw new GitCloneError( - 'Clone timed out after 60s. Ensure you have access and credentials configured.\n' + - ' SSH: ssh-add -l | HTTPS: gh auth status', - url, true, false - ); - } - if (isAuth) { - throw new GitCloneError( - `Authentication failed for ${url}.\n` + - ' SSH: ssh -T git@github.com | HTTPS: gh auth login', - url, false, true - ); - } - throw new GitCloneError(`Failed to clone ${url}: ${msg}`, url); - } -} - -/** Safe cleanup — validates path is within tmpdir before deleting */ -export async function cleanupTempDir(dir: string): Promise { - const normalizedDir = normalize(resolve(dir)); - const normalizedTmp = normalize(resolve(tmpdir())); - - if (!normalizedDir.startsWith(normalizedTmp + sep) && normalizedDir !== normalizedTmp) { - throw new Error('Attempted to clean up directory outside of temp directory'); - } - await rm(dir, { recursive: true, force: true }); -} diff --git a/packages/cli/src/lib/install/install-engine.ts b/packages/cli/src/lib/install/install-engine.ts index 0c7dad7..c5a5f42 100644 --- a/packages/cli/src/lib/install/install-engine.ts +++ b/packages/cli/src/lib/install/install-engine.ts @@ -5,7 +5,7 @@ import type { Recovery } from './apply-transaction.js'; import { fetchVerified } from './fetch-verify.js'; import type { VerifiedTree } from './fetch-verify.js'; import type { InstallContext } from './install-context.js'; -import { conflictError } from './install-errors.js'; +import { conflictError, integrityError } from './install-errors.js'; import { RECEIPT_SCHEMA, listReceipts, readOwners, readReceipt, writeOwners, writeReceipt } from './install-state.js'; import type { InstallReceipt } from './install-state.js'; import { packagesToFetch, planInstall } from './planner.js'; @@ -28,6 +28,12 @@ export interface OwnedLock { ref: string; lock: InstallLock; overlays: Map; + /** + * Trees already read and checked by the caller, by alias (`skillx add`, whose + * bytes come from git or a local path, not the registry). When set, nothing + * is fetched: a planned alias without a tree here is an integrity error. + */ + trees?: ReadonlyMap; } /** Splits a lock into owners: kits and collections own all their packages; explicit packages own themselves. */ @@ -90,7 +96,11 @@ export async function applyOwned(ctx: InstallContext, owned: OwnedLock, plan: In refuseConflicts(plan); const trees = new Map(); const deps = { registry: ctx.registry, keys: ctx.keys, cacheDir: join(ctx.home, 'cache', 'blobs'), runtime: ctx.runtime }; - for (const pkg of packagesToFetch(plan, owned.lock)) trees.set(pkg.alias, await fetchVerified(deps, pkg, owned.overlays.get(pkg.alias) ?? [])); + for (const pkg of packagesToFetch(plan, owned.lock)) { + const given = owned.trees?.get(pkg.alias); + if (owned.trees !== undefined && given === undefined) throw integrityError(`no checked tree for ${pkg.alias}`, { alias: pkg.alias }); + trees.set(pkg.alias, given ?? (await fetchVerified(deps, pkg, owned.overlays.get(pkg.alias) ?? []))); + } const result: ApplyResult = { owner: owned.owner, planDigest: plan.planDigest, changed: [], removed: [] }; const byAlias = new Map(owned.lock.packages.map((p) => [p.alias, p])); diff --git a/packages/cli/src/lib/install/install-operations.ts b/packages/cli/src/lib/install/install-operations.ts index cd8d3ab..75c8a13 100644 --- a/packages/cli/src/lib/install/install-operations.ts +++ b/packages/cli/src/lib/install/install-operations.ts @@ -1,11 +1,11 @@ -import { canonicalJsonBytes, lockDigest } from '@skillx/contracts'; +import { canonicalJsonBytes, isInstallAlias, lockDigest } from '@skillx/contracts'; import type { InstallLock } from '@skillx/contracts'; import { sha256Hex } from './fetch-verify.js'; import { applyOwned, ownedLocks, planOwned, recover } from './install-engine.js'; import type { ApplyResult, OwnedLock } from './install-engine.js'; import type { InstallContext } from './install-context.js'; import { withInstallLock } from './install-context.js'; -import { InstallError, conflictError } from './install-errors.js'; +import { InstallError, conflictError, usageError } from './install-errors.js'; import { listReceipts, readOwners } from './install-state.js'; import type { InstallPlan } from './planner.js'; import { checkRuntimes, parseRefs, resolveSource } from './resolver.js'; @@ -17,6 +17,10 @@ import type { Resolution, SourceRef } from './resolver.js'; * recovers interrupted transactions first and then plans afresh. */ +/** Owner key prefix of a skill installed by `skillx add`: one owner per skill, `add:`. */ +export const ADD_OWNER_PREFIX = 'add:'; +export const isAddOwner = (owner: string) => owner.startsWith(ADD_OWNER_PREFIX); + export interface PlannedItem extends OwnedLock { plan: InstallPlan; } @@ -80,6 +84,13 @@ export const installRefs = (ctx: InstallContext, refs: readonly string[], expect * splits them, so each package is updated and uninstalled on its own. */ export function ownersOfRefs(refs: readonly string[]): string[] { + const added = refs.filter(isAddOwner); + if (added.length > 0) { + if (added.length !== refs.length) throw usageError(`name ${ADD_OWNER_PREFIX} owners apart from registry sources`); + const bad = added.find((r) => !isInstallAlias(r.slice(ADD_OWNER_PREFIX.length))); + if (bad !== undefined) throw usageError(`'${bad}' is not ${ADD_OWNER_PREFIX}`); + return [...new Set(added)]; + } const ref = parseRefs(refs); if (ref.kind === 'kit') return [`kit:${ref.kit}`]; if (ref.kind === 'collection') return [`collection:${ref.id}`]; @@ -109,10 +120,19 @@ export async function uninstallRef(ctx: InstallContext, refs: readonly string[], }); } -/** Re-resolves the named installed sources (all when none is named) and applies the changes. */ +/** + * Re-resolves the named installed sources (all when none is named) and applies + * the changes. Skills from `skillx add` have no registry source to re-resolve: + * updating all leaves them alone, and naming one is a usage error that points + * at `skillx add --force`. + */ export async function updateInstalled(ctx: InstallContext, refs: readonly string[]): Promise { const records = readOwners(ctx.home, ctx.runtime).owners; - const wanted = refs.length > 0 ? ownersOfRefs(refs) : Object.keys(records).sort(); + const named = refs.length > 0 ? ownersOfRefs(refs) : []; + if (named.some(isAddOwner)) { + throw new InstallError('usage_error', `${named.join(', ')} came from skillx add; refresh with skillx add --force`, { sources: named.map((o) => records[o]?.ref ?? null) }); + } + const wanted = refs.length > 0 ? named : Object.keys(records).filter((o) => !isAddOwner(o)).sort(); const outcomes: ApplyOutcome[] = []; for (const owner of wanted) { const record = records[owner]; diff --git a/packages/cli/src/lib/skill-discovery.test.ts b/packages/cli/src/lib/skill-discovery.test.ts deleted file mode 100644 index f731cd9..0000000 --- a/packages/cli/src/lib/skill-discovery.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, writeFile, rm } from 'fs/promises'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { discoverSkills, filterSkills, getSkillDisplayName, parseSkillMd } from './skill-discovery.js'; - -let testDir: string; - -beforeEach(async () => { - testDir = join(tmpdir(), `skillx-test-${Date.now()}`); - await mkdir(testDir, { recursive: true }); -}); - -afterEach(async () => { - await rm(testDir, { recursive: true, force: true }); -}); - -async function createSkill(dir: string, name: string, description: string): Promise { - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, 'SKILL.md'), - `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\nContent here.` - ); -} - -describe('parseSkillMd', () => { - it('parses valid SKILL.md with frontmatter', async () => { - await createSkill(testDir, 'test-skill', 'A test skill'); - const skill = await parseSkillMd(join(testDir, 'SKILL.md')); - expect(skill).not.toBeNull(); - expect(skill!.name).toBe('test-skill'); - expect(skill!.description).toBe('A test skill'); - }); - - it('returns null for missing name', async () => { - await mkdir(testDir, { recursive: true }); - await writeFile(join(testDir, 'SKILL.md'), '---\ndescription: no name\n---\n'); - const skill = await parseSkillMd(join(testDir, 'SKILL.md')); - expect(skill).toBeNull(); - }); - - it('returns null for non-existent file', async () => { - const skill = await parseSkillMd(join(testDir, 'nonexistent', 'SKILL.md')); - expect(skill).toBeNull(); - }); -}); - -describe('discoverSkills', () => { - it('finds root-level SKILL.md', async () => { - await createSkill(testDir, 'root-skill', 'Root skill'); - const skills = await discoverSkills(testDir); - expect(skills).toHaveLength(1); - expect(skills[0]!.name).toBe('root-skill'); - }); - - it('finds skills in skills/ subdirectory', async () => { - await createSkill(join(testDir, 'skills', 'alpha'), 'alpha', 'Alpha skill'); - await createSkill(join(testDir, 'skills', 'beta'), 'beta', 'Beta skill'); - const skills = await discoverSkills(testDir); - expect(skills).toHaveLength(2); - const names = skills.map((s) => s.name).sort(); - expect(names).toEqual(['alpha', 'beta']); - }); - - it('finds skills in .claude/skills/ subdirectory', async () => { - await createSkill(join(testDir, '.claude', 'skills', 'my-skill'), 'my-skill', 'Claude skill'); - const skills = await discoverSkills(testDir); - expect(skills).toHaveLength(1); - expect(skills[0]!.name).toBe('my-skill'); - }); - - it('deduplicates skills by name', async () => { - await createSkill(join(testDir, 'skills', 'dupe'), 'same-name', 'First'); - await createSkill(join(testDir, '.claude', 'skills', 'dupe'), 'same-name', 'Second'); - const skills = await discoverSkills(testDir); - expect(skills).toHaveLength(1); - }); - - it('respects subpath parameter', async () => { - await createSkill(join(testDir, 'sub', 'skills', 'nested'), 'nested', 'Nested'); - const skills = await discoverSkills(testDir, 'sub'); - expect(skills).toHaveLength(1); - expect(skills[0]!.name).toBe('nested'); - }); - - it('rejects path traversal in subpath', async () => { - await expect(discoverSkills(testDir, '../../etc')).rejects.toThrow('Invalid subpath'); - }); - - it('returns empty for directory with no skills', async () => { - const skills = await discoverSkills(testDir); - expect(skills).toHaveLength(0); - }); - - it('recursive fallback finds deeply nested skills', async () => { - await createSkill(join(testDir, 'deep', 'nested', 'skill'), 'deep-skill', 'Deep'); - const skills = await discoverSkills(testDir); - expect(skills).toHaveLength(1); - expect(skills[0]!.name).toBe('deep-skill'); - }); -}); - -describe('filterSkills', () => { - const skills = [ - { name: 'alpha', description: 'A', path: '/a' }, - { name: 'Beta', description: 'B', path: '/b' }, - { name: 'gamma', description: 'G', path: '/g' }, - ]; - - it('filters by exact name (case-insensitive)', () => { - const result = filterSkills(skills, ['alpha']); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('alpha'); - }); - - it('filters case-insensitively', () => { - const result = filterSkills(skills, ['BETA']); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('Beta'); - }); - - it('filters multiple names', () => { - const result = filterSkills(skills, ['alpha', 'gamma']); - expect(result).toHaveLength(2); - }); - - it('returns empty for no matches', () => { - const result = filterSkills(skills, ['nonexistent']); - expect(result).toHaveLength(0); - }); -}); - -describe('getSkillDisplayName', () => { - it('returns skill name when available', () => { - expect(getSkillDisplayName({ name: 'my-skill', description: '', path: '/x/my-skill' })).toBe('my-skill'); - }); - - it('falls back to basename when name is empty', () => { - expect(getSkillDisplayName({ name: '', description: '', path: '/x/fallback-dir' })).toBe('fallback-dir'); - }); -}); diff --git a/packages/cli/src/lib/skill-discovery.ts b/packages/cli/src/lib/skill-discovery.ts deleted file mode 100644 index 19bba68..0000000 --- a/packages/cli/src/lib/skill-discovery.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { readdir, readFile, stat } from 'fs/promises'; -import { join, basename, dirname, normalize, resolve, sep } from 'path'; -import matter from 'gray-matter'; -import type { Skill } from './add-types.js'; - -const SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__']; - -async function hasSkillMd(dir: string): Promise { - try { - const stats = await stat(join(dir, 'SKILL.md')); - return stats.isFile(); - } catch { - return false; - } -} - -/** Parse a SKILL.md file into a Skill object. Returns null if invalid. */ -export async function parseSkillMd(skillMdPath: string): Promise { - try { - const content = await readFile(skillMdPath, 'utf-8'); - const { data } = matter(content); - - if (!data.name || !data.description) return null; - if (typeof data.name !== 'string' || typeof data.description !== 'string') return null; - - return { - name: data.name, - description: data.description, - path: dirname(skillMdPath), - rawContent: content, - metadata: data.metadata, - }; - } catch { - return null; - } -} - -/** Recursively find directories containing SKILL.md */ -async function findSkillDirs(dir: string, depth = 0, maxDepth = 5): Promise { - if (depth > maxDepth) return []; - - try { - const [hasSkill, entries] = await Promise.all([ - hasSkillMd(dir), - readdir(dir, { withFileTypes: true }).catch(() => []), - ]); - - const currentDir = hasSkill ? [dir] : []; - const subResults = await Promise.all( - entries - .filter((e) => e.isDirectory() && !SKIP_DIRS.includes(e.name)) - .map((e) => findSkillDirs(join(dir, e.name), depth + 1, maxDepth)) - ); - - return [...currentDir, ...subResults.flat()]; - } catch { - return []; - } -} - -export interface DiscoverOptions { - fullDepth?: boolean; -} - -/** Validate subpath stays within base directory */ -function isSubpathSafe(basePath: string, subpath: string): boolean { - const normalizedBase = normalize(resolve(basePath)); - const normalizedTarget = normalize(resolve(join(basePath, subpath))); - return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase; -} - -/** - * Discover all skills in a directory by searching for SKILL.md files. - * Priority: root → common skill directories → recursive fallback. - */ -export async function discoverSkills( - basePath: string, - subpath?: string, - options?: DiscoverOptions -): Promise { - const skills: Skill[] = []; - const seenNames = new Set(); - - if (subpath && !isSubpathSafe(basePath, subpath)) { - throw new Error(`Invalid subpath: "${subpath}" resolves outside the repository directory.`); - } - - const searchPath = subpath ? join(basePath, subpath) : basePath; - - // If pointing directly at a skill, add it - if (await hasSkillMd(searchPath)) { - const skill = await parseSkillMd(join(searchPath, 'SKILL.md')); - if (skill) { - skills.push(skill); - seenNames.add(skill.name); - if (!options?.fullDepth) return skills; - } - } - - // Search common skill locations first - const priorityDirs = [ - searchPath, - join(searchPath, 'skills'), - join(searchPath, '.agent/skills'), - join(searchPath, '.agents/skills'), - join(searchPath, '.claude/skills'), - join(searchPath, '.cline/skills'), - join(searchPath, '.github/skills'), - join(searchPath, '.goose/skills'), - join(searchPath, '.kiro/skills'), - join(searchPath, '.opencode/skills'), - join(searchPath, '.roo/skills'), - join(searchPath, '.trae/skills'), - join(searchPath, '.windsurf/skills'), - ]; - - for (const dir of priorityDirs) { - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const skillDir = join(dir, entry.name); - if (await hasSkillMd(skillDir)) { - const skill = await parseSkillMd(join(skillDir, 'SKILL.md')); - if (skill && !seenNames.has(skill.name)) { - skills.push(skill); - seenNames.add(skill.name); - } - } - } - } catch { - // Directory doesn't exist — skip - } - } - - // Recursive fallback if nothing found, or fullDepth requested - if (skills.length === 0 || options?.fullDepth) { - const allDirs = await findSkillDirs(searchPath); - for (const skillDir of allDirs) { - const skill = await parseSkillMd(join(skillDir, 'SKILL.md')); - if (skill && !seenNames.has(skill.name)) { - skills.push(skill); - seenNames.add(skill.name); - } - } - } - - return skills; -} - -export function getSkillDisplayName(skill: Skill): string { - return skill.name || basename(skill.path); -} - -/** Filter skills by name (case-insensitive) */ -export function filterSkills(skills: Skill[], inputNames: string[]): Skill[] { - const normalized = inputNames.map((n) => n.toLowerCase()); - return skills.filter((skill) => { - const name = skill.name.toLowerCase(); - const display = getSkillDisplayName(skill).toLowerCase(); - return normalized.some((input) => input === name || input === display); - }); -} diff --git a/packages/cli/src/lib/skill-installer.ts b/packages/cli/src/lib/skill-installer.ts deleted file mode 100644 index 043154b..0000000 --- a/packages/cli/src/lib/skill-installer.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { mkdir, cp, lstat, rm, symlink, readlink, realpath } from 'fs/promises'; -import { join, basename, normalize, resolve, sep, relative, dirname } from 'path'; -import { homedir } from 'os'; -import type { Skill, AgentType, InstallMode, InstallResult } from './add-types.js'; -import { agents, isUniversalAgent, getCanonicalSkillsDir, getAgentBaseDir } from './agent-registry.js'; - -/** - * Sanitize a name for safe use in file paths. - * Lowercase, replace special chars with hyphens, trim dots/hyphens. - */ -export function sanitizeName(name: string): string { - const sanitized = name - .toLowerCase() - .replace(/[^a-z0-9._]+/g, '-') - .replace(/^[.\-]+|[.\-]+$/g, ''); - return sanitized.substring(0, 255) || 'unnamed-skill'; -} - -/** Check if a skill is already installed for an agent */ -export async function isSkillInstalled( - skillName: string, - agent: AgentType, - options: { global?: boolean } -): Promise { - const baseDir = getAgentBaseDir(agent, options.global ?? false); - const skillDir = join(baseDir, sanitizeName(skillName)); - try { - await lstat(skillDir); - return true; - } catch { - return false; - } -} - -/** Get the canonical install path for a skill */ -export function getCanonicalPath( - skillName: string, - options: { global?: boolean } -): string { - return join(getCanonicalSkillsDir(options.global ?? false), sanitizeName(skillName)); -} - -/** Get the install path for a specific agent */ -export function getInstallPath( - skillName: string, - agent: AgentType, - options: { global?: boolean } -): string { - return join(getAgentBaseDir(agent, options.global ?? false), sanitizeName(skillName)); -} - -/** Clean and recreate a directory */ -async function cleanAndCreateDir(path: string): Promise { - try { - await rm(path, { recursive: true, force: true }); - } catch { - // Ignore — mkdir will fail if real problem - } - await mkdir(path, { recursive: true }); -} - -/** Resolve parent symlinks while keeping final component */ -async function resolveParentSymlinks(path: string): Promise { - const resolved = resolve(path); - const dir = dirname(resolved); - const base = basename(resolved); - try { - const realDir = await realpath(dir); - return join(realDir, base); - } catch { - return resolved; - } -} - -/** Create symlink with cross-platform handling. Returns false if fallback to copy needed. */ -async function createSymlink(target: string, linkPath: string): Promise { - try { - const resolvedTarget = resolve(target); - const resolvedLinkPath = resolve(linkPath); - - // Check if paths resolve to same location - const [realTarget, realLinkPath] = await Promise.all([ - realpath(resolvedTarget).catch(() => resolvedTarget), - realpath(resolvedLinkPath).catch(() => resolvedLinkPath), - ]); - - if (realTarget === realLinkPath) return true; - - // Check with parent symlinks resolved - const realTargetParent = await resolveParentSymlinks(target); - const realLinkParent = await resolveParentSymlinks(linkPath); - if (realTargetParent === realLinkParent) return true; - - // Remove existing link/dir if present - try { - const stats = await lstat(linkPath); - if (stats.isSymbolicLink()) { - const existing = await readlink(linkPath); - if (resolve(dirname(linkPath), existing) === resolvedTarget) return true; - await rm(linkPath); - } else { - await rm(linkPath, { recursive: true }); - } - } catch (err: unknown) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ELOOP') { - try { await rm(linkPath, { force: true }); } catch { /* noop */ } - } - } - - const linkDir = dirname(linkPath); - await mkdir(linkDir, { recursive: true }); - - const realLinkDir = await resolveParentSymlinks(linkDir); - const relTarget = relative(realLinkDir, resolvedTarget); - - // Use 'junction' on Windows for directory symlinks (no admin rights needed) - const symlinkType = process.platform === 'win32' ? 'junction' : undefined; - await symlink( - symlinkType === 'junction' ? resolvedTarget : relTarget, - linkPath, - symlinkType - ); - return true; - } catch { - return false; - } -} - -/** Install a skill for a specific agent (copy or symlink) */ -export async function installSkillForAgent( - skill: Skill, - agent: AgentType, - options: { global?: boolean; mode?: InstallMode } -): Promise { - const global = options.global ?? false; - const mode = options.mode ?? 'symlink'; - const safeName = sanitizeName(skill.name); - - const canonicalDir = join(getCanonicalSkillsDir(global), safeName); - const agentDir = join(getAgentBaseDir(agent, global), safeName); - - try { - // Always copy to canonical directory first - await cleanAndCreateDir(canonicalDir); - await cp(skill.path, canonicalDir, { recursive: true }); - - // For universal agents, canonical IS the agent dir - if (isUniversalAgent(agent)) { - return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode }; - } - - if (mode === 'copy') { - await cleanAndCreateDir(agentDir); - await cp(skill.path, agentDir, { recursive: true }); - return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'copy' }; - } - - // Symlink mode: link agent dir → canonical dir - const linked = await createSymlink(canonicalDir, agentDir); - if (linked) { - return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink' }; - } - - // Fallback to copy if symlink fails (e.g., Windows without Developer Mode) - await cleanAndCreateDir(agentDir); - await cp(skill.path, agentDir, { recursive: true }); - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, - mode: 'symlink', - symlinkFailed: true, - }; - } catch (error) { - return { - success: false, - path: agentDir, - canonicalPath: canonicalDir, - mode, - error: error instanceof Error ? error.message : String(error), - }; - } -} diff --git a/packages/cli/src/lib/skill-lock.ts b/packages/cli/src/lib/skill-lock.ts deleted file mode 100644 index ca9f2fa..0000000 --- a/packages/cli/src/lib/skill-lock.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { readFile, writeFile, mkdir } from 'fs/promises'; -import { join, dirname } from 'path'; -import { homedir } from 'os'; - -const LOCK_FILE = '.skills.lock.json'; - -interface LockEntry { - source: string; - sourceType: string; - sourceUrl?: string; - skillPath?: string; - skillFolderHash?: string; - pluginName?: string; - installedAt: string; -} - -interface LockFile { - version: number; - skills: Record; - preferences?: { - lastSelectedAgents?: string[]; - dismissedPrompts?: string[]; - }; -} - -function getLockFilePath(): string { - return join(homedir(), '.agents', LOCK_FILE); -} - -async function readLockFile(): Promise { - try { - const content = await readFile(getLockFilePath(), 'utf-8'); - return JSON.parse(content) as LockFile; - } catch { - return { version: 1, skills: {} }; - } -} - -async function writeLockFile(data: LockFile): Promise { - const path = getLockFilePath(); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(data, null, 2), 'utf-8'); -} - -/** Add or update a skill entry in the lock file */ -export async function addSkillToLock( - skillName: string, - entry: Omit -): Promise { - const lock = await readLockFile(); - lock.skills[skillName] = { ...entry, installedAt: new Date().toISOString() }; - await writeLockFile(lock); -} - -/** Get the list of last selected agents */ -export async function getLastSelectedAgents(): Promise { - const lock = await readLockFile(); - return lock.preferences?.lastSelectedAgents; -} - -/** Save the list of selected agents for next time */ -export async function saveSelectedAgents(agents: string[]): Promise { - const lock = await readLockFile(); - if (!lock.preferences) lock.preferences = {}; - lock.preferences.lastSelectedAgents = agents; - await writeLockFile(lock); -} - -/** Check if a prompt has been dismissed */ -export async function isPromptDismissed(key: string): Promise { - const lock = await readLockFile(); - return lock.preferences?.dismissedPrompts?.includes(key) ?? false; -} - -/** Dismiss a prompt so it won't show again */ -export async function dismissPrompt(key: string): Promise { - const lock = await readLockFile(); - if (!lock.preferences) lock.preferences = {}; - if (!lock.preferences.dismissedPrompts) lock.preferences.dismissedPrompts = []; - if (!lock.preferences.dismissedPrompts.includes(key)) { - lock.preferences.dismissedPrompts.push(key); - } - await writeLockFile(lock); -} diff --git a/packages/cli/src/lib/source-parser.test.ts b/packages/cli/src/lib/source-parser.test.ts deleted file mode 100644 index 7ebef04..0000000 --- a/packages/cli/src/lib/source-parser.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseSource, getOwnerRepo, parseOwnerRepo, isSubpathSafe } from './source-parser.js'; - -describe('parseSource', () => { - it('parses GitHub shorthand: owner/repo', () => { - const result = parseSource('vercel-labs/agent-skills'); - expect(result.type).toBe('github'); - expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); - expect(result.subpath).toBeUndefined(); - }); - - it('parses GitHub shorthand with subpath: owner/repo/path', () => { - const result = parseSource('vercel-labs/agent-skills/skills/pr-review'); - expect(result.type).toBe('github'); - expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); - expect(result.subpath).toBe('skills/pr-review'); - }); - - it('parses GitHub shorthand with @skill filter', () => { - const result = parseSource('vercel-labs/agent-skills@find-skills'); - expect(result.type).toBe('github'); - expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); - expect(result.skillFilter).toBe('find-skills'); - }); - - it('parses GitHub URL: https://github.com/owner/repo', () => { - const result = parseSource('https://github.com/vercel-labs/agent-skills'); - expect(result.type).toBe('github'); - expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git'); - }); - - it('parses GitHub URL with tree/branch/path', () => { - const result = parseSource('https://github.com/owner/repo/tree/main/skills/web'); - expect(result.type).toBe('github'); - expect(result.url).toBe('https://github.com/owner/repo.git'); - expect(result.ref).toBe('main'); - expect(result.subpath).toBe('skills/web'); - }); - - it('parses GitHub URL with tree/branch only', () => { - const result = parseSource('https://github.com/owner/repo/tree/develop'); - expect(result.type).toBe('github'); - expect(result.ref).toBe('develop'); - }); - - it('parses GitLab URL', () => { - const result = parseSource('https://gitlab.com/myorg/myrepo'); - expect(result.type).toBe('gitlab'); - expect(result.url).toBe('https://gitlab.com/myorg/myrepo.git'); - }); - - it('parses local absolute path', () => { - const result = parseSource('/home/user/my-skills'); - expect(result.type).toBe('local'); - expect(result.localPath).toBeTruthy(); - }); - - it('parses local relative path ./', () => { - const result = parseSource('./my-skills'); - expect(result.type).toBe('local'); - expect(result.localPath).toBeTruthy(); - }); - - it('parses github: prefix shorthand', () => { - const result = parseSource('github:owner/repo'); - expect(result.type).toBe('github'); - expect(result.url).toBe('https://github.com/owner/repo.git'); - }); - - it('parses gitlab: prefix shorthand', () => { - const result = parseSource('gitlab:myorg/myrepo'); - expect(result.type).toBe('gitlab'); - expect(result.url).toBe('https://gitlab.com/myorg/myrepo.git'); - }); - - it('falls back to git type for unknown URLs', () => { - const result = parseSource('git@custom-host.com:org/repo.git'); - expect(result.type).toBe('git'); - }); - - it('rejects path traversal in subpath', () => { - expect(() => parseSource('owner/repo/../../../etc/passwd')).toThrow('Unsafe subpath'); - }); -}); - -describe('getOwnerRepo', () => { - it('extracts owner/repo from github parsed source', () => { - const parsed = parseSource('vercel-labs/agent-skills'); - expect(getOwnerRepo(parsed)).toBe('vercel-labs/agent-skills'); - }); - - it('returns null for local sources', () => { - const parsed = parseSource('./local-path'); - expect(getOwnerRepo(parsed)).toBeNull(); - }); -}); - -describe('parseOwnerRepo', () => { - it('parses valid owner/repo', () => { - expect(parseOwnerRepo('vercel/next.js')).toEqual({ owner: 'vercel', repo: 'next.js' }); - }); - - it('returns null for invalid format', () => { - expect(parseOwnerRepo('just-a-name')).toBeNull(); - expect(parseOwnerRepo('a/b/c')).toBeNull(); - }); -}); - -describe('isSubpathSafe', () => { - it('returns true for valid subpath', () => { - expect(isSubpathSafe('/base', 'skills/web')).toBe(true); - }); - - it('returns false for path traversal', () => { - expect(isSubpathSafe('/base', '../../etc')).toBe(false); - }); -}); diff --git a/packages/cli/src/lib/source-parser.ts b/packages/cli/src/lib/source-parser.ts deleted file mode 100644 index 777874c..0000000 --- a/packages/cli/src/lib/source-parser.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { isAbsolute, resolve, normalize, sep } from 'path'; -import type { ParsedSource } from './add-types.js'; - -/** - * Extract owner/repo from a parsed source for tracking. - * Returns null for local paths or unparseable sources. - */ -export function getOwnerRepo(parsed: ParsedSource): string | null { - if (parsed.type === 'local') return null; - - // SSH URLs: git@github.com:owner/repo.git - const sshMatch = parsed.url.match(/^git@[^:]+:(.+)$/); - if (sshMatch) { - const path = sshMatch[1]!.replace(/\.git$/, ''); - return path.includes('/') ? path : null; - } - - if (!parsed.url.startsWith('http://') && !parsed.url.startsWith('https://')) { - return null; - } - - try { - const url = new URL(parsed.url); - const path = url.pathname.slice(1).replace(/\.git$/, ''); - return path.includes('/') ? path : null; - } catch { - return null; - } -} - -/** Extract owner and repo from "owner/repo" string */ -export function parseOwnerRepo(ownerRepo: string): { owner: string; repo: string } | null { - const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/); - return match ? { owner: match[1]!, repo: match[2]! } : null; -} - -/** Sanitize subpath to prevent path traversal */ -function sanitizeSubpath(subpath: string): string { - const normalized = subpath.replace(/\\/g, '/'); - for (const segment of normalized.split('/')) { - if (segment === '..') { - throw new Error( - `Unsafe subpath: "${subpath}" contains path traversal segments.` - ); - } - } - return subpath; -} - -/** Validate subpath stays within base directory */ -export function isSubpathSafe(basePath: string, subpath: string): boolean { - const normalizedBase = normalize(resolve(basePath)); - const normalizedTarget = normalize(resolve(basePath, subpath)); - return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase; -} - -function isLocalPath(input: string): boolean { - return ( - isAbsolute(input) || - input.startsWith('./') || - input.startsWith('../') || - input === '.' || - input === '..' || - /^[a-zA-Z]:[/\\]/.test(input) // Windows absolute paths - ); -} - -/** - * Parse a source string into a structured format. - * Supports: local paths, GitHub URLs, GitLab URLs, GitHub shorthand, git URLs. - */ -export function parseSource(input: string): ParsedSource { - // github: prefix shorthand - const githubPrefix = input.match(/^github:(.+)$/); - if (githubPrefix) return parseSource(githubPrefix[1]!); - - // gitlab: prefix shorthand - const gitlabPrefix = input.match(/^gitlab:(.+)$/); - if (gitlabPrefix) return parseSource(`https://gitlab.com/${gitlabPrefix[1]!}`); - - // Local path - if (isLocalPath(input)) { - const resolvedPath = resolve(input); - return { type: 'local', url: resolvedPath, localPath: resolvedPath }; - } - - // GitHub URL with path: .../tree/branch/path - const ghTreePath = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/); - if (ghTreePath) { - const [, owner, repo, ref, subpath] = ghTreePath; - return { - type: 'github', - url: `https://github.com/${owner}/${repo}.git`, - ref, - subpath: subpath ? sanitizeSubpath(subpath) : subpath, - }; - } - - // GitHub URL with branch only: .../tree/branch - const ghTree = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)$/); - if (ghTree) { - const [, owner, repo, ref] = ghTree; - return { type: 'github', url: `https://github.com/${owner}/${repo}.git`, ref }; - } - - // GitHub URL: https://github.com/owner/repo - const ghRepo = input.match(/github\.com\/([^/]+)\/([^/]+)/); - if (ghRepo) { - const [, owner, repo] = ghRepo; - const cleanRepo = repo!.replace(/\.git$/, ''); - return { type: 'github', url: `https://github.com/${owner}/${cleanRepo}.git` }; - } - - // GitLab URL with path - const glTreePath = input.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)\/(.+)/); - if (glTreePath && glTreePath[2] !== 'github.com') { - const [, protocol, hostname, repoPath, ref, subpath] = glTreePath; - return { - type: 'gitlab', - url: `${protocol}://${hostname}/${repoPath!.replace(/\.git$/, '')}.git`, - ref, - subpath: subpath ? sanitizeSubpath(subpath) : subpath, - }; - } - - // GitLab URL - const glRepo = input.match(/gitlab\.com\/(.+?)(?:\.git)?\/?$/); - if (glRepo && glRepo[1]!.includes('/')) { - return { type: 'gitlab', url: `https://gitlab.com/${glRepo[1]!}.git` }; - } - - // GitHub shorthand with @skill filter: owner/repo@skill-name - const atSkill = input.match(/^([^/]+)\/([^/@]+)@(.+)$/); - if (atSkill && !input.includes(':') && !input.startsWith('.')) { - const [, owner, repo, skillFilter] = atSkill; - return { type: 'github', url: `https://github.com/${owner}/${repo}.git`, skillFilter }; - } - - // GitHub shorthand: owner/repo or owner/repo/subpath - const shorthand = input.match(/^([^/]+)\/([^/]+)(?:\/(.+))?$/); - if (shorthand && !input.includes(':') && !input.startsWith('.')) { - const [, owner, repo, subpath] = shorthand; - return { - type: 'github', - url: `https://github.com/${owner}/${repo}.git`, - subpath: subpath ? sanitizeSubpath(subpath) : subpath, - }; - } - - // Fallback: direct git URL - return { type: 'git', url: input }; -} diff --git a/plans/reports/researcher-260308-1226-vercel-skills-add-command.md b/plans/reports/researcher-260308-1226-vercel-skills-add-command.md deleted file mode 100644 index 97b7f16..0000000 --- a/plans/reports/researcher-260308-1226-vercel-skills-add-command.md +++ /dev/null @@ -1,578 +0,0 @@ -# Research Report: Vercel Labs `skills add` Command - -**Date:** 2026-03-08 -**Source:** https://github.com/vercel-labs/skills -**Focus:** Complete reverse engineering of `skills add` command - ---- - -## 1. What `skills add` Does (Full User Flow) - -### High-Level Purpose -The `skills add` command installs reusable AI agent skills from various sources (GitHub repos, local paths, or well-known endpoints) into agent-specific or global installation directories. - -### Complete User Flow - -``` -User runs: npx skills add [options] - ↓ -Parse source (GitHub shorthand, URLs, local paths) - ↓ -Clone/validate repository (or use local path) - ↓ -Discover available SKILL.md files in repository - ↓ -Select skills (interactive multiselect or via --skill flag) - ↓ -Detect installed agents on system - ↓ -Select target agents (interactive or via --agent flag) - ↓ -Choose installation scope (global vs project-local, unless -g specified) - ↓ -Choose installation method (symlink vs copy, unless --copy specified) - ↓ -Display installation summary + security audit results - ↓ -Confirm installation (unless -y flag) - ↓ -Install skills for each agent (create dirs, symlink/copy files) - ↓ -Update skill lock files (.claude/skills.lock.json, .skills.lock.json) - ↓ -Prompt to install find-skills skill (one-time only) - ↓ -Display results summary -``` - ---- - -## 2. Arguments and Flags - -### Required Argument -- **``** (positional) - Skill source identifier (required, no default) - - Examples: `vercel-labs/skills`, `/path/to/local`, `https://github.com/owner/repo` - -### Optional Flags - -| Flag | Long Form | Type | Default | Purpose | -|------|-----------|------|---------|---------| -| `-g` | `--global` | boolean | false | Install to home directory instead of project | -| `-y` | `--yes` | boolean | false | Skip all confirmation prompts | -| `-l` | `--list` | boolean | false | List available skills without installing | -| `-a` | `--agent` | string[] | (detect) | Target specific agents; can repeat: `-a claude-code -a cursor` | -| `-s` | `--skill` | string[] | (interactive) | Install specific skills; can repeat: `-s frontend-design -s utils` | -| `--all` | `--all` | boolean | false | Install all skills to all agents (implies `-y`) | -| `--copy` | `--copy` | boolean | false | Copy files instead of symlinking | -| `--full-depth` | `--full-depth` | boolean | false | Search all subdirectories even if root has SKILL.md | - -### Flag Combinations - -Special handling: -- `--all` implies `--skill '*'` + `--agent '*'` + `-y` -- `--skill '*'` installs all discovered skills -- `--agent '*'` installs to all available agents -- If source contains `@skill-name` syntax (e.g., `owner/repo@find-skills`), it sets a skill filter - -### Example Commands -```bash -# Interactive mode -npx skills add vercel-labs/skills - -# Specific skill & agent, skip prompts -npx skills add vercel-labs/skills -s find-skills -a claude-code -y -g - -# All skills, all agents, global -npx skills add vercel-labs/agent-skills --all -g - -# List available skills -npx skills add /path/to/repo --list - -# Using @skill syntax -npx skills add vercel-labs/skills@find-skills -y -g -a claude-code -``` - ---- - -## 3. Skill Name Resolution - -### Source Parsing Priority (parseSource function) - -The `parseSource()` function accepts multiple input formats: - -1. **Source Aliases** - - `coinbase/agentWallet` → `coinbase/agentic-wallet-skills` - -2. **GitHub Prefix** - - `github:owner/repo` → parses as GitHub shorthand - -3. **GitLab Prefix** - - `gitlab:owner/repo` → `https://gitlab.com/owner/repo` - -4. **Local Paths** (Absolute, relative, or current directory) - - `/path/to/skills`, `./skills`, `../repo`, `.`, `..`, `C:\Users\...` (Windows) - - Returns: `ParsedSource` with `type: 'local'`, `localPath` set - -5. **GitHub URLs with Path + Branch** - - `https://github.com/owner/repo/tree/main/path/to/skills` - - Returns: `type: 'github'`, `ref: 'main'`, `subpath: 'path/to/skills'` - -6. **GitHub Shorthand** (Most Common) - - `owner/repo` → `https://github.com/owner/repo.git` - - `owner/repo/path/to/skills` → adds `subpath` - - `owner/repo@skill-name` → adds `skillFilter` (extracts single skill) - -7. **GitHub URLs (Full)** - - `https://github.com/owner/repo` - - Automatically strips `.git` suffix - -8. **GitLab URLs** (with group/subgroup support) - - `https://gitlab.com/owner/repo` or `https://gitlab.com/group/subgroup/repo` - - Also supports path + branch: `/tree/main/path` - -9. **Well-Known Endpoints** (RFC 8615) - - Any `http(s)://` URL not matching GitHub/GitLab - - Expects `/.well-known/skills/index.json` endpoint - -10. **Direct Git URLs** (Fallback) - - Any other git URL (e.g., private git servers) - -### Returned ParsedSource Structure -```typescript -interface ParsedSource { - type: 'github' | 'gitlab' | 'git' | 'local' | 'well-known'; - url: string; // Full URL or resolved local path - subpath?: string; // Path within repository - localPath?: string; // Set only for local type - ref?: string; // Git branch/tag - skillFilter?: string; // Skill name extracted from @syntax -} -``` - ---- - -## 4. Files Created/Modified on User's System - -### Installation Directory Structure - -**Global Installation:** -- Base: `~/.agent/skills/` (canonical directory for all agents) -- Per-agent (symlink): - - `~/.agent//skills/` → symlinks to canonical - - Examples: `~/.claude-code/skills/`, `~/.cline/skills/`, `~/.cursor/skills/` - -**Project-Local Installation:** -- Base: `./.agent/skills/` (in current working directory) -- Per-agent: `./.agent//skills/` - -### Directory Creation Pattern - -For global install of skill `find-skills` to `claude-code`: - -``` -~/.agent/ -├── skills/ # Canonical dir (universal agents + symlink targets) -│ └── find-skills/ # Sanitized skill name -│ ├── SKILL.md # Skill metadata + instructions -│ └── [other files from repo] -├── .claude-code/ # Agent-specific dir -│ └── skills/ -│ └── find-skills → symlink to ~/.agent/skills/find-skills -└── .universal/ # Special agent type - └── skills/ - └── find-skills → symlink or copy -``` - -### Lock Files Updated - -**Global Skills Lock (for update tracking):** -- Path: `~/.agent/.skills.lock.json` -- Version: 3 -- Contains: Map of installed skills with source, branch, folder hash, plugin name - -Example: -```json -{ - "version": 3, - "skills": { - "find-skills": { - "source": "vercel-labs/skills", - "sourceType": "github", - "sourceUrl": "https://github.com/vercel-labs/skills", - "skillPath": "skills/find-skills/SKILL.md", - "skillFolderHash": "a1b2c3d...", - "pluginName": "curated" - } - } -} -``` - -**Project-Local Skills Lock:** -- Path: `.skills.lock.json` (in project root) -- Contains: Similar structure but tracks project-scoped installs -- Stores: `computedHash` instead of `skillFolderHash` (file-based hash, not GitHub tree hash) - -**Telemetry/Tracking:** (Opt-out) -- Only sent for public repos (private repos never tracked) -- Tracks: event type, source, skill names, agents, installation mode, skill file paths - ---- - -## 5. SKILL.md File Handling - -### Structure Expected - -**Minimal SKILL.md:** -```yaml ---- -name: "Find Skills" -description: "Help your agent discover and suggest skills" -# Optional metadata -metadata: - internal: false - category: "discovery" ---- - -# Skill Implementation - -[Markdown content with agent instructions and code] -``` - -### Parsing (parseSkillMd function) - -```typescript -async function parseSkillMd(skillMdPath: string): Promise -``` - -1. **Reads file** from `SKILL.md` (must be exact filename) -2. **Parses frontmatter** (YAML) using gray-matter library -3. **Validates:** - - `data.name` exists and is string - - `data.description` exists and is string - - Skips internal skills unless `INSTALL_INTERNAL_SKILLS=1` or `includeInternal=true` -4. **Returns:** Skill object with name, description, path, rawContent, metadata -5. **Returns null** if parsing fails or validation fails (skipped silently) - -### Returned Skill Object -```typescript -interface Skill { - name: string; // From frontmatter - description: string; // From frontmatter - path: string; // Directory containing SKILL.md - rawContent?: string; // Full file content (for hashing) - metadata?: Record; // Additional frontmatter fields - pluginName?: string; // Assigned during discovery (optional) -} -``` - -### Internal Skills - -Hidden by default. Revealed only if: -1. Environment variable `INSTALL_INTERNAL_SKILLS=1` is set, OR -2. User explicitly requests the skill (via `--skill` or `@skill` syntax) - ---- - -## 6. Directory Structure & Discovery Algorithm - -### Skill Discovery (discoverSkills function) - -**Search Order (Priority Lookup):** - -1. **Direct path check:** If `basePath/SKILL.md` exists, return it (unless `--full-depth`) -2. **Priority directories** (checked in order): - ``` - skills/ - skills/.curated/ - skills/.experimental/ - skills/.system/ - .agent/skills/ - .agents/skills/ - .claude/skills/ - .cline/skills/ - .codebuddy/skills/ - .codex/skills/ - .commandcode/skills/ - .continue/skills/ - [30+ more agent-specific dirs...] - ``` -3. **Plugin-declared paths** (from plugin manifests) -4. **Recursive fallback:** If no skills found in priority dirs, recursively search up to 5 levels deep - - Skips: `node_modules`, `.git`, `dist`, `build`, `__pycache__` - -### De-duplication -- Tracks seen skill names to prevent duplicates -- First match wins in priority order - -### Plugin Grouping -Skills discovered within a plugin are grouped together in UI (e.g., "Curated Skills", "Experimental Skills") - ---- - -## 7. Interactive Prompts and Confirmations - -### Prompt Sequence (Unless -y flag) - -**Step 1: Skill Selection** -- If single skill: Auto-selected, shown for confirmation -- If multiple skills: Grouped multiselect (by plugin if available) - - Space to toggle, Enter to confirm - - Shows description hints (truncated to 60 chars) - -**Step 2: Agent Detection** -- Auto-detects installed agents on system -- If none detected: Prompts to select from all available agents - - Can select multiple agents - - Uses searchable multiselect if many agents -- If detected: Shows detected agents, optionally prompts if multiple - -**Step 3: Installation Scope** (only if agent supports global) -- Options: "Project" (local ./.agent) or "Global" (~/.agent) -- Only shown if `-g` not specified and `--global` not implied - -**Step 4: Installation Method** -- Options: "Symlink (Recommended)" or "Copy to all agents" -- Symlink = single source of truth, easy updates -- Copy = independent copies per agent (fallback if symlink fails on Windows) -- Only shown if `--copy` not specified - -**Step 5: Installation Summary** (p.note display) -- Shows: Skill names → installation paths → target agents → overwrite status - -**Step 6: Security Audit Results** (if available) -- Fetched in parallel from telemetry service -- Shows risk levels: Critical/High/Medium/Low/Safe -- Shows partner audit results (Socket, Snyk, ATH) - -**Step 7: Final Confirmation** -- Message: "Proceed with installation?" -- Yes/No prompt (unless -y flag) - -**Step 8: Post-Installation Prompts** -- "Install find-skills skill?" (one-time only, after first install) - - If dismissed, won't ask again - -### Cancellation -- `Ctrl+C` at any prompt cancels and cleans up -- All prompts allow `p.cancel(symbol)` to exit gracefully - ---- - -## 8. Error Handling Patterns - -### Source Validation -```typescript -if (!source) { - console.error('ERROR: Missing required argument: source'); - console.error('Usage: npx skills add [options]'); - process.exit(1); -} -``` - -### Repository Cloning -```typescript -try { - tempDir = await cloneRepo(parsed.url, parsed.ref); -} catch (error) { - if (error instanceof GitCloneError) { - p.log.error('Failed to clone repository'); - // Print each error line - } - process.exit(1); -} -``` - -### Skill Discovery -```typescript -if (skills.length === 0) { - p.outro(pc.red('No valid skills found. Skills require a SKILL.md with name and description.')); - process.exit(1); -} -``` - -### Skill Selection -```typescript -if (selectedSkills.length === 0) { - p.log.error(`No matching skills found for: ${options.skill.join(', ')}`); - p.log.info('Available skills:'); - for (const s of skills) { - p.log.message(` - ${getSkillDisplayName(s)}`); - } - process.exit(1); -} -``` - -### Agent Validation -```typescript -const invalidAgents = options.agent.filter((a) => !validAgents.includes(a)); -if (invalidAgents.length > 0) { - p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`); - p.log.info(`Valid agents: ${validAgents.join(', ')}`); - process.exit(1); -} -``` - -### Installation Failures -- Per-skill failures don't stop other installations -- Results collected in array: `{ success, path, error, symlinkFailed, mode }` -- Symlink failures fallback to copy (Windows developer mode issue) -- Final summary shows successes and failures separately - -### Path Safety -- Validates subpaths don't contain `..` segments (path traversal protection) -- Checks symlink targets are within expected directories -- Handles ELOOP (circular symlinks) gracefully - -### Cleanup -- Temporary cloned directories cleaned up on exit (finally block) -- Errors in cleanup don't fail the main process - ---- - -## 9. Agent Configuration - -### Supported Agents (40+) -```typescript -type AgentType = - 'amp' | 'antigravity' | 'augment' | 'claude-code' | 'cline' | 'cursor' - | 'continue' | 'vscode' | 'github-copilot' | 'windsurf' - | 'replit' | 'openhands' | 'goose' | 'command-code' - // ... 28 more -``` - -### Universal vs Symlinked Agents - -**Universal Agents** (shared canonical directory): -- `claude-code` (Claude Code), `cline`, `copilot`, `windsurf`, `cursor` (partial list) -- Installed to: `~/.agent/skills/` -- No symlinks needed (prevent redundancy) - -**Symlinked Agents** (per-agent symlink): -- Most others: `~/.claude-code/skills/` → `~/.agent/skills/` -- Use symlinks to save space and enable easy updates - -### Agent Detection -```typescript -const installedAgents = await detectInstalledAgents(); -``` -Checks for agent presence on system (config files, env vars, executables) - -### Install Mode Impact - -**Symlink mode (recommended):** -- Universal agents: Installed to canonical dir -- Symlinked agents: Dir created with symlinks to canonical -- Benefit: Single source of truth, easy updates, no duplication - -**Copy mode:** -- All agents: Independent full copies -- Benefit: Works on Windows without Developer Mode -- Cost: Duplicated files, harder updates - ---- - -## 10. Key Implementation Details - -### Sanitization (sanitizeName function) -```typescript -name - .toLowerCase() - .replace(/[^a-z0-9._]+/g, '-') // Spaces & special chars → hyphens - .replace(/^[.\-]+|[.\-]+$/g, '') // Trim leading/trailing dots & hyphens - .substring(0, 255) // Limit to 255 chars - || 'unnamed-skill' // Fallback if empty -``` - -### Hashing for Updates -- **Global installs:** GitHub Trees API hash (folder-level, tracks updates) -- **Local installs:** File-based hash (computeSkillFolderHash, for integrity check) -- Used in `check` and `update` commands - -### Telemetry -- Event type: `'install'` -- Tracked fields: source, skill names, agents, global flag, skill files map -- **Only for public repos** (private repos excluded) -- Can be opted out at system level - -### Skill Filtering with @syntax -``` -Source: owner/repo@find-skills - ↓ -Parser extracts: skillFilter: 'find-skills' - ↓ -Merged into options.skill array - ↓ -Only 'find-skills' displayed/installed -``` - ---- - -## 11. File System Operations Summary - -### Operations per Skill Installation - -For each skill × agent combination: - -1. **Create directory:** `getAgentBaseDir(agent) / skill-name/` -2. **Symlink mode (non-universal):** - - Compute relative path from agent dir to canonical dir - - Create symlink at agent dir pointing to canonical - - Fallback to copy on ENOENT, ELOOP, or permission errors (Windows) -3. **Copy mode:** - - Recursively copy skill files to agent dir - - Preserve permissions and structure -4. **Write lock file:** - - Update global or local `.skills.lock.json` - - Record source, hash, plugin name for future updates - -### Cleanup -- Temporary clone directory deleted on process exit -- Partial failures don't prevent other installations -- Lock file updates wrapped in try-catch (don't fail install) - ---- - -## 12. Dependencies & Libraries - -**Key packages:** -- `@clack/prompts` - Interactive prompts (multiselect, confirm, etc.) -- `picocolors` - Terminal colors (no dependencies) -- `gray-matter` - YAML frontmatter parsing -- `fs/promises` - Async file operations -- Built-in: `path`, `os`, `child_process` - -**Custom modules:** -- `parseSource()` - Source string → ParsedSource -- `discoverSkills()` - Directory → Skill[] array -- `installSkillForAgent()` - Skill × Agent → install result -- `fetchSkillFolderHash()` - GitHub Trees API lookup -- `addSkillToLock()` / `addSkillToLocalLock()` - Lock file management - ---- - -## Unresolved Questions - -1. **Well-known endpoint format** - Exact JSON structure of `/.well-known/skills/index.json` (assumed from code but not fully documented in repo) -2. **Plugin manifest format** - Exact structure of `plugin-manifest.ts` (mentioned but not fully explored) -3. **Private repo handling** - How are credentials passed for private GitHub repos? -4. **Symlink fallback mechanics** - Exact conditions that trigger copy fallback on Windows -5. **Agent detection details** - How each agent type is detected (env vars? Config file locations?) -6. **Rate limiting** - GitHub API rate limits and behavior when exceeded - ---- - -## Summary for SkillX Implementation - -### Key Learnings for `skillx add` - -1. **Source parsing is complex but handles many cases** - Shorthand, URLs, local paths, well-known endpoints -2. **Skill discovery uses priority search** - Common dirs first, then recursive fallback, prevents duplicates -3. **Installation is agent-aware** - Universal vs symlinked requires different handling; per-agent discovery needed -4. **Lock files enable updates** - Track source, path, folder hash for checking updates later -5. **Prompts are ergonomic** - Grouped multiselect, search, hints; respect -y flag -6. **Error handling is graceful** - Per-skill failures don't block; cleanup happens regardless -7. **Symlink vs copy trade-off** - Symlink preferred (space, updates) but copy fallback for Windows -8. **Telemetry respects privacy** - Only public repos; can be disabled -9. **Security scanning is parallel** - Fetched while user selects agents (non-blocking) -10. **Sanitization is strict** - File names sanitized; paths validated against traversal attacks - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601d83a..5b7576f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,9 +90,6 @@ importers: packages/cli: dependencies: - '@clack/prompts': - specifier: ^1.1.0 - version: 1.8.1 chalk: specifier: ^5.3.0 version: 5.6.2 @@ -102,18 +99,9 @@ importers: conf: specifier: ^13.0.0 version: 13.1.0 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 ora: specifier: ^8.1.0 version: 8.2.0 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - simple-git: - specifier: ^3.32.3 - version: 3.36.0 yaml: specifier: ^2.9.1 version: 2.9.1 @@ -314,14 +302,6 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} - '@clack/core@1.5.1': - resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} - engines: {node: '>= 20.12.0'} - - '@clack/prompts@1.8.1': - resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} - engines: {node: '>= 20.12.0'} - '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -1148,12 +1128,6 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@kwsites/file-exists@1.1.1': - resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} - - '@kwsites/promise-deferred@1.1.1': - resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} @@ -1339,12 +1313,6 @@ packages: cpu: [x64] os: [win32] - '@simple-git/args-pathspec@1.0.4': - resolution: {integrity: sha512-EtMX6XjRWSastG2SdkmSQByPtJ9dx/NIjnHbpQPCYt62j4RmSx5rgLTGpw0YCjF5h191gZOmBfheOT23cRSFdw==} - - '@simple-git/argv-parser@1.1.1': - resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} - '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1544,9 +1512,6 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1912,11 +1877,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -1934,28 +1894,15 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-wrap-ansi@0.2.2: - resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1995,10 +1942,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} - hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -2020,10 +1963,6 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} - is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -2065,10 +2004,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.2: - resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} - hasBin: true - jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -2085,10 +2020,6 @@ packages: engines: {node: '>=6'} hasBin: true - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -2459,10 +2390,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2490,12 +2417,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-git@3.36.0: - resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2514,9 +2435,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2538,10 +2456,6 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - stubborn-fs@2.0.0: resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} @@ -3060,18 +2974,6 @@ snapshots: '@better-fetch/fetch@1.1.21': {} - '@clack/core@1.5.1': - dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - - '@clack/prompts@1.8.1': - dependencies: - '@clack/core': 1.5.1 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0)': @@ -3551,14 +3453,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kwsites/file-exists@1.1.1': - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@kwsites/promise-deferred@1.1.1': {} - '@mjackson/node-fetch-server@0.2.0': {} '@noble/ciphers@2.1.1': {} @@ -3714,12 +3608,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@simple-git/args-pathspec@1.0.4': {} - - '@simple-git/argv-parser@1.1.1': - dependencies: - '@simple-git/args-pathspec': 1.0.4 - '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.14': {} @@ -3897,10 +3785,6 @@ snapshots: arg@5.0.2: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - assertion-error@2.0.1: {} atomically@2.1.1: @@ -4205,8 +4089,6 @@ snapshots: escalade@3.2.0: {} - esprima@4.0.1: {} - estree-util-is-identifier-name@3.0.0: {} estree-walker@3.0.3: @@ -4219,26 +4101,12 @@ snapshots: exsolve@1.0.8: {} - extend-shallow@2.0.1: - dependencies: - is-extendable: 0.1.1 - extend@3.0.2: {} fast-deep-equal@3.1.3: {} - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} - fast-wrap-ansi@0.2.2: - dependencies: - fast-string-width: 3.0.2 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4276,13 +4144,6 @@ snapshots: graceful-fs@4.2.11: {} - gray-matter@4.0.3: - dependencies: - js-yaml: 3.15.2 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -4320,8 +4181,6 @@ snapshots: is-decimal@2.0.1: {} - is-extendable@0.1.1: {} - is-hexadecimal@2.0.1: {} is-interactive@2.0.0: {} @@ -4345,11 +4204,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - jsesc@3.0.2: {} json-schema-traverse@1.0.0: {} @@ -4358,8 +4212,6 @@ snapshots: json5@2.2.3: {} - kind-of@6.0.3: {} - kleur@4.1.5: {} kysely@0.28.11: {} @@ -4871,11 +4723,6 @@ snapshots: scheduler@0.27.0: {} - section-matter@1.0.0: - dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 - semver@6.3.1: {} semver@7.7.4: {} @@ -4920,18 +4767,6 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.36.0: - dependencies: - '@kwsites/file-exists': 1.1.1 - '@kwsites/promise-deferred': 1.1.1 - '@simple-git/args-pathspec': 1.0.4 - '@simple-git/argv-parser': 1.1.1 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - sisteransi@1.0.5: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -4945,8 +4780,6 @@ snapshots: space-separated-tokens@2.0.2: {} - sprintf-js@1.0.3: {} - stackback@0.0.2: {} std-env@3.10.0: {} @@ -4968,8 +4801,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-bom-string@1.0.0: {} - stubborn-fs@2.0.0: dependencies: stubborn-utils: 1.0.2 From ad21db2d5fd32133a7e41c7b0e44586f35c72394 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Fri, 30 Oct 2026 01:33:23 +0700 Subject: [PATCH 5/6] docs(cli): describe skillx add sources, safety checks and exit codes Co-Authored-By: Claude Opus 5.5 --- docs/cli-reference.md | 127 ++++++++++++++++++++++++++++++++---------- 1 file changed, 97 insertions(+), 30 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b3f33c7..2d071b1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -211,42 +211,106 @@ skillx import --dry-run ### `skillx add ` -Copy or symlink skills straight from a GitHub repo, a git URL or a local path -into agent skill directories. This does not go through the registry; use the -[installer](#installer) for signed releases, kits and collections. - -Skills are written once to a canonical `.agents/skills/` directory and -linked (or, with `--copy`, copied) into each target agent's skills directory. -With `--global` they go under your home directory, and the source is recorded in -the global skill lock. - -**Arguments:** -- ``: `owner/repo`, `owner/repo@skill`, a git or GitHub URL (a `tree//` URL selects a ref and subpath), or a local path. +Install skills straight from GitHub, an https git URL or a local directory. +`add` is a thin front end to the [installer](#installer): skills land in the +same runtime skills directory (`--runtime`, default `claude-code`) with the same +receipts, install lock, conflicts and backups as `skillx install`. Unlike a +registry release, a skill added this way is **not scanned, signed or pinned in +`skillx.lock.json`**; review it before use. + +**Sources:** +| Source | Meaning | +|--------|---------| +| `owner/repo` | A GitHub repository (default branch) | +| `owner/repo/skill` | One skill of it, by name or directory name (same as `--skill skill`) | +| `github:owner/repo[/path][@ref]` | A directory of the repository, at a branch or tag | +| `https://github.com/owner/repo[/tree/[/]]` | Read as `github:` | +| `https://host/repo.git` | Any https git repository (default branch) | +| `./dir`, `../dir`, an absolute path | A local directory | + +Only `https` is accepted. `http`, `ssh`, `git@host:` (scp style), `file://`, +`ext::` and other `::` transport helpers, a URL with a query or fragment, and +anything starting with `-` are refused as `usage_error`; git is also run with +every other protocol disabled. A path or ref with `..`, `.`, empty, `\` or `:` +segments is refused. `@publisher/package`, `kit:` and `collection:` belong to +`skillx install`; `@agentkit/*` is reserved. **Options:** | Flag | Description | Default | |------|-------------|---------| -| `-g, --global` | Install under the home directory instead of the project | asks, or project with `-y` | -| `-a, --agent ` | Target agents (`*` for all) | detected agents | -| `-s, --skill ` | Skills to install (`*` for all) | asks, or all with `-y` | -| `-l, --list` | List the skills in the source without installing | `false` | -| `-y, --yes` | Skip every prompt | `false` | -| `--copy` | Copy files instead of symlinking | symlink | -| `--all` | Shorthand for `--skill '*' --agent '*' -y` | `false` | -| `--full-depth` | Search all subdirectories even when a root SKILL.md exists | `false` | -| `--json` | Envelope with `status` (`listed`, `installed` or `cancelled`) and per-agent results; implies `--yes` | `false` | - -A missing local path or an unknown agent is a `usage_error` (exit 2), a source -with no matching skills is `not_found` (exit 3), and a failed clone is -`network_error` (exit 6). Review skills before use; they run with full agent -permissions. +| `-s, --skill ` | Skills to install, by front matter name or directory name (`*` for all) | ask; the only skill; all with `-y` | +| `-l, --list` | List the skills in the source; install nothing | `false` | +| `-y, --yes` | Do not ask (no selection prompt, no confirmation) | `false` | +| `--force` | Replace a skill added before, or install over a directory nothing owns (edited files are backed up, unknown files kept) | `false` | +| `--runtime ` | `claude-code` or `codex` | `claude-code` | +| `--json` | Envelope with `status` (`listed`, `installed` or `cancelled`); never asks, so several skills need `--skill` | `false` | + +**How a skill is found and read.** A skill is a directory with a regular +`SKILL.md` whose front matter (between a first `---` line and the next `---` +line) is plain YAML with a non-empty `name` and `description`. The front matter +is parsed as data only (YAML 1.2 core schema): a `---js`/`---javascript` fence +is not front matter, and tags or duplicate keys reject the file, so nothing in +a source ever runs. When the source (or `github:` path) is itself a skill, it +is the only one; otherwise every skill up to 6 directories deep counts, +outside `.git`, other VCS and cache directories and `node_modules`, and a +skill's own subdirectories are its content. Skills that cannot be installed are +listed under `skipped` with the reason. + +- **Names.** `name` is the installed directory name and is never rewritten: + it must be a valid alias (up to 64 lowercase letters, digits, `.`, `_`, `-`, + not ending in `.`, not a Windows device name). Names starting with `ak-` or `ak:` are reserved for + AgentKit and refused. Naming a skipped skill with `--skill` is a + `usage_error`; two chosen skills with one name are a `usage_error`. +- **Content.** Each chosen skill is checked with the release tree rules + (`agentkit.skill-tree/v1`): a symlink, junction, hardlink, git submodule or + other special entry, an unsafe or colliding path, or a tree over the release + limits (4,096 files, 32 MiB per file, 256 MiB in all) refuses the skill as + `incompatible`. Links are never followed or copied, neither while searching + nor while reading. +- **Git.** The clone is shallow, single-branch and without a checkout, into a + temporary directory removed afterwards: files are read from git objects, so + no repository file is written, and no hook, filter or LFS download runs. git + runs without a shell, with `--` before the URL and all credential prompts + off (`core.askPass=`, `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`), so + a private repository works only with credentials git already has (a + credential helper, or a token in the URL). +- **Credentials.** A token in the URL is used to clone and nothing else: it is + removed from the recorded source and masked (`https://***@host`) in every + message and envelope. + +**Where it is recorded.** Each added skill is its own installer source, +`add:`, recorded with the source it came from (no credentials): its +receipt's `package` is the skill's origin (for example +`github:owner/repo/skills/pdf`) and `version` the commit read, or `local`. +`skillx list` shows it, and `skillx uninstall add:` removes it with +ownership proof. `skillx update` skips added skills (naming one is a +`usage_error`); refresh one with `skillx add --force`. `sync --locked` +does not restore them. + +**Overwrites.** Nothing installed is replaced without `--force`: adding a name +that `add` installed before from a different tree is a `conflict`, and so is a +directory no source owns (with `--force` its unknown files are kept). A skill +that another source (a registry package, kit or collection) installed at +other content is a `conflict` even with `--force`; the same content just adds +`add:` as a second owner. + +**Exit codes.** `0` installed, listed or cancelled at a prompt; `2` +`usage_error` (bad source, refused transport or path, reserved or invalid name, +several skills under `--json` without `--skill`); `3` `not_found` (no skill, +no such `--skill`, path, repository or branch); `4` `incompatible` (a link, +special file or limit in a chosen skill); `5` `auth_error` (git asked for +credentials); `6` `network_error` (clone failed or timed out after 120 s); +`8` `conflict` (see Overwrites). Nothing is installed when any chosen skill +fails. **Examples:** ```bash -skillx add owner/repo --list -skillx add owner/repo --skill my-skill --agent claude-code codex -skillx add https://github.com/owner/repo/tree/main/skills/my-skill -y -skillx add ./my-skills --all --copy --json +skillx add anthropics/skills --list +skillx add anthropics/skills/pdf +skillx add github:acme/agent-skills/skills@v1.2 --skill lint review --yes +skillx add https://git.example.com/team/skills.git --skill '*' --json +skillx add ./my-skills --force +skillx uninstall add:pdf ``` --- @@ -547,7 +611,10 @@ is sent only over `https`; plain `http` is refused except for `localhost`, - **update** re-resolves installed sources (all, or those named) and applies the difference, then refreshes the lock file when its scope was updated. With nothing installed it prints `Nothing to update.` (`--json`: empty `outcomes`). -- **uninstall** drops a kit, a collection, or one or more packages; each + Skills installed by [`skillx add`](#skillx-add-source) (`add:`) are + skipped; naming one is a `usage_error`. +- **uninstall** drops a kit, a collection, one or more packages, or skills + installed by `skillx add` (`add:`, named on their own); each explicit package is its own source (`packages:@scope/name`), whether it was installed alone, with others, or by `sync --locked` from a packages lock. Each package is dropped from a packages lock file as soon as it is From 73556e613904bdd4304da289f98f223c786fe40e Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Fri, 30 Oct 2026 01:48:22 +0700 Subject: [PATCH 6/6] fix(cli): harden skillx add against inherited git env, alias abuse and non-interactive runs - Drop GIT_DIR, GIT_WORK_TREE and other repository-redirecting variables from the env passed to git, and pin ls-tree, rev-parse and cat-file to the clone with an explicit --git-dir, so an inherited GIT_DIR can no longer make add read another repository. - Catch the yaml alias-count error so a SKILL.md with alias abuse is skipped with a reason instead of aborting discovery for the source. - Refuse human mode on a non-terminal stdin without --yes or --json as a usage_error (exit 2) instead of a silent exit-0 cancel; an empty answer at a real prompt still cancels with exit 0. Co-Authored-By: Claude Opus 5.5 --- docs/cli-reference.md | 10 +++--- packages/cli/src/built-cli.test.ts | 8 ++++- packages/cli/src/commands/add-prompts.ts | 9 +++++ packages/cli/src/commands/add.ts | 3 +- packages/cli/src/lib/add/add-install.test.ts | 18 ++++++++++ packages/cli/src/lib/add/git-source.test.ts | 33 +++++++++++++++++++ packages/cli/src/lib/add/git-source.ts | 20 ++++++++--- packages/cli/src/lib/add/skill-frontmatter.ts | 8 ++++- packages/cli/src/lib/add/skill-safety.test.ts | 10 ++++++ 9 files changed, 107 insertions(+), 12 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2d071b1..ae0b840 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -240,7 +240,7 @@ segments is refused. `@publisher/package`, `kit:` and `collection:` belong to |------|-------------|---------| | `-s, --skill ` | Skills to install, by front matter name or directory name (`*` for all) | ask; the only skill; all with `-y` | | `-l, --list` | List the skills in the source; install nothing | `false` | -| `-y, --yes` | Do not ask (no selection prompt, no confirmation) | `false` | +| `-y, --yes` | Do not ask (no selection prompt, no confirmation); required to install in human mode when stdin is not a terminal | `false` | | `--force` | Replace a skill added before, or install over a directory nothing owns (edited files are backed up, unknown files kept) | `false` | | `--runtime ` | `claude-code` or `codex` | `claude-code` | | `--json` | Envelope with `status` (`listed`, `installed` or `cancelled`); never asks, so several skills need `--skill` | `false` | @@ -294,9 +294,11 @@ that another source (a registry package, kit or collection) installed at other content is a `conflict` even with `--force`; the same content just adds `add:` as a second owner. -**Exit codes.** `0` installed, listed or cancelled at a prompt; `2` -`usage_error` (bad source, refused transport or path, reserved or invalid name, -several skills under `--json` without `--skill`); `3` `not_found` (no skill, +**Exit codes.** `0` installed, listed, or cancelled by an empty answer at a +prompt; `2` `usage_error` (bad source, refused transport or path, reserved or +invalid name, several skills under `--json` without `--skill`, or stdin not a +terminal without `--yes` or `--json`: pass `--yes`, with `--skill` to choose, +since human mode cannot ask); `3` `not_found` (no skill, no such `--skill`, path, repository or branch); `4` `incompatible` (a link, special file or limit in a chosen skill); `5` `auth_error` (git asked for credentials); `6` `network_error` (clone failed or timed out after 120 s); diff --git a/packages/cli/src/built-cli.test.ts b/packages/cli/src/built-cli.test.ts index b185b01..c671f1d 100644 --- a/packages/cli/src/built-cli.test.ts +++ b/packages/cli/src/built-cli.test.ts @@ -3,7 +3,7 @@ import { execFile, execFileSync, execSync, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -390,6 +390,12 @@ describe("built cli", { timeout: 30_000 }, () => { expect((await run("add", src, "--force", "--json")).code).toBe(0); expect((await run("uninstall", "add:smoke-skill", "--json")).code).toBe(0); + // Human mode on a piped stdin cannot ask: a usage error, never a silent exit-0 cancel. + const piped = await run("add", src); + expect(piped.code).toBe(2); + expect(piped.stdout).not.toContain("Cancelled"); + expect(existsSync(join(home, ".claude", "skills", "smoke-skill"))).toBe(false); + const missing = await run("add", join(root, "no-such-dir"), "--json"); expect(missing.code).toBe(2); expect(JSON.parse(missing.stdout)).toMatchObject({ ok: false, error: { code: "usage_error" } }); diff --git a/packages/cli/src/commands/add-prompts.ts b/packages/cli/src/commands/add-prompts.ts index f9dc1c3..f96c76e 100644 --- a/packages/cli/src/commands/add-prompts.ts +++ b/packages/cli/src/commands/add-prompts.ts @@ -48,6 +48,15 @@ export function parseChoice(answer: string, count: number): number[] | null { return picked.size > 0 ? [...picked].sort((a, b) => a - b) : null; } +/** + * Human mode asks before it installs, so it needs a terminal. A pipe, a closed + * stdin or CI is a usage error, never a silent "cancelled" with exit 0. + */ +export function assertCanPrompt(mode: { yes?: boolean; json?: boolean }, isTTY = process.stdin.isTTY === true): void { + if (mode.yes || mode.json || isTTY) return; + throw usageError('stdin is not a terminal, so skillx add cannot ask; pass --yes to install without a prompt (with --skill to choose skills)'); +} + export async function selectSkills( found: readonly FoundSkill[], skipped: readonly SkippedSkill[], diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 02e84c2..e5a339e 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -9,7 +9,7 @@ import { openLocalSource } from '../lib/add/local-source.js'; import { discoverSkills, prepareSkill } from '../lib/add/skill-discovery.js'; import type { SourceSnapshot } from '../lib/add/skill-discovery.js'; import { CliError } from '../lib/output.js'; -import { confirmInstall, selectSkills } from './add-prompts.js'; +import { assertCanPrompt, confirmInstall, selectSkills } from './add-prompts.js'; import { printAdd, printSkipped } from './add-display.js'; import type { AddResult } from './add-display.js'; import { contextFrom, printPlan, runInstaller } from './install-runner.js'; @@ -47,6 +47,7 @@ export async function runAdd(input: string, options: AddOptions): Promise { expect((await refusal(() => add(['nope']))).code).toBe('not_found'); }); + it('refuses to prompt without a terminal unless --yes or --json says not to ask', () => { + const refused = (() => { + try { + assertCanPrompt({}, false); + } catch (error) { + return error as CliError; + } + throw new Error('expected a refusal'); + })(); + expect(refused).toBeInstanceOf(CliError); + expect(refused.code).toBe('usage_error'); + expect(refused.message).toContain('--yes'); + expect(() => assertCanPrompt({ yes: true }, false)).not.toThrow(); + expect(() => assertCanPrompt({ json: true }, false)).not.toThrow(); + expect(() => assertCanPrompt({}, true)).not.toThrow(); + }); + it('lists without installing anything', async () => { write('a/SKILL.md', skillMd('a')); const listed = await runAdd(src, { json: true, list: true }); diff --git a/packages/cli/src/lib/add/git-source.test.ts b/packages/cli/src/lib/add/git-source.test.ts index 7ce8692..37f5ebd 100644 --- a/packages/cli/src/lib/add/git-source.test.ts +++ b/packages/cli/src/lib/add/git-source.test.ts @@ -79,6 +79,39 @@ describe('git sources', () => { } }); + it('drops inherited variables that point git at another repository', () => { + const env = gitEnv({ GIT_DIR: '/x/.git', git_work_tree: '/x', GIT_INDEX_FILE: 'i', GIT_COMMON_DIR: 'c', PATH: '/bin' }); + expect(env).not.toHaveProperty('GIT_DIR'); + expect(env).not.toHaveProperty('git_work_tree'); + expect(env).not.toHaveProperty('GIT_INDEX_FILE'); + expect(env).not.toHaveProperty('GIT_COMMON_DIR'); + expect(env.PATH).toBe('/bin'); + }); + + it('reads the clone even when GIT_DIR names another repository', () => { + const other = join(root, 'other'); + mkdirSync(join(other, 'skills', 'intruder'), { recursive: true }); + writeFileSync(join(other, 'skills', 'intruder', 'SKILL.md'), '---\nname: intruder\ndescription: Wrong repo\n---\n'); + git(other, ['init', '-q', '-b', 'main']); + git(other, ['add', '-A']); + git(other, ['commit', '-q', '-m', 'other']); + const expected = git(join(root, 'repo'), ['rev-parse', 'HEAD']); + const saved = process.env.GIT_DIR; + process.env.GIT_DIR = join(other, '.git'); + try { + const source = openGitSource(repoUrl, 'test-repo', 'main', 'file'); + try { + expect(source.commit).toBe(expected); + expect(discoverSkills(source, '').skills.map((s) => s.name)).toEqual(['demo', 'leaky']); + } finally { + source.dispose(); + } + } finally { + if (saved === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = saved; + } + }); + it('lets git itself refuse every transport but https', () => { const refused = failure(() => openGitSource(repoUrl, 'test-repo')); expect(refused.code).toBe('usage_error'); diff --git a/packages/cli/src/lib/add/git-source.ts b/packages/cli/src/lib/add/git-source.ts index bfcc96e..4f10ee2 100644 --- a/packages/cli/src/lib/add/git-source.ts +++ b/packages/cli/src/lib/add/git-source.ts @@ -16,16 +16,26 @@ import { isDiscoverable } from './skill-discovery.js'; * URL, every credential prompt off (`core.askPass=` plus an empty * `GIT_ASKPASS`, `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`), and only * the https transport allowed, so `ext::`, `file://` and `ssh` are refused by - * git itself too, also on a redirect. Every message is redacted. + * git itself too, also on a redirect. Variables that point git at another + * repository (`GIT_DIR`, `GIT_WORK_TREE`, ...) are never inherited, and every + * read names the clone's `--git-dir`. Every message is redacted. */ const CLONE_TIMEOUT_MS = 120_000; const MAX_OUTPUT = 1024 * 1024 * 1024; +/** Variables that redirect git to another repository, index or object store. */ +const REPO_ENV = new Set(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_CEILING_DIRECTORIES']); + export function gitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { ...base, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_LFS_SKIP_SMUDGE: '1' }; + // Compared in upper case: Windows environment names are case-insensitive. + const env = Object.fromEntries(Object.entries(base).filter(([key]) => !REPO_ENV.has(key.toUpperCase()))); + return { ...env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_LFS_SKIP_SMUDGE: '1' }; } +/** Pins a read to the clone itself, never a repository found from the directory or environment. */ +const inClone = (repo: string) => ['-C', repo, `--git-dir=${join(repo, '.git')}`]; + /** The clone command line. `transport` is `https` outside tests. */ export function cloneArgs(url: string, dest: string, ref?: string, transport = 'https'): string[] { return [ @@ -63,7 +73,7 @@ function classify(stderr: string): 'auth_error' | 'not_found' | 'usage_error' | /** `ls-tree -r -l -z` records: every blob and submodule of the commit, with its mode and size. */ function listTree(dir: string): Array { - const out = git(['-C', dir, 'ls-tree', '-r', '-l', '-z', '--full-tree', 'HEAD'], 'git ls-tree'); + const out = git([...inClone(dir), 'ls-tree', '-r', '-l', '-z', '--full-tree', 'HEAD'], 'git ls-tree'); const decoder = new TextDecoder('utf-8', { fatal: true }); const entries: Array = []; for (const record of out.toString('latin1').split('\0')) { @@ -87,7 +97,7 @@ function listTree(dir: string): Array { /** Splits `git cat-file --batch` output into blob contents, in request order. */ function readBlobs(dir: string, oids: readonly string[]): Uint8Array[] { if (oids.length === 0) return []; - const out = git(['-C', dir, 'cat-file', '--batch'], 'git cat-file', `${oids.join('\n')}\n`); + const out = git([...inClone(dir), 'cat-file', '--batch'], 'git cat-file', `${oids.join('\n')}\n`); const blobs: Uint8Array[] = []; let at = 0; for (const oid of oids) { @@ -110,7 +120,7 @@ export function openGitSource(url: string, label: string, ref?: string, transpor const repo = join(dir, 'repo'); let commit: string; try { - commit = git(['-C', repo, 'rev-parse', '--verify', 'HEAD^{commit}'], 'git rev-parse').toString('utf8').trim(); + commit = git([...inClone(repo), 'rev-parse', '--verify', 'HEAD^{commit}'], 'git rev-parse').toString('utf8').trim(); } catch { throw new InstallError('not_found', `${label} has no commit to read`); } diff --git a/packages/cli/src/lib/add/skill-frontmatter.ts b/packages/cli/src/lib/add/skill-frontmatter.ts index 5b08268..38f5dc3 100644 --- a/packages/cli/src/lib/add/skill-frontmatter.ts +++ b/packages/cli/src/lib/add/skill-frontmatter.ts @@ -36,7 +36,13 @@ export function readFrontmatter(bytes: Uint8Array): Frontmatter { const doc = parseDocument(lines.slice(1, end).join('\n'), { version: '1.2', schema: 'core', uniqueKeys: true }); if (doc.errors.length > 0 || doc.warnings.length > 0) return fail('SKILL.md front matter is not plain YAML'); if (hasTags(doc.contents)) return fail('SKILL.md front matter uses YAML tags'); - const data: unknown = doc.toJS({ maxAliasCount: 10 }); + let data: unknown; + try { + // Throws when aliases expand past the cap (a "billion laughs" block). + data = doc.toJS({ maxAliasCount: 10 }); + } catch { + return fail('SKILL.md front matter uses too many aliases'); + } if (data === null || typeof data !== 'object' || Array.isArray(data)) return fail('SKILL.md front matter is not a mapping'); const { name, description } = data as Record; if (typeof name !== 'string' || name.trim() === '') return fail('SKILL.md front matter needs a non-empty name'); diff --git a/packages/cli/src/lib/add/skill-safety.test.ts b/packages/cli/src/lib/add/skill-safety.test.ts index 3d8cddf..815d489 100644 --- a/packages/cli/src/lib/add/skill-safety.test.ts +++ b/packages/cli/src/lib/add/skill-safety.test.ts @@ -62,6 +62,16 @@ describe('front matter is data, never code', () => { expect(readFrontmatter(enc('---\n- a\n---\n')).ok).toBe(false); }); + it('refuses alias abuse as a skipped skill instead of aborting discovery', () => { + const laughs = 'a: &a [x, x, x, x, x, x, x, x, x]\nb: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]\nc: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b]\nd: [*c, *c, *c, *c, *c, *c, *c, *c, *c]\n'; + expect(readFrontmatter(enc(skillMd('bomb', laughs)))).toEqual({ ok: false, reason: 'SKILL.md front matter uses too many aliases' }); + write('skills/bomb/SKILL.md', skillMd('bomb', laughs)); + write('skills/fine/SKILL.md', skillMd('fine')); + const { skills, skipped } = discoverSkills(openLocalSource(root), ''); + expect(skills.map((s) => s.name)).toEqual(['fine']); + expect(skipped).toEqual([{ dir: 'skills/bomb', reason: 'SKILL.md front matter uses too many aliases' }]); + }); + it('accepts plain front matter with a BOM and CRLF', () => { expect(readFrontmatter(enc('---\r\nname: demo\r\ndescription: Does a thing\r\n---\r\n'))).toEqual({ ok: true, name: 'demo', description: 'Does a thing' }); });