1729 lines
63 KiB
Rust
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
|
|
}
|