1176 lines
44 KiB
Rust
1176 lines
44 KiB
Rust
use crate::*;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use serde_json::json;
|
|
use commands::SlashCommand;
|
|
use ::tools::{mvp_tool_specs, execute_tool};
|
|
|
|
impl LiveCli {
|
|
#[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::Steer { text } => {
|
|
if text.trim().is_empty() {
|
|
eprintln!("Usage: /steer <text>");
|
|
} else {
|
|
steer_push(&self.steer_queue, text.clone());
|
|
println!("🧭 Steer queued: {text}");
|
|
}
|
|
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 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 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(&serde_json::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 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_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(())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) {
|
|
let Ok(cwd) = env::current_dir() else {
|
|
return;
|
|
};
|
|
let source = resolve_expected_base(flag_value, &cwd);
|
|
let state = check_base_commit(&cwd, source.as_ref());
|
|
if let Some(warning) = format_stale_base_warning(&state) {
|
|
eprintln!("{warning}");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn run_mcp_serve() -> Result<(), Box<dyn std::error::Error>> {
|
|
let tools = ::tools::mvp_tool_specs()
|
|
.into_iter()
|
|
.map(|spec| McpTool {
|
|
name: spec.name.to_string(),
|
|
description: Some(spec.description.to_string()),
|
|
input_schema: Some(spec.input_schema),
|
|
annotations: None,
|
|
meta: None,
|
|
})
|
|
.collect();
|
|
|
|
let spec = McpServerSpec {
|
|
server_name: "claw".to_string(),
|
|
server_version: VERSION.to_string(),
|
|
tools,
|
|
tool_handler: Box::new(::tools::execute_tool),
|
|
};
|
|
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
runtime.block_on(async move {
|
|
let mut server = McpServer::new(spec);
|
|
server.run().await
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn run_resume_command(
|
|
session_path: &Path,
|
|
session: &Session,
|
|
command: &SlashCommand,
|
|
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
|
|
match command {
|
|
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(render_repl_help()),
|
|
json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })),
|
|
}),
|
|
SlashCommand::Compact => {
|
|
let result = runtime::compact_session(
|
|
session,
|
|
CompactionConfig {
|
|
max_estimated_tokens: 0,
|
|
..CompactionConfig::default()
|
|
},
|
|
);
|
|
let removed = result.removed_message_count;
|
|
let kept = result.compacted_session.messages.len();
|
|
let skipped = removed == 0;
|
|
result.compacted_session.save_to_path(session_path)?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: result.compacted_session,
|
|
message: Some(format_compact_report(removed, kept, skipped)),
|
|
json: Some(serde_json::json!({
|
|
"kind": "compact",
|
|
"skipped": skipped,
|
|
"removed_messages": removed,
|
|
"kept_messages": kept,
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Clear { confirm } => {
|
|
if !confirm {
|
|
return Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(
|
|
"clear: confirmation required; rerun with /clear --confirm".to_string(),
|
|
),
|
|
json: Some(serde_json::json!({
|
|
"kind": "error",
|
|
"error": "confirmation required",
|
|
"hint": "rerun with /clear --confirm",
|
|
})),
|
|
});
|
|
}
|
|
let backup_path = write_session_clear_backup(session, session_path)?;
|
|
let previous_session_id = session.session_id.clone();
|
|
let cleared = new_cli_session()?;
|
|
let new_session_id = cleared.session_id.clone();
|
|
cleared.save_to_path(session_path)?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: cleared,
|
|
message: Some(format!(
|
|
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
|
|
backup_path.display(),
|
|
backup_path.display(),
|
|
session_path.display()
|
|
)),
|
|
json: Some(serde_json::json!({
|
|
"kind": "clear",
|
|
"previous_session_id": previous_session_id,
|
|
"new_session_id": new_session_id,
|
|
"backup": backup_path.display().to_string(),
|
|
"session_file": session_path.display().to_string(),
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Status => {
|
|
let tracker = UsageTracker::from_session(session);
|
|
let usage = tracker.cumulative_usage();
|
|
let context = status_context(Some(session_path))?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(format_status_report(
|
|
session.model.as_deref().unwrap_or("restored-session"),
|
|
StatusUsage {
|
|
message_count: session.messages.len(),
|
|
turns: tracker.turns(),
|
|
latest: tracker.current_turn_usage(),
|
|
cumulative: usage,
|
|
estimated_tokens: 0,
|
|
},
|
|
default_permission_mode().as_str(),
|
|
&context,
|
|
None, // #148: resumed sessions don't have flag provenance
|
|
)),
|
|
json: Some(status_json_value(
|
|
session.model.as_deref(),
|
|
StatusUsage {
|
|
message_count: session.messages.len(),
|
|
turns: tracker.turns(),
|
|
latest: tracker.current_turn_usage(),
|
|
cumulative: usage,
|
|
estimated_tokens: 0,
|
|
},
|
|
default_permission_mode().as_str(),
|
|
&context,
|
|
None, // #148: resumed sessions don't have flag provenance
|
|
None,
|
|
)),
|
|
})
|
|
}
|
|
SlashCommand::Sandbox => {
|
|
let cwd = env::current_dir()?;
|
|
let loader = ConfigLoader::default_for(&cwd);
|
|
let runtime_config = loader.load()?;
|
|
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(format_sandbox_report(&status)),
|
|
json: Some(sandbox_json_value(&status)),
|
|
})
|
|
}
|
|
SlashCommand::Cost => {
|
|
let usage = UsageTracker::from_session(session).cumulative_usage();
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(format_cost_report(usage)),
|
|
json: Some(serde_json::json!({
|
|
"kind": "cost",
|
|
"input_tokens": usage.input_tokens,
|
|
"output_tokens": usage.output_tokens,
|
|
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
|
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
|
"total_tokens": usage.total_tokens(),
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Config { section } => {
|
|
let message = render_config_report(section.as_deref())?;
|
|
let json = render_config_json(section.as_deref())?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(message),
|
|
json: Some(json),
|
|
})
|
|
}
|
|
SlashCommand::Mcp { action, target } => {
|
|
let cwd = env::current_dir()?;
|
|
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()),
|
|
};
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)),
|
|
json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)),
|
|
})
|
|
}
|
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(render_memory_report()?),
|
|
json: Some(render_memory_json()?),
|
|
}),
|
|
SlashCommand::Init => {
|
|
// #142: run the init once, then render both text + structured JSON
|
|
// from the same InitReport so both surfaces stay in sync.
|
|
let cwd = env::current_dir()?;
|
|
let report = crate::init::initialize_repo(&cwd)?;
|
|
let message = report.render();
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(message.clone()),
|
|
json: Some(init_json_value(&report, &message)),
|
|
})
|
|
}
|
|
SlashCommand::Diff => {
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
let message = render_diff_report_for(&cwd)?;
|
|
let json = render_diff_json_for(&cwd)?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(message),
|
|
json: Some(json),
|
|
})
|
|
}
|
|
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(render_version_report()),
|
|
json: Some(version_json_value()),
|
|
}),
|
|
SlashCommand::Export { path } => {
|
|
let export_path = resolve_export_path(path.as_deref(), session)?;
|
|
fs::write(&export_path, render_export_text(session))?;
|
|
let msg_count = session.messages.len();
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(format!(
|
|
"Export\n Result wrote transcript\n File {}\n Messages {}",
|
|
export_path.display(),
|
|
msg_count,
|
|
)),
|
|
json: Some(serde_json::json!({
|
|
"kind": "export",
|
|
"file": export_path.display().to_string(),
|
|
"message_count": msg_count,
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Agents { args } => {
|
|
let cwd = env::current_dir()?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
|
|
json: Some(serde_json::json!({
|
|
"kind": "agents",
|
|
"text": handle_agents_slash_command(args.as_deref(), &cwd)?,
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Skills { args } => {
|
|
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
|
|
return Err(
|
|
"resumed /skills invocations are interactive-only; start `claw` and run `/skills <skill>` in the REPL".into(),
|
|
);
|
|
}
|
|
let cwd = env::current_dir()?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
|
|
json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?),
|
|
})
|
|
}
|
|
SlashCommand::Doctor => {
|
|
let report = render_doctor_report()?;
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(report.render()),
|
|
json: Some(report.json_value()),
|
|
})
|
|
}
|
|
SlashCommand::Stats => {
|
|
let usage = UsageTracker::from_session(session).cumulative_usage();
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(format_cost_report(usage)),
|
|
json: Some(serde_json::json!({
|
|
"kind": "stats",
|
|
"input_tokens": usage.input_tokens,
|
|
"output_tokens": usage.output_tokens,
|
|
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
|
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
|
"total_tokens": usage.total_tokens(),
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::History { count } => {
|
|
let limit = parse_history_count(count.as_deref())
|
|
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
|
|
let entries = collect_session_prompt_history(session);
|
|
let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect();
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(render_prompt_history_report(&entries, limit)),
|
|
json: Some(serde_json::json!({
|
|
"kind": "history",
|
|
"total": entries.len(),
|
|
"showing": shown.len(),
|
|
"entries": shown.iter().map(|e| serde_json::json!({
|
|
"timestamp_ms": e.timestamp_ms,
|
|
"text": e.text,
|
|
})).collect::<Vec<_>>(),
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
|
// /session list can be served from the sessions directory without a live session.
|
|
SlashCommand::Session {
|
|
action: Some(ref act),
|
|
..
|
|
} if act == "list" => {
|
|
let sessions = list_managed_sessions().unwrap_or_default();
|
|
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
|
let active_id = session.session_id.clone();
|
|
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
|
|
Ok(ResumeCommandOutcome {
|
|
session: session.clone(),
|
|
message: Some(text),
|
|
json: Some(serde_json::json!({
|
|
"kind": "session_list",
|
|
"sessions": session_ids,
|
|
"active": active_id,
|
|
})),
|
|
})
|
|
}
|
|
SlashCommand::Bughunter { .. }
|
|
| SlashCommand::Commit { .. }
|
|
| SlashCommand::Pr { .. }
|
|
| SlashCommand::Issue { .. }
|
|
| SlashCommand::Ultraplan { .. }
|
|
| SlashCommand::Teleport { .. }
|
|
| SlashCommand::DebugToolCall { .. }
|
|
| SlashCommand::Resume { .. }
|
|
| SlashCommand::Model { .. }
|
|
| SlashCommand::Permissions { .. }
|
|
| SlashCommand::Session { .. }
|
|
| SlashCommand::Plugins { .. }
|
|
| 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 { .. }
|
|
| SlashCommand::Steer { .. } => Err("unsupported resumed slash command".into()),
|
|
}
|
|
}
|
|
|
|
pub(crate) struct ResumeCommandOutcome {
|
|
pub(crate) session: Session,
|
|
pub(crate) message: Option<String>,
|
|
pub(crate) json: Option<serde_json::Value>,
|
|
}
|