Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ jobs:
- name: Run type check
run: npm run typecheck

- name: Run tests
run: npm test

- name: Build
run: npm run build

- name: Run tests
run: npm test

- name: Test CLI
run: |
npm link
Expand Down
12 changes: 9 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ program
.command('sync')
.description('Update AGENTS.md with installed skills (interactive, pre-selects current state)')
.option('-y, --yes', 'Skip interactive selection, sync all skills')
.option('-o, --output <path>', 'Output file path (default: AGENTS.md)')
.action(syncAgentsMd);

program
Expand Down
192 changes: 168 additions & 24 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, readdirSync, existsSync, mkdirSync, rmSync, cpSync, statSync } from 'fs';
import { join, basename } from 'path';
import { join, basename, resolve } from 'path';
import { homedir } from 'os';
import { execSync } from 'child_process';
import chalk from 'chalk';
Expand All @@ -11,7 +11,42 @@ import { ANTHROPIC_MARKETPLACE_SKILLS } from '../utils/marketplace-skills.js';
import type { InstallOptions } from '../types.js';

/**
* Install skill from GitHub or Git URL
* Check if source is a local path
*/
function isLocalPath(source: string): boolean {
return (
source.startsWith('/') ||
source.startsWith('./') ||
source.startsWith('../') ||
source.startsWith('~/')
);
}

/**
* Check if source is a git URL (SSH, git://, or HTTPS)
*/
function isGitUrl(source: string): boolean {
return (
source.startsWith('git@') ||
source.startsWith('git://') ||
source.startsWith('http://') ||
source.startsWith('https://') ||
source.endsWith('.git')
);
}

/**
* Expand ~ to home directory
*/
function expandPath(source: string): string {
if (source.startsWith('~/')) {
return join(homedir(), source.slice(2));
}
return resolve(source);
}

