This commit is contained in:
zhaoyanchao 2026-04-30 10:58:25 +08:00
parent 3810cbb5e4
commit 56dbb298a8
7 changed files with 349 additions and 604 deletions

5
.gitignore vendored
View File

@ -9,6 +9,11 @@ archive/
.claw/settings.local.json
.claw/sessions/
.clawhip/
.claude/
.clawd-todos.json
.learnings/
.sandbox-home/
.sandbox-tmp/
status-help.txt

102
rust/Cargo.lock generated
View File

@ -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",

View File

@ -1,6 +1,6 @@
[workspace]
members = ["crates/*"]
exclude = ["crates/server", "crates/claw-cli"]
exclude = ["crates/server"]
resolver = "2"
[workspace.package]

View File

@ -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

View File

@ -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<PathBuf>,
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<String>) -> 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<Self> {
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<ConversationMessage>,
}
impl CliApp {
pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
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<CommandResult> {
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<CommandResult> {
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<CommandResult> {
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<CommandResult> {
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("<none>"), |path| path.display().to_string())
)?;
Ok(CommandResult::Continue)
}
fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
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")));
}
}

View File

@ -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<PathBuf>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub output_format: OutputFormat,
#[command(subcommand)]
pub command: Option<Command>,
}
#[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<String> },
}
#[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);
}
}

View File

@ -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<CliAction, String> {
/// 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<String>,
}
/// Combined output of flag scanning: parsed flags + remaining positional args.
struct ScanResult {
flags: ParsedFlags,
rest: Vec<String>,
}
/// 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<ScanResult, String> {
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<CliAction, String> {
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<CliAction, String> {
}
}
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<CliAction, String> {
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<CliAction, String> {
}
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<CliAction, String> {
// 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<String> {
let joined = args.join(" ");
let trimmed = joined.trim();
@ -329,10 +371,10 @@ fn join_optional_args(args: &[String]) -> Option<String> {
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
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<CliAction, String> {
.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<PathBuf>, Option<S
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
// git status --short format: "## branch...remote/branch" or "## branch"
// Split on "..." first to strip remote tracking info, then trailing whitespace.
let after_dots = line.split("...").next().unwrap_or(line);
after_dots.trim().to_string()
})
.filter(|value| !value.is_empty())
});
@ -986,8 +1028,6 @@ fn run_resume_command(
}
SlashCommand::Bughunter { .. }
| SlashCommand::Branch { .. }
| SlashCommand::Worktree { .. }
| SlashCommand::CommitPushPr { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
@ -999,7 +1039,7 @@ fn run_resume_command(
| SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Unknown(_) => 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::<usize>()
}
})
.sum::<usize>()
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
let converted: Vec<InputMessage> = messages
.iter()
.filter_map(|message| {
let role = match message.role {
@ -3938,26 +4038,31 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
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::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
@ -3965,7 +4070,35 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
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::<usize>();
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 <session-id>]"));
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]|delete <session-id> [--force]]"));
assert!(help.contains(
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
@ -4498,13 +4630,22 @@ mod tests {
.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",
]
);
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]