diff --git a/ROADMAP.md b/ROADMAP.md index 6fe5c9b..3d9d7ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -438,7 +438,7 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes 36. **Custom/project skill invocation disconnected from skill discovery** -- dogfooded 2026-04-09. /skills lists custom skills (e.g. caveman) but bare skill-name invocation does not dispatch them; falls through to plain model prompt. Fix: audit classify_skills_slash_command, ensure any skill listed by /skills has a deterministic invocation path, or document the correct syntax. Source: gaebal-gajae dogfood 2026-04-09. -37. **Claude subscription login path should be removed, not deprecated** -- dogfooded 2026-04-09. Official auth should be API key only (ANTHROPIC_API_KEY or OAuth access token via ANTHROPIC_AUTH_TOKEN). claw login with Claude subscription credentials creates legal/billing ambiguity. Fix: remove the subscription login surface entirely; update README/USAGE.md to say API key is the only supported path. Source: gaebal-gajae policy decision 2026-04-09. +37. **Claude subscription login path should be removed, not deprecated** -- dogfooded 2026-04-09. Official auth should be API key only (`ANTHROPIC_API_KEY`) or OAuth bearer token via `ANTHROPIC_AUTH_TOKEN`; the local `claw login` / `claw logout` subscription-style flow created legal/billing ambiguity and a misleading saved-OAuth fallback. **Done (verified 2026-04-11):** removed the direct `claw login` / `claw logout` CLI surface, removed `/login` and `/logout` from shared slash-command discovery, changed both CLI and provider startup auth resolution to ignore saved OAuth credentials, and updated auth diagnostics to point only at `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`. Verification: targeted `commands`, `api`, and `rusty-claude-cli` tests for removed login/logout guidance and ignored saved OAuth all pass, and `cargo check -p api -p commands -p rusty-claude-cli` passes. Source: gaebal-gajae policy decision 2026-04-09. 38. **Dead-session opacity: bot cannot self-detect compaction vs broken tool surface** -- dogfooded 2026-04-09. Jobdori session spent ~15h declaring itself "dead" in-channel while tools were actually returning correct results within each turn. Root cause: context compaction causes tool outputs to be summarised away between turns, making the bot interpret absence-of-remembered-output as tool failure. This is a distinct failure mode from ROADMAP #31 (executor quirks): the session is alive and tools are functional, but the agent cannot tell the difference between "my last tool call produced no output" (compaction) and "the tool is broken". Downstream: repetitive false-dead signals in the channel, work not getting done despite the execution surface being live. Fix shape: (a) probe with a short known-output command at turn start if context has been compacted; (b) gate "I am dead" declarations behind at least one within-turn tool call with a verified non-empty result; (c) consider adding a session-health canary cron that fires a wake with a minimal probe and checks the result. Source: Jobdori self-dogfood 2026-04-09; observed in #clawcode-building-in-public across multiple Clawhip nudge cycles. diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 59ccdaf..6e68fd2 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -232,10 +232,7 @@ mod tests { openai_client.base_url() ); } - other => panic!( - "Expected ProviderClient::OpenAi for qwen-plus, got: {:?}", - other - ), + other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"), } } } diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index 3fa4995..4200036 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -24,7 +24,7 @@ pub enum ApiError { env_vars: &'static [&'static str], /// Optional, runtime-computed hint appended to the error Display /// output. Populated when the provider resolver can infer what the - /// user probably intended (e.g. an OpenAI key is set but Anthropic + /// user probably intended (e.g. an `OpenAI` key is set but Anthropic /// was selected because no Anthropic credentials exist). hint: Option, }, diff --git a/rust/crates/api/src/http_client.rs b/rust/crates/api/src/http_client.rs index 508a577..e2a2350 100644 --- a/rust/crates/api/src/http_client.rs +++ b/rust/crates/api/src/http_client.rs @@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result (Some(unified), Some(unified)), None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()), }; - if let Some(url) = https_proxy_url { + if let Some(url) = https_url { let mut proxy = reqwest::Proxy::https(url)?; if let Some(filter) = no_proxy.clone() { proxy = proxy.no_proxy(Some(filter)); diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 6e62b7d..dd18a4d 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -502,9 +502,8 @@ impl AnthropicClient { // Best-effort refinement using the Anthropic count_tokens endpoint. // On any failure (network, parse, auth), fall back to the local // byte-estimate result which already passed above. - let counted_input_tokens = match self.count_tokens(request).await { - Ok(count) => count, - Err(_) => return Ok(()), + let Ok(counted_input_tokens) = self.count_tokens(request).await else { + return Ok(()); }; let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens); if estimated_total_tokens > limit.context_window_tokens { @@ -631,21 +630,7 @@ impl AuthSource { if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { return Ok(Self::BearerToken(bearer_token)); } - match load_saved_oauth_token() { - Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => { - if token_set.refresh_token.is_some() { - Err(ApiError::Auth( - "saved OAuth token is expired; load runtime OAuth config to refresh it" - .to_string(), - )) - } else { - Err(ApiError::ExpiredOAuthToken) - } - } - Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)), - Ok(None) => Err(anthropic_missing_credentials()), - Err(error) => Err(error), - } + Err(anthropic_missing_credentials()) } } @@ -665,14 +650,14 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result Result { Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some() - || read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some() - || load_saved_oauth_token()?.is_some()) + || read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()) } pub fn resolve_startup_auth_source(load_oauth_config: F) -> Result where F: FnOnce() -> Result, ApiError>, { + let _ = load_oauth_config; if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? { return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer { @@ -685,25 +670,7 @@ where if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? { return Ok(AuthSource::BearerToken(bearer_token)); } - - let Some(token_set) = load_saved_oauth_token()? else { - return Err(anthropic_missing_credentials()); - }; - if !oauth_token_is_expired(&token_set) { - return Ok(AuthSource::BearerToken(token_set.access_token)); - } - if token_set.refresh_token.is_none() { - return Err(ApiError::ExpiredOAuthToken); - } - - let Some(config) = load_oauth_config()? else { - return Err(ApiError::Auth( - "saved OAuth token is expired; runtime OAuth config is missing".to_string(), - )); - }; - Ok(AuthSource::from(resolve_saved_oauth_token_set( - &config, token_set, - )?)) + Err(anthropic_missing_credentials()) } fn resolve_saved_oauth_token_set( @@ -1016,7 +983,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) { object.remove("presence_penalty"); // Anthropic uses "stop_sequences" not "stop". Convert if present. if let Some(stop_val) = object.remove("stop") { - if stop_val.as_array().map_or(false, |a| !a.is_empty()) { + if stop_val.as_array().is_some_and(|a| !a.is_empty()) { object.insert("stop_sequences".to_string(), stop_val); } } @@ -1180,7 +1147,7 @@ mod tests { } #[test] - fn auth_source_from_saved_oauth_when_env_absent() { + fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() { let _guard = env_lock(); let config_home = temp_config_home(); std::env::set_var("CLAW_CONFIG_HOME", &config_home); @@ -1194,8 +1161,8 @@ mod tests { }) .expect("save oauth credentials"); - let auth = AuthSource::from_env_or_saved().expect("saved auth"); - assert_eq!(auth.bearer_token(), Some("saved-access-token")); + let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored"); + assert!(error.to_string().contains("ANTHROPIC_API_KEY")); clear_oauth_credentials().expect("clear credentials"); std::env::remove_var("CLAW_CONFIG_HOME"); @@ -1251,7 +1218,7 @@ mod tests { } #[test] - fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() { + fn resolve_startup_auth_source_ignores_saved_oauth_without_loading_config() { let _guard = env_lock(); let config_home = temp_config_home(); std::env::set_var("CLAW_CONFIG_HOME", &config_home); @@ -1265,41 +1232,9 @@ mod tests { }) .expect("save oauth credentials"); - let auth = resolve_startup_auth_source(|| panic!("config should not be loaded")) - .expect("startup auth"); - assert_eq!(auth.bearer_token(), Some("saved-access-token")); - - clear_oauth_credentials().expect("clear credentials"); - std::env::remove_var("CLAW_CONFIG_HOME"); - cleanup_temp_config_home(&config_home); - } - - #[test] - fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() { - let _guard = env_lock(); - let config_home = temp_config_home(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); - std::env::remove_var("ANTHROPIC_API_KEY"); - save_oauth_credentials(&runtime::OAuthTokenSet { - access_token: "expired-access-token".to_string(), - refresh_token: Some("refresh-token".to_string()), - expires_at: Some(1), - scopes: vec!["scope:a".to_string()], - }) - .expect("save expired oauth credentials"); - - let error = - resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error"); - assert!( - matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing")) - ); - - let stored = runtime::load_oauth_credentials() - .expect("load stored credentials") - .expect("stored token set"); - assert_eq!(stored.access_token, "expired-access-token"); - assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token")); + let error = resolve_startup_auth_source(|| panic!("config should not be loaded")) + .expect_err("saved oauth should be ignored"); + assert!(error.to_string().contains("ANTHROPIC_API_KEY")); clear_oauth_credentials().expect("clear credentials"); std::env::remove_var("CLAW_CONFIG_HOME"); diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 24d5630..58978c8 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -508,9 +508,10 @@ mod tests { // ANTHROPIC_API_KEY was set because metadata_for_model returned None // and detect_provider_kind fell through to auth-sniffer order. // The model prefix must win over env-var presence. - let kind = super::metadata_for_model("openai/gpt-4.1-mini") - .map(|m| m.provider) - .unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini")); + let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else( + || detect_provider_kind("openai/gpt-4.1-mini"), + |m| m.provider, + ); assert_eq!( kind, ProviderKind::OpenAi, @@ -519,8 +520,7 @@ mod tests { // Also cover bare gpt- prefix let kind2 = super::metadata_for_model("gpt-4o") - .map(|m| m.provider) - .unwrap_or_else(|| detect_provider_kind("gpt-4o")); + .map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider); assert_eq!(kind2, ProviderKind::OpenAi); } diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 1d46ee6..09edb88 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -58,10 +58,10 @@ impl OpenAiCompatConfig { } } - /// Alibaba DashScope compatible-mode endpoint (Qwen family models). + /// Alibaba `DashScope` compatible-mode endpoint (Qwen family models). /// Uses the OpenAI-compatible REST shape at /compatible-mode/v1. /// Requested via Discord #clawcode-get-help: native Alibaba API for - /// higher rate limits than going through OpenRouter. + /// higher rate limits than going through `OpenRouter`. #[must_use] pub const fn dashscope() -> Self { Self { @@ -170,7 +170,7 @@ impl OpenAiCompatClient { .to_string(); let code = err_obj .get("code") - .and_then(|c| c.as_u64()) + .and_then(serde_json::Value::as_u64) .map(|c| c as u16); return Err(ApiError::Api { status: reqwest::StatusCode::from_u16(code.unwrap_or(400)) @@ -750,7 +750,7 @@ struct ErrorBody { } /// Returns true for models known to reject tuning parameters like temperature, -/// top_p, frequency_penalty, and presence_penalty. These are typically +/// `top_p`, `frequency_penalty`, and `presence_penalty`. These are typically /// reasoning/chain-of-thought models with fixed sampling. fn is_reasoning_model(model: &str) -> bool { let lowered = model.to_ascii_lowercase(); @@ -974,12 +974,11 @@ fn sanitize_tool_message_pairing(messages: Vec) -> Vec { } let paired = preceding .and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array())) - .map(|tool_calls| { + .is_some_and(|tool_calls| { tool_calls .iter() .any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id)) - }) - .unwrap_or(false); + }); if !paired { drop_indices.insert(i); } @@ -1008,7 +1007,7 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String { /// Recursively ensure every object-type node in a JSON Schema has /// `"properties"` (at least `{}`) and `"additionalProperties": false`. -/// The OpenAI `/responses` endpoint validates schemas strictly and rejects +/// The `OpenAI` `/responses` endpoint validates schemas strictly and rejects /// objects that omit these fields; `/chat/completions` is lenient but also /// accepts them, so we normalise unconditionally. fn normalize_object_schema(schema: &mut Value) { @@ -1173,7 +1172,7 @@ fn parse_sse_frame( .to_string(); let code = err_obj .get("code") - .and_then(|c| c.as_u64()) + .and_then(serde_json::Value::as_u64) .map(|c| c as u16); let status = reqwest::StatusCode::from_u16(code.unwrap_or(400)) .unwrap_or(reqwest::StatusCode::BAD_REQUEST); @@ -1185,7 +1184,7 @@ fn parse_sse_frame( .map(str::to_owned), message: Some(msg), request_id: None, - body: payload.to_string(), + body: payload.clone(), retryable: false, }); } @@ -1642,6 +1641,16 @@ mod tests { /// Before the fix this produced: `invalid type: null, expected a sequence`. #[test] fn delta_with_null_tool_calls_deserializes_as_empty_vec() { + use super::deserialize_null_as_empty_vec; + + #[allow(dead_code)] + #[derive(serde::Deserialize, Debug)] + struct Delta { + content: Option, + #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] + tool_calls: Vec, + } + // Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09) let json = r#"{ "content": "", @@ -1650,15 +1659,6 @@ mod tests { "role": "assistant", "tool_calls": null }"#; - - use super::deserialize_null_as_empty_vec; - #[allow(dead_code)] - #[derive(serde::Deserialize, Debug)] - struct Delta { - content: Option, - #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] - tool_calls: Vec, - } let delta: Delta = serde_json::from_str(json) .expect("delta with tool_calls:null must deserialize without error"); assert!( @@ -1670,7 +1670,7 @@ mod tests { /// Regression: when building a multi-turn request where a prior assistant /// turn has no tool calls, the serialized assistant message must NOT include /// `tool_calls: []`. Some providers reject requests that carry an empty - /// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09). + /// `tool_calls` array on assistant turns (gaebal-gajae repro 2026-04-09). #[test] fn assistant_message_without_tool_calls_omits_tool_calls_field() { use crate::types::{InputContentBlock, InputMessage}; @@ -1695,13 +1695,12 @@ mod tests { .expect("assistant message must be present"); assert!( assistant_msg.get("tool_calls").is_none(), - "assistant message without tool calls must omit tool_calls field: {:?}", - assistant_msg + "assistant message without tool calls must omit tool_calls field: {assistant_msg:?}" ); } /// Regression: assistant messages WITH tool calls must still include - /// the tool_calls array (normal multi-turn tool-use flow). + /// the `tool_calls` array (normal multi-turn tool-use flow). #[test] fn assistant_message_with_tool_calls_includes_tool_calls_field() { use crate::types::{InputContentBlock, InputMessage}; @@ -1733,7 +1732,7 @@ mod tests { assert_eq!(tool_calls.as_array().unwrap().len(), 1); } - /// Orphaned tool messages (no preceding assistant tool_calls) must be + /// Orphaned tool messages (no preceding assistant `tool_calls`) must be /// dropped by the request-builder sanitizer. Regression for the second /// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10). #[test] diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index dd109b1..f81644a 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -257,20 +257,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, - SlashCommandSpec { - name: "login", - aliases: &[], - summary: "Log in to the service", - argument_hint: None, - resume_supported: false, - }, - SlashCommandSpec { - name: "logout", - aliases: &[], - summary: "Log out of the current session", - argument_hint: None, - resume_supported: false, - }, SlashCommandSpec { name: "plan", aliases: &[], @@ -1291,7 +1277,6 @@ impl SlashCommand { Self::Tag { .. } => "/tag", Self::OutputStyle { .. } => "/output-style", Self::AddDir { .. } => "/add-dir", - Self::Unknown(_) => "/unknown", Self::Sandbox => "/sandbox", Self::Mcp { .. } => "/mcp", Self::Export { .. } => "/export", @@ -1402,13 +1387,12 @@ pub fn validate_slash_command_input( validate_no_args(command, &args)?; SlashCommand::Doctor } - "login" => { - validate_no_args(command, &args)?; - SlashCommand::Login - } - "logout" => { - validate_no_args(command, &args)?; - SlashCommand::Logout + "login" | "logout" => { + return Err(command_error( + "This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.", + command, + "", + )); } "vim" => { validate_no_args(command, &args)?; @@ -1893,20 +1877,12 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> { fn slash_command_category(name: &str) -> &'static str { match name { - "help" | "status" | "cost" | "resume" | "session" | "version" | "login" | "logout" - | "usage" | "stats" | "rename" | "clear" | "compact" | "history" | "tokens" | "cache" - | "exit" | "summary" | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" - | "pin" | "unpin" | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" - | "stop" | "undo" => "Session", - "diff" | "commit" | "pr" | "issue" | "branch" | "blame" | "log" | "git" | "stash" - | "init" | "export" | "plan" | "review" | "security-review" | "bughunter" | "ultraplan" - | "teleport" | "refactor" | "fix" | "autofix" | "explain" | "docs" | "perf" | "search" - | "references" | "definition" | "hover" | "symbols" | "map" | "web" | "image" - | "screenshot" | "paste" | "listen" | "speak" | "test" | "lint" | "build" | "run" - | "format" | "parallel" | "multi" | "macro" | "alias" | "templates" | "migrate" - | "benchmark" | "cron" | "agent" | "subagent" | "agents" | "skills" | "team" | "plugin" - | "mcp" | "hooks" | "tasks" | "advisor" | "insights" | "release-notes" | "chat" - | "approve" | "deny" | "allowed-tools" | "add-dir" => "Tools", + "help" | "status" | "cost" | "resume" | "session" | "version" | "usage" | "stats" + | "rename" | "clear" | "compact" | "history" | "tokens" | "cache" | "exit" | "summary" + | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" | "pin" | "unpin" + | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" | "stop" | "undo" => { + "Session" + } "model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color" | "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings" | "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt" @@ -2477,7 +2453,8 @@ pub fn resolve_skill_invocation( .map(|s| s.name.clone()) .collect(); if !names.is_empty() { - message.push_str(&format!("\n Available skills: {}", names.join(", "))); + message.push_str("\n Available skills: "); + message.push_str(&names.join(", ")); } } message.push_str("\n Usage: /skills [list|install |help| [args]]"); @@ -2699,7 +2676,7 @@ pub fn render_plugins_report_with_failures( // Show warnings for broken plugins if !failures.is_empty() { - lines.push("".to_string()); + lines.push(String::new()); lines.push("Warnings:".to_string()); for failure in failures { lines.push(format!( @@ -4598,6 +4575,14 @@ mod tests { assert!(action_error.contains(" Usage /mcp [list|show |help]")); } + #[test] + fn removed_login_and_logout_commands_report_env_auth_guidance() { + let login_error = parse_error_message("/login"); + assert!(login_error.contains("ANTHROPIC_API_KEY")); + let logout_error = parse_error_message("/logout"); + assert!(logout_error.contains("ANTHROPIC_AUTH_TOKEN")); + } + #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); @@ -4639,7 +4624,9 @@ mod tests { assert!(help.contains("/agents [list|help]")); assert!(help.contains("/skills [list|install |help| [args]]")); assert!(help.contains("aliases: /skill")); - assert_eq!(slash_command_specs().len(), 141); + assert!(!help.contains("/login")); + assert!(!help.contains("/logout")); + assert_eq!(slash_command_specs().len(), 139); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 922e52a..3e805dd 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -135,8 +135,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio let starts_with_tool_result = first_preserved .blocks .first() - .map(|b| matches!(b, ContentBlock::ToolResult { .. })) - .unwrap_or(false); + .is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. })); if !starts_with_tool_result { break; } @@ -741,7 +740,7 @@ mod tests { /// Regression: compaction must not split an assistant(ToolUse) / /// user(ToolResult) pair at the boundary. An orphaned tool-result message - /// without the preceding assistant tool_calls causes a 400 on the + /// without the preceding assistant `tool_calls` causes a 400 on the /// OpenAI-compat path (gaebal-gajae repro 2026-04-09). #[test] fn compaction_does_not_split_tool_use_tool_result_pair() { @@ -795,8 +794,7 @@ mod tests { let curr_is_tool_result = messages[i] .blocks .first() - .map(|b| matches!(b, ContentBlock::ToolResult { .. })) - .unwrap_or(false); + .is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. })); if curr_is_tool_result { let prev_has_tool_use = messages[i - 1] .blocks diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index 7d905fa..e832495 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -1467,12 +1467,8 @@ mod tests { /// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD. #[allow(dead_code)] pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result { - let store = crate::session_control::SessionStore::from_cwd(cwd).map_err(|e| { - SessionError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )) - })?; + let store = crate::session_control::SessionStore::from_cwd(cwd) + .map_err(|e| SessionError::Io(std::io::Error::other(e.to_string())))?; Ok(store.sessions_dir().to_path_buf()) } @@ -1489,8 +1485,7 @@ mod workspace_sessions_dir_tests { let result = workspace_sessions_dir(&tmp); assert!( result.is_ok(), - "workspace_sessions_dir should succeed for a valid CWD, got: {:?}", - result + "workspace_sessions_dir should succeed for a valid CWD, got: {result:?}" ); let dir = result.unwrap(); // The returned path should be non-empty and end with a hash component diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 1d86e24..eaf4de4 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -74,6 +74,7 @@ impl SessionStore { &self.workspace_root } + #[must_use] pub fn create_handle(&self, session_id: &str) -> SessionHandle { let id = session_id.to_string(); let path = self diff --git a/rust/crates/runtime/src/worker_boot.rs b/rust/crates/runtime/src/worker_boot.rs index 6909768..d133bfa 100644 --- a/rust/crates/runtime/src/worker_boot.rs +++ b/rust/crates/runtime/src/worker_boot.rs @@ -575,28 +575,28 @@ fn push_event( /// Write current worker state to `.claw/worker-state.json` under the worker's cwd. /// This is the file-based observability surface: external observers (clawhip, orchestrators) /// poll this file instead of requiring an HTTP route on the opencode binary. +#[derive(serde::Serialize)] +struct StateSnapshot<'a> { + worker_id: &'a str, + status: WorkerStatus, + is_ready: bool, + trust_gate_cleared: bool, + prompt_in_flight: bool, + last_event: Option<&'a WorkerEvent>, + updated_at: u64, + /// Seconds since last state transition. Clawhip uses this to detect + /// stalled workers without computing epoch deltas. + seconds_since_update: u64, +} + fn emit_state_file(worker: &Worker) { let state_dir = std::path::Path::new(&worker.cwd).join(".claw"); - if let Err(_) = std::fs::create_dir_all(&state_dir) { + if std::fs::create_dir_all(&state_dir).is_err() { return; } let state_path = state_dir.join("worker-state.json"); let tmp_path = state_dir.join("worker-state.json.tmp"); - #[derive(serde::Serialize)] - struct StateSnapshot<'a> { - worker_id: &'a str, - status: WorkerStatus, - is_ready: bool, - trust_gate_cleared: bool, - prompt_in_flight: bool, - last_event: Option<&'a WorkerEvent>, - updated_at: u64, - /// Seconds since last state transition. Clawhip uses this to detect - /// stalled workers without computing epoch deltas. - seconds_since_update: u64, - } - let now = now_secs(); let snapshot = StateSnapshot { worker_id: &worker.worker_id, diff --git a/rust/crates/rusty-claude-cli/build.rs b/rust/crates/rusty-claude-cli/build.rs index a6e77ce..551408c 100644 --- a/rust/crates/rusty-claude-cli/build.rs +++ b/rust/crates/rusty-claude-cli/build.rs @@ -14,14 +14,13 @@ fn main() { None } }) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()); - println!("cargo:rustc-env=GIT_SHA={}", git_sha); + println!("cargo:rustc-env=GIT_SHA={git_sha}"); // TARGET is always set by Cargo during build let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string()); - println!("cargo:rustc-env=TARGET={}", target); + println!("cargo:rustc-env=TARGET={target}"); // Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date. // Intentionally ignoring time component to keep output deterministic within a day. @@ -48,8 +47,7 @@ fn main() { None } }) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()) + .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()) }); println!("cargo:rustc-env=BUILD_DATE={build_date}"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ce26eab..5bd93ca 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -24,11 +24,10 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; use api::{ - detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, - AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, - MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, - ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, - ToolResultContentBlock, + detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource, + ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, + OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind, + StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; use commands::{ @@ -43,15 +42,13 @@ use init::initialize_repo; use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ - check_base_commit, clear_oauth_credentials, format_stale_base_warning, format_usd, - generate_pkce_pair, generate_state, load_oauth_credentials, load_system_prompt, - parse_oauth_callback_request_target, pricing_for_model, resolve_expected_base, - resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, - CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, - ConversationRuntime, McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, - ModelPricing, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, - PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials, + load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status, + ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, + ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager, + McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy, + ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, + ToolError, ToolExecutor, UsageTracker, }; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -244,8 +241,6 @@ fn run() -> Result<(), Box> { cli.set_reasoning_effort(reasoning_effort); cli.run_turn_with_output(&effective_prompt, output_format, compact)?; } - CliAction::Login { output_format } => run_login(output_format)?, - CliAction::Logout { output_format } => run_logout(output_format)?, CliAction::Doctor { output_format } => run_doctor(output_format)?, CliAction::State { output_format } => run_worker_state(output_format)?, CliAction::Init { output_format } => run_init(output_format)?, @@ -332,12 +327,6 @@ enum CliAction { reasoning_effort: Option, allow_broad_cwd: bool, }, - Login { - output_format: CliOutputFormat, - }, - Logout { - output_format: CliOutputFormat, - }, Doctor { output_format: CliOutputFormat, }, @@ -418,8 +407,6 @@ fn parse_args(args: &[String]) -> Result { && matches!( rest[0].as_str(), "prompt" - | "login" - | "logout" | "version" | "state" | "init" @@ -667,8 +654,7 @@ fn parse_args(args: &[String]) -> Result { } } "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), - "login" => Ok(CliAction::Login { output_format }), - "logout" => Ok(CliAction::Logout { output_format }), + "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), "init" => Ok(CliAction::Init { output_format }), "export" => parse_export_args(&rest[1..], output_format), "prompt" => { @@ -765,8 +751,6 @@ fn bare_slash_command_guidance(command_name: &str) -> Option { | "mcp" | "skills" | "system-prompt" - | "login" - | "logout" | "init" | "prompt" | "export" @@ -788,12 +772,19 @@ fn bare_slash_command_guidance(command_name: &str) -> Option { Some(guidance) } +fn removed_auth_surface_error(command_name: &str) -> String { + format!( + "`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." + ) +} + fn join_optional_args(args: &[String]) -> Option { let joined = args.join(" "); let trimmed = joined.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } +#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] fn parse_direct_slash_cli_action( rest: &[String], model: String, @@ -1168,7 +1159,7 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result< let value = args .get(index + 1) .ok_or_else(|| "missing value for --session".to_string())?; - session_reference = value.clone(); + session_reference.clone_from(value); index += 2; } flag if flag.starts_with("--session=") => { @@ -1529,79 +1520,65 @@ fn check_auth_health() -> DiagnosticCheck { 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)) => { - 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(|| "".to_string(), |value| value.to_string()), - if token_set.refresh_token.is_some() { - "present" - } else { - "absent" - }, - if token_set.scopes.is_empty() { - "".to_string() - } else { - token_set.scopes.join(",") - } - ), - ]; - if expired { - details.push( - "Suggested action claw login to refresh local OAuth credentials".to_string(), - ); - } - DiagnosticCheck::new( - "Auth", - if expired { - DiagnosticLevel::Warn + Ok(Some(token_set)) => DiagnosticCheck::new( + "Auth", + if api_key_present || auth_token_present { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if api_key_present || auth_token_present { + "supported auth env vars are configured; legacy saved OAuth is ignored" + } else { + "legacy saved OAuth credentials are present but unsupported" + }, + ) + .with_details(vec![ + env_details, + format!( + "Legacy OAuth expires_at={} refresh_token={} scopes={}", + token_set + .expires_at + .map_or_else(|| "".to_string(), |value| value.to_string()), + if token_set.refresh_token.is_some() { + "present" } else { - DiagnosticLevel::Ok + "absent" }, - if expired { - "saved OAuth credentials are present but expired" - } else if api_key_present || auth_token_present { - "environment and saved credentials are available" + if token_set.scopes.is_empty() { + "".to_string() } else { - "saved OAuth credentials are available" - }, - ) - .with_details(details) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("saved_oauth_present".to_string(), json!(true)), - ("saved_oauth_expired".to_string(), json!(expired)), - ( - "saved_oauth_expires_at".to_string(), - json!(token_set.expires_at), - ), - ( - "refresh_token_present".to_string(), - json!(token_set.refresh_token.is_some()), - ), - ("scopes".to_string(), json!(token_set.scopes)), - ])) - } + 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 { @@ -1610,43 +1587,33 @@ fn check_auth_health() -> DiagnosticCheck { DiagnosticLevel::Warn }, if api_key_present || auth_token_present { - "environment credentials are configured" + "supported auth env vars are configured" } else { - "no API key or saved OAuth credentials were found" + "no supported auth env vars 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" - } - )]) + .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)), - ("saved_oauth_present".to_string(), json!(false)), - ("saved_oauth_expired".to_string(), json!(false)), - ("saved_oauth_expires_at".to_string(), Value::Null), - ("refresh_token_present".to_string(), json!(false)), - ("scopes".to_string(), json!(Vec::::new())), + ("legacy_saved_oauth_present".to_string(), json!(false)), + ("legacy_saved_oauth_expires_at".to_string(), Value::Null), + ("legacy_refresh_token_present".to_string(), json!(false)), + ("legacy_scopes".to_string(), json!(Vec::::new())), ])), Err(error) => DiagnosticCheck::new( "Auth", DiagnosticLevel::Fail, - format!("failed to inspect saved credentials: {error}"), + 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)), - ("saved_oauth_present".to_string(), Value::Null), - ("saved_oauth_expired".to_string(), Value::Null), - ("saved_oauth_expires_at".to_string(), Value::Null), - ("refresh_token_present".to_string(), Value::Null), - ("scopes".to_string(), Value::Null), - ("saved_oauth_error".to_string(), json!(error.to_string())), + ("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())), ])), } } @@ -1993,182 +1960,6 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box OAuthConfig { - OAuthConfig { - client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"), - authorize_url: String::from("https://platform.claude.com/oauth/authorize"), - token_url: String::from("https://platform.claude.com/v1/oauth/token"), - callback_port: None, - manual_redirect_url: None, - scopes: vec![ - String::from("user:profile"), - String::from("user:inference"), - String::from("user:sessions:claude_code"), - ], - } -} - -fn run_login(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let config = ConfigLoader::default_for(&cwd).load()?; - let default_oauth = default_oauth_config(); - let oauth = config.oauth().unwrap_or(&default_oauth); - let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT); - let redirect_uri = runtime::loopback_redirect_uri(callback_port); - let pkce = generate_pkce_pair()?; - let state = generate_state()?; - let authorize_url = - OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce) - .build_url(); - - if output_format == CliOutputFormat::Text { - println!("Starting Claude OAuth login..."); - println!("Listening for callback on {redirect_uri}"); - } - if let Err(error) = open_browser(&authorize_url) { - emit_login_browser_open_failure( - output_format, - &authorize_url, - &error, - &mut io::stdout(), - &mut io::stderr(), - )?; - } - - let callback = wait_for_oauth_callback(callback_port)?; - if let Some(error) = callback.error { - let description = callback - .error_description - .unwrap_or_else(|| "authorization failed".to_string()); - return Err(io::Error::other(format!("{error}: {description}")).into()); - } - let code = callback.code.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "callback did not include code") - })?; - let returned_state = callback.state.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "callback did not include state") - })?; - if returned_state != state { - return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into()); - } - - let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); - let exchange_request = OAuthTokenExchangeRequest::from_config( - oauth, - code, - state, - pkce.verifier, - redirect_uri.clone(), - ); - let runtime = tokio::runtime::Runtime::new()?; - let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?; - save_oauth_credentials(&runtime::OAuthTokenSet { - access_token: token_set.access_token, - refresh_token: token_set.refresh_token, - expires_at: token_set.expires_at, - scopes: token_set.scopes, - })?; - match output_format { - CliOutputFormat::Text => println!("Claude OAuth login complete."), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "login", - "callback_port": callback_port, - "redirect_uri": redirect_uri, - "message": "Claude OAuth login complete.", - }))? - ), - } - Ok(()) -} - -fn emit_login_browser_open_failure( - output_format: CliOutputFormat, - authorize_url: &str, - error: &io::Error, - stdout: &mut impl Write, - stderr: &mut impl Write, -) -> io::Result<()> { - writeln!( - stderr, - "warning: failed to open browser automatically: {error}" - )?; - match output_format { - CliOutputFormat::Text => writeln!(stdout, "Open this URL manually:\n{authorize_url}"), - CliOutputFormat::Json => writeln!(stderr, "Open this URL manually:\n{authorize_url}"), - } -} - -fn run_logout(output_format: CliOutputFormat) -> Result<(), Box> { - clear_oauth_credentials()?; - match output_format { - CliOutputFormat::Text => println!("Claude OAuth credentials cleared."), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "logout", - "message": "Claude OAuth credentials cleared.", - }))? - ), - } - Ok(()) -} - -fn open_browser(url: &str) -> io::Result<()> { - let commands = if cfg!(target_os = "macos") { - vec![("open", vec![url])] - } else if cfg!(target_os = "windows") { - vec![("cmd", vec!["/C", "start", "", url])] - } else { - vec![("xdg-open", vec![url])] - }; - for (program, args) in commands { - match Command::new(program).args(args).spawn() { - Ok(_) => return Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - } - Err(io::Error::new( - io::ErrorKind::NotFound, - "no supported browser opener command found", - )) -} - -fn wait_for_oauth_callback( - port: u16, -) -> Result> { - let listener = TcpListener::bind(("127.0.0.1", port))?; - let (mut stream, _) = listener.accept()?; - let mut buffer = [0_u8; 4096]; - let bytes_read = stream.read(&mut buffer)?; - let request = String::from_utf8_lossy(&buffer[..bytes_read]); - let request_line = request.lines().next().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "missing callback request line") - })?; - let target = request_line.split_whitespace().nth(1).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "missing callback request target", - ) - })?; - let callback = parse_oauth_callback_request_target(target) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let body = if callback.error.is_some() { - "Claude OAuth login failed. You can close this window." - } else { - "Claude OAuth login succeeded. You can close this window." - }; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(response.as_bytes())?; - Ok(callback) -} - fn print_system_prompt( cwd: PathBuf, date: String, @@ -2214,6 +2005,7 @@ fn version_json_value() -> serde_json::Value { }) } +#[allow(clippy::too_many_lines)] fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { let session_reference = session_path.display().to_string(); let (handle, session) = match load_session_reference(&session_reference) { @@ -3036,8 +2828,7 @@ fn detect_broad_cwd() -> Option { }; let is_home = env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) - .map(|h| PathBuf::from(h) == cwd) - .unwrap_or(false); + .is_some_and(|h| Path::new(&h) == cwd); let is_root = cwd.parent().is_none(); if is_home || is_root { Some(cwd) @@ -3109,9 +2900,8 @@ fn enforce_broad_cwd_policy( } fn run_stale_base_preflight(flag_value: Option<&str>) { - let cwd = match env::current_dir() { - Ok(cwd) => cwd, - Err(_) => return, + let Ok(cwd) = env::current_dir() else { + return; }; let source = resolve_expected_base(flag_value, &cwd); let state = check_base_commit(&cwd, source.as_ref()); @@ -3120,6 +2910,7 @@ fn run_stale_base_preflight(flag_value: Option<&str>) { } } +#[allow(clippy::needless_pass_by_value)] fn run_repl( model: String, allowed_tools: Option, @@ -4474,6 +4265,7 @@ impl LiveCli { Ok(()) } + #[allow(clippy::too_many_lines)] fn handle_session_command( &mut self, action: Option<&str>, @@ -4765,8 +4557,7 @@ fn new_cli_session() -> Result> { fn create_managed_session_handle( session_id: &str, ) -> Result> { - let handle = current_session_store()? - .create_handle(session_id); + let handle = current_session_store()?.create_handle(session_id); Ok(SessionHandle { id: handle.id, path: handle.path, @@ -5366,14 +5157,14 @@ fn render_config_json( ConfigSource::Project => "project", ConfigSource::Local => "local", }; - let loaded = runtime_config + let is_loaded = runtime_config .loaded_entries() .iter() .any(|le| le.path == e.path); serde_json::json!({ "path": e.path.display().to_string(), "source": source, - "loaded": loaded, + "loaded": is_loaded, }) }) .collect(); @@ -5798,6 +5589,11 @@ fn format_history_timestamp(timestamp_ms: u64) -> String { // Computes civil (Gregorian) year/month/day from days since the Unix epoch // (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm. +#[allow( + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::cast_possible_truncation +)] fn civil_from_days(days: i64) -> (i32, u32, u32) { let z = days + 719_468; let era = if z >= 0 { @@ -6852,29 +6648,11 @@ impl AnthropicRuntimeClient { } fn resolve_cli_auth_source() -> Result> { - let cwd = env::current_dir()?; - Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?) + Ok(resolve_cli_auth_source_for_cwd()?) } -fn resolve_cli_auth_source_for_cwd( - cwd: &Path, - default_oauth: F, -) -> Result -where - F: FnOnce() -> OAuthConfig, -{ - resolve_startup_auth_source(|| { - Ok(Some( - load_runtime_oauth_config_for(cwd)?.unwrap_or_else(default_oauth), - )) - }) -} - -fn load_runtime_oauth_config_for(cwd: &Path) -> Result, api::ApiError> { - let config = ConfigLoader::default_for(cwd).load().map_err(|error| { - api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}")) - })?; - Ok(config.oauth().cloned()) +fn resolve_cli_auth_source_for_cwd() -> Result { + resolve_startup_auth_source(|| Ok(None)) } impl ApiClient for AnthropicRuntimeClient { @@ -6917,7 +6695,6 @@ impl ApiClient for AnthropicRuntimeClient { { // Stalled after tool completion — nudge the model by // re-sending the same request. - continue; } Err(error) => return Err(error), } @@ -8251,8 +8028,6 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " claw mcp")?; writeln!(out, " claw skills")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; - writeln!(out, " claw login")?; - writeln!(out, " claw logout")?; writeln!(out, " claw init")?; writeln!( out, @@ -8336,7 +8111,6 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " claw mcp show my-server")?; writeln!(out, " claw /skills")?; writeln!(out, " claw doctor")?; - writeln!(out, " claw login")?; writeln!(out, " claw init")?; writeln!(out, " claw export")?; writeln!(out, " claw export conversation.md")?; @@ -8612,36 +8386,6 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn sample_oauth_config(token_url: String) -> OAuthConfig { - OAuthConfig { - client_id: "runtime-client".to_string(), - authorize_url: "https://console.test/oauth/authorize".to_string(), - token_url, - callback_port: Some(4545), - manual_redirect_url: Some("https://console.test/oauth/callback".to_string()), - scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()], - } - } - - fn spawn_token_server(response_body: &'static str) -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); - let address = listener.local_addr().expect("local addr"); - thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept connection"); - let mut buffer = [0_u8; 4096]; - let _ = stream.read(&mut buffer).expect("read request"); - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - response_body.len(), - response_body - ); - stream - .write_all(response.as_bytes()) - .expect("write response"); - }); - format!("http://{address}/oauth/token") - } - fn with_current_dir(cwd: &Path, f: impl FnOnce() -> T) -> T { let _guard = cwd_lock() .lock() @@ -8784,25 +8528,9 @@ mod tests { } #[test] - fn load_runtime_oauth_config_for_returns_none_without_project_config() { + fn resolve_cli_auth_source_ignores_saved_oauth_credentials() { let _guard = env_lock(); - let root = temp_dir(); - std::fs::create_dir_all(&root).expect("workspace should exist"); - - let oauth = super::load_runtime_oauth_config_for(&root) - .expect("loading config should succeed when files are absent"); - - std::fs::remove_dir_all(root).expect("temp workspace should clean up"); - - assert_eq!(oauth, None); - } - - #[test] - fn resolve_cli_auth_source_uses_default_oauth_when_runtime_config_is_missing() { - let _guard = env_lock(); - let workspace = temp_dir(); let config_home = temp_dir(); - std::fs::create_dir_all(&workspace).expect("workspace should exist"); std::fs::create_dir_all(&config_home).expect("config home should exist"); let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); @@ -8820,17 +8548,8 @@ mod tests { }) .expect("save expired oauth credentials"); - let token_url = spawn_token_server( - r#"{"access_token":"refreshed-access-token","refresh_token":"refreshed-refresh-token","expires_at":4102444800,"scopes":["org:create_api_key","user:profile"]}"#, - ); - - let auth = - super::resolve_cli_auth_source_for_cwd(&workspace, || sample_oauth_config(token_url)) - .expect("expired saved oauth should refresh via default config"); - - let stored = load_oauth_credentials() - .expect("load stored credentials") - .expect("stored credentials should exist"); + let error = super::resolve_cli_auth_source_for_cwd() + .expect_err("saved oauth should be ignored without env auth"); match original_config_home { Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), @@ -8844,15 +8563,9 @@ mod tests { Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value), None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"), } - std::fs::remove_dir_all(workspace).expect("temp workspace should clean up"); std::fs::remove_dir_all(config_home).expect("temp config home should clean up"); - assert_eq!(auth.bearer_token(), Some("refreshed-access-token")); - assert_eq!(stored.access_token, "refreshed-access-token"); - assert_eq!( - stored.refresh_token.as_deref(), - Some("refreshed-refresh-token") - ); + assert!(error.to_string().contains("ANTHROPIC_API_KEY")); } #[test] @@ -9229,19 +8942,11 @@ mod tests { } #[test] - fn parses_login_and_logout_subcommands() { - assert_eq!( - parse_args(&["login".to_string()]).expect("login should parse"), - CliAction::Login { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["logout".to_string()]).expect("logout should parse"), - CliAction::Logout { - output_format: CliOutputFormat::Text, - } - ); + fn removed_login_and_logout_subcommands_error_helpfully() { + let login = parse_args(&["login".to_string()]).expect_err("login should be removed"); + assert!(login.contains("ANTHROPIC_API_KEY")); + let logout = parse_args(&["logout".to_string()]).expect_err("logout should be removed"); + assert!(logout.contains("ANTHROPIC_AUTH_TOKEN")); assert_eq!( parse_args(&["doctor".to_string()]).expect("doctor should parse"), CliAction::Doctor { @@ -10218,6 +9923,8 @@ mod tests { assert!(help.contains("claw mcp")); assert!(help.contains("claw skills")); assert!(help.contains("claw /skills")); + assert!(!help.contains("claw login")); + assert!(!help.contains("claw logout")); } #[test] @@ -11308,31 +11015,6 @@ UU conflicted.rs", assert!(!rendered.contains("step 1")); } - #[test] - fn login_browser_failure_keeps_json_stdout_clean() { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let error = std::io::Error::new( - std::io::ErrorKind::NotFound, - "no supported browser opener command found", - ); - - super::emit_login_browser_open_failure( - CliOutputFormat::Json, - "https://example.test/oauth/authorize", - &error, - &mut stdout, - &mut stderr, - ) - .expect("browser warning should render"); - - assert!(stdout.is_empty()); - let stderr = String::from_utf8(stderr).expect("utf8"); - assert!(stderr.contains("failed to open browser automatically")); - assert!(stderr.contains("Open this URL manually:")); - assert!(stderr.contains("https://example.test/oauth/authorize")); - } - #[test] fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { let config_home = temp_dir(); @@ -11620,8 +11302,7 @@ UU conflicted.rs", ]); assert!( result.is_ok(), - "--reasoning-effort {value} should be accepted, got: {:?}", - result + "--reasoning-effort {value} should be accepted, got: {result:?}" ); if let Ok(CliAction::Prompt { reasoning_effort, .. diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index cb7828d..24b77d0 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -639,10 +639,16 @@ fn apply_code_block_background(line: &str) -> String { /// fence markers of equal or greater length are wrapped with a longer fence. /// /// LLMs frequently emit triple-backtick code blocks that contain triple-backtick -/// examples. CommonMark (and pulldown-cmark) treats the inner marker as the +/// examples. `CommonMark` (and pulldown-cmark) treats the inner marker as the /// closing fence, breaking the render. This function detects the situation and /// upgrades the outer fence to use enough backticks (or tildes) that the inner /// markers become ordinary content. +#[allow( + clippy::too_many_lines, + clippy::items_after_statements, + clippy::manual_repeat_n, + clippy::manual_str_repeat +)] fn normalize_nested_fences(markdown: &str) -> String { // A fence line is either "labeled" (has an info string ⇒ always an opener) // or "bare" (no info string ⇒ could be opener or closer). diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index aa2de6e..cffd68e 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -3771,10 +3771,7 @@ impl ProviderRuntimeClient { allowed_tools: BTreeSet, fallback_config: &ProviderFallbackConfig, ) -> Result { - let primary_model = fallback_config - .primary() - .map(str::to_string) - .unwrap_or(model); + let primary_model = fallback_config.primary().map_or(model, str::to_string); let primary = build_provider_entry(&primary_model)?; let mut chain = vec![primary]; for fallback_model in fallback_config.fallbacks() { @@ -3852,17 +3849,15 @@ impl ApiClient for ProviderRuntimeClient { entry.model ); last_error = Some(error); - continue; } Err(error) => return Err(RuntimeError::new(error.to_string())), } } - Err(RuntimeError::new( - last_error - .map(|error| error.to_string()) - .unwrap_or_else(|| String::from("provider chain exhausted with no attempts")), - )) + Err(RuntimeError::new(last_error.map_or_else( + || String::from("provider chain exhausted with no attempts"), + |error| error.to_string(), + ))) } }