Compare commits

..

1 Commits

Author SHA1 Message Date
Yeachan-Heo
30883bddbd Keep doctor and local help paths shell-native
Promote doctor into a real top-level CLI action, reuse the same local report for resumed and REPL doctor invocations, and intercept doctor/status/sandbox help flags before prompt-mode dispatch. The parser change also closes the help fallthrough that previously wandered into runtime startup for local-info commands.

Constraint: Preserve prompt shorthand for normal multi-word text input while fixing exact local subcommand help paths
Rejected: Route \7⠋ 🦀 Thinking...8✘  Request failed
 through prompt/slash guidance | still shells out through the wrong surface and keeps health checks hidden
Rejected: Reuse the status report as doctor output | status does not explain auth/config health or expose a dedicated diagnostic summary
Confidence: high
Scope-risk: narrow
Directive: Keep doctor local-only unless an explicit network probe is intentionally added and separately tested
Tested: cargo build -p rusty-claude-cli; cargo test -p rusty-claude-cli; cargo run -p rusty-claude-cli -- doctor --help; CLAW_CONFIG_HOME=/tmp/tmp.7pm9SVzOPN ANTHROPIC_API_KEY= ANTHROPIC_AUTH_TOKEN= cargo run -p rusty-claude-cli -- doctor
Not-tested: direct /doctor outside the REPL remains interactive-only
2026-04-05 16:44:36 +00:00
8 changed files with 626 additions and 313 deletions

View File

