@@ -11,8 +11,8 @@ use std::process::Command;
use std ::time ::{ SystemTime , UNIX_EPOCH } ;
use api ::{
resolve_startup_auth_source , AnthropicClient , AuthSource , ContentBlockDelta , InputContentBlock ,
InputMessage , MessageRequest , MessageResponse , OutputContentBlock ,
resolve_startup_auth_source , AnthropicClient , AuthSource , ContentBlockDelta , ImageSource ,
InputContentBlock , InputMessage, MessageRequest , MessageResponse , OutputContentBlock ,
StreamEvent as ApiStreamEvent , ToolChoice , ToolDefinition , ToolResultContentBlock ,
} ;
@@ -22,9 +22,9 @@ use commands::{
use compat_harness ::{ extract_manifest , UpstreamPaths } ;
use render ::{ Spinner , TerminalRenderer } ;
use runtime ::{
clear_oauth_credentials , format_usd , generate_pkce_pair , generate_state , load_system_prompt ,
parse_oauth_callback_request_target , pricing_for_model , save_oauth_credentials , ApiClient ,
ApiRequest , AssistantEvent, CompactionConfig , ConfigLoader , ConfigSource , ContentBlock ,
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 ,
OAuthTokenExchangeRequest , PermissionMode , PermissionPolicy , ProjectContext , RuntimeError ,
Session , TokenUsage , ToolError , ToolExecutor , UsageTracker ,
@@ -36,12 +36,12 @@ const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS : u32 = 32 ;
const DEFAULT_DATE : & str = " 2026-03-31 " ;
const DEFAULT_OAUTH_CALLBACK_PORT : u16 = 4545 ;
const COST_WARNING_FRACTION : f64 = 0.8 ;
const VERSION : & str = env! ( " CARGO_PKG_VERSION " ) ;
const BUILD_TARGET : Option < & str > = option_env! ( " TARGET " ) ;
const GIT_SHA : Option < & str > = option_env! ( " GIT_SHA " ) ;
type AllowedToolSet = BTreeSet < String > ;
const IMAGE_REF_PREFIX : & str = " @ " ;
fn main ( ) {
if let Err ( error ) = run ( ) {
@@ -71,8 +71,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
output_format ,
allowed_tools ,
permission_mode ,
max_cost_usd ,
} = > LiveCli ::new ( model , false , allowed_tools , permission_mode , max_cost_usd ) ?
} = > LiveCli ::new ( model , false , allowed_tools , permission_mode ) ?
. run_turn_with_output ( & prompt , output_format ) ? ,
CliAction ::Login = > run_login ( ) ? ,
CliAction ::Logout = > run_logout ( ) ? ,
@@ -80,14 +79,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model ,
allowed_tools ,
permission_mode ,
max_cost_usd ,
} = > run_repl ( model , allowed_tools , permission_mode , max_cost_usd ) ? ,
} = > run_repl ( model , allowed_tools , permission_mode ) ? ,
CliAction ::Help = > print_help ( ) ,
}
Ok ( ( ) )
}
#[ derive(Debug, Clone, PartialEq) ]
#[ derive(Debug, Clone, PartialEq, Eq ) ]
enum CliAction {
DumpManifests ,
BootstrapPlan ,
@@ -106,7 +104,6 @@ enum CliAction {
output_format : CliOutputFormat ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
max_cost_usd : Option < f64 > ,
} ,
Login ,
Logout ,
@@ -114,7 +111,6 @@ enum CliAction {
model : String ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
max_cost_usd : Option < f64 > ,
} ,
// prompt-mode formatting is only supported for non-interactive runs
Help ,
@@ -144,7 +140,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut output_format = CliOutputFormat ::Text ;
let mut permission_mode = default_permission_mode ( ) ;
let mut wants_version = false ;
let mut max_cost_usd : Option < f64 > = None ;
let mut allowed_tool_values = Vec ::new ( ) ;
let mut rest = Vec ::new ( ) ;
let mut index = 0 ;
@@ -180,13 +175,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg ( value ) ? ;
index + = 2 ;
}
" --max-cost " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --max-cost " . to_string ( ) ) ? ;
max_cost_usd = Some ( parse_max_cost_arg ( value ) ? ) ;
index + = 2 ;
}
flag if flag . starts_with ( " --output-format= " ) = > {
output_format = CliOutputFormat ::parse ( & flag [ 16 .. ] ) ? ;
index + = 1 ;
@@ -195,10 +183,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg ( & flag [ 18 .. ] ) ? ;
index + = 1 ;
}
flag if flag . starts_with ( " --max-cost= " ) = > {
max_cost_usd = Some ( parse_max_cost_arg ( & flag [ 11 .. ] ) ? ) ;
index + = 1 ;
}
" --allowedTools " | " --allowed-tools " = > {
let value = args
. get ( index + 1 )
@@ -232,7 +216,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model ,
allowed_tools ,
permission_mode ,
max_cost_usd ,
} ) ;
}
if matches! ( rest . first ( ) . map ( String ::as_str ) , Some ( " --help " | " -h " ) ) {
@@ -259,7 +242,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format ,
allowed_tools ,
permission_mode ,
max_cost_usd ,
} )
}
other if ! other . starts_with ( '/' ) = > Ok ( CliAction ::Prompt {
@@ -268,7 +250,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format ,
allowed_tools ,
permission_mode ,
max_cost_usd ,
} ) ,
other = > Err ( format! ( " unknown subcommand: {other} " ) ) ,
}
@@ -332,18 +313,6 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
. map ( permission_mode_from_label )
}
fn parse_max_cost_arg ( value : & str ) -> Result < f64 , String > {
let parsed = value
. parse ::< f64 > ( )
. map_err ( | _ | format! ( " invalid value for --max-cost: {value} " ) ) ? ;
if ! parsed . is_finite ( ) | | parsed < = 0.0 {
return Err ( format! (
" --max-cost must be a positive finite USD amount: {value} "
) ) ;
}
Ok ( parsed )
}
fn permission_mode_from_label ( mode : & str ) -> PermissionMode {
match mode {
" read-only " = > PermissionMode ::ReadOnly ,
@@ -710,78 +679,22 @@ fn format_permissions_switch_report(previous: &str, next: &str) -> String {
)
}
fn format_cost_report ( model : & str , usage : TokenUsage , max_cost_usd : Option < f64 > ) -> String {
let estimate = usage_cost_estimate ( model , usage ) ;
fn format_cost_report ( usage : TokenUsage ) -> String {
format! (
" Cost
Model {model}
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}
Input cost {}
Output cost {}
Cache create usd {}
Cache read usd {}
Estimated cost {}
Budget {} " ,
Total tokens {} " ,
usage . input_tokens ,
usage . output_tokens ,
usage . cache_creation_input_tokens ,
usage . cache_read_input_tokens ,
usage . total_tokens ( ) ,
format_usd ( estimate . input_cost_usd ) ,
format_usd ( estimate . output_cost_usd ) ,
format_usd ( estimate . cache_creation_cost_usd ) ,
format_usd ( estimate . cache_read_cost_usd ) ,
format_usd ( estimate . total_cost_usd ( ) ) ,
format_budget_line ( estimate . total_cost_usd ( ) , max_cost_usd ) ,
)
}
fn usage_cost_estimate ( model : & str , usage : TokenUsage ) -> runtime ::UsageCostEstimate {
pricing_for_model ( model ) . map_or_else (
| | usage . estimate_cost_usd ( ) ,
| pricing | usage . estimate_cost_usd_with_pricing ( pricing ) ,
)
}
fn usage_cost_total ( model : & str , usage : TokenUsage ) -> f64 {
usage_cost_estimate ( model , usage ) . total_cost_usd ( )
}
fn format_budget_line ( cost_usd : f64 , max_cost_usd : Option < f64 > ) -> String {
match max_cost_usd {
Some ( limit ) = > format! ( " {} / {} " , format_usd ( cost_usd ) , format_usd ( limit ) ) ,
None = > format! ( " {} (unlimited) " , format_usd ( cost_usd ) ) ,
}
}
fn budget_notice_message (
model : & str ,
usage : TokenUsage ,
max_cost_usd : Option < f64 > ,
) -> Option < String > {
let limit = max_cost_usd ? ;
let cost = usage_cost_total ( model , usage ) ;
if cost > = limit {
Some ( format! (
" cost budget exceeded: cumulative= {} budget= {} " ,
format_usd ( cost ) ,
format_usd ( limit )
) )
} else if cost > = limit * COST_WARNING_FRACTION {
Some ( format! (
" approaching cost budget: cumulative= {} budget= {} " ,
format_usd ( cost ) ,
format_usd ( limit )
) )
} else {
None
}
}
fn format_resume_report ( session_path : & str , message_count : usize , turns : u32 ) -> String {
format! (
" Session resumed
@@ -925,7 +838,6 @@ fn run_resume_command(
} ,
default_permission_mode ( ) . as_str ( ) ,
& status_context ( Some ( session_path ) ) ? ,
None ,
) ) ,
} )
}
@@ -933,7 +845,7 @@ fn run_resume_command(
let usage = UsageTracker ::from_session ( session ) . cumulative_usage ( ) ;
Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( format_cost_report ( " restored-session " , usage , Non e) ) ,
message : Some ( format_cost_report ( usag e) ) ,
} )
}
SlashCommand ::Config { section } = > Ok ( ResumeCommandOutcome {
@@ -980,9 +892,8 @@ fn run_repl(
model : String ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
max_cost_usd : Option < f64 > ,
) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let mut cli = LiveCli ::new ( model , true , allowed_tools , permission_mode , max_cost_usd )? ;
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 ( ) ) ;
@@ -1035,7 +946,6 @@ struct LiveCli {
model : String ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
max_cost_usd : Option < f64 > ,
system_prompt : Vec < String > ,
runtime : ConversationRuntime < AnthropicRuntimeClient , CliToolExecutor > ,
session : SessionHandle ,
@@ -1047,7 +957,6 @@ impl LiveCli {
enable_tools : bool ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
max_cost_usd : Option < f64 > ,
) -> Result < Self , Box < dyn std ::error ::Error > > {
let system_prompt = build_system_prompt ( ) ? ;
let session = create_managed_session_handle ( ) ? ;
@@ -1063,7 +972,6 @@ impl LiveCli {
model ,
allowed_tools ,
permission_mode ,
max_cost_usd ,
system_prompt ,
runtime ,
session ,
@@ -1074,10 +982,9 @@ impl LiveCli {
fn startup_banner ( & self ) -> String {
format! (
" Rusty Claude CLI \n Model {} \n Permission mode {} \n Cost budget {} \n Working directory {} \n Session {} \n \n Type /help for commands. Shift+Enter or Ctrl+J inserts a newline. " ,
" Rusty Claude CLI \n Model {} \n Permission mode {} \n Working directory {} \n Session {} \n \n Type /help for commands. Shift+Enter or Ctrl+J inserts a newline. " ,
self . model ,
self . permission_mode . as_str ( ) ,
self . max_cost_usd . map_or_else ( | | " none " . to_string ( ) , format_usd ) ,
env ::current_dir ( ) . map_or_else (
| _ | " <unknown> " . to_string ( ) ,
| path | path . display ( ) . to_string ( ) ,
@@ -1087,7 +994,6 @@ impl LiveCli {
}
fn run_turn ( & mut self , input : & str ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
self . enforce_budget_before_turn ( ) ? ;
let mut spinner = Spinner ::new ( ) ;
let mut stdout = io ::stdout ( ) ;
spinner . tick (
@@ -1098,14 +1004,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter ::new ( self . permission_mode ) ;
let result = self . runtime . run_turn ( input , Some ( & mut permission_prompter ) ) ;
match result {
Ok ( summary ) = > {
Ok ( _ ) = > {
spinner . finish (
" Claude response complete " ,
TerminalRenderer ::new ( ) . color_theme ( ) ,
& mut stdout ,
) ? ;
println! ( ) ;
self . print_budget_notice ( summary . usage ) ;
self . persist_session ( ) ? ;
Ok ( ( ) )
}
@@ -1132,16 +1037,13 @@ impl LiveCli {
}
fn run_prompt_json ( & mut self , input : & str ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
self . enforce_budget_before_turn ( ) ? ;
let client = AnthropicClient ::from_auth ( resolve_cli_auth_source ( ) ? ) ;
let request = MessageRequest {
model : self . model . clone ( ) ,
max_tokens : DEFAULT_MAX_TOKENS ,
messages : vec ! [ InputMessage {
role : " user " . to_string ( ) ,
content : vec ! [ I nputContentBlock ::Text {
text : input . to_string ( ) ,
} ] ,
content : prompt_to_content_blocks ( i nput, & env ::current_dir ( ) ? ) ? ,
} ] ,
system : ( ! self . system_prompt . is_empty ( ) ) . then ( | | self . system_prompt . join ( " \n \n " ) ) ,
tools : None ,
@@ -1159,27 +1061,17 @@ impl LiveCli {
} )
. collect ::< Vec < _ > > ( )
. join ( " " ) ;
let 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 ,
} ;
println! (
" {} " ,
json! ( {
" message " : text ,
" model " : self . model ,
" usage " : {
" input_tokens " : usage . input_tokens ,
" output_tokens " : usage . output_tokens ,
" cache_creation_input_tokens " : usage . cache_creation_input_tokens ,
" cache_read_input_tokens " : usage . cache_read_input_tokens ,
} ,
" cost_usd " : usage_cost_total ( & self . model , usage ) ,
" cumulative_cost_usd " : usage_cost_total ( & self . model , usage ) ,
" max_cost_usd " : self . max_cost_usd ,
" budget_warning " : budget_notice_message ( & self . model , usage , self . max_cost_usd ) ,
" 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 ,
}
} )
) ;
Ok ( ( ) )
@@ -1249,28 +1141,6 @@ impl LiveCli {
Ok ( ( ) )
}
fn enforce_budget_before_turn ( & self ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let Some ( limit ) = self . max_cost_usd else {
return Ok ( ( ) ) ;
} ;
let cost = usage_cost_total ( & self . model , self . runtime . usage ( ) . cumulative_usage ( ) ) ;
if cost > = limit {
return Err ( format! (
" cost budget exceeded before starting turn: cumulative= {} budget= {} " ,
format_usd ( cost ) ,
format_usd ( limit )
)
. into ( ) ) ;
}
Ok ( ( ) )
}
fn print_budget_notice ( & self , usage : TokenUsage ) {
if let Some ( message ) = budget_notice_message ( & self . model , usage , self . max_cost_usd ) {
eprintln! ( " warning: {message} " ) ;
}
}
fn print_status ( & self ) {
let cumulative = self . runtime . usage ( ) . cumulative_usage ( ) ;
let latest = self . runtime . usage ( ) . current_turn_usage ( ) ;
@@ -1287,7 +1157,6 @@ impl LiveCli {
} ,
self . permission_mode . as_str ( ) ,
& status_context ( Some ( & self . session . path ) ) . expect ( " status context should load " ) ,
self . max_cost_usd ,
)
) ;
}
@@ -1405,10 +1274,7 @@ impl LiveCli {
fn print_cost ( & self ) {
let cumulative = self . runtime . usage ( ) . cumulative_usage ( ) ;
println! (
" {} " ,
format_cost_report ( & self . model , cumulative , self . max_cost_usd )
) ;
println! ( " {} " , format_cost_report ( cumulative ) ) ;
}
fn resume_session (
@@ -1686,10 +1552,7 @@ fn format_status_report(
usage : StatusUsage ,
permission_mode : & str ,
context : & StatusContext ,
max_cost_usd : Option < f64 > ,
) -> String {
let latest_cost = usage_cost_total ( model , usage . latest ) ;
let cumulative_cost = usage_cost_total ( model , usage . cumulative ) ;
[
format! (
" Status
@@ -1697,27 +1560,19 @@ fn format_status_report(
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}
Cost budget {} " ,
usage . message_count ,
usage . turns ,
usage . estimated_tokens ,
format_budget_line ( cumulative_cost , max_cost_usd ) ,
Estimated tokens {} " ,
usage . message_count , usage . turns , usage . estimated_tokens ,
) ,
format! (
" Usage
Latest total {}
Latest cost {}
Cumulative input {}
Cumulative output {}
Cumulative total {}
Cumulative cost {} " ,
Cumulative total {} " ,
usage . latest . total_tokens ( ) ,
format_usd ( latest_cost ) ,
usage . cumulative . input_tokens ,
usage . cumulative . output_tokens ,
usage . cumulative . total_tokens ( ) ,
format_usd ( cumulative_cost ) ,
) ,
format! (
" Workspace
@@ -2165,7 +2020,7 @@ impl ApiClient for AnthropicRuntimeClient {
let message_request = MessageRequest {
model : self . model . clone ( ) ,
max_tokens : DEFAULT_MAX_TOKENS ,
messages : convert_messages ( & request . messages ) ,
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 . allowed_tools . as_ref ( ) )
@@ -2444,7 +2299,10 @@ fn tool_permission_specs() -> Vec<ToolSpec> {
mvp_tool_specs ( )
}
fn convert_messages ( messages : & [ ConversationMessage ] ) -> Vec < InputMessage > {
fn convert_messages ( messages : & [ ConversationMessage ] ) -> Result < Vec< InputMessage > , RuntimeError > {
let cwd = env ::current_dir ( ) . map_err ( | error | {
RuntimeError ::new ( format! ( " failed to resolve current directory: {error} " ) )
} ) ? ;
messages
. iter ( )
. filter_map ( | message | {
@@ -2455,43 +2313,231 @@ 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 {
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 ,
} )
. try_fold ( Vec ::new ( ) , | mut acc , block | {
match block {
ContentBlock ::Text { text } = > {
if message . role = = MessageRole ::User {
acc . extend (
prompt_to_content_blocks ( text , & cwd )
. map_err ( RuntimeError ::new ) ? ,
) ;
} else {
acc . push ( InputContentBlock ::Text { text : text . clone ( ) } ) ;
}
}
ContentBlock ::ToolUse { id , name , input } = > {
acc . push ( 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 ,
..
} = > acc . push ( InputContentBlock ::ToolResult {
tool_use_id : tool_use_id . clone ( ) ,
content : vec ! [ ToolResultContentBlock ::Text {
text : output . clone ( ) ,
} ] ,
is_error : * is_error ,
} ) ,
}
Ok ::< _ , RuntimeError > ( acc )
} ) ;
match content {
Ok ( content ) if ! content . is_empty ( ) = > Some ( Ok ( InputMessage {
role : role . to_string ( ) ,
content ,
} ) ) ,
Ok ( _ ) = > None ,
Err ( error ) = > Some ( Err ( error ) ) ,
}
} )
. collect ( )
}
fn prompt_to_content_blocks ( input : & str , cwd : & Path ) -> Result < Vec < InputContentBlock > , String > {
let mut blocks = Vec ::new ( ) ;
let mut text_buffer = String ::new ( ) ;
let mut chars = input . char_indices ( ) . peekable ( ) ;
while let Some ( ( index , ch ) ) = chars . next ( ) {
if ch = = '!' & & input [ index .. ] . starts_with ( " ![ " ) {
if let Some ( ( alt_end , path_start , path_end ) ) = parse_markdown_image_ref ( input , index ) {
let _ = alt_end ;
flush_text_block ( & mut blocks , & mut text_buffer ) ;
let path = & input [ path_start .. path_end ] ;
blocks . push ( load_image_block ( path , cwd ) ? ) ;
while let Some ( ( next_index , _ ) ) = chars . peek ( ) {
if * next_index < path_end + 1 {
let _ = chars . next ( ) ;
} else {
break ;
}
}
continue ;
}
}
if ch = = '@' & & is_ref_boundary ( input [ .. index ] . chars ( ) . next_back ( ) ) {
let path_end = find_path_end ( input , index + 1 ) ;
if path_end > index + 1 {
let candidate = & input [ index + 1 .. path_end ] ;
if looks_like_image_ref ( candidate , cwd ) {
flush_text_block ( & mut blocks , & mut text_buffer ) ;
blocks . push ( load_image_block ( candidate , cwd ) ? ) ;
while let Some ( ( next_index , _ ) ) = chars . peek ( ) {
if * next_index < path_end {
let _ = chars . next ( ) ;
} else {
break ;
}
}
continue ;
}
}
}
text_buffer . push ( ch ) ;
}
flush_text_block ( & mut blocks , & mut text_buffer ) ;
if blocks . is_empty ( ) {
blocks . push ( InputContentBlock ::Text {
text : input . to_string ( ) ,
} ) ;
}
Ok ( blocks )
}
fn parse_markdown_image_ref ( input : & str , start : usize ) -> Option < ( usize , usize , usize ) > {
let after_bang = input . get ( start + 2 .. ) ? ;
let alt_end_offset = after_bang . find ( " ]( " ) ? ;
let path_start = start + 2 + alt_end_offset + 2 ;
let remainder = input . get ( path_start .. ) ? ;
let path_end_offset = remainder . find ( ')' ) ? ;
let path_end = path_start + path_end_offset ;
Some ( ( start + 2 + alt_end_offset , path_start , path_end ) )
}
fn is_ref_boundary ( ch : Option < char > ) -> bool {
ch . is_none_or ( char ::is_whitespace )
}
fn find_path_end ( input : & str , start : usize ) -> usize {
input [ start .. ]
. char_indices ( )
. find_map ( | ( offset , ch ) | ( ch . is_whitespace ( ) ) . then_some ( start + offset ) )
. unwrap_or ( input . len ( ) )
}
fn looks_like_image_ref ( candidate : & str , cwd : & Path ) -> bool {
let resolved = resolve_prompt_path ( candidate , cwd ) ;
media_type_for_path ( Path ::new ( candidate ) ) . is_some ( )
| | resolved . is_file ( )
| | candidate . contains ( std ::path ::MAIN_SEPARATOR )
| | candidate . starts_with ( " ./ " )
| | candidate . starts_with ( " ../ " )
}
fn flush_text_block ( blocks : & mut Vec < InputContentBlock > , text_buffer : & mut String ) {
if text_buffer . is_empty ( ) {
return ;
}
blocks . push ( InputContentBlock ::Text {
text : std ::mem ::take ( text_buffer ) ,
} ) ;
}
fn load_image_block ( path_ref : & str , cwd : & Path ) -> Result < InputContentBlock , String > {
let resolved = resolve_prompt_path ( path_ref , cwd ) ;
let media_type = media_type_for_path ( & resolved ) . ok_or_else ( | | {
format! (
" unsupported image format for reference {IMAGE_REF_PREFIX} {path_ref} ; supported: png, jpg, jpeg, gif, webp "
)
} ) ? ;
let bytes = fs ::read ( & resolved ) . map_err ( | error | {
format! (
" failed to read image reference {} : {error} " ,
resolved . display ( )
)
} ) ? ;
Ok ( InputContentBlock ::Image {
source : ImageSource {
kind : " base64 " . to_string ( ) ,
media_type : media_type . to_string ( ) ,
data : encode_base64 ( & bytes ) ,
} ,
} )
}
fn resolve_prompt_path ( path_ref : & str , cwd : & Path ) -> PathBuf {
let path = Path ::new ( path_ref ) ;
if path . is_absolute ( ) {
path . to_path_buf ( )
} else {
cwd . join ( path )
}
}
fn media_type_for_path ( path : & Path ) -> Option < & 'static str > {
let extension = path . extension ( ) ? . to_str ( ) ? . to_ascii_lowercase ( ) ;
match extension . as_str ( ) {
" png " = > Some ( " image/png " ) ,
" jpg " | " jpeg " = > Some ( " image/jpeg " ) ,
" gif " = > Some ( " image/gif " ) ,
" webp " = > Some ( " image/webp " ) ,
_ = > None ,
}
}
fn encode_base64 ( bytes : & [ u8 ] ) -> String {
const TABLE : & [ u8 ; 64 ] = b " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ " ;
let mut output = String ::new ( ) ;
let mut index = 0 ;
while index + 3 < = bytes . len ( ) {
let block = ( u32 ::from ( bytes [ index ] ) < < 16 )
| ( u32 ::from ( bytes [ index + 1 ] ) < < 8 )
| u32 ::from ( bytes [ index + 2 ] ) ;
output . push ( TABLE [ ( ( block > > 18 ) & 0x3F ) as usize ] as char ) ;
output . push ( TABLE [ ( ( block > > 12 ) & 0x3F ) as usize ] as char ) ;
output . push ( TABLE [ ( ( block > > 6 ) & 0x3F ) as usize ] as char ) ;
output . push ( TABLE [ ( block & 0x3F ) as usize ] as char ) ;
index + = 3 ;
}
match bytes . len ( ) . saturating_sub ( index ) {
1 = > {
let block = u32 ::from ( bytes [ index ] ) < < 16 ;
output . push ( TABLE [ ( ( block > > 18 ) & 0x3F ) as usize ] as char ) ;
output . push ( TABLE [ ( ( block > > 12 ) & 0x3F ) as usize ] as char ) ;
output . push ( '=' ) ;
output . push ( '=' ) ;
}
2 = > {
let block = ( u32 ::from ( bytes [ index ] ) < < 16 ) | ( u32 ::from ( bytes [ index + 1 ] ) < < 8 ) ;
output . push ( TABLE [ ( ( block > > 18 ) & 0x3F ) as usize ] as char ) ;
output . push ( TABLE [ ( ( block > > 12 ) & 0x3F ) as usize ] as char ) ;
output . push ( TABLE [ ( ( block > > 6 ) & 0x3F ) as usize ] as char ) ;
output . push ( '=' ) ;
}
_ = > { }
}
output
}
fn print_help ( ) {
println! ( " rusty-claude-cli v {VERSION} " ) ;
println! ( ) ;
println! ( " Usage: " ) ;
println! ( " rusty-claude-cli [--model MODEL] [--max-cost USD] [--allowedTools TOOL[,TOOL...]] " ) ;
println! ( " rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]] " ) ;
println! ( " Start the interactive REPL " ) ;
println! ( " rusty-claude-cli [--model MODEL] [--max-cost USD] [--output-format text|json] prompt TEXT " ) ;
println! ( " rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT " ) ;
println! ( " Send one prompt and exit " ) ;
println! ( " rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT " ) ;
println! ( " Shorthand non-interactive prompt mode " ) ;
@@ -2507,7 +2553,6 @@ fn print_help() {
println! ( " --model MODEL Override the active model " ) ;
println! ( " --output-format FORMAT Non-interactive output format: text or json " ) ;
println! ( " --permission-mode MODE Set read-only, workspace-write, or danger-full-access " ) ;
println! ( " --max-cost USD Warn at 80% of budget and stop at/exceeding the budget " ) ;
println! ( " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported) " ) ;
println! ( " --version, -V Print version and build information locally " ) ;
println! ( ) ;
@@ -2534,17 +2579,18 @@ fn print_help() {
#[ cfg(test) ]
mod tests {
use super ::{
budget_notice_message , filter_tool_specs, format_compact_report , format_cost_report ,
format_init_report , format_ model_report , format_model_switch_report ,
format_permissions_report , format_permissions_ switch_report , format_resume_report ,
format_status_repo rt , format_tool_call_star t , f ormat_tool_result ,
normalize_permission_mode , parse_args , parse_git_status_metadata, render_config_report ,
render_init_claude_md , render_memory_report , render_repl_help ,
resume_supported_slash_commands , status_context , CliAction, CliOutputFormat , SlashCommand ,
StatusUsage , DEFAULT_MODEL ,
filter_tool_specs , format_compact_report , format_cost_report , format_init_report ,
format_model_report , format_model_switch_report , format_permissions_report ,
format_permissions_switch_report , format_resume_report , format_status_report ,
format_tool_call_sta rt , format_tool_resul t , n ormalize_permission_mode , parse_args ,
parse_git_status_metadata , render_config_report , render_init_claude_md ,
render_memory_report , render_repl_help , resume_supported_slash_commands , status_context ,
CliAction , CliOutputFormat , SlashCommand , StatusUsage , DEFAULT_MODEL ,
} ;
use api ::InputContentBlock ;
use runtime ::{ ContentBlock , ConversationMessage , MessageRole , PermissionMode } ;
use std ::path ::{ Path , PathBuf } ;
use std ::time ::{ SystemTime , UNIX_EPOCH } ;
#[ test ]
fn defaults_to_repl_when_no_args ( ) {
@@ -2554,7 +2600,6 @@ mod tests {
model : DEFAULT_MODEL . to_string ( ) ,
allowed_tools : None ,
permission_mode : PermissionMode ::WorkspaceWrite ,
max_cost_usd : None ,
}
) ;
}
@@ -2574,7 +2619,6 @@ mod tests {
output_format : CliOutputFormat ::Text ,
allowed_tools : None ,
permission_mode : PermissionMode ::WorkspaceWrite ,
max_cost_usd : None ,
}
) ;
}
@@ -2596,7 +2640,6 @@ mod tests {
output_format : CliOutputFormat ::Json ,
allowed_tools : None ,
permission_mode : PermissionMode ::WorkspaceWrite ,
max_cost_usd : None ,
}
) ;
}
@@ -2622,32 +2665,10 @@ mod tests {
model : DEFAULT_MODEL . to_string ( ) ,
allowed_tools : None ,
permission_mode : PermissionMode ::ReadOnly ,
max_cost_usd : None ,
}
) ;
}
#[ test ]
fn parses_max_cost_flag ( ) {
let args = vec! [ " --max-cost=1.25 " . to_string ( ) ] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::Repl {
model : DEFAULT_MODEL . to_string ( ) ,
allowed_tools : None ,
permission_mode : PermissionMode ::WorkspaceWrite ,
max_cost_usd : Some ( 1.25 ) ,
}
) ;
}
#[ test ]
fn rejects_invalid_max_cost_flag ( ) {
let error = parse_args ( & [ " --max-cost " . to_string ( ) , " 0 " . to_string ( ) ] )
. expect_err ( " zero max cost should be rejected " ) ;
assert! ( error . contains ( " --max-cost must be a positive finite USD amount " ) ) ;
}
#[ test ]
fn parses_allowed_tools_flags_with_aliases_and_lists ( ) {
let args = vec! [
@@ -2666,7 +2687,6 @@ mod tests {
. collect ( )
) ,
permission_mode : PermissionMode ::WorkspaceWrite ,
max_cost_usd : None ,
}
) ;
}
@@ -2824,24 +2844,18 @@ mod tests {
#[ test ]
fn cost_report_uses_sectioned_layout ( ) {
let report = format_cost_report (
" claude-sonnet " ,
runtime ::TokenUsage {
input_tokens : 20 ,
out put_tokens : 8 ,
cache_creation_input_tokens : 3 ,
cache_read_input_tokens : 1 ,
} ,
None ,
) ;
let report = format_cost_report ( runtime ::TokenUsage {
input_tokens : 20 ,
output_tokens : 8 ,
cache_creation_ input_tokens : 3 ,
cache_read_in put_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 " ) ) ;
assert! ( report . contains ( " Estimated cost " ) ) ;
assert! ( report . contains ( " Budget $0.0010 (unlimited) " ) ) ;
}
#[ test ]
@@ -2923,7 +2937,6 @@ mod tests {
project_root : Some ( PathBuf ::from ( " /tmp " ) ) ,
git_branch : Some ( " main " . to_string ( ) ) ,
} ,
Some ( 1.0 ) ,
) ;
assert! ( status . contains ( " Status " ) ) ;
assert! ( status . contains ( " Model claude-sonnet " ) ) ;
@@ -2931,7 +2944,6 @@ mod tests {
assert! ( status . contains ( " Messages 7 " ) ) ;
assert! ( status . contains ( " Latest total 10 " ) ) ;
assert! ( status . contains ( " Cumulative total 31 " ) ) ;
assert! ( status . contains ( " Cost budget $0.0009 / $1.0000 " ) ) ;
assert! ( status . contains ( " Cwd /tmp/project " ) ) ;
assert! ( status . contains ( " Project root /tmp " ) ) ;
assert! ( status . contains ( " Git branch main " ) ) ;
@@ -2940,22 +2952,6 @@ mod tests {
assert! ( status . contains ( " Memory files 4 " ) ) ;
}
#[ test ]
fn budget_notice_warns_near_limit ( ) {
let message = budget_notice_message (
" claude-sonnet " ,
runtime ::TokenUsage {
input_tokens : 60_000 ,
output_tokens : 0 ,
cache_creation_input_tokens : 0 ,
cache_read_input_tokens : 0 ,
} ,
Some ( 1.0 ) ,
)
. expect ( " budget warning expected " ) ;
assert! ( message . contains ( " approaching cost budget " ) ) ;
}
#[ test ]
fn config_report_supports_section_views ( ) {
let report = render_config_report ( Some ( " env " ) ) . expect ( " config report should render " ) ;
@@ -2993,8 +2989,8 @@ mod tests {
fn status_context_reads_real_workspace_metadata ( ) {
let context = status_context ( None ) . expect ( " status context should load " ) ;
assert! ( context . cwd . is_absolute ( ) ) ;
assert! ( context . discovered_config_files > = context . loaded_config_files ) ;
assert! ( context . discover ed_config_files > = 1 ) ;
assert! ( context . discovered_config_files > = 3 ) ;
assert! ( context . load ed_config_files < = context . discovered_config_files ) ;
}
#[ test ]
@@ -3077,11 +3073,110 @@ mod tests {
} ,
] ;
let converted = super ::convert_messages ( & messages ) ;
let converted = super ::convert_messages ( & messages ) . expect ( " messages should convert " ) ;
assert_eq! ( converted . len ( ) , 3 ) ;
assert_eq! ( converted [ 1 ] . role , " assistant " ) ;
assert_eq! ( converted [ 2 ] . role , " user " ) ;
}
#[ test ]
fn prompt_to_content_blocks_keeps_text_only_prompt ( ) {
let blocks = super ::prompt_to_content_blocks ( " hello world " , Path ::new ( " . " ) )
. expect ( " text prompt should parse " ) ;
assert_eq! (
blocks ,
vec! [ InputContentBlock ::Text {
text : " hello world " . to_string ( )
} ]
) ;
}
#[ test ]
fn prompt_to_content_blocks_embeds_at_image_refs ( ) {
let temp = temp_fixture_dir ( " at-image-ref " ) ;
let image_path = temp . join ( " sample.png " ) ;
std ::fs ::write ( & image_path , [ 1_ u8 , 2 , 3 ] ) . expect ( " fixture write " ) ;
let prompt = format! ( " describe @ {} please " , image_path . display ( ) ) ;
let blocks = super ::prompt_to_content_blocks ( & prompt , Path ::new ( " . " ) )
. expect ( " image ref should parse " ) ;
assert! ( matches! (
& blocks [ 0 ] ,
InputContentBlock ::Text { text } if text = = " describe "
) ) ;
assert! ( matches! (
& blocks [ 1 ] ,
InputContentBlock ::Image { source }
if source . kind = = " base64 "
& & source . media_type = = " image/png "
& & source . data = = " AQID "
) ) ;
assert! ( matches! (
& blocks [ 2 ] ,
InputContentBlock ::Text { text } if text = = " please "
) ) ;
}
#[ test ]
fn prompt_to_content_blocks_embeds_markdown_image_refs ( ) {
let temp = temp_fixture_dir ( " markdown-image-ref " ) ;
let image_path = temp . join ( " sample.webp " ) ;
std ::fs ::write ( & image_path , [ 255_ u8 ] ) . expect ( " fixture write " ) ;
let prompt = format! ( " see  now " , image_path . display ( ) ) ;
let blocks = super ::prompt_to_content_blocks ( & prompt , Path ::new ( " . " ) )
. expect ( " markdown image ref should parse " ) ;
assert! ( matches! (
& blocks [ 1 ] ,
InputContentBlock ::Image { source }
if source . media_type = = " image/webp " & & source . data = = " /w== "
) ) ;
}
#[ test ]
fn prompt_to_content_blocks_rejects_unsupported_formats ( ) {
let temp = temp_fixture_dir ( " unsupported-image-ref " ) ;
let image_path = temp . join ( " sample.bmp " ) ;
std ::fs ::write ( & image_path , [ 1_ u8 ] ) . expect ( " fixture write " ) ;
let prompt = format! ( " describe @ {} " , image_path . display ( ) ) ;
let error = super ::prompt_to_content_blocks ( & prompt , Path ::new ( " . " ) )
. expect_err ( " unsupported image ref should fail " ) ;
assert! ( error . contains ( " unsupported image format " ) ) ;
}
#[ test ]
fn convert_messages_expands_user_text_image_refs ( ) {
let temp = temp_fixture_dir ( " convert-message-image-ref " ) ;
let image_path = temp . join ( " sample.gif " ) ;
std ::fs ::write ( & image_path , [ 71_ u8 , 73 , 70 ] ) . expect ( " fixture write " ) ;
let messages = vec! [ ConversationMessage ::user_text ( format! (
" inspect @ {} " ,
image_path . display ( )
) ) ] ;
let converted = super ::convert_messages ( & messages ) . expect ( " messages should convert " ) ;
assert_eq! ( converted . len ( ) , 1 ) ;
assert! ( matches! (
& converted [ 0 ] . content [ 1 ] ,
InputContentBlock ::Image { source }
if source . media_type = = " image/gif " & & source . data = = " R0lG "
) ) ;
}
fn temp_fixture_dir ( label : & str ) -> PathBuf {
let unique = SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. expect ( " clock should advance " )
. as_nanos ( ) ;
let path = std ::env ::temp_dir ( ) . join ( format! ( " rusty-claude-cli- {label} - {unique} " ) ) ;
std ::fs ::create_dir_all ( & path ) . expect ( " temp dir should exist " ) ;
path
}
#[ test ]
fn repl_help_mentions_history_completion_and_multiline ( ) {
let help = render_repl_help ( ) ;