From e9f6de7cb8b9f889553880b45a597307a1193f4f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Dec 2025 00:21:18 +0100 Subject: [PATCH] WP14: Create main-new.ts and cli-new.ts using AgentSession and new modes --- packages/coding-agent/docs/refactor.md | 20 +- packages/coding-agent/src/cli-new.ts | 10 + packages/coding-agent/src/main-new.ts | 1254 ++++++++++++++++++++++++ 3 files changed, 1274 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/src/cli-new.ts create mode 100644 packages/coding-agent/src/main-new.ts diff --git a/packages/coding-agent/docs/refactor.md b/packages/coding-agent/docs/refactor.md index 420a052f..285ee7f1 100644 --- a/packages/coding-agent/docs/refactor.md +++ b/packages/coding-agent/docs/refactor.md @@ -1390,16 +1390,16 @@ npx tsx src/cli-new.ts # interactive mode 3. Manual test: `npx tsx src/cli-new.ts --mode json "hello"` works 4. Manual test: `npx tsx src/cli-new.ts --mode rpc` works -- [ ] Copy main.ts to main-new.ts -- [ ] Remove `runSingleShotMode()` from main-new.ts -- [ ] Remove `runRpcMode()` from main-new.ts -- [ ] Remove `executeRpcBashCommand()` from main-new.ts -- [ ] Import and use `runPrintMode` from modes -- [ ] Import and use `runRpcMode` from modes -- [ ] Create `AgentSession` in main() -- [ ] Update mode routing to use new functions -- [ ] Create cli-new.ts -- [ ] Verify with `npm run check` +- [x] Copy main.ts to main-new.ts +- [x] Remove `runSingleShotMode()` from main-new.ts +- [x] Remove `runRpcMode()` from main-new.ts +- [x] Remove `executeRpcBashCommand()` from main-new.ts +- [x] Import and use `runPrintMode` from modes +- [x] Import and use `runRpcMode` from modes +- [x] Create `AgentSession` in main() +- [x] Update mode routing to use new functions +- [x] Create cli-new.ts +- [x] Verify with `npm run check` - [ ] Manual test all three modes via cli-new.ts --- diff --git a/packages/coding-agent/src/cli-new.ts b/packages/coding-agent/src/cli-new.ts new file mode 100644 index 00000000..e68991ca --- /dev/null +++ b/packages/coding-agent/src/cli-new.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +/** + * CLI entry point for the refactored coding agent. + * Uses main-new.ts with AgentSession and new mode modules. + * + * Test with: npx tsx src/cli-new.ts [args...] + */ +import { main } from "./main-new.js"; + +main(process.argv.slice(2)); diff --git a/packages/coding-agent/src/main-new.ts b/packages/coding-agent/src/main-new.ts new file mode 100644 index 00000000..907d60c2 --- /dev/null +++ b/packages/coding-agent/src/main-new.ts @@ -0,0 +1,1254 @@ +import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Api, KnownProvider, Model } from "@mariozechner/pi-ai"; +import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; +import chalk from "chalk"; +import { existsSync, readFileSync, statSync } from "fs"; +import { homedir } from "os"; +import { extname, join, resolve } from "path"; +import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js"; +import { + APP_NAME, + CONFIG_DIR_NAME, + ENV_AGENT_DIR, + getAgentDir, + getModelsPath, + getReadmePath, + VERSION, +} from "./config.js"; +import { AgentSession } from "./core/agent-session.js"; +import { exportFromFile } from "./export-html.js"; +import { messageTransformer } from "./messages.js"; +import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; +import { runPrintMode, runRpcMode } from "./modes/index.js"; +import { SessionManager } from "./session-manager.js"; +import { SettingsManager } from "./settings-manager.js"; +import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js"; +import { initTheme } from "./theme/theme.js"; +import { allTools, codingTools, type ToolName } from "./tools/index.js"; +import { ensureTool } from "./tools-manager.js"; +import { SessionSelectorComponent } from "./tui/session-selector.js"; +import { TuiRenderer } from "./tui/tui-renderer.js"; + +const defaultModelPerProvider: Record = { + anthropic: "claude-sonnet-4-5", + openai: "gpt-5.1-codex", + google: "gemini-2.5-pro", + openrouter: "openai/gpt-5.1-codex", + xai: "grok-4-fast-non-reasoning", + groq: "openai/gpt-oss-120b", + cerebras: "zai-glm-4.6", + zai: "glm-4.6", +}; + +type Mode = "text" | "json" | "rpc"; + +interface Args { + provider?: string; + model?: string; + apiKey?: string; + systemPrompt?: string; + appendSystemPrompt?: string; + thinking?: ThinkingLevel; + continue?: boolean; + resume?: boolean; + help?: boolean; + mode?: Mode; + noSession?: boolean; + session?: string; + models?: string[]; + tools?: ToolName[]; + print?: boolean; + export?: string; + messages: string[]; + fileArgs: string[]; +} + +function parseArgs(args: string[]): Args { + const result: Args = { + messages: [], + fileArgs: [], + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--help" || arg === "-h") { + result.help = true; + } else if (arg === "--mode" && i + 1 < args.length) { + const mode = args[++i]; + if (mode === "text" || mode === "json" || mode === "rpc") { + result.mode = mode; + } + } else if (arg === "--continue" || arg === "-c") { + result.continue = true; + } else if (arg === "--resume" || arg === "-r") { + result.resume = true; + } else if (arg === "--provider" && i + 1 < args.length) { + result.provider = args[++i]; + } else if (arg === "--model" && i + 1 < args.length) { + result.model = args[++i]; + } else if (arg === "--api-key" && i + 1 < args.length) { + result.apiKey = args[++i]; + } else if (arg === "--system-prompt" && i + 1 < args.length) { + result.systemPrompt = args[++i]; + } else if (arg === "--append-system-prompt" && i + 1 < args.length) { + result.appendSystemPrompt = args[++i]; + } else if (arg === "--no-session") { + result.noSession = true; + } else if (arg === "--session" && i + 1 < args.length) { + result.session = args[++i]; + } else if (arg === "--models" && i + 1 < args.length) { + result.models = args[++i].split(",").map((s) => s.trim()); + } else if (arg === "--tools" && i + 1 < args.length) { + const toolNames = args[++i].split(",").map((s) => s.trim()); + const validTools: ToolName[] = []; + for (const name of toolNames) { + if (name in allTools) { + validTools.push(name as ToolName); + } else { + console.error( + chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`), + ); + } + } + result.tools = validTools; + } else if (arg === "--thinking" && i + 1 < args.length) { + const level = args[++i]; + if ( + level === "off" || + level === "minimal" || + level === "low" || + level === "medium" || + level === "high" || + level === "xhigh" + ) { + result.thinking = level; + } else { + console.error( + chalk.yellow( + `Warning: Invalid thinking level "${level}". Valid values: off, minimal, low, medium, high, xhigh`, + ), + ); + } + } else if (arg === "--print" || arg === "-p") { + result.print = true; + } else if (arg === "--export" && i + 1 < args.length) { + result.export = args[++i]; + } else if (arg.startsWith("@")) { + result.fileArgs.push(arg.slice(1)); // Remove @ prefix + } else if (!arg.startsWith("-")) { + result.messages.push(arg); + } + } + + return result; +} + +/** + * Map of file extensions to MIME types for common image formats + */ +const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +}; + +/** + * Check if a file is an image based on its extension + */ +function isImageFile(filePath: string): string | null { + const ext = extname(filePath).toLowerCase(); + return IMAGE_MIME_TYPES[ext] || null; +} + +/** + * Expand ~ to home directory + */ +function expandPath(filePath: string): string { + if (filePath === "~") { + return homedir(); + } + if (filePath.startsWith("~/")) { + return homedir() + filePath.slice(1); + } + return filePath; +} + +/** + * Process @file arguments into text content and image attachments + */ +function processFileArguments(fileArgs: string[]): { textContent: string; imageAttachments: Attachment[] } { + let textContent = ""; + const imageAttachments: Attachment[] = []; + + for (const fileArg of fileArgs) { + // Expand and resolve path + const expandedPath = expandPath(fileArg); + const absolutePath = resolve(expandedPath); + + // Check if file exists + if (!existsSync(absolutePath)) { + console.error(chalk.red(`Error: File not found: ${absolutePath}`)); + process.exit(1); + } + + // Check if file is empty + const stats = statSync(absolutePath); + if (stats.size === 0) { + // Skip empty files + continue; + } + + const mimeType = isImageFile(absolutePath); + + if (mimeType) { + // Handle image file + const content = readFileSync(absolutePath); + const base64Content = content.toString("base64"); + + const attachment: Attachment = { + id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + type: "image", + fileName: absolutePath.split("/").pop() || absolutePath, + mimeType, + size: stats.size, + content: base64Content, + }; + + imageAttachments.push(attachment); + + // Add text reference to image + textContent += `\n`; + } else { + // Handle text file + try { + const content = readFileSync(absolutePath, "utf-8"); + textContent += `\n${content}\n\n`; + } catch (error: any) { + console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`)); + process.exit(1); + } + } + } + + return { textContent, imageAttachments }; +} + +function printHelp() { + console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools + +${chalk.bold("Usage:")} + ${APP_NAME} [options] [@files...] [messages...] + +${chalk.bold("Options:")} + --provider Provider name (default: google) + --model Model ID (default: gemini-2.5-flash) + --api-key API key (defaults to env vars) + --system-prompt System prompt (default: coding assistant prompt) + --append-system-prompt Append text or file contents to the system prompt + --mode Output mode: text (default), json, or rpc + --print, -p Non-interactive mode: process prompt and exit + --continue, -c Continue previous session + --resume, -r Select a session to resume + --session Use specific session file + --no-session Don't save session (ephemeral) + --models Comma-separated model patterns for quick cycling with Ctrl+P + --tools Comma-separated list of tools to enable (default: read,bash,edit,write) + Available: read, bash, edit, write, grep, find, ls + --thinking Set thinking level: off, minimal, low, medium, high, xhigh + --export Export session file to HTML and exit + --help, -h Show this help + +${chalk.bold("Examples:")} + # Interactive mode + ${APP_NAME} + + # Interactive mode with initial prompt + ${APP_NAME} "List all .ts files in src/" + + # Include files in initial message + ${APP_NAME} @prompt.md @image.png "What color is the sky?" + + # Non-interactive mode (process and exit) + ${APP_NAME} -p "List all .ts files in src/" + + # Multiple messages (interactive) + ${APP_NAME} "Read package.json" "What dependencies do we have?" + + # Continue previous session + ${APP_NAME} --continue "What did we discuss?" + + # Use different model + ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code" + + # Limit model cycling to specific models + ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o + + # Cycle models with fixed thinking levels + ${APP_NAME} --models sonnet:high,haiku:low + + # Start with a specific thinking level + ${APP_NAME} --thinking high "Solve this complex problem" + + # Read-only mode (no file modifications possible) + ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/" + + # Export a session file to HTML + ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl + ${APP_NAME} --export session.jsonl output.html + +${chalk.bold("Environment Variables:")} + ANTHROPIC_API_KEY - Anthropic Claude API key + ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) + OPENAI_API_KEY - OpenAI GPT API key + GEMINI_API_KEY - Google Gemini API key + GROQ_API_KEY - Groq API key + CEREBRAS_API_KEY - Cerebras API key + XAI_API_KEY - xAI Grok API key + OPENROUTER_API_KEY - OpenRouter API key + ZAI_API_KEY - ZAI API key + ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) + +${chalk.bold("Available Tools (default: read, bash, edit, write):")} + read - Read file contents + bash - Execute bash commands + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents (read-only, off by default) + find - Find files by glob pattern (read-only, off by default) + ls - List directory contents (read-only, off by default) +`); +} + +// Tool descriptions for system prompt +const toolDescriptions: Record = { + read: "Read file contents", + bash: "Execute bash commands (ls, grep, find, etc.)", + edit: "Make surgical edits to files (find exact text and replace)", + write: "Create or overwrite files", + grep: "Search file contents for patterns (respects .gitignore)", + find: "Find files by glob pattern (respects .gitignore)", + ls: "List directory contents", +}; + +function resolvePromptInput(input: string | undefined, description: string): string | undefined { + if (!input) { + return undefined; + } + + if (existsSync(input)) { + try { + return readFileSync(input, "utf-8"); + } catch (error) { + console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); + return input; + } + } + + return input; +} + +function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[], appendSystemPrompt?: string): string { + const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); + const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); + + const now = new Date(); + const dateTime = now.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }); + + const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : ""; + + if (resolvedCustomPrompt) { + let prompt = resolvedCustomPrompt; + + if (appendSection) { + prompt += appendSection; + } + + // Append project context files + const contextFiles = loadProjectContextFiles(); + if (contextFiles.length > 0) { + prompt += "\n\n# Project Context\n\n"; + prompt += "The following project context files have been loaded:\n\n"; + for (const { path: filePath, content } of contextFiles) { + prompt += `## ${filePath}\n\n${content}\n\n`; + } + } + + // Add date/time and working directory last + prompt += `\nCurrent date and time: ${dateTime}`; + prompt += `\nCurrent working directory: ${process.cwd()}`; + + return prompt; + } + + // Get absolute path to README.md + const readmePath = getReadmePath(); + + // Build tools list based on selected tools + const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); + const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n"); + + // Build guidelines based on which tools are actually available + const guidelinesList: string[] = []; + + const hasBash = tools.includes("bash"); + const hasEdit = tools.includes("edit"); + const hasWrite = tools.includes("write"); + const hasGrep = tools.includes("grep"); + const hasFind = tools.includes("find"); + const hasLs = tools.includes("ls"); + const hasRead = tools.includes("read"); + + // Read-only mode notice (no bash, edit, or write) + if (!hasBash && !hasEdit && !hasWrite) { + guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands"); + } + + // Bash without edit/write = read-only bash mode + if (hasBash && !hasEdit && !hasWrite) { + guidelinesList.push( + "Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files", + ); + } + + // File exploration guidelines + if (hasBash && !hasGrep && !hasFind && !hasLs) { + guidelinesList.push("Use bash for file operations like ls, grep, find"); + } else if (hasBash && (hasGrep || hasFind || hasLs)) { + guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)"); + } + + // Read before edit guideline + if (hasRead && hasEdit) { + guidelinesList.push("Use read to examine files before editing"); + } + + // Edit guideline + if (hasEdit) { + guidelinesList.push("Use edit for precise changes (old text must match exactly)"); + } + + // Write guideline + if (hasWrite) { + guidelinesList.push("Use write only for new files or complete rewrites"); + } + + // Output guideline (only when actually writing/executing) + if (hasEdit || hasWrite) { + guidelinesList.push( + "When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did", + ); + } + + // Always include these + guidelinesList.push("Be concise in your responses"); + guidelinesList.push("Show file paths clearly when working with files"); + + const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n"); + + let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. + +Available tools: +${toolsList} + +Guidelines: +${guidelines} + +Documentation: +- Your own documentation (including custom model setup and theme creation) is at: ${readmePath} +- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`; + + if (appendSection) { + prompt += appendSection; + } + + // Append project context files + const contextFiles = loadProjectContextFiles(); + if (contextFiles.length > 0) { + prompt += "\n\n# Project Context\n\n"; + prompt += "The following project context files have been loaded:\n\n"; + for (const { path: filePath, content } of contextFiles) { + prompt += `## ${filePath}\n\n${content}\n\n`; + } + } + + // Add date/time and working directory last + prompt += `\nCurrent date and time: ${dateTime}`; + prompt += `\nCurrent working directory: ${process.cwd()}`; + + return prompt; +} + +/** + * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) + */ +function loadContextFileFromDir(dir: string): { path: string; content: string } | null { + const candidates = ["AGENTS.md", "CLAUDE.md"]; + for (const filename of candidates) { + const filePath = join(dir, filename); + if (existsSync(filePath)) { + try { + return { + path: filePath, + content: readFileSync(filePath, "utf-8"), + }; + } catch (error) { + console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); + } + } + } + return null; +} + +/** + * Load all project context files in order: + * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md + * 2. Parent directories (top-most first) down to cwd + * Each returns {path, content} for separate messages + */ +function loadProjectContextFiles(): Array<{ path: string; content: string }> { + const contextFiles: Array<{ path: string; content: string }> = []; + + // 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/ + const globalContextDir = getAgentDir(); + const globalContext = loadContextFileFromDir(globalContextDir); + if (globalContext) { + contextFiles.push(globalContext); + } + + // 2. Walk up from cwd to root, collecting all context files + const cwd = process.cwd(); + const ancestorContextFiles: Array<{ path: string; content: string }> = []; + + let currentDir = cwd; + const root = resolve("/"); + + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile) { + // Add to beginning so we get top-most parent first + ancestorContextFiles.unshift(contextFile); + } + + // Stop if we've reached root + if (currentDir === root) break; + + // Move up one directory + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; // Safety check + currentDir = parentDir; + } + + // Add ancestor files in order (top-most → cwd) + contextFiles.push(...ancestorContextFiles); + + return contextFiles; +} + +async function checkForNewVersion(currentVersion: string): Promise { + try { + const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); + if (!response.ok) return null; + + const data = (await response.json()) as { version?: string }; + const latestVersion = data.version; + + if (latestVersion && latestVersion !== currentVersion) { + return latestVersion; + } + + return null; + } catch (error) { + // Silently fail - don't disrupt the user experience + return null; + } +} + +/** + * Resolve model patterns to actual Model objects with optional thinking levels + * Format: "pattern:level" where :level is optional + * For each pattern, finds all matching models and picks the best version: + * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929) + * 2. If no alias, pick the latest dated version + */ +async function resolveModelScope( + patterns: string[], +): Promise; thinkingLevel: ThinkingLevel }>> { + const { models: availableModels, error } = await getAvailableModels(); + + if (error) { + console.warn(chalk.yellow(`Warning: Error loading models: ${error}`)); + return []; + } + + const scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = []; + + for (const pattern of patterns) { + // Parse pattern:level format + const parts = pattern.split(":"); + const modelPattern = parts[0]; + let thinkingLevel: ThinkingLevel = "off"; + + if (parts.length > 1) { + const level = parts[1]; + if ( + level === "off" || + level === "minimal" || + level === "low" || + level === "medium" || + level === "high" || + level === "xhigh" + ) { + thinkingLevel = level; + } else { + console.warn( + chalk.yellow(`Warning: Invalid thinking level "${level}" in pattern "${pattern}". Using "off" instead.`), + ); + } + } + + // Check for provider/modelId format (provider is everything before the first /) + const slashIndex = modelPattern.indexOf("/"); + if (slashIndex !== -1) { + const provider = modelPattern.substring(0, slashIndex); + const modelId = modelPattern.substring(slashIndex + 1); + const providerMatch = availableModels.find( + (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatch) { + if ( + !scopedModels.find( + (sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider, + ) + ) { + scopedModels.push({ model: providerMatch, thinkingLevel }); + } + continue; + } + // No exact provider/model match - fall through to other matching + } + + // Check for exact ID match (case-insensitive) + const exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase()); + if (exactMatch) { + // Exact match found - use it directly + if (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) { + scopedModels.push({ model: exactMatch, thinkingLevel }); + } + continue; + } + + // No exact match - fall back to partial matching + const matches = availableModels.filter( + (m) => + m.id.toLowerCase().includes(modelPattern.toLowerCase()) || + m.name?.toLowerCase().includes(modelPattern.toLowerCase()), + ); + + if (matches.length === 0) { + console.warn(chalk.yellow(`Warning: No models match pattern "${modelPattern}"`)); + continue; + } + + // Helper to check if a model ID looks like an alias (no date suffix) + // Dates are typically in format: -20241022 or -20250929 + const isAlias = (id: string): boolean => { + // Check if ID ends with -latest + if (id.endsWith("-latest")) return true; + + // Check if ID ends with a date pattern (-YYYYMMDD) + const datePattern = /-\d{8}$/; + return !datePattern.test(id); + }; + + // Separate into aliases and dated versions + const aliases = matches.filter((m) => isAlias(m.id)); + const datedVersions = matches.filter((m) => !isAlias(m.id)); + + let bestMatch: Model; + + if (aliases.length > 0) { + // Prefer alias - if multiple aliases, pick the one that sorts highest + aliases.sort((a, b) => b.id.localeCompare(a.id)); + bestMatch = aliases[0]; + } else { + // No alias found, pick latest dated version + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + bestMatch = datedVersions[0]; + } + + // Avoid duplicates + if (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) { + scopedModels.push({ model: bestMatch, thinkingLevel }); + } + } + + return scopedModels; +} + +async function selectSession(sessionManager: SessionManager): Promise { + return new Promise((resolve) => { + const ui = new TUI(new ProcessTerminal()); + let resolved = false; + + const selector = new SessionSelectorComponent( + sessionManager, + (path: string) => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(path); + } + }, + () => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(null); + } + }, + ); + + ui.addChild(selector); + ui.setFocus(selector.getSessionList()); + ui.start(); + }); +} + +async function runInteractiveMode( + agent: Agent, + sessionManager: SessionManager, + settingsManager: SettingsManager, + version: string, + changelogMarkdown: string | null = null, + collapseChangelog = false, + modelFallbackMessage: string | null = null, + versionCheckPromise: Promise, + scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], + initialMessages: string[] = [], + initialMessage?: string, + initialAttachments?: Attachment[], + fdPath: string | null = null, +): Promise { + const renderer = new TuiRenderer( + agent, + sessionManager, + settingsManager, + version, + changelogMarkdown, + collapseChangelog, + scopedModels, + fdPath, + ); + + // Initialize TUI (subscribes to agent events internally) + await renderer.init(); + + // Handle version check result when it completes (don't block) + versionCheckPromise.then((newVersion) => { + if (newVersion) { + renderer.showNewVersionNotification(newVersion); + } + }); + + // Render any existing messages (from --continue mode) + renderer.renderInitialMessages(agent.state); + + // Show model fallback warning at the end of the chat if applicable + if (modelFallbackMessage) { + renderer.showWarning(modelFallbackMessage); + } + + // Load file-based slash commands for expansion + const fileCommands = loadSlashCommands(); + + // Process initial message with attachments if provided (from @file args) + if (initialMessage) { + try { + await agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + renderer.showError(errorMessage); + } + } + + // Process remaining initial messages if provided (from CLI args) + for (const message of initialMessages) { + try { + await agent.prompt(expandSlashCommand(message, fileCommands)); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + renderer.showError(errorMessage); + } + } + + // Interactive loop + while (true) { + const userInput = await renderer.getUserInput(); + + // Process the message - agent.prompt will add user message and trigger state updates + try { + await agent.prompt(userInput); + } catch (error: unknown) { + // Display error in the TUI by adding an error message to the chat + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + renderer.showError(errorMessage); + } + } +} + +export async function main(args: string[]) { + const parsed = parseArgs(args); + + if (parsed.help) { + printHelp(); + return; + } + + // Handle --export flag: convert session file to HTML and exit + if (parsed.export) { + try { + // Use first message as output path if provided + const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; + const result = exportFromFile(parsed.export, outputPath); + console.log(`Exported to: ${result}`); + return; + } catch (error: any) { + console.error(chalk.red(`Error: ${error.message || "Failed to export session"}`)); + process.exit(1); + } + } + + // Validate: RPC mode doesn't support @file arguments + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { + console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); + process.exit(1); + } + + // Process @file arguments if any + let initialMessage: string | undefined; + let initialAttachments: Attachment[] | undefined; + + if (parsed.fileArgs.length > 0) { + const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs); + + // Combine file content with first plain text message (if any) + if (parsed.messages.length > 0) { + initialMessage = textContent + parsed.messages[0]; + parsed.messages.shift(); // Remove first message as it's been combined + } else { + initialMessage = textContent; + } + + initialAttachments = imageAttachments.length > 0 ? imageAttachments : undefined; + } + + // Initialize theme (before any TUI rendering) + const settingsManager = new SettingsManager(); + const themeName = settingsManager.getTheme(); + initTheme(themeName); + + // Setup session manager + const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session); + + // Disable session saving if --no-session flag is set + if (parsed.noSession) { + sessionManager.disable(); + } + + // Handle --resume flag: show session selector + if (parsed.resume) { + const selectedSession = await selectSession(sessionManager); + if (!selectedSession) { + console.log(chalk.dim("No session selected")); + return; + } + // Set the selected session as the active session + sessionManager.setSessionFile(selectedSession); + } + + // Resolve model scope early if provided (needed for initial model selection) + let scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = []; + if (parsed.models && parsed.models.length > 0) { + scopedModels = await resolveModelScope(parsed.models); + } + + // Determine initial model using priority system: + // 1. CLI args (--provider and --model) + // 2. First model from --models scope + // 3. Restored from session (if --continue or --resume) + // 4. Saved default from settings.json + // 5. First available model with valid API key + // 6. null (allowed in interactive mode) + let initialModel: Model | null = null; + let initialThinking: ThinkingLevel = "off"; + + if (parsed.provider && parsed.model) { + // 1. CLI args take priority + const { model, error } = findModel(parsed.provider, parsed.model); + if (error) { + console.error(chalk.red(error)); + process.exit(1); + } + if (!model) { + console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`)); + process.exit(1); + } + initialModel = model; + } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { + // 2. Use first model from --models scope (skip if continuing/resuming session) + initialModel = scopedModels[0].model; + initialThinking = scopedModels[0].thinkingLevel; + } else if (parsed.continue || parsed.resume) { + // 3. Restore from session (will be handled below after loading session) + // Leave initialModel as null for now + } + + if (!initialModel) { + // 3. Try saved default from settings + const defaultProvider = settingsManager.getDefaultProvider(); + const defaultModel = settingsManager.getDefaultModel(); + if (defaultProvider && defaultModel) { + const { model, error } = findModel(defaultProvider, defaultModel); + if (error) { + console.error(chalk.red(error)); + process.exit(1); + } + initialModel = model; + + // Also load saved thinking level if we're using saved model + const savedThinking = settingsManager.getDefaultThinkingLevel(); + if (savedThinking) { + initialThinking = savedThinking; + } + } + } + + if (!initialModel) { + // 4. Try first available model with valid API key + // Prefer default model for each provider if available + const { models: availableModels, error } = await getAvailableModels(); + + if (error) { + console.error(chalk.red(error)); + process.exit(1); + } + + if (availableModels.length > 0) { + // Try to find a default model from known providers + for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { + const defaultModelId = defaultModelPerProvider[provider]; + const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId); + if (match) { + initialModel = match; + break; + } + } + + // If no default found, use first available + if (!initialModel) { + initialModel = availableModels[0]; + } + } + } + + // Determine mode early to know if we should print messages and fail early + // Interactive mode: no --print flag and no --mode flag + // Having initial messages doesn't make it non-interactive anymore + const isInteractive = !parsed.print && parsed.mode === undefined; + const mode = parsed.mode || "text"; + // Only print informational messages in interactive mode + // Non-interactive modes (-p, --mode json, --mode rpc) should be silent except for output + const shouldPrintMessages = isInteractive; + + // Non-interactive mode: fail early if no model available + if (!isInteractive && !initialModel) { + console.error(chalk.red("No models available.")); + console.error(chalk.yellow("\nSet an API key environment variable:")); + console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); + console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); + process.exit(1); + } + + // Non-interactive mode: validate API key exists + if (!isInteractive && initialModel) { + const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel)); + if (!apiKey) { + console.error(chalk.red(`No API key found for ${initialModel.provider}`)); + process.exit(1); + } + } + + const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt); + + // Load previous messages if continuing or resuming + // This may update initialModel if restoring from session + if (parsed.continue || parsed.resume) { + // Load and restore model (overrides initialModel if found and has API key) + const savedModel = sessionManager.loadModel(); + if (savedModel) { + const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId); + + if (error) { + console.error(chalk.red(error)); + process.exit(1); + } + + // Check if restored model exists and has a valid API key + const hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false; + + if (restoredModel && hasApiKey) { + initialModel = restoredModel; + if (shouldPrintMessages) { + console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`)); + } + } else { + // Model not found or no API key - fall back to default selection + const reason = !restoredModel ? "model no longer exists" : "no API key available"; + + if (shouldPrintMessages) { + console.error( + chalk.yellow( + `Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`, + ), + ); + } + + // Ensure we have a valid model - use the same fallback logic + if (!initialModel) { + const { models: availableModels, error: availableError } = await getAvailableModels(); + if (availableError) { + console.error(chalk.red(availableError)); + process.exit(1); + } + if (availableModels.length > 0) { + // Try to find a default model from known providers + for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { + const defaultModelId = defaultModelPerProvider[provider]; + const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId); + if (match) { + initialModel = match; + break; + } + } + + // If no default found, use first available + if (!initialModel) { + initialModel = availableModels[0]; + } + + if (initialModel && shouldPrintMessages) { + console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`)); + } + } else { + // No models available at all + if (shouldPrintMessages) { + console.error(chalk.red("\nNo models available.")); + console.error(chalk.yellow("Set an API key environment variable:")); + console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc."); + console.error(chalk.yellow(`\nOr create ${getModelsPath()}`)); + } + process.exit(1); + } + } else if (shouldPrintMessages) { + console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`)); + } + } + } + } + + // CLI --thinking flag takes highest priority + if (parsed.thinking) { + initialThinking = parsed.thinking; + } + + // Determine which tools to use + const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools; + + // Create agent (initialModel can be null in interactive mode) + const agent = new Agent({ + initialState: { + systemPrompt, + model: initialModel as any, // Can be null + thinkingLevel: initialThinking, + tools: selectedTools, + }, + messageTransformer, + queueMode: settingsManager.getQueueMode(), + transport: new ProviderTransport({ + // Dynamic API key lookup based on current model's provider + getApiKey: async () => { + const currentModel = agent.state.model; + if (!currentModel) { + throw new Error("No model selected"); + } + + // Try CLI override first + if (parsed.apiKey) { + return parsed.apiKey; + } + + // Use model-specific key lookup + const key = await getApiKeyForModel(currentModel); + if (!key) { + throw new Error( + `No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ${getModelsPath()}`, + ); + } + return key; + }, + }), + }); + + // If initial thinking was requested but model doesn't support it, silently reset to off + if (initialThinking !== "off" && initialModel && !initialModel.reasoning) { + agent.setThinkingLevel("off"); + } + + // Track if we had to fall back from saved model (to show in chat later) + let modelFallbackMessage: string | null = null; + + // Load previous messages if continuing or resuming + if (parsed.continue || parsed.resume) { + const messages = sessionManager.loadMessages(); + if (messages.length > 0) { + agent.replaceMessages(messages); + } + + // Load and restore thinking level + const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel; + if (thinkingLevel) { + agent.setThinkingLevel(thinkingLevel); + if (shouldPrintMessages) { + console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`)); + } + } + + // Check if we had to fall back from saved model + const savedModel = sessionManager.loadModel(); + if (savedModel && initialModel) { + const savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId; + if (!savedMatches) { + const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId); + if (error) { + // Config error - already shown above, just use generic message + modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`; + } else { + const reason = !restoredModel ? "model no longer exists" : "no API key available"; + modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`; + } + } + } + } + + // Log loaded context files (they're already in the system prompt) + if (shouldPrintMessages && !parsed.continue && !parsed.resume) { + const contextFiles = loadProjectContextFiles(); + if (contextFiles.length > 0) { + console.log(chalk.dim("Loaded project context from:")); + for (const { path: filePath } of contextFiles) { + console.log(chalk.dim(` - ${filePath}`)); + } + } + } + + // Create AgentSession for non-interactive modes + // (Interactive mode will create its own session when we refactor TuiRenderer) + const fileCommands = loadSlashCommands(); + + // Route to appropriate mode + if (mode === "rpc") { + // RPC mode - headless operation + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + scopedModels, + fileCommands, + }); + await runRpcMode(session); + } else if (isInteractive) { + // Check for new version in the background (don't block startup) + const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null); + + // Check if we should show changelog (only in interactive mode, only for new sessions) + let changelogMarkdown: string | null = null; + if (!parsed.continue && !parsed.resume) { + const lastVersion = settingsManager.getLastChangelogVersion(); + + // Check if we need to show changelog + if (!lastVersion) { + // First run - show all entries + const changelogPath = getChangelogPath(); + const entries = parseChangelog(changelogPath); + if (entries.length > 0) { + changelogMarkdown = entries.map((e) => e.content).join("\n\n"); + settingsManager.setLastChangelogVersion(VERSION); + } + } else { + // Parse current and last versions + const changelogPath = getChangelogPath(); + const entries = parseChangelog(changelogPath); + const newEntries = getNewEntries(entries, lastVersion); + + if (newEntries.length > 0) { + changelogMarkdown = newEntries.map((e) => e.content).join("\n\n"); + settingsManager.setLastChangelogVersion(VERSION); + } + } + } + + // Show model scope if provided + if (scopedModels.length > 0) { + const modelList = scopedModels + .map((sm) => { + const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : ""; + return `${sm.model.id}${thinkingStr}`; + }) + .join(", "); + console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); + } + + // Ensure fd tool is available for file autocomplete + const fdPath = await ensureTool("fd"); + + // Interactive mode - use TUI (may have initial messages from CLI args) + const collapseChangelog = settingsManager.getCollapseChangelog(); + await runInteractiveMode( + agent, + sessionManager, + settingsManager, + VERSION, + changelogMarkdown, + collapseChangelog, + modelFallbackMessage, + versionCheckPromise, + scopedModels, + parsed.messages, + initialMessage, + initialAttachments, + fdPath, + ); + } else { + // Non-interactive mode (--print flag or --mode flag) + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + scopedModels, + fileCommands, + }); + await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments); + } +}