diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696631..72d2041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,40 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### New Features + +- **Cude Claw** (`cude claw`) — an interactive agent session that keeps context + between turns. Every file edit is previewed as a diff and approved + individually (yes / no / always / stop); a declined edit tells the model not + to retry it, and stopping mid-turn still answers every tool call the model + made. `@path` in a message attaches that file's contents. Slash commands: + `/mode` `/model` `/tools` `/mcp` `/rules` `/cost` `/undo` `/checkpoints` + `/auto` `/clear` `/exit`. +- **Agent modes** — `code`, `architect`, `ask`, `debug`, `orchestrator`. A mode + is a system prompt plus a tool budget, and the budget is enforced twice: when + the tool list is built for the model, and again before each call, so a model + asking for a tool it was never offered is refused rather than obeyed. + Architect's "writes only Markdown" is a path rule, not a description. + `cude run --mode `, `cude modes list|show`. +- **Project rules** — `AGENTS.md`, `CUDE.md`, `.cuderules` and + `.cude/rules/*.md` are discovered from the filesystem root down to the + workspace root, so a monorepo rule applies to packages inside it and the + closest file wins. `cude rules`. +- **Checkpoints** — the state of every file is captured before the agent + changes it, so any edit can be undone. Works without git and never touches + git if present. `cude checkpoint list|show|restore|restore-run|clear`. +- **MCP server support** — connect Model Context Protocol servers over stdio or + HTTP and their tools become agent tools, namespaced `mcp____` + so none can shadow a built-in. Implemented against the protocol directly, so + no new runtime dependency. `~/.cude/mcp.json` uses the same `mcpServers` + shape as other MCP clients. `cude mcp list|test|add|remove|enable|disable`. + +### Bug Fixes + Ten defects found by an end-to-end audit that installed the tool and ran the agent against a local OpenAI-compatible endpoint. F1, F2 and F5 change behaviour. -### Bug Fixes - - **[F1] The agent reported success even when it failed.** `runToolsAgent` and `runReActAgent` returned `success: true` unconditionally, so a run that exhausted `--max-iterations` or tripped the budget gate printed @@ -59,8 +87,11 @@ behaviour. - `test/helpers/openai-stub.mjs`: a scripted local OpenAI-compatible server that makes the agent loop testable end-to-end with no API key. -- New suites: `agent`, `wire`, `config`, `budget`, `providers`, `spinner`. - 80 tests total, up from 24. +- New suites: `agent`, `wire`, `config`, `budget`, `providers`, `spinner`, + `modes`, `checkpoints`, `mcp`, `claw`. 130 tests total, up from 24. +- `test/helpers/mcp-stub-server.mjs`: a real stdio MCP server, so the client is + tested against the protocol rather than a mock of it — which is how two + Windows spawn bugs and a tool-namespacing bug were caught. - `CUDE_HOME` redirects persisted state so budget- and config-backed behaviour can be tested without touching the real `~/.cude`. diff --git a/README.md b/README.md index 728f0e7..4ca45ee 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

- Cude Code — YAZ. ANLA. ÜRET. + Cude Code — YAZ. ANLA. ÜRET.

- Cude Code running in the terminal + Cude Code running in the terminal

