diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 7fbf6367..8400b507 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1503,6 +1503,7 @@ fn parse_sse_frame( body: trimmed.chars().take(500).collect(), retryable: false, suggested_action: suggested_action_for_status(status), + }); } } @@ -1518,6 +1519,7 @@ fn parse_sse_frame( body: trimmed.chars().take(200).collect(), retryable: false, suggested_action: Some("verify the API endpoint URL is correct".to_string()), + }); } return Ok(None); @@ -1569,6 +1571,7 @@ fn parse_sse_frame( body: payload.chars().take(200).collect(), retryable: false, suggested_action: Some("verify the API endpoint URL is correct".to_string()), + }); } serde_json::from_str::(&payload) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index de9af6c3..d431ad9f 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1180,6 +1180,9 @@ pub enum SlashCommand { count: Option, }, Unknown(String), + Team { + action: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1277,6 +1280,7 @@ impl SlashCommand { Self::Tag { .. } => "/tag", Self::OutputStyle { .. } => "/output-style", Self::AddDir { .. } => "/add-dir", + Self::Team { .. } => "/team", Self::Sandbox => "/sandbox", Self::Mcp { .. } => "/mcp", Self::Export { .. } => "/export", @@ -4312,6 +4316,7 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } + | SlashCommand::Team { .. } | SlashCommand::Unknown(_) => None, } } diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index c1ec17d4..874c59d9 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -1230,4 +1230,35 @@ mod tests { ); fs::remove_dir_all(base).expect("temp dir should clean up"); } + + /// #160 regression: store-level list_sessions/session_exists/delete_session + /// lifecycle works end-to-end. + #[test] + fn session_store_lifecycle_regression_160() { + // given + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let session = persist_session_via_store(&store, "160 regression test"); + + // when/then — session exists and is listed before deletion + assert!(!store.list_sessions().expect("list").is_empty(), + "store should have at least one session"); + assert!(store.session_exists(&session.session_id), + "session should exist before deletion"); + + // when — delete the session + let deleted = store.delete_session(&session.session_id) + .expect("delete should succeed"); + + // then — session is gone + assert_eq!(deleted.id, session.session_id); + assert!(!deleted.path.exists(), "session file should be removed"); + assert!(!store.session_exists(&session.session_id), + "session should not exist after deletion"); + assert!(store.list_sessions().expect("list").is_empty(), + "store should have no sessions after deletion"); + + fs::remove_dir_all(base).expect("temp dir should clean up"); + } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a3e04434..02089be9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -274,6 +274,8 @@ fn classify_error_kind(message: &str) -> &'static str { "no_managed_sessions" } else if message.contains("unsupported ACP invocation") { "unsupported_acp_invocation" + } else if message.contains("unsupported skills action") { + "unsupported_skills_action" } else if message.contains("unrecognized argument") || message.contains("unknown option") { "cli_parse" } else if message.contains("invalid model syntax") { @@ -877,6 +879,32 @@ fn parse_args(args: &[String]) -> Result { } if wants_help { + // #684: --help before subcommand should still route to subcommand-specific + // help when the subcommand is one of the local-help-topic commands. + if let Some(action) = parse_local_help_action(&rest, output_format) { + return action?; + } + // When --help was consumed before the subcommand, rest has no help flag. + // If rest is a simple local-help subcommand with no extra args, route there. + if !rest.is_empty() && rest[1..].iter().all(|a| is_help_flag(a)) { + let topic = match rest[0].as_str() { + "status" => Some(LocalHelpTopic::Status), + "sandbox" => Some(LocalHelpTopic::Sandbox), + "doctor" => Some(LocalHelpTopic::Doctor), + "acp" => Some(LocalHelpTopic::Acp), + "init" => Some(LocalHelpTopic::Init), + "state" => Some(LocalHelpTopic::State), + "export" => Some(LocalHelpTopic::Export), + "version" => Some(LocalHelpTopic::Version), + "system-prompt" => Some(LocalHelpTopic::SystemPrompt), + "dump-manifests" => Some(LocalHelpTopic::DumpManifests), + "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + _ => None, + }; + if let Some(topic) = topic { + return Ok(CliAction::HelpTopic { topic, output_format }); + } + } return Ok(CliAction::Help { output_format }); } @@ -1020,6 +1048,14 @@ fn parse_args(args: &[String]) -> Result { ), "skills" => { let args = join_optional_args(&rest[1..]); + if let Some(action) = args.as_deref() { + let first_word = action.split_whitespace().next().unwrap_or(action); + if matches!(first_word, "remove" | "add" | "uninstall" | "delete") { + return Err(format!( + "unsupported skills action: {first_word}. Supported actions: list, install , help, or [args]" + )); + } + } match classify_skills_slash_command(args.as_deref()) { SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { prompt, @@ -1117,7 +1153,10 @@ fn parse_local_help_action( rest: &[String], output_format: CliOutputFormat, ) -> Option> { - if rest.len() != 2 || !is_help_flag(&rest[1]) { + if rest.is_empty() { + return None; + } + if !rest.iter().any(|a| is_help_flag(a)) { return None; } @@ -1126,10 +1165,6 @@ fn parse_local_help_action( "sandbox" => LocalHelpTopic::Sandbox, "doctor" => LocalHelpTopic::Doctor, "acp" => LocalHelpTopic::Acp, - // #141: add the subcommands that were previously falling back - // to global help (init/state/export/version) or erroring out - // (system-prompt/dump-manifests) or printing their primary - // output instead of help text (bootstrap-plan). "init" => LocalHelpTopic::Init, "state" => LocalHelpTopic::State, "export" => LocalHelpTopic::Export, @@ -1139,6 +1174,10 @@ fn parse_local_help_action( "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, _ => return None, }; + let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); + if has_non_help { + return None; + } Some(Ok(CliAction::HelpTopic { topic, output_format, @@ -1172,8 +1211,9 @@ fn parse_single_word_command_alias( if is_diagnostic && rest.len() > 1 { // Diagnostic verb with trailing args: reject unrecognized suffix - if is_help_flag(&rest[1]) && rest.len() == 2 { - // "doctor --help" is valid, routed to parse_local_help_action() instead + let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a)); + if all_extra_are_help { + // "doctor --help -h" is valid, routed to parse_local_help_action() instead return None; } // Unrecognized suffix like "--json" @@ -4209,7 +4249,8 @@ fn run_resume_command( | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } + | SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()), } } @@ -5456,7 +5497,8 @@ impl LiveCli { | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => { + | SlashCommand::AddDir { .. } + | SlashCommand::Team { .. } => { let cmd_name = command.slash_name(); eprintln!("{cmd_name} is not yet implemented in this build."); false @@ -12711,6 +12753,23 @@ mod tests { assert!(error.contains("skills")); } + #[test] + fn unsupported_skills_actions_return_typed_error_683() { + for action in ["remove", "add", "uninstall", "delete"] { + let error = parse_args(&["skills".to_string(), action.to_string()]) + .expect_err(&format!("skills {action} should error")); + assert!( + error.contains("unsupported skills action"), + "skills {action} should contain 'unsupported skills action', got: {error}" + ); + assert_eq!( + classify_error_kind(&error), + "unsupported_skills_action", + "skills {action} should classify as unsupported_skills_action, got: {error}" + ); + } + } + #[test] fn typoed_status_subcommand_returns_did_you_mean_error() { let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error");