mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-15 00:45:50 +00:00
Provider skills were hidden behind provider-specific filesystem rules. That made the backend and UI unable to offer one discovery path for skills. Add a normalized skills contract, provider service, and provider skills API. Keep provider-specific lookup rules inside adapters so routes and UI stay generic. Claude needs plugin handling because enabled plugins resolve through installed_plugins.json. Plugin folders can expose commands or skills, so Claude scans both forms. Claude plugin commands are namespaced to avoid collisions with user and project skills. Codex, Gemini, and Cursor adapters map their expected skill roots into the same contract. The slash menu now shows skills beside built-in and custom commands for discovery. The menu avoids mid-message activation, duplicate rows, loose namespace matches, and input overlap. Provider tests cover discovery locations and Claude plugin edge cases.
101 lines
2.8 KiB
TypeScript
101 lines
2.8 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
|
import type { ProviderSkillSource } from '@/shared/types.js';
|
|
|
|
const hasGitMarker = async (dirPath: string): Promise<boolean> => {
|
|
try {
|
|
const gitMarkerStats = await fs.stat(path.join(dirPath, '.git'));
|
|
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const findTopmostGitRoot = async (startPath: string): Promise<string | null> => {
|
|
let currentPath = path.resolve(startPath);
|
|
let topmostGitRoot: string | null = null;
|
|
|
|
while (true) {
|
|
if (await hasGitMarker(currentPath)) {
|
|
topmostGitRoot = currentPath;
|
|
}
|
|
|
|
const parentPath = path.dirname(currentPath);
|
|
if (parentPath === currentPath) {
|
|
break;
|
|
}
|
|
|
|
currentPath = parentPath;
|
|
}
|
|
|
|
return topmostGitRoot;
|
|
};
|
|
|
|
const addUniqueSource = (
|
|
sources: ProviderSkillSource[],
|
|
seenRootDirs: Set<string>,
|
|
source: ProviderSkillSource,
|
|
): void => {
|
|
const normalizedRootDir = path.resolve(source.rootDir);
|
|
if (seenRootDirs.has(normalizedRootDir)) {
|
|
return;
|
|
}
|
|
|
|
seenRootDirs.add(normalizedRootDir);
|
|
sources.push({ ...source, rootDir: normalizedRootDir });
|
|
};
|
|
|
|
export class CodexSkillsProvider extends SkillsProvider {
|
|
constructor() {
|
|
super('codex');
|
|
}
|
|
|
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
|
const sources: ProviderSkillSource[] = [];
|
|
const seenRootDirs = new Set<string>();
|
|
const repoRoot = await findTopmostGitRoot(workspacePath);
|
|
|
|
addUniqueSource(sources, seenRootDirs, {
|
|
scope: 'repo',
|
|
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
|
commandPrefix: '$',
|
|
});
|
|
|
|
if (repoRoot) {
|
|
// Codex checks repository skills at the launch folder, one folder above it,
|
|
// and the topmost git root; these can collapse to the same directory.
|
|
addUniqueSource(sources, seenRootDirs, {
|
|
scope: 'repo',
|
|
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
|
|
commandPrefix: '$',
|
|
});
|
|
addUniqueSource(sources, seenRootDirs, {
|
|
scope: 'repo',
|
|
rootDir: path.join(repoRoot, '.agents', 'skills'),
|
|
commandPrefix: '$',
|
|
});
|
|
}
|
|
|
|
addUniqueSource(sources, seenRootDirs, {
|
|
scope: 'user',
|
|
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
|
commandPrefix: '$',
|
|
});
|
|
addUniqueSource(sources, seenRootDirs, {
|
|
scope: 'admin',
|
|
rootDir: path.join('/etc', 'codex', 'skills'),
|
|
commandPrefix: '$',
|
|
});
|
|
addUniqueSource(sources, seenRootDirs, {
|
|
scope: 'system',
|
|
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
|
|
commandPrefix: '$',
|
|
});
|
|
|
|
return sources;
|
|
}
|
|
}
|