From 9ddc554f31f0d38e3fd5e7b2e48afe2ef3c7e9c6 Mon Sep 17 00:00:00 2001 From: hagenkellermann Date: Mon, 16 Mar 2026 23:33:00 +0100 Subject: [PATCH 1/2] feat: add 'link' command to link skills to agents from the .agents/skills directory There currently is no way of simply linking any existing skills in .agents/skills to agent specific folders. With the new 'link' command this now is possible which is especially handy when sharing custom skills in a repository where different developers use different agents. --- src/cli.ts | 17 ++++ src/installer.ts | 230 +++++++++++++++++++++++++-------------------- src/link.ts | 168 +++++++++++++++++++++++++++++++++ tests/link.test.ts | 97 +++++++++++++++++++ 4 files changed, 412 insertions(+), 100 deletions(-) create mode 100644 src/link.ts create mode 100644 tests/link.test.ts diff --git a/src/cli.ts b/src/cli.ts index 30282096..d4db5523 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,10 @@ ${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} + -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 +175,8 @@ ${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 --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 +692,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..ad588b2e --- /dev/null +++ b/src/link.ts @@ -0,0 +1,168 @@ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { readdir } from 'fs/promises'; +import { platform } from 'os'; +import { agents, detectInstalledAgents } from './agents.ts'; +import { getCanonicalSkillsDir, materializeCanonicalSkillForAgent } from './installer.ts'; +import type { AgentType } from './types.ts'; + +export interface LinkOptions { + agent?: string[]; + copy?: boolean; +} + +function dedupe(items: T[]): T[] { + return [...new Set(items)]; +} + +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 === '--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 spinner = p.spinner(); + const canonicalDir = getCanonicalSkillsDir(false, cwd); + const validAgents = Object.keys(agents); + const requestedAgents = dedupe([...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 || platform() === 'win32' ? 'copy' : 'symlink'; + + spinner.stop( + `Found ${pc.green(String(skillNames.length))} skill${skillNames.length !== 1 ? 's' : ''}` + ); + + p.note( + [ + `${pc.dim('source:')} ${pc.cyan('.agents/skills/')}`, + `${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, { + 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..dbfc4757 --- /dev/null +++ b/tests/link.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, lstatSync } from 'fs'; +import { join } from 'path'; +import { platform } from 'os'; +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' + ); +} + +describe('link command', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `skills-link-test-${Date.now()}`); + 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]'); + }); + + 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'); + + if (platform() === 'win32') { + expect(lstatSync(agentPath).isDirectory()).toBe(true); + } else { + expect(lstatSync(agentPath).isSymbolicLink()).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'); + }); +}); From eac862e75e3037b4ee3a007a708436dc6edc16c6 Mon Sep 17 00:00:00 2001 From: Hagen Kellermann Date: Tue, 17 Mar 2026 12:07:18 +0100 Subject: [PATCH 2/2] Add global option to link command Remove --copy default for windows for link command Add link command to readme --- README.md | 27 ++++++++++++++++++++++++ src/cli.ts | 2 ++ src/link.ts | 20 ++++++++++-------- tests/link.test.ts | 51 ++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 83 insertions(+), 17 deletions(-) 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 d4db5523..751f9326 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -147,6 +147,7 @@ ${BOLD}Remove Options:${RESET} --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 @@ -176,6 +177,7 @@ ${BOLD}Examples:${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} diff --git a/src/link.ts b/src/link.ts index ad588b2e..33137d7a 100644 --- a/src/link.ts +++ b/src/link.ts @@ -1,20 +1,16 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { readdir } from 'fs/promises'; -import { platform } from 'os'; 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; } -function dedupe(items: T[]): T[] { - return [...new Set(items)]; -} - export function parseLinkOptions(args: string[]): { agents: string[]; options: LinkOptions } { const positionalAgents: string[] = []; const options: LinkOptions = {}; @@ -35,6 +31,8 @@ export function parseLinkOptions(args: string[]): { agents: string[]; options: L nextArg = args[i]; } i--; + } else if (arg === '-g' || arg === '--global') { + options.global = true; } else if (arg === '--copy') { options.copy = true; } else if (!arg.startsWith('-')) { @@ -50,10 +48,11 @@ export async function runLink( options: LinkOptions = {} ): Promise { const cwd = process.cwd(); + const isGlobal = options.global ?? false; const spinner = p.spinner(); - const canonicalDir = getCanonicalSkillsDir(false, cwd); + const canonicalDir = getCanonicalSkillsDir(isGlobal, cwd); const validAgents = Object.keys(agents); - const requestedAgents = dedupe([...positionalAgents, ...(options.agent ?? [])]); + const requestedAgents = [...new Set([...positionalAgents, ...(options.agent ?? [])])]; spinner.start('Scanning canonical skills...'); const entries = await readdir(canonicalDir, { withFileTypes: true }).catch(() => []); @@ -93,7 +92,8 @@ export async function runLink( targetAgents = detectedAgents; } - const installMode = options.copy || platform() === 'win32' ? 'copy' : 'symlink'; + 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' : ''}` @@ -101,7 +101,8 @@ export async function runLink( p.note( [ - `${pc.dim('source:')} ${pc.cyan('.agents/skills/')}`, + `${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'), @@ -121,6 +122,7 @@ export async function runLink( for (const skillName of skillNames) { for (const agent of targetAgents) { const result = await materializeCanonicalSkillForAgent(skillName, agent, { + global: isGlobal, cwd, mode: installMode, }); diff --git a/tests/link.test.ts b/tests/link.test.ts index dbfc4757..2a0626ce 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, lstatSync } from 'fs'; import { join } from 'path'; -import { platform } from 'os'; import { tmpdir } from 'os'; import { runCli } from '../src/test-utils.ts'; @@ -15,11 +14,28 @@ function createCanonicalSkill(root: string, skillName: string, content = '# Shar ); } +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()}`); + testDir = join( + tmpdir(), + `skills-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); mkdirSync(testDir, { recursive: true }); }); @@ -32,6 +48,7 @@ describe('link command', () => { 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', () => { @@ -51,12 +68,8 @@ describe('link command', () => { 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'); - - if (platform() === 'win32') { - expect(lstatSync(agentPath).isDirectory()).toBe(true); - } else { - expect(lstatSync(agentPath).isSymbolicLink()).toBe(true); - } + const agentStats = lstatSync(agentPath); + expect(agentStats.isSymbolicLink() || agentStats.isDirectory()).toBe(true); }); it('preserves canonical directories for universal agents', () => { @@ -94,4 +107,26 @@ describe('link command', () => { 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' + ); + }); });