From 9c3d52d0cdd108f69fd6f05f5280f6111c60401a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 30 Apr 2026 10:07:50 +0000 Subject: [PATCH 1/4] Report structured config file load statuses --- rust/crates/runtime/src/config.rs | 550 ++--- rust/crates/rusty-claude-cli/src/main.rs | 2424 ++++------------------ 2 files changed, 677 insertions(+), 2297 deletions(-) diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 073eb6fb15..d7e7dcaac7 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -1,22 +1,7 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -/// Process-lifetime set of already-emitted config deprecation warning strings. -/// Prevents duplicate warnings when `ConfigLoader::load()` is called multiple -/// times within a single CLI invocation. (ROADMAP #698) -static EMITTED_CONFIG_WARNINGS: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -fn emit_config_warning_once(warning: &str) { - let set = EMITTED_CONFIG_WARNINGS.get_or_init(|| Mutex::new(HashSet::new())); - let mut guard = set.lock().unwrap_or_else(|e| e.into_inner()); - if guard.insert(warning.to_string()) { - eprintln!("warning: {warning}"); - } -} use crate::json::JsonValue; use crate::sandbox::{FilesystemIsolationMode, SandboxConfig}; @@ -55,6 +40,45 @@ pub struct RuntimeConfig { feature_config: RuntimeFeatureConfig, } +/// Machine-readable load state for a discovered config file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigFileStatus { + Loaded, + NotFound, + Skipped, + LoadError, +} + +impl ConfigFileStatus { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Loaded => "loaded", + Self::NotFound => "not_found", + Self::Skipped => "skipped", + Self::LoadError => "load_error", + } + } +} + +/// Structured status for a single discovered config file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigFileReport { + pub entry: ConfigEntry, + pub loaded: bool, + pub status: ConfigFileStatus, + pub reason: Option, + pub detail: Option, +} + +/// Best-effort inspection of the full config discovery/load pipeline. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigInspection { + pub files: Vec, + pub runtime_config: Option, + pub load_error: Option, +} + /// Parsed plugin-related settings extracted from runtime config. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimePluginConfig { @@ -105,10 +129,6 @@ pub struct RuntimePermissionRuleConfig { allow: Vec, deny: Vec, ask: Vec, - /// #159: simple tool-name denials parsed from the `deniedTools` config field. - /// Unlike the `deny` rules (pattern-based), `denied_tools` is a flat list of - /// tool names that are unconditionally denied regardless of permission mode. - denied_tools: Vec, } /// Collection of configured MCP servers after scope-aware merging. @@ -120,7 +140,6 @@ pub struct McpConfigCollection { /// MCP server config paired with the scope that defined it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ScopedMcpServerConfig { - pub required: bool, pub scope: ConfigSource, pub config: McpServerConfig, } @@ -296,7 +315,7 @@ impl ConfigLoader { for entry in self.discover() { crate::config_validate::check_unsupported_format(&entry.path)?; - let Some(parsed) = read_optional_json_object(&entry.path)? else { + let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else { continue; }; let validation = crate::config_validate::validate_config_file( @@ -316,35 +335,191 @@ impl ConfigLoader { } for warning in &all_warnings { - emit_config_warning_once(&warning.to_string()); + eprintln!("warning: {warning}"); } - let merged_value = JsonValue::Object(merged.clone()); + build_runtime_config(merged, loaded_entries, mcp_servers) + } - let feature_config = RuntimeFeatureConfig { - hooks: parse_optional_hooks_config(&merged_value)?, - plugins: parse_optional_plugin_config(&merged_value)?, - mcp: McpConfigCollection { - servers: mcp_servers, + /// Inspect discovered files and return per-file statuses without aborting + /// the whole report on the first missing/skipped/invalid file. + #[must_use] + pub fn inspect(&self) -> ConfigInspection { + let mut merged = BTreeMap::new(); + let mut loaded_entries = Vec::new(); + let mut mcp_servers = BTreeMap::new(); + let mut all_warnings = Vec::new(); + let mut files = Vec::new(); + let mut first_error = None; + + for entry in self.discover() { + if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("unsupported_format".to_string()), + detail: Some(detail), + }); + continue; + } + + let parsed = match read_optional_json_object(&entry.path) { + Ok(OptionalConfigFile::Loaded(parsed)) => parsed, + Ok(OptionalConfigFile::NotFound) => { + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::NotFound, + reason: Some("not_found".to_string()), + detail: None, + }); + continue; + } + Ok(OptionalConfigFile::Skipped { reason, detail }) => { + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::Skipped, + reason: Some(reason), + detail, + }); + continue; + } + Err(error) => { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + let reason = match &error { + ConfigError::Io(io_error) + if io_error.kind() == std::io::ErrorKind::PermissionDenied => + { + "permission_denied" + } + ConfigError::Io(_) => "io_error", + ConfigError::Parse(_) => "parse_error", + }; + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some(reason.to_string()), + detail: Some(detail), + }); + continue; + } + }; + + let validation = crate::config_validate::validate_config_file( + &parsed.object, + &parsed.source, + &entry.path, + ); + if !validation.is_ok() { + let detail = validation.errors[0].to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("validation_error".to_string()), + detail: Some(detail), + }); + continue; + } + all_warnings.extend(validation.warnings); + + if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("validation_error".to_string()), + detail: Some(detail), + }); + continue; + } + + if let Err(error) = + merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path) + { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("parse_error".to_string()), + detail: Some(detail), + }); + continue; + } + + deep_merge_objects(&mut merged, &parsed.object); + loaded_entries.push(entry.clone()); + files.push(ConfigFileReport { + entry, + loaded: true, + status: ConfigFileStatus::Loaded, + reason: None, + detail: None, + }); + } + + for warning in &all_warnings { + eprintln!("warning: {warning}"); + } + + match build_runtime_config(merged, loaded_entries, mcp_servers) { + Ok(runtime_config) => ConfigInspection { + files, + runtime_config: Some(runtime_config), + load_error: first_error, }, - oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, - model: parse_optional_model(&merged_value), - aliases: parse_optional_aliases(&merged_value)?, - permission_mode: parse_optional_permission_mode(&merged_value)?, - permission_rules: parse_optional_permission_rules(&merged_value)?, - sandbox: parse_optional_sandbox_config(&merged_value)?, - provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, - trusted_roots: parse_optional_trusted_roots(&merged_value)?, - }; - - Ok(RuntimeConfig { - merged, - loaded_entries, - feature_config, - }) + Err(error) => { + first_error.get_or_insert_with(|| error.to_string()); + ConfigInspection { + files, + runtime_config: None, + load_error: first_error, + } + } + } } } +fn build_runtime_config( + merged: BTreeMap, + loaded_entries: Vec, + mcp_servers: BTreeMap, +) -> Result { + let merged_value = JsonValue::Object(merged.clone()); + let feature_config = RuntimeFeatureConfig { + hooks: parse_optional_hooks_config(&merged_value)?, + plugins: parse_optional_plugin_config(&merged_value)?, + mcp: McpConfigCollection { + servers: mcp_servers, + }, + oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + model: parse_optional_model(&merged_value), + aliases: parse_optional_aliases(&merged_value)?, + permission_mode: parse_optional_permission_mode(&merged_value)?, + permission_rules: parse_optional_permission_rules(&merged_value)?, + sandbox: parse_optional_sandbox_config(&merged_value)?, + provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, + trusted_roots: parse_optional_trusted_roots(&merged_value)?, + }; + + Ok(RuntimeConfig { + merged, + loaded_entries, + feature_config, + }) +} + impl RuntimeConfig { #[must_use] pub fn empty() -> Self { @@ -434,17 +609,6 @@ impl RuntimeConfig { pub fn trusted_roots(&self) -> &[String] { &self.feature_config.trusted_roots } - - /// Merge config-level default trusted roots with per-call roots. - /// - /// Config roots are defaults and are kept first; per-call roots extend the - /// allowlist for a specific worker/session creation request. Duplicates are - /// removed without reordering the first occurrence so evidence remains - /// deterministic while avoiding repeated trust checks. - #[must_use] - pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec { - merge_trusted_roots(self.trusted_roots(), per_call_roots) - } } impl RuntimeFeatureConfig { @@ -514,22 +678,6 @@ impl RuntimeFeatureConfig { pub fn trusted_roots(&self) -> &[String] { &self.trusted_roots } - - /// Merge this config's default trusted roots with per-call roots. - #[must_use] - pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec { - merge_trusted_roots(self.trusted_roots(), per_call_roots) - } -} - -fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec { - let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len()); - for root in config_roots.iter().chain(per_call_roots.iter()) { - if !merged.contains(root) { - merged.push(root.clone()); - } - } - merged } impl ProviderFallbackConfig { @@ -611,104 +759,6 @@ pub fn default_config_home() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".claw")) } -/// Save provider settings to the user-level `~/.claw/settings.json`. -/// Creates the file and directory if they don't exist. Sets file permissions -/// to `0o600` (owner read/write only) to protect stored API keys. -pub fn save_user_provider_settings( - kind: &str, - api_key: &str, - base_url: Option<&str>, - model: Option<&str>, -) -> Result<(), ConfigError> { - let config_home = default_config_home(); - fs::create_dir_all(&config_home).map_err(ConfigError::Io)?; - let settings_path = config_home.join("settings.json"); - - let mut root = read_settings_root(&settings_path); - - let mut provider = serde_json::Map::new(); - provider.insert( - "kind".to_string(), - serde_json::Value::String(kind.to_string()), - ); - provider.insert( - "apiKey".to_string(), - serde_json::Value::String(api_key.to_string()), - ); - if let Some(base_url) = base_url { - provider.insert( - "baseUrl".to_string(), - serde_json::Value::String(base_url.to_string()), - ); - } else { - provider.remove("baseUrl"); - } - root.insert("provider".to_string(), serde_json::Value::Object(provider)); - if let Some(model) = model { - root.insert( - "model".to_string(), - serde_json::Value::String(model.to_string()), - ); - } else { - root.remove("model"); - } - - write_settings_root(&settings_path, &root)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(&settings_path, perms).map_err(ConfigError::Io)?; - } - - Ok(()) -} - -/// Remove the `provider` section from the user-level `~/.claw/settings.json`. -pub fn clear_user_provider_settings() -> Result<(), ConfigError> { - let config_home = default_config_home(); - let settings_path = config_home.join("settings.json"); - - if !settings_path.exists() { - return Ok(()); - } - - let mut root = read_settings_root(&settings_path); - if root.remove("provider").is_none() { - return Ok(()); - } - root.remove("model"); - - write_settings_root(&settings_path, &root)?; - - Ok(()) -} - -fn read_settings_root(path: &Path) -> serde_json::Map { - match fs::read_to_string(path) { - Ok(contents) if !contents.trim().is_empty() => { - serde_json::from_str::(&contents) - .ok() - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default() - } - _ => serde_json::Map::new(), - } -} - -fn write_settings_root( - path: &Path, - root: &serde_json::Map, -) -> Result<(), ConfigError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(ConfigError::Io)?; - } - let rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) - .map_err(|e| ConfigError::Parse(e.to_string()))?; - fs::write(path, format!("{rendered}\n")).map_err(ConfigError::Io) -} - impl RuntimeHookConfig { #[must_use] pub fn new( @@ -757,18 +807,8 @@ impl RuntimeHookConfig { impl RuntimePermissionRuleConfig { #[must_use] - pub fn new( - allow: Vec, - deny: Vec, - ask: Vec, - denied_tools: Vec, - ) -> Self { - Self { - allow, - deny, - ask, - denied_tools, - } + pub fn new(allow: Vec, deny: Vec, ask: Vec) -> Self { + Self { allow, deny, ask } } #[must_use] @@ -785,11 +825,6 @@ impl RuntimePermissionRuleConfig { pub fn ask(&self) -> &[String] { &self.ask } - - #[must_use] - pub fn denied_tools(&self) -> &[String] { - &self.denied_tools - } } impl McpConfigCollection { @@ -831,16 +866,27 @@ struct ParsedConfigFile { source: String, } -fn read_optional_json_object(path: &Path) -> Result, ConfigError> { +enum OptionalConfigFile { + Loaded(ParsedConfigFile), + NotFound, + Skipped { + reason: String, + detail: Option, + }, +} + +fn read_optional_json_object(path: &Path) -> Result { let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json"); let contents = match fs::read_to_string(path) { Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(OptionalConfigFile::NotFound); + } Err(error) => return Err(ConfigError::Io(error)), }; if contents.trim().is_empty() { - return Ok(Some(ParsedConfigFile { + return Ok(OptionalConfigFile::Loaded(ParsedConfigFile { object: BTreeMap::new(), source: contents, })); @@ -848,19 +894,30 @@ fn read_optional_json_object(path: &Path) -> Result, Co let parsed = match JsonValue::parse(&contents) { Ok(parsed) => parsed, - Err(_error) if is_legacy_config => return Ok(None), + Err(error) if is_legacy_config => { + return Ok(OptionalConfigFile::Skipped { + reason: "legacy_invalid_json".to_string(), + detail: Some(format!("{}: {error}", path.display())), + }); + } Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))), }; let Some(object) = parsed.as_object() else { if is_legacy_config { - return Ok(None); + return Ok(OptionalConfigFile::Skipped { + reason: "legacy_non_object".to_string(), + detail: Some(format!( + "{}: top-level legacy settings value is not a JSON object", + path.display() + )), + }); } return Err(ConfigError::Parse(format!( "{}: top-level settings value must be a JSON object", path.display() ))); }; - Ok(Some(ParsedConfigFile { + Ok(OptionalConfigFile::Loaded(ParsedConfigFile { object: object.clone(), source: contents, })) @@ -885,12 +942,6 @@ fn merge_mcp_servers( target.insert( name.clone(), ScopedMcpServerConfig { - required: optional_bool( - expect_object(value, &format!("{}: mcpServers.{name}", path.display()))?, - "required", - &format!("{}: mcpServers.{name}", path.display()), - )? - .unwrap_or(false), scope: source, config: parsed, }, @@ -960,12 +1011,6 @@ fn parse_optional_permission_rules( .unwrap_or_default(), ask: optional_string_array(permissions, "ask", "merged settings.permissions")? .unwrap_or_default(), - denied_tools: optional_string_array( - permissions, - "deniedTools", - "merged settings.permissions", - )? - .unwrap_or_default(), }) } @@ -1416,9 +1461,9 @@ fn push_unique(target: &mut Vec, value: String) { #[cfg(test)] mod tests { use super::{ - deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource, - McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig, - RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME, + deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader, + ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig, + RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; use crate::sandbox::FilesystemIsolationMode; @@ -1674,51 +1719,6 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } - #[test] - fn trusted_roots_with_overrides_preserves_config_defaults_and_adds_per_call_roots() { - // given - let root = temp_dir(); - let cwd = root.join("project"); - let home = root.join("home").join(".claw"); - fs::create_dir_all(&home).expect("home config dir"); - fs::create_dir_all(&cwd).expect("project dir"); - fs::write( - home.join("settings.json"), - r#"{"trustedRoots": ["/tmp/config-default", "/tmp/shared"]}"#, - ) - .expect("write settings"); - - // when - let loaded = ConfigLoader::new(&cwd, &home) - .load() - .expect("config should load"); - let merged = loaded.trusted_roots_with_overrides(&[ - "/tmp/per-call".to_string(), - "/tmp/shared".to_string(), - ]); - - // then - assert_eq!( - merged, - ["/tmp/config-default", "/tmp/shared", "/tmp/per-call"] - ); - - fs::remove_dir_all(root).expect("cleanup temp dir"); - } - - #[test] - fn runtime_feature_trusted_roots_with_overrides_matches_runtime_config_merge() { - let config = RuntimeFeatureConfig { - trusted_roots: vec!["/tmp/config".to_string()], - ..RuntimeFeatureConfig::default() - }; - - assert_eq!( - config.trusted_roots_with_overrides(&["/tmp/per-call".to_string()]), - ["/tmp/config", "/tmp/per-call"] - ); - } - #[test] fn trusted_roots_default_is_empty_when_unset() { // given @@ -1755,8 +1755,7 @@ mod tests { "stdio-server": { "command": "uvx", "args": ["mcp-server"], - "env": {"TOKEN": "secret"}, - "required": true + "env": {"TOKEN": "secret"} }, "remote-server": { "type": "http", @@ -1805,7 +1804,6 @@ mod tests { .get("stdio-server") .expect("stdio server should exist"); assert_eq!(stdio_server.scope, ConfigSource::User); - assert!(stdio_server.required); assert_eq!(stdio_server.transport(), McpTransport::Stdio); let remote_server = loaded @@ -1813,7 +1811,6 @@ mod tests { .get("remote-server") .expect("remote server should exist"); assert_eq!(remote_server.scope, ConfigSource::Local); - assert!(!remote_server.required); assert_eq!(remote_server.transport(), McpTransport::Ws); match &remote_server.config { McpServerConfig::Ws(config) => { @@ -2055,6 +2052,47 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn inspect_classifies_missing_loaded_and_legacy_skipped_files() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(cwd.join(".claw")).expect("project claw dir"); + fs::create_dir_all(&home).expect("home dir"); + fs::write(cwd.join(".claw.json"), "{not json").expect("legacy config"); + fs::write(cwd.join(".claw/settings.json"), r#"{"model":"opus"}"#) + .expect("project settings"); + + let inspection = ConfigLoader::new(&cwd, &home).inspect(); + assert!(inspection.load_error.is_none()); + assert!(inspection.runtime_config.is_some()); + + let loaded = inspection + .files + .iter() + .find(|file| file.loaded) + .expect("loaded file"); + assert_eq!(loaded.status, ConfigFileStatus::Loaded); + assert_eq!(loaded.reason, None); + + let missing = inspection + .files + .iter() + .find(|file| file.status == ConfigFileStatus::NotFound) + .expect("missing file"); + assert_eq!(missing.reason.as_deref(), Some("not_found")); + + let skipped = inspection + .files + .iter() + .find(|file| file.status == ConfigFileStatus::Skipped) + .expect("skipped legacy file"); + assert_eq!(skipped.reason.as_deref(), Some("legacy_invalid_json")); + assert!(!skipped.loaded); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn deep_merge_objects_merges_nested_maps() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 41cdf194c2..a722c7a029 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,13 +2,6 @@ dead_code, unused_imports, unused_variables, - clippy::doc_markdown, - clippy::len_zero, - clippy::manual_string_new, - clippy::match_same_arms, - clippy::result_large_err, - clippy::too_many_lines, - clippy::uninlined_format_args, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self @@ -30,14 +23,11 @@ use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; -use log::debug; - use api::{ - detect_provider_kind, model_family_identity_for, 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::{ @@ -45,8 +35,7 @@ use commands::{ handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help, render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands, - slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch, - SlashCommand, + slash_command_specs, validate_slash_command_input, SkillSlashDispatch, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -55,11 +44,11 @@ use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ 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, BaseCommitState, CompactionConfig, ConfigLoader, - ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServer, - McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, - PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, - Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + 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}; @@ -67,7 +56,7 @@ use tools::{ execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput, }; -const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-6"; +const DEFAULT_MODEL: &str = "claude-opus-4-6"; /// #148: Model provenance for `claw status` JSON/text output. Records where /// the resolved model string came from so claws don't have to re-read argv @@ -159,7 +148,11 @@ impl ModelProvenance { } fn max_tokens_for_model(model: &str) -> u32 { - api::max_tokens_for_model(model) + if model.contains("opus") { + 32_000 + } else { + 64_000 + } } // Build-time constants injected by build.rs (fall back to static values when // build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). @@ -228,7 +221,6 @@ fn main() { "error": short_reason, "kind": kind, "hint": hint, - "exit_code": 1, }) ); } else { @@ -272,10 +264,6 @@ fn classify_error_kind(message: &str) -> &'static str { "session_load_failed" } else if message.contains("no managed sessions found") { "no_managed_sessions" - } else if message.contains("unsupported ACP invocation") { - "unsupported_acp_invocation" - } else if message.contains("unsupported skills action") { - "unsupported_skills_action" } else if message.contains("unrecognized argument") || message.contains("unknown option") { "cli_parse" } else if message.contains("invalid model syntax") { @@ -288,16 +276,6 @@ fn classify_error_kind(message: &str) -> &'static str { "confirmation_required" } else if message.contains("api failed") || message.contains("api returned") { "api_http_error" - } else if message.contains("mcpServers") { - "malformed_mcp_config" - } else if message.starts_with("empty prompt") { - "empty_prompt" - } else if message.starts_with("interactive_only:") || message.contains("stdin is not a TTY") { - "interactive_only" - } else if message.starts_with("unknown agents subcommand:") { - "unknown_agents_subcommand" - } else if message.contains("is not installed") { - "plugin_not_found" } else { "unknown" } @@ -355,59 +333,6 @@ fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String format!("{prompt}\n\n{trimmed}") } -fn plugin_command_json( - action: &str, - target: Option<&str>, - result: &commands::PluginsCommandResult, - report: &plugins::PluginRegistryReport, -) -> Value { - let failures = report.failures(); - json!({ - "kind": "plugin", - "action": action, - "target": target, - "status": if failures.is_empty() { "ok" } else { "degraded" }, - "message": result.message, - "reload_runtime": result.reload_runtime, - "plugins": report.summaries().iter().map(plugin_summary_json).collect::>(), - "load_failures": failures.iter().map(plugin_load_failure_json).collect::>(), - }) -} - -fn plugin_summary_json(plugin: &plugins::PluginSummary) -> Value { - json!({ - "id": &plugin.metadata.id, - "name": &plugin.metadata.name, - "version": &plugin.metadata.version, - "description": &plugin.metadata.description, - "kind": plugin.metadata.kind.to_string(), - "source": &plugin.metadata.source, - "enabled": plugin.enabled, - "lifecycle_state": plugin.lifecycle_state(), - "lifecycle": { - "configured": !plugin.lifecycle.is_empty(), - "init": { - "configured": !plugin.lifecycle.init.is_empty(), - "command_count": plugin.lifecycle.init.len(), - }, - "shutdown": { - "configured": !plugin.lifecycle.shutdown.is_empty(), - "command_count": plugin.lifecycle.shutdown.len(), - }, - }, - }) -} - -fn plugin_load_failure_json(failure: &plugins::PluginLoadFailure) -> Value { - json!({ - "plugin_root": failure.plugin_root.display().to_string(), - "kind": failure.kind.to_string(), - "source": &failure.source, - "lifecycle_state": "load_failed", - "error": failure.error().to_string(), - }) -} - fn run() -> Result<(), Box> { let args: Vec = env::args().skip(1).collect(); match parse_args(&args)? { @@ -436,9 +361,8 @@ fn run() -> Result<(), Box> { CliAction::PrintSystemPrompt { cwd, date, - model, output_format, - } => print_system_prompt(cwd, date, &model, output_format)?, + } => print_system_prompt(cwd, date, output_format)?, CliAction::Version { output_format } => print_version(output_format)?, CliAction::ResumeSession { session_path, @@ -540,10 +464,7 @@ fn run() -> Result<(), Box> { reasoning_effort, allow_broad_cwd, )?, - CliAction::HelpTopic { - topic, - output_format, - } => print_help_topic(topic, output_format)?, + CliAction::HelpTopic(topic) => print_help_topic(topic), CliAction::Help { output_format } => print_help(output_format)?, } Ok(()) @@ -578,7 +499,6 @@ enum CliAction { PrintSystemPrompt { cwd: PathBuf, date: String, - model: String, output_format: CliOutputFormat, }, Version { @@ -647,10 +567,7 @@ enum CliAction { reasoning_effort: Option, allow_broad_cwd: bool, }, - HelpTopic { - topic: LocalHelpTopic, - output_format: CliOutputFormat, - }, + HelpTopic(LocalHelpTopic), // prompt-mode formatting is only supported for non-interactive runs Help { output_format: CliOutputFormat, @@ -739,19 +656,15 @@ fn parse_args(args: &[String]) -> Result { let value = args .get(index + 1) .ok_or_else(|| "missing value for --model".to_string())?; - let resolved = resolve_model_alias_with_config(value); - debug!("Resolved --model '{}' -> '{}'", value, resolved); - validate_model_syntax(&resolved)?; - model = resolved; + validate_model_syntax(value)?; + model = resolve_model_alias_with_config(value); model_flag_raw = Some(value.clone()); // #148 index += 2; } flag if flag.starts_with("--model=") => { let value = &flag[8..]; - let resolved = resolve_model_alias_with_config(value); - debug!("Resolved --model='{}' -> '{}'", value, resolved); - validate_model_syntax(&resolved)?; - model = resolved; + validate_model_syntax(value)?; + model = resolve_model_alias_with_config(value); model_flag_raw = Some(value.to_string()); // #148 index += 1; } @@ -885,35 +798,6 @@ fn parse_args(args: &[String]) -> Result { } if wants_help { - // #684: --help before subcommand should still route to subcommand-specific - // help when the subcommand is one of the local-help-topic commands. - if let Some(action) = parse_local_help_action(&rest, output_format) { - return action; - } - // When --help was consumed before the subcommand, rest has no help flag. - // If rest is a simple local-help subcommand with no extra args, route there. - if !rest.is_empty() && rest[1..].iter().all(|a| is_help_flag(a)) { - let topic = match rest[0].as_str() { - "status" => Some(LocalHelpTopic::Status), - "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), - "acp" => Some(LocalHelpTopic::Acp), - "init" => Some(LocalHelpTopic::Init), - "state" => Some(LocalHelpTopic::State), - "export" => Some(LocalHelpTopic::Export), - "version" => Some(LocalHelpTopic::Version), - "system-prompt" => Some(LocalHelpTopic::SystemPrompt), - "dump-manifests" => Some(LocalHelpTopic::DumpManifests), - "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), - _ => None, - }; - if let Some(topic) = topic { - return Ok(CliAction::HelpTopic { - topic, - output_format, - }); - } - } return Ok(CliAction::Help { output_format }); } @@ -946,12 +830,6 @@ fn parse_args(args: &[String]) -> Result { allow_broad_cwd, }); } - // Non-TTY stdin with no piped content: refuse to start the interactive - // REPL (it would block forever waiting for input that will never arrive). - // (#696: emit a typed error instead of hanging indefinitely) - // Skip this guard in test builds (parse_args tests run in non-TTY context). - #[cfg(not(test))] - return Err("interactive_only: claw requires an interactive terminal (stdin is not a TTY and no prompt was provided — pipe a prompt or run in a TTY)".into()); } return Ok(CliAction::Repl { model, @@ -965,7 +843,7 @@ fn parse_args(args: &[String]) -> Result { if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..], output_format); } - if let Some(action) = parse_local_help_action(&rest, output_format) { + if let Some(action) = parse_local_help_action(&rest) { return action; } if let Some(action) = parse_single_word_command_alias( @@ -999,17 +877,13 @@ fn parse_args(args: &[String]) -> Result { // `missing Anthropic credentials` even though the command is purely // local introspection. Mirror `agents`/`mcp`/`skills`: action is the // first positional arg, target is the second. - // `plugin` (singular) and `marketplace` are aliases for `plugins`. - // All three must route to the same local handler so that no form - // falls through to the LLM/prompt path. - "plugins" | "plugin" | "marketplace" => { + "plugins" => { let tail = &rest[1..]; let action = tail.first().cloned(); let target = tail.get(1).cloned(); if tail.len() > 2 { return Err(format!( - "unexpected extra arguments after `claw {} {}`: {}", - rest[0], + "unexpected extra arguments after `claw plugins {}`: {}", tail[..2].join(" "), tail[2..].join(" ") )); @@ -1052,25 +926,8 @@ fn parse_args(args: &[String]) -> Result { } Ok(CliAction::Diff { output_format }) } - // `claw permissions ` falls through to the LLM when called - // with a subcommand argument because parse_single_word_command_alias - // only intercepts the bare single-word form. Catch all multi-word - // forms here and return a structured guidance error so no network - // call or session is created. - "permissions" => Err( - "`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]" - .to_string(), - ), "skills" => { let args = join_optional_args(&rest[1..]); - if let Some(action) = args.as_deref() { - let first_word = action.split_whitespace().next().unwrap_or(action); - if matches!(first_word, "remove" | "add" | "uninstall" | "delete") { - return Err(format!( - "unsupported skills action: {first_word}. Supported actions: list, install , help, or [args]" - )); - } - } match classify_skills_slash_command(args.as_deref()) { SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { prompt, @@ -1089,7 +946,7 @@ fn parse_args(args: &[String]) -> Result { }), } } - "system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format), + "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), "acp" => parse_acp_args(&rest[1..], output_format), "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), "init" => Ok(CliAction::Init { output_format }), @@ -1164,14 +1021,8 @@ fn parse_args(args: &[String]) -> Result { } } -fn parse_local_help_action( - rest: &[String], - output_format: CliOutputFormat, -) -> Option> { - if rest.is_empty() { - return None; - } - if !rest.iter().any(|a| is_help_flag(a)) { +fn parse_local_help_action(rest: &[String]) -> Option> { + if rest.len() != 2 || !is_help_flag(&rest[1]) { return None; } @@ -1180,6 +1031,10 @@ fn parse_local_help_action( "sandbox" => LocalHelpTopic::Sandbox, "doctor" => LocalHelpTopic::Doctor, "acp" => LocalHelpTopic::Acp, + // #141: add the subcommands that were previously falling back + // to global help (init/state/export/version) or erroring out + // (system-prompt/dump-manifests) or printing their primary + // output instead of help text (bootstrap-plan). "init" => LocalHelpTopic::Init, "state" => LocalHelpTopic::State, "export" => LocalHelpTopic::Export, @@ -1189,14 +1044,7 @@ fn parse_local_help_action( "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, _ => return None, }; - let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); - if has_non_help { - return None; - } - Some(Ok(CliAction::HelpTopic { - topic, - output_format, - })) + Some(Ok(CliAction::HelpTopic(topic))) } fn is_help_flag(value: &str) -> bool { @@ -1226,9 +1074,8 @@ fn parse_single_word_command_alias( if is_diagnostic && rest.len() > 1 { // Diagnostic verb with trailing args: reject unrecognized suffix - let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a)); - if all_extra_are_help { - // "doctor --help -h" is valid, routed to parse_local_help_action() instead + if is_help_flag(&rest[1]) && rest.len() == 2 { + // "doctor --help" is valid, routed to parse_local_help_action() instead return None; } // Unrecognized suffix like "--json" @@ -1277,9 +1124,6 @@ fn bare_slash_command_guidance(command_name: &str) -> Option { | "bootstrap-plan" | "agents" | "mcp" - | "plugin" - | "plugins" - | "marketplace" | "skills" | "system-prompt" | "init" @@ -1584,9 +1428,9 @@ fn levenshtein_distance(left: &str, right: &str) -> usize { fn resolve_model_alias(model: &str) -> &str { match model { - "opus" => "anthropic/claude-opus-4-6", - "sonnet" => "anthropic/claude-sonnet-4-6", - "haiku" => "anthropic/claude-haiku-4-5-20251213", + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-4-5-20251213", _ => model, } } @@ -1610,6 +1454,11 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { if trimmed.is_empty() { return Err("model string cannot be empty".to_string()); } + // Known aliases are always valid + match trimmed { + "opus" | "sonnet" | "haiku" => return Ok(()), + _ => {} + } // Check for spaces (malformed) if trimmed.contains(' ') { return Err(format!( @@ -1622,7 +1471,7 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { // #154: hint if the model looks like it belongs to a different provider let mut err_msg = format!( - "invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6)", + "invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)", trimmed ); if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { @@ -1769,7 +1618,6 @@ fn filter_tool_specs( fn parse_system_prompt_args( args: &[String], - model: String, output_format: CliOutputFormat, ) -> Result { let mut cwd = env::current_dir().map_err(|error| error.to_string())?; @@ -1806,7 +1654,6 @@ fn parse_system_prompt_args( Ok(CliAction::PrintSystemPrompt { cwd, date, - model, output_format, }) } @@ -2042,17 +1889,6 @@ impl DoctorReport { self.checks.iter().any(|check| check.level.is_failure()) } - fn status(&self) -> &'static str { - let (_, warn_count, fail_count) = self.counts(); - if fail_count > 0 { - "fail" - } else if warn_count > 0 { - "warn" - } else { - "ok" - } - } - fn render(&self) -> String { let (ok_count, warn_count, fail_count) = self.counts(); let mut lines = vec![ @@ -2070,7 +1906,6 @@ impl DoctorReport { let (ok_count, warn_count, fail_count) = self.counts(); json!({ "kind": "doctor", - "status": self.status(), "message": report, "report": report, "has_failures": self.has_failures(), @@ -2112,17 +1947,8 @@ fn render_doctor_report() -> Result> { 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 branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref()); - let stale_base_state = stale_base_state_for(&cwd, None); let empty_config = runtime::RuntimeConfig::empty(); let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); - let boot_preflight = build_boot_preflight_snapshot( - &cwd, - project_root.as_deref(), - project_context.git_status.as_deref(), - config.as_ref().ok(), - config.as_ref().err().map(ToString::to_string).as_deref(), - ); let context = StatusContext { cwd: cwd.clone(), session_path: None, @@ -2135,15 +1961,11 @@ fn render_doctor_report() -> Result> { project_root, git_branch, git_summary, - branch_freshness, - stale_base_state, session_lifecycle: classify_session_lifecycle_for(&cwd), - boot_preflight, sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), // Doctor path has its own config check; StatusContext here is only // fed into health renderers that don't read config_load_error. config_load_error: config.as_ref().err().map(ToString::to_string), - config_load_error_kind: None, }; Ok(DoctorReport { checks: vec![ @@ -2151,7 +1973,6 @@ fn render_doctor_report() -> Result> { check_config_health(&config_loader, config.as_ref()), check_install_source_health(), check_workspace_health(&context), - check_boot_preflight_health(&context), check_sandbox_health(&context.sandbox_status), check_system_health(&cwd, config.as_ref().ok()), ], @@ -2254,34 +2075,25 @@ fn check_auth_health() -> DiagnosticCheck { let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") .ok() .is_some_and(|value| !value.trim().is_empty()); - let openai_key_present = env::var("OPENAI_API_KEY") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let any_auth_present = api_key_present || auth_token_present || openai_key_present; let env_details = format!( - "Environment api_key={} auth_token={} openai_key={}", + "Environment api_key={} auth_token={}", if api_key_present { "present" } else { "absent" }, if auth_token_present { "present" } else { "absent" - }, - if openai_key_present { - "present" - } else { - "absent" } ); match load_oauth_credentials() { Ok(Some(token_set)) => DiagnosticCheck::new( "Auth", - if any_auth_present { + if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn }, - if any_auth_present { + 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" @@ -2324,12 +2136,12 @@ fn check_auth_health() -> DiagnosticCheck { ])), Ok(None) => DiagnosticCheck::new( "Auth", - if any_auth_present { + if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn }, - if any_auth_present { + if api_key_present || auth_token_present { "supported auth env vars are configured" } else { "no supported auth env vars were found" @@ -2486,10 +2298,9 @@ fn check_install_source_health() -> DiagnosticCheck { fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { let in_repo = context.project_root.is_some(); - let stale_base_warning = format_stale_base_warning(&context.stale_base_state); DiagnosticCheck::new( "Workspace", - if in_repo && stale_base_warning.is_none() { + if in_repo { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn @@ -2522,10 +2333,6 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { "Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files ), - format!( - "Stale base {}", - stale_base_warning.as_deref().unwrap_or("ok") - ), ]) .with_data(Map::from_iter([ ("cwd".to_string(), json!(context.cwd.display().to_string())), @@ -2558,80 +2365,9 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { "discovered_config_files".to_string(), json!(context.discovered_config_files), ), - ( - "stale_base".to_string(), - stale_base_json_value(&context.stale_base_state), - ), ])) } -fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck { - let preflight = &context.boot_preflight; - let missing_binaries = preflight - .required_binaries - .iter() - .filter(|binary| !binary.available) - .map(|binary| binary.name) - .collect::>(); - let socket_details = preflight - .control_sockets - .iter() - .map(|socket| { - format!( - "Control socket {} configured={} exists={} path={}", - socket.name, - socket.configured, - socket.exists, - socket.path.as_deref().unwrap_or("") - ) - }) - .collect::>(); - let mut details = vec![ - format!("Repo exists {}", preflight.repo_exists), - format!("Worktree exists {}", preflight.worktree_exists), - format!("Git dir exists {}", preflight.git_dir_exists), - format!("Branch behind {}", preflight.branch_freshness.behind), - format!("Trust allowlist {:?}", preflight.trust_gate_allowed), - format!("Trusted roots {}", preflight.trusted_roots_count), - format!( - "MCP eligible {} · servers {}", - preflight.mcp_startup_eligible, preflight.mcp_servers_configured - ), - format!( - "Plugin eligible {} · configured {}", - preflight.plugin_startup_eligible, preflight.plugins_configured - ), - format!( - "Last failed boot {}", - preflight - .last_failed_boot_reason - .as_deref() - .unwrap_or("") - ), - ]; - details.extend(preflight.required_binaries.iter().map(|binary| { - format!( - "Required binary {} available={}", - binary.name, binary.available - ) - })); - details.extend(socket_details); - DiagnosticCheck::new( - "Boot preflight", - if preflight.repo_exists && preflight.worktree_exists && missing_binaries.is_empty() { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - preflight.summary(), - ) - .with_details(details) - .with_data(Map::from_iter([( - "boot_preflight".to_string(), - preflight.json_value(), - )])) -} - fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { let degraded = status.enabled && !status.active; let mut details = vec![ @@ -2858,16 +2594,9 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box Result<(), Box> { - let sections = load_system_prompt( - cwd, - date, - env::consts::OS, - "unknown", - model_family_identity_for(model), - )?; + let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; let message = sections.join( " @@ -2898,15 +2627,12 @@ fn print_version(output_format: CliOutputFormat) -> Result<(), Box serde_json::Value { - let executable_path = env::current_exe().ok().map(|p| p.display().to_string()); json!({ "kind": "version", "message": render_version_report(), "version": VERSION, "git_sha": GIT_SHA, "target": BUILD_TARGET, - "build_date": DEFAULT_DATE, - "executable_path": executable_path, }) } @@ -3080,10 +2806,7 @@ struct StatusContext { project_root: Option, git_branch: Option, git_summary: GitWorkspaceSummary, - branch_freshness: BranchFreshness, - stale_base_state: BaseCommitState, session_lifecycle: SessionLifecycleSummary, - boot_preflight: BootPreflightSnapshot, sandbox_status: runtime::SandboxStatus, /// #143: when `.claw.json` (or another loaded config file) fails to parse, /// we capture the parse error here and still populate every field that @@ -3092,167 +2815,6 @@ struct StatusContext { /// `status: "degraded"` so claws can distinguish "status ran but config /// is broken" from "status ran cleanly". config_load_error: Option, - /// #143: machine-readable kind for the config load error, derived from - /// `classify_error_kind`. Included in JSON output alongside the human - /// readable string so downstream claws can switch on the kind token - /// instead of regex-scraping the prose. - config_load_error_kind: Option<&'static str>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BranchFreshness { - upstream: Option, - ahead: u32, - behind: u32, - fresh: Option, -} - -impl BranchFreshness { - fn from_git_status(status: Option<&str>) -> Self { - let first_line = status - .and_then(|status| status.lines().next()) - .unwrap_or_default(); - let upstream = first_line - .split_once("...") - .and_then(|(_, rest)| rest.split([' ', '[']).next()) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - let mut ahead = 0; - let mut behind = 0; - if let Some((_, bracketed)) = first_line.split_once('[') { - let bracketed = bracketed.trim_end_matches(']'); - for part in bracketed.split(',').map(str::trim) { - if let Some(value) = part.strip_prefix("ahead ") { - ahead = value.parse().unwrap_or(0); - } else if let Some(value) = part.strip_prefix("behind ") { - behind = value.parse().unwrap_or(0); - } - } - } - let fresh = upstream.as_ref().map(|_| behind == 0); - Self { - upstream, - ahead, - behind, - fresh, - } - } - - fn json_value(&self) -> serde_json::Value { - json!({ - "upstream": self.upstream, - "ahead": self.ahead, - "behind": self.behind, - "fresh": self.fresh, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BinaryPreflight { - name: &'static str, - available: bool, -} - -impl BinaryPreflight { - fn json_value(&self) -> serde_json::Value { - json!({ - "name": self.name, - "available": self.available, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ControlSocketPreflight { - name: &'static str, - configured: bool, - exists: bool, - path: Option, -} - -impl ControlSocketPreflight { - fn json_value(&self) -> serde_json::Value { - json!({ - "name": self.name, - "configured": self.configured, - "exists": self.exists, - "path": self.path, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BootPreflightSnapshot { - repo_exists: bool, - worktree_exists: bool, - git_dir_exists: bool, - branch_freshness: BranchFreshness, - trust_gate_allowed: Option, - trusted_roots_count: usize, - required_binaries: Vec, - control_sockets: Vec, - mcp_startup_eligible: bool, - mcp_servers_configured: usize, - plugin_startup_eligible: bool, - plugins_configured: usize, - last_failed_boot_reason: Option, -} - -impl BootPreflightSnapshot { - fn json_value(&self) -> serde_json::Value { - json!({ - "repo": { - "exists": self.repo_exists, - "worktree_exists": self.worktree_exists, - "git_dir_exists": self.git_dir_exists, - }, - "branch_freshness": self.branch_freshness.json_value(), - "trust_gate": { - "allowlisted": self.trust_gate_allowed, - "trusted_roots_count": self.trusted_roots_count, - }, - "required_binaries": self.required_binaries.iter().map(BinaryPreflight::json_value).collect::>(), - "control_sockets": self.control_sockets.iter().map(ControlSocketPreflight::json_value).collect::>(), - "mcp_startup": { - "eligible": self.mcp_startup_eligible, - "servers_configured": self.mcp_servers_configured, - }, - "plugin_startup": { - "eligible": self.plugin_startup_eligible, - "plugins_configured": self.plugins_configured, - }, - "last_failed_boot_reason": self.last_failed_boot_reason, - }) - } - - fn summary(&self) -> String { - let trust = self - .trust_gate_allowed - .map(|value| { - if value { - "allowlisted" - } else { - "not allowlisted" - } - }) - .unwrap_or("unknown"); - let freshness = self - .branch_freshness - .fresh - .map(|fresh| if fresh { "fresh" } else { "behind" }) - .unwrap_or("no upstream"); - format!( - "repo={} worktree={} branch={} trust={} mcp={} plugins={} last_failed={}", - self.repo_exists, - self.worktree_exists, - freshness, - trust, - self.mcp_startup_eligible, - self.plugin_startup_eligible, - self.last_failed_boot_reason.as_deref().unwrap_or("none") - ) - } } #[derive(Debug, Clone, Copy)] @@ -3463,9 +3025,6 @@ fn parse_tmux_pane_snapshots(output: &str) -> Vec { } fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { - if pane_path == workspace || pane_path.starts_with(workspace) { - return true; - } let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf()); let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); pane_path == workspace || pane_path.starts_with(&workspace) @@ -3585,21 +3144,18 @@ fn format_permissions_switch_report(previous: &str, next: &str) -> String { } fn format_cost_report(usage: TokenUsage) -> String { - let estimated_cost = usage.estimate_cost_usd(); format!( "Cost Input tokens {} Output tokens {} Cache create {} Cache read {} - Total tokens {} - Estimated cost {}", + Total tokens {}", usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, usage.total_tokens(), - format_usd(estimated_cost.total_cost_usd()), ) } @@ -3616,7 +3172,7 @@ fn render_resume_usage() -> String { format!( "Resume Usage /resume - Auto-save .claw/sessions//.{PRIMARY_SESSION_EXTENSION} + Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} Tip use /session list to inspect saved sessions" ) } @@ -3703,123 +3259,6 @@ fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { summary } -fn build_boot_preflight_snapshot( - cwd: &Path, - project_root: Option<&Path>, - git_status: Option<&str>, - runtime_config: Option<&runtime::RuntimeConfig>, - config_load_error: Option<&str>, -) -> BootPreflightSnapshot { - let branch_freshness = BranchFreshness::from_git_status(git_status); - let worktree_exists = run_git_bool(cwd, &["rev-parse", "--is-inside-work-tree"]); - let git_dir_exists = run_git_capture_in(cwd, &["rev-parse", "--git-dir"]) - .map(|path| { - let path = PathBuf::from(path.trim()); - if path.is_absolute() { - path - } else { - cwd.join(path) - } - }) - .is_some_and(|path| path.exists()); - let trusted_roots = runtime_config - .map(runtime::RuntimeConfig::trusted_roots) - .unwrap_or(&[]); - let trust_gate_allowed = runtime_config.map(|_| { - trusted_roots - .iter() - .any(|root| path_matches_trusted_root_local(cwd, root)) - }); - let plugin_configured = runtime_config - .map(|config| config.plugins().enabled_plugins().len()) - .unwrap_or_default(); - let mcp_configured = runtime_config - .map(|config| config.mcp().servers().len()) - .unwrap_or_default(); - let config_ok = config_load_error.is_none(); - BootPreflightSnapshot { - repo_exists: project_root.is_some_and(Path::exists), - worktree_exists, - git_dir_exists, - branch_freshness, - trust_gate_allowed, - trusted_roots_count: trusted_roots.len(), - required_binaries: vec![ - BinaryPreflight { - name: "claw", - available: env::current_exe().is_ok_and(|path| path.exists()), - }, - BinaryPreflight { - name: "git", - available: command_available("git"), - }, - BinaryPreflight { - name: "tmux", - available: command_available("tmux"), - }, - ], - control_sockets: vec![tmux_control_socket_preflight()], - mcp_startup_eligible: config_ok, - mcp_servers_configured: mcp_configured, - plugin_startup_eligible: config_ok, - plugins_configured: plugin_configured, - last_failed_boot_reason: last_failed_boot_reason(cwd), - } -} - -fn run_git_bool(cwd: &Path, args: &[&str]) -> bool { - Command::new("git") - .args(args) - .current_dir(cwd) - .output() - .is_ok_and(|output| output.status.success()) -} - -fn command_available(command: &str) -> bool { - Command::new(command) - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()) -} - -fn tmux_control_socket_preflight() -> ControlSocketPreflight { - let path = env::var("TMUX") - .ok() - .and_then(|value| value.split(',').next().map(str::to_string)) - .filter(|value| !value.is_empty()); - let exists = path.as_ref().is_some_and(|path| Path::new(path).exists()); - ControlSocketPreflight { - name: "tmux", - configured: path.is_some(), - exists, - path, - } -} - -fn last_failed_boot_reason(cwd: &Path) -> Option { - env::var("CLAW_LAST_FAILED_BOOT_REASON") - .ok() - .filter(|value| !value.trim().is_empty()) - .or_else(|| { - fs::read_to_string(cwd.join(".claw").join("last-failed-boot.txt")) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) -} - -fn path_matches_trusted_root_local(cwd: &Path, trusted_root: &str) -> bool { - let cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); - let trusted_root = Path::new(trusted_root); - let trusted_root = if trusted_root.is_absolute() { - trusted_root.to_path_buf() - } else { - cwd.join(trusted_root) - }; - let trusted_root = fs::canonicalize(&trusted_root).unwrap_or(trusted_root); - cwd == trusted_root || cwd.starts_with(trusted_root) -} - fn resolve_git_branch_for(cwd: &Path) -> Option { let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; let branch = branch.trim(); @@ -3880,35 +3319,6 @@ fn run_resume_command( session: &Session, command: &SlashCommand, ) -> Result> { - let session_list_outcome = || -> Result> { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let session_details: Vec = sessions - .iter() - .map(|session| { - serde_json::json!({ - "id": session.id, - "path": session.path.display().to_string(), - "message_count": session.message_count, - "updated_at_ms": session.updated_at_ms, - "lifecycle": session.lifecycle.json_value(), - }) - }) - .collect(); - let active_id = session.session_id.clone(); - let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(text), - json: Some(serde_json::json!({ - "kind": "session_list", - "sessions": session_ids, - "session_details": session_details, - "active": active_id, - })), - }) - }; - match command { SlashCommand::Help => Ok(ResumeCommandOutcome { session: session.clone(), @@ -3916,13 +3326,12 @@ fn run_resume_command( json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), }), SlashCommand::Compact => { - let result = runtime::trident::trident_compact_session( + let result = runtime::compact_session( session, CompactionConfig { max_estimated_tokens: 0, ..CompactionConfig::default() }, - &runtime::trident::TridentConfig::default(), ); let removed = result.removed_message_count; let kept = result.compacted_session.messages.len(); @@ -4033,8 +3442,6 @@ fn run_resume_command( "cache_creation_input_tokens": usage.cache_creation_input_tokens, "cache_read_input_tokens": usage.cache_read_input_tokens, "total_tokens": usage.total_tokens(), - "estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), - "pricing": "estimated-default", })), }) } @@ -4116,10 +3523,10 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), - json: Some( - serde_json::to_value(handle_agents_slash_command_json(args.as_deref(), &cwd)?) - .unwrap_or(Value::Null), - ), + json: Some(serde_json::json!({ + "kind": "agents", + "text": handle_agents_slash_command(args.as_deref(), &cwd)?, + })), }) } SlashCommand::Skills { args } => { @@ -4135,37 +3542,6 @@ fn run_resume_command( json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), }) } - SlashCommand::Plugins { action, target } => { - // Only list is supported in resume mode (no runtime to reload) - match action.as_deref() { - Some("install") | Some("uninstall") | Some("enable") | Some("disable") - | Some("update") => { - return Err( - "resumed /plugins mutations are interactive-only; start `claw` and run `/plugins` in the REPL".into(), - ); - } - _ => {} - } - let cwd = env::current_dir()?; - let payload = plugins_command_payload_for(&cwd, action.as_deref(), target.as_deref())?; - let action_str = action.as_deref().unwrap_or("list"); - let json = serde_json::json!({ - "kind": "plugin", - "action": action_str, - "target": target, - "status": payload.status, - "config_load_error": payload.config_load_error, - "message": &payload.message, - "reload_runtime": payload.reload_runtime, - "plugins": payload.plugins, - "load_failures": payload.load_failures, - }); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(payload.message), - json: Some(json), - }) - } SlashCommand::Doctor => { let report = render_doctor_report()?; Ok(ResumeCommandOutcome { @@ -4186,8 +3562,6 @@ fn run_resume_command( "cache_creation_input_tokens": usage.cache_creation_input_tokens, "cache_read_input_tokens": usage.cache_read_input_tokens, "total_tokens": usage.total_tokens(), - "estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), - "pricing": "estimated-default", })), }) } @@ -4211,11 +3585,37 @@ fn run_resume_command( }) } SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), - // /session list/exists/delete can be served from the managed sessions directory - // in resume mode without starting an interactive REPL. Mutating delete remains - // opt-in through /session delete --force so JSON callers never hang on a prompt. - SlashCommand::Session { action, target } => { - run_resumed_session_command(session_path, session, action.as_deref(), target.as_deref()) + // /session list can be served from the sessions directory without a live session. + SlashCommand::Session { + action: Some(ref act), + .. + } if act == "list" => { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let session_details: Vec = sessions + .iter() + .map(|session| { + serde_json::json!({ + "id": session.id, + "path": session.path.display().to_string(), + "message_count": session.message_count, + "updated_at_ms": session.updated_at_ms, + "lifecycle": session.lifecycle.json_value(), + }) + }) + .collect(); + let active_id = session.session_id.clone(); + let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(text), + json: Some(serde_json::json!({ + "kind": "session_list", + "sessions": session_ids, + "session_details": session_details, + "active": active_id, + })), + }) } SlashCommand::Bughunter { .. } | SlashCommand::Commit { .. } @@ -4227,6 +3627,8 @@ fn run_resume_command( | SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } | SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim @@ -4264,8 +3666,7 @@ fn run_resume_command( | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } - | SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), } } @@ -4348,30 +3749,12 @@ fn enforce_broad_cwd_policy( } } -fn stale_base_state_for(cwd: &Path, flag_value: Option<&str>) -> BaseCommitState { - let source = resolve_expected_base(flag_value, cwd); - check_base_commit(cwd, source.as_ref()) -} - -fn stale_base_json_value(state: &BaseCommitState) -> serde_json::Value { - match state { - BaseCommitState::Matches => json!({"status": "matches", "fresh": true}), - BaseCommitState::Diverged { expected, actual } => json!({ - "status": "diverged", - "fresh": false, - "expected": expected, - "actual": actual, - }), - BaseCommitState::NoExpectedBase => json!({"status": "no_expected_base", "fresh": null}), - BaseCommitState::NotAGitRepo => json!({"status": "not_git_repo", "fresh": null}), - } -} - fn run_stale_base_preflight(flag_value: Option<&str>) { let Ok(cwd) = env::current_dir() else { return; }; - let state = stale_base_state_for(&cwd, flag_value); + let source = resolve_expected_base(flag_value, &cwd); + let state = check_base_commit(&cwd, source.as_ref()); if let Some(warning) = format_stale_base_warning(&state) { eprintln!("{warning}"); } @@ -4644,10 +4027,7 @@ impl RuntimeMcpState { runtime::McpLifecyclePhase::ToolDiscovery, Some(failure.server_name.clone()), failure.error.clone(), - std::collections::BTreeMap::from([( - "required".to_string(), - failure.required.to_string(), - )]), + std::collections::BTreeMap::new(), true, ), }) @@ -4659,13 +4039,10 @@ impl RuntimeMcpState { runtime::McpLifecyclePhase::ServerRegistration, Some(server.server_name.clone()), server.reason.clone(), - std::collections::BTreeMap::from([ - ( - "transport".to_string(), - format!("{:?}", server.transport).to_ascii_lowercase(), - ), - ("required".to_string(), server.required.to_string()), - ]), + std::collections::BTreeMap::from([( + "transport".to_string(), + format!("{:?}", server.transport).to_ascii_lowercase(), + )]), false, ), } @@ -4964,7 +4341,7 @@ impl LiveCli { allowed_tools: Option, permission_mode: PermissionMode, ) -> Result> { - let system_prompt = build_system_prompt(&model)?; + let system_prompt = build_system_prompt()?; let session_state = new_cli_session()?; let session = create_managed_session_handle(&session_state.session_id)?; let runtime = build_runtime( @@ -5100,10 +4477,6 @@ impl LiveCli { TerminalRenderer::new().color_theme(), &mut stdout, )?; - let final_text = final_assistant_text(&summary); - if !final_text.is_empty() { - println!("{final_text}"); - } println!(); if let Some(event) = summary.auto_compaction { println!( @@ -5121,135 +4494,6 @@ impl LiveCli { TerminalRenderer::new().color_theme(), &mut stdout, )?; - - // ============================================================================ - // Auto-compact retry on context window errors - // ============================================================================ - // When the model API returns a context_window_blocked error (because the request - // exceeds the model's context window), we automatically: - // 1. Compact the session (remove old messages to free up space) - // 2. Retry the original request with the compacted session - // 3. Report results to the user - // - // This eliminates the need for users to manually run /compact when they - // hit context limits - the recovery happens automatically. - // - // Detection: We look for "context_window" or "Context window" in the error - // message, which covers error types like: - // - "context_window_blocked" - // - "Context window blocked" - // - "This model's maximum context length is X tokens..." - // ============================================================================ - - let error_str = error.to_string(); - // Detect context window overflow. Some providers (e.g. OpenAI-compat backends) - // return 400 with "no parseable body" instead of a proper context_length_exceeded - // error when the request is too large to even parse — treat that as context overflow too. - let is_context_window = error_str.contains("context_window") - || error_str.contains("Context window") - || error_str.contains("no parseable body"); - - if is_context_window { - // A single compaction pass may not free enough context space. - // Progressive retry: each round preserves fewer recent messages (4→2→1→0), - // trading conversation continuity for a smaller payload until it fits. - // Max 4 rounds before giving up and surfacing the error to the user. - let max_compact_rounds = 4; - let preserve_schedule = [4, 2, 1, 0]; - - for round in 0..max_compact_rounds { - let preserve = preserve_schedule[round]; - println!( - " Auto-compacting session (round {}/{}, preserving {} recent messages)...", - round + 1, - max_compact_rounds, - preserve - ); - - // Run Trident pipeline then summary-based compaction - let result = runtime::trident::trident_compact_session( - runtime.session(), - CompactionConfig { - preserve_recent_messages: preserve, - max_estimated_tokens: 0, - }, - &runtime::trident::TridentConfig::default(), - ); - let removed = result.removed_message_count; - - if removed == 0 && round > 0 { - // No more messages to compact — further rounds won't help - println!(" No further compaction possible."); - break; - } - - if removed > 0 { - println!( - "{}", - format_compact_report( - removed, - result.compacted_session.messages.len(), - false - ) - ); - } - - // Without this, prepare_turn_runtime() reads from self.runtime.session() - // which still holds the ORIGINAL un-compacted session, so every retry round - // would send the same bloated request — compaction was wasted. - *self.runtime.session_mut() = result.compacted_session.clone(); - - // Build a new runtime with the compacted session and retry - let (mut new_runtime, hook_abort_monitor) = - self.prepare_turn_runtime(true)?; - drop(hook_abort_monitor); - - let mut rp = CliPermissionPrompter::new(self.permission_mode); - match new_runtime.run_turn(input, Some(&mut rp)) { - Ok(summary) => { - self.replace_runtime(new_runtime)?; - spinner.finish( - if round == 0 { - "✨ Done (after auto-compact)" - } else { - "✨ Done (after aggressive auto-compact)" - }, - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - println!(); - if let Some(event) = summary.auto_compaction { - println!( - "{}", - format_auto_compaction_notice(event.removed_message_count) - ); - } - self.persist_session()?; - return Ok(()); - } - Err(retry_error) => { - let retry_str = retry_error.to_string(); - let still_context_window = retry_str.contains("context_window") - || retry_str.contains("Context window") - || retry_str.contains("no parseable body"); - - if still_context_window && round + 1 < max_compact_rounds { - // The compacted session was still too large for the model's context. - // Shut down the old runtime, adopt the partially-compacted one, - // and loop — the next round will compact more aggressively. - runtime.shutdown_plugins()?; - runtime = new_runtime; - continue; - } - - // Not a context window error, or out of rounds - return Err(Box::new(retry_error)); - } - } - } - } - - // If not a context window error, return original error Err(Box::new(error)) } } @@ -5444,20 +4688,14 @@ impl LiveCli { self.handle_plugins_command(action.as_deref(), target.as_deref())? } SlashCommand::Agents { args } => { - if let Err(error) = Self::print_agents(args.as_deref(), CliOutputFormat::Text) { - eprintln!("{error}"); - } + Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; false } SlashCommand::Skills { args } => { match classify_skills_slash_command(args.as_deref()) { SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, SkillSlashDispatch::Local => { - if let Err(error) = - Self::print_skills(args.as_deref(), CliOutputFormat::Text) - { - eprintln!("{error}"); - } + Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; } } false @@ -5512,8 +4750,7 @@ impl LiveCli { | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } - | SlashCommand::Team { .. } => { + | SlashCommand::AddDir { .. } => { let cmd_name = command.slash_name(); eprintln!("{cmd_name} is not yet implemented in this build."); false @@ -5825,17 +5062,10 @@ impl LiveCli { let cwd = env::current_dir()?; match output_format { CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)?), - CliOutputFormat::Json => { - let value = handle_mcp_slash_command_json(args, &cwd)?; - // Propagate ok:false → non-zero exit so automation callers - // can rely on exit code instead of inspecting the envelope. - // (#68: mcp error envelopes previously always exited 0.) - let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false); - println!("{}", serde_json::to_string_pretty(&value)?); - if is_error { - std::process::exit(1); - } - } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd)?)? + ), } Ok(()) } @@ -5861,21 +5091,20 @@ impl LiveCli { output_format: CliOutputFormat, ) -> Result<(), Box> { let cwd = env::current_dir()?; - let payload = plugins_command_payload_for(&cwd, action, target)?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; match output_format { - CliOutputFormat::Text => println!("{}", payload.message), + CliOutputFormat::Text => println!("{}", result.message), CliOutputFormat::Json => println!( "{}", serde_json::to_string_pretty(&json!({ "kind": "plugin", "action": action.unwrap_or("list"), "target": target, - "status": payload.status, - "config_load_error": payload.config_load_error, - "message": payload.message, - "reload_runtime": payload.reload_runtime, - "plugins": payload.plugins, - "load_failures": payload.load_failures, + "message": result.message, + "reload_runtime": result.reload_runtime, }))? ), } @@ -5916,22 +5145,6 @@ impl LiveCli { println!("{}", render_session_list(&self.session.id)?); Ok(false) } - Some("exists") => { - let Some(target) = target else { - println!("Usage: /session exists "); - return Ok(false); - }; - let exists = session_reference_exists(target)?; - let handle = resolve_session_reference(target).ok(); - println!( - "Session exists\n Session {target}\n Exists {exists}{}", - handle - .as_ref() - .map(|handle| format!("\n File {}", handle.path.display())) - .unwrap_or_default() - ); - Ok(false) - } Some("switch") => { let Some(target) = target else { println!("Usage: /session switch "); @@ -6046,7 +5259,7 @@ impl LiveCli { } Some(other) => { println!( - "Unknown /session action '{other}'. Use /session list, /session exists , /session switch , /session fork [branch-name], or /session delete [--force]." + "Unknown /session action '{other}'. Use /session list, /session switch , /session fork [branch-name], or /session delete [--force]." ); Ok(false) } @@ -6059,9 +5272,12 @@ impl LiveCli { target: Option<&str>, ) -> Result> { let cwd = env::current_dir()?; - let payload = plugins_command_payload_for(&cwd, action, target)?; - println!("{}", payload.message); - if payload.reload_runtime { + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + println!("{}", result.message); + if result.reload_runtime { self.reload_runtime_features()?; } Ok(false) @@ -6227,10 +5443,6 @@ fn resolve_session_reference(reference: &str) -> Result Result> { - Ok(current_session_store()?.session_exists(reference)) -} - fn resolve_managed_session_path(session_id: &str) -> Result> { current_session_store()? .resolve_managed_path(session_id) @@ -6278,16 +5490,9 @@ fn latest_managed_session() -> Result Result<(SessionHandle, Session), Box> { - let store = current_session_store()?; - // For alias references ("latest", "last", "recent"), allow cross-workspace - // resume so /resume latest finds the most recent session globally. - // For explicit references, workspace validation is enforced. - let result = if runtime::session_control::is_session_reference_alias(reference) { - store.load_session_loose(reference) - } else { - store.load_session(reference) - }; - let loaded = result.map_err(|e| Box::new(e) as Box)?; + let loaded = current_session_store()? + .load_session(reference) + .map_err(|e| Box::new(e) as Box)?; Ok(( SessionHandle { id: loaded.handle.id, @@ -6315,140 +5520,6 @@ fn confirm_session_deletion(session_id: &str) -> bool { matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") } -fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec { - sessions - .iter() - .map(|session| { - serde_json::json!({ - "id": session.id, - "path": session.path.display().to_string(), - "message_count": session.message_count, - "updated_at_ms": session.updated_at_ms, - "modified_epoch_millis": session.modified_epoch_millis, - "parent_session_id": session.parent_session_id, - "branch_name": session.branch_name, - "lifecycle": session.lifecycle.json_value(), - }) - }) - .collect() -} - -fn session_exists_json( - target: &str, - active_session_id: &str, -) -> Result> { - let handle = create_managed_session_handle(target)?; - let resolved = resolve_session_reference(target).ok(); - let exists = resolved.is_some(); - let resolved_id = resolved - .as_ref() - .map_or(target, |handle| handle.id.as_str()); - Ok(serde_json::json!({ - "kind": "session_exists", - "session_id": resolved_id, - "session": target, - "requested": target, - "exists": exists, - "active": resolved_id == active_session_id, - "path": resolved - .as_ref() - .map(|handle| handle.path.display().to_string()), - "candidate_path": handle.path.display().to_string(), - })) -} - -fn run_resumed_session_command( - session_path: &Path, - session: &Session, - action: Option<&str>, - target: Option<&str>, -) -> Result> { - match action { - None | Some("list") => { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let active_id = session.session_id.clone(); - let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(text), - json: Some(serde_json::json!({ - "kind": "session_list", - "sessions": session_ids, - "session_details": session_details_json(&sessions), - "active": active_id, - })), - }) - } - Some("exists") => { - let Some(target) = target else { - return Err("/session exists requires a session id".into()); - }; - let value = session_exists_json(target, &session.session_id)?; - let exists = value - .get("exists") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Session exists\n Session {}\n Exists {}", - target, - if exists { "yes" } else { "no" } - )), - json: Some(value), - }) - } - Some("delete") => { - let Some(target) = target else { - return Err("/session delete requires a session id".into()); - }; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "delete: confirmation required; rerun with /session delete {target} --force" - )), - json: Some(serde_json::json!({ - "kind": "error", - "error": "confirmation required", - "hint": format!("rerun with /session delete {target} --force"), - "session_id": target, - })), - }) - } - Some("delete-force") => { - let Some(target) = target else { - return Err("/session delete requires a session id".into()); - }; - let handle = resolve_session_reference(target)?; - if handle.id == session.session_id || handle.path == session_path { - return Err(format!( - "delete: refusing to delete the active session '{}'. Resume or switch to another session first.", - handle.id - ) - .into()); - } - delete_managed_session(&handle.path)?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - )), - json: Some(serde_json::json!({ - "kind": "session_delete", - "deleted": true, - "session_id": handle.id, - "path": handle.path.display().to_string(), - })), - }) - } - Some("switch" | "fork") => Err("unsupported resumed slash command".into()), - Some(other) => Err(format!("unsupported resumed /session action: {other}").into()), - } -} - fn render_session_list(active_session_id: &str) -> Result> { let sessions = list_managed_sessions()?; let mut lines = vec![ @@ -6538,8 +5609,7 @@ fn render_repl_help() -> String { " Tab Complete commands, modes, and recent sessions".to_string(), " Ctrl-C Clear input (or exit on empty prompt)".to_string(), " Shift+Enter/Ctrl+J Insert a newline".to_string(), - " Auto-save .claw/sessions//.jsonl" - .to_string(), + " Auto-save .claw/sessions/.jsonl".to_string(), " Resume latest /resume latest".to_string(), " Browse sessions /session list".to_string(), " Show prompt history /history [count]".to_string(), @@ -6622,8 +5692,6 @@ fn status_json_value( // are still populated). `config_load_error` carries the parse-error string // when present; it's a string rather than a typed object in Phase 1 and // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). - // `config_load_error_kind` is the machine-readable kind token derived from - // `classify_error_kind` so downstream claws can switch on it directly. let degraded = context.config_load_error.is_some(); let model_source = provenance.map(|p| p.source.as_str()); let model_raw = provenance.and_then(|p| p.raw.clone()); @@ -6632,7 +5700,6 @@ fn status_json_value( "kind": "status", "status": if degraded { "degraded" } else { "ok" }, "config_load_error": context.config_load_error, - "config_load_error_kind": context.config_load_error_kind, "model": model, "model_source": model_source, "model_raw": model_raw, @@ -6645,27 +5712,12 @@ fn status_json_value( "usage": { "messages": usage.message_count, "turns": usage.turns, - "latest_input": usage.latest.input_tokens, - "latest_output": usage.latest.output_tokens, - "latest_cache_creation_input": usage.latest.cache_creation_input_tokens, - "latest_cache_read_input": usage.latest.cache_read_input_tokens, "latest_total": usage.latest.total_tokens(), "cumulative_input": usage.cumulative.input_tokens, "cumulative_output": usage.cumulative.output_tokens, - "cumulative_cache_creation_input": usage.cumulative.cache_creation_input_tokens, - "cumulative_cache_read_input": usage.cumulative.cache_read_input_tokens, "cumulative_total": usage.cumulative.total_tokens(), - "estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), - "pricing": "estimated-default", "estimated_tokens": usage.estimated_tokens, }, - "lane_board": { - "schema": "task_registry_v1", - "status_json_supported": true, - "heartbeat_freshness_supported": true, - "states": ["active", "blocked", "finished"], - "freshness_states": ["healthy", "stalled", "transport_dead", "unknown"], - }, "workspace": { "cwd": context.cwd, "project_root": context.project_root, @@ -6682,8 +5734,6 @@ fn status_json_value( path.file_stem().map(|n| n.to_string_lossy().into_owned()) }), "session_lifecycle": context.session_lifecycle.json_value(), - "branch_freshness": context.branch_freshness.json_value(), - "boot_preflight": context.boot_preflight.json_value(), "loaded_config_files": context.loaded_config_files, "discovered_config_files": context.discovered_config_files, "memory_file_count": context.memory_file_count, @@ -6717,44 +5767,27 @@ fn status_context( // so that one malformed `mcpServers.*` entry doesn't take down the whole // health surface (workspace, git, model, permission, sandbox can still be // reported independently). - let runtime_config = loader.load(); - let (loaded_config_files, sandbox_status, config_load_error, config_load_error_kind) = - match runtime_config.as_ref() { - Ok(cfg) => ( - cfg.loaded_entries().len(), - resolve_sandbox_status(cfg.sandbox(), &cwd), - None, - None, - ), - Err(err) => { - let err_string = err.to_string(); - let err_kind = classify_error_kind(&err_string); - ( - 0, - // Fall back to defaults for sandbox resolution so claws still see - // a populated sandbox section instead of a missing field. Defaults - // produce the same output as a runtime config with no sandbox - // overrides, which is the right degraded-mode shape: we cannot - // report what the user *intended*, only what is actually in effect. - resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), - Some(err_string), - Some(err_kind), - ) - } - }; + let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() { + Ok(runtime_config) => ( + runtime_config.loaded_entries().len(), + resolve_sandbox_status(runtime_config.sandbox(), &cwd), + None, + ), + Err(err) => ( + 0, + // Fall back to defaults for sandbox resolution so claws still see + // a populated sandbox section instead of a missing field. Defaults + // produce the same output as a runtime config with no sandbox + // overrides, which is the right degraded-mode shape: we cannot + // report what the user *intended*, only what is actually in effect. + resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), + Some(err.to_string()), + ), + }; 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 branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref()); - let stale_base_state = stale_base_state_for(&cwd, None); - let boot_preflight = build_boot_preflight_snapshot( - &cwd, - project_root.as_deref(), - project_context.git_status.as_deref(), - runtime_config.as_ref().ok(), - config_load_error.as_deref(), - ); Ok(StatusContext { cwd: cwd.clone(), session_path: session_path.map(Path::to_path_buf), @@ -6764,13 +5797,9 @@ fn status_context( project_root, git_branch, git_summary, - branch_freshness, - stale_base_state, session_lifecycle: classify_session_lifecycle_for(&cwd), - boot_preflight, sandbox_status, config_load_error, - config_load_error_kind, }) } @@ -6825,17 +5854,11 @@ fn format_status_report( Latest total {} Cumulative input {} Cumulative output {} - Cache create {} - Cache read {} - Cumulative total {} - Estimated cost {}", + Cumulative total {}", usage.latest.total_tokens(), usage.cumulative.input_tokens, usage.cumulative.output_tokens, - usage.cumulative.cache_creation_input_tokens, - usage.cumulative.cache_read_input_tokens, usage.cumulative.total_tokens(), - format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), ), format!( "Workspace @@ -6849,8 +5872,6 @@ fn format_status_report( Untracked {} Session {} Lifecycle {} - Branch fresh {} - Boot preflight {} Config files loaded {}/{} Memory files {} Suggested flow /status → /diff → /commit", @@ -6870,12 +5891,6 @@ fn format_status_report( |path| path.display().to_string() ), context.session_lifecycle.signal(), - context - .branch_freshness - .fresh - .map(|fresh| if fresh { "yes" } else { "behind" }) - .unwrap_or("no upstream"), - context.boot_preflight.summary(), context.loaded_config_files, context.discovered_config_files, context.memory_file_count, @@ -7075,144 +6090,38 @@ fn render_help_topic(topic: LocalHelpTopic) -> String { } } -fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { - match topic { - LocalHelpTopic::Status => "status", - LocalHelpTopic::Sandbox => "sandbox", - LocalHelpTopic::Doctor => "doctor", - LocalHelpTopic::Acp => "acp", - LocalHelpTopic::Init => "init", - LocalHelpTopic::State => "state", - LocalHelpTopic::Export => "export", - LocalHelpTopic::Version => "version", - LocalHelpTopic::SystemPrompt => "system-prompt", - LocalHelpTopic::DumpManifests => "dump-manifests", - LocalHelpTopic::BootstrapPlan => "bootstrap-plan", - } -} - -fn render_export_help_json() -> serde_json::Value { - json!({ - "kind": "help", - "topic": "export", - "command": "export", - "usage": "claw export [--session ] [--output ] [--output-format ]", - "purpose": "serialize a managed session to JSON for review, transfer, or archival", - "defaults": { - "session": LATEST_SESSION_REFERENCE, - "session_source": ".claw/sessions/", - "output": "derived from the selected session when omitted" - }, - "formats": ["text", "json"], - "options": [ - { - "name": "--session", - "value": "", - "default": LATEST_SESSION_REFERENCE, - "description": "managed session to export" - }, - { - "name": "--output", - "aliases": ["-o"], - "value": "", - "description": "write the exported transcript to this path" - }, - { - "name": "--output-format", - "value": "", - "values": ["text", "json"], - "default": "text", - "description": "format for the command result envelope" - }, - { - "name": "--help", - "aliases": ["-h"], - "description": "show help for the export command" - } - ], - "related": ["/session list", "claw --resume latest"] - }) -} - -fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value { - if topic == LocalHelpTopic::Export { - return render_export_help_json(); - } - - json!({ - "kind": "help", - "topic": local_help_topic_command(topic), - "command": local_help_topic_command(topic), - "message": render_help_topic(topic), - }) -} - -fn print_help_topic( - topic: LocalHelpTopic, - output_format: CliOutputFormat, -) -> Result<(), Box> { - match output_format { - CliOutputFormat::Text => println!("{}", render_help_topic(topic)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&render_help_topic_json(topic))? - ), - } - Ok(()) -} - -fn acp_status_message() -> &'static str { - "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon, JSON-RPC endpoint, or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support." -} - -fn acp_status_json() -> serde_json::Value { - json!({ - "schema_version": "1.0", - "kind": "acp", - "status": "unsupported", - "phase": "discoverability_only", - "supported": false, - "exit_code": 0, - "serve_alias_only": true, - "message": acp_status_message(), - "launch_command": serde_json::Value::Null, - "protocol": { - "name": "ACP/Zed", - "json_rpc": false, - "daemon": false, - "endpoint": serde_json::Value::Null, - "serve_starts_daemon": false - }, - "contracts": { - "blocking_gates": [ - "task_packet_schema", - "session_control_schema", - "event_report_schema" - ], - "stable_status_surface": "claw acp [serve] --output-format json", - "unsupported_invocation_kind": "unsupported_acp_invocation" - }, - "aliases": ["acp", "--acp", "-acp"], - "discoverability_tracking": "ROADMAP #64a", - "tracking": "ROADMAP #76 / #3033 / #3004", - "recommended_workflows": [ - "claw prompt TEXT", - "claw", - "claw doctor" - ], - }) +fn print_help_topic(topic: LocalHelpTopic) { + println!("{}", render_help_topic(topic)); } fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { + let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; match output_format { CliOutputFormat::Text => { println!( - "ACP / Zed\n Status unsupported (discoverability only)\n Exit code 0 for status queries; unsupported invocations exit 1\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon or JSON-RPC endpoint is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76 / #3033 / #3004\n Message {}", - acp_status_message() + "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" ); } CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&acp_status_json())?); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "acp", + "status": "discoverability_only", + "supported": false, + "serve_alias_only": true, + "message": message, + "launch_command": serde_json::Value::Null, + "aliases": ["acp", "--acp", "-acp"], + "discoverability_tracking": "ROADMAP #64a", + "tracking": "ROADMAP #76", + "recommended_workflows": [ + "claw prompt TEXT", + "claw", + "claw doctor" + ], + }))? + ); } } Ok(()) @@ -7298,85 +6207,92 @@ fn render_config_report(section: Option<&str>) -> Result, + _section: Option<&str>, ) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; + let inspection = loader.inspect(); - let loaded_paths: Vec<_> = runtime_config - .loaded_entries() - .iter() - .map(|e| e.path.display().to_string()) - .collect(); + let (loaded_files, merged_key_count) = + inspection + .runtime_config + .as_ref() + .map_or((0, 0), |runtime_config| { + ( + runtime_config.loaded_entries().len(), + runtime_config.merged().len(), + ) + }); - let files: Vec<_> = discovered + let files: Vec<_> = inspection + .files .iter() - .map(|e| { - let source = match e.source { + .map(|file| { + let source = match file.entry.source { ConfigSource::User => "user", ConfigSource::Project => "project", ConfigSource::Local => "local", }; - 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": is_loaded, - }) + let mut object = serde_json::Map::new(); + object.insert( + "path".to_string(), + serde_json::Value::String(file.entry.path.display().to_string()), + ); + object.insert( + "source".to_string(), + serde_json::Value::String(source.to_string()), + ); + object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded)); + object.insert( + "status".to_string(), + serde_json::Value::String(file.status.as_str().to_string()), + ); + if let Some(reason) = &file.reason { + object.insert( + "reason".to_string(), + serde_json::Value::String(reason.clone()), + ); + } + if let Some(reason) = &file.reason { + object.insert( + "skip_reason".to_string(), + serde_json::Value::String(reason.clone()), + ); + } + if let Some(detail) = &file.detail { + object.insert( + "detail".to_string(), + serde_json::Value::String(detail.clone()), + ); + } + serde_json::Value::Object(object) }) .collect(); - let base = serde_json::json!({ + let status = if inspection.load_error.is_some() { + "error" + } else { + "ok" + }; + + let mut value = serde_json::json!({ "kind": "config", + "status": status, "cwd": cwd.display().to_string(), - "loaded_files": loaded_paths.len(), - "merged_keys": runtime_config.merged().len(), + "loaded_files": loaded_files, + "merged_keys": merged_key_count, + "merged_key_count": merged_key_count, + "merged_keys_meaning": "count of top-level keys in the effective merged JSON object", "files": files, }); - if let Some(section) = section { - let section_rendered: Option = match section { - "env" => runtime_config.get("env").map(|v| v.render()), - "hooks" => runtime_config.get("hooks").map(|v| v.render()), - "model" => runtime_config.get("model").map(|v| v.render()), - "plugins" => runtime_config - .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")) - .map(|v| v.render()), - other => { - return Ok(serde_json::json!({ - "kind": "config", - "section": other, - "ok": false, - "error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."), - "cwd": cwd.display().to_string(), - "loaded_files": loaded_paths.len(), - "files": files, - })); - } - }; - // Parse the rendered JSON string back into serde_json::Value so that - // section_value is a real JSON object/array in the envelope, not a quoted string. - let section_value: serde_json::Value = section_rendered - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(serde_json::Value::Null); - let mut obj = base; - let map = obj.as_object_mut().expect("base is object"); - map.insert( - "section".to_string(), - serde_json::Value::String(section.to_string()), - ); - map.insert("section_value".to_string(), section_value); - return Ok(obj); + if let Some(error) = inspection.load_error { + if let Some(object) = value.as_object_mut() { + object.insert("load_error".to_string(), serde_json::Value::String(error)); + } } - Ok(base) + Ok(value) } fn render_memory_report() -> Result> { @@ -7954,7 +6870,6 @@ fn render_export_text(session: &Session) -> String { for block in &message.blocks { match block { ContentBlock::Text { text } => lines.push(text.clone()), - ContentBlock::Thinking { .. } => {} ContentBlock::ToolUse { id, name, input } => { lines.push(format!("[tool_use id={id} name={name}] {input}")); } @@ -8141,7 +7056,6 @@ fn render_session_markdown(session: &Session, session_id: &str, session_path: &P lines.push(String::new()); } } - ContentBlock::Thinking { .. } => {} ContentBlock::ToolUse { id, name, input } => { lines.push(format!( "**Tool call** `{name}` _(id `{}`)_", @@ -8195,73 +7109,15 @@ fn short_tool_id(id: &str) -> String { format!("{prefix}…") } -fn build_system_prompt(model: &str) -> Result, Box> { +fn build_system_prompt() -> Result, Box> { Ok(load_system_prompt( env::current_dir()?, DEFAULT_DATE, env::consts::OS, "unknown", - model_family_identity_for(model), )?) } -struct PluginsCommandPayload { - message: String, - reload_runtime: bool, - status: &'static str, - config_load_error: Option, - plugins: Vec, - load_failures: Vec, -} - -fn plugins_command_payload_for( - cwd: &Path, - action: Option<&str>, - target: Option<&str>, -) -> Result> { - let loader = ConfigLoader::default_for(cwd); - let (runtime_config, config_load_error) = match loader.load() { - Ok(runtime_config) => (runtime_config, None), - Err(error) => (runtime::RuntimeConfig::empty(), Some(error.to_string())), - }; - let mut manager = build_plugin_manager(cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - let report = manager.installed_plugin_registry_report()?; - Ok(plugins_command_payload_from_result( - result, - config_load_error, - &report, - )) -} - -fn plugins_command_payload_from_result( - result: PluginsCommandResult, - config_load_error: Option, - report: &plugins::PluginRegistryReport, -) -> PluginsCommandPayload { - let failures = report.failures(); - let status = if config_load_error.is_some() || !failures.is_empty() { - "degraded" - } else { - "ok" - }; - let message = match config_load_error.as_deref() { - Some(error) => format!( - "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial plugins view\n Details {error}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}", - result.message - ), - None => result.message, - }; - PluginsCommandPayload { - message, - reload_runtime: result.reload_runtime, - status, - config_load_error, - plugins: report.summaries().iter().map(plugin_summary_json).collect(), - load_failures: failures.iter().map(plugin_load_failure_json).collect(), - } -} - fn build_runtime_plugin_state() -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); @@ -8923,7 +7779,6 @@ fn resolve_cli_auth_source() -> Result> { Ok(resolve_cli_auth_source_for_cwd()?) } -#[allow(clippy::result_large_err)] fn resolve_cli_auth_source_for_cwd() -> Result { resolve_startup_auth_source(|| Ok(None)) } @@ -9005,8 +7860,6 @@ impl AnthropicRuntimeClient { let mut markdown_stream = MarkdownStreamState::default(); let mut events = Vec::new(); let mut pending_tool: Option<(String, String, String)> = None; - // 累积 reasoning_content 到 Thinking 块(修复 DeepSeek V4 reasoning_content 协议 bug) - let mut pending_thinking: Option<(String, Option)> = None; let mut block_has_thinking_summary = false; let mut saw_stop = false; let mut received_any_event = false; @@ -9048,14 +7901,6 @@ impl AnthropicRuntimeClient { } } ApiStreamEvent::ContentBlockStart(start) => { - // 特判 Thinking 块:初始化 pending_thinking(用于累积后续 ThinkingDelta) - if let OutputContentBlock::Thinking { - thinking, - signature, - } = &start.content_block - { - pending_thinking = Some((thinking.clone(), signature.clone())); - } push_output_block( start.content_block, out, @@ -9084,22 +7929,13 @@ impl AnthropicRuntimeClient { input.push_str(&partial_json); } } - ContentBlockDelta::ThinkingDelta { thinking } => { + ContentBlockDelta::ThinkingDelta { .. } => { if !block_has_thinking_summary { render_thinking_block_summary(out, None, false)?; block_has_thinking_summary = true; } - // 累积 thinking 文本到 pending_thinking(让 session 持久化能拿到) - if let Some((t, _)) = &mut pending_thinking { - t.push_str(&thinking); - } - } - ContentBlockDelta::SignatureDelta { signature } => { - // 累积 signature 到 pending_thinking - if let Some((_, sig)) = &mut pending_thinking { - sig.get_or_insert_with(String::new).push_str(&signature); - } } + ContentBlockDelta::SignatureDelta { .. } => {} }, ApiStreamEvent::ContentBlockStop(_) => { block_has_thinking_summary = false; @@ -9108,13 +7944,6 @@ impl AnthropicRuntimeClient { .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; } - // 把累积的 thinking 转成 AssistantEvent::Thinking(让 build_assistant_message 写入 session) - if let Some((thinking, signature)) = pending_thinking.take() { - events.push(AssistantEvent::Thinking { - thinking, - signature, - }); - } if let Some((id, name, input)) = pending_tool.take() { if let Some(progress_reporter) = &self.progress_reporter { progress_reporter.mark_tool_phase(&name, &input); @@ -10247,39 +9076,26 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { let content = message .blocks .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => { - Some(InputContentBlock::Text { text: text.clone() }) - } - ContentBlock::Thinking { - thinking, - signature, - } => { - // 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段 - // 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误) - Some(InputContentBlock::Thinking { - thinking: thinking.clone(), - signature: signature.clone(), - }) - } - ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { + .map(|block| match block { + ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, + ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }), + }, ContentBlock::ToolResult { tool_use_id, output, is_error, .. - } => Some(InputContentBlock::ToolResult { + } => InputContentBlock::ToolResult { tool_use_id: tool_use_id.clone(), content: vec![ToolResultContentBlock::Text { text: output.clone(), }], is_error: *is_error, - }), + }, }) .collect::>(); (!content.is_empty()).then(|| InputMessage { @@ -10465,7 +9281,7 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box { - assert_eq!(model, "openai/gpt-4.1-mini"); - } - other => panic!("expected PrintSystemPrompt, got {other:?}"), - } - } - #[test] fn removed_login_and_logout_subcommands_error_helpfully() { let login = parse_args(&["login".to_string()]).expect_err("login should be removed"); @@ -11492,41 +10242,6 @@ mod tests { output_format: CliOutputFormat::Json, } ); - for alias in ["plugin", "marketplace"] { - assert_eq!( - parse_args(&[alias.to_string()]).expect("plugin alias should parse"), - CliAction::Plugins { - action: None, - target: None, - output_format: CliOutputFormat::Text, - }, - "{alias} should route to local plugin handling, not Prompt" - ); - assert_eq!( - parse_args(&[alias.to_string(), "list".to_string()]) - .expect("plugin alias list should parse"), - CliAction::Plugins { - action: Some("list".to_string()), - target: None, - output_format: CliOutputFormat::Text, - }, - "{alias} list should route to local plugin handling, not Prompt" - ); - assert_eq!( - parse_args(&[ - alias.to_string(), - "install".to_string(), - "./fixtures/plugin-demo".to_string(), - ]) - .expect("plugin alias install should parse"), - CliAction::Plugins { - action: Some("install".to_string()), - target: Some("./fixtures/plugin-demo".to_string()), - output_format: CliOutputFormat::Text, - }, - "{alias} install should route to local plugin handling, not Prompt" - ); - } // #146: `config` and `diff` must parse as standalone CLI actions, // not fall through to the "is a slash command" error. Both are // pure-local read-only introspection. @@ -11618,10 +10333,7 @@ mod tests { model_flag_raw, .. } => { - assert_eq!( - model, "anthropic/claude-sonnet-4-6", - "sonnet alias should resolve" - ); + assert_eq!(model, "claude-sonnet-4-6", "sonnet alias should resolve"); assert_eq!( model_flag_raw.as_deref(), Some("sonnet"), @@ -11706,41 +10418,6 @@ mod tests { output_format: CliOutputFormat::Text, } ); - assert_eq!( - parse_args(&[ - "acp".to_string(), - "serve".to_string(), - "--output-format".to_string(), - "json".to_string() - ]) - .expect("acp serve json should parse"), - CliAction::Acp { - output_format: CliOutputFormat::Json, - } - ); - let unsupported = parse_args(&["acp".to_string(), "start".to_string()]) - .expect_err("unknown ACP subcommand should fail with a typed contract"); - assert!(unsupported.contains("unsupported ACP invocation")); - } - - #[test] - fn acp_status_json_is_truthful_unsupported_contract() { - let value = acp_status_json(); - assert_eq!(value["schema_version"], "1.0"); - assert_eq!(value["kind"], "acp"); - assert_eq!(value["status"], "unsupported"); - assert_eq!(value["phase"], "discoverability_only"); - assert_eq!(value["supported"], false); - assert_eq!(value["exit_code"], 0); - assert_eq!(value["serve_alias_only"], true); - assert_eq!(value["protocol"]["json_rpc"], false); - assert_eq!(value["protocol"]["daemon"], false); - assert_eq!(value["protocol"]["serve_starts_daemon"], false); - assert!(value["protocol"]["endpoint"].is_null()); - assert_eq!( - value["contracts"]["unsupported_invocation_kind"], - "unsupported_acp_invocation" - ); } #[test] @@ -11748,33 +10425,21 @@ mod tests { assert_eq!( parse_args(&["status".to_string(), "--help".to_string()]) .expect("status help should parse"), - CliAction::HelpTopic { - topic: LocalHelpTopic::Status, - output_format: CliOutputFormat::Text, - } + CliAction::HelpTopic(LocalHelpTopic::Status) ); assert_eq!( parse_args(&["sandbox".to_string(), "-h".to_string()]) .expect("sandbox help should parse"), - CliAction::HelpTopic { - topic: LocalHelpTopic::Sandbox, - output_format: CliOutputFormat::Text, - } + CliAction::HelpTopic(LocalHelpTopic::Sandbox) ); assert_eq!( parse_args(&["doctor".to_string(), "--help".to_string()]) .expect("doctor help should parse"), - CliAction::HelpTopic { - topic: LocalHelpTopic::Doctor, - output_format: CliOutputFormat::Text, - } + CliAction::HelpTopic(LocalHelpTopic::Doctor) ); assert_eq!( parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"), - CliAction::HelpTopic { - topic: LocalHelpTopic::Acp, - output_format: CliOutputFormat::Text, - } + CliAction::HelpTopic(LocalHelpTopic::Acp) ); } @@ -11804,30 +10469,10 @@ mod tests { }); assert_eq!( parsed, - CliAction::HelpTopic { - topic: *expected_topic, - output_format: CliOutputFormat::Text, - }, + CliAction::HelpTopic(*expected_topic), "`{subcommand} {flag}` should resolve to HelpTopic({expected_topic:?})" ); } - let json_parsed = parse_args(&[ - subcommand.to_string(), - "--help".to_string(), - "--output-format".to_string(), - "json".to_string(), - ]) - .unwrap_or_else(|error| { - panic!("`{subcommand} --help --output-format json` should parse: {error}") - }); - assert_eq!( - json_parsed, - CliAction::HelpTopic { - topic: *expected_topic, - output_format: CliOutputFormat::Json, - }, - "`{subcommand} --help --output-format json` should preserve json output format" - ); // And the rendered help must actually mention the subcommand name // (or its canonical title) so users know they got the right help. let rendered = render_help_topic(*expected_topic); @@ -11842,70 +10487,6 @@ mod tests { } } - #[test] - fn export_help_json_is_bounded_and_parseable_384() { - let value = render_help_topic_json(LocalHelpTopic::Export); - assert_eq!(value["kind"], "help"); - assert_eq!(value["topic"], "export"); - assert_eq!(value["command"], "export"); - assert_eq!( - value["usage"], - "claw export [--session ] [--output ] [--output-format ]" - ); - assert_eq!(value["defaults"]["session"], LATEST_SESSION_REFERENCE); - assert!(value["options"].as_array().expect("options array").len() >= 4); - assert!( - value.get("message").is_none(), - "export help json should be a bounded envelope, not plaintext help wrapped in json" - ); - } - - #[test] - fn plugins_degrades_gracefully_on_malformed_mcp_config() { - // Keep the plugins surface consistent with status/doctor/mcp: a bad - // MCP entry should not make local plugin introspection unusable. - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project-with-malformed-mcp-for-plugins"); - let config_home = root.join("config-home"); - std::fs::create_dir_all(&cwd).expect("project dir should exist"); - std::fs::create_dir_all(&config_home).expect("config home should exist"); - std::fs::write( - cwd.join(".claw.json"), - r#"{ - "mcpServers": { - "missing-command": {"args": ["arg-only-no-command"]} - } -} -"#, - ) - .expect("write malformed .claw.json"); - - let previous_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - let payload = super::plugins_command_payload_for(&cwd, None, None) - .expect("plugins list should not hard-fail on malformed MCP config"); - match previous_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } - - assert_eq!(payload.status, "degraded"); - let err = payload - .config_load_error - .as_deref() - .expect("config_load_error should be populated"); - assert!( - err.contains("mcpServers.missing-command"), - "config_load_error should name the malformed MCP field: {err}" - ); - assert!(payload.message.contains("Config load error")); - assert!(payload.message.contains("partial plugins view")); - assert!(payload.message.contains("Plugins")); - - let _ = std::fs::remove_dir_all(root); - } - #[test] fn status_degrades_gracefully_on_malformed_mcp_config_143() { // #143: previously `claw status` hard-failed on any config parse error, @@ -11996,18 +10577,6 @@ mod tests { json.get("workspace").is_some(), "workspace field still reported" ); - assert_eq!( - json.pointer("/lane_board/status_json_supported") - .and_then(|v| v.as_bool()), - Some(true), - "status JSON should advertise lane board support: {json}" - ); - assert_eq!( - json.pointer("/lane_board/freshness_states/2") - .and_then(|v| v.as_str()), - Some("transport_dead"), - "status JSON should advertise transport-dead freshness: {json}" - ); assert!( json.get("sandbox").is_some(), "sandbox field still reported" @@ -12239,10 +10808,6 @@ mod tests { classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse" ); - assert_eq!( - classify_error_kind("unsupported ACP invocation. Use `claw acp`."), - "unsupported_acp_invocation" - ); assert_eq!( classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), "invalid_model_syntax" @@ -12255,18 +10820,6 @@ mod tests { classify_error_kind("api failed after 3 attempts: ..."), "api_http_error" ); - assert_eq!( - classify_error_kind("/tmp/settings.json: mcpServers.foo: expected JSON object"), - "malformed_mcp_config" - ); - assert_eq!( - classify_error_kind("settings.json: mcpServers: field must be an object"), - "malformed_mcp_config" - ); - assert_eq!( - classify_error_kind("empty prompt: provide a subcommand or a non-empty prompt string"), - "empty_prompt" - ); assert_eq!( classify_error_kind("something completely unknown"), "unknown" @@ -12605,7 +11158,7 @@ mod tests { .expect("prompt shorthand should still work"), CliAction::Prompt { prompt: "please debug this".to_string(), - model: "anthropic/claude-opus-4-6".to_string(), + model: "claude-opus-4-6".to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: crate::default_permission_mode(), @@ -12619,9 +11172,6 @@ mod tests { #[test] fn parses_direct_agents_mcp_and_skills_slash_commands() { - let _guard = env_lock(); - let _cwd_guard = cwd_guard(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); assert_eq!( parse_args(&["/agents".to_string()]).expect("/agents should parse"), CliAction::Agents { @@ -12734,15 +11284,6 @@ mod tests { .expect_err("invalid /plugins list shape should be rejected"); assert!(plugins_error.contains("Usage: /plugin list")); assert!(plugins_error.contains("Aliases /plugins, /marketplace")); - - for alias in ["/plugin", "/plugins", "/marketplace"] { - let error = parse_args(&[alias.to_string()]) - .expect_err("valid plugin slash aliases are local/interactive, never prompts"); - assert!( - error.contains("interactive-only"), - "{alias} should reject as an interactive plugin command outside the REPL, got: {error}" - ); - } } #[test] @@ -12768,23 +11309,6 @@ mod tests { assert!(error.contains("skills")); } - #[test] - fn unsupported_skills_actions_return_typed_error_683() { - for action in ["remove", "add", "uninstall", "delete"] { - let error = parse_args(&["skills".to_string(), action.to_string()]) - .expect_err(&format!("skills {action} should error")); - assert!( - error.contains("unsupported skills action"), - "skills {action} should contain 'unsupported skills action', got: {error}" - ); - assert_eq!( - classify_error_kind(&error), - "unsupported_skills_action", - "skills {action} should classify as unsupported_skills_action, got: {error}" - ); - } - } - #[test] fn typoed_status_subcommand_returns_did_you_mean_error() { let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error"); @@ -13101,8 +11625,7 @@ mod tests { assert!(help.contains("/export [file]")); // Batch 5 added `/session delete`; match on the stable core rather than // the trailing bracket so future additions don't re-break this. - assert!(help - .contains("/session [list|exists |switch |fork [branch-name]")); + assert!(help.contains("/session [list|switch |fork [branch-name]")); assert!(help.contains( "/plugin [list|install |enable |disable |uninstall |update ]" )); @@ -13110,9 +11633,7 @@ mod tests { assert!(help.contains("/agents")); assert!(help.contains("/skills")); assert!(help.contains("/exit")); - assert!(help.contains( - "Auto-save .claw/sessions//.jsonl" - )); + assert!(help.contains("Auto-save .claw/sessions/.jsonl")); assert!(help.contains("Resume latest /resume latest")); } @@ -13124,7 +11645,7 @@ mod tests { vec!["session-old".to_string()], ); - assert!(completions.contains(&"/model anthropic/claude-sonnet-4-6".to_string())); + assert!(completions.contains(&"/model claude-sonnet-4-6".to_string())); assert!(completions.contains(&"/permissions workspace-write".to_string())); assert!(completions.contains(&"/session list".to_string())); assert!(completions.contains(&"/session switch session-current".to_string())); @@ -13143,7 +11664,7 @@ mod tests { let banner = with_current_dir(&root, || { LiveCli::new( - "anthropic/claude-sonnet-4-6".to_string(), + "claude-sonnet-4-6".to_string(), true, None, PermissionMode::DangerFullAccess, @@ -13161,11 +11682,11 @@ mod tests { #[test] fn format_connected_line_renders_anthropic_provider_for_claude_model() { - let model = "anthropic/claude-sonnet-4-6"; + let model = "claude-sonnet-4-6"; let line = format_connected_line(model); - assert_eq!(line, "Connected: anthropic/claude-sonnet-4-6 via anthropic"); + assert_eq!(line, "Connected: claude-sonnet-4-6 via anthropic"); } #[test] @@ -13179,11 +11700,11 @@ mod tests { #[test] fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { - let user_model = "anthropic/claude-sonnet-4-6".to_string(); + let user_model = "claude-sonnet-4-6".to_string(); let resolved = resolve_repl_model(user_model); - assert_eq!(resolved, "anthropic/claude-sonnet-4-6"); + assert_eq!(resolved, "claude-sonnet-4-6"); } #[test] @@ -13199,7 +11720,7 @@ mod tests { let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); - assert_eq!(resolved, "anthropic/claude-sonnet-4-6"); + assert_eq!(resolved, "claude-sonnet-4-6"); std::env::remove_var("ANTHROPIC_MODEL"); std::env::remove_var("CLAW_CONFIG_HOME"); @@ -13242,26 +11763,6 @@ mod tests { assert!(names.contains(&"compact")); } - #[test] - fn session_exists_resume_command_reports_json_contract() { - let session = Session::new(); - let path = PathBuf::from("missing-session.jsonl"); - let outcome = run_resume_command( - &path, - &session, - &SlashCommand::Session { - action: Some("exists".to_string()), - target: Some("definitely-missing-session".to_string()), - }, - ) - .expect("exists command should not fail for missing sessions"); - - let json = outcome.json.expect("json contract"); - assert_eq!(json["kind"], "session_exists"); - assert_eq!(json["exists"], false); - assert_eq!(json["session"], "definitely-missing-session"); - } - #[test] fn resume_report_uses_sectioned_layout() { let report = format_resume_report("session.jsonl", 14, 6); @@ -13295,7 +11796,6 @@ mod tests { assert!(report.contains("Cache create 3")); assert!(report.contains("Cache read 1")); assert!(report.contains("Total tokens 32")); - assert!(report.contains("Estimated cost")); } #[test] @@ -13349,33 +11849,6 @@ mod tests { assert!(report.contains("Switch models with /model ")); } - fn test_branch_freshness() -> super::BranchFreshness { - super::BranchFreshness { - upstream: Some("origin/main".to_string()), - ahead: 0, - behind: 0, - fresh: Some(true), - } - } - - fn test_boot_preflight() -> super::BootPreflightSnapshot { - super::BootPreflightSnapshot { - repo_exists: true, - worktree_exists: true, - git_dir_exists: true, - branch_freshness: test_branch_freshness(), - trust_gate_allowed: Some(false), - trusted_roots_count: 0, - required_binaries: Vec::new(), - control_sockets: Vec::new(), - mcp_startup_eligible: true, - mcp_servers_configured: 0, - plugin_startup_eligible: true, - plugins_configured: 0, - last_failed_boot_reason: None, - } - } - #[test] fn model_switch_report_preserves_context_summary() { let report = format_model_switch_report("claude-sonnet", "claude-opus", 9); @@ -13422,8 +11895,6 @@ mod tests { untracked_files: 1, conflicted_files: 0, }, - branch_freshness: test_branch_freshness(), - stale_base_state: super::BaseCommitState::NoExpectedBase, session_lifecycle: SessionLifecycleSummary { kind: SessionLifecycleKind::IdleShell, pane_id: Some("%7".to_string()), @@ -13432,10 +11903,8 @@ mod tests { workspace_dirty: true, abandoned: true, }, - boot_preflight: test_boot_preflight(), sandbox_status: runtime::SandboxStatus::default(), config_load_error: None, - config_load_error_kind: None, }, None, // #148 ); @@ -13444,10 +11913,7 @@ mod tests { assert!(status.contains("Permission mode workspace-write")); assert!(status.contains("Messages 7")); assert!(status.contains("Latest total 10")); - assert!(status.contains("Cache create 2")); - assert!(status.contains("Cache read 1")); assert!(status.contains("Cumulative total 31")); - assert!(status.contains("Estimated cost")); assert!(status.contains("Cwd /tmp/project")); assert!(status.contains("Project root /tmp")); assert!(status.contains("Git branch main")); @@ -13553,47 +12019,6 @@ mod tests { fs::remove_dir_all(workspace).expect("cleanup temp dir"); } - #[test] - fn workspace_health_warns_when_stale_base_diverged() { - let context = super::StatusContext { - cwd: PathBuf::from("/tmp/project"), - session_path: None, - loaded_config_files: 0, - discovered_config_files: 0, - memory_file_count: 0, - project_root: Some(PathBuf::from("/tmp/project")), - git_branch: Some("feature/stale-base".to_string()), - git_summary: GitWorkspaceSummary::default(), - branch_freshness: test_branch_freshness(), - stale_base_state: super::BaseCommitState::Diverged { - expected: "base".to_string(), - actual: "head".to_string(), - }, - session_lifecycle: SessionLifecycleSummary { - kind: SessionLifecycleKind::SavedOnly, - pane_id: None, - pane_command: None, - pane_path: None, - workspace_dirty: false, - abandoned: false, - }, - boot_preflight: test_boot_preflight(), - sandbox_status: runtime::SandboxStatus::default(), - config_load_error: None, - config_load_error_kind: None, - }; - - let check = super::check_workspace_health(&context); - - assert_eq!(check.level, super::DiagnosticLevel::Warn); - assert_eq!(check.data["stale_base"]["status"], "diverged"); - assert_eq!(check.data["stale_base"]["fresh"], false); - assert!(check - .details - .iter() - .any(|detail| detail.contains("stale codebase"))); - } - #[test] fn status_json_surfaces_session_lifecycle_for_clawhip() { let context = super::StatusContext { @@ -13605,8 +12030,6 @@ mod tests { project_root: Some(PathBuf::from("/tmp/project")), git_branch: Some("feature/session-lifecycle".to_string()), git_summary: GitWorkspaceSummary::default(), - branch_freshness: test_branch_freshness(), - stale_base_state: super::BaseCommitState::NoExpectedBase, session_lifecycle: SessionLifecycleSummary { kind: SessionLifecycleKind::RunningProcess, pane_id: Some("%9".to_string()), @@ -13615,10 +12038,8 @@ mod tests { workspace_dirty: false, abandoned: false, }, - boot_preflight: test_boot_preflight(), sandbox_status: runtime::SandboxStatus::default(), config_load_error: None, - config_load_error_kind: None, }; let value = status_json_value( @@ -13645,67 +12066,6 @@ mod tests { "claw" ); assert_eq!(value["workspace"]["session_lifecycle"]["abandoned"], false); - assert_eq!(value["workspace"]["branch_freshness"]["fresh"], true); - assert_eq!( - value["workspace"]["boot_preflight"]["repo"]["worktree_exists"], - true - ); - assert_eq!( - value["workspace"]["boot_preflight"]["mcp_startup"]["eligible"], - true - ); - assert_eq!( - value["workspace"]["boot_preflight"]["last_failed_boot_reason"], - serde_json::Value::Null - ); - } - - #[test] - fn branch_freshness_parses_ahead_behind_status_header() { - let freshness = super::BranchFreshness::from_git_status(Some( - "## feature/boot...origin/feature/boot [ahead 2, behind 3]\n M src/main.rs", - )); - - assert_eq!(freshness.upstream.as_deref(), Some("origin/feature/boot")); - assert_eq!(freshness.ahead, 2); - assert_eq!(freshness.behind, 3); - assert_eq!(freshness.fresh, Some(false)); - } - - #[test] - fn boot_preflight_snapshot_reports_machine_readable_contract_fields() { - let _guard = env_lock(); - let workspace = temp_workspace("boot-preflight-json"); - fs::create_dir_all(&workspace).expect("workspace should create"); - git(&["init", "--quiet"], &workspace); - git(&["config", "user.email", "tests@example.com"], &workspace); - git(&["config", "user.name", "Rusty Claude Tests"], &workspace); - fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked"); - fs::write(workspace.join(".claw.json"), r#"{"trustedRoots": ["."]}"#) - .expect("write config"); - git(&["add", "tracked.txt"], &workspace); - git(&["commit", "-m", "init", "--quiet"], &workspace); - - let loader = ConfigLoader::default_for(&workspace); - let config = loader.load().expect("config should load"); - let status = super::run_git_capture_in(&workspace, &["status", "--short", "--branch"]); - let snapshot = super::build_boot_preflight_snapshot( - &workspace, - Some(&workspace), - status.as_deref(), - Some(&config), - None, - ); - let json = snapshot.json_value(); - - assert_eq!(json["repo"]["exists"], true); - assert_eq!(json["repo"]["worktree_exists"], true); - assert_eq!(json["trust_gate"]["allowlisted"], true); - assert_eq!(json["mcp_startup"]["eligible"], true); - assert!(json["required_binaries"] - .as_array() - .is_some_and(|items| { items.iter().any(|item| item["name"] == "git") })); - fs::remove_dir_all(workspace).expect("cleanup temp dir"); } #[test] @@ -13792,6 +12152,102 @@ mod tests { assert!(report.contains("Merged JSON")); } + #[test] + fn config_json_reports_structured_unloaded_file_reasons() { + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + fs::write(workspace.join(".claw/settings.json"), r#"{"model":"opus"}"#) + .expect("write project settings"); + + let previous_config_home = std::env::var_os("CLAW_CONFIG_HOME"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + let value = with_current_dir(&workspace, || { + render_config_json(None).expect("config json should render") + }); + match previous_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + + assert_eq!(value["kind"], "config"); + assert_eq!(value["status"], "ok"); + assert_eq!(value["loaded_files"].as_u64(), Some(1)); + assert_eq!(value["merged_keys"], value["merged_key_count"]); + assert_eq!( + value["merged_keys_meaning"].as_str(), + Some("count of top-level keys in the effective merged JSON object") + ); + + let files = value["files"].as_array().expect("files array"); + let loaded_project = files + .iter() + .find(|file| { + file["loaded"] == true + && file["path"] + .as_str() + .is_some_and(|path| path.ends_with(".claw/settings.json")) + }) + .expect("project settings entry"); + assert_eq!(loaded_project["loaded"], true); + assert_eq!(loaded_project["status"], "loaded"); + assert!(loaded_project.get("reason").is_none()); + + let missing = files + .iter() + .find(|file| file["loaded"] == false && file["status"] == "not_found") + .expect("at least one missing discovered config"); + assert_eq!(missing["reason"], "not_found"); + assert_eq!(missing["skip_reason"], "not_found"); + + fs::remove_dir_all(root).expect("cleanup temp root"); + } + + #[test] + fn config_json_reports_parse_errors_without_dropping_file_statuses() { + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + fs::write(workspace.join(".claw/settings.json"), "{not json") + .expect("write invalid project settings"); + + let previous_config_home = std::env::var_os("CLAW_CONFIG_HOME"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + let value = with_current_dir(&workspace, || { + render_config_json(None).expect("config json should render") + }); + match previous_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + + assert_eq!(value["status"], "error"); + assert!(value["load_error"].as_str().is_some()); + let files = value["files"].as_array().expect("files array"); + let error_file = files + .iter() + .find(|file| { + file["status"] == "load_error" + && file["path"] + .as_str() + .is_some_and(|path| path.ends_with(".claw/settings.json")) + }) + .expect("project settings entry"); + assert_eq!(error_file["loaded"], false); + assert_eq!(error_file["status"], "load_error"); + assert_eq!(error_file["reason"], "parse_error"); + assert_eq!(error_file["skip_reason"], "parse_error"); + assert!(error_file["detail"].as_str().is_some()); + + fs::remove_dir_all(root).expect("cleanup temp root"); + } + #[test] fn parses_git_status_metadata() { let _guard = env_lock(); @@ -14064,68 +12520,6 @@ UU conflicted.rs", std::fs::remove_dir_all(workspace).expect("workspace should clean up"); } - #[test] - fn resumed_session_exists_and_delete_have_json_contracts() { - let _guard = cwd_guard(); - let workspace = temp_workspace("resume-session-json-contracts"); - std::fs::create_dir_all(&workspace).expect("workspace should create"); - let previous = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(&workspace).expect("switch cwd"); - - let active = create_managed_session_handle("session-active").expect("active handle"); - let active_session = Session::new() - .with_workspace_root(workspace.clone()) - .with_persistence_path(active.path.clone()); - active_session - .save_to_path(&active.path) - .expect("active session should save"); - let saved = create_managed_session_handle("session-saved").expect("saved handle"); - Session::new() - .with_workspace_root(workspace.clone()) - .with_persistence_path(saved.path.clone()) - .save_to_path(&saved.path) - .expect("saved session should save"); - - let exists_command = SlashCommand::parse("/session exists session-saved") - .expect("parse should succeed") - .expect("command should exist"); - let exists = run_resume_command(&active.path, &active_session, &exists_command) - .expect("exists should run") - .json - .expect("exists should return json"); - assert_eq!(exists["kind"], "session_exists"); - assert_eq!(exists["session_id"], "session-saved"); - assert_eq!(exists["exists"], true); - assert_eq!(exists["active"], false); - assert!(exists["path"].as_str().is_some()); - - let missing_command = SlashCommand::parse("/session exists missing-session") - .expect("parse should succeed") - .expect("command should exist"); - let missing = run_resume_command(&active.path, &active_session, &missing_command) - .expect("missing exists should run") - .json - .expect("missing exists should return json"); - assert_eq!(missing["kind"], "session_exists"); - assert_eq!(missing["exists"], false); - assert_eq!(missing["session_id"], "missing-session"); - assert!(missing["candidate_path"].as_str().is_some()); - - let delete_command = SlashCommand::parse("/session delete session-saved --force") - .expect("parse should succeed") - .expect("command should exist"); - let deleted = run_resume_command(&active.path, &active_session, &delete_command) - .expect("delete should run") - .json - .expect("delete should return json"); - assert_eq!(deleted["kind"], "session_delete"); - assert_eq!(deleted["deleted"], true); - assert!(!saved.path.exists(), "saved session should be deleted"); - - std::env::set_current_dir(previous).expect("restore cwd"); - std::fs::remove_dir_all(workspace).expect("workspace should clean up"); - } - #[test] fn latest_session_alias_resolves_most_recent_managed_session() { let _guard = cwd_guard(); @@ -14226,7 +12620,7 @@ UU conflicted.rs", fn resume_usage_mentions_latest_shortcut() { let usage = render_resume_usage(); assert!(usage.contains("/resume ")); - assert!(usage.contains(".claw/sessions//.jsonl")); + assert!(usage.contains(".claw/sessions/.jsonl")); assert!(usage.contains("/session list")); } @@ -14692,7 +13086,7 @@ UU conflicted.rs", MessageResponse { id: "msg-1".to_string(), kind: "message".to_string(), - model: "anthropic/claude-opus-4-6".to_string(), + model: "claude-opus-4-6".to_string(), role: "assistant".to_string(), content: vec![OutputContentBlock::ToolUse { id: "tool-1".to_string(), @@ -14727,7 +13121,7 @@ UU conflicted.rs", MessageResponse { id: "msg-2".to_string(), kind: "message".to_string(), - model: "anthropic/claude-opus-4-6".to_string(), + model: "claude-opus-4-6".to_string(), role: "assistant".to_string(), content: vec![OutputContentBlock::ToolUse { id: "tool-2".to_string(), @@ -14762,7 +13156,7 @@ UU conflicted.rs", MessageResponse { id: "msg-3".to_string(), kind: "message".to_string(), - model: "anthropic/claude-opus-4-6".to_string(), + model: "claude-opus-4-6".to_string(), role: "assistant".to_string(), content: vec![ OutputContentBlock::Thinking { @@ -15367,55 +13761,3 @@ mod dump_manifests_tests { let _ = fs::remove_dir_all(&root); } } - -#[cfg(test)] -mod alias_resolution_tests { - use super::{resolve_model_alias_with_config, validate_model_syntax}; - - #[test] - fn test_alias_resolution_builtin() { - // Built-in aliases should resolve to their full IDs - assert_eq!( - resolve_model_alias_with_config("opus"), - "anthropic/claude-opus-4-6" - ); - assert_eq!( - resolve_model_alias_with_config("sonnet"), - "anthropic/claude-sonnet-4-6" - ); - assert_eq!( - resolve_model_alias_with_config("haiku"), - "anthropic/claude-haiku-4-5-20251213" - ); - } - - #[test] - fn test_alias_resolution_syntax_validation() { - // Resolved aliases should pass syntax validation - let resolved = resolve_model_alias_with_config("opus"); - assert!(validate_model_syntax(&resolved).is_ok()); - - // Raw aliases should FAIL syntax validation (this is why we resolve first!) - assert!(validate_model_syntax("opus").is_err()); - } - - #[test] - fn test_unknown_alias_fails_validation() { - // Unknown aliases resolve to themselves - let resolved = resolve_model_alias_with_config("unknown-alias"); - assert_eq!(resolved, "unknown-alias"); - - // And then fail validation with a helpful error - let result = validate_model_syntax(&resolved); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("invalid model syntax")); - } - - #[test] - fn test_direct_provider_model_passes() { - // Direct provider/model strings should remain unchanged and pass - let model = "openai/gpt-4o"; - assert_eq!(resolve_model_alias_with_config(model), model); - assert!(validate_model_syntax(model).is_ok()); - } -} From 707f4a4102c12444de844f41d78b1dcb36270509 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 30 Apr 2026 10:11:27 +0000 Subject: [PATCH 2/4] Fix config JSON test isolation --- rust/crates/rusty-claude-cli/src/main.rs | 44 +++++++++++++++--------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a722c7a029..092f2691d8 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9543,7 +9543,25 @@ mod tests { let previous = std::env::current_dir().expect("cwd should load"); std::env::set_current_dir(cwd).expect("cwd should change"); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); - std::env::set_current_dir(previous).expect("cwd should restore"); + if previous.exists() { + std::env::set_current_dir(previous).expect("cwd should restore"); + } else { + std::env::set_current_dir(std::env::temp_dir()).expect("cwd should restore to temp"); + } + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } + } + + fn with_config_home(config_home: &Path, f: impl FnOnce() -> T) -> T { + let previous = std::env::var_os("CLAW_CONFIG_HOME"); + std::env::set_var("CLAW_CONFIG_HOME", config_home); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + match previous { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } match result { Ok(value) => value, Err(payload) => std::panic::resume_unwind(payload), @@ -12163,15 +12181,11 @@ mod tests { fs::write(workspace.join(".claw/settings.json"), r#"{"model":"opus"}"#) .expect("write project settings"); - let previous_config_home = std::env::var_os("CLAW_CONFIG_HOME"); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - let value = with_current_dir(&workspace, || { - render_config_json(None).expect("config json should render") + let value = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_json(None).expect("config json should render") + }) }); - match previous_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } assert_eq!(value["kind"], "config"); assert_eq!(value["status"], "ok"); @@ -12217,15 +12231,11 @@ mod tests { fs::write(workspace.join(".claw/settings.json"), "{not json") .expect("write invalid project settings"); - let previous_config_home = std::env::var_os("CLAW_CONFIG_HOME"); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - let value = with_current_dir(&workspace, || { - render_config_json(None).expect("config json should render") + let value = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_json(None).expect("config json should render") + }) }); - match previous_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } assert_eq!(value["status"], "error"); assert!(value["load_error"].as_str().is_some()); From 688f33189e2af746166425796d9dcec7aa026c5f Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 30 Apr 2026 10:14:13 +0000 Subject: [PATCH 3/4] Isolate config report tests --- rust/crates/rusty-claude-cli/src/main.rs | 37 +++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 092f2691d8..2462ed5de4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -12146,11 +12146,27 @@ mod tests { #[test] fn config_report_supports_section_views() { - let report = render_config_report(Some("env")).expect("config report should render"); + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + + let report = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_report(Some("env")).expect("config report should render") + }) + }); assert!(report.contains("Merged section: env")); - let plugins_report = - render_config_report(Some("plugins")).expect("plugins config report should render"); + let plugins_report = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_report(Some("plugins")).expect("plugins config report should render") + }) + }); assert!(plugins_report.contains("Merged section: plugins")); + + fs::remove_dir_all(root).expect("cleanup temp root"); } #[test] @@ -12164,10 +12180,23 @@ mod tests { #[test] fn config_report_uses_sectioned_layout() { - let report = render_config_report(None).expect("config report should render"); + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + + let report = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_report(None).expect("config report should render") + }) + }); assert!(report.contains("Config")); assert!(report.contains("Discovered files")); assert!(report.contains("Merged JSON")); + + fs::remove_dir_all(root).expect("cleanup temp root"); } #[test] From 39ab68ec3104b422ed74ee2d4824d9c784808937 Mon Sep 17 00:00:00 2001 From: Independent Security Research Date: Mon, 25 May 2026 05:09:47 +0000 Subject: [PATCH 4/4] Add .worktrees to gitignore for worktree isolation Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6259e5b79f..bd2361bf82 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ archive/ status-help.txt # Legacy Python port session scratch artifacts .port_sessions/ + +.port_sessions/ +.worktrees