diff --git a/README.md b/README.md index bb09029d..5fd92767 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ When installing interactively, you can choose: | Command | Description | | ---------------------------- | ---------------------------------------------- | | `npx skills list` | List installed skills (alias: `ls`) | +| `npx skills link [agent]` | Link canonical skills into agent directories | | `npx skills find [query]` | Search for skills interactively or by keyword | | `npx skills remove [skills]` | Remove installed skills from agents | | `npx skills check` | Check for available skill updates | @@ -116,6 +117,32 @@ npx skills ls -g npx skills ls -a claude-code -a cursor ``` +### `skills link` + +Materialize canonical skills from `.agents/skills/` into agent-specific skill directories. +This is useful when your project or home directory already has canonical skills and you want +to link them into agents like Claude Code or Continue. + +```bash +# Link project-level canonical skills into a specific agent +npx skills link claude-code + +# Link global canonical skills into a specific agent +npx skills link -g continue + +# Auto-detect installed agents and link project-level skills +npx skills link + +# Copy instead of symlinking +npx skills link --copy continue +``` + +| Option | Description | +| ------------------------- | ------------------------------------------------------------ | +| `-g, --global` | Link global skills from `~/.agents/skills/` | +| `-a, --agent ` | Target specific agents (use `'*'` for all supported agents) | +| `--copy` | Copy skill directories instead of symlinking | + ### `skills find` Search for skills interactively or by keyword. diff --git a/src/cli.ts b/src/cli.ts index 30282096..751f9326 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { runFind } from './find.ts'; import { runInstallFromLock } from './install.ts'; import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; +import { runLink, parseLinkOptions } from './link.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { track } from './telemetry.ts'; import { fetchSkillFolderHash, getGitHubToken } from './skill-lock.ts'; @@ -76,6 +77,9 @@ function showBanner(): void { console.log( ` ${DIM}$${RESET} ${TEXT}npx skills list${RESET} ${DIM}List installed skills${RESET}` ); + console.log( + ` ${DIM}$${RESET} ${TEXT}npx skills link ${DIM}[agent]${RESET} ${DIM}Link skills from .agents/skills${RESET}` + ); console.log( ` ${DIM}$${RESET} ${TEXT}npx skills find ${DIM}[query]${RESET} ${DIM}Search for skills${RESET}` ); @@ -113,6 +117,7 @@ ${BOLD}Manage Skills:${RESET} https://github.com/vercel-labs/agent-skills remove [skills] Remove installed skills list, ls List installed skills + link [agent] Link canonical .agents/skills into agent directories find [query] Search for skills interactively ${BOLD}Updates:${RESET} @@ -140,6 +145,11 @@ ${BOLD}Remove Options:${RESET} -s, --skill Specify skills to remove (use '*' for all skills) -y, --yes Skip confirmation prompts --all Shorthand for --skill '*' --agent '*' -y + +${BOLD}Link Options:${RESET} + -g, --global Link global skills from ~/.agents/skills + -a, --agent Specify agents to materialize skills for + --copy Copy skill directories instead of linking ${BOLD}Experimental Sync Options:${RESET} -a, --agent Specify agents to install to (use '*' for all agents) @@ -166,6 +176,9 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} skills ls -g ${DIM}# list global skills${RESET} ${DIM}$${RESET} skills ls -a claude-code ${DIM}# filter by agent${RESET} ${DIM}$${RESET} skills ls --json ${DIM}# JSON output${RESET} + ${DIM}$${RESET} skills link claude-code ${DIM}# link from .agents/skills${RESET} + ${DIM}$${RESET} skills link -g continue ${DIM}# link from ~/.agents/skills${RESET} + ${DIM}$${RESET} skills link --copy continue ${DIM}# force local copies${RESET} ${DIM}$${RESET} skills find ${DIM}# interactive search${RESET} ${DIM}$${RESET} skills find typescript ${DIM}# search by keyword${RESET} ${DIM}$${RESET} skills check @@ -681,6 +694,12 @@ async function main(): Promise { await runSync(restArgs, syncOptions); break; } + case 'link': { + showLogo(); + const { agents: linkAgents, options: linkOptions } = parseLinkOptions(restArgs); + await runLink(linkAgents, linkOptions); + break; + } case 'list': case 'ls': await runList(restArgs); diff --git a/src/installer.ts b/src/installer.ts index 43ac9c85..730d214b 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -22,7 +22,7 @@ import { parseSkillMd } from './skills.ts'; export type InstallMode = 'symlink' | 'copy'; -interface InstallResult { +export interface InstallResult { success: boolean; path: string; canonicalPath?: string; @@ -277,41 +277,11 @@ export async function installSkillForAgent( // Symlink mode: copy to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); await copyDirectory(skill.path, canonicalDir); - - // For universal agents with global install, the skill is already in the canonical - // ~/.agents/skills directory. Skip creating a symlink to the agent-specific global dir - // (e.g. ~/.copilot/skills) to avoid duplicates. - if (isGlobal && isUniversalAgent(agentType)) { - return { - success: true, - path: canonicalDir, - canonicalPath: canonicalDir, - mode: 'symlink', - }; - } - - const symlinkCreated = await createSymlink(canonicalDir, agentDir); - - if (!symlinkCreated) { - // Symlink failed, fall back to copy - await cleanAndCreateDirectory(agentDir); - await copyDirectory(skill.path, agentDir); - - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, - mode: 'symlink', - symlinkFailed: true, - }; - } - - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, + return await materializeCanonicalSkillForAgent(skillName, agentType, { + global: isGlobal, + cwd, mode: 'symlink', - }; + }); } catch (error) { return { success: false, @@ -444,6 +414,123 @@ export function getCanonicalPath( return canonicalPath; } +/** + * Materialize an already-installed canonical skill into an agent-specific location. + * This is used both by `skills link` and by the install flows after they finish + * writing canonical content into .agents/skills. + */ +export async function materializeCanonicalSkillForAgent( + skillName: string, + agentType: AgentType, + options: { global?: boolean; cwd?: string; mode?: InstallMode } = {} +): Promise { + const agent = agents[agentType]; + const isGlobal = options.global ?? false; + const cwd = options.cwd || process.cwd(); + const installMode = options.mode ?? 'symlink'; + + if (isGlobal && agent.globalSkillsDir === undefined) { + return { + success: false, + path: '', + mode: installMode, + error: `${agent.displayName} does not support global skill installation`, + }; + } + + const canonicalDir = getCanonicalPath(skillName, { global: isGlobal, cwd }); + const agentBase = getAgentBaseDir(agentType, isGlobal, cwd); + const agentDir = join(agentBase, sanitizeName(skillName)); + + if (!isPathSafe(agentBase, agentDir)) { + return { + success: false, + path: agentDir, + mode: installMode, + error: 'Invalid skill name: potential path traversal detected', + }; + } + + try { + const canonicalStats = await stat(canonicalDir); + if (!canonicalStats.isDirectory()) { + return { + success: false, + path: agentDir, + canonicalPath: canonicalDir, + mode: installMode, + error: 'Canonical skill is not a directory', + }; + } + } catch { + return { + success: false, + path: agentDir, + canonicalPath: canonicalDir, + mode: installMode, + error: 'Canonical skill not found', + }; + } + + const [realCanonicalDir, realAgentDir] = await Promise.all([ + resolveParentSymlinks(canonicalDir), + resolveParentSymlinks(agentDir), + ]); + + if (realCanonicalDir === realAgentDir) { + return { + success: true, + path: canonicalDir, + canonicalPath: canonicalDir, + mode: installMode, + }; + } + + try { + if (installMode === 'copy') { + await cleanAndCreateDirectory(agentDir); + await copyDirectory(canonicalDir, agentDir); + + return { + success: true, + path: agentDir, + canonicalPath: canonicalDir, + mode: 'copy', + }; + } + + const symlinkCreated = await createSymlink(canonicalDir, agentDir); + + if (!symlinkCreated) { + await cleanAndCreateDirectory(agentDir); + await copyDirectory(canonicalDir, agentDir); + + return { + success: true, + path: agentDir, + canonicalPath: canonicalDir, + mode: 'symlink', + symlinkFailed: true, + }; + } + + return { + success: true, + path: agentDir, + canonicalPath: canonicalDir, + mode: 'symlink', + }; + } catch (error) { + return { + success: false, + path: agentDir, + canonicalPath: canonicalDir, + mode: installMode, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + /** * Install a remote skill from any host provider. * The skill directory name is derived from the installName field. @@ -518,40 +605,11 @@ export async function installRemoteSkillForAgent( await cleanAndCreateDirectory(canonicalDir); const skillMdPath = join(canonicalDir, 'SKILL.md'); await writeFile(skillMdPath, skill.content, 'utf-8'); - - // For universal agents with global install, skip creating agent-specific symlink - if (isGlobal && isUniversalAgent(agentType)) { - return { - success: true, - path: canonicalDir, - canonicalPath: canonicalDir, - mode: 'symlink', - }; - } - - const symlinkCreated = await createSymlink(canonicalDir, agentDir); - - if (!symlinkCreated) { - // Symlink failed, fall back to copy - await cleanAndCreateDirectory(agentDir); - const agentSkillMdPath = join(agentDir, 'SKILL.md'); - await writeFile(agentSkillMdPath, skill.content, 'utf-8'); - - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, - mode: 'symlink', - symlinkFailed: true, - }; - } - - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, + return await materializeCanonicalSkillForAgent(skillName, agentType, { + global: isGlobal, + cwd, mode: 'symlink', - }; + }); } catch (error) { return { success: false, @@ -656,39 +714,11 @@ export async function installWellKnownSkillForAgent( // Symlink mode: write to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); await writeSkillFiles(canonicalDir); - - // For universal agents with global install, skip creating agent-specific symlink - if (isGlobal && isUniversalAgent(agentType)) { - return { - success: true, - path: canonicalDir, - canonicalPath: canonicalDir, - mode: 'symlink', - }; - } - - const symlinkCreated = await createSymlink(canonicalDir, agentDir); - - if (!symlinkCreated) { - // Symlink failed, fall back to copy - await cleanAndCreateDirectory(agentDir); - await writeSkillFiles(agentDir); - - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, - mode: 'symlink', - symlinkFailed: true, - }; - } - - return { - success: true, - path: agentDir, - canonicalPath: canonicalDir, + return await materializeCanonicalSkillForAgent(skillName, agentType, { + global: isGlobal, + cwd, mode: 'symlink', - }; + }); } catch (error) { return { success: false, diff --git a/src/link.ts b/src/link.ts new file mode 100644 index 00000000..33137d7a --- /dev/null +++ b/src/link.ts @@ -0,0 +1,170 @@ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { readdir } from 'fs/promises'; +import { agents, detectInstalledAgents } from './agents.ts'; +import { getCanonicalSkillsDir, materializeCanonicalSkillForAgent } from './installer.ts'; +import type { AgentType } from './types.ts'; + +export interface LinkOptions { + global?: boolean; + agent?: string[]; + copy?: boolean; +} + +export function parseLinkOptions(args: string[]): { agents: string[]; options: LinkOptions } { + const positionalAgents: string[] = []; + const options: LinkOptions = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg) { + continue; + } + + if (arg === '-a' || arg === '--agent') { + options.agent = options.agent || []; + i++; + let nextArg = args[i]; + while (i < args.length && nextArg && !nextArg.startsWith('-')) { + options.agent.push(nextArg); + i++; + nextArg = args[i]; + } + i--; + } else if (arg === '-g' || arg === '--global') { + options.global = true; + } else if (arg === '--copy') { + options.copy = true; + } else if (!arg.startsWith('-')) { + positionalAgents.push(arg); + } + } + + return { agents: positionalAgents, options }; +} + +export async function runLink( + positionalAgents: string[], + options: LinkOptions = {} +): Promise { + const cwd = process.cwd(); + const isGlobal = options.global ?? false; + const spinner = p.spinner(); + const canonicalDir = getCanonicalSkillsDir(isGlobal, cwd); + const validAgents = Object.keys(agents); + const requestedAgents = [...new Set([...positionalAgents, ...(options.agent ?? [])])]; + + spinner.start('Scanning canonical skills...'); + const entries = await readdir(canonicalDir, { withFileTypes: true }).catch(() => []); + const skillNames = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .sort(); + + if (skillNames.length === 0) { + spinner.stop(pc.yellow('No canonical skills found')); + p.outro( + pc.dim(`No skills found in ${pc.cyan('.agents/skills/')}. Install or restore skills first.`) + ); + return; + } + + let targetAgents: AgentType[]; + if (requestedAgents.includes('*')) { + targetAgents = validAgents as AgentType[]; + } else if (requestedAgents.length > 0) { + const invalidAgents = requestedAgents.filter((agent) => !validAgents.includes(agent)); + if (invalidAgents.length > 0) { + spinner.stop(pc.red('Invalid agent selection')); + p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`); + p.log.info(`Valid agents: ${validAgents.join(', ')}`); + process.exit(1); + } + targetAgents = requestedAgents as AgentType[]; + } else { + const detectedAgents = await detectInstalledAgents(); + if (detectedAgents.length === 0) { + spinner.stop(pc.yellow('No agents detected')); + p.log.error('No installed agents detected. Pass an agent name explicitly.'); + p.log.info(`Valid agents: ${validAgents.join(', ')}`); + process.exit(1); + } + targetAgents = detectedAgents; + } + + const installMode = options.copy ? 'copy' : 'symlink'; + const sourceLabel = isGlobal ? '~/.agents/skills/' : '.agents/skills/'; + + spinner.stop( + `Found ${pc.green(String(skillNames.length))} skill${skillNames.length !== 1 ? 's' : ''}` + ); + + p.note( + [ + `${pc.dim('source:')} ${pc.cyan(sourceLabel)}`, + `${pc.dim('scope:')} ${isGlobal ? 'global' : 'project'}`, + `${pc.dim('agents:')} ${targetAgents.map((agent) => agents[agent].displayName).join(', ')}`, + `${pc.dim('mode:')} ${installMode === 'copy' ? 'copy' : 'symlink with copy fallback'}`, + ].join('\n'), + 'Link Summary' + ); + + spinner.start('Linking skills...'); + + const results: Array<{ + success: boolean; + skill: string; + agent: AgentType; + symlinkFailed?: boolean; + error?: string; + }> = []; + + for (const skillName of skillNames) { + for (const agent of targetAgents) { + const result = await materializeCanonicalSkillForAgent(skillName, agent, { + global: isGlobal, + cwd, + mode: installMode, + }); + + results.push({ + success: result.success, + skill: skillName, + agent, + symlinkFailed: result.symlinkFailed, + error: result.error, + }); + } + } + + const successful = results.filter((result) => result.success); + const failed = results.filter((result) => !result.success); + const copyFallbacks = successful.filter((result) => result.symlinkFailed); + + spinner.stop( + `Processed ${pc.green(String(successful.length))}/${String(results.length)} link target${results.length !== 1 ? 's' : ''}` + ); + + if (copyFallbacks.length > 0) { + p.log.warn( + `${copyFallbacks.length} target${copyFallbacks.length !== 1 ? 's' : ''} fell back to copy mode` + ); + } + + if (failed.length > 0) { + for (const result of failed.slice(0, 10)) { + p.log.error( + `${result.skill} → ${agents[result.agent].displayName}: ${result.error ?? 'Unknown error'}` + ); + } + if (failed.length > 10) { + p.log.info(`${failed.length - 10} additional link failures omitted`); + } + p.outro(pc.red('Link completed with errors.')); + process.exit(1); + } + + p.outro( + `Linked ${pc.green(String(skillNames.length))} skill${skillNames.length !== 1 ? 's' : ''} for ${pc.green(String(targetAgents.length))} agent${targetAgents.length !== 1 ? 's' : ''}.` + ); +} diff --git a/tests/link.test.ts b/tests/link.test.ts new file mode 100644 index 00000000..2a0626ce --- /dev/null +++ b/tests/link.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, lstatSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { runCli } from '../src/test-utils.ts'; + +function createCanonicalSkill(root: string, skillName: string, content = '# Shared Skill\n') { + const skillDir = join(root, '.agents', 'skills', skillName); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: ${skillName}\ndescription: test skill\n---\n\n${content}`, + 'utf-8' + ); +} + +function createGlobalCanonicalSkill( + homeDir: string, + skillName: string, + content = '# Shared Skill\n' +) { + const skillDir = join(homeDir, '.agents', 'skills', skillName); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: ${skillName}\ndescription: test skill\n---\n\n${content}`, + 'utf-8' + ); +} + +describe('link command', () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `skills-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('shows link in help output', () => { + const result = runCli(['--help']); + expect(result.stdout).toContain('link [agent]'); + expect(result.stdout).toContain('Link global skills from ~/.agents/skills'); + }); + + it('shows link in banner output', () => { + const result = runCli([]); + expect(result.stdout).toContain('npx skills link'); + }); + + it('links canonical skills into a non-universal agent directory', () => { + createCanonicalSkill(testDir, 'shared-skill'); + + const result = runCli(['link', 'claude-code'], testDir); + expect(result.exitCode).toBe(0); + + const canonicalPath = join(testDir, '.agents', 'skills', 'shared-skill'); + const agentPath = join(testDir, '.claude', 'skills', 'shared-skill'); + + expect(existsSync(canonicalPath)).toBe(true); + expect(existsSync(join(agentPath, 'SKILL.md'))).toBe(true); + expect(readFileSync(join(agentPath, 'SKILL.md'), 'utf-8')).toContain('name: shared-skill'); + const agentStats = lstatSync(agentPath); + expect(agentStats.isSymbolicLink() || agentStats.isDirectory()).toBe(true); + }); + + it('preserves canonical directories for universal agents', () => { + createCanonicalSkill(testDir, 'universal-skill'); + + const result = runCli(['link', 'codex'], testDir); + expect(result.exitCode).toBe(0); + + const canonicalPath = join(testDir, '.agents', 'skills', 'universal-skill'); + expect(lstatSync(canonicalPath).isDirectory()).toBe(true); + expect(readFileSync(join(canonicalPath, 'SKILL.md'), 'utf-8')).toContain( + 'name: universal-skill' + ); + }); + + it('auto-detects installed agents when none are provided', () => { + createCanonicalSkill(testDir, 'detected-skill'); + mkdirSync(join(testDir, '.continue'), { recursive: true }); + + const result = runCli(['link'], testDir); + expect(result.exitCode).toBe(0); + expect(existsSync(join(testDir, '.continue', 'skills', 'detected-skill', 'SKILL.md'))).toBe( + true + ); + }); + + it('supports forced copy mode', () => { + createCanonicalSkill(testDir, 'copy-skill'); + + const result = runCli(['link', 'continue', '--copy'], testDir); + expect(result.exitCode).toBe(0); + + const agentPath = join(testDir, '.continue', 'skills', 'copy-skill'); + expect(lstatSync(agentPath).isSymbolicLink()).toBe(false); + expect(lstatSync(agentPath).isDirectory()).toBe(true); + expect(readFileSync(join(agentPath, 'SKILL.md'), 'utf-8')).toContain('name: copy-skill'); + }); + + it('links global canonical skills when -g is provided', () => { + const homeDir = join(testDir, 'fake-home'); + createGlobalCanonicalSkill(homeDir, 'global-skill'); + + const result = runCli(['link', '-g', 'continue'], testDir, { + HOME: homeDir, + USERPROFILE: homeDir, + }); + expect(result.exitCode).toBe(0); + + const globalCanonicalPath = join(homeDir, '.agents', 'skills', 'global-skill'); + const globalAgentPath = join(homeDir, '.continue', 'skills', 'global-skill'); + const projectAgentPath = join(testDir, '.continue', 'skills', 'global-skill'); + + expect(existsSync(globalCanonicalPath)).toBe(true); + expect(existsSync(join(globalAgentPath, 'SKILL.md'))).toBe(true); + expect(existsSync(projectAgentPath)).toBe(false); + expect(readFileSync(join(globalAgentPath, 'SKILL.md'), 'utf-8')).toContain( + 'name: global-skill' + ); + }); +});