| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357 |
- mod input;
- mod render;
- use std::collections::{BTreeMap, BTreeSet};
- use std::env;
- use std::fmt::Write as _;
- use std::fs;
- use std::io::{self, Read, Write};
- use std::net::TcpListener;
- use std::path::{Path, PathBuf};
- use std::process::Command;
- use std::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 render::{Spinner, TerminalRenderer};
- use reqwest::blocking::Client;
- 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,
- OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
- Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
- };
- use serde::Deserialize;
- use serde_json::json;
- use sha2::{Digest, Sha256};
- use tools::{execute_tool, mvp_tool_specs, ToolSpec};
- const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
- const DEFAULT_MAX_TOKENS: u32 = 32;
- const DEFAULT_DATE: &str = "2026-03-31";
- const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
- const VERSION: &str = env!("CARGO_PKG_VERSION");
- const BUILD_TARGET: Option<&str> = option_env!("TARGET");
- const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
- const SELF_UPDATE_REPOSITORY: &str = "instructkr/clawd-code";
- const SELF_UPDATE_LATEST_RELEASE_URL: &str =
- "https://api.github.com/repos/instructkr/clawd-code/releases/latest";
- const SELF_UPDATE_USER_AGENT: &str = "rusty-claude-cli-self-update";
- const CHECKSUM_ASSET_CANDIDATES: &[&str] = &[
- "SHA256SUMS",
- "SHA256SUMS.txt",
- "sha256sums",
- "sha256sums.txt",
- "checksums.txt",
- "checksums.sha256",
- ];
- type AllowedToolSet = BTreeSet<String>;
- fn main() {
- if let Err(error) = run() {
- eprintln!(
- "error: {error}
- Run `rusty-claude-cli --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::SelfUpdate => run_self_update()?,
- CliAction::ResumeSession {
- session_path,
- commands,
- } => resume_session(&session_path, &commands),
- CliAction::Prompt {
- prompt,
- model,
- output_format,
- allowed_tools,
- permission_mode,
- } => LiveCli::new(model, false, allowed_tools, permission_mode)?
- .run_turn_with_output(&prompt, output_format)?,
- CliAction::Login => run_login()?,
- CliAction::Logout => run_logout()?,
- 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,
- SelfUpdate,
- ResumeSession {
- session_path: PathBuf,
- commands: Vec<String>,
- },
- Prompt {
- prompt: String,
- model: String,
- output_format: CliOutputFormat,
- allowed_tools: Option<AllowedToolSet>,
- permission_mode: PermissionMode,
- },
- Login,
- Logout,
- 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.clone_from(value);
- index += 2;
- }
- flag if flag.starts_with("--model=") => {
- model = 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;
- }
- "--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..]),
- "self-update" => Ok(CliAction::SelfUpdate),
- "login" => Ok(CliAction::Login),
- "logout" => Ok(CliAction::Logout),
- "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 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::WorkspaceWrite, 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 run_login() -> Result<(), Box<dyn std::error::Error>> {
- let cwd = env::current_dir()?;
- let config = ConfigLoader::default_for(&cwd).load()?;
- let oauth = config.oauth().ok_or_else(|| {
- io::Error::new(
- io::ErrorKind::NotFound,
- "OAuth config is missing. Add settings.oauth.clientId/authorizeUrl/tokenUrl first.",
- )
- })?;
- 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);
- 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\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
- body.len(),
- body
- );
- stream.write_all(response.as_bytes())?;
- Ok(callback)
- }
- fn print_system_prompt(cwd: PathBuf, date: String) {
- match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
- Ok(sections) => println!("{}", sections.join("\n\n")),
- Err(error) => {
- eprintln!("failed to build system prompt: {error}");
- std::process::exit(1);
- }
- }
- }
- fn print_version() {
- println!("{}", render_version_report());
- }
- #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
- struct GitHubRelease {
- tag_name: String,
- #[serde(default)]
- body: String,
- #[serde(default)]
- assets: Vec<GitHubReleaseAsset>,
- }
- #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
- struct GitHubReleaseAsset {
- name: String,
- browser_download_url: String,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- struct SelectedReleaseAssets {
- binary: GitHubReleaseAsset,
- checksum: GitHubReleaseAsset,
- }
- fn run_self_update() -> Result<(), Box<dyn std::error::Error>> {
- let Some(release) = fetch_latest_release()? else {
- println!(
- "{}",
- render_update_report(
- "No published release available",
- Some(VERSION),
- None,
- Some("GitHub latest release endpoint returned no published release for instructkr/clawd-code."),
- None,
- )
- );
- return Ok(());
- };
- let latest_version = normalize_version_tag(&release.tag_name);
- if !is_newer_version(VERSION, &latest_version) {
- println!(
- "{}",
- render_update_report(
- "Already up to date",
- Some(VERSION),
- Some(&latest_version),
- Some("Current binary already matches the latest published release."),
- Some(&release.body),
- )
- );
- return Ok(());
- }
- let selected = match select_release_assets(&release) {
- Ok(selected) => selected,
- Err(message) => {
- println!(
- "{}",
- render_update_report(
- "Release found, but no installable asset matched this platform",
- Some(VERSION),
- Some(&latest_version),
- Some(&message),
- Some(&release.body),
- )
- );
- return Ok(());
- }
- };
- let client = build_self_update_client()?;
- let binary_bytes = download_bytes(&client, &selected.binary.browser_download_url)?;
- let checksum_manifest = download_text(&client, &selected.checksum.browser_download_url)?;
- let expected_checksum = parse_checksum_for_asset(&checksum_manifest, &selected.binary.name)
- .ok_or_else(|| {
- format!(
- "checksum manifest did not contain an entry for {}",
- selected.binary.name
- )
- })?;
- let actual_checksum = sha256_hex(&binary_bytes);
- if actual_checksum != expected_checksum {
- return Err(format!(
- "downloaded asset checksum mismatch for {} (expected {}, got {})",
- selected.binary.name, expected_checksum, actual_checksum
- )
- .into());
- }
- replace_current_executable(&binary_bytes)?;
- println!(
- "{}",
- render_update_report(
- "Update installed",
- Some(VERSION),
- Some(&latest_version),
- Some(&format!(
- "Installed {} from GitHub release assets for {}.",
- selected.binary.name,
- current_target()
- )),
- Some(&release.body),
- )
- );
- Ok(())
- }
- fn fetch_latest_release() -> Result<Option<GitHubRelease>, Box<dyn std::error::Error>> {
- let client = build_self_update_client()?;
- let response = client
- .get(SELF_UPDATE_LATEST_RELEASE_URL)
- .header(reqwest::header::ACCEPT, "application/vnd.github+json")
- .send()?;
- if response.status() == reqwest::StatusCode::NOT_FOUND {
- return Ok(None);
- }
- let response = response.error_for_status()?;
- Ok(Some(response.json()?))
- }
- fn build_self_update_client() -> Result<Client, reqwest::Error> {
- Client::builder().user_agent(SELF_UPDATE_USER_AGENT).build()
- }
- fn download_bytes(client: &Client, url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
- let response = client.get(url).send()?.error_for_status()?;
- Ok(response.bytes()?.to_vec())
- }
- fn download_text(client: &Client, url: &str) -> Result<String, Box<dyn std::error::Error>> {
- let response = client.get(url).send()?.error_for_status()?;
- Ok(response.text()?)
- }
- fn normalize_version_tag(version: &str) -> String {
- version.trim().trim_start_matches('v').to_string()
- }
- fn is_newer_version(current: &str, latest: &str) -> bool {
- compare_versions(latest, current).is_gt()
- }
- fn current_target() -> String {
- BUILD_TARGET.map_or_else(default_target_triple, str::to_string)
- }
- fn release_asset_candidates() -> Vec<String> {
- let mut candidates = target_name_candidates()
- .into_iter()
- .flat_map(|target| {
- let mut names = vec![format!("rusty-claude-cli-{target}")];
- if env::consts::OS == "windows" {
- names.push(format!("rusty-claude-cli-{target}.exe"));
- }
- names
- })
- .collect::<Vec<_>>();
- if env::consts::OS == "windows" {
- candidates.push("rusty-claude-cli.exe".to_string());
- }
- candidates.push("rusty-claude-cli".to_string());
- candidates.sort();
- candidates.dedup();
- candidates
- }
- fn select_release_assets(release: &GitHubRelease) -> Result<SelectedReleaseAssets, String> {
- let binary = release_asset_candidates()
- .into_iter()
- .find_map(|candidate| {
- release
- .assets
- .iter()
- .find(|asset| asset.name == candidate)
- .cloned()
- })
- .ok_or_else(|| {
- format!(
- "no binary asset matched target {} (expected one of: {})",
- current_target(),
- release_asset_candidates().join(", ")
- )
- })?;
- let checksum = CHECKSUM_ASSET_CANDIDATES
- .iter()
- .find_map(|candidate| {
- release
- .assets
- .iter()
- .find(|asset| asset.name == *candidate)
- .cloned()
- })
- .ok_or_else(|| {
- format!(
- "release did not include a checksum manifest (expected one of: {})",
- CHECKSUM_ASSET_CANDIDATES.join(", ")
- )
- })?;
- Ok(SelectedReleaseAssets { binary, checksum })
- }
- fn parse_checksum_for_asset(manifest: &str, asset_name: &str) -> Option<String> {
- manifest.lines().find_map(|line| {
- let trimmed = line.trim();
- if trimmed.is_empty() {
- return None;
- }
- if let Some((left, right)) = trimmed.split_once(" = ") {
- return left
- .strip_prefix("SHA256 (")
- .and_then(|value| value.strip_suffix(')'))
- .filter(|file| *file == asset_name)
- .map(|_| right.to_ascii_lowercase());
- }
- let mut parts = trimmed.split_whitespace();
- let checksum = parts.next()?;
- let file = parts
- .next_back()
- .or_else(|| parts.next())?
- .trim_start_matches('*');
- (file == asset_name).then(|| checksum.to_ascii_lowercase())
- })
- }
- fn sha256_hex(bytes: &[u8]) -> String {
- format!("{:x}", Sha256::digest(bytes))
- }
- fn replace_current_executable(binary_bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
- let current = env::current_exe()?;
- replace_executable_at(¤t, binary_bytes)
- }
- fn replace_executable_at(
- current: &Path,
- binary_bytes: &[u8],
- ) -> Result<(), Box<dyn std::error::Error>> {
- let temp_path = current.with_extension("download");
- let backup_path = current.with_extension("bak");
- if backup_path.exists() {
- fs::remove_file(&backup_path)?;
- }
- fs::write(&temp_path, binary_bytes)?;
- copy_executable_permissions(current, &temp_path)?;
- fs::rename(current, &backup_path)?;
- if let Err(error) = fs::rename(&temp_path, current) {
- let _ = fs::rename(&backup_path, current);
- let _ = fs::remove_file(&temp_path);
- return Err(format!("failed to replace current executable: {error}").into());
- }
- if let Err(error) = fs::remove_file(&backup_path) {
- eprintln!(
- "warning: failed to remove self-update backup {}: {error}",
- backup_path.display()
- );
- }
- Ok(())
- }
- #[cfg(unix)]
- fn copy_executable_permissions(
- source: &Path,
- destination: &Path,
- ) -> Result<(), Box<dyn std::error::Error>> {
- use std::os::unix::fs::PermissionsExt;
- let mode = fs::metadata(source)?.permissions().mode();
- fs::set_permissions(destination, fs::Permissions::from_mode(mode))?;
- Ok(())
- }
- #[cfg(not(unix))]
- fn copy_executable_permissions(
- _source: &Path,
- _destination: &Path,
- ) -> Result<(), Box<dyn std::error::Error>> {
- Ok(())
- }
- fn render_update_report(
- result: &str,
- current_version: Option<&str>,
- latest_version: Option<&str>,
- detail: Option<&str>,
- changelog: Option<&str>,
- ) -> String {
- let mut report = String::from(
- "Self-update
- ",
- );
- let _ = writeln!(report, " Repository {SELF_UPDATE_REPOSITORY}");
- let _ = writeln!(report, " Result {result}");
- if let Some(current_version) = current_version {
- let _ = writeln!(report, " Current version {current_version}");
- }
- if let Some(latest_version) = latest_version {
- let _ = writeln!(report, " Latest version {latest_version}");
- }
- if let Some(detail) = detail {
- let _ = writeln!(report, " Detail {detail}");
- }
- let trimmed = changelog.map(str::trim).filter(|value| !value.is_empty());
- if let Some(changelog) = trimmed {
- report.push_str(
- "
- Changelog
- ",
- );
- report.push_str(changelog);
- }
- report.trim_end().to_string()
- }
- fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
- let left = normalize_version_tag(left);
- let right = normalize_version_tag(right);
- let left_parts = version_components(&left);
- let right_parts = version_components(&right);
- let max_len = left_parts.len().max(right_parts.len());
- for index in 0..max_len {
- let left_part = *left_parts.get(index).unwrap_or(&0);
- let right_part = *right_parts.get(index).unwrap_or(&0);
- match left_part.cmp(&right_part) {
- std::cmp::Ordering::Equal => {}
- ordering => return ordering,
- }
- }
- std::cmp::Ordering::Equal
- }
- fn version_components(version: &str) -> Vec<u64> {
- version
- .split(['.', '-'])
- .map(|part| {
- part.chars()
- .take_while(char::is_ascii_digit)
- .collect::<String>()
- })
- .filter(|part| !part.is_empty())
- .filter_map(|part| part.parse::<u64>().ok())
- .collect()
- }
- fn default_target_triple() -> String {
- let os = match env::consts::OS {
- "linux" => "unknown-linux-gnu",
- "macos" => "apple-darwin",
- "windows" => "pc-windows-msvc",
- other => other,
- };
- format!("{}-{os}", env::consts::ARCH)
- }
- fn target_name_candidates() -> Vec<String> {
- let mut candidates = Vec::new();
- if let Some(target) = BUILD_TARGET {
- candidates.push(target.to_string());
- }
- candidates.push(default_target_triple());
- candidates.push(format!("{}-{}", env::consts::ARCH, env::consts::OS));
- candidates
- }
- 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_init_report(path: &Path, created: bool) -> String {
- if created {
- format!(
- "Init
- CLAUDE.md {}
- Result created
- Next step Review and tailor the generated guidance",
- path.display()
- )
- } else {
- format!(
- "Init
- CLAUDE.md {}
- Result skipped (already exists)
- Next step Edit the existing file intentionally if workflows changed",
- path.display()
- )
- }
- }
- fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
- if skipped {
- format!(
- "Compact
- Result skipped
- Reason session below compaction threshold
- Messages kept {resulting_messages}"
- )
- } else {
- format!(
- "Compact
- Result compacted
- Messages removed {removed}
- Messages kept {resulting_messages}"
- )
- }
- }
- fn parse_git_status_metadata(status: Option<&str>) -> (Option<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::Resume { .. }
- | SlashCommand::Model { .. }
- | SlashCommand::Permissions { .. }
- | SlashCommand::Session { .. }
- | 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,
- 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 {
- format!(
- "Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
- self.model,
- self.permission_mode.as_str(),
- env::current_dir().map_or_else(
- |_| "<unknown>".to_string(),
- |path| path.display().to_string(),
- ),
- 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(
- "Waiting for Claude",
- TerminalRenderer::new().color_theme(),
- &mut stdout,
- )?;
- let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
- let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
- match result {
- Ok(_) => {
- spinner.finish(
- "Claude response complete",
- TerminalRenderer::new().color_theme(),
- &mut stdout,
- )?;
- println!();
- self.persist_session()?;
- Ok(())
- }
- Err(error) => {
- spinner.fail(
- "Claude 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 client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
- let request = MessageRequest {
- model: self.model.clone(),
- max_tokens: DEFAULT_MAX_TOKENS,
- messages: vec![InputMessage {
- role: "user".to_string(),
- content: vec![InputContentBlock::Text {
- text: input.to_string(),
- }],
- }],
- system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")),
- tools: None,
- tool_choice: None,
- stream: false,
- };
- let runtime = tokio::runtime::Runtime::new()?;
- let response = runtime.block_on(client.send_message(&request))?;
- let text = response
- .content
- .iter()
- .filter_map(|block| match block {
- OutputContentBlock::Text { text } => Some(text.as_str()),
- OutputContentBlock::ToolUse { .. } => None,
- })
- .collect::<Vec<_>>()
- .join("");
- println!(
- "{}",
- json!({
- "message": text,
- "model": self.model,
- "usage": {
- "input_tokens": response.usage.input_tokens,
- "output_tokens": response.usage.output_tokens,
- "cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
- "cache_read_input_tokens": response.usage.cache_read_input_tokens,
- }
- })
- );
- Ok(())
- }
- 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::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 => {
- Self::run_init()?;
- false
- }
- SlashCommand::Diff => {
- Self::print_diff()?;
- false
- }
- SlashCommand::Version => {
- Self::print_version();
- false
- }
- SlashCommand::Export { path } => {
- self.export_session(path.as_deref())?;
- false
- }
- SlashCommand::Session { action, target } => {
- self.handle_session_command(action.as_deref(), target.as_deref())?
- }
- SlashCommand::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);
- };
- 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,
- 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,
- 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,
- 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,
- 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 run_init() -> Result<(), Box<dyn std::error::Error>> {
- println!("{}", init_claude_md()?);
- 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,
- 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)
- }
- }
- }
- 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,
- self.allowed_tools.clone(),
- self.permission_mode,
- )?;
- self.persist_session()?;
- println!("{}", format_compact_report(removed, kept, skipped));
- 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(
- "
- ",
- )
- }
- 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"),
- other => {
- lines.push(format!(
- " Unsupported config section '{other}'. Use env, hooks, or model."
- ));
- 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()?;
- let claude_md = cwd.join("CLAUDE.md");
- if claude_md.exists() {
- return Ok(format_init_report(&claude_md, false));
- }
- let content = render_init_claude_md(&cwd);
- fs::write(&claude_md, content)?;
- Ok(format_init_report(&claude_md, true))
- }
- fn render_init_claude_md(cwd: &Path) -> String {
- let mut lines = vec![
- "# CLAUDE.md".to_string(),
- String::new(),
- "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
- String::new(),
- ];
- let mut command_lines = Vec::new();
- if cwd.join("rust").join("Cargo.toml").is_file() {
- command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
- } else if cwd.join("Cargo.toml").is_file() {
- command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
- }
- if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
- command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
- }
- if !command_lines.is_empty() {
- lines.push("## Verification".to_string());
- lines.extend(command_lines);
- lines.push(String::new());
- }
- let mut structure_lines = Vec::new();
- if cwd.join("rust").is_dir() {
- structure_lines.push(
- "- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
- .to_string(),
- );
- }
- if cwd.join("src").is_dir() {
- structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
- }
- if cwd.join("tests").is_dir() {
- structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
- }
- if !structure_lines.is_empty() {
- lines.push("## Repository shape".to_string());
- lines.extend(structure_lines);
- lines.push(String::new());
- }
- lines.push("## Working agreement".to_string());
- lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
- lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
- lines.push(String::new());
- lines.join(
- "
- ",
- )
- }
- 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_version_report() -> String {
- let git_sha = GIT_SHA.unwrap_or("unknown");
- let target = BUILD_TARGET.unwrap_or("unknown");
- format!(
- "Version\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(
- session: Session,
- model: String,
- system_prompt: Vec<String>,
- enable_tools: bool,
- allowed_tools: Option<AllowedToolSet>,
- permission_mode: PermissionMode,
- ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
- {
- Ok(ConversationRuntime::new(
- session,
- AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?,
- CliToolExecutor::new(allowed_tools),
- permission_policy(permission_mode),
- system_prompt,
- ))
- }
- 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,
- allowed_tools: Option<AllowedToolSet>,
- }
- impl AnthropicRuntimeClient {
- fn new(
- model: String,
- enable_tools: 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()?),
- model,
- enable_tools,
- 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: DEFAULT_MAX_TOKENS,
- 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 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, &mut stdout, &mut events, &mut pending_tool)?;
- }
- }
- ApiStreamEvent::ContentBlockStart(start) => {
- push_output_block(
- start.content_block,
- &mut stdout,
- &mut events,
- &mut pending_tool,
- )?;
- }
- ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
- ContentBlockDelta::TextDelta { text } => {
- if !text.is_empty() {
- write!(stdout, "{text}")
- .and_then(|()| stdout.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((id, name, input)) = pending_tool.take() {
- 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;
- 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, &mut stdout)
- })
- }
- }
- 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 {
- format!(
- "Tool call
- Name {name}
- Input {}",
- summarize_tool_payload(input)
- )
- }
- fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
- let status = if is_error { "error" } else { "ok" };
- format!(
- "### Tool `{name}`
- - Status: {status}
- - Output:
- ```json
- {}
- ```
- ",
- prettify_tool_payload(output)
- )
- }
- 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 prettify_tool_payload(payload: &str) -> String {
- match serde_json::from_str::<serde_json::Value>(payload) {
- Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()),
- Err(_) => payload.to_string(),
- }
- }
- 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,
- events: &mut Vec<AssistantEvent>,
- pending_tool: &mut Option<(String, String, String)>,
- ) -> Result<(), RuntimeError> {
- match block {
- OutputContentBlock::Text { text } => {
- if !text.is_empty() {
- write!(out, "{text}")
- .and_then(|()| out.flush())
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- events.push(AssistantEvent::TextDelta(text));
- }
- }
- OutputContentBlock::ToolUse { id, name, input } => {
- writeln!(
- out,
- "
- {}",
- format_tool_call_start(&name, &input.to_string())
- )
- .and_then(|()| out.flush())
- .map_err(|error| RuntimeError::new(error.to_string()))?;
- *pending_tool = Some((id, name, input.to_string()));
- }
- }
- Ok(())
- }
- fn response_to_events(
- response: MessageResponse,
- out: &mut impl Write,
- ) -> 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)?;
- 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,
- allowed_tools: Option<AllowedToolSet>,
- }
- impl CliToolExecutor {
- fn new(allowed_tools: Option<AllowedToolSet>) -> Self {
- Self {
- renderer: TerminalRenderer::new(),
- 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) => {
- 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) => {
- 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() {
- println!("rusty-claude-cli v{VERSION}");
- println!();
- println!("Usage:");
- println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
- println!(" Start the interactive REPL");
- println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
- println!(" Send one prompt and exit");
- println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
- println!(" Shorthand non-interactive prompt mode");
- println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
- println!(" Inspect or maintain a saved session without entering the REPL");
- println!(" rusty-claude-cli dump-manifests");
- println!(" rusty-claude-cli bootstrap-plan");
- println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
- println!(" rusty-claude-cli login");
- println!(" rusty-claude-cli logout");
- println!(" rusty-claude-cli self-update");
- println!(" Update the installed binary from the latest GitHub release");
- println!();
- println!("Flags:");
- println!(" --model MODEL Override the active model");
- println!(" --output-format FORMAT Non-interactive output format: text or json");
- println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access");
- println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
- println!(" --version, -V Print version and build information locally");
- println!();
- println!("Interactive slash commands:");
- println!("{}", render_slash_command_help());
- println!();
- 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(", ");
- println!("Resume-safe commands: {resume_commands}");
- println!("Examples:");
- println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
- println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
- println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\"");
- println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
- println!(" rusty-claude-cli login");
- println!(" rusty-claude-cli self-update");
- }
- #[cfg(test)]
- mod tests {
- use super::{
- filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
- format_model_report, format_model_switch_report, format_permissions_report,
- format_permissions_switch_report, format_resume_report, format_status_report,
- format_tool_call_start, format_tool_result, is_newer_version, normalize_permission_mode,
- normalize_version_tag, parse_args, parse_checksum_for_asset, parse_git_status_metadata,
- render_config_report, render_init_claude_md, render_memory_report, render_repl_help,
- render_update_report, resume_supported_slash_commands, select_release_assets,
- status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
- };
- use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
- use std::path::{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::WorkspaceWrite,
- }
- );
- }
- #[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::WorkspaceWrite,
- }
- );
- }
- #[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::WorkspaceWrite,
- }
- );
- }
- #[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_self_update_subcommand() {
- assert_eq!(
- parse_args(&["self-update".to_string()]).expect("self-update should parse"),
- CliAction::SelfUpdate
- );
- }
- #[test]
- fn normalize_version_tag_trims_v_prefix() {
- assert_eq!(normalize_version_tag("v0.1.0"), "0.1.0");
- assert_eq!(normalize_version_tag("0.1.0"), "0.1.0");
- }
- #[test]
- fn detects_when_latest_version_differs() {
- assert!(!is_newer_version("0.1.0", "v0.1.0"));
- assert!(is_newer_version("0.1.0", "v0.2.0"));
- }
- #[test]
- fn parses_checksum_manifest_for_named_asset() {
- let manifest = "abc123 *rusty-claude-cli\ndef456 other-file\n";
- assert_eq!(
- parse_checksum_for_asset(manifest, "rusty-claude-cli"),
- Some("abc123".to_string())
- );
- }
- #[test]
- fn select_release_assets_requires_checksum_file() {
- let release = super::GitHubRelease {
- tag_name: "v0.2.0".to_string(),
- body: String::new(),
- assets: vec![super::GitHubReleaseAsset {
- name: "rusty-claude-cli".to_string(),
- browser_download_url: "https://example.invalid/rusty-claude-cli".to_string(),
- }],
- };
- let error = select_release_assets(&release).expect_err("missing checksum should error");
- assert!(error.contains("checksum manifest"));
- }
- #[test]
- fn update_report_includes_changelog_when_present() {
- let report = render_update_report(
- "Already up to date",
- Some("0.1.0"),
- Some("0.1.0"),
- Some("No action taken."),
- Some("- Added self-update"),
- );
- assert!(report.contains("Self-update"));
- assert!(report.contains("Changelog"));
- assert!(report.contains("- Added self-update"));
- }
- #[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::WorkspaceWrite,
- }
- );
- }
- #[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
- );
- }
- #[test]
- fn parses_resume_flag_with_slash_command() {
- let args = vec![
- "--resume".to_string(),
- "session.json".to_string(),
- "/compact".to_string(),
- ];
- assert_eq!(
- parse_args(&args).expect("args should parse"),
- CliAction::ResumeSession {
- session_path: PathBuf::from("session.json"),
- commands: vec!["/compact".to_string()],
- }
- );
- }
- #[test]
- fn parses_resume_flag_with_multiple_slash_commands() {
- let args = vec![
- "--resume".to_string(),
- "session.json".to_string(),
- "/status".to_string(),
- "/compact".to_string(),
- "/cost".to_string(),
- ];
- assert_eq!(
- parse_args(&args).expect("args should parse"),
- CliAction::ResumeSession {
- session_path: PathBuf::from("session.json"),
- commands: vec![
- "/status".to_string(),
- "/compact".to_string(),
- "/cost".to_string(),
- ],
- }
- );
- }
- #[test]
- fn filtered_tool_specs_respect_allowlist() {
- let allowed = ["read_file", "grep_search"]
- .into_iter()
- .map(str::to_string)
- .collect();
- let filtered = filter_tool_specs(Some(&allowed));
- 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>"));
- assert!(help.contains("/config [env|hooks|model]"));
- assert!(help.contains("/memory"));
- assert!(help.contains("/init"));
- assert!(help.contains("/diff"));
- assert!(help.contains("/version"));
- assert!(help.contains("/export [file]"));
- assert!(help.contains("/session [list|switch <session-id>]"));
- assert!(help.contains("/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_report_uses_structured_output() {
- let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
- assert!(created.contains("Init"));
- assert!(created.contains("Result created"));
- let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
- assert!(skipped.contains("skipped (already exists)"));
- }
- #[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"));
- }
- #[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!(context.discovered_config_files >= 3);
- 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 = render_init_claude_md(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("Tool call"));
- assert!(start.contains("src/main.rs"));
- let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
- assert!(done.contains("Tool `read_file`"));
- assert!(done.contains("contents"));
- }
- }
|