diff --git a/src/config.test.ts b/src/config.test.ts index f56fc0d..35b305d 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, afterEach } from 'vitest'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { expandTildePath, getOpenCodeConfigPaths } from './config'; +import { + expandTildePath, + getOpenCodeConfigPaths, + normalizeBasePaths, + resolveBasePath, +} from './config'; describe('expandTildePath', () => { describe('tilde expansion', () => { @@ -72,6 +77,65 @@ describe('expandTildePath', () => { }); }); +describe('resolveBasePath', () => { + const projectDirectory = '/workspace/project'; + + it('resolves relative paths from project directory', () => { + const result = resolveBasePath('.opencode/skills', projectDirectory); + expect(result).toBe('/workspace/project/.opencode/skills'); + }); + + it('keeps absolute paths absolute', () => { + const absolutePath = '/custom/skills'; + const result = resolveBasePath(absolutePath, projectDirectory); + expect(result).toBe(absolutePath); + }); + + it('expands tilde paths before resolving', () => { + const result = resolveBasePath('~/skills', projectDirectory); + expect(result).toBe(join(homedir(), 'skills')); + }); + + it('returns empty string for blank values', () => { + const result = resolveBasePath(' ', projectDirectory); + expect(result).toBe(''); + }); +}); + +describe('normalizeBasePaths', () => { + const projectDirectory = '/workspace/project'; + + it('resolves relative paths against project directory', () => { + const result = normalizeBasePaths(['.opencode/skills'], projectDirectory); + expect(result).toEqual(['/workspace/project/.opencode/skills']); + }); + + it('drops empty entries', () => { + const result = normalizeBasePaths(['', ' ', '.opencode/skills'], projectDirectory); + expect(result).toEqual(['/workspace/project/.opencode/skills']); + }); + + it('deduplicates semantically equivalent paths while preserving order', () => { + const result = normalizeBasePaths( + ['.opencode/skills', '/workspace/project/.opencode/skills', '.opencode/skills/'], + projectDirectory + ); + + expect(result).toEqual(['/workspace/project/.opencode/skills']); + }); + + it('deduplicates Windows paths case-insensitively on Windows', () => { + if (process.platform !== 'win32') { + return; + } + + const result = normalizeBasePaths(['C:\\Skills', 'c:\\skills'], 'C:\\workspace'); + + expect(result).toHaveLength(1); + expect(result[0]?.toLowerCase()).toBe('c:\\skills'); + }); +}); + describe('getOpenCodeConfigPaths', () => { const originalEnv = { ...process.env }; diff --git a/src/config.ts b/src/config.ts index fd0f20c..93f4215 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,8 +34,8 @@ import { loadConfig } from 'bunfig'; import type { PluginInput } from '@opencode-ai/plugin'; import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { PluginConfig } from './types'; +import { isAbsolute, join, normalize, resolve } from 'node:path'; +import type { PluginConfig } from './types'; /** * Gets OpenCode-compatible config paths for the current platform. @@ -101,6 +101,67 @@ export function expandTildePath(path: string): string { return path; } +const createPathKey = (absolutePath: string): string => { + const normalizedPath = normalize(absolutePath); + if (process.platform === 'win32') { + return normalizedPath.toLowerCase(); + } + return normalizedPath; +}; + +/** + * Resolve a configured base path to an absolute path. + * + * Resolution rules: + * - "~" / "~/..." are expanded to the user home directory + * - Absolute paths are preserved + * - Relative paths are resolved from the project directory + */ +export function resolveBasePath(basePath: string, projectDirectory: string): string { + const trimmedPath = basePath.trim(); + + if (trimmedPath.length === 0) { + return ''; + } + + const expandedPath = expandTildePath(trimmedPath); + + if (isAbsolute(expandedPath)) { + return normalize(expandedPath); + } + + return resolve(projectDirectory, expandedPath); +} + +/** + * Normalize configured base paths: + * - Resolve to absolute paths + * - Remove empty entries + * - Remove duplicates while preserving priority order + */ +export function normalizeBasePaths(basePaths: string[], projectDirectory: string): string[] { + const uniquePaths = new Set(); + const normalizedPaths: string[] = []; + + for (const basePath of basePaths) { + const normalizedPath = resolveBasePath(basePath, projectDirectory); + + if (!normalizedPath) { + continue; + } + + const key = createPathKey(normalizedPath); + if (uniquePaths.has(key)) { + continue; + } + + uniquePaths.add(key); + normalizedPaths.push(normalizedPath); + } + + return normalizedPaths; +} + /** * Default skill base paths matching OpenCode's conventions. * Paths are in priority order (lowest to highest). @@ -123,12 +184,12 @@ const options: Config = { export async function getPluginConfig(ctx: PluginInput) { const resolvedConfig = await loadConfig(options); - resolvedConfig.basePaths.push( - join(ctx.directory, '.opencode', 'skills') // Highest priority: Project-local - ); + const configuredBasePaths = [ + ...resolvedConfig.basePaths, + join(ctx.directory, '.opencode', 'skills'), // Highest priority: Project-local + ]; - // Resolve '~' paths in basePaths to absolute paths - resolvedConfig.basePaths = resolvedConfig.basePaths.map(expandTildePath); + resolvedConfig.basePaths = normalizeBasePaths(configuredBasePaths, ctx.directory); return resolvedConfig; } diff --git a/src/services/SkillRegistry.test.ts b/src/services/SkillRegistry.test.ts new file mode 100644 index 0000000..8bc53d1 --- /dev/null +++ b/src/services/SkillRegistry.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { stripTrailingPathSeparators, suggestSkillsDirectoryPath } from './SkillRegistry'; + +describe('stripTrailingPathSeparators', () => { + it('removes trailing forward slashes', () => { + expect(stripTrailingPathSeparators('/tmp/skill///')).toBe('/tmp/skill'); + }); + + it('removes trailing backslashes', () => { + expect(stripTrailingPathSeparators('C:\\tmp\\skill\\\\')).toBe('C:\\tmp\\skill'); + }); + + it('keeps paths without trailing separators unchanged', () => { + expect(stripTrailingPathSeparators('/tmp/skill')).toBe('/tmp/skill'); + }); +}); + +describe('suggestSkillsDirectoryPath', () => { + it('handles uppercase SKILL suffix case-insensitively', () => { + const result = suggestSkillsDirectoryPath('/tmp/SKILL'); + expect(result).toBe('/tmp/skills'); + }); + + it('returns "skills" when path is only "skill"', () => { + expect(suggestSkillsDirectoryPath('skill')).toBe('skills'); + }); + + it('returns "skills" when path is only "SKILL"', () => { + expect(suggestSkillsDirectoryPath('SKILL')).toBe('skills'); + }); + + it('supports trailing separators before checking suffix', () => { + expect(suggestSkillsDirectoryPath('/tmp/skill/')).toBe('/tmp/skills'); + expect(suggestSkillsDirectoryPath('C:\\tmp\\skill\\')).toBe('C:\\tmp\\skills'); + }); + + it('returns null when path does not end with skill', () => { + expect(suggestSkillsDirectoryPath('/tmp/skills')).toBeNull(); + expect(suggestSkillsDirectoryPath('/tmp/project')).toBeNull(); + }); + + it('returns null for empty path', () => { + expect(suggestSkillsDirectoryPath('')).toBeNull(); + }); +}); diff --git a/src/services/SkillRegistry.ts b/src/services/SkillRegistry.ts index 7cbd635..08e99e2 100644 --- a/src/services/SkillRegistry.ts +++ b/src/services/SkillRegistry.ts @@ -74,6 +74,22 @@ const SkillFrontmatterSchema = tool.schema.object({ metadata: tool.schema.record(tool.schema.string(), tool.schema.string()).optional(), }); +export const stripTrailingPathSeparators = (path: string): string => path.replace(/[\\/]+$/, ''); + +export const suggestSkillsDirectoryPath = (path: string): string | null => { + const trimmedPath = stripTrailingPathSeparators(path); + + if (trimmedPath.toLowerCase() === 'skill') { + return 'skills'; + } + + if (!/[\\/]skill$/i.test(trimmedPath)) { + return null; + } + + return trimmedPath.replace(/skill$/i, () => 'skills'); +}; + export function createSkillRegistryController() { const store = new Map(); @@ -154,6 +170,28 @@ export async function createSkillRegistry( '[OpencodeSkillful] No valid base paths found for skill discovery:', config.basePaths ); + + const typoCandidates = config.basePaths.flatMap((basePath) => { + const suggestedPath = suggestSkillsDirectoryPath(basePath); + + if (!suggestedPath) { + return []; + } + + if (doesPathExist(suggestedPath)) { + return [`${basePath} -> ${suggestedPath}`]; + } + + return []; + }); + + if (typoCandidates.length > 0) { + logger.warn( + '[OpencodeSkillful] Detected possible "skill" vs "skills" typo in basePaths:', + typoCandidates + ); + } + controller.ready.setStatus('ready'); return; }