diff --git a/README.md b/README.md index 03e2d77..cd265c2 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,13 @@ npx openskills install ./local-skills/my-skill npx openskills install git@github.com:your-org/private-skills.git ``` +### Install from a Specific Branch or Tag + +```bash +npx openskills install your-org/your-skills --branch develop +npx openskills install your-org/your-skills#develop +``` + --- ## 🌍 Universal Mode (Multi-Agent Setups) @@ -185,6 +192,7 @@ npx openskills remove # Remove specific skill - `--global` — Install globally to `~/.claude/skills` (default: project install) - `--universal` — Install to `.agent/skills/` instead of `.claude/skills/` - `-y, --yes` — Skip prompts (useful for CI) +- `-b, --branch ` — Install from a Git branch/tag (or use `#`) - `-o, --output ` — Output file for sync (default: `AGENTS.md`) --- diff --git a/src/cli.ts b/src/cli.ts index 41d0c95..e840fe7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,6 +47,7 @@ program .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('-b, --branch ', 'Git branch/tag to install from (or use #)') .action(installSkill); program diff --git a/src/commands/install.ts b/src/commands/install.ts index b4c015e..e30087c 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -17,6 +17,7 @@ interface InstallSourceInfo { sourceType: SkillSourceType; repoUrl?: string; localRoot?: string; + branch?: string; } /** @@ -77,10 +78,58 @@ function isPathInside(targetPath: string, targetDir: string): boolean { return resolvedTargetPath.startsWith(resolvedTargetDirWithSep); } +interface ParsedSourceRef { + source: string; + branch?: string; +} + +function parseSourceRef(source: string, branchOption?: string): ParsedSourceRef { + const hashIndex = source.lastIndexOf('#'); + if (hashIndex === -1) { + return { source, branch: branchOption }; + } + + const parsedSource = source.slice(0, hashIndex).trim(); + const parsedBranch = source.slice(hashIndex + 1).trim(); + + if (!parsedSource) { + console.error(chalk.red('Error: Invalid source format')); + console.error('Expected format: #'); + process.exit(1); + } + + if (!parsedBranch) { + console.error(chalk.red('Error: Branch name cannot be empty')); + process.exit(1); + } + + if (branchOption) { + console.error(chalk.red('Error: Branch specified twice')); + console.error('Use either --branch or #, not both'); + process.exit(1); + } + + return { source: parsedSource, branch: parsedBranch }; +} + +function buildCloneCommand(repoUrl: string, destination: string, branch?: string): string { + const branchArgs = branch ? ` --branch "${branch}" --single-branch` : ''; + return `git clone --depth 1${branchArgs} --quiet "${repoUrl}" "${destination}"`; +} + /** * Install skill from local path, GitHub, or Git URL */ export async function installSkill(source: string, options: InstallOptions): Promise { + const sourceRef = isLocalPath(source) ? { source, branch: options.branch } : parseSourceRef(source, options.branch); + source = sourceRef.source; + const branch = sourceRef.branch; + + if (isLocalPath(source) && branch) { + console.error(chalk.red('Error: --branch is only supported for Git sources')); + process.exit(1); + } + const folder = options.universal ? '.agent/skills' : '.claude/skills'; const isProject = !options.global; // Default to project unless --global specified const targetDir = isProject @@ -95,6 +144,9 @@ export async function installSkill(source: string, options: InstallOptions): Pro const globalLocation = `~/${folder}`; console.log(`Installing from: ${chalk.cyan(source)}`); + if (branch) { + console.log(`Branch: ${chalk.cyan(branch)}`); + } console.log(`Location: ${location}`); if (isProject) { console.log( @@ -114,6 +166,7 @@ export async function installSkill(source: string, options: InstallOptions): Pro source, sourceType: 'local', localRoot: localPath, + branch, }; await installFromLocal(localPath, targetDir, options, sourceInfo); printPostInstallHints(isProject); @@ -149,12 +202,13 @@ export async function installSkill(source: string, options: InstallOptions): Pro source, sourceType: 'git', repoUrl, + branch, }; try { const spinner = ora('Cloning repository...').start(); try { - execSync(`git clone --depth 1 --quiet "${repoUrl}" "${tempDir}/repo"`, { + execSync(buildCloneCommand(repoUrl, `${tempDir}/repo`, branch), { stdio: 'pipe', }); spinner.succeed('Repository cloned'); @@ -500,6 +554,7 @@ function buildGitMetadata(sourceInfo: InstallSourceInfo, subpath: string): Skill source: sourceInfo.source, sourceType: 'git', repoUrl: sourceInfo.repoUrl, + branch: sourceInfo.branch, subpath, installedAt: new Date().toISOString(), }; diff --git a/src/commands/update.ts b/src/commands/update.ts index 90664a7..11fe777 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -8,6 +8,11 @@ import { findAllSkills } from '../utils/skills.js'; import { normalizeSkillNames } from '../utils/skill-names.js'; import { readSkillMetadata, writeSkillMetadata } from '../utils/skill-metadata.js'; +function buildCloneCommand(repoUrl: string, destination: string, branch?: string): string { + const branchArgs = branch ? ` --branch "${branch}" --single-branch` : ''; + return `git clone --depth 1${branchArgs} --quiet "${repoUrl}" "${destination}"`; +} + /** * Update installed skills from their recorded source metadata. */ @@ -94,7 +99,7 @@ export async function updateSkills(skillNames: string[] | string | undefined): P const spinner = ora(`Updating ${skill.name}...`).start(); try { - execSync(`git clone --depth 1 --quiet "${metadata.repoUrl}" "${tempDir}/repo"`, { stdio: 'pipe' }); + execSync(buildCloneCommand(metadata.repoUrl, `${tempDir}/repo`, metadata.branch), { stdio: 'pipe' }); const repoDir = join(tempDir, 'repo'); const subpath = metadata.subpath && metadata.subpath !== '.' ? metadata.subpath : ''; const sourceDir = subpath ? join(repoDir, subpath) : repoDir; diff --git a/src/types.ts b/src/types.ts index f0d0040..67b654a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface InstallOptions { global?: boolean; universal?: boolean; yes?: boolean; + branch?: string; } export interface SkillMetadata { diff --git a/src/utils/skill-metadata.ts b/src/utils/skill-metadata.ts index bf720b3..9bf2b86 100644 --- a/src/utils/skill-metadata.ts +++ b/src/utils/skill-metadata.ts @@ -9,6 +9,7 @@ export interface SkillSourceMetadata { source: string; sourceType: SkillSourceType; repoUrl?: string; + branch?: string; subpath?: string; localPath?: string; installedAt: string; diff --git a/tests/commands/install.test.ts b/tests/commands/install.test.ts index f89d811..403d919 100644 --- a/tests/commands/install.test.ts +++ b/tests/commands/install.test.ts @@ -289,3 +289,62 @@ describe('GitHub shorthand parsing', () => { expect(result).toBeNull(); }); }); + +describe('Git source branch parsing', () => { + const parseSourceRef = (source: string, branchOption?: string): { source: string; branch?: string } => { + const hashIndex = source.lastIndexOf('#'); + if (hashIndex === -1) { + return { source, branch: branchOption }; + } + + const parsedSource = source.slice(0, hashIndex).trim(); + const parsedBranch = source.slice(hashIndex + 1).trim(); + + if (!parsedSource) { + throw new Error('invalid-source'); + } + if (!parsedBranch) { + throw new Error('empty-branch'); + } + if (branchOption) { + throw new Error('branch-specified-twice'); + } + + return { source: parsedSource, branch: parsedBranch }; + }; + + const buildCloneCommand = (repoUrl: string, destination: string, branch?: string): string => { + const branchArgs = branch ? ` --branch "${branch}" --single-branch` : ''; + return `git clone --depth 1${branchArgs} --quiet "${repoUrl}" "${destination}"`; + }; + + it('should parse branch from source#branch', () => { + const result = parseSourceRef('owner/repo#develop'); + expect(result).toEqual({ source: 'owner/repo', branch: 'develop' }); + }); + + it('should parse branch from --branch when source has no #', () => { + const result = parseSourceRef('owner/repo', 'develop'); + expect(result).toEqual({ source: 'owner/repo', branch: 'develop' }); + }); + + it('should fail if branch is specified both inline and via option', () => { + expect(() => parseSourceRef('owner/repo#develop', 'main')).toThrow('branch-specified-twice'); + }); + + it('should fail if source has empty branch marker', () => { + expect(() => parseSourceRef('owner/repo#')).toThrow('empty-branch'); + }); + + it('should include --branch flags in clone command when branch is set', () => { + expect(buildCloneCommand('git@github.com:owner/repo.git', '/tmp/repo', 'feature')).toBe( + 'git clone --depth 1 --branch "feature" --single-branch --quiet "git@github.com:owner/repo.git" "/tmp/repo"' + ); + }); + + it('should omit --branch flags in clone command when branch is not set', () => { + expect(buildCloneCommand('git@github.com:owner/repo.git', '/tmp/repo')).toBe( + 'git clone --depth 1 --quiet "git@github.com:owner/repo.git" "/tmp/repo"' + ); + }); +}); diff --git a/tests/commands/update.test.ts b/tests/commands/update.test.ts index 41f7568..63998d0 100644 --- a/tests/commands/update.test.ts +++ b/tests/commands/update.test.ts @@ -2,9 +2,36 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { execSync } from 'child_process'; import { updateSkills } from '../../src/commands/update.js'; import { writeSkillMetadata } from '../../src/utils/skill-metadata.js'; +function run(cmd: string, cwd: string): void { + execSync(cmd, { cwd, stdio: 'pipe' }); +} + +function createBranchRepo(repoDir: string): void { + mkdirSync(repoDir, { recursive: true }); + run('git init', repoDir); + run('git config user.email "test@example.com"', repoDir); + run('git config user.name "Test User"', repoDir); + + writeFileSync( + join(repoDir, 'SKILL.md'), + "---\nname: demo\ndescription: main\n---\n\n# Demo\nmain\n" + ); + run('git add SKILL.md', repoDir); + run('git commit -m "main"', repoDir); + run('git checkout -b feature', repoDir); + + writeFileSync( + join(repoDir, 'SKILL.md'), + "---\nname: demo\ndescription: feature\n---\n\n# Demo\nfeature\n" + ); + run('git add SKILL.md', repoDir); + run('git commit -m "feature"', repoDir); +} + describe('updateSkills', () => { const originalCwd = process.cwd(); const originalHome = process.env.HOME; @@ -72,4 +99,30 @@ describe('updateSkills', () => { const content = readFileSync(join(targetDir, 'SKILL.md'), 'utf-8'); expect(content).toContain('v1'); }); + + it('updates a git skill from the recorded branch', async () => { + const repoDir = join(projectDir, 'source.git'); + createBranchRepo(repoDir); + + const targetDir = join(projectDir, '.claude/skills/demo'); + mkdirSync(targetDir, { recursive: true }); + writeFileSync( + join(targetDir, 'SKILL.md'), + "---\nname: demo\ndescription: stale\n---\n\n# Demo\nstale\n" + ); + + writeSkillMetadata(targetDir, { + source: 'source.git#feature', + sourceType: 'git', + repoUrl: 'source.git', + branch: 'feature', + subpath: '', + installedAt: '2026-01-01T00:00:00.000Z', + }); + + await updateSkills([]); + + const updated = readFileSync(join(targetDir, 'SKILL.md'), 'utf-8'); + expect(updated).toContain('feature'); + }); }); diff --git a/tests/integration/e2e.test.ts b/tests/integration/e2e.test.ts index b4d328d..3683f93 100644 --- a/tests/integration/e2e.test.ts +++ b/tests/integration/e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -6,7 +6,8 @@ import { execSync } from 'child_process'; const testId = Math.random().toString(36).slice(2); const testTempDir = join(tmpdir(), `openskills-e2e-${testId}`); -const cliPath = join(process.cwd(), 'dist', 'cli.js'); +const repoRoot = process.cwd(); +const cliPath = join(repoRoot, 'dist', 'cli.js'); // Helper to run CLI commands function runCli(args: string, cwd?: string): { stdout: string; stderr: string; exitCode: number } { @@ -46,6 +47,15 @@ Instructions for ${name}. } describe('End-to-end CLI tests', () => { + beforeAll(() => { + if (!existsSync(cliPath)) { + execSync('npm run build', { + cwd: repoRoot, + stdio: 'pipe', + }); + } + }); + beforeEach(() => { mkdirSync(testTempDir, { recursive: true }); });