Files
claudecodeui/server/routes/commands.js
2026-05-14 16:57:46 +03:00

603 lines
17 KiB
JavaScript

import { promises as fs } from "fs";
import os from "os";
import path from "path";
import express from "express";
import { providerModelsService } from "../modules/providers/services/provider-models.service.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();
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
const MODEL_PROVIDER_LABELS = {
claude: "Claude",
cursor: "Cursor",
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
};
const readModelProvider = (value) => {
if (typeof value !== "string") {
return "claude";
}
const normalized = value.trim().toLowerCase();
return MODEL_PROVIDERS.includes(normalized) ? normalized : "claude";
};
const getProviderModelOptions = (provider, context) => {
if (provider !== "opencode") {
return undefined;
}
const cwd =
typeof context?.projectPath === "string" ? context.projectPath : undefined;
return { cwd };
};
export const executeModelsCommand = async (args, context) => {
const currentProvider = readModelProvider(context?.provider);
const catalog = await providerModelsService.getProviderModels(
currentProvider,
getProviderModelOptions(currentProvider, context),
);
const availableModels = catalog.OPTIONS.map((option) => option.value);
const availableOptions = catalog.OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}));
const currentModel =
typeof context?.model === "string" && context.model
? context.model
: catalog.DEFAULT;
return {
type: "builtin",
action: "models",
data: {
current: {
provider: currentProvider,
providerLabel: MODEL_PROVIDER_LABELS[currentProvider],
model: currentModel,
},
available: {
[currentProvider]: availableModels,
},
availableModels,
availableOptions,
defaultModel: catalog.DEFAULT,
message: `Current model: ${currentModel}`,
},
};
};
/**
* 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: "/models",
description: "View available models for the current provider",
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" },
},
];
/**
* 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",
commands: builtInCommands.map((command) => ({
name: command.name,
description: command.description,
namespace: command.namespace,
})),
},
};
},
"/models": executeModelsCommand,
"/cost": async (args, context) => {
const tokenUsage = context?.tokenUsage || {};
const provider = readModelProvider(context?.provider);
const catalog = await providerModelsService.getProviderModels(
provider,
getProviderModelOptions(provider, context),
);
const model = context?.model || catalog.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,
},
tokenBreakdown: {
input: inputTokens,
output: outputTokens,
cache: cacheTokens,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
},
provider,
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`;
const statusProvider = readModelProvider(context?.provider);
const statusCatalog = await providerModelsService.getProviderModels(
statusProvider,
getProviderModelOptions(statusProvider, context),
);
const memoryUsage = process.memoryUsage();
return {
type: "builtin",
action: "status",
data: {
version,
packageName,
uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime),
model: context?.model || statusCatalog.DEFAULT,
provider: statusProvider,
nodeVersion: process.version,
platform: process.platform,
pid: process.pid,
memoryUsage: {
rssMb: Math.round(memoryUsage.rss / 1024 / 1024),
heapUsedMb: Math.round(memoryUsage.heapUsed / 1024 / 1024),
heapTotalMb: Math.round(memoryUsage.heapTotal / 1024 / 1024),
},
},
};
},
"/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...",
},
};
},
};
/**
* 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;