|
| 1 | +/** |
| 2 | + * Slash command registry for runcode. |
| 3 | + * Extracted from loop.ts for maintainability. |
| 4 | + * |
| 5 | + * Two types of commands: |
| 6 | + * 1. "Handled" — execute directly, emit events, return { handled: true } |
| 7 | + * 2. "Rewrite" — transform input into a prompt for the agent, return { handled: false, rewritten } |
| 8 | + */ |
| 9 | +import fs from 'node:fs'; |
| 10 | +import path from 'node:path'; |
| 11 | +import { execSync } from 'node:child_process'; |
| 12 | +import { BLOCKRUN_DIR, VERSION } from '../config.js'; |
| 13 | +import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js'; |
| 14 | +import { forceCompact } from './compact.js'; |
| 15 | +import { listSessions, loadSessionHistory, } from '../session/storage.js'; |
| 16 | +// ─── Git helpers ────────────────────────────────────────────────────────── |
| 17 | +function gitExec(cmd, cwd, timeout = 5000, maxBuffer) { |
| 18 | + return execSync(cmd, { |
| 19 | + cwd, |
| 20 | + encoding: 'utf-8', |
| 21 | + timeout, |
| 22 | + maxBuffer: maxBuffer || 1024 * 1024, |
| 23 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 24 | + }).trim(); |
| 25 | +} |
| 26 | +function gitCmd(ctx, cmd, timeout, maxBuffer) { |
| 27 | + try { |
| 28 | + return gitExec(cmd, ctx.config.workingDir || process.cwd(), timeout, maxBuffer); |
| 29 | + } |
| 30 | + catch (e) { |
| 31 | + ctx.onEvent({ kind: 'text_delta', text: `Git error: ${e.message?.split('\n')[0] || 'unknown'}\n` }); |
| 32 | + return null; |
| 33 | + } |
| 34 | +} |
| 35 | +function emitDone(ctx) { |
| 36 | + ctx.onEvent({ kind: 'turn_done', reason: 'completed' }); |
| 37 | +} |
| 38 | +// ─── Command Definitions ────────────────────────────────────────────────── |
| 39 | +// Direct-handled commands (don't go to agent) |
| 40 | +const DIRECT_COMMANDS = { |
| 41 | + '/stash': (ctx) => { |
| 42 | + const r = gitCmd(ctx, 'git stash push -m "runcode auto-stash"', 10000); |
| 43 | + if (r !== null) |
| 44 | + ctx.onEvent({ kind: 'text_delta', text: r || 'No changes to stash.\n' }); |
| 45 | + emitDone(ctx); |
| 46 | + }, |
| 47 | + '/unstash': (ctx) => { |
| 48 | + const r = gitCmd(ctx, 'git stash pop', 10000); |
| 49 | + if (r !== null) |
| 50 | + ctx.onEvent({ kind: 'text_delta', text: r || 'Stash applied.\n' }); |
| 51 | + emitDone(ctx); |
| 52 | + }, |
| 53 | + '/log': (ctx) => { |
| 54 | + const r = gitCmd(ctx, 'git log --oneline -15 --no-color'); |
| 55 | + ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No commits or not a git repo.\n' }); |
| 56 | + emitDone(ctx); |
| 57 | + }, |
| 58 | + '/status': (ctx) => { |
| 59 | + const r = gitCmd(ctx, 'git status --short --branch'); |
| 60 | + ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Not a git repo.\n' }); |
| 61 | + emitDone(ctx); |
| 62 | + }, |
| 63 | + '/diff': (ctx) => { |
| 64 | + const r = gitCmd(ctx, 'git diff --stat && echo "---" && git diff', 10000, 512 * 1024); |
| 65 | + ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`diff\n${r}\n\`\`\`\n` : 'No changes.\n' }); |
| 66 | + emitDone(ctx); |
| 67 | + }, |
| 68 | + '/undo': (ctx) => { |
| 69 | + const r = gitCmd(ctx, 'git reset --soft HEAD~1'); |
| 70 | + if (r !== null) |
| 71 | + ctx.onEvent({ kind: 'text_delta', text: 'Last commit undone. Changes preserved in staging.\n' }); |
| 72 | + emitDone(ctx); |
| 73 | + }, |
| 74 | + '/bug': (ctx) => { |
| 75 | + ctx.onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' }); |
| 76 | + emitDone(ctx); |
| 77 | + }, |
| 78 | + '/version': (ctx) => { |
| 79 | + ctx.onEvent({ kind: 'text_delta', text: `RunCode v${VERSION}\n` }); |
| 80 | + emitDone(ctx); |
| 81 | + }, |
| 82 | + '/mcp': async (ctx) => { |
| 83 | + const { listMcpServers } = await import('../mcp/client.js'); |
| 84 | + const servers = listMcpServers(); |
| 85 | + if (servers.length === 0) { |
| 86 | + ctx.onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' }); |
| 87 | + } |
| 88 | + else { |
| 89 | + let text = `**${servers.length} MCP server(s) connected:**\n\n`; |
| 90 | + for (const s of servers) { |
| 91 | + text += ` **${s.name}** — ${s.toolCount} tools\n`; |
| 92 | + for (const t of s.tools) |
| 93 | + text += ` · ${t}\n`; |
| 94 | + } |
| 95 | + ctx.onEvent({ kind: 'text_delta', text }); |
| 96 | + } |
| 97 | + emitDone(ctx); |
| 98 | + }, |
| 99 | + '/context': async (ctx) => { |
| 100 | + const { estimated, apiAnchored } = getAnchoredTokenCount(ctx.history); |
| 101 | + const contextWindow = getContextWindow(ctx.config.model); |
| 102 | + const usagePct = ((estimated / contextWindow) * 100).toFixed(1); |
| 103 | + ctx.onEvent({ kind: 'text_delta', text: `**Session Context**\n` + |
| 104 | + ` Model: ${ctx.config.model}\n` + |
| 105 | + ` Mode: ${ctx.config.permissionMode || 'default'}\n` + |
| 106 | + ` Messages: ${ctx.history.length}\n` + |
| 107 | + ` Tokens: ~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k (${usagePct}%)${apiAnchored ? ' ✓' : ' ~'}\n` + |
| 108 | + ` Session: ${ctx.sessionId}\n` + |
| 109 | + ` Directory: ${ctx.config.workingDir || process.cwd()}\n` |
| 110 | + }); |
| 111 | + emitDone(ctx); |
| 112 | + }, |
| 113 | + '/doctor': (ctx) => { |
| 114 | + const checks = []; |
| 115 | + try { |
| 116 | + execSync('git --version', { stdio: 'pipe' }); |
| 117 | + checks.push('✓ git available'); |
| 118 | + } |
| 119 | + catch { |
| 120 | + checks.push('✗ git not found'); |
| 121 | + } |
| 122 | + try { |
| 123 | + execSync('rg --version', { stdio: 'pipe' }); |
| 124 | + checks.push('✓ ripgrep available'); |
| 125 | + } |
| 126 | + catch { |
| 127 | + checks.push('⚠ ripgrep not found (using native grep fallback)'); |
| 128 | + } |
| 129 | + checks.push(fs.existsSync(path.join(BLOCKRUN_DIR, 'wallet.json')) ? '✓ wallet configured' : '⚠ no wallet — run: runcode setup'); |
| 130 | + checks.push(fs.existsSync(path.join(BLOCKRUN_DIR, 'runcode-config.json')) ? '✓ config file exists' : '⚠ no config — using defaults'); |
| 131 | + checks.push(`✓ model: ${ctx.config.model}`); |
| 132 | + checks.push(`✓ history: ${ctx.history.length} messages, ~${estimateHistoryTokens(ctx.history).toLocaleString()} tokens`); |
| 133 | + checks.push(`✓ session: ${ctx.sessionId}`); |
| 134 | + checks.push(`✓ version: v${VERSION}`); |
| 135 | + ctx.onEvent({ kind: 'text_delta', text: `**Health Check**\n${checks.map(c => ' ' + c).join('\n')}\n` }); |
| 136 | + emitDone(ctx); |
| 137 | + }, |
| 138 | + '/plan': (ctx) => { |
| 139 | + if (ctx.config.permissionMode === 'plan') { |
| 140 | + ctx.onEvent({ kind: 'text_delta', text: 'Already in plan mode. Use /execute to exit.\n' }); |
| 141 | + } |
| 142 | + else { |
| 143 | + ctx.config.permissionMode = 'plan'; |
| 144 | + ctx.onEvent({ kind: 'text_delta', text: '**Plan mode active.** Tools restricted to read-only. Use /execute when ready to implement.\n' }); |
| 145 | + } |
| 146 | + emitDone(ctx); |
| 147 | + }, |
| 148 | + '/execute': (ctx) => { |
| 149 | + if (ctx.config.permissionMode !== 'plan') { |
| 150 | + ctx.onEvent({ kind: 'text_delta', text: 'Not in plan mode. Use /plan to enter.\n' }); |
| 151 | + } |
| 152 | + else { |
| 153 | + ctx.config.permissionMode = 'default'; |
| 154 | + ctx.onEvent({ kind: 'text_delta', text: '**Execution mode.** All tools enabled with permissions.\n' }); |
| 155 | + } |
| 156 | + emitDone(ctx); |
| 157 | + }, |
| 158 | + '/sessions': (ctx) => { |
| 159 | + const sessions = listSessions(); |
| 160 | + if (sessions.length === 0) { |
| 161 | + ctx.onEvent({ kind: 'text_delta', text: 'No saved sessions.\n' }); |
| 162 | + } |
| 163 | + else { |
| 164 | + let text = `**${sessions.length} saved sessions:**\n\n`; |
| 165 | + for (const s of sessions.slice(0, 10)) { |
| 166 | + const date = new Date(s.updatedAt).toLocaleString(); |
| 167 | + const dir = s.workDir ? ` — ${s.workDir.split('/').pop()}` : ''; |
| 168 | + text += ` ${s.id} ${s.model} ${s.turnCount} turns ${date}${dir}\n`; |
| 169 | + } |
| 170 | + if (sessions.length > 10) |
| 171 | + text += ` ... and ${sessions.length - 10} more\n`; |
| 172 | + text += '\nUse /resume <session-id> to continue a session.\n'; |
| 173 | + ctx.onEvent({ kind: 'text_delta', text }); |
| 174 | + } |
| 175 | + emitDone(ctx); |
| 176 | + }, |
| 177 | + '/compact': async (ctx) => { |
| 178 | + const beforeTokens = estimateHistoryTokens(ctx.history); |
| 179 | + const { history: compacted, compacted: didCompact } = await forceCompact(ctx.history, ctx.config.model, ctx.client, ctx.config.debug); |
| 180 | + if (didCompact) { |
| 181 | + ctx.history.length = 0; |
| 182 | + ctx.history.push(...compacted); |
| 183 | + resetTokenAnchor(); |
| 184 | + } |
| 185 | + const afterTokens = estimateHistoryTokens(ctx.history); |
| 186 | + ctx.onEvent({ kind: 'text_delta', text: didCompact |
| 187 | + ? `Compacted: ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens\n` |
| 188 | + : `History too short to compact (${beforeTokens.toLocaleString()} tokens, ${ctx.history.length} messages).\n` |
| 189 | + }); |
| 190 | + emitDone(ctx); |
| 191 | + }, |
| 192 | +}; |
| 193 | +// Prompt-rewrite commands (transformed into agent prompts) |
| 194 | +const REWRITE_COMMANDS = { |
| 195 | + '/commit': 'Review the current git diff and staged changes. Stage relevant files with `git add`, then create a commit with a concise message summarizing the changes. Do NOT push to remote.', |
| 196 | + '/push': 'Push the current branch to the remote repository using `git push`. Show the result.', |
| 197 | + '/pr': 'Create a pull request for the current branch. First check `git log --oneline main..HEAD` to see commits, then use `gh pr create` with a descriptive title and body summarizing the changes. If gh CLI is not available, show the manual steps.', |
| 198 | + '/review': 'Review the current git diff. For each changed file, check for: bugs, security issues, missing error handling, performance problems, and style issues. Provide a brief summary of findings.', |
| 199 | + '/fix': 'Look at the most recent error or issue we discussed and fix it. Check the relevant files, identify the root cause, and apply the fix.', |
| 200 | + '/test': 'Detect the project test framework (look for package.json scripts, pytest, etc.) and run the test suite. Show a summary of results.', |
| 201 | + '/debug': 'Look at the most recent error in this session. Read the relevant source files, analyze the root cause, and suggest a fix with specific code changes.', |
| 202 | + '/init': 'Read the project structure: check package.json (or equivalent), README, and key config files. Summarize: what this project is, main language/framework, entry points, and how to run/test it.', |
| 203 | + '/todo': 'Search the codebase for TODO, FIXME, HACK, and XXX comments using Grep. Show the results grouped by file.', |
| 204 | + '/deps': 'Read the project dependency file (package.json, requirements.txt, go.mod, Cargo.toml, etc.) and list key dependencies with their versions.', |
| 205 | + '/optimize': 'Analyze the codebase for performance issues. Check for: unnecessary re-renders, N+1 queries, missing indexes, unoptimized loops, large bundle sizes, and memory leaks. Provide specific recommendations.', |
| 206 | + '/security': 'Audit the codebase for security issues. Check for: SQL injection, XSS, command injection, hardcoded secrets, insecure dependencies, OWASP top 10 vulnerabilities. Report findings with severity.', |
| 207 | + '/lint': 'Check for code quality issues: unused imports, inconsistent naming, missing type annotations, long functions, duplicated code. Suggest improvements.', |
| 208 | + '/migrate': 'Check for pending database migrations, outdated dependencies, or breaking changes that need addressing. List required migration steps.', |
| 209 | + '/clean': 'Find and remove dead code: unused imports, unreachable code, commented-out blocks, unused variables and functions. Show what would be removed before making changes.', |
| 210 | + '/tasks': 'List all current tasks using the Task tool.', |
| 211 | +}; |
| 212 | +// Commands with arguments (prefix match → rewrite) |
| 213 | +const ARG_COMMANDS = [ |
| 214 | + { prefix: '/explain ', rewrite: (a) => `Read and explain the code in ${a}. Cover: what it does, key functions/classes, how it connects to the rest of the codebase.` }, |
| 215 | + { prefix: '/search ', rewrite: (a) => `Search the codebase for "${a}" using Grep. Show the matching files and relevant code context.` }, |
| 216 | + { prefix: '/find ', rewrite: (a) => `Find files matching the pattern "${a}" using Glob. Show the results.` }, |
| 217 | + { prefix: '/refactor ', rewrite: (a) => `Refactor: ${a}. Read the relevant code first, then make targeted changes. Explain each change.` }, |
| 218 | + { prefix: '/scaffold ', rewrite: (a) => `Create the scaffolding/boilerplate for: ${a}. Generate the file structure and initial code. Ask me if you need clarification on requirements.` }, |
| 219 | + { prefix: '/doc ', rewrite: (a) => `Generate documentation for ${a}. Include: purpose, API/interface description, usage examples, and important notes.` }, |
| 220 | +]; |
| 221 | +// ─── Main dispatch ──────────────────────────────────────────────────────── |
| 222 | +/** |
| 223 | + * Handle a slash command. Returns result indicating what happened. |
| 224 | + */ |
| 225 | +export async function handleSlashCommand(input, ctx) { |
| 226 | + // Direct-handled commands |
| 227 | + if (input in DIRECT_COMMANDS) { |
| 228 | + await DIRECT_COMMANDS[input](ctx); |
| 229 | + return { handled: true }; |
| 230 | + } |
| 231 | + // /branch has both no-arg and with-arg forms |
| 232 | + if (input === '/branch' || input.startsWith('/branch ')) { |
| 233 | + const cwd = ctx.config.workingDir || process.cwd(); |
| 234 | + if (input === '/branch') { |
| 235 | + const r = gitCmd(ctx, 'git branch -v --no-color'); |
| 236 | + ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Not a git repo.\n' }); |
| 237 | + } |
| 238 | + else { |
| 239 | + const branchName = input.slice(8).trim(); |
| 240 | + const r = gitCmd(ctx, `git checkout -b ${branchName}`); |
| 241 | + if (r !== null) |
| 242 | + ctx.onEvent({ kind: 'text_delta', text: `Created and switched to branch: **${branchName}**\n` }); |
| 243 | + } |
| 244 | + emitDone(ctx); |
| 245 | + return { handled: true }; |
| 246 | + } |
| 247 | + // /resume <id> |
| 248 | + if (input.startsWith('/resume ')) { |
| 249 | + const targetId = input.slice(8).trim(); |
| 250 | + const restored = loadSessionHistory(targetId); |
| 251 | + if (restored.length === 0) { |
| 252 | + ctx.onEvent({ kind: 'text_delta', text: `Session "${targetId}" not found or empty.\n` }); |
| 253 | + } |
| 254 | + else { |
| 255 | + ctx.history.length = 0; |
| 256 | + ctx.history.push(...restored); |
| 257 | + resetTokenAnchor(); |
| 258 | + ctx.onEvent({ kind: 'text_delta', text: `Restored ${restored.length} messages from ${targetId}. Continue where you left off.\n` }); |
| 259 | + } |
| 260 | + emitDone(ctx); |
| 261 | + return { handled: true }; |
| 262 | + } |
| 263 | + // Simple rewrite commands (exact match) |
| 264 | + if (input in REWRITE_COMMANDS) { |
| 265 | + return { handled: false, rewritten: REWRITE_COMMANDS[input] }; |
| 266 | + } |
| 267 | + // Argument-based rewrite commands (prefix match) |
| 268 | + for (const { prefix, rewrite } of ARG_COMMANDS) { |
| 269 | + if (input.startsWith(prefix)) { |
| 270 | + const arg = input.slice(prefix.length).trim(); |
| 271 | + return { handled: false, rewritten: rewrite(arg) }; |
| 272 | + } |
| 273 | + } |
| 274 | + // Not a recognized command |
| 275 | + return { handled: false }; |
| 276 | +} |
0 commit comments