From ca5fb61d4248933804edaf4838f2b75619a6dfe4 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 1 Apr 2026 20:36:06 +0900 Subject: [PATCH] feat: interactive CLI with REPL, markdown rendering, and project init --- rust/crates/claw-cli/Cargo.toml | 27 + rust/crates/claw-cli/src/app.rs | 398 +++ rust/crates/claw-cli/src/args.rs | 104 + rust/crates/claw-cli/src/init.rs | 433 +++ rust/crates/claw-cli/src/input.rs | 269 ++ rust/crates/claw-cli/src/main.rs | 4768 ++++++++++++++++++++++++++++ rust/crates/claw-cli/src/render.rs | 797 +++++ 7 files changed, 6796 insertions(+) create mode 100644 rust/crates/claw-cli/Cargo.toml create mode 100644 rust/crates/claw-cli/src/app.rs create mode 100644 rust/crates/claw-cli/src/args.rs create mode 100644 rust/crates/claw-cli/src/init.rs create mode 100644 rust/crates/claw-cli/src/input.rs create mode 100644 rust/crates/claw-cli/src/main.rs create mode 100644 rust/crates/claw-cli/src/render.rs diff --git a/rust/crates/claw-cli/Cargo.toml b/rust/crates/claw-cli/Cargo.toml new file mode 100644 index 0000000..074718a --- /dev/null +++ b/rust/crates/claw-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "claw-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "claw" +path = "src/main.rs" + +[dependencies] +api = { path = "../api" } +commands = { path = "../commands" } +compat-harness = { path = "../compat-harness" } +crossterm = "0.28" +pulldown-cmark = "0.13" +rustyline = "15" +runtime = { path = "../runtime" } +plugins = { path = "../plugins" } +serde_json.workspace = true +syntect = "5" +tokio = { version = "1", features = ["rt-multi-thread", "time"] } +tools = { path = "../tools" } + +[lints] +workspace = true diff --git a/rust/crates/claw-cli/src/app.rs b/rust/crates/claw-cli/src/app.rs new file mode 100644 index 0000000..e012f27 --- /dev/null +++ b/rust/crates/claw-cli/src/app.rs @@ -0,0 +1,398 @@ +use std::io::{self, Write}; +use std::path::PathBuf; + +use crate::args::{OutputFormat, PermissionMode}; +use crate::input::{LineEditor, ReadOutcome}; +use crate::render::{Spinner, TerminalRenderer}; +use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionConfig { + pub model: String, + pub permission_mode: PermissionMode, + pub config: Option, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionState { + pub turns: usize, + pub compacted_messages: usize, + pub last_model: String, + pub last_usage: UsageSummary, +} + +impl SessionState { + #[must_use] + pub fn new(model: impl Into) -> Self { + Self { + turns: 0, + compacted_messages: 0, + last_model: model.into(), + last_usage: UsageSummary::default(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandResult { + Continue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SlashCommand { + Help, + Status, + Compact, + Unknown(String), +} + +impl SlashCommand { + #[must_use] + pub fn parse(input: &str) -> Option { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + + let command = trimmed + .trim_start_matches('/') + .split_whitespace() + .next() + .unwrap_or_default(); + Some(match command { + "help" => Self::Help, + "status" => Self::Status, + "compact" => Self::Compact, + other => Self::Unknown(other.to_string()), + }) + } +} + +struct SlashCommandHandler { + command: SlashCommand, + summary: &'static str, +} + +const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[ + SlashCommandHandler { + command: SlashCommand::Help, + summary: "Show command help", + }, + SlashCommandHandler { + command: SlashCommand::Status, + summary: "Show current session status", + }, + SlashCommandHandler { + command: SlashCommand::Compact, + summary: "Compact local session history", + }, +]; + +pub struct CliApp { + config: SessionConfig, + renderer: TerminalRenderer, + state: SessionState, + conversation_client: ConversationClient, + conversation_history: Vec, +} + +impl CliApp { + pub fn new(config: SessionConfig) -> Result { + let state = SessionState::new(config.model.clone()); + let conversation_client = ConversationClient::from_env(config.model.clone())?; + Ok(Self { + config, + renderer: TerminalRenderer::new(), + state, + conversation_client, + conversation_history: Vec::new(), + }) + } + + pub fn run_repl(&mut self) -> io::Result<()> { + let mut editor = LineEditor::new("› ", Vec::new()); + println!("Claw Code interactive mode"); + println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); + + loop { + match editor.read_line()? { + ReadOutcome::Submit(input) => { + if input.trim().is_empty() { + continue; + } + self.handle_submission(&input, &mut io::stdout())?; + } + ReadOutcome::Cancel => continue, + ReadOutcome::Exit => break, + } + } + + Ok(()) + } + + pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> { + self.render_response(prompt, out) + } + + pub fn handle_submission( + &mut self, + input: &str, + out: &mut impl Write, + ) -> io::Result { + if let Some(command) = SlashCommand::parse(input) { + return self.dispatch_slash_command(command, out); + } + + self.state.turns += 1; + self.render_response(input, out)?; + Ok(CommandResult::Continue) + } + + fn dispatch_slash_command( + &mut self, + command: SlashCommand, + out: &mut impl Write, + ) -> io::Result { + match command { + SlashCommand::Help => Self::handle_help(out), + SlashCommand::Status => self.handle_status(out), + SlashCommand::Compact => self.handle_compact(out), + SlashCommand::Unknown(name) => { + writeln!(out, "Unknown slash command: /{name}")?; + Ok(CommandResult::Continue) + } + } + } + + fn handle_help(out: &mut impl Write) -> io::Result { + writeln!(out, "Available commands:")?; + for handler in SLASH_COMMAND_HANDLERS { + let name = match handler.command { + SlashCommand::Help => "/help", + SlashCommand::Status => "/status", + SlashCommand::Compact => "/compact", + SlashCommand::Unknown(_) => continue, + }; + writeln!(out, " {name:<9} {}", handler.summary)?; + } + Ok(CommandResult::Continue) + } + + fn handle_status(&mut self, out: &mut impl Write) -> io::Result { + writeln!( + out, + "status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}", + self.state.turns, + self.state.last_model, + self.config.permission_mode, + self.config.output_format, + self.state.last_usage.input_tokens, + self.state.last_usage.output_tokens, + self.config + .config + .as_ref() + .map_or_else(|| String::from(""), |path| path.display().to_string()) + )?; + Ok(CommandResult::Continue) + } + + fn handle_compact(&mut self, out: &mut impl Write) -> io::Result { + self.state.compacted_messages += self.state.turns; + self.state.turns = 0; + self.conversation_history.clear(); + writeln!( + out, + "Compacted session history into a local summary ({} messages total compacted).", + self.state.compacted_messages + )?; + Ok(CommandResult::Continue) + } + + fn handle_stream_event( + renderer: &TerminalRenderer, + event: StreamEvent, + stream_spinner: &mut Spinner, + tool_spinner: &mut Spinner, + saw_text: &mut bool, + turn_usage: &mut UsageSummary, + out: &mut impl Write, + ) { + match event { + StreamEvent::TextDelta(delta) => { + if !*saw_text { + let _ = + stream_spinner.finish("Streaming response", renderer.color_theme(), out); + *saw_text = true; + } + let _ = write!(out, "{delta}"); + let _ = out.flush(); + } + StreamEvent::ToolCallStart { name, input } => { + if *saw_text { + let _ = writeln!(out); + } + let _ = tool_spinner.tick( + &format!("Running tool `{name}` with {input}"), + renderer.color_theme(), + out, + ); + } + StreamEvent::ToolCallResult { + name, + output, + is_error, + } => { + let label = if is_error { + format!("Tool `{name}` failed") + } else { + format!("Tool `{name}` completed") + }; + let _ = tool_spinner.finish(&label, renderer.color_theme(), out); + let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n"); + let _ = renderer.stream_markdown(&rendered_output, out); + } + StreamEvent::Usage(usage) => { + *turn_usage = usage; + } + } + } + + fn write_turn_output( + &self, + summary: &runtime::TurnSummary, + out: &mut impl Write, + ) -> io::Result<()> { + match self.config.output_format { + OutputFormat::Text => { + writeln!( + out, + "\nToken usage: {} input / {} output", + self.state.last_usage.input_tokens, self.state.last_usage.output_tokens + )?; + } + OutputFormat::Json => { + writeln!( + out, + "{}", + serde_json::json!({ + "message": summary.assistant_text, + "usage": { + "input_tokens": self.state.last_usage.input_tokens, + "output_tokens": self.state.last_usage.output_tokens, + } + }) + )?; + } + OutputFormat::Ndjson => { + writeln!( + out, + "{}", + serde_json::json!({ + "type": "message", + "text": summary.assistant_text, + "usage": { + "input_tokens": self.state.last_usage.input_tokens, + "output_tokens": self.state.last_usage.output_tokens, + } + }) + )?; + } + } + Ok(()) + } + + fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> { + let mut stream_spinner = Spinner::new(); + stream_spinner.tick( + "Opening conversation stream", + self.renderer.color_theme(), + out, + )?; + + let mut turn_usage = UsageSummary::default(); + let mut tool_spinner = Spinner::new(); + let mut saw_text = false; + let renderer = &self.renderer; + + let result = + self.conversation_client + .run_turn(&mut self.conversation_history, input, |event| { + Self::handle_stream_event( + renderer, + event, + &mut stream_spinner, + &mut tool_spinner, + &mut saw_text, + &mut turn_usage, + out, + ); + }); + + let summary = match result { + Ok(summary) => summary, + Err(error) => { + stream_spinner.fail( + "Streaming response failed", + self.renderer.color_theme(), + out, + )?; + return Err(io::Error::other(error)); + } + }; + self.state.last_usage = summary.usage.clone(); + if saw_text { + writeln!(out)?; + } else { + stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?; + } + + self.write_turn_output(&summary, out)?; + let _ = turn_usage; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::args::{OutputFormat, PermissionMode}; + + use super::{CommandResult, SessionConfig, SlashCommand}; + + #[test] + fn parses_required_slash_commands() { + assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); + assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); + assert_eq!( + SlashCommand::parse("/compact now"), + Some(SlashCommand::Compact) + ); + } + + #[test] + fn help_output_lists_commands() { + let mut out = Vec::new(); + let result = super::CliApp::handle_help(&mut out).expect("help succeeds"); + assert_eq!(result, CommandResult::Continue); + let output = String::from_utf8_lossy(&out); + assert!(output.contains("/help")); + assert!(output.contains("/status")); + assert!(output.contains("/compact")); + } + + #[test] + fn session_state_tracks_config_values() { + let config = SessionConfig { + model: "sonnet".into(), + permission_mode: PermissionMode::DangerFullAccess, + config: Some(PathBuf::from("settings.toml")), + output_format: OutputFormat::Text, + }; + + assert_eq!(config.model, "sonnet"); + assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess); + assert_eq!(config.config, Some(PathBuf::from("settings.toml"))); + } +} diff --git a/rust/crates/claw-cli/src/args.rs b/rust/crates/claw-cli/src/args.rs new file mode 100644 index 0000000..3c204a9 --- /dev/null +++ b/rust/crates/claw-cli/src/args.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Clone, Parser, PartialEq, Eq)] +#[command(name = "claw-cli", version, about = "Claw Code CLI")] +pub struct Cli { + #[arg(long, default_value = "claude-opus-4-6")] + pub model: String, + + #[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)] + pub permission_mode: PermissionMode, + + #[arg(long)] + pub config: Option, + + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub output_format: OutputFormat, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Clone, Subcommand, PartialEq, Eq)] +pub enum Command { + /// Read upstream TS sources and print extracted counts + DumpManifests, + /// Print the current bootstrap phase skeleton + BootstrapPlan, + /// Start the OAuth login flow + Login, + /// Clear saved OAuth credentials + Logout, + /// Run a non-interactive prompt and exit + Prompt { prompt: Vec }, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum PermissionMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum OutputFormat { + Text, + Json, + Ndjson, +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Command, OutputFormat, PermissionMode}; + + #[test] + fn parses_requested_flags() { + let cli = Cli::parse_from([ + "claw-cli", + "--model", + "claude-haiku-4-5-20251213", + "--permission-mode", + "read-only", + "--config", + "/tmp/config.toml", + "--output-format", + "ndjson", + "prompt", + "hello", + "world", + ]); + + assert_eq!(cli.model, "claude-haiku-4-5-20251213"); + assert_eq!(cli.permission_mode, PermissionMode::ReadOnly); + assert_eq!( + cli.config.as_deref(), + Some(std::path::Path::new("/tmp/config.toml")) + ); + assert_eq!(cli.output_format, OutputFormat::Ndjson); + assert_eq!( + cli.command, + Some(Command::Prompt { + prompt: vec!["hello".into(), "world".into()] + }) + ); + } + + #[test] + fn parses_login_and_logout_commands() { + let login = Cli::parse_from(["claw-cli", "login"]); + assert_eq!(login.command, Some(Command::Login)); + + let logout = Cli::parse_from(["claw-cli", "logout"]); + assert_eq!(logout.command, Some(Command::Logout)); + } + + #[test] + fn defaults_to_danger_full_access_permission_mode() { + let cli = Cli::parse_from(["claw-cli"]); + assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess); + } +} diff --git a/rust/crates/claw-cli/src/init.rs b/rust/crates/claw-cli/src/init.rs new file mode 100644 index 0000000..69095cd --- /dev/null +++ b/rust/crates/claw-cli/src/init.rs @@ -0,0 +1,433 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const STARTER_CLAW_JSON: &str = concat!( + "{\n", + " \"permissions\": {\n", + " \"defaultMode\": \"dontAsk\"\n", + " }\n", + "}\n", +); +const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts"; +const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InitStatus { + Created, + Updated, + Skipped, +} + +impl InitStatus { + #[must_use] + pub(crate) fn label(self) -> &'static str { + match self { + Self::Created => "created", + Self::Updated => "updated", + Self::Skipped => "skipped (already exists)", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InitArtifact { + pub(crate) name: &'static str, + pub(crate) status: InitStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InitReport { + pub(crate) project_root: PathBuf, + pub(crate) artifacts: Vec, +} + +impl InitReport { + #[must_use] + pub(crate) fn render(&self) -> String { + let mut lines = vec![ + "Init".to_string(), + format!(" Project {}", self.project_root.display()), + ]; + for artifact in &self.artifacts { + lines.push(format!( + " {:<16} {}", + artifact.name, + artifact.status.label() + )); + } + lines.push(" Next step Review and tailor the generated guidance".to_string()); + lines.join("\n") + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[allow(clippy::struct_excessive_bools)] +struct RepoDetection { + rust_workspace: bool, + rust_root: bool, + python: bool, + package_json: bool, + typescript: bool, + nextjs: bool, + react: bool, + vite: bool, + nest: bool, + src_dir: bool, + tests_dir: bool, + rust_dir: bool, +} + +pub(crate) fn initialize_repo(cwd: &Path) -> Result> { + let mut artifacts = Vec::new(); + + let claw_dir = cwd.join(".claw"); + artifacts.push(InitArtifact { + name: ".claw/", + status: ensure_dir(&claw_dir)?, + }); + + let claw_json = cwd.join(".claw.json"); + artifacts.push(InitArtifact { + name: ".claw.json", + status: write_file_if_missing(&claw_json, STARTER_CLAW_JSON)?, + }); + + let gitignore = cwd.join(".gitignore"); + artifacts.push(InitArtifact { + name: ".gitignore", + status: ensure_gitignore_entries(&gitignore)?, + }); + + let claw_md = cwd.join("CLAW.md"); + let content = render_init_claw_md(cwd); + artifacts.push(InitArtifact { + name: "CLAW.md", + status: write_file_if_missing(&claw_md, &content)?, + }); + + Ok(InitReport { + project_root: cwd.to_path_buf(), + artifacts, + }) +} + +fn ensure_dir(path: &Path) -> Result { + if path.is_dir() { + return Ok(InitStatus::Skipped); + } + fs::create_dir_all(path)?; + Ok(InitStatus::Created) +} + +fn write_file_if_missing(path: &Path, content: &str) -> Result { + if path.exists() { + return Ok(InitStatus::Skipped); + } + fs::write(path, content)?; + Ok(InitStatus::Created) +} + +fn ensure_gitignore_entries(path: &Path) -> Result { + if !path.exists() { + let mut lines = vec![GITIGNORE_COMMENT.to_string()]; + lines.extend(GITIGNORE_ENTRIES.iter().map(|entry| (*entry).to_string())); + fs::write(path, format!("{}\n", lines.join("\n")))?; + return Ok(InitStatus::Created); + } + + let existing = fs::read_to_string(path)?; + let mut lines = existing.lines().map(ToOwned::to_owned).collect::>(); + let mut changed = false; + + if !lines.iter().any(|line| line == GITIGNORE_COMMENT) { + lines.push(GITIGNORE_COMMENT.to_string()); + changed = true; + } + + for entry in GITIGNORE_ENTRIES { + if !lines.iter().any(|line| line == entry) { + lines.push(entry.to_string()); + changed = true; + } + } + + if !changed { + return Ok(InitStatus::Skipped); + } + + fs::write(path, format!("{}\n", lines.join("\n")))?; + Ok(InitStatus::Updated) +} + +pub(crate) fn render_init_claw_md(cwd: &Path) -> String { + let detection = detect_repo(cwd); + let mut lines = vec![ + "# CLAW.md".to_string(), + String::new(), + "This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(), + String::new(), + ]; + + let detected_languages = detected_languages(&detection); + let detected_frameworks = detected_frameworks(&detection); + lines.push("## Detected stack".to_string()); + if detected_languages.is_empty() { + lines.push("- No specific language markers were detected yet; document the primary language and verification commands once the project structure settles.".to_string()); + } else { + lines.push(format!("- Languages: {}.", detected_languages.join(", "))); + } + if detected_frameworks.is_empty() { + lines.push("- Frameworks: none detected from the supported starter markers.".to_string()); + } else { + lines.push(format!( + "- Frameworks/tooling markers: {}.", + detected_frameworks.join(", ") + )); + } + lines.push(String::new()); + + let verification_lines = verification_lines(cwd, &detection); + if !verification_lines.is_empty() { + lines.push("## Verification".to_string()); + lines.extend(verification_lines); + lines.push(String::new()); + } + + let structure_lines = repository_shape_lines(&detection); + if !structure_lines.is_empty() { + lines.push("## Repository shape".to_string()); + lines.extend(structure_lines); + lines.push(String::new()); + } + + let framework_lines = framework_notes(&detection); + if !framework_lines.is_empty() { + lines.push("## Framework notes".to_string()); + lines.extend(framework_lines); + lines.push(String::new()); + } + + lines.push("## Working agreement".to_string()); + lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string()); + lines.push("- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.".to_string()); + lines.push("- Do not overwrite existing `CLAW.md` content automatically; update it intentionally when repo workflows change.".to_string()); + lines.push(String::new()); + + lines.join("\n") +} + +fn detect_repo(cwd: &Path) -> RepoDetection { + let package_json_contents = fs::read_to_string(cwd.join("package.json")) + .unwrap_or_default() + .to_ascii_lowercase(); + RepoDetection { + rust_workspace: cwd.join("rust").join("Cargo.toml").is_file(), + rust_root: cwd.join("Cargo.toml").is_file(), + python: cwd.join("pyproject.toml").is_file() + || cwd.join("requirements.txt").is_file() + || cwd.join("setup.py").is_file(), + package_json: cwd.join("package.json").is_file(), + typescript: cwd.join("tsconfig.json").is_file() + || package_json_contents.contains("typescript"), + nextjs: package_json_contents.contains("\"next\""), + react: package_json_contents.contains("\"react\""), + vite: package_json_contents.contains("\"vite\""), + nest: package_json_contents.contains("@nestjs"), + src_dir: cwd.join("src").is_dir(), + tests_dir: cwd.join("tests").is_dir(), + rust_dir: cwd.join("rust").is_dir(), + } +} + +fn detected_languages(detection: &RepoDetection) -> Vec<&'static str> { + let mut languages = Vec::new(); + if detection.rust_workspace || detection.rust_root { + languages.push("Rust"); + } + if detection.python { + languages.push("Python"); + } + if detection.typescript { + languages.push("TypeScript"); + } else if detection.package_json { + languages.push("JavaScript/Node.js"); + } + languages +} + +fn detected_frameworks(detection: &RepoDetection) -> Vec<&'static str> { + let mut frameworks = Vec::new(); + if detection.nextjs { + frameworks.push("Next.js"); + } + if detection.react { + frameworks.push("React"); + } + if detection.vite { + frameworks.push("Vite"); + } + if detection.nest { + frameworks.push("NestJS"); + } + frameworks +} + +fn verification_lines(cwd: &Path, detection: &RepoDetection) -> Vec { + let mut lines = Vec::new(); + if detection.rust_workspace { + lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); + } else if detection.rust_root { + lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); + } + if detection.python { + if cwd.join("pyproject.toml").is_file() { + lines.push("- Run the Python project checks declared in `pyproject.toml` (for example: `pytest`, `ruff check`, and `mypy` when configured).".to_string()); + } else { + lines.push( + "- Run the repo's Python test/lint commands before shipping changes.".to_string(), + ); + } + } + if detection.package_json { + lines.push("- Run the JavaScript/TypeScript checks from `package.json` before shipping changes (`npm test`, `npm run lint`, `npm run build`, or the repo equivalent).".to_string()); + } + if detection.tests_dir && detection.src_dir { + lines.push("- `src/` and `tests/` are both present; update both surfaces together when behavior changes.".to_string()); + } + lines +} + +fn repository_shape_lines(detection: &RepoDetection) -> Vec { + let mut lines = Vec::new(); + if detection.rust_dir { + lines.push( + "- `rust/` contains the Rust workspace and active CLI/runtime implementation." + .to_string(), + ); + } + if detection.src_dir { + lines.push("- `src/` contains source files that should stay consistent with generated guidance and tests.".to_string()); + } + if detection.tests_dir { + lines.push("- `tests/` contains validation surfaces that should be reviewed alongside code changes.".to_string()); + } + lines +} + +fn framework_notes(detection: &RepoDetection) -> Vec { + let mut lines = Vec::new(); + if detection.nextjs { + lines.push("- Next.js detected: preserve routing/data-fetching conventions and verify production builds after changing app structure.".to_string()); + } + if detection.react && !detection.nextjs { + lines.push("- React detected: keep component behavior covered with focused tests and avoid unnecessary prop/API churn.".to_string()); + } + if detection.vite { + lines.push("- Vite detected: validate the production bundle after changing build-sensitive configuration or imports.".to_string()); + } + if detection.nest { + lines.push("- NestJS detected: keep module/provider boundaries explicit and verify controller/service wiring after refactors.".to_string()); + } + lines +} + +#[cfg(test)] +mod tests { + use super::{initialize_repo, render_init_claw_md}; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("claw-init-{nanos}")) + } + + #[test] + fn initialize_repo_creates_expected_files_and_gitignore_entries() { + let root = temp_dir(); + fs::create_dir_all(root.join("rust")).expect("create rust dir"); + fs::write(root.join("rust").join("Cargo.toml"), "[workspace]\n").expect("write cargo"); + + let report = initialize_repo(&root).expect("init should succeed"); + let rendered = report.render(); + assert!(rendered.contains(".claw/ created")); + assert!(rendered.contains(".claw.json created")); + assert!(rendered.contains(".gitignore created")); + assert!(rendered.contains("CLAW.md created")); + assert!(root.join(".claw").is_dir()); + assert!(root.join(".claw.json").is_file()); + assert!(root.join("CLAW.md").is_file()); + assert_eq!( + fs::read_to_string(root.join(".claw.json")).expect("read claw json"), + concat!( + "{\n", + " \"permissions\": {\n", + " \"defaultMode\": \"dontAsk\"\n", + " }\n", + "}\n", + ) + ); + let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore"); + assert!(gitignore.contains(".claw/settings.local.json")); + assert!(gitignore.contains(".claw/sessions/")); + let claw_md = fs::read_to_string(root.join("CLAW.md")).expect("read claw md"); + assert!(claw_md.contains("Languages: Rust.")); + assert!(claw_md.contains("cargo clippy --workspace --all-targets -- -D warnings")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn initialize_repo_is_idempotent_and_preserves_existing_files() { + let root = temp_dir(); + fs::create_dir_all(&root).expect("create root"); + fs::write(root.join("CLAW.md"), "custom guidance\n").expect("write existing claw md"); + fs::write(root.join(".gitignore"), ".claw/settings.local.json\n") + .expect("write gitignore"); + + let first = initialize_repo(&root).expect("first init should succeed"); + assert!(first + .render() + .contains("CLAW.md skipped (already exists)")); + let second = initialize_repo(&root).expect("second init should succeed"); + let second_rendered = second.render(); + assert!(second_rendered.contains(".claw/ skipped (already exists)")); + assert!(second_rendered.contains(".claw.json skipped (already exists)")); + assert!(second_rendered.contains(".gitignore skipped (already exists)")); + assert!(second_rendered.contains("CLAW.md skipped (already exists)")); + assert_eq!( + fs::read_to_string(root.join("CLAW.md")).expect("read existing claw md"), + "custom guidance\n" + ); + let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore"); + assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1); + assert_eq!(gitignore.matches(".claw/sessions/").count(), 1); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn render_init_template_mentions_detected_python_and_nextjs_markers() { + let root = temp_dir(); + fs::create_dir_all(&root).expect("create root"); + fs::write(root.join("pyproject.toml"), "[project]\nname = \"demo\"\n") + .expect("write pyproject"); + fs::write( + root.join("package.json"), + r#"{"dependencies":{"next":"14.0.0","react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}"#, + ) + .expect("write package json"); + + let rendered = render_init_claw_md(Path::new(&root)); + assert!(rendered.contains("Languages: Python, TypeScript.")); + assert!(rendered.contains("Frameworks/tooling markers: Next.js, React.")); + assert!(rendered.contains("pyproject.toml")); + assert!(rendered.contains("Next.js detected")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } +} diff --git a/rust/crates/claw-cli/src/input.rs b/rust/crates/claw-cli/src/input.rs new file mode 100644 index 0000000..1cf6029 --- /dev/null +++ b/rust/crates/claw-cli/src/input.rs @@ -0,0 +1,269 @@ +use std::borrow::Cow; +use std::cell::RefCell; +use std::io::{self, IsTerminal, Write}; + +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::{CmdKind, Highlighter}; +use rustyline::hint::Hinter; +use rustyline::history::DefaultHistory; +use rustyline::validate::Validator; +use rustyline::{ + Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReadOutcome { + Submit(String), + Cancel, + Exit, +} + +struct SlashCommandHelper { + completions: Vec, + current_line: RefCell, +} + +impl SlashCommandHelper { + fn new(completions: Vec) -> Self { + Self { + completions, + current_line: RefCell::new(String::new()), + } + } + + fn reset_current_line(&self) { + self.current_line.borrow_mut().clear(); + } + + fn current_line(&self) -> String { + self.current_line.borrow().clone() + } + + fn set_current_line(&self, line: &str) { + let mut current = self.current_line.borrow_mut(); + current.clear(); + current.push_str(line); + } +} + +impl Completer for SlashCommandHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let Some(prefix) = slash_command_prefix(line, pos) else { + return Ok((0, Vec::new())); + }; + + let matches = self + .completions + .iter() + .filter(|candidate| candidate.starts_with(prefix)) + .map(|candidate| Pair { + display: candidate.clone(), + replacement: candidate.clone(), + }) + .collect(); + + Ok((0, matches)) + } +} + +impl Hinter for SlashCommandHelper { + type Hint = String; +} + +impl Highlighter for SlashCommandHelper { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + self.set_current_line(line); + Cow::Borrowed(line) + } + + fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool { + self.set_current_line(line); + false + } +} + +impl Validator for SlashCommandHelper {} +impl Helper for SlashCommandHelper {} + +pub struct LineEditor { + prompt: String, + editor: Editor, +} + +impl LineEditor { + #[must_use] + pub fn new(prompt: impl Into, completions: Vec) -> Self { + let config = Config::builder() + .completion_type(CompletionType::List) + .edit_mode(EditMode::Emacs) + .build(); + let mut editor = Editor::::with_config(config) + .expect("rustyline editor should initialize"); + editor.set_helper(Some(SlashCommandHelper::new(completions))); + editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline); + editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline); + + Self { + prompt: prompt.into(), + editor, + } + } + + pub fn push_history(&mut self, entry: impl Into) { + let entry = entry.into(); + if entry.trim().is_empty() { + return; + } + + let _ = self.editor.add_history_entry(entry); + } + + pub fn read_line(&mut self) -> io::Result { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return self.read_line_fallback(); + } + + if let Some(helper) = self.editor.helper_mut() { + helper.reset_current_line(); + } + + match self.editor.readline(&self.prompt) { + Ok(line) => Ok(ReadOutcome::Submit(line)), + Err(ReadlineError::Interrupted) => { + let has_input = !self.current_line().is_empty(); + self.finish_interrupted_read()?; + if has_input { + Ok(ReadOutcome::Cancel) + } else { + Ok(ReadOutcome::Exit) + } + } + Err(ReadlineError::Eof) => { + self.finish_interrupted_read()?; + Ok(ReadOutcome::Exit) + } + Err(error) => Err(io::Error::other(error)), + } + } + + fn current_line(&self) -> String { + self.editor + .helper() + .map_or_else(String::new, SlashCommandHelper::current_line) + } + + fn finish_interrupted_read(&mut self) -> io::Result<()> { + if let Some(helper) = self.editor.helper_mut() { + helper.reset_current_line(); + } + let mut stdout = io::stdout(); + writeln!(stdout) + } + + fn read_line_fallback(&self) -> io::Result { + let mut stdout = io::stdout(); + write!(stdout, "{}", self.prompt)?; + stdout.flush()?; + + let mut buffer = String::new(); + let bytes_read = io::stdin().read_line(&mut buffer)?; + if bytes_read == 0 { + return Ok(ReadOutcome::Exit); + } + + while matches!(buffer.chars().last(), Some('\n' | '\r')) { + buffer.pop(); + } + Ok(ReadOutcome::Submit(buffer)) + } +} + +fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> { + if pos != line.len() { + return None; + } + + let prefix = &line[..pos]; + if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') { + return None; + } + + Some(prefix) +} + +#[cfg(test)] +mod tests { + use super::{slash_command_prefix, LineEditor, SlashCommandHelper}; + use rustyline::completion::Completer; + use rustyline::highlight::Highlighter; + use rustyline::history::{DefaultHistory, History}; + use rustyline::Context; + + #[test] + fn extracts_only_terminal_slash_command_prefixes() { + assert_eq!(slash_command_prefix("/he", 3), Some("/he")); + assert_eq!(slash_command_prefix("/help me", 5), None); + assert_eq!(slash_command_prefix("hello", 5), None); + assert_eq!(slash_command_prefix("/help", 2), None); + } + + #[test] + fn completes_matching_slash_commands() { + let helper = SlashCommandHelper::new(vec![ + "/help".to_string(), + "/hello".to_string(), + "/status".to_string(), + ]); + let history = DefaultHistory::new(); + let ctx = Context::new(&history); + let (start, matches) = helper + .complete("/he", 3, &ctx) + .expect("completion should work"); + + assert_eq!(start, 0); + assert_eq!( + matches + .into_iter() + .map(|candidate| candidate.replacement) + .collect::>(), + vec!["/help".to_string(), "/hello".to_string()] + ); + } + + #[test] + fn ignores_non_slash_command_completion_requests() { + let helper = SlashCommandHelper::new(vec!["/help".to_string()]); + let history = DefaultHistory::new(); + let ctx = Context::new(&history); + let (_, matches) = helper + .complete("hello", 5, &ctx) + .expect("completion should work"); + + assert!(matches.is_empty()); + } + + #[test] + fn tracks_current_buffer_through_highlighter() { + let helper = SlashCommandHelper::new(Vec::new()); + let _ = helper.highlight("draft", 5); + + assert_eq!(helper.current_line(), "draft"); + } + + #[test] + fn push_history_ignores_blank_entries() { + let mut editor = LineEditor::new("> ", vec!["/help".to_string()]); + editor.push_history(" "); + editor.push_history("/help"); + + assert_eq!(editor.editor.history().len(), 1); + } +} diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs new file mode 100644 index 0000000..0abaad5 --- /dev/null +++ b/rust/crates/claw-cli/src/main.rs @@ -0,0 +1,4768 @@ +mod init; +mod input; +mod render; + +use std::collections::BTreeSet; +use std::env; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use api::{ + resolve_startup_auth_source, ClawApiClient, AuthSource, ContentBlockDelta, InputContentBlock, + InputMessage, MessageRequest, MessageResponse, OutputContentBlock, + StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, +}; + +use commands::{ + handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, + render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, +}; +use compat_harness::{extract_manifest, UpstreamPaths}; +use init::initialize_repo; +use plugins::{PluginManager, PluginManagerConfig}; +use render::{MarkdownStreamState, Spinner, TerminalRenderer}; +use runtime::{ + clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, + parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, + AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, + ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, + OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, + Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde_json::json; +use tools::GlobalToolRegistry; + +const DEFAULT_MODEL: &str = "claude-opus-4-6"; +fn max_tokens_for_model(model: &str) -> u32 { + if model.contains("opus") { + 32_000 + } else { + 64_000 + } +} +const DEFAULT_DATE: &str = "2026-03-31"; +const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const BUILD_TARGET: Option<&str> = option_env!("TARGET"); +const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); + +type AllowedToolSet = BTreeSet; + +fn main() { + if let Err(error) = run() { + eprintln!( + "error: {error} + +Run `claw --help` for usage." + ); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let args: Vec = env::args().skip(1).collect(); + match parse_args(&args)? { + CliAction::DumpManifests => dump_manifests(), + CliAction::BootstrapPlan => print_bootstrap_plan(), + CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, + CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, + CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), + CliAction::Version => print_version(), + CliAction::ResumeSession { + session_path, + commands, + } => resume_session(&session_path, &commands), + CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + } => LiveCli::new(model, true, allowed_tools, permission_mode)? + .run_turn_with_output(&prompt, output_format)?, + CliAction::Login => run_login()?, + CliAction::Logout => run_logout()?, + CliAction::Init => run_init()?, + CliAction::Repl { + model, + allowed_tools, + permission_mode, + } => run_repl(model, allowed_tools, permission_mode)?, + CliAction::Help => print_help(), + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CliAction { + DumpManifests, + BootstrapPlan, + Agents { + args: Option, + }, + Skills { + args: Option, + }, + PrintSystemPrompt { + cwd: PathBuf, + date: String, + }, + Version, + ResumeSession { + session_path: PathBuf, + commands: Vec, + }, + Prompt { + prompt: String, + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, + }, + Login, + Logout, + Init, + Repl { + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + }, + // prompt-mode formatting is only supported for non-interactive runs + Help, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CliOutputFormat { + Text, + Json, +} + +impl CliOutputFormat { + fn parse(value: &str) -> Result { + match value { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + other => Err(format!( + "unsupported value for --output-format: {other} (expected text or json)" + )), + } + } +} + +#[allow(clippy::too_many_lines)] +fn parse_args(args: &[String]) -> Result { + let mut model = DEFAULT_MODEL.to_string(); + let mut output_format = CliOutputFormat::Text; + let mut permission_mode = default_permission_mode(); + let mut wants_version = false; + let mut allowed_tool_values = Vec::new(); + let mut rest = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--version" | "-V" => { + wants_version = true; + index += 1; + } + "--model" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --model".to_string())?; + model = resolve_model_alias(value).to_string(); + index += 2; + } + flag if flag.starts_with("--model=") => { + model = resolve_model_alias(&flag[8..]).to_string(); + index += 1; + } + "--output-format" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --output-format".to_string())?; + output_format = CliOutputFormat::parse(value)?; + index += 2; + } + "--permission-mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --permission-mode".to_string())?; + permission_mode = parse_permission_mode_arg(value)?; + index += 2; + } + flag if flag.starts_with("--output-format=") => { + output_format = CliOutputFormat::parse(&flag[16..])?; + index += 1; + } + flag if flag.starts_with("--permission-mode=") => { + permission_mode = parse_permission_mode_arg(&flag[18..])?; + index += 1; + } + "--dangerously-skip-permissions" => { + permission_mode = PermissionMode::DangerFullAccess; + index += 1; + } + "-p" => { + // Claw Code compat: -p "prompt" = one-shot prompt + let prompt = args[index + 1..].join(" "); + if prompt.trim().is_empty() { + return Err("-p requires a prompt string".to_string()); + } + return Ok(CliAction::Prompt { + prompt, + model: resolve_model_alias(&model).to_string(), + output_format, + allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, + permission_mode, + }); + } + "--print" => { + // Claw Code compat: --print makes output non-interactive + output_format = CliOutputFormat::Text; + index += 1; + } + "--allowedTools" | "--allowed-tools" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --allowedTools".to_string())?; + allowed_tool_values.push(value.clone()); + index += 2; + } + flag if flag.starts_with("--allowedTools=") => { + allowed_tool_values.push(flag[15..].to_string()); + index += 1; + } + flag if flag.starts_with("--allowed-tools=") => { + allowed_tool_values.push(flag[16..].to_string()); + index += 1; + } + other => { + rest.push(other.to_string()); + index += 1; + } + } + } + + if wants_version { + return Ok(CliAction::Version); + } + + let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + + if rest.is_empty() { + return Ok(CliAction::Repl { + model, + allowed_tools, + permission_mode, + }); + } + if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { + return Ok(CliAction::Help); + } + if rest.first().map(String::as_str) == Some("--resume") { + return parse_resume_args(&rest[1..]); + } + + match rest[0].as_str() { + "dump-manifests" => Ok(CliAction::DumpManifests), + "bootstrap-plan" => Ok(CliAction::BootstrapPlan), + "agents" => Ok(CliAction::Agents { + args: join_optional_args(&rest[1..]), + }), + "skills" => Ok(CliAction::Skills { + args: join_optional_args(&rest[1..]), + }), + "system-prompt" => parse_system_prompt_args(&rest[1..]), + "login" => Ok(CliAction::Login), + "logout" => Ok(CliAction::Logout), + "init" => Ok(CliAction::Init), + "prompt" => { + let prompt = rest[1..].join(" "); + if prompt.trim().is_empty() { + return Err("prompt subcommand requires a prompt string".to_string()); + } + Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }) + } + other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), + _other => Ok(CliAction::Prompt { + prompt: rest.join(" "), + model, + output_format, + allowed_tools, + permission_mode, + }), + } +} + +fn join_optional_args(args: &[String]) -> Option { + let joined = args.join(" "); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn parse_direct_slash_cli_action(rest: &[String]) -> Result { + let raw = rest.join(" "); + match SlashCommand::parse(&raw) { + Some(SlashCommand::Help) => Ok(CliAction::Help), + Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), + Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), + Some(command) => Err(format!( + "unsupported direct slash command outside the REPL: {command_name}", + command_name = match command { + SlashCommand::Unknown(name) => format!("/{name}"), + _ => rest[0].clone(), + } + )), + None => Err(format!("unknown subcommand: {}", rest[0])), + } +} + +fn resolve_model_alias(model: &str) -> &str { + match model { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-4-5-20251213", + _ => model, + } +} + +fn normalize_allowed_tools(values: &[String]) -> Result, String> { + current_tool_registry()?.normalize_allowed_tools(values) +} + +fn current_tool_registry() -> Result { + let cwd = env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_tools = plugin_manager + .aggregated_tools() + .map_err(|error| error.to_string())?; + GlobalToolRegistry::with_plugin_tools(plugin_tools) +} + +fn parse_permission_mode_arg(value: &str) -> Result { + normalize_permission_mode(value) + .ok_or_else(|| { + format!( + "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access." + ) + }) + .map(permission_mode_from_label) +} + +fn permission_mode_from_label(mode: &str) -> PermissionMode { + match mode { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported permission mode label: {other}"), + } +} + +fn default_permission_mode() -> PermissionMode { + env::var("CLAW_PERMISSION_MODE") + .ok() + .as_deref() + .and_then(normalize_permission_mode) + .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label) +} + +fn filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec { + tool_registry.definitions(allowed_tools) +} + +fn parse_system_prompt_args(args: &[String]) -> Result { + let mut cwd = env::current_dir().map_err(|error| error.to_string())?; + let mut date = DEFAULT_DATE.to_string(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--cwd" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --cwd".to_string())?; + cwd = PathBuf::from(value); + index += 2; + } + "--date" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --date".to_string())?; + date.clone_from(value); + index += 2; + } + other => return Err(format!("unknown system-prompt option: {other}")), + } + } + + Ok(CliAction::PrintSystemPrompt { cwd, date }) +} + +fn parse_resume_args(args: &[String]) -> Result { + let session_path = args + .first() + .ok_or_else(|| "missing session path for --resume".to_string()) + .map(PathBuf::from)?; + let commands = args[1..].to_vec(); + if commands + .iter() + .any(|command| !command.trim_start().starts_with('/')) + { + return Err("--resume trailing arguments must be slash commands".to_string()); + } + Ok(CliAction::ResumeSession { + session_path, + commands, + }) +} + +fn dump_manifests() { + let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let paths = UpstreamPaths::from_workspace_dir(&workspace_dir); + match extract_manifest(&paths) { + Ok(manifest) => { + println!("commands: {}", manifest.commands.entries().len()); + println!("tools: {}", manifest.tools.entries().len()); + println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); + } + Err(error) => { + eprintln!("failed to extract manifests: {error}"); + std::process::exit(1); + } + } +} + +fn print_bootstrap_plan() { + for phase in runtime::BootstrapPlan::claw_default().phases() { + println!("- {phase:?}"); + } +} + +fn default_oauth_config() -> OAuthConfig { + OAuthConfig { + client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"), + authorize_url: String::from("https://platform.claw.dev/oauth/authorize"), + token_url: String::from("https://platform.claw.dev/v1/oauth/token"), + callback_port: None, + manual_redirect_url: None, + scopes: vec![ + String::from("user:profile"), + String::from("user:inference"), + String::from("user:sessions:claw_code"), + ], + } +} + +fn run_login() -> Result<(), Box> { + let cwd = env::current_dir()?; + let config = ConfigLoader::default_for(&cwd).load()?; + let default_oauth = default_oauth_config(); + let oauth = config.oauth().unwrap_or(&default_oauth); + let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT); + let redirect_uri = runtime::loopback_redirect_uri(callback_port); + let pkce = generate_pkce_pair()?; + let state = generate_state()?; + let authorize_url = + OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce) + .build_url(); + + println!("Starting Claw OAuth login..."); + println!("Listening for callback on {redirect_uri}"); + if let Err(error) = open_browser(&authorize_url) { + eprintln!("warning: failed to open browser automatically: {error}"); + println!("Open this URL manually:\n{authorize_url}"); + } + + let callback = wait_for_oauth_callback(callback_port)?; + if let Some(error) = callback.error { + let description = callback + .error_description + .unwrap_or_else(|| "authorization failed".to_string()); + return Err(io::Error::other(format!("{error}: {description}")).into()); + } + let code = callback.code.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "callback did not include code") + })?; + let returned_state = callback.state.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "callback did not include state") + })?; + if returned_state != state { + return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into()); + } + + let client = ClawApiClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); + let exchange_request = + OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri); + let runtime = tokio::runtime::Runtime::new()?; + let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?; + save_oauth_credentials(&runtime::OAuthTokenSet { + access_token: token_set.access_token, + refresh_token: token_set.refresh_token, + expires_at: token_set.expires_at, + scopes: token_set.scopes, + })?; + println!("Claw OAuth login complete."); + Ok(()) +} + +fn run_logout() -> Result<(), Box> { + clear_oauth_credentials()?; + println!("Claw OAuth credentials cleared."); + Ok(()) +} + +fn open_browser(url: &str) -> io::Result<()> { + let commands = if cfg!(target_os = "macos") { + vec![("open", vec![url])] + } else if cfg!(target_os = "windows") { + vec![("cmd", vec!["/C", "start", "", url])] + } else { + vec![("xdg-open", vec![url])] + }; + for (program, args) in commands { + match Command::new(program).args(args).spawn() { + Ok(_) => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "no supported browser opener command found", + )) +} + +fn wait_for_oauth_callback( + port: u16, +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", port))?; + let (mut stream, _) = listener.accept()?; + let mut buffer = [0_u8; 4096]; + let bytes_read = stream.read(&mut buffer)?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let request_line = request.lines().next().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "missing callback request line") + })?; + let target = request_line.split_whitespace().nth(1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "missing callback request target", + ) + })?; + let callback = parse_oauth_callback_request_target(target) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let body = if callback.error.is_some() { + "Claw OAuth login failed. You can close this window." + } else { + "Claw OAuth login succeeded. You can close this window." + }; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes())?; + Ok(callback) +} + +fn print_system_prompt(cwd: PathBuf, date: String) { + match load_system_prompt(cwd, date, env::consts::OS, "unknown") { + Ok(sections) => println!("{}", sections.join("\n\n")), + Err(error) => { + eprintln!("failed to build system prompt: {error}"); + std::process::exit(1); + } + } +} + +fn print_version() { + println!("{}", render_version_report()); +} + +fn resume_session(session_path: &Path, commands: &[String]) { + let session = match Session::load_from_path(session_path) { + Ok(session) => session, + Err(error) => { + eprintln!("failed to restore session: {error}"); + std::process::exit(1); + } + }; + + if commands.is_empty() { + println!( + "Restored session from {} ({} messages).", + session_path.display(), + session.messages.len() + ); + return; + } + + let mut session = session; + for raw_command in commands { + let Some(command) = SlashCommand::parse(raw_command) else { + eprintln!("unsupported resumed command: {raw_command}"); + std::process::exit(2); + }; + match run_resume_command(session_path, &session, &command) { + Ok(ResumeCommandOutcome { + session: next_session, + message, + }) => { + session = next_session; + if let Some(message) = message { + println!("{message}"); + } + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } + } +} + +#[derive(Debug, Clone)] +struct ResumeCommandOutcome { + session: Session, + message: Option, +} + +#[derive(Debug, Clone)] +struct StatusContext { + cwd: PathBuf, + session_path: Option, + loaded_config_files: usize, + discovered_config_files: usize, + memory_file_count: usize, + project_root: Option, + git_branch: Option, +} + +#[derive(Debug, Clone, Copy)] +struct StatusUsage { + message_count: usize, + turns: u32, + latest: TokenUsage, + cumulative: TokenUsage, + estimated_tokens: usize, +} + +fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { + format!( + "Model + Current model {model} + Session messages {message_count} + Session turns {turns} + +Usage + Inspect current model with /model + Switch models with /model " + ) +} + +fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { + format!( + "Model updated + Previous {previous} + Current {next} + Preserved msgs {message_count}" + ) +} + +fn format_permissions_report(mode: &str) -> String { + let modes = [ + ("read-only", "Read/search tools only", mode == "read-only"), + ( + "workspace-write", + "Edit files inside the workspace", + mode == "workspace-write", + ), + ( + "danger-full-access", + "Unrestricted tool access", + mode == "danger-full-access", + ), + ] + .into_iter() + .map(|(name, description, is_current)| { + let marker = if is_current { + "● current" + } else { + "○ available" + }; + format!(" {name:<18} {marker:<11} {description}") + }) + .collect::>() + .join( + " +", + ); + + format!( + "Permissions + Active mode {mode} + Mode status live session default + +Modes +{modes} + +Usage + Inspect current mode with /permissions + Switch modes with /permissions " + ) +} + +fn format_permissions_switch_report(previous: &str, next: &str) -> String { + format!( + "Permissions updated + Result mode switched + Previous mode {previous} + Active mode {next} + Applies to subsequent tool calls + Usage /permissions to inspect current mode" + ) +} + +fn format_cost_report(usage: TokenUsage) -> String { + format!( + "Cost + Input tokens {} + Output tokens {} + Cache create {} + Cache read {} + Total tokens {}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ) +} + +fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { + format!( + "Session resumed + Session file {session_path} + Messages {message_count} + Turns {turns}" + ) +} + +fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { + if skipped { + format!( + "Compact + Result skipped + Reason session below compaction threshold + Messages kept {resulting_messages}" + ) + } else { + format!( + "Compact + Result compacted + Messages removed {removed} + Messages kept {resulting_messages}" + ) + } +} + +fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { + let Some(status) = status else { + return (None, None); + }; + let branch = status.lines().next().and_then(|line| { + line.strip_prefix("## ") + .map(|line| { + line.split(['.', ' ']) + .next() + .unwrap_or_default() + .to_string() + }) + .filter(|value| !value.is_empty()) + }); + let project_root = find_git_root().ok(); + (project_root, branch) +} + +fn find_git_root() -> Result> { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + return Err("not a git repository".into()); + } + let path = String::from_utf8(output.stdout)?.trim().to_string(); + if path.is_empty() { + return Err("empty git root".into()); + } + Ok(PathBuf::from(path)) +} + +#[allow(clippy::too_many_lines)] +fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result> { + match command { + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + }), + SlashCommand::Compact => { + let result = runtime::compact_session( + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + result.compacted_session.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: result.compacted_session, + message: Some(format_compact_report(removed, kept, skipped)), + }) + } + SlashCommand::Clear { confirm } => { + if !confirm { + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), + }); + } + let cleared = Session::new(); + cleared.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: cleared, + message: Some(format!( + "Cleared resumed session file {}.", + session_path.display() + )), + }) + } + SlashCommand::Status => { + let tracker = UsageTracker::from_session(session); + let usage = tracker.cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_status_report( + "restored-session", + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &status_context(Some(session_path))?, + )), + }) + } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + }) + } + SlashCommand::Config { section } => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_config_report(section.as_deref())?), + }), + SlashCommand::Memory => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_memory_report()?), + }), + SlashCommand::Init => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(init_claw_md()?), + }), + SlashCommand::Diff => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_diff_report()?), + }), + SlashCommand::Version => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_version_report()), + }), + SlashCommand::Export { path } => { + let export_path = resolve_export_path(path.as_deref(), session)?; + fs::write(&export_path, render_export_text(session))?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + session.messages.len(), + )), + }) + } + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Skills { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall + | SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } + | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), + } +} + +fn run_repl( + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, +) -> Result<(), Box> { + let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; + let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates()); + println!("{}", cli.startup_banner()); + + loop { + match editor.read_line()? { + input::ReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.persist_session()?; + break; + } + if let Some(command) = SlashCommand::parse(&trimmed) { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + editor.push_history(input); + cli.run_turn(&trimmed)?; + } + input::ReadOutcome::Cancel => {} + input::ReadOutcome::Exit => { + cli.persist_session()?; + break; + } + } + } + + Ok(()) +} + +#[derive(Debug, Clone)] +struct SessionHandle { + id: String, + path: PathBuf, +} + +#[derive(Debug, Clone)] +struct ManagedSessionSummary { + id: String, + path: PathBuf, + modified_epoch_secs: u64, + message_count: usize, +} + +struct LiveCli { + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + system_prompt: Vec, + runtime: ConversationRuntime, + session: SessionHandle, +} + +impl LiveCli { + fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + ) -> Result> { + let system_prompt = build_system_prompt()?; + let session = create_managed_session_handle()?; + let runtime = build_runtime( + Session::new(), + model.clone(), + system_prompt.clone(), + enable_tools, + true, + allowed_tools.clone(), + permission_mode, + None, + )?; + let cli = Self { + model, + allowed_tools, + permission_mode, + system_prompt, + runtime, + session, + }; + cli.persist_session()?; + Ok(cli) + } + + fn startup_banner(&self) -> String { + let cwd = env::current_dir().map_or_else( + |_| "".to_string(), + |path| path.display().to_string(), + ); + format!( + "\x1b[38;5;196m\ + ██████╗██╗ █████╗ ██╗ ██╗\n\ +██╔════╝██║ ██╔══██╗██║ ██║\n\ +██║ ██║ ███████║██║ █╗ ██║\n\ +██║ ██║ ██╔══██║██║███╗██║\n\ +╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ + ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ + \x1b[2mModel\x1b[0m {}\n\ + \x1b[2mPermissions\x1b[0m {}\n\ + \x1b[2mDirectory\x1b[0m {}\n\ + \x1b[2mSession\x1b[0m {}\n\n\ + Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline", + self.model, + self.permission_mode.as_str(), + cwd, + self.session.id, + ) + } + + fn run_turn(&mut self, input: &str) -> Result<(), Box> { + let mut spinner = Spinner::new(); + let mut stdout = io::stdout(); + spinner.tick( + "🦀 Thinking...", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); + match result { + Ok(_) => { + spinner.finish( + "✨ Done", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + println!(); + self.persist_session()?; + Ok(()) + } + Err(error) => { + spinner.fail( + "❌ Request failed", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + Err(Box::new(error)) + } + } + } + + fn run_turn_with_output( + &mut self, + input: &str, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => self.run_turn(input), + CliOutputFormat::Json => self.run_prompt_json(input), + } + } + + fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + false, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(input, Some(&mut permission_prompter))?; + self.runtime = runtime; + self.persist_session()?; + println!( + "{}", + json!({ + "message": final_assistant_text(&summary), + "model": self.model, + "iterations": summary.iterations, + "tool_uses": collect_tool_uses(&summary), + "tool_results": collect_tool_results(&summary), + "usage": { + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + } + }) + ); + Ok(()) + } + + fn handle_repl_command( + &mut self, + command: SlashCommand, + ) -> Result> { + Ok(match command { + SlashCommand::Help => { + println!("{}", render_repl_help()); + false + } + SlashCommand::Status => { + self.print_status(); + false + } + SlashCommand::Bughunter { scope } => { + self.run_bughunter(scope.as_deref())?; + false + } + SlashCommand::Commit => { + self.run_commit()?; + true + } + SlashCommand::Pr { context } => { + self.run_pr(context.as_deref())?; + false + } + SlashCommand::Issue { context } => { + self.run_issue(context.as_deref())?; + false + } + SlashCommand::Ultraplan { task } => { + self.run_ultraplan(task.as_deref())?; + false + } + SlashCommand::Teleport { target } => { + self.run_teleport(target.as_deref())?; + false + } + SlashCommand::DebugToolCall => { + self.run_debug_tool_call()?; + false + } + SlashCommand::Compact => { + self.compact()?; + false + } + SlashCommand::Model { model } => self.set_model(model)?, + SlashCommand::Permissions { mode } => self.set_permissions(mode)?, + SlashCommand::Clear { confirm } => self.clear_session(confirm)?, + SlashCommand::Cost => { + self.print_cost(); + false + } + SlashCommand::Resume { session_path } => self.resume_session(session_path)?, + SlashCommand::Config { section } => { + Self::print_config(section.as_deref())?; + false + } + SlashCommand::Memory => { + Self::print_memory()?; + false + } + SlashCommand::Init => { + run_init()?; + false + } + SlashCommand::Diff => { + Self::print_diff()?; + false + } + SlashCommand::Version => { + Self::print_version(); + false + } + SlashCommand::Export { path } => { + self.export_session(path.as_deref())?; + false + } + SlashCommand::Session { action, target } => { + self.handle_session_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Plugins { action, target } => { + self.handle_plugins_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Agents { args } => { + Self::print_agents(args.as_deref())?; + false + } + SlashCommand::Skills { args } => { + Self::print_skills(args.as_deref())?; + false + } + SlashCommand::Unknown(name) => { + eprintln!("unknown slash command: /{name}"); + false + } + }) + } + + fn persist_session(&self) -> Result<(), Box> { + self.runtime.session().save_to_path(&self.session.path)?; + Ok(()) + } + + fn print_status(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + let latest = self.runtime.usage().current_turn_usage(); + println!( + "{}", + format_status_report( + &self.model, + StatusUsage { + message_count: self.runtime.session().messages.len(), + turns: self.runtime.usage().turns(), + latest, + cumulative, + estimated_tokens: self.runtime.estimated_tokens(), + }, + self.permission_mode.as_str(), + &status_context(Some(&self.session.path)).expect("status context should load"), + ) + ); + } + + fn set_model(&mut self, model: Option) -> Result> { + let Some(model) = model else { + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); + return Ok(false); + }; + + let model = resolve_model_alias(&model).to_string(); + + if model == self.model { + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); + return Ok(false); + } + + let previous = self.model.clone(); + let session = self.runtime.session().clone(); + let message_count = session.messages.len(); + self.runtime = build_runtime( + session, + model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.model.clone_from(&model); + println!( + "{}", + format_model_switch_report(&previous, &model, message_count) + ); + Ok(true) + } + + fn set_permissions( + &mut self, + mode: Option, + ) -> Result> { + let Some(mode) = mode else { + println!( + "{}", + format_permissions_report(self.permission_mode.as_str()) + ); + return Ok(false); + }; + + let normalized = normalize_permission_mode(&mode).ok_or_else(|| { + format!( + "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + ) + })?; + + if normalized == self.permission_mode.as_str() { + println!("{}", format_permissions_report(normalized)); + return Ok(false); + } + + let previous = self.permission_mode.as_str().to_string(); + let session = self.runtime.session().clone(); + self.permission_mode = permission_mode_from_label(normalized); + self.runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + println!( + "{}", + format_permissions_switch_report(&previous, normalized) + ); + Ok(true) + } + + fn clear_session(&mut self, confirm: bool) -> Result> { + if !confirm { + println!( + "clear: confirmation required; run /clear --confirm to start a fresh session." + ); + return Ok(false); + } + + self.session = create_managed_session_handle()?; + self.runtime = build_runtime( + Session::new(), + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + println!( + "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", + self.model, + self.permission_mode.as_str(), + self.session.id, + ); + Ok(true) + } + + fn print_cost(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + println!("{}", format_cost_report(cumulative)); + } + + fn resume_session( + &mut self, + session_path: Option, + ) -> Result> { + let Some(session_ref) = session_path else { + println!("Usage: /resume "); + return Ok(false); + }; + + let handle = resolve_session_reference(&session_ref)?; + let session = Session::load_from_path(&handle.path)?; + let message_count = session.messages.len(); + self.runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.session = handle; + println!( + "{}", + format_resume_report( + &self.session.path.display().to_string(), + message_count, + self.runtime.usage().turns(), + ) + ); + Ok(true) + } + + fn print_config(section: Option<&str>) -> Result<(), Box> { + println!("{}", render_config_report(section)?); + Ok(()) + } + + fn print_memory() -> Result<(), Box> { + println!("{}", render_memory_report()?); + Ok(()) + } + + fn print_agents(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_agents_slash_command(args, &cwd)?); + Ok(()) + } + + fn print_skills(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_skills_slash_command(args, &cwd)?); + Ok(()) + } + + fn print_diff() -> Result<(), Box> { + println!("{}", render_diff_report()?); + Ok(()) + } + + fn print_version() { + println!("{}", render_version_report()); + } + + fn export_session( + &self, + requested_path: Option<&str>, + ) -> Result<(), Box> { + let export_path = resolve_export_path(requested_path, self.runtime.session())?; + fs::write(&export_path, render_export_text(self.runtime.session()))?; + println!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + self.runtime.session().messages.len(), + ); + Ok(()) + } + + fn handle_session_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + match action { + None | Some("list") => { + println!("{}", render_session_list(&self.session.id)?); + Ok(false) + } + Some("switch") => { + let Some(target) = target else { + println!("Usage: /session switch "); + return Ok(false); + }; + let handle = resolve_session_reference(target)?; + let session = Session::load_from_path(&handle.path)?; + let message_count = session.messages.len(); + self.runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.session = handle; + println!( + "Session switched\n Active session {}\n File {}\n Messages {}", + self.session.id, + self.session.path.display(), + message_count, + ); + Ok(true) + } + Some(other) => { + println!("Unknown /session action '{other}'. Use /session list or /session switch ."); + Ok(false) + } + } + } + + fn handle_plugins_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + println!("{}", result.message); + if result.reload_runtime { + self.reload_runtime_features()?; + } + Ok(false) + } + + fn reload_runtime_features(&mut self) -> Result<(), Box> { + self.runtime = build_runtime( + self.runtime.session().clone(), + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.persist_session() + } + + fn compact(&mut self) -> Result<(), Box> { + let result = self.runtime.compact(CompactionConfig::default()); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + self.runtime = build_runtime( + result.compacted_session, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.persist_session()?; + println!("{}", format_compact_report(removed, kept, skipped)); + Ok(()) + } + + fn run_internal_prompt_text_with_progress( + &self, + prompt: &str, + enable_tools: bool, + progress: Option, + ) -> Result> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + enable_tools, + false, + self.allowed_tools.clone(), + self.permission_mode, + progress, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; + Ok(final_assistant_text(&summary).trim().to_string()) + } + + fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) + } + + fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { + let scope = scope.unwrap_or("the current repository"); + let prompt = format!( + "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed." + ); + println!("{}", self.run_internal_prompt_text(&prompt, true)?); + Ok(()) + } + + fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { + let task = task.unwrap_or("the current repo work"); + let prompt = format!( + "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." + ); + let mut progress = InternalPromptProgressRun::start_ultraplan(task); + match self.run_internal_prompt_text_with_progress(&prompt, true, Some(progress.reporter())) + { + Ok(plan) => { + progress.finish_success(); + println!("{plan}"); + Ok(()) + } + Err(error) => { + progress.finish_failure(&error.to_string()); + Err(error) + } + } + } + + #[allow(clippy::unused_self)] + fn run_teleport(&self, target: Option<&str>) -> Result<(), Box> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + println!("Usage: /teleport "); + return Ok(()); + }; + + println!("{}", render_teleport_report(target)?); + Ok(()) + } + + fn run_debug_tool_call(&self) -> Result<(), Box> { + println!("{}", render_last_tool_debug_report(self.runtime.session())?); + Ok(()) + } + + fn run_commit(&mut self) -> Result<(), Box> { + let status = git_output(&["status", "--short"])?; + if status.trim().is_empty() { + println!("Commit\n Result skipped\n Reason no workspace changes"); + return Ok(()); + } + + git_status_ok(&["add", "-A"])?; + let staged_stat = git_output(&["diff", "--cached", "--stat"])?; + let prompt = format!( + "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}", + truncate_for_prompt(&staged_stat, 8_000), + recent_user_context(self.runtime.session(), 6) + ); + let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + if message.trim().is_empty() { + return Err("generated commit message was empty".into()); + } + + let path = write_temp_text_file("claw-commit-message.txt", &message)?; + let output = Command::new("git") + .args(["commit", "--file"]) + .arg(&path) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git commit failed: {stderr}").into()); + } + + println!( + "Commit\n Result created\n Message file {}\n\n{}", + path.display(), + message.trim() + ); + Ok(()) + } + + fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { + let staged = git_output(&["diff", "--stat"])?; + let prompt = format!( + "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: \nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&staged, 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated PR title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-pr-body.md", &body)?; + let output = Command::new("gh") + .args(["pr", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "PR\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("PR draft\n Title {title}\n\n{body}"); + Ok(()) + } + + fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { + let prompt = format!( + "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated issue title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-issue-body.md", &body)?; + let output = Command::new("gh") + .args(["issue", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "Issue\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("Issue draft\n Title {title}\n\n{body}"); + Ok(()) + } +} + +fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let path = cwd.join(".claw").join("sessions"); + fs::create_dir_all(&path)?; + Ok(path) +} + +fn create_managed_session_handle() -> Result<SessionHandle, Box<dyn std::error::Error>> { + let id = generate_session_id(); + let path = sessions_dir()?.join(format!("{id}.json")); + Ok(SessionHandle { id, path }) +} + +fn generate_session_id() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + format!("session-{millis}") +} + +fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { + let direct = PathBuf::from(reference); + let path = if direct.exists() { + direct + } else { + sessions_dir()?.join(format!("{reference}.json")) + }; + if !path.exists() { + return Err(format!("session not found: {reference}").into()); + } + let id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(reference) + .to_string(); + Ok(SessionHandle { id, path }) +} + +fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> { + let mut sessions = Vec::new(); + for entry in fs::read_dir(sessions_dir()?)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_secs = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let message_count = Session::load_from_path(&path) + .map(|session| session.messages.len()) + .unwrap_or_default(); + let id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(); + sessions.push(ManagedSessionSummary { + id, + path, + modified_epoch_secs, + message_count, + }); + } + sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); + Ok(sessions) +} + +fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> { + let sessions = list_managed_sessions()?; + let mut lines = vec![ + "Sessions".to_string(), + format!(" Directory {}", sessions_dir()?.display()), + ]; + if sessions.is_empty() { + lines.push(" No managed sessions saved yet.".to_string()); + return Ok(lines.join("\n")); + } + for session in sessions { + let marker = if session.id == active_session_id { + "● current" + } else { + "○ saved" + }; + lines.push(format!( + " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", + id = session.id, + msgs = session.message_count, + modified = session.modified_epoch_secs, + path = session.path.display(), + )); + } + Ok(lines.join("\n")) +} + +fn render_repl_help() -> String { + [ + "REPL".to_string(), + " /exit Quit the REPL".to_string(), + " /quit Quit the REPL".to_string(), + " Up/Down Navigate prompt history".to_string(), + " Tab Complete slash commands".to_string(), + " Ctrl-C Clear input (or exit on empty prompt)".to_string(), + " Shift+Enter/Ctrl+J Insert a newline".to_string(), + String::new(), + render_slash_command_help(), + ] + .join( + " +", + ) +} + +fn status_context( + session_path: Option<&Path>, +) -> Result<StatusContext, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered_config_files = loader.discover().len(); + let runtime_config = loader.load()?; + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + Ok(StatusContext { + cwd, + session_path: session_path.map(Path::to_path_buf), + loaded_config_files: runtime_config.loaded_entries().len(), + discovered_config_files, + memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, + }) +} + +fn format_status_report( + model: &str, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, +) -> String { + [ + format!( + "Status + Model {model} + Permission mode {permission_mode} + Messages {} + Turns {} + Estimated tokens {}", + usage.message_count, usage.turns, usage.estimated_tokens, + ), + format!( + "Usage + Latest total {} + Cumulative input {} + Cumulative output {} + Cumulative total {}", + usage.latest.total_tokens(), + usage.cumulative.input_tokens, + usage.cumulative.output_tokens, + usage.cumulative.total_tokens(), + ), + format!( + "Workspace + Cwd {} + Project root {} + Git branch {} + Session {} + Config files loaded {}/{} + Memory files {}", + context.cwd.display(), + context + .project_root + .as_ref() + .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()), + context.git_branch.as_deref().unwrap_or("unknown"), + context.session_path.as_ref().map_or_else( + || "live-repl".to_string(), + |path| path.display().to_string() + ), + context.loaded_config_files, + context.discovered_config_files, + context.memory_file_count, + ), + ] + .join( + " + +", + ) +} + +fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let mut lines = vec![ + format!( + "Config + Working directory {} + Loaded files {} + Merged keys {}", + cwd.display(), + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + ), + "Discovered files".to_string(), + ]; + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + lines.push(format!( + " {source:<7} {status:<7} {}", + entry.path.display() + )); + } + + if let Some(section) = section { + lines.push(format!("Merged section: {section}")); + let value = match section { + "env" => runtime_config.get("env"), + "hooks" => runtime_config.get("hooks"), + "model" => runtime_config.get("model"), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")), + other => { + lines.push(format!( + " Unsupported config section '{other}'. Use env, hooks, model, or plugins." + )); + return Ok(lines.join( + " +", + )); + } + }; + lines.push(format!( + " {}", + match value { + Some(value) => value.render(), + None => "<unset>".to_string(), + } + )); + return Ok(lines.join( + " +", + )); + } + + lines.push("Merged JSON".to_string()); + lines.push(format!(" {}", runtime_config.as_json().render())); + Ok(lines.join( + " +", + )) +} + +fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let mut lines = vec![format!( + "Memory + Working directory {} + Instruction files {}", + cwd.display(), + project_context.instruction_files.len() + )]; + if project_context.instruction_files.is_empty() { + lines.push("Discovered files".to_string()); + lines.push( + " No CLAW instruction files discovered in the current directory ancestry." + .to_string(), + ); + } else { + lines.push("Discovered files".to_string()); + for (index, file) in project_context.instruction_files.iter().enumerate() { + let preview = file.content.lines().next().unwrap_or("").trim(); + let preview = if preview.is_empty() { + "<empty>" + } else { + preview + }; + lines.push(format!(" {}. {}", index + 1, file.path.display(),)); + lines.push(format!( + " lines={} preview={}", + file.content.lines().count(), + preview + )); + } + } + Ok(lines.join( + " +", + )) +} + +fn init_claw_md() -> Result<String, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + Ok(initialize_repo(&cwd)?.render()) +} + +fn run_init() -> Result<(), Box<dyn std::error::Error>> { + println!("{}", init_claw_md()?); + Ok(()) +} + +fn normalize_permission_mode(mode: &str) -> Option<&'static str> { + match mode.trim() { + "read-only" => Some("read-only"), + "workspace-write" => Some("workspace-write"), + "danger-full-access" => Some("danger-full-access"), + _ => None, + } +} + +fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> { + let output = std::process::Command::new("git") + .args(["diff", "--", ":(exclude).omx"]) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git diff failed: {stderr}").into()); + } + let diff = String::from_utf8(output.stdout)?; + if diff.trim().is_empty() { + return Ok( + "Diff\n Result clean working tree\n Detail no current changes" + .to_string(), + ); + } + Ok(format!("Diff\n\n{}", diff.trim_end())) +} + +fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::<Vec<_>>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![format!("Teleport\n Target {target}")]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> { + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::<Vec<_>>() + .join("\n") +} + +fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(()) +} + +fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result<PathBuf, Box<dyn std::error::Error>> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) +} + +fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .rev() + .take(limit) + .collect::<Vec<_>>(); + + if requests.is_empty() { + "<no prior user messages>".to_string() + } else { + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::<Vec<_>>() + .join("\n") + } +} + +fn truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() + } else { + let truncated = value.chars().take(limit).collect::<String>(); + format!("{}\n…[truncated]", truncated.trim_end()) + } +} + +fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") +} + +fn parse_titled_body(value: &str) -> Option<(String, String)> { + let normalized = sanitize_generated_message(value); + let title = normalized + .lines() + .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; + let body_start = normalized.find("BODY:")?; + let body = normalized[body_start + "BODY:".len()..].trim(); + Some((title.to_string(), body.to_string())) +} + +fn render_version_report() -> String { + let git_sha = GIT_SHA.unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + format!( + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + ) +} + +fn render_export_text(session: &Session) -> String { + let mut lines = vec!["# Conversation Export".to_string(), String::new()]; + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "system", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => lines.push(text.clone()), + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!("[tool_use id={id} name={name}] {input}")); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + lines.push(format!( + "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" + )); + } + } + } + lines.push(String::new()); + } + lines.join("\n") +} + +fn default_export_filename(session: &Session) -> String { + let stem = session + .messages + .iter() + .find_map(|message| match message.role { + MessageRole::User => message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }), + _ => None, + }) + .map_or("conversation", |text| { + text.lines().next().unwrap_or("conversation") + }) + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::<String>() + .split('-') + .filter(|part| !part.is_empty()) + .take(8) + .collect::<Vec<_>>() + .join("-"); + let fallback = if stem.is_empty() { + "conversation" + } else { + &stem + }; + format!("{fallback}.txt") +} + +fn resolve_export_path( + requested_path: Option<&str>, + session: &Session, +) -> Result<PathBuf, Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let file_name = + requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); + let final_name = if Path::new(&file_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) + { + file_name + } else { + format!("{file_name}.txt") + }; + Ok(cwd.join(final_name)) +} + +fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> { + Ok(load_system_prompt( + env::current_dir()?, + DEFAULT_DATE, + env::consts::OS, + "unknown", + )?) +} + +fn build_runtime_plugin_state( +) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box<dyn std::error::Error>> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?; + Ok((runtime_config.feature_config().clone(), tool_registry)) +} + +fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InternalPromptProgressState { + command_label: &'static str, + task_label: String, + step: usize, + phase: String, + detail: Option<String>, + saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +struct InternalPromptProgressShared { + state: Mutex<InternalPromptProgressState>, + output_lock: Mutex<()>, + started_at: Instant, +} + +#[derive(Debug, Clone)] +struct InternalPromptProgressReporter { + shared: Arc<InternalPromptProgressShared>, +} + +#[derive(Debug)] +struct InternalPromptProgressRun { + reporter: InternalPromptProgressReporter, + heartbeat_stop: Option<mpsc::Sender<()>>, + heartbeat_handle: Option<thread::JoinHandle<()>>, +} + +impl InternalPromptProgressReporter { + fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Complete, None); + } + + fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +fn format_internal_prompt_progress_line( + event: InternalPromptProgressEvent, + snapshot: &InternalPromptProgressState, + elapsed: Duration, + error: Option<&str>, +) -> String { + let elapsed_seconds = elapsed.as_secs(); + let step_label = if snapshot.step == 0 { + "current step pending".to_string() + } else { + format!("current step {}", snapshot.step) + }; + let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; + if let Some(detail) = snapshot + .detail + .as_deref() + .filter(|detail| !detail.is_empty()) + { + status_bits.push(detail.to_string()); + } + let status = status_bits.join(" · "); + match event { + InternalPromptProgressEvent::Started => { + format!( + "🧭 {} status · planning started · {status}", + snapshot.command_label + ) + } + InternalPromptProgressEvent::Update => { + format!("… {} status · {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Heartbeat => format!( + "… {} heartbeat · {elapsed_seconds}s elapsed · {status}", + snapshot.command_label + ), + InternalPromptProgressEvent::Complete => format!( + "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", + snapshot.command_label, snapshot.step + ), + InternalPromptProgressEvent::Failed => format!( + "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", + snapshot.command_label, + error.unwrap_or("unknown error") + ), + } +} + +fn describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +fn build_runtime( + session: Session, + model: String, + system_prompt: Vec<String>, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option<AllowedToolSet>, + permission_mode: PermissionMode, + progress_reporter: Option<InternalPromptProgressReporter>, +) -> Result<ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { + let (feature_config, tool_registry) = build_runtime_plugin_state()?; + Ok(ConversationRuntime::new_with_features( + session, + DefaultRuntimeClient::new( + model, + enable_tools, + emit_output, + allowed_tools.clone(), + tool_registry.clone(), + progress_reporter, + )?, + CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()), + permission_policy(permission_mode, &tool_registry), + system_prompt, + feature_config, + )) +} + +struct CliPermissionPrompter { + current_mode: PermissionMode, +} + +impl CliPermissionPrompter { + fn new(current_mode: PermissionMode) -> Self { + Self { current_mode } + } +} + +impl runtime::PermissionPrompter for CliPermissionPrompter { + fn decide( + &mut self, + request: &runtime::PermissionRequest, + ) -> runtime::PermissionPromptDecision { + println!(); + println!("Permission approval required"); + println!(" Tool {}", request.tool_name); + println!(" Current mode {}", self.current_mode.as_str()); + println!(" Required mode {}", request.required_mode.as_str()); + println!(" Input {}", request.input); + print!("Approve this tool call? [y/N]: "); + let _ = io::stdout().flush(); + + let mut response = String::new(); + match io::stdin().read_line(&mut response) { + Ok(_) => { + let normalized = response.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "y" | "yes") { + runtime::PermissionPromptDecision::Allow + } else { + runtime::PermissionPromptDecision::Deny { + reason: format!( + "tool '{}' denied by user approval prompt", + request.tool_name + ), + } + } + } + Err(error) => runtime::PermissionPromptDecision::Deny { + reason: format!("permission approval failed: {error}"), + }, + } + } +} + +struct DefaultRuntimeClient { + runtime: tokio::runtime::Runtime, + client: ClawApiClient, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, + progress_reporter: Option<InternalPromptProgressReporter>, +} + +impl DefaultRuntimeClient { + fn new( + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, + progress_reporter: Option<InternalPromptProgressReporter>, + ) -> Result<Self, Box<dyn std::error::Error>> { + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client: ClawApiClient::from_auth(resolve_cli_auth_source()?) + .with_base_url(api::read_base_url()), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + }) + } +} + +fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> { + Ok(resolve_startup_auth_source(|| { + let cwd = env::current_dir().map_err(api::ApiError::from)?; + let config = ConfigLoader::default_for(&cwd).load().map_err(|error| { + api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}")) + })?; + Ok(config.oauth().cloned()) + })?) +} + +impl ApiClient for DefaultRuntimeClient { + #[allow(clippy::too_many_lines)] + fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } + let message_request = MessageRequest { + model: self.model.clone(), + max_tokens: max_tokens_for_model(&self.model), + messages: convert_messages(&request.messages), + system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), + tools: self + .enable_tools + .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), + tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + stream: true, + }; + + self.runtime.block_on(async { + let mut stream = self + .client + .stream_message(&message_request) + .await + .map_err(|error| RuntimeError::new(error.to_string()))?; + let mut stdout = io::stdout(); + let mut sink = io::sink(); + let out: &mut dyn Write = if self.emit_output { + &mut stdout + } else { + &mut sink + }; + let renderer = TerminalRenderer::new(); + let mut markdown_stream = MarkdownStreamState::default(); + let mut events = Vec::new(); + let mut pending_tool: Option<(String, String, String)> = None; + let mut saw_stop = false; + + while let Some(event) = stream + .next_event() + .await + .map_err(|error| RuntimeError::new(error.to_string()))? + { + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block(block, out, &mut events, &mut pending_tool, true)?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + if let Some(rendered) = markdown_stream.push(&renderer, &text) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::TextDelta(text)); + } + } + ContentBlockDelta::InputJsonDelta { partial_json } => { + if let Some((_, _, input)) = &mut pending_tool { + input.push_str(&partial_json); + } + } + ContentBlockDelta::ThinkingDelta { .. } + | ContentBlockDelta::SignatureDelta { .. } => {} + }, + ApiStreamEvent::ContentBlockStop(_) => { + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + // Display tool call now that input is fully accumulated + writeln!(out, "\n{}", format_tool_call_start(&name, &input)) + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + ApiStreamEvent::MessageDelta(delta) => { + events.push(AssistantEvent::Usage(TokenUsage { + input_tokens: delta.usage.input_tokens, + output_tokens: delta.usage.output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + })); + } + ApiStreamEvent::MessageStop(_) => { + saw_stop = true; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::MessageStop); + } + } + } + + if !saw_stop + && events.iter().any(|event| { + matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ToolUse { .. }) + }) + { + events.push(AssistantEvent::MessageStop); + } + + if events + .iter() + .any(|event| matches!(event, AssistantEvent::MessageStop)) + { + return Ok(events); + } + + let response = self + .client + .send_message(&MessageRequest { + stream: false, + ..message_request.clone() + }) + .await + .map_err(|error| RuntimeError::new(error.to_string()))?; + response_to_events(response, out) + }) + } +} + +fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::<Vec<_>>() + .join("") + }) + .unwrap_or_default() +} + +fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> { + summary + .assistant_messages + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => Some(json!({ + "id": id, + "name": name, + "input": input, + })), + _ => None, + }) + .collect() +} + +fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> { + summary + .tool_results + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => Some(json!({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output, + "is_error": is_error, + })), + _ => None, + }) + .collect() +} + +fn slash_command_completion_candidates() -> Vec<String> { + slash_command_specs() + .iter() + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::<Vec<_>>() + }) + .collect() +} + +fn format_tool_call_start(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + + let detail = match name { + "bash" | "Bash" => format_bash_call(&parsed), + "read_file" | "Read" => { + let path = extract_tool_path(&parsed); + format!("\x1b[2m📄 Reading {path}…\x1b[0m") + } + "write_file" | "Write" => { + let path = extract_tool_path(&parsed); + let lines = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m") + } + "edit_file" | "Edit" => { + let path = extract_tool_path(&parsed); + let old_value = parsed + .get("old_string") + .or_else(|| parsed.get("oldString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("new_string") + .or_else(|| parsed.get("newString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format!( + "\x1b[1;33m📝 Editing {path}\x1b[0m{}", + format_patch_preview(old_value, new_value) + .map(|preview| format!("\n{preview}")) + .unwrap_or_default() + ) + } + "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), + "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string(), + _ => summarize_tool_payload(input), + }; + + let border = "─".repeat(name.len() + 8); + format!( + "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m" + ) +} + +fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { + let icon = if is_error { + "\x1b[1;31m✗\x1b[0m" + } else { + "\x1b[1;32m✓\x1b[0m" + }; + if is_error { + let summary = truncate_for_summary(output.trim(), 160); + return if summary.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m") + }; + } + + let parsed: serde_json::Value = + serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string())); + match name { + "bash" | "Bash" => format_bash_result(icon, &parsed), + "read_file" | "Read" => format_read_result(icon, &parsed), + "write_file" | "Write" => format_write_result(icon, &parsed), + "edit_file" | "Edit" => format_edit_result(icon, &parsed), + "glob_search" | "Glob" => format_glob_result(icon, &parsed), + "grep_search" | "Grep" => format_grep_result(icon, &parsed), + _ => format_generic_tool_result(icon, name, &parsed), + } +} + +const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +const READ_DISPLAY_MAX_LINES: usize = 80; +const READ_DISPLAY_MAX_CHARS: usize = 6_000; +const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; +const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; + +fn extract_tool_path(parsed: &serde_json::Value) -> String { + parsed + .get("file_path") + .or_else(|| parsed.get("filePath")) + .or_else(|| parsed.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string() +} + +fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") +} + +fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> { + if old_value.is_empty() && new_value.is_empty() { + return None; + } + Some(format!( + "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m", + truncate_for_summary(first_visible_line(old_value), 72), + truncate_for_summary(first_visible_line(new_value), 72) + )) +} + +fn format_bash_call(parsed: &serde_json::Value) -> String { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + String::new() + } else { + format!( + "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", + truncate_for_summary(command, 160) + ) + } +} + +fn first_visible_line(text: &str) -> &str { + text.lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(text) +} + +fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { + let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")]; + if let Some(task_id) = parsed + .get("backgroundTaskId") + .and_then(|value| value.as_str()) + { + write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); + } else if let Some(status) = parsed + .get("returnCodeInterpretation") + .and_then(|value| value.as_str()) + .filter(|status| !status.is_empty()) + { + write!(&mut lines[0], " {status}").expect("write to string"); + } + + if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { + if !stdout.trim().is_empty() { + lines.push(truncate_output_for_display( + stdout, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + )); + } + } + if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { + if !stderr.trim().is_empty() { + lines.push(format!( + "\x1b[38;5;203m{}\x1b[0m", + truncate_output_for_display( + stderr, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + )); + } + } + + lines.join("\n\n") +} + +fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { + let file = parsed.get("file").unwrap_or(parsed); + let path = extract_tool_path(file); + let start_line = file + .get("startLine") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1); + let num_lines = file + .get("numLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_lines = file + .get("totalLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(num_lines); + let content = file + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); + + format!( + "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}", + start_line, + end_line.max(start_line), + total_lines, + truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) + ) +} + +fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let kind = parsed + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("write"); + let line_count = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!( + "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", + if kind == "create" { "Wrote" } else { "Updated" }, + ) +} + +fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> { + let hunks = parsed.get("structuredPatch")?.as_array()?; + let mut preview = Vec::new(); + for hunk in hunks.iter().take(2) { + let lines = hunk.get("lines")?.as_array()?; + for line in lines.iter().filter_map(|value| value.as_str()).take(6) { + match line.chars().next() { + Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")), + Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")), + _ => preview.push(line.to_string()), + } + } + } + if preview.is_empty() { + None + } else { + Some(preview.join("\n")) + } +} + +fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let suffix = if parsed + .get("replaceAll") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + " (replace all)" + } else { + "" + }; + let preview = format_structured_patch_preview(parsed).or_else(|| { + let old_value = parsed + .get("oldString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("newString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format_patch_preview(old_value, new_value) + }); + + match preview { + Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), + None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), + } +} + +fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::<Vec<_>>() + .join("\n") + }) + .unwrap_or_default(); + if filenames.is_empty() { + format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files") + } else { + format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}") + } +} + +fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_matches = parsed + .get("numMatches") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let content = parsed + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::<Vec<_>>() + .join("\n") + }) + .unwrap_or_default(); + let summary = format!( + "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" + ); + if !content.trim().is_empty() { + format!( + "{summary}\n{}", + truncate_output_for_display( + content, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + ) + } else if !filenames.is_empty() { + format!("{summary}\n{filenames}") + } else { + summary + } +} + +fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String { + let rendered_output = match parsed { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) + } + _ => parsed.to_string(), + }; + let preview = truncate_output_for_display( + &rendered_output, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ); + + if preview.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else if preview.contains('\n') { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}") + } else { + format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") + } +} + +fn summarize_tool_payload(payload: &str) -> String { + let compact = match serde_json::from_str::<serde_json::Value>(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + truncate_for_summary(&compact, 96) +} + +fn truncate_for_summary(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(limit).collect::<String>(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::<String>()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(DISPLAY_TRUNCATION_NOTICE); + } + preview +} + +fn push_output_block( + block: OutputContentBlock, + out: &mut (impl Write + ?Sized), + events: &mut Vec<AssistantEvent>, + pending_tool: &mut Option<(String, String, String)>, + streaming_tool_input: bool, +) -> Result<(), RuntimeError> { + match block { + OutputContentBlock::Text { text } => { + if !text.is_empty() { + let rendered = TerminalRenderer::new().markdown_to_ansi(&text); + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::TextDelta(text)); + } + } + OutputContentBlock::ToolUse { id, name, input } => { + // During streaming, the initial content_block_start has an empty input ({}). + // The real input arrives via input_json_delta events. In + // non-streaming responses, preserve a legitimate empty object. + let initial_input = if streaming_tool_input + && input.is_object() + && input.as_object().is_some_and(serde_json::Map::is_empty) + { + String::new() + } else { + input.to_string() + }; + *pending_tool = Some((id, name, initial_input)); + } + OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {} + } + Ok(()) +} + +fn response_to_events( + response: MessageResponse, + out: &mut (impl Write + ?Sized), +) -> Result<Vec<AssistantEvent>, RuntimeError> { + let mut events = Vec::new(); + let mut pending_tool = None; + + for block in response.content { + push_output_block(block, out, &mut events, &mut pending_tool, false)?; + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(TokenUsage { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: response.usage.cache_creation_input_tokens, + cache_read_input_tokens: response.usage.cache_read_input_tokens, + })); + events.push(AssistantEvent::MessageStop); + Ok(events) +} + +struct CliToolExecutor { + renderer: TerminalRenderer, + emit_output: bool, + allowed_tools: Option<AllowedToolSet>, + tool_registry: GlobalToolRegistry, +} + +impl CliToolExecutor { + fn new( + allowed_tools: Option<AllowedToolSet>, + emit_output: bool, + tool_registry: GlobalToolRegistry, + ) -> Self { + Self { + renderer: TerminalRenderer::new(), + emit_output, + allowed_tools, + tool_registry, + } + } +} + +impl ToolExecutor for CliToolExecutor { + fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> { + if self + .allowed_tools + .as_ref() + .is_some_and(|allowed| !allowed.contains(tool_name)) + { + return Err(ToolError::new(format!( + "tool `{tool_name}` is not enabled by the current --allowedTools setting" + ))); + } + let value = serde_json::from_str(input) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + match self.tool_registry.execute(tool_name, &value) { + Ok(output) => { + if self.emit_output { + let markdown = format_tool_result(tool_name, &output, false); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|error| ToolError::new(error.to_string()))?; + } + Ok(output) + } + Err(error) => { + if self.emit_output { + let markdown = format_tool_result(tool_name, &error, true); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + } + Err(ToolError::new(error)) + } + } + } +} + +fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy { + tool_registry.permission_specs(None).into_iter().fold( + PermissionPolicy::new(mode), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + ) +} + +fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { + messages + .iter() + .filter_map(|message| { + let role = match message.role { + MessageRole::System | MessageRole::User | MessageRole::Tool => "user", + MessageRole::Assistant => "assistant", + }; + let content = message + .blocks + .iter() + .map(|block| match block { + ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, + ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: serde_json::from_str(input) + .unwrap_or_else(|_| serde_json::json!({ "raw": input })), + }, + ContentBlock::ToolResult { + tool_use_id, + output, + is_error, + .. + } => InputContentBlock::ToolResult { + tool_use_id: tool_use_id.clone(), + content: vec![ToolResultContentBlock::Text { + text: output.clone(), + }], + is_error: *is_error, + }, + }) + .collect::<Vec<_>>(); + (!content.is_empty()).then(|| InputMessage { + role: role.to_string(), + content, + }) + }) + .collect() +} + +fn print_help_to(out: &mut impl Write) -> io::Result<()> { + writeln!(out, "claw v{VERSION}")?; + writeln!(out)?; + writeln!(out, "Usage:")?; + writeln!( + out, + " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + )?; + writeln!(out, " Start the interactive REPL")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] prompt TEXT" + )?; + writeln!(out, " Send one prompt and exit")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] TEXT" + )?; + writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " claw --resume SESSION.json [/status] [/compact] [...]" + )?; + writeln!( + out, + " Inspect or maintain a saved session without entering the REPL" + )?; + writeln!(out, " claw dump-manifests")?; + writeln!(out, " claw bootstrap-plan")?; + writeln!(out, " claw agents")?; + writeln!(out, " claw skills")?; + writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; + writeln!(out, " claw login")?; + writeln!(out, " claw logout")?; + writeln!(out, " claw init")?; + writeln!(out)?; + writeln!(out, "Flags:")?; + writeln!( + out, + " --model MODEL Override the active model" + )?; + writeln!( + out, + " --output-format FORMAT Non-interactive output format: text or json" + )?; + writeln!( + out, + " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" + )?; + writeln!( + out, + " --dangerously-skip-permissions Skip all permission checks" + )?; + writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; + writeln!( + out, + " --version, -V Print version and build information locally" + )?; + writeln!(out)?; + writeln!(out, "Interactive slash commands:")?; + writeln!(out, "{}", render_slash_command_help())?; + writeln!(out)?; + let resume_commands = resume_supported_slash_commands() + .into_iter() + .map(|spec| match spec.argument_hint { + Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), + None => format!("/{}", spec.name), + }) + .collect::<Vec<_>>() + .join(", "); + writeln!(out, "Resume-safe commands: {resume_commands}")?; + writeln!(out, "Examples:")?; + writeln!(out, " claw --model opus \"summarize this repo\"")?; + writeln!( + out, + " claw --output-format json prompt \"explain src/main.rs\"" + )?; + writeln!( + out, + " claw --allowedTools read,glob \"summarize Cargo.toml\"" + )?; + writeln!( + out, + " claw --resume session.json /status /diff /export notes.txt" + )?; + writeln!(out, " claw agents")?; + writeln!(out, " claw /skills")?; + writeln!(out, " claw login")?; + writeln!(out, " claw init")?; + Ok(()) +} + +fn print_help() { + let _ = print_help_to(&mut io::stdout()); +} + +#[cfg(test)] +mod tests { + use super::{ + describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report, + format_internal_prompt_progress_line, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, format_tool_call_start, format_tool_result, + normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, + print_help_to, push_output_block, render_config_report, render_memory_report, + render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, + status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, + InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, + }; + use api::{MessageResponse, OutputContentBlock, Usage}; + use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; + use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use serde_json::json; + use std::path::PathBuf; + use std::time::Duration; + use tools::GlobalToolRegistry; + + fn registry_with_plugin_tool() -> GlobalToolRegistry { + GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "plugin-demo@external", + "plugin-demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin payload".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + }), + }, + "echo".to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + None, + )]) + .expect("plugin tool registry should build") + } + + #[test] + fn defaults_to_repl_when_no_args() { + assert_eq!( + parse_args(&[]).expect("args should parse"), + CliAction::Repl { + model: DEFAULT_MODEL.to_string(), + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + } + ); + } + + #[test] + fn parses_prompt_subcommand() { + let args = vec![ + "prompt".to_string(), + "hello".to_string(), + "world".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Prompt { + prompt: "hello world".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + } + ); + } + + #[test] + fn parses_bare_prompt_and_json_output_flag() { + let args = vec![ + "--output-format=json".to_string(), + "--model".to_string(), + "custom-opus".to_string(), + "explain".to_string(), + "this".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Prompt { + prompt: "explain this".to_string(), + model: "custom-opus".to_string(), + output_format: CliOutputFormat::Json, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + } + ); + } + + #[test] + fn resolves_model_aliases_in_args() { + let args = vec![ + "--model".to_string(), + "opus".to_string(), + "explain".to_string(), + "this".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Prompt { + prompt: "explain this".to_string(), + model: "claude-opus-4-6".to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + } + ); + } + + #[test] + fn resolves_known_model_aliases() { + assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); + assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6"); + assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213"); + assert_eq!(resolve_model_alias("custom-opus"), "custom-opus"); + } + + #[test] + fn parses_version_flags_without_initializing_prompt_mode() { + assert_eq!( + parse_args(&["--version".to_string()]).expect("args should parse"), + CliAction::Version + ); + assert_eq!( + parse_args(&["-V".to_string()]).expect("args should parse"), + CliAction::Version + ); + } + + #[test] + fn parses_permission_mode_flag() { + let args = vec!["--permission-mode=read-only".to_string()]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Repl { + model: DEFAULT_MODEL.to_string(), + allowed_tools: None, + permission_mode: PermissionMode::ReadOnly, + } + ); + } + + #[test] + fn parses_allowed_tools_flags_with_aliases_and_lists() { + let args = vec![ + "--allowedTools".to_string(), + "read,glob".to_string(), + "--allowed-tools=write_file".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Repl { + model: DEFAULT_MODEL.to_string(), + allowed_tools: Some( + ["glob_search", "read_file", "write_file"] + .into_iter() + .map(str::to_string) + .collect() + ), + permission_mode: PermissionMode::DangerFullAccess, + } + ); + } + + #[test] + fn rejects_unknown_allowed_tools() { + let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()]) + .expect_err("tool should be rejected"); + assert!(error.contains("unsupported tool in --allowedTools: teleport")); + } + + #[test] + fn parses_system_prompt_options() { + let args = vec![ + "system-prompt".to_string(), + "--cwd".to_string(), + "/tmp/project".to_string(), + "--date".to_string(), + "2026-04-01".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::PrintSystemPrompt { + cwd: PathBuf::from("/tmp/project"), + date: "2026-04-01".to_string(), + } + ); + } + + #[test] + fn parses_login_and_logout_subcommands() { + assert_eq!( + parse_args(&["login".to_string()]).expect("login should parse"), + CliAction::Login + ); + assert_eq!( + parse_args(&["logout".to_string()]).expect("logout should parse"), + CliAction::Logout + ); + assert_eq!( + parse_args(&["init".to_string()]).expect("init should parse"), + CliAction::Init + ); + assert_eq!( + parse_args(&["agents".to_string()]).expect("agents should parse"), + CliAction::Agents { args: None } + ); + assert_eq!( + parse_args(&["skills".to_string()]).expect("skills should parse"), + CliAction::Skills { args: None } + ); + assert_eq!( + parse_args(&["agents".to_string(), "--help".to_string()]) + .expect("agents help should parse"), + CliAction::Agents { + args: Some("--help".to_string()) + } + ); + } + + #[test] + fn parses_direct_agents_and_skills_slash_commands() { + assert_eq!( + parse_args(&["/agents".to_string()]).expect("/agents should parse"), + CliAction::Agents { args: None } + ); + assert_eq!( + parse_args(&["/skills".to_string()]).expect("/skills should parse"), + CliAction::Skills { args: None } + ); + assert_eq!( + parse_args(&["/skills".to_string(), "help".to_string()]) + .expect("/skills help should parse"), + CliAction::Skills { + args: Some("help".to_string()) + } + ); + let error = parse_args(&["/status".to_string()]) + .expect_err("/status should remain REPL-only when invoked directly"); + assert!(error.contains("unsupported direct slash command")); + } + + #[test] + fn parses_resume_flag_with_slash_command() { + let args = vec![ + "--resume".to_string(), + "session.json".to_string(), + "/compact".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ResumeSession { + session_path: PathBuf::from("session.json"), + commands: vec!["/compact".to_string()], + } + ); + } + + #[test] + fn parses_resume_flag_with_multiple_slash_commands() { + let args = vec![ + "--resume".to_string(), + "session.json".to_string(), + "/status".to_string(), + "/compact".to_string(), + "/cost".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::ResumeSession { + session_path: PathBuf::from("session.json"), + commands: vec![ + "/status".to_string(), + "/compact".to_string(), + "/cost".to_string(), + ], + } + ); + } + + #[test] + fn filtered_tool_specs_respect_allowlist() { + let allowed = ["read_file", "grep_search"] + .into_iter() + .map(str::to_string) + .collect(); + let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed)); + let names = filtered + .into_iter() + .map(|spec| spec.name) + .collect::<Vec<_>>(); + assert_eq!(names, vec!["read_file", "grep_search"]); + } + + #[test] + fn filtered_tool_specs_include_plugin_tools() { + let filtered = filter_tool_specs(®istry_with_plugin_tool(), None); + let names = filtered + .into_iter() + .map(|definition| definition.name) + .collect::<Vec<_>>(); + assert!(names.contains(&"bash".to_string())); + assert!(names.contains(&"plugin_echo".to_string())); + } + + #[test] + fn permission_policy_uses_plugin_tool_permissions() { + let policy = permission_policy(PermissionMode::ReadOnly, ®istry_with_plugin_tool()); + let required = policy.required_mode_for("plugin_echo"); + assert_eq!(required, PermissionMode::WorkspaceWrite); + } + + #[test] + fn shared_help_uses_resume_annotation_copy() { + let help = commands::render_slash_command_help(); + assert!(help.contains("Slash commands")); + assert!(help.contains("works with --resume SESSION.json")); + } + + #[test] + fn repl_help_includes_shared_commands_and_exit() { + let help = render_repl_help(); + assert!(help.contains("REPL")); + assert!(help.contains("/help")); + assert!(help.contains("/status")); + assert!(help.contains("/model [model]")); + assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); + assert!(help.contains("/clear [--confirm]")); + assert!(help.contains("/cost")); + assert!(help.contains("/resume <session-path>")); + assert!(help.contains("/config [env|hooks|model|plugins]")); + assert!(help.contains("/memory")); + assert!(help.contains("/init")); + assert!(help.contains("/diff")); + assert!(help.contains("/version")); + assert!(help.contains("/export [file]")); + assert!(help.contains("/session [list|switch <session-id>]")); + assert!(help.contains( + "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" + )); + assert!(help.contains("aliases: /plugins, /marketplace")); + assert!(help.contains("/agents")); + assert!(help.contains("/skills")); + assert!(help.contains("/exit")); + } + + #[test] + fn resume_supported_command_list_matches_expected_surface() { + let names = resume_supported_slash_commands() + .into_iter() + .map(|spec| spec.name) + .collect::<Vec<_>>(); + assert_eq!( + names, + vec![ + "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", + "version", "export", "agents", "skills", + ] + ); + } + + #[test] + fn resume_report_uses_sectioned_layout() { + let report = format_resume_report("session.json", 14, 6); + assert!(report.contains("Session resumed")); + assert!(report.contains("Session file session.json")); + assert!(report.contains("Messages 14")); + assert!(report.contains("Turns 6")); + } + + #[test] + fn compact_report_uses_structured_output() { + let compacted = format_compact_report(8, 5, false); + assert!(compacted.contains("Compact")); + assert!(compacted.contains("Result compacted")); + assert!(compacted.contains("Messages removed 8")); + let skipped = format_compact_report(0, 3, true); + assert!(skipped.contains("Result skipped")); + } + + #[test] + fn cost_report_uses_sectioned_layout() { + let report = format_cost_report(runtime::TokenUsage { + input_tokens: 20, + output_tokens: 8, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 1, + }); + assert!(report.contains("Cost")); + assert!(report.contains("Input tokens 20")); + assert!(report.contains("Output tokens 8")); + assert!(report.contains("Cache create 3")); + assert!(report.contains("Cache read 1")); + assert!(report.contains("Total tokens 32")); + } + + #[test] + fn permissions_report_uses_sectioned_layout() { + let report = format_permissions_report("workspace-write"); + assert!(report.contains("Permissions")); + assert!(report.contains("Active mode workspace-write")); + assert!(report.contains("Modes")); + assert!(report.contains("read-only ○ available Read/search tools only")); + assert!(report.contains("workspace-write ● current Edit files inside the workspace")); + assert!(report.contains("danger-full-access ○ available Unrestricted tool access")); + } + + #[test] + fn permissions_switch_report_is_structured() { + let report = format_permissions_switch_report("read-only", "workspace-write"); + assert!(report.contains("Permissions updated")); + assert!(report.contains("Result mode switched")); + assert!(report.contains("Previous mode read-only")); + assert!(report.contains("Active mode workspace-write")); + assert!(report.contains("Applies to subsequent tool calls")); + } + + #[test] + fn init_help_mentions_direct_subcommand() { + let mut help = Vec::new(); + print_help_to(&mut help).expect("help should render"); + let help = String::from_utf8(help).expect("help should be utf8"); + assert!(help.contains("claw init")); + assert!(help.contains("claw agents")); + assert!(help.contains("claw skills")); + assert!(help.contains("claw /skills")); + } + + #[test] + fn model_report_uses_sectioned_layout() { + let report = format_model_report("sonnet", 12, 4); + assert!(report.contains("Model")); + assert!(report.contains("Current model sonnet")); + assert!(report.contains("Session messages 12")); + assert!(report.contains("Switch models with /model <name>")); + } + + #[test] + fn model_switch_report_preserves_context_summary() { + let report = format_model_switch_report("sonnet", "opus", 9); + assert!(report.contains("Model updated")); + assert!(report.contains("Previous sonnet")); + assert!(report.contains("Current opus")); + assert!(report.contains("Preserved msgs 9")); + } + + #[test] + fn status_line_reports_model_and_token_totals() { + let status = format_status_report( + "sonnet", + StatusUsage { + message_count: 7, + turns: 3, + latest: runtime::TokenUsage { + input_tokens: 5, + output_tokens: 4, + cache_creation_input_tokens: 1, + cache_read_input_tokens: 0, + }, + cumulative: runtime::TokenUsage { + input_tokens: 20, + output_tokens: 8, + cache_creation_input_tokens: 2, + cache_read_input_tokens: 1, + }, + estimated_tokens: 128, + }, + "workspace-write", + &super::StatusContext { + cwd: PathBuf::from("/tmp/project"), + session_path: Some(PathBuf::from("session.json")), + loaded_config_files: 2, + discovered_config_files: 3, + memory_file_count: 4, + project_root: Some(PathBuf::from("/tmp")), + git_branch: Some("main".to_string()), + }, + ); + assert!(status.contains("Status")); + assert!(status.contains("Model sonnet")); + assert!(status.contains("Permission mode workspace-write")); + assert!(status.contains("Messages 7")); + assert!(status.contains("Latest total 10")); + assert!(status.contains("Cumulative total 31")); + assert!(status.contains("Cwd /tmp/project")); + assert!(status.contains("Project root /tmp")); + assert!(status.contains("Git branch main")); + assert!(status.contains("Session session.json")); + assert!(status.contains("Config files loaded 2/3")); + assert!(status.contains("Memory files 4")); + } + + #[test] + fn config_report_supports_section_views() { + let report = render_config_report(Some("env")).expect("config report should render"); + assert!(report.contains("Merged section: env")); + let plugins_report = + render_config_report(Some("plugins")).expect("plugins config report should render"); + assert!(plugins_report.contains("Merged section: plugins")); + } + + #[test] + fn memory_report_uses_sectioned_layout() { + let report = render_memory_report().expect("memory report should render"); + assert!(report.contains("Memory")); + assert!(report.contains("Working directory")); + assert!(report.contains("Instruction files")); + assert!(report.contains("Discovered files")); + } + + #[test] + fn config_report_uses_sectioned_layout() { + let report = render_config_report(None).expect("config report should render"); + assert!(report.contains("Config")); + assert!(report.contains("Discovered files")); + assert!(report.contains("Merged JSON")); + } + + #[test] + fn parses_git_status_metadata() { + let (root, branch) = parse_git_status_metadata(Some( + "## rcc/cli...origin/rcc/cli + M src/main.rs", + )); + assert_eq!(branch.as_deref(), Some("rcc/cli")); + let _ = root; + } + + #[test] + fn status_context_reads_real_workspace_metadata() { + let context = status_context(None).expect("status context should load"); + assert!(context.cwd.is_absolute()); + assert_eq!(context.discovered_config_files, 5); + assert!(context.loaded_config_files <= context.discovered_config_files); + } + + #[test] + fn normalizes_supported_permission_modes() { + assert_eq!(normalize_permission_mode("read-only"), Some("read-only")); + assert_eq!( + normalize_permission_mode("workspace-write"), + Some("workspace-write") + ); + assert_eq!( + normalize_permission_mode("danger-full-access"), + Some("danger-full-access") + ); + assert_eq!(normalize_permission_mode("unknown"), None); + } + + #[test] + fn clear_command_requires_explicit_confirmation_flag() { + assert_eq!( + SlashCommand::parse("/clear"), + Some(SlashCommand::Clear { confirm: false }) + ); + assert_eq!( + SlashCommand::parse("/clear --confirm"), + Some(SlashCommand::Clear { confirm: true }) + ); + } + + #[test] + fn parses_resume_and_config_slash_commands() { + assert_eq!( + SlashCommand::parse("/resume saved-session.json"), + Some(SlashCommand::Resume { + session_path: Some("saved-session.json".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/clear --confirm"), + Some(SlashCommand::Clear { confirm: true }) + ); + assert_eq!( + SlashCommand::parse("/config"), + Some(SlashCommand::Config { section: None }) + ); + assert_eq!( + SlashCommand::parse("/config env"), + Some(SlashCommand::Config { + section: Some("env".to_string()) + }) + ); + assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); + assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); + } + + #[test] + fn init_template_mentions_detected_rust_workspace() { + let rendered = crate::init::render_init_claw_md(std::path::Path::new(".")); + assert!(rendered.contains("# CLAW.md")); + assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings")); + } + + #[test] + fn converts_tool_roundtrip_messages() { + let messages = vec![ + ConversationMessage::user_text("hello"), + ConversationMessage::assistant(vec![ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "bash".to_string(), + input: "{\"command\":\"pwd\"}".to_string(), + }]), + ConversationMessage { + role: MessageRole::Tool, + blocks: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + tool_name: "bash".to_string(), + output: "ok".to_string(), + is_error: false, + }], + usage: None, + }, + ]; + + let converted = super::convert_messages(&messages); + assert_eq!(converted.len(), 3); + assert_eq!(converted[1].role, "assistant"); + assert_eq!(converted[2].role, "user"); + } + #[test] + fn repl_help_mentions_history_completion_and_multiline() { + let help = render_repl_help(); + assert!(help.contains("Up/Down")); + assert!(help.contains("Tab")); + assert!(help.contains("Shift+Enter/Ctrl+J")); + } + + #[test] + fn tool_rendering_helpers_compact_output() { + let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#); + assert!(start.contains("read_file")); + assert!(start.contains("src/main.rs")); + + let done = format_tool_result( + "read_file", + r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#, + false, + ); + assert!(done.contains("📄 Read src/main.rs")); + assert!(done.contains("hello")); + } + + #[test] + fn tool_rendering_truncates_large_read_output_for_display_only() { + let content = (0..200) + .map(|index| format!("line {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + let output = json!({ + "file": { + "filePath": "src/main.rs", + "content": content, + "numLines": 200, + "startLine": 1, + "totalLines": 200 + } + }) + .to_string(); + + let rendered = format_tool_result("read_file", &output, false); + + assert!(rendered.contains("line 000")); + assert!(rendered.contains("line 079")); + assert!(!rendered.contains("line 199")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("line 199")); + } + + #[test] + fn tool_rendering_truncates_large_bash_output_for_display_only() { + let stdout = (0..120) + .map(|index| format!("stdout {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + let output = json!({ + "stdout": stdout, + "stderr": "", + "returnCodeInterpretation": "completed successfully" + }) + .to_string(); + + let rendered = format_tool_result("bash", &output, false); + + assert!(rendered.contains("stdout 000")); + assert!(rendered.contains("stdout 059")); + assert!(!rendered.contains("stdout 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("stdout 119")); + } + + #[test] + fn tool_rendering_truncates_generic_long_output_for_display_only() { + let items = (0..120) + .map(|index| format!("payload {index:03}")) + .collect::<Vec<_>>(); + let output = json!({ + "summary": "plugin payload", + "items": items, + }) + .to_string(); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("payload 000")); + assert!(rendered.contains("payload 040")); + assert!(!rendered.contains("payload 080")); + assert!(!rendered.contains("payload 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("payload 119")); + } + + #[test] + fn tool_rendering_truncates_raw_generic_output_for_display_only() { + let output = (0..120) + .map(|index| format!("raw {index:03}")) + .collect::<Vec<_>>() + .join("\n"); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("raw 000")); + assert!(rendered.contains("raw 059")); + assert!(!rendered.contains("raw 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("raw 119")); + } + + #[test] + fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() { + let snapshot = InternalPromptProgressState { + command_label: "Ultraplan", + task_label: "ship plugin progress".to_string(), + step: 3, + phase: "running read_file".to_string(), + detail: Some("reading rust/crates/claw-cli/src/main.rs".to_string()), + saw_final_text: false, + }; + + let started = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Started, + &snapshot, + Duration::from_secs(0), + None, + ); + let heartbeat = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + Duration::from_secs(9), + None, + ); + let completed = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Complete, + &snapshot, + Duration::from_secs(12), + None, + ); + let failed = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Failed, + &snapshot, + Duration::from_secs(12), + Some("network timeout"), + ); + + assert!(started.contains("planning started")); + assert!(started.contains("current step 3")); + assert!(heartbeat.contains("heartbeat")); + assert!(heartbeat.contains("9s elapsed")); + assert!(heartbeat.contains("phase running read_file")); + assert!(completed.contains("completed")); + assert!(completed.contains("3 steps total")); + assert!(failed.contains("failed")); + assert!(failed.contains("network timeout")); + } + + #[test] + fn describe_tool_progress_summarizes_known_tools() { + assert_eq!( + describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), + "reading src/main.rs" + ); + assert!( + describe_tool_progress("bash", r#"{"command":"cargo test -p claw-cli"}"#) + .contains("cargo test -p claw-cli") + ); + assert_eq!( + describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), + "grep `ultraplan` in rust" + ); + } + + #[test] + fn push_output_block_renders_markdown_text() { + let mut out = Vec::new(); + let mut events = Vec::new(); + let mut pending_tool = None; + + push_output_block( + OutputContentBlock::Text { + text: "# Heading".to_string(), + }, + &mut out, + &mut events, + &mut pending_tool, + false, + ) + .expect("text block should render"); + + let rendered = String::from_utf8(out).expect("utf8"); + assert!(rendered.contains("Heading")); + assert!(rendered.contains('\u{1b}')); + } + + #[test] + fn push_output_block_skips_empty_object_prefix_for_tool_streams() { + let mut out = Vec::new(); + let mut events = Vec::new(); + let mut pending_tool = None; + + push_output_block( + OutputContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({}), + }, + &mut out, + &mut events, + &mut pending_tool, + true, + ) + .expect("tool block should accumulate"); + + assert!(events.is_empty()); + assert_eq!( + pending_tool, + Some(("tool-1".to_string(), "read_file".to_string(), String::new(),)) + ); + } + + #[test] + fn response_to_events_preserves_empty_object_json_input_outside_streaming() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-1".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![OutputContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({}), + }], + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::ToolUse { name, input, .. } + if name == "read_file" && input == "{}" + )); + } + + #[test] + fn response_to_events_preserves_non_empty_json_input_outside_streaming() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-2".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![OutputContentBlock::ToolUse { + id: "tool-2".to_string(), + name: "read_file".to_string(), + input: json!({ "path": "rust/Cargo.toml" }), + }], + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::ToolUse { name, input, .. } + if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" + )); + } + + #[test] + fn response_to_events_ignores_thinking_blocks() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-3".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![ + OutputContentBlock::Thinking { + thinking: "step 1".to_string(), + signature: Some("sig_123".to_string()), + }, + OutputContentBlock::Text { + text: "Final answer".to_string(), + }, + ], + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); + + assert!(matches!( + &events[0], + AssistantEvent::TextDelta(text) if text == "Final answer" + )); + assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); + } +} diff --git a/rust/crates/claw-cli/src/render.rs b/rust/crates/claw-cli/src/render.rs new file mode 100644 index 0000000..01751fd --- /dev/null +++ b/rust/crates/claw-cli/src/render.rs @@ -0,0 +1,797 @@ +use std::fmt::Write as FmtWrite; +use std::io::{self, Write}; + +use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition}; +use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize}; +use crossterm::terminal::{Clear, ClearType}; +use crossterm::{execute, queue}; +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; +use syntect::easy::HighlightLines; +use syntect::highlighting::{Theme, ThemeSet}; +use syntect::parsing::SyntaxSet; +use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorTheme { + heading: Color, + emphasis: Color, + strong: Color, + inline_code: Color, + link: Color, + quote: Color, + table_border: Color, + code_block_border: Color, + spinner_active: Color, + spinner_done: Color, + spinner_failed: Color, +} + +impl Default for ColorTheme { + fn default() -> Self { + Self { + heading: Color::Cyan, + emphasis: Color::Magenta, + strong: Color::Yellow, + inline_code: Color::Green, + link: Color::Blue, + quote: Color::DarkGrey, + table_border: Color::DarkCyan, + code_block_border: Color::DarkGrey, + spinner_active: Color::Blue, + spinner_done: Color::Green, + spinner_failed: Color::Red, + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Spinner { + frame_index: usize, +} + +impl Spinner { + const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn tick( + &mut self, + label: &str, + theme: &ColorTheme, + out: &mut impl Write, + ) -> io::Result<()> { + let frame = Self::FRAMES[self.frame_index % Self::FRAMES.len()]; + self.frame_index += 1; + queue!( + out, + SavePosition, + MoveToColumn(0), + Clear(ClearType::CurrentLine), + SetForegroundColor(theme.spinner_active), + Print(format!("{frame} {label}")), + ResetColor, + RestorePosition + )?; + out.flush() + } + + pub fn finish( + &mut self, + label: &str, + theme: &ColorTheme, + out: &mut impl Write, + ) -> io::Result<()> { + self.frame_index = 0; + execute!( + out, + MoveToColumn(0), + Clear(ClearType::CurrentLine), + SetForegroundColor(theme.spinner_done), + Print(format!("✔ {label}\n")), + ResetColor + )?; + out.flush() + } + + pub fn fail( + &mut self, + label: &str, + theme: &ColorTheme, + out: &mut impl Write, + ) -> io::Result<()> { + self.frame_index = 0; + execute!( + out, + MoveToColumn(0), + Clear(ClearType::CurrentLine), + SetForegroundColor(theme.spinner_failed), + Print(format!("✘ {label}\n")), + ResetColor + )?; + out.flush() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ListKind { + Unordered, + Ordered { next_index: u64 }, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct TableState { + headers: Vec<String>, + rows: Vec<Vec<String>>, + current_row: Vec<String>, + current_cell: String, + in_head: bool, +} + +impl TableState { + fn push_cell(&mut self) { + let cell = self.current_cell.trim().to_string(); + self.current_row.push(cell); + self.current_cell.clear(); + } + + fn finish_row(&mut self) { + if self.current_row.is_empty() { + return; + } + let row = std::mem::take(&mut self.current_row); + if self.in_head { + self.headers = row; + } else { + self.rows.push(row); + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct RenderState { + emphasis: usize, + strong: usize, + heading_level: Option<u8>, + quote: usize, + list_stack: Vec<ListKind>, + link_stack: Vec<LinkState>, + table: Option<TableState>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LinkState { + destination: String, + text: String, +} + +impl RenderState { + fn style_text(&self, text: &str, theme: &ColorTheme) -> String { + let mut style = text.stylize(); + + if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 { + style = style.bold(); + } + if self.emphasis > 0 { + style = style.italic(); + } + + if let Some(level) = self.heading_level { + style = match level { + 1 => style.with(theme.heading), + 2 => style.white(), + 3 => style.with(Color::Blue), + _ => style.with(Color::Grey), + }; + } else if self.strong > 0 { + style = style.with(theme.strong); + } else if self.emphasis > 0 { + style = style.with(theme.emphasis); + } + + if self.quote > 0 { + style = style.with(theme.quote); + } + + format!("{style}") + } + + fn append_raw(&mut self, output: &mut String, text: &str) { + if let Some(link) = self.link_stack.last_mut() { + link.text.push_str(text); + } else if let Some(table) = self.table.as_mut() { + table.current_cell.push_str(text); + } else { + output.push_str(text); + } + } + + fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) { + let styled = self.style_text(text, theme); + self.append_raw(output, &styled); + } +} + +#[derive(Debug)] +pub struct TerminalRenderer { + syntax_set: SyntaxSet, + syntax_theme: Theme, + color_theme: ColorTheme, +} + +impl Default for TerminalRenderer { + fn default() -> Self { + let syntax_set = SyntaxSet::load_defaults_newlines(); + let syntax_theme = ThemeSet::load_defaults() + .themes + .remove("base16-ocean.dark") + .unwrap_or_default(); + Self { + syntax_set, + syntax_theme, + color_theme: ColorTheme::default(), + } + } +} + +impl TerminalRenderer { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn color_theme(&self) -> &ColorTheme { + &self.color_theme + } + + #[must_use] + pub fn render_markdown(&self, markdown: &str) -> String { + let mut output = String::new(); + let mut state = RenderState::default(); + let mut code_language = String::new(); + let mut code_buffer = String::new(); + let mut in_code_block = false; + + for event in Parser::new_ext(markdown, Options::all()) { + self.render_event( + event, + &mut state, + &mut output, + &mut code_buffer, + &mut code_language, + &mut in_code_block, + ); + } + + output.trim_end().to_string() + } + + #[must_use] + pub fn markdown_to_ansi(&self, markdown: &str) -> String { + self.render_markdown(markdown) + } + + #[allow(clippy::too_many_lines)] + fn render_event( + &self, + event: Event<'_>, + state: &mut RenderState, + output: &mut String, + code_buffer: &mut String, + code_language: &mut String, + in_code_block: &mut bool, + ) { + match event { + Event::Start(Tag::Heading { level, .. }) => { + self.start_heading(state, level as u8, output); + } + Event::End(TagEnd::Paragraph) => output.push_str("\n\n"), + Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), + Event::End(TagEnd::BlockQuote(..)) => { + state.quote = state.quote.saturating_sub(1); + output.push('\n'); + } + Event::End(TagEnd::Heading(..)) => { + state.heading_level = None; + output.push_str("\n\n"); + } + Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => { + state.append_raw(output, "\n"); + } + Event::Start(Tag::List(first_item)) => { + let kind = match first_item { + Some(index) => ListKind::Ordered { next_index: index }, + None => ListKind::Unordered, + }; + state.list_stack.push(kind); + } + Event::End(TagEnd::List(..)) => { + state.list_stack.pop(); + output.push('\n'); + } + Event::Start(Tag::Item) => Self::start_item(state, output), + Event::Start(Tag::CodeBlock(kind)) => { + *in_code_block = true; + *code_language = match kind { + CodeBlockKind::Indented => String::from("text"), + CodeBlockKind::Fenced(lang) => lang.to_string(), + }; + code_buffer.clear(); + self.start_code_block(code_language, output); + } + Event::End(TagEnd::CodeBlock) => { + self.finish_code_block(code_buffer, code_language, output); + *in_code_block = false; + code_language.clear(); + code_buffer.clear(); + } + Event::Start(Tag::Emphasis) => state.emphasis += 1, + Event::End(TagEnd::Emphasis) => state.emphasis = state.emphasis.saturating_sub(1), + Event::Start(Tag::Strong) => state.strong += 1, + Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1), + Event::Code(code) => { + let rendered = + format!("{}", format!("`{code}`").with(self.color_theme.inline_code)); + state.append_raw(output, &rendered); + } + Event::Rule => output.push_str("---\n"), + Event::Text(text) => { + self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block); + } + Event::Html(html) | Event::InlineHtml(html) => { + state.append_raw(output, &html); + } + Event::FootnoteReference(reference) => { + state.append_raw(output, &format!("[{reference}]")); + } + Event::TaskListMarker(done) => { + state.append_raw(output, if done { "[x] " } else { "[ ] " }); + } + Event::InlineMath(math) | Event::DisplayMath(math) => { + state.append_raw(output, &math); + } + Event::Start(Tag::Link { dest_url, .. }) => { + state.link_stack.push(LinkState { + destination: dest_url.to_string(), + text: String::new(), + }); + } + Event::End(TagEnd::Link) => { + if let Some(link) = state.link_stack.pop() { + let label = if link.text.is_empty() { + link.destination.clone() + } else { + link.text + }; + let rendered = format!( + "{}", + format!("[{label}]({})", link.destination) + .underlined() + .with(self.color_theme.link) + ); + state.append_raw(output, &rendered); + } + } + Event::Start(Tag::Image { dest_url, .. }) => { + let rendered = format!( + "{}", + format!("[image:{dest_url}]").with(self.color_theme.link) + ); + state.append_raw(output, &rendered); + } + Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()), + Event::End(TagEnd::Table) => { + if let Some(table) = state.table.take() { + output.push_str(&self.render_table(&table)); + output.push_str("\n\n"); + } + } + Event::Start(Tag::TableHead) => { + if let Some(table) = state.table.as_mut() { + table.in_head = true; + } + } + Event::End(TagEnd::TableHead) => { + if let Some(table) = state.table.as_mut() { + table.finish_row(); + table.in_head = false; + } + } + Event::Start(Tag::TableRow) => { + if let Some(table) = state.table.as_mut() { + table.current_row.clear(); + table.current_cell.clear(); + } + } + Event::End(TagEnd::TableRow) => { + if let Some(table) = state.table.as_mut() { + table.finish_row(); + } + } + Event::Start(Tag::TableCell) => { + if let Some(table) = state.table.as_mut() { + table.current_cell.clear(); + } + } + Event::End(TagEnd::TableCell) => { + if let Some(table) = state.table.as_mut() { + table.push_cell(); + } + } + Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _) + | Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {} + } + } + + #[allow(clippy::unused_self)] + fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) { + state.heading_level = Some(level); + if !output.is_empty() { + output.push('\n'); + } + } + + fn start_quote(&self, state: &mut RenderState, output: &mut String) { + state.quote += 1; + let _ = write!(output, "{}", "│ ".with(self.color_theme.quote)); + } + + fn start_item(state: &mut RenderState, output: &mut String) { + let depth = state.list_stack.len().saturating_sub(1); + output.push_str(&" ".repeat(depth)); + + let marker = match state.list_stack.last_mut() { + Some(ListKind::Ordered { next_index }) => { + let value = *next_index; + *next_index += 1; + format!("{value}. ") + } + _ => "• ".to_string(), + }; + output.push_str(&marker); + } + + fn start_code_block(&self, code_language: &str, output: &mut String) { + let label = if code_language.is_empty() { + "code".to_string() + } else { + code_language.to_string() + }; + let _ = writeln!( + output, + "{}", + format!("╭─ {label}") + .bold() + .with(self.color_theme.code_block_border) + ); + } + + fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) { + output.push_str(&self.highlight_code(code_buffer, code_language)); + let _ = write!( + output, + "{}", + "╰─".bold().with(self.color_theme.code_block_border) + ); + output.push_str("\n\n"); + } + + fn push_text( + &self, + text: &str, + state: &mut RenderState, + output: &mut String, + code_buffer: &mut String, + in_code_block: bool, + ) { + if in_code_block { + code_buffer.push_str(text); + } else { + state.append_styled(output, text, &self.color_theme); + } + } + + fn render_table(&self, table: &TableState) -> String { + let mut rows = Vec::new(); + if !table.headers.is_empty() { + rows.push(table.headers.clone()); + } + rows.extend(table.rows.iter().cloned()); + + if rows.is_empty() { + return String::new(); + } + + let column_count = rows.iter().map(Vec::len).max().unwrap_or(0); + let widths = (0..column_count) + .map(|column| { + rows.iter() + .filter_map(|row| row.get(column)) + .map(|cell| visible_width(cell)) + .max() + .unwrap_or(0) + }) + .collect::<Vec<_>>(); + + let border = format!("{}", "│".with(self.color_theme.table_border)); + let separator = widths + .iter() + .map(|width| "─".repeat(*width + 2)) + .collect::<Vec<_>>() + .join(&format!("{}", "┼".with(self.color_theme.table_border))); + let separator = format!("{border}{separator}{border}"); + + let mut output = String::new(); + if !table.headers.is_empty() { + output.push_str(&self.render_table_row(&table.headers, &widths, true)); + output.push('\n'); + output.push_str(&separator); + if !table.rows.is_empty() { + output.push('\n'); + } + } + + for (index, row) in table.rows.iter().enumerate() { + output.push_str(&self.render_table_row(row, &widths, false)); + if index + 1 < table.rows.len() { + output.push('\n'); + } + } + + output + } + + fn render_table_row(&self, row: &[String], widths: &[usize], is_header: bool) -> String { + let border = format!("{}", "│".with(self.color_theme.table_border)); + let mut line = String::new(); + line.push_str(&border); + + for (index, width) in widths.iter().enumerate() { + let cell = row.get(index).map_or("", String::as_str); + line.push(' '); + if is_header { + let _ = write!(line, "{}", cell.bold().with(self.color_theme.heading)); + } else { + line.push_str(cell); + } + let padding = width.saturating_sub(visible_width(cell)); + line.push_str(&" ".repeat(padding + 1)); + line.push_str(&border); + } + + line + } + + #[must_use] + pub fn highlight_code(&self, code: &str, language: &str) -> String { + let syntax = self + .syntax_set + .find_syntax_by_token(language) + .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); + let mut syntax_highlighter = HighlightLines::new(syntax, &self.syntax_theme); + let mut colored_output = String::new(); + + for line in LinesWithEndings::from(code) { + match syntax_highlighter.highlight_line(line, &self.syntax_set) { + Ok(ranges) => { + let escaped = as_24_bit_terminal_escaped(&ranges[..], false); + colored_output.push_str(&apply_code_block_background(&escaped)); + } + Err(_) => colored_output.push_str(&apply_code_block_background(line)), + } + } + + colored_output + } + + pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> { + let rendered_markdown = self.markdown_to_ansi(markdown); + write!(out, "{rendered_markdown}")?; + if !rendered_markdown.ends_with('\n') { + writeln!(out)?; + } + out.flush() + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MarkdownStreamState { + pending: String, +} + +impl MarkdownStreamState { + #[must_use] + pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> { + self.pending.push_str(delta); + let split = find_stream_safe_boundary(&self.pending)?; + let ready = self.pending[..split].to_string(); + self.pending.drain(..split); + Some(renderer.markdown_to_ansi(&ready)) + } + + #[must_use] + pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option<String> { + if self.pending.trim().is_empty() { + self.pending.clear(); + None + } else { + let pending = std::mem::take(&mut self.pending); + Some(renderer.markdown_to_ansi(&pending)) + } + } +} + +fn apply_code_block_background(line: &str) -> String { + let trimmed = line.trim_end_matches('\n'); + let trailing_newline = if trimmed.len() == line.len() { + "" + } else { + "\n" + }; + let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m"); + format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}") +} + +fn find_stream_safe_boundary(markdown: &str) -> Option<usize> { + let mut in_fence = false; + let mut last_boundary = None; + + for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| { + let start = *cursor; + *cursor += line.len(); + Some((start, line)) + }) { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + if !in_fence { + last_boundary = Some(offset + line.len()); + } + continue; + } + + if in_fence { + continue; + } + + if trimmed.is_empty() { + last_boundary = Some(offset + line.len()); + } + } + + last_boundary +} + +fn visible_width(input: &str) -> usize { + strip_ansi(input).chars().count() +} + +fn strip_ansi(input: &str) -> String { + let mut output = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + if chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } + } else { + output.push(ch); + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer}; + + #[test] + fn renders_markdown_with_styling_and_lists() { + let terminal_renderer = TerminalRenderer::new(); + let markdown_output = terminal_renderer + .render_markdown("# Heading\n\nThis is **bold** and *italic*.\n\n- item\n\n`code`"); + + assert!(markdown_output.contains("Heading")); + assert!(markdown_output.contains("• item")); + assert!(markdown_output.contains("code")); + assert!(markdown_output.contains('\u{1b}')); + } + + #[test] + fn renders_links_as_colored_markdown_labels() { + let terminal_renderer = TerminalRenderer::new(); + let markdown_output = + terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now."); + let plain_text = strip_ansi(&markdown_output); + + assert!(plain_text.contains("[Claw](https://example.com/docs)")); + assert!(markdown_output.contains('\u{1b}')); + } + + #[test] + fn highlights_fenced_code_blocks() { + let terminal_renderer = TerminalRenderer::new(); + let markdown_output = + terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```"); + let plain_text = strip_ansi(&markdown_output); + + assert!(plain_text.contains("╭─ rust")); + assert!(plain_text.contains("fn hi")); + assert!(markdown_output.contains('\u{1b}')); + assert!(markdown_output.contains("[48;5;236m")); + } + + #[test] + fn renders_ordered_and_nested_lists() { + let terminal_renderer = TerminalRenderer::new(); + let markdown_output = + terminal_renderer.render_markdown("1. first\n2. second\n - nested\n - child"); + let plain_text = strip_ansi(&markdown_output); + + assert!(plain_text.contains("1. first")); + assert!(plain_text.contains("2. second")); + assert!(plain_text.contains(" • nested")); + assert!(plain_text.contains(" • child")); + } + + #[test] + fn renders_tables_with_alignment() { + let terminal_renderer = TerminalRenderer::new(); + let markdown_output = terminal_renderer + .render_markdown("| Name | Value |\n| ---- | ----- |\n| alpha | 1 |\n| beta | 22 |"); + let plain_text = strip_ansi(&markdown_output); + let lines = plain_text.lines().collect::<Vec<_>>(); + + assert_eq!(lines[0], "│ Name │ Value │"); + assert_eq!(lines[1], "│───────┼───────│"); + assert_eq!(lines[2], "│ alpha │ 1 │"); + assert_eq!(lines[3], "│ beta │ 22 │"); + assert!(markdown_output.contains('\u{1b}')); + } + + #[test] + fn streaming_state_waits_for_complete_blocks() { + let renderer = TerminalRenderer::new(); + let mut state = MarkdownStreamState::default(); + + assert_eq!(state.push(&renderer, "# Heading"), None); + let flushed = state + .push(&renderer, "\n\nParagraph\n\n") + .expect("completed block"); + let plain_text = strip_ansi(&flushed); + assert!(plain_text.contains("Heading")); + assert!(plain_text.contains("Paragraph")); + + assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None); + let code = state + .push(&renderer, "```\n") + .expect("closed code fence flushes"); + assert!(strip_ansi(&code).contains("fn main()")); + } + + #[test] + fn spinner_advances_frames() { + let terminal_renderer = TerminalRenderer::new(); + let mut spinner = Spinner::new(); + let mut out = Vec::new(); + spinner + .tick("Working", terminal_renderer.color_theme(), &mut out) + .expect("tick succeeds"); + spinner + .tick("Working", terminal_renderer.color_theme(), &mut out) + .expect("tick succeeds"); + + let output = String::from_utf8_lossy(&out); + assert!(output.contains("Working")); + } +}