diff --git a/src/commands/install.ts b/src/commands/install.ts index b4c015e..0441d99 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -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 */ @@ -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) ); } @@ -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); } @@ -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); } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index d8746eb..9478723 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -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'; @@ -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 { - const outputPath = options.output || 'AGENTS.md'; + const outputPath = expandOutputPath(options.output || 'AGENTS.md'); const outputName = basename(outputPath); // Validate output file is markdown diff --git a/tests/commands/install.test.ts b/tests/commands/install.test.ts index f89d811..67e0179 100644 --- a/tests/commands/install.test.ts +++ b/tests/commands/install.test.ts @@ -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) ); }; @@ -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); @@ -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); }; @@ -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')); @@ -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', () => { diff --git a/tests/commands/sync.test.ts b/tests/commands/sync.test.ts index a6ebc70..e7c6f1e 100644 --- a/tests/commands/sync.test.ts +++ b/tests/commands/sync.test.ts @@ -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 { @@ -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', @@ -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', () => {