From 494aafa00e24bd495bd70d999baa946d7fe7ef82 Mon Sep 17 00:00:00 2001 From: zhaoyanchao Date: Thu, 28 May 2026 20:45:39 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 10244 bytes add_imports.py | 39 + anthropic_full.rs | 255 ++ anthropic_impl.rs | 243 ++ anthropic_struct.rs | 12 + anthropic_trait_impl.rs | 49 + ...l_progress_summarizes_known_tools.rs_chunk | 15 + extract_client.py | 67 + extract_executor.py | 26 + extract_stream.py | 42 + extract_tests.py | 30 + fix_all.py | 40 + fix_anthropic.py | 20 + fix_diagnostics.sh | 9 + fix_models_test.py | 52 + fix_progress.sh | 8 + fix_prompter.py | 20 + fix_stream_client.py | 20 + make_pub_crate.py | 37 + move_tests.py | 114 + openspec/.DS_Store | Bin 0 -> 6148 bytes .../refactor-main-module-split/.openspec.yaml | 2 + .../refactor-main-module-split/design.md | 62 + .../refactor-main-module-split/proposal.md | 30 + .../specs/main-module-refactoring/spec.md | 20 + .../refactor-main-module-split/tasks.md | 34 + ...licy_uses_plugin_tool_permissions.rs_chunk | 12 + ..._anthropic_model_env_when_default.rs_chunk | 19 + ...ault_when_env_unset_and_no_config.rs_chunk | 17 + ...ied_model_unchanged_when_explicit.rs_chunk | 8 + resolves_known_model_aliases.rs_chunk | 7 + rm_from_main.py | 14 + rust/.DS_Store | Bin 6148 -> 8196 bytes rust/crates/rusty-claude-cli/fix.py | 24 + rust/crates/rusty-claude-cli/src/cli/mod.rs | 1 + .../rusty-claude-cli/src/cli/suggestions.rs | 128 + .../crates/rusty-claude-cli/src/config/mod.rs | 1 + .../rusty-claude-cli/src/config/models.rs | 250 ++ .../rusty-claude-cli/src/diagnostics/mod.rs | 695 +++++ .../src/diagnostics/mod.rs.patch | 9 + .../rusty-claude-cli/src/execution/client.rs | 1079 ++++++++ .../src/execution/executor.rs | 261 ++ .../rusty-claude-cli/src/execution/mod.rs | 3 + .../rusty-claude-cli/src/execution/stream.rs | 186 ++ rust/crates/rusty-claude-cli/src/main.rs | 2316 +---------------- .../crates/rusty-claude-cli/src/main_tests.rs | 611 +---- .../rusty-claude-cli/src/permissions/mod.rs | 1 + .../src/permissions/prompter.rs | 164 ++ rust/crates/rusty-claude-cli/src/repl.rs | 1 + rust/crates/rusty-claude-cli/src/ui/mod.rs | 1 + .../rusty-claude-cli/src/ui/progress.rs | 375 +++ rust/dedup_tests.py | 53 + rust/dup_tests.txt | 7 + rust/extract_tests_execution.py | 74 + rust/extract_tests_safe.py | 81 + rust/fix_client_test.py | 47 + rust/remove_7_tests.py | 35 + rust/remove_7_tests_safe.py | 51 + rust/test_out.txt | 256 ++ src/.DS_Store | Bin 0 -> 12292 bytes ..._resolve_before_provider_dispatch.rs_chunk | 38 + 61 files changed, 5193 insertions(+), 2878 deletions(-) create mode 100644 add_imports.py create mode 100644 anthropic_full.rs create mode 100644 anthropic_impl.rs create mode 100644 anthropic_struct.rs create mode 100644 anthropic_trait_impl.rs create mode 100644 describe_tool_progress_summarizes_known_tools.rs_chunk create mode 100644 extract_client.py create mode 100644 extract_executor.py create mode 100644 extract_stream.py create mode 100644 extract_tests.py create mode 100644 fix_all.py create mode 100644 fix_anthropic.py create mode 100644 fix_diagnostics.sh create mode 100644 fix_models_test.py create mode 100644 fix_progress.sh create mode 100644 fix_prompter.py create mode 100644 fix_stream_client.py create mode 100644 make_pub_crate.py create mode 100644 move_tests.py create mode 100644 openspec/.DS_Store create mode 100644 openspec/changes/refactor-main-module-split/.openspec.yaml create mode 100644 openspec/changes/refactor-main-module-split/design.md create mode 100644 openspec/changes/refactor-main-module-split/proposal.md create mode 100644 openspec/changes/refactor-main-module-split/specs/main-module-refactoring/spec.md create mode 100644 openspec/changes/refactor-main-module-split/tasks.md create mode 100644 permission_policy_uses_plugin_tool_permissions.rs_chunk create mode 100644 resolve_repl_model_falls_back_to_anthropic_model_env_when_default.rs_chunk create mode 100644 resolve_repl_model_returns_default_when_env_unset_and_no_config.rs_chunk create mode 100644 resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit.rs_chunk create mode 100644 resolves_known_model_aliases.rs_chunk create mode 100644 rm_from_main.py create mode 100644 rust/crates/rusty-claude-cli/fix.py create mode 100644 rust/crates/rusty-claude-cli/src/cli/mod.rs create mode 100644 rust/crates/rusty-claude-cli/src/cli/suggestions.rs create mode 100644 rust/crates/rusty-claude-cli/src/config/mod.rs create mode 100644 rust/crates/rusty-claude-cli/src/config/models.rs create mode 100644 rust/crates/rusty-claude-cli/src/diagnostics/mod.rs create mode 100644 rust/crates/rusty-claude-cli/src/diagnostics/mod.rs.patch create mode 100644 rust/crates/rusty-claude-cli/src/execution/client.rs create mode 100644 rust/crates/rusty-claude-cli/src/execution/executor.rs create mode 100644 rust/crates/rusty-claude-cli/src/execution/mod.rs create mode 100644 rust/crates/rusty-claude-cli/src/execution/stream.rs create mode 100644 rust/crates/rusty-claude-cli/src/permissions/mod.rs create mode 100644 rust/crates/rusty-claude-cli/src/permissions/prompter.rs create mode 100644 rust/crates/rusty-claude-cli/src/ui/mod.rs create mode 100644 rust/crates/rusty-claude-cli/src/ui/progress.rs create mode 100644 rust/dedup_tests.py create mode 100644 rust/dup_tests.txt create mode 100644 rust/extract_tests_execution.py create mode 100644 rust/extract_tests_safe.py create mode 100644 rust/fix_client_test.py create mode 100644 rust/remove_7_tests.py create mode 100644 rust/remove_7_tests_safe.py create mode 100644 rust/test_out.txt create mode 100644 src/.DS_Store create mode 100644 user_defined_aliases_resolve_before_provider_dispatch.rs_chunk diff --git a/.DS_Store b/.DS_Store index 4cfd2012c771122b67b351f3108dbed1207568c4..b08b1edcfc4b077e2cff8032e5e53ce4c5f2337a 100644 GIT binary patch literal 10244 zcmeHMO-NKx82z3`sb28 zL@p6UaEcgh!X@Mod2t4g8vRZj(Lp`Y93^QoHPthm$Oh2%4eNk)z&cS&DnA z)q|r;9lgYGR5|jlfFs6qEUKu=$x-FxXl6&RP#npQI#)&c84&;hY_Z=h~U zP>RO+`aPXf;F{(jBAt!LCsNr7WFl`?zVse@|GF;>m8=4ln-;qTJ8W|H)_Z!csl5c1 zbvm{Oz{0XzN9*d5#rbzqhk@`lgd;e=(EZ1c zAKF#_l)?JycEg%FPlBEfL1&J#P+6c+%0qD;e=~R|Q>wRSem6YVJrundU4AaTtjLkh z`ueM_G20On?Px|j@^CwYKA(X4>(qsjn5Fd2o}QYA!)-qoDx-ND`UXz9CzSUU?$29L zT?N-aa%NrVZwk?0fcl&{W@xz*mTSL|Z0ozKw3od%S6&UC>(<|u#Ph0v(KBxPOtkP3 zN?Dd`oBKBUY}KD|4M z7&?ajfCkUjBt~1JL<4oasb2U{%J(%@M^gqVR_E$ zOpgiP25=_8Jp(sNM+DK+AcAXn6y3U1RO7PbK3qw86xXBYM=kWslG!VDx4NN2XE^_P zH@(vrUe@n{VAkMkEaIk^aRvDd543&msF~%wUtg^?>t{8q=WPy082lvpg$8?A2do3u z0qcNuV6PmgRX29U{{K0A)|UDGf3N86eXRr5fqmeBQx_kO_d|%@TMtMQd+jdHF`VKM zxT1=(1Q)p-k0`g}@sHS!KaNvih!yf}Gtmot2u4Ogr idHxN*8oc>^KPqfsD|~b1Y1jY49=rbMqr&-5>;He)^#11n delta 171 zcmZn(XfcprU|?W$DortDU=RQ@Ie-{MGqg`E6q~50D98e2GlIl&(hY-?^K%O(7HZ3a z1X&o07)lw68A@OhAYF1os_~N#h)b|(-4b7P;QZu!;!-f?KXEB$x$+Mi3t89~vvY6= sGK16u4dw, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + reasoning_effort: Option, +} +impl AnthropicRuntimeClient { + pub(crate) fn new( + session_id: &str, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + ) -> Result> { + // Dispatch to the correct provider at construction time. + // `ApiProviderClient` (exposed by the api crate as + // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI + // variants, where xAI and OpenAI both use the OpenAI-compat + // wire format under the hood. We consult + // `detect_provider_kind(&resolved_model)` so model-name prefix + // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over + // env-var presence. + // + // For Anthropic we build the client directly instead of going + // through `ApiProviderClient::from_model_with_anthropic_auth` + // so we can explicitly apply `api::read_base_url()` — that + // reads `ANTHROPIC_BASE_URL` and is required for the local + // mock-server test harness + // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point + // claw at its fake Anthropic endpoint. We also attach a + // session-scoped prompt cache on the Anthropic path; the + // prompt cache is Anthropic-only so non-Anthropic variants + // skip it. + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + // The api crate's `ProviderClient::from_model_with_anthropic_auth` + // with `None` for the anthropic auth routes via + // `detect_provider_kind` and builds an + // `OpenAiCompatClient::from_env` with the matching + // `OpenAiCompatConfig` (openai / xai / dashscope). + // That reads the correct API-key env var and BASE_URL + // override internally, so this one call covers OpenAI, + // OpenRouter, xAI, DashScope, Ollama, and any other + // OpenAI-compat endpoint users configure via + // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client, + session_id: session_id.to_string(), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + reasoning_effort: None, + }) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.reasoning_effort = effort; + } +} +impl AnthropicRuntimeClient { + /// Consume a single streaming response, optionally applying a stall + /// timeout on the first event for post-tool continuations. + #[allow(clippy::too_many_lines)] + pub(crate) async fn consume_stream( + &self, + message_request: &MessageRequest, + apply_stall_timeout: bool, + ) -> Result, RuntimeError> { + let mut stream = self + .client + .stream_message(message_request) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + 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 block_has_thinking_summary = false; + let mut saw_stop = false; + let mut received_any_event = false; + + loop { + let next = if apply_stall_timeout && !received_any_event { + match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { + Ok(inner) => inner.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?, + Err(_elapsed) => { + return Err(RuntimeError::new( + "post-tool stall: model did not respond within timeout", + )); + } + } + } else { + stream.next_event().await.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })? + }; + + let Some(event) = next else { + break; + }; + received_any_event = true; + + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + 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); + } + } + ContentBlockDelta::ThinkingDelta { thinking } => { + if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; + block_has_thinking_summary = true; + } + events.push(AssistantEvent::ThinkingDelta(thinking)); + } + ContentBlockDelta::SignatureDelta { signature } => { + events.push(AssistantEvent::SignatureDelta(signature)); + } + }, + ApiStreamEvent::ContentBlockStop(_) => { + block_has_thinking_summary = false; + 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() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + // 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(delta.usage.token_usage())); + } + 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); + } + } + } + + push_prompt_cache_record(&self.client, &mut events); + + 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(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut events = response_to_events(response, out)?; + push_prompt_cache_record(&self.client, &mut events); + Ok(events) + } +} diff --git a/anthropic_impl.rs b/anthropic_impl.rs new file mode 100644 index 00000000..ed5d01fb --- /dev/null +++ b/anthropic_impl.rs @@ -0,0 +1,243 @@ +impl AnthropicRuntimeClient { + fn new( + session_id: &str, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + ) -> Result> { + // Dispatch to the correct provider at construction time. + // `ApiProviderClient` (exposed by the api crate as + // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI + // variants, where xAI and OpenAI both use the OpenAI-compat + // wire format under the hood. We consult + // `detect_provider_kind(&resolved_model)` so model-name prefix + // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over + // env-var presence. + // + // For Anthropic we build the client directly instead of going + // through `ApiProviderClient::from_model_with_anthropic_auth` + // so we can explicitly apply `api::read_base_url()` — that + // reads `ANTHROPIC_BASE_URL` and is required for the local + // mock-server test harness + // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point + // claw at its fake Anthropic endpoint. We also attach a + // session-scoped prompt cache on the Anthropic path; the + // prompt cache is Anthropic-only so non-Anthropic variants + // skip it. + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + // The api crate's `ProviderClient::from_model_with_anthropic_auth` + // with `None` for the anthropic auth routes via + // `detect_provider_kind` and builds an + // `OpenAiCompatClient::from_env` with the matching + // `OpenAiCompatConfig` (openai / xai / dashscope). + // That reads the correct API-key env var and BASE_URL + // override internally, so this one call covers OpenAI, + // OpenRouter, xAI, DashScope, Ollama, and any other + // OpenAI-compat endpoint users configure via + // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client, + session_id: session_id.to_string(), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + reasoning_effort: None, + }) + } + + fn set_reasoning_effort(&mut self, effort: Option) { + self.reasoning_effort = effort; + } +} +impl AnthropicRuntimeClient { + /// Consume a single streaming response, optionally applying a stall + /// timeout on the first event for post-tool continuations. + #[allow(clippy::too_many_lines)] + async fn consume_stream( + &self, + message_request: &MessageRequest, + apply_stall_timeout: bool, + ) -> Result, RuntimeError> { + let mut stream = self + .client + .stream_message(message_request) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + 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 block_has_thinking_summary = false; + let mut saw_stop = false; + let mut received_any_event = false; + + loop { + let next = if apply_stall_timeout && !received_any_event { + match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { + Ok(inner) => inner.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?, + Err(_elapsed) => { + return Err(RuntimeError::new( + "post-tool stall: model did not respond within timeout", + )); + } + } + } else { + stream.next_event().await.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })? + }; + + let Some(event) = next else { + break; + }; + received_any_event = true; + + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + 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); + } + } + ContentBlockDelta::ThinkingDelta { thinking } => { + if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; + block_has_thinking_summary = true; + } + events.push(AssistantEvent::ThinkingDelta(thinking)); + } + ContentBlockDelta::SignatureDelta { signature } => { + events.push(AssistantEvent::SignatureDelta(signature)); + } + }, + ApiStreamEvent::ContentBlockStop(_) => { + block_has_thinking_summary = false; + 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() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + // 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(delta.usage.token_usage())); + } + 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); + } + } + } + + push_prompt_cache_record(&self.client, &mut events); + + 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(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut events = response_to_events(response, out)?; + push_prompt_cache_record(&self.client, &mut events); + Ok(events) + } +} diff --git a/anthropic_struct.rs b/anthropic_struct.rs new file mode 100644 index 00000000..6da0a6d8 --- /dev/null +++ b/anthropic_struct.rs @@ -0,0 +1,12 @@ +struct AnthropicRuntimeClient { + runtime: tokio::runtime::Runtime, + client: ApiProviderClient, + session_id: String, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + reasoning_effort: Option, +} diff --git a/anthropic_trait_impl.rs b/anthropic_trait_impl.rs new file mode 100644 index 00000000..a4476c5c --- /dev/null +++ b/anthropic_trait_impl.rs @@ -0,0 +1,49 @@ +impl ApiClient for AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } + let is_post_tool = request_ends_with_tool_result(&request); + 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.tool_registry, self.allowed_tools.as_ref())), + tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + stream: true, + reasoning_effort: self.reasoning_effort.clone(), + ..Default::default() + }; + + self.runtime.block_on(async { + // When resuming after tool execution, apply a stall timeout on the + // first stream event. If the model does not respond within the + // deadline we drop the stalled connection and re-send the request as + // a continuation nudge (one retry only). + let max_attempts: usize = if is_post_tool { 2 } else { 1 }; + + for attempt in 1..=max_attempts { + let result = self + .consume_stream(&message_request, is_post_tool && attempt == 1) + .await; + match result { + Ok(events) => return Ok(events), + Err(error) + if error.to_string().contains("post-tool stall") + && attempt < max_attempts => + { + // Stalled after tool completion — nudge the model by + // re-sending the same request. + } + Err(error) => return Err(error), + } + } + + Err(RuntimeError::new("post-tool continuation nudge exhausted")) + }) + } +} diff --git a/describe_tool_progress_summarizes_known_tools.rs_chunk b/describe_tool_progress_summarizes_known_tools.rs_chunk new file mode 100644 index 00000000..270db5bf --- /dev/null +++ b/describe_tool_progress_summarizes_known_tools.rs_chunk @@ -0,0 +1,15 @@ +#[test] + fn describe_tool_progress_summarizes_known_tools() { + assert_eq!( + describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), + "reading src/main.rs" + ); + assert!( + describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) + .contains("cargo test -p rusty-claude-cli") + ); + assert_eq!( + describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), + "grep `ultraplan` in rust" + ); + } \ No newline at end of file diff --git a/extract_client.py b/extract_client.py new file mode 100644 index 00000000..9fd04321 --- /dev/null +++ b/extract_client.py @@ -0,0 +1,67 @@ +import re + +MAIN_FILE = "rust/crates/rusty-claude-cli/src/main.rs" + +with open(MAIN_FILE, 'r') as f: + content = f.read() + +def extract_and_remove(pattern, content): + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if match: + extracted = match.group(0) + content = content.replace(extracted, "") + return extracted, content + return None, content + +anthropic_client_pattern = r'struct AnthropicRuntimeClient \{.*?\n\}\n\nimpl AnthropicRuntimeClient \{.*?\n\}\n\nimpl runtime::Client for AnthropicRuntimeClient \{.*?\n\}' +anthropic_client_code, content = extract_and_remove(anthropic_client_pattern, content) + +build_runtime_pattern = r'fn build_runtime\(\n session: Session,\n session_id: &str,\n model: String,\n system_prompts: Vec,\n enable_tools: bool,\n emit_output: bool,\n allowed_tools: Option,\n permission_mode: PermissionMode,\n reasoning_effort: Option,\n\) -> Result> \{.*?\n\}' +build_runtime_code, content = extract_and_remove(build_runtime_pattern, content) + +build_runtime_with_plugin_state_pattern = r'fn build_runtime_with_plugin_state\(\n session: Session,\n session_id: &str,\n model: String,\n system_prompts: Vec,\n enable_tools: bool,\n emit_output: bool,\n allowed_tools: Option,\n permission_mode: PermissionMode,\n reasoning_effort: Option,\n plugin_state: RuntimePluginState,\n\) -> Result> \{.*?\n\}' +build_runtime_with_plugin_state_code, content = extract_and_remove(build_runtime_with_plugin_state_pattern, content) + +build_runtime_plugin_state_pattern = r'fn build_runtime_plugin_state\(\) -> Result> \{.*?\n\}' +build_runtime_plugin_state_code, content = extract_and_remove(build_runtime_plugin_state_pattern, content) + +build_runtime_plugin_state_with_loader_pattern = r'fn build_runtime_plugin_state_with_loader\(\n cwd: &Path,\n loader: &ConfigLoader,\n runtime_config: &runtime::RuntimeConfig,\n\) -> Result> \{.*?\n\}' +build_runtime_plugin_state_with_loader_code, content = extract_and_remove(build_runtime_plugin_state_with_loader_pattern, content) + +runtime_plugin_state_pattern = r'struct RuntimePluginState \{.*?\n\}' +runtime_plugin_state_code, content = extract_and_remove(runtime_plugin_state_pattern, content) + +build_runtime_mcp_state_pattern = r'fn build_runtime_mcp_state\(\n mcp_config: Option<&runtime::McpConfig>,\n cwd: &Path,\n\) -> Option>> \{.*?\n\}' +build_runtime_mcp_state_code, content = extract_and_remove(build_runtime_mcp_state_pattern, content) + +mcp_wrapper_tools_pattern = r'fn mcp_wrapper_tool_definitions\(\) -> Vec \{.*?\n\}' +mcp_wrapper_tools_code, content = extract_and_remove(mcp_wrapper_tools_pattern, content) + +mcp_runtime_tool_definition_pattern = r'fn mcp_runtime_tool_definition\(tool: &runtime::ManagedMcpTool\) -> RuntimeToolDefinition \{.*?\n\}' +mcp_runtime_tool_definition_code, content = extract_and_remove(mcp_runtime_tool_definition_pattern, content) + +mcp_permission_mode_pattern = r'fn permission_mode_for_mcp_tool\(tool: &McpTool\) -> PermissionMode \{.*?\n\}' +mcp_permission_mode_code, content = extract_and_remove(mcp_permission_mode_pattern, content) + +mcp_annotation_flag_pattern = r'fn mcp_annotation_flag\(tool: &McpTool, key: &str\) -> bool \{.*?\n\}' +mcp_annotation_flag_code, content = extract_and_remove(mcp_annotation_flag_pattern, content) + +mcp_state_pattern = r'struct RuntimeMcpState \{.*?\n\}\n\nimpl RuntimeMcpState \{.*?\n\}' +mcp_state_code, content = extract_and_remove(mcp_state_pattern, content) + +with open(MAIN_FILE, 'w') as f: + f.write(content) + +with open('client_extracted.rs', 'w') as f: + blocks = [ + anthropic_client_code, build_runtime_code, build_runtime_with_plugin_state_code, + build_runtime_plugin_state_code, build_runtime_plugin_state_with_loader_code, + runtime_plugin_state_code, build_runtime_mcp_state_code, mcp_wrapper_tools_code, + mcp_runtime_tool_definition_code, mcp_permission_mode_code, mcp_annotation_flag_code, + mcp_state_code + ] + for block in blocks: + if block: + f.write(block + "\n\n") + +print("Client extraction complete") diff --git a/extract_executor.py b/extract_executor.py new file mode 100644 index 00000000..3c486048 --- /dev/null +++ b/extract_executor.py @@ -0,0 +1,26 @@ +import re + +MAIN_FILE = "rust/crates/rusty-claude-cli/src/main.rs" + +with open(MAIN_FILE, 'r') as f: + content = f.read() + +def extract_and_remove(pattern, content): + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if match: + extracted = match.group(0) + content = content.replace(extracted, "") + return extracted, content + return None, content + +executor_pattern = r'struct CliToolExecutor \{.*?\n\}\n\nimpl CliToolExecutor \{.*?\n\}\n\nimpl ToolExecutor for CliToolExecutor \{.*?\n\}' +executor_code, content = extract_and_remove(executor_pattern, content) + +with open(MAIN_FILE, 'w') as f: + f.write(content) + +with open('executor_extracted.rs', 'w') as f: + if executor_code: + f.write(executor_code + "\n\n") + +print("Executor extraction complete") diff --git a/extract_stream.py b/extract_stream.py new file mode 100644 index 00000000..afc4b656 --- /dev/null +++ b/extract_stream.py @@ -0,0 +1,42 @@ +import re +import os + +MAIN_FILE = "rust/crates/rusty-claude-cli/src/main.rs" + +with open(MAIN_FILE, 'r') as f: + content = f.read() + +def extract_and_remove(pattern, content): + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if match: + extracted = match.group(0) + content = content.replace(extracted, "") + return extracted, content + return None, content + +consume_stream_pattern = r' /// Consume a single streaming response, optionally applying a stall\n /// timeout on the first event for post-tool continuations\.\n #\[allow\(clippy::too_many_lines\)\]\n async fn consume_stream\(\n &self,\n message_request: &MessageRequest,\n apply_stall_timeout: bool,\n \) -> Result, RuntimeError> \{.*?\n \}' +consume_stream_code, content = extract_and_remove(consume_stream_pattern, content) + +response_to_events_pattern = r'fn response_to_events\(\n response: MessageResponse,\n out: &mut \(impl Write \+ \?Sized\),\n\) -> Result, RuntimeError> \{.*?\n\}' +response_to_events_code, content = extract_and_remove(response_to_events_pattern, content) + +push_prompt_cache_record_pattern = r'fn push_prompt_cache_record\(client: &ApiProviderClient, events: &mut Vec\) \{.*?\n\}' +push_prompt_cache_record_code, content = extract_and_remove(push_prompt_cache_record_pattern, content) + +prompt_cache_record_to_runtime_event_pattern = r'fn prompt_cache_record_to_runtime_event\(\n record: api::PromptCacheRecord,\n\) -> Option \{.*?\n\}' +prompt_cache_record_to_runtime_event_code, content = extract_and_remove(prompt_cache_record_to_runtime_event_pattern, content) + +request_ends_with_tool_result_pattern = r'/// Returns `true` when the conversation ends with a tool-result message,\n/// meaning the model is expected to continue after tool execution\.\nfn request_ends_with_tool_result\(request: &ApiRequest\) -> bool \{.*?\n\}' +request_ends_with_tool_result_code, content = extract_and_remove(request_ends_with_tool_result_pattern, content) + +with open(MAIN_FILE, 'w') as f: + f.write(content) + +with open('stream_extracted.rs', 'w') as f: + if consume_stream_code: f.write(consume_stream_code + "\n\n") + if response_to_events_code: f.write(response_to_events_code + "\n\n") + if push_prompt_cache_record_code: f.write(push_prompt_cache_record_code + "\n\n") + if prompt_cache_record_to_runtime_event_code: f.write(prompt_cache_record_to_runtime_event_code + "\n\n") + if request_ends_with_tool_result_code: f.write(request_ends_with_tool_result_code + "\n\n") + +print("Extraction complete") diff --git a/extract_tests.py b/extract_tests.py new file mode 100644 index 00000000..273af909 --- /dev/null +++ b/extract_tests.py @@ -0,0 +1,30 @@ +import re + +with open('rust/crates/rusty-claude-cli/src/main_tests.rs', 'r') as f: + content = f.read() + +def extract_test(name): + pattern = r'(#\[test\]\s+fn ' + name + r'\b.*?^ \})' + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if match: + return match.group(1) + return None + +tests = [ + "resolves_known_model_aliases", + "user_defined_aliases_resolve_before_provider_dispatch", + "resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit", + "resolve_repl_model_falls_back_to_anthropic_model_env_when_default", + "resolve_repl_model_returns_default_when_env_unset_and_no_config", + "permission_policy_uses_plugin_tool_permissions", + "describe_tool_progress_summarizes_known_tools" +] + +for test in tests: + extracted = extract_test(test) + if extracted: + with open(f"{test}.rs_chunk", "w") as out: + out.write(extracted) + else: + print(f"Test not found: {test}") + diff --git a/fix_all.py b/fix_all.py new file mode 100644 index 00000000..e7095029 --- /dev/null +++ b/fix_all.py @@ -0,0 +1,40 @@ +import re + +STREAM_FILE = "rust/crates/rusty-claude-cli/src/execution/stream.rs" +CLIENT_FILE = "rust/crates/rusty-claude-cli/src/execution/client.rs" +EXECUTOR_FILE = "rust/crates/rusty-claude-cli/src/execution/executor.rs" + +with open(STREAM_FILE, 'r') as f: + stream_content = f.read() + +# Extract consume_stream +consume_stream_pattern = r' /// Consume a single streaming response.*?\}\n\n' +match = re.search(consume_stream_pattern, stream_content, re.DOTALL | re.MULTILINE) +if match: + consume_stream_code = match.group(0) + stream_content = stream_content.replace(consume_stream_code, "") + + with open(STREAM_FILE, 'w') as f: + f.write(stream_content) + + with open(CLIENT_FILE, 'r') as f: + client_content = f.read() + + # insert inside impl AnthropicRuntimeClient + client_content = client_content.replace("impl AnthropicRuntimeClient {", "impl AnthropicRuntimeClient {\n" + consume_stream_code) + + # fix trait visibility issues + client_content = client_content.replace("pub(crate) fn deref(", "fn deref(") + client_content = client_content.replace("pub(crate) fn deref_mut(", "fn deref_mut(") + client_content = client_content.replace("pub(crate) fn drop(", "fn drop(") + + with open(CLIENT_FILE, 'w') as f: + f.write(client_content) + +with open(EXECUTOR_FILE, 'r') as f: + executor_content = f.read() +executor_content = executor_content.replace("pub(crate) fn execute(", "fn execute(") +with open(EXECUTOR_FILE, 'w') as f: + f.write(executor_content) + +print("Fixed.") diff --git a/fix_anthropic.py b/fix_anthropic.py new file mode 100644 index 00000000..d6dd508a --- /dev/null +++ b/fix_anthropic.py @@ -0,0 +1,20 @@ +import re + +with open('anthropic_full.rs', 'r') as f: + content = f.read() + +content = re.sub(r'^struct ', r'pub(crate) struct ', content, flags=re.MULTILINE) +content = re.sub(r'^(\s+)(async )?fn ', r'\1pub(crate) \2fn ', content, flags=re.MULTILINE) + +# except for trait methods +content = re.sub(r'pub\(crate\) fn name\(', r'fn name(', content) +content = re.sub(r'pub\(crate\) async fn process_stream\(', r'async fn process_stream(', content) +content = re.sub(r'pub\(crate\) fn session_id\(', r'fn session_id(', content) +content = re.sub(r'pub\(crate\) fn model\(', r'fn model(', content) +content = re.sub(r'pub\(crate\) fn enable_tools\(', r'fn enable_tools(', content) + +with open('anthropic_full.rs', 'w') as f: + f.write(content) + +with open('rust/crates/rusty-claude-cli/src/execution/client.rs', 'a') as f: + f.write("\n" + content + "\n") diff --git a/fix_diagnostics.sh b/fix_diagnostics.sh new file mode 100644 index 00000000..d3a6230c --- /dev/null +++ b/fix_diagnostics.sh @@ -0,0 +1,9 @@ +sed -i '' '1i\ +use crate::{CliOutputFormat, OFFICIAL_REPO_SLUG, DEPRECATED_INSTALL_COMMAND, OFFICIAL_REPO_URL, resolve_cli_auth_source_for_cwd};\ +use tools::{mvp_tool_specs, execute_tool};\ +use runtime::{McpTool, McpServerSpec, McpServer, load_oauth_credentials};\ +' rust/crates/rusty-claude-cli/src/diagnostics/mod.rs + +sed -i '' 's/fn run_doctor(/pub(crate) fn run_doctor(/g' rust/crates/rusty-claude-cli/src/diagnostics/mod.rs +sed -i '' 's/fn run_worker_state(/pub(crate) fn run_worker_state(/g' rust/crates/rusty-claude-cli/src/diagnostics/mod.rs +sed -i '' 's/fn run_mcp_serve(/pub(crate) fn run_mcp_serve(/g' rust/crates/rusty-claude-cli/src/diagnostics/mod.rs diff --git a/fix_models_test.py b/fix_models_test.py new file mode 100644 index 00000000..83aa46ca --- /dev/null +++ b/fix_models_test.py @@ -0,0 +1,52 @@ +import re + +MODELS_FILE = "rust/crates/rusty-claude-cli/src/config/models.rs" + +with open(MODELS_FILE, 'r') as f: + content = f.read() + +content = content.replace("use runtime::test_utils::{env_lock, temp_dir, with_current_dir};", """ + use std::sync::{Mutex, MutexGuard, OnceLock}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::path::{Path, PathBuf}; + use std::fs; + + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn cwd_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn temp_dir() -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}")) + } + + fn with_current_dir(cwd: &Path, f: impl FnOnce() -> T) -> T { + let _guard = cwd_lock(); + let previous = std::env::current_dir().expect("cwd should load"); + std::env::set_current_dir(cwd).expect("cwd should change"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + std::env::set_current_dir(previous).expect("cwd should restore"); + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } + } +""") + +with open(MODELS_FILE, 'w') as f: + f.write(content) diff --git a/fix_progress.sh b/fix_progress.sh new file mode 100644 index 00000000..c29881ea --- /dev/null +++ b/fix_progress.sh @@ -0,0 +1,8 @@ +sed -i '' 's/^struct InternalPromptProgressState/pub(crate) struct InternalPromptProgressState/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +sed -i '' 's/^enum InternalPromptProgressEvent/pub(crate) enum InternalPromptProgressEvent/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +sed -i '' 's/^struct InternalPromptProgressShared/pub(crate) struct InternalPromptProgressShared/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +sed -i '' 's/^struct InternalPromptProgressReporter/pub(crate) struct InternalPromptProgressReporter/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +sed -i '' 's/^struct InternalPromptProgressRun/pub(crate) struct InternalPromptProgressRun/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +sed -i '' 's/^struct CliHookProgressReporter/pub(crate) struct CliHookProgressReporter/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +sed -ised -i '' 's/^struct InternalPromptProgrplsed -i '' 's/^enum InternalPromptProgressEvent/pub(crate) enum InternalPromptProgressEvent/g' rust/crates/rusty-claude-cli/src/ui/progress.rs +seresed -i '' 's/^struct InternalPromptProgressShared/pub(crate) struct InternalPromptProgressShared/g' rust/crates/rusty-claude-cli/src/ui/prog sed -i "''" "'s/^struct InternalPromptProgressReporter/pub(crate) struct InternalPromptProgressReporter/g'" rust/crates/rusty-claude-cli/src/ui/progrerased -i "''" "'s/^struct InternalPromptProgressRun/pub(crate) struct InternalPromptProgressRun/g'" rust/crates/rusty-claude-cli/src/ui/progress.rs \ No newline at end of file diff --git a/fix_prompter.py b/fix_prompter.py new file mode 100644 index 00000000..2ad26cf2 --- /dev/null +++ b/fix_prompter.py @@ -0,0 +1,20 @@ +import re + +with open("rust/crates/rusty-claude-cli/src/permissions/prompter.rs", "r") as f: + content = f.read() + +content = re.sub(r"^fn (parse_permission_mode_arg|permission_mode_from_label|permission_mode_from_resolved|default_permission_mode|config_permission_mode_for_current_dir|permission_policy)\(", r"pub(crate) fn \1(", content, flags=re.M) +content = re.sub(r"^struct CliPermissionPrompter", r"pub(crate) struct CliPermissionPrompter", content, flags=re.M) +content = re.sub(r"fn new\(", r"pub(crate) fn new(", content) + +prefix = """use std::env; +use std::io::{self, Write}; +use runtime::{PermissionMode, ResolvedPermissionMode, ConfigLoader, PermissionPolicy}; +use tools::GlobalToolRegistry; + +""" + +with open("rust/crates/rusty-claude-cli/src/permissions/prompter.rs", "w") as f: + f.write(prefix + content) + +print("done") diff --git a/fix_stream_client.py b/fix_stream_client.py new file mode 100644 index 00000000..f29c0faa --- /dev/null +++ b/fix_stream_client.py @@ -0,0 +1,20 @@ +import os + +with open('rust/crates/rusty-claude-cli/src/execution/stream.rs', 'r') as f: + stream_content = f.read() + +import re + +# extract consume_stream from stream.rs +match = re.search(r'impl AnthropicRuntimeClient \{.*?\n\}', stream_content, re.MULTILINE | re.DOTALL) +if match: + consume_stream_code = match.group(0) + stream_content = stream_content.replace(consume_stream_code, "") + + with open('rust/crates/rusty-claude-cli/src/execution/stream.rs', 'w') as f: + f.write(stream_content) + + with open('rust/crates/rusty-claude-cli/src/execution/client.rs', 'a') as f: + f.write("\n" + consume_stream_code + "\n") + +print("Fixed stream and client") diff --git a/make_pub_crate.py b/make_pub_crate.py new file mode 100644 index 00000000..625e10b8 --- /dev/null +++ b/make_pub_crate.py @@ -0,0 +1,37 @@ +import os +import re + +files = [ + 'rust/crates/rusty-claude-cli/src/execution/client.rs', + 'rust/crates/rusty-claude-cli/src/execution/stream.rs', + 'rust/crates/rusty-claude-cli/src/execution/executor.rs' +] + +for file in files: + with open(file, 'r') as f: + content = f.read() + + # Change fn to pub(crate) fn + content = re.sub(r'^(async )?fn ', r'pub(crate) \1fn ', content, flags=re.MULTILINE) + content = re.sub(r'pub\(crate\) pub\(crate\) ', r'pub(crate) ', content) + + # Inside impl blocks, we also want pub(crate) fn + content = re.sub(r'^(\s+)(async )?fn ', r'\1pub(crate) \2fn ', content, flags=re.MULTILINE) + + # Structs + content = re.sub(r'^struct ', r'pub(crate) struct ', content, flags=re.MULTILINE) + + # Struct fields + # Match structs block and add pub(crate) to fields + def repl_struct(m): + inner = m.group(2) + # replace field definitions: "name: type," + inner = re.sub(r'^(\s+)([a-zA-Z0-9_]+)\s*:\s*', r'\1pub(crate) \2: ', inner, flags=re.MULTILINE) + return m.group(1) + inner + m.group(3) + + content = re.sub(r'(pub\(crate\) struct [a-zA-Z0-9_]+ \{)(.*?)(\})', repl_struct, content, flags=re.DOTALL) + + with open(file, 'w') as f: + f.write(content) + +print("Visibility updated.") diff --git a/move_tests.py b/move_tests.py new file mode 100644 index 00000000..7fd3af5f --- /dev/null +++ b/move_tests.py @@ -0,0 +1,114 @@ +import re +import os + +MAIN_TESTS_FILE = "rust/crates/rusty-claude-cli/src/main_tests.rs" +CONFIG_MODELS_FILE = "rust/crates/rusty-claude-cli/src/config/models.rs" +PERMISSIONS_PROMPTER_FILE = "rust/crates/rusty-claude-cli/src/permissions/prompter.rs" +UI_PROGRESS_FILE = "rust/crates/rusty-claude-cli/src/ui/progress.rs" + +with open(MAIN_TESTS_FILE, 'r') as f: + content = f.read() + +def remove_test(name, content): + pattern = r'#\[test\]\s+fn ' + name + r'\b.*?^ \}' + new_content, count = re.subn(pattern, '', content, flags=re.MULTILINE | re.DOTALL) + if count == 0: + print(f"Warning: Test {name} not found to remove.") + return new_content + +tests_for_models = [ + "resolves_known_model_aliases", + "user_defined_aliases_resolve_before_provider_dispatch", + "resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit", + "resolve_repl_model_falls_back_to_anthropic_model_env_when_default", + "resolve_repl_model_returns_default_when_env_unset_and_no_config", +] + +for test in tests_for_models: + content = remove_test(test, content) + +content = remove_test("permission_policy_uses_plugin_tool_permissions", content) +content = remove_test("describe_tool_progress_summarizes_known_tools", content) + +# Remove imports from main_tests.rs +imports_to_remove = [ + "resolve_model_alias,", "resolve_model_alias_with_config,", "resolve_repl_model,", + "permission_policy,", "describe_tool_progress," +] +for imp in imports_to_remove: + content = content.replace(imp, "") + +with open(MAIN_TESTS_FILE, 'w') as f: + f.write(content) + +# --- Append to src/config/models.rs --- +models_tests = [] +for test in tests_for_models: + with open(f"{test}.rs_chunk", "r") as f: + models_tests.append(" " + f.read().replace("\n", "\n ").strip()) + +models_test_mod = """ +#[cfg(test)] +mod tests { + use super::*; + use runtime::test_utils::{env_lock, temp_dir, with_current_dir}; + +""" + "\n\n".join(models_tests) + "\n}\n" + +with open(CONFIG_MODELS_FILE, 'a') as f: + f.write(models_test_mod) + +# --- Append to src/permissions/prompter.rs --- +with open("permission_policy_uses_plugin_tool_permissions.rs_chunk", "r") as f: + prompter_test = " " + f.read().replace("\n", "\n ").strip() + +prompter_test_mod = """ +#[cfg(test)] +mod tests { + use super::*; + use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; + use serde_json::json; + use tools::GlobalToolRegistry; + use runtime::{PermissionMode, RuntimeFeatureConfig}; + + fn registry_with_plugin_tool() -> GlobalToolRegistry { + GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "plugin-demo@external", + "plugin-demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin payload".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + }), + }, + "echo".to_string(), + PluginToolPermission::WorkspaceWrite, + )]) + } + +""" + prompter_test + "\n}\n" + +with open(PERMISSIONS_PROMPTER_FILE, 'a') as f: + f.write(prompter_test_mod) + +# --- Append to src/ui/progress.rs --- +with open("describe_tool_progress_summarizes_known_tools.rs_chunk", "r") as f: + progress_test = " " + f.read().replace("\n", "\n ").strip() + +progress_test_mod = """ +#[cfg(test)] +mod tests { + use super::*; + +""" + progress_test + "\n}\n" + +with open(UI_PROGRESS_FILE, 'a') as f: + f.write(progress_test_mod) + +print("Done moving tests.") diff --git a/openspec/.DS_Store b/openspec/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..183f8f7542e5075b2253b16cc945d50237614fe1 GIT binary patch literal 6148 zcmeHKJ5Iwu5SQ4z{E1dA!xvFYh059Tv- zHpe6B-r9RO-fKPd2Fk*DL2zG!LEMUw%UkgUGz$El2f$P@2*LxgAAv}N8DrpI8TbY+ CflogG literal 0 HcmV?d00001 diff --git a/openspec/changes/refactor-main-module-split/.openspec.yaml b/openspec/changes/refactor-main-module-split/.openspec.yaml new file mode 100644 index 00000000..12e66c27 --- /dev/null +++ b/openspec/changes/refactor-main-module-split/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-30 diff --git a/openspec/changes/refactor-main-module-split/design.md b/openspec/changes/refactor-main-module-split/design.md new file mode 100644 index 00000000..a1dfd000 --- /dev/null +++ b/openspec/changes/refactor-main-module-split/design.md @@ -0,0 +1,62 @@ +## Context + +The `rusty-claude-cli` application currently suffers from having a massive entry point. The `src/main.rs` file is over 6,200 lines long, combining CLI argument parsing, configuration loading, API interaction, terminal UI rendering, tool execution, and permission prompting. This violates the Single Responsibility Principle, making the codebase difficult to navigate, test, and maintain. Compiling a single massive file also creates a bottleneck for the Rust compiler. + +## Goals / Non-Goals + +**Goals:** +- Break down `src/main.rs` into smaller, logically grouped modules following Domain-Driven Design (DDD). +- Establish clear dependency boundaries between these new modules. +- Maintain 100% backward compatibility with the existing CLI behavior. +- Ensure all existing tests pass (`cargo test`). +- Improve incremental compilation times. + +**Non-Goals:** +- Introducing any new CLI features, commands, or behaviors. +- Rewriting the internal logic of functions (beyond fixing visibility `pub(crate)` and imports). +- Modifying the underlying `api`, `runtime`, or `tools` crates. + +## Decisions + +### 1. Target Directory Structure +We will adopt the following module structure: +- `src/main.rs`: Stripped down to the bare minimum. Contains `fn main()`, top-level `mod` declarations, and the root routing logic. +- `src/cli/`: Handles command-line input. + - `parser.rs`: `parse()`, `CliAction`, `parse_acp_args()`, etc. + - `suggestions.rs`: Spell checking and command suggestions (`suggest_slash_commands`, `levenshtein_distance`). +- `src/config/`: Configuration and model validation. + - `models.rs`: `resolve_model_alias()`, `validate_model_syntax()`, etc. +- `src/execution/`: Core engine logic. + - `executor.rs`: `CliToolExecutor`, `execute_runtime_tool()`. + - `client.rs`: `AnthropicRuntimeClient`, `build_runtime()`. + - `stream.rs`: API streaming responses (`consume_stream()`, `response_to_events()`). +- `src/permissions/`: Security policies. + - `prompter.rs`: `CliPermissionPrompter`, `permission_policy()`. +- `src/diagnostics/`: System health checks. + - `mod.rs`: `DiagnosticCheck`, `DiagnosticLevel`. +- `src/ui/`: Terminal interactions. + - `progress.rs`: `CliHookProgressReporter`, `describe_tool_progress()`. + +*Rationale:* This structure separates concerns naturally. The `cli` handles user input, `config` manages state, `execution` orchestrates the logic, and `ui` handles the output. + +### 2. Incremental Extraction Strategy +Instead of moving all code in one massive cut-and-paste operation, the refactoring will proceed in topological order from the leaves to the root: +1. Extract leaf nodes with few dependencies (e.g., `diagnostics`, `config/models.rs`, `cli/suggestions.rs`). +2. Extract intermediate nodes (e.g., `permissions`, `ui/progress.rs`). +3. Extract core components (`execution`). +4. Extract the top-level parser (`cli/parser.rs`). +5. Update `main.rs` to route to these modules. + +*Rationale:* Attempting to split everything simultaneously often leads to overwhelming compiler errors regarding missing imports or circular dependencies. Step-by-step extraction ensures that `cargo check` can validate each step. + +### 3. Visibility Changes +Moved structures and functions that were previously private within `main.rs` will be updated to `pub(crate)` so they can be accessed across the new module boundaries within the crate, without exposing them publicly outside the crate. + +## Risks / Trade-offs + +- **Risk: Circular Dependencies.** Code currently co-located in `main.rs` might have hidden cyclic dependencies that will become apparent when split into separate files. + - *Mitigation:* Ensure a strict dependency graph: `cli` depends on `execution` and `config`; `execution` depends on `permissions`, `ui`, and `config`. If cycles are found, we may need to introduce shared generic interfaces or move shared types to a common `types.rs` or `models.rs` module. +- **Risk: Broken Unit Tests.** Tests residing at the bottom of `main.rs` might break when functions are moved. + - *Mitigation:* Move unit tests alongside the code they test into their respective new modules. Verify with `cargo test` after each module extraction. +- **Risk: Extensive Import Updates.** Every file will need its `use` statements heavily updated. + - *Mitigation:* Rely on the Rust compiler and IDE diagnostics to find and fix missing imports iteratively. diff --git a/openspec/changes/refactor-main-module-split/proposal.md b/openspec/changes/refactor-main-module-split/proposal.md new file mode 100644 index 00000000..9434ad5d --- /dev/null +++ b/openspec/changes/refactor-main-module-split/proposal.md @@ -0,0 +1,30 @@ +## Why + +The `rusty-claude-cli/src/main.rs` file has grown to over 6200 lines, violating the Single Responsibility Principle and becoming a "God File". This high coupling makes the code difficult to read, maintain, and test. It also slows down compilation because all logic resides in a single file. Refactoring is necessary now to improve maintainability, reduce developer cognitive load, and enable easier addition of future features. + +## What Changes + +- Split `main.rs` into smaller, focused modules based on Domain-Driven Design (DDD) boundaries. +- Create `cli` module for command-line argument parsing, prompt actions, and spelling suggestions. +- Create `config` module for model resolution and configuration management. +- Create `execution` module for the core tool execution engine (`CliToolExecutor`) and API client runtime interactions (`AnthropicRuntimeClient`). +- Create `permissions` module for security, policy enforcement, and permission prompting (`CliPermissionPrompter`). +- Create `diagnostics` module for diagnostic checks and error states. +- Create `ui` module for terminal rendering, hooks, and progress tracking. +- Shrink `main.rs` so it only contains the entry point (`main()` function), global initialization, and top-level routing. + +## Capabilities + +### New Capabilities +- `main-module-refactoring`: Refactoring of the `main.rs` file into modular components (`cli`, `config`, `execution`, `permissions`, `diagnostics`, `ui`) without altering the external CLI behavior. + +### Modified Capabilities +- (None - This is a pure refactoring change; no external requirements or behaviors are changing.) + +## Impact + +- `rusty-claude-cli/src/main.rs` will be heavily reduced in size. +- Numerous new files and folders will be created under `rusty-claude-cli/src/` (e.g., `src/cli/`, `src/execution/`, etc.). +- Internal module imports (`use` statements) across the entire `rusty-claude-cli` crate will be updated to reflect the new structure. +- Incremental compilation speed should improve. +- Unit tests currently residing in `main.rs` or `main_tests.rs` might need to be relocated to their respective new modules. diff --git a/openspec/changes/refactor-main-module-split/specs/main-module-refactoring/spec.md b/openspec/changes/refactor-main-module-split/specs/main-module-refactoring/spec.md new file mode 100644 index 00000000..4a965eaa --- /dev/null +++ b/openspec/changes/refactor-main-module-split/specs/main-module-refactoring/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Modular CLI Core +The `rusty-claude-cli` application SHALL be structured into multiple distinct modules (`cli`, `config`, `execution`, `permissions`, `diagnostics`, `ui`) instead of a single monolithic `main.rs` file. + +#### Scenario: Unchanged Application Behavior +- **WHEN** the application is compiled and executed +- **THEN** all CLI commands, arguments, and interactive features function exactly as they did before the refactoring. + +#### Scenario: Unchanged Test Suite +- **WHEN** `cargo test` is run for the `rusty-claude-cli` crate +- **THEN** all existing unit and integration tests pass successfully. + +#### Scenario: Code Compilation +- **WHEN** `cargo check` is run +- **THEN** the crate compiles without any new warnings or errors related to module structure or unresolved imports. + +#### Scenario: Code Formatting +- **WHEN** `cargo fmt --all` is run +- **THEN** the new module structure complies with the project's formatting standards. diff --git a/openspec/changes/refactor-main-module-split/tasks.md b/openspec/changes/refactor-main-module-split/tasks.md new file mode 100644 index 00000000..42e2cda1 --- /dev/null +++ b/openspec/changes/refactor-main-module-split/tasks.md @@ -0,0 +1,34 @@ +## 1. Initial Setup & Leaf Node Extraction + +- [x] 1.1 Create the directory structure: `src/cli`, `src/config`, `src/execution`, `src/permissions`, `src/diagnostics`, `src/ui`. +- [x] 1.2 Create `mod.rs` files in each new directory to expose them to `main.rs`. +- [x] 1.3 Extract `DiagnosticCheck` and `DiagnosticLevel` related code into `src/diagnostics/mod.rs`. Update visibility to `pub(crate)` and fix imports in `main.rs`. +- [x] 1.4 Extract model validation and alias resolution (`resolve_model_alias`, `validate_model_syntax`) into `src/config/models.rs`. Fix imports in `main.rs`. +- [x] 1.5 Extract spelling and suggestion logic (`suggest_slash_commands`, `levenshtein_distance`) into `src/cli/suggestions.rs`. Fix imports in `main.rs`. +- [x] 1.6 Verify compilation (`cargo check`) and format (`cargo fmt --all`) after leaf node extraction. + +## 2. Intermediate Node Extraction + +- [x] 2.1 Extract terminal UI progress and hooks (`CliHookProgressReporter`, `describe_tool_progress`) into `src/ui/progress.rs`. Fix imports. +- [x] 2.2 Extract permission policies and prompting (`CliPermissionPrompter`, `permission_policy`) into `src/permissions/prompter.rs`. Fix imports. +- [x] 2.3 Move any relevant unit tests from the bottom of `main.rs` to their new respective modules (`diagnostics`, `config`, `cli/suggestions`, `ui`, `permissions`). +- [x] 2.4 Verify compilation (`cargo check`) and run tests (`cargo test`). + +## 3. Core Engine Extraction + +- [x] 3.1 Extract API stream consumption logic (`consume_stream`, `response_to_events`) into `src/execution/stream.rs`. Fix imports. +- [x] 3.2 Extract the API client wrapper (`AnthropicRuntimeClient`, `build_runtime`) into `src/execution/client.rs`. Fix imports. +- [x] 3.3 Extract the tool execution engine (`CliToolExecutor`, `execute_runtime_tool`) into `src/execution/executor.rs`. This may require careful handling of circular dependencies with `ui` and `permissions`. +- [x] 3.4 Move relevant tests from `main.rs` to `src/execution/`. +- [x] 3.5 Verify compilation (`cargo check`) and tests (`cargo test`). + +## 4. CLI Parser Extraction + +- [ ] 4.1 Extract command line argument parsing (`parse`, `CliAction`, `parse_acp_args`, etc.) into `src/cli/parser.rs`. Fix imports. +- [ ] 4.2 Move CLI parsing tests to `src/cli/parser.rs`. +- [ ] 4.3 Verify compilation (`cargo check`) and tests (`cargo test`). + +## 5. Final Cleanup + +- [ ] 5.1 Clean up `src/main.rs`, removing unused imports and dead code. Ensure it only acts as the main entry point and module router. +- [ ] 5.2 Run a final `cargo check`, `cargo test`, and `cargo fmt --all` to guarantee no regressions. diff --git a/permission_policy_uses_plugin_tool_permissions.rs_chunk b/permission_policy_uses_plugin_tool_permissions.rs_chunk new file mode 100644 index 00000000..a73cc994 --- /dev/null +++ b/permission_policy_uses_plugin_tool_permissions.rs_chunk @@ -0,0 +1,12 @@ +#[test] + fn permission_policy_uses_plugin_tool_permissions() { + let feature_config = runtime::RuntimeFeatureConfig::default(); + let policy = permission_policy( + PermissionMode::ReadOnly, + &feature_config, + ®istry_with_plugin_tool(), + ) + .expect("permission policy should build"); + let required = policy.required_mode_for("plugin_echo"); + assert_eq!(required, PermissionMode::WorkspaceWrite); + } \ No newline at end of file diff --git a/resolve_repl_model_falls_back_to_anthropic_model_env_when_default.rs_chunk b/resolve_repl_model_falls_back_to_anthropic_model_env_when_default.rs_chunk new file mode 100644 index 00000000..60eee673 --- /dev/null +++ b/resolve_repl_model_falls_back_to_anthropic_model_env_when_default.rs_chunk @@ -0,0 +1,19 @@ +#[test] + fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + let config_home = root.join("config"); + fs::create_dir_all(&config_home).expect("config home dir"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::set_var("ANTHROPIC_MODEL", "sonnet"); + + let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); + + assert_eq!(resolved, "claude-sonnet-4-6"); + + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::remove_var("CLAW_CONFIG_HOME"); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } \ No newline at end of file diff --git a/resolve_repl_model_returns_default_when_env_unset_and_no_config.rs_chunk b/resolve_repl_model_returns_default_when_env_unset_and_no_config.rs_chunk new file mode 100644 index 00000000..7728cde5 --- /dev/null +++ b/resolve_repl_model_returns_default_when_env_unset_and_no_config.rs_chunk @@ -0,0 +1,17 @@ +#[test] + fn resolve_repl_model_returns_default_when_env_unset_and_no_config() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + let config_home = root.join("config"); + fs::create_dir_all(&config_home).expect("config home dir"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("ANTHROPIC_MODEL"); + + let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); + + assert_eq!(resolved, DEFAULT_MODEL); + + std::env::remove_var("CLAW_CONFIG_HOME"); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } \ No newline at end of file diff --git a/resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit.rs_chunk b/resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit.rs_chunk new file mode 100644 index 00000000..bb12d14d --- /dev/null +++ b/resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit.rs_chunk @@ -0,0 +1,8 @@ +#[test] + fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { + let user_model = "claude-sonnet-4-6".to_string(); + + let resolved = resolve_repl_model(user_model); + + assert_eq!(resolved, "claude-sonnet-4-6"); + } \ No newline at end of file diff --git a/resolves_known_model_aliases.rs_chunk b/resolves_known_model_aliases.rs_chunk new file mode 100644 index 00000000..a532303c --- /dev/null +++ b/resolves_known_model_aliases.rs_chunk @@ -0,0 +1,7 @@ +#[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"); + } \ No newline at end of file diff --git a/rm_from_main.py b/rm_from_main.py new file mode 100644 index 00000000..1be8a216 --- /dev/null +++ b/rm_from_main.py @@ -0,0 +1,14 @@ +import re + +MAIN_FILE = "rust/crates/rusty-claude-cli/src/main.rs" + +with open(MAIN_FILE, 'r') as f: + content = f.read() + +# remove AnthropicRuntimeClient from main.rs +content = re.sub(r'struct AnthropicRuntimeClient \{.*?\n\}\n\nimpl AnthropicRuntimeClient \{.*?\n\}\n\nimpl runtime::Client for AnthropicRuntimeClient \{.*?\n\}', '', content, flags=re.DOTALL | re.MULTILINE) + +with open(MAIN_FILE, 'w') as f: + f.write(content) + +print("Removed from main.rs") diff --git a/rust/.DS_Store b/rust/.DS_Store index aa96e908709683a0e85cc291b2628123158cb147..9f990dfd0d67580c77df269de8e2d1871dbe2781 100644 GIT binary patch literal 8196 zcmeHM&ubGw6n-0{38>T_3OT6kKVUHk;zeCcJXr8l1P>nCq$LR?&Dx~cgXn^%_TjF3h?^i<6;{a8)>VS4%}1;0Gr2S)$kd0fQs>r4UCPnRiWrp zR}Y>E^-PK3>2UachQkKNM%wCd@^mI1=_m1Q@|;ZRDf&u0!>nl zS~Mr?_jahjg|W3HqTQ)fs;y2Hp5o1mW0eQ*-d{>X&5wWz8+=+|vK60m(t|OdTly@) zc&~I{+g{1z=a-m=h;MiL9MTb1{%N1{AMNMIdB!Q|Ql|x4q#kuGK3;HbI9l@i{p|eo z!%s@5A^l6fqeWQ!b8nbGk7u2l;M>56RxQqyyO%^rp755wzKX|F^dq@tqbxppG3W7U z^2rd**<8+?L_fkSdAykTb@k~-#lu4M$1@#a=Z}y8JIo|?s7*CH`?<%`Cv&fUkLOYx zE8jlNZ|4m-FOPE>FQTY@{tbXn)}#U|?^r#8pbx-Y)l>ER5W5$nF23 O8eDSwzt&;@-TwgHCK-VM delta 139 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGjdEU6q~50D9QrjfW=Z7N^;T-gOl@f3nmsW zXJpx&BQ~3HV?zS#Vs;J=L1v&jAQ0dN60RWq8w Vec { + let mut candidates = slash_command_specs() + .iter() + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::>() + }) + .collect::>(); + candidates.sort(); + candidates.dedup(); + let candidate_refs = candidates.iter().map(String::as_str).collect::>(); + ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) + .into_iter() + .map(str::to_string) + .collect() +} + +pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { + ranked_suggestions(input, candidates).into_iter().next() +} + +pub(crate) fn suggest_similar_subcommand(input: &str) -> Option> { + const KNOWN_SUBCOMMANDS: &[&str] = &[ + "help", + "version", + "status", + "sandbox", + "doctor", + "state", + "dump-manifests", + "bootstrap-plan", + "agents", + "mcp", + "skills", + "system-prompt", + "acp", + "init", + "export", + "prompt", + ]; + + let normalized_input = input.to_ascii_lowercase(); + let mut ranked = KNOWN_SUBCOMMANDS + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; + let substring_match = normalized_candidate.contains(&normalized_input) + || normalized_input.contains(&normalized_candidate); + ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked.dedup_by(|left, right| left.1 == right.1); + let suggestions = ranked + .into_iter() + .map(|(_, candidate)| candidate.to_string()) + .take(3) + .collect::>(); + (!suggestions.is_empty()).then_some(suggestions) +} + +pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(l, r)| l == r) + .count() +} + +pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool { + !input.is_empty() + && input + .chars() + .all(|ch| ch.is_ascii_alphabetic() || ch == '-') +} + +pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { + let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); + let mut ranked = candidates + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_bonus = usize::from( + !(normalized_candidate.starts_with(&normalized_input) + || normalized_input.starts_with(&normalized_candidate)), + ); + let score = distance + prefix_bonus; + (score <= 4).then_some((score, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked + .into_iter() + .map(|(_, candidate)| candidate) + .take(3) + .collect() +} + +pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize { + if left.is_empty() { + return right.chars().count(); + } + if right.is_empty() { + return left.chars().count(); + } + + let right_chars = right.chars().collect::>(); + let mut previous = (0..=right_chars.len()).collect::>(); + let mut current = vec![0; right_chars.len() + 1]; + + for (left_index, left_char) in left.chars().enumerate() { + current[0] = left_index + 1; + for (right_index, right_char) in right_chars.iter().enumerate() { + let substitution_cost = usize::from(left_char != *right_char); + current[right_index + 1] = (previous[right_index + 1] + 1) + .min(current[right_index] + 1) + .min(previous[right_index] + substitution_cost); + } + previous.clone_from(¤t); + } + + previous[right_chars.len()] +} diff --git a/rust/crates/rusty-claude-cli/src/config/mod.rs b/rust/crates/rusty-claude-cli/src/config/mod.rs new file mode 100644 index 00000000..3605c984 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/config/mod.rs @@ -0,0 +1 @@ +pub(crate) mod models; diff --git a/rust/crates/rusty-claude-cli/src/config/models.rs b/rust/crates/rusty-claude-cli/src/config/models.rs new file mode 100644 index 00000000..924db1f8 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/config/models.rs @@ -0,0 +1,250 @@ +use crate::DEFAULT_MODEL; +use runtime::ConfigLoader; +use std::env; +pub(crate) 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, + } +} + +/// Resolve a model name through user-defined config aliases first, then fall +/// back to the built-in alias table. This is the entry point used wherever a +/// user-supplied model string is about to be dispatched to a provider. +pub(crate) fn resolve_model_alias_with_config(model: &str) -> String { + let trimmed = model.trim(); + if let Some(resolved) = config_alias_for_current_dir(trimmed) { + return resolve_model_alias(&resolved).to_string(); + } + resolve_model_alias(trimmed).to_string() +} + +/// Validate model syntax at parse time. +/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. +/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. +pub(crate) fn validate_model_syntax(model: &str) -> Result<(), String> { + let trimmed = model.trim(); + if trimmed.is_empty() { + return Err("model string cannot be empty".to_string()); + } + // Known aliases are always valid + match trimmed { + "opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini" + | "grok-3-mini" | "kimi" | "glm-5" => return Ok(()), + _ => {} + } + + // Dashscope and xAI models that don't use provider/model syntax + if trimmed.starts_with("qwen-") + || trimmed.starts_with("ali-") + || trimmed.starts_with("glm-") + || trimmed.starts_with("kimi-") + { + return Ok(()); + } + // Check for spaces (malformed) + if trimmed.contains(' ') { + return Err(format!( + "invalid model syntax: '{trimmed}' contains spaces. Use provider/model format or known alias" + )); + } + // Check provider/model format: provider_id/model_id + let parts: Vec<&str> = trimmed.split('/').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + // #154: hint if the model looks like it belongs to a different provider + let mut err_msg = format!( + "invalid model syntax: '{trimmed}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)" + ); + if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { + err_msg.push_str("\nDid you mean `openai/"); + err_msg.push_str(trimmed); + err_msg.push_str("`? (Requires OPENAI_API_KEY env var)"); + } else if trimmed.starts_with("qwen") { + err_msg.push_str("\nDid you mean `qwen/"); + err_msg.push_str(trimmed); + err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)"); + } else if trimmed.starts_with("grok") { + err_msg.push_str("\nDid you mean `xai/"); + err_msg.push_str(trimmed); + err_msg.push_str("`? (Requires XAI_API_KEY env var)"); + } + return Err(err_msg); + } + Ok(()) +} + +pub(crate) fn config_alias_for_current_dir(alias: &str) -> Option { + if alias.is_empty() { + return None; + } + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + let config = loader.load().ok()?; + config.aliases().get(alias).cloned() +} + +pub(crate) fn config_model_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader.load().ok()?.model().map(ToOwned::to_owned) +} + +pub(crate) fn resolve_repl_model(cli_model: String) -> String { + if cli_model != DEFAULT_MODEL { + return cli_model; + } + if let Some(env_model) = env::var("ANTHROPIC_MODEL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return resolve_model_alias_with_config(&env_model); + } + if let Some(config_model) = config_model_for_current_dir() { + return resolve_model_alias_with_config(&config_model); + } + cli_model +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::{Mutex, MutexGuard, OnceLock}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::path::{Path, PathBuf}; + use std::fs; + + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn cwd_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn temp_dir() -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}")) + } + + fn with_current_dir(cwd: &Path, f: impl FnOnce() -> T) -> T { + let _guard = cwd_lock(); + let previous = std::env::current_dir().expect("cwd should load"); + std::env::set_current_dir(cwd).expect("cwd should change"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + std::env::set_current_dir(previous).expect("cwd should restore"); + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } + } + + + #[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 user_defined_aliases_resolve_before_provider_dispatch() { + // given + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + // when + let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast")); + let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart")); + let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap")); + let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model")); + let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku")); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + // then + assert_eq!(direct, "claude-haiku-4-5-20251213"); + assert_eq!(chained, "claude-opus-4-6"); + assert_eq!(cross_provider, "grok-3-mini"); + assert_eq!(unknown, "unknown-model"); + assert_eq!(builtin, "claude-haiku-4-5-20251213"); + } + + #[test] + fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { + let user_model = "claude-sonnet-4-6".to_string(); + + let resolved = resolve_repl_model(user_model); + + assert_eq!(resolved, "claude-sonnet-4-6"); + } + + #[test] + fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + let config_home = root.join("config"); + fs::create_dir_all(&config_home).expect("config home dir"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::set_var("ANTHROPIC_MODEL", "sonnet"); + + let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); + + assert_eq!(resolved, "claude-sonnet-4-6"); + + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::remove_var("CLAW_CONFIG_HOME"); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn resolve_repl_model_returns_default_when_env_unset_and_no_config() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + let config_home = root.join("config"); + fs::create_dir_all(&config_home).expect("config home dir"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("ANTHROPIC_MODEL"); + + let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); + + assert_eq!(resolved, DEFAULT_MODEL); + + std::env::remove_var("CLAW_CONFIG_HOME"); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } +} diff --git a/rust/crates/rusty-claude-cli/src/diagnostics/mod.rs b/rust/crates/rusty-claude-cli/src/diagnostics/mod.rs new file mode 100644 index 00000000..ddebe9a1 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/diagnostics/mod.rs @@ -0,0 +1,695 @@ +use crate::{ + classify_session_lifecycle_for, parse_git_status_metadata, parse_git_workspace_summary, + StatusContext, BUILD_TARGET, DEFAULT_DATE, GIT_SHA, VERSION, +}; +use crate::{ + resolve_cli_auth_source_for_cwd, CliOutputFormat, DEPRECATED_INSTALL_COMMAND, + OFFICIAL_REPO_SLUG, OFFICIAL_REPO_URL, +}; +use plugins::PluginManager; +use runtime::{ + check_base_commit, format_stale_base_warning, resolve_expected_base, resolve_sandbox_status, + ConfigLoader, McpServerManager, ProjectContext, RuntimeConfig, +}; +use runtime::{load_oauth_credentials, McpServer, McpServerSpec, McpTool}; +use serde_json::{json, Map, Value}; +use std::env; +use std::path::{Path, PathBuf}; +use tools::{execute_tool, mvp_tool_specs}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DiagnosticLevel { + Ok, + Warn, + Fail, +} + +impl DiagnosticLevel { + fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } + + fn is_failure(self) -> bool { + matches!(self, Self::Fail) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiagnosticCheck { + name: &'static str, + level: DiagnosticLevel, + summary: String, + details: Vec, + data: Map, +} + +impl DiagnosticCheck { + fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { + Self { + name, + level, + summary: summary.into(), + details: Vec::new(), + data: Map::new(), + } + } + + fn with_details(mut self, details: Vec) -> Self { + self.details = details; + self + } + + fn with_data(mut self, data: Map) -> Self { + self.data = data; + self + } + + pub(crate) fn json_value(&self) -> Value { + let mut value = Map::from_iter([ + ( + "name".to_string(), + Value::String(self.name.to_ascii_lowercase()), + ), + ( + "status".to_string(), + Value::String(self.level.label().to_string()), + ), + ("summary".to_string(), Value::String(self.summary.clone())), + ( + "details".to_string(), + Value::Array( + self.details + .iter() + .cloned() + .map(Value::String) + .collect::>(), + ), + ), + ]); + value.extend(self.data.clone()); + Value::Object(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DoctorReport { + checks: Vec, +} + +impl DoctorReport { + fn counts(&self) -> (usize, usize, usize) { + ( + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Ok) + .count(), + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Warn) + .count(), + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Fail) + .count(), + ) + } + + fn has_failures(&self) -> bool { + self.checks.iter().any(|check| check.level.is_failure()) + } + + pub(crate) fn render(&self) -> String { + let (ok_count, warn_count, fail_count) = self.counts(); + let mut lines = vec![ + "Doctor".to_string(), + format!( + "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" + ), + ]; + lines.extend(self.checks.iter().map(render_diagnostic_check)); + lines.join("\n\n") + } + + pub(crate) fn json_value(&self) -> Value { + let report = self.render(); + let (ok_count, warn_count, fail_count) = self.counts(); + json!({ + "kind": "doctor", + "message": report, + "report": report, + "has_failures": self.has_failures(), + "summary": { + "total": self.checks.len(), + "ok": ok_count, + "warnings": warn_count, + "failures": fail_count, + }, + "checks": self + .checks + .iter() + .map(DiagnosticCheck::json_value) + .collect::>(), + }) + } +} + +fn render_diagnostic_check(check: &DiagnosticCheck) -> String { + let mut lines = vec![format!( + "{}\n Status {}\n Summary {}", + check.name, + check.level.label(), + check.summary + )]; + if !check.details.is_empty() { + lines.push(" Details".to_string()); + lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); + } + lines.join("\n") +} + +pub(crate) fn render_doctor_report() -> Result> { + let cwd = env::current_dir()?; + let config_loader = ConfigLoader::default_for(&cwd); + let config = config_loader.load(); + let discovered_config = config_loader.discover(); + 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()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let empty_config = runtime::RuntimeConfig::empty(); + let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); + let context = StatusContext { + cwd: cwd.clone(), + session_path: None, + loaded_config_files: config + .as_ref() + .ok() + .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), + discovered_config_files: discovered_config.len(), + memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, + git_summary, + session_lifecycle: classify_session_lifecycle_for(&cwd), + sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), + // Doctor path has its own config check; StatusContext here is only + // fed into health renderers that don't read config_load_error. + config_load_error: config.as_ref().err().map(ToString::to_string), + }; + Ok(DoctorReport { + checks: vec![ + check_auth_health(), + check_config_health(&config_loader, config.as_ref()), + check_install_source_health(), + check_workspace_health(&context), + check_sandbox_health(&context.sandbox_status), + check_system_health(&cwd, config.as_ref().ok()), + ], + }) +} + +pub(crate) fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { + let report = render_doctor_report()?; + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&report.json_value())?); + } + } + if report.has_failures() { + return Err("doctor found failing checks".into()); + } + Ok(()) +} + +/// Starts a minimal Model Context Protocol server that exposes claw's +/// built-in tools over stdio. +/// +/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are +/// dispatched through [`tools::execute_tool`], so this server exposes exactly +/// Read `.claw/worker-state.json` from the current working directory and print it. +/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs` +/// atomically writes state transitions here so external observers (clawhip, orchestrators) +/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary. +pub(crate) fn run_worker_state( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let cwd = env::current_dir()?; + let state_path = cwd.join(".claw").join("worker-state.json"); + if !state_path.exists() { + // #139: this error used to say "run a worker first" without telling + // callers how to run one. "worker" is an internal concept (there is + // no `claw worker` subcommand), so claws/CI had no discoverable path + // from the error to a fix. Emit an actionable, structured error that + // names the two concrete commands that produce worker state. + // + // Format in both text and JSON modes is stable so scripts can match: + // error: no worker state file found at + // Hint: worker state is written by the interactive REPL or a non-interactive prompt. + // Run: claw # start the REPL (writes state on first turn) + // Or: claw prompt # run one non-interactive turn + // Then rerun: claw state [--output-format json] + return Err(format!( + "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", + path = state_path.display() + ) + .into()); + } + let raw = std::fs::read_to_string(&state_path)?; + match output_format { + CliOutputFormat::Text => println!("{raw}"), + CliOutputFormat::Json => { + // Validate it parses as JSON before re-emitting + let _: serde_json::Value = serde_json::from_str(&raw)?; + println!("{raw}"); + } + } + Ok(()) +} + +/// the same surface the in-process agent loop uses. +pub(crate) fn run_mcp_serve() -> Result<(), Box> { + let tools = mvp_tool_specs() + .into_iter() + .map(|spec| McpTool { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: Some(spec.input_schema), + annotations: None, + meta: None, + }) + .collect(); + + let spec = McpServerSpec { + server_name: "claw".to_string(), + server_version: VERSION.to_string(), + tools, + tool_handler: Box::new(execute_tool), + }; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async move { + let mut server = McpServer::new(spec); + server.run().await + })?; + Ok(()) +} + +#[allow(clippy::too_many_lines)] +fn check_auth_health() -> DiagnosticCheck { + let api_key_present = env::var("ANTHROPIC_API_KEY") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + let env_details = format!( + "Environment api_key={} auth_token={}", + if api_key_present { "present" } else { "absent" }, + if auth_token_present { + "present" + } else { + "absent" + } + ); + + match load_oauth_credentials() { + Ok(Some(token_set)) => DiagnosticCheck::new( + "Auth", + if api_key_present || auth_token_present { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if api_key_present || auth_token_present { + "supported auth env vars are configured; legacy saved OAuth is ignored" + } else { + "legacy saved OAuth credentials are present but unsupported" + }, + ) + .with_details(vec![ + env_details, + format!( + "Legacy OAuth expires_at={} refresh_token={} scopes={}", + token_set + .expires_at + .map_or_else(|| "".to_string(), |value| value.to_string()), + if token_set.refresh_token.is_some() { + "present" + } else { + "absent" + }, + if token_set.scopes.is_empty() { + "".to_string() + } else { + token_set.scopes.join(",") + } + ), + "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed" + .to_string(), + ]) + .with_data(Map::from_iter([ + ("api_key_present".to_string(), json!(api_key_present)), + ("auth_token_present".to_string(), json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(), json!(true)), + ( + "legacy_saved_oauth_expires_at".to_string(), + json!(token_set.expires_at), + ), + ( + "legacy_refresh_token_present".to_string(), + json!(token_set.refresh_token.is_some()), + ), + ("legacy_scopes".to_string(), json!(token_set.scopes)), + ])), + Ok(None) => DiagnosticCheck::new( + "Auth", + if api_key_present || auth_token_present { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if api_key_present || auth_token_present { + "supported auth env vars are configured" + } else { + "no supported auth env vars were found" + }, + ) + .with_details(vec![env_details]) + .with_data(Map::from_iter([ + ("api_key_present".to_string(), json!(api_key_present)), + ("auth_token_present".to_string(), json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(), json!(false)), + ("legacy_saved_oauth_expires_at".to_string(), Value::Null), + ("legacy_refresh_token_present".to_string(), json!(false)), + ("legacy_scopes".to_string(), json!(Vec::::new())), + ])), + Err(error) => DiagnosticCheck::new( + "Auth", + DiagnosticLevel::Fail, + format!("failed to inspect legacy saved credentials: {error}"), + ) + .with_data(Map::from_iter([ + ("api_key_present".to_string(), json!(api_key_present)), + ("auth_token_present".to_string(), json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(), Value::Null), + ("legacy_saved_oauth_expires_at".to_string(), Value::Null), + ("legacy_refresh_token_present".to_string(), Value::Null), + ("legacy_scopes".to_string(), Value::Null), + ("legacy_saved_oauth_error".to_string(), json!(error.to_string())), + ])), + } +} + +fn check_config_health( + config_loader: &ConfigLoader, + config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, +) -> DiagnosticCheck { + let discovered = config_loader.discover(); + let discovered_count = discovered.len(); + // Separate candidate paths that actually exist from those that don't. + // Showing non-existent paths as "Discovered file" implies they loaded + // but something went wrong, which is confusing. We only surface paths + // that exist on disk as discovered; non-existent ones are silently + // omitted from the display (they are just the standard search locations). + let present_paths: Vec = discovered + .iter() + .filter(|e| e.path.exists()) + .map(|e| e.path.display().to_string()) + .collect(); + let discovered_paths = discovered + .iter() + .map(|entry| entry.path.display().to_string()) + .collect::>(); + match config { + Ok(runtime_config) => { + let loaded_entries = runtime_config.loaded_entries(); + let loaded_count = loaded_entries.len(); + let present_count = present_paths.len(); + let mut details = vec![format!( + "Config files loaded {}/{}", + loaded_count, present_count + )]; + if let Some(model) = runtime_config.model() { + details.push(format!("Resolved model {model}")); + } + details.push(format!( + "MCP servers {}", + runtime_config.mcp().servers().len() + )); + if present_paths.is_empty() { + details.push("Discovered files (defaults active)".to_string()); + } else { + details.extend( + present_paths + .iter() + .map(|path| format!("Discovered file {path}")), + ); + } + DiagnosticCheck::new( + "Config", + DiagnosticLevel::Ok, + if present_count == 0 { + "no config files present; defaults are active" + } else { + "runtime config loaded successfully" + }, + ) + .with_details(details) + .with_data(Map::from_iter([ + ("discovered_files".to_string(), json!(present_paths)), + ("discovered_files_count".to_string(), json!(present_count)), + ("loaded_config_files".to_string(), json!(loaded_count)), + ("resolved_model".to_string(), json!(runtime_config.model())), + ( + "mcp_servers".to_string(), + json!(runtime_config.mcp().servers().len()), + ), + ])) + } + Err(error) => DiagnosticCheck::new( + "Config", + DiagnosticLevel::Fail, + format!("runtime config failed to load: {error}"), + ) + .with_details(if discovered_paths.is_empty() { + vec!["Discovered files ".to_string()] + } else { + discovered_paths + .iter() + .map(|path| format!("Discovered file {path}")) + .collect() + }) + .with_data(Map::from_iter([ + ("discovered_files".to_string(), json!(discovered_paths)), + ( + "discovered_files_count".to_string(), + json!(discovered_count), + ), + ("loaded_config_files".to_string(), json!(0)), + ("resolved_model".to_string(), Value::Null), + ("mcp_servers".to_string(), Value::Null), + ("load_error".to_string(), json!(error.to_string())), + ])), + } +} + +fn check_install_source_health() -> DiagnosticCheck { + DiagnosticCheck::new( + "Install source", + DiagnosticLevel::Ok, + format!( + "official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`" + ), + ) + .with_details(vec![ + format!("Official repo {OFFICIAL_REPO_URL}"), + "Recommended path build from this repo or use the upstream binary documented in README.md" + .to_string(), + format!( + "Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary" + ) + .to_string(), + ]) + .with_data(Map::from_iter([ + ("official_repo".to_string(), json!(OFFICIAL_REPO_URL)), + ( + "deprecated_install".to_string(), + json!(DEPRECATED_INSTALL_COMMAND), + ), + ( + "recommended_install".to_string(), + json!("build from source or follow the upstream binary instructions in README.md"), + ), + ])) +} + +fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { + let in_repo = context.project_root.is_some(); + DiagnosticCheck::new( + "Workspace", + if in_repo { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if in_repo { + format!( + "project root detected on branch {}", + context.git_branch.as_deref().unwrap_or("unknown") + ) + } else { + "current directory is not inside a git project".to_string() + }, + ) + .with_details(vec![ + format!("Cwd {}", context.cwd.display()), + format!( + "Project root {}", + context + .project_root + .as_ref() + .map_or_else(|| "".to_string(), |path| path.display().to_string()) + ), + format!( + "Git branch {}", + context.git_branch.as_deref().unwrap_or("unknown") + ), + format!("Git state {}", context.git_summary.headline()), + format!("Changed files {}", context.git_summary.changed_files), + format!( + "Memory files {} · config files loaded {}/{}", + context.memory_file_count, context.loaded_config_files, context.discovered_config_files + ), + ]) + .with_data(Map::from_iter([ + ("cwd".to_string(), json!(context.cwd.display().to_string())), + ( + "project_root".to_string(), + json!(context + .project_root + .as_ref() + .map(|path| path.display().to_string())), + ), + ("in_git_repo".to_string(), json!(in_repo)), + ("git_branch".to_string(), json!(context.git_branch)), + ( + "git_state".to_string(), + json!(context.git_summary.headline()), + ), + ( + "changed_files".to_string(), + json!(context.git_summary.changed_files), + ), + ( + "memory_file_count".to_string(), + json!(context.memory_file_count), + ), + ( + "loaded_config_files".to_string(), + json!(context.loaded_config_files), + ), + ( + "discovered_config_files".to_string(), + json!(context.discovered_config_files), + ), + ])) +} + +fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { + let degraded = status.enabled && !status.active; + let mut details = vec![ + format!("Enabled {}", status.enabled), + format!("Active {}", status.active), + format!("Supported {}", status.supported), + format!("Filesystem mode {}", status.filesystem_mode.as_str()), + format!("Filesystem live {}", status.filesystem_active), + ]; + if let Some(reason) = &status.fallback_reason { + details.push(format!("Fallback reason {reason}")); + } + DiagnosticCheck::new( + "Sandbox", + if degraded { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if degraded { + "sandbox was requested but is not currently active" + } else if status.active { + "sandbox protections are active" + } else { + "sandbox is not active for this session" + }, + ) + .with_details(details) + .with_data(Map::from_iter([ + ("enabled".to_string(), json!(status.enabled)), + ("active".to_string(), json!(status.active)), + ("supported".to_string(), json!(status.supported)), + ( + "namespace_supported".to_string(), + json!(status.namespace_supported), + ), + ( + "namespace_active".to_string(), + json!(status.namespace_active), + ), + ( + "network_supported".to_string(), + json!(status.network_supported), + ), + ("network_active".to_string(), json!(status.network_active)), + ( + "filesystem_mode".to_string(), + json!(status.filesystem_mode.as_str()), + ), + ( + "filesystem_active".to_string(), + json!(status.filesystem_active), + ), + ("allowed_mounts".to_string(), json!(status.allowed_mounts)), + ("in_container".to_string(), json!(status.in_container)), + ( + "container_markers".to_string(), + json!(status.container_markers), + ), + ("fallback_reason".to_string(), json!(status.fallback_reason)), + ])) +} + +fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck { + let default_model = config.and_then(runtime::RuntimeConfig::model); + let mut details = vec![ + format!("OS {} {}", env::consts::OS, env::consts::ARCH), + format!("Working dir {}", cwd.display()), + format!("Version {}", VERSION), + format!("Build target {}", BUILD_TARGET.unwrap_or("")), + format!("Git SHA {}", GIT_SHA.unwrap_or("")), + ]; + if let Some(model) = default_model { + details.push(format!("Default model {model}")); + } + DiagnosticCheck::new( + "System", + DiagnosticLevel::Ok, + "captured local runtime metadata", + ) + .with_details(details) + .with_data(Map::from_iter([ + ("os".to_string(), json!(env::consts::OS)), + ("arch".to_string(), json!(env::consts::ARCH)), + ("working_dir".to_string(), json!(cwd.display().to_string())), + ("version".to_string(), json!(VERSION)), + ("build_target".to_string(), json!(BUILD_TARGET)), + ("git_sha".to_string(), json!(GIT_SHA)), + ("default_model".to_string(), json!(default_model)), + ])) +} diff --git a/rust/crates/rusty-claude-cli/src/diagnostics/mod.rs.patch b/rust/crates/rusty-claude-cli/src/diagnostics/mod.rs.patch new file mode 100644 index 00000000..54c8f996 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/diagnostics/mod.rs.patch @@ -0,0 +1,9 @@ +1i +use serde_json::{json, Map, Value}; +use std::path::{Path, PathBuf}; +use std::env; +use crate::{StatusContext, parse_git_status_metadata, parse_git_workspace_summary, classify_session_lifecycle_for, VERSION, BUILD_TARGET, GIT_SHA}; +use crate::models::*; +use runtime::{ConfigLoader, ProjectContext, resolve_sandbox_status, McpServerManager, PluginManager, resolve_expected_base, format_stale_base_warning, check_base_commit}; + +. diff --git a/rust/crates/rusty-claude-cli/src/execution/client.rs b/rust/crates/rusty-claude-cli/src/execution/client.rs new file mode 100644 index 00000000..8641e35f --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/execution/client.rs @@ -0,0 +1,1079 @@ +use runtime::ApiClient; +use api::max_tokens_for_model; +use crate::resolve_cli_auth_source; + +use crate::*; +use api::*; +use runtime::*; +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; +use std::path::Path; + +pub(crate) fn build_runtime_plugin_state() -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) +} + +pub(crate) fn build_runtime_plugin_state_with_loader( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hook_config = + runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); + let feature_config = runtime_config + .feature_config() + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); + let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? + .with_runtime_tools(runtime_tools)?; + Ok(RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + }) +} + +pub(crate) struct RuntimePluginState { + pub(crate) feature_config: runtime::RuntimeFeatureConfig, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) mcp_state: Option>>, +} + +pub(crate) fn mcp_wrapper_tool_definitions() -> Vec { + vec![ + RuntimeToolDefinition { + name: "MCPTool".to_string(), + description: Some( + "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), + ), + input_schema: json!({ + "type": "object", + "properties": { + "qualifiedName": { "type": "string" }, + "arguments": {} + }, + "required": ["qualifiedName"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + RuntimeToolDefinition { + name: "ListMcpResourcesTool".to_string(), + description: Some( + "List MCP resources from one configured server or from every connected server." + .to_string(), + ), + input_schema: json!({ + "type": "object", + "properties": { + "server": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + RuntimeToolDefinition { + name: "ReadMcpResourceTool".to_string(), + description: Some("Read a specific MCP resource from a configured server.".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "server": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["server", "uri"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ] +} + +pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { + RuntimeToolDefinition { + name: tool.qualified_name.clone(), + description: Some( + tool.tool + .description + .clone() + .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), + ), + input_schema: tool + .tool + .input_schema + .clone() + .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), + required_permission: permission_mode_for_mcp_tool(&tool.tool), + } +} + +pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { + let read_only = mcp_annotation_flag(tool, "readOnlyHint"); + let destructive = mcp_annotation_flag(tool, "destructiveHint"); + let open_world = mcp_annotation_flag(tool, "openWorldHint"); + + if read_only && !destructive && !open_world { + PermissionMode::ReadOnly + } else if destructive || open_world { + PermissionMode::DangerFullAccess + } else { + PermissionMode::WorkspaceWrite + } +} + +pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +pub(crate) struct RuntimeMcpState { + pub(crate) runtime: tokio::runtime::Runtime, + pub(crate) manager: McpServerManager, + pub(crate) pending_servers: Vec, + pub(crate) degraded_report: Option, +} + +pub(crate) struct BuiltRuntime { + pub(crate) runtime: Option>, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) plugins_active: bool, + pub(crate) mcp_state: Option>>, + pub(crate) mcp_active: bool, +} + +impl BuiltRuntime { + pub(crate) fn new( + runtime: ConversationRuntime, + plugin_registry: PluginRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + runtime: Some(runtime), + plugin_registry, + plugins_active: true, + mcp_state, + mcp_active: true, + } + } + + pub(crate) fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { + let runtime = self + .runtime + .take() + .expect("runtime should exist before installing hook abort signal"); + self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); + self + } + + pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box> { + if self.plugins_active { + self.plugin_registry.shutdown()?; + self.plugins_active = false; + } + Ok(()) + } + + pub(crate) fn shutdown_mcp(&mut self) -> Result<(), Box> { + if self.mcp_active { + if let Some(mcp_state) = &self.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown()?; + } + self.mcp_active = false; + } + Ok(()) + } +} + +impl Deref for BuiltRuntime { + type Target = ConversationRuntime; + + fn deref(&self) -> &Self::Target { + self.runtime + .as_ref() + .expect("runtime should exist while built runtime is alive") + } +} + +impl DerefMut for BuiltRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + self.runtime + .as_mut() + .expect("runtime should exist while built runtime is alive") + } +} + +impl Drop for BuiltRuntime { + fn drop(&mut self) { + let _ = self.shutdown_mcp(); + let _ = self.shutdown_plugins(); + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolSearchRequest { + pub(crate) query: String, + pub(crate) max_results: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct McpToolRequest { + #[serde(rename = "qualifiedName")] + pub(crate) qualified_name: Option, + pub(crate) tool: Option, + pub(crate) arguments: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ListMcpResourcesRequest { + pub(crate) server: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ReadMcpResourceRequest { + pub(crate) server: String, + pub(crate) uri: String, +} + +impl RuntimeMcpState { + pub(crate) fn new( + runtime_config: &runtime::RuntimeConfig, + ) -> Result, Box> { + let mut manager = McpServerManager::from_runtime_config(runtime_config); + if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { + return Ok(None); + } + + let runtime = tokio::runtime::Runtime::new()?; + let discovery = runtime.block_on(manager.discover_tools_best_effort()); + let pending_servers = discovery + .failed_servers + .iter() + .map(|failure| failure.server_name.clone()) + .chain( + discovery + .unsupported_servers + .iter() + .map(|server| server.server_name.clone()), + ) + .collect::>() + .into_iter() + .collect::>(); + let available_tools = discovery + .tools + .iter() + .map(|tool| tool.qualified_name.clone()) + .collect::>(); + let failed_server_names = pending_servers.iter().cloned().collect::>(); + let working_servers = manager + .server_names() + .into_iter() + .filter(|server_name| !failed_server_names.contains(server_name)) + .collect::>(); + let failed_servers = + discovery + .failed_servers + .iter() + .map(|failure| runtime::McpFailedServer { + server_name: failure.server_name.clone(), + phase: runtime::McpLifecyclePhase::ToolDiscovery, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ToolDiscovery, + Some(failure.server_name.clone()), + failure.error.clone(), + std::collections::BTreeMap::new(), + true, + ), + }) + .chain(discovery.unsupported_servers.iter().map(|server| { + runtime::McpFailedServer { + server_name: server.server_name.clone(), + phase: runtime::McpLifecyclePhase::ServerRegistration, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ServerRegistration, + Some(server.server_name.clone()), + server.reason.clone(), + std::collections::BTreeMap::from([( + "transport".to_string(), + format!("{:?}", server.transport).to_ascii_lowercase(), + )]), + false, + ), + } + })) + .collect::>(); + let degraded_report = (!failed_servers.is_empty()).then(|| { + runtime::McpDegradedReport::new( + working_servers, + failed_servers, + available_tools.clone(), + available_tools, + ) + }); + + Ok(Some(( + Self { + runtime, + manager, + pending_servers, + degraded_report, + }, + discovery, + ))) + } + + pub(crate) fn shutdown(&mut self) -> Result<(), Box> { + self.runtime.block_on(self.manager.shutdown())?; + Ok(()) + } + + pub(crate) fn pending_servers(&self) -> Option> { + (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) + } + + pub(crate) fn degraded_report(&self) -> Option { + self.degraded_report.clone() + } + + pub(crate) fn server_names(&self) -> Vec { + self.manager.server_names() + } + + pub(crate) fn call_tool( + &mut self, + qualified_tool_name: &str, + arguments: Option, + ) -> Result { + let response = self + .runtime + .block_on(self.manager.call_tool(qualified_tool_name, arguments)) + .map_err(|error| ToolError::new(error.to_string()))?; + if let Some(error) = response.error { + return Err(ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", + error.message, error.code + ))); + } + + let result = response.result.ok_or_else(|| { + ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned no result payload" + )) + })?; + serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_server(&mut self, server_name: &str) -> Result { + let result = self + .runtime + .block_on(self.manager.list_resources(server_name)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&json!({ + "server": server_name, + "resources": result.resources, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_all_servers(&mut self) -> Result { + let mut resources = Vec::new(); + let mut failures = Vec::new(); + + for server_name in self.server_names() { + match self + .runtime + .block_on(self.manager.list_resources(&server_name)) + { + Ok(result) => resources.push(json!({ + "server": server_name, + "resources": result.resources, + })), + Err(error) => failures.push(json!({ + "server": server_name, + "error": error.to_string(), + })), + } + } + + if resources.is_empty() && !failures.is_empty() { + let message = failures + .iter() + .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) + .collect::>() + .join("; "); + return Err(ToolError::new(message)); + } + + serde_json::to_string_pretty(&json!({ + "resources": resources, + "failures": failures, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { + let result = self + .runtime + .block_on(self.manager.read_resource(server_name, uri)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&json!({ + "server": server_name, + "contents": result.contents, + })) + .map_err(|error| ToolError::new(error.to_string())) + } +} + + +pub(crate) struct AnthropicRuntimeClient { + runtime: tokio::runtime::Runtime, + client: ApiProviderClient, + session_id: String, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + reasoning_effort: Option, +} +impl AnthropicRuntimeClient { + pub(crate) fn new( + session_id: &str, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + ) -> Result> { + // Dispatch to the correct provider at construction time. + // `ApiProviderClient` (exposed by the api crate as + // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI + // variants, where xAI and OpenAI both use the OpenAI-compat + // wire format under the hood. We consult + // `detect_provider_kind(&resolved_model)` so model-name prefix + // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over + // env-var presence. + // + // For Anthropic we build the client directly instead of going + // through `ApiProviderClient::from_model_with_anthropic_auth` + // so we can explicitly apply `api::read_base_url()` — that + // reads `ANTHROPIC_BASE_URL` and is required for the local + // mock-server test harness + // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point + // claw at its fake Anthropic endpoint. We also attach a + // session-scoped prompt cache on the Anthropic path; the + // prompt cache is Anthropic-only so non-Anthropic variants + // skip it. + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + // The api crate's `ProviderClient::from_model_with_anthropic_auth` + // with `None` for the anthropic auth routes via + // `detect_provider_kind` and builds an + // `OpenAiCompatClient::from_env` with the matching + // `OpenAiCompatConfig` (openai / xai / dashscope). + // That reads the correct API-key env var and BASE_URL + // override internally, so this one call covers OpenAI, + // OpenRouter, xAI, DashScope, Ollama, and any other + // OpenAI-compat endpoint users configure via + // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client, + session_id: session_id.to_string(), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + reasoning_effort: None, + }) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.reasoning_effort = effort; + } +} +impl AnthropicRuntimeClient { + /// Consume a single streaming response, optionally applying a stall + /// timeout on the first event for post-tool continuations. + #[allow(clippy::too_many_lines)] + pub(crate) async fn consume_stream( + &self, + message_request: &MessageRequest, + apply_stall_timeout: bool, + ) -> Result, RuntimeError> { + let mut stream = self + .client + .stream_message(message_request) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + 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 block_has_thinking_summary = false; + let mut saw_stop = false; + let mut received_any_event = false; + + loop { + let next = if apply_stall_timeout && !received_any_event { + match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { + Ok(inner) => inner.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?, + Err(_elapsed) => { + return Err(RuntimeError::new( + "post-tool stall: model did not respond within timeout", + )); + } + } + } else { + stream.next_event().await.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })? + }; + + let Some(event) = next else { + break; + }; + received_any_event = true; + + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + 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); + } + } + ContentBlockDelta::ThinkingDelta { thinking } => { + if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; + block_has_thinking_summary = true; + } + events.push(AssistantEvent::ThinkingDelta(thinking)); + } + ContentBlockDelta::SignatureDelta { signature } => { + events.push(AssistantEvent::SignatureDelta(signature)); + } + }, + ApiStreamEvent::ContentBlockStop(_) => { + block_has_thinking_summary = false; + 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() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + // 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(delta.usage.token_usage())); + } + 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); + } + } + } + + push_prompt_cache_record(&self.client, &mut events); + + 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(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut events = response_to_events(response, out)?; + push_prompt_cache_record(&self.client, &mut events); + Ok(events) + } +} + +impl ApiClient for AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } + let is_post_tool = request_ends_with_tool_result(&request); + 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.tool_registry, self.allowed_tools.as_ref())), + tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + stream: true, + reasoning_effort: self.reasoning_effort.clone(), + ..Default::default() + }; + + self.runtime.block_on(async { + // When resuming after tool execution, apply a stall timeout on the + // first stream event. If the model does not respond within the + // deadline we drop the stalled connection and re-send the request as + // a continuation nudge (one retry only). + let max_attempts: usize = if is_post_tool { 2 } else { 1 }; + + for attempt in 1..=max_attempts { + let result = self + .consume_stream(&message_request, is_post_tool && attempt == 1) + .await; + match result { + Ok(events) => return Ok(events), + Err(error) + if error.to_string().contains("post-tool stall") + && attempt < max_attempts => + { + // Stalled after tool completion — nudge the model by + // re-sending the same request. + } + Err(error) => return Err(error), + } + } + + Err(RuntimeError::new("post-tool continuation nudge exhausted")) + }) + } +} + + + + + + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + use std::sync::{MutexGuard, Mutex, OnceLock}; + use std::env; + use std::time::SystemTime; + + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + fn temp_dir() -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}")) + } + fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) { + fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); + if include_hooks { + fs::create_dir_all(root.join("hooks")).expect("hooks dir"); + fs::write( + root.join("hooks").join("pre.sh"), + "#!/bin/sh\nprintf 'plugin pre hook'\n", + ) + .expect("write hook"); + } + if include_lifecycle { + fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); + fs::write( + root.join("lifecycle").join("init.sh"), + "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", + ) + .expect("write init lifecycle"); + fs::write( + root.join("lifecycle").join("shutdown.sh"), + "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", + ) + .expect("write shutdown lifecycle"); + } + + let hooks = if include_hooks { + ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }" + } else { + "" + }; + let lifecycle = if include_lifecycle { + ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }" + } else { + "" + }; + fs::write( + root.join(".claude-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime plugin fixture\"{hooks}{lifecycle}\n}}" + ), + ) + .expect("write plugin manifest"); + } + + #[test] + fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let pre_hooks = state.feature_config.hooks().pre_tool_use(); + assert_eq!(pre_hooks.len(), 1); + assert!( + pre_hooks[0].ends_with("hooks/pre.sh"), + "expected installed plugin hook path, got {pre_hooks:?}" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() { + let config_home = temp_dir(); + let workspace = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + let script_path = workspace.join("fixture-mcp.py"); + write_mcp_server_fixture(&script_path); + fs::write( + config_home.join("settings.json"), + format!( + r#"{{ + "mcpServers": {{ + "alpha": {{ + "command": "python3", + "args": ["{}"] + }}, + "broken": {{ + "command": "python3", + "args": ["-c", "import sys; sys.exit(0)"] + }} + }} + }}"#, + script_path.to_string_lossy() + ), + ) + .expect("write mcp settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("runtime plugin state should load"); + + let allowed = state + .tool_registry + .normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()]) + .expect("mcp tools should be allow-listable") + .expect("allow-list should exist"); + assert!(allowed.contains("mcp__alpha__echo")); + assert!(allowed.contains("MCPTool")); + + let mut executor = CliToolExecutor::new( + None, + false, + state.tool_registry.clone(), + state.mcp_state.clone(), + ); + + let tool_output = executor + .execute("mcp__alpha__echo", r#"{"text":"hello"}"#) + .expect("discovered mcp tool should execute"); + let tool_json: serde_json::Value = + serde_json::from_str(&tool_output).expect("tool output should be json"); + assert_eq!(tool_json["structuredContent"]["echoed"], "hello"); + + let wrapped_output = executor + .execute( + "MCPTool", + r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#, + ) + .expect("generic mcp wrapper should execute"); + let wrapped_json: serde_json::Value = + serde_json::from_str(&wrapped_output).expect("wrapped output should be json"); + assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped"); + + let search_output = executor + .execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#) + .expect("tool search should execute"); + let search_json: serde_json::Value = + serde_json::from_str(&search_output).expect("search output should be json"); + assert_eq!(search_json["matches"][0], "mcp__alpha__echo"); + assert_eq!(search_json["pending_mcp_servers"][0], "broken"); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["server_name"], + "broken" + ); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["phase"], + "tool_discovery" + ); + assert_eq!( + search_json["mcp_degraded"]["available_tools"][0], + "mcp__alpha__echo" + ); + + let listed = executor + .execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#) + .expect("resources should list"); + let listed_json: serde_json::Value = + serde_json::from_str(&listed).expect("resource output should be json"); + assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt"); + + let read = executor + .execute( + "ReadMcpResourceTool", + r#"{"server":"alpha","uri":"file://guide.txt"}"#, + ) + .expect("resource should read"); + let read_json: serde_json::Value = + serde_json::from_str(&read).expect("resource read output should be json"); + assert_eq!( + read_json["contents"][0]["text"], + "contents for file://guide.txt" + ); + + if let Some(mcp_state) = state.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown() + .expect("mcp shutdown should succeed"); + } + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() { + let config_home = temp_dir(); + let workspace = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::write( + config_home.join("settings.json"), + r#"{ + "mcpServers": { + "remote": { + "url": "https://example.test/mcp" + } + } + }"#, + ) + .expect("write mcp settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("runtime plugin state should load"); + let mut executor = CliToolExecutor::new( + None, + false, + state.tool_registry.clone(), + state.mcp_state.clone(), + ); + + let search_output = executor + .execute("ToolSearch", r#"{"query":"remote","max_results":5}"#) + .expect("tool search should execute"); + let search_json: serde_json::Value = + serde_json::from_str(&search_output).expect("search output should be json"); + assert_eq!(search_json["pending_mcp_servers"][0], "remote"); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["server_name"], + "remote" + ); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["phase"], + "server_registration" + ); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"], + "http" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() { + // Serialize access to process-wide env vars so parallel tests that + // set/remove ANTHROPIC_API_KEY do not race with this test. + let _guard = env_lock(); + let config_home = temp_dir(); + // Inject a dummy API key so runtime construction succeeds without real credentials. + // This test only exercises plugin lifecycle (init/shutdown), never calls the API. + std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle"); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + let install = manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let log_path = install.install_path.join("lifecycle.log"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let runtime_plugin_state = + build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let mut runtime = build_runtime_with_plugin_state( + Session::new(), + "runtime-plugin-lifecycle", + DEFAULT_MODEL.to_string(), + vec!["test system prompt".to_string()], + true, + false, + None, + PermissionMode::DangerFullAccess, + None, + runtime_plugin_state, + ) + .expect("runtime should build"); + + assert_eq!( + fs::read_to_string(&log_path).expect("init log should exist"), + "init\n" + ); + + runtime + .shutdown_plugins() + .expect("plugin shutdown should succeed"); + + assert_eq!( + fs::read_to_string(&log_path).expect("shutdown log should exist"), + "init\nshutdown\n" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + std::env::remove_var("ANTHROPIC_API_KEY"); + } +} diff --git a/rust/crates/rusty-claude-cli/src/execution/executor.rs b/rust/crates/rusty-claude-cli/src/execution/executor.rs new file mode 100644 index 00000000..653bac27 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/execution/executor.rs @@ -0,0 +1,261 @@ + +use crate::*; +use api::*; +use runtime::*; +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; +use std::path::Path; + +pub(crate) struct CliToolExecutor { + pub(crate) renderer: TerminalRenderer, + pub(crate) emit_output: bool, + pub(crate) allowed_tools: Option, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) mcp_state: Option>>, +} + +impl CliToolExecutor { + pub(crate) fn new( + allowed_tools: Option, + emit_output: bool, + tool_registry: GlobalToolRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + renderer: TerminalRenderer::new(), + emit_output, + allowed_tools, + tool_registry, + mcp_state, + } + } + + pub(crate) fn execute_search_tool(&self, value: serde_json::Value) -> Result { + let input: ToolSearchRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let (pending_mcp_servers, mcp_degraded) = + self.mcp_state.as_ref().map_or((None, None), |state| { + let state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.pending_servers(), state.degraded_report()) + }); + serde_json::to_string_pretty(&self.tool_registry.search( + &input.query, + input.max_results.unwrap_or(5), + pending_mcp_servers, + mcp_degraded, + )) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn execute_runtime_tool( + &self, + tool_name: &str, + value: serde_json::Value, + ) -> Result { + let Some(mcp_state) = &self.mcp_state else { + return Err(ToolError::new(format!( + "runtime tool `{tool_name}` is unavailable without configured MCP servers" + ))); + }; + let mut mcp_state = mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + match tool_name { + "MCPTool" => { + let input: McpToolRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let qualified_name = input + .qualified_name + .or(input.tool) + .ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?; + mcp_state.call_tool(&qualified_name, input.arguments) + } + "ListMcpResourcesTool" => { + let input: ListMcpResourcesRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + match input.server { + Some(server_name) => mcp_state.list_resources_for_server(&server_name), + None => mcp_state.list_resources_for_all_servers(), + } + } + "ReadMcpResourceTool" => { + let input: ReadMcpResourceRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + mcp_state.read_resource(&input.server, &input.uri) + } + _ => mcp_state.call_tool(tool_name, Some(value)), + } + } +} + +impl ToolExecutor for CliToolExecutor { + fn execute(&mut self, tool_name: &str, input: &str) -> Result { + 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}")))?; + let result = if tool_name == "ToolSearch" { + self.execute_search_tool(value) + } else if self.tool_registry.has_runtime_tool(tool_name) { + self.execute_runtime_tool(tool_name, value) + } else { + self.tool_registry + .execute(tool_name, &value) + .map_err(ToolError::new) + }; + match result { + 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.to_string(), true); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + } + Err(error) + } + } + } +} + + + +#[cfg(test)] +mod tests { + use super::*; + + #[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 tool_rendering_truncates_large_read_output_for_display_only() { + let content = (0..200) + .map(|index| format!("line {index:03}")) + .collect::>() + .join("\n"); + let output = json!({ + "file": { + "filePath": "src/main.rs", + "content": content, + "numLines": 200, + "startLine": 1, + "totalLines": 200 + } + }) + .to_string(); + + let rendered = format_tool_result("read_file", &output, false); + + assert!(rendered.contains("line 000")); + assert!(rendered.contains("line 079")); + assert!(!rendered.contains("line 199")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("line 199")); + } + + #[test] + fn tool_rendering_truncates_large_bash_output_for_display_only() { + let stdout = (0..120) + .map(|index| format!("stdout {index:03}")) + .collect::>() + .join("\n"); + let output = json!({ + "stdout": stdout, + "stderr": "", + "returnCodeInterpretation": "completed successfully" + }) + .to_string(); + + let rendered = format_tool_result("bash", &output, false); + + assert!(rendered.contains("stdout 000")); + assert!(rendered.contains("stdout 059")); + assert!(!rendered.contains("stdout 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("stdout 119")); + } + + #[test] + fn tool_rendering_truncates_generic_long_output_for_display_only() { + let items = (0..120) + .map(|index| format!("payload {index:03}")) + .collect::>(); + let output = json!({ + "summary": "plugin payload", + "items": items, + }) + .to_string(); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("payload 000")); + assert!(rendered.contains("payload 040")); + assert!(!rendered.contains("payload 080")); + assert!(!rendered.contains("payload 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("payload 119")); + } + + #[test] + fn tool_rendering_truncates_raw_generic_output_for_display_only() { + let output = (0..120) + .map(|index| format!("raw {index:03}")) + .collect::>() + .join("\n"); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("raw 000")); + assert!(rendered.contains("raw 059")); + assert!(!rendered.contains("raw 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("raw 119")); + } + + #[test] + fn short_tool_id_truncates_long_identifiers_with_ellipsis() { + // given + let long = "toolu_01ABCDEFGHIJKLMN"; + let short = "tool_1"; + + // when + let trimmed_long = short_tool_id(long); + let trimmed_short = short_tool_id(short); + + // then + assert_eq!(trimmed_long, "toolu_01ABCD…"); + assert_eq!(trimmed_short, "tool_1"); + } +} diff --git a/rust/crates/rusty-claude-cli/src/execution/mod.rs b/rust/crates/rusty-claude-cli/src/execution/mod.rs new file mode 100644 index 00000000..d72a4629 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/execution/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod client; +pub(crate) mod executor; +pub(crate) mod stream; diff --git a/rust/crates/rusty-claude-cli/src/execution/stream.rs b/rust/crates/rusty-claude-cli/src/execution/stream.rs new file mode 100644 index 00000000..8bba054a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/execution/stream.rs @@ -0,0 +1,186 @@ + +use crate::*; +use api::*; +use runtime::*; +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; +use std::path::Path; +pub(crate) fn response_to_events( + response: MessageResponse, + out: &mut (impl Write + ?Sized), +) -> Result, RuntimeError> { + let mut events = Vec::new(); + let mut pending_tool = None; + + for block in response.content { + let mut block_has_thinking_summary = false; + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + false, + &mut block_has_thinking_summary, + )?; + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(response.usage.token_usage())); + events.push(AssistantEvent::MessageStop); + Ok(events) +} + +pub(crate) fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec) { + // `ApiProviderClient::take_last_prompt_cache_record` is a pass-through + // to the Anthropic variant and returns `None` for OpenAI-compat / + // xAI variants, which do not have a prompt cache. So this helper + // remains a no-op on non-Anthropic providers without any extra + // branching here. + if let Some(record) = client.take_last_prompt_cache_record() { + if let Some(event) = prompt_cache_record_to_runtime_event(record) { + events.push(AssistantEvent::PromptCache(event)); + } + } +} + +pub(crate) fn prompt_cache_record_to_runtime_event( + record: api::PromptCacheRecord, +) -> Option { + let cache_break = record.cache_break?; + Some(PromptCacheEvent { + unexpected: cache_break.unexpected, + reason: cache_break.reason, + previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, + current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, + token_drop: cache_break.token_drop, + }) +} + +/// Returns `true` when the conversation ends with a tool-result message, +/// meaning the model is expected to continue after tool execution. +pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool { + request + .messages + .last() + .is_some_and(|message| message.role == MessageRole::Tool) +} + + + +#[cfg(test)] +mod tests { + use super::*; + + #[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\"}" + )); + } + + #[test] + fn response_to_events_renders_collapsed_thinking_summary() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-3".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![ + OutputContentBlock::Thinking { + thinking: "step 1".to_string(), + signature: Some("sig_123".to_string()), + }, + OutputContentBlock::Text { + text: "Final answer".to_string(), + }, + ], + stop_reason: Some("end_turn".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::TextDelta(text) if text == "Final answer" + )); + let rendered = String::from_utf8(out).expect("utf8"); + assert!(rendered.contains("▶ Thinking (6 chars hidden)")); + assert!(!rendered.contains("step 1")); + } +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 1ee1d92e..81e1e6c7 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -10,8 +10,24 @@ clippy::result_large_err )] mod init; +pub(crate) use crate::permissions::prompter::*; +pub(crate) use crate::ui::progress::*; +pub(crate) use crate::cli::suggestions::*; +pub(crate) use crate::config::models::*; +use crate::diagnostics::{render_doctor_report, run_doctor, run_worker_state}; mod input; +pub(crate) mod cli; +pub(crate) mod config; +pub(crate) mod diagnostics; +pub(crate) mod execution; +pub(crate) mod permissions; +pub(crate) mod ui; + +use crate::execution::client::*; +use crate::execution::stream::*; +use crate::execution::executor::*; + use std::collections::BTreeSet; pub(crate) mod models; pub(crate) use models::*; @@ -716,218 +732,6 @@ fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'stat ) } -fn suggest_slash_commands(input: &str) -> Vec { - let mut candidates = slash_command_specs() - .iter() - .flat_map(|spec| { - std::iter::once(spec.name) - .chain(spec.aliases.iter().copied()) - .map(|name| format!("/{name}")) - .collect::>() - }) - .collect::>(); - candidates.sort(); - candidates.dedup(); - let candidate_refs = candidates.iter().map(String::as_str).collect::>(); - ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) - .into_iter() - .map(str::to_string) - .collect() -} - -fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { - ranked_suggestions(input, candidates).into_iter().next() -} - -fn suggest_similar_subcommand(input: &str) -> Option> { - const KNOWN_SUBCOMMANDS: &[&str] = &[ - "help", - "version", - "status", - "sandbox", - "doctor", - "state", - "dump-manifests", - "bootstrap-plan", - "agents", - "mcp", - "skills", - "system-prompt", - "acp", - "init", - "export", - "prompt", - ]; - - let normalized_input = input.to_ascii_lowercase(); - let mut ranked = KNOWN_SUBCOMMANDS - .iter() - .filter_map(|candidate| { - let normalized_candidate = candidate.to_ascii_lowercase(); - let distance = levenshtein_distance(&normalized_input, &normalized_candidate); - let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; - let substring_match = normalized_candidate.contains(&normalized_input) - || normalized_input.contains(&normalized_candidate); - ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate)) - }) - .collect::>(); - ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); - ranked.dedup_by(|left, right| left.1 == right.1); - let suggestions = ranked - .into_iter() - .map(|(_, candidate)| candidate.to_string()) - .take(3) - .collect::>(); - (!suggestions.is_empty()).then_some(suggestions) -} - -fn common_prefix_len(left: &str, right: &str) -> usize { - left.chars() - .zip(right.chars()) - .take_while(|(l, r)| l == r) - .count() -} - -fn looks_like_subcommand_typo(input: &str) -> bool { - !input.is_empty() - && input - .chars() - .all(|ch| ch.is_ascii_alphabetic() || ch == '-') -} - -fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { - let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); - let mut ranked = candidates - .iter() - .filter_map(|candidate| { - let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); - let distance = levenshtein_distance(&normalized_input, &normalized_candidate); - let prefix_bonus = usize::from( - !(normalized_candidate.starts_with(&normalized_input) - || normalized_input.starts_with(&normalized_candidate)), - ); - let score = distance + prefix_bonus; - (score <= 4).then_some((score, *candidate)) - }) - .collect::>(); - ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); - ranked - .into_iter() - .map(|(_, candidate)| candidate) - .take(3) - .collect() -} - -fn levenshtein_distance(left: &str, right: &str) -> usize { - if left.is_empty() { - return right.chars().count(); - } - if right.is_empty() { - return left.chars().count(); - } - - let right_chars = right.chars().collect::>(); - let mut previous = (0..=right_chars.len()).collect::>(); - let mut current = vec![0; right_chars.len() + 1]; - - for (left_index, left_char) in left.chars().enumerate() { - current[0] = left_index + 1; - for (right_index, right_char) in right_chars.iter().enumerate() { - let substitution_cost = usize::from(left_char != *right_char); - current[right_index + 1] = (previous[right_index + 1] + 1) - .min(current[right_index] + 1) - .min(previous[right_index] + substitution_cost); - } - previous.clone_from(¤t); - } - - previous[right_chars.len()] -} - -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, - } -} - -/// Resolve a model name through user-defined config aliases first, then fall -/// back to the built-in alias table. This is the entry point used wherever a -/// user-supplied model string is about to be dispatched to a provider. -fn resolve_model_alias_with_config(model: &str) -> String { - let trimmed = model.trim(); - if let Some(resolved) = config_alias_for_current_dir(trimmed) { - return resolve_model_alias(&resolved).to_string(); - } - resolve_model_alias(trimmed).to_string() -} - -/// Validate model syntax at parse time. -/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. -/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. -fn validate_model_syntax(model: &str) -> Result<(), String> { - let trimmed = model.trim(); - if trimmed.is_empty() { - return Err("model string cannot be empty".to_string()); - } - // Known aliases are always valid - match trimmed { - "opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini" - | "grok-3-mini" | "kimi" | "glm-5" => return Ok(()), - _ => {} - } - - // Dashscope and xAI models that don't use provider/model syntax - if trimmed.starts_with("qwen-") - || trimmed.starts_with("ali-") - || trimmed.starts_with("glm-") - || trimmed.starts_with("kimi-") - { - return Ok(()); - } - // Check for spaces (malformed) - if trimmed.contains(' ') { - return Err(format!( - "invalid model syntax: '{trimmed}' contains spaces. Use provider/model format or known alias" - )); - } - // Check provider/model format: provider_id/model_id - let parts: Vec<&str> = trimmed.split('/').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - // #154: hint if the model looks like it belongs to a different provider - let mut err_msg = format!( - "invalid model syntax: '{trimmed}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)" - ); - if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { - err_msg.push_str("\nDid you mean `openai/"); - err_msg.push_str(trimmed); - err_msg.push_str("`? (Requires OPENAI_API_KEY env var)"); - } else if trimmed.starts_with("qwen") { - err_msg.push_str("\nDid you mean `qwen/"); - err_msg.push_str(trimmed); - err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)"); - } else if trimmed.starts_with("grok") { - err_msg.push_str("\nDid you mean `xai/"); - err_msg.push_str(trimmed); - err_msg.push_str("`? (Requires XAI_API_KEY env var)"); - } - return Err(err_msg); - } - Ok(()) -} - -fn config_alias_for_current_dir(alias: &str) -> Option { - if alias.is_empty() { - return None; - } - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - let config = loader.load().ok()?; - config.aliases().get(alias).cloned() -} - fn normalize_allowed_tools(values: &[String]) -> Result, String> { if values.is_empty() { return Ok(None); @@ -952,75 +756,6 @@ fn current_tool_registry() -> Result { Ok(registry) } -fn parse_permission_mode_arg(value: &str) -> Result { - 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 permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { - match mode { - ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, - ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, - ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, - } -} - -fn default_permission_mode() -> PermissionMode { - env::var("RUSTY_CLAUDE_PERMISSION_MODE") - .ok() - .as_deref() - .and_then(normalize_permission_mode) - .map(permission_mode_from_label) - .or_else(config_permission_mode_for_current_dir) - .unwrap_or(PermissionMode::DangerFullAccess) -} - -fn config_permission_mode_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader - .load() - .ok()? - .permission_mode() - .map(permission_mode_from_resolved) -} - -fn config_model_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader.load().ok()?.model().map(ToOwned::to_owned) -} - -fn resolve_repl_model(cli_model: String) -> String { - if cli_model != DEFAULT_MODEL { - return cli_model; - } - if let Some(env_model) = env::var("ANTHROPIC_MODEL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return resolve_model_alias_with_config(&env_model); - } - if let Some(config_model) = config_model_for_current_dir() { - return resolve_model_alias_with_config(&config_model); - } - cli_model -} fn provider_label(kind: ProviderKind) -> &'static str { match kind { @@ -1156,682 +891,6 @@ fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result< }) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DiagnosticLevel { - Ok, - Warn, - Fail, -} - -impl DiagnosticLevel { - fn label(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Warn => "warn", - Self::Fail => "fail", - } - } - - fn is_failure(self) -> bool { - matches!(self, Self::Fail) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DiagnosticCheck { - name: &'static str, - level: DiagnosticLevel, - summary: String, - details: Vec, - data: Map, -} - -impl DiagnosticCheck { - fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { - Self { - name, - level, - summary: summary.into(), - details: Vec::new(), - data: Map::new(), - } - } - - fn with_details(mut self, details: Vec) -> Self { - self.details = details; - self - } - - fn with_data(mut self, data: Map) -> Self { - self.data = data; - self - } - - fn json_value(&self) -> Value { - let mut value = Map::from_iter([ - ( - "name".to_string(), - Value::String(self.name.to_ascii_lowercase()), - ), - ( - "status".to_string(), - Value::String(self.level.label().to_string()), - ), - ("summary".to_string(), Value::String(self.summary.clone())), - ( - "details".to_string(), - Value::Array( - self.details - .iter() - .cloned() - .map(Value::String) - .collect::>(), - ), - ), - ]); - value.extend(self.data.clone()); - Value::Object(value) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DoctorReport { - checks: Vec, -} - -impl DoctorReport { - fn counts(&self) -> (usize, usize, usize) { - ( - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Ok) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Warn) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Fail) - .count(), - ) - } - - fn has_failures(&self) -> bool { - self.checks.iter().any(|check| check.level.is_failure()) - } - - fn render(&self) -> String { - let (ok_count, warn_count, fail_count) = self.counts(); - let mut lines = vec![ - "Doctor".to_string(), - format!( - "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" - ), - ]; - lines.extend(self.checks.iter().map(render_diagnostic_check)); - lines.join("\n\n") - } - - fn json_value(&self) -> Value { - let report = self.render(); - let (ok_count, warn_count, fail_count) = self.counts(); - json!({ - "kind": "doctor", - "message": report, - "report": report, - "has_failures": self.has_failures(), - "summary": { - "total": self.checks.len(), - "ok": ok_count, - "warnings": warn_count, - "failures": fail_count, - }, - "checks": self - .checks - .iter() - .map(DiagnosticCheck::json_value) - .collect::>(), - }) - } -} - -fn render_diagnostic_check(check: &DiagnosticCheck) -> String { - let mut lines = vec![format!( - "{}\n Status {}\n Summary {}", - check.name, - check.level.label(), - check.summary - )]; - if !check.details.is_empty() { - lines.push(" Details".to_string()); - lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); - } - lines.join("\n") -} - -fn render_doctor_report() -> Result> { - let cwd = env::current_dir()?; - let config_loader = ConfigLoader::default_for(&cwd); - let config = config_loader.load(); - let discovered_config = config_loader.discover(); - 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()); - let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); - let empty_config = runtime::RuntimeConfig::empty(); - let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); - let context = StatusContext { - cwd: cwd.clone(), - session_path: None, - loaded_config_files: config - .as_ref() - .ok() - .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), - discovered_config_files: discovered_config.len(), - memory_file_count: project_context.instruction_files.len(), - project_root, - git_branch, - git_summary, - session_lifecycle: classify_session_lifecycle_for(&cwd), - sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), - // Doctor path has its own config check; StatusContext here is only - // fed into health renderers that don't read config_load_error. - config_load_error: config.as_ref().err().map(ToString::to_string), - }; - Ok(DoctorReport { - checks: vec![ - check_auth_health(), - check_config_health(&config_loader, config.as_ref()), - check_install_source_health(), - check_workspace_health(&context), - check_sandbox_health(&context.sandbox_status), - check_system_health(&cwd, config.as_ref().ok()), - ], - }) -} - -fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { - let report = render_doctor_report()?; - let message = report.render(); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&report.json_value())?); - } - } - if report.has_failures() { - return Err("doctor found failing checks".into()); - } - Ok(()) -} - -/// Starts a minimal Model Context Protocol server that exposes claw's -/// built-in tools over stdio. -/// -/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are -/// dispatched through [`tools::execute_tool`], so this server exposes exactly -/// Read `.claw/worker-state.json` from the current working directory and print it. -/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs` -/// atomically writes state transitions here so external observers (clawhip, orchestrators) -/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary. -fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let state_path = cwd.join(".claw").join("worker-state.json"); - if !state_path.exists() { - // #139: this error used to say "run a worker first" without telling - // callers how to run one. "worker" is an internal concept (there is - // no `claw worker` subcommand), so claws/CI had no discoverable path - // from the error to a fix. Emit an actionable, structured error that - // names the two concrete commands that produce worker state. - // - // Format in both text and JSON modes is stable so scripts can match: - // error: no worker state file found at - // Hint: worker state is written by the interactive REPL or a non-interactive prompt. - // Run: claw # start the REPL (writes state on first turn) - // Or: claw prompt # run one non-interactive turn - // Then rerun: claw state [--output-format json] - return Err(format!( - "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", - path = state_path.display() - ) - .into()); - } - let raw = std::fs::read_to_string(&state_path)?; - match output_format { - CliOutputFormat::Text => println!("{raw}"), - CliOutputFormat::Json => { - // Validate it parses as JSON before re-emitting - let _: serde_json::Value = serde_json::from_str(&raw)?; - println!("{raw}"); - } - } - Ok(()) -} - -/// the same surface the in-process agent loop uses. -fn run_mcp_serve() -> Result<(), Box> { - let tools = mvp_tool_specs() - .into_iter() - .map(|spec| McpTool { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: Some(spec.input_schema), - annotations: None, - meta: None, - }) - .collect(); - - let spec = McpServerSpec { - server_name: "claw".to_string(), - server_version: VERSION.to_string(), - tools, - tool_handler: Box::new(execute_tool), - }; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - runtime.block_on(async move { - let mut server = McpServer::new(spec); - server.run().await - })?; - Ok(()) -} - -#[allow(clippy::too_many_lines)] -fn check_auth_health() -> DiagnosticCheck { - let api_key_present = env::var("ANTHROPIC_API_KEY") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let env_details = format!( - "Environment api_key={} auth_token={}", - if api_key_present { "present" } else { "absent" }, - if auth_token_present { - "present" - } else { - "absent" - } - ); - - match load_oauth_credentials() { - Ok(Some(token_set)) => DiagnosticCheck::new( - "Auth", - if api_key_present || auth_token_present { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if api_key_present || auth_token_present { - "supported auth env vars are configured; legacy saved OAuth is ignored" - } else { - "legacy saved OAuth credentials are present but unsupported" - }, - ) - .with_details(vec![ - env_details, - format!( - "Legacy OAuth expires_at={} refresh_token={} scopes={}", - token_set - .expires_at - .map_or_else(|| "".to_string(), |value| value.to_string()), - if token_set.refresh_token.is_some() { - "present" - } else { - "absent" - }, - if token_set.scopes.is_empty() { - "".to_string() - } else { - token_set.scopes.join(",") - } - ), - "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed" - .to_string(), - ]) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), json!(true)), - ( - "legacy_saved_oauth_expires_at".to_string(), - json!(token_set.expires_at), - ), - ( - "legacy_refresh_token_present".to_string(), - json!(token_set.refresh_token.is_some()), - ), - ("legacy_scopes".to_string(), json!(token_set.scopes)), - ])), - Ok(None) => DiagnosticCheck::new( - "Auth", - if api_key_present || auth_token_present { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if api_key_present || auth_token_present { - "supported auth env vars are configured" - } else { - "no supported auth env vars were found" - }, - ) - .with_details(vec![env_details]) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), json!(false)), - ("legacy_saved_oauth_expires_at".to_string(), Value::Null), - ("legacy_refresh_token_present".to_string(), json!(false)), - ("legacy_scopes".to_string(), json!(Vec::::new())), - ])), - Err(error) => DiagnosticCheck::new( - "Auth", - DiagnosticLevel::Fail, - format!("failed to inspect legacy saved credentials: {error}"), - ) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), Value::Null), - ("legacy_saved_oauth_expires_at".to_string(), Value::Null), - ("legacy_refresh_token_present".to_string(), Value::Null), - ("legacy_scopes".to_string(), Value::Null), - ("legacy_saved_oauth_error".to_string(), json!(error.to_string())), - ])), - } -} - -fn check_config_health( - config_loader: &ConfigLoader, - config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, -) -> DiagnosticCheck { - let discovered = config_loader.discover(); - let discovered_count = discovered.len(); - // Separate candidate paths that actually exist from those that don't. - // Showing non-existent paths as "Discovered file" implies they loaded - // but something went wrong, which is confusing. We only surface paths - // that exist on disk as discovered; non-existent ones are silently - // omitted from the display (they are just the standard search locations). - let present_paths: Vec = discovered - .iter() - .filter(|e| e.path.exists()) - .map(|e| e.path.display().to_string()) - .collect(); - let discovered_paths = discovered - .iter() - .map(|entry| entry.path.display().to_string()) - .collect::>(); - match config { - Ok(runtime_config) => { - let loaded_entries = runtime_config.loaded_entries(); - let loaded_count = loaded_entries.len(); - let present_count = present_paths.len(); - let mut details = vec![format!( - "Config files loaded {}/{}", - loaded_count, present_count - )]; - if let Some(model) = runtime_config.model() { - details.push(format!("Resolved model {model}")); - } - details.push(format!( - "MCP servers {}", - runtime_config.mcp().servers().len() - )); - if present_paths.is_empty() { - details.push("Discovered files (defaults active)".to_string()); - } else { - details.extend( - present_paths - .iter() - .map(|path| format!("Discovered file {path}")), - ); - } - DiagnosticCheck::new( - "Config", - DiagnosticLevel::Ok, - if present_count == 0 { - "no config files present; defaults are active" - } else { - "runtime config loaded successfully" - }, - ) - .with_details(details) - .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(present_paths)), - ("discovered_files_count".to_string(), json!(present_count)), - ("loaded_config_files".to_string(), json!(loaded_count)), - ("resolved_model".to_string(), json!(runtime_config.model())), - ( - "mcp_servers".to_string(), - json!(runtime_config.mcp().servers().len()), - ), - ])) - } - Err(error) => DiagnosticCheck::new( - "Config", - DiagnosticLevel::Fail, - format!("runtime config failed to load: {error}"), - ) - .with_details(if discovered_paths.is_empty() { - vec!["Discovered files ".to_string()] - } else { - discovered_paths - .iter() - .map(|path| format!("Discovered file {path}")) - .collect() - }) - .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(discovered_paths)), - ( - "discovered_files_count".to_string(), - json!(discovered_count), - ), - ("loaded_config_files".to_string(), json!(0)), - ("resolved_model".to_string(), Value::Null), - ("mcp_servers".to_string(), Value::Null), - ("load_error".to_string(), json!(error.to_string())), - ])), - } -} - -fn check_install_source_health() -> DiagnosticCheck { - DiagnosticCheck::new( - "Install source", - DiagnosticLevel::Ok, - format!( - "official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`" - ), - ) - .with_details(vec![ - format!("Official repo {OFFICIAL_REPO_URL}"), - "Recommended path build from this repo or use the upstream binary documented in README.md" - .to_string(), - format!( - "Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary" - ) - .to_string(), - ]) - .with_data(Map::from_iter([ - ("official_repo".to_string(), json!(OFFICIAL_REPO_URL)), - ( - "deprecated_install".to_string(), - json!(DEPRECATED_INSTALL_COMMAND), - ), - ( - "recommended_install".to_string(), - json!("build from source or follow the upstream binary instructions in README.md"), - ), - ])) -} - -fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { - let in_repo = context.project_root.is_some(); - DiagnosticCheck::new( - "Workspace", - if in_repo { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if in_repo { - format!( - "project root detected on branch {}", - context.git_branch.as_deref().unwrap_or("unknown") - ) - } else { - "current directory is not inside a git project".to_string() - }, - ) - .with_details(vec![ - format!("Cwd {}", context.cwd.display()), - format!( - "Project root {}", - context - .project_root - .as_ref() - .map_or_else(|| "".to_string(), |path| path.display().to_string()) - ), - format!( - "Git branch {}", - context.git_branch.as_deref().unwrap_or("unknown") - ), - format!("Git state {}", context.git_summary.headline()), - format!("Changed files {}", context.git_summary.changed_files), - format!( - "Memory files {} · config files loaded {}/{}", - context.memory_file_count, context.loaded_config_files, context.discovered_config_files - ), - ]) - .with_data(Map::from_iter([ - ("cwd".to_string(), json!(context.cwd.display().to_string())), - ( - "project_root".to_string(), - json!(context - .project_root - .as_ref() - .map(|path| path.display().to_string())), - ), - ("in_git_repo".to_string(), json!(in_repo)), - ("git_branch".to_string(), json!(context.git_branch)), - ( - "git_state".to_string(), - json!(context.git_summary.headline()), - ), - ( - "changed_files".to_string(), - json!(context.git_summary.changed_files), - ), - ( - "memory_file_count".to_string(), - json!(context.memory_file_count), - ), - ( - "loaded_config_files".to_string(), - json!(context.loaded_config_files), - ), - ( - "discovered_config_files".to_string(), - json!(context.discovered_config_files), - ), - ])) -} - -fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { - let degraded = status.enabled && !status.active; - let mut details = vec![ - format!("Enabled {}", status.enabled), - format!("Active {}", status.active), - format!("Supported {}", status.supported), - format!("Filesystem mode {}", status.filesystem_mode.as_str()), - format!("Filesystem live {}", status.filesystem_active), - ]; - if let Some(reason) = &status.fallback_reason { - details.push(format!("Fallback reason {reason}")); - } - DiagnosticCheck::new( - "Sandbox", - if degraded { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - if degraded { - "sandbox was requested but is not currently active" - } else if status.active { - "sandbox protections are active" - } else { - "sandbox is not active for this session" - }, - ) - .with_details(details) - .with_data(Map::from_iter([ - ("enabled".to_string(), json!(status.enabled)), - ("active".to_string(), json!(status.active)), - ("supported".to_string(), json!(status.supported)), - ( - "namespace_supported".to_string(), - json!(status.namespace_supported), - ), - ( - "namespace_active".to_string(), - json!(status.namespace_active), - ), - ( - "network_supported".to_string(), - json!(status.network_supported), - ), - ("network_active".to_string(), json!(status.network_active)), - ( - "filesystem_mode".to_string(), - json!(status.filesystem_mode.as_str()), - ), - ( - "filesystem_active".to_string(), - json!(status.filesystem_active), - ), - ("allowed_mounts".to_string(), json!(status.allowed_mounts)), - ("in_container".to_string(), json!(status.in_container)), - ( - "container_markers".to_string(), - json!(status.container_markers), - ), - ("fallback_reason".to_string(), json!(status.fallback_reason)), - ])) -} - -fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck { - let default_model = config.and_then(runtime::RuntimeConfig::model); - let mut details = vec![ - format!("OS {} {}", env::consts::OS, env::consts::ARCH), - format!("Working dir {}", cwd.display()), - format!("Version {}", VERSION), - format!("Build target {}", BUILD_TARGET.unwrap_or("")), - format!("Git SHA {}", GIT_SHA.unwrap_or("")), - ]; - if let Some(model) = default_model { - details.push(format!("Default model {model}")); - } - DiagnosticCheck::new( - "System", - DiagnosticLevel::Ok, - "captured local runtime metadata", - ) - .with_details(details) - .with_data(Map::from_iter([ - ("os".to_string(), json!(env::consts::OS)), - ("arch".to_string(), json!(env::consts::ARCH)), - ("working_dir".to_string(), json!(cwd.display().to_string())), - ("version".to_string(), json!(VERSION)), - ("build_target".to_string(), json!(BUILD_TARGET)), - ("git_sha".to_string(), json!(GIT_SHA)), - ("default_model".to_string(), json!(default_model)), - ])) -} - fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { matches!( SlashCommand::parse(current_command), @@ -2820,312 +1879,9 @@ fn run_stale_base_preflight(flag_value: Option<&str>) { } } -struct RuntimePluginState { - feature_config: runtime::RuntimeFeatureConfig, - tool_registry: GlobalToolRegistry, - plugin_registry: PluginRegistry, - mcp_state: Option>>, -} -struct RuntimeMcpState { - runtime: tokio::runtime::Runtime, - manager: McpServerManager, - pending_servers: Vec, - degraded_report: Option, -} -struct BuiltRuntime { - runtime: Option>, - plugin_registry: PluginRegistry, - plugins_active: bool, - mcp_state: Option>>, - mcp_active: bool, -} -impl BuiltRuntime { - fn new( - runtime: ConversationRuntime, - plugin_registry: PluginRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - runtime: Some(runtime), - plugin_registry, - plugins_active: true, - mcp_state, - mcp_active: true, - } - } - - fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { - let runtime = self - .runtime - .take() - .expect("runtime should exist before installing hook abort signal"); - self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); - self - } - - fn shutdown_plugins(&mut self) -> Result<(), Box> { - if self.plugins_active { - self.plugin_registry.shutdown()?; - self.plugins_active = false; - } - Ok(()) - } - - fn shutdown_mcp(&mut self) -> Result<(), Box> { - if self.mcp_active { - if let Some(mcp_state) = &self.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown()?; - } - self.mcp_active = false; - } - Ok(()) - } -} - -impl Deref for BuiltRuntime { - type Target = ConversationRuntime; - - fn deref(&self) -> &Self::Target { - self.runtime - .as_ref() - .expect("runtime should exist while built runtime is alive") - } -} - -impl DerefMut for BuiltRuntime { - fn deref_mut(&mut self) -> &mut Self::Target { - self.runtime - .as_mut() - .expect("runtime should exist while built runtime is alive") - } -} - -impl Drop for BuiltRuntime { - fn drop(&mut self) { - let _ = self.shutdown_mcp(); - let _ = self.shutdown_plugins(); - } -} - -#[derive(Debug, Deserialize)] -struct ToolSearchRequest { - query: String, - max_results: Option, -} - -#[derive(Debug, Deserialize)] -struct McpToolRequest { - #[serde(rename = "qualifiedName")] - qualified_name: Option, - tool: Option, - arguments: Option, -} - -#[derive(Debug, Deserialize)] -struct ListMcpResourcesRequest { - server: Option, -} - -#[derive(Debug, Deserialize)] -struct ReadMcpResourceRequest { - server: String, - uri: String, -} - -impl RuntimeMcpState { - fn new( - runtime_config: &runtime::RuntimeConfig, - ) -> Result, Box> { - let mut manager = McpServerManager::from_runtime_config(runtime_config); - if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { - return Ok(None); - } - - let runtime = tokio::runtime::Runtime::new()?; - let discovery = runtime.block_on(manager.discover_tools_best_effort()); - let pending_servers = discovery - .failed_servers - .iter() - .map(|failure| failure.server_name.clone()) - .chain( - discovery - .unsupported_servers - .iter() - .map(|server| server.server_name.clone()), - ) - .collect::>() - .into_iter() - .collect::>(); - let available_tools = discovery - .tools - .iter() - .map(|tool| tool.qualified_name.clone()) - .collect::>(); - let failed_server_names = pending_servers.iter().cloned().collect::>(); - let working_servers = manager - .server_names() - .into_iter() - .filter(|server_name| !failed_server_names.contains(server_name)) - .collect::>(); - let failed_servers = - discovery - .failed_servers - .iter() - .map(|failure| runtime::McpFailedServer { - server_name: failure.server_name.clone(), - phase: runtime::McpLifecyclePhase::ToolDiscovery, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ToolDiscovery, - Some(failure.server_name.clone()), - failure.error.clone(), - std::collections::BTreeMap::new(), - true, - ), - }) - .chain(discovery.unsupported_servers.iter().map(|server| { - runtime::McpFailedServer { - server_name: server.server_name.clone(), - phase: runtime::McpLifecyclePhase::ServerRegistration, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ServerRegistration, - Some(server.server_name.clone()), - server.reason.clone(), - std::collections::BTreeMap::from([( - "transport".to_string(), - format!("{:?}", server.transport).to_ascii_lowercase(), - )]), - false, - ), - } - })) - .collect::>(); - let degraded_report = (!failed_servers.is_empty()).then(|| { - runtime::McpDegradedReport::new( - working_servers, - failed_servers, - available_tools.clone(), - available_tools, - ) - }); - - Ok(Some(( - Self { - runtime, - manager, - pending_servers, - degraded_report, - }, - discovery, - ))) - } - - fn shutdown(&mut self) -> Result<(), Box> { - self.runtime.block_on(self.manager.shutdown())?; - Ok(()) - } - - fn pending_servers(&self) -> Option> { - (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) - } - - fn degraded_report(&self) -> Option { - self.degraded_report.clone() - } - - fn server_names(&self) -> Vec { - self.manager.server_names() - } - - fn call_tool( - &mut self, - qualified_tool_name: &str, - arguments: Option, - ) -> Result { - let response = self - .runtime - .block_on(self.manager.call_tool(qualified_tool_name, arguments)) - .map_err(|error| ToolError::new(error.to_string()))?; - if let Some(error) = response.error { - return Err(ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", - error.message, error.code - ))); - } - - let result = response.result.ok_or_else(|| { - ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned no result payload" - )) - })?; - serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_server(&mut self, server_name: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.list_resources(server_name)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "resources": result.resources, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_all_servers(&mut self) -> Result { - let mut resources = Vec::new(); - let mut failures = Vec::new(); - - for server_name in self.server_names() { - match self - .runtime - .block_on(self.manager.list_resources(&server_name)) - { - Ok(result) => resources.push(json!({ - "server": server_name, - "resources": result.resources, - })), - Err(error) => failures.push(json!({ - "server": server_name, - "error": error.to_string(), - })), - } - } - - if resources.is_empty() && !failures.is_empty() { - let message = failures - .iter() - .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) - .collect::>() - .join("; "); - return Err(ToolError::new(message)); - } - - serde_json::to_string_pretty(&json!({ - "resources": resources, - "failures": failures, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.read_resource(server_name, uri)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "contents": result.contents, - })) - .map_err(|error| ToolError::new(error.to_string())) - } -} fn build_runtime_mcp_state( runtime_config: &runtime::RuntimeConfig, @@ -3146,95 +1902,13 @@ fn build_runtime_mcp_state( Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) } -fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { - RuntimeToolDefinition { - name: tool.qualified_name.clone(), - description: Some( - tool.tool - .description - .clone() - .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), - ), - input_schema: tool - .tool - .input_schema - .clone() - .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), - required_permission: permission_mode_for_mcp_tool(&tool.tool), - } -} -fn mcp_wrapper_tool_definitions() -> Vec { - vec![ - RuntimeToolDefinition { - name: "MCPTool".to_string(), - description: Some( - "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "qualifiedName": { "type": "string" }, - "arguments": {} - }, - "required": ["qualifiedName"], - "additionalProperties": false - }), - required_permission: PermissionMode::DangerFullAccess, - }, - RuntimeToolDefinition { - name: "ListMcpResourcesTool".to_string(), - description: Some( - "List MCP resources from one configured server or from every connected server." - .to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" } - }, - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - RuntimeToolDefinition { - name: "ReadMcpResourceTool".to_string(), - description: Some("Read a specific MCP resource from a configured server.".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" }, - "uri": { "type": "string" } - }, - "required": ["server", "uri"], - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - ] -} -fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { - let read_only = mcp_annotation_flag(tool, "readOnlyHint"); - let destructive = mcp_annotation_flag(tool, "destructiveHint"); - let open_world = mcp_annotation_flag(tool, "openWorldHint"); - if read_only && !destructive && !open_world { - PermissionMode::ReadOnly - } else if destructive || open_world { - PermissionMode::DangerFullAccess - } else { - PermissionMode::WorkspaceWrite - } -} -fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { - tool.annotations - .as_ref() - .and_then(|annotations| annotations.get(key)) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) -} + + + struct HookAbortMonitor { stop_tx: Option>, @@ -3936,14 +2610,6 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso }) } -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> { render_diff_report_for(&env::current_dir()?) @@ -4415,36 +3081,9 @@ fn build_system_prompt() -> Result, Box> { )?) } -fn build_runtime_plugin_state() -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) -} -fn build_runtime_plugin_state_with_loader( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); - let plugin_registry = plugin_manager.plugin_registry()?; - let plugin_hook_config = - runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); - let feature_config = runtime_config - .feature_config() - .clone() - .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); - let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; - let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? - .with_runtime_tools(runtime_tools)?; - Ok(RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - }) -} + + fn build_plugin_manager( cwd: &Path, @@ -4490,287 +3129,6 @@ fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::Runtime ) } -#[derive(Debug, Clone, PartialEq, Eq)] -struct InternalPromptProgressState { - command_label: &'static str, - task_label: String, - step: usize, - phase: String, - detail: Option, - saw_final_text: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InternalPromptProgressEvent { - Started, - Update, - Heartbeat, - Complete, - Failed, -} - -#[derive(Debug)] -struct InternalPromptProgressShared { - state: Mutex, - output_lock: Mutex<()>, - started_at: Instant, -} - -#[derive(Debug, Clone)] -struct InternalPromptProgressReporter { - shared: Arc, -} - -#[derive(Debug)] -struct InternalPromptProgressRun { - reporter: InternalPromptProgressReporter, - heartbeat_stop: Option>, - heartbeat_handle: Option>, -} - -impl InternalPromptProgressReporter { - fn ultraplan(task: &str) -> Self { - Self { - shared: Arc::new(InternalPromptProgressShared { - state: Mutex::new(InternalPromptProgressState { - command_label: "Ultraplan", - task_label: task.to_string(), - step: 0, - phase: "planning started".to_string(), - detail: Some(format!("task: {task}")), - saw_final_text: false, - }), - output_lock: Mutex::new(()), - started_at: Instant::now(), - }), - } - } - - fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { - let snapshot = self.snapshot(); - let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); - self.write_line(&line); - } - - fn mark_model_phase(&self) { - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - state.step += 1; - state.phase = if state.step == 1 { - "analyzing request".to_string() - } else { - "reviewing findings".to_string() - }; - state.detail = Some(format!("task: {}", state.task_label)); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } - - fn mark_tool_phase(&self, name: &str, input: &str) { - let detail = describe_tool_progress(name, input); - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - state.step += 1; - state.phase = format!("running {name}"); - state.detail = Some(detail); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } - - fn mark_text_phase(&self, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let detail = truncate_for_summary(first_visible_line(trimmed), 120); - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - if state.saw_final_text { - return; - } - state.saw_final_text = true; - state.step += 1; - state.phase = "drafting final plan".to_string(); - state.detail = (!detail.is_empty()).then_some(detail); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } - - fn emit_heartbeat(&self) { - let snapshot = self.snapshot(); - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Heartbeat, - &snapshot, - self.elapsed(), - None, - )); - } - - fn snapshot(&self) -> InternalPromptProgressState { - self.shared - .state - .lock() - .expect("internal prompt progress state poisoned") - .clone() - } - - fn elapsed(&self) -> Duration { - self.shared.started_at.elapsed() - } - - fn write_line(&self, line: &str) { - let _guard = self - .shared - .output_lock - .lock() - .expect("internal prompt progress output lock poisoned"); - let mut stdout = io::stdout(); - let _ = writeln!(stdout, "{line}"); - let _ = stdout.flush(); - } -} - -impl InternalPromptProgressRun { - fn start_ultraplan(task: &str) -> Self { - let reporter = InternalPromptProgressReporter::ultraplan(task); - reporter.emit(InternalPromptProgressEvent::Started, None); - - let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); - let heartbeat_reporter = reporter.clone(); - let heartbeat_handle = thread::spawn(move || loop { - match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { - Ok(()) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), - } - }); - - Self { - reporter, - heartbeat_stop: Some(heartbeat_stop), - heartbeat_handle: Some(heartbeat_handle), - } - } - - fn reporter(&self) -> InternalPromptProgressReporter { - self.reporter.clone() - } - - fn finish_success(&mut self) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Complete, None); - } - - fn finish_failure(&mut self, error: &str) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Failed, Some(error)); - } - - fn stop_heartbeat(&mut self) { - if let Some(sender) = self.heartbeat_stop.take() { - let _ = sender.send(()); - } - if let Some(handle) = self.heartbeat_handle.take() { - let _ = handle.join(); - } - } -} - -impl Drop for InternalPromptProgressRun { - fn drop(&mut self) { - self.stop_heartbeat(); - } -} - -fn describe_tool_progress(name: &str, input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); - match name { - "bash" | "Bash" => { - let command = parsed - .get("command") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if command.is_empty() { - "running shell command".to_string() - } else { - format!("command {}", truncate_for_summary(command.trim(), 100)) - } - } - "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), - "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), - "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), - "glob_search" | "Glob" => { - 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!("glob `{pattern}` in {scope}") - } - "grep_search" | "Grep" => { - 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!("grep `{pattern}` in {scope}") - } - "web_search" | "WebSearch" => parsed - .get("query") - .and_then(|value| value.as_str()) - .map_or_else( - || "running web search".to_string(), - |query| format!("query {}", truncate_for_summary(query, 100)), - ), - _ => { - let summary = summarize_tool_payload(input); - if summary.is_empty() { - format!("running {name}") - } else { - format!("{name}: {summary}") - } - } - } -} - #[allow(clippy::needless_pass_by_value)] #[allow(clippy::too_many_arguments)] fn build_runtime( @@ -4853,418 +3211,14 @@ fn build_runtime_with_plugin_state( Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) } -struct CliHookProgressReporter; -impl runtime::HookProgressReporter for CliHookProgressReporter { - fn on_event(&mut self, event: &runtime::HookProgressEvent) { - match event { - runtime::HookProgressEvent::Started { - event, - tool_name, - command, - } => eprintln!( - "[hook {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - runtime::HookProgressEvent::Completed { - event, - tool_name, - command, - } => eprintln!( - "[hook done {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - runtime::HookProgressEvent::Cancelled { - event, - tool_name, - command, - } => eprintln!( - "[hook cancelled {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - } - } -} - -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()); - if let Some(reason) = &request.reason { - println!(" Reason {reason}"); - } - 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}"), - }, - } - } -} // NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct // now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, // OpenAI, or DashScope at construction time based on // `detect_provider_kind(&model)`. The struct name is kept to avoid // churning `BuiltRuntime` and every Deref/DerefMut site that references -// it. See ROADMAP #29 for the provider-dispatch routing fix. -struct AnthropicRuntimeClient { - runtime: tokio::runtime::Runtime, - client: ApiProviderClient, - session_id: String, - model: String, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - progress_reporter: Option, - reasoning_effort: Option, -} -impl AnthropicRuntimeClient { - fn new( - session_id: &str, - model: String, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - progress_reporter: Option, - ) -> Result> { - // Dispatch to the correct provider at construction time. - // `ApiProviderClient` (exposed by the api crate as - // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI - // variants, where xAI and OpenAI both use the OpenAI-compat - // wire format under the hood. We consult - // `detect_provider_kind(&resolved_model)` so model-name prefix - // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over - // env-var presence. - // - // For Anthropic we build the client directly instead of going - // through `ApiProviderClient::from_model_with_anthropic_auth` - // so we can explicitly apply `api::read_base_url()` — that - // reads `ANTHROPIC_BASE_URL` and is required for the local - // mock-server test harness - // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point - // claw at its fake Anthropic endpoint. We also attach a - // session-scoped prompt cache on the Anthropic path; the - // prompt cache is Anthropic-only so non-Anthropic variants - // skip it. - let resolved_model = api::resolve_model_alias(&model); - let client = match detect_provider_kind(&resolved_model) { - ProviderKind::Anthropic => { - let auth = resolve_cli_auth_source()?; - let inner = AnthropicClient::from_auth(auth) - .with_base_url(api::read_base_url()) - .with_prompt_cache(PromptCache::new(session_id)); - ApiProviderClient::Anthropic(inner) - } - ProviderKind::Xai | ProviderKind::OpenAi => { - // The api crate's `ProviderClient::from_model_with_anthropic_auth` - // with `None` for the anthropic auth routes via - // `detect_provider_kind` and builds an - // `OpenAiCompatClient::from_env` with the matching - // `OpenAiCompatConfig` (openai / xai / dashscope). - // That reads the correct API-key env var and BASE_URL - // override internally, so this one call covers OpenAI, - // OpenRouter, xAI, DashScope, Ollama, and any other - // OpenAI-compat endpoint users configure via - // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. - ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? - } - }; - Ok(Self { - runtime: tokio::runtime::Runtime::new()?, - client, - session_id: session_id.to_string(), - model, - enable_tools, - emit_output, - allowed_tools, - tool_registry, - progress_reporter, - reasoning_effort: None, - }) - } - - fn set_reasoning_effort(&mut self, effort: Option) { - self.reasoning_effort = effort; - } -} - -fn resolve_cli_auth_source() -> Result> { - Ok(resolve_cli_auth_source_for_cwd()?) -} - -fn resolve_cli_auth_source_for_cwd() -> Result { - resolve_startup_auth_source(|| Ok(None)) -} - -impl ApiClient for AnthropicRuntimeClient { - #[allow(clippy::too_many_lines)] - fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_model_phase(); - } - let is_post_tool = request_ends_with_tool_result(&request); - 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.tool_registry, self.allowed_tools.as_ref())), - tool_choice: self.enable_tools.then_some(ToolChoice::Auto), - stream: true, - reasoning_effort: self.reasoning_effort.clone(), - ..Default::default() - }; - - self.runtime.block_on(async { - // When resuming after tool execution, apply a stall timeout on the - // first stream event. If the model does not respond within the - // deadline we drop the stalled connection and re-send the request as - // a continuation nudge (one retry only). - let max_attempts: usize = if is_post_tool { 2 } else { 1 }; - - for attempt in 1..=max_attempts { - let result = self - .consume_stream(&message_request, is_post_tool && attempt == 1) - .await; - match result { - Ok(events) => return Ok(events), - Err(error) - if error.to_string().contains("post-tool stall") - && attempt < max_attempts => - { - // Stalled after tool completion — nudge the model by - // re-sending the same request. - } - Err(error) => return Err(error), - } - } - - Err(RuntimeError::new("post-tool continuation nudge exhausted")) - }) - } -} - -impl AnthropicRuntimeClient { - /// Consume a single streaming response, optionally applying a stall - /// timeout on the first event for post-tool continuations. - #[allow(clippy::too_many_lines)] - async fn consume_stream( - &self, - message_request: &MessageRequest, - apply_stall_timeout: bool, - ) -> Result, RuntimeError> { - let mut stream = self - .client - .stream_message(message_request) - .await - .map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?; - 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 block_has_thinking_summary = false; - let mut saw_stop = false; - let mut received_any_event = false; - - loop { - let next = if apply_stall_timeout && !received_any_event { - match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { - Ok(inner) => inner.map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?, - Err(_elapsed) => { - return Err(RuntimeError::new( - "post-tool stall: model did not respond within timeout", - )); - } - } - } else { - stream.next_event().await.map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })? - }; - - let Some(event) = next else { - break; - }; - received_any_event = true; - - match event { - ApiStreamEvent::MessageStart(start) => { - for block in start.message.content { - push_output_block( - block, - out, - &mut events, - &mut pending_tool, - true, - &mut block_has_thinking_summary, - )?; - } - } - ApiStreamEvent::ContentBlockStart(start) => { - push_output_block( - start.content_block, - out, - &mut events, - &mut pending_tool, - true, - &mut block_has_thinking_summary, - )?; - } - ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { - ContentBlockDelta::TextDelta { text } => { - if !text.is_empty() { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_text_phase(&text); - } - 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); - } - } - ContentBlockDelta::ThinkingDelta { thinking } => { - if !block_has_thinking_summary { - render_thinking_block_summary(out, None, false)?; - block_has_thinking_summary = true; - } - events.push(AssistantEvent::ThinkingDelta(thinking)); - } - ContentBlockDelta::SignatureDelta { signature } => { - events.push(AssistantEvent::SignatureDelta(signature)); - } - }, - ApiStreamEvent::ContentBlockStop(_) => { - block_has_thinking_summary = false; - 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() { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_tool_phase(&name, &input); - } - // 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(delta.usage.token_usage())); - } - 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); - } - } - } - - push_prompt_cache_record(&self.client, &mut events); - - 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(format_user_visible_api_error(&self.session_id, &error)) - })?; - let mut events = response_to_events(response, out)?; - push_prompt_cache_record(&self.client, &mut events); - Ok(events) - } -} - -/// Returns `true` when the conversation ends with a tool-result message, -/// meaning the model is expected to continue after tool execution. -fn request_ends_with_tool_result(request: &ApiRequest) -> bool { - request - .messages - .last() - .is_some_and(|message| message.role == MessageRole::Tool) -} fn final_assistant_text(summary: &runtime::TurnSummary) -> String { summary @@ -5544,39 +3498,6 @@ const READ_DISPLAY_MAX_CHARS: usize = 6_000; const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; -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 first_visible_line(text: &str) -> &str { - text.lines() - .find(|line| !line.trim().is_empty()) - .unwrap_or(text) -} - -fn summarize_tool_payload(payload: &str) -> String { - let compact = match serde_json::from_str::(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::(); - if chars.next().is_some() { - format!("{truncated}…") - } else { - truncated - } -} fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { let original = content.trim_end_matches('\n'); @@ -5622,201 +3543,14 @@ fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize preview } -fn response_to_events( - response: MessageResponse, - out: &mut (impl Write + ?Sized), -) -> Result, RuntimeError> { - let mut events = Vec::new(); - let mut pending_tool = None; - for block in response.content { - let mut block_has_thinking_summary = false; - push_output_block( - block, - out, - &mut events, - &mut pending_tool, - false, - &mut block_has_thinking_summary, - )?; - if let Some((id, name, input)) = pending_tool.take() { - events.push(AssistantEvent::ToolUse { id, name, input }); - } - } - events.push(AssistantEvent::Usage(response.usage.token_usage())); - events.push(AssistantEvent::MessageStop); - Ok(events) -} -fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec) { - // `ApiProviderClient::take_last_prompt_cache_record` is a pass-through - // to the Anthropic variant and returns `None` for OpenAI-compat / - // xAI variants, which do not have a prompt cache. So this helper - // remains a no-op on non-Anthropic providers without any extra - // branching here. - if let Some(record) = client.take_last_prompt_cache_record() { - if let Some(event) = prompt_cache_record_to_runtime_event(record) { - events.push(AssistantEvent::PromptCache(event)); - } - } -} -fn prompt_cache_record_to_runtime_event( - record: api::PromptCacheRecord, -) -> Option { - let cache_break = record.cache_break?; - Some(PromptCacheEvent { - unexpected: cache_break.unexpected, - reason: cache_break.reason, - previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, - current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, - token_drop: cache_break.token_drop, - }) -} -struct CliToolExecutor { - renderer: TerminalRenderer, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - mcp_state: Option>>, -} -impl CliToolExecutor { - fn new( - allowed_tools: Option, - emit_output: bool, - tool_registry: GlobalToolRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - renderer: TerminalRenderer::new(), - emit_output, - allowed_tools, - tool_registry, - mcp_state, - } - } - fn execute_search_tool(&self, value: serde_json::Value) -> Result { - let input: ToolSearchRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let (pending_mcp_servers, mcp_degraded) = - self.mcp_state.as_ref().map_or((None, None), |state| { - let state = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - (state.pending_servers(), state.degraded_report()) - }); - serde_json::to_string_pretty(&self.tool_registry.search( - &input.query, - input.max_results.unwrap_or(5), - pending_mcp_servers, - mcp_degraded, - )) - .map_err(|error| ToolError::new(error.to_string())) - } - fn execute_runtime_tool( - &self, - tool_name: &str, - value: serde_json::Value, - ) -> Result { - let Some(mcp_state) = &self.mcp_state else { - return Err(ToolError::new(format!( - "runtime tool `{tool_name}` is unavailable without configured MCP servers" - ))); - }; - let mut mcp_state = mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - - match tool_name { - "MCPTool" => { - let input: McpToolRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let qualified_name = input - .qualified_name - .or(input.tool) - .ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?; - mcp_state.call_tool(&qualified_name, input.arguments) - } - "ListMcpResourcesTool" => { - let input: ListMcpResourcesRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - match input.server { - Some(server_name) => mcp_state.list_resources_for_server(&server_name), - None => mcp_state.list_resources_for_all_servers(), - } - } - "ReadMcpResourceTool" => { - let input: ReadMcpResourceRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - mcp_state.read_resource(&input.server, &input.uri) - } - _ => mcp_state.call_tool(tool_name, Some(value)), - } - } -} - -impl ToolExecutor for CliToolExecutor { - fn execute(&mut self, tool_name: &str, input: &str) -> Result { - 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}")))?; - let result = if tool_name == "ToolSearch" { - self.execute_search_tool(value) - } else if self.tool_registry.has_runtime_tool(tool_name) { - self.execute_runtime_tool(tool_name, value) - } else { - self.tool_registry - .execute(tool_name, &value) - .map_err(ToolError::new) - }; - match result { - 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.to_string(), true); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; - } - Err(error) - } - } - } -} - -fn permission_policy( - mode: PermissionMode, - feature_config: &runtime::RuntimeFeatureConfig, - tool_registry: &GlobalToolRegistry, -) -> Result { - Ok(tool_registry.permission_specs(None)?.into_iter().fold( - PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), - |policy, (name, required_permission)| { - policy.with_tool_requirement(name, required_permission) - }, - )) -} fn convert_messages(messages: &[ConversationMessage]) -> Vec { messages @@ -6271,3 +4005,11 @@ mod dump_manifests_tests { let _ = fs::remove_dir_all(&root); } } + +pub(crate) fn resolve_cli_auth_source() -> Result> { + Ok(resolve_cli_auth_source_for_cwd()?) +} + +pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result { + api::resolve_startup_auth_source(|| Ok(None)) +} diff --git a/rust/crates/rusty-claude-cli/src/main_tests.rs b/rust/crates/rusty-claude-cli/src/main_tests.rs index 1b87c6c0..5b19d3d9 100644 --- a/rust/crates/rusty-claude-cli/src/main_tests.rs +++ b/rust/crates/rusty-claude-cli/src/main_tests.rs @@ -638,52 +638,9 @@ mod tests { ); } - #[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 user_defined_aliases_resolve_before_provider_dispatch() { - // given - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project"); - let config_home = root.join("config-home"); - std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); - std::fs::create_dir_all(&config_home).expect("config home should exist"); - std::fs::write( - cwd.join(".claw").join("settings.json"), - r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#, - ) - .expect("project config should write"); - - let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - - // when - let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast")); - let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart")); - let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap")); - let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model")); - let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku")); - - match original_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } - std::fs::remove_dir_all(root).expect("temp config root should clean up"); - - // then - assert_eq!(direct, "claude-haiku-4-5-20251213"); - assert_eq!(chained, "claude-opus-4-6"); - assert_eq!(cross_provider, "grok-3-mini"); - assert_eq!(unknown, "unknown-model"); - assert_eq!(builtin, "claude-haiku-4-5-20251213"); - } + #[test] fn parses_version_flags_without_initializing_prompt_mode() { @@ -1799,20 +1756,7 @@ mod tests { assert!(truncated.chars().count() <= 281); } - #[test] - fn short_tool_id_truncates_long_identifiers_with_ellipsis() { - // given - let long = "toolu_01ABCDEFGHIJKLMN"; - let short = "tool_1"; - - // when - let trimmed_long = short_tool_id(long); - let trimmed_short = short_tool_id(short); - - // then - assert_eq!(trimmed_long, "toolu_01ABCD…"); - assert_eq!(trimmed_short, "tool_1"); - } + #[test] fn parses_json_output_for_mcp_and_skills_commands() { @@ -2256,18 +2200,7 @@ mod tests { assert!(names.contains(&"plugin_echo".to_string())); } - #[test] - fn permission_policy_uses_plugin_tool_permissions() { - let feature_config = runtime::RuntimeFeatureConfig::default(); - let policy = permission_policy( - PermissionMode::ReadOnly, - &feature_config, - ®istry_with_plugin_tool(), - ) - .expect("permission policy should build"); - let required = policy.required_mode_for("plugin_echo"); - assert_eq!(required, PermissionMode::WorkspaceWrite); - } + #[test] fn shared_help_uses_resume_annotation_copy() { @@ -2403,52 +2336,11 @@ mod tests { assert_eq!(line, "Connected: grok-3 via xai"); } - #[test] - fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { - let user_model = "claude-sonnet-4-6".to_string(); + - let resolved = resolve_repl_model(user_model); + - assert_eq!(resolved, "claude-sonnet-4-6"); - } - - #[test] - fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() { - let _guard = env_lock(); - let root = temp_dir(); - fs::create_dir_all(&root).expect("root dir"); - let config_home = root.join("config"); - fs::create_dir_all(&config_home).expect("config home dir"); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("ANTHROPIC_MODEL"); - std::env::set_var("ANTHROPIC_MODEL", "sonnet"); - - let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); - - assert_eq!(resolved, "claude-sonnet-4-6"); - - std::env::remove_var("ANTHROPIC_MODEL"); - std::env::remove_var("CLAW_CONFIG_HOME"); - fs::remove_dir_all(root).expect("cleanup temp dir"); - } - - #[test] - fn resolve_repl_model_returns_default_when_env_unset_and_no_config() { - let _guard = env_lock(); - let root = temp_dir(); - fs::create_dir_all(&root).expect("root dir"); - let config_home = root.join("config"); - fs::create_dir_all(&config_home).expect("config home dir"); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("ANTHROPIC_MODEL"); - - let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); - - assert_eq!(resolved, DEFAULT_MODEL); - - std::env::remove_var("CLAW_CONFIG_HOME"); - fs::remove_dir_all(root).expect("cleanup temp dir"); - } + #[test] fn resume_supported_command_list_matches_expected_surface() { @@ -3471,107 +3363,15 @@ UU conflicted.rs", assert_eq!(entries[1].text, "world"); } - #[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 tool_rendering_truncates_large_read_output_for_display_only() { - let content = (0..200) - .map(|index| format!("line {index:03}")) - .collect::>() - .join("\n"); - let output = json!({ - "file": { - "filePath": "src/main.rs", - "content": content, - "numLines": 200, - "startLine": 1, - "totalLines": 200 - } - }) - .to_string(); + - let rendered = format_tool_result("read_file", &output, false); + - assert!(rendered.contains("line 000")); - assert!(rendered.contains("line 079")); - assert!(!rendered.contains("line 199")); - assert!(rendered.contains("full result preserved in session")); - assert!(output.contains("line 199")); - } - - #[test] - fn tool_rendering_truncates_large_bash_output_for_display_only() { - let stdout = (0..120) - .map(|index| format!("stdout {index:03}")) - .collect::>() - .join("\n"); - let output = json!({ - "stdout": stdout, - "stderr": "", - "returnCodeInterpretation": "completed successfully" - }) - .to_string(); - - let rendered = format_tool_result("bash", &output, false); - - assert!(rendered.contains("stdout 000")); - assert!(rendered.contains("stdout 059")); - assert!(!rendered.contains("stdout 119")); - assert!(rendered.contains("full result preserved in session")); - assert!(output.contains("stdout 119")); - } - - #[test] - fn tool_rendering_truncates_generic_long_output_for_display_only() { - let items = (0..120) - .map(|index| format!("payload {index:03}")) - .collect::>(); - let output = json!({ - "summary": "plugin payload", - "items": items, - }) - .to_string(); - - let rendered = format_tool_result("plugin_echo", &output, false); - - assert!(rendered.contains("plugin_echo")); - assert!(rendered.contains("payload 000")); - assert!(rendered.contains("payload 040")); - assert!(!rendered.contains("payload 080")); - assert!(!rendered.contains("payload 119")); - assert!(rendered.contains("full result preserved in session")); - assert!(output.contains("payload 119")); - } - - #[test] - fn tool_rendering_truncates_raw_generic_output_for_display_only() { - let output = (0..120) - .map(|index| format!("raw {index:03}")) - .collect::>() - .join("\n"); - - let rendered = format_tool_result("plugin_echo", &output, false); - - assert!(rendered.contains("plugin_echo")); - assert!(rendered.contains("raw 000")); - assert!(rendered.contains("raw 059")); - assert!(!rendered.contains("raw 119")); - assert!(rendered.contains("full result preserved in session")); - assert!(output.contains("raw 119")); - } + #[test] fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() { @@ -3620,21 +3420,7 @@ UU conflicted.rs", assert!(failed.contains("network timeout")); } - #[test] - fn describe_tool_progress_summarizes_known_tools() { - assert_eq!( - describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), - "reading src/main.rs" - ); - assert!( - describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) - .contains("cargo test -p rusty-claude-cli") - ); - assert_eq!( - describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), - "grep `ultraplan` in rust" - ); - } + #[test] fn push_output_block_renders_markdown_text() { @@ -3688,376 +3474,19 @@ UU conflicted.rs", ); } - #[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\"}" - )); - } + - #[test] - fn response_to_events_renders_collapsed_thinking_summary() { - let mut out = Vec::new(); - let events = response_to_events( - MessageResponse { - id: "msg-3".to_string(), - kind: "message".to_string(), - model: "claude-opus-4-6".to_string(), - role: "assistant".to_string(), - content: vec![ - OutputContentBlock::Thinking { - thinking: "step 1".to_string(), - signature: Some("sig_123".to_string()), - }, - OutputContentBlock::Text { - text: "Final answer".to_string(), - }, - ], - stop_reason: Some("end_turn".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::TextDelta(text) if text == "Final answer" - )); - let rendered = String::from_utf8(out).expect("utf8"); - assert!(rendered.contains("▶ Thinking (6 chars hidden)")); - assert!(!rendered.contains("step 1")); - } + - #[test] - fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { - let config_home = temp_dir(); - let workspace = temp_dir(); - let source_root = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - fs::create_dir_all(&source_root).expect("source root"); - write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); - - let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - manager - .install(source_root.to_str().expect("utf8 source path")) - .expect("plugin install should succeed"); - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("plugin state should load"); - let pre_hooks = state.feature_config.hooks().pre_tool_use(); - assert_eq!(pre_hooks.len(), 1); - assert!( - pre_hooks[0].ends_with("hooks/pre.sh"), - "expected installed plugin hook path, got {pre_hooks:?}" - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - let _ = fs::remove_dir_all(source_root); - } - - #[test] - #[allow(clippy::too_many_lines)] - fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() { - let config_home = temp_dir(); - let workspace = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - let script_path = workspace.join("fixture-mcp.py"); - write_mcp_server_fixture(&script_path); - fs::write( - config_home.join("settings.json"), - format!( - r#"{{ - "mcpServers": {{ - "alpha": {{ - "command": "python3", - "args": ["{}"] - }}, - "broken": {{ - "command": "python3", - "args": ["-c", "import sys; sys.exit(0)"] - }} - }} - }}"#, - script_path.to_string_lossy() - ), - ) - .expect("write mcp settings"); - - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("runtime plugin state should load"); - - let allowed = state - .tool_registry - .normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()]) - .expect("mcp tools should be allow-listable") - .expect("allow-list should exist"); - assert!(allowed.contains("mcp__alpha__echo")); - assert!(allowed.contains("MCPTool")); - - let mut executor = CliToolExecutor::new( - None, - false, - state.tool_registry.clone(), - state.mcp_state.clone(), - ); - - let tool_output = executor - .execute("mcp__alpha__echo", r#"{"text":"hello"}"#) - .expect("discovered mcp tool should execute"); - let tool_json: serde_json::Value = - serde_json::from_str(&tool_output).expect("tool output should be json"); - assert_eq!(tool_json["structuredContent"]["echoed"], "hello"); - - let wrapped_output = executor - .execute( - "MCPTool", - r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#, - ) - .expect("generic mcp wrapper should execute"); - let wrapped_json: serde_json::Value = - serde_json::from_str(&wrapped_output).expect("wrapped output should be json"); - assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped"); - - let search_output = executor - .execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#) - .expect("tool search should execute"); - let search_json: serde_json::Value = - serde_json::from_str(&search_output).expect("search output should be json"); - assert_eq!(search_json["matches"][0], "mcp__alpha__echo"); - assert_eq!(search_json["pending_mcp_servers"][0], "broken"); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["server_name"], - "broken" - ); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["phase"], - "tool_discovery" - ); - assert_eq!( - search_json["mcp_degraded"]["available_tools"][0], - "mcp__alpha__echo" - ); - - let listed = executor - .execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#) - .expect("resources should list"); - let listed_json: serde_json::Value = - serde_json::from_str(&listed).expect("resource output should be json"); - assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt"); - - let read = executor - .execute( - "ReadMcpResourceTool", - r#"{"server":"alpha","uri":"file://guide.txt"}"#, - ) - .expect("resource should read"); - let read_json: serde_json::Value = - serde_json::from_str(&read).expect("resource read output should be json"); - assert_eq!( - read_json["contents"][0]["text"], - "contents for file://guide.txt" - ); - - if let Some(mcp_state) = state.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown() - .expect("mcp shutdown should succeed"); - } - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - } - - #[test] - fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() { - let config_home = temp_dir(); - let workspace = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - fs::write( - config_home.join("settings.json"), - r#"{ - "mcpServers": { - "remote": { - "url": "https://example.test/mcp" - } - } - }"#, - ) - .expect("write mcp settings"); - - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("runtime plugin state should load"); - let mut executor = CliToolExecutor::new( - None, - false, - state.tool_registry.clone(), - state.mcp_state.clone(), - ); - - let search_output = executor - .execute("ToolSearch", r#"{"query":"remote","max_results":5}"#) - .expect("tool search should execute"); - let search_json: serde_json::Value = - serde_json::from_str(&search_output).expect("search output should be json"); - assert_eq!(search_json["pending_mcp_servers"][0], "remote"); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["server_name"], - "remote" - ); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["phase"], - "server_registration" - ); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"], - "http" - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - } - - #[test] - fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() { - // Serialize access to process-wide env vars so parallel tests that - // set/remove ANTHROPIC_API_KEY do not race with this test. - let _guard = env_lock(); - let config_home = temp_dir(); - // Inject a dummy API key so runtime construction succeeds without real credentials. - // This test only exercises plugin lifecycle (init/shutdown), never calls the API. - std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle"); - let workspace = temp_dir(); - let source_root = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - fs::create_dir_all(&source_root).expect("source root"); - write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true); - - let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - let install = manager - .install(source_root.to_str().expect("utf8 source path")) - .expect("plugin install should succeed"); - let log_path = install.install_path.join("lifecycle.log"); - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let runtime_plugin_state = - build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("plugin state should load"); - let mut runtime = build_runtime_with_plugin_state( - Session::new(), - "runtime-plugin-lifecycle", - DEFAULT_MODEL.to_string(), - vec!["test system prompt".to_string()], - true, - false, - None, - PermissionMode::DangerFullAccess, - None, - runtime_plugin_state, - ) - .expect("runtime should build"); - - assert_eq!( - fs::read_to_string(&log_path).expect("init log should exist"), - "init\n" - ); - - runtime - .shutdown_plugins() - .expect("plugin shutdown should succeed"); - - assert_eq!( - fs::read_to_string(&log_path).expect("shutdown log should exist"), - "init\nshutdown\n" - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - let _ = fs::remove_dir_all(source_root); - std::env::remove_var("ANTHROPIC_API_KEY"); - } + #[test] fn rejects_invalid_reasoning_effort_value() { diff --git a/rust/crates/rusty-claude-cli/src/permissions/mod.rs b/rust/crates/rusty-claude-cli/src/permissions/mod.rs new file mode 100644 index 00000000..e08cf122 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/permissions/mod.rs @@ -0,0 +1 @@ +pub(crate) mod prompter; diff --git a/rust/crates/rusty-claude-cli/src/permissions/prompter.rs b/rust/crates/rusty-claude-cli/src/permissions/prompter.rs new file mode 100644 index 00000000..077e403e --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/permissions/prompter.rs @@ -0,0 +1,164 @@ +use std::env; +use std::io::{self, Write}; +use runtime::{PermissionMode, ResolvedPermissionMode, ConfigLoader, PermissionPolicy}; +use tools::GlobalToolRegistry; + +pub(crate) fn parse_permission_mode_arg(value: &str) -> Result { + 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) +} + +pub(crate) 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}"), + } +} + +pub(crate) fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { + match mode { + ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, + ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, + ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, + } +} + +pub(crate) fn default_permission_mode() -> PermissionMode { + env::var("RUSTY_CLAUDE_PERMISSION_MODE") + .ok() + .as_deref() + .and_then(normalize_permission_mode) + .map(permission_mode_from_label) + .or_else(config_permission_mode_for_current_dir) + .unwrap_or(PermissionMode::DangerFullAccess) +} + +pub(crate) fn config_permission_mode_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader + .load() + .ok()? + .permission_mode() + .map(permission_mode_from_resolved) +} +pub(crate) struct CliPermissionPrompter { + current_mode: PermissionMode, +} + +impl CliPermissionPrompter { + pub(crate) 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()); + if let Some(reason) = &request.reason { + println!(" Reason {reason}"); + } + 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}"), + }, + } + } +} +pub(crate) fn permission_policy( + mode: PermissionMode, + feature_config: &runtime::RuntimeFeatureConfig, + tool_registry: &GlobalToolRegistry, +) -> Result { + Ok(tool_registry.permission_specs(None)?.into_iter().fold( + PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + )) +} +pub(crate) 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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; + use serde_json::json; + use tools::GlobalToolRegistry; + use runtime::{PermissionMode, RuntimeFeatureConfig}; + + fn registry_with_plugin_tool() -> GlobalToolRegistry { + GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "plugin-demo@external", + "plugin-demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin payload".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + }), + }, + "echo".to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + None, + )]).expect("registry should build") + } + + #[test] + fn permission_policy_uses_plugin_tool_permissions() { + let feature_config = runtime::RuntimeFeatureConfig::default(); + let policy = permission_policy( + PermissionMode::ReadOnly, + &feature_config, + ®istry_with_plugin_tool(), + ) + .expect("permission policy should build"); + let required = policy.required_mode_for("plugin_echo"); + assert_eq!(required, PermissionMode::WorkspaceWrite); + } +} diff --git a/rust/crates/rusty-claude-cli/src/repl.rs b/rust/crates/rusty-claude-cli/src/repl.rs index 94b2f70e..b27cb87e 100644 --- a/rust/crates/rusty-claude-cli/src/repl.rs +++ b/rust/crates/rusty-claude-cli/src/repl.rs @@ -1,3 +1,4 @@ +use crate::diagnostics::{render_doctor_report, run_mcp_serve}; use crate::TokenUsage; use crate::*; use api::Usage; diff --git a/rust/crates/rusty-claude-cli/src/ui/mod.rs b/rust/crates/rusty-claude-cli/src/ui/mod.rs new file mode 100644 index 00000000..d8f5edbb --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/ui/mod.rs @@ -0,0 +1 @@ +pub(crate) mod progress; diff --git a/rust/crates/rusty-claude-cli/src/ui/progress.rs b/rust/crates/rusty-claude-cli/src/ui/progress.rs new file mode 100644 index 00000000..261a11c4 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/ui/progress.rs @@ -0,0 +1,375 @@ +use std::sync::{Arc, Mutex}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::{Duration, Instant}; +use std::thread; +use std::io::{self, Write}; +use crate::INTERNAL_PROGRESS_HEARTBEAT_INTERVAL; +use crate::render::format_internal_prompt_progress_line; + + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InternalPromptProgressState { + pub(crate) command_label: &'static str, + pub(crate) task_label: String, + pub(crate) step: usize, + pub(crate) phase: String, + pub(crate) detail: Option, + pub(crate) saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +pub(crate) struct InternalPromptProgressShared { + state: Mutex, + output_lock: Mutex<()>, + started_at: Instant, +} + +#[derive(Debug, Clone)] +pub(crate) struct InternalPromptProgressReporter { + shared: Arc, +} + +#[derive(Debug)] +pub(crate) struct InternalPromptProgressRun { + reporter: InternalPromptProgressReporter, + heartbeat_stop: Option>, + heartbeat_handle: Option>, +} + +impl InternalPromptProgressReporter { + pub(crate) fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + pub(crate) fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + pub(crate) fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + pub(crate) fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + pub(crate) fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + pub(crate) fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Complete, None); + } + + pub(crate) fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + pub(crate) fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +pub(crate) fn describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + 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!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + 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!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} +pub(crate) struct CliHookProgressReporter; + +impl runtime::HookProgressReporter for CliHookProgressReporter { + fn on_event(&mut self, event: &runtime::HookProgressEvent) { + match event { + runtime::HookProgressEvent::Started { + event, + tool_name, + command, + } => eprintln!( + "[hook {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Completed { + event, + tool_name, + command, + } => eprintln!( + "[hook done {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Cancelled { + event, + tool_name, + command, + } => eprintln!( + "[hook cancelled {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + } + } +} +pub(crate) 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() +} + +pub(crate) fn first_visible_line(text: &str) -> &str { + text.lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(text) +} + +pub(crate) fn summarize_tool_payload(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + truncate_for_summary(&compact, 96) +} + +pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(limit).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn describe_tool_progress_summarizes_known_tools() { + assert_eq!( + describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), + "reading src/main.rs" + ); + assert!( + describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) + .contains("cargo test -p rusty-claude-cli") + ); + assert_eq!( + describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), + "grep `ultraplan` in rust" + ); + } +} diff --git a/rust/dedup_tests.py b/rust/dedup_tests.py new file mode 100644 index 00000000..f38adc93 --- /dev/null +++ b/rust/dedup_tests.py @@ -0,0 +1,53 @@ +import os +import re + +def get_all_tests_in_dir(directory): + tests = set() + for root, _, files in os.walk(directory): + for file in files: + if file.endswith('.rs') and file != 'main_tests.rs': + path = os.path.join(root, file) + with open(path, 'r') as f: + content = f.read() + # Find all #[test] functions + matches = re.findall(r'#\[(?:tokio::)?test\].*?(?:async )?fn\s+([a-zA-Z0-9_]+)\s*\(', content, re.DOTALL) + tests.update(matches) + return tests + +def remove_tests(file_path, tests_to_remove): + with open(file_path, 'r') as f: + content = f.read() + + for name in tests_to_remove: + # Find the function definition + pattern = re.compile(r'(#\[(?:tokio::)?test\].*?(?:async )?fn ' + name + r'\s*\([^)]*\)\s*\{)', re.DOTALL) + match = pattern.search(content) + if not match: + continue + + start_idx = match.start() + brace_count = 1 + curr_idx = match.end() + + while brace_count > 0 and curr_idx < len(content): + if content[curr_idx] == '{': + brace_count += 1 + elif content[curr_idx] == '}': + brace_count -= 1 + curr_idx += 1 + + end_idx = curr_idx + content = content[:start_idx] + content[end_idx:] + print(f"Removed {name} from {file_path}") + + with open(file_path, 'w') as f: + f.write(content) + +def main(): + src_dir = 'crates/rusty-claude-cli/src' + tests = get_all_tests_in_dir(src_dir) + print(f"Found {len(tests)} tests in other files") + remove_tests(os.path.join(src_dir, 'main_tests.rs'), tests) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/rust/dup_tests.txt b/rust/dup_tests.txt new file mode 100644 index 00000000..29c4fbd2 --- /dev/null +++ b/rust/dup_tests.txt @@ -0,0 +1,7 @@ +describe_tool_progress_summarizes_known_tools +permission_policy_uses_plugin_tool_permissions +resolve_repl_model_falls_back_to_anthropic_model_env_when_default +resolve_repl_model_returns_default_when_env_unset_and_no_config +resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit +resolves_known_model_aliases +user_defined_aliases_resolve_before_provider_dispatch diff --git a/rust/extract_tests_execution.py b/rust/extract_tests_execution.py new file mode 100644 index 00000000..fb8e079c --- /dev/null +++ b/rust/extract_tests_execution.py @@ -0,0 +1,74 @@ +import re +import sys + +def main(): + with open('crates/rusty-claude-cli/src/main_tests.rs', 'r') as f: + content = f.read() + + # Tests for client.rs + client_test_names = [ + 'build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features', + 'build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers', + 'build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally', + 'build_runtime_runs_plugin_lifecycle_init_and_shutdown' + ] + + # Tests for stream.rs + stream_test_names = [ + 'response_to_events_preserves_empty_object_json_input_outside_streaming', + 'response_to_events_preserves_non_empty_json_input_outside_streaming', + 'response_to_events_renders_collapsed_thinking_summary' + ] + + # Tests for executor.rs (tool rendering) + executor_test_names = [ + 'tool_rendering_helpers_compact_output', + 'tool_rendering_truncates_large_read_output_for_display_only', + 'tool_rendering_truncates_large_bash_output_for_display_only', + 'tool_rendering_truncates_generic_long_output_for_display_only', + 'tool_rendering_truncates_raw_generic_output_for_display_only', + 'short_tool_id_truncates_long_identifiers_with_ellipsis' + ] + + def extract_and_remove(test_names, output_file): + nonlocal content + extracted_tests = [] + for name in test_names: + # Find the function definition + pattern = re.compile(r'(#\[(?:tokio::)?test\].*?(?:async )?fn ' + name + r'\s*\([^)]*\)\s*\{)', re.DOTALL) + match = pattern.search(content) + if not match: + print(f"Warning: Test {name} not found") + continue + + start_idx = match.start() + brace_count = 1 + curr_idx = match.end() + + while brace_count > 0 and curr_idx < len(content): + if content[curr_idx] == '{': + brace_count += 1 + elif content[curr_idx] == '}': + brace_count -= 1 + curr_idx += 1 + + end_idx = curr_idx + extracted_tests.append(content[start_idx:end_idx]) + content = content[:start_idx] + content[end_idx:] + + if extracted_tests: + with open(output_file, 'a') as f: + f.write('\n\n#[cfg(test)]\nmod tests {\n use super::*;\n') + for t in extracted_tests: + f.write('\n ' + t.replace('\n', '\n ') + '\n') + f.write('}\n') + + extract_and_remove(client_test_names, 'crates/rusty-claude-cli/src/execution/client.rs') + extract_and_remove(stream_test_names, 'crates/rusty-claude-cli/src/execution/stream.rs') + extract_and_remove(executor_test_names, 'crates/rusty-claude-cli/src/execution/executor.rs') + + with open('crates/rusty-claude-cli/src/main_tests.rs', 'w') as f: + f.write(content) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/rust/extract_tests_safe.py b/rust/extract_tests_safe.py new file mode 100644 index 00000000..55ff6d1f --- /dev/null +++ b/rust/extract_tests_safe.py @@ -0,0 +1,81 @@ +def extract_tests(file_path, test_names, output_file): + with open(file_path, 'r') as f: + content = f.read() + + extracted_tests = [] + + for name in test_names: + # Find the function definition + func_def = f'fn {name}(' + idx = content.find(func_def) + if idx == -1: + print(f"Warning: Test {name} not found") + continue + + # Backtrack to find the preceding #[test] + test_attr_idx = content.rfind('#[test]', 0, idx) + if test_attr_idx == -1: + test_attr_idx = content.rfind('#[tokio::test]', 0, idx) + + if test_attr_idx == -1: + print(f"Warning: #[test] not found for {name}") + continue + + start_idx = test_attr_idx + + # Find the opening brace of the function + brace_idx = content.find('{', idx) + if brace_idx == -1: + print(f"Warning: opening brace not found for {name}") + continue + + brace_count = 1 + curr_idx = brace_idx + 1 + + while brace_count > 0 and curr_idx < len(content): + if content[curr_idx] == '{': + brace_count += 1 + elif content[curr_idx] == '}': + brace_count -= 1 + curr_idx += 1 + + end_idx = curr_idx + extracted_tests.append(content[start_idx:end_idx]) + content = content[:start_idx] + content[end_idx:] + print(f"Extracted {name}") + + if extracted_tests: + with open(output_file, 'a') as f: + f.write('\n\n#[cfg(test)]\nmod tests {\n use super::*;\n') + for t in extracted_tests: + f.write('\n ' + t.replace('\n', '\n ') + '\n') + f.write('}\n') + + with open(file_path, 'w') as f: + f.write(content) + +client_test_names = [ + 'build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features', + 'build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers', + 'build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally', + 'build_runtime_runs_plugin_lifecycle_init_and_shutdown' +] + +stream_test_names = [ + 'response_to_events_preserves_empty_object_json_input_outside_streaming', + 'response_to_events_preserves_non_empty_json_input_outside_streaming', + 'response_to_events_renders_collapsed_thinking_summary' +] + +executor_test_names = [ + 'tool_rendering_helpers_compact_output', + 'tool_rendering_truncates_large_read_output_for_display_only', + 'tool_rendering_truncates_large_bash_output_for_display_only', + 'tool_rendering_truncates_generic_long_output_for_display_only', + 'tool_rendering_truncates_raw_generic_output_for_display_only', + 'short_tool_id_truncates_long_identifiers_with_ellipsis' +] + +extract_tests('crates/rusty-claude-cli/src/main_tests.rs', client_test_names, 'crates/rusty-claude-cli/src/execution/client.rs') +extract_tests('crates/rusty-claude-cli/src/main_tests.rs', stream_test_names, 'crates/rusty-claude-cli/src/execution/stream.rs') +extract_tests('crates/rusty-claude-cli/src/main_tests.rs', executor_test_names, 'crates/rusty-claude-cli/src/execution/executor.rs') diff --git a/rust/fix_client_test.py b/rust/fix_client_test.py new file mode 100644 index 00000000..78817f3e --- /dev/null +++ b/rust/fix_client_test.py @@ -0,0 +1,47 @@ +import re + +def main(): + with open('crates/rusty-claude-cli/src/main_tests.rs', 'r') as f: + main_content = f.read() + + helpers = [] + + # Extract env_lock + env_lock_match = re.search(r'fn env_lock\(\) -> MutexGuard<\'static, \(\)> \{.*?\n \}', main_content, re.DOTALL) + if env_lock_match: + helpers.append(env_lock_match.group(0)) + + # Extract temp_dir + temp_dir_match = re.search(r'fn temp_dir\(\) -> std::path::PathBuf \{.*?\n \}', main_content, re.DOTALL) + if not temp_dir_match: + temp_dir_match = re.search(r'fn temp_dir\(\) -> PathBuf \{.*?\n \}', main_content, re.DOTALL) + if temp_dir_match: + helpers.append(temp_dir_match.group(0)) + + # Extract write_plugin_fixture + wpf_match = re.search(r'fn write_plugin_fixture\(.*?\n \}', main_content, re.DOTALL) + if wpf_match: + helpers.append(wpf_match.group(0)) + + # Extract write_mcp_server_fixture + wmsf_match = re.search(r'fn write_mcp_server_fixture\(.*?\n \}', main_content, re.DOTALL) + if wmsf_match: + helpers.append(wmsf_match.group(0)) + + with open('crates/rusty-claude-cli/src/execution/client.rs', 'r') as f: + client_content = f.read() + + if helpers: + helper_code = '\n ' + '\n '.join(helpers).replace('\n', '\n ') + '\n' + # Insert helpers right after mod tests { + client_content = client_content.replace('mod tests {\n use super::*;\n', 'mod tests {\n use super::*;\n' + helper_code) + + # Add necessary imports + imports = ' use std::path::{Path, PathBuf};\n use std::sync::{MutexGuard, Mutex, OnceLock};\n use std::env;\n' + client_content = client_content.replace('use super::*;\n', 'use super::*;\n' + imports) + + with open('crates/rusty-claude-cli/src/execution/client.rs', 'w') as f: + f.write(client_content) + +if __name__ == '__main__': + main() diff --git a/rust/remove_7_tests.py b/rust/remove_7_tests.py new file mode 100644 index 00000000..63b0fd8d --- /dev/null +++ b/rust/remove_7_tests.py @@ -0,0 +1,35 @@ +import re + +def remove_tests(file_path, tests_to_remove): + with open(file_path, 'r') as f: + content = f.read() + + for name in tests_to_remove: + # Find the function definition + pattern = re.compile(r'(#\[(?:tokio::)?test\].*?(?:async )?fn ' + name + r'\s*\([^)]*\)\s*\{)', re.DOTALL) + match = pattern.search(content) + if not match: + continue + + start_idx = match.start() + brace_count = 1 + curr_idx = match.end() + + while brace_count > 0 and curr_idx < len(content): + if content[curr_idx] == '{': + brace_count += 1 + elif content[curr_idx] == '}': + brace_count -= 1 + curr_idx += 1 + + end_idx = curr_idx + content = content[:start_idx] + content[end_idx:] + print(f"Removed {name} from {file_path}") + + with open(file_path, 'w') as f: + f.write(content) + +with open('dup_tests.txt', 'r') as f: + tests = [line.strip() for line in f if line.strip()] + +remove_tests('crates/rusty-claude-cli/src/main_tests.rs', tests) diff --git a/rust/remove_7_tests_safe.py b/rust/remove_7_tests_safe.py new file mode 100644 index 00000000..80e71917 --- /dev/null +++ b/rust/remove_7_tests_safe.py @@ -0,0 +1,51 @@ +def remove_tests(file_path, tests_to_remove): + with open(file_path, 'r') as f: + content = f.read() + + for name in tests_to_remove: + # Find the function definition + func_def = f'fn {name}(' + idx = content.find(func_def) + if idx == -1: + print(f"Warning: Test {name} not found") + continue + + # Backtrack to find the preceding #[test] + test_attr_idx = content.rfind('#[test]', 0, idx) + if test_attr_idx == -1: + # maybe tokio::test? + test_attr_idx = content.rfind('#[tokio::test]', 0, idx) + + if test_attr_idx == -1: + print(f"Warning: #[test] not found for {name}") + continue + + start_idx = test_attr_idx + + # Find the opening brace of the function + brace_idx = content.find('{', idx) + if brace_idx == -1: + print(f"Warning: opening brace not found for {name}") + continue + + brace_count = 1 + curr_idx = brace_idx + 1 + + while brace_count > 0 and curr_idx < len(content): + if content[curr_idx] == '{': + brace_count += 1 + elif content[curr_idx] == '}': + brace_count -= 1 + curr_idx += 1 + + end_idx = curr_idx + content = content[:start_idx] + content[end_idx:] + print(f"Removed {name} from {file_path}") + + with open(file_path, 'w') as f: + f.write(content) + +with open('dup_tests.txt', 'r') as f: + tests = [line.strip() for line in f if line.strip()] + +remove_tests('crates/rusty-claude-cli/src/main_tests.rs', tests) diff --git a/rust/test_out.txt b/rust/test_out.txt new file mode 100644 index 00000000..aed8035d --- /dev/null +++ b/rust/test_out.txt @@ -0,0 +1,256 @@ + +running 192 tests +test config::models::tests::resolves_known_model_aliases ... ok +test config::models::tests::resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit ... ok +test dump_manifests_tests::dump_manifests_shows_helpful_error_when_manifests_missing ... ok +test config::models::tests::resolve_repl_model_falls_back_to_anthropic_model_env_when_default ... ok +test init::tests::artifacts_with_status_partitions_fresh_and_idempotent_runs ... ok +test init::tests::initialize_repo_creates_expected_files_and_gitignore_entries ... ok +test init::tests::initialize_repo_is_idempotent_and_preserves_existing_files ... ok +test input::tests::extracts_terminal_slash_command_prefixes_with_arguments ... ok +test config::models::tests::resolve_repl_model_returns_default_when_env_unset_and_no_config ... ok +test dump_manifests_tests::dump_manifests_uses_explicit_manifest_dir ... ok +test init::tests::render_init_template_mentions_detected_python_and_nextjs_markers ... ok +test input::tests::completes_matching_slash_command_arguments ... ok +test input::tests::ignores_non_slash_command_completion_requests ... ok +test input::tests::completes_matching_slash_commands ... ok +test input::tests::push_history_ignores_blank_entries ... ok +test input::tests::set_completions_replaces_and_normalizes_candidates ... ok +test input::tests::tracks_current_buffer_through_highlighter ... ok +test main_tests::tests::accepts_valid_reasoning_effort_values ... ok +test config::models::tests::user_defined_aliases_resolve_before_provider_dispatch ... ok +test main_tests::tests::classify_error_kind_returns_correct_discriminants ... ok +test main_tests::tests::bare_skill_dispatch_ignores_unknown_or_non_skill_input ... ok +test main_tests::tests::clear_command_requires_explicit_confirmation_flag ... ok +test main_tests::tests::collect_session_prompt_history_extracts_user_text_blocks ... ok +test main_tests::tests::commit_reports_surface_workspace_context ... ok +test main_tests::tests::commit_skipped_report_points_to_next_steps ... ok +test main_tests::tests::compact_report_uses_structured_output ... ok +test main_tests::tests::completion_candidates_include_workflow_shortcuts_and_dynamic_sessions ... ok +test main_tests::tests::config_report_supports_section_views ... ok +test main_tests::tests::config_report_uses_sectioned_layout ... ok +test main_tests::tests::context_window_preflight_errors_render_recovery_steps ... ok +test main_tests::tests::converts_tool_roundtrip_messages ... ok +test main_tests::tests::cost_report_uses_sectioned_layout ... ok +test main_tests::tests::bare_skill_dispatch_resolves_known_project_skill_to_prompt ... ok +test main_tests::tests::cwd_guard_recovers_after_poisoning ... ok +test main_tests::tests::build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features ... ok +test main_tests::tests::describe_tool_progress_summarizes_known_tools ... ok +test main_tests::tests::direct_slash_commands_surface_shared_validation_errors ... ok +test main_tests::tests::dump_manifests_subcommand_accepts_explicit_manifest_dir ... ok +test main_tests::tests::build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally ... ok +test main_tests::tests::filtered_tool_specs_include_plugin_tools ... ok +test main_tests::tests::filtered_tool_specs_respect_allowlist ... ok +test main_tests::tests::format_connected_line_renders_anthropic_provider_for_claude_model ... ok +test main_tests::tests::format_connected_line_renders_xai_provider_for_grok_model ... ok +test main_tests::tests::format_history_timestamp_renders_iso8601_utc ... ok +test main_tests::tests::format_history_timestamp_renders_unix_epoch_origin ... ok +test main_tests::tests::formats_namespaced_omc_slash_command_with_contract_guidance ... ok +test main_tests::tests::formats_unknown_slash_command_with_suggestions ... ok +test main_tests::tests::help_mentions_jsonl_resume_examples ... ok +test main_tests::tests::init_help_mentions_direct_subcommand ... ok +test main_tests::tests::init_template_mentions_detected_rust_workspace ... ok +test main_tests::tests::build_runtime_runs_plugin_lifecycle_init_and_shutdown ... ok +test main_tests::tests::dangerously_skip_permissions_flag_applies_to_prompt_subcommand ... ok +test main_tests::tests::dangerously_skip_permissions_flag_forces_danger_full_access_in_repl ... ok +test main_tests::tests::local_command_help_flags_stay_on_the_local_parser_path ... ok +test main_tests::tests::memory_report_uses_sectioned_layout ... ok +test main_tests::tests::merge_prompt_with_stdin_appends_piped_content_as_context ... ok +test main_tests::tests::merge_prompt_with_stdin_ignores_whitespace_only_pipe ... ok +test main_tests::tests::merge_prompt_with_stdin_returns_pipe_when_prompt_is_empty ... ok +test main_tests::tests::merge_prompt_with_stdin_returns_prompt_unchanged_when_no_pipe ... ok +test main_tests::tests::merge_prompt_with_stdin_trims_surrounding_whitespace_on_pipe ... ok +test main_tests::tests::model_report_uses_sectioned_layout ... ok +test main_tests::tests::model_switch_report_preserves_context_summary ... ok +test main_tests::tests::multi_word_prompt_still_bypasses_subcommand_typo_guard ... ok +test main_tests::tests::latest_session_alias_resolves_most_recent_managed_session ... ok +test main_tests::tests::no_arg_commands_reject_unexpected_arguments ... ok +test main_tests::tests::normalizes_supported_permission_modes ... ok +test main_tests::tests::opaque_provider_wrapper_surfaces_failure_class_session_and_trace ... ok +test main_tests::tests::parse_export_args_helper_defaults_to_latest_reference_and_no_output ... ok +test main_tests::tests::parse_history_count_accepts_positive_integers ... ok +test main_tests::tests::parse_history_count_defaults_to_twenty_when_missing ... ok +test main_tests::tests::parse_history_count_rejects_non_numeric ... ok +test main_tests::tests::parse_history_count_rejects_zero ... ok +test main_tests::tests::parses_acp_command_surfaces ... ok +test main_tests::tests::load_session_reference_rejects_workspace_mismatch ... ok +test main_tests::tests::managed_sessions_default_to_jsonl_and_resolve_legacy_json ... ok +test main_tests::tests::default_permission_mode_uses_project_config_when_env_is_unset ... ok +test main_tests::tests::defaults_to_repl_when_no_args ... ok +test main_tests::tests::env_permission_mode_overrides_project_config_default ... ok +test main_tests::tests::multi_word_prompt_still_uses_shorthand_prompt_mode ... ok +test main_tests::tests::parses_export_subcommand_with_inline_flag_values ... ok +test main_tests::tests::parses_export_subcommand_with_json_output_format ... ok +test main_tests::tests::parses_export_subcommand_with_positional_output_path ... ok +test main_tests::tests::parses_direct_agents_mcp_and_skills_slash_commands ... ok +test main_tests::tests::parses_export_subcommand_with_session_and_output_flags ... ok +test main_tests::tests::parses_git_workspace_summary_counts ... ok +test main_tests::tests::parses_json_output_for_mcp_and_skills_commands ... ok +test main_tests::tests::parses_permission_mode_flag ... ok +test main_tests::tests::parses_allowed_tools_flags_with_aliases_and_lists ... ok +test main_tests::tests::parses_bare_export_subcommand_targeting_latest_session ... ok +test main_tests::tests::parses_resume_and_config_slash_commands ... ok +test main_tests::tests::parses_resume_flag_with_absolute_export_path ... ok +test main_tests::tests::parses_resume_flag_with_multiple_slash_commands ... ok +test main_tests::tests::parses_bare_prompt_and_json_output_flag ... ok +test main_tests::tests::parses_resume_flag_with_slash_command ... ok +test main_tests::tests::parses_resume_flag_without_path_as_latest_session ... ok +test main_tests::tests::parses_compact_flag_for_prompt_mode ... ok +test main_tests::tests::parses_detached_head_from_status_snapshot ... ok +test main_tests::tests::parses_single_word_command_aliases_without_falling_back_to_prompt_mode ... ok +test main_tests::tests::parses_resume_flag_with_slash_command_arguments ... ok +test main_tests::tests::parses_system_prompt_options ... ok +test main_tests::tests::parses_version_flags_without_initializing_prompt_mode ... ok +test main_tests::tests::permissions_report_uses_sectioned_layout ... ok +test main_tests::tests::parses_prompt_subcommand ... ok +test main_tests::tests::permissions_switch_report_is_structured ... ok +test main_tests::tests::provider_context_window_errors_are_reframed_with_same_guidance ... ok +test main_tests::tests::prompt_subcommand_allows_literal_typo_word ... ok +test main_tests::tests::push_output_block_skips_empty_object_prefix_for_tool_streams ... ok +test main_tests::tests::rejects_export_with_extra_positional_after_path ... ok +test main_tests::tests::rejects_invalid_reasoning_effort_value ... ok +test main_tests::tests::permission_policy_uses_plugin_tool_permissions ... ok +test main_tests::tests::rejects_unknown_export_options_with_helpful_message ... ok +test main_tests::tests::rejects_unknown_options_with_helpful_guidance ... ok +test main_tests::tests::rejects_unknown_allowed_tools ... ok +test main_tests::tests::removed_login_and_logout_subcommands_error_helpfully ... ok +test main_tests::tests::rejects_empty_allowed_tools_flag ... ok +test main_tests::tests::parses_git_status_metadata ... ok +test main_tests::tests::render_prompt_history_report_handles_empty_history ... ok +test main_tests::tests::prompt_subcommand_defaults_compact_to_false ... ok +test main_tests::tests::render_prompt_history_report_truncates_to_limit_from_the_tail ... ok +test main_tests::tests::render_prompt_history_report_lists_entries_with_timestamps ... ok +test main_tests::tests::render_session_markdown_marks_tool_errors_and_skips_empty_summaries ... ok +test main_tests::tests::punctuation_bearing_single_token_still_dispatches_to_prompt ... ok +test main_tests::tests::render_session_markdown_includes_header_and_summarized_tool_calls ... ok +test main_tests::tests::repl_help_mentions_history_completion_and_multiline ... ok +test main_tests::tests::repl_help_includes_shared_commands_and_exit ... ok +test main_tests::tests::push_output_block_renders_markdown_text ... ok +test main_tests::tests::resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit ... ok +test main_tests::tests::resolves_known_model_aliases ... ok +test main_tests::tests::render_diff_report_includes_staged_and_unstaged_sections ... ok +test main_tests::tests::response_to_events_preserves_empty_object_json_input_outside_streaming ... ok +test main_tests::tests::response_to_events_preserves_non_empty_json_input_outside_streaming ... ok +test main_tests::tests::build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers ... ok +test main_tests::tests::response_to_events_renders_collapsed_thinking_summary ... ok +test main_tests::tests::resume_report_uses_sectioned_layout ... ok +test main_tests::tests::resume_supported_command_list_matches_expected_surface ... ok +test main_tests::tests::resume_usage_mentions_latest_shortcut ... ok +test main_tests::tests::retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper ... ok +test main_tests::tests::retry_wrapped_context_window_errors_keep_recovery_guidance ... ok +test main_tests::tests::runtime_slash_reports_describe_command_behavior ... ok +test main_tests::tests::render_diff_report_omits_ignored_files ... ok +test main_tests::tests::session_lifecycle_prefers_running_process_over_idle_shell ... ok +test main_tests::tests::render_diff_report_shows_clean_tree_for_committed_repo ... ok +test main_tests::tests::shared_help_uses_resume_annotation_copy ... ok +test main_tests::tests::short_tool_id_truncates_long_identifiers_with_ellipsis ... ok +test main_tests::tests::single_word_slash_command_names_return_guidance_instead_of_hitting_prompt_mode ... ok +test main_tests::tests::split_error_hint_separates_reason_from_runbook ... ok +test main_tests::tests::resolve_cli_auth_source_ignores_saved_oauth_credentials ... ok +test main_tests::tests::session_list_surfaces_saved_dirty_abandoned_lifecycle ... ok +test main_tests::tests::resolve_repl_model_falls_back_to_anthropic_model_env_when_default ... ok +test main_tests::tests::resolve_repl_model_returns_default_when_env_unset_and_no_config ... ok +test main_tests::tests::status_json_surfaces_session_lifecycle_for_clawhip ... ok +test main_tests::tests::status_line_reports_model_and_token_totals ... ok +test main_tests::tests::stub_commands_absent_from_repl_completions ... ok +test main_tests::tests::stub_commands_absent_from_resume_safe_help ... ok +test main_tests::tests::subcommand_help_flag_has_one_contract_across_all_subcommands_141 ... ok +test main_tests::tests::summarize_tool_payload_for_markdown_compacts_json_and_truncates_overflow ... ok +test main_tests::tests::tool_rendering_helpers_compact_output ... ok +test main_tests::tests::tool_rendering_truncates_generic_long_output_for_display_only ... ok +test main_tests::tests::tool_rendering_truncates_large_bash_output_for_display_only ... ok +test main_tests::tests::tool_rendering_truncates_large_read_output_for_display_only ... ok +test main_tests::tests::tool_rendering_truncates_raw_generic_output_for_display_only ... ok +test main_tests::tests::resolves_model_aliases_in_args ... ok +test main_tests::tests::typoed_doctor_subcommand_returns_did_you_mean_error ... ok +test main_tests::tests::typoed_export_subcommand_returns_did_you_mean_error ... ok +test main_tests::tests::typoed_mcp_subcommand_returns_did_you_mean_error ... ok +test main_tests::tests::typoed_skills_subcommand_returns_did_you_mean_error ... ok +test main_tests::tests::typoed_status_subcommand_returns_did_you_mean_error ... ok +test main_tests::tests::ultraplan_progress_lines_include_phase_step_and_elapsed_status ... ok +test main_tests::tests::unknown_slash_command_guidance_suggests_nearby_commands ... ok +test main_tests::tests::unknown_omc_slash_command_guidance_explains_runtime_gap ... ok +test permissions::prompter::tests::permission_policy_uses_plugin_tool_permissions ... ok +test main_tests::tests::status_context_reads_real_workspace_metadata ... ok +test render::tests::highlights_fenced_code_blocks ... ok +test render::tests::renders_links_as_colored_markdown_labels ... ok +test render::tests::renders_markdown_with_styling_and_lists ... ok +test render::tests::renders_ordered_and_nested_lists ... ok +test render::tests::renders_nested_fenced_code_block_preserves_inner_markers ... ok +test render::tests::renders_tables_with_alignment ... ok +test main_tests::tests::resume_diff_command_renders_report_for_saved_session ... ok +test render::tests::spinner_advances_frames ... ok +test render::tests::streaming_state_distinguishes_backtick_and_tilde_fences ... ok +test sandbox_report_tests::hook_abort_monitor_propagates_interrupt ... ok +test sandbox_report_tests::hook_abort_monitor_stops_without_aborting ... ok +test sandbox_report_tests::sandbox_report_renders_expected_fields ... ok +test ui::progress::tests::describe_tool_progress_summarizes_known_tools ... ok +test render::tests::streaming_state_holds_outer_fence_with_nested_inner_fence ... ok +test render::tests::streaming_state_waits_for_complete_blocks ... ok +test main_tests::tests::session_lifecycle_marks_dirty_idle_shell_as_abandoned ... ok +test main_tests::tests::startup_banner_mentions_workflow_completions ... ok +test main_tests::tests::state_error_surfaces_actionable_worker_commands_139 ... ok +test main_tests::tests::status_degrades_gracefully_on_malformed_mcp_config_143 ... ok +test main_tests::tests::user_defined_aliases_resolve_before_provider_dispatch ... ok + +test result: ok. 192 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.66s + + +running 7 tests +test local_subcommand_help_does_not_fall_through_to_runtime_or_provider_calls ... ok +test config_command_loads_defaults_from_standard_config_locations ... ok +test omc_namespaced_slash_commands_surface_a_targeted_compatibility_hint ... ok +test slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones ... ok +test status_command_applies_model_and_permission_mode_flags ... ok +test resume_flag_loads_a_saved_session_and_dispatches_status ... ok +test doctor_command_runs_as_a_local_shell_entrypoint ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.47s + + +running 3 tests +test compact_flag_streaming_text_only_emits_final_message_text ... ok +test compact_flag_with_json_output_emits_structured_json ... ok +test compact_flag_prints_only_final_assistant_text_without_tool_call_details ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + + +running 1 test +test clean_env_cli_reaches_mock_anthropic_service_across_scripted_parity_scenarios ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.43s + + +running 11 tests +test help_emits_json_when_requested ... ok +test agents_command_emits_structured_agent_entries_when_requested ... ok +test acp_guidance_emits_json_when_requested ... ok +test version_emits_json_when_requested ... ok +test resumed_inventory_commands_emit_structured_json_when_requested ... ok +test dump_manifests_and_init_emit_json_when_requested ... ok +test inventory_commands_emit_structured_json_when_requested ... ok +test resumed_version_and_init_emit_structured_json_when_requested ... ok +test bootstrap_and_system_prompt_emit_json_when_requested ... ok +test status_and_sandbox_emit_json_when_requested ... ok +test doctor_and_resume_status_emit_json_when_requested ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + +running 12 tests +test resumed_config_command_loads_settings_files_end_to_end ... ok +test resumed_help_command_emits_structured_json ... ok +test resumed_sandbox_command_emits_structured_json_when_requested ... ok +test resumed_no_command_emits_restored_json ... ok +test resumed_export_command_emits_structured_json ... ok +test resumed_binary_accepts_slash_commands_with_arguments ... ok +test resumed_stub_command_emits_not_implemented_json ... ok +test resumed_version_command_emits_structured_json ... ok +test status_command_applies_cli_flags_end_to_end ... ok +test resume_latest_restores_the_most_recent_managed_session ... ok +test resumed_status_command_emits_structured_json_when_requested ... ok +test resumed_status_surfaces_persisted_model ... ok + +test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7d72c15c91430527f4b1ef98561cb56166d5e2f8 GIT binary patch literal 12292 zcmeI2O^X~w7{{NU%}!=^5{-yx2!TEb1{HBpa}a`)FuvRua*gw{*(f?D`seY>Zsh-WnL#!TpYPRcN{ru)%HTBj@ zMA?nIlXapiMAX5_>4kH6yGNAwkcxxz`K%o)xRT-g6em7kAljlm8qxvnP*GFYFSKxp z%b&ryMl+O><#%a+RtyHCX)!{6r}NuCpMUY=@pZaIb2_3iuB9V6kQ_)3BnOfM$$=+# zfX7}OonXATSJQiPAUW{Ac7Xeb94Dv!GF{lzbRBr(DLm)%6y6;8jCFvymX~S2Ocypa zMN!5SMQV#?0-=>4HUG}<<(faD ztO%QxD&KCoQ*j8!TiCPMSNUox$#?$`!KWxIzGkJucMk5|1=ZZVZ{X7rO+gw`UV@8J ztz=#K)3G+_E8^4W74EYjo`QJ`wT>N}z|-eI8z>~>+KFSVsVcI#O5u6Nw87h^9d3eX zY-)RA>VD|aW#cyJ8ugO(^_NcUe$>)375Qn)c;90s2Nt!>_u${u0d0ER)Ot3|mz;;W z8Syn9H0Vj6{G6&weq7YKh%=i!XVl?;#GatxlL zazj_B@wrEzqm_g%*!V72#y`0#;tNF;Usovc&!dl?8xOe65AkcBqtzDl2^|Hk_A>lv zP)ok|-gSJ9B`V(2l~DLs&A78CzE<@%cNaBRP;9 zNDd?io*oC@!B^>F{rmW-`mmhca^D@_$6kJuj); z`>3fT-(Oz|K1EsaH7k8SGnZ(Ck9TDzSfvNEiS~zZ&dZlfuYT>A0!>8|)A$@Y!eEv? z!YporS2*Xia~hW`8Q%WJF^KZAP3a0hzVTrNZGB2RLEYFnoXL@K$KScQWjPtMo~y)L zL%hPwv(N^`bOsjBq4JTI9;2WxxcJGeAji-`~-R~Wv zazE)^vr^&f89v@!oS+rkeK-6S)Z;}s8Bt2kUmiNnsJe1#B3hyfRceww$6RxdV_IK%WCD-8Caf$M>o^^#1zlX@1@M>h7vUR*w zW3hgzO3C%vPmU{cU&**!t?;(-o96v6CJkGm-O~{HlI`NJj?KG=B#LPi4s{(}C>FU}M1BX63W>+PCCq?K#emakAziPCoG2 z6u}!y_z&|q9;ta8kMvEx&IdmC%XDE=T*2|5|04iq3bGGgKBCLsc)42XmwV-0FOC0? ZpO}dmg6fw#ss|%k8vny*?oTxS{|iUh6fFP% literal 0 HcmV?d00001 diff --git a/user_defined_aliases_resolve_before_provider_dispatch.rs_chunk b/user_defined_aliases_resolve_before_provider_dispatch.rs_chunk new file mode 100644 index 00000000..8bcd5f8d --- /dev/null +++ b/user_defined_aliases_resolve_before_provider_dispatch.rs_chunk @@ -0,0 +1,38 @@ +#[test] + fn user_defined_aliases_resolve_before_provider_dispatch() { + // given + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + // when + let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast")); + let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart")); + let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap")); + let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model")); + let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku")); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + // then + assert_eq!(direct, "claude-haiku-4-5-20251213"); + assert_eq!(chained, "claude-opus-4-6"); + assert_eq!(cross_provider, "grok-3-mini"); + assert_eq!(unknown, "unknown-model"); + assert_eq!(builtin, "claude-haiku-4-5-20251213"); + } \ No newline at end of file