claw-code/rust/crates/rusty-claude-cli/src/repl.rs

1729 lines
63 KiB
Rust

use crate::TokenUsage;
use crate::*;
use api::Usage;
use std::env;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::UNIX_EPOCH;
impl LiveCli {
pub(crate) 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_state = new_cli_session()?;
let session = create_managed_session_handle(&session_state.session_id)?;
let runtime = build_runtime(
session_state.with_persistence_path(session.path.clone()),
&session.id,
model.clone(),
system_prompt.clone(),
enable_tools,
true,
allowed_tools.clone(),
permission_mode,
None,
)?;
let cli = Self {
model,
allowed_tools,
permission_mode,
system_prompt,
runtime,
session,
prompt_history: Vec::new(),
};
cli.persist_session()?;
Ok(cli)
}
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<String>) {
if let Some(rt) = self.runtime.runtime.as_mut() {
rt.api_client_mut().set_reasoning_effort(effort);
}
}
pub(crate) fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
let status = status_context(None).ok();
let git_branch = status
.as_ref()
.and_then(|context| context.git_branch.as_deref())
.unwrap_or("unknown");
let workspace = status.as_ref().map_or_else(
|| "unknown".to_string(),
|context| context.git_summary.headline(),
);
let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else(
|_| self.session.path.display().to_string(),
|path| path.display().to_string(),
);
format!(
"\x1b[38;5;196m\
██████╗██╗ █████╗ ██╗ ██╗\n\
██╔════╝██║ ██╔══██╗██║ ██║\n\
██║ ██║ ███████║██║ █╗ ██║\n\
██║ ██║ ██╔══██║██║███╗██║\n\
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
\x1b[2mModel\x1b[0m {}\n\
\x1b[2mPermissions\x1b[0m {}\n\
\x1b[2mBranch\x1b[0m {}\n\
\x1b[2mWorkspace\x1b[0m {}\n\
\x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\
\x1b[2mAuto-save\x1b[0m {}\n\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
self.model,
self.permission_mode.as_str(),
git_branch,
workspace,
cwd,
self.session.id,
session_path,
)
}
pub(crate) fn repl_completion_candidates(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
Ok(slash_command_completion_candidates_with_sessions(
&self.model,
Some(&self.session.id),
list_managed_sessions()?
.into_iter()
.map(|session| session.id)
.collect(),
))
}
pub(crate) fn prepare_turn_runtime(
&self,
emit_output: bool,
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
emit_output,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?
.with_hook_abort_signal(hook_abort_signal.clone());
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
Ok((runtime, hook_abort_monitor))
}
pub(crate) fn replace_runtime(
&mut self,
runtime: BuiltRuntime,
) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.shutdown_plugins()?;
self.runtime = runtime;
Ok(())
}
pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
match result {
Ok(summary) => {
self.replace_runtime(runtime)?;
spinner.finish(
"✨ Done",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
if let Some(event) = summary.auto_compaction {
println!(
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
}
self.persist_session()?;
Ok(())
}
Err(error) => {
runtime.shutdown_plugins()?;
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
Err(Box::new(error))
}
}
}
pub(crate) fn run_turn_with_output(
&mut self,
input: &str,
output_format: CliOutputFormat,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
CliOutputFormat::Text => self.run_turn(input),
CliOutputFormat::Json => self.run_prompt_json(input),
}
}
pub(crate) fn run_prompt_compact(
&mut self,
input: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
let final_text = final_assistant_text(&summary);
println!("{final_text}");
Ok(())
}
pub(crate) fn run_prompt_compact_json(
&mut self,
input: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!(
"{}",
json!({
"message": final_assistant_text(&summary),
"compact": true,
"model": self.model,
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
},
})
);
Ok(())
}
pub(crate) fn run_prompt_json(
&mut self,
input: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!(
"{}",
json!({
"message": final_assistant_text(&summary),
"model": self.model,
"iterations": summary.iterations,
"auto_compaction": summary.auto_compaction.map(|event| json!({
"removed_messages": event.removed_message_count,
"notice": format_auto_compaction_notice(event.removed_message_count),
})),
"tool_uses": collect_tool_uses(&summary),
"tool_results": collect_tool_results(&summary),
"prompt_cache_events": collect_prompt_cache_events(&summary),
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
},
"estimated_cost": format_usd(
summary.usage.estimate_cost_usd_with_pricing(
pricing_for_model(&self.model)
.unwrap_or_else(runtime::ModelPricing::default_sonnet_tier)
).total_cost_usd()
)
})
);
Ok(())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn handle_repl_command(
&mut self,
command: SlashCommand,
) -> Result<bool, Box<dyn std::error::Error>> {
Ok(match command {
SlashCommand::Help => {
println!("{}", render_repl_help());
false
}
SlashCommand::Status => {
self.print_status();
false
}
SlashCommand::Bughunter { scope } => {
self.run_bughunter(scope.as_deref())?;
false
}
SlashCommand::Commit => {
self.run_commit(None)?;
false
}
SlashCommand::Pr { context } => {
self.run_pr(context.as_deref())?;
false
}
SlashCommand::Issue { context } => {
self.run_issue(context.as_deref())?;
false
}
SlashCommand::Ultraplan { task } => {
self.run_ultraplan(task.as_deref())?;
false
}
SlashCommand::Teleport { target } => {
Self::run_teleport(target.as_deref())?;
false
}
SlashCommand::DebugToolCall => {
self.run_debug_tool_call(None)?;
false
}
SlashCommand::Sandbox => {
Self::print_sandbox_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::Mcp { action, target } => {
let args = match (action.as_deref(), target.as_deref()) {
(None, None) => None,
(Some(action), None) => Some(action.to_string()),
(Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target.to_string()),
};
Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?;
false
}
SlashCommand::Memory => {
Self::print_memory()?;
false
}
SlashCommand::Init => {
run_init(CliOutputFormat::Text)?;
false
}
SlashCommand::Diff => {
Self::print_diff()?;
false
}
SlashCommand::Version => {
Self::print_version(CliOutputFormat::Text);
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::Plugins { action, target } => {
self.handle_plugins_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Agents { args } => {
Self::print_agents(args.as_deref(), CliOutputFormat::Text)?;
false
}
SlashCommand::Skills { args } => {
match classify_skills_slash_command(args.as_deref()) {
SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?,
SkillSlashDispatch::Local => {
Self::print_skills(args.as_deref(), CliOutputFormat::Text)?;
}
}
false
}
SlashCommand::Doctor => {
println!("{}", render_doctor_report()?.render());
false
}
SlashCommand::History { count } => {
self.print_prompt_history(count.as_deref());
false
}
SlashCommand::Stats => {
let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage();
println!("{}", format_cost_report(usage));
false
}
SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
| SlashCommand::Share
| SlashCommand::Feedback
| SlashCommand::Files
| SlashCommand::Fast
| SlashCommand::Exit
| SlashCommand::Summary
| SlashCommand::Desktop
| SlashCommand::Brief
| SlashCommand::Advisor
| SlashCommand::Stickers
| SlashCommand::Insights
| SlashCommand::Thinkback
| SlashCommand::ReleaseNotes
| SlashCommand::SecurityReview
| SlashCommand::Keybindings
| SlashCommand::PrivacySettings
| SlashCommand::Plan { .. }
| SlashCommand::Review { .. }
| SlashCommand::Tasks { .. }
| SlashCommand::Theme { .. }
| SlashCommand::Voice { .. }
| SlashCommand::Usage { .. }
| SlashCommand::Rename { .. }
| SlashCommand::Copy { .. }
| SlashCommand::Hooks { .. }
| SlashCommand::Context { .. }
| SlashCommand::Color { .. }
| SlashCommand::Effort { .. }
| SlashCommand::Branch { .. }
| SlashCommand::Rewind { .. }
| SlashCommand::Ide { .. }
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. } => {
let cmd_name = command.slash_name();
eprintln!("{cmd_name} is not yet implemented in this build.");
false
}
SlashCommand::Unknown(name) => {
eprintln!("{}", format_unknown_slash_command(&name));
false
}
})
}
pub(crate) fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())
}
pub(crate) 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"),
None, // #148: REPL /status doesn't carry flag provenance
)
);
}
pub(crate) fn record_prompt_history(&mut self, prompt: &str) {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(self.runtime.session().updated_at_ms, |duration| {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
});
let entry = PromptHistoryEntry {
timestamp_ms,
text: prompt.to_string(),
};
self.prompt_history.push(entry);
if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) {
eprintln!("warning: failed to persist prompt history: {error}");
}
}
pub(crate) fn print_prompt_history(&self, count: Option<&str>) {
let limit = match parse_history_count(count) {
Ok(limit) => limit,
Err(message) => {
eprintln!("{message}");
return;
}
};
let session_entries = &self.runtime.session().prompt_history;
let entries = if session_entries.is_empty() {
if self.prompt_history.is_empty() {
collect_session_prompt_history(self.runtime.session())
} else {
self.prompt_history
.iter()
.map(|entry| PromptHistoryEntry {
timestamp_ms: entry.timestamp_ms,
text: entry.text.clone(),
})
.collect()
}
} else {
session_entries
.iter()
.map(|entry| PromptHistoryEntry {
timestamp_ms: entry.timestamp_ms,
text: entry.text.clone(),
})
.collect()
};
println!("{}", render_prompt_history_report(&entries, limit));
}
pub(crate) fn print_sandbox_status() {
let cwd = env::current_dir().expect("current dir");
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader
.load()
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
println!(
"{}",
format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd))
);
}
pub(crate) fn set_model(
&mut self,
model: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(model) = model else {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(false);
};
let model = resolve_model_alias_with_config(&model);
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();
let runtime = build_runtime(
session,
&self.session.id,
model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.model.clone_from(&model);
println!(
"{}",
format_model_switch_report(&previous, &model, message_count)
);
Ok(true)
}
pub(crate) 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);
let runtime = build_runtime(
session,
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
);
Ok(true)
}
pub(crate) 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);
}
let previous_session = self.session.clone();
let session_state = new_cli_session()?;
self.session = create_managed_session_handle(&session_state.session_id)?;
let runtime = build_runtime(
session_state.with_persistence_path(self.session.path.clone()),
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
println!(
"Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}",
previous_session.id,
previous_session.id,
self.model,
self.permission_mode.as_str(),
self.session.id,
self.session.path.display(),
);
Ok(true)
}
pub(crate) fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
println!("{}", format_cost_report(cumulative));
}
pub(crate) fn resume_session(
&mut self,
session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else {
println!("{}", render_resume_usage());
return Ok(false);
};
let (handle, session) = load_session_reference(&session_ref)?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
let runtime = build_runtime(
session,
&handle.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = SessionHandle {
id: session_id,
path: handle.path,
};
println!(
"{}",
format_resume_report(
&self.session.path.display().to_string(),
message_count,
self.runtime.usage().turns(),
)
);
Ok(true)
}
pub(crate) fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_config_report(section)?);
Ok(())
}
pub(crate) fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_memory_report()?);
Ok(())
}
pub(crate) fn print_agents(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)?
),
}
Ok(())
}
pub(crate) fn print_mcp(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
// `claw mcp serve` starts a stdio MCP server exposing claw's built-in
// tools. All other `mcp` subcommands fall through to the existing
// configured-server reporter (`list`, `status`, ...).
if matches!(args.map(str::trim), Some("serve")) {
return run_mcp_serve();
}
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))?
),
}
Ok(())
}
pub(crate) fn print_skills(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)?
),
}
Ok(())
}
pub(crate) fn print_plugins(
action: Option<&str>,
target: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let result = handle_plugins_slash_command(action, target, &mut manager)?;
match output_format {
CliOutputFormat::Text => println!("{}", result.message),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "plugin",
"action": action.unwrap_or("list"),
"target": target,
"message": result.message,
"reload_runtime": result.reload_runtime,
}))?
),
}
Ok(())
}
pub(crate) fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?);
Ok(())
}
pub(crate) fn print_version(output_format: CliOutputFormat) {
let _ = crate::print_version(output_format);
}
pub(crate) 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(())
}
#[allow(clippy::too_many_lines)]
pub(crate) 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, session) = load_session_reference(target)?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
let runtime = build_runtime(
session,
&handle.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = SessionHandle {
id: session_id,
path: handle.path,
};
println!(
"Session switched\n Active session {}\n File {}\n Messages {}",
self.session.id,
self.session.path.display(),
message_count,
);
Ok(true)
}
Some("fork") => {
let forked = self.runtime.fork_session(target.map(ToOwned::to_owned));
let parent_session_id = self.session.id.clone();
let handle = create_managed_session_handle(&forked.session_id)?;
let branch_name = forked
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
let forked = forked.with_persistence_path(handle.path.clone());
let message_count = forked.messages.len();
forked.save_to_path(&handle.path)?;
let runtime = build_runtime(
forked,
&handle.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = handle;
println!(
"Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}",
parent_session_id,
self.session.id,
branch_name.as_deref().unwrap_or("(unnamed)"),
self.session.path.display(),
message_count,
);
Ok(true)
}
Some("delete") => {
let Some(target) = target else {
println!("Usage: /session delete <session-id> [--force]");
return Ok(false);
};
let handle = resolve_session_reference(target)?;
if handle.id == self.session.id {
println!(
"delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch <session-id>.",
handle.id
);
return Ok(false);
}
if !confirm_session_deletion(&handle.id) {
println!("delete: cancelled.");
return Ok(false);
}
delete_managed_session(&handle.path)?;
println!(
"Session deleted\n Deleted session {}\n File {}",
handle.id,
handle.path.display(),
);
Ok(false)
}
Some("delete-force") => {
let Some(target) = target else {
println!("Usage: /session delete <session-id> [--force]");
return Ok(false);
};
let handle = resolve_session_reference(target)?;
if handle.id == self.session.id {
println!(
"delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch <session-id>.",
handle.id
);
return Ok(false);
}
delete_managed_session(&handle.path)?;
println!(
"Session deleted\n Deleted session {}\n File {}",
handle.id,
handle.path.display(),
);
Ok(false)
}
Some(other) => {
println!(
"Unknown /session action '{other}'. Use /session list, /session switch <session-id>, /session fork [branch-name], or /session delete <session-id> [--force]."
);
Ok(false)
}
}
}
pub(crate) fn handle_plugins_command(
&mut self,
action: Option<&str>,
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let result = handle_plugins_slash_command(action, target, &mut manager)?;
println!("{}", result.message);
if result.reload_runtime {
self.reload_runtime_features()?;
}
Ok(false)
}
pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()
}
pub(crate) 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;
let runtime = build_runtime(
result.compacted_session,
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped));
Ok(())
}
pub(crate) fn run_internal_prompt_text_with_progress(
&self,
prompt: &str,
enable_tools: bool,
progress: Option<InternalPromptProgressReporter>,
) -> Result<String, Box<dyn std::error::Error>> {
let session = self.runtime.session().clone();
let mut runtime = build_runtime(
session,
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
enable_tools,
false,
self.allowed_tools.clone(),
self.permission_mode,
progress,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
let text = final_assistant_text(&summary).trim().to_string();
runtime.shutdown_plugins()?;
Ok(text)
}
pub(crate) fn run_internal_prompt_text(
&self,
prompt: &str,
enable_tools: bool,
) -> Result<String, Box<dyn std::error::Error>> {
self.run_internal_prompt_text_with_progress(prompt, enable_tools, None)
}
pub(crate) fn run_bughunter(
&self,
scope: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", format_bughunter_report(scope));
Ok(())
}
pub(crate) fn run_ultraplan(
&self,
task: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", format_ultraplan_report(task));
Ok(())
}
pub(crate) fn run_teleport(target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
println!("Usage: /teleport <symbol-or-path>");
return Ok(());
};
println!("{}", render_teleport_report(target)?);
Ok(())
}
pub(crate) fn run_debug_tool_call(
&self,
args: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
validate_no_args("/debug-tool-call", args)?;
println!("{}", render_last_tool_debug_report(self.runtime.session())?);
Ok(())
}
pub(crate) fn run_commit(
&mut self,
args: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
validate_no_args("/commit", args)?;
let status = git_output(&["status", "--short", "--branch"])?;
let summary = parse_git_workspace_summary(Some(&status));
let branch = parse_git_status_branch(Some(&status));
if summary.is_clean() {
println!("{}", format_commit_skipped_report());
return Ok(());
}
println!(
"{}",
format_commit_preflight_report(branch.as_deref(), summary)
);
Ok(())
}
pub(crate) fn run_pr(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let branch =
resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string());
println!("{}", format_pr_report(&branch, context));
Ok(())
}
pub(crate) fn run_issue(
&self,
context: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", format_issue_report(context));
Ok(())
}
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
) -> Result<(), Box<dyn std::error::Error>> {
enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?;
run_stale_base_preflight(base_commit.as_deref());
let resolved_model = resolve_repl_model(model);
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
cli.set_reasoning_effort(reasoning_effort);
let mut editor =
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
println!("{}", cli.startup_banner());
println!("{}", format_connected_line(&cli.model));
loop {
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
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;
}
match SlashCommand::parse(&trimmed) {
Ok(Some(command)) => {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
continue;
}
}
// Bare-word skill dispatch: if the first token of the input
// matches a known skill name, invoke it as `/skills <input>`
// rather than forwarding raw text to the LLM (ROADMAP #36).
let cwd = std::env::current_dir().unwrap_or_default();
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&prompt)?;
continue;
}
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;
break;
}
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string();
// #148: when user passes --model/--model=, capture the raw input so we
// can attribute source: "flag" later. None means no flag was supplied.
let mut model_flag_raw: Option<String> = None;
let mut output_format = CliOutputFormat::Text;
let mut permission_mode_override = None;
let mut wants_help = false;
let mut wants_version = false;
let mut allowed_tool_values = Vec::new();
let mut compact = false;
let mut base_commit: Option<String> = None;
let mut reasoning_effort: Option<String> = None;
let mut allow_broad_cwd = false;
let mut rest: Vec<String> = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--help" | "-h" if rest.is_empty() => {
wants_help = true;
index += 1;
}
"--help" | "-h"
if !rest.is_empty()
&& matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") =>
{
// `--help` following a subcommand that would otherwise forward
// the arg to the API (e.g. `claw prompt --help`) should show
// top-level help instead. Subcommands that consume their own
// args (agents, mcp, plugins, skills) and local help-topic
// subcommands (status, sandbox, doctor, init, state, export,
// version, system-prompt, dump-manifests, bootstrap-plan) must
// NOT be intercepted here — they handle --help in their own
// dispatch paths via parse_local_help_action(). See #141.
wants_help = true;
index += 1;
}
"--version" | "-V" => {
wants_version = true;
index += 1;
}
"--model" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
model_flag_raw = Some(value.clone()); // #148
index += 2;
}
flag if flag.starts_with("--model=") => {
let value = &flag[8..];
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
model_flag_raw = Some(value.to_string()); // #148
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_override = Some(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_override = Some(parse_permission_mode_arg(&flag[18..])?);
index += 1;
}
"--dangerously-skip-permissions" => {
permission_mode_override = Some(PermissionMode::DangerFullAccess);
index += 1;
}
"--compact" => {
compact = true;
index += 1;
}
"--base-commit" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --base-commit".to_string())?;
base_commit = Some(value.clone());
index += 2;
}
flag if flag.starts_with("--base-commit=") => {
base_commit = Some(flag[14..].to_string());
index += 1;
}
"--reasoning-effort" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --reasoning-effort".to_string())?;
if !matches!(value.as_str(), "low" | "medium" | "high") {
return Err(format!(
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
));
}
reasoning_effort = Some(value.clone());
index += 2;
}
flag if flag.starts_with("--reasoning-effort=") => {
let value = &flag[19..];
if !matches!(value, "low" | "medium" | "high") {
return Err(format!(
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
));
}
reasoning_effort = Some(value.to_string());
index += 1;
}
"--allow-broad-cwd" => {
allow_broad_cwd = true;
index += 1;
}
"-p" => {
// Claw Code compat: -p "prompt" = one-shot prompt
let prompt = args[index + 1..].join(" ");
if prompt.trim().is_empty() {
return Err("-p requires a prompt string".to_string());
}
return Ok(CliAction::Prompt {
prompt,
model: resolve_model_alias_with_config(&model),
output_format,
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
permission_mode: permission_mode_override
.unwrap_or_else(default_permission_mode),
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
});
}
"--print" => {
// Claw Code compat: --print makes output non-interactive
output_format = CliOutputFormat::Text;
index += 1;
}
"--resume" if rest.is_empty() => {
rest.push("--resume".to_string());
index += 1;
}
flag if rest.is_empty() && flag.starts_with("--resume=") => {
rest.push("--resume".to_string());
rest.push(flag[9..].to_string());
index += 1;
}
"--acp" | "-acp" => {
rest.push("acp".to_string());
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 if rest.is_empty() && other.starts_with('-') => {
return Err(format_unknown_option(other))
}
other => {
rest.push(other.to_string());
index += 1;
}
}
}
if wants_help {
return Ok(CliAction::Help { output_format });
}
if wants_version {
return Ok(CliAction::Version { output_format });
}
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
if rest.is_empty() {
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
// When stdin is not a terminal (pipe/redirect) and no prompt is given on the
// command line, read stdin as the prompt and dispatch as a one-shot Prompt
// rather than starting the interactive REPL (which would consume the pipe and
// print the startup banner, then exit without sending anything to the API).
if !std::io::stdin().is_terminal() {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf);
let piped = buf.trim().to_string();
if !piped.is_empty() {
return Ok(CliAction::Prompt {
model,
prompt: piped,
allowed_tools,
permission_mode,
output_format,
compact: false,
base_commit,
reasoning_effort,
allow_broad_cwd,
});
}
}
return Ok(CliAction::Repl {
model,
allowed_tools,
permission_mode,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
});
}
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..], output_format);
}
if let Some(action) = parse_local_help_action(&rest) {
return action;
}
if let Some(action) = parse_single_word_command_alias(
&rest,
&model,
model_flag_raw.as_deref(),
permission_mode_override,
output_format,
allowed_tools.clone(),
) {
return action;
}
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() {
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
"agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
output_format,
}),
"mcp" => Ok(CliAction::Mcp {
args: join_optional_args(&rest[1..]),
output_format,
}),
// #145: `plugins` was routed through the prompt fallback because no
// top-level parser arm produced CliAction::Plugins. That made `claw
// plugins` (and `claw plugins --help`, `claw plugins list`, ...)
// attempt an Anthropic network call, surfacing the misleading error
// `missing Anthropic credentials` even though the command is purely
// local introspection. Mirror `agents`/`mcp`/`skills`: action is the
// first positional arg, target is the second.
"plugins" => {
let tail = &rest[1..];
let action = tail.first().cloned();
let target = tail.get(1).cloned();
if tail.len() > 2 {
return Err(format!(
"unexpected extra arguments after `claw plugins {}`: {}",
tail[..2].join(" "),
tail[2..].join(" ")
));
}
Ok(CliAction::Plugins {
action,
target,
output_format,
})
}
// #146: `config` is pure-local read-only introspection (merges
// `.claw.json` + `.claw/settings.json` from disk, no network, no
// state mutation). Previously callers had to spin up a session with
// `claw --resume SESSION.jsonl /config` to see their own config,
// which is synthetic friction. Accepts an optional section name
// (env|hooks|model|plugins) matching the slash command shape.
"config" => {
let tail = &rest[1..];
let section = tail.first().cloned();
if tail.len() > 1 {
return Err(format!(
"unexpected extra arguments after `claw config {}`: {}",
tail[0],
tail[1..].join(" ")
));
}
Ok(CliAction::Config {
section,
output_format,
})
}
// #146: `diff` is pure-local (shells out to `git diff --cached` +
// `git diff`). No session needed to inspect the working tree.
"diff" => {
if rest.len() > 1 {
return Err(format!(
"unexpected extra arguments after `claw diff`: {}",
rest[1..].join(" ")
));
}
Ok(CliAction::Diff { output_format })
}
"skills" => {
let args = join_optional_args(&rest[1..]);
match classify_skills_slash_command(args.as_deref()) {
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
SkillSlashDispatch::Local => Ok(CliAction::Skills {
args,
output_format,
}),
}
}
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
"acp" => parse_acp_args(&rest[1..], output_format),
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
"init" => Ok(CliAction::Init { output_format }),
"export" => parse_export_args(&rest[1..], output_format),
"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,
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
})
}
other if other.starts_with('/') => parse_direct_slash_cli_action(
&rest,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort,
allow_broad_cwd,
),
other => {
if rest.len() == 1 && looks_like_subcommand_typo(other) {
if let Some(suggestions) = suggest_similar_subcommand(other) {
let mut message = format!("unknown subcommand: {other}.");
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
message.push('\n');
message.push_str(&line);
}
message.push_str(
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
);
return Err(message);
}
}
// #147: guard empty/whitespace-only prompts at the fallthrough
// path the same way `"prompt"` arm above does. Without this,
// `claw ""`, `claw " "`, and `claw "" ""` silently route to
// the Anthropic call and surface a misleading
// `missing Anthropic credentials` error (or burn API tokens on
// an empty prompt when credentials are present).
let joined = rest.join(" ");
if joined.trim().is_empty() {
return Err(
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
.to_string(),
);
}
Ok(CliAction::Prompt {
prompt: joined,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
})
}
}
}
pub(crate) fn parse_export_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
let mut output_path: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--session" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --session".to_string())?;
session_reference.clone_from(value);
index += 2;
}
flag if flag.starts_with("--session=") => {
session_reference = flag[10..].to_string();
index += 1;
}
"--output" | "-o" => {
let value = args
.get(index + 1)
.ok_or_else(|| format!("missing value for {}", args[index]))?;
output_path = Some(PathBuf::from(value));
index += 2;
}
flag if flag.starts_with("--output=") => {
output_path = Some(PathBuf::from(&flag[9..]));
index += 1;
}
other if other.starts_with('-') => {
return Err(format!("unknown export option: {other}"));
}
other if output_path.is_none() => {
output_path = Some(PathBuf::from(other));
index += 1;
}
other => {
return Err(format!("unexpected export argument: {other}"));
}
}
}
Ok(CliAction::Export {
session_reference,
output_path,
output_format,
})
}
pub(crate) fn parse_history_count(raw: Option<&str>) -> Result<usize, String> {
let Some(raw) = raw else {
return Ok(DEFAULT_HISTORY_LIMIT);
};
let parsed: usize = raw
.parse()
.map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?;
if parsed == 0 {
return Err("history: count must be greater than 0.".to_string());
}
Ok(parsed)
}
pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
let status = status?;
let first_line = status.lines().next()?;
let line = first_line.strip_prefix("## ")?;
if line.starts_with("HEAD") {
return Some("detached HEAD".to_string());
}
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
pub(crate) fn parse_git_status_metadata_for(
cwd: &Path,
status: Option<&str>,
) -> (Option<PathBuf>, Option<String>) {
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
let project_root = find_git_root_in(cwd).ok();
(project_root, branch)
}
pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
let mut summary = GitWorkspaceSummary::default();
let Some(status) = status else {
return summary;
};
for line in status.lines() {
if line.starts_with("## ") || line.trim().is_empty() {
continue;
}
summary.changed_files += 1;
let mut chars = line.chars();
let index_status = chars.next().unwrap_or(' ');
let worktree_status = chars.next().unwrap_or(' ');
if index_status == '?' && worktree_status == '?' {
summary.untracked_files += 1;
continue;
}
if index_status != ' ' {
summary.staged_files += 1;
}
if worktree_status != ' ' {
summary.unstaged_files += 1;
}
if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A'))
|| index_status == 'U'
|| worktree_status == 'U'
{
summary.conflicted_files += 1;
}
}
summary
}