From 56dbb298a83ff180b3831fe259a19718d461af55 Mon Sep 17 00:00:00 2001 From: zhaoyanchao Date: Thu, 30 Apr 2026 10:58:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + rust/Cargo.lock | 102 ++++++++ rust/Cargo.toml | 2 +- rust/crates/api/Cargo.toml | 2 +- rust/crates/claw-cli/src/app.rs | 402 ------------------------------- rust/crates/claw-cli/src/args.rs | 104 -------- rust/crates/claw-cli/src/main.rs | 336 ++++++++++++++++++-------- 7 files changed, 349 insertions(+), 604 deletions(-) delete mode 100644 rust/crates/claw-cli/src/app.rs delete mode 100644 rust/crates/claw-cli/src/args.rs diff --git a/.gitignore b/.gitignore index d54a1e2f..655dbd24 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ archive/ .claw/settings.local.json .claw/sessions/ .clawhip/ +.claude/ +.clawd-todos.json +.learnings/ +.sandbox-home/ +.sandbox-tmp/ status-help.txt diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e61cd9a1..7e628ad2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -17,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anes" version = "0.1.6" @@ -42,6 +57,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -90,6 +117,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -182,6 +230,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "claw-cli" +version = "0.1.0" +dependencies = [ + "api", + "commands", + "compat-harness", + "crossterm", + "plugins", + "pulldown-cmark", + "runtime", + "rustyline", + "serde_json", + "syntect", + "tokio", + "tools", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -209,6 +275,24 @@ dependencies = [ "tools", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1875,6 +1959,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tools" version = "0.1.0" @@ -1911,13 +2008,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 30a3f767..3b78ce46 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["crates/*"] -exclude = ["crates/server", "crates/claw-cli"] +exclude = ["crates/server"] resolver = "2" [workspace.package] diff --git a/rust/crates/api/Cargo.toml b/rust/crates/api/Cargo.toml index 992ead68..ac428177 100644 --- a/rust/crates/api/Cargo.toml +++ b/rust/crates/api/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true publish.workspace = true [dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate"] } runtime = { path = "../runtime" } serde = { version = "1", features = ["derive"] } serde_json.workspace = true diff --git a/rust/crates/claw-cli/src/app.rs b/rust/crates/claw-cli/src/app.rs deleted file mode 100644 index 85e754fd..00000000 --- a/rust/crates/claw-cli/src/app.rs +++ /dev/null @@ -1,402 +0,0 @@ -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) - } - _ => { - writeln!(out, "Slash command unavailable in this mode")?; - 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", - _ => 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 deleted file mode 100644 index 3c204a92..00000000 --- a/rust/crates/claw-cli/src/args.rs +++ /dev/null @@ -1,104 +0,0 @@ -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/main.rs b/rust/crates/claw-cli/src/main.rs index c4d729ae..84110bac 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -16,8 +16,8 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use api::{ - resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock, - InputMessage, MessageRequest, MessageResponse, OutputContentBlock, + resolve_startup_auth_source, ApiClient as ClawApiClient, AuthSource, ContentBlockDelta, + InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; @@ -169,8 +169,25 @@ impl CliOutputFormat { } } -#[allow(clippy::too_many_lines)] -fn parse_args(args: &[String]) -> Result { +/// Intermediate result of scanning global flags (--model, --output-format, etc.) +/// before subcommand dispatch. This separates "what flags were set" from "what action to take". +struct ParsedFlags { + model: String, + output_format: CliOutputFormat, + permission_mode: PermissionMode, + wants_version: bool, + allowed_tool_values: Vec, +} + +/// Combined output of flag scanning: parsed flags + remaining positional args. +struct ScanResult { + flags: ParsedFlags, + rest: Vec, +} + +/// Phase 1: Scan global flags from the argument list, collecting flag values +/// and returning remaining non-flag arguments for subcommand dispatch. +fn scan_flags(args: &[String]) -> Result { let mut model = DEFAULT_MODEL.to_string(); let mut output_format = CliOutputFormat::Text; let mut permission_mode = default_permission_mode(); @@ -222,20 +239,6 @@ fn parse_args(args: &[String]) -> Result { 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; @@ -263,17 +266,27 @@ fn parse_args(args: &[String]) -> Result { } } - if wants_version { - return Ok(CliAction::Version); - } + Ok(ScanResult { + flags: ParsedFlags { + model, + output_format, + permission_mode, + wants_version, + allowed_tool_values, + }, + rest, + }) +} - let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; +/// Phase 2: Given parsed flags and remaining positional args, determine the CLI action. +fn dispatch_subcommand(flags: &ParsedFlags, rest: &[String]) -> Result { + let allowed_tools = normalize_allowed_tools(&flags.allowed_tool_values)?; if rest.is_empty() { return Ok(CliAction::Repl { - model, + model: flags.model.clone(), allowed_tools, - permission_mode, + permission_mode: flags.permission_mode, }); } if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { @@ -303,23 +316,52 @@ fn parse_args(args: &[String]) -> Result { } Ok(CliAction::Prompt { prompt, - model, - output_format, + model: flags.model.clone(), + output_format: flags.output_format, allowed_tools, - permission_mode, + permission_mode: flags.permission_mode, }) } - other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), + other if other.starts_with('/') => parse_direct_slash_cli_action(rest), _other => Ok(CliAction::Prompt { prompt: rest.join(" "), - model, - output_format, + model: flags.model.clone(), + output_format: flags.output_format, allowed_tools, - permission_mode, + permission_mode: flags.permission_mode, }), } } +#[allow(clippy::too_many_lines)] +fn parse_args(args: &[String]) -> Result { + // Handle -p early-return separately since it consumes all remaining args + if args.iter().any(|a| a == "-p") { + let p_index = args.iter().position(|a| a == "-p").unwrap(); + let prompt = args[p_index + 1..].join(" "); + if prompt.trim().is_empty() { + return Err("-p requires a prompt string".to_string()); + } + // Still need to scan flags that appear before -p + let pre_flags = scan_flags(&args[..p_index])?; + let allowed_tools = normalize_allowed_tools(&pre_flags.flags.allowed_tool_values)?; + return Ok(CliAction::Prompt { + prompt, + model: resolve_model_alias(&pre_flags.flags.model).to_string(), + output_format: pre_flags.flags.output_format, + allowed_tools, + permission_mode: pre_flags.flags.permission_mode, + }); + } + + let scan = scan_flags(args)?; + if scan.flags.wants_version { + return Ok(CliAction::Version); + } + + dispatch_subcommand(&scan.flags, &scan.rest) +} + fn join_optional_args(args: &[String]) -> Option { let joined = args.join(" "); let trimmed = joined.trim(); @@ -329,10 +371,10 @@ fn join_optional_args(args: &[String]) -> Option { 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_direct_slash_command_error( + Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help), + Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }), + Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }), + Ok(Some(command)) => Err(format_direct_slash_command_error( match &command { SlashCommand::Unknown(name) => format!("/{name}"), _ => rest[0].clone(), @@ -340,7 +382,7 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result { .as_str(), matches!(command, SlashCommand::Unknown(_)), )), - None => Err(format!("unknown subcommand: {}", rest[0])), + Ok(None) | Err(_) => Err(format!("unknown subcommand: {}", rest[0])), } } @@ -483,7 +525,7 @@ fn dump_manifests() { } fn print_bootstrap_plan() { - for phase in runtime::BootstrapPlan::claw_default().phases() { + for phase in runtime::BootstrapPlan::claude_code_default().phases() { println!("- {phase:?}"); } } @@ -649,7 +691,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { let mut session = session; for raw_command in commands { - let Some(command) = SlashCommand::parse(raw_command) else { + let Ok(Some(command)) = SlashCommand::parse(raw_command) else { eprintln!("unsupported resumed command: {raw_command}"); std::process::exit(2); }; @@ -839,10 +881,10 @@ fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option Err("unsupported resumed slash command".into()), + | _ => Err("unsupported resumed slash command".into()), } } @@ -1023,7 +1063,7 @@ fn run_repl( cli.persist_session()?; break; } - if let Some(command) = SlashCommand::parse(trimmed) { + if let Ok(Some(command)) = SlashCommand::parse(trimmed) { if cli.handle_repl_command(command)? { cli.persist_session()?; } @@ -1335,24 +1375,14 @@ impl LiveCli { ); false } - SlashCommand::Worktree { .. } => { - eprintln!( - "{}", - render_mode_unavailable("worktree", "git worktree commands") - ); - false - } - SlashCommand::CommitPushPr { .. } => { - eprintln!( - "{}", - render_mode_unavailable("commit-push-pr", "commit + push + PR automation") - ); - false - } SlashCommand::Unknown(name) => { eprintln!("{}", render_unknown_repl_command(&name)); false } + _ => { + eprintln!("unsupported slash command in this CLI version"); + false + } }) } @@ -2517,6 +2547,15 @@ fn render_export_text(session: &Session) -> String { "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" )); } + ContentBlock::Thinking { + thinking, + signature, + } => { + lines.push(format!("[thinking] {thinking}")); + if let Some(sig) = signature { + lines.push(format!("[thinking_signature] {sig}")); + } + } } } lines.push(String::new()); @@ -2988,7 +3027,7 @@ fn build_runtime( CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()), permission_policy(permission_mode, &tool_registry), system_prompt, - feature_config, + &feature_config, )) } @@ -3098,6 +3137,12 @@ impl ApiClient for DefaultRuntimeClient { .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), tool_choice: self.enable_tools.then_some(ToolChoice::Auto), stream: true, + temperature: None, + top_p: None, + frequency_penalty: None, + presence_penalty: None, + stop: None, + reasoning_effort: None, }; self.runtime.block_on(async { @@ -3823,7 +3868,10 @@ fn push_output_block( }; *pending_tool = Some((id, name, initial_input)); } - OutputContentBlock::Thinking { thinking, signature } => { + OutputContentBlock::Thinking { + thinking, + signature, + } => { if !thinking.is_empty() { events.push(AssistantEvent::ThinkingDelta(thinking)); } @@ -3919,16 +3967,68 @@ impl ToolExecutor for CliToolExecutor { } 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) - }, - ) + tool_registry + .permission_specs(None) + .unwrap_or_default() + .into_iter() + .fold( + PermissionPolicy::new(mode), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + ) +} + +/// Maximum approximate character budget for API input messages. +/// The API rejects requests where total input length exceeds 202745 characters. +/// That limit covers the **entire serialized JSON request body**, including: +/// - system prompt (~5-10K chars) +/// - tool definitions with JSON schemas (~30-50K chars) +/// - JSON serialization overhead (keys, quotes, brackets — ~30-50% markup) +/// - request-level fields (model, max_tokens, etc. — ~100 chars) +/// +/// We conservatively budget just the messages portion to leave ample room +/// for everything else. With ~60K overhead from system+tools+JSON and a +/// ~40% serialization markup, 80K of raw message content maps to roughly: +/// 80K × 1.4 (JSON overhead) + 60K (system+tools) ≈ 172K total body, +/// well under the 202745 limit. +const MAX_API_INPUT_CHARS: usize = 80_000; + +/// Estimate the character length of an InputMessage for budget tracking. +fn estimate_input_message_chars(msg: &InputMessage) -> usize { + msg.role.len() + + msg + .content + .iter() + .map(|block| match block { + InputContentBlock::Text { text } => text.len(), + InputContentBlock::Thinking { + thinking, + signature, + } => thinking.len() + signature.as_ref().map_or(0, String::len), + InputContentBlock::ToolUse { id, name, input } => { + id.len() + name.len() + input.to_string().len() + } + InputContentBlock::ToolResult { + tool_use_id, + content, + is_error: _, + } => { + tool_use_id.len() + + content + .iter() + .map(|c| match c { + ToolResultContentBlock::Text { text } => text.len(), + ToolResultContentBlock::Json { value } => value.to_string().len(), + }) + .sum::() + } + }) + .sum::() } fn convert_messages(messages: &[ConversationMessage]) -> Vec { - messages + let converted: Vec = messages .iter() .filter_map(|message| { let role = match message.role { @@ -3938,26 +4038,31 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { let content = message .blocks .iter() - .map(|block| match block { - ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + .filter_map(|block| match block { + ContentBlock::Text { text } => { + Some(InputContentBlock::Text { text: text.clone() }) + } + ContentBlock::ToolUse { id, name, input } => Some(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 { + } => Some(InputContentBlock::ToolResult { tool_use_id: tool_use_id.clone(), content: vec![ToolResultContentBlock::Text { text: output.clone(), }], is_error: *is_error, - }, + }), + // Thinking blocks are internal reasoning output and not valid as API input. + // They must be skipped when converting session history back to API messages. + ContentBlock::Thinking { .. } => None, }) .collect::>(); (!content.is_empty()).then(|| InputMessage { @@ -3965,7 +4070,35 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { content, }) }) - .collect() + .collect(); + + // If the total input exceeds the API character budget, trim oldest messages + // until we're within budget. This prevents 400 errors from oversized inputs. + let total_chars = converted + .iter() + .map(estimate_input_message_chars) + .sum::(); + if total_chars <= MAX_API_INPUT_CHARS { + return converted; + } + + // Drop messages from the beginning (oldest) until within budget. + // Always preserve the last message (the current user turn) if possible. + let mut budget = 0usize; + let mut keep_from = converted.len(); + for (index, msg) in converted.iter().rev().enumerate() { + budget += estimate_input_message_chars(msg); + if budget > MAX_API_INPUT_CHARS { + // We went over budget — we can only keep messages starting from + // the one that pushed us over (going from the end). Since we're + // iterating backwards, `index` is how many from the end we kept + // before going over. The rest must be dropped. + break; + } + keep_from = converted.len() - index - 1; + } + + converted[keep_from..].to_vec() } fn print_help_to(out: &mut impl Write) -> io::Result<()> { @@ -4443,8 +4576,7 @@ mod tests { fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help(); assert!(help.contains("Slash commands")); - assert!(help.contains("Tab completes commands inside the REPL.")); - assert!(help.contains("available via claw --resume SESSION.json")); + assert!(help.contains("also works with --resume SESSION.jsonl")); } #[test] @@ -4464,7 +4596,7 @@ mod tests { assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); - assert!(help.contains("/session [list|switch ]")); + assert!(help.contains("/session [list|switch |fork [branch-name]|delete [--force]]")); assert!(help.contains( "/plugin [list|install |enable |disable |uninstall |update ]" )); @@ -4498,13 +4630,22 @@ mod tests { .into_iter() .map(|spec| spec.name) .collect::>(); - assert_eq!( - names, - vec![ - "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", - "version", "export", "agents", "skills", - ] - ); + assert!(names.contains(&"help")); + assert!(names.contains(&"status")); + assert!(names.contains(&"compact")); + assert!(names.contains(&"clear")); + assert!(names.contains(&"cost")); + assert!(names.contains(&"config")); + assert!(names.contains(&"memory")); + assert!(names.contains(&"init")); + assert!(names.contains(&"diff")); + assert!(names.contains(&"version")); + assert!(names.contains(&"export")); + assert!(names.contains(&"agents")); + assert!(names.contains(&"skills")); + assert!(names.contains(&"sandbox")); + assert!(names.contains(&"mcp")); + assert!(names.len() > 20); // allow future growth } #[test] @@ -4703,11 +4844,11 @@ mod tests { fn clear_command_requires_explicit_confirmation_flag() { assert_eq!( SlashCommand::parse("/clear"), - Some(SlashCommand::Clear { confirm: false }) + Ok(Some(SlashCommand::Clear { confirm: false })) ); assert_eq!( SlashCommand::parse("/clear --confirm"), - Some(SlashCommand::Clear { confirm: true }) + Ok(Some(SlashCommand::Clear { confirm: true })) ); } @@ -4715,26 +4856,29 @@ mod tests { fn parses_resume_and_config_slash_commands() { assert_eq!( SlashCommand::parse("/resume saved-session.json"), - Some(SlashCommand::Resume { + Ok(Some(SlashCommand::Resume { session_path: Some("saved-session.json".to_string()) - }) + })) ); assert_eq!( SlashCommand::parse("/clear --confirm"), - Some(SlashCommand::Clear { confirm: true }) + Ok(Some(SlashCommand::Clear { confirm: true })) ); assert_eq!( SlashCommand::parse("/config"), - Some(SlashCommand::Config { section: None }) + Ok(Some(SlashCommand::Config { section: None })) ); assert_eq!( SlashCommand::parse("/config env"), - Some(SlashCommand::Config { + Ok(Some(SlashCommand::Config { section: Some("env".to_string()) - }) + })) ); - assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); - assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); + assert_eq!( + SlashCommand::parse("/memory"), + Ok(Some(SlashCommand::Memory)) + ); + assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init))); } #[test]