Skip to content
This repository was archived by the owner on Feb 14, 2026. It is now read-only.
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
66 changes: 65 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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']);
});
Comment thread
airtonix marked this conversation as resolved.

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 };

Expand Down
75 changes: 68 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string>();
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).
Expand All @@ -123,12 +184,12 @@ const options: Config<PluginConfig> = {
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;
}
45 changes: 45 additions & 0 deletions src/services/SkillRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
38 changes: 38 additions & 0 deletions src/services/SkillRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Comment thread
airtonix marked this conversation as resolved.

if (!/[\\/]skill$/i.test(trimmedPath)) {
return null;
}

return trimmedPath.replace(/skill$/i, () => 'skills');
};

export function createSkillRegistryController() {
const store = new Map<string, Skill>();

Expand Down Expand Up @@ -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;
}
Expand Down
Loading