Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 <agents...>` | Target specific agents (use `'*'` for all supported agents) |
| `--copy` | Copy skill directories instead of symlinking |

### `skills find`

Search for skills interactively or by keyword.
Expand Down
19 changes: 19 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`
);
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -140,6 +145,11 @@ ${BOLD}Remove Options:${RESET}
-s, --skill <skills> 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 <agents> Specify agents to materialize skills for
--copy Copy skill directories instead of linking

${BOLD}Experimental Sync Options:${RESET}
-a, --agent <agents> Specify agents to install to (use '*' for all agents)
Expand All @@ -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
Expand Down Expand Up @@ -681,6 +694,12 @@ async function main(): Promise<void> {
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);
Expand Down
230 changes: 130 additions & 100 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<InstallResult> {
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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading