mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-13 16:24:43 +00:00
Compare commits
2 Commits
feature/se
...
v1.32.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f721cf14 | ||
|
|
631695ef73 |
@@ -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
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
346
server/modules/providers/README.md
Normal file
346
server/modules/providers/README.md
Normal 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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
257
server/modules/providers/list/claude/claude-skills.provider.ts
Normal file
257
server/modules/providers/list/claude/claude-skills.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
100
server/modules/providers/list/codex/codex-skills.provider.ts
Normal file
100
server/modules/providers/list/codex/codex-skills.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
15
server/modules/providers/services/skills.service.ts
Normal file
15
server/modules/providers/services/skills.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
64
server/modules/providers/shared/skills/skills.provider.ts
Normal file
64
server/modules/providers/shared/skills/skills.provider.ts
Normal 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[]>;
|
||||
}
|
||||
446
server/modules/providers/tests/skills.test.ts
Normal file
446
server/modules/providers/tests/skills.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ------------
|
||||
/**
|
||||
|
||||
@@ -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 ------------
|
||||
/**
|
||||
|
||||
@@ -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 ------------
|
||||
/**
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user