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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -185,6 +192,7 @@ npx openskills remove <name> # 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 <name>` — Install from a Git branch/tag (or use `<source>#<branch>`)
- `-o, --output <path>` — Output file for sync (default: `AGENTS.md`)

---
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>', 'Git branch/tag to install from (or use <source>#<branch>)')
.action(installSkill);

program
Expand Down
57 changes: 56 additions & 1 deletion src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface InstallSourceInfo {
sourceType: SkillSourceType;
repoUrl?: string;
localRoot?: string;
branch?: string;
}

/**
Expand Down Expand Up @@ -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: <source>#<branch>');
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 <name> or <source>#<branch>, 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<void> {
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
Expand All @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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(),
};
Expand Down
7 changes: 6 additions & 1 deletion src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
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;
branch?: string;
}

export interface SkillMetadata {
Expand Down
1 change: 1 addition & 0 deletions src/utils/skill-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface SkillSourceMetadata {
source: string;
sourceType: SkillSourceType;
repoUrl?: string;
branch?: string;
subpath?: string;
localPath?: string;
installedAt: string;
Expand Down
59 changes: 59 additions & 0 deletions tests/commands/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"'
);
});
});
53 changes: 53 additions & 0 deletions tests/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});
14 changes: 12 additions & 2 deletions tests/integration/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
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 } {
Expand Down Expand Up @@ -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 });
});
Expand Down