This commit is contained in:
parent
c166999d65
commit
494aafa00e
|
|
@ -0,0 +1,39 @@
|
|||
import os
|
||||
|
||||
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'
|
||||
]
|
||||
|
||||
imports = """
|
||||
use crate::*;
|
||||
use api::*;
|
||||
use runtime::*;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::path::Path;
|
||||
"""
|
||||
|
||||
for file in files:
|
||||
with open(file, 'r') as f:
|
||||
content = f.read()
|
||||
with open(file, 'w') as f:
|
||||
f.write(imports + "\n" + content)
|
||||
|
||||
main_file = "rust/crates/rusty-claude-cli/src/main.rs"
|
||||
with open(main_file, 'r') as f:
|
||||
main_content = f.read()
|
||||
|
||||
main_imports = """
|
||||
use crate::execution::client::*;
|
||||
use crate::execution::stream::*;
|
||||
use crate::execution::executor::*;
|
||||
"""
|
||||
# inject right after mod declarations
|
||||
main_content = main_content.replace("pub(crate) mod ui;\n", "pub(crate) mod ui;\n" + main_imports)
|
||||
|
||||
with open(main_file, 'w') as f:
|
||||
f.write(main_content)
|
||||
|
||||
print("Imports added.")
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
pub(crate) struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: ApiProviderClient,
|
||||
session_id: String,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
reasoning_effort: Option<String>,
|
||||
}
|
||||
impl AnthropicRuntimeClient {
|
||||
pub(crate) fn new(
|
||||
session_id: &str,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// 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<String>) {
|
||||
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<Vec<AssistantEvent>, 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
impl AnthropicRuntimeClient {
|
||||
fn new(
|
||||
session_id: &str,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// 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<String>) {
|
||||
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<Vec<AssistantEvent>, 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
reasoning_effort: Option<String>,
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
impl ApiClient for AnthropicRuntimeClient {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, 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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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<String>,\n enable_tools: bool,\n emit_output: bool,\n allowed_tools: Option<AllowedToolSet>,\n permission_mode: PermissionMode,\n reasoning_effort: Option<String>,\n\) -> Result<Runtime, Box<dyn std::error::Error>> \{.*?\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<String>,\n enable_tools: bool,\n emit_output: bool,\n allowed_tools: Option<AllowedToolSet>,\n permission_mode: PermissionMode,\n reasoning_effort: Option<String>,\n plugin_state: RuntimePluginState,\n\) -> Result<Runtime, Box<dyn std::error::Error>> \{.*?\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<RuntimePluginState, Box<dyn std::error::Error>> \{.*?\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<RuntimePluginState, Box<dyn std::error::Error>> \{.*?\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<Arc<Mutex<RuntimeMcpState>>> \{.*?\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<RuntimeToolDefinition> \{.*?\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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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<Vec<AssistantEvent>, 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<Vec<AssistantEvent>, 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<AssistantEvent>\) \{.*?\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<PromptCacheEvent> \{.*?\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")
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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<Mutex<()>> = 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<Mutex<()>> = 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<T>(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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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.")
|
||||
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-30
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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")
|
||||
Binary file not shown.
|
|
@ -0,0 +1,24 @@
|
|||
import re
|
||||
|
||||
with open("rust/crates/rusty-claude-cli/src/ui/progress.rs", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
content = re.sub(r"^struct (InternalPromptProgressState|InternalPromptProgressShared|InternalPromptProgressReporter|InternalPromptProgressRun|CliHookProgressReporter)", r"pub(crate) struct \1", content, flags=re.M)
|
||||
content = re.sub(r"^enum (InternalPromptProgressEvent)", r"pub(crate) enum \1", content, flags=re.M)
|
||||
content = re.sub(r"fn (ultraplan|emit|mark_model_phase|mark_tool_phase|mark_text_phase|emit_heartbeat|start_ultraplan|reporter|finish_success|finish_failure|stop_heartbeat)\(", r"pub(crate) fn \1(", content)
|
||||
content = re.sub(r"^fn (describe_tool_progress|extract_tool_path|first_visible_line|summarize_tool_payload|truncate_for_summary)\(", r"pub(crate) fn \1(", content, flags=re.M)
|
||||
|
||||
prefix = """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;
|
||||
|
||||
"""
|
||||
|
||||
with open("rust/crates/rusty-claude-cli/src/ui/progress.rs", "w") as f:
|
||||
f.write(prefix + content)
|
||||
|
||||
print("done")
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub(crate) mod suggestions;
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
use commands::slash_command_specs;
|
||||
pub(crate) fn suggest_slash_commands(input: &str) -> Vec<String> {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates.dedup();
|
||||
let candidate_refs = candidates.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
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<Vec<String>> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
(!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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
|
||||
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()]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub(crate) mod models;
|
||||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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<Mutex<()>> = 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<Mutex<()>> = 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<T>(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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
data: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl DiagnosticCheck {
|
||||
fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
level,
|
||||
summary: summary.into(),
|
||||
details: Vec::new(),
|
||||
data: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_details(mut self, details: Vec<String>) -> Self {
|
||||
self.details = details;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_data(mut self, data: Map<String, Value>) -> 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::<Vec<_>>(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
value.extend(self.data.clone());
|
||||
Value::Object(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DoctorReport {
|
||||
checks: Vec<DiagnosticCheck>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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<DoctorReport, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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 <path>
|
||||
// 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 <text> # 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 <text> # 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<dyn std::error::Error>> {
|
||||
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(|| "<none>".to_string(), |value| value.to_string()),
|
||||
if token_set.refresh_token.is_some() {
|
||||
"present"
|
||||
} else {
|
||||
"absent"
|
||||
},
|
||||
if token_set.scopes.is_empty() {
|
||||
"<none>".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::<String>::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<String> = 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::<Vec<_>>();
|
||||
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 <none> (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 <none>".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(|| "<none>".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("<unknown>")),
|
||||
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
|
||||
];
|
||||
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)),
|
||||
]))
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<AllowedToolSet>,
|
||||
pub(crate) tool_registry: GlobalToolRegistry,
|
||||
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
}
|
||||
|
||||
impl CliToolExecutor {
|
||||
pub(crate) fn new(
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
emit_output: bool,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> 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<String, ToolError> {
|
||||
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<String, ToolError> {
|
||||
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<String, ToolError> {
|
||||
if self
|
||||
.allowed_tools
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(tool_name))
|
||||
{
|
||||
return Err(ToolError::new(format!(
|
||||
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
|
||||
)));
|
||||
}
|
||||
let value = serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub(crate) mod client;
|
||||
pub(crate) mod executor;
|
||||
pub(crate) mod stream;
|
||||
|
|
@ -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<Vec<AssistantEvent>, 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<AssistantEvent>) {
|
||||
// `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<PromptCacheEvent> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.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() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
pub(crate) mod prompter;
|
||||
|
|
@ -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<PermissionMode, String> {
|
||||
normalize_permission_mode(value)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
|
||||
)
|
||||
})
|
||||
.map(permission_mode_from_label)
|
||||
}
|
||||
|
||||
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<PermissionMode> {
|
||||
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<PermissionPolicy, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::diagnostics::{render_doctor_report, run_mcp_serve};
|
||||
use crate::TokenUsage;
|
||||
use crate::*;
|
||||
use api::Usage;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
pub(crate) mod progress;
|
||||
|
|
@ -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<String>,
|
||||
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<InternalPromptProgressState>,
|
||||
output_lock: Mutex<()>,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalPromptProgressReporter {
|
||||
shared: Arc<InternalPromptProgressShared>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalPromptProgressRun {
|
||||
reporter: InternalPromptProgressReporter,
|
||||
heartbeat_stop: Option<mpsc::Sender<()>>,
|
||||
heartbeat_handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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::<serde_json::Value>(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::<String>();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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')
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
Binary file not shown.
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue