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
20 changes: 17 additions & 3 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ interface InstallSourceInfo {
localRoot?: string;
}

const WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/;
const WINDOWS_MSYS_PATH = /^\/([a-zA-Z])([\\/].*)$/;

/**
* Check if source is a local path
*/
Expand All @@ -27,7 +30,9 @@ function isLocalPath(source: string): boolean {
source.startsWith('/') ||
source.startsWith('./') ||
source.startsWith('../') ||
source.startsWith('~/')
source.startsWith('~/') ||
source.startsWith('~\\') ||
WINDOWS_DRIVE_PATH.test(source)
);
}

Expand Down Expand Up @@ -59,9 +64,18 @@ function getRepoName(repoUrl: string): string | null {
* Expand ~ to home directory
*/
function expandPath(source: string): string {
if (source.startsWith('~/')) {
if (source.startsWith('~/') || source.startsWith('~\\')) {
return join(homedir(), source.slice(2));
}

if (process.platform === 'win32') {
const msysMatch = source.match(WINDOWS_MSYS_PATH);
if (msysMatch) {
const [, drive, rest] = msysMatch;
return resolve(`${drive.toUpperCase()}:${rest}`);
}
}

return resolve(source);
}

Expand Down Expand Up @@ -491,7 +505,7 @@ function buildMetadataFromSource(
return buildLocalMetadata(sourceInfo, skillDir);
}
const subpath = relative(repoDir, skillDir);
const normalizedSubpath = subpath === '' ? '' : subpath;
const normalizedSubpath = subpath === '' ? '' : subpath.split(sep).join('/');
return buildGitMetadata(sourceInfo, normalizedSubpath);
}

Expand Down
13 changes: 11 additions & 2 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { dirname, basename } from 'path';
import { dirname, basename, resolve, join } from 'path';
import { homedir } from 'os';
import chalk from 'chalk';
import { checkbox } from '@inquirer/prompts';
import { ExitPromptError } from '@inquirer/core';
Expand All @@ -12,11 +13,19 @@ export interface SyncOptions {
output?: string;
}

function expandOutputPath(outputPath: string): string {
if (outputPath.startsWith('~/') || outputPath.startsWith('~\\')) {
return resolve(join(homedir(), outputPath.slice(2)));
}

return resolve(outputPath);
}

/**
* Sync installed skills to a markdown file
*/
export async function syncAgentsMd(options: SyncOptions = {}): Promise<void> {
const outputPath = options.output || 'AGENTS.md';
const outputPath = expandOutputPath(options.output || 'AGENTS.md');
const outputName = basename(outputPath);

// Validate output file is markdown
Expand Down
46 changes: 43 additions & 3 deletions tests/commands/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import { extractYamlField, hasValidFrontmatter } from '../../src/utils/yaml.js';

describe('install.ts helper functions', () => {
describe('isLocalPath detection', () => {
const windowsDrivePath = /^[a-zA-Z]:[\\/]/;
// Replicate the logic from isLocalPath()
const isLocalPath = (source: string): boolean => {
return (
source.startsWith('/') ||
source.startsWith('./') ||
source.startsWith('../') ||
source.startsWith('~/')
source.startsWith('~/') ||
source.startsWith('~\\') ||
windowsDrivePath.test(source)
);
};

Expand All @@ -40,6 +43,12 @@ describe('install.ts helper functions', () => {
expect(isLocalPath('~/.claude/skills')).toBe(true);
});

it('should detect Windows local path formats', () => {
expect(isLocalPath('~\\skills\\my-skill')).toBe(true);
expect(isLocalPath('C:/Users/dev/skills/my-skill')).toBe(true);
expect(isLocalPath('D:\\Users\\dev\\skills\\my-skill')).toBe(true);
});

it('should NOT detect GitHub shorthand as local path', () => {
expect(isLocalPath('owner/repo')).toBe(false);
expect(isLocalPath('anthropics/skills')).toBe(false);
Expand Down Expand Up @@ -108,11 +117,21 @@ describe('install.ts helper functions', () => {
});

describe('expandPath tilde expansion', () => {
const windowsMsysPath = /^\/([a-zA-Z])([\\/].*)$/;
// Replicate the logic from expandPath()
const expandPath = (source: string): string => {
if (source.startsWith('~/')) {
const expandPath = (source: string, platform: NodeJS.Platform = process.platform): string => {
if (source.startsWith('~/') || source.startsWith('~\\')) {
return join(homedir(), source.slice(2));
}

if (platform === 'win32') {
const msysMatch = source.match(windowsMsysPath);
if (msysMatch) {
const [, drive, rest] = msysMatch;
return resolve(`${drive.toUpperCase()}:${rest}`);
}
}

return resolve(source);
};

Expand All @@ -126,6 +145,17 @@ describe('install.ts helper functions', () => {
expect(expanded).toBe(join(homedir(), '.claude/skills'));
});

it('should expand ~\\ on Windows-style inputs', () => {
const expanded = expandPath('~\\skills\\demo');
expect(expanded).toBe(join(homedir(), 'skills\\demo'));
});

it('should map /c/... paths on Windows', () => {
const expanded = expandPath('/c/Users/dev/skills/demo', 'win32');
expect(expanded).toContain('C:');
expect(expanded.toLowerCase()).toContain('users');
});

it('should resolve relative paths', () => {
const expanded = expandPath('./relative');
expect(expanded).toBe(resolve('./relative'));
Expand Down Expand Up @@ -243,6 +273,16 @@ describe('install.ts helper functions', () => {
}
});
});

describe('metadata subpath normalization', () => {
const normalizeSubpath = (subpath: string, pathSep: string): string =>
subpath === '' ? '' : subpath.split(pathSep).join('/');

it('normalizes Windows separators in subpath metadata', () => {
expect(normalizeSubpath('plugins\\ui-design\\skills\\accessibility-compliance', '\\'))
.toBe('plugins/ui-design/skills/accessibility-compliance');
});
});
});

describe('GitHub shorthand parsing', () => {
Expand Down
15 changes: 15 additions & 0 deletions tests/commands/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs';
import { join, dirname, basename } from 'path';
import { tmpdir } from 'os';
import { homedir } from 'os';

// Test the sync utility functions directly
import {
Expand Down Expand Up @@ -179,6 +180,13 @@ describe('sync --output flag logic', () => {
});

describe('output path validation', () => {
const expandOutputPath = (outputPath: string): string => {
if (outputPath.startsWith('~/') || outputPath.startsWith('~\\')) {
return join(homedir(), outputPath.slice(2));
}
return outputPath;
};

it('should accept .md files', () => {
const validPaths = [
'AGENTS.md',
Expand All @@ -204,6 +212,13 @@ describe('sync --output flag logic', () => {
expect(path.endsWith('.md')).toBe(false);
}
});

it('should expand tilde paths for output destination', () => {
expect(expandOutputPath('~/.config/opencode/AGENTS.md'))
.toBe(join(homedir(), '.config/opencode/AGENTS.md'));
expect(expandOutputPath('~\\.config\\opencode\\AGENTS.md'))
.toBe(join(homedir(), '.config\\opencode\\AGENTS.md'));
});
});

describe('auto-create file behavior', () => {
Expand Down