Improve resumed CLI workflows beyond one-shot inspection
Extend --resume so operators can run multiple safe slash commands in sequence against a saved session file, including mutating maintenance actions like /compact and /clear plus useful local /init scaffolding. This brings resumed sessions closer to the live REPL command surface without pretending unsupported runtime-bound commands work offline. Constraint: Resumed sessions only have serialized session state, not a live model client or interactive runtime Rejected: Support every slash command under --resume | model and permission changes do not affect offline saved-session inspection meaningfully Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep --resume limited to commands that can operate purely from session files or local filesystem context Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace Not-tested: Manual interactive smoke test of chained --resume commands in a shell session
This commit is contained in:
@@ -42,8 +42,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||
CliAction::ResumeSession {
|
||||
session_path,
|
||||
command,
|
||||
} => resume_session(&session_path, command),
|
||||
commands,
|
||||
} => resume_session(&session_path, &commands),
|
||||
CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
|
||||
CliAction::Repl { model } => run_repl(model)?,
|
||||
CliAction::Help => print_help(),
|
||||
@@ -61,7 +61,7 @@ enum CliAction {
|
||||
},
|
||||
ResumeSession {
|
||||
session_path: PathBuf,
|
||||
command: Option<String>,
|
||||
commands: Vec<String>,
|
||||
},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
@@ -156,13 +156,16 @@ fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
||||
.first()
|
||||
.ok_or_else(|| "missing session path for --resume".to_string())
|
||||
.map(PathBuf::from)?;
|
||||
let command = args.get(1).cloned();
|
||||
if args.len() > 2 {
|
||||
return Err("--resume accepts at most one trailing slash command".to_string());
|
||||
let commands = args[1..].to_vec();
|
||||
if commands
|
||||
.iter()
|
||||
.any(|command| !command.trim_start().starts_with('/'))
|
||||
{
|
||||
return Err("--resume trailing arguments must be slash commands".to_string());
|
||||
}
|
||||
Ok(CliAction::ResumeSession {
|
||||
session_path,
|
||||
command,
|
||||
commands,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ fn print_system_prompt(cwd: PathBuf, date: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_session(session_path: &Path, command: Option<String>) {
|
||||
fn resume_session(session_path: &Path, commands: &[String]) {
|
||||
let session = match Session::load_from_path(session_path) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
@@ -207,39 +210,55 @@ fn resume_session(session_path: &Path, command: Option<String>) {
|
||||
}
|
||||
};
|
||||
|
||||
match command.as_deref().and_then(SlashCommand::parse) {
|
||||
Some(command) => match run_resume_command(session_path, &session, &command) {
|
||||
Ok(Some(message)) => println!("{message}"),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
None if command.is_some() => {
|
||||
eprintln!(
|
||||
"unsupported resumed command: {}",
|
||||
command.unwrap_or_default()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
None => {
|
||||
if commands.is_empty() {
|
||||
println!(
|
||||
"Restored session from {} ({} messages).",
|
||||
session_path.display(),
|
||||
session.messages.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut session = session;
|
||||
for raw_command in commands {
|
||||
let Some(command) = SlashCommand::parse(raw_command) else {
|
||||
eprintln!("unsupported resumed command: {raw_command}");
|
||||
std::process::exit(2);
|
||||
};
|
||||
match run_resume_command(session_path, &session, &command) {
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: next_session,
|
||||
message,
|
||||
}) => {
|
||||
session = next_session;
|
||||
if let Some(message) = message {
|
||||
println!("{message}");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResumeCommandOutcome {
|
||||
session: Session,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
fn run_resume_command(
|
||||
session_path: &Path,
|
||||
session: &Session,
|
||||
command: &SlashCommand,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
SlashCommand::Help => Ok(Some(render_repl_help())),
|
||||
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_repl_help()),
|
||||
}),
|
||||
SlashCommand::Compact => {
|
||||
let Some(result) = handle_slash_command(
|
||||
"/compact",
|
||||
@@ -249,41 +268,73 @@ fn run_resume_command(
|
||||
..CompactionConfig::default()
|
||||
},
|
||||
) else {
|
||||
return Ok(None);
|
||||
return Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: None,
|
||||
});
|
||||
};
|
||||
result.session.save_to_path(session_path)?;
|
||||
Ok(Some(result.message))
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: result.session,
|
||||
message: Some(result.message),
|
||||
})
|
||||
}
|
||||
SlashCommand::Clear => {
|
||||
let cleared = Session::new();
|
||||
cleared.save_to_path(session_path)?;
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: cleared,
|
||||
message: Some(format!(
|
||||
"Cleared resumed session file {}.",
|
||||
session_path.display()
|
||||
)),
|
||||
})
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
let usage = UsageTracker::from_session(session).cumulative_usage();
|
||||
Ok(Some(format_status_line(
|
||||
let tracker = UsageTracker::from_session(session);
|
||||
let usage = tracker.cumulative_usage();
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(format_status_line(
|
||||
"restored-session",
|
||||
session.messages.len(),
|
||||
UsageTracker::from_session(session).turns(),
|
||||
UsageTracker::from_session(session).current_turn_usage(),
|
||||
tracker.turns(),
|
||||
tracker.current_turn_usage(),
|
||||
usage,
|
||||
0,
|
||||
permission_mode_label(),
|
||||
)))
|
||||
)),
|
||||
})
|
||||
}
|
||||
SlashCommand::Cost => {
|
||||
let usage = UsageTracker::from_session(session).cumulative_usage();
|
||||
Ok(Some(format!(
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(format!(
|
||||
"cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cache_read_input_tokens,
|
||||
usage.total_tokens(),
|
||||
)))
|
||||
)),
|
||||
})
|
||||
}
|
||||
SlashCommand::Config => Ok(Some(render_config_report()?)),
|
||||
SlashCommand::Memory => Ok(Some(render_memory_report()?)),
|
||||
SlashCommand::Config => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_config_report()?),
|
||||
}),
|
||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_memory_report()?),
|
||||
}),
|
||||
SlashCommand::Init => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(init_claude_md()?),
|
||||
}),
|
||||
SlashCommand::Resume { .. }
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
||||
}
|
||||
}
|
||||
@@ -1021,7 +1072,7 @@ fn print_help() {
|
||||
println!(" rusty-claude-cli dump-manifests");
|
||||
println!(" rusty-claude-cli bootstrap-plan");
|
||||
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
||||
println!(" rusty-claude-cli --resume SESSION.json [/compact]");
|
||||
println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1088,7 +1139,29 @@ mod tests {
|
||||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("session.json"),
|
||||
command: Some("/compact".to_string()),
|
||||
commands: vec!["/compact".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_resume_flag_with_multiple_slash_commands() {
|
||||
let args = vec![
|
||||
"--resume".to_string(),
|
||||
"session.json".to_string(),
|
||||
"/status".to_string(),
|
||||
"/compact".to_string(),
|
||||
"/cost".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("session.json"),
|
||||
commands: vec![
|
||||
"/status".to_string(),
|
||||
"/compact".to_string(),
|
||||
"/cost".to_string(),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user