mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-16 02:21:31 +00:00
* fix: remove project dependency from settings controller and onboarding * fix(settings): remove onClose prop from useSettingsController args * chore: tailwind classes order * refactor: move provider auth status management to custom hook * refactor: rename SessionProvider to LLMProvider * feat(frontend): support for @ alias based imports) * fix: replace init.sql with schema.js * fix: refactor database initialization to use schema.js for SQL statements * feat(server): add a real backend TypeScript build and enforce module boundaries The backend had started to grow beyond what the frontend-only tooling setup could support safely. We were still running server code directly from /server, linting mainly the client, and relying on path assumptions such as "../.." that only worked in the source layout. That created three problems: - backend alias imports were hard to resolve consistently in the editor, ESLint, and the runtime - server code had no enforced module boundary rules, so cross-module deep imports could bypass intended public entry points - building the backend into a separate output directory would break repo-level lookups for package.json, .env, dist, and public assets because those paths were derived from source-only relative assumptions This change makes the backend tooling explicit and runtime-safe. A dedicated backend TypeScript config now lives in server/tsconfig.json, with tsconfig.server.json reduced to a compatibility shim. This gives the language service and backend tooling a canonical project rooted in /server while still preserving top-level compatibility for any existing references. The backend alias mapping now resolves relative to /server, which avoids colliding with the frontend's "@/..." -> "src/*" mapping. The package scripts were updated so development runs through tsx with the backend tsconfig, build now produces a compiled backend in dist-server, and typecheck/lint cover both client and server. A new build-server.mjs script runs TypeScript and tsc-alias and cleans dist-server first, which prevents stale compiled files from shadowing current source files after refactors. To make the compiled backend behave the same as the source backend, runtime path resolution was centralized in server/utils/runtime-paths.js. Instead of assuming fixed relative paths from each module, server entry points now resolve the actual app root and server root at runtime. That keeps package.json, .env, dist, public, and default database paths stable whether code is executed from /server or from /dist-server/server. ESLint was expanded from a frontend-only setup into a backend-aware one. The backend now uses import resolution tied to the backend tsconfig so aliased imports resolve correctly in linting, import ordering matches the frontend style, and unused/duplicate imports are surfaced consistently. Most importantly, eslint-plugin-boundaries now enforces server module boundaries. Files under server/modules can no longer import another module's internals directly. Cross-module imports must go through that module's barrel file (index.ts/index.js). boundaries/no-unknown was also enabled so alias-resolution gaps cannot silently bypass the rule. Together, these changes make the backend buildable, keep runtime path resolution stable after compilation, align server tooling with the client where appropriate, and enforce a stricter modular architecture for server code. * fix: update package.json to include dist-server in files and remove tsconfig.server.json * refactor: remove build-server.mjs and inline its logic into package.json scripts * fix: update paths in package.json and bin.js to use dist-server directory * feat(eslint): add backend shared types and enforce compile-time contract for imports * fix(eslint): update shared types pattern --------- Co-authored-by: Haileyesus <something@gmail.com>
604 lines
17 KiB
JavaScript
604 lines
17 KiB
JavaScript
import express from 'express';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
|
import { parseFrontmatter } from '../utils/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-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 } = parseFrontmatter(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 } = 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;
|