Files
claudecodeui/server/routes/commands.js
Haile 631695ef73 Surface provider skills in the slash command menu (#759)
* feat(providers): surface skills in slash command menu

Provider skills were hidden behind provider-specific filesystem rules.

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

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

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

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

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

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

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

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

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

Provider tests cover discovery locations and Claude plugin edge cases.

* fix(providers): guard invalid skill command namespaces

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

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

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

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

Add regression coverage for empty and @ plugin ids.

Keyboard selection in the slash menu should match mouse selection.

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

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

* fix(security): centralize safe frontmatter parsing

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

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

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

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

* feat(providers): add comprehensive guide for provider module setup and usage
2026-05-12 21:33:12 +03:00

557 lines
16 KiB
JavaScript

import { promises as fs } from 'fs';
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 '../shared/frontmatter.js';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
const __dirname = getModuleDir(import.meta.url);
// This route reads the top-level package.json for the status command, so it needs the real
// app root even after compilation moves the route file under dist-server/server/routes.
const APP_ROOT = findAppRoot(__dirname);
const router = express.Router();
/**
* Recursively scan directory for command files (.md)
* @param {string} dir - Directory to scan
* @param {string} baseDir - Base directory for relative paths
* @param {string} namespace - Namespace for commands (e.g., 'project', 'user')
* @returns {Promise<Array>} Array of command objects
*/
async function scanCommandsDirectory(dir, baseDir, namespace) {
const commands = [];
try {
// Check if directory exists
await fs.access(dir);
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
commands.push(...subCommands);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
// Parse markdown file for metadata
try {
const content = await fs.readFile(fullPath, 'utf8');
const { data: frontmatter, content: commandContent } = parseFrontMatter(content);
// Calculate relative path from baseDir for command name
const relativePath = path.relative(baseDir, fullPath);
// Remove .md extension and convert to command name
const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
// Extract description from frontmatter or first line of content
let description = frontmatter.description || '';
if (!description) {
const firstLine = commandContent.trim().split('\n')[0];
description = firstLine.replace(/^#+\s*/, '').trim();
}
commands.push({
name: commandName,
path: fullPath,
relativePath,
description,
namespace,
metadata: frontmatter
});
} catch (err) {
console.error(`Error parsing command file ${fullPath}:`, err.message);
}
}
}
} catch (err) {
// Directory doesn't exist or can't be accessed - this is okay
if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
console.error(`Error scanning directory ${dir}:`, err.message);
}
}
return commands;
}
/**
* Built-in commands that are always available
*/
const builtInCommands = [
{
name: '/help',
description: 'Show help documentation for Claude Code',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/clear',
description: 'Clear the conversation history',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/model',
description: 'Switch or view the current AI model',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/cost',
description: 'Display token usage and cost information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/memory',
description: 'Open CLAUDE.md memory file for editing',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/config',
description: 'Open settings and configuration',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/status',
description: 'Show system status and version information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/rewind',
description: 'Rewind the conversation to a previous state',
namespace: 'builtin',
metadata: { type: 'builtin' }
}
];
/**
* Built-in command handlers
* Each handler returns { type: 'builtin', action: string, data: any }
*/
const builtInHandlers = {
'/help': async (args, context) => {
const helpText = `# Claude Code Commands
## Built-in Commands
${builtInCommands.map(cmd => `### ${cmd.name}
${cmd.description}
`).join('\n')}
## Custom Commands
Custom commands can be created in:
- Project: \`.claude/commands/\` (project-specific)
- User: \`~/.claude/commands/\` (available in all projects)
### Command Syntax
- **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional
- **File Includes**: Use \`@filename\` to include file contents
- **Bash Commands**: Use \`!command\` to execute bash commands
### Examples
\`\`\`markdown
/mycommand arg1 arg2
\`\`\`
`;
return {
type: 'builtin',
action: 'help',
data: {
content: helpText,
format: 'markdown'
}
};
},
'/clear': async (args, context) => {
return {
type: 'builtin',
action: 'clear',
data: {
message: 'Conversation history cleared'
}
};
},
'/model': async (args, context) => {
// Read available models from centralized constants
const availableModels = {
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
};
const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
return {
type: 'builtin',
action: 'model',
data: {
current: {
provider: currentProvider,
model: currentModel
},
available: availableModels,
message: args.length > 0
? `Switching to model: ${args[0]}`
: `Current model: ${currentModel}`
}
};
},
'/cost': async (args, context) => {
const tokenUsage = context?.tokenUsage || {};
const provider = context?.provider || 'claude';
const model =
context?.model ||
(provider === 'cursor'
? CURSOR_MODELS.DEFAULT
: provider === 'codex'
? CODEX_MODELS.DEFAULT
: CLAUDE_MODELS.DEFAULT);
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
const total =
Number(
tokenUsage.total ??
tokenUsage.contextWindow ??
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
) || 160000;
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.promptTokens ??
0,
) || 0;
const outputTokens =
Number(
tokenUsage.outputTokens ??
tokenUsage.output ??
tokenUsage.cumulativeOutputTokens ??
tokenUsage.completionTokens ??
0,
) || 0;
const cacheTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cacheCreationTokens ??
tokenUsage.cacheTokens ??
tokenUsage.cachedTokens ??
0,
) || 0;
// If we only have total used tokens, treat them as input for display/estimation.
const inputTokens =
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
// Rough default rates by provider (USD / 1M tokens).
const pricingByProvider = {
claude: { input: 3, output: 15 },
cursor: { input: 3, output: 15 },
codex: { input: 1.5, output: 6 },
};
const rates = pricingByProvider[provider] || pricingByProvider.claude;
const inputCost = (inputTokens / 1_000_000) * rates.input;
const outputCost = (outputTokens / 1_000_000) * rates.output;
const totalCost = inputCost + outputCost;
return {
type: 'builtin',
action: 'cost',
data: {
tokenUsage: {
used,
total,
percentage,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
},
model,
},
};
},
'/status': async (args, context) => {
// Read version from package.json
const packageJsonPath = path.join(APP_ROOT, 'package.json');
let version = 'unknown';
let packageName = 'claude-code-ui';
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
version = packageJson.version;
packageName = packageJson.name;
} catch (err) {
console.error('Error reading package.json:', err);
}
const uptime = process.uptime();
const uptimeMinutes = Math.floor(uptime / 60);
const uptimeHours = Math.floor(uptimeMinutes / 60);
const uptimeFormatted = uptimeHours > 0
? `${uptimeHours}h ${uptimeMinutes % 60}m`
: `${uptimeMinutes}m`;
return {
type: 'builtin',
action: 'status',
data: {
version,
packageName,
uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime),
model: context?.model || CLAUDE_MODELS.DEFAULT,
provider: context?.provider || 'claude',
nodeVersion: process.version,
platform: process.platform
}
};
},
'/memory': async (args, context) => {
const projectPath = context?.projectPath;
if (!projectPath) {
return {
type: 'builtin',
action: 'memory',
data: {
error: 'No project selected',
message: 'Please select a project to access its CLAUDE.md file'
}
};
}
const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
// Check if CLAUDE.md exists
let exists = false;
try {
await fs.access(claudeMdPath);
exists = true;
} catch (err) {
// File doesn't exist
}
return {
type: 'builtin',
action: 'memory',
data: {
path: claudeMdPath,
exists,
message: exists
? `Opening CLAUDE.md at ${claudeMdPath}`
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
}
};
},
'/config': async (args, context) => {
return {
type: 'builtin',
action: 'config',
data: {
message: 'Opening settings...'
}
};
},
'/rewind': async (args, context) => {
const steps = args[0] ? parseInt(args[0]) : 1;
if (isNaN(steps) || steps < 1) {
return {
type: 'builtin',
action: 'rewind',
data: {
error: 'Invalid steps parameter',
message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
}
};
}
return {
type: 'builtin',
action: 'rewind',
data: {
steps,
message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
}
};
}
};
/**
* POST /api/commands/list
* List all available commands from project and user directories
*/
router.post('/list', async (req, res) => {
try {
const { projectPath } = req.body;
const allCommands = [...builtInCommands];
// Scan project-level commands (.claude/commands/)
if (projectPath) {
const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
const projectCommands = await scanCommandsDirectory(
projectCommandsDir,
projectCommandsDir,
'project'
);
allCommands.push(...projectCommands);
}
// Scan user-level commands (~/.claude/commands/)
const homeDir = os.homedir();
const userCommandsDir = path.join(homeDir, '.claude', 'commands');
const userCommands = await scanCommandsDirectory(
userCommandsDir,
userCommandsDir,
'user'
);
allCommands.push(...userCommands);
// Separate built-in and custom commands
const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
// Sort commands alphabetically by name
customCommands.sort((a, b) => a.name.localeCompare(b.name));
res.json({
builtIn: builtInCommands,
custom: customCommands,
count: allCommands.length
});
} catch (error) {
console.error('Error listing commands:', error);
res.status(500).json({
error: 'Failed to list commands',
message: error.message
});
}
});
/**
* POST /api/commands/execute
* Execute a command with argument replacement
* This endpoint prepares the command content but doesn't execute bash commands yet
* (that will be handled in the command parser utility)
*/
router.post('/execute', async (req, res) => {
try {
const { commandName, commandPath, args = [], context = {} } = req.body;
if (!commandName) {
return res.status(400).json({
error: 'Command name is required'
});
}
// Handle built-in commands
const handler = builtInHandlers[commandName];
if (handler) {
try {
const result = await handler(args, context);
return res.json({
...result,
command: commandName
});
} catch (error) {
console.error(`Error executing built-in command ${commandName}:`, error);
return res.status(500).json({
error: 'Command execution failed',
message: error.message,
command: commandName
});
}
}
// Handle custom commands
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required for custom commands'
});
}
// Load command content
// Security: validate commandPath is within allowed directories
{
const resolvedPath = path.resolve(commandPath);
const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
const projectBase = context?.projectPath
? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
: null;
const isUnder = (base) => {
const rel = path.relative(base, resolvedPath);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
};
if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
});
}
}
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontMatter(content);
// Basic argument replacement (will be enhanced in command parser utility)
let processedContent = commandContent;
// Replace $ARGUMENTS with all arguments joined
const argsString = args.join(' ');
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
// Replace $1, $2, etc. with positional arguments
args.forEach((arg, index) => {
const placeholder = `$${index + 1}`;
processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
});
res.json({
type: 'custom',
command: commandName,
content: processedContent,
metadata,
hasFileIncludes: processedContent.includes('@'),
hasBashCommands: processedContent.includes('!')
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
});
}
console.error('Error executing command:', error);
res.status(500).json({
error: 'Failed to execute command',
message: error.message
});
}
});
export default router;