diff --git a/crates/minimal/src/lib.rs b/crates/minimal/src/lib.rs index 854ff2ebd..d178a7503 100644 --- a/crates/minimal/src/lib.rs +++ b/crates/minimal/src/lib.rs @@ -343,7 +343,14 @@ pub struct LsArgs { #[derive(Debug, Args)] pub struct DestroyArgs { /// Session identifier (UUID or session name) - pub session: String, + #[arg(required_unless_present = "all", conflicts_with = "all")] + pub session: Option, + /// Destroy all sessions + #[arg(long)] + pub all: bool, + /// Skip confirmation when destroying all sessions + #[arg(long, short, requires = "all")] + pub force: bool, } #[derive(Debug, Args)] @@ -585,10 +592,10 @@ fn ensure_daemon(global: &GlobalArgs) -> Result<(), anyhow::Error> { .context("Failed to ensure the minimald daemon is running") } -/// Prompt the user with a yes/no question on stderr. Defaults to yes -/// (empty input or Y/y/yes returns true; anything else returns false). -fn confirm(question: &str) -> Result { - eprint!("{question} [Y/n] "); +/// Prompt the user with a yes/no question on stderr. +fn confirm(question: &str, default: bool) -> Result { + let prompt = if default { "[Y/n]" } else { "[y/N]" }; + eprint!("{question} {prompt} "); std::io::stderr().flush().ok(); let mut input = String::new(); @@ -596,9 +603,11 @@ fn confirm(question: &str) -> Result { .read_line(&mut input) .context("reading stdin")?; let trimmed = input.trim(); - Ok(trimmed.is_empty() - || trimmed.eq_ignore_ascii_case("y") - || trimmed.eq_ignore_ascii_case("yes")) + Ok(if trimmed.is_empty() { + default + } else { + trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes") + }) } /// List sessions via the `ListSessions` RPC. @@ -859,7 +868,7 @@ fn offer_mfile_scaffold( // "yes" — and, when a config is discovered under `.minimal/`, the init // writer would clobber it. Only prompt on a real terminal; anywhere else // (and on a declined prompt) carry on without scaffolding. - if !std::io::stdin().is_terminal() || !confirm("Would you like to create one?")? { + if !std::io::stdin().is_terminal() || !confirm("Would you like to create one?", true)? { eprintln!( "Continuing without one; the session gets a default environment. \ Run 'minimal init' to give the project its own config." @@ -1267,20 +1276,80 @@ pub async fn cmd_destroy(global: &GlobalArgs, args: DestroyArgs) -> Result<(), a let mut client = connect_daemon(global).await?; + if args.all { + return destroy_all_sessions(&mut client, args.force).await; + } + + let session = args + .session + .as_deref() + .context("a session or --all is required")?; + let record = resolve_session(&mut client, session).await?; + + destroy_session(&mut client, record.id, record.name.as_deref()).await +} + +async fn destroy_all_sessions( + client: &mut client::Client, + force: bool, +) -> Result<(), anyhow::Error> { + use minimald_rpc::ListSessions; + + let sessions = client + .oneshot_rpc::(()) + .await + .context("ListSessions RPC failed")? + .sessions; + + if sessions.is_empty() { + println!("No active sessions."); + return Ok(()); + } + + if !force { + if !std::io::stdin().is_terminal() { + bail!("refusing to destroy all sessions without confirmation; pass --force") + } + if !confirm(&format!("Destroy all {} sessions?", sessions.len()), false)? { + println!("Aborted."); + return Ok(()); + } + } + + let session_count = sessions.len(); + let mut failures = 0; + for session in sessions { + if let Err(error) = destroy_session(client, session.id, session.name.as_deref()).await { + failures += 1; + eprintln!( + "Failed to destroy session {} ({}): {error:#}", + session.id, + session.name.as_deref().unwrap_or("-") + ); + } + } + + if failures > 0 { + bail!("failed to destroy {failures} of {session_count} sessions") + } + + Ok(()) +} + +async fn destroy_session( + client: &mut client::Client, + id: sessions::SessionId, + name: Option<&str>, +) -> Result<(), anyhow::Error> { use minimald_rpc::{DestroySession, DestroySessionRequest}; - let record = resolve_session(&mut client, &args.session).await?; let resp = client - .oneshot_rpc::(DestroySessionRequest { id: record.id }) + .oneshot_rpc::(DestroySessionRequest { id }) .await .context("DestroySession RPC failed")?; if resp.ok().is_some() { - println!( - "Destroyed session {} ({})", - record.id, - record.name.as_deref().unwrap_or("-") - ); + println!("Destroyed session {} ({})", id, name.unwrap_or("-")); } else { bail!("DestroySession returned an error from the daemon"); } @@ -1566,7 +1635,7 @@ fn run_init_flow(config: mctx::Config, skip_confirm: bool) -> Result<(), anyhow: eprint!("{}", plan.content); eprintln!("---"); eprintln!(); - if !confirm("Continue?")? { + if !confirm("Continue?", true)? { eprintln!("Aborted."); return Ok(()); } diff --git a/crates/minimal/tests/cli.rs b/crates/minimal/tests/cli.rs index 36b5f93e8..08c192a55 100644 --- a/crates/minimal/tests/cli.rs +++ b/crates/minimal/tests/cli.rs @@ -263,7 +263,9 @@ async fn destroy_removes_session() { cmd_destroy( &args, DestroyArgs { - session: session_id.to_string(), + session: Some(session_id.to_string()), + all: false, + force: false, }, ) .await @@ -284,7 +286,9 @@ async fn destroy_by_name() { cmd_destroy( &args, DestroyArgs { - session: "by-name".to_string(), + session: Some("by-name".to_string()), + all: false, + force: false, }, ) .await @@ -297,13 +301,54 @@ async fn destroy_unknown_session_fails() { let result = cmd_destroy( &args, DestroyArgs { - session: "nonexistent".to_string(), + session: Some("nonexistent".to_string()), + all: false, + force: false, }, ) .await; assert!(result.is_err()); } +#[tokio::test] +async fn destroy_all_removes_every_session() { + let (daemon, args) = setup().await; + let _ = create_session(&daemon, "first").await; + let _ = create_session(&daemon, "second").await; + + cmd_destroy( + &args, + DestroyArgs { + session: None, + all: true, + force: true, + }, + ) + .await + .unwrap(); + + let mut client = daemon.server.connect().await; + use minimald_rpc::ListSessions; + let resp = client.call::(&()).await; + assert!(resp.sessions.is_empty()); +} + +#[tokio::test] +async fn destroy_all_succeeds_when_there_are_no_sessions() { + let (_daemon, args) = setup().await; + + cmd_destroy( + &args, + DestroyArgs { + session: None, + all: true, + force: true, + }, + ) + .await + .unwrap(); +} + // --- stop --- #[tokio::test]