Compare commits

...

2 Commits

Author SHA1 Message Date
viper151
10f721cf14 chore(release): v1.32.0 2026-05-13 12:02:26 +00:00
Haile
631695ef73 Surface provider skills in the slash command menu (#759)
* feat(providers): surface skills in slash command menu

Provider skills were hidden behind provider-specific filesystem rules.

That made the backend and UI unable to offer one discovery path for skills.

Add a normalized skills contract, provider service, and provider skills API.

Keep provider-specific lookup rules inside adapters so routes and UI stay generic.

Claude needs plugin handling because enabled plugins resolve through installed_plugins.json.

Plugin folders can expose commands or skills, so Claude scans both forms.

Claude plugin commands are namespaced to avoid collisions with user and project skills.

Codex, Gemini, and Cursor adapters map their expected skill roots into the same contract.

The slash menu now shows skills beside built-in and custom commands for discovery.

The menu avoids mid-message activation, duplicate rows, loose namespace matches, and input overlap.

Provider tests cover discovery locations and Claude plugin edge cases.

* fix(providers): guard invalid skill command namespaces

Claude plugin ids come from local settings and installed plugin metadata.

Invalid ids such as empty strings or @ should not become command namespaces.

Skip plugin folders when no safe plugin name can be derived.

This prevents malformed slash commands like /:command from reaching the UI.

Add regression coverage for empty and @ plugin ids.

Keyboard selection in the slash menu should match mouse selection.

Only skills are inserted into the composer because they are provider invocations.

Built-in and custom commands execute directly and close the menu on success or failure.

* fix(security): centralize safe frontmatter parsing

Move frontmatter parsing into server/shared/frontmatter.ts so every backend caller
uses the same gray-matter configuration instead of importing gray-matter directly.

The goal is to keep executable JS and JSON frontmatter engines disabled for
all markdown discovered from the filesystem, not only command routes.

Provider skills and shared skill metadata now go through parseFrontMatter too.
That closes the gap where plugin or provider markdown could regain default
gray-matter behavior simply because it lived outside the original command path.

Classify the new parser in backend boundaries so modules can depend on the
safe shared API without reaching into legacy utility paths.

* feat(providers): add comprehensive guide for provider module setup and usage
2026-05-12 21:33:12 +03:00
28 changed files with 1885 additions and 142 deletions

View File

@@ -3,6 +3,13 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes
* add clarification on auto mode ([392c73b](https://github.com/siteboon/claudecodeui/commit/392c73b6933600ea8a589c5d4eff5f7b830f99c5))
* enhance regex to correctly parse wrapper file paths for claude.exe ([#741](https://github.com/siteboon/claudecodeui/issues/741)) ([beb0a50](https://github.com/siteboon/claudecodeui/commit/beb0a50413beddfb16f6b49103e1b6b80567cb90))
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
### New Features

View File

@@ -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",
},
{

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.31.5",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cloudcli-ai/cloudcli",
"version": "1.31.5",
"version": "1.32.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.31.5",
"version": "1.32.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",

View File

@@ -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>/
<provider>.provider.ts
<provider>-auth.provider.ts
<provider>-mcp.provider.ts
<provider>-skills.provider.ts
<provider>-sessions.provider.ts
<provider>-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>/<provider>.provider.ts`.
- Extend `AbstractProvider`.
- Expose readonly `auth`, `mcp`, `skills`, `sessions`, and `sessionSynchronizer`.
- Call `super('<provider>')`.
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` | `<workspace>/.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` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.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 { <Provider>ProviderAuth } from './<provider>-auth.provider.js';
import { <Provider>McpProvider } from './<provider>-mcp.provider.js';
import { <Provider>SkillsProvider } from './<provider>-skills.provider.js';
import { <Provider>SessionsProvider } from './<provider>-sessions.provider.js';
import { <Provider>SessionSynchronizer } from './<provider>-session-synchronizer.provider.js';
import type {
IProviderAuth,
IProviderMcp,
IProviderSessionSynchronizer,
IProviderSessions,
IProviderSkills,
} from '@/shared/interfaces.js';
export class <Provider>Provider extends AbstractProvider {
readonly auth: IProviderAuth = new <Provider>ProviderAuth();
readonly mcp: IProviderMcp = new <Provider>McpProvider();
readonly skills: IProviderSkills = new <Provider>SkillsProvider();
readonly sessions: IProviderSessions = new <Provider>SessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer =
new <Provider>SessionSynchronizer();
constructor() {
super('<provider>');
}
}
```
## 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 <Provider>SkillsProvider extends SkillsProvider {
constructor() {
super('<provider>');
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
return [
{
scope: 'project',
rootDir: path.join(workspacePath, '.<provider>', 'skills'),
commandPrefix: '/',
},
];
}
}
```
## Minimal Session Sync Template
```ts
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
export class <Provider>SessionSynchronizer implements IProviderSessionSynchronizer {
async synchronize(since?: Date): Promise<number> {
return 0;
}
async synchronizeFile(filePath: string): Promise<string | null> {
return null;
}
}
```
## AI Prompt Template
Use this prompt when asking an AI agent to add a provider:
```text
Add a new provider "<provider>" using the current provider module architecture.
Requirements:
1) Create:
- server/modules/providers/list/<provider>/<provider>.provider.ts
- server/modules/providers/list/<provider>/<provider>-auth.provider.ts
- server/modules/providers/list/<provider>/<provider>-mcp.provider.ts
- server/modules/providers/list/<provider>/<provider>-skills.provider.ts
- server/modules/providers/list/<provider>/<provider>-sessions.provider.ts
- server/modules/providers/list/<provider>/<provider>-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 <touched files>
- 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.

View File

@@ -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';
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';

View File

@@ -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<boolean> => {
try {
const directoryStats = await stat(directoryPath);
return directoryStats.isDirectory();
} catch {
return false;
}
};
const listChildDirectories = async (directoryPath: string): Promise<string[]> => {
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<string | null> => {
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<ProviderSkill[]> {
return [
...(await super.listSkills(options)),
...(await this.listPluginSkills(getClaudeHomePath())),
];
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
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<ProviderSkill[]> {
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<string>();
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<ProviderSkill[]> {
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<ProviderSkill[]> {
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;
}
}

View File

@@ -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();

View File

@@ -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<boolean> => {
try {
const gitMarkerStats = await fs.stat(path.join(dirPath, '.git'));
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
} catch {
return false;
}
};
const findTopmostGitRoot = async (startPath: string): Promise<string | null> => {
let currentPath = path.resolve(startPath);
let topmostGitRoot: string | null = null;
while (true) {
if (await hasGitMarker(currentPath)) {
topmostGitRoot = currentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
return topmostGitRoot;
};
const addUniqueSource = (
sources: ProviderSkillSource[],
seenRootDirs: Set<string>,
source: ProviderSkillSource,
): void => {
const normalizedRootDir = path.resolve(source.rootDir);
if (seenRootDirs.has(normalizedRootDir)) {
return;
}
seenRootDirs.add(normalizedRootDir);
sources.push({ ...source, rootDir: normalizedRootDir });
};
export class CodexSkillsProvider extends SkillsProvider {
constructor() {
super('codex');
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
const sources: ProviderSkillSource[] = [];
const seenRootDirs = new Set<string>();
const repoRoot = await findTopmostGitRoot(workspacePath);
addUniqueSource(sources, seenRootDirs, {
scope: 'repo',
rootDir: path.join(workspacePath, '.agents', 'skills'),
commandPrefix: '$',
});
if (repoRoot) {
// Codex checks repository skills at the launch folder, one folder above it,
// and the topmost git root; these can collapse to the same directory.
addUniqueSource(sources, seenRootDirs, {
scope: 'repo',
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
commandPrefix: '$',
});
addUniqueSource(sources, seenRootDirs, {
scope: 'repo',
rootDir: path.join(repoRoot, '.agents', 'skills'),
commandPrefix: '$',
});
}
addUniqueSource(sources, seenRootDirs, {
scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$',
});
addUniqueSource(sources, seenRootDirs, {
scope: 'admin',
rootDir: path.join('/etc', 'codex', 'skills'),
commandPrefix: '$',
});
addUniqueSource(sources, seenRootDirs, {
scope: 'system',
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
commandPrefix: '$',
});
return sources;
}
}

View File

@@ -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();

View File

@@ -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<ProviderSkillSource[]> {
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: '/',
},
];
}
}

View File

@@ -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();

View File

@@ -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<ProviderSkillSource[]> {
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: '/',
},
];
}
}

View File

@@ -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();

View File

@@ -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',

View File

@@ -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<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
},
};

View File

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

View File

@@ -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<ProviderSkill[]> {
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<ProviderSkillSource[]>;
}

View File

@@ -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<string> => {
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<void> => {
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<string> => {
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 });
}
});

View File

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

View File

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

View File

@@ -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<ProviderAuthStatus>;
}
// ---------------------------
//----------------- 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<ProviderSkill[]>;
}
// ---------------------------
//----------------- PROVIDER MCP INTERFACE ------------
/**

View File

@@ -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 ------------
/**

View File

@@ -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<string, unk
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
};
// ---------------------------
//----------------- PROVIDER SKILL FILE UTILITIES ------------
/**
* Finds direct child skill markdown files under a provider skill root.
*
* Skill systems usually store one skill per child directory, so direct mode
* scans only `<root>/<skill-name>/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<string[]> {
const skillFiles: string[] = [];
const collectRecursive = async (dirPath: string): Promise<void> => {
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 ------------
/**

View File

@@ -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 || '',

View File

@@ -152,6 +152,7 @@ export function useChatComposerState({
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | 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) {

View File

@@ -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<string, unknown>;
[key: string]: unknown;
}
interface UseSlashCommandsOptions {
selectedProject: Project | null;
provider: LLMProvider;
input: string;
setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>;
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
}
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<string, number> => {
@@ -48,8 +66,78 @@ const saveCommandHistory = (projectName: string, history: Record<string, number>
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
const isSkillCommand = (command: SlashCommand) =>
command.type === 'skill' || command.metadata?.type === 'skill';
const dedupeProviderSkills = (skills: ProviderSkill[]): ProviderSkill[] => {
const seenCommands = new Set<string>();
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);

View File

@@ -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<string, string> = {
frequent: 'Frequently Used',
builtin: 'Built-in Commands',
skill: 'Skills',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands',
};
const namespaceIcons: Record<string, string> = {
frequent: '[*]',
builtin: '[B]',
project: '[P]',
user: '[U]',
other: '[O]',
const namespaceIcons: Record<string, LucideIcon> = {
frequent: Star,
builtin: Terminal,
skill: Sparkles,
project: Folder,
user: User,
other: MessageSquare,
};
const namespaceAccentClasses: Record<string, string> = {
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<Record<string, CommandMenuCommand[]>>((groups, command) => {
const commandIndexesByKey = new Map<string, number[]>();
commands.forEach((command, index) => {
const key = getCommandKey(command);
const commandIndexes = commandIndexesByKey.get(key) ?? [];
commandIndexes.push(index);
commandIndexesByKey.set(key, commandIndexes);
});
const frequentCommandOccurrences = new Map<string, number>();
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<Record<string, CommandMenuRow[]>>((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<string, number>();
commands.forEach((command, index) => {
const key = getCommandKey(command);
if (!commandIndexByKey.has(key)) {
commandIndexByKey.set(key, index);
}
});
if (commands.length === 0) {
return (
<div
ref={menuRef}
className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
style={{
...menuBaseStyle,
...menuPosition,
overflowY: 'hidden',
padding: '20px',
opacity: 1,
transform: 'translateY(0)',
textAlign: 'center',
}}
>
No commands available
</div>
@@ -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) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{namespaceLabels[namespace] || namespace}
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
<span>{namespaceLabels[namespace] || namespace}</span>
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
{(groupedCommands[namespace] || []).length}
</span>
</div>
)}
{(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 (
<div
key={`${namespace}-${command.name}-${command.path || ''}`}
key={renderKey}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
isSelected
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
<div className="min-w-0 flex-1">
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
{isSelected && (
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
)}
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
</span>
<div className="min-w-0 flex-1 pr-1">
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
title={command.name}
>
{command.name}
</span>
{command.metadata?.type && (
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
{command.metadata.type}
</span>
)}
</div>
{command.description && (
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
<div
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
title={command.description}
>
{command.description}
</div>
)}
</div>
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
{isSelected && (
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
</span>
)}
</div>
);
})}