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
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ openskills remove <name> # Remove specific skill

- `--global` — Install globally to `~/.claude/skills` (default: project install)
- `--universal` — Install to `.agent/skills/` instead of `.claude/skills/` (advanced)
- `-s, --symlink` — Create symbolic links for local paths instead of copying files
- `-y, --yes` — Skip all prompts including overwrites (for scripts/CI)
- `-o, --output <path>` — Custom output file for sync (default: `AGENTS.md`)

Expand Down Expand Up @@ -356,6 +357,12 @@ openskills install ~/my-skills/custom-skill

# Install all skills from a directory
openskills install ./my-skills-folder

# Using symlinks (recommended for development)
openskills install ./local-skills/my-skill --symlink

# Install all skills from a directory as symlinks
openskills install ./my-skills-folder -s
```

### Install from Private Git Repos
Expand Down Expand Up @@ -479,26 +486,27 @@ Base directory: /path/to/.claude/skills/my-skill

### Local Development with Symlinks

For active skill development, symlink your skill into the skills directory:
For active skill development, use the `--symlink` (or `-s`) flag. This creates a symbolic link in the target directory pointing back to your source code.

```bash
# Clone a skills repo you're developing
git clone git@github.com:your-org/my-skills.git ~/dev/my-skills

# Symlink into your project's skills directory
mkdir -p .claude/skills
ln -s ~/dev/my-skills/my-skill .claude/skills/my-skill
# Install a local skill as a symlink
openskills install ~/dev/my-skills/my-skill --symlink

# Now changes to ~/dev/my-skills/my-skill are immediately reflected
openskills list # Shows my-skill
openskills sync # Includes my-skill in AGENTS.md
```

This approach lets you:
- Edit skills in your preferred location
- Keep skills under version control
- Test changes instantly without reinstalling
- Share skills across multiple projects via symlinks
**Benefits:**
- ✅ **Live Updates**: Changes in your source directory are reflected instantly.
- ✅ **Version Control**: Keep your skills in a dedicated repo while using them across projects.
- ✅ **No Duplication**: Avoid manual copying when updating skills.

> [!NOTE]
> **Conflict Resolution**: If a physical directory already exists at the target location, `openskills` will (with your confirmation, or automatically with `-y`) delete the existing folder and replace it with a symbolic link.

### Authoring Guide

Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ program

program
.command('install <source>')
.description('Install skill from GitHub or Git URL')
.description('Install skill from GitHub, Git URL, or local path')
.option('-g, --global', 'Install globally (default: project install)')
.option('-u, --universal', 'Install to .agent/skills/ (for universal AGENTS.md usage)')
.option('-y, --yes', 'Skip interactive selection, install all skills found')
.option('-s, --symlink', 'Create symbolic link for local paths instead of copying')
.action(installSkill);

program
Expand Down
23 changes: 17 additions & 6 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync, existsSync, mkdirSync, rmSync, cpSync, statSync } from 'fs';
import { readFileSync, readdirSync, existsSync, mkdirSync, rmSync, cpSync, statSync, symlinkSync } from 'fs';
import { join, basename, resolve } from 'path';
import { homedir } from 'os';
import { execSync } from 'child_process';
Expand Down Expand Up @@ -194,12 +194,15 @@ async function installSingleLocalSkill(
// Security: ensure target path stays within target directory
const resolvedTargetPath = resolve(targetPath);
const resolvedTargetDir = resolve(targetDir);
if (!resolvedTargetPath.startsWith(resolvedTargetDir + '/')) {
console.error(chalk.red(`Security error: Installation path outside target directory`));
process.exit(1);
if (existsSync(targetPath)) {
rmSync(targetPath, { recursive: true, force: true });
}

cpSync(skillDir, targetPath, { recursive: true, dereference: true });
if (options.symlink) {
symlinkSync(resolve(skillDir), targetPath, 'dir');
} else {
cpSync(skillDir, targetPath, { recursive: true, dereference: true });
}

console.log(chalk.green(`✅ Installed: ${skillName}`));
console.log(` Location: ${targetPath}`);
Expand Down Expand Up @@ -374,7 +377,15 @@ async function installFromRepo(
console.error(chalk.red(`Security error: Installation path outside target directory`));
continue;
}
cpSync(info.skillDir, info.targetPath, { recursive: true, dereference: true });
if (existsSync(info.targetPath)) {
rmSync(info.targetPath, { recursive: true, force: true });
}

if (options.symlink) {
symlinkSync(resolve(info.skillDir), info.targetPath, 'dir');
} else {
cpSync(info.skillDir, info.targetPath, { recursive: true, dereference: true });
}

console.log(chalk.green(`✅ Installed: ${info.skillName}`));
installedCount++;
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface InstallOptions {
global?: boolean;
universal?: boolean;
yes?: boolean;
symlink?: boolean;
}

export interface SkillMetadata {
Expand Down
97 changes: 97 additions & 0 deletions tests/commands/symlink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdirSync, writeFileSync, rmSync, readlinkSync, lstatSync } from 'fs';
import { join } from 'path';
import { installSkill } from '../../src/commands/install.js';

describe('Symlink support', () => {
const testDir = join(process.cwd(), 'temp-test-symlink');
const sourceSkillDir = join(testDir, 'source-skill');
const targetBaseDir = join(testDir, 'target-skills');
const targetPath = join(targetBaseDir, 'source-skill');

beforeEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
mkdirSync(testDir);
mkdirSync(sourceSkillDir);
mkdirSync(targetBaseDir);
});

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});

it('should create a symlink when --symlink is provided for a local path', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(testDir);
writeFileSync(join(sourceSkillDir, 'SKILL.md'), '---\nname: Test Skill\ndescription: Test\n---');

// We use --universal and --project (default) to target testDir/.agent/skills
const options = { symlink: true, universal: true, yes: true };
const finalTargetDir = join(testDir, '.agent/skills');
const finalTargetPath = join(finalTargetDir, 'source-skill');

await installSkill(sourceSkillDir, options);

expect(existsSync(finalTargetPath)).toBe(true);
expect(lstatSync(finalTargetPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(finalTargetPath)).toBe(sourceSkillDir);

vi.restoreAllMocks();
});

it('should overwrite existing directory with a symlink when --symlink is provided', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(testDir);
writeFileSync(join(sourceSkillDir, 'SKILL.md'), '---\nname: Test Skill\ndescription: Test\n---');

const finalTargetDir = join(testDir, '.agent/skills');
const finalTargetPath = join(finalTargetDir, 'source-skill');

// Create a directory at the target path first
mkdirSync(finalTargetDir, { recursive: true });
mkdirSync(finalTargetPath);
writeFileSync(join(finalTargetPath, 'old.txt'), 'old');

const options = { symlink: true, universal: true, yes: true };
await installSkill(sourceSkillDir, options);

expect(existsSync(finalTargetPath)).toBe(true);
expect(lstatSync(finalTargetPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(finalTargetPath)).toBe(sourceSkillDir);
expect(existsSync(join(finalTargetPath, 'old.txt'))).toBe(false);

vi.restoreAllMocks();
});

it('should create multiple symlinks when installing from a directory of skills', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(testDir);

const skill1Dir = join(sourceSkillDir, 'skill1');
const skill2Dir = join(sourceSkillDir, 'skill2');
mkdirSync(skill1Dir);
mkdirSync(skill2Dir);
writeFileSync(join(skill1Dir, 'SKILL.md'), '---\nname: Skill 1\n---');
writeFileSync(join(skill2Dir, 'SKILL.md'), '---\nname: Skill 2\n---');

// We use -y to skip interactive selection
const options = { symlink: true, universal: true, yes: true };
const finalTargetDir = join(testDir, '.agent/skills');

await installSkill(sourceSkillDir, options);

const target1 = join(finalTargetDir, 'skill1');
const target2 = join(finalTargetDir, 'skill2');

expect(existsSync(target1)).toBe(true);
expect(lstatSync(target1).isSymbolicLink()).toBe(true);
expect(readlinkSync(target1)).toBe(skill1Dir);

expect(existsSync(target2)).toBe(true);
expect(lstatSync(target2).isSymbolicLink()).toBe(true);
expect(readlinkSync(target2)).toBe(skill2Dir);

vi.restoreAllMocks();
});
});