mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-23 09:57:32 +00:00
Previously, the model parameter was accepted by the /api/agent endpoint and extracted from requests, but was never passed through to the Claude SDK or Codex SDK, causing all requests to use default models regardless of user selection. Changes: - Add model parameter to queryClaudeSDK() options in routes/agent.js - Add model to threadOptions in openai-codex.js - Remove unused /cost slash command and PRICING constants - Centralize all model definitions in shared/modelConstants.js - Update API documentation to dynamically load models from constants
522 lines
14 KiB
JavaScript
522 lines
14 KiB
JavaScript
import express from 'express';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import os from 'os';
|
|
import matter from 'gray-matter';
|
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
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 } = matter(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}`
|
|
}
|
|
};
|
|
},
|
|
|
|
'/status': async (args, context) => {
|
|
// Read version from package.json
|
|
const packageJsonPath = path.join(path.dirname(__dirname), '..', '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-sonnet-4.5',
|
|
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/load
|
|
* Load a specific command file and return its content and metadata
|
|
*/
|
|
router.post('/load', async (req, res) => {
|
|
try {
|
|
const { commandPath } = req.body;
|
|
|
|
if (!commandPath) {
|
|
return res.status(400).json({
|
|
error: 'Command path is required'
|
|
});
|
|
}
|
|
|
|
// Security: Prevent path traversal
|
|
const resolvedPath = path.resolve(commandPath);
|
|
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
|
|
!resolvedPath.includes('.claude/commands')) {
|
|
return res.status(403).json({
|
|
error: 'Access denied',
|
|
message: 'Command must be in .claude/commands directory'
|
|
});
|
|
}
|
|
|
|
// Read and parse the command file
|
|
const content = await fs.readFile(commandPath, 'utf8');
|
|
const { data: metadata, content: commandContent } = matter(content);
|
|
|
|
res.json({
|
|
path: commandPath,
|
|
metadata,
|
|
content: commandContent
|
|
});
|
|
} 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 loading command:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to load command',
|
|
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 } = matter(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;
|