From cd01d0e3876570d9127e13723e63855853d84209 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 00:36:32 +0000 Subject: [PATCH 1/2] Honor Claude config defaults across runtime sessions The runtime now discovers both legacy and current Claude config files at user and project scope, merges them in precedence order, and carries the resolved model, permission mode, instruction files, and MCP server configuration into session startup. This keeps CLI defaults aligned with project policy and exposes configured MCP tools without requiring manual flags. Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts Constraint: Session startup must preserve CLI flag precedence over config defaults Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features Not-tested: Live remote MCP servers and interactive REPL session startup against external services --- rust/crates/runtime/src/config.rs | 143 ++++++- rust/crates/runtime/src/lib.rs | 11 +- rust/crates/runtime/src/oauth.rs | 6 +- rust/crates/runtime/src/prompt.rs | 58 ++- rust/crates/rusty-claude-cli/src/main.rs | 472 +++++++++++++++++++++-- 5 files changed, 630 insertions(+), 60 deletions(-) diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 559ae6a..9ea937e 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -14,6 +14,13 @@ pub enum ConfigSource { Local, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolvedPermissionMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigEntry { pub source: ConfigSource, @@ -31,6 +38,8 @@ pub struct RuntimeConfig { pub struct RuntimeFeatureConfig { mcp: McpConfigCollection, oauth: Option, + model: Option, + permission_mode: Option, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -165,11 +174,23 @@ impl ConfigLoader { #[must_use] pub fn discover(&self) -> Vec { + let user_legacy_path = self.config_home.parent().map_or_else( + || PathBuf::from(".claude.json"), + |parent| parent.join(".claude.json"), + ); vec![ + ConfigEntry { + source: ConfigSource::User, + path: user_legacy_path, + }, ConfigEntry { source: ConfigSource::User, path: self.config_home.join("settings.json"), }, + ConfigEntry { + source: ConfigSource::Project, + path: self.cwd.join(".claude.json"), + }, ConfigEntry { source: ConfigSource::Project, path: self.cwd.join(".claude").join("settings.json"), @@ -195,14 +216,15 @@ impl ConfigLoader { loaded_entries.push(entry); } + let merged_value = JsonValue::Object(merged.clone()); + let feature_config = RuntimeFeatureConfig { mcp: McpConfigCollection { servers: mcp_servers, }, - oauth: parse_optional_oauth_config( - &JsonValue::Object(merged.clone()), - "merged settings.oauth", - )?, + oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + model: parse_optional_model(&merged_value), + permission_mode: parse_optional_permission_mode(&merged_value)?, }; Ok(RuntimeConfig { @@ -257,6 +279,16 @@ impl RuntimeConfig { pub fn oauth(&self) -> Option<&OAuthConfig> { self.feature_config.oauth.as_ref() } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.feature_config.model.as_deref() + } + + #[must_use] + pub fn permission_mode(&self) -> Option { + self.feature_config.permission_mode + } } impl RuntimeFeatureConfig { @@ -269,6 +301,16 @@ impl RuntimeFeatureConfig { pub fn oauth(&self) -> Option<&OAuthConfig> { self.oauth.as_ref() } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + + #[must_use] + pub fn permission_mode(&self) -> Option { + self.permission_mode + } } impl McpConfigCollection { @@ -307,6 +349,7 @@ impl McpServerConfig { fn read_optional_json_object( path: &Path, ) -> Result>, ConfigError> { + let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json"); let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -317,14 +360,20 @@ fn read_optional_json_object( return Ok(Some(BTreeMap::new())); } - let parsed = JsonValue::parse(&contents) - .map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?; - let object = parsed.as_object().ok_or_else(|| { - ConfigError::Parse(format!( + let parsed = match JsonValue::parse(&contents) { + Ok(parsed) => parsed, + Err(error) if is_legacy_config => return Ok(None), + 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 Err(ConfigError::Parse(format!( "{}: top-level settings value must be a JSON object", path.display() - )) - })?; + ))); + }; Ok(Some(object.clone())) } @@ -355,6 +404,47 @@ fn merge_mcp_servers( Ok(()) } +fn parse_optional_model(root: &JsonValue) -> Option { + root.as_object() + .and_then(|object| object.get("model")) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) +} + +fn parse_optional_permission_mode( + root: &JsonValue, +) -> Result, ConfigError> { + let Some(object) = root.as_object() else { + return Ok(None); + }; + if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) { + return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some); + } + let Some(mode) = object + .get("permissions") + .and_then(JsonValue::as_object) + .and_then(|permissions| permissions.get("defaultMode")) + .and_then(JsonValue::as_str) + else { + return Ok(None); + }; + parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some) +} + +fn parse_permission_mode_label( + mode: &str, + context: &str, +) -> Result { + match mode { + "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly), + "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite), + "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess), + other => Err(ConfigError::Parse(format!( + "{context}: unsupported permission mode {other}" + ))), + } +} + fn parse_optional_oauth_config( root: &JsonValue, context: &str, @@ -594,7 +684,8 @@ fn deep_merge_objects( #[cfg(test)] mod tests { use super::{ - ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, + CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; use std::fs; @@ -635,14 +726,24 @@ mod tests { fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(&home).expect("home config dir"); + fs::write( + home.parent().expect("home parent").join(".claude.json"), + r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#, + ) + .expect("write user compat config"); fs::write( home.join("settings.json"), - r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#, + r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#, ) .expect("write user settings"); + fs::write( + cwd.join(".claude.json"), + r#"{"model":"project-compat","env":{"B":"2"}}"#, + ) + .expect("write project compat config"); fs::write( cwd.join(".claude").join("settings.json"), - r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#, + r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#, ) .expect("write project settings"); fs::write( @@ -656,25 +757,37 @@ mod tests { .expect("config should load"); assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema"); - assert_eq!(loaded.loaded_entries().len(), 3); + assert_eq!(loaded.loaded_entries().len(), 5); assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User); assert_eq!( loaded.get("model"), Some(&JsonValue::String("opus".to_string())) ); + assert_eq!(loaded.model(), Some("opus")); + assert_eq!( + loaded.permission_mode(), + Some(ResolvedPermissionMode::WorkspaceWrite) + ); assert_eq!( loaded .get("env") .and_then(JsonValue::as_object) .expect("env object") .len(), - 2 + 4 ); assert!(loaded .get("hooks") .and_then(JsonValue::as_object) .expect("hooks object") .contains_key("PreToolUse")); + assert!(loaded + .get("hooks") + .and_then(JsonValue::as_object) + .expect("hooks object") + .contains_key("PostToolUse")); + assert!(loaded.mcp().get("home").is_some()); + assert!(loaded.mcp().get("project").is_some()); fs::remove_dir_all(root).expect("cleanup temp dir"); } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 816ace0..a13ae2d 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -25,7 +25,8 @@ pub use config::{ ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, - RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, + CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, @@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; + +#[cfg(test)] +pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/rust/crates/runtime/src/oauth.rs b/rust/crates/runtime/src/oauth.rs index db68bf9..3f30a00 100644 --- a/rust/crates/runtime/src/oauth.rs +++ b/rust/crates/runtime/src/oauth.rs @@ -448,7 +448,6 @@ fn decode_hex(byte: u8) -> Result { #[cfg(test)] mod tests { - use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ @@ -470,10 +469,7 @@ mod tests { } fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("env lock") + crate::test_env_lock() } fn temp_config_home() -> std::path::PathBuf { diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 99eae97..da213f2 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { dir.join("CLAUDE.md"), dir.join("CLAUDE.local.md"), dir.join(".claude").join("CLAUDE.md"), + dir.join(".claude").join("instructions.md"), ] { push_context_file(&mut files, candidate)?; } @@ -468,6 +469,10 @@ mod tests { std::env::temp_dir().join(format!("runtime-prompt-{nanos}")) } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + crate::test_env_lock() + } + #[test] fn discovers_instruction_files_from_ancestor_chain() { let root = temp_dir(); @@ -477,10 +482,21 @@ mod tests { fs::write(root.join("CLAUDE.local.md"), "local instructions") .expect("write local instructions"); fs::create_dir_all(root.join("apps")).expect("apps dir"); + fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir"); fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions") .expect("write apps instructions"); + fs::write( + root.join("apps").join(".claude").join("instructions.md"), + "apps dot claude instructions", + ) + .expect("write apps dot claude instructions"); fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules") .expect("write nested rules"); + fs::write( + nested.join(".claude").join("instructions.md"), + "nested instructions", + ) + .expect("write nested instructions"); let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); let contents = context @@ -495,7 +511,9 @@ mod tests { "root instructions", "local instructions", "apps instructions", - "nested rules" + "apps dot claude instructions", + "nested rules", + "nested instructions" ] ); fs::remove_dir_all(root).expect("cleanup temp dir"); @@ -574,7 +592,12 @@ mod tests { ) .expect("write settings"); + let _guard = env_lock(); let previous = std::env::current_dir().expect("cwd"); + let original_home = std::env::var("HOME").ok(); + let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok(); + std::env::set_var("HOME", &root); + std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home")); std::env::set_current_dir(&root).expect("change cwd"); let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8") .expect("system prompt should load") @@ -584,6 +607,16 @@ mod tests { ", ); std::env::set_current_dir(previous).expect("restore cwd"); + if let Some(value) = original_home { + std::env::set_var("HOME", value); + } else { + std::env::remove_var("HOME"); + } + if let Some(value) = original_claude_home { + std::env::set_var("CLAUDE_CONFIG_HOME", value); + } else { + std::env::remove_var("CLAUDE_CONFIG_HOME"); + } assert!(prompt.contains("Project rules")); assert!(prompt.contains("permissionMode")); @@ -631,6 +664,29 @@ mod tests { assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count()); } + #[test] + fn discovers_dot_claude_instructions_markdown() { + let root = temp_dir(); + let nested = root.join("apps").join("api"); + fs::create_dir_all(nested.join(".claude")).expect("nested claude dir"); + fs::write( + nested.join(".claude").join("instructions.md"), + "instruction markdown", + ) + .expect("write instructions.md"); + + let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); + assert!(context + .instruction_files + .iter() + .any(|file| file.path.ends_with(".claude/instructions.md"))); + assert!( + render_instruction_files(&context.instruction_files).contains("instruction markdown") + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn renders_instruction_file_metadata() { let rendered = render_instruction_files(&[ContextFile { diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3fc05da..151267d 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -23,9 +23,10 @@ use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, - ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, - OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, - Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, + ConversationMessage, ConversationRuntime, McpServerManager, MessageRole, + OAuthAuthorizationRequest, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, + ProjectContext, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError, + ToolExecutor, UsageTracker, }; use serde_json::json; use tools::{execute_tool, mvp_tool_specs}; @@ -53,7 +54,9 @@ Run `rusty-claude-cli --help` for usage." fn run() -> Result<(), Box> { let args: Vec = env::args().skip(1).collect(); - match parse_args(&args)? { + let runtime_config = load_runtime_config()?; + let defaults = RuntimeDefaults::from_config(&runtime_config); + match parse_args(&args, &defaults)? { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), @@ -80,6 +83,11 @@ fn run() -> Result<(), Box> { Ok(()) } +fn load_runtime_config() -> Result> { + let cwd = env::current_dir()?; + Ok(ConfigLoader::default_for(&cwd).load()?) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum CliAction { DumpManifests, @@ -127,8 +135,8 @@ impl CliOutputFormat { } } -fn parse_args(args: &[String]) -> Result { - let mut model = DEFAULT_MODEL.to_string(); +fn parse_args(args: &[String], defaults: &RuntimeDefaults) -> Result { + let mut model = defaults.model.clone(); let mut output_format = CliOutputFormat::Text; let mut wants_version = false; let mut allowed_tool_values = Vec::new(); @@ -232,6 +240,32 @@ fn parse_args(args: &[String]) -> Result { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeDefaults { + model: String, +} + +impl RuntimeDefaults { + fn from_config(config: &runtime::RuntimeConfig) -> Self { + Self { + model: config.model().unwrap_or(DEFAULT_MODEL).to_string(), + } + } +} + +fn resolved_permission_mode_label(config: &runtime::RuntimeConfig) -> &'static str { + match env::var("RUSTY_CLAUDE_PERMISSION_MODE") { + Ok(value) if value == "read-only" => "read-only", + Ok(value) if value == "danger-full-access" => "danger-full-access", + Ok(value) if value == "workspace-write" => "workspace-write", + _ => match config.permission_mode() { + Some(ResolvedPermissionMode::ReadOnly) => "read-only", + Some(ResolvedPermissionMode::DangerFullAccess) => "danger-full-access", + Some(ResolvedPermissionMode::WorkspaceWrite) | None => "workspace-write", + }, + } +} + fn normalize_allowed_tools(values: &[String]) -> Result, String> { if values.is_empty() { return Ok(None); @@ -892,14 +926,18 @@ impl LiveCli { enable_tools: bool, allowed_tools: Option, ) -> Result> { + let config = load_runtime_config()?; let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; + let permission_mode = resolved_permission_mode_label(&config); let runtime = build_runtime( Session::new(), model.clone(), system_prompt.clone(), enable_tools, allowed_tools.clone(), + &config, + permission_mode, )?; let cli = Self { model, @@ -1089,12 +1127,15 @@ impl LiveCli { let previous = self.model.clone(); let session = self.runtime.session().clone(); let message_count = session.messages.len(); + let config = load_runtime_config()?; self.runtime = build_runtime( session, model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), + &config, + resolved_permission_mode_label(&config), )?; self.model.clone_from(&model); self.persist_session()?; @@ -1124,12 +1165,14 @@ impl LiveCli { let previous = permission_mode_label().to_string(); let session = self.runtime.session().clone(); + let config = load_runtime_config()?; self.runtime = build_runtime_with_permission_mode( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), + &config, normalized, )?; self.persist_session()?; @@ -1149,12 +1192,14 @@ impl LiveCli { } self.session = create_managed_session_handle()?; + let config = load_runtime_config()?; self.runtime = build_runtime_with_permission_mode( Session::new(), self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), + &config, permission_mode_label(), )?; self.persist_session()?; @@ -1184,12 +1229,14 @@ impl LiveCli { let handle = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); + let config = load_runtime_config()?; self.runtime = build_runtime_with_permission_mode( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), + &config, permission_mode_label(), )?; self.session = handle; @@ -1261,12 +1308,14 @@ impl LiveCli { let handle = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); + let config = load_runtime_config()?; self.runtime = build_runtime_with_permission_mode( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), + &config, permission_mode_label(), )?; self.session = handle; @@ -1291,12 +1340,14 @@ impl LiveCli { let removed = result.removed_message_count; let kept = result.compacted_session.messages.len(); let skipped = removed == 0; + let config = load_runtime_config()?; self.runtime = build_runtime_with_permission_mode( result.compacted_session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), + &config, permission_mode_label(), )?; self.persist_session()?; @@ -1687,11 +1738,11 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> { } fn permission_mode_label() -> &'static str { - match env::var("RUSTY_CLAUDE_PERMISSION_MODE") { - Ok(value) if value == "read-only" => "read-only", - Ok(value) if value == "danger-full-access" => "danger-full-access", - _ => "workspace-write", - } + let cwd = env::current_dir().ok(); + let config = cwd.and_then(|cwd| ConfigLoader::default_for(cwd).load().ok()); + config + .as_ref() + .map_or("workspace-write", resolved_permission_mode_label) } fn render_diff_report() -> Result> { @@ -1823,6 +1874,8 @@ fn build_runtime( system_prompt: Vec, enable_tools: bool, allowed_tools: Option, + config: &runtime::RuntimeConfig, + permission_mode: &str, ) -> Result, Box> { build_runtime_with_permission_mode( @@ -1831,7 +1884,8 @@ fn build_runtime( system_prompt, enable_tools, allowed_tools, - permission_mode_label(), + config, + permission_mode, ) } @@ -1841,13 +1895,14 @@ fn build_runtime_with_permission_mode( system_prompt: Vec, enable_tools: bool, allowed_tools: Option, + config: &runtime::RuntimeConfig, permission_mode: &str, ) -> Result, Box> { Ok(ConversationRuntime::new( session, - AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?, - CliToolExecutor::new(allowed_tools), + AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), config)?, + CliToolExecutor::new(allowed_tools, config), permission_policy(permission_mode), system_prompt, )) @@ -1859,6 +1914,7 @@ struct AnthropicRuntimeClient { model: String, enable_tools: bool, allowed_tools: Option, + mcp_tool_definitions: Vec, } impl AnthropicRuntimeClient { @@ -1866,17 +1922,49 @@ impl AnthropicRuntimeClient { model: String, enable_tools: bool, allowed_tools: Option, + config: &runtime::RuntimeConfig, ) -> Result> { + let mcp_tool_definitions = discover_mcp_tool_definitions(config, allowed_tools.as_ref())?; Ok(Self { runtime: tokio::runtime::Runtime::new()?, client: AnthropicClient::from_auth(resolve_cli_auth_source()?), model, enable_tools, allowed_tools, + mcp_tool_definitions, }) } } +fn discover_mcp_tool_definitions( + config: &runtime::RuntimeConfig, + allowed_tools: Option<&AllowedToolSet>, +) -> Result, Box> { + if allowed_tools.is_some() || config.mcp().servers().is_empty() { + return Ok(Vec::new()); + } + + let runtime = tokio::runtime::Runtime::new()?; + let tools = runtime.block_on(async { + let mut manager = McpServerManager::from_runtime_config(config); + let tools = manager.discover_tools().await?; + manager.shutdown().await?; + Ok::<_, runtime::McpServerManagerError>(tools) + })?; + + Ok(tools + .into_iter() + .map(|tool| ToolDefinition { + name: tool.qualified_name, + description: tool.tool.description, + input_schema: tool + .tool + .input_schema + .unwrap_or_else(|| serde_json::json!({"type":"object"})), + }) + .collect()) +} + fn resolve_cli_auth_source() -> Result> { match AuthSource::from_env() { Ok(auth) => Ok(auth), @@ -1910,6 +1998,7 @@ impl ApiClient for AnthropicRuntimeClient { description: Some(spec.description.to_string()), input_schema: spec.input_schema, }) + .chain(self.mcp_tool_definitions.iter().cloned()) .collect() }), tool_choice: self.enable_tools.then_some(ToolChoice::Auto), @@ -2059,13 +2148,22 @@ fn response_to_events( struct CliToolExecutor { renderer: TerminalRenderer, allowed_tools: Option, + mcp_runtime: Option, + mcp_servers: Option, } impl CliToolExecutor { - fn new(allowed_tools: Option) -> Self { + fn new(allowed_tools: Option, config: &runtime::RuntimeConfig) -> Self { + let mcp_servers = (!config.mcp().servers().is_empty()) + .then(|| McpServerManager::from_runtime_config(config)); + let mcp_runtime = mcp_servers + .as_ref() + .map(|_| tokio::runtime::Runtime::new().expect("mcp runtime")); Self { renderer: TerminalRenderer::new(), allowed_tools, + mcp_runtime, + mcp_servers, } } } @@ -2081,8 +2179,35 @@ impl ToolExecutor for CliToolExecutor { "tool `{tool_name}` is not enabled by the current --allowedTools setting" ))); } - let value = serde_json::from_str(input) + let value: serde_json::Value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + if tool_name.starts_with("mcp__") { + let runtime = self + .mcp_runtime + .as_mut() + .ok_or_else(|| ToolError::new("MCP runtime is not configured"))?; + let manager = self + .mcp_servers + .as_mut() + .ok_or_else(|| ToolError::new("MCP servers are not configured"))?; + let response = runtime + .block_on(manager.call_tool(tool_name, Some(value.clone()))) + .map_err(|error| ToolError::new(error.to_string()))?; + let output = serde_json::to_string_pretty(&response) + .map_err(|error| ToolError::new(error.to_string()))?; + let markdown = format!( + "### Tool `{tool_name}` + +```json +{output} +``` +" + ); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|error| ToolError::new(error.to_string()))?; + return Ok(output); + } match execute_tool(tool_name, &value) { Ok(output) => { let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n"); @@ -2195,21 +2320,116 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_init_report, - format_model_report, format_model_switch_report, format_permissions_report, - format_permissions_switch_report, format_resume_report, format_status_report, - normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, - render_init_claude_md, render_memory_report, render_repl_help, - resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, - StatusUsage, DEFAULT_MODEL, + discover_mcp_tool_definitions, filter_tool_specs, format_compact_report, + format_cost_report, format_init_report, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata, + render_config_report, render_init_claude_md, render_memory_report, render_repl_help, + resolved_permission_mode_label, resume_supported_slash_commands, status_context, CliAction, + CliOutputFormat, RuntimeDefaults, SlashCommand, StatusUsage, DEFAULT_MODEL, }; - use runtime::{ContentBlock, ConversationMessage, MessageRole}; + use runtime::{ + ConfigLoader, ContentBlock, ConversationMessage, MessageRole, ResolvedPermissionMode, + }; + use std::fs; + use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("rusty-claude-cli-tests-{nanos}")) + } + + fn write_mcp_server_script() -> PathBuf { + let root = temp_dir(); + fs::create_dir_all(&root).expect("temp dir"); + let path = root.join("mcp-server.py"); + fs::write( + &path, + r#"#!/usr/bin/env python3 +import json +import sys + +def send(obj): + payload = json.dumps(obj) + sys.stdout.write(f"Content-Length: {len(payload)}\r\n\r\n{payload}") + sys.stdout.flush() + +def read_request(): + headers = {} + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + if line in (b"\r\n", b"\n"): + break + key, _, value = line.decode().partition(":") + headers[key.strip().lower()] = value.strip() + length = int(headers.get("content-length", "0")) + if length <= 0: + return None + payload = sys.stdin.buffer.read(length) + return json.loads(payload.decode()) + +while True: + req = read_request() + if req is None: + break + method = req.get("method") + req_id = req.get("id") + if method == "initialize": + send({ + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": {"name": "test-server", "version": "0.1.0"} + } + }) + elif method == "tools/list": + send({ + "jsonrpc": "2.0", + "id": req_id, + "result": { + "tools": [{ + "name": "echo", + "description": "Echo from MCP", + "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}} + }] + } + }) + elif method == "tools/call": + send({ + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": req.get("params", {}).get("name", "")}] + } + }) +"#, + ) + .expect("write mcp server"); + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod"); + path + } #[test] fn defaults_to_repl_when_no_args() { assert_eq!( - parse_args(&[]).expect("args should parse"), + parse_args( + &[], + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: None, @@ -2217,6 +2437,42 @@ mod tests { ); } + #[test] + fn parse_args_uses_config_default_model_when_no_override_is_supplied() { + let parsed = parse_args( + &[], + &RuntimeDefaults { + model: "claude-opus-config".to_string(), + }, + ) + .expect("args should parse"); + assert_eq!( + parsed, + CliAction::Repl { + model: "claude-opus-config".to_string(), + allowed_tools: None, + } + ); + } + + #[test] + fn explicit_model_flag_beats_config_default_model() { + let parsed = parse_args( + &["--model".to_string(), "cli-model".to_string()], + &RuntimeDefaults { + model: "config-model".to_string(), + }, + ) + .expect("args should parse"); + assert_eq!( + parsed, + CliAction::Repl { + model: "cli-model".to_string(), + allowed_tools: None, + } + ); + } + #[test] fn parses_prompt_subcommand() { let args = vec![ @@ -2225,7 +2481,13 @@ mod tests { "world".to_string(), ]; assert_eq!( - parse_args(&args).expect("args should parse"), + parse_args( + &args, + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::Prompt { prompt: "hello world".to_string(), model: DEFAULT_MODEL.to_string(), @@ -2245,7 +2507,13 @@ mod tests { "this".to_string(), ]; assert_eq!( - parse_args(&args).expect("args should parse"), + parse_args( + &args, + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::Prompt { prompt: "explain this".to_string(), model: "claude-opus".to_string(), @@ -2258,11 +2526,23 @@ mod tests { #[test] fn parses_version_flags_without_initializing_prompt_mode() { assert_eq!( - parse_args(&["--version".to_string()]).expect("args should parse"), + parse_args( + &["--version".to_string()], + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::Version ); assert_eq!( - parse_args(&["-V".to_string()]).expect("args should parse"), + parse_args( + &["-V".to_string()], + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::Version ); } @@ -2275,7 +2555,13 @@ mod tests { "--allowed-tools=write_file".to_string(), ]; assert_eq!( - parse_args(&args).expect("args should parse"), + parse_args( + &args, + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: Some( @@ -2290,8 +2576,13 @@ mod tests { #[test] fn rejects_unknown_allowed_tools() { - let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()]) - .expect_err("tool should be rejected"); + let error = parse_args( + &["--allowedTools".to_string(), "teleport".to_string()], + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string(), + }, + ) + .expect_err("tool should be rejected"); assert!(error.contains("unsupported tool in --allowedTools: teleport")); } @@ -2305,7 +2596,13 @@ mod tests { "2026-04-01".to_string(), ]; assert_eq!( - parse_args(&args).expect("args should parse"), + parse_args( + &args, + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::PrintSystemPrompt { cwd: PathBuf::from("/tmp/project"), date: "2026-04-01".to_string(), @@ -2316,11 +2613,23 @@ mod tests { #[test] fn parses_login_and_logout_subcommands() { assert_eq!( - parse_args(&["login".to_string()]).expect("login should parse"), + parse_args( + &["login".to_string()], + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string(), + }, + ) + .expect("login should parse"), CliAction::Login ); assert_eq!( - parse_args(&["logout".to_string()]).expect("logout should parse"), + parse_args( + &["logout".to_string()], + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string(), + }, + ) + .expect("logout should parse"), CliAction::Logout ); } @@ -2333,7 +2642,13 @@ mod tests { "/compact".to_string(), ]; assert_eq!( - parse_args(&args).expect("args should parse"), + parse_args( + &args, + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.json"), commands: vec!["/compact".to_string()], @@ -2351,7 +2666,13 @@ mod tests { "/cost".to_string(), ]; assert_eq!( - parse_args(&args).expect("args should parse"), + parse_args( + &args, + &RuntimeDefaults { + model: DEFAULT_MODEL.to_string() + } + ) + .expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.json"), commands: vec![ @@ -2586,10 +2907,25 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert_eq!(context.discovered_config_files, 5); assert!(context.loaded_config_files <= context.discovered_config_files); } + #[test] + fn resolved_permission_mode_prefers_env_override() { + let original = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok(); + std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "danger-full-access"); + let config = runtime::RuntimeConfig::empty(); + assert_eq!( + super::resolved_permission_mode_label(&config), + "danger-full-access" + ); + if let Some(value) = original { + std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value); + } else { + std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + } + } #[test] fn normalizes_supported_permission_modes() { assert_eq!(normalize_permission_mode("read-only"), Some("read-only")); @@ -2604,6 +2940,66 @@ mod tests { assert_eq!(normalize_permission_mode("unknown"), None); } + #[test] + fn resolves_permission_mode_from_config_defaults() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + fs::write( + cwd.join(".claude").join("settings.json"), + r#"{"permissions":{"defaultMode":"dontAsk"}}"#, + ) + .expect("write settings"); + + let config = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + assert_eq!( + config.permission_mode(), + Some(ResolvedPermissionMode::DangerFullAccess) + ); + assert_eq!( + resolved_permission_mode_label(&config), + "danger-full-access" + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn discovers_mcp_tool_definitions_from_config() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claude"); + fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + let script = write_mcp_server_script(); + fs::write( + cwd.join(".claude").join("settings.json"), + format!( + r#"{{"mcpServers":{{"alpha":{{"command":"{}"}}}}}}"#, + script.display() + ), + ) + .expect("write settings"); + + let config = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + let tool_defs = + discover_mcp_tool_definitions(&config, None).expect("mcp tool definitions should load"); + + assert_eq!(tool_defs.len(), 1); + assert_eq!(tool_defs[0].name, "mcp__alpha__echo"); + assert_eq!(tool_defs[0].description.as_deref(), Some("Echo from MCP")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + fs::remove_file(&script).ok(); + fs::remove_dir_all(script.parent().expect("script parent")).ok(); + } + #[test] fn clear_command_requires_explicit_confirmation_flag() { assert_eq!( From 146260083c6a593ecd020a1924fd824de1809f25 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 00:58:14 +0000 Subject: [PATCH 2/2] Persist CLI conversation history across sessions The Rust CLI now stores managed sessions under ~/.claude/sessions, records additive session metadata in the canonical JSON transcript, and exposes a /sessions listing alias alongside ID-or-path resume. Inactive oversized sessions are compacted automatically so old transcripts remain resumable without growing unchecked. Constraint: Session JSON must stay backward-compatible with legacy files that lack metadata Constraint: Managed sessions must use a single canonical JSON file per session without new dependencies Rejected: Sidecar metadata/index files | duplicated state and diverged from the requested single-file persistence model Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep CLI policy in the CLI; only add transcript-adjacent metadata to runtime::Session unless another consumer truly needs more Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: Manual interactive REPL smoke test against the live Anthropic API --- rust/README.md | 10 +- rust/crates/commands/src/lib.rs | 22 ++- rust/crates/runtime/src/compact.rs | 3 + rust/crates/runtime/src/conversation.rs | 8 +- rust/crates/runtime/src/lib.rs | 4 +- rust/crates/runtime/src/session.rs | 92 ++++++++- rust/crates/runtime/src/usage.rs | 1 + rust/crates/rusty-claude-cli/src/main.rs | 239 ++++++++++++++++++++--- 8 files changed, 342 insertions(+), 37 deletions(-) diff --git a/rust/README.md b/rust/README.md index f5fb366..2934027 100644 --- a/rust/README.md +++ b/rust/README.md @@ -133,6 +133,7 @@ Inside the REPL, useful commands include: /diff /version /export notes.txt +/sessions /session list /exit ``` @@ -143,14 +144,14 @@ Inspect or maintain a saved session file without entering the REPL: ```bash cd rust -cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost +cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost ``` You can also inspect memory/config state for a restored session: ```bash cd rust -cargo run -p rusty-claude-cli -- --resume session.json /memory /config +cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config ``` ## Available commands @@ -158,7 +159,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config ### Top-level CLI commands - `prompt ` — run one prompt non-interactively -- `--resume [/commands...]` — inspect or maintain a saved session +- `--resume [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/` - `dump-manifests` — print extracted upstream manifest counts - `bootstrap-plan` — print the current bootstrap skeleton - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt @@ -176,13 +177,14 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions - `/clear [--confirm]` — clear the current local session - `/cost` — show token usage totals -- `/resume ` — load a saved session into the REPL +- `/resume ` — load a saved session into the REPL - `/config [env|hooks|model]` — inspect discovered Claude config - `/memory` — inspect loaded instruction memory files - `/init` — create a starter `CLAUDE.md` - `/diff` — show the current git diff for the workspace - `/version` — print version and build metadata locally - `/export [file]` — export the current conversation transcript +- `/sessions` — list recent managed local sessions from `~/.claude/sessions/` - `/session [list|switch ]` — inspect or switch managed local sessions - `/exit` — leave the REPL diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b396bb0..3ac9a52 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "resume", summary: "Load a saved session into the REPL", - argument_hint: Some(""), + argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { @@ -129,6 +129,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[list|switch ]"), resume_supported: false, }, + SlashCommandSpec { + name: "sessions", + summary: "List recent managed local sessions", + argument_hint: None, + resume_supported: false, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -163,6 +169,7 @@ pub enum SlashCommand { action: Option, target: Option, }, + Sessions, Unknown(String), } @@ -207,6 +214,7 @@ impl SlashCommand { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, + "sessions" => Self::Sessions, other => Self::Unknown(other.to_string()), }) } @@ -291,6 +299,7 @@ pub fn handle_slash_command( | SlashCommand::Version | SlashCommand::Export { .. } | SlashCommand::Session { .. } + | SlashCommand::Sessions | SlashCommand::Unknown(_) => None, } } @@ -365,6 +374,10 @@ mod tests { target: Some("abc123".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/sessions"), + Some(SlashCommand::Sessions) + ); } #[test] @@ -378,7 +391,7 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); - assert!(help.contains("/resume ")); + assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); @@ -386,7 +399,8 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert_eq!(slash_command_specs().len(), 15); + assert!(help.contains("/sessions")); + assert_eq!(slash_command_specs().len(), 16); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -404,6 +418,7 @@ mod tests { text: "recent".to_string(), }]), ], + metadata: None, }; let result = handle_slash_command( @@ -468,5 +483,6 @@ mod tests { assert!( handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() ); + assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none()); } } diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e227019..8a63253 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -105,6 +105,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio compacted_session: Session { version: session.version, messages: compacted_messages, + metadata: session.metadata.clone(), }, removed_message_count: removed.len(), } @@ -393,6 +394,7 @@ mod tests { let session = Session { version: 1, messages: vec![ConversationMessage::user_text("hello")], + metadata: None, }; let result = compact_session(&session, CompactionConfig::default()); @@ -420,6 +422,7 @@ mod tests { usage: None, }, ], + metadata: None, }; let result = compact_session( diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..136aaa2 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,7 +408,7 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -487,7 +487,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), vec!["system".to_string()], ); @@ -536,7 +536,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -563,7 +563,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index a13ae2d..ebc0035 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -73,7 +73,9 @@ pub use remote::{ RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, }; -pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; +pub use session::{ + ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata, +}; pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index beaa435..737cdef 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -39,10 +39,19 @@ pub struct ConversationMessage { pub usage: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionMetadata { + pub started_at: String, + pub model: String, + pub message_count: u32, + pub last_prompt: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { pub version: u32, pub messages: Vec, + pub metadata: Option, } #[derive(Debug)] @@ -82,6 +91,7 @@ impl Session { Self { version: 1, messages: Vec::new(), + metadata: None, } } @@ -111,6 +121,9 @@ impl Session { .collect(), ), ); + if let Some(metadata) = &self.metadata { + object.insert("metadata".to_string(), metadata.to_json()); + } JsonValue::Object(object) } @@ -131,7 +144,15 @@ impl Session { .iter() .map(ConversationMessage::from_json) .collect::, _>>()?; - Ok(Self { version, messages }) + let metadata = object + .get("metadata") + .map(SessionMetadata::from_json) + .transpose()?; + Ok(Self { + version, + messages, + metadata, + }) } } @@ -141,6 +162,41 @@ impl Default for Session { } } +impl SessionMetadata { + #[must_use] + pub fn to_json(&self) -> JsonValue { + let mut object = BTreeMap::new(); + object.insert( + "started_at".to_string(), + JsonValue::String(self.started_at.clone()), + ); + object.insert("model".to_string(), JsonValue::String(self.model.clone())); + object.insert( + "message_count".to_string(), + JsonValue::Number(i64::from(self.message_count)), + ); + if let Some(last_prompt) = &self.last_prompt { + object.insert( + "last_prompt".to_string(), + JsonValue::String(last_prompt.clone()), + ); + } + JsonValue::Object(object) + } + + fn from_json(value: &JsonValue) -> Result { + let object = value.as_object().ok_or_else(|| { + SessionError::Format("session metadata must be an object".to_string()) + })?; + Ok(Self { + started_at: required_string(object, "started_at")?, + model: required_string(object, "model")?, + message_count: required_u32(object, "message_count")?, + last_prompt: optional_string(object, "last_prompt"), + }) + } +} + impl ConversationMessage { #[must_use] pub fn user_text(text: impl Into) -> Self { @@ -368,6 +424,13 @@ fn required_string( .ok_or_else(|| SessionError::Format(format!("missing {key}"))) } +fn optional_string(object: &BTreeMap, key: &str) -> Option { + object + .get(key) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) +} + fn required_u32(object: &BTreeMap, key: &str) -> Result { let value = object .get(key) @@ -378,7 +441,8 @@ fn required_u32(object: &BTreeMap, key: &str) -> Result = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); @@ -535,7 +536,14 @@ fn print_version() { } fn resume_session(session_path: &Path, commands: &[String]) { - let session = match Session::load_from_path(session_path) { + let handle = match resolve_session_reference(&session_path.display().to_string()) { + Ok(handle) => handle, + Err(error) => { + eprintln!("failed to resolve session: {error}"); + std::process::exit(1); + } + }; + let session = match Session::load_from_path(&handle.path) { Ok(session) => session, Err(error) => { eprintln!("failed to restore session: {error}"); @@ -546,7 +554,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { if commands.is_empty() { println!( "Restored session from {} ({} messages).", - session_path.display(), + handle.path.display(), session.messages.len() ); return; @@ -558,7 +566,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { eprintln!("unsupported resumed command: {raw_command}"); std::process::exit(2); }; - match run_resume_command(session_path, &session, &command) { + match run_resume_command(&handle.path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, @@ -883,6 +891,7 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } + | SlashCommand::Sessions | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -939,6 +948,9 @@ struct ManagedSessionSummary { path: PathBuf, modified_epoch_secs: u64, message_count: usize, + model: Option, + started_at: Option, + last_prompt: Option, } struct LiveCli { @@ -959,6 +971,7 @@ impl LiveCli { ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; + auto_compact_inactive_sessions(&session.id)?; let runtime = build_runtime( Session::new(), model.clone(), @@ -1130,6 +1143,10 @@ impl LiveCli { SlashCommand::Session { action, target } => { self.handle_session_command(action.as_deref(), target.as_deref())? } + SlashCommand::Sessions => { + println!("{}", render_session_list(&self.session.id)?); + false + } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false @@ -1138,7 +1155,10 @@ impl LiveCli { } fn persist_session(&self) -> Result<(), Box> { - self.runtime.session().save_to_path(&self.session.path)?; + let mut session = self.runtime.session().clone(); + session.metadata = Some(derive_session_metadata(&session, &self.model)); + session.save_to_path(&self.session.path)?; + auto_compact_inactive_sessions(&self.session.id)?; Ok(()) } @@ -1283,13 +1303,20 @@ impl LiveCli { session_path: Option, ) -> Result> { let Some(session_ref) = session_path else { - println!("Usage: /resume "); + println!("Usage: /resume "); return Ok(false); }; let handle = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); + if let Some(model) = session + .metadata + .as_ref() + .map(|metadata| metadata.model.clone()) + { + self.model = model; + } self.runtime = build_runtime( session, self.model.clone(), @@ -1366,6 +1393,13 @@ impl LiveCli { let handle = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); + if let Some(model) = session + .metadata + .as_ref() + .map(|metadata| metadata.model.clone()) + { + self.model = model; + } self.runtime = build_runtime( session, self.model.clone(), @@ -1410,8 +1444,10 @@ impl LiveCli { } fn sessions_dir() -> Result> { - let cwd = env::current_dir()?; - let path = cwd.join(".claude").join("sessions"); + let home = env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?; + let path = home.join(".claude").join("sessions"); fs::create_dir_all(&path)?; Ok(path) } @@ -1432,8 +1468,19 @@ fn generate_session_id() -> String { fn resolve_session_reference(reference: &str) -> Result> { let direct = PathBuf::from(reference); + let expanded = if let Some(stripped) = reference.strip_prefix("~/") { + sessions_dir()? + .parent() + .and_then(|claude| claude.parent()) + .map(|home| home.join(stripped)) + .unwrap_or(direct.clone()) + } else { + direct.clone() + }; let path = if direct.exists() { direct + } else if expanded.exists() { + expanded } else { sessions_dir()?.join(format!("{reference}.json")) }; @@ -1463,9 +1510,11 @@ fn list_managed_sessions() -> Result, Box Result, Box Result u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn current_timestamp_rfc3339ish() -> String { + format!("{}Z", current_epoch_secs()) +} + +fn last_prompt_from_session(session: &Session) -> Option { + session + .messages + .iter() + .rev() + .find(|message| message.role == MessageRole::User) + .and_then(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .filter(|text| !text.is_empty()) +} + +fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata { + let started_at = session + .metadata + .as_ref() + .map_or_else(current_timestamp_rfc3339ish, |metadata| { + metadata.started_at.clone() + }); + SessionMetadata { + started_at, + model: model.to_string(), + message_count: session.messages.len().try_into().unwrap_or(u32::MAX), + last_prompt: last_prompt_from_session(session), + } +} + +fn session_age_secs(modified_epoch_secs: u64) -> u64 { + current_epoch_secs().saturating_sub(modified_epoch_secs) +} + +fn auto_compact_inactive_sessions( + active_session_id: &str, +) -> Result<(), Box> { + for summary in list_managed_sessions()? { + if summary.id == active_session_id + || session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS + { + continue; + } + let path = summary.path.clone(); + let Ok(session) = Session::load_from_path(&path) else { + continue; + }; + if !runtime::should_compact(&session, CompactionConfig::default()) { + continue; + } + let mut compacted = + runtime::compact_session(&session, CompactionConfig::default()).compacted_session; + let model = compacted.metadata.as_ref().map_or_else( + || DEFAULT_MODEL.to_string(), + |metadata| metadata.model.clone(), + ); + compacted.metadata = Some(derive_session_metadata(&compacted, &model)); + compacted.save_to_path(&path)?; + } + Ok(()) +} + fn render_repl_help() -> String { [ "REPL".to_string(), @@ -2389,17 +2525,73 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_init_report, - format_model_report, format_model_switch_report, format_permissions_report, - format_permissions_switch_report, format_resume_report, format_status_report, - format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, - parse_git_status_metadata, render_config_report, render_init_claude_md, - render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report, + format_init_report, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions, + normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, + render_init_claude_md, render_memory_report, render_repl_help, + resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat, + SlashCommand, StatusUsage, DEFAULT_MODEL, }; - use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session}; + use std::fs; use std::path::{Path, PathBuf}; + #[test] + fn derive_session_metadata_recomputes_prompt_and_count() { + let mut session = Session::new(); + session + .messages + .push(ConversationMessage::user_text("first prompt")); + session + .messages + .push(ConversationMessage::assistant(vec![ContentBlock::Text { + text: "reply".to_string(), + }])); + let metadata = derive_session_metadata(&session, "claude-test"); + assert_eq!(metadata.model, "claude-test"); + assert_eq!(metadata.message_count, 2); + assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt")); + assert!(metadata.started_at.ends_with('Z')); + } + + #[test] + fn managed_sessions_use_home_directory_and_list_metadata() { + let temp = + std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id())); + let _ = fs::remove_dir_all(&temp); + fs::create_dir_all(&temp).expect("temp home should exist"); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &temp); + + let dir = sessions_dir().expect("sessions dir"); + assert_eq!(dir, temp.join(".claude").join("sessions")); + + let mut session = Session::new(); + session + .messages + .push(ConversationMessage::user_text("persist me")); + session.metadata = Some(derive_session_metadata(&session, "claude-home")); + let file = dir.join("session-test.json"); + session.save_to_path(&file).expect("session save"); + + let listed = list_managed_sessions().expect("session list"); + let found = listed + .into_iter() + .find(|entry| entry.id == "session-test") + .expect("saved session should be listed"); + assert_eq!(found.message_count, 1); + assert_eq!(found.model.as_deref(), Some("claude-home")); + assert_eq!(found.last_prompt.as_deref(), Some("persist me")); + + fs::remove_file(file).ok(); + if let Some(previous_home) = previous_home { + std::env::set_var("HOME", previous_home); + } + fs::remove_dir_all(temp).ok(); + } + #[test] fn defaults_to_repl_when_no_args() { assert_eq!( @@ -2605,7 +2797,8 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); - assert!(help.contains("/resume ")); + assert!(help.contains("/resume ")); + assert!(help.contains("/sessions")); assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); @@ -2797,7 +2990,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert!(context.discovered_config_files >= 3); assert!(context.loaded_config_files <= context.discovered_config_files); }