mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 01:12:46 +00:00
603 lines
17 KiB
JavaScript
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;
|