@@ -8,13 +8,6 @@ pub enum ApiError {
provider: &'static str,
env_vars: &'static [&'static str],
},
ContextWindowExceeded {
model: String,
estimated_input_tokens: u32,
requested_output_tokens: u32,
estimated_total_tokens: u32,
context_window_tokens: u32,
},
ExpiredOAuthToken,
Auth(String),
InvalidApiKeyEnv(VarError),
@@ -55,7 +48,6 @@ impl ApiError {
Self::Api { retryable, .. } => *retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
@@ -75,16 +67,6 @@ impl Display for ApiError {
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
),
Self::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => write!(
f,
"context_window_blocked for {model}: estimated input {estimated_input_tokens} + requested output {requested_output_tokens} = {estimated_total_tokens} tokens exceeds the {context_window_tokens}-token context window; compact the session or reduce request size before retrying"
),
Self::ExpiredOAuthToken => {
write!(
f,

View File

@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{preflight_message_request, Provider, ProviderFuture};
use super::{Provider, ProviderFuture};
use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
@@ -294,8 +294,6 @@ impl AnthropicClient {
}
}
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let mut response = response
@@ -339,7 +337,6 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;

View File

@@ -1,8 +1,6 @@
use std::future::Future;
use std::pin::Pin;
use serde::Serialize;
use crate::error::ApiError;
use crate::types::{MessageRequest, MessageResponse};
@@ -42,12 +40,6 @@ pub struct ProviderMetadata {
pub default_base_url: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelTokenLimit {
pub max_output_tokens: u32,
pub context_window_tokens: u32,
}
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
(
"opus",
@@ -190,86 +182,17 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
#[must_use]
pub fn max_tokens_for_model(model: &str) -> u32 {
model_token_limit(model).map_or_else(
|| {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
}
},
|limit| limit.max_output_tokens,
)
}
#[must_use]
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
match canonical.as_str() {
"claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
}),
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 200_000,
}),
"grok-3" | "grok-3-mini" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 131_072,
}),
_ => None,
if canonical.contains("opus") {
32_000
} else {
64_000
}
}
pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> {
let Some(limit) = model_token_limit(&request.model) else {
return Ok(());
};
let estimated_input_tokens = estimate_message_request_input_tokens(request);
let estimated_total_tokens = estimated_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
return Err(ApiError::ContextWindowExceeded {
model: resolve_model_alias(&request.model),
estimated_input_tokens,
requested_output_tokens: request.max_tokens,
estimated_total_tokens,
context_window_tokens: limit.context_window_tokens,
});
}
Ok(())
}
fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
let mut estimate = estimate_serialized_tokens(&request.messages);
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tool_choice));
estimate
}
fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
serde_json::to_vec(value)
.ok()
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::error::ApiError;
use crate::types::{
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
};
use super::{
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
resolve_model_alias, ProviderKind,
};
use super::{detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind};
#[test]
fn resolves_grok_aliases() {
@@ -292,86 +215,4 @@ mod tests {
assert_eq!(max_tokens_for_model("opus"), 32_000);
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
}
#[test]
fn returns_context_window_metadata_for_supported_models() {
assert_eq!(
model_token_limit("claude-sonnet-4-6")
.expect("claude-sonnet-4-6 should be registered")
.context_window_tokens,
200_000
);
assert_eq!(
model_token_limit("grok-mini")
.expect("grok-mini should resolve to a registered model")
.context_window_tokens,
131_072
);
}
#[test]
fn preflight_blocks_requests_that_exceed_the_model_context_window() {
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": { "city": { "type": "string" } },
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: true,
};
let error = preflight_message_request(&request)
.expect_err("oversized request should be rejected before the provider call");
match error {
ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
assert_eq!(model, "claude-sonnet-4-6");
assert!(estimated_input_tokens > 136_000);
assert_eq!(requested_output_tokens, 64_000);
assert!(estimated_total_tokens > context_window_tokens);
assert_eq!(context_window_tokens, 200_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn preflight_skips_unknown_models() {
let request = MessageRequest {
model: "unknown-model".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
preflight_message_request(&request)
.expect("models without context metadata should skip the guarded preflight");
}
}

View File

@@ -12,7 +12,7 @@ use crate::types::{
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
use super::{preflight_message_request, Provider, ProviderFuture};
use super::{Provider, ProviderFuture};
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -128,7 +128,6 @@ impl OpenAiCompatClient {
stream: false,
..request.clone()
};
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let payload = response.json::<ChatCompletionResponse>().await?;
@@ -143,7 +142,6 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;

View File

@@ -103,41 +103,6 @@ async fn send_message_posts_json_and_parses_response() {
);
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -4,10 +4,10 @@ use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use api::{
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent,
ToolChoice, ToolDefinition,
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -63,42 +63,6 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
assert_eq!(body["tools"][0]["type"], json!("function"));
}
#[tokio::test]
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(300_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -24,9 +24,10 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, UNIX_EPOCH};
use api::{
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, AuthSource,
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
OutputContentBlock, PromptCache, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
};
use commands::{
@@ -39,14 +40,14 @@ use init::initialize_repo;
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, pricing_for_model, resolve_sandbox_status,
save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServerManager,
McpTool, MessageRole, ModelPricing, OAuthAuthorizationRequest, OAuthConfig,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent,
ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
UsageTracker,
clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state,
load_oauth_credentials, load_system_prompt, parse_oauth_callback_request_target,
pricing_for_model, resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, McpServerManager, McpTool, MessageRole, ModelPricing,
OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode,
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
};
use serde::Deserialize;
use serde_json::json;
@@ -133,12 +134,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?,
CliAction::Doctor => run_doctor()?,
CliAction::Init => run_init()?,
CliAction::Repl {
model,
allowed_tools,
permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::HelpTopic(topic) => print_help_topic(topic),
CliAction::Help => print_help(),
}
Ok(())
@@ -180,16 +183,25 @@ enum CliAction {
},
Login,
Logout,
Doctor,
Init,
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
HelpTopic(LocalHelpTopic),
// prompt-mode formatting is only supported for non-interactive runs
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalHelpTopic {
Status,
Sandbox,
Doctor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliOutputFormat {
Text,
@@ -341,8 +353,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]);
}
if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override)
{
if let Some(action) = parse_local_help_action(&rest) {
return action;
}
if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override) {
return action;
}
@@ -388,6 +402,24 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
}
fn parse_local_help_action(rest: &[String]) -> Option<Result<CliAction, String>> {
if rest.len() != 2 || !is_help_flag(&rest[1]) {
return None;
}
let topic = match rest[0].as_str() {
"status" => LocalHelpTopic::Status,
"sandbox" => LocalHelpTopic::Sandbox,
"doctor" => LocalHelpTopic::Doctor,
_ => return None,
};
Some(Ok(CliAction::HelpTopic(topic)))
}
fn is_help_flag(value: &str) -> bool {
matches!(value, "--help" | "-h")
}
fn parse_single_word_command_alias(
rest: &[String],
model: &str,
@@ -405,6 +437,7 @@ fn parse_single_word_command_alias(
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
})),
"sandbox" => Some(Ok(CliAction::Sandbox)),
"doctor" => Some(Ok(CliAction::Doctor)),
other => bare_slash_command_guidance(other).map(Err),
}
}
@@ -741,6 +774,396 @@ fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiagnosticLevel {
Ok,
Warn,
Fail,
}
impl DiagnosticLevel {
fn label(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
fn is_failure(self) -> bool {
matches!(self, Self::Fail)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DiagnosticCheck {
name: &'static str,
level: DiagnosticLevel,
summary: String,
details: Vec<String>,
}
impl DiagnosticCheck {
fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into<String>) -> Self {
Self {
name,
level,
summary: summary.into(),
details: Vec::new(),
}
}
fn with_details(mut self, details: Vec<String>) -> Self {
self.details = details;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DoctorReport {
checks: Vec<DiagnosticCheck>,
}
impl DoctorReport {
fn has_failures(&self) -> bool {
self.checks.iter().any(|check| check.level.is_failure())
}
fn render(&self) -> String {
let ok_count = self
.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Ok)
.count();
let warn_count = self
.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Warn)
.count();
let fail_count = self
.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Fail)
.count();
let mut lines = vec![
"Doctor".to_string(),
format!(
"Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}"
),
];
lines.extend(self.checks.iter().map(render_diagnostic_check));
lines.join("\n\n")
}
}
fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
let mut lines = vec![format!(
"{}\n Status {}\n Summary {}",
check.name,
check.level.label(),
check.summary
)];
if !check.details.is_empty() {
lines.push(" Details".to_string());
lines.extend(check.details.iter().map(|detail| format!(" - {detail}")));
}
lines.join("\n")
}
fn render_doctor_report() -> Result<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,
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
};
Ok(DoctorReport {
checks: vec![
check_auth_health(),
check_config_health(&config_loader, config.as_ref()),
check_workspace_health(&context),
check_sandbox_health(&context.sandbox_status),
check_system_health(&cwd, config.as_ref().ok()),
],
})
}
fn run_doctor() -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report()?;
println!("{}", report.render());
if report.has_failures() {
return Err("doctor found failing checks".into());
}
Ok(())
}
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());
match load_oauth_credentials() {
Ok(Some(token_set)) => {
let expired = oauth_token_is_expired(&api::OAuthTokenSet {
access_token: token_set.access_token.clone(),
refresh_token: token_set.refresh_token.clone(),
expires_at: token_set.expires_at,
scopes: token_set.scopes.clone(),
});
let mut details = vec![
format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present {
"present"
} else {
"absent"
}
),
format!(
"Saved 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(",")
}
),
];
if expired {
details.push(
"Suggested action claw login to refresh local OAuth credentials".to_string(),
);
}
DiagnosticCheck::new(
"Auth",
if expired {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
if expired {
"saved OAuth credentials are present but expired"
} else if api_key_present || auth_token_present {
"environment and saved credentials are available"
} else {
"saved OAuth credentials are available"
},
)
.with_details(details)
}
Ok(None) => DiagnosticCheck::new(
"Auth",
if api_key_present || auth_token_present {
DiagnosticLevel::Ok
} else {
DiagnosticLevel::Warn
},
if api_key_present || auth_token_present {
"environment credentials are configured"
} else {
"no API key or saved OAuth credentials were found"
},
)
.with_details(vec![format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present {
"present"
} else {
"absent"
}
)]),
Err(error) => DiagnosticCheck::new(
"Auth",
DiagnosticLevel::Fail,
format!("failed to inspect saved credentials: {error}"),
),
}
}
fn check_config_health(
config_loader: &ConfigLoader,
config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>,
) -> DiagnosticCheck {
let discovered = config_loader.discover();
let discovered_count = discovered.len();
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 mut details = vec![format!(
"Config files loaded {}/{}",
loaded_entries.len(),
discovered_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 discovered_paths.is_empty() {
details.push("Discovered files <none>".to_string());
} else {
details.extend(
discovered_paths
.into_iter()
.map(|path| format!("Discovered file {path}")),
);
}
DiagnosticCheck::new(
"Config",
if discovered_count == 0 {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
if discovered_count == 0 {
"no config files were found; defaults are active"
} else {
"runtime config loaded successfully"
},
)
.with_details(details)
}
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
.into_iter()
.map(|path| format!("Discovered file {path}"))
.collect()
}),
}
}
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
),
])
}
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)
}
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
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) = config.and_then(runtime::RuntimeConfig::model) {
details.push(format!("Default model {model}"));
}
DiagnosticCheck::new(
"System",
DiagnosticLevel::Ok,
"captured local runtime metadata",
)
.with_details(details)
}
fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
matches!(
SlashCommand::parse(current_command),
@@ -1468,6 +1891,10 @@ fn run_resume_command(
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
})
}
SlashCommand::Doctor => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_doctor_report()?.render()),
}),
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
SlashCommand::Bughunter { .. }
| SlashCommand::Commit { .. }
@@ -1481,7 +1908,6 @@ fn run_resume_command(
| SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Doctor
| SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
@@ -1751,37 +2177,38 @@ impl RuntimeMcpState {
.into_iter()
.filter(|server_name| !failed_server_names.contains(server_name))
.collect::<Vec<_>>();
let failed_servers = discovery
.failed_servers
.iter()
.map(|failure| runtime::McpFailedServer {
server_name: failure.server_name.clone(),
phase: runtime::McpLifecyclePhase::ToolDiscovery,
error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ToolDiscovery,
Some(failure.server_name.clone()),
failure.error.clone(),
std::collections::BTreeMap::new(),
true,
),
})
.chain(discovery.unsupported_servers.iter().map(|server| {
runtime::McpFailedServer {
server_name: server.server_name.clone(),
phase: runtime::McpLifecyclePhase::ServerRegistration,
let failed_servers =
discovery
.failed_servers
.iter()
.map(|failure| runtime::McpFailedServer {
server_name: failure.server_name.clone(),
phase: runtime::McpLifecyclePhase::ToolDiscovery,
error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ServerRegistration,
Some(server.server_name.clone()),
server.reason.clone(),
std::collections::BTreeMap::from([(
"transport".to_string(),
format!("{:?}", server.transport).to_ascii_lowercase(),
)]),
false,
runtime::McpLifecyclePhase::ToolDiscovery,
Some(failure.server_name.clone()),
failure.error.clone(),
std::collections::BTreeMap::new(),
true,
),
}
}))
.collect::<Vec<_>>();
})
.chain(discovery.unsupported_servers.iter().map(|server| {
runtime::McpFailedServer {
server_name: server.server_name.clone(),
phase: runtime::McpLifecyclePhase::ServerRegistration,
error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ServerRegistration,
Some(server.server_name.clone()),
server.reason.clone(),
std::collections::BTreeMap::from([(
"transport".to_string(),
format!("{:?}", server.transport).to_ascii_lowercase(),
)]),
false,
),
}
}))
.collect::<Vec<_>>();
let degraded_report = (!failed_servers.is_empty()).then(|| {
runtime::McpDegradedReport::new(
working_servers,
@@ -2387,8 +2814,11 @@ impl LiveCli {
Self::print_skills(args.as_deref())?;
false
}
SlashCommand::Doctor
| SlashCommand::Login
SlashCommand::Doctor => {
println!("{}", render_doctor_report()?.render());
false
}
SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
@@ -3371,6 +3801,33 @@ fn print_sandbox_status_snapshot() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn render_help_topic(topic: LocalHelpTopic) -> String {
match topic {
LocalHelpTopic::Status => "Status
Usage claw status
Purpose show the local workspace snapshot without entering the REPL
Output model, permissions, git state, config files, and sandbox status
Related /status · claw --resume latest /status"
.to_string(),
LocalHelpTopic::Sandbox => "Sandbox
Usage claw sandbox
Purpose inspect the resolved sandbox and isolation state for the current directory
Output namespace, network, filesystem, and fallback details
Related /sandbox · claw status"
.to_string(),
LocalHelpTopic::Doctor => "Doctor
Usage claw doctor
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
Output local-only health report; no provider request or session resume required
Related /doctor · claw --resume latest /doctor"
.to_string(),
}
}
fn print_help_topic(topic: LocalHelpTopic) {
println!("{}", render_help_topic(topic));
}
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
@@ -5549,6 +6006,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
)?;
writeln!(out, " claw sandbox")?;
writeln!(out, " Show the current sandbox isolation snapshot")?;
writeln!(out, " claw doctor")?;
writeln!(
out,
" Diagnose local auth, config, workspace, and sandbox health"
)?;
writeln!(out, " claw dump-manifests")?;
writeln!(out, " claw bootstrap-plan")?;
writeln!(out, " claw agents")?;
@@ -5626,6 +6088,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, " claw agents")?;
writeln!(out, " claw mcp show my-server")?;
writeln!(out, " claw /skills")?;
writeln!(out, " claw doctor")?;
writeln!(out, " claw login")?;
writeln!(out, " claw init")?;
Ok(())
@@ -5654,8 +6117,8 @@ mod tests {
resume_supported_slash_commands, run_resume_command,
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand,
StatusUsage, DEFAULT_MODEL,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{
@@ -6021,6 +6484,10 @@ mod tests {
parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout
);
assert_eq!(
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
CliAction::Doctor
);
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init
@@ -6046,6 +6513,25 @@ mod tests {
);
}
#[test]
fn local_command_help_flags_stay_on_the_local_parser_path() {
assert_eq!(
parse_args(&["status".to_string(), "--help".to_string()])
.expect("status help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Status)
);
assert_eq!(
parse_args(&["sandbox".to_string(), "-h".to_string()])
.expect("sandbox help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Sandbox)
);
assert_eq!(
parse_args(&["doctor".to_string(), "--help".to_string()])
.expect("doctor help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Doctor)
);
}
#[test]
fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() {
let _guard = env_lock();
@@ -7509,8 +7995,12 @@ UU conflicted.rs",
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 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}"#)

View File

@@ -160,6 +160,82 @@ fn config_command_loads_defaults_from_standard_config_locations() {
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn doctor_command_runs_as_a_local_shell_entrypoint() {
// given
let temp_dir = unique_temp_dir("doctor-entrypoint");
let config_home = temp_dir.join("home").join(".claw");
fs::create_dir_all(&config_home).expect("config home should exist");
// when
let output = command_in(&temp_dir)
.env("CLAW_CONFIG_HOME", &config_home)
.env_remove("ANTHROPIC_API_KEY")
.env_remove("ANTHROPIC_AUTH_TOKEN")
.env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9")
.arg("doctor")
.output()
.expect("claw doctor should launch");
// then
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Doctor"));
assert!(stdout.contains("Auth"));
assert!(stdout.contains("Config"));
assert!(stdout.contains("Workspace"));
assert!(stdout.contains("Sandbox"));
assert!(!stdout.contains("Thinking"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn local_subcommand_help_does_not_fall_through_to_runtime_or_provider_calls() {
// given
let temp_dir = unique_temp_dir("subcommand-help");
let config_home = temp_dir.join("home").join(".claw");
fs::create_dir_all(&config_home).expect("config home should exist");
// when
let doctor_help = command_in(&temp_dir)
.env("CLAW_CONFIG_HOME", &config_home)
.env_remove("ANTHROPIC_API_KEY")
.env_remove("ANTHROPIC_AUTH_TOKEN")
.env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9")
.args(["doctor", "--help"])
.output()
.expect("doctor help should launch");
let status_help = command_in(&temp_dir)
.env("CLAW_CONFIG_HOME", &config_home)
.env_remove("ANTHROPIC_API_KEY")
.env_remove("ANTHROPIC_AUTH_TOKEN")
.env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9")
.args(["status", "--help"])
.output()
.expect("status help should launch");
// then
assert_success(&doctor_help);
let doctor_stdout = String::from_utf8(doctor_help.stdout).expect("stdout should be utf8");
assert!(doctor_stdout.contains("Usage claw doctor"));
assert!(doctor_stdout.contains("local-only health report"));
assert!(!doctor_stdout.contains("Thinking"));
assert_success(&status_help);
let status_stdout = String::from_utf8(status_help.stdout).expect("stdout should be utf8");
assert!(status_stdout.contains("Usage claw status"));
assert!(status_stdout.contains("local workspace snapshot"));
assert!(!status_stdout.contains("Thinking"));
let doctor_stderr = String::from_utf8(doctor_help.stderr).expect("stderr should be utf8");
let status_stderr = String::from_utf8(status_help.stderr).expect("stderr should be utf8");
assert!(!doctor_stderr.contains("auth_unavailable"));
assert!(!status_stderr.contains("auth_unavailable"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
fn command_in(cwd: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(cwd);