2026-04-01 03:55:00 +00:00
mod init ;
mod input ;
mod render ;
2026-04-01 04:30:28 +00:00
use std ::collections ::BTreeSet ;
2026-04-01 03:55:00 +00:00
use std ::env ;
2026-04-01 04:40:19 +00:00
use std ::fmt ::Write as _ ;
2026-04-01 03:55:00 +00:00
use std ::fs ;
use std ::io ::{ self , Read , Write } ;
use std ::net ::TcpListener ;
use std ::path ::{ Path , PathBuf } ;
use std ::process ::Command ;
use std ::time ::{ SystemTime , UNIX_EPOCH } ;
use api ::{
resolve_startup_auth_source , AnthropicClient , AuthSource , ContentBlockDelta , InputContentBlock ,
InputMessage , MessageRequest , MessageResponse , OutputContentBlock ,
StreamEvent as ApiStreamEvent , ToolChoice , ToolDefinition , ToolResultContentBlock ,
} ;
use commands ::{
render_slash_command_help , resume_supported_slash_commands , slash_command_specs , SlashCommand ,
} ;
use compat_harness ::{ extract_manifest , UpstreamPaths } ;
use init ::initialize_repo ;
2026-04-01 04:40:19 +00:00
use plugins ::{ PluginKind , PluginManager , PluginManagerConfig , PluginSummary } ;
2026-04-01 03:55:00 +00:00
use render ::{ MarkdownStreamState , Spinner , TerminalRenderer } ;
use runtime ::{
clear_oauth_credentials , generate_pkce_pair , generate_state , load_system_prompt ,
parse_oauth_callback_request_target , save_oauth_credentials , ApiClient , ApiRequest ,
AssistantEvent , CompactionConfig , ConfigLoader , ConfigSource , ContentBlock ,
ConversationMessage , ConversationRuntime , MessageRole , OAuthAuthorizationRequest , OAuthConfig ,
OAuthTokenExchangeRequest , PermissionMode , PermissionPolicy , ProjectContext , RuntimeError ,
2026-04-01 04:40:19 +00:00
RuntimeHookConfig , Session , TokenUsage , ToolError , ToolExecutor , UsageTracker ,
2026-04-01 03:55:00 +00:00
} ;
use serde_json ::json ;
use tools ::{ execute_tool , mvp_tool_specs , ToolSpec } ;
const DEFAULT_MODEL : & str = " claude-opus-4-6 " ;
fn max_tokens_for_model ( model : & str ) -> u32 {
if model . contains ( " opus " ) {
32_000
} else {
64_000
}
}
const DEFAULT_DATE : & str = " 2026-03-31 " ;
const DEFAULT_OAUTH_CALLBACK_PORT : u16 = 4545 ;
const VERSION : & str = env! ( " CARGO_PKG_VERSION " ) ;
const BUILD_TARGET : Option < & str > = option_env! ( " TARGET " ) ;
const GIT_SHA : Option < & str > = option_env! ( " GIT_SHA " ) ;
type AllowedToolSet = BTreeSet < String > ;
fn main ( ) {
if let Err ( error ) = run ( ) {
eprintln! (
" error: {error}
Run ` claw - - help ` for usage . "
) ;
std ::process ::exit ( 1 ) ;
}
}
fn run ( ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let args : Vec < String > = env ::args ( ) . skip ( 1 ) . collect ( ) ;
match parse_args ( & args ) ? {
CliAction ::DumpManifests = > dump_manifests ( ) ,
CliAction ::BootstrapPlan = > print_bootstrap_plan ( ) ,
CliAction ::PrintSystemPrompt { cwd , date } = > print_system_prompt ( cwd , date ) ,
CliAction ::Version = > print_version ( ) ,
CliAction ::ResumeSession {
session_path ,
commands ,
} = > resume_session ( & session_path , & commands ) ,
CliAction ::Prompt {
prompt ,
model ,
output_format ,
allowed_tools ,
permission_mode ,
} = > LiveCli ::new ( model , true , allowed_tools , permission_mode ) ?
. run_turn_with_output ( & prompt , output_format ) ? ,
CliAction ::Login = > run_login ( ) ? ,
CliAction ::Logout = > run_logout ( ) ? ,
CliAction ::Init = > run_init ( ) ? ,
CliAction ::Repl {
model ,
allowed_tools ,
permission_mode ,
} = > run_repl ( model , allowed_tools , permission_mode ) ? ,
CliAction ::Help = > print_help ( ) ,
}
Ok ( ( ) )
}
#[ derive(Debug, Clone, PartialEq, Eq) ]
enum CliAction {
DumpManifests ,
BootstrapPlan ,
PrintSystemPrompt {
cwd : PathBuf ,
date : String ,
} ,
Version ,
ResumeSession {
session_path : PathBuf ,
commands : Vec < String > ,
} ,
Prompt {
prompt : String ,
model : String ,
output_format : CliOutputFormat ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
} ,
Login ,
Logout ,
Init ,
Repl {
model : String ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
} ,
// prompt-mode formatting is only supported for non-interactive runs
Help ,
}
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
enum CliOutputFormat {
Text ,
Json ,
}
impl CliOutputFormat {
fn parse ( value : & str ) -> Result < Self , String > {
match value {
" text " = > Ok ( Self ::Text ) ,
" json " = > Ok ( Self ::Json ) ,
other = > Err ( format! (
" unsupported value for --output-format: {other} (expected text or json) "
) ) ,
}
}
}
#[ allow(clippy::too_many_lines) ]
fn parse_args ( args : & [ String ] ) -> Result < CliAction , String > {
let mut model = DEFAULT_MODEL . to_string ( ) ;
let mut output_format = CliOutputFormat ::Text ;
let mut permission_mode = default_permission_mode ( ) ;
let mut wants_version = false ;
let mut allowed_tool_values = Vec ::new ( ) ;
let mut rest = Vec ::new ( ) ;
let mut index = 0 ;
while index < args . len ( ) {
match args [ index ] . as_str ( ) {
" --version " | " -V " = > {
wants_version = true ;
index + = 1 ;
}
" --model " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --model " . to_string ( ) ) ? ;
model = resolve_model_alias ( value ) . to_string ( ) ;
index + = 2 ;
}
flag if flag . starts_with ( " --model= " ) = > {
model = resolve_model_alias ( & flag [ 8 .. ] ) . to_string ( ) ;
index + = 1 ;
}
" --output-format " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --output-format " . to_string ( ) ) ? ;
output_format = CliOutputFormat ::parse ( value ) ? ;
index + = 2 ;
}
" --permission-mode " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --permission-mode " . to_string ( ) ) ? ;
permission_mode = parse_permission_mode_arg ( value ) ? ;
index + = 2 ;
}
flag if flag . starts_with ( " --output-format= " ) = > {
output_format = CliOutputFormat ::parse ( & flag [ 16 .. ] ) ? ;
index + = 1 ;
}
flag if flag . starts_with ( " --permission-mode= " ) = > {
permission_mode = parse_permission_mode_arg ( & flag [ 18 .. ] ) ? ;
index + = 1 ;
}
" --dangerously-skip-permissions " = > {
permission_mode = PermissionMode ::DangerFullAccess ;
index + = 1 ;
}
" -p " = > {
// Claw Code compat: -p "prompt" = one-shot prompt
let prompt = args [ index + 1 .. ] . join ( " " ) ;
if prompt . trim ( ) . is_empty ( ) {
return Err ( " -p requires a prompt string " . to_string ( ) ) ;
}
return Ok ( CliAction ::Prompt {
prompt ,
model : resolve_model_alias ( & model ) . to_string ( ) ,
output_format ,
allowed_tools : normalize_allowed_tools ( & allowed_tool_values ) ? ,
permission_mode ,
} ) ;
}
" --print " = > {
// Claw Code compat: --print makes output non-interactive
output_format = CliOutputFormat ::Text ;
index + = 1 ;
}
" --allowedTools " | " --allowed-tools " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --allowedTools " . to_string ( ) ) ? ;
allowed_tool_values . push ( value . clone ( ) ) ;
index + = 2 ;
}
flag if flag . starts_with ( " --allowedTools= " ) = > {
allowed_tool_values . push ( flag [ 15 .. ] . to_string ( ) ) ;
index + = 1 ;
}
flag if flag . starts_with ( " --allowed-tools= " ) = > {
allowed_tool_values . push ( flag [ 16 .. ] . to_string ( ) ) ;
index + = 1 ;
}
other = > {
rest . push ( other . to_string ( ) ) ;
index + = 1 ;
}
}
}
if wants_version {
return Ok ( CliAction ::Version ) ;
}
let allowed_tools = normalize_allowed_tools ( & allowed_tool_values ) ? ;
if rest . is_empty ( ) {
return Ok ( CliAction ::Repl {
model ,
allowed_tools ,
permission_mode ,
} ) ;
}
if matches! ( rest . first ( ) . map ( String ::as_str ) , Some ( " --help " | " -h " ) ) {
return Ok ( CliAction ::Help ) ;
}
if rest . first ( ) . map ( String ::as_str ) = = Some ( " --resume " ) {
return parse_resume_args ( & rest [ 1 .. ] ) ;
}
match rest [ 0 ] . as_str ( ) {
" dump-manifests " = > Ok ( CliAction ::DumpManifests ) ,
" bootstrap-plan " = > Ok ( CliAction ::BootstrapPlan ) ,
" system-prompt " = > parse_system_prompt_args ( & rest [ 1 .. ] ) ,
" login " = > Ok ( CliAction ::Login ) ,
" logout " = > Ok ( CliAction ::Logout ) ,
" init " = > Ok ( CliAction ::Init ) ,
" prompt " = > {
let prompt = rest [ 1 .. ] . join ( " " ) ;
if prompt . trim ( ) . is_empty ( ) {
return Err ( " prompt subcommand requires a prompt string " . to_string ( ) ) ;
}
Ok ( CliAction ::Prompt {
prompt ,
model ,
output_format ,
allowed_tools ,
permission_mode ,
} )
}
other if ! other . starts_with ( '/' ) = > Ok ( CliAction ::Prompt {
prompt : rest . join ( " " ) ,
model ,
output_format ,
allowed_tools ,
permission_mode ,
} ) ,
other = > Err ( format! ( " unknown subcommand: {other} " ) ) ,
}
}
fn resolve_model_alias ( model : & str ) -> & str {
match model {
" opus " = > " claude-opus-4-6 " ,
" sonnet " = > " claude-sonnet-4-6 " ,
" haiku " = > " claude-haiku-4-5-20251213 " ,
_ = > model ,
}
}
fn normalize_allowed_tools ( values : & [ String ] ) -> Result < Option < AllowedToolSet > , String > {
if values . is_empty ( ) {
return Ok ( None ) ;
}
let canonical_names = mvp_tool_specs ( )
. into_iter ( )
. map ( | spec | spec . name . to_string ( ) )
. collect ::< Vec < _ > > ( ) ;
let mut name_map = canonical_names
. iter ( )
. map ( | name | ( normalize_tool_name ( name ) , name . clone ( ) ) )
. collect ::< BTreeMap < _ , _ > > ( ) ;
for ( alias , canonical ) in [
( " read " , " read_file " ) ,
( " write " , " write_file " ) ,
( " edit " , " edit_file " ) ,
( " glob " , " glob_search " ) ,
( " grep " , " grep_search " ) ,
] {
name_map . insert ( alias . to_string ( ) , canonical . to_string ( ) ) ;
}
let mut allowed = AllowedToolSet ::new ( ) ;
for value in values {
for token in value
. split ( | ch : char | ch = = ',' | | ch . is_whitespace ( ) )
. filter ( | token | ! token . is_empty ( ) )
{
let normalized = normalize_tool_name ( token ) ;
let canonical = name_map . get ( & normalized ) . ok_or_else ( | | {
format! (
" unsupported tool in --allowedTools: {token} (expected one of: {}) " ,
canonical_names . join ( " , " )
)
} ) ? ;
allowed . insert ( canonical . clone ( ) ) ;
}
}
Ok ( Some ( allowed ) )
}
fn normalize_tool_name ( value : & str ) -> String {
value . trim ( ) . replace ( '-' , " _ " ) . to_ascii_lowercase ( )
}
fn parse_permission_mode_arg ( value : & str ) -> Result < PermissionMode , String > {
normalize_permission_mode ( value )
. ok_or_else ( | | {
format! (
" unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access. "
)
} )
. map ( permission_mode_from_label )
}
fn permission_mode_from_label ( mode : & str ) -> PermissionMode {
match mode {
" read-only " = > PermissionMode ::ReadOnly ,
" workspace-write " = > PermissionMode ::WorkspaceWrite ,
" danger-full-access " = > PermissionMode ::DangerFullAccess ,
other = > panic! ( " unsupported permission mode label: {other} " ) ,
}
}
fn default_permission_mode ( ) -> PermissionMode {
env ::var ( " RUSTY_CLAUDE_PERMISSION_MODE " )
. ok ( )
. as_deref ( )
. and_then ( normalize_permission_mode )
. map_or ( PermissionMode ::DangerFullAccess , permission_mode_from_label )
}
fn filter_tool_specs ( allowed_tools : Option < & AllowedToolSet > ) -> Vec < tools ::ToolSpec > {
mvp_tool_specs ( )
. into_iter ( )
. filter ( | spec | allowed_tools . is_none_or ( | allowed | allowed . contains ( spec . name ) ) )
. collect ( )
}
fn parse_system_prompt_args ( args : & [ String ] ) -> Result < CliAction , String > {
let mut cwd = env ::current_dir ( ) . map_err ( | error | error . to_string ( ) ) ? ;
let mut date = DEFAULT_DATE . to_string ( ) ;
let mut index = 0 ;
while index < args . len ( ) {
match args [ index ] . as_str ( ) {
" --cwd " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --cwd " . to_string ( ) ) ? ;
cwd = PathBuf ::from ( value ) ;
index + = 2 ;
}
" --date " = > {
let value = args
. get ( index + 1 )
. ok_or_else ( | | " missing value for --date " . to_string ( ) ) ? ;
date . clone_from ( value ) ;
index + = 2 ;
}
other = > return Err ( format! ( " unknown system-prompt option: {other} " ) ) ,
}
}
Ok ( CliAction ::PrintSystemPrompt { cwd , date } )
}
fn parse_resume_args ( args : & [ String ] ) -> Result < CliAction , String > {
let session_path = args
. first ( )
. ok_or_else ( | | " missing session path for --resume " . to_string ( ) )
. map ( PathBuf ::from ) ? ;
let commands = args [ 1 .. ] . to_vec ( ) ;
if commands
. iter ( )
. any ( | command | ! command . trim_start ( ) . starts_with ( '/' ) )
{
return Err ( " --resume trailing arguments must be slash commands " . to_string ( ) ) ;
}
Ok ( CliAction ::ResumeSession {
session_path ,
commands ,
} )
}
fn dump_manifests ( ) {
let workspace_dir = PathBuf ::from ( env! ( " CARGO_MANIFEST_DIR " ) ) . join ( " ../.. " ) ;
let paths = UpstreamPaths ::from_workspace_dir ( & workspace_dir ) ;
match extract_manifest ( & paths ) {
Ok ( manifest ) = > {
println! ( " commands: {} " , manifest . commands . entries ( ) . len ( ) ) ;
println! ( " tools: {} " , manifest . tools . entries ( ) . len ( ) ) ;
println! ( " bootstrap phases: {} " , manifest . bootstrap . phases ( ) . len ( ) ) ;
}
Err ( error ) = > {
eprintln! ( " failed to extract manifests: {error} " ) ;
std ::process ::exit ( 1 ) ;
}
}
}
fn print_bootstrap_plan ( ) {
for phase in runtime ::BootstrapPlan ::claude_code_default ( ) . phases ( ) {
println! ( " - {phase:?} " ) ;
}
}
fn default_oauth_config ( ) -> OAuthConfig {
OAuthConfig {
client_id : String ::from ( " 9d1c250a-e61b-44d9-88ed-5944d1962f5e " ) ,
authorize_url : String ::from ( " https://platform.claude.com/oauth/authorize " ) ,
token_url : String ::from ( " https://platform.claude.com/v1/oauth/token " ) ,
callback_port : None ,
manual_redirect_url : None ,
scopes : vec ! [
String ::from ( " user:profile " ) ,
String ::from ( " user:inference " ) ,
String ::from ( " user:sessions:claude_code " ) ,
] ,
}
}
fn run_login ( ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let config = ConfigLoader ::default_for ( & cwd ) . load ( ) ? ;
let default_oauth = default_oauth_config ( ) ;
let oauth = config . oauth ( ) . unwrap_or ( & default_oauth ) ;
let callback_port = oauth . callback_port . unwrap_or ( DEFAULT_OAUTH_CALLBACK_PORT ) ;
let redirect_uri = runtime ::loopback_redirect_uri ( callback_port ) ;
let pkce = generate_pkce_pair ( ) ? ;
let state = generate_state ( ) ? ;
let authorize_url =
OAuthAuthorizationRequest ::from_config ( oauth , redirect_uri . clone ( ) , state . clone ( ) , & pkce )
. build_url ( ) ;
println! ( " Starting Claude OAuth login... " ) ;
println! ( " Listening for callback on {redirect_uri} " ) ;
if let Err ( error ) = open_browser ( & authorize_url ) {
eprintln! ( " warning: failed to open browser automatically: {error} " ) ;
println! ( " Open this URL manually: \n {authorize_url} " ) ;
}
let callback = wait_for_oauth_callback ( callback_port ) ? ;
if let Some ( error ) = callback . error {
let description = callback
. error_description
. unwrap_or_else ( | | " authorization failed " . to_string ( ) ) ;
return Err ( io ::Error ::other ( format! ( " {error} : {description} " ) ) . into ( ) ) ;
}
let code = callback . code . ok_or_else ( | | {
io ::Error ::new ( io ::ErrorKind ::InvalidData , " callback did not include code " )
} ) ? ;
let returned_state = callback . state . ok_or_else ( | | {
io ::Error ::new ( io ::ErrorKind ::InvalidData , " callback did not include state " )
} ) ? ;
if returned_state ! = state {
return Err ( io ::Error ::new ( io ::ErrorKind ::InvalidData , " oauth state mismatch " ) . into ( ) ) ;
}
let client = AnthropicClient ::from_auth ( AuthSource ::None ) . with_base_url ( api ::read_base_url ( ) ) ;
let exchange_request =
OAuthTokenExchangeRequest ::from_config ( oauth , code , state , pkce . verifier , redirect_uri ) ;
let runtime = tokio ::runtime ::Runtime ::new ( ) ? ;
let token_set = runtime . block_on ( client . exchange_oauth_code ( oauth , & exchange_request ) ) ? ;
save_oauth_credentials ( & runtime ::OAuthTokenSet {
access_token : token_set . access_token ,
refresh_token : token_set . refresh_token ,
expires_at : token_set . expires_at ,
scopes : token_set . scopes ,
} ) ? ;
println! ( " Claude OAuth login complete. " ) ;
Ok ( ( ) )
}
fn run_logout ( ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
clear_oauth_credentials ( ) ? ;
println! ( " Claude OAuth credentials cleared. " ) ;
Ok ( ( ) )
}
fn open_browser ( url : & str ) -> io ::Result < ( ) > {
let commands = if cfg! ( target_os = " macos " ) {
vec! [ ( " open " , vec! [ url ] ) ]
} else if cfg! ( target_os = " windows " ) {
vec! [ ( " cmd " , vec! [ " /C " , " start " , " " , url ] ) ]
} else {
vec! [ ( " xdg-open " , vec! [ url ] ) ]
} ;
for ( program , args ) in commands {
match Command ::new ( program ) . args ( args ) . spawn ( ) {
Ok ( _ ) = > return Ok ( ( ) ) ,
Err ( error ) if error . kind ( ) = = io ::ErrorKind ::NotFound = > { }
Err ( error ) = > return Err ( error ) ,
}
}
Err ( io ::Error ::new (
io ::ErrorKind ::NotFound ,
" no supported browser opener command found " ,
) )
}
fn wait_for_oauth_callback (
port : u16 ,
) -> Result < runtime ::OAuthCallbackParams , Box < dyn std ::error ::Error > > {
let listener = TcpListener ::bind ( ( " 127.0.0.1 " , port ) ) ? ;
let ( mut stream , _ ) = listener . accept ( ) ? ;
let mut buffer = [ 0_ u8 ; 4096 ] ;
let bytes_read = stream . read ( & mut buffer ) ? ;
let request = String ::from_utf8_lossy ( & buffer [ .. bytes_read ] ) ;
let request_line = request . lines ( ) . next ( ) . ok_or_else ( | | {
io ::Error ::new ( io ::ErrorKind ::InvalidData , " missing callback request line " )
} ) ? ;
let target = request_line . split_whitespace ( ) . nth ( 1 ) . ok_or_else ( | | {
io ::Error ::new (
io ::ErrorKind ::InvalidData ,
" missing callback request target " ,
)
} ) ? ;
let callback = parse_oauth_callback_request_target ( target )
. map_err ( | error | io ::Error ::new ( io ::ErrorKind ::InvalidData , error ) ) ? ;
let body = if callback . error . is_some ( ) {
" Claude OAuth login failed. You can close this window. "
} else {
" Claude OAuth login succeeded. You can close this window. "
} ;
let response = format! (
" HTTP/1.1 200 OK \r \n content-type: text/plain; charset=utf-8 \r \n content-length: {} \r \n connection: close \r \n \r \n {} " ,
body . len ( ) ,
body
) ;
stream . write_all ( response . as_bytes ( ) ) ? ;
Ok ( callback )
}
fn print_system_prompt ( cwd : PathBuf , date : String ) {
match load_system_prompt ( cwd , date , env ::consts ::OS , " unknown " ) {
Ok ( sections ) = > println! ( " {} " , sections . join ( " \n \n " ) ) ,
Err ( error ) = > {
eprintln! ( " failed to build system prompt: {error} " ) ;
std ::process ::exit ( 1 ) ;
}
}
}
fn print_version ( ) {
println! ( " {} " , render_version_report ( ) ) ;
}
fn resume_session ( session_path : & Path , commands : & [ String ] ) {
let session = match Session ::load_from_path ( session_path ) {
Ok ( session ) = > session ,
Err ( error ) = > {
eprintln! ( " failed to restore session: {error} " ) ;
std ::process ::exit ( 1 ) ;
}
} ;
if commands . is_empty ( ) {
println! (
" Restored session from {} ({} messages). " ,
session_path . display ( ) ,
session . messages . len ( )
) ;
return ;
}
let mut session = session ;
for raw_command in commands {
let Some ( command ) = SlashCommand ::parse ( raw_command ) else {
eprintln! ( " unsupported resumed command: {raw_command} " ) ;
std ::process ::exit ( 2 ) ;
} ;
match run_resume_command ( session_path , & session , & command ) {
Ok ( ResumeCommandOutcome {
session : next_session ,
message ,
} ) = > {
session = next_session ;
if let Some ( message ) = message {
println! ( " {message} " ) ;
}
}
Err ( error ) = > {
eprintln! ( " {error} " ) ;
std ::process ::exit ( 2 ) ;
}
}
}
}
#[ derive(Debug, Clone) ]
struct ResumeCommandOutcome {
session : Session ,
message : Option < String > ,
}
#[ derive(Debug, Clone) ]
struct StatusContext {
cwd : PathBuf ,
session_path : Option < PathBuf > ,
loaded_config_files : usize ,
discovered_config_files : usize ,
memory_file_count : usize ,
project_root : Option < PathBuf > ,
git_branch : Option < String > ,
}
#[ derive(Debug, Clone, Copy) ]
struct StatusUsage {
message_count : usize ,
turns : u32 ,
latest : TokenUsage ,
cumulative : TokenUsage ,
estimated_tokens : usize ,
}
fn format_model_report ( model : & str , message_count : usize , turns : u32 ) -> String {
format! (
" Model
Current model { model }
Session messages { message_count }
Session turns { turns }
Usage
Inspect current model with / model
Switch models with / model < name > "
)
}
fn format_model_switch_report ( previous : & str , next : & str , message_count : usize ) -> String {
format! (
" Model updated
Previous { previous }
Current { next }
Preserved msgs { message_count } "
)
}
fn format_permissions_report ( mode : & str ) -> String {
let modes = [
( " read-only " , " Read/search tools only " , mode = = " read-only " ) ,
(
" workspace-write " ,
" Edit files inside the workspace " ,
mode = = " workspace-write " ,
) ,
(
" danger-full-access " ,
" Unrestricted tool access " ,
mode = = " danger-full-access " ,
) ,
]
. into_iter ( )
. map ( | ( name , description , is_current ) | {
let marker = if is_current {
" ● current "
} else {
" ○ available "
} ;
format! ( " {name:<18} {marker:<11} {description} " )
} )
. collect ::< Vec < _ > > ( )
. join (
"
" ,
) ;
format! (
" Permissions
Active mode { mode }
Mode status live session default
Modes
{ modes }
Usage
Inspect current mode with / permissions
Switch modes with / permissions < mode > "
)
}
fn format_permissions_switch_report ( previous : & str , next : & str ) -> String {
format! (
" Permissions updated
Result mode switched
Previous mode { previous }
Active mode { next }
Applies to subsequent tool calls
Usage / permissions to inspect current mode "
)
}
fn format_cost_report ( usage : TokenUsage ) -> String {
format! (
" Cost
Input tokens { }
Output tokens { }
Cache create { }
Cache read { }
Total tokens { } " ,
usage . input_tokens ,
usage . output_tokens ,
usage . cache_creation_input_tokens ,
usage . cache_read_input_tokens ,
usage . total_tokens ( ) ,
)
}
fn format_resume_report ( session_path : & str , message_count : usize , turns : u32 ) -> String {
format! (
" Session resumed
Session file { session_path }
Messages { message_count }
Turns { turns } "
)
}
fn format_compact_report ( removed : usize , resulting_messages : usize , skipped : bool ) -> String {
if skipped {
format! (
" Compact
Result skipped
Reason session below compaction threshold
Messages kept { resulting_messages } "
)
} else {
format! (
" Compact
Result compacted
Messages removed { removed }
Messages kept { resulting_messages } "
)
}
}
fn format_auto_compaction_notice ( removed : usize ) -> String {
format! ( " [auto-compacted: removed {removed} messages] " )
}
fn parse_git_status_metadata ( status : Option < & str > ) -> ( Option < PathBuf > , Option < String > ) {
let Some ( status ) = status else {
return ( None , None ) ;
} ;
let branch = status . lines ( ) . next ( ) . and_then ( | line | {
line . strip_prefix ( " ## " )
. map ( | line | {
line . split ( [ '.' , ' ' ] )
. next ( )
. unwrap_or_default ( )
. to_string ( )
} )
. filter ( | value | ! value . is_empty ( ) )
} ) ;
let project_root = find_git_root ( ) . ok ( ) ;
( project_root , branch )
}
fn find_git_root ( ) -> Result < PathBuf , Box < dyn std ::error ::Error > > {
let output = std ::process ::Command ::new ( " git " )
. args ( [ " rev-parse " , " --show-toplevel " ] )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if ! output . status . success ( ) {
return Err ( " not a git repository " . into ( ) ) ;
}
let path = String ::from_utf8 ( output . stdout ) ? . trim ( ) . to_string ( ) ;
if path . is_empty ( ) {
return Err ( " empty git root " . into ( ) ) ;
}
Ok ( PathBuf ::from ( path ) )
}
#[ allow(clippy::too_many_lines) ]
fn run_resume_command (
session_path : & Path ,
session : & Session ,
command : & SlashCommand ,
) -> Result < ResumeCommandOutcome , Box < dyn std ::error ::Error > > {
match command {
SlashCommand ::Help = > Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( render_repl_help ( ) ) ,
} ) ,
SlashCommand ::Compact = > {
let result = runtime ::compact_session (
session ,
CompactionConfig {
max_estimated_tokens : 0 ,
.. CompactionConfig ::default ( )
} ,
) ;
let removed = result . removed_message_count ;
let kept = result . compacted_session . messages . len ( ) ;
let skipped = removed = = 0 ;
result . compacted_session . save_to_path ( session_path ) ? ;
Ok ( ResumeCommandOutcome {
session : result . compacted_session ,
message : Some ( format_compact_report ( removed , kept , skipped ) ) ,
} )
}
SlashCommand ::Clear { confirm } = > {
if ! confirm {
return Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some (
" clear: confirmation required; rerun with /clear --confirm " . to_string ( ) ,
) ,
} ) ;
}
let cleared = Session ::new ( ) ;
cleared . save_to_path ( session_path ) ? ;
Ok ( ResumeCommandOutcome {
session : cleared ,
message : Some ( format! (
" Cleared resumed session file {}. " ,
session_path . display ( )
) ) ,
} )
}
SlashCommand ::Status = > {
let tracker = UsageTracker ::from_session ( session ) ;
let usage = tracker . cumulative_usage ( ) ;
Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( format_status_report (
" restored-session " ,
StatusUsage {
message_count : session . messages . len ( ) ,
turns : tracker . turns ( ) ,
latest : tracker . current_turn_usage ( ) ,
cumulative : usage ,
estimated_tokens : 0 ,
} ,
default_permission_mode ( ) . as_str ( ) ,
& status_context ( Some ( session_path ) ) ? ,
) ) ,
} )
}
SlashCommand ::Cost = > {
let usage = UsageTracker ::from_session ( session ) . cumulative_usage ( ) ;
Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( format_cost_report ( usage ) ) ,
} )
}
SlashCommand ::Config { section } = > Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( render_config_report ( section . as_deref ( ) ) ? ) ,
} ) ,
SlashCommand ::Memory = > Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( render_memory_report ( ) ? ) ,
} ) ,
SlashCommand ::Init = > Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( init_claude_md ( ) ? ) ,
} ) ,
SlashCommand ::Diff = > Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( render_diff_report ( ) ? ) ,
} ) ,
SlashCommand ::Version = > Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( render_version_report ( ) ) ,
} ) ,
SlashCommand ::Export { path } = > {
let export_path = resolve_export_path ( path . as_deref ( ) , session ) ? ;
fs ::write ( & export_path , render_export_text ( session ) ) ? ;
Ok ( ResumeCommandOutcome {
session : session . clone ( ) ,
message : Some ( format! (
" Export \n Result wrote transcript \n File {} \n Messages {} " ,
export_path . display ( ) ,
session . messages . len ( ) ,
) ) ,
} )
}
SlashCommand ::Bughunter { .. }
| SlashCommand ::Commit
| SlashCommand ::Pr { .. }
| SlashCommand ::Issue { .. }
| SlashCommand ::Ultraplan { .. }
| SlashCommand ::Teleport { .. }
| SlashCommand ::DebugToolCall
| SlashCommand ::Resume { .. }
| SlashCommand ::Model { .. }
| SlashCommand ::Permissions { .. }
| SlashCommand ::Session { .. }
2026-04-01 04:30:28 +00:00
| SlashCommand ::Plugins { .. }
2026-04-01 03:55:00 +00:00
| SlashCommand ::Unknown ( _ ) = > Err ( " unsupported resumed slash command " . into ( ) ) ,
}
}
fn run_repl (
model : String ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let mut cli = LiveCli ::new ( model , true , allowed_tools , permission_mode ) ? ;
let mut editor = input ::LineEditor ::new ( " > " , slash_command_completion_candidates ( ) ) ;
println! ( " {} " , cli . startup_banner ( ) ) ;
loop {
match editor . read_line ( ) ? {
input ::ReadOutcome ::Submit ( input ) = > {
let trimmed = input . trim ( ) . to_string ( ) ;
if trimmed . is_empty ( ) {
continue ;
}
if matches! ( trimmed . as_str ( ) , " /exit " | " /quit " ) {
cli . persist_session ( ) ? ;
break ;
}
if let Some ( command ) = SlashCommand ::parse ( & trimmed ) {
if cli . handle_repl_command ( command ) ? {
cli . persist_session ( ) ? ;
}
continue ;
}
editor . push_history ( input ) ;
cli . run_turn ( & trimmed ) ? ;
}
input ::ReadOutcome ::Cancel = > { }
input ::ReadOutcome ::Exit = > {
cli . persist_session ( ) ? ;
break ;
}
}
}
Ok ( ( ) )
}
#[ derive(Debug, Clone) ]
struct SessionHandle {
id : String ,
path : PathBuf ,
}
#[ derive(Debug, Clone) ]
struct ManagedSessionSummary {
id : String ,
path : PathBuf ,
modified_epoch_secs : u64 ,
message_count : usize ,
}
struct LiveCli {
model : String ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
system_prompt : Vec < String > ,
runtime : ConversationRuntime < AnthropicRuntimeClient , CliToolExecutor > ,
session : SessionHandle ,
}
impl LiveCli {
fn new (
model : String ,
enable_tools : bool ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
) -> Result < Self , Box < dyn std ::error ::Error > > {
let system_prompt = build_system_prompt ( ) ? ;
let session = create_managed_session_handle ( ) ? ;
let runtime = build_runtime (
Session ::new ( ) ,
model . clone ( ) ,
system_prompt . clone ( ) ,
enable_tools ,
true ,
allowed_tools . clone ( ) ,
permission_mode ,
) ? ;
let cli = Self {
model ,
allowed_tools ,
permission_mode ,
system_prompt ,
runtime ,
session ,
} ;
cli . persist_session ( ) ? ;
Ok ( cli )
}
fn startup_banner ( & self ) -> String {
let cwd = env ::current_dir ( ) . map_or_else (
| _ | " <unknown> " . to_string ( ) ,
| path | path . display ( ) . to_string ( ) ,
) ;
format! (
" \x1b [38;5;196m \
█ █ █ █ █ █ ╗ █ █ ╗ █ █ █ █ █ ╗ █ █ ╗ █ █ ╗ \ n \
█ █ ╔ ═ ═ ═ ═ ╝ █ █ ║ █ █ ╔ ═ ═ █ █ ╗ █ █ ║ █ █ ║ \ n \
█ █ ║ █ █ ║ █ █ █ █ █ █ █ ║ █ █ ║ █ ╗ █ █ ║ \ n \
█ █ ║ █ █ ║ █ █ ╔ ═ ═ █ █ ║ █ █ ║ █ █ █ ╗ █ █ ║ \ n \
╚ █ █ █ █ █ █ ╗ █ █ █ █ █ █ █ ╗ █ █ ║ █ █ ║ ╚ █ █ █ ╔ █ █ █ ╔ ╝ \ n \
╚ ═ ═ ═ ═ ═ ╝ ╚ ═ ═ ═ ═ ═ ═ ╝ ╚ ═ ╝ ╚ ═ ╝ ╚ ═ ═ ╝ ╚ ═ ═ ╝ \ x1b [ 0 m \ x1b [ 38 ; 5 ; 208 mCode \ x1b [ 0 m 🦞 \ n \ n \
\ x1b [ 2 mModel \ x1b [ 0 m { } \ n \
\ x1b [ 2 mPermissions \ x1b [ 0 m { } \ n \
\ x1b [ 2 mDirectory \ x1b [ 0 m { } \ n \
\ x1b [ 2 mSession \ x1b [ 0 m { } \ n \ n \
Type \ x1b [ 1 m / help \ x1b [ 0 m for commands · \ x1b [ 2 mShift + Enter \ x1b [ 0 m for newline " ,
self . model ,
self . permission_mode . as_str ( ) ,
cwd ,
self . session . id ,
)
}
fn run_turn ( & mut self , input : & str ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let mut spinner = Spinner ::new ( ) ;
let mut stdout = io ::stdout ( ) ;
spinner . tick (
" 🦀 Thinking... " ,
TerminalRenderer ::new ( ) . color_theme ( ) ,
& mut stdout ,
) ? ;
let mut permission_prompter = CliPermissionPrompter ::new ( self . permission_mode ) ;
let result = self . runtime . run_turn ( input , Some ( & mut permission_prompter ) ) ;
match result {
Ok ( summary ) = > {
spinner . finish (
" ✨ Done " ,
TerminalRenderer ::new ( ) . color_theme ( ) ,
& mut stdout ,
) ? ;
println! ( ) ;
if let Some ( event ) = summary . auto_compaction {
println! (
" {} " ,
format_auto_compaction_notice ( event . removed_message_count )
) ;
}
self . persist_session ( ) ? ;
Ok ( ( ) )
}
Err ( error ) = > {
spinner . fail (
" ❌ Request failed " ,
TerminalRenderer ::new ( ) . color_theme ( ) ,
& mut stdout ,
) ? ;
Err ( Box ::new ( error ) )
}
}
}
fn run_turn_with_output (
& mut self ,
input : & str ,
output_format : CliOutputFormat ,
) -> Result < ( ) , Box < dyn std ::error ::Error > > {
match output_format {
CliOutputFormat ::Text = > self . run_turn ( input ) ,
CliOutputFormat ::Json = > self . run_prompt_json ( input ) ,
}
}
fn run_prompt_json ( & mut self , input : & str ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let session = self . runtime . session ( ) . clone ( ) ;
let mut runtime = build_runtime (
session ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
false ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
let mut permission_prompter = CliPermissionPrompter ::new ( self . permission_mode ) ;
let summary = runtime . run_turn ( input , Some ( & mut permission_prompter ) ) ? ;
self . runtime = runtime ;
self . persist_session ( ) ? ;
println! (
" {} " ,
json! ( {
" message " : final_assistant_text ( & summary ) ,
" model " : self . model ,
" iterations " : summary . iterations ,
" auto_compaction " : summary . auto_compaction . map ( | event | json! ( {
" removed_messages " : event . removed_message_count ,
" notice " : format_auto_compaction_notice ( event . removed_message_count ) ,
} ) ) ,
" tool_uses " : collect_tool_uses ( & summary ) ,
" tool_results " : collect_tool_results ( & summary ) ,
" usage " : {
" input_tokens " : summary . usage . input_tokens ,
" output_tokens " : summary . usage . output_tokens ,
" cache_creation_input_tokens " : summary . usage . cache_creation_input_tokens ,
" cache_read_input_tokens " : summary . usage . cache_read_input_tokens ,
}
} )
) ;
Ok ( ( ) )
}
fn handle_repl_command (
& mut self ,
command : SlashCommand ,
) -> Result < bool , Box < dyn std ::error ::Error > > {
Ok ( match command {
SlashCommand ::Help = > {
println! ( " {} " , render_repl_help ( ) ) ;
false
}
SlashCommand ::Status = > {
self . print_status ( ) ;
false
}
SlashCommand ::Bughunter { scope } = > {
self . run_bughunter ( scope . as_deref ( ) ) ? ;
false
}
SlashCommand ::Commit = > {
self . run_commit ( ) ? ;
true
}
SlashCommand ::Pr { context } = > {
self . run_pr ( context . as_deref ( ) ) ? ;
false
}
SlashCommand ::Issue { context } = > {
self . run_issue ( context . as_deref ( ) ) ? ;
false
}
SlashCommand ::Ultraplan { task } = > {
self . run_ultraplan ( task . as_deref ( ) ) ? ;
false
}
SlashCommand ::Teleport { target } = > {
self . run_teleport ( target . as_deref ( ) ) ? ;
false
}
SlashCommand ::DebugToolCall = > {
self . run_debug_tool_call ( ) ? ;
false
}
SlashCommand ::Compact = > {
self . compact ( ) ? ;
false
}
SlashCommand ::Model { model } = > self . set_model ( model ) ? ,
SlashCommand ::Permissions { mode } = > self . set_permissions ( mode ) ? ,
SlashCommand ::Clear { confirm } = > self . clear_session ( confirm ) ? ,
SlashCommand ::Cost = > {
self . print_cost ( ) ;
false
}
SlashCommand ::Resume { session_path } = > self . resume_session ( session_path ) ? ,
SlashCommand ::Config { section } = > {
Self ::print_config ( section . as_deref ( ) ) ? ;
false
}
SlashCommand ::Memory = > {
Self ::print_memory ( ) ? ;
false
}
SlashCommand ::Init = > {
run_init ( ) ? ;
false
}
SlashCommand ::Diff = > {
Self ::print_diff ( ) ? ;
false
}
SlashCommand ::Version = > {
Self ::print_version ( ) ;
false
}
SlashCommand ::Export { path } = > {
self . export_session ( path . as_deref ( ) ) ? ;
false
}
SlashCommand ::Session { action , target } = > {
self . handle_session_command ( action . as_deref ( ) , target . as_deref ( ) ) ?
}
2026-04-01 04:30:28 +00:00
SlashCommand ::Plugins { action , target } = > {
self . handle_plugins_command ( action . as_deref ( ) , target . as_deref ( ) ) ?
}
2026-04-01 03:55:00 +00:00
SlashCommand ::Unknown ( name ) = > {
eprintln! ( " unknown slash command: / {name} " ) ;
false
}
} )
}
fn persist_session ( & self ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
self . runtime . session ( ) . save_to_path ( & self . session . path ) ? ;
Ok ( ( ) )
}
fn print_status ( & self ) {
let cumulative = self . runtime . usage ( ) . cumulative_usage ( ) ;
let latest = self . runtime . usage ( ) . current_turn_usage ( ) ;
println! (
" {} " ,
format_status_report (
& self . model ,
StatusUsage {
message_count : self . runtime . session ( ) . messages . len ( ) ,
turns : self . runtime . usage ( ) . turns ( ) ,
latest ,
cumulative ,
estimated_tokens : self . runtime . estimated_tokens ( ) ,
} ,
self . permission_mode . as_str ( ) ,
& status_context ( Some ( & self . session . path ) ) . expect ( " status context should load " ) ,
)
) ;
}
fn set_model ( & mut self , model : Option < String > ) -> Result < bool , Box < dyn std ::error ::Error > > {
let Some ( model ) = model else {
println! (
" {} " ,
format_model_report (
& self . model ,
self . runtime . session ( ) . messages . len ( ) ,
self . runtime . usage ( ) . turns ( ) ,
)
) ;
return Ok ( false ) ;
} ;
let model = resolve_model_alias ( & model ) . to_string ( ) ;
if model = = self . model {
println! (
" {} " ,
format_model_report (
& self . model ,
self . runtime . session ( ) . messages . len ( ) ,
self . runtime . usage ( ) . turns ( ) ,
)
) ;
return Ok ( false ) ;
}
let previous = self . model . clone ( ) ;
let session = self . runtime . session ( ) . clone ( ) ;
let message_count = session . messages . len ( ) ;
self . runtime = build_runtime (
session ,
model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
self . model . clone_from ( & model ) ;
println! (
" {} " ,
format_model_switch_report ( & previous , & model , message_count )
) ;
Ok ( true )
}
fn set_permissions (
& mut self ,
mode : Option < String > ,
) -> Result < bool , Box < dyn std ::error ::Error > > {
let Some ( mode ) = mode else {
println! (
" {} " ,
format_permissions_report ( self . permission_mode . as_str ( ) )
) ;
return Ok ( false ) ;
} ;
let normalized = normalize_permission_mode ( & mode ) . ok_or_else ( | | {
format! (
" unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access. "
)
} ) ? ;
if normalized = = self . permission_mode . as_str ( ) {
println! ( " {} " , format_permissions_report ( normalized ) ) ;
return Ok ( false ) ;
}
let previous = self . permission_mode . as_str ( ) . to_string ( ) ;
let session = self . runtime . session ( ) . clone ( ) ;
self . permission_mode = permission_mode_from_label ( normalized ) ;
self . runtime = build_runtime (
session ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
println! (
" {} " ,
format_permissions_switch_report ( & previous , normalized )
) ;
Ok ( true )
}
fn clear_session ( & mut self , confirm : bool ) -> Result < bool , Box < dyn std ::error ::Error > > {
if ! confirm {
println! (
" clear: confirmation required; run /clear --confirm to start a fresh session. "
) ;
return Ok ( false ) ;
}
self . session = create_managed_session_handle ( ) ? ;
self . runtime = build_runtime (
Session ::new ( ) ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
println! (
" Session cleared \n Mode fresh session \n Preserved model {} \n Permission mode {} \n Session {} " ,
self . model ,
self . permission_mode . as_str ( ) ,
self . session . id ,
) ;
Ok ( true )
}
fn print_cost ( & self ) {
let cumulative = self . runtime . usage ( ) . cumulative_usage ( ) ;
println! ( " {} " , format_cost_report ( cumulative ) ) ;
}
fn resume_session (
& mut self ,
session_path : Option < String > ,
) -> Result < bool , Box < dyn std ::error ::Error > > {
let Some ( session_ref ) = session_path else {
println! ( " Usage: /resume <session-path> " ) ;
return Ok ( false ) ;
} ;
let handle = resolve_session_reference ( & session_ref ) ? ;
let session = Session ::load_from_path ( & handle . path ) ? ;
let message_count = session . messages . len ( ) ;
self . runtime = build_runtime (
session ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
self . session = handle ;
println! (
" {} " ,
format_resume_report (
& self . session . path . display ( ) . to_string ( ) ,
message_count ,
self . runtime . usage ( ) . turns ( ) ,
)
) ;
Ok ( true )
}
fn print_config ( section : Option < & str > ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
println! ( " {} " , render_config_report ( section ) ? ) ;
Ok ( ( ) )
}
fn print_memory ( ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
println! ( " {} " , render_memory_report ( ) ? ) ;
Ok ( ( ) )
}
fn print_diff ( ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
println! ( " {} " , render_diff_report ( ) ? ) ;
Ok ( ( ) )
}
fn print_version ( ) {
println! ( " {} " , render_version_report ( ) ) ;
}
fn export_session (
& self ,
requested_path : Option < & str > ,
) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let export_path = resolve_export_path ( requested_path , self . runtime . session ( ) ) ? ;
fs ::write ( & export_path , render_export_text ( self . runtime . session ( ) ) ) ? ;
println! (
" Export \n Result wrote transcript \n File {} \n Messages {} " ,
export_path . display ( ) ,
self . runtime . session ( ) . messages . len ( ) ,
) ;
Ok ( ( ) )
}
fn handle_session_command (
& mut self ,
action : Option < & str > ,
target : Option < & str > ,
) -> Result < bool , Box < dyn std ::error ::Error > > {
match action {
None | Some ( " list " ) = > {
println! ( " {} " , render_session_list ( & self . session . id ) ? ) ;
Ok ( false )
}
Some ( " switch " ) = > {
let Some ( target ) = target else {
println! ( " Usage: /session switch <session-id> " ) ;
return Ok ( false ) ;
} ;
let handle = resolve_session_reference ( target ) ? ;
let session = Session ::load_from_path ( & handle . path ) ? ;
let message_count = session . messages . len ( ) ;
self . runtime = build_runtime (
session ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
self . session = handle ;
println! (
" Session switched \n Active session {} \n File {} \n Messages {} " ,
self . session . id ,
self . session . path . display ( ) ,
message_count ,
) ;
Ok ( true )
}
Some ( other ) = > {
println! ( " Unknown /session action ' {other} '. Use /session list or /session switch <session-id>. " ) ;
Ok ( false )
}
}
}
2026-04-01 04:30:28 +00:00
fn handle_plugins_command (
& mut self ,
action : Option < & str > ,
target : Option < & str > ,
) -> Result < bool , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
2026-04-01 04:40:19 +00:00
let loader = ConfigLoader ::default_for ( & cwd ) ;
let runtime_config = loader . load ( ) ? ;
let mut manager = build_plugin_manager ( & cwd , & loader , & runtime_config ) ;
2026-04-01 04:30:28 +00:00
match action {
None | Some ( " list " ) = > {
2026-04-01 04:40:19 +00:00
let plugins = manager . list_plugins ( ) ? ;
2026-04-01 04:30:28 +00:00
println! ( " {} " , render_plugins_report ( & plugins ) ) ;
}
Some ( " install " ) = > {
let Some ( target ) = target else {
2026-04-01 04:40:19 +00:00
println! ( " Usage: /plugins install <path-or-git-url> " ) ;
2026-04-01 04:30:28 +00:00
return Ok ( false ) ;
} ;
2026-04-01 04:40:19 +00:00
let result = manager . install ( target ) ? ;
println! (
" Plugins
Result installed { }
Version { }
Path { } " ,
result . plugin_id ,
result . version ,
result . install_path . display ( ) ,
) ;
2026-04-01 04:30:28 +00:00
self . reload_runtime_features ( ) ? ;
}
Some ( " enable " ) = > {
let Some ( target ) = target else {
println! ( " Usage: /plugins enable <plugin-id> " ) ;
return Ok ( false ) ;
} ;
2026-04-01 04:40:19 +00:00
manager . enable ( target ) ? ;
println! (
" Plugins
Result enabled { target } "
) ;
2026-04-01 04:30:28 +00:00
self . reload_runtime_features ( ) ? ;
}
Some ( " disable " ) = > {
let Some ( target ) = target else {
println! ( " Usage: /plugins disable <plugin-id> " ) ;
return Ok ( false ) ;
} ;
2026-04-01 04:40:19 +00:00
manager . disable ( target ) ? ;
println! (
" Plugins
Result disabled { target } "
) ;
2026-04-01 04:30:28 +00:00
self . reload_runtime_features ( ) ? ;
}
Some ( " uninstall " ) = > {
let Some ( target ) = target else {
println! ( " Usage: /plugins uninstall <plugin-id> " ) ;
return Ok ( false ) ;
} ;
2026-04-01 04:40:19 +00:00
manager . uninstall ( target ) ? ;
println! (
" Plugins
Result uninstalled { target } "
) ;
2026-04-01 04:30:28 +00:00
self . reload_runtime_features ( ) ? ;
}
Some ( " update " ) = > {
let Some ( target ) = target else {
println! ( " Usage: /plugins update <plugin-id> " ) ;
return Ok ( false ) ;
} ;
2026-04-01 04:40:19 +00:00
let result = manager . update ( target ) ? ;
println! (
" Plugins
Result updated { }
Old version { }
New version { }
Path { } " ,
result . plugin_id ,
result . old_version ,
result . new_version ,
result . install_path . display ( ) ,
) ;
2026-04-01 04:30:28 +00:00
self . reload_runtime_features ( ) ? ;
}
Some ( other ) = > {
println! (
" Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update. "
) ;
}
}
Ok ( false )
}
fn reload_runtime_features ( & mut self ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
self . runtime = build_runtime (
self . runtime . session ( ) . clone ( ) ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
self . persist_session ( )
}
2026-04-01 03:55:00 +00:00
fn compact ( & mut self ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let result = self . runtime . compact ( CompactionConfig ::default ( ) ) ;
let removed = result . removed_message_count ;
let kept = result . compacted_session . messages . len ( ) ;
let skipped = removed = = 0 ;
self . runtime = build_runtime (
result . compacted_session ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
true ,
true ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
self . persist_session ( ) ? ;
println! ( " {} " , format_compact_report ( removed , kept , skipped ) ) ;
Ok ( ( ) )
}
fn run_internal_prompt_text (
& self ,
prompt : & str ,
enable_tools : bool ,
) -> Result < String , Box < dyn std ::error ::Error > > {
let session = self . runtime . session ( ) . clone ( ) ;
let mut runtime = build_runtime (
session ,
self . model . clone ( ) ,
self . system_prompt . clone ( ) ,
enable_tools ,
false ,
self . allowed_tools . clone ( ) ,
self . permission_mode ,
) ? ;
let mut permission_prompter = CliPermissionPrompter ::new ( self . permission_mode ) ;
let summary = runtime . run_turn ( prompt , Some ( & mut permission_prompter ) ) ? ;
Ok ( final_assistant_text ( & summary ) . trim ( ) . to_string ( ) )
}
fn run_bughunter ( & self , scope : Option < & str > ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let scope = scope . unwrap_or ( " the current repository " ) ;
let prompt = format! (
" You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed. "
) ;
println! ( " {} " , self . run_internal_prompt_text ( & prompt , true ) ? ) ;
Ok ( ( ) )
}
fn run_ultraplan ( & self , task : Option < & str > ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let task = task . unwrap_or ( " the current repo work " ) ;
let prompt = format! (
" You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed. "
) ;
println! ( " {} " , self . run_internal_prompt_text ( & prompt , true ) ? ) ;
Ok ( ( ) )
}
2026-04-01 04:30:28 +00:00
#[ allow(clippy::unused_self) ]
2026-04-01 03:55:00 +00:00
fn run_teleport ( & self , target : Option < & str > ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let Some ( target ) = target . map ( str ::trim ) . filter ( | value | ! value . is_empty ( ) ) else {
println! ( " Usage: /teleport <symbol-or-path> " ) ;
return Ok ( ( ) ) ;
} ;
println! ( " {} " , render_teleport_report ( target ) ? ) ;
Ok ( ( ) )
}
fn run_debug_tool_call ( & self ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
println! ( " {} " , render_last_tool_debug_report ( self . runtime . session ( ) ) ? ) ;
Ok ( ( ) )
}
fn run_commit ( & mut self ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let status = git_output ( & [ " status " , " --short " ] ) ? ;
if status . trim ( ) . is_empty ( ) {
println! ( " Commit \n Result skipped \n Reason no workspace changes " ) ;
return Ok ( ( ) ) ;
}
git_status_ok ( & [ " add " , " -A " ] ) ? ;
let staged_stat = git_output ( & [ " diff " , " --cached " , " --stat " ] ) ? ;
let prompt = format! (
" Generate a git commit message in plain text Lore format only. Base it on this staged diff summary: \n \n {} \n \n Recent conversation context: \n {} " ,
truncate_for_prompt ( & staged_stat , 8_000 ) ,
recent_user_context ( self . runtime . session ( ) , 6 )
) ;
let message = sanitize_generated_message ( & self . run_internal_prompt_text ( & prompt , false ) ? ) ;
if message . trim ( ) . is_empty ( ) {
return Err ( " generated commit message was empty " . into ( ) ) ;
}
let path = write_temp_text_file ( " claw-commit-message.txt " , & message ) ? ;
let output = Command ::new ( " git " )
. args ( [ " commit " , " --file " ] )
. arg ( & path )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) . trim ( ) . to_string ( ) ;
return Err ( format! ( " git commit failed: {stderr} " ) . into ( ) ) ;
}
println! (
" Commit \n Result created \n Message file {} \n \n {} " ,
path . display ( ) ,
message . trim ( )
) ;
Ok ( ( ) )
}
fn run_pr ( & self , context : Option < & str > ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let staged = git_output ( & [ " diff " , " --stat " ] ) ? ;
let prompt = format! (
" Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly: \n TITLE: <title> \n BODY: \n <body markdown> \n \n Context hint: {} \n \n Diff summary: \n {} " ,
context . unwrap_or ( " none " ) ,
truncate_for_prompt ( & staged , 10_000 )
) ;
let draft = sanitize_generated_message ( & self . run_internal_prompt_text ( & prompt , false ) ? ) ;
let ( title , body ) = parse_titled_body ( & draft )
. ok_or_else ( | | " failed to parse generated PR title/body " . to_string ( ) ) ? ;
if command_exists ( " gh " ) {
let body_path = write_temp_text_file ( " claw-pr-body.md " , & body ) ? ;
let output = Command ::new ( " gh " )
. args ( [ " pr " , " create " , " --title " , & title , " --body-file " ] )
. arg ( & body_path )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if output . status . success ( ) {
let stdout = String ::from_utf8_lossy ( & output . stdout ) . trim ( ) . to_string ( ) ;
println! (
" PR \n Result created \n Title {title} \n URL {} " ,
if stdout . is_empty ( ) { " <unknown> " } else { & stdout }
) ;
return Ok ( ( ) ) ;
}
}
println! ( " PR draft \n Title {title} \n \n {body} " ) ;
Ok ( ( ) )
}
fn run_issue ( & self , context : Option < & str > ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let prompt = format! (
" Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly: \n TITLE: <title> \n BODY: \n <body markdown> \n \n Context hint: {} \n \n Conversation context: \n {} " ,
context . unwrap_or ( " none " ) ,
truncate_for_prompt ( & recent_user_context ( self . runtime . session ( ) , 10 ) , 10_000 )
) ;
let draft = sanitize_generated_message ( & self . run_internal_prompt_text ( & prompt , false ) ? ) ;
let ( title , body ) = parse_titled_body ( & draft )
. ok_or_else ( | | " failed to parse generated issue title/body " . to_string ( ) ) ? ;
if command_exists ( " gh " ) {
let body_path = write_temp_text_file ( " claw-issue-body.md " , & body ) ? ;
let output = Command ::new ( " gh " )
. args ( [ " issue " , " create " , " --title " , & title , " --body-file " ] )
. arg ( & body_path )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if output . status . success ( ) {
let stdout = String ::from_utf8_lossy ( & output . stdout ) . trim ( ) . to_string ( ) ;
println! (
" Issue \n Result created \n Title {title} \n URL {} " ,
if stdout . is_empty ( ) { " <unknown> " } else { & stdout }
) ;
return Ok ( ( ) ) ;
}
}
println! ( " Issue draft \n Title {title} \n \n {body} " ) ;
Ok ( ( ) )
}
}
fn sessions_dir ( ) -> Result < PathBuf , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let path = cwd . join ( " .claude " ) . join ( " sessions " ) ;
fs ::create_dir_all ( & path ) ? ;
Ok ( path )
}
fn create_managed_session_handle ( ) -> Result < SessionHandle , Box < dyn std ::error ::Error > > {
let id = generate_session_id ( ) ;
let path = sessions_dir ( ) ? . join ( format! ( " {id} .json " ) ) ;
Ok ( SessionHandle { id , path } )
}
fn generate_session_id ( ) -> String {
let millis = SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. map ( | duration | duration . as_millis ( ) )
. unwrap_or_default ( ) ;
format! ( " session- {millis} " )
}
fn resolve_session_reference ( reference : & str ) -> Result < SessionHandle , Box < dyn std ::error ::Error > > {
let direct = PathBuf ::from ( reference ) ;
let path = if direct . exists ( ) {
direct
} else {
sessions_dir ( ) ? . join ( format! ( " {reference} .json " ) )
} ;
if ! path . exists ( ) {
return Err ( format! ( " session not found: {reference} " ) . into ( ) ) ;
}
let id = path
. file_stem ( )
. and_then ( | value | value . to_str ( ) )
. unwrap_or ( reference )
. to_string ( ) ;
Ok ( SessionHandle { id , path } )
}
fn list_managed_sessions ( ) -> Result < Vec < ManagedSessionSummary > , Box < dyn std ::error ::Error > > {
let mut sessions = Vec ::new ( ) ;
for entry in fs ::read_dir ( sessions_dir ( ) ? ) ? {
let entry = entry ? ;
let path = entry . path ( ) ;
if path . extension ( ) . and_then ( | ext | ext . to_str ( ) ) ! = Some ( " json " ) {
continue ;
}
let metadata = entry . metadata ( ) ? ;
let modified_epoch_secs = metadata
. modified ( )
. ok ( )
. and_then ( | time | time . duration_since ( UNIX_EPOCH ) . ok ( ) )
. map ( | duration | duration . as_secs ( ) )
. unwrap_or_default ( ) ;
let message_count = Session ::load_from_path ( & path )
. map ( | session | session . messages . len ( ) )
. unwrap_or_default ( ) ;
let id = path
. file_stem ( )
. and_then ( | value | value . to_str ( ) )
. unwrap_or ( " unknown " )
. to_string ( ) ;
sessions . push ( ManagedSessionSummary {
id ,
path ,
modified_epoch_secs ,
message_count ,
} ) ;
}
sessions . sort_by ( | left , right | right . modified_epoch_secs . cmp ( & left . modified_epoch_secs ) ) ;
Ok ( sessions )
}
fn render_session_list ( active_session_id : & str ) -> Result < String , Box < dyn std ::error ::Error > > {
let sessions = list_managed_sessions ( ) ? ;
let mut lines = vec! [
" Sessions " . to_string ( ) ,
format! ( " Directory {} " , sessions_dir ( ) ? . display ( ) ) ,
] ;
if sessions . is_empty ( ) {
lines . push ( " No managed sessions saved yet. " . to_string ( ) ) ;
return Ok ( lines . join ( " \n " ) ) ;
}
for session in sessions {
let marker = if session . id = = active_session_id {
" ● current "
} else {
" ○ saved "
} ;
lines . push ( format! (
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path} " ,
id = session . id ,
msgs = session . message_count ,
modified = session . modified_epoch_secs ,
path = session . path . display ( ) ,
) ) ;
}
Ok ( lines . join ( " \n " ) )
}
fn render_repl_help ( ) -> String {
[
" REPL " . to_string ( ) ,
" /exit Quit the REPL " . to_string ( ) ,
" /quit Quit the REPL " . to_string ( ) ,
" Up/Down Navigate prompt history " . to_string ( ) ,
" Tab Complete slash commands " . to_string ( ) ,
" Ctrl-C Clear input (or exit on empty prompt) " . to_string ( ) ,
" Shift+Enter/Ctrl+J Insert a newline " . to_string ( ) ,
String ::new ( ) ,
render_slash_command_help ( ) ,
]
. join (
"
" ,
)
}
2026-04-01 04:40:19 +00:00
fn render_plugins_report ( plugins : & [ PluginSummary ] ) -> String {
2026-04-01 04:30:28 +00:00
let mut lines = vec! [ " Plugins " . to_string ( ) ] ;
if plugins . is_empty ( ) {
lines . push ( " No plugins discovered. " . to_string ( ) ) ;
return lines . join ( " \n " ) ;
}
for plugin in plugins {
2026-04-01 04:40:19 +00:00
let kind = match plugin . metadata . kind {
PluginKind ::Builtin = > " builtin " ,
PluginKind ::Bundled = > " bundled " ,
PluginKind ::External = > " external " ,
} ;
let location = plugin . metadata . root . as_ref ( ) . map_or_else (
| | plugin . metadata . source . clone ( ) ,
| root | root . display ( ) . to_string ( ) ,
) ;
2026-04-01 04:30:28 +00:00
let enabled = if plugin . enabled {
" enabled "
} else {
" disabled "
} ;
lines . push ( format! (
" {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location} " ,
2026-04-01 04:40:19 +00:00
id = plugin . metadata . id ,
2026-04-01 04:30:28 +00:00
kind = kind ,
2026-04-01 04:40:19 +00:00
version = plugin . metadata . version ,
2026-04-01 04:30:28 +00:00
) ) ;
}
lines . join ( " \n " )
}
2026-04-01 03:55:00 +00:00
fn status_context (
session_path : Option < & Path > ,
) -> Result < StatusContext , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let loader = ConfigLoader ::default_for ( & cwd ) ;
let discovered_config_files = loader . discover ( ) . len ( ) ;
let runtime_config = loader . load ( ) ? ;
let project_context = ProjectContext ::discover_with_git ( & cwd , DEFAULT_DATE ) ? ;
let ( project_root , git_branch ) =
parse_git_status_metadata ( project_context . git_status . as_deref ( ) ) ;
Ok ( StatusContext {
cwd ,
session_path : session_path . map ( Path ::to_path_buf ) ,
loaded_config_files : runtime_config . loaded_entries ( ) . len ( ) ,
discovered_config_files ,
memory_file_count : project_context . instruction_files . len ( ) ,
project_root ,
git_branch ,
} )
}
fn format_status_report (
model : & str ,
usage : StatusUsage ,
permission_mode : & str ,
context : & StatusContext ,
) -> String {
[
format! (
" Status
Model { model }
Permission mode { permission_mode }
Messages { }
Turns { }
Estimated tokens { } " ,
usage . message_count , usage . turns , usage . estimated_tokens ,
) ,
format! (
" Usage
Latest total { }
Cumulative input { }
Cumulative output { }
Cumulative total { } " ,
usage . latest . total_tokens ( ) ,
usage . cumulative . input_tokens ,
usage . cumulative . output_tokens ,
usage . cumulative . total_tokens ( ) ,
) ,
format! (
" Workspace
Cwd { }
Project root { }
Git branch { }
Session { }
Config files loaded { } / { }
Memory files { } " ,
context . cwd . display ( ) ,
context
. project_root
. as_ref ( )
. map_or_else ( | | " unknown " . to_string ( ) , | path | path . display ( ) . to_string ( ) ) ,
context . git_branch . as_deref ( ) . unwrap_or ( " unknown " ) ,
context . session_path . as_ref ( ) . map_or_else (
| | " live-repl " . to_string ( ) ,
| path | path . display ( ) . to_string ( )
) ,
context . loaded_config_files ,
context . discovered_config_files ,
context . memory_file_count ,
) ,
]
. join (
"
" ,
)
}
fn render_config_report ( section : Option < & str > ) -> Result < String , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let loader = ConfigLoader ::default_for ( & cwd ) ;
let discovered = loader . discover ( ) ;
let runtime_config = loader . load ( ) ? ;
let mut lines = vec! [
format! (
" Config
Working directory { }
Loaded files { }
Merged keys { } " ,
cwd . display ( ) ,
runtime_config . loaded_entries ( ) . len ( ) ,
runtime_config . merged ( ) . len ( )
) ,
" Discovered files " . to_string ( ) ,
] ;
for entry in discovered {
let source = match entry . source {
ConfigSource ::User = > " user " ,
ConfigSource ::Project = > " project " ,
ConfigSource ::Local = > " local " ,
} ;
let status = if runtime_config
. loaded_entries ( )
. iter ( )
. any ( | loaded_entry | loaded_entry . path = = entry . path )
{
" loaded "
} else {
" missing "
} ;
lines . push ( format! (
" {source:<7} {status:<7} {} " ,
entry . path . display ( )
) ) ;
}
if let Some ( section ) = section {
lines . push ( format! ( " Merged section: {section} " ) ) ;
let value = match section {
" env " = > runtime_config . get ( " env " ) ,
" hooks " = > runtime_config . get ( " hooks " ) ,
" model " = > runtime_config . get ( " model " ) ,
2026-04-01 04:30:28 +00:00
" plugins " = > runtime_config
. get ( " plugins " )
. or_else ( | | runtime_config . get ( " enabledPlugins " ) ) ,
2026-04-01 03:55:00 +00:00
other = > {
lines . push ( format! (
2026-04-01 04:30:28 +00:00
" Unsupported config section '{other}'. Use env, hooks, model, or plugins. "
2026-04-01 03:55:00 +00:00
) ) ;
return Ok ( lines . join (
"
" ,
) ) ;
}
} ;
lines . push ( format! (
" {} " ,
match value {
Some ( value ) = > value . render ( ) ,
None = > " <unset> " . to_string ( ) ,
}
) ) ;
return Ok ( lines . join (
"
" ,
) ) ;
}
lines . push ( " Merged JSON " . to_string ( ) ) ;
lines . push ( format! ( " {} " , runtime_config . as_json ( ) . render ( ) ) ) ;
Ok ( lines . join (
"
" ,
) )
}
fn render_memory_report ( ) -> Result < String , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let project_context = ProjectContext ::discover ( & cwd , DEFAULT_DATE ) ? ;
let mut lines = vec! [ format! (
" Memory
Working directory { }
Instruction files { } " ,
cwd . display ( ) ,
project_context . instruction_files . len ( )
) ] ;
if project_context . instruction_files . is_empty ( ) {
lines . push ( " Discovered files " . to_string ( ) ) ;
lines . push (
" No CLAUDE instruction files discovered in the current directory ancestry. "
. to_string ( ) ,
) ;
} else {
lines . push ( " Discovered files " . to_string ( ) ) ;
for ( index , file ) in project_context . instruction_files . iter ( ) . enumerate ( ) {
let preview = file . content . lines ( ) . next ( ) . unwrap_or ( " " ) . trim ( ) ;
let preview = if preview . is_empty ( ) {
" <empty> "
} else {
preview
} ;
lines . push ( format! ( " {} . {} " , index + 1 , file . path . display ( ) , ) ) ;
lines . push ( format! (
" lines={} preview={} " ,
file . content . lines ( ) . count ( ) ,
preview
) ) ;
}
}
Ok ( lines . join (
"
" ,
) )
}
fn init_claude_md ( ) -> Result < String , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
Ok ( initialize_repo ( & cwd ) ? . render ( ) )
}
fn run_init ( ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
println! ( " {} " , init_claude_md ( ) ? ) ;
Ok ( ( ) )
}
fn normalize_permission_mode ( mode : & str ) -> Option < & 'static str > {
match mode . trim ( ) {
" read-only " = > Some ( " read-only " ) ,
" workspace-write " = > Some ( " workspace-write " ) ,
" danger-full-access " = > Some ( " danger-full-access " ) ,
_ = > None ,
}
}
fn render_diff_report ( ) -> Result < String , Box < dyn std ::error ::Error > > {
let output = std ::process ::Command ::new ( " git " )
. args ( [ " diff " , " -- " , " :(exclude).omx " ] )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) . trim ( ) . to_string ( ) ;
return Err ( format! ( " git diff failed: {stderr} " ) . into ( ) ) ;
}
let diff = String ::from_utf8 ( output . stdout ) ? ;
if diff . trim ( ) . is_empty ( ) {
return Ok (
" Diff \n Result clean working tree \n Detail no current changes "
. to_string ( ) ,
) ;
}
Ok ( format! ( " Diff \n \n {} " , diff . trim_end ( ) ) )
}
fn render_teleport_report ( target : & str ) -> Result < String , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let file_list = Command ::new ( " rg " )
. args ( [ " --files " ] )
. current_dir ( & cwd )
. output ( ) ? ;
let file_matches = if file_list . status . success ( ) {
String ::from_utf8 ( file_list . stdout ) ?
. lines ( )
. filter ( | line | line . contains ( target ) )
. take ( 10 )
. map ( ToOwned ::to_owned )
. collect ::< Vec < _ > > ( )
} else {
Vec ::new ( )
} ;
let content_output = Command ::new ( " rg " )
. args ( [ " -n " , " -S " , " --color " , " never " , target , " . " ] )
. current_dir ( & cwd )
. output ( ) ? ;
let mut lines = vec! [ format! ( " Teleport \n Target {target} " ) ] ;
if ! file_matches . is_empty ( ) {
lines . push ( String ::new ( ) ) ;
lines . push ( " File matches " . to_string ( ) ) ;
lines . extend ( file_matches . into_iter ( ) . map ( | path | format! ( " {path} " ) ) ) ;
}
if content_output . status . success ( ) {
let matches = String ::from_utf8 ( content_output . stdout ) ? ;
if ! matches . trim ( ) . is_empty ( ) {
lines . push ( String ::new ( ) ) ;
lines . push ( " Content matches " . to_string ( ) ) ;
lines . push ( truncate_for_prompt ( & matches , 4_000 ) ) ;
}
}
if lines . len ( ) = = 1 {
lines . push ( " Result no matches found " . to_string ( ) ) ;
}
Ok ( lines . join ( " \n " ) )
}
fn render_last_tool_debug_report ( session : & Session ) -> Result < String , Box < dyn std ::error ::Error > > {
let last_tool_use = session
. messages
. iter ( )
. rev ( )
. find_map ( | message | {
message . blocks . iter ( ) . rev ( ) . find_map ( | block | match block {
ContentBlock ::ToolUse { id , name , input } = > {
Some ( ( id . clone ( ) , name . clone ( ) , input . clone ( ) ) )
}
_ = > None ,
} )
} )
. ok_or_else ( | | " no prior tool call found in session " . to_string ( ) ) ? ;
let tool_result = session . messages . iter ( ) . rev ( ) . find_map ( | message | {
message . blocks . iter ( ) . rev ( ) . find_map ( | block | match block {
ContentBlock ::ToolResult {
tool_use_id ,
tool_name ,
output ,
is_error ,
} if tool_use_id = = & last_tool_use . 0 = > {
Some ( ( tool_name . clone ( ) , output . clone ( ) , * is_error ) )
}
_ = > None ,
} )
} ) ;
let mut lines = vec! [
" Debug tool call " . to_string ( ) ,
format! ( " Tool id {} " , last_tool_use . 0 ) ,
format! ( " Tool name {} " , last_tool_use . 1 ) ,
" Input " . to_string ( ) ,
indent_block ( & last_tool_use . 2 , 4 ) ,
] ;
match tool_result {
Some ( ( tool_name , output , is_error ) ) = > {
lines . push ( " Result " . to_string ( ) ) ;
lines . push ( format! ( " name {tool_name} " ) ) ;
lines . push ( format! (
" status {} " ,
if is_error { " error " } else { " ok " }
) ) ;
lines . push ( indent_block ( & output , 4 ) ) ;
}
None = > lines . push ( " Result missing tool result " . to_string ( ) ) ,
}
Ok ( lines . join ( " \n " ) )
}
fn indent_block ( value : & str , spaces : usize ) -> String {
let indent = " " . repeat ( spaces ) ;
value
. lines ( )
. map ( | line | format! ( " {indent} {line} " ) )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
}
fn git_output ( args : & [ & str ] ) -> Result < String , Box < dyn std ::error ::Error > > {
let output = Command ::new ( " git " )
. args ( args )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) . trim ( ) . to_string ( ) ;
return Err ( format! ( " git {} failed: {stderr} " , args . join ( " " ) ) . into ( ) ) ;
}
Ok ( String ::from_utf8 ( output . stdout ) ? )
}
fn git_status_ok ( args : & [ & str ] ) -> Result < ( ) , Box < dyn std ::error ::Error > > {
let output = Command ::new ( " git " )
. args ( args )
. current_dir ( env ::current_dir ( ) ? )
. output ( ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) . trim ( ) . to_string ( ) ;
return Err ( format! ( " git {} failed: {stderr} " , args . join ( " " ) ) . into ( ) ) ;
}
Ok ( ( ) )
}
fn command_exists ( name : & str ) -> bool {
Command ::new ( " which " )
. arg ( name )
. output ( )
. map ( | output | output . status . success ( ) )
. unwrap_or ( false )
}
fn write_temp_text_file (
filename : & str ,
contents : & str ,
) -> Result < PathBuf , Box < dyn std ::error ::Error > > {
let path = env ::temp_dir ( ) . join ( filename ) ;
fs ::write ( & path , contents ) ? ;
Ok ( path )
}
fn recent_user_context ( session : & Session , limit : usize ) -> String {
let requests = session
. messages
. iter ( )
. filter ( | message | message . role = = MessageRole ::User )
. filter_map ( | message | {
message . blocks . iter ( ) . find_map ( | block | match block {
ContentBlock ::Text { text } = > Some ( text . trim ( ) . to_string ( ) ) ,
_ = > None ,
} )
} )
. rev ( )
. take ( limit )
. collect ::< Vec < _ > > ( ) ;
if requests . is_empty ( ) {
" <no prior user messages> " . to_string ( )
} else {
requests
. into_iter ( )
. rev ( )
. enumerate ( )
. map ( | ( index , text ) | format! ( " {} . {} " , index + 1 , text ) )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
}
}
fn truncate_for_prompt ( value : & str , limit : usize ) -> String {
if value . chars ( ) . count ( ) < = limit {
value . trim ( ) . to_string ( )
} else {
let truncated = value . chars ( ) . take ( limit ) . collect ::< String > ( ) ;
format! ( " {} \n …[truncated] " , truncated . trim_end ( ) )
}
}
fn sanitize_generated_message ( value : & str ) -> String {
value . trim ( ) . trim_matches ( '`' ) . trim ( ) . replace ( " \r \n " , " \n " )
}
fn parse_titled_body ( value : & str ) -> Option < ( String , String ) > {
let normalized = sanitize_generated_message ( value ) ;
let title = normalized
. lines ( )
. find_map ( | line | line . strip_prefix ( " TITLE: " ) . map ( str ::trim ) ) ? ;
let body_start = normalized . find ( " BODY: " ) ? ;
let body = normalized [ body_start + " BODY: " . len ( ) .. ] . trim ( ) ;
Some ( ( title . to_string ( ) , body . to_string ( ) ) )
}
fn render_version_report ( ) -> String {
let git_sha = GIT_SHA . unwrap_or ( " unknown " ) ;
let target = BUILD_TARGET . unwrap_or ( " unknown " ) ;
format! (
" Claw Code \n Version {VERSION} \n Git SHA {git_sha} \n Target {target} \n Build date {DEFAULT_DATE} "
)
}
fn render_export_text ( session : & Session ) -> String {
let mut lines = vec! [ " # Conversation Export " . to_string ( ) , String ::new ( ) ] ;
for ( index , message ) in session . messages . iter ( ) . enumerate ( ) {
let role = match message . role {
MessageRole ::System = > " system " ,
MessageRole ::User = > " user " ,
MessageRole ::Assistant = > " assistant " ,
MessageRole ::Tool = > " tool " ,
} ;
lines . push ( format! ( " ## {} . {role} " , index + 1 ) ) ;
for block in & message . blocks {
match block {
ContentBlock ::Text { text } = > lines . push ( text . clone ( ) ) ,
ContentBlock ::ToolUse { id , name , input } = > {
lines . push ( format! ( " [tool_use id= {id} name= {name} ] {input} " ) ) ;
}
ContentBlock ::ToolResult {
tool_use_id ,
tool_name ,
output ,
is_error ,
} = > {
lines . push ( format! (
" [tool_result id={tool_use_id} name={tool_name} error={is_error}] {output} "
) ) ;
}
}
}
lines . push ( String ::new ( ) ) ;
}
lines . join ( " \n " )
}
fn default_export_filename ( session : & Session ) -> String {
let stem = session
. messages
. iter ( )
. find_map ( | message | match message . role {
MessageRole ::User = > message . blocks . iter ( ) . find_map ( | block | match block {
ContentBlock ::Text { text } = > Some ( text . as_str ( ) ) ,
_ = > None ,
} ) ,
_ = > None ,
} )
. map_or ( " conversation " , | text | {
text . lines ( ) . next ( ) . unwrap_or ( " conversation " )
} )
. chars ( )
. map ( | ch | {
if ch . is_ascii_alphanumeric ( ) {
ch . to_ascii_lowercase ( )
} else {
'-'
}
} )
. collect ::< String > ( )
. split ( '-' )
. filter ( | part | ! part . is_empty ( ) )
. take ( 8 )
. collect ::< Vec < _ > > ( )
. join ( " - " ) ;
let fallback = if stem . is_empty ( ) {
" conversation "
} else {
& stem
} ;
format! ( " {fallback} .txt " )
}
fn resolve_export_path (
requested_path : Option < & str > ,
session : & Session ,
) -> Result < PathBuf , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
let file_name =
requested_path . map_or_else ( | | default_export_filename ( session ) , ToOwned ::to_owned ) ;
let final_name = if Path ::new ( & file_name )
. extension ( )
. is_some_and ( | ext | ext . eq_ignore_ascii_case ( " txt " ) )
{
file_name
} else {
format! ( " {file_name} .txt " )
} ;
Ok ( cwd . join ( final_name ) )
}
fn build_system_prompt ( ) -> Result < Vec < String > , Box < dyn std ::error ::Error > > {
Ok ( load_system_prompt (
env ::current_dir ( ) ? ,
DEFAULT_DATE ,
env ::consts ::OS ,
" unknown " ,
) ? )
}
fn build_runtime_feature_config (
) -> Result < runtime ::RuntimeFeatureConfig , Box < dyn std ::error ::Error > > {
let cwd = env ::current_dir ( ) ? ;
2026-04-01 04:30:28 +00:00
let loader = ConfigLoader ::default_for ( & cwd ) ;
let runtime_config = loader . load ( ) ? ;
2026-04-01 04:40:19 +00:00
let plugin_manager = build_plugin_manager ( & cwd , & loader , & runtime_config ) ;
let plugin_hooks = plugin_manager . aggregated_hooks ( ) ? ;
2026-04-01 04:30:28 +00:00
Ok ( runtime_config
2026-04-01 03:55:00 +00:00
. feature_config ( )
2026-04-01 04:30:28 +00:00
. clone ( )
2026-04-01 04:40:19 +00:00
. with_hooks ( runtime_config . hooks ( ) . merged ( & RuntimeHookConfig ::new (
plugin_hooks . pre_tool_use ,
plugin_hooks . post_tool_use ,
) ) ) )
}
fn build_plugin_manager (
cwd : & Path ,
loader : & ConfigLoader ,
runtime_config : & runtime ::RuntimeConfig ,
) -> PluginManager {
let plugin_settings = runtime_config . plugins ( ) ;
let mut plugin_config = PluginManagerConfig ::new ( loader . config_home ( ) . to_path_buf ( ) ) ;
plugin_config . enabled_plugins = plugin_settings . enabled_plugins ( ) . clone ( ) ;
plugin_config . external_dirs = plugin_settings
. external_directories ( )
. iter ( )
. map ( | path | resolve_plugin_path ( cwd , loader . config_home ( ) , path ) )
. collect ( ) ;
plugin_config . install_root = plugin_settings
. install_root ( )
. map ( | path | resolve_plugin_path ( cwd , loader . config_home ( ) , path ) ) ;
plugin_config . registry_path = plugin_settings
. registry_path ( )
. map ( | path | resolve_plugin_path ( cwd , loader . config_home ( ) , path ) ) ;
plugin_config . bundled_root = plugin_settings
. bundled_root ( )
. map ( | path | resolve_plugin_path ( cwd , loader . config_home ( ) , path ) ) ;
PluginManager ::new ( plugin_config )
}
fn resolve_plugin_path ( cwd : & Path , config_home : & Path , value : & str ) -> PathBuf {
let path = PathBuf ::from ( value ) ;
if path . is_absolute ( ) {
path
} else if value . starts_with ( '.' ) {
cwd . join ( path )
} else {
config_home . join ( path )
}
2026-04-01 03:55:00 +00:00
}
2026-04-01 04:30:28 +00:00
#[ allow(clippy::needless_pass_by_value) ]
2026-04-01 03:55:00 +00:00
fn build_runtime (
session : Session ,
model : String ,
system_prompt : Vec < String > ,
enable_tools : bool ,
emit_output : bool ,
allowed_tools : Option < AllowedToolSet > ,
permission_mode : PermissionMode ,
) -> Result < ConversationRuntime < AnthropicRuntimeClient , CliToolExecutor > , Box < dyn std ::error ::Error > >
{
Ok ( ConversationRuntime ::new_with_features (
session ,
AnthropicRuntimeClient ::new ( model , enable_tools , emit_output , allowed_tools . clone ( ) ) ? ,
CliToolExecutor ::new ( allowed_tools , emit_output ) ,
permission_policy ( permission_mode ) ,
system_prompt ,
build_runtime_feature_config ( ) ? ,
) )
}
struct CliPermissionPrompter {
current_mode : PermissionMode ,
}
impl CliPermissionPrompter {
fn new ( current_mode : PermissionMode ) -> Self {
Self { current_mode }
}
}
impl runtime ::PermissionPrompter for CliPermissionPrompter {
fn decide (
& mut self ,
request : & runtime ::PermissionRequest ,
) -> runtime ::PermissionPromptDecision {
println! ( ) ;
println! ( " Permission approval required " ) ;
println! ( " Tool {} " , request . tool_name ) ;
println! ( " Current mode {} " , self . current_mode . as_str ( ) ) ;
println! ( " Required mode {} " , request . required_mode . as_str ( ) ) ;
println! ( " Input {} " , request . input ) ;
print! ( " Approve this tool call? [y/N]: " ) ;
let _ = io ::stdout ( ) . flush ( ) ;
let mut response = String ::new ( ) ;
match io ::stdin ( ) . read_line ( & mut response ) {
Ok ( _ ) = > {
let normalized = response . trim ( ) . to_ascii_lowercase ( ) ;
if matches! ( normalized . as_str ( ) , " y " | " yes " ) {
runtime ::PermissionPromptDecision ::Allow
} else {
runtime ::PermissionPromptDecision ::Deny {
reason : format ! (
" tool '{}' denied by user approval prompt " ,
request . tool_name
) ,
}
}
}
Err ( error ) = > runtime ::PermissionPromptDecision ::Deny {
reason : format ! ( " permission approval failed: {error} " ) ,
} ,
}
}
}
struct AnthropicRuntimeClient {
runtime : tokio ::runtime ::Runtime ,
client : AnthropicClient ,
model : String ,
enable_tools : bool ,
emit_output : bool ,
allowed_tools : Option < AllowedToolSet > ,
}
impl AnthropicRuntimeClient {
fn new (
model : String ,
enable_tools : bool ,
emit_output : bool ,
allowed_tools : Option < AllowedToolSet > ,
) -> Result < Self , Box < dyn std ::error ::Error > > {
Ok ( Self {
runtime : tokio ::runtime ::Runtime ::new ( ) ? ,
client : AnthropicClient ::from_auth ( resolve_cli_auth_source ( ) ? )
. with_base_url ( api ::read_base_url ( ) ) ,
model ,
enable_tools ,
emit_output ,
allowed_tools ,
} )
}
}
fn resolve_cli_auth_source ( ) -> Result < AuthSource , Box < dyn std ::error ::Error > > {
Ok ( resolve_startup_auth_source ( | | {
let cwd = env ::current_dir ( ) . map_err ( api ::ApiError ::from ) ? ;
let config = ConfigLoader ::default_for ( & cwd ) . load ( ) . map_err ( | error | {
api ::ApiError ::Auth ( format! ( " failed to load runtime OAuth config: {error} " ) )
} ) ? ;
Ok ( config . oauth ( ) . cloned ( ) )
} ) ? )
}
impl ApiClient for AnthropicRuntimeClient {
#[ allow(clippy::too_many_lines) ]
fn stream ( & mut self , request : ApiRequest ) -> Result < Vec < AssistantEvent > , RuntimeError > {
let message_request = MessageRequest {
model : self . model . clone ( ) ,
max_tokens : max_tokens_for_model ( & self . model ) ,
messages : convert_messages ( & request . messages ) ,
system : ( ! request . system_prompt . is_empty ( ) ) . then ( | | request . system_prompt . join ( " \n \n " ) ) ,
tools : self . enable_tools . then ( | | {
filter_tool_specs ( self . allowed_tools . as_ref ( ) )
. into_iter ( )
. map ( | spec | ToolDefinition {
name : spec . name . to_string ( ) ,
description : Some ( spec . description . to_string ( ) ) ,
input_schema : spec . input_schema ,
} )
. collect ( )
} ) ,
tool_choice : self . enable_tools . then_some ( ToolChoice ::Auto ) ,
stream : true ,
} ;
self . runtime . block_on ( async {
let mut stream = self
. client
. stream_message ( & message_request )
. await
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
let mut stdout = io ::stdout ( ) ;
let mut sink = io ::sink ( ) ;
let out : & mut dyn Write = if self . emit_output {
& mut stdout
} else {
& mut sink
} ;
let renderer = TerminalRenderer ::new ( ) ;
let mut markdown_stream = MarkdownStreamState ::default ( ) ;
let mut events = Vec ::new ( ) ;
let mut pending_tool : Option < ( String , String , String ) > = None ;
let mut saw_stop = false ;
while let Some ( event ) = stream
. next_event ( )
. await
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ?
{
match event {
ApiStreamEvent ::MessageStart ( start ) = > {
for block in start . message . content {
push_output_block ( block , out , & mut events , & mut pending_tool , true ) ? ;
}
}
ApiStreamEvent ::ContentBlockStart ( start ) = > {
push_output_block (
start . content_block ,
out ,
& mut events ,
& mut pending_tool ,
true ,
) ? ;
}
ApiStreamEvent ::ContentBlockDelta ( delta ) = > match delta . delta {
ContentBlockDelta ::TextDelta { text } = > {
if ! text . is_empty ( ) {
if let Some ( rendered ) = markdown_stream . push ( & renderer , & text ) {
write! ( out , " {rendered} " )
. and_then ( | ( ) | out . flush ( ) )
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
}
events . push ( AssistantEvent ::TextDelta ( text ) ) ;
}
}
ContentBlockDelta ::InputJsonDelta { partial_json } = > {
if let Some ( ( _ , _ , input ) ) = & mut pending_tool {
input . push_str ( & partial_json ) ;
}
}
} ,
ApiStreamEvent ::ContentBlockStop ( _ ) = > {
if let Some ( rendered ) = markdown_stream . flush ( & renderer ) {
write! ( out , " {rendered} " )
. and_then ( | ( ) | out . flush ( ) )
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
}
if let Some ( ( id , name , input ) ) = pending_tool . take ( ) {
// Display tool call now that input is fully accumulated
writeln! ( out , " \n {} " , format_tool_call_start ( & name , & input ) )
. and_then ( | ( ) | out . flush ( ) )
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
events . push ( AssistantEvent ::ToolUse { id , name , input } ) ;
}
}
ApiStreamEvent ::MessageDelta ( delta ) = > {
events . push ( AssistantEvent ::Usage ( TokenUsage {
input_tokens : delta . usage . input_tokens ,
output_tokens : delta . usage . output_tokens ,
cache_creation_input_tokens : 0 ,
cache_read_input_tokens : 0 ,
} ) ) ;
}
ApiStreamEvent ::MessageStop ( _ ) = > {
saw_stop = true ;
if let Some ( rendered ) = markdown_stream . flush ( & renderer ) {
write! ( out , " {rendered} " )
. and_then ( | ( ) | out . flush ( ) )
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
}
events . push ( AssistantEvent ::MessageStop ) ;
}
}
}
if ! saw_stop
& & events . iter ( ) . any ( | event | {
matches! ( event , AssistantEvent ::TextDelta ( text ) if ! text . is_empty ( ) )
| | matches! ( event , AssistantEvent ::ToolUse { .. } )
} )
{
events . push ( AssistantEvent ::MessageStop ) ;
}
if events
. iter ( )
. any ( | event | matches! ( event , AssistantEvent ::MessageStop ) )
{
return Ok ( events ) ;
}
let response = self
. client
. send_message ( & MessageRequest {
stream : false ,
.. message_request . clone ( )
} )
. await
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
response_to_events ( response , out )
} )
}
}
fn final_assistant_text ( summary : & runtime ::TurnSummary ) -> String {
summary
. assistant_messages
. last ( )
. map ( | message | {
message
. blocks
. iter ( )
. filter_map ( | block | match block {
ContentBlock ::Text { text } = > Some ( text . as_str ( ) ) ,
_ = > None ,
} )
. collect ::< Vec < _ > > ( )
. join ( " " )
} )
. unwrap_or_default ( )
}
fn collect_tool_uses ( summary : & runtime ::TurnSummary ) -> Vec < serde_json ::Value > {
summary
. assistant_messages
. iter ( )
. flat_map ( | message | message . blocks . iter ( ) )
. filter_map ( | block | match block {
ContentBlock ::ToolUse { id , name , input } = > Some ( json! ( {
" id " : id ,
" name " : name ,
" input " : input ,
} ) ) ,
_ = > None ,
} )
. collect ( )
}
fn collect_tool_results ( summary : & runtime ::TurnSummary ) -> Vec < serde_json ::Value > {
summary
. tool_results
. iter ( )
. flat_map ( | message | message . blocks . iter ( ) )
. filter_map ( | block | match block {
ContentBlock ::ToolResult {
tool_use_id ,
tool_name ,
output ,
is_error ,
} = > Some ( json! ( {
" tool_use_id " : tool_use_id ,
" tool_name " : tool_name ,
" output " : output ,
" is_error " : is_error ,
} ) ) ,
_ = > None ,
} )
. collect ( )
}
fn slash_command_completion_candidates ( ) -> Vec < String > {
slash_command_specs ( )
. iter ( )
. map ( | spec | format! ( " / {} " , spec . name ) )
. collect ( )
}
fn format_tool_call_start ( name : & str , input : & str ) -> String {
let parsed : serde_json ::Value =
serde_json ::from_str ( input ) . unwrap_or ( serde_json ::Value ::String ( input . to_string ( ) ) ) ;
let detail = match name {
" bash " | " Bash " = > format_bash_call ( & parsed ) ,
" read_file " | " Read " = > {
let path = extract_tool_path ( & parsed ) ;
format! ( " \x1b [2m📄 Reading {path} … \x1b [0m " )
}
" write_file " | " Write " = > {
let path = extract_tool_path ( & parsed ) ;
let lines = parsed
. get ( " content " )
. and_then ( | value | value . as_str ( ) )
. map_or ( 0 , | content | content . lines ( ) . count ( ) ) ;
format! ( " \x1b [1;32m✏️ Writing {path} \x1b [0m \x1b [2m( {lines} lines) \x1b [0m " )
}
" edit_file " | " Edit " = > {
let path = extract_tool_path ( & parsed ) ;
let old_value = parsed
. get ( " old_string " )
. or_else ( | | parsed . get ( " oldString " ) )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
let new_value = parsed
. get ( " new_string " )
. or_else ( | | parsed . get ( " newString " ) )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
format! (
" \x1b [1;33m📝 Editing {path} \x1b [0m{} " ,
format_patch_preview ( old_value , new_value )
. map ( | preview | format! ( " \n {preview} " ) )
. unwrap_or_default ( )
)
}
" glob_search " | " Glob " = > format_search_start ( " 🔎 Glob " , & parsed ) ,
" grep_search " | " Grep " = > format_search_start ( " 🔎 Grep " , & parsed ) ,
" web_search " | " WebSearch " = > parsed
. get ( " query " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or ( " ? " )
. to_string ( ) ,
_ = > summarize_tool_payload ( input ) ,
} ;
let border = " ─ " . repeat ( name . len ( ) + 8 ) ;
format! (
" \x1b [38;5;245m╭─ \x1b [1;36m{name} \x1b [0;38;5;245m ─╮ \x1b [0m \n \x1b [38;5;245m│ \x1b [0m {detail} \n \x1b [38;5;245m╰{border}╯ \x1b [0m "
)
}
fn format_tool_result ( name : & str , output : & str , is_error : bool ) -> String {
let icon = if is_error {
" \x1b [1;31m✗ \x1b [0m "
} else {
" \x1b [1;32m✓ \x1b [0m "
} ;
if is_error {
let summary = truncate_for_summary ( output . trim ( ) , 160 ) ;
return if summary . is_empty ( ) {
format! ( " {icon} \x1b [38;5;245m {name} \x1b [0m " )
} else {
format! ( " {icon} \x1b [38;5;245m {name} \x1b [0m \n \x1b [38;5;203m {summary} \x1b [0m " )
} ;
}
let parsed : serde_json ::Value =
serde_json ::from_str ( output ) . unwrap_or ( serde_json ::Value ::String ( output . to_string ( ) ) ) ;
match name {
" bash " | " Bash " = > format_bash_result ( icon , & parsed ) ,
" read_file " | " Read " = > format_read_result ( icon , & parsed ) ,
" write_file " | " Write " = > format_write_result ( icon , & parsed ) ,
" edit_file " | " Edit " = > format_edit_result ( icon , & parsed ) ,
" glob_search " | " Glob " = > format_glob_result ( icon , & parsed ) ,
" grep_search " | " Grep " = > format_grep_result ( icon , & parsed ) ,
_ = > {
let summary = truncate_for_summary ( output . trim ( ) , 200 ) ;
format! ( " {icon} \x1b [38;5;245m {name} : \x1b [0m {summary} " )
}
}
}
fn extract_tool_path ( parsed : & serde_json ::Value ) -> String {
parsed
. get ( " file_path " )
. or_else ( | | parsed . get ( " filePath " ) )
. or_else ( | | parsed . get ( " path " ) )
. and_then ( | value | value . as_str ( ) )
. unwrap_or ( " ? " )
. to_string ( )
}
fn format_search_start ( label : & str , parsed : & serde_json ::Value ) -> String {
let pattern = parsed
. get ( " pattern " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or ( " ? " ) ;
let scope = parsed
. get ( " path " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or ( " . " ) ;
format! ( " {label} {pattern} \n \x1b [2min {scope} \x1b [0m " )
}
fn format_patch_preview ( old_value : & str , new_value : & str ) -> Option < String > {
if old_value . is_empty ( ) & & new_value . is_empty ( ) {
return None ;
}
Some ( format! (
" \x1b [38;5;203m- {} \x1b [0m \n \x1b [38;5;70m+ {} \x1b [0m " ,
truncate_for_summary ( first_visible_line ( old_value ) , 72 ) ,
truncate_for_summary ( first_visible_line ( new_value ) , 72 )
) )
}
fn format_bash_call ( parsed : & serde_json ::Value ) -> String {
let command = parsed
. get ( " command " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
if command . is_empty ( ) {
String ::new ( )
} else {
format! (
" \x1b [48;5;236;38;5;255m $ {} \x1b [0m " ,
truncate_for_summary ( command , 160 )
)
}
}
fn first_visible_line ( text : & str ) -> & str {
text . lines ( )
. find ( | line | ! line . trim ( ) . is_empty ( ) )
. unwrap_or ( text )
}
fn format_bash_result ( icon : & str , parsed : & serde_json ::Value ) -> String {
let mut lines = vec! [ format! ( " {icon} \x1b [38;5;245mbash \x1b [0m " ) ] ;
if let Some ( task_id ) = parsed
. get ( " backgroundTaskId " )
. and_then ( | value | value . as_str ( ) )
{
2026-04-01 04:40:19 +00:00
write! ( & mut lines [ 0 ] , " backgrounded ({task_id}) " ) . expect ( " write to string " ) ;
2026-04-01 03:55:00 +00:00
} else if let Some ( status ) = parsed
. get ( " returnCodeInterpretation " )
. and_then ( | value | value . as_str ( ) )
. filter ( | status | ! status . is_empty ( ) )
{
2026-04-01 04:40:19 +00:00
write! ( & mut lines [ 0 ] , " {status} " ) . expect ( " write to string " ) ;
2026-04-01 03:55:00 +00:00
}
if let Some ( stdout ) = parsed . get ( " stdout " ) . and_then ( | value | value . as_str ( ) ) {
if ! stdout . trim ( ) . is_empty ( ) {
lines . push ( stdout . trim_end ( ) . to_string ( ) ) ;
}
}
if let Some ( stderr ) = parsed . get ( " stderr " ) . and_then ( | value | value . as_str ( ) ) {
if ! stderr . trim ( ) . is_empty ( ) {
lines . push ( format! ( " \x1b [38;5;203m {} \x1b [0m " , stderr . trim_end ( ) ) ) ;
}
}
lines . join ( " \n \n " )
}
fn format_read_result ( icon : & str , parsed : & serde_json ::Value ) -> String {
let file = parsed . get ( " file " ) . unwrap_or ( parsed ) ;
let path = extract_tool_path ( file ) ;
let start_line = file
. get ( " startLine " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_u64 )
2026-04-01 03:55:00 +00:00
. unwrap_or ( 1 ) ;
let num_lines = file
. get ( " numLines " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_u64 )
2026-04-01 03:55:00 +00:00
. unwrap_or ( 0 ) ;
let total_lines = file
. get ( " totalLines " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_u64 )
2026-04-01 03:55:00 +00:00
. unwrap_or ( num_lines ) ;
let content = file
. get ( " content " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
let end_line = start_line . saturating_add ( num_lines . saturating_sub ( 1 ) ) ;
format! (
" {icon} \x1b [2m📄 Read {path} (lines {}-{} of {}) \x1b [0m \n {} " ,
start_line ,
end_line . max ( start_line ) ,
total_lines ,
content
)
}
fn format_write_result ( icon : & str , parsed : & serde_json ::Value ) -> String {
let path = extract_tool_path ( parsed ) ;
let kind = parsed
. get ( " type " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or ( " write " ) ;
let line_count = parsed
. get ( " content " )
. and_then ( | value | value . as_str ( ) )
2026-04-01 04:40:19 +00:00
. map_or ( 0 , | content | content . lines ( ) . count ( ) ) ;
2026-04-01 03:55:00 +00:00
format! (
" {icon} \x1b [1;32m✏️ {} {path} \x1b [0m \x1b [2m({line_count} lines) \x1b [0m " ,
if kind = = " create " { " Wrote " } else { " Updated " } ,
)
}
fn format_structured_patch_preview ( parsed : & serde_json ::Value ) -> Option < String > {
let hunks = parsed . get ( " structuredPatch " ) ? . as_array ( ) ? ;
let mut preview = Vec ::new ( ) ;
for hunk in hunks . iter ( ) . take ( 2 ) {
let lines = hunk . get ( " lines " ) ? . as_array ( ) ? ;
for line in lines . iter ( ) . filter_map ( | value | value . as_str ( ) ) . take ( 6 ) {
match line . chars ( ) . next ( ) {
Some ( '+' ) = > preview . push ( format! ( " \x1b [38;5;70m {line} \x1b [0m " ) ) ,
Some ( '-' ) = > preview . push ( format! ( " \x1b [38;5;203m {line} \x1b [0m " ) ) ,
_ = > preview . push ( line . to_string ( ) ) ,
}
}
}
if preview . is_empty ( ) {
None
} else {
Some ( preview . join ( " \n " ) )
}
}
fn format_edit_result ( icon : & str , parsed : & serde_json ::Value ) -> String {
let path = extract_tool_path ( parsed ) ;
let suffix = if parsed
. get ( " replaceAll " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_bool )
2026-04-01 03:55:00 +00:00
. unwrap_or ( false )
{
" (replace all) "
} else {
" "
} ;
let preview = format_structured_patch_preview ( parsed ) . or_else ( | | {
let old_value = parsed
. get ( " oldString " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
let new_value = parsed
. get ( " newString " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
format_patch_preview ( old_value , new_value )
} ) ;
match preview {
Some ( preview ) = > format! ( " {icon} \x1b [1;33m📝 Edited {path} {suffix} \x1b [0m \n {preview} " ) ,
None = > format! ( " {icon} \x1b [1;33m📝 Edited {path} {suffix} \x1b [0m " ) ,
}
}
fn format_glob_result ( icon : & str , parsed : & serde_json ::Value ) -> String {
let num_files = parsed
. get ( " numFiles " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_u64 )
2026-04-01 03:55:00 +00:00
. unwrap_or ( 0 ) ;
let filenames = parsed
. get ( " filenames " )
. and_then ( | value | value . as_array ( ) )
. map ( | files | {
files
. iter ( )
. filter_map ( | value | value . as_str ( ) )
. take ( 8 )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
} )
. unwrap_or_default ( ) ;
if filenames . is_empty ( ) {
format! ( " {icon} \x1b [38;5;245mglob_search \x1b [0m matched {num_files} files " )
} else {
format! ( " {icon} \x1b [38;5;245mglob_search \x1b [0m matched {num_files} files \n {filenames} " )
}
}
fn format_grep_result ( icon : & str , parsed : & serde_json ::Value ) -> String {
let num_matches = parsed
. get ( " numMatches " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_u64 )
2026-04-01 03:55:00 +00:00
. unwrap_or ( 0 ) ;
let num_files = parsed
. get ( " numFiles " )
2026-04-01 04:40:19 +00:00
. and_then ( serde_json ::Value ::as_u64 )
2026-04-01 03:55:00 +00:00
. unwrap_or ( 0 ) ;
let content = parsed
. get ( " content " )
. and_then ( | value | value . as_str ( ) )
. unwrap_or_default ( ) ;
let filenames = parsed
. get ( " filenames " )
. and_then ( | value | value . as_array ( ) )
. map ( | files | {
files
. iter ( )
. filter_map ( | value | value . as_str ( ) )
. take ( 8 )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
} )
. unwrap_or_default ( ) ;
let summary = format! (
" {icon} \x1b [38;5;245mgrep_search \x1b [0m {num_matches} matches across {num_files} files "
) ;
if ! content . trim ( ) . is_empty ( ) {
format! ( " {summary} \n {} " , content . trim_end ( ) )
} else if ! filenames . is_empty ( ) {
format! ( " {summary} \n {filenames} " )
} else {
summary
}
}
fn summarize_tool_payload ( payload : & str ) -> String {
let compact = match serde_json ::from_str ::< serde_json ::Value > ( payload ) {
Ok ( value ) = > value . to_string ( ) ,
Err ( _ ) = > payload . trim ( ) . to_string ( ) ,
} ;
truncate_for_summary ( & compact , 96 )
}
fn truncate_for_summary ( value : & str , limit : usize ) -> String {
let mut chars = value . chars ( ) ;
let truncated = chars . by_ref ( ) . take ( limit ) . collect ::< String > ( ) ;
if chars . next ( ) . is_some ( ) {
format! ( " {truncated} … " )
} else {
truncated
}
}
fn push_output_block (
block : OutputContentBlock ,
out : & mut ( impl Write + ? Sized ) ,
events : & mut Vec < AssistantEvent > ,
pending_tool : & mut Option < ( String , String , String ) > ,
streaming_tool_input : bool ,
) -> Result < ( ) , RuntimeError > {
match block {
OutputContentBlock ::Text { text } = > {
if ! text . is_empty ( ) {
let rendered = TerminalRenderer ::new ( ) . markdown_to_ansi ( & text ) ;
write! ( out , " {rendered} " )
. and_then ( | ( ) | out . flush ( ) )
. map_err ( | error | RuntimeError ::new ( error . to_string ( ) ) ) ? ;
events . push ( AssistantEvent ::TextDelta ( text ) ) ;
}
}
OutputContentBlock ::ToolUse { id , name , input } = > {
// During streaming, the initial content_block_start has an empty input ({}).
// The real input arrives via input_json_delta events. In
// non-streaming responses, preserve a legitimate empty object.
let initial_input = if streaming_tool_input
& & input . is_object ( )
& & input . as_object ( ) . is_some_and ( serde_json ::Map ::is_empty )
{
String ::new ( )
} else {
input . to_string ( )
} ;
* pending_tool = Some ( ( id , name , initial_input ) ) ;
}
}
Ok ( ( ) )
}
fn response_to_events (
response : MessageResponse ,
out : & mut ( impl Write + ? Sized ) ,
) -> Result < Vec < AssistantEvent > , RuntimeError > {
let mut events = Vec ::new ( ) ;
let mut pending_tool = None ;
for block in response . content {
push_output_block ( block , out , & mut events , & mut pending_tool , false ) ? ;
if let Some ( ( id , name , input ) ) = pending_tool . take ( ) {
events . push ( AssistantEvent ::ToolUse { id , name , input } ) ;
}
}
events . push ( AssistantEvent ::Usage ( TokenUsage {
input_tokens : response . usage . input_tokens ,
output_tokens : response . usage . output_tokens ,
cache_creation_input_tokens : response . usage . cache_creation_input_tokens ,
cache_read_input_tokens : response . usage . cache_read_input_tokens ,
} ) ) ;
events . push ( AssistantEvent ::MessageStop ) ;
Ok ( events )
}
struct CliToolExecutor {
renderer : TerminalRenderer ,
emit_output : bool ,
allowed_tools : Option < AllowedToolSet > ,
}
impl CliToolExecutor {
fn new ( allowed_tools : Option < AllowedToolSet > , emit_output : bool ) -> Self {
Self {
renderer : TerminalRenderer ::new ( ) ,
emit_output ,
allowed_tools ,
}
}
}
impl ToolExecutor for CliToolExecutor {
fn execute ( & mut self , tool_name : & str , input : & str ) -> Result < String , ToolError > {
if self
. allowed_tools
. as_ref ( )
. is_some_and ( | allowed | ! allowed . contains ( tool_name ) )
{
return Err ( ToolError ::new ( format! (
" tool `{tool_name}` is not enabled by the current --allowedTools setting "
) ) ) ;
}
let value = serde_json ::from_str ( input )
. map_err ( | error | ToolError ::new ( format! ( " invalid tool input JSON: {error} " ) ) ) ? ;
match execute_tool ( tool_name , & value ) {
Ok ( output ) = > {
if self . emit_output {
let markdown = format_tool_result ( tool_name , & output , false ) ;
self . renderer
. stream_markdown ( & markdown , & mut io ::stdout ( ) )
. map_err ( | error | ToolError ::new ( error . to_string ( ) ) ) ? ;
}
Ok ( output )
}
Err ( error ) = > {
if self . emit_output {
let markdown = format_tool_result ( tool_name , & error , true ) ;
self . renderer
. stream_markdown ( & markdown , & mut io ::stdout ( ) )
. map_err ( | stream_error | ToolError ::new ( stream_error . to_string ( ) ) ) ? ;
}
Err ( ToolError ::new ( error ) )
}
}
}
}
fn permission_policy ( mode : PermissionMode ) -> PermissionPolicy {
tool_permission_specs ( )
. into_iter ( )
. fold ( PermissionPolicy ::new ( mode ) , | policy , spec | {
policy . with_tool_requirement ( spec . name , spec . required_permission )
} )
}
fn tool_permission_specs ( ) -> Vec < ToolSpec > {
mvp_tool_specs ( )
}
fn convert_messages ( messages : & [ ConversationMessage ] ) -> Vec < InputMessage > {
messages
. iter ( )
. filter_map ( | message | {
let role = match message . role {
MessageRole ::System | MessageRole ::User | MessageRole ::Tool = > " user " ,
MessageRole ::Assistant = > " assistant " ,
} ;
let content = message
. blocks
. iter ( )
. map ( | block | match block {
ContentBlock ::Text { text } = > InputContentBlock ::Text { text : text . clone ( ) } ,
ContentBlock ::ToolUse { id , name , input } = > InputContentBlock ::ToolUse {
id : id . clone ( ) ,
name : name . clone ( ) ,
input : serde_json ::from_str ( input )
. unwrap_or_else ( | _ | serde_json ::json! ( { " raw " : input } ) ) ,
} ,
ContentBlock ::ToolResult {
tool_use_id ,
output ,
is_error ,
..
} = > InputContentBlock ::ToolResult {
tool_use_id : tool_use_id . clone ( ) ,
content : vec ! [ ToolResultContentBlock ::Text {
text : output . clone ( ) ,
} ] ,
is_error : * is_error ,
} ,
} )
. collect ::< Vec < _ > > ( ) ;
( ! content . is_empty ( ) ) . then ( | | InputMessage {
role : role . to_string ( ) ,
content ,
} )
} )
. collect ( )
}
fn print_help_to ( out : & mut impl Write ) -> io ::Result < ( ) > {
writeln! ( out , " claw v{VERSION} " ) ? ;
writeln! ( out ) ? ;
writeln! ( out , " Usage: " ) ? ;
writeln! (
out ,
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]] "
) ? ;
writeln! ( out , " Start the interactive REPL " ) ? ;
writeln! (
out ,
" claw [--model MODEL] [--output-format text|json] prompt TEXT "
) ? ;
writeln! ( out , " Send one prompt and exit " ) ? ;
writeln! (
out ,
" claw [--model MODEL] [--output-format text|json] TEXT "
) ? ;
writeln! ( out , " Shorthand non-interactive prompt mode " ) ? ;
writeln! (
out ,
" claw --resume SESSION.json [/status] [/compact] [...] "
) ? ;
writeln! (
out ,
" Inspect or maintain a saved session without entering the REPL "
) ? ;
writeln! ( out , " claw dump-manifests " ) ? ;
writeln! ( out , " claw bootstrap-plan " ) ? ;
writeln! ( out , " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD] " ) ? ;
writeln! ( out , " claw login " ) ? ;
writeln! ( out , " claw logout " ) ? ;
writeln! ( out , " claw init " ) ? ;
writeln! ( out ) ? ;
writeln! ( out , " Flags: " ) ? ;
writeln! (
out ,
" --model MODEL Override the active model "
) ? ;
writeln! (
out ,
" --output-format FORMAT Non-interactive output format: text or json "
) ? ;
writeln! (
out ,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access "
) ? ;
writeln! (
out ,
" --dangerously-skip-permissions Skip all permission checks "
) ? ;
writeln! ( out , " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported) " ) ? ;
writeln! (
out ,
" --version, -V Print version and build information locally "
) ? ;
writeln! ( out ) ? ;
writeln! ( out , " Interactive slash commands: " ) ? ;
writeln! ( out , " {} " , render_slash_command_help ( ) ) ? ;
writeln! ( out ) ? ;
let resume_commands = resume_supported_slash_commands ( )
. into_iter ( )
. map ( | spec | match spec . argument_hint {
Some ( argument_hint ) = > format! ( " / {} {} " , spec . name , argument_hint ) ,
None = > format! ( " / {} " , spec . name ) ,
} )
. collect ::< Vec < _ > > ( )
. join ( " , " ) ;
writeln! ( out , " Resume-safe commands: {resume_commands} " ) ? ;
writeln! ( out , " Examples: " ) ? ;
writeln! ( out , " claw --model claude-opus \" summarize this repo \" " ) ? ;
writeln! (
out ,
" claw --output-format json prompt \" explain src/main.rs \" "
) ? ;
writeln! (
out ,
" claw --allowedTools read,glob \" summarize Cargo.toml \" "
) ? ;
writeln! (
out ,
" claw --resume session.json /status /diff /export notes.txt "
) ? ;
writeln! ( out , " claw login " ) ? ;
writeln! ( out , " claw init " ) ? ;
Ok ( ( ) )
}
fn print_help ( ) {
let _ = print_help_to ( & mut io ::stdout ( ) ) ;
}
#[ cfg(test) ]
mod tests {
use super ::{
filter_tool_specs , format_compact_report , format_cost_report , format_model_report ,
format_model_switch_report , format_permissions_report , format_permissions_switch_report ,
format_resume_report , format_status_report , format_tool_call_start , format_tool_result ,
normalize_permission_mode , parse_args , parse_git_status_metadata , print_help_to ,
push_output_block , render_config_report , render_memory_report , render_repl_help ,
resolve_model_alias , response_to_events , resume_supported_slash_commands , status_context ,
CliAction , CliOutputFormat , SlashCommand , StatusUsage , DEFAULT_MODEL ,
} ;
use api ::{ MessageResponse , OutputContentBlock , Usage } ;
use runtime ::{ AssistantEvent , ContentBlock , ConversationMessage , MessageRole , PermissionMode } ;
use serde_json ::json ;
use std ::path ::PathBuf ;
#[ test ]
fn defaults_to_repl_when_no_args ( ) {
assert_eq! (
parse_args ( & [ ] ) . expect ( " args should parse " ) ,
CliAction ::Repl {
model : DEFAULT_MODEL . to_string ( ) ,
allowed_tools : None ,
permission_mode : PermissionMode ::DangerFullAccess ,
}
) ;
}
#[ test ]
fn parses_prompt_subcommand ( ) {
let args = vec! [
" prompt " . to_string ( ) ,
" hello " . to_string ( ) ,
" world " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::Prompt {
prompt : " hello world " . to_string ( ) ,
model : DEFAULT_MODEL . to_string ( ) ,
output_format : CliOutputFormat ::Text ,
allowed_tools : None ,
permission_mode : PermissionMode ::DangerFullAccess ,
}
) ;
}
#[ test ]
fn parses_bare_prompt_and_json_output_flag ( ) {
let args = vec! [
" --output-format=json " . to_string ( ) ,
" --model " . to_string ( ) ,
" claude-opus " . to_string ( ) ,
" explain " . to_string ( ) ,
" this " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::Prompt {
prompt : " explain this " . to_string ( ) ,
model : " claude-opus " . to_string ( ) ,
output_format : CliOutputFormat ::Json ,
allowed_tools : None ,
permission_mode : PermissionMode ::DangerFullAccess ,
}
) ;
}
#[ test ]
fn resolves_model_aliases_in_args ( ) {
let args = vec! [
" --model " . to_string ( ) ,
" opus " . to_string ( ) ,
" explain " . to_string ( ) ,
" this " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::Prompt {
prompt : " explain this " . to_string ( ) ,
model : " claude-opus-4-6 " . to_string ( ) ,
output_format : CliOutputFormat ::Text ,
allowed_tools : None ,
permission_mode : PermissionMode ::DangerFullAccess ,
}
) ;
}
#[ test ]
fn resolves_known_model_aliases ( ) {
assert_eq! ( resolve_model_alias ( " opus " ) , " claude-opus-4-6 " ) ;
assert_eq! ( resolve_model_alias ( " sonnet " ) , " claude-sonnet-4-6 " ) ;
assert_eq! ( resolve_model_alias ( " haiku " ) , " claude-haiku-4-5-20251213 " ) ;
assert_eq! ( resolve_model_alias ( " claude-opus " ) , " claude-opus " ) ;
}
#[ test ]
fn parses_version_flags_without_initializing_prompt_mode ( ) {
assert_eq! (
parse_args ( & [ " --version " . to_string ( ) ] ) . expect ( " args should parse " ) ,
CliAction ::Version
) ;
assert_eq! (
parse_args ( & [ " -V " . to_string ( ) ] ) . expect ( " args should parse " ) ,
CliAction ::Version
) ;
}
#[ test ]
fn parses_permission_mode_flag ( ) {
let args = vec! [ " --permission-mode=read-only " . to_string ( ) ] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::Repl {
model : DEFAULT_MODEL . to_string ( ) ,
allowed_tools : None ,
permission_mode : PermissionMode ::ReadOnly ,
}
) ;
}
#[ test ]
fn parses_allowed_tools_flags_with_aliases_and_lists ( ) {
let args = vec! [
" --allowedTools " . to_string ( ) ,
" read,glob " . to_string ( ) ,
" --allowed-tools=write_file " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::Repl {
model : DEFAULT_MODEL . to_string ( ) ,
allowed_tools : Some (
[ " glob_search " , " read_file " , " write_file " ]
. into_iter ( )
. map ( str ::to_string )
. collect ( )
) ,
permission_mode : PermissionMode ::DangerFullAccess ,
}
) ;
}
#[ test ]
fn rejects_unknown_allowed_tools ( ) {
let error = parse_args ( & [ " --allowedTools " . to_string ( ) , " teleport " . to_string ( ) ] )
. expect_err ( " tool should be rejected " ) ;
assert! ( error . contains ( " unsupported tool in --allowedTools: teleport " ) ) ;
}
#[ test ]
fn parses_system_prompt_options ( ) {
let args = vec! [
" system-prompt " . to_string ( ) ,
" --cwd " . to_string ( ) ,
" /tmp/project " . to_string ( ) ,
" --date " . to_string ( ) ,
" 2026-04-01 " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::PrintSystemPrompt {
cwd : PathBuf ::from ( " /tmp/project " ) ,
date : " 2026-04-01 " . to_string ( ) ,
}
) ;
}
#[ test ]
fn parses_login_and_logout_subcommands ( ) {
assert_eq! (
parse_args ( & [ " login " . to_string ( ) ] ) . expect ( " login should parse " ) ,
CliAction ::Login
) ;
assert_eq! (
parse_args ( & [ " logout " . to_string ( ) ] ) . expect ( " logout should parse " ) ,
CliAction ::Logout
) ;
assert_eq! (
parse_args ( & [ " init " . to_string ( ) ] ) . expect ( " init should parse " ) ,
CliAction ::Init
) ;
}
#[ test ]
fn parses_resume_flag_with_slash_command ( ) {
let args = vec! [
" --resume " . to_string ( ) ,
" session.json " . to_string ( ) ,
" /compact " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::ResumeSession {
session_path : PathBuf ::from ( " session.json " ) ,
commands : vec ! [ " /compact " . to_string ( ) ] ,
}
) ;
}
#[ test ]
fn parses_resume_flag_with_multiple_slash_commands ( ) {
let args = vec! [
" --resume " . to_string ( ) ,
" session.json " . to_string ( ) ,
" /status " . to_string ( ) ,
" /compact " . to_string ( ) ,
" /cost " . to_string ( ) ,
] ;
assert_eq! (
parse_args ( & args ) . expect ( " args should parse " ) ,
CliAction ::ResumeSession {
session_path : PathBuf ::from ( " session.json " ) ,
commands : vec ! [
" /status " . to_string ( ) ,
" /compact " . to_string ( ) ,
" /cost " . to_string ( ) ,
] ,
}
) ;
}
#[ test ]
fn filtered_tool_specs_respect_allowlist ( ) {
let allowed = [ " read_file " , " grep_search " ]
. into_iter ( )
. map ( str ::to_string )
. collect ( ) ;
2026-04-01 04:30:28 +00:00
let filtered = filter_tool_specs ( & GlobalToolRegistry ::builtin ( ) , Some ( & allowed ) ) ;
2026-04-01 03:55:00 +00:00
let names = filtered
. into_iter ( )
. map ( | spec | spec . name )
. collect ::< Vec < _ > > ( ) ;
assert_eq! ( names , vec! [ " read_file " , " grep_search " ] ) ;
}
#[ test ]
fn shared_help_uses_resume_annotation_copy ( ) {
let help = commands ::render_slash_command_help ( ) ;
assert! ( help . contains ( " Slash commands " ) ) ;
assert! ( help . contains ( " works with --resume SESSION.json " ) ) ;
}
#[ test ]
fn repl_help_includes_shared_commands_and_exit ( ) {
let help = render_repl_help ( ) ;
assert! ( help . contains ( " REPL " ) ) ;
assert! ( help . contains ( " /help " ) ) ;
assert! ( help . contains ( " /status " ) ) ;
assert! ( help . contains ( " /model [model] " ) ) ;
assert! ( help . contains ( " /permissions [read-only|workspace-write|danger-full-access] " ) ) ;
assert! ( help . contains ( " /clear [--confirm] " ) ) ;
assert! ( help . contains ( " /cost " ) ) ;
assert! ( help . contains ( " /resume <session-path> " ) ) ;
2026-04-01 04:30:28 +00:00
assert! ( help . contains ( " /config [env|hooks|model|plugins] " ) ) ;
2026-04-01 03:55:00 +00:00
assert! ( help . contains ( " /memory " ) ) ;
assert! ( help . contains ( " /init " ) ) ;
assert! ( help . contains ( " /diff " ) ) ;
assert! ( help . contains ( " /version " ) ) ;
assert! ( help . contains ( " /export [file] " ) ) ;
assert! ( help . contains ( " /session [list|switch <session-id>] " ) ) ;
2026-04-01 04:30:28 +00:00
assert! ( help . contains (
" /plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>] "
) ) ;
2026-04-01 03:55:00 +00:00
assert! ( help . contains ( " /exit " ) ) ;
}
#[ test ]
fn resume_supported_command_list_matches_expected_surface ( ) {
let names = resume_supported_slash_commands ( )
. into_iter ( )
. map ( | spec | spec . name )
. collect ::< Vec < _ > > ( ) ;
assert_eq! (
names ,
vec! [
" help " , " status " , " compact " , " clear " , " cost " , " config " , " memory " , " init " , " diff " ,
" version " , " export " ,
]
) ;
}
#[ test ]
fn resume_report_uses_sectioned_layout ( ) {
let report = format_resume_report ( " session.json " , 14 , 6 ) ;
assert! ( report . contains ( " Session resumed " ) ) ;
assert! ( report . contains ( " Session file session.json " ) ) ;
assert! ( report . contains ( " Messages 14 " ) ) ;
assert! ( report . contains ( " Turns 6 " ) ) ;
}
#[ test ]
fn compact_report_uses_structured_output ( ) {
let compacted = format_compact_report ( 8 , 5 , false ) ;
assert! ( compacted . contains ( " Compact " ) ) ;
assert! ( compacted . contains ( " Result compacted " ) ) ;
assert! ( compacted . contains ( " Messages removed 8 " ) ) ;
let skipped = format_compact_report ( 0 , 3 , true ) ;
assert! ( skipped . contains ( " Result skipped " ) ) ;
}
#[ test ]
fn cost_report_uses_sectioned_layout ( ) {
let report = format_cost_report ( runtime ::TokenUsage {
input_tokens : 20 ,
output_tokens : 8 ,
cache_creation_input_tokens : 3 ,
cache_read_input_tokens : 1 ,
} ) ;
assert! ( report . contains ( " Cost " ) ) ;
assert! ( report . contains ( " Input tokens 20 " ) ) ;
assert! ( report . contains ( " Output tokens 8 " ) ) ;
assert! ( report . contains ( " Cache create 3 " ) ) ;
assert! ( report . contains ( " Cache read 1 " ) ) ;
assert! ( report . contains ( " Total tokens 32 " ) ) ;
}
#[ test ]
fn permissions_report_uses_sectioned_layout ( ) {
let report = format_permissions_report ( " workspace-write " ) ;
assert! ( report . contains ( " Permissions " ) ) ;
assert! ( report . contains ( " Active mode workspace-write " ) ) ;
assert! ( report . contains ( " Modes " ) ) ;
assert! ( report . contains ( " read-only ○ available Read/search tools only " ) ) ;
assert! ( report . contains ( " workspace-write ● current Edit files inside the workspace " ) ) ;
assert! ( report . contains ( " danger-full-access ○ available Unrestricted tool access " ) ) ;
}
#[ test ]
fn permissions_switch_report_is_structured ( ) {
let report = format_permissions_switch_report ( " read-only " , " workspace-write " ) ;
assert! ( report . contains ( " Permissions updated " ) ) ;
assert! ( report . contains ( " Result mode switched " ) ) ;
assert! ( report . contains ( " Previous mode read-only " ) ) ;
assert! ( report . contains ( " Active mode workspace-write " ) ) ;
assert! ( report . contains ( " Applies to subsequent tool calls " ) ) ;
}
#[ test ]
fn init_help_mentions_direct_subcommand ( ) {
let mut help = Vec ::new ( ) ;
print_help_to ( & mut help ) . expect ( " help should render " ) ;
let help = String ::from_utf8 ( help ) . expect ( " help should be utf8 " ) ;
assert! ( help . contains ( " claw init " ) ) ;
}
#[ test ]
fn model_report_uses_sectioned_layout ( ) {
let report = format_model_report ( " claude-sonnet " , 12 , 4 ) ;
assert! ( report . contains ( " Model " ) ) ;
assert! ( report . contains ( " Current model claude-sonnet " ) ) ;
assert! ( report . contains ( " Session messages 12 " ) ) ;
assert! ( report . contains ( " Switch models with /model <name> " ) ) ;
}
#[ test ]
fn model_switch_report_preserves_context_summary ( ) {
let report = format_model_switch_report ( " claude-sonnet " , " claude-opus " , 9 ) ;
assert! ( report . contains ( " Model updated " ) ) ;
assert! ( report . contains ( " Previous claude-sonnet " ) ) ;
assert! ( report . contains ( " Current claude-opus " ) ) ;
assert! ( report . contains ( " Preserved msgs 9 " ) ) ;
}
#[ test ]
fn status_line_reports_model_and_token_totals ( ) {
let status = format_status_report (
" claude-sonnet " ,
StatusUsage {
message_count : 7 ,
turns : 3 ,
latest : runtime ::TokenUsage {
input_tokens : 5 ,
output_tokens : 4 ,
cache_creation_input_tokens : 1 ,
cache_read_input_tokens : 0 ,
} ,
cumulative : runtime ::TokenUsage {
input_tokens : 20 ,
output_tokens : 8 ,
cache_creation_input_tokens : 2 ,
cache_read_input_tokens : 1 ,
} ,
estimated_tokens : 128 ,
} ,
" workspace-write " ,
& super ::StatusContext {
cwd : PathBuf ::from ( " /tmp/project " ) ,
session_path : Some ( PathBuf ::from ( " session.json " ) ) ,
loaded_config_files : 2 ,
discovered_config_files : 3 ,
memory_file_count : 4 ,
project_root : Some ( PathBuf ::from ( " /tmp " ) ) ,
git_branch : Some ( " main " . to_string ( ) ) ,
} ,
) ;
assert! ( status . contains ( " Status " ) ) ;
assert! ( status . contains ( " Model claude-sonnet " ) ) ;
assert! ( status . contains ( " Permission mode workspace-write " ) ) ;
assert! ( status . contains ( " Messages 7 " ) ) ;
assert! ( status . contains ( " Latest total 10 " ) ) ;
assert! ( status . contains ( " Cumulative total 31 " ) ) ;
assert! ( status . contains ( " Cwd /tmp/project " ) ) ;
assert! ( status . contains ( " Project root /tmp " ) ) ;
assert! ( status . contains ( " Git branch main " ) ) ;
assert! ( status . contains ( " Session session.json " ) ) ;
assert! ( status . contains ( " Config files loaded 2/3 " ) ) ;
assert! ( status . contains ( " Memory files 4 " ) ) ;
}
#[ test ]
fn config_report_supports_section_views ( ) {
let report = render_config_report ( Some ( " env " ) ) . expect ( " config report should render " ) ;
assert! ( report . contains ( " Merged section: env " ) ) ;
2026-04-01 04:30:28 +00:00
let plugins_report =
render_config_report ( Some ( " plugins " ) ) . expect ( " plugins config report should render " ) ;
assert! ( plugins_report . contains ( " Merged section: plugins " ) ) ;
2026-04-01 03:55:00 +00:00
}
#[ test ]
fn memory_report_uses_sectioned_layout ( ) {
let report = render_memory_report ( ) . expect ( " memory report should render " ) ;
assert! ( report . contains ( " Memory " ) ) ;
assert! ( report . contains ( " Working directory " ) ) ;
assert! ( report . contains ( " Instruction files " ) ) ;
assert! ( report . contains ( " Discovered files " ) ) ;
}
#[ test ]
fn config_report_uses_sectioned_layout ( ) {
let report = render_config_report ( None ) . expect ( " config report should render " ) ;
assert! ( report . contains ( " Config " ) ) ;
assert! ( report . contains ( " Discovered files " ) ) ;
assert! ( report . contains ( " Merged JSON " ) ) ;
}
#[ test ]
fn parses_git_status_metadata ( ) {
let ( root , branch ) = parse_git_status_metadata ( Some (
" ## rcc/cli...origin/rcc/cli
M src / main . rs " ,
) ) ;
assert_eq! ( branch . as_deref ( ) , Some ( " rcc/cli " ) ) ;
let _ = root ;
}
#[ test ]
fn status_context_reads_real_workspace_metadata ( ) {
let context = status_context ( None ) . expect ( " status context should load " ) ;
assert! ( context . cwd . is_absolute ( ) ) ;
assert_eq! ( context . discovered_config_files , 5 ) ;
assert! ( context . loaded_config_files < = context . discovered_config_files ) ;
}
#[ test ]
fn normalizes_supported_permission_modes ( ) {
assert_eq! ( normalize_permission_mode ( " read-only " ) , Some ( " read-only " ) ) ;
assert_eq! (
normalize_permission_mode ( " workspace-write " ) ,
Some ( " workspace-write " )
) ;
assert_eq! (
normalize_permission_mode ( " danger-full-access " ) ,
Some ( " danger-full-access " )
) ;
assert_eq! ( normalize_permission_mode ( " unknown " ) , None ) ;
}
#[ test ]
fn clear_command_requires_explicit_confirmation_flag ( ) {
assert_eq! (
SlashCommand ::parse ( " /clear " ) ,
Some ( SlashCommand ::Clear { confirm : false } )
) ;
assert_eq! (
SlashCommand ::parse ( " /clear --confirm " ) ,
Some ( SlashCommand ::Clear { confirm : true } )
) ;
}
#[ test ]
fn parses_resume_and_config_slash_commands ( ) {
assert_eq! (
SlashCommand ::parse ( " /resume saved-session.json " ) ,
Some ( SlashCommand ::Resume {
session_path : Some ( " saved-session.json " . to_string ( ) )
} )
) ;
assert_eq! (
SlashCommand ::parse ( " /clear --confirm " ) ,
Some ( SlashCommand ::Clear { confirm : true } )
) ;
assert_eq! (
SlashCommand ::parse ( " /config " ) ,
Some ( SlashCommand ::Config { section : None } )
) ;
assert_eq! (
SlashCommand ::parse ( " /config env " ) ,
Some ( SlashCommand ::Config {
section : Some ( " env " . to_string ( ) )
} )
) ;
assert_eq! ( SlashCommand ::parse ( " /memory " ) , Some ( SlashCommand ::Memory ) ) ;
assert_eq! ( SlashCommand ::parse ( " /init " ) , Some ( SlashCommand ::Init ) ) ;
}
#[ test ]
fn init_template_mentions_detected_rust_workspace ( ) {
let rendered = crate ::init ::render_init_claude_md ( std ::path ::Path ::new ( " . " ) ) ;
assert! ( rendered . contains ( " # CLAUDE.md " ) ) ;
assert! ( rendered . contains ( " cargo clippy --workspace --all-targets -- -D warnings " ) ) ;
}
#[ test ]
fn converts_tool_roundtrip_messages ( ) {
let messages = vec! [
ConversationMessage ::user_text ( " hello " ) ,
ConversationMessage ::assistant ( vec! [ ContentBlock ::ToolUse {
id : " tool-1 " . to_string ( ) ,
name : " bash " . to_string ( ) ,
input : " { \" command \" : \" pwd \" } " . to_string ( ) ,
} ] ) ,
ConversationMessage {
role : MessageRole ::Tool ,
blocks : vec ! [ ContentBlock ::ToolResult {
tool_use_id : " tool-1 " . to_string ( ) ,
tool_name : " bash " . to_string ( ) ,
output : " ok " . to_string ( ) ,
is_error : false ,
} ] ,
usage : None ,
} ,
] ;
let converted = super ::convert_messages ( & messages ) ;
assert_eq! ( converted . len ( ) , 3 ) ;
assert_eq! ( converted [ 1 ] . role , " assistant " ) ;
assert_eq! ( converted [ 2 ] . role , " user " ) ;
}
#[ test ]
fn repl_help_mentions_history_completion_and_multiline ( ) {
let help = render_repl_help ( ) ;
assert! ( help . contains ( " Up/Down " ) ) ;
assert! ( help . contains ( " Tab " ) ) ;
assert! ( help . contains ( " Shift+Enter/Ctrl+J " ) ) ;
}
#[ test ]
fn tool_rendering_helpers_compact_output ( ) {
let start = format_tool_call_start ( " read_file " , r # "{"path":"src/main.rs"}"# ) ;
assert! ( start . contains ( " read_file " ) ) ;
assert! ( start . contains ( " src/main.rs " ) ) ;
let done = format_tool_result (
" read_file " ,
r # "{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"# ,
false ,
) ;
assert! ( done . contains ( " 📄 Read src/main.rs " ) ) ;
assert! ( done . contains ( " hello " ) ) ;
}
#[ test ]
fn push_output_block_renders_markdown_text ( ) {
let mut out = Vec ::new ( ) ;
let mut events = Vec ::new ( ) ;
let mut pending_tool = None ;
push_output_block (
OutputContentBlock ::Text {
text : " # Heading " . to_string ( ) ,
} ,
& mut out ,
& mut events ,
& mut pending_tool ,
false ,
)
. expect ( " text block should render " ) ;
let rendered = String ::from_utf8 ( out ) . expect ( " utf8 " ) ;
assert! ( rendered . contains ( " Heading " ) ) ;
assert! ( rendered . contains ( '\u{1b}' ) ) ;
}
#[ test ]
fn push_output_block_skips_empty_object_prefix_for_tool_streams ( ) {
let mut out = Vec ::new ( ) ;
let mut events = Vec ::new ( ) ;
let mut pending_tool = None ;
push_output_block (
OutputContentBlock ::ToolUse {
id : " tool-1 " . to_string ( ) ,
name : " read_file " . to_string ( ) ,
input : json ! ( { } ) ,
} ,
& mut out ,
& mut events ,
& mut pending_tool ,
true ,
)
. expect ( " tool block should accumulate " ) ;
assert! ( events . is_empty ( ) ) ;
assert_eq! (
pending_tool ,
Some ( ( " tool-1 " . to_string ( ) , " read_file " . to_string ( ) , String ::new ( ) , ) )
) ;
}
#[ test ]
fn response_to_events_preserves_empty_object_json_input_outside_streaming ( ) {
let mut out = Vec ::new ( ) ;
let events = response_to_events (
MessageResponse {
id : " msg-1 " . to_string ( ) ,
kind : " message " . to_string ( ) ,
model : " claude-opus-4-6 " . to_string ( ) ,
role : " assistant " . to_string ( ) ,
content : vec ! [ OutputContentBlock ::ToolUse {
id : " tool-1 " . to_string ( ) ,
name : " read_file " . to_string ( ) ,
input : json ! ( { } ) ,
} ] ,
stop_reason : Some ( " tool_use " . to_string ( ) ) ,
stop_sequence : None ,
usage : Usage {
input_tokens : 1 ,
output_tokens : 1 ,
cache_creation_input_tokens : 0 ,
cache_read_input_tokens : 0 ,
} ,
request_id : None ,
} ,
& mut out ,
)
. expect ( " response conversion should succeed " ) ;
assert! ( matches! (
& events [ 0 ] ,
AssistantEvent ::ToolUse { name , input , .. }
if name = = " read_file " & & input = = " {} "
) ) ;
}
#[ test ]
fn response_to_events_preserves_non_empty_json_input_outside_streaming ( ) {
let mut out = Vec ::new ( ) ;
let events = response_to_events (
MessageResponse {
id : " msg-2 " . to_string ( ) ,
kind : " message " . to_string ( ) ,
model : " claude-opus-4-6 " . to_string ( ) ,
role : " assistant " . to_string ( ) ,
content : vec ! [ OutputContentBlock ::ToolUse {
id : " tool-2 " . to_string ( ) ,
name : " read_file " . to_string ( ) ,
input : json ! ( { " path " : " rust/Cargo.toml " } ) ,
} ] ,
stop_reason : Some ( " tool_use " . to_string ( ) ) ,
stop_sequence : None ,
usage : Usage {
input_tokens : 1 ,
output_tokens : 1 ,
cache_creation_input_tokens : 0 ,
cache_read_input_tokens : 0 ,
} ,
request_id : None ,
} ,
& mut out ,
)
. expect ( " response conversion should succeed " ) ;
assert! ( matches! (
& events [ 0 ] ,
AssistantEvent ::ToolUse { name , input , .. }
if name = = " read_file " & & input = = " { \" path \" : \" rust/Cargo.toml \" } "
) ) ;
}
}