From 321a1a681aa7d2046c564fafe009a67acd5ec0e3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:45:25 +0000 Subject: [PATCH] feat(cli): add resume and config inspection commands Add in-REPL session restoration and read-only config inspection so the CLI can recover saved conversations and expose Claude settings without leaving interactive mode. /resume now reloads a session file into the live runtime, and /config shows discovered settings files plus the merged effective JSON. The new commands stay on the shared slash-command surface and rebuild runtime state using the current model, system prompt, and permission mode so existing REPL behavior remains stable. Constraint: /resume must update the live REPL session rather than only supporting top-level --resume Constraint: /config should inspect existing settings without mutating user files Rejected: Add editable /config writes in this slice | read-only inspection is safer and sufficient for immediate parity work Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep resume/config behavior on the shared slash command surface so non-REPL entrypoints can reuse it later Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual interactive restore against real saved session files outside automated fixtures --- rust/crates/commands/src/lib.rs | 36 ++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 77 ++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 6ca2cdf..a60975e 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -73,6 +73,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Show cumulative token usage for this session", argument_hint: None, }, + SlashCommandSpec { + name: "resume", + summary: "Load a saved session into the REPL", + argument_hint: Some(""), + }, + SlashCommandSpec { + name: "config", + summary: "Inspect discovered Claude config files", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -84,6 +94,8 @@ pub enum SlashCommand { Permissions { mode: Option }, Clear, Cost, + Resume { session_path: Option }, + Config, Unknown(String), } @@ -109,6 +121,10 @@ impl SlashCommand { }, "clear" => Self::Clear, "cost" => Self::Cost, + "resume" => Self::Resume { + session_path: parts.next().map(ToOwned::to_owned), + }, + "config" => Self::Config, other => Self::Unknown(other.to_string()), }) } @@ -169,6 +185,8 @@ pub fn handle_slash_command( | SlashCommand::Permissions { .. } | SlashCommand::Clear | SlashCommand::Cost + | SlashCommand::Resume { .. } + | SlashCommand::Config | SlashCommand::Unknown(_) => None, } } @@ -202,6 +220,13 @@ mod tests { ); assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear)); assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost)); + assert_eq!( + SlashCommand::parse("/resume session.json"), + Some(SlashCommand::Resume { + session_path: Some("session.json".to_string()), + }) + ); + assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); } #[test] @@ -214,7 +239,9 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear")); assert!(help.contains("/cost")); - assert_eq!(slash_command_specs().len(), 7); + assert!(help.contains("/resume ")); + assert!(help.contains("/config")); + assert_eq!(slash_command_specs().len(), 9); } #[test] @@ -272,5 +299,12 @@ mod tests { .is_none()); assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command( + "/resume session.json", + &session, + CompactionConfig::default() + ) + .is_none()); + assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b703a22..1c998ae 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -15,9 +15,9 @@ use commands::{handle_slash_command, render_slash_command_help, SlashCommand}; use compat_harness::{extract_manifest, UpstreamPaths}; use render::{Spinner, TerminalRenderer}; use runtime::{ - load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ContentBlock, - ConversationMessage, ConversationRuntime, MessageRole, PermissionMode, PermissionPolicy, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, + ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, + PermissionMode, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, }; use tools::{execute_tool, mvp_tool_specs}; @@ -326,6 +326,8 @@ impl LiveCli { SlashCommand::Permissions { mode } => self.set_permissions(mode)?, SlashCommand::Clear => self.clear_session()?, SlashCommand::Cost => self.print_cost(), + SlashCommand::Resume { session_path } => self.resume_session(session_path)?, + SlashCommand::Config => Self::print_config()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -419,6 +421,60 @@ impl LiveCli { ); } + fn resume_session( + &mut self, + session_path: Option, + ) -> Result<(), Box> { + let Some(session_path) = session_path else { + println!("Usage: /resume "); + return Ok(()); + }; + + let session = Session::load_from_path(&session_path)?; + let message_count = session.messages.len(); + self.runtime = build_runtime_with_permission_mode( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + permission_mode_label(), + )?; + println!("Resumed session from {session_path} ({message_count} messages)."); + Ok(()) + } + + fn print_config() -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + println!( + "config: loaded_files={} merged_keys={}", + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + ); + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + println!(" {source:<7} {status:<7} {}", entry.path.display()); + } + println!(" merged {}", runtime_config.as_json().render()); + Ok(()) + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -798,7 +854,7 @@ fn print_help() { mod tests { use super::{ format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction, - DEFAULT_MODEL, + SlashCommand, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::PathBuf; @@ -872,6 +928,8 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear")); assert!(help.contains("/cost")); + assert!(help.contains("/resume ")); + assert!(help.contains("/config")); assert!(help.contains("/exit")); } @@ -917,6 +975,17 @@ mod tests { assert_eq!(normalize_permission_mode("unknown"), None); } + #[test] + fn parses_resume_and_config_slash_commands() { + assert_eq!( + SlashCommand::parse("/resume saved-session.json"), + Some(SlashCommand::Resume { + session_path: Some("saved-session.json".to_string()) + }) + ); + assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); + } + #[test] fn converts_tool_roundtrip_messages() { let messages = vec![