Parcourir la source

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 Claw 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
Yeachan-Heo il y a 2 mois
Parent
commit
da7b8a758a
2 fichiers modifiés avec 108 ajouts et 5 suppressions
  1. 35 1
      rust/crates/commands/src/lib.rs
  2. 73 4
      rust/crates/rusty-claude-cli/src/main.rs

+ 35 - 1
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("<session-path>"),
+    },
+    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<String> },
     Clear,
     Cost,
+    Resume { session_path: Option<String> },
+    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 <session-path>"));
+        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());
     }
 }

+ 73 - 4
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<String>,
+    ) -> Result<(), Box<dyn std::error::Error>> {
+        let Some(session_path) = session_path else {
+            println!("Usage: /resume <session-path>");
+            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<dyn std::error::Error>> {
+        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<dyn std::error::Error>> {
         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 <session-path>"));
+        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![