diff --git a/server/modules/providers/index.ts b/server/modules/providers/index.ts index 28287299..0d8d8edd 100644 --- a/server/modules/providers/index.ts +++ b/server/modules/providers/index.ts @@ -1,4 +1,5 @@ export { sessionSynchronizerService } from './services/session-synchronizer.service.js'; +export { providerSkillsService } from './services/skills.service.js'; export { initializeSessionsWatcher } from './services/sessions-watcher.service.js'; -export { closeSessionsWatcher } from './services/sessions-watcher.service.js'; \ No newline at end of file +export { closeSessionsWatcher } from './services/sessions-watcher.service.js'; diff --git a/server/modules/providers/list/claude/claude-skills.provider.ts b/server/modules/providers/list/claude/claude-skills.provider.ts new file mode 100644 index 00000000..9795529a --- /dev/null +++ b/server/modules/providers/list/claude/claude-skills.provider.ts @@ -0,0 +1,249 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import matter from 'gray-matter'; + +import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; +import type { + ProviderSkill, + ProviderSkillListOptions, + ProviderSkillSource, +} from '@/shared/types.js'; +import { + findProviderSkillMarkdownFiles, + readJsonConfig, + readObjectRecord, + readOptionalString, + readProviderSkillMarkdownDefinition, +} from '@/shared/utils.js'; + +const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude'); + +const getClaudePluginName = (pluginId: string): string => { + const [pluginName] = pluginId.split('@'); + return pluginName || pluginId; +}; + +const stripMarkdownExtension = (filename: string): string => + filename.replace(/\.md$/i, ''); + +const pathExistsAsDirectory = async (directoryPath: string): Promise => { + try { + const directoryStats = await stat(directoryPath); + return directoryStats.isDirectory(); + } catch { + return false; + } +}; + +const listChildDirectories = async (directoryPath: string): Promise => { + try { + const entries = await readdir(directoryPath, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(directoryPath, entry.name)) + .sort((left, right) => left.localeCompare(right)); + } catch { + return []; + } +}; + +const readClaudePluginName = async ( + installPath: string, + pluginId: string, +): Promise => { + try { + const pluginConfig = await readJsonConfig( + path.join(installPath, '.claude-plugin', 'plugin.json'), + ); + + // Older or partial plugin installs may not have plugin.json yet. Falling + // back keeps discovery useful without inventing a separate namespace. + return readOptionalString(pluginConfig.name) ?? getClaudePluginName(pluginId); + } catch { + return getClaudePluginName(pluginId); + } +}; + +export class ClaudeSkillsProvider extends SkillsProvider { + constructor() { + super('claude'); + } + + async listSkills(options?: ProviderSkillListOptions): Promise { + return [ + ...(await super.listSkills(options)), + ...(await this.listPluginSkills(getClaudeHomePath())), + ]; + } + + protected async getSkillSources(workspacePath: string): Promise { + const claudeHomePath = getClaudeHomePath(); + + return [ + { + scope: 'user', + rootDir: path.join(claudeHomePath, 'skills'), + commandPrefix: '/', + }, + { + scope: 'project', + rootDir: path.join(workspacePath, '.claude', 'skills'), + commandPrefix: '/', + }, + ]; + } + + private async listPluginSkills(claudeHomePath: string): Promise { + const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json')); + const enabledPlugins = readObjectRecord(settings.enabledPlugins); + if (!enabledPlugins) { + return []; + } + + const installedConfig = await readJsonConfig( + path.join(claudeHomePath, 'plugins', 'installed_plugins.json'), + ); + const installedPlugins = readObjectRecord(installedConfig.plugins); + if (!installedPlugins) { + return []; + } + + const skills: ProviderSkill[] = []; + const visitedPluginFolders = new Set(); + const pluginEntries = Object.entries(enabledPlugins) + .sort(([left], [right]) => left.localeCompare(right)); + for (const [pluginId, enabled] of pluginEntries) { + if (enabled !== true) { + continue; + } + + const installs = installedPlugins[pluginId]; + if (!Array.isArray(installs)) { + continue; + } + + for (const install of installs) { + const installRecord = readObjectRecord(install); + const installPath = readOptionalString(installRecord?.installPath); + if (!installPath) { + continue; + } + + // Claude's installed path points at one version folder; the usable + // plugin payloads live in the direct child folders beside it. + const pluginFolders = await listChildDirectories(path.dirname(installPath)); + for (const pluginFolder of pluginFolders) { + const pluginFolderKey = `${pluginId}:${path.resolve(pluginFolder)}`; + if (visitedPluginFolders.has(pluginFolderKey)) { + continue; + } + visitedPluginFolders.add(pluginFolderKey); + + const pluginName = await readClaudePluginName(pluginFolder, pluginId); + const commandsPath = path.join(pluginFolder, 'commands'); + if (await pathExistsAsDirectory(commandsPath)) { + skills.push( + ...(await this.listPluginCommandSkills(commandsPath, pluginId, pluginName)), + ); + continue; + } + + const skillsPath = path.join(pluginFolder, 'skills'); + if (!(await pathExistsAsDirectory(skillsPath))) { + continue; + } + + skills.push( + ...(await this.listPluginSkillMarkdowns(pluginFolder, pluginId, pluginName)), + ); + } + } + } + + return skills; + } + + private async listPluginCommandSkills( + commandsPath: string, + pluginId: string, + pluginName: string, + ): Promise { + const skills: ProviderSkill[] = []; + + try { + const entries = await readdir(commandsPath, { withFileTypes: true }); + const commandFiles = entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md')) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const commandFile of commandFiles) { + const sourcePath = path.join(commandsPath, commandFile.name); + try { + const definition = await this.readPluginCommandDefinition(sourcePath); + skills.push({ + provider: this.provider, + name: definition.name, + description: definition.description, + command: `/${pluginName}:${definition.name}`, + scope: 'plugin', + sourcePath, + pluginName, + pluginId, + }); + } catch { + // Malformed command markdown should not block sibling plugin commands. + } + } + } catch { + // Missing or unreadable command folders are treated as empty plugin command sets. + } + + return skills; + } + + private async readPluginCommandDefinition( + commandPath: string, + ): Promise<{ name: string; description: string }> { + const content = await readFile(commandPath, 'utf8'); + const parsed = matter(content); + const data = readObjectRecord(parsed.data) ?? {}; + + return { + name: stripMarkdownExtension(path.basename(commandPath)), + description: readOptionalString(data.description) ?? '', + }; + } + + private async listPluginSkillMarkdowns( + installPath: string, + pluginId: string, + pluginName: string, + ): Promise { + const skillFiles = await findProviderSkillMarkdownFiles(path.join(installPath, 'skills'), { + recursive: true, + }); + const skills: ProviderSkill[] = []; + + for (const skillPath of skillFiles) { + try { + const definition = await readProviderSkillMarkdownDefinition(skillPath); + skills.push({ + provider: this.provider, + name: definition.name, + description: definition.description, + command: `/${pluginName}:${definition.name}`, + scope: 'plugin', + sourcePath: skillPath, + pluginName, + pluginId, + }); + } catch { + // A bad plugin skill file should not block other installed plugin skills. + } + } + + return skills; + } +} diff --git a/server/modules/providers/list/claude/claude.provider.ts b/server/modules/providers/list/claude/claude.provider.ts index eeec1eb4..efd3bd4a 100644 --- a/server/modules/providers/list/claude/claude.provider.ts +++ b/server/modules/providers/list/claude/claude.provider.ts @@ -3,11 +3,18 @@ import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth. import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js'; import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; -import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js'; +import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js'; +import type { + IProviderAuth, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; export class ClaudeProvider extends AbstractProvider { readonly mcp = new ClaudeMcpProvider(); readonly auth: IProviderAuth = new ClaudeProviderAuth(); + readonly skills: IProviderSkills = new ClaudeSkillsProvider(); readonly sessions: IProviderSessions = new ClaudeSessionsProvider(); readonly sessionSynchronizer: IProviderSessionSynchronizer = new ClaudeSessionSynchronizer(); diff --git a/server/modules/providers/list/codex/codex-skills.provider.ts b/server/modules/providers/list/codex/codex-skills.provider.ts new file mode 100644 index 00000000..a4dd4add --- /dev/null +++ b/server/modules/providers/list/codex/codex-skills.provider.ts @@ -0,0 +1,100 @@ +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 => { + try { + const gitMarkerStats = await fs.stat(path.join(dirPath, '.git')); + return gitMarkerStats.isDirectory() || gitMarkerStats.isFile(); + } catch { + return false; + } +}; + +const findTopmostGitRoot = async (startPath: string): Promise => { + 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, + 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 { + const sources: ProviderSkillSource[] = []; + const seenRootDirs = new Set(); + 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; + } +} diff --git a/server/modules/providers/list/codex/codex.provider.ts b/server/modules/providers/list/codex/codex.provider.ts index 593297bc..811ff6de 100644 --- a/server/modules/providers/list/codex/codex.provider.ts +++ b/server/modules/providers/list/codex/codex.provider.ts @@ -3,11 +3,18 @@ import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.pro import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js'; import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js'; -import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js'; +import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js'; +import type { + IProviderAuth, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; export class CodexProvider extends AbstractProvider { readonly mcp = new CodexMcpProvider(); readonly auth: IProviderAuth = new CodexProviderAuth(); + readonly skills: IProviderSkills = new CodexSkillsProvider(); readonly sessions: IProviderSessions = new CodexSessionsProvider(); readonly sessionSynchronizer: IProviderSessionSynchronizer = new CodexSessionSynchronizer(); diff --git a/server/modules/providers/list/cursor/cursor-skills.provider.ts b/server/modules/providers/list/cursor/cursor-skills.provider.ts new file mode 100644 index 00000000..3da72b9f --- /dev/null +++ b/server/modules/providers/list/cursor/cursor-skills.provider.ts @@ -0,0 +1,31 @@ +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'; + +export class CursorSkillsProvider extends SkillsProvider { + constructor() { + super('cursor'); + } + + protected async getSkillSources(workspacePath: string): Promise { + return [ + { + scope: 'project', + rootDir: path.join(workspacePath, '.agents', 'skills'), + commandPrefix: '/', + }, + { + scope: 'project', + rootDir: path.join(workspacePath, '.cursor', 'skills'), + commandPrefix: '/', + }, + { + scope: 'user', + rootDir: path.join(os.homedir(), '.cursor', 'skills'), + commandPrefix: '/', + }, + ]; + } +} diff --git a/server/modules/providers/list/cursor/cursor.provider.ts b/server/modules/providers/list/cursor/cursor.provider.ts index 72edf80c..7fc4abf5 100644 --- a/server/modules/providers/list/cursor/cursor.provider.ts +++ b/server/modules/providers/list/cursor/cursor.provider.ts @@ -3,11 +3,18 @@ import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth. import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js'; import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js'; -import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js'; +import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js'; +import type { + IProviderAuth, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; export class CursorProvider extends AbstractProvider { readonly mcp = new CursorMcpProvider(); readonly auth: IProviderAuth = new CursorProviderAuth(); + readonly skills: IProviderSkills = new CursorSkillsProvider(); readonly sessions: IProviderSessions = new CursorSessionsProvider(); readonly sessionSynchronizer: IProviderSessionSynchronizer = new CursorSessionSynchronizer(); diff --git a/server/modules/providers/list/gemini/gemini-skills.provider.ts b/server/modules/providers/list/gemini/gemini-skills.provider.ts new file mode 100644 index 00000000..e49746a5 --- /dev/null +++ b/server/modules/providers/list/gemini/gemini-skills.provider.ts @@ -0,0 +1,36 @@ +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'; + +export class GeminiSkillsProvider extends SkillsProvider { + constructor() { + super('gemini'); + } + + protected async getSkillSources(workspacePath: string): Promise { + return [ + { + scope: 'user', + rootDir: path.join(os.homedir(), '.gemini', 'skills'), + commandPrefix: '/', + }, + { + scope: 'user', + rootDir: path.join(os.homedir(), '.agents', 'skills'), + commandPrefix: '/', + }, + { + scope: 'project', + rootDir: path.join(workspacePath, '.gemini', 'skills'), + commandPrefix: '/', + }, + { + scope: 'project', + rootDir: path.join(workspacePath, '.agents', 'skills'), + commandPrefix: '/', + }, + ]; + } +} diff --git a/server/modules/providers/list/gemini/gemini.provider.ts b/server/modules/providers/list/gemini/gemini.provider.ts index 2fb8a7c2..626cacf6 100644 --- a/server/modules/providers/list/gemini/gemini.provider.ts +++ b/server/modules/providers/list/gemini/gemini.provider.ts @@ -3,11 +3,18 @@ import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth. import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js'; import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js'; -import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js'; +import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js'; +import type { + IProviderAuth, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; export class GeminiProvider extends AbstractProvider { readonly mcp = new GeminiMcpProvider(); readonly auth: IProviderAuth = new GeminiProviderAuth(); + readonly skills: IProviderSkills = new GeminiSkillsProvider(); readonly sessions: IProviderSessions = new GeminiSessionsProvider(); readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer(); diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index ea95f83d..819959c4 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; +import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js'; import { sessionsService } from '@/modules/providers/services/sessions.service.js'; import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js'; @@ -247,6 +248,17 @@ router.get( }), ); +// ----------------- Skills routes ----------------- +router.get( + '/:provider/skills', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const workspacePath = readOptionalQueryString(req.query.workspacePath); + const skills = await providerSkillsService.listProviderSkills(provider, { workspacePath }); + res.json(createApiSuccessResponse({ provider, skills })); + }), +); + // ----------------- MCP routes ----------------- router.get( '/:provider/mcp/servers', diff --git a/server/modules/providers/services/skills.service.ts b/server/modules/providers/services/skills.service.ts new file mode 100644 index 00000000..2a02ad22 --- /dev/null +++ b/server/modules/providers/services/skills.service.ts @@ -0,0 +1,15 @@ +import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js'; + +export const providerSkillsService = { + /** + * Lists normalized skills visible to one provider. + */ + async listProviderSkills( + providerName: string, + options?: ProviderSkillListOptions, + ): Promise { + const provider = providerRegistry.resolveProvider(providerName); + return provider.skills.listSkills(options); + }, +}; diff --git a/server/modules/providers/shared/base/abstract.provider.ts b/server/modules/providers/shared/base/abstract.provider.ts index c674364d..03701d3e 100644 --- a/server/modules/providers/shared/base/abstract.provider.ts +++ b/server/modules/providers/shared/base/abstract.provider.ts @@ -3,6 +3,7 @@ import type { IProviderAuth, IProviderMcp, IProviderSessionSynchronizer, + IProviderSkills, IProviderSessions, } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; @@ -18,6 +19,7 @@ export abstract class AbstractProvider implements IProvider { readonly id: LLMProvider; abstract readonly mcp: IProviderMcp; abstract readonly auth: IProviderAuth; + abstract readonly skills: IProviderSkills; abstract readonly sessions: IProviderSessions; abstract readonly sessionSynchronizer: IProviderSessionSynchronizer; diff --git a/server/modules/providers/shared/skills/skills.provider.ts b/server/modules/providers/shared/skills/skills.provider.ts new file mode 100644 index 00000000..07e83a5b --- /dev/null +++ b/server/modules/providers/shared/skills/skills.provider.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; + +import type { IProviderSkills } from '@/shared/interfaces.js'; +import type { + LLMProvider, + ProviderSkill, + ProviderSkillListOptions, + ProviderSkillSource, +} from '@/shared/types.js'; +import { + findProviderSkillMarkdownFiles, + readProviderSkillMarkdownDefinition, +} from '@/shared/utils.js'; + +const resolveWorkspacePath = (workspacePath?: string): string => + path.resolve(workspacePath ?? process.cwd()); + +/** + * Shared skills provider for provider-specific skill source discovery. + */ +export abstract class SkillsProvider implements IProviderSkills { + protected readonly provider: LLMProvider; + + protected constructor(provider: LLMProvider) { + this.provider = provider; + } + + async listSkills(options?: ProviderSkillListOptions): Promise { + const workspacePath = resolveWorkspacePath(options?.workspacePath); + const sources = await this.getSkillSources(workspacePath); + const skills: ProviderSkill[] = []; + + for (const source of sources) { + const skillFiles = await findProviderSkillMarkdownFiles(source.rootDir, { + recursive: source.recursive, + }); + for (const skillPath of skillFiles) { + try { + const definition = await readProviderSkillMarkdownDefinition(skillPath); + const command = source.commandForSkill + ? source.commandForSkill(definition.name) + : `${source.commandPrefix ?? '/'}${definition.name}`; + + skills.push({ + provider: this.provider, + name: definition.name, + description: definition.description, + command, + scope: source.scope, + sourcePath: skillPath, + pluginName: source.pluginName, + pluginId: source.pluginId, + }); + } catch { + // A malformed or unreadable skill markdown file should not hide other valid skills. + } + } + } + + return skills; + } + + protected abstract getSkillSources(workspacePath: string): Promise; +} diff --git a/server/modules/providers/tests/skills.test.ts b/server/modules/providers/tests/skills.test.ts new file mode 100644 index 00000000..c0690914 --- /dev/null +++ b/server/modules/providers/tests/skills.test.ts @@ -0,0 +1,399 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; + +const patchHomeDir = (nextHomeDir: string) => { + const original = os.homedir; + (os as any).homedir = () => nextHomeDir; + return () => { + (os as any).homedir = original; + }; +}; + +const writeSkill = async ( + skillsRoot: string, + directoryName: string, + name: string, + description: string, +): Promise => { + const skillDir = path.join(skillsRoot, directoryName); + await fs.mkdir(skillDir, { recursive: true }); + const skillPath = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillPath, + `---\nname: ${name}\ndescription: ${description}\n---\n\n`, + 'utf8', + ); + return skillPath; +}; + +const writeClaudePluginManifest = async ( + installPath: string, + name: string, +): Promise => { + const pluginConfigDir = path.join(installPath, '.claude-plugin'); + await fs.mkdir(pluginConfigDir, { recursive: true }); + await fs.writeFile( + path.join(pluginConfigDir, 'plugin.json'), + JSON.stringify( + { + name, + version: '0.1.0', + description: `${name} test plugin`, + }, + null, + 2, + ), + 'utf8', + ); +}; + +const writeClaudePluginCommand = async ( + commandsRoot: string, + commandName: string, + description: string, +): Promise => { + await fs.mkdir(commandsRoot, { recursive: true }); + const commandPath = path.join(commandsRoot, `${commandName}.md`); + await fs.writeFile( + commandPath, + `---\ndescription: ${description}\nargument-hint: 'test args'\n---\n\nCommand body.\n`, + 'utf8', + ); + return commandPath; +}; + +/** + * This test covers Claude user/project skill folders plus plugin discovery from + * installed plugin command files and fallback plugin skill files. + */ +test('providerSkillsService lists claude user, project, and enabled plugin skills', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-claude-')); + const workspacePath = path.join(tempRoot, 'workspace'); + const commandPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'notion-plugin', + 'notion', + 'abc123', + ); + const skillPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'anthropic-agent-skills', + 'example-skills', + 'def456', + ); + const disabledPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'disabled-marketplace', + 'disabled-skills', + 'ghi789', + ); + const siblingSkillPluginPath = path.join(path.dirname(skillPluginInstallPath), 'legacy777'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + await writeSkill( + path.join(tempRoot, '.claude', 'skills'), + 'claude-user-dir', + 'claude-user', + 'Claude user skill', + ); + await writeSkill( + path.join(workspacePath, '.claude', 'skills'), + 'claude-project-dir', + 'claude-project', + 'Claude project skill', + ); + await writeClaudePluginManifest(commandPluginInstallPath, 'Notion'); + await writeClaudePluginCommand( + path.join(commandPluginInstallPath, 'commands'), + 'insert-row', + 'Insert a Notion database row', + ); + await writeSkill( + path.join(commandPluginInstallPath, 'skills'), + 'ignored-command-plugin-skill-dir', + 'ignored-command-plugin-skill', + 'Command plugin fallback skill should be ignored', + ); + await writeClaudePluginManifest(skillPluginInstallPath, 'ExampleSkills'); + await writeSkill( + path.join(skillPluginInstallPath, 'skills'), + 'claude-plugin-dir', + 'claude-plugin', + 'Claude plugin skill', + ); + await writeSkill( + path.join(skillPluginInstallPath, 'skills'), + 'claude-plugin-second-dir', + 'claude-plugin-second', + 'Second Claude plugin skill', + ); + await writeSkill( + path.join(skillPluginInstallPath, 'skills', 'nested', 'collection'), + 'claude-plugin-nested-dir', + 'claude-plugin-nested', + 'Nested Claude plugin skill', + ); + await writeSkill( + path.join(siblingSkillPluginPath, 'skills'), + 'claude-plugin-sibling-dir', + 'claude-plugin-sibling', + 'Sibling Claude plugin skill', + ); + await writeClaudePluginManifest(disabledPluginInstallPath, 'DisabledSkills'); + await writeClaudePluginCommand( + path.join(disabledPluginInstallPath, 'commands'), + 'disabled-command', + 'Disabled plugin command', + ); + await writeSkill( + path.join( + disabledPluginInstallPath, + 'skills', + ), + 'disabled-plugin-dir', + 'disabled-plugin', + 'Disabled plugin skill', + ); + + await fs.writeFile( + path.join(tempRoot, '.claude', 'settings.json'), + JSON.stringify( + { + enabledPlugins: { + 'notion@notion-marketplace': true, + 'example-skills@anthropic-agent-skills': true, + 'disabled-skills@disabled-marketplace': false, + }, + }, + null, + 2, + ), + 'utf8', + ); + await fs.writeFile( + path.join(tempRoot, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify( + { + version: 2, + plugins: { + 'notion@notion-marketplace': [ + { + scope: 'user', + installPath: commandPluginInstallPath, + version: 'abc123', + }, + ], + 'example-skills@anthropic-agent-skills': [ + { + scope: 'user', + installPath: skillPluginInstallPath, + version: 'def456', + }, + ], + 'disabled-skills@disabled-marketplace': [ + { + scope: 'user', + installPath: disabledPluginInstallPath, + version: 'ghi789', + }, + ], + }, + }, + null, + 2, + ), + 'utf8', + ); + + const skills = await providerSkillsService.listProviderSkills('claude', { workspacePath }); + const byName = new Map(skills.map((skill) => [skill.name, skill])); + + assert.equal(byName.get('claude-user')?.scope, 'user'); + assert.equal(byName.get('claude-user')?.command, '/claude-user'); + assert.equal(byName.get('claude-project')?.scope, 'project'); + assert.equal(byName.get('claude-project')?.command, '/claude-project'); + + const pluginCommand = byName.get('insert-row'); + assert.equal(pluginCommand?.scope, 'plugin'); + assert.equal(pluginCommand?.pluginName, 'Notion'); + assert.equal(pluginCommand?.pluginId, 'notion@notion-marketplace'); + assert.equal(pluginCommand?.command, '/Notion:insert-row'); + assert.equal(pluginCommand?.description, 'Insert a Notion database row'); + assert.match(pluginCommand?.sourcePath ?? '', /commands[\\/]insert-row\.md$/); + assert.equal(byName.has('ignored-command-plugin-skill'), false); + + const pluginSkill = byName.get('claude-plugin'); + assert.equal(pluginSkill?.scope, 'plugin'); + assert.equal(pluginSkill?.pluginName, 'ExampleSkills'); + assert.equal(pluginSkill?.pluginId, 'example-skills@anthropic-agent-skills'); + assert.equal(pluginSkill?.command, '/ExampleSkills:claude-plugin'); + assert.equal(pluginSkill?.description, 'Claude plugin skill'); + assert.match( + pluginSkill?.sourcePath ?? '', + /cache[\\/]anthropic-agent-skills[\\/]example-skills[\\/]def456[\\/]skills[\\/]/, + ); + + const secondPluginSkill = byName.get('claude-plugin-second'); + assert.equal(secondPluginSkill?.scope, 'plugin'); + assert.equal(secondPluginSkill?.command, '/ExampleSkills:claude-plugin-second'); + + const nestedPluginSkill = byName.get('claude-plugin-nested'); + assert.equal(nestedPluginSkill?.scope, 'plugin'); + assert.equal(nestedPluginSkill?.command, '/ExampleSkills:claude-plugin-nested'); + assert.equal(nestedPluginSkill?.description, 'Nested Claude plugin skill'); + + const siblingPluginSkill = byName.get('claude-plugin-sibling'); + assert.equal(siblingPluginSkill?.scope, 'plugin'); + assert.equal(siblingPluginSkill?.pluginName, 'example-skills'); + assert.equal(siblingPluginSkill?.command, '/example-skills:claude-plugin-sibling'); + assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill'); + assert.equal(byName.has('disabled-command'), false); + assert.equal(byName.has('disabled-plugin'), false); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +/** + * This test covers Codex repository/user/system skill folders and verifies that + * repository lookup includes cwd, parent, and git root skill locations. + */ +test('providerSkillsService lists codex repository, user, and system skills', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-codex-')); + const repoRoot = path.join(tempRoot, 'repo'); + const workspacePath = path.join(repoRoot, 'packages', 'app'); + await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true }); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + await writeSkill( + path.join(workspacePath, '.agents', 'skills'), + 'codex-cwd-dir', + 'codex-cwd', + 'Codex cwd skill', + ); + await writeSkill( + path.join(repoRoot, 'packages', '.agents', 'skills'), + 'codex-parent-dir', + 'codex-parent', + 'Codex parent skill', + ); + await writeSkill( + path.join(repoRoot, '.agents', 'skills'), + 'codex-root-dir', + 'codex-root', + 'Codex root skill', + ); + await writeSkill( + path.join(tempRoot, '.agents', 'skills'), + 'codex-user-dir', + 'codex-user', + 'Codex user skill', + ); + await writeSkill( + path.join(tempRoot, '.codex', 'skills', '.system'), + 'codex-system-dir', + 'codex-system', + 'Codex system skill', + ); + + const skills = await providerSkillsService.listProviderSkills('codex', { workspacePath }); + const byName = new Map(skills.map((skill) => [skill.name, skill])); + + assert.equal(byName.get('codex-cwd')?.scope, 'repo'); + assert.equal(byName.get('codex-parent')?.scope, 'repo'); + assert.equal(byName.get('codex-root')?.scope, 'repo'); + assert.equal(byName.get('codex-user')?.scope, 'user'); + assert.equal(byName.get('codex-system')?.scope, 'system'); + assert.equal(byName.get('codex-root')?.command, '$codex-root'); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +/** + * This test covers Gemini and Cursor skill directory rules, including shared + * `.agents/skills` project support. + */ +test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + await writeSkill( + path.join(tempRoot, '.gemini', 'skills'), + 'gemini-user-dir', + 'gemini-user', + 'Gemini user skill', + ); + await writeSkill( + path.join(tempRoot, '.agents', 'skills'), + 'agents-user-dir', + 'agents-user', + 'Agents user skill', + ); + await writeSkill( + path.join(workspacePath, '.gemini', 'skills'), + 'gemini-project-dir', + 'gemini-project', + 'Gemini project skill', + ); + await writeSkill( + path.join(workspacePath, '.agents', 'skills'), + 'agents-project-dir', + 'agents-project', + 'Agents project skill', + ); + await writeSkill( + path.join(workspacePath, '.cursor', 'skills'), + 'cursor-project-dir', + 'cursor-project', + 'Cursor project skill', + ); + await writeSkill( + path.join(tempRoot, '.cursor', 'skills'), + 'cursor-user-dir', + 'cursor-user', + 'Cursor user skill', + ); + + const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath }); + const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill])); + assert.equal(geminiByName.get('gemini-user')?.scope, 'user'); + assert.equal(geminiByName.get('agents-user')?.scope, 'user'); + assert.equal(geminiByName.get('gemini-project')?.scope, 'project'); + assert.equal(geminiByName.get('agents-project')?.scope, 'project'); + assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project'); + + const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath }); + const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill])); + assert.equal(cursorByName.get('agents-project')?.scope, 'project'); + assert.equal(cursorByName.get('cursor-project')?.scope, 'project'); + assert.equal(cursorByName.get('cursor-user')?.scope, 'user'); + assert.equal(cursorByName.get('cursor-user')?.command, '/cursor-user'); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index c5354dda..bc97ffb3 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -4,6 +4,8 @@ import type { LLMProvider, McpScope, NormalizedMessage, + ProviderSkill, + ProviderSkillListOptions, ProviderAuthStatus, ProviderMcpServer, UpsertProviderMcpServerInput, @@ -20,6 +22,7 @@ export interface IProvider { readonly id: LLMProvider; readonly mcp: IProviderMcp; readonly auth: IProviderAuth; + readonly skills: IProviderSkills; readonly sessions: IProviderSessions; readonly sessionSynchronizer: IProviderSessionSynchronizer; } @@ -39,6 +42,22 @@ export interface IProviderAuth { getStatus(): Promise; } +// --------------------------- +//----------------- PROVIDER SKILLS INTERFACE ------------ +/** + * Skills contract for one provider. + * + * Implementations discover provider-native skill markdown locations and return + * normalized skill records with the exact command syntax expected by that + * provider. Each skill is read from a `SKILL.md` file under its skill directory. + */ +export interface IProviderSkills { + /** + * Lists all skills visible to this provider for the optional workspace. + */ + listSkills(options?: ProviderSkillListOptions): Promise; +} + // --------------------------- //----------------- PROVIDER MCP INTERFACE ------------ /** diff --git a/server/shared/types.ts b/server/shared/types.ts index af09abf2..de277d83 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -171,6 +171,69 @@ export type FetchHistoryResult = { tokenUsage?: unknown; }; +// --------------------------- +//----------------- PROVIDER SKILL TYPES ------------ +/** + * Scope where a provider skill definition was discovered. + * + * Provider skill adapters should use this to describe the origin of each + * skill markdown file without leaking provider-specific folder names into route + * contracts. `repo` is used for Codex repository lookup locations, while + * `project` is used for providers that treat workspace-local skills as project + * scoped. + */ +export type ProviderSkillScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system'; + +/** + * Shared input accepted by provider skill listing operations. + * + * Routes pass `workspacePath` when a caller wants project/repository skills for + * a specific folder. Providers should fall back to the backend process cwd when + * this option is omitted. + */ +export type ProviderSkillListOptions = { + workspacePath?: string; +}; + +/** + * Normalized skill record returned by provider skill adapters. + * + * The `command` value is the exact invocation text the selected provider expects + * for this skill. Claude plugin skills use a namespaced command such as + * `/plugin-name:skill-name`, while Codex skills use the `$skill-name` form. + * `sourcePath` points to the skill markdown file that produced the record so + * callers can distinguish duplicate skill names across scopes. + */ +export type ProviderSkill = { + provider: LLMProvider; + name: string; + description: string; + command: string; + scope: ProviderSkillScope; + sourcePath: string; + pluginName?: string; + pluginId?: string; +}; + +/** + * Internal source descriptor consumed by shared provider skill discovery logic. + * + * Concrete provider adapters build these records from their native lookup rules. + * The shared skills provider then scans `rootDir` for child skill markdown files + * and uses `commandForSkill` or `commandPrefix` to produce the provider-specific + * invocation command. Set `recursive` only when a provider stores skills under + * arbitrary nested folders below the source root. + */ +export type ProviderSkillSource = { + scope: ProviderSkillScope; + rootDir: string; + recursive?: boolean; + commandPrefix?: '/' | '$'; + commandForSkill?: (skillName: string) => string; + pluginName?: string; + pluginId?: string; +}; + // --------------------------- //----------------- SHARED ERROR TYPES ------------ /** diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 84a382c3..239a951c 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -15,6 +15,7 @@ import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; +import matter from 'gray-matter'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { @@ -503,6 +504,99 @@ export const writeJsonConfig = async (filePath: string, data: Record//SKILL.md`. Recursive mode is reserved for + * provider sources that can nest skills arbitrarily, and it returns every + * descendant `SKILL.md`. Missing or unreadable roots return an empty list + * because users may not have every provider installed or configured. + */ +export async function findProviderSkillMarkdownFiles( + rootDir: string, + options: { recursive?: boolean } = {}, +): Promise { + const skillFiles: string[] = []; + + const collectRecursive = async (dirPath: string): Promise => { + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + return; + } + + try { + const skillPath = path.join(dirPath, 'SKILL.md'); + const skillStats = await stat(skillPath); + if (skillStats.isFile()) { + skillFiles.push(skillPath); + } + } catch { + // Directories without SKILL.md are expected while walking plugin trees. + } + + for (const entry of entries) { + if (entry.isDirectory()) { + await collectRecursive(path.join(dirPath, entry.name)); + } + } + }; + + if (options.recursive) { + await collectRecursive(rootDir); + return skillFiles.sort((left, right) => left.localeCompare(right)); + } + + try { + const entries = await readdir(rootDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const skillPath = path.join(rootDir, entry.name, 'SKILL.md'); + try { + const skillStats = await stat(skillPath); + if (skillStats.isFile()) { + skillFiles.push(skillPath); + } + } catch { + // A partial skill directory should not block discovery of sibling skills. + } + } + + return skillFiles.sort((left, right) => left.localeCompare(right)); + } catch { + return []; + } +} + +/** + * Reads the `name` and `description` fields from a provider skill markdown file. + * + * The metadata is expected in markdown front matter. If a skill omits `name`, the + * parent directory name is used as a stable fallback so providers can still + * expose the skill. Missing descriptions are normalized to an empty string. + */ +export async function readProviderSkillMarkdownDefinition( + skillPath: string, +): Promise<{ name: string; description: string }> { + const content = await readFile(skillPath, 'utf8'); + const parsed = matter(content); + const data = readObjectRecord(parsed.data) ?? {}; + const fallbackName = path.basename(path.dirname(skillPath)); + + return { + name: readOptionalString(data.name) ?? fallbackName, + description: readOptionalString(data.description) ?? '', + }; +} + // --------------------------- //----------------- SESSION SYNCHRONIZER TITLE HELPERS ------------ /** diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 000cd33f..883a12f5 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -152,6 +152,7 @@ export function useChatComposerState({ ((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => Promise) | null >(null); const inputValueRef = useRef(input); + const selectedProjectId = selectedProject?.projectId; const handleBuiltInCommand = useCallback( (result: CommandExecutionResult) => { @@ -361,6 +362,7 @@ export function useChatComposerState({ handleCommandMenuKeyDown, } = useSlashCommands({ selectedProject, + provider, input, setInput, textareaRef, @@ -470,14 +472,14 @@ export function useChatComposerState({ return; } - // Intercept slash commands: if input starts with /commandName, execute as command with args - const trimmedInput = currentInput.trim(); - if (trimmedInput.startsWith('/')) { - const firstSpace = trimmedInput.indexOf(' '); - const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput; + // Intercept slash commands only when "/" is the first input character. + const commandInput = currentInput.trimEnd(); + if (commandInput.startsWith('/')) { + const firstSpace = commandInput.indexOf(' '); + const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput; const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName); - if (matchedCommand) { - executeCommand(matchedCommand, trimmedInput); + if (matchedCommand && matchedCommand.type !== 'skill') { + executeCommand(matchedCommand, commandInput); setInput(''); inputValueRef.current = ''; setAttachedImages([]); @@ -713,27 +715,27 @@ export function useChatComposerState({ }, [input]); useEffect(() => { - if (!selectedProject) { + if (!selectedProjectId) { return; } - const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || ''; + const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProjectId}`) || ''; setInput((previous) => { const next = previous === savedInput ? previous : savedInput; inputValueRef.current = next; return next; }); - }, [selectedProject?.projectId]); + }, [selectedProjectId]); useEffect(() => { - if (!selectedProject) { + if (!selectedProjectId) { return; } if (input !== '') { - safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input); + safeLocalStorage.setItem(`draft_input_${selectedProjectId}`, input); } else { - safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); + safeLocalStorage.removeItem(`draft_input_${selectedProjectId}`); } - }, [input, selectedProject]); + }, [input, selectedProjectId]); useEffect(() => { if (!textareaRef.current) { diff --git a/src/components/chat/hooks/useSlashCommands.ts b/src/components/chat/hooks/useSlashCommands.ts index 89408420..931354eb 100644 --- a/src/components/chat/hooks/useSlashCommands.ts +++ b/src/components/chat/hooks/useSlashCommands.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react'; -import Fuse from 'fuse.js'; + import { authenticatedFetch } from '../../../utils/api'; import { safeLocalStorage } from '../utils/chatStorage'; -import type { Project } from '../../../types/app'; +import type { LLMProvider, Project } from '../../../types/app'; const COMMAND_QUERY_DEBOUNCE_MS = 150; @@ -12,19 +12,37 @@ export interface SlashCommand { description?: string; namespace?: string; path?: string; - type?: string; + type?: 'built-in' | 'custom' | 'skill' | string; metadata?: Record; [key: string]: unknown; } interface UseSlashCommandsOptions { selectedProject: Project | null; + provider: LLMProvider; input: string; setInput: Dispatch>; textareaRef: RefObject; onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise; } +type ProviderSkill = { + name: string; + description?: string; + command: string; + scope: string; + sourcePath?: string; + pluginName?: string; + pluginId?: string; +}; + +type ProviderSkillsResponse = { + success?: boolean; + data?: { + skills?: ProviderSkill[]; + }; +}; + const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`; const readCommandHistory = (projectName: string): Record => { @@ -48,8 +66,78 @@ const saveCommandHistory = (projectName: string, history: Record const isPromiseLike = (value: unknown): value is Promise => Boolean(value) && typeof (value as Promise).then === 'function'; +const isSkillCommand = (command: SlashCommand) => + command.type === 'skill' || command.metadata?.type === 'skill'; + +const dedupeProviderSkills = (skills: ProviderSkill[]): ProviderSkill[] => { + const seenCommands = new Set(); + + return skills.filter((skill) => { + // Multiple physical Claude plugin folders can expose the same invocation. + // The slash menu should show each executable command only once. + const key = skill.command; + if (seenCommands.has(key)) { + return false; + } + + seenCommands.add(key); + return true; + }); +}; + +const mapSkillToSlashCommand = (skill: ProviderSkill): SlashCommand => ({ + name: skill.command, + description: skill.description, + namespace: 'skill', + path: skill.sourcePath, + type: 'skill', + metadata: { + type: skill.scope, + scope: skill.scope, + sourcePath: skill.sourcePath, + pluginName: skill.pluginName, + pluginId: skill.pluginId, + skillName: skill.name, + }, +}); + +const filterSlashCommands = ( + commands: SlashCommand[], + query: string, +): SlashCommand[] => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return commands; + } + + const commandPrefix = normalizedQuery.startsWith('/') + ? normalizedQuery + : `/${normalizedQuery}`; + const namePrefixMatches = commands.filter((command) => + command.name.toLowerCase().startsWith(commandPrefix), + ); + + // Namespaced commands should behave like path completion. Once a provider + // namespace is typed, only exact command-prefix matches should stay visible. + if (normalizedQuery.includes(':') || namePrefixMatches.length > 0) { + return namePrefixMatches; + } + + const nameSubstringMatches = commands.filter((command) => + command.name.toLowerCase().includes(normalizedQuery), + ); + if (nameSubstringMatches.length > 0) { + return nameSubstringMatches; + } + + return commands.filter((command) => + command.description?.toLowerCase().includes(normalizedQuery), + ); +}; + export function useSlashCommands({ selectedProject, + provider, input, setInput, textareaRef, @@ -80,6 +168,8 @@ export function useSlashCommands({ }, [clearCommandQueryTimer]); useEffect(() => { + let cancelled = false; + const fetchCommands = async () => { if (!selectedProject) { setSlashCommands([]); @@ -88,13 +178,14 @@ export function useSlashCommands({ } try { + const workspacePath = selectedProject.fullPath || selectedProject.path || ''; const response = await authenticatedFetch('/api/commands/list', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - projectPath: selectedProject.path, + projectPath: workspacePath || selectedProject.path, }), }); @@ -103,11 +194,25 @@ export function useSlashCommands({ } const data = await response.json(); + const skillsParams = new URLSearchParams(); + if (workspacePath) { + skillsParams.set('workspacePath', workspacePath); + } + + const skillsResponse = await authenticatedFetch( + `/api/providers/${encodeURIComponent(provider)}/skills${skillsParams.toString() ? `?${skillsParams.toString()}` : ''}`, + ); + const skillsData = skillsResponse.ok + ? ((await skillsResponse.json()) as ProviderSkillsResponse) + : null; + const skillCommands = dedupeProviderSkills(skillsData?.data?.skills || []) + .map(mapSkillToSlashCommand); const allCommands: SlashCommand[] = [ ...((data.builtIn || []) as SlashCommand[]).map((command) => ({ ...command, type: 'built-in', })), + ...skillCommands, ...((data.custom || []) as SlashCommand[]).map((command) => ({ ...command, type: 'custom', @@ -121,15 +226,22 @@ export function useSlashCommands({ return commandBUsage - commandAUsage; }); - setSlashCommands(sortedCommands); + if (!cancelled) { + setSlashCommands(sortedCommands); + } } catch (error) { console.error('Error fetching slash commands:', error); - setSlashCommands([]); + if (!cancelled) { + setSlashCommands([]); + } } }; fetchCommands(); - }, [selectedProject]); + return () => { + cancelled = true; + }; + }, [selectedProject, provider]); useEffect(() => { if (!showCommandMenu) { @@ -137,36 +249,9 @@ export function useSlashCommands({ } }, [showCommandMenu]); - const fuse = useMemo(() => { - if (!slashCommands.length) { - return null; - } - - return new Fuse(slashCommands, { - keys: [ - { name: 'name', weight: 2 }, - { name: 'description', weight: 1 }, - ], - threshold: 0.4, - includeScore: true, - minMatchCharLength: 1, - }); - }, [slashCommands]); - useEffect(() => { - if (!commandQuery) { - setFilteredCommands(slashCommands); - return; - } - - if (!fuse) { - setFilteredCommands([]); - return; - } - - const results = fuse.search(commandQuery); - setFilteredCommands(results.map((result) => result.item)); - }, [commandQuery, slashCommands, fuse]); + setFilteredCommands(filterSlashCommands(slashCommands, commandQuery)); + }, [commandQuery, slashCommands]); const frequentCommands = useMemo(() => { if (!selectedProject || slashCommands.length === 0) { @@ -198,17 +283,41 @@ export function useSlashCommands({ [selectedProject], ); - const selectCommandFromKeyboard = useCallback( + const insertCommandIntoInput = useCallback( (command: SlashCommand) => { - const textBeforeSlash = input.slice(0, slashPosition); - const textAfterSlash = input.slice(slashPosition); - const spaceIndex = textAfterSlash.indexOf(' '); - const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : ''; - const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`; + const currentTextarea = textareaRef.current; + const insertionStart = slashPosition >= 0 + ? slashPosition + : currentTextarea?.selectionStart ?? input.length; + const textBeforeCommand = input.slice(0, insertionStart); + const textAfterCommandStart = input.slice(insertionStart); + const spaceIndex = textAfterCommandStart.indexOf(' '); + const textAfterCommand = slashPosition >= 0 && spaceIndex !== -1 + ? textAfterCommandStart.slice(spaceIndex).trimStart() + : input.slice(currentTextarea?.selectionEnd ?? insertionStart); + const separator = textBeforeCommand && !/\s$/.test(textBeforeCommand) ? ' ' : ''; + const newInput = `${textBeforeCommand}${separator}${command.name}${textAfterCommand ? ` ${textAfterCommand}` : ' '}`; setInput(newInput); resetCommandMenuState(); + window.requestAnimationFrame(() => { + currentTextarea?.focus(); + const nextCursorPosition = `${textBeforeCommand}${separator}${command.name} `.length; + currentTextarea?.setSelectionRange(nextCursorPosition, nextCursorPosition); + }); + }, + [input, resetCommandMenuState, setInput, slashPosition, textareaRef], + ); + + const selectCommandFromKeyboard = useCallback( + (command: SlashCommand) => { + insertCommandIntoInput(command); + + if (isSkillCommand(command)) { + return; + } + const executionResult = onExecuteCommand(command); if (isPromiseLike(executionResult)) { executionResult.catch(() => { @@ -216,7 +325,7 @@ export function useSlashCommands({ }); } }, - [input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand], + [insertCommandIntoInput, onExecuteCommand], ); const handleCommandSelect = useCallback( @@ -231,6 +340,11 @@ export function useSlashCommands({ } trackCommandUsage(command); + if (isSkillCommand(command)) { + insertCommandIntoInput(command); + return; + } + const executionResult = onExecuteCommand(command); if (isPromiseLike(executionResult)) { @@ -244,7 +358,7 @@ export function useSlashCommands({ resetCommandMenuState(); } }, - [selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState], + [selectedProject, trackCommandUsage, insertCommandIntoInput, onExecuteCommand, resetCommandMenuState], ); const handleToggleCommandMenu = useCallback(() => { @@ -276,7 +390,7 @@ export function useSlashCommands({ return; } - const slashPattern = /(^|\s)\/(\S*)$/; + const slashPattern = /^\/(\S*)$/; const match = textBeforeCursor.match(slashPattern); if (!match) { @@ -284,8 +398,8 @@ export function useSlashCommands({ return; } - const slashPos = (match.index || 0) + match[1].length; - const query = match[2]; + const slashPos = 0; + const query = match[1]; setSlashPosition(slashPos); setShowCommandMenu(true); diff --git a/src/components/chat/view/subcomponents/CommandMenu.tsx b/src/components/chat/view/subcomponents/CommandMenu.tsx index 92a598ea..3e6116a0 100644 --- a/src/components/chat/view/subcomponents/CommandMenu.tsx +++ b/src/components/chat/view/subcomponents/CommandMenu.tsx @@ -1,5 +1,15 @@ import { useEffect, useRef } from 'react'; import type { CSSProperties } from 'react'; +import { + CornerDownLeft, + Folder, + MessageSquare, + Sparkles, + Star, + Terminal, + User, + type LucideIcon, +} from 'lucide-react'; type CommandMenuCommand = { name: string; @@ -21,59 +31,92 @@ type CommandMenuProps = { frequentCommands?: CommandMenuCommand[]; }; +type CommandMenuRow = { + command: CommandMenuCommand; + commandIndex: number; + renderKey: string; +}; + const menuBaseStyle: CSSProperties = { - maxHeight: '300px', + maxHeight: '360px', overflowY: 'auto', borderRadius: '8px', - boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', + boxShadow: '0 24px 60px rgba(2, 6, 23, 0.38), 0 0 0 1px rgba(148, 163, 184, 0.12)', zIndex: 1000, - padding: '8px', + padding: '6px', transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out', + backdropFilter: 'blur(12px)', }; const namespaceLabels: Record = { frequent: 'Frequently Used', builtin: 'Built-in Commands', + skill: 'Skills', project: 'Project Commands', user: 'User Commands', other: 'Other Commands', }; -const namespaceIcons: Record = { - frequent: '[*]', - builtin: '[B]', - project: '[P]', - user: '[U]', - other: '[O]', +const namespaceIcons: Record = { + frequent: Star, + builtin: Terminal, + skill: Sparkles, + project: Folder, + user: User, + other: MessageSquare, }; +const namespaceAccentClasses: Record = { + frequent: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200', + builtin: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200', + skill: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200', + project: 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-400/10 dark:text-indigo-200', + user: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200', + other: 'border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-500/20 dark:bg-gray-500/10 dark:text-gray-200', +}; + +const MENU_EDGE_GAP = 16; +const MENU_MAX_HEIGHT = 360; + const getCommandKey = (command: CommandMenuCommand) => `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`; const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other'; +const getNamespaceIcon = (namespace: string) => namespaceIcons[namespace] || namespaceIcons.other; + +const getNamespaceAccentClass = (namespace: string) => + namespaceAccentClasses[namespace] || namespaceAccentClasses.other; + const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => { if (typeof window === 'undefined') { return { position: 'fixed', top: '16px', left: '16px' }; } if (window.innerWidth < 640) { + const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90); return { position: 'fixed', - bottom: `${position.bottom ?? 90}px`, + bottom: `${anchorBottom}px`, left: '16px', right: '16px', width: 'auto', maxWidth: 'calc(100vw - 32px)', - maxHeight: 'min(50vh, 300px)', + maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`, }; } + const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90); + const clampedLeft = Math.max( + MENU_EDGE_GAP, + Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP), + ); + return { position: 'fixed', - top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`, - left: `${position.left}px`, - width: 'min(400px, calc(100vw - 32px))', + bottom: `${anchorBottom}px`, + left: `${clampedLeft}px`, + width: 'min(440px, calc(100vw - 32px))', maxWidth: 'calc(100vw - 32px)', - maxHeight: '300px', + maxHeight: `min(${MENU_MAX_HEIGHT}px, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`, }; }; @@ -123,7 +166,24 @@ export default function CommandMenu({ const hasFrequentCommands = frequentCommands.length > 0; const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey)); - const groupedCommands = commands.reduce>((groups, command) => { + const commandIndexesByKey = new Map(); + commands.forEach((command, index) => { + const key = getCommandKey(command); + const commandIndexes = commandIndexesByKey.get(key) ?? []; + commandIndexes.push(index); + commandIndexesByKey.set(key, commandIndexes); + }); + const frequentCommandOccurrences = new Map(); + const getFrequentCommandIndex = (command: CommandMenuCommand): number => { + const key = getCommandKey(command); + const occurrence = frequentCommandOccurrences.get(key) ?? 0; + frequentCommandOccurrences.set(key, occurrence + 1); + + const commandIndexes = commandIndexesByKey.get(key) ?? []; + return commandIndexes[occurrence] ?? commandIndexes[0] ?? -1; + }; + + const groupedCommands = commands.reduce>((groups, command, index) => { if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) { return groups; } @@ -131,33 +191,46 @@ export default function CommandMenu({ if (!groups[namespace]) { groups[namespace] = []; } - groups[namespace].push(command); + groups[namespace].push({ + command, + commandIndex: index, + renderKey: `${namespace}-${index}-${getCommandKey(command)}`, + }); return groups; }, {}); if (hasFrequentCommands) { - groupedCommands.frequent = frequentCommands; + groupedCommands.frequent = frequentCommands + .map((command, index) => { + const commandIndex = getFrequentCommandIndex(command); + return { + command, + commandIndex, + renderKey: `frequent-${index}-${commandIndex}-${getCommandKey(command)}`, + }; + }) + .filter((row) => row.commandIndex >= 0); } const preferredOrder = hasFrequentCommands - ? ['frequent', 'builtin', 'project', 'user', 'other'] - : ['builtin', 'project', 'user', 'other']; + ? ['frequent', 'builtin', 'skill', 'project', 'user', 'other'] + : ['builtin', 'skill', 'project', 'user', 'other']; const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace)); const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]); - const commandIndexByKey = new Map(); - commands.forEach((command, index) => { - const key = getCommandKey(command); - if (!commandIndexByKey.has(key)) { - commandIndexByKey.set(key, index); - } - }); - if (commands.length === 0) { return (
No commands available
@@ -169,51 +242,73 @@ export default function CommandMenu({ ref={menuRef} role="listbox" aria-label="Available commands" - className="command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800" - style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }} + className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100" + style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }} > {orderedNamespaces.map((namespace) => (
{orderedNamespaces.length > 1 && ( -
- {namespaceLabels[namespace] || namespace} +
+ {namespaceLabels[namespace] || namespace} + + {(groupedCommands[namespace] || []).length} +
)} - {(groupedCommands[namespace] || []).map((command) => { - const commandKey = getCommandKey(command); - const commandIndex = commandIndexByKey.get(commandKey) ?? -1; + {(groupedCommands[namespace] || []).map(({ command, commandIndex, renderKey }) => { const isSelected = commandIndex === selectedIndex; + const NamespaceIcon = getNamespaceIcon(namespace); + const accentClass = getNamespaceAccentClass(namespace); return (
onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)} onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)} onMouseDown={(event) => event.preventDefault()} > -
-
- {namespaceIcons[namespace] || namespaceIcons.other} - {command.name} + {isSelected && ( + + )} + + +
+
+ + {command.name} + {command.metadata?.type && ( - + {command.metadata.type} )}
{command.description && ( -
+
{command.description}
)}
- {isSelected && {'<-'}} + {isSelected && ( + + + )}
); })}