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