# Cude Code - Professional AI Development CLI @@ -71,6 +71,9 @@ cude chat -p openai -m gpt-4 # Run an autonomous task cude run "Create a REST API in TypeScript" + +# Or work with it interactively, approving each edit +cude claw ``` ## Usage Examples @@ -90,6 +93,92 @@ cude chat -s my-project cude chat --free ``` +### Cude Claw — interactive sessions + +Claw keeps context between turns and shows you every edit before it happens. + +```bash +cude claw # start a session +cude claw "refactor src/api" # start with a task +cude claw --mode ask # read-only: it cannot modify anything +cude claw -y # apply edits without asking +``` + +Inside a session: + +| | | +|---|---| +| `@src/app.ts` | Attach a file's contents to your message | +| `/mode architect` | Switch mode mid-conversation | +| `/model claude-sonnet-5` | Switch model | +| `/cost` | Spend so far this session | +| `/undo` | Revert every file change this session made | +| `/tools` `/mcp` `/rules` | What the agent currently has available | +| `/help` | Everything else | + +When the agent wants to change a file, you see the diff and choose +`y` / `n` / `a`(lways) / `s`(top). + +### Agent Modes + +A mode is a system prompt plus a **tool budget** — what the agent may touch, not +just what it is told to do. The restriction is enforced when the tool list is +built *and* again before each call. + +| Mode | Can do | +|---|---| +| `code` | Everything (default) | +| `architect` | Reads anything, writes only Markdown | +| `ask` | Read-only — cannot modify anything | +| `debug` | Everything, prompted to find causes before fixes | +| `orchestrator` | Everything, works through ordered sub-tasks | + +```bash +cude run "plan the migration" --mode architect +cude modes list +cude modes show ask +``` + +### Project Rules + +Standing instructions live in the repository, not in every prompt. Cude reads +`AGENTS.md`, `CUDE.md`, `.cuderules` and `.cude/rules/*.md`, walking from the +filesystem root down to your workspace — so a monorepo-wide rule applies to the +packages inside it, and the closest file wins. + +```bash +cude rules # show which files are in effect +``` + +### Undo + +Every agent file change is checkpointed first, so a wrong edit is not permanent. +Works without git, and never touches git if present — an agent run is not a +commit. + +```bash +cude checkpoint list +cude checkpoint restore-run # undo a whole run +cude checkpoint restore # undo one tool call +``` + +### MCP Servers + +Connect Model Context Protocol servers to give the agent tools beyond the +built-in ones. `~/.cude/mcp.json` uses the same `mcpServers` shape as other MCP +clients, so an existing configuration copies across unchanged. + +```bash +cude mcp add files --command npx -- -y @modelcontextprotocol/server-filesystem . +cude mcp add docs --url https://example.com/mcp +cude mcp test # connect to each and list its tools +cude mcp disable docs +``` + +Servers are verified before they are saved, tools are namespaced +`mcp____` so none can shadow a built-in, and a server that fails +to start is reported and skipped rather than taking the run down. + ### Autonomous Tasks ```bash # Code generation @@ -145,8 +234,15 @@ cude budget status # Set alert cude budget alert 5 + +# Remove a limit (reset only clears the counters) +cude budget unset --total +cude budget unset --all ``` +Free and local providers (Ollama, vLLM, llama.cpp) are never blocked by a +spending limit — they do not cost anything to block. + ### Session Management ```bash # List sessions @@ -267,15 +363,25 @@ cude config set default-model gpt-4o - **Linux/macOS**: `~/.cude/config.json` - **Windows**: `%USERPROFILE%\.cude\config.json` -Sessions are stored under `~/.cude/sessions/` and spending records under `~/.cude/budget.json`. +Sessions are stored under `~/.cude/sessions/`, spending records under +`~/.cude/budget.json`, undo history under `~/.cude/checkpoints/`, and MCP +servers in `~/.cude/mcp.json`. Set `CUDE_HOME` to move all of it. ## Security -- All data stored locally -- No cloud sync (unless enabled) +- **Workspace boundary** — file-modifying tools are confined to a workspace + root (default: the current directory). Anything outside it is refused. Reads + are unrestricted. Override with `CUDE_WORKSPACE_ROOT` or + `cude config set workspace-root `. +- **Destructive commands require confirmation**, including deletes, and + including `git_command` and `npm_command` — covering POSIX *and* Windows + (`del /f`, `rd /s`, `Remove-Item -Recurse`, `diskpart`), pipe-to-shell, and + the git subcommands that destroy unrecoverable work. +- **Every file change is reversible** via checkpoints. +- **Read-only modes are actually read-only** — enforced at execution, not by + prompt. +- All data stored locally; no cloud sync - API keys never logged -- Destructive commands require confirmation -- Safe command execution - Open source for transparency ## Benchmarks @@ -327,8 +433,15 @@ Free for personal and commercial use. - Environment-variable key fallback - Automatic legacy data migration +### Unreleased +- **Cude Claw** — interactive sessions with per-edit approval and diffs +- **Agent modes** with enforced tool budgets, and project rule files +- **Checkpoints** — undo any agent file change, no git required +- **MCP server support** (stdio and HTTP) +- Workspace boundary for file writes; Windows-aware destructive-command filter +- Correct failure reporting and exit codes from `cude run` + ### Planned (v0.2) -- MCP (Model Context Protocol) server support - VS Code extension - Advanced analytics & spend reports diff --git a/assets/cude-banner.png b/assets/cude-banner.png new file mode 100644 index 0000000..537d210 Binary files /dev/null and b/assets/cude-banner.png differ diff --git a/assets/cude-banner.svg b/assets/cude-banner.svg deleted file mode 100644 index fbc1504..0000000 --- a/assets/cude-banner.svg +++ /dev/null @@ -1,136 +0,0 @@ - - Cude Code — YAZ. ANLA. ÜRET. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CUDE CODE - - - YAZ. ANLA. ÜRET. - - - AI destekli yeni nesil CLI kodlama asistanı. - Terminalinden çıkmadan üretkenliğini katla. - - - - - - - - - - >_ - Terminal Odaklı - Komut satırından - tam kontrol. - - - - - AI Destekli - Kod yaz, açıkla, - hataları bul ve düzelt. - - - - - - Akıllı Üretkenlik - Daha hızlı kodla, - daha fazlasını üret. - - - - - - Güvenli & Gizli - Kodların sadece - sende kalır. - - - - - - - >_ - - cude code --help - - - - - - - - - - Kodlama artık - - komutun kadar yakın. - - - - CUDE CODE · AI-POWERED CLI ASSISTANT - - diff --git a/assets/cude-cli.png b/assets/cude-cli.png deleted file mode 100644 index 5b1b7ca..0000000 Binary files a/assets/cude-cli.png and /dev/null differ diff --git a/assets/cude-cli.svg b/assets/cude-cli.svg new file mode 100644 index 0000000..5cdbdb6 --- /dev/null +++ b/assets/cude-cli.svg @@ -0,0 +1,656 @@ + + cude chat + + + + + + + + + + + + + + + cude-code — zsh + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cude chat + CUDE CODEv0.1.0 + YAZ. ANLA. ÜRET. + 19 providers · 22 tools · 9 task types · browser · RAG + + diff --git a/assets/cude-mark.png b/assets/cude-mark.png new file mode 100644 index 0000000..5bca91c Binary files /dev/null and b/assets/cude-mark.png differ diff --git a/assets/cude-mark.svg b/assets/cude-mark.svg index 7deaa06..9b3c6fd 100644 --- a/assets/cude-mark.svg +++ b/assets/cude-mark.svg @@ -4,11 +4,18 @@ - + the bottom vertex. --> + - - + + + + + + diff --git a/src/cli.ts b/src/cli.ts index c06b7dd..dbfbe6b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,11 @@ import chalk from 'chalk'; import { showBanner } from './ui/display.js'; import { isFirstRun, markFirstRunDone } from './config/index.js'; +/** Commander repeatable option collector. */ +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + export function createCLI(): Command { const program = new Command(); @@ -16,7 +21,7 @@ export function createCLI(): Command { if (opts.banner !== false) { // Only show banner on top-level commands, not sub-commands const name = thisCommand.name(); - if (['chat', 'run'].includes(name)) { + if (['chat', 'run', 'claw'].includes(name)) { showBanner(); } } @@ -65,6 +70,7 @@ export function createCLI(): Command { .option('-v, --verbose', 'Show detailed execution steps') .option('-y, --yes', 'Skip confirmation prompt') .option('--max-iterations ', 'Maximum agent iterations (default: 10)', '10') + .option('--mode ', 'Agent mode: code|architect|ask|debug|orchestrator', 'code') .action(async (task: string, options: { provider?: string; model?: string; @@ -73,6 +79,7 @@ export function createCLI(): Command { verbose?: boolean; yes?: boolean; maxIterations?: string; + mode?: string; }) => { const { runRun } = await import('./commands/run.js'); await runRun(task, { @@ -83,9 +90,69 @@ export function createCLI(): Command { verbose: options.verbose ?? false, yes: options.yes ?? false, maxIterations: parseInt(options.maxIterations ?? '10', 10), + mode: options.mode, }); }); + // ─── CLAW COMMAND ───────────────────────────────────────────────────────── + program + .command('claw [task]') + .description('Interactive agent session — keeps context between turns and shows every edit before it happens') + .option('-p, --provider ', 'AI provider to use') + .option('-m, --model ', 'Model to use') + .option('--mode ', 'Agent mode: code|architect|ask|debug|orchestrator', 'code') + .option('--free', 'Use only free providers') + .option('-y, --yes', 'Apply edits without asking') + .option('--max-iterations ', 'Maximum steps per turn (default: 12)', '12') + .action(async (task: string | undefined, options: { + provider?: string; + model?: string; + mode?: string; + free?: boolean; + yes?: boolean; + maxIterations?: string; + }) => { + const { runClaw } = await import('./commands/claw.js'); + await runClaw(task, { + provider: options.provider, + model: options.model, + mode: options.mode, + free: options.free ?? false, + yes: options.yes ?? false, + maxIterations: parseInt(options.maxIterations ?? '12', 10), + }); + }); + + // ─── MODES COMMAND ──────────────────────────────────────────────────────── + const modesCmd = program + .command('modes') + .description('Agent modes: what the agent does, and what it may touch'); + + modesCmd + .command('list', { isDefault: true }) + .description('List available agent modes') + .action(async () => { + const { runModesList } = await import('./commands/modes.js'); + runModesList(); + }); + + modesCmd + .command('show ') + .description('Show the system prompt and tool budget for a mode') + .action(async (name: string) => { + const { runModesShow } = await import('./commands/modes.js'); + runModesShow(name); + }); + + // ─── RULES COMMAND ──────────────────────────────────────────────────────── + program + .command('rules') + .description('Show the project rule files the agent will follow') + .action(async () => { + const { runRulesList } = await import('./commands/modes.js'); + runRulesList(); + }); + // ─── CONFIG COMMAND ─────────────────────────────────────────────────────── const configCmd = program .command('config') @@ -190,6 +257,116 @@ export function createCLI(): Command { await runBudgetAlert(amount); }); + // ─── MCP COMMAND ────────────────────────────────────────────────────────── + const mcpCmd = program + .command('mcp') + .description('Model Context Protocol servers — extend the agent with external tools'); + + mcpCmd + .command('list', { isDefault: true }) + .description('List configured MCP servers') + .action(async () => { + const { runMcpList } = await import('./commands/mcp.js'); + runMcpList(); + }); + + mcpCmd + .command('test') + .description('Connect to every server and list the tools it offers') + .action(async () => { + const { runMcpTest } = await import('./commands/mcp.js'); + await runMcpTest(); + }); + + mcpCmd + .command('add [args...]') + .description('Add a server (verified before it is saved)') + .option('--command ', 'Executable to run for a stdio server') + .option('--url ', 'Endpoint for an HTTP server') + .option('--env ', 'Environment variable for a stdio server', collect, []) + .option('--header ', 'HTTP header for an HTTP server', collect, []) + .option('--cwd ', 'Working directory for a stdio server') + .action(async (name: string, args: string[], options: { + command?: string; + url?: string; + env?: string[]; + header?: string[]; + cwd?: string; + }) => { + const { runMcpAdd } = await import('./commands/mcp.js'); + await runMcpAdd(name, { ...options, args }); + }); + + mcpCmd + .command('remove ') + .description('Remove a server') + .action(async (name: string) => { + const { runMcpRemove } = await import('./commands/mcp.js'); + await runMcpRemove(name); + }); + + mcpCmd + .command('enable ') + .description('Re-enable a disabled server') + .action(async (name: string) => { + const { runMcpToggle } = await import('./commands/mcp.js'); + runMcpToggle(name, false); + }); + + mcpCmd + .command('disable ') + .description('Keep a server configured but stop loading it') + .action(async (name: string) => { + const { runMcpToggle } = await import('./commands/mcp.js'); + runMcpToggle(name, true); + }); + + // ─── CHECKPOINT COMMAND ─────────────────────────────────────────────────── + const checkpointCmd = program + .command('checkpoint') + .alias('checkpoints') + .description('Undo agent file edits'); + + checkpointCmd + .command('list', { isDefault: true }) + .description('List checkpoints, grouped by agent run') + .action(async () => { + const { runCheckpointList } = await import('./commands/checkpoint.js'); + runCheckpointList(); + }); + + checkpointCmd + .command('show ') + .description('Show what a checkpoint captured') + .action(async (id: string) => { + const { runCheckpointShow } = await import('./commands/checkpoint.js'); + runCheckpointShow(id); + }); + + checkpointCmd + .command('restore ') + .description('Undo a single tool call') + .action(async (id: string) => { + const { runCheckpointRestore } = await import('./commands/checkpoint.js'); + runCheckpointRestore(id); + }); + + checkpointCmd + .command('restore-run ') + .description('Undo every file change made by an agent run') + .action(async (runId: string) => { + const { runCheckpointRestoreRun } = await import('./commands/checkpoint.js'); + runCheckpointRestoreRun(runId); + }); + + checkpointCmd + .command('clear') + .description('Delete all checkpoints') + .action(async () => { + const { runCheckpointClear } = await import('./commands/checkpoint.js'); + await runCheckpointClear(); + }); + // ─── SESSIONS COMMAND ───────────────────────────────────────────────────── const sessionsCmd = program .command('sessions') diff --git a/src/commands/checkpoint.ts b/src/commands/checkpoint.ts new file mode 100644 index 0000000..326505a --- /dev/null +++ b/src/commands/checkpoint.ts @@ -0,0 +1,180 @@ +import chalk from 'chalk'; +import { format } from 'date-fns'; +import { + listCheckpoints, + loadCheckpoint, + restoreCheckpoint, + restoreRun, + clearCheckpoints, + displayPath, + type Checkpoint, + type RestoreResult, +} from '../core/checkpoints.js'; +import { showSuccess, showError, showInfo, printSeparator } from '../ui/display.js'; + +function describeChange(checkpoint: Checkpoint): string { + const created = checkpoint.files.filter(f => f.content === null && !f.skipped).length; + const modified = checkpoint.files.filter(f => f.content !== null).length; + const parts: string[] = []; + if (modified) parts.push(`${modified} modified`); + if (created) parts.push(`${created} created`); + return parts.join(', ') || 'no captured files'; +} + +export function runCheckpointList(): void { + const checkpoints = listCheckpoints(); + + console.log(); + console.log(chalk.bold.cyan(' Checkpoints')); + printSeparator(); + console.log(); + + if (checkpoints.length === 0) { + showInfo('No checkpoints yet. They are recorded automatically before the agent changes a file.'); + console.log(); + return; + } + + // Grouped by run, because undoing a whole run is the common case. + const byRun = new Map(); + for (const checkpoint of checkpoints) { + const existing = byRun.get(checkpoint.runId); + if (existing) existing.push(checkpoint); + else byRun.set(checkpoint.runId, [checkpoint]); + } + + for (const [runId, group] of byRun) { + const newest = group[0]; + console.log( + chalk.bold.white(` run ${runId}`) + + chalk.dim(` ${format(new Date(newest.createdAt), 'MMM d HH:mm')} ` + + `${group.length} checkpoint${group.length !== 1 ? 's' : ''}`) + ); + console.log(chalk.dim(` ${newest.task.substring(0, 68)}${newest.task.length > 68 ? '…' : ''}`)); + + for (const checkpoint of group) { + const files = checkpoint.files.map(f => displayPath(f.path)).join(', '); + console.log( + ` ${chalk.cyan(checkpoint.id)} ${chalk.yellow(checkpoint.toolName.padEnd(16))}` + + chalk.dim(`${files.substring(0, 50)} (${describeChange(checkpoint)})`) + ); + } + console.log(); + } + + console.log(chalk.dim(' Undo one: ') + chalk.cyan('cude checkpoint restore ')); + console.log(chalk.dim(' Undo a run: ') + chalk.cyan('cude checkpoint restore-run ')); + console.log(); +} + +export function runCheckpointShow(id: string): void { + const checkpoint = loadCheckpoint(id); + if (!checkpoint) { + showError(`No checkpoint with id: ${id}\nRun "cude checkpoint list" to see them.`); + process.exitCode = 1; + return; + } + + console.log(); + console.log(chalk.bold.cyan(` Checkpoint ${checkpoint.id}`)); + printSeparator(); + console.log(); + console.log(chalk.dim(' Run: ') + checkpoint.runId); + console.log(chalk.dim(' Recorded: ') + format(new Date(checkpoint.createdAt), 'PPpp')); + console.log(chalk.dim(' Before: ') + chalk.yellow(checkpoint.toolName)); + console.log(chalk.dim(' Task: ') + checkpoint.task); + console.log(); + + for (const file of checkpoint.files) { + const state = file.skipped + ? chalk.red('not captured (too large)') + : file.content === null + ? chalk.green('did not exist — restoring will delete it') + : chalk.cyan(`${file.content.split('\n').length} lines captured`); + console.log(` ${chalk.white(displayPath(file.path))}`); + console.log(` ${state}`); + } + console.log(); +} + +function report(result: RestoreResult, label: string): void { + if (result.restored.length === 0 && result.removed.length === 0) { + if (result.failed.length > 0) { + showError( + `Could not restore ${label}:\n` + + result.failed.map(f => ` ${displayPath(f.path)} — ${f.reason}`).join('\n') + ); + process.exitCode = 1; + return; + } + showInfo(`Nothing to restore for ${label}.`); + return; + } + + console.log(); + for (const path of result.restored) { + console.log(` ${chalk.green('restored')} ${chalk.white(displayPath(path))}`); + } + for (const path of result.removed) { + console.log(` ${chalk.yellow('removed ')} ${chalk.white(displayPath(path))} ${chalk.dim('(did not exist before)')}`); + } + for (const failure of result.failed) { + console.log(` ${chalk.red('failed ')} ${chalk.white(displayPath(failure.path))} ${chalk.dim(failure.reason)}`); + } + console.log(); + + const summary = + `${result.restored.length} restored, ${result.removed.length} removed` + + (result.failed.length > 0 ? `, ${result.failed.length} failed` : ''); + if (result.failed.length > 0) { + showError(`${label}: ${summary}`); + process.exitCode = 1; + } else { + showSuccess(`${label}: ${summary}`); + } +} + +export function runCheckpointRestore(id: string): void { + const checkpoint = loadCheckpoint(id); + if (!checkpoint) { + showError(`No checkpoint with id: ${id}\nRun "cude checkpoint list" to see them.`); + process.exitCode = 1; + return; + } + report(restoreCheckpoint(checkpoint), `checkpoint ${id}`); +} + +export function runCheckpointRestoreRun(runId: string): void { + const belonging = listCheckpoints().filter(c => c.runId === runId); + if (belonging.length === 0) { + showError(`No checkpoints for run: ${runId}\nRun "cude checkpoint list" to see them.`); + process.exitCode = 1; + return; + } + report(restoreRun(runId), `run ${runId}`); +} + +export async function runCheckpointClear(): Promise { + const { default: inquirer } = await import('inquirer'); + const count = listCheckpoints().length; + if (count === 0) { + showInfo('No checkpoints to clear.'); + return; + } + + const answer = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `Delete all ${count} checkpoints? Agent edits will no longer be undoable.`, + default: false, + }, + ]) as { confirm: boolean }; + + if (!answer.confirm) { + showInfo('Cancelled'); + return; + } + + showSuccess(`Cleared ${clearCheckpoints()} checkpoints`); +} diff --git a/src/commands/claw.ts b/src/commands/claw.ts new file mode 100644 index 0000000..b5e5b93 --- /dev/null +++ b/src/commands/claw.ts @@ -0,0 +1,422 @@ +import readline from 'readline'; +import chalk from 'chalk'; +import { ClawSession, type PendingEdit, type ApprovalDecision } from '../core/claw.js'; +import { listModes, getMode, toolsForMode } from '../core/modes.js'; +import { renderDiff, diffStat } from '../ui/diff.js'; +import { renderMarkdown, showError, showInfo, printSeparator } from '../ui/display.js'; +import { startSpinner, stopSpinner, updateSpinner } from '../ui/spinner.js'; +import { setConfirmCallback } from '../core/tools.js'; +import { initializeMcp, shutdownMcp } from '../mcp/registry.js'; +import { listCheckpoints, restoreRun, displayPath } from '../core/checkpoints.js'; +import { findRuleFiles } from '../core/rules.js'; + +export interface ClawOptions { + provider?: string; + model?: string; + mode?: string; + free?: boolean; + yes?: boolean; + maxIterations?: number; +} + +const HELP = ` + ${chalk.bold('Slash commands')} + ${chalk.cyan('/help')} This list + ${chalk.cyan('/mode [name]')} Show or switch mode (code, architect, ask, debug, orchestrator) + ${chalk.cyan('/model ')} Switch model, e.g. /model claude-sonnet-5 + ${chalk.cyan('/tools')} Tools available in the current mode + ${chalk.cyan('/mcp')} Connected MCP servers + ${chalk.cyan('/rules')} Project rule files in effect + ${chalk.cyan('/cost')} Spend for this session + ${chalk.cyan('/undo')} Undo every file change made this session + ${chalk.cyan('/checkpoints')} Checkpoints recorded this session + ${chalk.cyan('/auto')} Toggle approving edits automatically + ${chalk.cyan('/clear')} Forget the conversation, keep the settings + ${chalk.cyan('/exit')} Leave + + ${chalk.bold('In a message')} + ${chalk.cyan('@path/to/file')} Attach a file's contents to your message +`; + +/** + * A session reads input at two levels — the main prompt, and the approval + * prompt nested inside a turn — so it cannot use `rl.question` on a + * short-lived interface: closing one drops whatever the terminal had already + * buffered, and the nested prompt then waits forever for a line that was + * already read. One interface for the whole session, with a queue in front of + * it, behaves the same whether input is typed or piped. + */ +class LineReader { + private readonly rl: readline.Interface; + private readonly buffered: string[] = []; + private readonly waiting: Array<(line: string | null) => void> = []; + private closed = false; + + constructor() { + this.rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + this.rl.on('line', line => { + const waiter = this.waiting.shift(); + if (waiter) waiter(line); + else this.buffered.push(line); + }); + this.rl.on('close', () => { + this.closed = true; + while (this.waiting.length > 0) this.waiting.shift()!(null); + }); + } + + /** Resolves with null at end of input, which ends the session. */ + ask(question: string): Promise { + process.stdout.write(question); + const queued = this.buffered.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (this.closed) return Promise.resolve(null); + return new Promise(resolve => this.waiting.push(resolve)); + } + + close(): void { + this.rl.close(); + } +} + +let reader: LineReader | null = null; + +async function prompt(question: string): Promise { + const line = await reader!.ask(question); + return line ?? '/exit'; +} + +/** The approve / decline / always / stop prompt shown before an edit. */ +async function askApproval(edit: PendingEdit): Promise { + console.log(); + const target = edit.path ? displayPath(edit.path) : ''; + console.log(chalk.bold.yellow(` ${edit.toolName}`) + (target ? chalk.white(` ${target}`) : '')); + + if (edit.before !== undefined && edit.after !== undefined) { + const diff = renderDiff(edit.before, edit.after); + console.log(chalk.dim(` ${edit.before === '' ? 'new file' : 'edit'} `) + diffStat(diff)); + console.log(diff.text); + } else { + // No previewable diff (a delete, a move, a patch we cannot pre-apply). + const summary = JSON.stringify(edit.args).slice(0, 200); + console.log(chalk.dim(` ${summary}`)); + } + + console.log(); + const answer = ( + await prompt(` ${chalk.yellow('?')} Apply this? ${chalk.dim('[y]es / [n]o / [a]lways / [s]top')}: `) + ).trim().toLowerCase(); + + if (answer === 'a' || answer === 'always') return 'always'; + if (answer === 's' || answer === 'stop') return 'abort'; + if (answer === 'y' || answer === 'yes' || answer === '') return 'yes'; + return 'no'; +} + +function showHeader(session: ClawSession): void { + console.log(); + console.log(chalk.bold.cyan(' Cude Claw')); + console.log(chalk.dim(' ─────────────────────────────────')); + console.log(chalk.dim(' Provider: ') + chalk.cyan(session.provider.displayName)); + console.log(chalk.dim(' Model: ') + chalk.cyan(session.model)); + console.log(chalk.dim(' Mode: ') + chalk.cyan(session.mode.displayName) + chalk.dim(` — ${session.mode.description}`)); + console.log(chalk.dim(' Session: ') + chalk.cyan(session.runId)); + console.log(); + console.log(chalk.dim(' /help for commands, @file to attach a file, /exit to leave.')); + console.log(); +} + +function showCost(session: ClawSession): void { + console.log(); + if (session.totalCost === 0) { + console.log(chalk.dim(' This session: ') + chalk.green('free') + + chalk.dim(` · ${session.totalInputTokens} in / ${session.totalOutputTokens} out · ${session.turns} turn${session.turns !== 1 ? 's' : ''}`)); + } else { + console.log(chalk.dim(' This session: ') + chalk.yellow(`$${session.totalCost.toFixed(6)}`) + + chalk.dim(` · ${session.totalInputTokens} in / ${session.totalOutputTokens} out · ${session.turns} turn${session.turns !== 1 ? 's' : ''}`)); + } + console.log(); +} + +/** Returns false when the session should end. */ +async function handleSlash(input: string, session: ClawSession): Promise { + const [command, ...rest] = input.slice(1).trim().split(/\s+/); + const argument = rest.join(' '); + + switch (command.toLowerCase()) { + case 'help': + console.log(HELP); + return true; + + case 'exit': + case 'quit': + return false; + + case 'mode': { + if (!argument) { + console.log(); + for (const mode of listModes()) { + const marker = mode.name === session.mode.name ? chalk.green('●') : chalk.dim('○'); + console.log(` ${marker} ${chalk.bold.white(mode.name.padEnd(14))}${chalk.dim(mode.description)}`); + } + console.log(); + return true; + } + try { + session.setMode(argument); + showInfo(`Mode: ${session.mode.displayName} — ${session.mode.description}`); + } catch (err) { + showError(err instanceof Error ? err.message : String(err)); + } + return true; + } + + case 'model': { + if (!argument) { + showInfo(`Model: ${session.model} on ${session.provider.displayName}`); + return true; + } + try { + session.setModel(undefined, argument); + showInfo(`Model: ${session.model} on ${session.provider.displayName}`); + } catch (err) { + showError(err instanceof Error ? err.message : String(err)); + } + return true; + } + + case 'tools': { + const tools = toolsForMode(session.mode); + console.log(); + console.log(chalk.dim(` ${tools.length} tools in ${session.mode.displayName} mode:`)); + console.log(chalk.dim(' ' + tools.map(t => t.name).join(', '))); + console.log(); + return true; + } + + case 'mcp': { + const mcp = await initializeMcp(); + console.log(); + if (mcp.connected.length === 0) { + console.log(chalk.dim(' No MCP servers connected. See: cude mcp list')); + } else { + for (const server of mcp.connected) { + const count = mcp.tools.filter(t => t.name.startsWith(`mcp__${server}__`)).length; + console.log(` ${chalk.green('✓')} ${chalk.white(server.padEnd(18))}${chalk.dim(`${count} tools`)}`); + } + } + for (const failure of mcp.failed) { + console.log(` ${chalk.red('✗')} ${chalk.white(failure.server.padEnd(18))}${chalk.red(failure.reason)}`); + } + console.log(); + return true; + } + + case 'rules': { + const files = findRuleFiles(); + console.log(); + if (files.length === 0) { + console.log(chalk.dim(' No rule files. See: cude rules')); + } else { + for (const file of files) { + console.log(` ${chalk.green('✓')} ${chalk.white(displayPath(file.path))}`); + } + } + console.log(); + return true; + } + + case 'cost': + showCost(session); + return true; + + case 'checkpoints': { + const mine = listCheckpoints().filter(c => c.runId === session.runId); + console.log(); + if (mine.length === 0) { + console.log(chalk.dim(' No file changes yet this session.')); + } else { + for (const checkpoint of mine) { + console.log( + ` ${chalk.cyan(checkpoint.id)} ${chalk.yellow(checkpoint.toolName.padEnd(16))}` + + chalk.dim(checkpoint.files.map(f => displayPath(f.path)).join(', ')) + ); + } + } + console.log(); + return true; + } + + case 'undo': { + const mine = listCheckpoints().filter(c => c.runId === session.runId); + if (mine.length === 0) { + showInfo('Nothing to undo — this session has not changed any files.'); + return true; + } + const answer = (await prompt(` ${chalk.yellow('?')} Undo ${mine.length} change${mine.length !== 1 ? 's' : ''} from this session? ${chalk.dim('[y/N]')}: `)).trim().toLowerCase(); + if (answer !== 'y' && answer !== 'yes') { + showInfo('Cancelled'); + return true; + } + const result = restoreRun(session.runId); + console.log(); + for (const path of result.restored) console.log(` ${chalk.green('restored')} ${displayPath(path)}`); + for (const path of result.removed) console.log(` ${chalk.yellow('removed ')} ${displayPath(path)}`); + for (const failure of result.failed) console.log(` ${chalk.red('failed ')} ${displayPath(failure.path)} ${chalk.dim(failure.reason)}`); + console.log(); + return true; + } + + case 'auto': + session.autoApprove = !session.autoApprove; + showInfo( + session.autoApprove + ? 'Edits will be applied without asking. /auto again to go back.' + : 'Edits will be shown for approval.' + ); + return true; + + case 'clear': + session.clear(); + showInfo('Conversation cleared. Mode, model and session cost are unchanged.'); + return true; + + default: + showError(`Unknown command: /${command}\nTry /help`); + return true; + } +} + +export async function runClaw(initialTask: string | undefined, options: ClawOptions = {}): Promise { + let session: ClawSession; + try { + session = new ClawSession({ + provider: options.provider, + model: options.model, + mode: options.mode, + free: options.free, + autoApprove: options.yes, + maxIterationsPerTurn: options.maxIterations, + }); + } catch (err) { + showError(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + + if (!session.supportsTools) { + showError( + `${session.provider.displayName} does not support tool calling, which Claw needs.\n` + + 'Pick a provider that does, e.g. "cude claw -p anthropic" or "cude claw -p openai".' + ); + process.exitCode = 1; + return; + } + + showHeader(session); + + const mcp = await initializeMcp(); + if (mcp.connected.length > 0) { + console.log(chalk.dim(` MCP: ${mcp.connected.join(', ')} (${mcp.tools.length} tools)`)); + console.log(); + } + for (const failure of mcp.failed) { + console.log(chalk.yellow(` MCP server "${failure.server}" unavailable: ${failure.reason}`)); + } + + reader = new LineReader(); + // Destructive shell commands still go through the tool layer's own gate. + setConfirmCallback(async message => { + const answer = (await prompt(`\n ${chalk.yellow('?')} ${message} ${chalk.dim('[y/N]')}: `)).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + }); + + let pending = initialTask; + + try { + for (;;) { + const input = pending ?? (await prompt(chalk.bold.cyan('\n › '))); + pending = undefined; + + const trimmed = input.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('/')) { + const keepGoing = await handleSlash(trimmed, session); + if (!keepGoing) break; + continue; + } + + console.log(); + startSpinner('Thinking...'); + + let turn; + try { + turn = await session.send(trimmed, { + onIteration: n => updateSpinner(`Thinking... (step ${n})`), + onThought: text => { + stopSpinner(true, ''); + console.log(renderMarkdown(text).split('\n').map(l => ' ' + l).join('\n')); + startSpinner('Working...'); + }, + onToolCall: (name, args) => { + updateSpinner(`${name} ${JSON.stringify(args).slice(0, 60)}`); + }, + onToolResult: (name, ok, summary) => { + stopSpinner(true, ''); + console.log( + (ok ? chalk.green(' ✓ ') : chalk.red(' ✗ ')) + + chalk.white(name) + ' ' + chalk.dim(summary) + ); + startSpinner('Working...'); + }, + onApproval: async edit => { + stopSpinner(true, ''); + const decision = await askApproval(edit); + startSpinner('Working...'); + return decision; + }, + }); + stopSpinner(true, ''); + } catch (err) { + stopSpinner(false, 'Turn failed'); + showError(err instanceof Error ? err.message : String(err)); + continue; + } + + if (turn.output) { + console.log(); + console.log(renderMarkdown(turn.output.replace(/TASK COMPLETE:\s*/i, '')).split('\n').map(l => ' ' + l).join('\n')); + } + + const costLabel = turn.cost === 0 ? chalk.green('free') : chalk.yellow(`$${turn.cost.toFixed(6)}`); + console.log( + chalk.dim(` ─ ${turn.iterations} step${turn.iterations !== 1 ? 's' : ''}, ` + + `${turn.toolCalls} tool call${turn.toolCalls !== 1 ? 's' : ''} · `) + costLabel + + chalk.dim(` · session $${session.totalCost.toFixed(6)}`) + ); + + if (turn.stopReason === 'max_iterations') { + console.log(chalk.yellow(' ⚠ Hit the per-turn step limit. Say "continue" to keep going.')); + } else if (turn.stopReason === 'budget_exceeded') { + console.log(chalk.red(' ⚠ Stopped by the spending limit. See: cude budget status')); + } + } + } finally { + reader?.close(); + reader = null; + await shutdownMcp(); + } + + showCost(session); + const changes = listCheckpoints().filter(c => c.runId === session.runId).length; + if (changes > 0) { + console.log(chalk.dim(' Undo this session: ') + chalk.cyan(`cude checkpoint restore-run ${session.runId}`)); + console.log(); + } + printSeparator(); + console.log(); +} + +/** Exposed for the tests, which drive a session without a terminal. */ +export { ClawSession, getMode }; diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..c3ba42c --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,207 @@ +import chalk from 'chalk'; +import { + loadMcpConfig, + saveMcpConfig, + getMcpConfigPath, + initializeMcp, + shutdownMcp, +} from '../mcp/registry.js'; +import { McpClient, isHttpConfig, type McpServerConfig } from '../mcp/client.js'; +import { showSuccess, showError, showInfo, printSeparator } from '../ui/display.js'; +import { startSpinner, stopSpinner } from '../ui/spinner.js'; + +function describeTransport(config: McpServerConfig): string { + if (isHttpConfig(config)) return config.url; + const parts = [config.command, ...(config.args ?? [])]; + return parts.join(' '); +} + +export function runMcpList(): void { + let config; + try { + config = loadMcpConfig(); + } catch (err) { + showError(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + + const servers = Object.entries(config.mcpServers); + + console.log(); + console.log(chalk.bold.cyan(' MCP Servers')); + printSeparator(); + console.log(); + + if (servers.length === 0) { + showInfo('No MCP servers configured.'); + console.log(); + console.log(chalk.dim(' Add one:')); + console.log(chalk.cyan(' cude mcp add filesystem --command npx ') + + chalk.dim('-- -y @modelcontextprotocol/server-filesystem /path')); + console.log(chalk.cyan(' cude mcp add docs --url https://example.com/mcp')); + console.log(); + console.log(chalk.dim(` Or edit ${getMcpConfigPath()} directly — it uses the same`)); + console.log(chalk.dim(' "mcpServers" shape as other MCP clients, so an existing')); + console.log(chalk.dim(' configuration can be copied across unchanged.')); + console.log(); + return; + } + + for (const [name, server] of servers) { + const state = server.disabled ? chalk.dim('○ disabled') : chalk.green('✓ enabled '); + const kind = isHttpConfig(server) ? chalk.magenta('http ') : chalk.blue('stdio'); + console.log(` ${state} ${kind} ${chalk.bold.white(name.padEnd(18))}${chalk.dim(describeTransport(server))}`); + } + + console.log(); + console.log(chalk.dim(` Config: ${getMcpConfigPath()}`)); + console.log(chalk.dim(' Check they work: ') + chalk.cyan('cude mcp test')); + console.log(); +} + +export async function runMcpTest(): Promise { + startSpinner('Connecting to MCP servers...'); + const result = await initializeMcp(); + stopSpinner(result.failed.length === 0, 'MCP check complete'); + + console.log(); + if (result.connected.length === 0 && result.failed.length === 0) { + showInfo('No MCP servers configured. Run "cude mcp list" for how to add one.'); + console.log(); + await shutdownMcp(); + return; + } + + for (const server of result.connected) { + const tools = result.tools.filter(t => t.name.startsWith(`mcp__${server}__`)); + console.log(` ${chalk.green('✓')} ${chalk.bold.white(server.padEnd(18))}${chalk.dim(`${tools.length} tool${tools.length !== 1 ? 's' : ''}`)}`); + for (const tool of tools) { + const bare = tool.name.replace(`mcp__${server}__`, ''); + console.log(` ${chalk.cyan(bare.padEnd(28))}${chalk.dim(tool.description.replace(`[${server}] `, '').substring(0, 60))}`); + } + } + + for (const failure of result.failed) { + console.log(` ${chalk.red('✗')} ${chalk.bold.white(failure.server.padEnd(18))}${chalk.red(failure.reason)}`); + } + + console.log(); + if (result.failed.length > 0) process.exitCode = 1; + await shutdownMcp(); +} + +export interface McpAddOptions { + command?: string; + args?: string[]; + url?: string; + header?: string[]; + env?: string[]; + cwd?: string; +} + +function parsePairs(pairs: string[] | undefined, label: string): Record { + const out: Record = {}; + for (const pair of pairs ?? []) { + const index = pair.indexOf('='); + if (index === -1) { + throw new Error(`${label} must be KEY=VALUE, got: ${pair}`); + } + out[pair.slice(0, index)] = pair.slice(index + 1); + } + return out; +} + +export async function runMcpAdd(name: string, options: McpAddOptions): Promise { + if (!options.command && !options.url) { + showError( + 'An MCP server needs either --command (stdio) or --url (http).\n' + + ' cude mcp add files --command npx -- -y @modelcontextprotocol/server-filesystem .\n' + + ' cude mcp add docs --url https://example.com/mcp' + ); + process.exitCode = 1; + return; + } + if (options.command && options.url) { + showError('Choose one transport: --command or --url, not both.'); + process.exitCode = 1; + return; + } + + let server: McpServerConfig; + try { + server = options.url + ? { url: options.url, headers: parsePairs(options.header, '--header') } + : { + command: options.command!, + args: options.args ?? [], + env: parsePairs(options.env, '--env'), + ...(options.cwd ? { cwd: options.cwd } : {}), + }; + } catch (err) { + showError(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + + const config = loadMcpConfig(); + const replacing = name in config.mcpServers; + + // Verify before saving, so a typo is caught now rather than mid-run. + startSpinner(`Connecting to ${name}...`); + const client = new McpClient(name, server); + let tools; + try { + tools = await client.listTools(); + stopSpinner(true, `${name}: ${tools.length} tool${tools.length !== 1 ? 's' : ''}`); + } catch (err) { + stopSpinner(false, `${name} did not respond`); + await client.close(); + showError( + `Could not connect to "${name}": ${err instanceof Error ? err.message : String(err)}\n` + + 'Nothing was saved. Fix the command or URL and try again.' + ); + process.exitCode = 1; + return; + } + await client.close(); + + config.mcpServers[name] = server; + saveMcpConfig(config); + + console.log(); + for (const tool of tools) { + console.log(` ${chalk.cyan(tool.name.padEnd(28))}${chalk.dim((tool.description ?? '').substring(0, 60))}`); + } + console.log(); + showSuccess(`${replacing ? 'Updated' : 'Added'} MCP server "${name}" — its tools are now available to the agent`); +} + +export async function runMcpRemove(name: string): Promise { + const config = loadMcpConfig(); + if (!(name in config.mcpServers)) { + showError(`No MCP server named "${name}".\nConfigured: ${Object.keys(config.mcpServers).join(', ') || '(none)'}`); + process.exitCode = 1; + return; + } + + delete config.mcpServers[name]; + saveMcpConfig(config); + showSuccess(`Removed MCP server "${name}"`); +} + +export function runMcpToggle(name: string, disabled: boolean): void { + const config = loadMcpConfig(); + const server = config.mcpServers[name]; + if (!server) { + showError(`No MCP server named "${name}".`); + process.exitCode = 1; + return; + } + + if (disabled) server.disabled = true; + else delete server.disabled; + + saveMcpConfig(config); + showSuccess(`MCP server "${name}" ${disabled ? 'disabled' : 'enabled'}`); +} diff --git a/src/commands/modes.ts b/src/commands/modes.ts new file mode 100644 index 0000000..4055ff0 --- /dev/null +++ b/src/commands/modes.ts @@ -0,0 +1,100 @@ +import chalk from 'chalk'; +import { listModes, getMode, toolsForMode } from '../core/modes.js'; +import { findRuleFiles, buildRulesPrompt } from '../core/rules.js'; +import { getWorkspaceRoot } from '../core/tools.js'; +import { showInfo, printSeparator } from '../ui/display.js'; +import { relative } from 'path'; + +export function runModesList(): void { + console.log(); + console.log(chalk.bold.cyan(' Agent Modes')); + printSeparator(); + console.log(); + + for (const mode of listModes()) { + const tools = toolsForMode(mode); + const budget = + mode.allowedTools === 'all' + ? chalk.dim(`all ${tools.length} tools`) + : chalk.yellow( + `${tools.length} tools` + + (mode.writablePathPattern ? `, writes limited to ${mode.writablePathPattern}` : ', read-only') + ); + + console.log(` ${chalk.bold.white(mode.name.padEnd(14))}${chalk.cyan(mode.displayName)}`); + console.log(` ${' '.repeat(14)}${chalk.dim(mode.description)}`); + console.log(` ${' '.repeat(14)}${budget}`); + console.log(); + } + + console.log(chalk.dim(' Use: ') + chalk.cyan('cude run "task" --mode architect')); + console.log(chalk.dim(' ') + chalk.cyan('cude claw --mode ask')); + console.log(); +} + +export function runModesShow(name: string): void { + let mode; + try { + mode = getMode(name); + } catch (err) { + console.log(chalk.red(` ${err instanceof Error ? err.message : String(err)}`)); + process.exitCode = 1; + return; + } + + const tools = toolsForMode(mode); + console.log(); + console.log(chalk.bold.cyan(` ${mode.displayName} mode`)); + printSeparator(); + console.log(); + console.log(chalk.dim(' ' + mode.description)); + console.log(); + console.log(chalk.bold(' System prompt:')); + console.log(chalk.dim(' ' + mode.systemPrompt.replace(/\n/g, '\n '))); + console.log(); + console.log(chalk.bold(` Tools (${tools.length}):`)); + console.log(chalk.dim(' ' + tools.map(t => t.name).join(', '))); + console.log(); +} + +export function runRulesList(): void { + const root = getWorkspaceRoot(); + const files = findRuleFiles(); + + console.log(); + console.log(chalk.bold.cyan(' Project Rules')); + printSeparator(); + console.log(); + console.log(chalk.dim(` Workspace root: ${root}`)); + console.log(); + + if (files.length === 0) { + showInfo('No rule files found.'); + console.log(); + console.log(chalk.dim(' Cude reads standing instructions from any of:')); + console.log(chalk.cyan(' AGENTS.md') + chalk.dim(' (also read by other agent tools)')); + console.log(chalk.cyan(' CUDE.md')); + console.log(chalk.cyan(' .cuderules')); + console.log(chalk.cyan(' .cude/rules/*.md')); + console.log(); + console.log(chalk.dim(' Files are discovered from the filesystem root down to the')); + console.log(chalk.dim(' workspace, so the closest one to your work wins.')); + console.log(); + return; + } + + // Nearest last, which is the order they reach the prompt. + for (const file of files) { + const label = relative(root, file.path) || file.path; + const lines = file.content.split('\n').length; + console.log( + ` ${chalk.green('✓')} ${chalk.white(label.padEnd(36))} ${chalk.dim(`${lines} lines, ${file.content.length} chars`)}` + ); + } + + const prompt = buildRulesPrompt(files); + console.log(); + console.log(chalk.dim(` ${prompt.length} characters appended to every agent system prompt.`)); + console.log(chalk.dim(' Later files override earlier ones.')); + console.log(); +} diff --git a/src/commands/run.ts b/src/commands/run.ts index 0438026..dea19f1 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,6 +1,7 @@ import readline from 'readline'; import chalk from 'chalk'; import { runAgent, STOP_REASON_MESSAGES } from '../core/agent.js'; +import { MUTATING_TOOLS } from '../core/checkpoints.js'; import { selectProviderAndModel, type TaskType } from '../core/selector.js'; import { startSpinner, stopSpinner, updateSpinner } from '../ui/spinner.js'; import { showError, showSuccess, showCostInfo, renderMarkdown } from '../ui/display.js'; @@ -14,6 +15,7 @@ export interface RunCommandOptions { verbose?: boolean; yes?: boolean; // Skip confirmation maxIterations?: number; + mode?: string; } function formatStep(step: AgentStep): string { @@ -50,6 +52,7 @@ export async function runRun(task: string, options: RunCommandOptions = {}): Pro verbose = false, yes = false, maxIterations = 10, + mode = 'code', } = options; if (!task || task.trim().length === 0) { @@ -70,6 +73,7 @@ export async function runRun(task: string, options: RunCommandOptions = {}): Pro console.log(chalk.dim(' Task: ') + chalk.white(task)); console.log(chalk.dim(' Provider: ') + chalk.cyan(provider.displayName)); console.log(chalk.dim(' Model: ') + chalk.cyan(model)); + console.log(chalk.dim(' Mode: ') + chalk.cyan(mode)); if (reason) console.log(chalk.dim(` (${reason})`)); console.log(); @@ -94,6 +98,7 @@ export async function runRun(task: string, options: RunCommandOptions = {}): Pro provider: preferredProvider, model: preferredModel, maxIterations, + mode, verbose, onProgress: (step) => { stepCount++; @@ -138,6 +143,10 @@ export async function runRun(task: string, options: RunCommandOptions = {}): Pro console.log(); console.log(chalk.dim(' ─────────────────────────────────')); console.log(chalk.dim(' Iterations: ') + result.iterations); + const changed = result.steps.some(s => s.type === 'tool_call' && s.toolName && MUTATING_TOOLS[s.toolName]); + if (changed) { + console.log(chalk.dim(' Undo: ') + chalk.cyan(`cude checkpoint restore-run ${result.runId}`)); + } showCostInfo(result.totalCost, result.totalInputTokens, result.totalOutputTokens); console.log(); diff --git a/src/core/agent.ts b/src/core/agent.ts index 3949cfd..9483346 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,11 +1,16 @@ import chalk from 'chalk'; import { selectProviderAndModel, type TaskType } from './selector.js'; -import { executeTool, TOOL_DEFINITIONS, setConfirmCallback, formatToolCall, formatToolResult } from './tools.js'; +import { executeTool, setConfirmCallback, formatToolCall, formatToolResult } from './tools.js'; import { recordSpending } from '../storage/budget.js'; import { checkBudgetAlert } from '../storage/budget.js'; import type { Message } from '../providers/types.js'; import { validateTurnSequence } from '../providers/wire.js'; import { MODELS } from '../config/models.js'; +import { getMode, toolsForMode, checkToolCall, DEFAULT_MODE, type AgentMode } from './modes.js'; +import { buildRulesPrompt } from './rules.js'; +import { recordCheckpoint, pruneCheckpoints } from './checkpoints.js'; +import { initializeMcp, shutdownMcp } from '../mcp/registry.js'; +import { randomUUID } from 'crypto'; export interface AgentOptions { task: string; @@ -14,6 +19,8 @@ export interface AgentOptions { provider?: string; model?: string; maxIterations?: number; + /** Agent mode: code | architect | ask | debug | orchestrator. */ + mode?: string; verbose?: boolean; onProgress?: (step: string) => void; onConfirm?: (message: string) => Promise; @@ -39,6 +46,8 @@ export interface AgentResult { totalOutputTokens: number; iterations: number; steps: AgentStep[]; + /** Identifies this run's checkpoints: `cude checkpoint restore-run `. */ + runId: string; } /** @@ -133,6 +142,13 @@ Important guidelines: When you have completed the task, start your final response with "TASK COMPLETE:" followed by a summary.`; +/** + * Base prompt + the mode's own instructions + any rules the repository carries. + */ +export function buildSystemPrompt(mode: AgentMode): string { + return `${AGENT_SYSTEM_PROMPT}\n\n${mode.systemPrompt}${buildRulesPrompt()}`; +} + export async function runAgent(options: AgentOptions): Promise { const { taskType = 'code', @@ -144,6 +160,22 @@ export async function runAgent(options: AgentOptions): Promise { onConfirm, } = options; + const mode = getMode(options.mode ?? DEFAULT_MODE); + // Checkpoints accumulate across runs; trim before adding more. + pruneCheckpoints(); + + // Connect configured MCP servers so their tools are in the list the model + // sees. No servers configured is a fast no-op. + if (mode.allowMcp) { + const mcp = await initializeMcp(); + if (verbose && mcp.connected.length > 0) { + console.log(chalk.dim(` MCP: ${mcp.connected.join(', ')} (${mcp.tools.length} tools)`)); + } + for (const failure of mcp.failed) { + console.log(chalk.yellow(` MCP server "${failure.server}" unavailable: ${failure.reason}`)); + } + } + if (onConfirm) { setConfirmCallback(onConfirm); } @@ -156,26 +188,37 @@ export async function runAgent(options: AgentOptions): Promise { if (verbose) { console.log(chalk.dim(` Using ${provider.displayName} / ${model} (${reason})`)); + console.log(chalk.dim(` Mode: ${mode.displayName} — ${mode.description}`)); } - if (!provider.supportsTools() && provider.name !== 'ollama') { - // For providers without native tool support, use ReAct-style prompting - return runReActAgent(provider, model, options, maxIterations); - } + try { + if (!provider.supportsTools() && provider.name !== 'ollama') { + // For providers without native tool support, use ReAct-style prompting + return await runReActAgent(provider, model, options, maxIterations, mode); + } - if (provider.supportsTools() && provider.chatWithTools) { - return runToolsAgent(provider, model, options, maxIterations); - } + if (provider.supportsTools() && provider.chatWithTools) { + return await runToolsAgent(provider, model, options, maxIterations, mode); + } - return runReActAgent(provider, model, options, maxIterations); + return await runReActAgent(provider, model, options, maxIterations, mode); + } finally { + // However the run ends, stop the servers — a stdio child would otherwise + // hold the CLI open. + await shutdownMcp(); + } } async function runToolsAgent( provider: import('../providers/types.js').Provider, model: string, options: AgentOptions, - maxIterations: number + maxIterations: number, + mode: AgentMode ): Promise { + const systemPrompt = buildSystemPrompt(mode); + const tools = toolsForMode(mode); + const runId = randomUUID().slice(0, 8); const messages: Message[] = [ { role: 'user', content: options.task }, ]; @@ -217,8 +260,8 @@ async function runToolsAgent( const { response, toolCalls } = await provider.chatWithTools!( messages, model, - TOOL_DEFINITIONS, - { systemPrompt: AGENT_SYSTEM_PROMPT, maxTokens: 4096 } + tools, + { systemPrompt, maxTokens: 4096 } ); totalCost += response.cost; @@ -264,7 +307,16 @@ async function runToolsAgent( toolArgs: toolCall.arguments, }); - const result = await executeTool(toolCall.name, toolCall.arguments); + // Prompt-level restriction is not restriction; the mode's budget is + // enforced here too, not just by omitting the tool definition. + const refusal = checkToolCall(mode, toolCall.name, toolCall.arguments); + if (!refusal) { + // Capture the pre-state so a wrong edit is reversible. + recordCheckpoint(runId, options.task, toolCall.name, toolCall.arguments); + } + const result = refusal + ? { success: false, output: '', error: refusal } + : await executeTool(toolCall.name, toolCall.arguments); if (options.verbose) { console.log(formatToolResult(result)); @@ -301,6 +353,7 @@ async function runToolsAgent( totalOutputTokens, iterations, steps, + runId, }); } @@ -308,13 +361,16 @@ async function runReActAgent( provider: import('../providers/types.js').Provider, model: string, options: AgentOptions, - maxIterations: number + maxIterations: number, + mode: AgentMode ): Promise { - const toolDescriptions = TOOL_DEFINITIONS.map(t => + const tools = toolsForMode(mode); + const runId = randomUUID().slice(0, 8); + const toolDescriptions = tools.map(t => `- ${t.name}: ${t.description}` ).join('\n'); - const systemPrompt = `${AGENT_SYSTEM_PROMPT} + const systemPrompt = `${buildSystemPrompt(mode)} Available tools: ${toolDescriptions} @@ -395,7 +451,13 @@ When done, start with "TASK COMPLETE:" to finish.`; console.log(formatToolCall(toolName, toolArgs)); } - const result = await executeTool(toolName, toolArgs); + const refusal = checkToolCall(mode, toolName, toolArgs); + if (!refusal) { + recordCheckpoint(runId, options.task, toolName, toolArgs); + } + const result = refusal + ? { success: false, output: '', error: refusal } + : await executeTool(toolName, toolArgs); if (options.verbose) { console.log(formatToolResult(result)); @@ -445,5 +507,6 @@ When done, start with "TASK COMPLETE:" to finish.`; totalOutputTokens, iterations, steps, + runId, }); } diff --git a/src/core/checkpoints.ts b/src/core/checkpoints.ts new file mode 100644 index 0000000..cd69ea9 --- /dev/null +++ b/src/core/checkpoints.ts @@ -0,0 +1,234 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'fs'; +import { join, resolve, dirname, relative } from 'path'; +import { randomUUID } from 'crypto'; +import { getDataDir } from '../config/index.js'; +import { getWorkspaceRoot } from './tools.js'; + +/** + * Undo for agent edits. + * + * The workspace boundary stops the agent writing somewhere it shouldn't; it + * does nothing about a wrong edit inside the boundary. Before each mutating + * tool call the prior state of the target is recorded, so a run can be put back + * the way it was without needing the project to be a git repository (and + * without touching git if it is — an agent run is not a commit). + * + * Snapshots are whole prior file contents. That is wasteful for large files and + * completely reliable, which is the right trade for an undo path. + */ + +/** Tools that change a file, and the argument naming what they change. */ +export const MUTATING_TOOLS: Record = { + write_file: 'path', + replace_in_file: 'path', + apply_patch: 'path', + delete_file: 'path', + move_file: 'destination', + copy_file: 'destination', +}; + +/** Files above this are recorded as "too large to snapshot" rather than copied. */ +export const MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024; + +export interface FileSnapshot { + path: string; + /** Prior contents, or null when the file did not exist yet. */ + content: string | null; + /** True when the file existed but was too large to capture. */ + skipped?: boolean; +} + +export interface Checkpoint { + id: string; + runId: string; + createdAt: string; + /** The tool call this checkpoint precedes. */ + toolName: string; + task: string; + files: FileSnapshot[]; +} + +function checkpointDir(): string { + const dir = join(getDataDir(), 'checkpoints'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return dir; +} + +function checkpointPath(id: string): string { + return join(checkpointDir(), `${id}.json`); +} + +/** Capture the current state of `filePath` before something changes it. */ +export function snapshotFile(filePath: string): FileSnapshot { + const target = resolve(filePath); + if (!existsSync(target)) { + return { path: target, content: null }; + } + try { + if (statSync(target).size > MAX_SNAPSHOT_BYTES) { + return { path: target, content: null, skipped: true }; + } + return { path: target, content: readFileSync(target, 'utf-8') }; + } catch { + return { path: target, content: null, skipped: true }; + } +} + +/** + * Records the pre-state for one tool call. Returns null when the call does not + * change a file, so callers can use it unconditionally. + */ +export function recordCheckpoint( + runId: string, + task: string, + toolName: string, + args: Record +): Checkpoint | null { + const pathArg = MUTATING_TOOLS[toolName]; + if (!pathArg) return null; + + const files: FileSnapshot[] = []; + const target = args[pathArg]; + if (typeof target === 'string') files.push(snapshotFile(target)); + + // move_file empties its source as well as writing its destination. + if (toolName === 'move_file' && typeof args.source === 'string') { + files.push(snapshotFile(args.source)); + } + + if (files.length === 0) return null; + + const checkpoint: Checkpoint = { + id: randomUUID().slice(0, 8), + runId, + createdAt: new Date().toISOString(), + toolName, + task, + files, + }; + + try { + writeFileSync(checkpointPath(checkpoint.id), JSON.stringify(checkpoint, null, 2), 'utf-8'); + } catch { + // A checkpoint that cannot be written must not stop the run. + return null; + } + return checkpoint; +} + +export function loadCheckpoint(id: string): Checkpoint | undefined { + const path = checkpointPath(id); + if (!existsSync(path)) return undefined; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as Checkpoint; + } catch { + return undefined; + } +} + +export function listCheckpoints(): Checkpoint[] { + const dir = checkpointDir(); + const entries = existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.json')) : []; + const checkpoints: Checkpoint[] = []; + for (const entry of entries) { + try { + checkpoints.push(JSON.parse(readFileSync(join(dir, entry), 'utf-8')) as Checkpoint); + } catch { + // Skip anything unreadable rather than failing the whole listing. + } + } + return checkpoints.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export interface RestoreResult { + restored: string[]; + removed: string[]; + failed: Array<{ path: string; reason: string }>; +} + +/** + * Puts every file in the checkpoint back to its recorded state: rewritten if it + * existed, deleted if it did not. Restoring is itself checkpointed by the + * caller if it wants to be able to undo the undo. + */ +export function restoreCheckpoint(checkpoint: Checkpoint): RestoreResult { + const result: RestoreResult = { restored: [], removed: [], failed: [] }; + + for (const file of checkpoint.files) { + if (file.skipped) { + result.failed.push({ path: file.path, reason: 'not captured (too large)' }); + continue; + } + try { + if (file.content === null) { + // It did not exist before the tool ran, so undoing means removing it. + if (existsSync(file.path)) { + unlinkSync(file.path); + result.removed.push(file.path); + } + } else { + const dir = dirname(file.path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(file.path, file.content, 'utf-8'); + result.restored.push(file.path); + } + } catch (err) { + result.failed.push({ + path: file.path, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + return result; +} + +/** Restores every checkpoint of a run, newest first, undoing the whole run. */ +export function restoreRun(runId: string): RestoreResult { + const merged: RestoreResult = { restored: [], removed: [], failed: [] }; + for (const checkpoint of listCheckpoints().filter(c => c.runId === runId)) { + const result = restoreCheckpoint(checkpoint); + merged.restored.push(...result.restored); + merged.removed.push(...result.removed); + merged.failed.push(...result.failed); + } + return merged; +} + +/** Drops checkpoints older than `keep` most recent runs. */ +export function pruneCheckpoints(keepRuns = 20): number { + const all = listCheckpoints(); + const runs: string[] = []; + for (const c of all) if (!runs.includes(c.runId)) runs.push(c.runId); + + const doomed = new Set(runs.slice(keepRuns)); + let removed = 0; + for (const checkpoint of all) { + if (!doomed.has(checkpoint.runId)) continue; + try { + rmSync(checkpointPath(checkpoint.id), { force: true }); + removed++; + } catch { + // Best-effort cleanup. + } + } + return removed; +} + +export function clearCheckpoints(): number { + const all = listCheckpoints(); + for (const checkpoint of all) { + try { + rmSync(checkpointPath(checkpoint.id), { force: true }); + } catch { + // Best-effort. + } + } + return all.length; +} + +/** Workspace-relative path for display. */ +export function displayPath(path: string): string { + const rel = relative(getWorkspaceRoot(), path); + return rel && !rel.startsWith('..') ? rel : path; +} diff --git a/src/core/claw.ts b/src/core/claw.ts new file mode 100644 index 0000000..6c569ec --- /dev/null +++ b/src/core/claw.ts @@ -0,0 +1,333 @@ +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { randomUUID } from 'crypto'; +import { executeTool } from './tools.js'; +import { getMode, toolsForMode, checkToolCall, DEFAULT_MODE, type AgentMode } from './modes.js'; +import { buildSystemPrompt, truncateToolOutput, TOOL_RESULT_MAX_CHARS, isFreeOrLocal } from './agent.js'; +import { recordCheckpoint, MUTATING_TOOLS } from './checkpoints.js'; +import { selectProviderAndModel, type TaskType } from './selector.js'; +import { validateTurnSequence } from '../providers/wire.js'; +import { checkBudgetAlert, recordSpending } from '../storage/budget.js'; +import type { Message, Provider, ToolCall } from '../providers/types.js'; + +/** + * Cude Claw: a conversation that keeps its context between turns, rather than + * `cude run` starting from nothing each time. + * + * The difference that matters is not the prompt — it is that the user is + * present. Every edit can be shown and approved before it happens, the mode and + * model can change mid-conversation, and the accumulated cost is visible as it + * grows. + */ + +export type ApprovalDecision = 'yes' | 'no' | 'always' | 'abort'; + +export interface PendingEdit { + toolName: string; + args: Record; + /** Present when the change can be previewed as a diff. */ + before?: string; + after?: string; + path?: string; +} + +export interface ClawEvents { + onThought?: (text: string) => void; + onToolCall?: (name: string, args: Record) => void; + onToolResult?: (name: string, ok: boolean, summary: string) => void; + onApproval?: (edit: PendingEdit) => Promise; + onIteration?: (n: number) => void; +} + +export interface ClawTurnResult { + output: string; + cost: number; + inputTokens: number; + outputTokens: number; + iterations: number; + toolCalls: number; + stopReason: 'completed' | 'max_iterations' | 'budget_exceeded' | 'aborted'; +} + +export interface ClawSessionOptions { + provider?: string; + model?: string; + mode?: string; + taskType?: TaskType; + free?: boolean; + maxIterationsPerTurn?: number; + /** Approve every edit without asking. */ + autoApprove?: boolean; +} + +export class ClawSession { + readonly runId = randomUUID().slice(0, 8); + readonly messages: Message[] = []; + + provider: Provider; + model: string; + mode: AgentMode; + autoApprove: boolean; + + totalCost = 0; + totalInputTokens = 0; + totalOutputTokens = 0; + turns = 0; + + private maxIterations: number; + private alwaysAllowed = new Set(); + + constructor(options: ClawSessionOptions = {}) { + this.mode = getMode(options.mode ?? DEFAULT_MODE); + this.maxIterations = options.maxIterationsPerTurn ?? 12; + this.autoApprove = options.autoApprove ?? false; + + const selected = selectProviderAndModel(options.taskType ?? this.mode.defaultTaskType, { + free: options.free ?? false, + preferredProvider: options.provider, + preferredModel: options.model, + }); + this.provider = selected.provider; + this.model = selected.model; + } + + get supportsTools(): boolean { + return this.provider.supportsTools() && typeof this.provider.chatWithTools === 'function'; + } + + setMode(name: string): void { + this.mode = getMode(name); + } + + setModel(provider: string | undefined, model: string): void { + const selected = selectProviderAndModel(this.mode.defaultTaskType, { + preferredProvider: provider ?? this.provider.name, + preferredModel: model, + }); + this.provider = selected.provider; + this.model = selected.model; + } + + /** Drops the conversation but keeps the session's cost tally and settings. */ + clear(): void { + this.messages.length = 0; + this.alwaysAllowed.clear(); + } + + /** + * Expands `@path` mentions into the file's contents, so "explain @src/x.ts" + * does not need a tool round-trip just to read something the user named. + */ + expandMentions(input: string): string { + const mentions = [...input.matchAll(/(?:^|\s)@([^\s]+)/g)].map(m => m[1]); + if (mentions.length === 0) return input; + + const attachments: string[] = []; + for (const mention of mentions) { + const path = resolve(mention); + if (!existsSync(path)) { + attachments.push(`--- @${mention} ---\n(no such file)`); + continue; + } + try { + const content = readFileSync(path, 'utf-8'); + attachments.push( + `--- @${mention} ---\n${truncateToolOutput(content, TOOL_RESULT_MAX_CHARS)}` + ); + } catch (err) { + attachments.push(`--- @${mention} ---\n(could not read: ${err instanceof Error ? err.message : String(err)})`); + } + } + + return `${input}\n\nReferenced files:\n\n${attachments.join('\n\n')}`; + } + + /** What a mutating call is about to do, for the approval prompt. */ + private describeEdit(toolName: string, args: Record): PendingEdit { + const pathArg = MUTATING_TOOLS[toolName]; + const target = pathArg ? args[pathArg] : undefined; + const edit: PendingEdit = { toolName, args }; + if (typeof target !== 'string') return edit; + + edit.path = target; + const resolved = resolve(target); + const before = existsSync(resolved) ? safeRead(resolved) : ''; + edit.before = before; + + if (toolName === 'write_file' && typeof args.content === 'string') { + edit.after = args.content; + } else if ( + toolName === 'replace_in_file' && + typeof args.old_text === 'string' && + typeof args.new_text === 'string' && + before + ) { + edit.after = args.replace_all + ? before.split(args.old_text).join(args.new_text) + : before.replace(args.old_text, args.new_text); + } + + return edit; + } + + private async approve( + toolName: string, + args: Record, + events: ClawEvents + ): Promise { + if (!MUTATING_TOOLS[toolName]) return 'yes'; + if (this.autoApprove || this.alwaysAllowed.has(toolName)) return 'yes'; + if (!events.onApproval) return 'yes'; + + const decision = await events.onApproval(this.describeEdit(toolName, args)); + if (decision === 'always') this.alwaysAllowed.add(toolName); + return decision; + } + + async send(userInput: string, events: ClawEvents = {}): Promise { + this.turns++; + this.messages.push({ role: 'user', content: this.expandMentions(userInput) }); + + const systemPrompt = buildSystemPrompt(this.mode); + const tools = toolsForMode(this.mode); + const budgetApplies = !isFreeOrLocal(this.provider, this.model); + + let iterations = 0; + let toolCallCount = 0; + let output = ''; + let cost = 0; + let inputTokens = 0; + let outputTokens = 0; + let stopReason: ClawTurnResult['stopReason'] = 'max_iterations'; + + while (iterations < this.maxIterations) { + iterations++; + events.onIteration?.(iterations); + + if (budgetApplies) { + const budget = checkBudgetAlert(); + if (budget.exceeded) { + output = `Budget exceeded: ${budget.message}`; + stopReason = 'budget_exceeded'; + break; + } + } + + const violation = validateTurnSequence(this.messages); + if (violation) { + throw new Error(`Refusing to send a malformed conversation: ${violation}`); + } + + const { response, toolCalls } = await this.provider.chatWithTools!( + this.messages, + this.model, + tools, + { systemPrompt, maxTokens: 4096 } + ); + + cost += response.cost; + inputTokens += response.inputTokens; + outputTokens += response.outputTokens; + recordSpending(this.provider.name, this.model, response.cost, response.inputTokens, response.outputTokens); + + if (response.content) events.onThought?.(response.content); + + if (toolCalls.length === 0) { + output = response.content; + stopReason = 'completed'; + break; + } + + this.messages.push({ role: 'assistant', content: response.content, tool_calls: toolCalls }); + + let aborted = false; + for (const call of toolCalls) { + toolCallCount++; + const result = await this.runCall(call, events); + if (result === 'abort') { + aborted = true; + // The assistant is still owed an answer for every call it made, or + // the next request is malformed. + this.messages.push({ + role: 'tool', + tool_call_id: call.id, + name: call.name, + content: 'The user stopped this turn.', + }); + continue; + } + } + + if (aborted) { + output = 'Stopped.'; + stopReason = 'aborted'; + break; + } + + if (response.content.includes('TASK COMPLETE:')) { + output = response.content; + stopReason = 'completed'; + break; + } + } + + if (!output && stopReason === 'max_iterations') { + output = `Stopped after ${iterations} steps without finishing.`; + } + + this.totalCost += cost; + this.totalInputTokens += inputTokens; + this.totalOutputTokens += outputTokens; + + return { output, cost, inputTokens, outputTokens, iterations, toolCalls: toolCallCount, stopReason }; + } + + /** Runs one tool call, appending its result message. Returns 'abort' to stop. */ + private async runCall(call: ToolCall, events: ClawEvents): Promise<'done' | 'abort'> { + events.onToolCall?.(call.name, call.arguments); + + const refusal = checkToolCall(this.mode, call.name, call.arguments); + if (refusal) { + events.onToolResult?.(call.name, false, refusal); + this.messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: `ERROR: ${refusal}` }); + return 'done'; + } + + const decision = await this.approve(call.name, call.arguments, events); + if (decision === 'abort') return 'abort'; + if (decision === 'no') { + const message = 'The user declined this edit. Do not retry it; ask what to do differently.'; + events.onToolResult?.(call.name, false, 'declined'); + this.messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: `ERROR: ${message}` }); + return 'done'; + } + + recordCheckpoint(this.runId, `claw turn ${this.turns}`, call.name, call.arguments); + const result = await executeTool(call.name, call.arguments); + + events.onToolResult?.( + call.name, + result.success, + result.success ? result.output.split('\n')[0].slice(0, 120) : (result.error ?? 'failed') + ); + + this.messages.push({ + role: 'tool', + tool_call_id: call.id, + name: call.name, + content: result.success + ? truncateToolOutput(result.output, TOOL_RESULT_MAX_CHARS) + : `ERROR: ${result.error}`, + }); + + return 'done'; + } +} + +function safeRead(path: string): string { + try { + return readFileSync(path, 'utf-8'); + } catch { + return ''; + } +} diff --git a/src/core/modes.ts b/src/core/modes.ts new file mode 100644 index 0000000..ce262df --- /dev/null +++ b/src/core/modes.ts @@ -0,0 +1,195 @@ +import { TOOL_DEFINITIONS } from './tools.js'; +import type { TaskType } from './selector.js'; +import { getMcpToolDefinitions, isMcpTool } from '../mcp/registry.js'; +import type { ToolDefinition } from '../providers/types.js'; + +/** + * Agent modes. A mode is a system prompt plus a tool budget: what the agent is + * being asked to do, and what it is allowed to touch while doing it. + * + * The tool budget is the part that matters. "Ask me about this codebase" should + * not be able to write to it, and enforcing that with a prompt alone is not + * enforcement — so the allow-list is applied twice, once when the tool list is + * built for the model and again in executeTool. + */ +export interface AgentMode { + name: string; + displayName: string; + description: string; + /** Appended to the base agent prompt. */ + systemPrompt: string; + /** Tool names this mode may call. `'all'` means every registered tool. */ + allowedTools: 'all' | string[]; + /** + * When set, writes are additionally restricted to paths matching this — a + * mode that says it only writes Markdown has to actually only write Markdown. + */ + writablePathPattern?: RegExp; + /** + * Whether tools contributed by MCP servers are offered in this mode. An + * external server's tools are arbitrary, so a mode that promises read-only + * cannot honestly hand them to the model. + */ + allowMcp: boolean; + defaultTaskType: TaskType; +} + +/** Tools whose first path argument is something the mode is about to write. */ +const WRITE_PATH_ARGS: Record = { + write_file: 'path', + create_directory: 'path', + replace_in_file: 'path', + apply_patch: 'path', + delete_file: 'path', + move_file: 'destination', + copy_file: 'destination', +}; + +/** Tools that only observe. Every mode gets these. */ +const READ_ONLY_TOOLS = [ + 'read_file', + 'list_directory', + 'search_files', + 'grep_search', + 'get_file_info', + 'diff_files', + 'rag_index', + 'rag_search', + 'rag_summary', + 'browser_navigate', + 'browser_screenshot', + 'browser_extract', +]; + +export const MODES: Record = { + code: { + name: 'code', + displayName: 'Code', + description: 'Write and modify code. Every tool available.', + systemPrompt: + 'You are in Code mode. Implement what was asked: read the surrounding code first, ' + + 'match its conventions, and make the smallest change that does the job. ' + + 'Verify your work by running the project\'s own tests or build when they exist.', + allowedTools: 'all', + allowMcp: true, + defaultTaskType: 'code', + }, + + architect: { + name: 'architect', + displayName: 'Architect', + description: 'Plan and design. Reads anything; writes only Markdown.', + systemPrompt: + 'You are in Architect mode. Investigate thoroughly and produce a plan — do not ' + + 'implement it. Read the code that matters, identify the files that would change ' + + 'and the order to change them, and name the trade-offs and risks you actually ' + + 'found. You may write Markdown documents; you cannot modify source files.', + allowedTools: [...READ_ONLY_TOOLS, 'write_file', 'create_directory'], + writablePathPattern: /\.(md|markdown|txt)$/i, + allowMcp: false, + defaultTaskType: 'complex', + }, + + ask: { + name: 'ask', + displayName: 'Ask', + description: 'Answer questions. Read-only — cannot modify anything.', + systemPrompt: + 'You are in Ask mode. Answer the question from what the codebase actually says, ' + + 'citing the files and line numbers you relied on. You have no ability to modify ' + + 'anything; if the answer requires a change, describe the change instead of ' + + 'attempting it.', + allowedTools: READ_ONLY_TOOLS, + allowMcp: false, + defaultTaskType: 'analysis', + }, + + debug: { + name: 'debug', + displayName: 'Debug', + description: 'Diagnose failures. Every tool available.', + systemPrompt: + 'You are in Debug mode. Find the actual cause before proposing a fix: reproduce ' + + 'the failure, read the code on the failing path, and add instrumentation if the ' + + 'evidence is not there. State what you confirmed versus what you are inferring. ' + + 'Do not guess at a fix and declare victory without re-running the failing case.', + allowedTools: 'all', + allowMcp: true, + defaultTaskType: 'complex', + }, + + orchestrator: { + name: 'orchestrator', + displayName: 'Orchestrator', + description: 'Break a large task into ordered steps and work through them.', + systemPrompt: + 'You are in Orchestrator mode. Decompose the task into concrete, ordered ' + + 'sub-tasks, then carry them out one at a time, reporting the outcome of each ' + + 'before starting the next. Keep a running list of what is done and what remains, ' + + 'and stop to say so if a sub-task turns out to be blocked.', + allowedTools: 'all', + allowMcp: true, + defaultTaskType: 'complex', + }, +}; + +export const DEFAULT_MODE = 'code'; + +export function getMode(name?: string): AgentMode { + if (!name) return MODES[DEFAULT_MODE]; + const mode = MODES[name.toLowerCase()]; + if (!mode) { + throw new Error( + `Unknown mode: ${name}. Available: ${Object.keys(MODES).join(', ')}` + ); + } + return mode; +} + +export function listModes(): AgentMode[] { + return Object.values(MODES); +} + +export function isToolAllowed(mode: AgentMode, toolName: string): boolean { + if (isMcpTool(toolName)) return mode.allowMcp; + if (mode.allowedTools === 'all') return true; + return mode.allowedTools.includes(toolName); +} + +/** + * Full check for one call. Returns the reason to refuse, or null to proceed. + */ +export function checkToolCall( + mode: AgentMode, + toolName: string, + args: Record +): string | null { + if (!isToolAllowed(mode, toolName)) { + const allowed = toolsForMode(mode).map(t => t.name).join(', '); + return `${toolName} is not available in ${mode.displayName} mode. Allowed: ${allowed}.`; + } + + const pattern = mode.writablePathPattern; + const pathArg = WRITE_PATH_ARGS[toolName]; + if (pattern && pathArg) { + const target = args[pathArg]; + // create_directory has no extension to match; a directory is fine. + if (toolName !== 'create_directory' && typeof target === 'string' && !pattern.test(target)) { + return ( + `${mode.displayName} mode may only write files matching ${pattern} — ` + + `${target} does not. Describe the change instead of making it.` + ); + } + } + + return null; +} + +/** The tool list handed to the model for this mode, MCP tools included. */ +export function toolsForMode(mode: AgentMode): ToolDefinition[] { + const builtIn = + mode.allowedTools === 'all' + ? TOOL_DEFINITIONS + : TOOL_DEFINITIONS.filter(t => isToolAllowed(mode, t.name)); + return mode.allowMcp ? [...builtIn, ...getMcpToolDefinitions()] : builtIn; +} diff --git a/src/core/rules.ts b/src/core/rules.ts new file mode 100644 index 0000000..5f31281 --- /dev/null +++ b/src/core/rules.ts @@ -0,0 +1,106 @@ +import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; +import { join, dirname, resolve, relative } from 'path'; +import { getWorkspaceRoot } from './tools.js'; + +/** + * Project rules: standing instructions the agent should follow in this + * repository, kept in the repository rather than repeated in every prompt. + * + * Discovery walks up from the workspace root so a rule file at the top of a + * monorepo applies to a package inside it, with the nearest file last — closest + * to the work wins. + */ +export interface RuleFile { + path: string; + content: string; +} + +/** Recognised at any level, in this order of preference within a directory. */ +const RULE_FILENAMES = ['AGENTS.md', 'CUDE.md', '.cuderules']; +const RULE_DIRECTORY = join('.cude', 'rules'); + +/** Guards against a rule file large enough to crowd out the actual task. */ +export const MAX_RULE_CHARS = 32_000; + +function readRuleDirectory(dir: string): RuleFile[] { + const rulesDir = join(dir, RULE_DIRECTORY); + if (!existsSync(rulesDir)) return []; + try { + return readdirSync(rulesDir) + .filter(f => f.endsWith('.md') || f.endsWith('.txt')) + .sort() + .map(f => join(rulesDir, f)) + .filter(p => { + try { + return statSync(p).isFile(); + } catch { + return false; + } + }) + .map(p => ({ path: p, content: readFileSync(p, 'utf-8') })); + } catch { + return []; + } +} + +export function findRuleFiles(startDir?: string): RuleFile[] { + const start = resolve(startDir ?? getWorkspaceRoot()); + + // Collect directories from the filesystem root down to `start`, so the + // nearest rules are appended last. + const chain: string[] = []; + let current = start; + for (;;) { + chain.unshift(current); + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + const found: RuleFile[] = []; + for (const dir of chain) { + for (const name of RULE_FILENAMES) { + const path = join(dir, name); + try { + if (existsSync(path) && statSync(path).isFile()) { + found.push({ path, content: readFileSync(path, 'utf-8') }); + } + } catch { + // An unreadable rule file must never block a run. + } + } + found.push(...readRuleDirectory(dir)); + } + + return found; +} + +/** + * The rules block appended to the system prompt, or '' when there are none. + * Each file is labelled with its path so the model can say which rule it is + * following. + */ +export function buildRulesPrompt(files?: RuleFile[]): string { + const rules = files ?? findRuleFiles(); + if (rules.length === 0) return ''; + + const root = getWorkspaceRoot(); + const sections: string[] = []; + let budget = MAX_RULE_CHARS; + + for (const rule of rules) { + if (budget <= 0) break; + const label = relative(root, rule.path) || rule.path; + const body = + rule.content.length > budget + ? rule.content.slice(0, budget) + '\n... [rule file truncated]' + : rule.content; + budget -= body.length; + sections.push(`--- ${label} ---\n${body.trim()}`); + } + + return ( + '\n\nProject rules (from this repository — follow them over your defaults):\n\n' + + sections.join('\n\n') + ); +} diff --git a/src/core/tools.ts b/src/core/tools.ts index 05b4060..e891e35 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -7,6 +7,7 @@ import { getWorkspaceRootSetting } from '../config/index.js'; import type { ToolDefinition } from '../providers/types.js'; import { BROWSER_TOOL_DEFINITIONS, executeBrowserTool } from './browser.js'; import { RAG_TOOL_DEFINITIONS, executeRagTool } from './rag.js'; +import { isMcpTool, executeMcpTool, getMcpToolDefinitions } from '../mcp/registry.js'; const execAsync = promisify(exec); @@ -430,8 +431,15 @@ async function requireConfirmation(message: string): Promise * implementation happens to throw (e.g. `paths[0] must be of type string`), * which tells the model nothing about how to retry. */ +function findDefinition(name: string) { + return ( + TOOL_DEFINITIONS.find(d => d.name === name) ?? + getMcpToolDefinitions().find(d => d.name === name) + ); +} + function findMissingParams(name: string, args: Record): string[] { - const def = TOOL_DEFINITIONS.find((d) => d.name === name); + const def = findDefinition(name); const required = (def?.parameters as { required?: unknown })?.required; if (!Array.isArray(required)) return []; return required.filter( @@ -446,7 +454,7 @@ export async function executeTool( ): Promise { const missing = findMissingParams(name, args); if (missing.length > 0) { - const def = TOOL_DEFINITIONS.find((d) => d.name === name); + const def = findDefinition(name); const known = Object.keys( ((def?.parameters as { properties?: Record })?.properties) ?? {} ); @@ -519,6 +527,12 @@ export async function executeTool( return executeRagTool(name, args); default: + // Tools contributed by MCP servers are namespaced and dispatched here, + // so mode checks, checkpoints and the agent loop treat them like any + // other tool. + if (isMcpTool(name)) { + return executeMcpTool(name, args); + } return { success: false, output: '', error: `Unknown tool: ${name}` }; } } diff --git a/src/mcp/client.ts b/src/mcp/client.ts new file mode 100644 index 0000000..c6c3d35 --- /dev/null +++ b/src/mcp/client.ts @@ -0,0 +1,324 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; + +/** + * A minimal Model Context Protocol client. + * + * Written against the protocol rather than the official SDK because Cude ships + * with no runtime dependency it does not need, and the client half of MCP is + * four JSON-RPC calls: initialize, initialized, tools/list, tools/call. + * + * Two transports: stdio (a local server started as a child process, framed as + * newline-delimited JSON) and HTTP (a single POST per request, accepting either + * a JSON body or an SSE stream). + */ + +export const PROTOCOL_VERSION = '2024-11-05'; + +export interface StdioServerConfig { + command: string; + args?: string[]; + env?: Record; + cwd?: string; +} + +export interface HttpServerConfig { + url: string; + headers?: Record; +} + +export type McpServerConfig = (StdioServerConfig | HttpServerConfig) & { + /** Skip this server without removing its configuration. */ + disabled?: boolean; + /** Per-request timeout in ms. */ + timeout?: number; +}; + +export function isHttpConfig(config: McpServerConfig): config is HttpServerConfig { + return typeof (config as HttpServerConfig).url === 'string'; +} + +export interface McpToolInfo { + name: string; + description?: string; + inputSchema?: Record; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id?: number | string; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +const DEFAULT_TIMEOUT = 30_000; + +export class McpClient { + readonly name: string; + private readonly config: McpServerConfig; + private child: ChildProcessWithoutNullStreams | null = null; + private nextId = 1; + private pending = new Map void; reject: (e: Error) => void; timer: NodeJS.Timeout }>(); + private buffer = ''; + private started = false; + private stderrTail: string[] = []; + + constructor(name: string, config: McpServerConfig) { + this.name = name; + this.config = config; + } + + private get timeout(): number { + return this.config.timeout ?? DEFAULT_TIMEOUT; + } + + // ─── stdio plumbing ─────────────────────────────────────────────────────── + + private startChild(): ChildProcessWithoutNullStreams { + if (this.child) return this.child; + const config = this.config as StdioServerConfig; + + // Windows needs a shell for the .cmd shims most MCP servers ship as (npx, + // npm, uvx). With shell: true Node hands the strings to cmd.exe verbatim + // and quotes nothing, so anything with a space — "C:\Program Files\..." — + // has to be quoted here or cmd splits it. + const useShell = process.platform === 'win32'; + const quote = (value: string): string => + useShell && /[^A-Za-z0-9_.:=\\/-]/.test(value) ? `"${value}"` : value; + + // Passing an args array alongside shell: true is deprecated (DEP0190) + // precisely because nothing escapes it — so build the one command line. + const args = config.args ?? []; + const child = useShell + ? spawn([quote(config.command), ...args.map(quote)].join(' '), [], { + env: { ...process.env, ...(config.env ?? {}) }, + cwd: config.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + shell: true, + }) + : spawn(config.command, args, { + env: { ...process.env, ...(config.env ?? {}) }, + cwd: config.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.stdout.setEncoding('utf-8'); + child.stdout.on('data', (chunk: string) => this.consume(chunk)); + + child.stderr.setEncoding('utf-8'); + child.stderr.on('data', (chunk: string) => { + // Servers log freely to stderr; keep only enough to explain a failure. + this.stderrTail.push(chunk); + if (this.stderrTail.length > 20) this.stderrTail.shift(); + }); + + const fail = (err: Error) => { + for (const [, waiter] of this.pending) { + clearTimeout(waiter.timer); + waiter.reject(err); + } + this.pending.clear(); + this.child = null; + this.started = false; + }; + + child.on('error', err => + fail(new Error(`MCP server "${this.name}" could not be started: ${err.message}`)) + ); + child.on('exit', code => + fail(new Error( + `MCP server "${this.name}" exited (code ${code})` + + (this.stderrTail.length ? `: ${this.stderrTail.join('').trim().slice(-400)}` : '') + )) + ); + + this.child = child; + return child; + } + + /** Newline-delimited JSON frames. */ + private consume(chunk: string): void { + this.buffer += chunk; + let index = this.buffer.indexOf('\n'); + while (index !== -1) { + const line = this.buffer.slice(0, index).trim(); + this.buffer = this.buffer.slice(index + 1); + if (line) this.dispatch(line); + index = this.buffer.indexOf('\n'); + } + } + + private dispatch(line: string): void { + let message: JsonRpcResponse; + try { + message = JSON.parse(line) as JsonRpcResponse; + } catch { + return; // Not a JSON-RPC frame — servers sometimes print banners. + } + if (typeof message.id !== 'number') return; // A notification from the server. + + const waiter = this.pending.get(message.id); + if (!waiter) return; + this.pending.delete(message.id); + clearTimeout(waiter.timer); + + if (message.error) { + waiter.reject(new Error(`${message.error.message} (code ${message.error.code})`)); + } else { + waiter.resolve(message.result); + } + } + + // ─── request ────────────────────────────────────────────────────────────── + + private async request(method: string, params?: Record): Promise { + if (isHttpConfig(this.config)) return this.httpRequest(method, params); + + const child = this.startChild(); + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`MCP server "${this.name}" did not answer ${method} within ${this.timeout}ms`)); + }, this.timeout); + + this.pending.set(id, { resolve, reject, timer }); + child.stdin.write(payload + '\n', err => { + if (err) { + clearTimeout(timer); + this.pending.delete(id); + reject(new Error(`MCP server "${this.name}": ${err.message}`)); + } + }); + }); + } + + private notify(method: string, params?: Record): void { + if (isHttpConfig(this.config)) { + void this.httpRequest(method, params, true).catch(() => undefined); + return; + } + const child = this.startChild(); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) }) + '\n'); + } + + private async httpRequest( + method: string, + params?: Record, + isNotification = false + ): Promise { + const config = this.config as HttpServerConfig; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(config.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...(config.headers ?? {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + ...(isNotification ? {} : { id: this.nextId++ }), + method, + ...(params ? { params } : {}), + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`MCP server "${this.name}" returned ${response.status} ${response.statusText}`); + } + if (isNotification || response.status === 202) return undefined; + + const text = await response.text(); + if (!text.trim()) return undefined; + + // An SSE response carries the JSON-RPC message in a `data:` line. + const body = text.includes('data:') + ? (text.split('\n').find(l => l.startsWith('data:'))?.slice(5).trim() ?? text) + : text; + + const message = JSON.parse(body) as JsonRpcResponse; + if (message.error) { + throw new Error(`${message.error.message} (code ${message.error.code})`); + } + return message.result; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error(`MCP server "${this.name}" did not answer ${method} within ${this.timeout}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } + } + + // ─── protocol ───────────────────────────────────────────────────────────── + + async connect(): Promise { + if (this.started) return; + await this.request('initialize', { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + clientInfo: { name: 'cude-code', version: '0.1.0' }, + }); + this.notify('notifications/initialized'); + this.started = true; + } + + async listTools(): Promise { + await this.connect(); + const result = (await this.request('tools/list')) as { tools?: McpToolInfo[] } | undefined; + return result?.tools ?? []; + } + + async callTool(name: string, args: Record): Promise<{ text: string; isError: boolean }> { + await this.connect(); + const result = (await this.request('tools/call', { name, arguments: args })) as + | { content?: Array<{ type: string; text?: string; [k: string]: unknown }>; isError?: boolean } + | undefined; + + const blocks = result?.content ?? []; + const text = blocks + .map(block => { + if (block.type === 'text') return block.text ?? ''; + // Keep non-text blocks legible rather than dropping them. + return `[${block.type}]`; + }) + .join('\n') + .trim(); + + return { text: text || '(no content)', isError: result?.isError === true }; + } + + async close(): Promise { + for (const [, waiter] of this.pending) { + clearTimeout(waiter.timer); + waiter.reject(new Error(`MCP server "${this.name}" was closed`)); + } + this.pending.clear(); + this.started = false; + + const child = this.child; + this.child = null; + if (!child) return; + + child.stdin.end(); + child.kill(); + // Do not let a server that ignores SIGTERM hold the CLI open. + await new Promise(resolve => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } +} diff --git a/src/mcp/registry.ts b/src/mcp/registry.ts new file mode 100644 index 0000000..f11b5e4 --- /dev/null +++ b/src/mcp/registry.ts @@ -0,0 +1,191 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { getDataDir } from '../config/index.js'; +import { McpClient, type McpServerConfig } from './client.js'; +import type { ToolDefinition } from '../providers/types.js'; + +/** + * Configured MCP servers, and the tools they contribute to the agent. + * + * Tool names are namespaced `mcp____` so a server cannot shadow a + * built-in tool, and so the model can see which server a capability came from. + */ + +export const MCP_PREFIX = 'mcp__'; +/** Between server and tool. A server name may therefore not contain `__`. */ +const SEPARATOR = '__'; + +export interface McpConfig { + mcpServers: Record; +} + +export function getMcpConfigPath(): string { + return join(getDataDir(), 'mcp.json'); +} + +export function loadMcpConfig(): McpConfig { + const path = getMcpConfigPath(); + if (!existsSync(path)) return { mcpServers: {} }; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial; + return { mcpServers: parsed.mcpServers ?? {} }; + } catch (err) { + throw new Error( + `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +export function saveMcpConfig(config: McpConfig): void { + const dir = getDataDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(getMcpConfigPath(), JSON.stringify(config, null, 2), 'utf-8'); +} + +export function qualifyToolName(server: string, tool: string): string { + return `${MCP_PREFIX}${server}${SEPARATOR}${tool}`; +} + +export function parseToolName(qualified: string): { server: string; tool: string } | null { + if (!qualified.startsWith(MCP_PREFIX)) return null; + const rest = qualified.slice(MCP_PREFIX.length); + const separator = rest.indexOf(SEPARATOR); + if (separator === -1) return null; + return { + server: rest.slice(0, separator), + tool: rest.slice(separator + SEPARATOR.length), + }; +} + +export function isMcpTool(name: string): boolean { + return name.startsWith(MCP_PREFIX); +} + +// ─── live state ───────────────────────────────────────────────────────────── + +const clients = new Map(); +let toolDefinitions: ToolDefinition[] = []; +let initialized = false; + +export interface McpInitResult { + connected: string[]; + failed: Array<{ server: string; reason: string }>; + tools: ToolDefinition[]; +} + +/** + * Connects every enabled server and collects their tools. Servers are + * independent: one that fails to start is reported and skipped rather than + * taking the run down with it. + */ +export async function initializeMcp(): Promise { + if (initialized) { + return { connected: [...clients.keys()], failed: [], tools: toolDefinitions }; + } + initialized = true; + + let config: McpConfig; + try { + config = loadMcpConfig(); + } catch (err) { + return { connected: [], failed: [{ server: 'mcp.json', reason: String(err) }], tools: [] }; + } + + const entries = Object.entries(config.mcpServers).filter(([, c]) => !c.disabled); + const connected: string[] = []; + const failed: McpInitResult['failed'] = []; + const definitions: ToolDefinition[] = []; + + const results = await Promise.all( + entries.map(async ([name, serverConfig]) => { + const client = new McpClient(name, serverConfig); + try { + const tools = await client.listTools(); + return { name, client, tools, error: null as string | null }; + } catch (err) { + await client.close().catch(() => undefined); + return { + name, + client: null, + tools: [], + error: err instanceof Error ? err.message : String(err), + }; + } + }) + ); + + for (const result of results) { + if (result.error || !result.client) { + failed.push({ server: result.name, reason: result.error ?? 'unknown error' }); + continue; + } + clients.set(result.name, result.client); + connected.push(result.name); + + for (const tool of result.tools) { + definitions.push({ + name: qualifyToolName(result.name, tool.name), + description: `[${result.name}] ${tool.description ?? tool.name}`, + parameters: (tool.inputSchema as Record) ?? { properties: {} }, + }); + } + } + + toolDefinitions = definitions; + return { connected, failed, tools: definitions }; +} + +export function getMcpToolDefinitions(): ToolDefinition[] { + return toolDefinitions; +} + +export async function executeMcpTool( + name: string, + args: Record +): Promise<{ success: boolean; output: string; error?: string }> { + const parsed = parseToolName(name); + if (!parsed) { + return { success: false, output: '', error: `Not an MCP tool name: ${name}` }; + } + + const client = clients.get(parsed.server); + if (!client) { + const known = [...clients.keys()]; + return { + success: false, + output: '', + error: + `MCP server "${parsed.server}" is not connected.` + + (known.length ? ` Connected: ${known.join(', ')}.` : ' No MCP servers are connected.'), + }; + } + + try { + const result = await client.callTool(parsed.tool, args); + if (result.isError) { + return { success: false, output: '', error: result.text }; + } + return { success: true, output: result.text }; + } catch (err) { + return { + success: false, + output: '', + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Stops every server. Without this a stdio child keeps the CLI alive. */ +export async function shutdownMcp(): Promise { + await Promise.all([...clients.values()].map(c => c.close().catch(() => undefined))); + clients.clear(); + toolDefinitions = []; + initialized = false; +} + +/** Test hook: forget cached connection state without touching disk. */ +export function resetMcpState(): void { + clients.clear(); + toolDefinitions = []; + initialized = false; +} diff --git a/src/ui/diff.ts b/src/ui/diff.ts new file mode 100644 index 0000000..d437437 --- /dev/null +++ b/src/ui/diff.ts @@ -0,0 +1,80 @@ +import chalk from 'chalk'; + +/** + * A compact line diff for the approval prompt. + * + * Trims the common prefix and suffix and shows what is left with a little + * context. That is not a minimal edit script — a change in the middle of a file + * shows as one block rather than several — but for "is this the edit I meant?" + * it is legible and, unlike an LCS implementation, cannot be slow on a large + * file. + */ + +export interface DiffSummary { + added: number; + removed: number; + text: string; +} + +const CONTEXT_LINES = 2; +const MAX_SHOWN = 40; + +export function renderDiff(before: string, after: string, maxLines = MAX_SHOWN): DiffSummary { + // An empty string splits to [''] — one blank line — which would report a new + // file as removing a line it never had. + const a = before === '' ? [] : before.split('\n'); + const b = after === '' ? [] : after.split('\n'); + + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + + let endA = a.length; + let endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { + endA--; + endB--; + } + + const removed = a.slice(start, endA); + const added = b.slice(start, endB); + + if (removed.length === 0 && added.length === 0) { + return { added: 0, removed: 0, text: chalk.dim(' (no change)') }; + } + + const lines: string[] = []; + const contextBefore = a.slice(Math.max(0, start - CONTEXT_LINES), start); + const contextAfter = a.slice(endA, Math.min(a.length, endA + CONTEXT_LINES)); + + for (const line of contextBefore) lines.push(chalk.dim(` ${line}`)); + + let shown = 0; + for (const line of removed) { + if (shown >= maxLines) break; + lines.push(chalk.red(` -${line}`)); + shown++; + } + for (const line of added) { + if (shown >= maxLines) break; + lines.push(chalk.green(` +${line}`)); + shown++; + } + + const hidden = removed.length + added.length - shown; + if (hidden > 0) { + lines.push(chalk.dim(` … ${hidden} more changed line${hidden !== 1 ? 's' : ''}`)); + } + + for (const line of contextAfter) lines.push(chalk.dim(` ${line}`)); + + return { + added: added.length, + removed: removed.length, + text: lines.join('\n'), + }; +} + +/** `+12 -3` style summary for a one-line status. */ +export function diffStat(summary: DiffSummary): string { + return chalk.green(`+${summary.added}`) + ' ' + chalk.red(`-${summary.removed}`); +} diff --git a/src/ui/display.ts b/src/ui/display.ts index 282e98f..bfdb4a0 100644 --- a/src/ui/display.ts +++ b/src/ui/display.ts @@ -11,25 +11,27 @@ marked.use(markedTerminal() as Parameters[0]); // Cude Code brand mark: hexagon opened on the right (forming a "C"), a ">" // chevron inside, and the split notch at the bottom vertex — the same geometry -// as assets/cude-mark.svg. Generated by tools/generate-logo.mjs into half-blocks. +// as assets/cude-mark.svg, reduced onto a quadrant-block grid by +// tools/generate-logo.mjs. Do not hand-edit: run `node tools/generate-logo.mjs +// --write` instead, so the mark and the artwork cannot drift apart. const LOGO_ART = ' ▗▄███▄▖\n' + - ' ▄▟█████████▙▄\n' + - ' ▗▄█████▀▘ ▝▀█████▄▖\n' + - ' ▄▟████▛▀ ▀▜████▙▄\n' + - ' ████▀▘ ▝▀████\n' + - ' ███ ▐█▙▄ ▀\n' + - ' ███ ▝▀███▙▄\n' + - ' ███ ▝▀███▙▄\n' + - ' ███ ▄████\n' + - ' ███ ▄▟███▀▘\n' + - ' ███ ▐███▀▘\n' + - ' ███▄ ▀▘ ▄▟█▄\n' + - ' ▀█████▄▖ ▗▄█████▀\n' + - ' ▀▜████▄▄ ▄▄████▛▀\n' + - ' ▝▀████▙▄ ▄▟████▀▘\n' + - ' ▝▀███ ███▀▘\n' + - ' ▜█▛ ▜█▛'; + ' ▄▄█████████▄▄\n' + + ' ▄▟████▛▀ ▀▜████▙▄\n' + + ' ▗▄█████▀▘ ▝▀█████▄▖\n' + + ' ▟███▛▀▘ ▝▀▜███▙\n' + + ' ███▘ ▗▄▄▖ ▀▀▘\n' + + ' ███ ▝████▄\n' + + ' ███ ▝▀███▙▄\n' + + ' ███ ▀████\n' + + ' ███ ▄▟███▛▘\n' + + ' ███ ▗▄███▛▀\n' + + ' ███ ▐██▀ ▄\n' + + ' ████▄▖ ▗▄████\n' + + ' ▝▀▜████▄▄ ▄▄████▛▀▘\n' + + ' ▀▜████▙▄ ▄▟████▛▀\n' + + ' ▝▀████▙ ▟████▀▘\n' + + ' ▝███ ███▘'; export function showBanner(): void { const gradient = gradientString('#ffffff', '#c7d2fe', '#6366f1'); diff --git a/test/brand.test.mjs b/test/brand.test.mjs new file mode 100644 index 0000000..10bc2eb --- /dev/null +++ b/test/brand.test.mjs @@ -0,0 +1,102 @@ +// The brand mark must not drift from the artwork. +// +// It did: the CLI's block art and the README screenshot both kept an older +// shape whose bottom notch was drawn with round caps, so the two legs bulged +// half a stroke-width below their end points and read as feet hanging off the +// mark. Nothing caught it, because nothing compared the rendered art to the +// SVG it is supposed to come from. + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = new URL('../', import.meta.url); +const read = (p) => readFileSync(fileURLToPath(new URL(p, root)), 'utf8'); + +const { renderBlockArt, loadMark, markBounds, makeInkTest } = + await import('../tools/mark-raster.mjs'); + +/** The LOGO_ART literal as the CLI will actually print it. */ +function logoArtFromSource() { + const source = read('src/ui/display.ts'); + const literal = source.match(/const LOGO_ART =\r?\n((?:.*\r?\n)*?.*?');/); + assert.ok(literal, 'could not find LOGO_ART in src/ui/display.ts'); + return literal[1] + .split(/\r?\n/) + .map((line) => line.trim().replace(/^'/, '').replace(/(\\n)?'\s*\+?;?$/, '')) + .filter((line) => line.length > 0); +} + +describe('brand mark', () => { + test('regression: the CLI art is what the artwork actually reduces to', () => { + // Run `node tools/generate-logo.mjs --write` after changing the SVG. + assert.deepEqual( + logoArtFromSource(), + renderBlockArt(), + 'src/ui/display.ts LOGO_ART is out of date with assets/cude-mark.svg' + ); + }); + + test('regression: the bottom notch has flat legs, not round feet', () => { + const svg = read('assets/cude-mark.svg'); + const legs = [...svg.matchAll(//g)]; + + assert.equal(legs.length, 2, 'expected exactly two bottom legs'); + for (const [, x, attrs] of legs) { + assert.match( + attrs, + /stroke-linecap="butt"/, + `leg at x=${x} must use a butt cap — a round one adds half the stroke ` + + 'width below the end point, which is what made the legs look like feet' + ); + } + }); + + test('the legs read as a split, not as protrusions', () => { + const strokes = loadMark(); + const [, , width, height] = markBounds(strokes); + + // Measured off the reference artwork: the legs are ~6% of the mark's + // height, and the slit between them ~2.5% of its width. + const legLength = (470 - 441) / height; + assert.ok(legLength > 0.04 && legLength < 0.09, `leg length is ${(legLength * 100).toFixed(1)}% of height`); + + const slit = (282 - 230 - 42) / width; + assert.ok(slit > 0.015 && slit < 0.04, `slit is ${(slit * 100).toFixed(1)}% of width`); + }); + + test('the mark is open on its right side — that is what makes it a "C"', () => { + const inked = makeInkTest(loadMark()); + // Mid-height on the right edge, between the two arm caps. + assert.equal(inked(429, 256), false, 'the right side should be open'); + assert.equal(inked(83, 256), true, 'the left side should be solid'); + }); + + test('every image the README points at exists', () => { + const readme = read('README.md'); + const referenced = [...readme.matchAll(/src="\.\/(assets\/[^"]+)"/g)].map((m) => m[1]); + + assert.ok(referenced.length > 0, 'expected the README to show the brand assets'); + for (const asset of referenced) { + assert.ok( + existsSync(fileURLToPath(new URL(asset, root))), + `README references ${asset}, which does not exist` + ); + } + }); + + test('regression: the terminal screenshot is generated from the same art', () => { + // It used to be a hand-captured PNG, so it kept showing the old mark after + // the artwork changed. + const svg = read('assets/cude-cli.svg'); + const art = logoArtFromSource(); + + const inkedCells = art.join('').replace(/ /g, '').length; + const rects = (svg.match(/= inkedCells, + `the screenshot draws ${rects} cells for ${inkedCells} inked characters — regenerate it with tools/generate-cli-shot.mjs` + ); + }); +}); diff --git a/test/checkpoints.test.mjs b/test/checkpoints.test.mjs new file mode 100644 index 0000000..766e777 --- /dev/null +++ b/test/checkpoints.test.mjs @@ -0,0 +1,160 @@ +// Checkpoints: agent file edits must be reversible. +// +// The workspace boundary (F5) stops the agent writing where it shouldn't. It +// does nothing about a wrong edit inside the boundary — that is what this is +// for. + +import { test, before, beforeEach, after, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startStubServer } from './helpers/openai-stub.mjs'; + +const home = mkdtempSync(join(tmpdir(), 'cude-home-')); +process.env.CUDE_HOME = home; + +const { + recordCheckpoint, + loadCheckpoint, + listCheckpoints, + restoreCheckpoint, + restoreRun, + clearCheckpoints, + MUTATING_TOOLS, +} = await import('../dist/core/checkpoints.js'); +const { executeTool, setWorkspaceRoot, resetWorkspaceRoot, setConfirmCallback, clearConfirmCallback } = + await import('../dist/core/tools.js'); +const { runAgent } = await import('../dist/core/agent.js'); +const { setApiKey } = await import('../dist/config/index.js'); + +let dir; +before(() => { + dir = mkdtempSync(join(tmpdir(), 'cude-ckpt-')); + setWorkspaceRoot(dir); + setConfirmCallback(async () => true); +}); +after(() => { + resetWorkspaceRoot(); + clearConfirmCallback(); + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); +}); + +beforeEach(() => clearCheckpoints()); + +describe('checkpoints capture the state before a change', () => { + test('an edit to an existing file is reversible', async () => { + const file = join(dir, 'edit-me.txt'); + writeFileSync(file, 'original content'); + + const checkpoint = recordCheckpoint('run1', 'a task', 'write_file', { path: file }); + await executeTool('write_file', { path: file, content: 'ruined' }); + assert.equal(readFileSync(file, 'utf-8'), 'ruined'); + + const result = restoreCheckpoint(checkpoint); + assert.deepEqual(result.restored, [file]); + assert.equal(readFileSync(file, 'utf-8'), 'original content'); + }); + + test('a file the agent created is removed on restore, not left behind', async () => { + const file = join(dir, 'brand-new.txt'); + const checkpoint = recordCheckpoint('run2', 'a task', 'write_file', { path: file }); + await executeTool('write_file', { path: file, content: 'new' }); + assert.equal(existsSync(file), true); + + const result = restoreCheckpoint(checkpoint); + assert.deepEqual(result.removed, [file]); + assert.equal(existsSync(file), false, 'a created file must not survive the undo'); + }); + + test('a delete is reversible', async () => { + const file = join(dir, 'delete-me.txt'); + writeFileSync(file, 'do not lose me'); + + const checkpoint = recordCheckpoint('run3', 'a task', 'delete_file', { path: file }); + await executeTool('delete_file', { path: file }); + assert.equal(existsSync(file), false); + + restoreCheckpoint(checkpoint); + assert.equal(readFileSync(file, 'utf-8'), 'do not lose me'); + }); + + test('move_file captures both ends', () => { + const source = join(dir, 'from.txt'); + const dest = join(dir, 'to.txt'); + writeFileSync(source, 'moving'); + + const checkpoint = recordCheckpoint('run4', 'a task', 'move_file', { source, destination: dest }); + assert.equal(checkpoint.files.length, 2, 'a move changes two paths'); + assert.ok(checkpoint.files.some(f => f.path === source && f.content === 'moving')); + assert.ok(checkpoint.files.some(f => f.path === dest && f.content === null)); + }); + + test('a read-only tool records nothing', () => { + assert.equal(recordCheckpoint('run5', 'a task', 'read_file', { path: 'x' }), null); + assert.equal(recordCheckpoint('run5', 'a task', 'grep_search', { pattern: 'x' }), null); + assert.equal(recordCheckpoint('run5', 'a task', 'run_command', { command: 'ls' }), null); + }); + + test('every mutating file tool is covered', async () => { + // A tool that changes files but is missing from MUTATING_TOOLS produces an + // unreversible edit, silently. + const { TOOL_DEFINITIONS } = await import('../dist/core/tools.js'); + const known = new Set(Object.keys(MUTATING_TOOLS)); + for (const name of ['write_file', 'replace_in_file', 'apply_patch', 'delete_file', 'move_file', 'copy_file']) { + assert.ok(known.has(name), `${name} changes files but is not checkpointed`); + assert.ok(TOOL_DEFINITIONS.some(t => t.name === name), `${name} is not a registered tool`); + } + }); +}); + +describe('checkpoints undo a whole agent run', () => { + test('restore-run puts back every file the run touched', async () => { + const a = join(dir, 'run-a.txt'); + const b = join(dir, 'run-b.txt'); + writeFileSync(a, 'A before'); + writeFileSync(b, 'B before'); + + const server = await startStubServer([ + { content: 'editing a', toolCalls: [{ name: 'write_file', arguments: { path: a, content: 'A after' } }] }, + { content: 'editing b', toolCalls: [{ name: 'write_file', arguments: { path: b, content: 'B after' } }] }, + { content: 'TASK COMPLETE: edited both' }, + ]); + + try { + setApiKey('vllm-endpoint', server.url); + const result = await runAgent({ + task: 'edit two files', + provider: 'vllm', + model: 'stub', + maxIterations: 4, + }); + + assert.equal(result.success, true); + assert.ok(result.runId, 'the run must expose an id to undo by'); + assert.equal(readFileSync(a, 'utf-8'), 'A after'); + assert.equal(readFileSync(b, 'utf-8'), 'B after'); + + const checkpoints = listCheckpoints().filter(c => c.runId === result.runId); + assert.equal(checkpoints.length, 2, 'one checkpoint per mutating call'); + + restoreRun(result.runId); + assert.equal(readFileSync(a, 'utf-8'), 'A before'); + assert.equal(readFileSync(b, 'utf-8'), 'B before'); + } finally { + await server.close(); + } + }); + + test('a checkpoint survives a reload from disk', () => { + const file = join(dir, 'persisted.txt'); + writeFileSync(file, 'persisted content'); + const checkpoint = recordCheckpoint('run6', 'a task', 'write_file', { path: file }); + + const reloaded = loadCheckpoint(checkpoint.id); + assert.equal(reloaded.files[0].content, 'persisted content'); + assert.equal(reloaded.toolName, 'write_file'); + }); +}); diff --git a/test/claw.test.mjs b/test/claw.test.mjs new file mode 100644 index 0000000..e9c747b --- /dev/null +++ b/test/claw.test.mjs @@ -0,0 +1,316 @@ +// Cude Claw: the interactive agent session. +// +// The session object is driven directly here — the REPL around it is a thin +// readline shell, but everything that decides what happens to the user's files +// lives in ClawSession and is testable without a terminal. + +import { test, before, beforeEach, after, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startStubServer } from './helpers/openai-stub.mjs'; + +const home = mkdtempSync(join(tmpdir(), 'cude-home-')); +process.env.CUDE_HOME = home; + +const { ClawSession } = await import('../dist/core/claw.js'); +const { setWorkspaceRoot, resetWorkspaceRoot, setConfirmCallback, clearConfirmCallback } = + await import('../dist/core/tools.js'); +const { setApiKey } = await import('../dist/config/index.js'); +const { listCheckpoints, restoreRun, clearCheckpoints } = await import('../dist/core/checkpoints.js'); +const { renderDiff } = await import('../dist/ui/diff.js'); + +let dir; +before(() => { + dir = mkdtempSync(join(tmpdir(), 'cude-claw-')); + setWorkspaceRoot(dir); + setConfirmCallback(async () => true); +}); +after(() => { + resetWorkspaceRoot(); + clearConfirmCallback(); + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); +}); +beforeEach(() => clearCheckpoints()); + +/** A session wired to a scripted stub server. */ +async function session(script, options = {}) { + const server = await startStubServer(script); + setApiKey('vllm-endpoint', server.url); + return { + server, + session: new ClawSession({ provider: 'vllm', model: 'stub', autoApprove: true, ...options }), + }; +} + +describe('claw keeps context between turns', () => { + test('the second turn sees the first', async () => { + const { server, session: claw } = await session([{ content: 'noted' }]); + try { + await claw.send('remember the number 41'); + await claw.send('what number?'); + + const second = server.sentMessages()[1]; + const userTurns = second.filter(m => m.role === 'user'); + assert.equal(userTurns.length, 2, 'the earlier turn must still be in context'); + assert.match(userTurns[0].content, /41/); + assert.equal(claw.turns, 2); + } finally { + await server.close(); + } + }); + + test('/clear forgets the conversation but keeps the cost tally', async () => { + const { server, session: claw } = await session([{ content: 'ok' }]); + try { + await claw.send('first'); + const costAfterFirst = claw.totalCost; + claw.clear(); + await claw.send('second'); + + const second = server.sentMessages()[1]; + assert.equal(second.filter(m => m.role === 'user').length, 1, 'the conversation was not cleared'); + assert.equal(claw.turns, 2, 'the turn count is a session tally, not a conversation one'); + assert.equal(claw.totalCost >= costAfterFirst, true); + } finally { + await server.close(); + } + }); + + test('@file mentions are expanded into the message', async () => { + const file = join(dir, 'mentioned.txt'); + writeFileSync(file, 'the contents of the mentioned file'); + + const { server, session: claw } = await session([{ content: 'read it' }]); + try { + await claw.send(`explain @${file}`); + const sent = server.sentMessages()[0].find(m => m.role === 'user'); + assert.match(sent.content, /the contents of the mentioned file/); + assert.match(sent.content, /Referenced files/); + } finally { + await server.close(); + } + }); + + test('a missing @file says so instead of failing the turn', async () => { + const { server, session: claw } = await session([{ content: 'ok' }]); + try { + const result = await claw.send('explain @definitely/not/here.txt'); + assert.equal(result.stopReason, 'completed'); + const sent = server.sentMessages()[0].find(m => m.role === 'user'); + assert.match(sent.content, /no such file/); + } finally { + await server.close(); + } + }); +}); + +describe('claw asks before it edits', () => { + const editCall = (path, content) => ({ name: 'write_file', arguments: { path, content } }); + + test('a declined edit is not applied, and the model is told', async () => { + const file = join(dir, 'declined.txt'); + writeFileSync(file, 'original'); + + const { server, session: claw } = await session( + [ + { content: 'editing', toolCalls: [editCall(file, 'replaced')] }, + { content: 'TASK COMPLETE: you declined' }, + ], + { autoApprove: false } + ); + + try { + await claw.send('change the file', { onApproval: async () => 'no' }); + + assert.equal(readFileSync(file, 'utf-8'), 'original', 'a declined edit was applied anyway'); + const toolMessage = server.sentMessages()[1].find(m => m.role === 'tool'); + assert.match(toolMessage.content, /declined/i); + assert.match(toolMessage.content, /Do not retry/i, 'the model needs to know not to loop on it'); + } finally { + await server.close(); + } + }); + + test('an approved edit is applied', async () => { + const file = join(dir, 'approved.txt'); + writeFileSync(file, 'original'); + + const { server, session: claw } = await session( + [ + { content: 'editing', toolCalls: [editCall(file, 'replaced')] }, + { content: 'TASK COMPLETE: done' }, + ], + { autoApprove: false } + ); + + try { + await claw.send('change the file', { onApproval: async () => 'yes' }); + assert.equal(readFileSync(file, 'utf-8'), 'replaced'); + } finally { + await server.close(); + } + }); + + test('"always" stops asking for that tool', async () => { + const a = join(dir, 'always-a.txt'); + const b = join(dir, 'always-b.txt'); + + const { server, session: claw } = await session( + [ + { content: 'first', toolCalls: [editCall(a, 'A')] }, + { content: 'second', toolCalls: [editCall(b, 'B')] }, + { content: 'TASK COMPLETE: both written' }, + ], + { autoApprove: false } + ); + + try { + let asked = 0; + await claw.send('write two files', { + onApproval: async () => { + asked++; + return 'always'; + }, + }); + + assert.equal(asked, 1, 'it kept asking after "always"'); + assert.equal(readFileSync(a, 'utf-8'), 'A'); + assert.equal(readFileSync(b, 'utf-8'), 'B'); + } finally { + await server.close(); + } + }); + + test('stopping mid-turn still answers every tool call the model made', async () => { + // Leaving a tool_call unanswered makes the next request malformed, which + // the F2 invariant would reject. + const file = join(dir, 'aborted.txt'); + + const { server, session: claw } = await session( + [{ content: 'editing', toolCalls: [editCall(file, 'nope')] }], + { autoApprove: false } + ); + + try { + const result = await claw.send('change it', { onApproval: async () => 'abort' }); + + assert.equal(result.stopReason, 'aborted'); + assert.equal(existsSync(file), false, 'the edit happened despite the abort'); + + const { validateTurnSequence } = await import('../dist/providers/wire.js'); + assert.equal( + validateTurnSequence(claw.messages), + null, + 'the conversation was left malformed after aborting' + ); + } finally { + await server.close(); + } + }); + + test('a read-only tool is not put through the approval prompt', async () => { + const { server, session: claw } = await session( + [ + { content: 'reading', toolCalls: [{ name: 'read_file', arguments: { path: 'package.json' } }] }, + { content: 'TASK COMPLETE: read it' }, + ], + { autoApprove: false } + ); + + try { + let asked = 0; + await claw.send('read the manifest', { onApproval: async () => { asked++; return 'yes'; } }); + assert.equal(asked, 0, 'reads should not need approval'); + } finally { + await server.close(); + } + }); +}); + +describe('claw records and undoes its own edits', () => { + test('every applied edit is checkpointed under the session id', async () => { + const file = join(dir, 'undoable.txt'); + writeFileSync(file, 'before claw'); + + const { server, session: claw } = await session([ + { content: 'editing', toolCalls: [{ name: 'write_file', arguments: { path: file, content: 'after claw' } }] }, + { content: 'TASK COMPLETE: done' }, + ]); + + try { + await claw.send('edit it'); + assert.equal(readFileSync(file, 'utf-8'), 'after claw'); + + const mine = listCheckpoints().filter(c => c.runId === claw.runId); + assert.equal(mine.length, 1); + + restoreRun(claw.runId); + assert.equal(readFileSync(file, 'utf-8'), 'before claw'); + } finally { + await server.close(); + } + }); +}); + +describe('claw respects modes mid-session', () => { + test('switching to ask mode takes the write tools away', async () => { + const { server, session: claw } = await session([{ content: 'ok' }]); + try { + claw.setMode('ask'); + assert.equal(claw.mode.name, 'ask'); + + const file = join(dir, 'not-in-ask-mode.txt'); + const scripted = await startStubServer([ + { content: 'trying', toolCalls: [{ name: 'write_file', arguments: { path: file, content: 'x' } }] }, + { content: 'TASK COMPLETE: refused' }, + ]); + try { + setApiKey('vllm-endpoint', scripted.url); + const asker = new ClawSession({ provider: 'vllm', model: 'stub', mode: 'ask', autoApprove: true }); + await asker.send('write a file'); + + const toolMessage = scripted.sentMessages()[1].find(m => m.role === 'tool'); + assert.match(toolMessage.content, /not available in Ask mode/); + assert.equal(existsSync(file), false); + } finally { + await scripted.close(); + } + } finally { + await server.close(); + } + }); +}); + +describe('diff rendering for the approval prompt', () => { + test('a changed line shows as a removal and an addition', () => { + const diff = renderDiff('one\ntwo\nthree\n', 'one\nTWO\nthree\n'); + assert.equal(diff.added, 1); + assert.equal(diff.removed, 1); + assert.match(diff.text, /-two/); + assert.match(diff.text, /\+TWO/); + }); + + test('a new file is all additions', () => { + const diff = renderDiff('', 'line one\nline two'); + assert.equal(diff.removed, 0); + assert.equal(diff.added, 2); + }); + + test('identical content reports no change', () => { + const diff = renderDiff('same\ncontent', 'same\ncontent'); + assert.equal(diff.added, 0); + assert.equal(diff.removed, 0); + assert.match(diff.text, /no change/); + }); + + test('a very large change is capped rather than flooding the terminal', () => { + const big = Array.from({ length: 500 }, (_, i) => `line ${i}`).join('\n'); + const diff = renderDiff('', big, 10); + assert.ok(diff.text.split('\n').length < 20, 'the preview was not capped'); + assert.match(diff.text, /more changed lines/); + }); +}); diff --git a/test/helpers/mcp-stub-server.mjs b/test/helpers/mcp-stub-server.mjs new file mode 100644 index 0000000..d7905d2 --- /dev/null +++ b/test/helpers/mcp-stub-server.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// A minimal MCP server over stdio, used to test the client against the actual +// protocol rather than a mock of it. Speaks newline-delimited JSON-RPC. +// +// Tools: `echo` (returns what it was given) and `explode` (returns isError). + +import { createInterface } from 'node:readline'; + +const TOOLS = [ + { + name: 'echo', + description: 'Return the message it was given', + inputSchema: { + type: 'object', + properties: { message: { type: 'string', description: 'Text to echo' } }, + required: ['message'], + }, + }, + { + name: 'explode', + description: 'Always fails, for testing error propagation', + inputSchema: { type: 'object', properties: {} }, + }, +]; + +function send(message) { + process.stdout.write(JSON.stringify(message) + '\n'); +} + +const rl = createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + const text = line.trim(); + if (!text) return; + + let request; + try { + request = JSON.parse(text); + } catch { + return; + } + + // Notifications carry no id and expect no reply. + if (request.id === undefined) return; + + const reply = (result) => send({ jsonrpc: '2.0', id: request.id, result }); + const fail = (code, message) => send({ jsonrpc: '2.0', id: request.id, error: { code, message } }); + + switch (request.method) { + case 'initialize': + reply({ + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'cude-test-server', version: '1.0.0' }, + }); + return; + + case 'tools/list': + reply({ tools: TOOLS }); + return; + + case 'tools/call': { + const { name, arguments: args = {} } = request.params ?? {}; + if (name === 'echo') { + reply({ content: [{ type: 'text', text: `echo: ${args.message ?? ''}` }] }); + return; + } + if (name === 'explode') { + reply({ content: [{ type: 'text', text: 'the tool refused' }], isError: true }); + return; + } + fail(-32602, `Unknown tool: ${name}`); + return; + } + + default: + fail(-32601, `Method not found: ${request.method}`); + } +}); diff --git a/test/mcp.test.mjs b/test/mcp.test.mjs new file mode 100644 index 0000000..b187adb --- /dev/null +++ b/test/mcp.test.mjs @@ -0,0 +1,234 @@ +// MCP client and registry. +// +// Driven against test/helpers/mcp-stub-server.mjs, a real stdio MCP server, so +// these exercise the protocol rather than a mock of it. + +import { test, before, after, beforeEach, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { startStubServer } from './helpers/openai-stub.mjs'; + +const home = mkdtempSync(join(tmpdir(), 'cude-home-')); +process.env.CUDE_HOME = home; + +const { McpClient } = await import('../dist/mcp/client.js'); +const { + loadMcpConfig, + saveMcpConfig, + initializeMcp, + shutdownMcp, + resetMcpState, + executeMcpTool, + qualifyToolName, + parseToolName, + isMcpTool, + getMcpToolDefinitions, +} = await import('../dist/mcp/registry.js'); +const { executeTool } = await import('../dist/core/tools.js'); +const { getMode, toolsForMode, isToolAllowed } = await import('../dist/core/modes.js'); +const { runAgent } = await import('../dist/core/agent.js'); +const { setApiKey } = await import('../dist/config/index.js'); + +const STUB_SERVER = fileURLToPath(new URL('./helpers/mcp-stub-server.mjs', import.meta.url)); +const stdioConfig = { command: process.execPath, args: [STUB_SERVER] }; + +after(() => rmSync(home, { recursive: true, force: true })); +beforeEach(async () => { + await shutdownMcp(); + resetMcpState(); +}); + +describe('MCP client speaks the protocol', () => { + test('lists the tools a server offers', async () => { + const client = new McpClient('test', stdioConfig); + try { + const tools = await client.listTools(); + assert.deepEqual(tools.map(t => t.name).sort(), ['echo', 'explode']); + assert.equal(tools.find(t => t.name === 'echo').inputSchema.required[0], 'message'); + } finally { + await client.close(); + } + }); + + test('calls a tool and returns its text content', async () => { + const client = new McpClient('test', stdioConfig); + try { + const result = await client.callTool('echo', { message: 'hello' }); + assert.equal(result.text, 'echo: hello'); + assert.equal(result.isError, false); + } finally { + await client.close(); + } + }); + + test('a tool that reports isError is surfaced as a failure', async () => { + const client = new McpClient('test', stdioConfig); + try { + const result = await client.callTool('explode', {}); + assert.equal(result.isError, true); + assert.match(result.text, /refused/); + } finally { + await client.close(); + } + }); + + test('a JSON-RPC error becomes a thrown error, not a silent empty result', async () => { + const client = new McpClient('test', stdioConfig); + try { + await assert.rejects(() => client.callTool('no_such_tool', {}), /Unknown tool/); + } finally { + await client.close(); + } + }); + + test('a server that cannot start reports why', async () => { + const client = new McpClient('broken', { command: 'definitely-not-a-real-binary-xyz' }); + try { + await assert.rejects(() => client.listTools(), /broken/); + } finally { + await client.close(); + } + }); + + test('a server that never answers times out rather than hanging', async () => { + // `node -e ""` exits immediately without speaking the protocol. + const client = new McpClient('silent', { command: process.execPath, args: ['-e', 'setTimeout(()=>{},5000)'], timeout: 300 }); + try { + await assert.rejects(() => client.listTools(), /within 300ms/); + } finally { + await client.close(); + } + }); +}); + +describe('MCP registry namespaces and dispatches tools', () => { + test('tool names are namespaced so a server cannot shadow a built-in', () => { + const qualified = qualifyToolName('files', 'read_file'); + assert.notEqual(qualified, 'read_file'); + assert.equal(isMcpTool(qualified), true); + assert.equal(isMcpTool('read_file'), false); + assert.deepEqual(parseToolName(qualified), { server: 'files', tool: 'read_file' }); + }); + + test('configured servers contribute tools to the agent', async () => { + saveMcpConfig({ mcpServers: { probe: stdioConfig } }); + const result = await initializeMcp(); + + assert.deepEqual(result.connected, ['probe']); + assert.deepEqual(result.failed, []); + assert.ok(result.tools.some(t => t.name === 'mcp__probe__echo')); + assert.ok( + result.tools.find(t => t.name === 'mcp__probe__echo').description.includes('probe'), + 'the description should name the server it came from' + ); + }); + + test('executeTool routes a namespaced call to the right server', async () => { + saveMcpConfig({ mcpServers: { probe: stdioConfig } }); + await initializeMcp(); + + const result = await executeTool('mcp__probe__echo', { message: 'through the dispatcher' }); + assert.equal(result.success, true); + assert.match(result.output, /through the dispatcher/); + }); + + test('a failing MCP tool comes back as a tool failure, not a crash', async () => { + saveMcpConfig({ mcpServers: { probe: stdioConfig } }); + await initializeMcp(); + + const result = await executeTool('mcp__probe__explode', {}); + assert.equal(result.success, false); + assert.match(result.error, /refused/); + }); + + test('one broken server does not stop the others', async () => { + saveMcpConfig({ + mcpServers: { + good: stdioConfig, + bad: { command: 'definitely-not-a-real-binary-xyz' }, + }, + }); + const result = await initializeMcp(); + + assert.deepEqual(result.connected, ['good']); + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].server, 'bad'); + assert.ok(result.tools.some(t => t.name === 'mcp__good__echo'), 'the working server still contributed'); + }); + + test('a disabled server is skipped', async () => { + saveMcpConfig({ mcpServers: { probe: { ...stdioConfig, disabled: true } } }); + const result = await initializeMcp(); + assert.deepEqual(result.connected, []); + assert.deepEqual(result.tools, []); + }); + + test('an unconfigured MCP tool call explains itself', async () => { + const result = await executeMcpTool('mcp__ghost__thing', {}); + assert.equal(result.success, false); + assert.match(result.error, /not connected/); + }); + + test('malformed mcp.json is reported, not swallowed', () => { + writeFileSync(join(home, 'mcp.json'), '{ not json'); + assert.throws(() => loadMcpConfig(), /not valid JSON/); + saveMcpConfig({ mcpServers: {} }); + }); +}); + +describe('MCP tools respect mode budgets', () => { + test('read-only modes are not handed arbitrary external tools', async () => { + saveMcpConfig({ mcpServers: { probe: stdioConfig } }); + await initializeMcp(); + + const ask = getMode('ask'); + const code = getMode('code'); + + assert.equal(isToolAllowed(ask, 'mcp__probe__echo'), false, 'ask mode must not expose MCP tools'); + assert.equal(isToolAllowed(code, 'mcp__probe__echo'), true); + assert.ok(!toolsForMode(ask).some(t => t.name.startsWith('mcp__'))); + assert.ok(toolsForMode(code).some(t => t.name.startsWith('mcp__'))); + }); + + test('the agent offers MCP tools to the model and can call them', async () => { + saveMcpConfig({ mcpServers: { probe: stdioConfig } }); + + const server = await startStubServer([ + { content: 'using the server', toolCalls: [{ name: 'mcp__probe__echo', arguments: { message: 'from the agent' } }] }, + { content: 'TASK COMPLETE: used an MCP tool' }, + ]); + + try { + setApiKey('vllm-endpoint', server.url); + const result = await runAgent({ task: 'use the echo tool', provider: 'vllm', model: 'stub', maxIterations: 3 }); + + assert.equal(result.success, true); + + const offered = server.requests[0].body.tools.map(t => t.function.name); + assert.ok(offered.includes('mcp__probe__echo'), 'the MCP tool was not offered to the model'); + + const toolMessage = server.sentMessages()[1].find(m => m.role === 'tool'); + assert.match(toolMessage.content, /echo: from the agent/); + } finally { + await server.close(); + } + }); + + test('MCP servers are shut down when the run ends', async () => { + saveMcpConfig({ mcpServers: { probe: stdioConfig } }); + const server = await startStubServer([{ content: 'TASK COMPLETE: nothing to do' }]); + try { + setApiKey('vllm-endpoint', server.url); + await runAgent({ task: 'do nothing', provider: 'vllm', model: 'stub', maxIterations: 2 }); + // A leaked stdio child would keep the process alive after the test file + // finishes; the registry must have released them. + assert.deepEqual(getMcpToolDefinitions(), []); + } finally { + await server.close(); + } + }); +}); diff --git a/test/modes.test.mjs b/test/modes.test.mjs new file mode 100644 index 0000000..5538072 --- /dev/null +++ b/test/modes.test.mjs @@ -0,0 +1,138 @@ +// Agent modes and project rules. +// +// The point of a mode is its tool budget, so these tests care most about what +// each mode is *prevented* from doing — a restriction that exists only in the +// system prompt is not a restriction. + +import { test, before, after, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startStubServer } from './helpers/openai-stub.mjs'; + +const home = mkdtempSync(join(tmpdir(), 'cude-home-')); +process.env.CUDE_HOME = home; + +const { getMode, listModes, toolsForMode, checkToolCall, isToolAllowed } = + await import('../dist/core/modes.js'); +const { findRuleFiles, buildRulesPrompt } = await import('../dist/core/rules.js'); +const { setWorkspaceRoot, resetWorkspaceRoot } = await import('../dist/core/tools.js'); +const { runAgent, buildSystemPrompt } = await import('../dist/core/agent.js'); +const { setApiKey } = await import('../dist/config/index.js'); + +let dir; +before(() => { + dir = mkdtempSync(join(tmpdir(), 'cude-modes-')); + setWorkspaceRoot(dir); +}); +after(() => { + resetWorkspaceRoot(); + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); +}); + +describe('modes: tool budgets are enforced, not just described', () => { + test('ask mode cannot reach any mutating tool', () => { + const ask = getMode('ask'); + for (const tool of ['write_file', 'delete_file', 'run_command', 'apply_patch', 'move_file', 'npm_command']) { + assert.equal(isToolAllowed(ask, tool), false, `ask mode should not allow ${tool}`); + assert.match(checkToolCall(ask, tool, { path: 'x.ts' }), /not available in Ask mode/); + } + assert.equal(checkToolCall(ask, 'read_file', { path: 'x.ts' }), null); + }); + + test('the restricted tool list is what the model is shown', () => { + const askTools = toolsForMode(getMode('ask')).map(t => t.name); + assert.ok(!askTools.includes('write_file')); + assert.ok(askTools.includes('read_file')); + assert.ok(toolsForMode(getMode('code')).length > askTools.length); + }); + + test('architect mode writes Markdown and refuses source files', () => { + const architect = getMode('architect'); + assert.equal(checkToolCall(architect, 'write_file', { path: 'docs/plan.md' }), null); + assert.match( + checkToolCall(architect, 'write_file', { path: 'src/index.ts' }), + /may only write files matching/ + ); + // Every other route to a source file is closed off by the tool budget. + for (const tool of ['replace_in_file', 'apply_patch', 'move_file', 'delete_file', 'run_command']) { + assert.ok( + checkToolCall(architect, tool, { path: 'src/index.ts', destination: 'src/index.ts' }), + `architect mode should refuse ${tool}` + ); + } + }); + + test('every mode declares a prompt, a description and a tool budget', () => { + for (const mode of listModes()) { + assert.ok(mode.systemPrompt.length > 40, `${mode.name}: thin system prompt`); + assert.ok(mode.description, `${mode.name}: missing description`); + assert.ok(toolsForMode(mode).length > 0, `${mode.name}: no tools at all`); + } + }); + + test('an unknown mode names the valid ones', () => { + assert.throws(() => getMode('nonsense'), /Unknown mode.*code/s); + }); + + test('regression: a blocked call is refused at execution, not just omitted', async () => { + // A model can ask for a tool that was never offered to it. Ask mode must + // refuse the call rather than run it. + const server = await startStubServer([ + { content: 'writing', toolCalls: [{ name: 'write_file', arguments: { path: join(dir, 'sneaky.txt'), content: 'x' } }] }, + { content: 'TASK COMPLETE: could not write' }, + ]); + try { + setApiKey('vllm-endpoint', server.url); + await runAgent({ task: 'try to write', provider: 'vllm', model: 'stub', mode: 'ask', maxIterations: 2 }); + + const toolMessage = server.sentMessages()[1].find(m => m.role === 'tool'); + assert.match(toolMessage.content, /not available in Ask mode/); + assert.equal( + (await import('node:fs')).existsSync(join(dir, 'sneaky.txt')), + false, + 'ask mode wrote a file' + ); + } finally { + await server.close(); + } + }); +}); + +describe('rules: repository instructions reach the system prompt', () => { + test('AGENTS.md is discovered and included', () => { + writeFileSync(join(dir, 'AGENTS.md'), '# House rules\nAlways use tabs.'); + const files = findRuleFiles(dir); + assert.ok(files.some(f => f.path.endsWith('AGENTS.md')), 'AGENTS.md was not found'); + assert.match(buildRulesPrompt(findRuleFiles(dir)), /Always use tabs/); + }); + + test('.cude/rules/*.md files are included in sorted order', () => { + mkdirSync(join(dir, '.cude', 'rules'), { recursive: true }); + writeFileSync(join(dir, '.cude', 'rules', '20-second.md'), 'second rule'); + writeFileSync(join(dir, '.cude', 'rules', '10-first.md'), 'first rule'); + + const prompt = buildRulesPrompt(findRuleFiles(dir)); + assert.ok(prompt.indexOf('first rule') < prompt.indexOf('second rule'), 'rules are not sorted'); + }); + + test('rules are appended to the agent system prompt', () => { + const prompt = buildSystemPrompt(getMode('code')); + assert.match(prompt, /Project rules/); + assert.match(prompt, /Always use tabs/); + assert.match(prompt, /You are in Code mode/, 'the mode prompt must still be there'); + }); + + test('no rule files means no rules block', () => { + const empty = mkdtempSync(join(tmpdir(), 'cude-norules-')); + try { + // A parent directory could still carry rules; assert on the explicit list. + assert.equal(buildRulesPrompt([]), ''); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/generate-cli-shot.mjs b/tools/generate-cli-shot.mjs new file mode 100644 index 0000000..98e2858 --- /dev/null +++ b/tools/generate-cli-shot.mjs @@ -0,0 +1,137 @@ +// Renders assets/cude-cli.svg from the CLI's actual banner output. +// +// The screenshot in the README used to be captured by hand, so when the brand +// mark changed the README kept showing the old one, feet and all. Generating it +// from the same LOGO_ART the CLI prints means the two cannot drift apart. +// +// Usage: node tools/generate-cli-shot.mjs [--write] + +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const displayPath = fileURLToPath(new URL('../src/ui/display.ts', import.meta.url)); +const source = readFileSync(displayPath, 'utf8'); + +const literal = source.match(/const LOGO_ART =\r?\n((?:.*\r?\n)*?.*?');/); +if (!literal) { + console.error('could not read LOGO_ART from src/ui/display.ts'); + process.exit(1); +} + +const art = literal[1] + .split(/\r?\n/) + .map((line) => line.trim().replace(/^'/, '').replace(/(\\n)?'\s*\+?;?$/, '')) + .filter((line) => line.length > 0); + +const version = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8') +).version; + +// The art is drawn as rectangles, not text. Quadrant block characters +// (U+2580..U+259F) are absent from some monospace fonts, and a viewer without +// them would see the mark collapse into tofu boxes; rectangles render anywhere. +// bit 0 = upper-left, 1 = upper-right, 2 = lower-left, 3 = lower-right +const QUADRANT_BITS = { + ' ': 0b0000, '▘': 0b0001, '▝': 0b0010, '▀': 0b0011, + '▖': 0b0100, '▌': 0b0101, '▞': 0b0110, '▛': 0b0111, + '▗': 0b1000, '▚': 0b1001, '▐': 0b1010, '▜': 0b1011, + '▄': 0b1100, '▙': 0b1101, '▟': 0b1110, '█': 0b1111, +}; + +// Layout +const CHAR_W = 13.2; +const LINE_H = 25; +const PAD_X = 48; +const PAD_Y = 34; +const TITLEBAR = 52; +const COLS = Math.max(...art.map((l) => l.length), 52); +const WIDTH = Math.round(PAD_X * 2 + COLS * CHAR_W); +const HEIGHT = Math.round(TITLEBAR + PAD_Y * 2 + (2 + art.length + 4) * LINE_H); + +const MONO = "ui-monospace,'SF Mono','Cascadia Mono','JetBrains Mono',Menlo,Consolas,monospace"; + +let y = TITLEBAR + PAD_Y + LINE_H; +const rows = []; + +rows.push( + ` ` + + `cude chat` +); +y += LINE_H * 2; + +// The mark +const artTop = y - LINE_H * 0.8; +const subW = CHAR_W / 2; +const subH = LINE_H / 2; +const marks = []; + +for (let r = 0; r < art.length; r++) { + for (let c = 0; c < art[r].length; c++) { + const bits = QUADRANT_BITS[art[r][c]]; + if (!bits) continue; + for (let q = 0; q < 4; q++) { + if (!(bits & (1 << q))) continue; + const x = PAD_X + c * CHAR_W + (q % 2) * subW; + const cellY = artTop + r * LINE_H + (q >> 1) * subH; + // The half-pixel overhang closes the hairline seams between sub-cells. + marks.push( + ` ` + ); + } + } +} +y += LINE_H * art.length; + +y += LINE_H * 0.5; +rows.push( + ` CUDE CODE` + + `v${version}` +); +y += LINE_H; +rows.push( + ` YAZ. ANLA. ` + + `ÜRET.` +); +y += LINE_H; +rows.push( + ` 19 providers · 22 tools · 9 task types · browser · RAG` +); + +const svg = [ + ``, + ' cude chat', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` cude-code — zsh`, + '', + ' ', + marks.join('\n'), + ' ', + '', + ` `, + rows.join('\n'), + ' ', + '', + '', +].join('\n'); + +if (process.argv.includes('--write')) { + const out = fileURLToPath(new URL('../assets/cude-cli.svg', import.meta.url)); + writeFileSync(out, svg, 'utf8'); + console.error(`wrote assets/cude-cli.svg (${WIDTH}x${HEIGHT})`); +} else { + process.stdout.write(svg); +} diff --git a/tools/generate-logo.mjs b/tools/generate-logo.mjs index 4aeb357..3d36cf4 100644 --- a/tools/generate-logo.mjs +++ b/tools/generate-logo.mjs @@ -1,6 +1,5 @@ // Generates the CLI block-art brand mark straight from assets/cude-mark.svg — -// no redrawing, no simplified variant. The SVG is rendered in Chromium and its -// alpha channel is box-filtered down onto a quadrant grid. +// no redrawing, no simplified variant. // // Quadrant blocks (▘▝▖▗▚▞▛▜▙▟▌▐▀▄█) split each character cell into 2x2, which // is what makes a faithful reduction possible: half-blocks give two sub-rows @@ -8,113 +7,36 @@ // about 0.7 of a cell — could not be drawn without widening it. At 2x // horizontal resolution it lands on ~1.5 sub-cells and survives as-is. // +// The mark is three stroked polylines with round joins, so its inked region is +// a closed form (see tools/mark-raster.mjs) and is sampled directly. It used to +// be rendered in Chromium and read back from the alpha channel, which meant +// this tool could not run wherever Playwright's 150MB browser was absent — CI +// skips that download deliberately, so the art could not be regenerated there. +// // Usage: node tools/generate-logo.mjs [--write] // --write patches LOGO_ART in src/ui/display.ts in place -import { launchChromium } from './chromium.mjs'; import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { renderBlockArt } from './mark-raster.mjs'; -const COLS = 29; // character columns -const ROWS = 17; // character rows -const SS = 10; // supersampling factor -const THRESHOLD = 0.45; // ink coverage needed to light a sub-cell - -// Each cell is 2x2 sub-cells. -const PX_W = COLS * 2; -const PX_H = ROWS * 2; - -// Bounding box of the mark's strokes in the SVG's own coordinates: the paths -// span x 83..429 and y 56..470, plus half of the 42-unit stroke on every side. -const VIEWBOX = '62 35 388 456'; - -const svgPath = fileURLToPath(new URL('../assets/cude-mark.svg', import.meta.url)); -const svg = readFileSync(svgPath, 'utf8') - .replace(/viewBox="[^"]*"/, `viewBox="${VIEWBOX}"`) - .replace(/\swidth="[^"]*"/, '') - .replace(/\sheight="[^"]*"/, ''); - -const browser = await launchChromium(); -const page = await browser.newPage({ viewport: { width: 400, height: 400 } }); - -await page.setContent( - ` - - ` -); -await page.waitForFunction('document.getElementById("m").complete'); - -const coverage = await page.evaluate(` - (() => { - const img = document.getElementById('m'); - const W = ${PX_W}, H = ${PX_H}, SS = ${SS}; - const c = document.createElement('canvas'); - c.width = W * SS; c.height = H * SS; - const ctx = c.getContext('2d'); - ctx.drawImage(img, 0, 0, W * SS, H * SS); - const d = ctx.getImageData(0, 0, W * SS, H * SS).data; - const out = []; - for (let y = 0; y < H; y++) { - const row = []; - for (let x = 0; x < W; x++) { - let sum = 0; - for (let j = 0; j < SS; j++) { - for (let i = 0; i < SS; i++) { - const px = (x * SS + i) + (y * SS + j) * W * SS; - sum += d[px * 4 + 3]; // alpha — the mark is white on transparent - } - } - row.push(sum / (SS * SS * 255)); - } - out.push(row); - } - return out; - })() -`); - -await browser.close(); - -// bit 1 = upper-left, 2 = upper-right, 4 = lower-left, 8 = lower-right -const QUADRANT = [ - ' ', '▘', '▝', '▀', - '▖', '▌', '▞', '▛', - '▗', '▚', '▐', '▜', - '▄', '▙', '▟', '█', -]; - -const on = (y, x) => ((coverage[y]?.[x] ?? 0) >= THRESHOLD ? 1 : 0); - -const lines = []; -for (let r = 0; r < ROWS; r++) { - let s = ''; - for (let c = 0; c < COLS; c++) { - const mask = - on(r * 2, c * 2) * 1 + - on(r * 2, c * 2 + 1) * 2 + - on(r * 2 + 1, c * 2) * 4 + - on(r * 2 + 1, c * 2 + 1) * 8; - s += QUADRANT[mask]; - } - lines.push(s.replace(/\s+$/, '')); -} -while (lines.length && lines[0] === '') lines.shift(); -while (lines.length && lines.at(-1) === '') lines.pop(); - -const indent = Math.min(...lines.filter(Boolean).map((l) => l.match(/^ */)[0].length)); -const art = lines.map((l) => (l ? ' ' + l.slice(indent) : '')); +const art = renderBlockArt(); console.log(art.join('\n')); if (process.argv.includes('--write')) { const target = fileURLToPath(new URL('../src/ui/display.ts', import.meta.url)); + const src = readFileSync(target, 'utf8'); + // A Windows checkout has CRLF line endings, so anchoring on "\n" alone never + // matched and --write silently reported that it could not find the literal. + const eol = src.includes('\r\n') ? '\r\n' : '\n'; + const literal = art .map((l, i) => ` '${l.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}${i === art.length - 1 ? "'" : "\\n' +"}`) - .join('\n'); - const src = readFileSync(target, 'utf8'); + .join(eol); const patched = src.replace( - /const LOGO_ART =\n(?:.*\n)*?.*?';\n/, - `const LOGO_ART =\n${literal};\n` + /const LOGO_ART =\r?\n(?:.*\r?\n)*?.*?';\r?\n/, + `const LOGO_ART =${eol}${literal};${eol}` ); if (patched === src) { console.error('\ncould not locate LOGO_ART in display.ts'); diff --git a/tools/mark-preview.mjs b/tools/mark-preview.mjs new file mode 100644 index 0000000..1b7f7e1 --- /dev/null +++ b/tools/mark-preview.mjs @@ -0,0 +1,75 @@ +// Renders assets/cude-mark.svg to a PNG so the mark can be eyeballed against +// the reference artwork without a browser. +// +// Usage: node tools/mark-preview.mjs [out.png] [size] + +import { deflateSync } from 'node:zlib'; +import { writeFileSync } from 'node:fs'; +import { loadMark, makeInkTest } from './mark-raster.mjs'; + +const out = process.argv[2] ?? 'mark-preview.png'; +const size = Number(process.argv[3] ?? 512); +const SS = 3; // supersampling, for smooth edges + +const VIEWBOX = [0, 0, 512, 512]; + +const inked = makeInkTest(loadMark()); + +// Greyscale, one byte per pixel, filter byte 0 per row. +const raw = Buffer.alloc((size + 1) * size); +for (let y = 0; y < size; y++) { + raw[y * (size + 1)] = 0; + for (let x = 0; x < size; x++) { + let hits = 0; + for (let j = 0; j < SS; j++) { + const sy = VIEWBOX[1] + ((y + (j + 0.5) / SS) / size) * VIEWBOX[3]; + for (let i = 0; i < SS; i++) { + const sx = VIEWBOX[0] + ((x + (i + 0.5) / SS) / size) * VIEWBOX[2]; + if (inked(sx, sy)) hits++; + } + } + raw[y * (size + 1) + 1 + x] = Math.round((hits / (SS * SS)) * 255); + } +} + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} + +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(size, 0); +ihdr.writeUInt32BE(size, 4); +ihdr[8] = 8; // bit depth +ihdr[9] = 0; // colour type: greyscale +writeFileSync( + out, + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +); + +console.log(`wrote ${out} (${size}x${size})`); diff --git a/tools/mark-raster.mjs b/tools/mark-raster.mjs new file mode 100644 index 0000000..e2b0bdc --- /dev/null +++ b/tools/mark-raster.mjs @@ -0,0 +1,218 @@ +// Rasterises assets/cude-mark.svg without a browser. +// +// The mark is three stroked polylines with round caps and round joins, so the +// inked region is exactly the union of capsules (a segment plus its radius) — +// "distance to the nearest segment <= stroke-width / 2". That is a closed form, +// which means the block art can be regenerated deterministically instead of +// depending on a 150MB Chromium download that CI deliberately skips. +// +// Exports the sampler; tools/generate-logo.mjs and tools/mark-preview.mjs use it. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** Parses `M x y L x y L x y ...` — the only path syntax the mark uses. */ +function parsePolyline(d) { + const numbers = d.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; + const points = []; + for (let i = 0; i + 1 < numbers.length; i += 2) points.push([numbers[i], numbers[i + 1]]); + return points; +} + +export function loadMark(svgPath = fileURLToPath(new URL('../assets/cude-mark.svg', import.meta.url))) { + const svg = readFileSync(svgPath, 'utf8'); + + const groupWidth = Number(svg.match(/stroke-width="(\d+(?:\.\d+)?)"/)?.[1] ?? 42); + const strokes = []; + + const groupCap = svg.match(/stroke-linecap="(\w+)"/)?.[1] ?? 'butt'; + + for (const match of svg.matchAll(/]*?)d="([^"]+)"([^>]*)\/>/g)) { + const attrs = match[1] + match[3]; + const own = attrs.match(/stroke-width="(\d+(?:\.\d+)?)"/); + const cap = attrs.match(/stroke-linecap="(\w+)"/)?.[1] ?? groupCap; + strokes.push({ + points: parsePolyline(match[2]), + radius: (own ? Number(own[1]) : groupWidth) / 2, + cap, + }); + } + + if (strokes.length === 0) throw new Error(`no found in ${svgPath}`); + return strokes; +} + +/** + * Whether a point lies within `radius` of a segment. + * + * `clampStart` / `clampEnd` model the cap: clamping t gives a round end (the + * capsule), leaving it unclamped gives a butt end (the rectangle stops square + * at the endpoint). Interior joins always clamp, so they stay round. + */ +function withinSegment(px, py, ax, ay, bx, by, r2, clampStart, clampEnd) { + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared === 0) return (px - ax) ** 2 + (py - ay) ** 2 <= r2; + + let t = ((px - ax) * dx + (py - ay) * dy) / lengthSquared; + if (t < 0) { + if (!clampStart) return false; + t = 0; + } + if (t > 1) { + if (!clampEnd) return false; + t = 1; + } + + const cx = ax + t * dx; + const cy = ay + t * dy; + return (px - cx) ** 2 + (py - cy) ** 2 <= r2; +} + +export function makeInkTest(strokes) { + return (x, y) => { + for (const { points, radius, cap } of strokes) { + const r2 = radius * radius; + const round = cap !== 'butt'; + const last = points.length - 2; + + if (points.length === 1) { + const [ax, ay] = points[0]; + if (round && (x - ax) ** 2 + (y - ay) ** 2 <= r2) return true; + continue; + } + + for (let i = 0; i <= last; i++) { + const [ax, ay] = points[i]; + const [bx, by] = points[i + 1]; + // Only the two ends of the polyline take the cap; joins stay round. + const clampStart = i > 0 || round; + const clampEnd = i < last || round; + if (withinSegment(x, y, ax, ay, bx, by, r2, clampStart, clampEnd)) return true; + } + } + return false; + }; +} + +/** + * Tight bounding box of the inked region, as `[x, y, width, height]`. + * + * Computed rather than hand-measured: a round cap reaches `radius` past its + * endpoint along the stroke, a butt cap does not, so changing a cap silently + * changes the box. Getting this wrong shifts and squashes the block art. + */ +export function markBounds(strokes) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const include = (x, y) => { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + }; + + for (const { points, radius, cap } of strokes) { + const round = cap !== 'butt'; + const last = points.length - 2; + + for (let i = 0; i <= last; i++) { + const [ax, ay] = points[i]; + const [bx, by] = points[i + 1]; + const dx = bx - ax; + const dy = by - ay; + const length = Math.hypot(dx, dy) || 1; + // The rectangle: both endpoints offset perpendicular by the radius. + const nx = (-dy / length) * radius; + const ny = (dx / length) * radius; + for (const [px, py] of [[ax, ay], [bx, by]]) { + include(px + nx, py + ny); + include(px - nx, py - ny); + } + // Round ends and interior joins add a disc. + if (round || i > 0) { + include(ax - radius, ay - radius); + include(ax + radius, ay + radius); + } + if (round || i < last) { + include(bx - radius, by - radius); + include(bx + radius, by + radius); + } + } + } + + return [minX, minY, maxX - minX, maxY - minY]; +} + +/** + * Ink coverage per cell of a `cols x rows` grid over `viewBox`, supersampled + * `ss` times per axis. Mirrors what the Chromium version measured from alpha. + */ +export function sampleCoverage({ strokes, viewBox, cols, rows, ss = 8 }) { + const [vx, vy, vw, vh] = viewBox; + const inked = makeInkTest(strokes); + const grid = []; + + for (let row = 0; row < rows; row++) { + const line = []; + for (let col = 0; col < cols; col++) { + let hits = 0; + for (let j = 0; j < ss; j++) { + const y = vy + ((row + (j + 0.5) / ss) / rows) * vh; + for (let i = 0; i < ss; i++) { + const x = vx + ((col + (i + 0.5) / ss) / cols) * vw; + if (inked(x, y)) hits++; + } + } + line.push(hits / (ss * ss)); + } + grid.push(line); + } + + return grid; +} + +// bit 1 = upper-left, 2 = upper-right, 4 = lower-left, 8 = lower-right +const QUADRANT = [ + ' ', '▘', '▝', '▀', + '▖', '▌', '▞', '▛', + '▗', '▚', '▐', '▜', + '▄', '▙', '▟', '█', +]; + +/** + * The mark reduced onto a quadrant-block grid — what the CLI prints. + * + * Quadrant blocks split each character cell into 2x2, which is what makes a + * faithful reduction possible: half-blocks give two sub-rows but only ONE + * sub-column, so the bottom notch (2.6% of the mark's width, about 0.7 of a + * cell) could not be drawn without widening it. At 2x horizontal resolution it + * lands on ~1.5 sub-cells and survives as-is. + */ +export function renderBlockArt({ cols = 29, rows = 17, ss = 12, threshold = 0.45 } = {}) { + const strokes = loadMark(); + const viewBox = markBounds(strokes); + const coverage = sampleCoverage({ strokes, viewBox, cols: cols * 2, rows: rows * 2, ss }); + + const on = (y, x) => ((coverage[y]?.[x] ?? 0) >= threshold ? 1 : 0); + + const lines = []; + for (let r = 0; r < rows; r++) { + let line = ''; + for (let c = 0; c < cols; c++) { + const mask = + on(r * 2, c * 2) * 1 + + on(r * 2, c * 2 + 1) * 2 + + on(r * 2 + 1, c * 2) * 4 + + on(r * 2 + 1, c * 2 + 1) * 8; + line += QUADRANT[mask]; + } + lines.push(line.replace(/\s+$/, '')); + } + + while (lines.length && lines[0] === '') lines.shift(); + while (lines.length && lines.at(-1) === '') lines.pop(); + + const indent = Math.min(...lines.filter(Boolean).map((l) => l.match(/^ */)[0].length)); + return lines.map((l) => (l ? ' ' + l.slice(indent) : '')); +} diff --git a/{} b/{} new file mode 100644 index 0000000..e69de29