diff --git a/eslint.config.js b/eslint.config.js index 57a71453..e002aece 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -157,7 +157,11 @@ export default tseslint.config( }, { type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly - pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly + pattern: [ + "server/shared/utils.{js,ts}", + "server/shared/frontmatter.ts", + "server/shared/claude-cli-path.ts", + ], // classify shared utility files so modules can depend on them explicitly mode: "file", }, { diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md new file mode 100644 index 00000000..c0d3fcb4 --- /dev/null +++ b/server/modules/providers/README.md @@ -0,0 +1,346 @@ +# Providers Module Guide + +This file documents the current provider contract in `server/modules/providers`. +Keep it current whenever provider wiring, skill discovery, or session sync +behavior changes. The goal is that a human or AI agent can add a new provider +without guessing which files need to move. + +## Current Provider Shape + +Every provider wrapper exposes five facets: + +- `auth` +- `mcp` +- `skills` +- `sessions` +- `sessionSynchronizer` + +These correspond to the shared interfaces in `server/shared/interfaces.ts`: + +- `IProviderAuth` +- `IProviderMcp` +- `IProviderSkills` +- `IProviderSessions` +- `IProviderSessionSynchronizer` + +The services that consume them are: + +- `providerAuthService` +- `providerMcpService` +- `providerSkillsService` +- `sessionsService` +- `sessionSynchronizerService` + +Current provider ids in this repo are: + +- `claude` +- `codex` +- `cursor` +- `gemini` + +Those ids are mirrored in backend unions and frontend provider constants. If +adding a new provider, update every place that hardcodes this list. + +## Current File Layout + +Each provider lives under its own folder in `server/modules/providers/list/`: + +```text +server/modules/providers/list// + .provider.ts + -auth.provider.ts + -mcp.provider.ts + -skills.provider.ts + -sessions.provider.ts + -session-synchronizer.provider.ts +``` + +The existing provider folders are `claude`, `codex`, `cursor`, and `gemini`. + +## What Each Facet Does + +| Facet | Responsibility | Base / Service | +| --- | --- | --- | +| `auth` | Report install/auth state for the provider runtime | `IProviderAuth` -> `providerAuthService` | +| `mcp` | Read, list, write, and remove provider-native MCP config | `McpProvider` -> `providerMcpService` | +| `skills` | Discover provider-native skill markdown files | `SkillsProvider` -> `providerSkillsService` | +| `sessions` | Normalize live events and fetch session history | `IProviderSessions` -> `sessionsService` | +| `sessionSynchronizer` | Scan transcript artifacts and upsert session metadata | `IProviderSessionSynchronizer` -> `sessionSynchronizerService` | + +`sessions` and `sessionSynchronizer` are separate concerns: + +- `sessions` handles runtime event normalization and history fetches. +- `sessionSynchronizer` handles file-backed session indexing into `sessionsDb`. + +## How To Add A Provider + +1. Add the provider id everywhere it is part of the contract. + +- Update `server/shared/types.ts` `LLMProvider`. +- Update `src/types/app.ts` `LLMProvider` if the frontend should know about it. +- Update `server/modules/providers/provider.routes.ts`. +- Update `server/routes/agent.js` if the provider is launchable from the agent runtime. +- Update `server/index.js` if the provider needs runtime boot or shutdown wiring. +- Update `shared/modelConstants.js` if the provider appears in UI provider pickers. +- Update `src/components/chat/hooks/useChatProviderState.ts` and + `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if + the provider should be selectable in chat. +- Update `src/components/provider-auth/view/ProviderLoginModal.tsx` if the + provider has a login/setup flow. + +2. Create the wrapper class. + +- Add `server/modules/providers/list//.provider.ts`. +- Extend `AbstractProvider`. +- Expose readonly `auth`, `mcp`, `skills`, `sessions`, and `sessionSynchronizer`. +- Call `super('')`. + +3. Implement auth. + +- Return a full `ProviderAuthStatus`. +- Treat normal `not installed` / `not authenticated` states as data, not exceptions. +- Keep provider-specific credential discovery inside the auth provider. +- If the provider has no auth step, return a stable unauthenticated or not-installed status instead of omitting the facet. + +4. Implement MCP. + +- Extend `McpProvider`. +- Pass the supported scopes and transports to `super(...)`. +- Implement the four required methods: + - `readScopedServers(...)` + - `writeScopedServers(...)` + - `buildServerConfig(...)` + - `normalizeServerConfig(...)` +- Use the shared validation and normalization behavior from `McpProvider`. +- Keep the provider-specific config format local to the provider implementation. + +Current MCP formats in this repo are: + +| Provider | User / Project Storage | Supported Scopes | Supported Transports | +| --- | --- | --- | --- | +| Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` | +| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` | +| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` | +| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` | + +5. Implement skills. + +- Extend `SkillsProvider`. +- Implement `getSkillSources(workspacePath)`. +- Return the actual discovery roots for the provider. +- Skills are discovered from `SKILL.md` files. +- `readProviderSkillMarkdownDefinition(...)` reads front matter `name` and `description`. +- If `name` is missing, the parent directory name is used as a fallback. +- Use `recursive: true` only when the provider stores skills in nested trees. +- Keep the emitted `command` string aligned with the provider's real skill syntax. + +Current skill discovery roots are: + +| Provider | User Roots | Project / Repo Roots | Prefix | Notes | +| --- | --- | --- | --- | --- | +| Claude | `~/.claude/skills` | `/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. | +| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. | +| Cursor | `~/.cursor/skills` | `/.cursor/skills`, `/.agents/skills` | `/` | Uses slash-style commands. | +| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `/.gemini/skills`, `/.agents/skills` | `/` | Uses slash-style commands. | + +Command forms currently used by the providers are: + +- Claude user/project skills: `/skill-name` +- Claude plugin skills: `/plugin-name:skill-name` +- Codex skills: `$skill-name` +- Cursor skills: `/skill-name` +- Gemini skills: `/skill-name` + +6. Implement sessions. + +- Implement `normalizeMessage(raw, sessionId)` and `fetchHistory(sessionId, options)`. +- Use `createNormalizedMessage(...)` and `generateMessageId(...)` for emitted messages. +- Keep normalized message ids unique. If one raw event produces multiple text + parts, append a discriminator so ids do not collide. +- Keep pagination consistent: + - `limit: null` means unbounded/full history. + - `limit: 0` means an empty page. + - always return `total`, `hasMore`, `offset`, and `limit` when paginating. +- Sanitize any filesystem-derived ids before using them in file or database paths. +- Do not assume a provider's history format matches another provider's format. + +7. Implement session synchronization. + +- Implement `synchronize(since?: Date)` to scan provider artifacts and upsert + sessions into `sessionsDb`. +- Implement `synchronizeFile(filePath)` for single-file watcher updates. +- Use the existing helpers when they fit: + - `buildLookupMap(...)` + - `extractFirstValidJsonlData(...)` + - `findFilesRecursivelyCreatedAfter(...)` + - `normalizeSessionName(...)` + - `readFileTimestamps(...)` +- Make the sync resilient to partial, malformed, or missing provider files. +- The orchestration service runs all provider synchronizers and only advances + `scan_state.last_scanned_at` when every provider succeeds. + +Current session sync roots are: + +| Provider | Scan Roots | Metadata Helpers / Notes | +| --- | --- | --- | +| Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. | +| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. | +| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. | +| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. | + +8. Register the provider. + +- Add the new provider class to `server/modules/providers/provider.registry.ts`. +- Update `server/modules/providers/provider.routes.ts` provider parsing. +- If the provider introduces a new service or lifecycle hook, export it from the module entrypoint that consumes providers. + +9. Wire runtime and UI surfaces outside the providers module when needed. + +If the provider can run live chat sessions, update the runtime entrypoints too: + +- `server/routes/agent.js` +- `server/index.js` + +If the provider is visible in the UI, update: + +- `shared/modelConstants.js` +- `src/components/chat/hooks/useChatProviderState.ts` +- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` +- `src/components/provider-auth/view/ProviderLoginModal.tsx` + +## Minimal Wrapper Template + +```ts +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { ProviderAuth } from './-auth.provider.js'; +import { McpProvider } from './-mcp.provider.js'; +import { SkillsProvider } from './-skills.provider.js'; +import { SessionsProvider } from './-sessions.provider.js'; +import { SessionSynchronizer } from './-session-synchronizer.provider.js'; +import type { + IProviderAuth, + IProviderMcp, + IProviderSessionSynchronizer, + IProviderSessions, + IProviderSkills, +} from '@/shared/interfaces.js'; + +export class Provider extends AbstractProvider { + readonly auth: IProviderAuth = new ProviderAuth(); + readonly mcp: IProviderMcp = new McpProvider(); + readonly skills: IProviderSkills = new SkillsProvider(); + readonly sessions: IProviderSessions = new SessionsProvider(); + readonly sessionSynchronizer: IProviderSessionSynchronizer = + new SessionSynchronizer(); + + constructor() { + super(''); + } +} +``` + +## Minimal Skills Template + +```ts +import path from 'node:path'; + +import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; +import type { ProviderSkillSource } from '@/shared/types.js'; + +export class SkillsProvider extends SkillsProvider { + constructor() { + super(''); + } + + protected async getSkillSources(workspacePath: string): Promise { + return [ + { + scope: 'project', + rootDir: path.join(workspacePath, '.', 'skills'), + commandPrefix: '/', + }, + ]; + } +} +``` + +## Minimal Session Sync Template + +```ts +import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; + +export class SessionSynchronizer implements IProviderSessionSynchronizer { + async synchronize(since?: Date): Promise { + return 0; + } + + async synchronizeFile(filePath: string): Promise { + return null; + } +} +``` + +## AI Prompt Template + +Use this prompt when asking an AI agent to add a provider: + +```text +Add a new provider "" using the current provider module architecture. + +Requirements: +1) Create: + - server/modules/providers/list//.provider.ts + - server/modules/providers/list//-auth.provider.ts + - server/modules/providers/list//-mcp.provider.ts + - server/modules/providers/list//-skills.provider.ts + - server/modules/providers/list//-sessions.provider.ts + - server/modules/providers/list//-session-synchronizer.provider.ts +2) Register in: + - server/modules/providers/provider.registry.ts + - server/modules/providers/provider.routes.ts + - server/shared/types.ts LLMProvider + - src/types/app.ts LLMProvider +3) Mirror the nearest existing provider implementation for file naming, style, + and error handling. +4) Implement skills support with SkillsProvider and the current skill roots. +5) Implement session synchronization if the provider stores transcript files. +6) Ensure sessions use unique ids, safe path handling, and correct pagination. +7) Keep `sessions` and `sessionSynchronizer` separate. +8) Run: + - npx eslint + - npx tsc --noEmit -p server/tsconfig.json +``` + +## Validation + +After adding or changing a provider, run the relevant checks: + +```bash +npx eslint server/modules/providers/**/*.ts server/shared/types.ts server/shared/interfaces.ts +npx tsc --noEmit -p server/tsconfig.json +``` + +Useful tests in this repo: + +- `server/modules/providers/tests/mcp.test.ts` +- `server/modules/providers/tests/skills.test.ts` + +If you touch sessions or session synchronization, add or update focused tests +alongside the implementation. + +## Common Mistakes + +- Adding provider files but forgetting `provider.registry.ts` or + `provider.routes.ts`. +- Updating backend provider ids but not `src/types/app.ts` or the frontend + provider constants. +- Omitting `skills` or `sessionSynchronizer` from the wrapper. +- Returning duplicate normalized message ids for split content. +- Treating `limit === 0` as unbounded history. +- Building file paths from raw session ids without validation. +- Hardcoding a skill root without checking the provider's actual discovery rules. +- Forgetting that Claude plugin skills are discovered differently from normal + user/project skill folders. +- Assuming one provider's MCP config file format works for the others. + + 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..cbb1073a --- /dev/null +++ b/server/modules/providers/list/claude/claude-skills.provider.ts @@ -0,0 +1,257 @@ +import { readFile, readdir, stat } 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 { parseFrontMatter } from '@/shared/frontmatter.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 | null => { + const normalizedPluginId = pluginId.trim(); + if (!normalizedPluginId || normalizedPluginId === '@') { + return null; + } + + const [pluginName] = normalizedPluginId.split('@'); + return readOptionalString(pluginName) ?? null; +}; + +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); + if (!pluginName) { + continue; + } + + 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 = parseFrontMatter(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..179ae400 --- /dev/null +++ b/server/modules/providers/tests/skills.test.ts @@ -0,0 +1,446 @@ +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 emptyIdPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'invalid-empty-plugin', + 'empty', + '000', + ); + const atIdPluginInstallPath = path.join( + tempRoot, + '.claude', + 'plugins', + 'cache', + 'invalid-at-plugin', + 'at', + '000', + ); + 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 writeClaudePluginCommand( + path.join(emptyIdPluginInstallPath, 'commands'), + 'invalid-empty-command', + 'Invalid empty id command', + ); + await writeClaudePluginCommand( + path.join(atIdPluginInstallPath, 'commands'), + 'invalid-at-command', + 'Invalid at id 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: { + '': true, + '@': true, + '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: { + '': [ + { + scope: 'user', + installPath: emptyIdPluginInstallPath, + version: '000', + }, + ], + '@': [ + { + scope: 'user', + installPath: atIdPluginInstallPath, + version: '000', + }, + ], + '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); + assert.equal(byName.has('invalid-empty-command'), false); + assert.equal(byName.has('invalid-at-command'), false); + assert.equal(skills.some((skill) => skill.command.startsWith('/:')), 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/routes/commands.js b/server/routes/commands.js index ac957511..bb61644f 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -1,9 +1,11 @@ -import express from 'express'; import { promises as fs } from 'fs'; -import path from 'path'; import os from 'os'; +import path from 'path'; + +import express from 'express'; + import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; -import { parseFrontmatter } from '../utils/frontmatter.js'; +import { parseFrontMatter } from '../shared/frontmatter.js'; import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js'; const __dirname = getModuleDir(import.meta.url); @@ -40,7 +42,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) { // Parse markdown file for metadata try { const content = await fs.readFile(fullPath, 'utf8'); - const { data: frontmatter, content: commandContent } = parseFrontmatter(content); + const { data: frontmatter, content: commandContent } = parseFrontMatter(content); // Calculate relative path from baseDir for command name const relativePath = path.relative(baseDir, fullPath); @@ -513,7 +515,7 @@ router.post('/execute', async (req, res) => { } } const content = await fs.readFile(commandPath, 'utf8'); - const { data: metadata, content: commandContent } = parseFrontmatter(content); + const { data: metadata, content: commandContent } = parseFrontMatter(content); // Basic argument replacement (will be enhanced in command parser utility) let processedContent = commandContent; diff --git a/server/utils/frontmatter.js b/server/shared/frontmatter.ts similarity index 81% rename from server/utils/frontmatter.js rename to server/shared/frontmatter.ts index 0a4b1eb8..9eb7ddd8 100644 --- a/server/utils/frontmatter.js +++ b/server/shared/frontmatter.ts @@ -9,10 +9,10 @@ const frontmatterOptions = { engines: { js: disabledFrontmatterEngine, javascript: disabledFrontmatterEngine, - json: disabledFrontmatterEngine - } + json: disabledFrontmatterEngine, + }, }; -export function parseFrontmatter(content) { +export function parseFrontMatter(content: string) { return matter(content, frontmatterOptions); } 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..2903814e 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -17,6 +17,7 @@ import readline from 'node:readline'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { parseFrontMatter } from '@/shared/frontmatter.js'; import type { AnyRecord, ApiSuccessShape, @@ -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 = parseFrontMatter(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/server/utils/commandParser.js b/server/utils/commandParser.js index 56e3f702..0451320d 100644 --- a/server/utils/commandParser.js +++ b/server/utils/commandParser.js @@ -1,9 +1,11 @@ +import { execFile } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; -import { execFile } from 'child_process'; import { promisify } from 'util'; + import { parse as parseShellCommand } from 'shell-quote'; -import { parseFrontmatter } from './frontmatter.js'; + +import { parseFrontMatter } from '../shared/frontmatter.js'; const execFileAsync = promisify(execFile); @@ -32,7 +34,7 @@ const BASH_COMMAND_ALLOWLIST = [ */ export function parseCommand(content) { try { - const parsed = parseFrontmatter(content); + const parsed = parseFrontMatter(content); return { data: parsed.data || {}, content: parsed.content || '', 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..db6eefaa 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,25 +283,63 @@ 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 executeNonSkillCommand = useCallback( + (command: SlashCommand) => { const executionResult = onExecuteCommand(command); if (isPromiseLike(executionResult)) { - executionResult.catch(() => { - // Keep behavior silent; execution errors are handled by caller. - }); + executionResult.then( + () => { + resetCommandMenuState(); + }, + () => { + resetCommandMenuState(); + // Keep behavior silent; execution errors are handled by caller. + }, + ); + } else { + resetCommandMenuState(); } }, - [input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand], + [onExecuteCommand, resetCommandMenuState], + ); + + const selectCommandFromKeyboard = useCallback( + (command: SlashCommand) => { + if (isSkillCommand(command)) { + insertCommandIntoInput(command); + return; + } + + executeNonSkillCommand(command); + }, + [executeNonSkillCommand, insertCommandIntoInput], ); const handleCommandSelect = useCallback( @@ -231,20 +354,14 @@ export function useSlashCommands({ } trackCommandUsage(command); - const executionResult = onExecuteCommand(command); - - if (isPromiseLike(executionResult)) { - executionResult.then(() => { - resetCommandMenuState(); - }); - executionResult.catch(() => { - // Keep behavior silent; execution errors are handled by caller. - }); - } else { - resetCommandMenuState(); + if (isSkillCommand(command)) { + insertCommandIntoInput(command); + return; } + + executeNonSkillCommand(command); }, - [selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState], + [selectedProject, trackCommandUsage, insertCommandIntoInput, executeNonSkillCommand], ); const handleToggleCommandMenu = useCallback(() => { @@ -276,7 +393,7 @@ export function useSlashCommands({ return; } - const slashPattern = /(^|\s)\/(\S*)$/; + const slashPattern = /^\/(\S*)$/; const match = textBeforeCursor.match(slashPattern); if (!match) { @@ -284,8 +401,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 && ( + + + )}
); })}