/**
* Install skill from local path, GitHub, or Git URL
*/
export async function installSkill(source: string, options: InstallOptions): Promise<void> {
const folder = options.universal ? '.agent/skills' : '.claude/skills';
Expand All @@ -27,68 +62,158 @@ export async function installSkill(source: string, options: InstallOptions): Pro
console.log(`Installing from: ${chalk.cyan(source)}`);
console.log(`Location: ${location}\n`);

// Parse source
// Handle local path installation
if (isLocalPath(source)) {
const localPath = expandPath(source);
await installFromLocal(localPath, targetDir, options);
printPostInstallHints(isProject);
return;
}

// Parse git source
let repoUrl: string;
let skillSubpath: string;
let skillSubpath: string = '';

if (source.startsWith('http://') || source.startsWith('https://')) {
if (isGitUrl(source)) {
// Full git URL (SSH, HTTPS, git://)
repoUrl = source;
skillSubpath = '';
} else {
// GitHub shorthand: owner/repo or owner/repo/skill-path
const parts = source.split('/');
if (parts.length === 2) {
repoUrl = `https://github.com/${source}`;
skillSubpath = '';
} else if (parts.length > 2) {
repoUrl = `https://github.com/${parts[0]}/${parts[1]}`;
skillSubpath = parts.slice(2).join('/');
} else {
console.error(chalk.red('Error: Invalid source format'));
console.error('Expected: owner/repo or owner/repo/skill-name');
console.error('Expected: owner/repo, owner/repo/skill-name, git URL, or local path');
process.exit(1);
}
}

// Create unique temp directory per invocation
// Clone and install from git
const tempDir = join(homedir(), `.openskills-temp-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });

try {
// Clone repository with spinner
const spinner = ora('Cloning repository...').start();
execSync(`git clone --depth 1 --quiet "${repoUrl}" "${tempDir}/repo"`, {
stdio: 'ignore',
});
spinner.succeed('Repository cloned');
try {
execSync(`git clone --depth 1 --quiet "${repoUrl}" "${tempDir}/repo"`, {
stdio: 'pipe',
});
spinner.succeed('Repository cloned');
} catch (error) {
spinner.fail('Failed to clone repository');
const err = error as { stderr?: Buffer };
if (err.stderr) {
console.error(chalk.dim(err.stderr.toString().trim()));
}
console.error(chalk.yellow('\nTip: For private repos, ensure git SSH keys or credentials are configured'));
process.exit(1);
}

const repoDir = join(tempDir, 'repo');

if (skillSubpath) {
// Specific skill path provided - install directly
await installSpecificSkill(repoDir, skillSubpath, targetDir, isProject);
await installSpecificSkill(repoDir, skillSubpath, targetDir, isProject, options);
} else {
// Find all skills in repo
await installFromRepo(repoDir, targetDir, options);
}
} finally {
// Cleanup
rmSync(tempDir, { recursive: true, force: true });
}

printPostInstallHints(isProject);
}

/**
* Print post-install hints
*/
function printPostInstallHints(isProject: boolean): void {
console.log(`\n${chalk.dim('Read skill:')} ${chalk.cyan('openskills read <skill-name>')}`);
if (isProject) {
console.log(`${chalk.dim('Sync to AGENTS.md:')} ${chalk.cyan('openskills sync')}`);
}
}

/**
* Install from local path (directory containing skills or single skill)
*/
async function installFromLocal(localPath: string, targetDir: string, options: InstallOptions): Promise<void> {
if (!existsSync(localPath)) {
console.error(chalk.red(`Error: Path does not exist: ${localPath}`));
process.exit(1);
}

const stats = statSync(localPath);
if (!stats.isDirectory()) {
console.error(chalk.red('Error: Path must be a directory'));
process.exit(1);
}

// Check if this is a single skill (has SKILL.md) or a directory of skills
const skillMdPath = join(localPath, 'SKILL.md');
if (existsSync(skillMdPath)) {
// Single skill directory
const isProject = targetDir.includes(process.cwd());
await installSingleLocalSkill(localPath, targetDir, isProject, options);
} else {
// Directory containing multiple skills
await installFromRepo(localPath, targetDir, options);
}
}

/**
* Install a single local skill directory
*/
async function installSingleLocalSkill(
skillDir: string,
targetDir: string,
isProject: boolean,
options: InstallOptions
): Promise<void> {
const skillMdPath = join(skillDir, 'SKILL.md');
const content = readFileSync(skillMdPath, 'utf-8');

if (!hasValidFrontmatter(content)) {
console.error(chalk.red('Error: Invalid SKILL.md (missing YAML frontmatter)'));
process.exit(1);
}

const skillName = basename(skillDir);
const targetPath = join(targetDir, skillName);

const shouldInstall = await warnIfConflict(skillName, targetPath, isProject, options.yes);
if (!shouldInstall) {
console.log(chalk.yellow(`Skipped: ${skillName}`));
return;
}

mkdirSync(targetDir, { recursive: true });
// 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);
}

cpSync(skillDir, targetPath, { recursive: true, dereference: true });

console.log(chalk.green(`✅ Installed: ${skillName}`));
console.log(` Location: ${targetPath}`);
}

/**
* Install specific skill from subpath (no interaction needed)
*/
async function installSpecificSkill(
repoDir: string,
skillSubpath: string,
targetDir: string,
isProject: boolean
isProject: boolean,
options: InstallOptions
): Promise<void> {
const skillDir = join(repoDir, skillSubpath);
const skillMdPath = join(skillDir, 'SKILL.md');
Expand All @@ -109,14 +234,21 @@ async function installSpecificSkill(
const targetPath = join(targetDir, skillName);

// Warn about potential conflicts
const shouldInstall = await warnIfConflict(skillName, targetPath, isProject);
const shouldInstall = await warnIfConflict(skillName, targetPath, isProject, options.yes);
if (!shouldInstall) {
console.log(chalk.yellow(`Skipped: ${skillName}`));
return;
}

mkdirSync(targetDir, { recursive: true });
cpSync(skillDir, targetPath, { recursive: true });
// 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);
}
cpSync(skillDir, targetPath, { recursive: true, dereference: true });

console.log(chalk.green(`✅ Installed: ${skillName}`));
console.log(` Location: ${targetPath}`);
Expand Down Expand Up @@ -228,14 +360,21 @@ async function installFromRepo(

for (const info of skillsToInstall) {
// Warn about conflicts
const shouldInstall = await warnIfConflict(info.skillName, info.targetPath, isProject);
const shouldInstall = await warnIfConflict(info.skillName, info.targetPath, isProject, options.yes);
if (!shouldInstall) {
console.log(chalk.yellow(`Skipped: ${info.skillName}`));
continue; // Skip this skill, continue with next
}

mkdirSync(targetDir, { recursive: true });
cpSync(info.skillDir, info.targetPath, { recursive: true });
// Security: ensure target path stays within target directory
const resolvedTargetPath = resolve(info.targetPath);
const resolvedTargetDir = resolve(targetDir);
if (!resolvedTargetPath.startsWith(resolvedTargetDir + '/')) {
console.error(chalk.red(`Security error: Installation path outside target directory`));
continue;
}
cpSync(info.skillDir, info.targetPath, { recursive: true, dereference: true });

console.log(chalk.green(`✅ Installed: ${info.skillName}`));
installedCount++;
Expand All @@ -248,9 +387,14 @@ async function installFromRepo(
* Warn if installing could conflict with Claude Code marketplace
* Returns true if should proceed, false if should skip
*/
async function warnIfConflict(skillName: string, targetPath: string, isProject: boolean): Promise<boolean> {
async function warnIfConflict(skillName: string, targetPath: string, isProject: boolean, skipPrompt = false): Promise<boolean> {
// Check if overwriting existing skill
if (existsSync(targetPath)) {
if (skipPrompt) {
// Auto-overwrite in non-interactive mode
console.log(chalk.dim(`Overwriting: ${skillName}`));
return true;
}
try {
const shouldOverwrite = await confirm({
message: chalk.yellow(`Skill '${skillName}' already exists. Overwrite?`),
Expand Down
Loading