1 Commits

Author SHA1 Message Date
Yeachan-Heo
b200198df7 Make local environment failures diagnosable from the CLI
Add a non-interactive doctor subcommand that checks API key reachability, OAuth credential state, config files, git, MCP servers, network access, and system metadata in one structured report. The implementation reuses existing runtime/auth plumbing and adds focused tests for parsing and report behavior.

Also update stale runtime permission-mode tests so workspace verification reflects the current enum model rather than historical Prompt/Allow variants.

Constraint: Keep diagnostics dependency-free and reuse existing runtime/auth/MCP code
Rejected: Add a REPL-only slash command | diagnostics must work before a session starts
Rejected: Split checks into multiple subcommands | higher surface area with less troubleshooting value
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep doctor checks bounded and non-destructive; if future probes become slower or stateful, gate them explicitly
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; cargo run -p rusty-claude-cli -- doctor
Not-tested: Positive live API-key validation path against a known-good production credential
2026-04-01 00:59:57 +00:00
9 changed files with 748 additions and 357 deletions

View File

@@ -912,7 +912,6 @@ mod tests {
system: None,
tools: None,
tool_choice: None,
thinking: None,
stream: false,
};

View File

@@ -13,5 +13,5 @@ pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};

View File

@@ -12,8 +12,6 @@ pub struct MessageRequest {
pub tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream: bool,
}
@@ -26,23 +24,6 @@ impl MessageRequest {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingConfig {
#[serde(rename = "type")]
pub kind: String,
pub budget_tokens: u32,
}
impl ThinkingConfig {
#[must_use]
pub fn enabled(budget_tokens: u32) -> Self {
Self {
kind: "enabled".to_string(),
budget_tokens,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InputMessage {
pub role: String,
@@ -149,11 +130,6 @@ pub enum OutputContentBlock {
Text {
text: String,
},
Thinking {
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
@@ -213,8 +189,6 @@ pub struct ContentBlockDeltaEvent {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlockDelta {
TextDelta { text: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
InputJsonDelta { partial_json: String },
}

View File

@@ -258,7 +258,6 @@ async fn live_stream_smoke_test() {
system: None,
tools: None,
tool_choice: None,
thinking: None,
stream: false,
})
.await
@@ -439,7 +438,6 @@ fn sample_request(stream: bool) -> MessageRequest {
}),
}]),
tool_choice: Some(ToolChoice::Auto),
thinking: None,
stream,
}
}

View File

@@ -57,12 +57,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "thinking",
summary: "Show or toggle extended thinking",
argument_hint: Some("[on|off]"),
resume_supported: false,
},
SlashCommandSpec {
name: "model",
summary: "Show or switch the active model",
@@ -142,9 +136,6 @@ pub enum SlashCommand {
Help,
Status,
Compact,
Thinking {
enabled: Option<bool>,
},
Model {
model: Option<String>,
},
@@ -189,13 +180,6 @@ impl SlashCommand {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
"thinking" => Self::Thinking {
enabled: match parts.next() {
Some("on") => Some(true),
Some("off") => Some(false),
Some(_) | None => None,
},
},
"model" => Self::Model {
model: parts.next().map(ToOwned::to_owned),
},
@@ -295,7 +279,6 @@ pub fn handle_slash_command(
session: session.clone(),
}),
SlashCommand::Status
| SlashCommand::Thinking { .. }
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
@@ -324,22 +307,6 @@ mod tests {
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/thinking on"),
Some(SlashCommand::Thinking {
enabled: Some(true),
})
);
assert_eq!(
SlashCommand::parse("/thinking off"),
Some(SlashCommand::Thinking {
enabled: Some(false),
})
);
assert_eq!(
SlashCommand::parse("/thinking"),
Some(SlashCommand::Thinking { enabled: None })
);
assert_eq!(
SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model {
@@ -407,7 +374,6 @@ mod tests {
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/compact"));
assert!(help.contains("/thinking [on|off]"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
@@ -420,7 +386,7 @@ mod tests {
assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 16);
assert_eq!(slash_command_specs().len(), 15);
assert_eq!(resume_supported_slash_commands().len(), 11);
}
@@ -468,9 +434,6 @@ mod tests {
let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
);

View File

@@ -130,7 +130,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
.filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
ContentBlock::Text { .. } => None,
})
.collect::<Vec<_>>();
tool_names.sort_unstable();
@@ -200,7 +200,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
fn summarize_block(block: &ContentBlock) -> String {
let raw = match block {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
ContentBlock::ToolResult {
tool_name,
@@ -259,7 +258,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
.iter()
.flat_map(|message| message.blocks.iter())
.map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(),
ContentBlock::Text { text } => text.as_str(),
ContentBlock::ToolUse { input, .. } => input.as_str(),
ContentBlock::ToolResult { output, .. } => output.as_str(),
})
@@ -281,15 +280,10 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
fn first_text_block(message: &ConversationMessage) -> Option<&str> {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. }
if !text.trim().is_empty() =>
{
Some(text.as_str())
}
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::Text { .. }
| ContentBlock::Thinking { .. } => None,
| ContentBlock::Text { .. } => None,
})
}
@@ -334,7 +328,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1,
ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
ContentBlock::ToolResult {
tool_name, output, ..

View File

@@ -17,8 +17,6 @@ pub struct ApiRequest {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssistantEvent {
TextDelta(String),
ThinkingDelta(String),
ThinkingSignature(String),
ToolUse {
id: String,
name: String,
@@ -249,26 +247,15 @@ fn build_assistant_message(
events: Vec<AssistantEvent>,
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
let mut text = String::new();
let mut thinking = String::new();
let mut thinking_signature: Option<String> = None;
let mut blocks = Vec::new();
let mut finished = false;
let mut usage = None;
for event in events {
match event {
AssistantEvent::TextDelta(delta) => {
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
text.push_str(&delta);
}
AssistantEvent::ThinkingDelta(delta) => {
flush_text_block(&mut text, &mut blocks);
thinking.push_str(&delta);
}
AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature),
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
AssistantEvent::ToolUse { id, name, input } => {
flush_text_block(&mut text, &mut blocks);
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
blocks.push(ContentBlock::ToolUse { id, name, input });
}
AssistantEvent::Usage(value) => usage = Some(value),
@@ -279,7 +266,6 @@ fn build_assistant_message(
}
flush_text_block(&mut text, &mut blocks);
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
if !finished {
return Err(RuntimeError::new(
@@ -304,19 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
}
}
fn flush_thinking_block(
thinking: &mut String,
signature: &mut Option<String>,
blocks: &mut Vec<ContentBlock>,
) {
if !thinking.is_empty() || signature.is_some() {
blocks.push(ContentBlock::Thinking {
text: std::mem::take(thinking),
signature: signature.take(),
});
}
}
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
#[derive(Default)]
@@ -352,8 +325,8 @@ impl ToolExecutor for StaticToolExecutor {
#[cfg(test)]
mod tests {
use super::{
build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime,
RuntimeError, StaticToolExecutor,
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
StaticToolExecutor,
};
use crate::compact::CompactionConfig;
use crate::permissions::{
@@ -529,29 +502,6 @@ mod tests {
));
}
#[test]
fn thinking_blocks_are_preserved_separately_from_text() {
let (message, usage) = build_assistant_message(vec![
AssistantEvent::ThinkingDelta("first ".to_string()),
AssistantEvent::ThinkingDelta("second".to_string()),
AssistantEvent::ThinkingSignature("sig-1".to_string()),
AssistantEvent::TextDelta("final".to_string()),
AssistantEvent::MessageStop,
])
.expect("assistant message should build");
assert_eq!(usage, None);
assert!(matches!(
&message.blocks[0],
ContentBlock::Thinking { text, signature }
if text == "first second" && signature.as_deref() == Some("sig-1")
));
assert!(matches!(
&message.blocks[1],
ContentBlock::Text { text } if text == "final"
));
}
#[test]
fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi;

View File

@@ -19,10 +19,6 @@ pub enum ContentBlock {
Text {
text: String,
},
Thinking {
text: String,
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
@@ -261,19 +257,6 @@ impl ContentBlock {
object.insert("type".to_string(), JsonValue::String("text".to_string()));
object.insert("text".to_string(), JsonValue::String(text.clone()));
}
Self::Thinking { text, signature } => {
object.insert(
"type".to_string(),
JsonValue::String("thinking".to_string()),
);
object.insert("text".to_string(), JsonValue::String(text.clone()));
if let Some(signature) = signature {
object.insert(
"signature".to_string(),
JsonValue::String(signature.clone()),
);
}
}
Self::ToolUse { id, name, input } => {
object.insert(
"type".to_string(),
@@ -320,13 +303,6 @@ impl ContentBlock {
"text" => Ok(Self::Text {
text: required_string(object, "text")?,
}),
"thinking" => Ok(Self::Thinking {
text: required_string(object, "text")?,
signature: object
.get("signature")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned),
}),
"tool_use" => Ok(Self::ToolUse {
id: required_string(object, "id")?,
name: required_string(object, "name")?,

File diff suppressed because it is too large Load Diff