diff --git a/README.md b/README.md index fa7087c..aff2425 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,7 @@ openskills remove # 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 ` — Custom output file for sync (default: `AGENTS.md`) @@ -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 @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 4804a6f..01ed2e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,10 +39,11 @@ program program .command('install ') - .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 diff --git a/src/commands/install.ts b/src/commands/install.ts index dc35b18..e97d2e9 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -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'; @@ -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}`); @@ -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++; diff --git a/src/types.ts b/src/types.ts index f0d0040..ef7316d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface InstallOptions { global?: boolean; universal?: boolean; yes?: boolean; + symlink?: boolean; } export interface SkillMetadata { diff --git a/tests/commands/symlink.test.ts b/tests/commands/symlink.test.ts new file mode 100644 index 0000000..21c68ea --- /dev/null +++ b/tests/commands/symlink.test.ts @@ -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(); + }); +});