diff --git a/crates/aether-cli/src/init/mod.rs b/crates/aether-cli/src/init/mod.rs index af8ad44ce..7df48fe19 100644 --- a/crates/aether-cli/src/init/mod.rs +++ b/crates/aether-cli/src/init/mod.rs @@ -36,13 +36,22 @@ pub struct InitTarget { pub asset_root: PathBuf, } +/// Whether [`apply_init`] may overwrite an existing settings file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverwriteMode { + /// Leave an existing settings file untouched, returning [`InitOutcome::AlreadyInitialized`]. + PreserveExisting, + /// Overwrite an existing settings file. + OverwriteExisting, +} + #[derive(Debug, Clone)] pub struct InitRequest { pub target: InitTargetRequest, pub provider: Option, pub preset: Option, pub harnesses: Vec, - pub force: bool, + pub overwrite: OverwriteMode, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -89,7 +98,13 @@ impl InitTarget { impl InitRequest { pub fn user_onboarding() -> Self { - Self { target: InitTargetRequest::User, provider: None, preset: None, harnesses: vec![], force: false } + Self { + target: InitTargetRequest::User, + provider: None, + preset: None, + harnesses: vec![], + overwrite: OverwriteMode::PreserveExisting, + } } } @@ -98,9 +113,9 @@ pub fn apply_init( provider: Provider, preset: Preset, harnesses: &[HarnessIntegration], - force: bool, + overwrite: OverwriteMode, ) -> Result { - if target.settings_path.is_file() && !force { + if target.settings_path.is_file() && matches!(overwrite, OverwriteMode::PreserveExisting) { return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path }); } @@ -132,7 +147,7 @@ pub fn apply_init( pub async fn run_init(request: InitRequest) -> Result { let target = resolve_target(&request)?; - if target.settings_path.is_file() && !request.force { + if target.settings_path.is_file() && matches!(request.overwrite, OverwriteMode::PreserveExisting) { return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path }); } @@ -142,7 +157,7 @@ pub async fn run_init(request: InitRequest) -> Result { return Ok(InitOutcome::Cancelled); }; - apply_init(target, provider, preset, &harnesses, request.force) + apply_init(target, provider, preset, &harnesses, request.overwrite) } pub fn next_steps_message(outcome: &InitOutcome) -> Option { diff --git a/crates/aether-cli/src/sandbox.rs b/crates/aether-cli/src/sandbox.rs index d685f0786..cb16aa2ce 100644 --- a/crates/aether-cli/src/sandbox.rs +++ b/crates/aether-cli/src/sandbox.rs @@ -38,6 +38,13 @@ struct BindMount { container: PathBuf, } +/// Whether the sandboxed process runs attached to an interactive terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TerminalMode { + Interactive, + NonInteractive, +} + #[derive(Debug, Error)] pub enum SandboxError { #[error("Docker is not installed or not in PATH")] @@ -80,8 +87,8 @@ fn try_exec_in_container(image: &str) -> Result { let aws = aws_bindings(&home, &env_vars); apply_overrides(&mut env_vars, aws.env_overrides); - let tty = io::stdin().is_terminal(); - let docker_args = build_docker_args(image, &cwd, &aether_home, &aws.mounts, &env_vars, &inner_args, tty); + let terminal = if io::stdin().is_terminal() { TerminalMode::Interactive } else { TerminalMode::NonInteractive }; + let docker_args = build_docker_args(image, &cwd, &aether_home, &aws.mounts, &env_vars, &inner_args, terminal); exec_docker(&docker_args) } @@ -191,10 +198,10 @@ fn build_docker_args( mounts: &[BindMount], env_vars: &[(String, String)], inner_args: &[String], - tty: bool, + terminal: TerminalMode, ) -> Vec { let mut args = vec!["run".to_string(), "--rm".to_string(), "-i".to_string()]; - if tty { + if terminal == TerminalMode::Interactive { args.push("-t".to_string()); } args.extend( @@ -362,7 +369,15 @@ mod tests { let env_vars = vec![("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]; let inner_args = vec!["aether".to_string(), "headless".to_string(), "-m".to_string(), "gpt-4".to_string()]; - let args = build_docker_args("test-image:latest", cwd, aether_home, &[], &env_vars, &inner_args, false); + let args = build_docker_args( + "test-image:latest", + cwd, + aether_home, + &[], + &env_vars, + &inner_args, + TerminalMode::NonInteractive, + ); assert!(args.contains(&"run".to_string())); assert!(args.contains(&"--rm".to_string())); @@ -397,7 +412,7 @@ mod tests { &[], &[], &["aether".to_string(), "headless".to_string()], - false, + TerminalMode::NonInteractive, ); assert!(args.contains(&"my-go-sandbox:v2".to_string())); @@ -408,7 +423,15 @@ mod tests { fn build_docker_args_adds_tty_flag_when_requested() { let cwd = Path::new("/tmp"); let aether_home = Path::new("/home/user/.aether"); - let args = build_docker_args("test-image", cwd, aether_home, &[], &[], &["aether".to_string()], true); + let args = build_docker_args( + "test-image", + cwd, + aether_home, + &[], + &[], + &["aether".to_string()], + TerminalMode::Interactive, + ); assert!(args.contains(&"-t".to_string())); assert!(args.contains(&"-i".to_string())); @@ -418,7 +441,15 @@ mod tests { fn build_docker_args_skips_binary_name_only() { let cwd = Path::new("/tmp"); let aether_home = Path::new("/home/user/.aether"); - let args = build_docker_args("test-image:latest", cwd, aether_home, &[], &[], &["aether".to_string()], false); + let args = build_docker_args( + "test-image:latest", + cwd, + aether_home, + &[], + &[], + &["aether".to_string()], + TerminalMode::NonInteractive, + ); // Only the binary name — nothing after image assert_eq!(args.last().unwrap(), "test-image:latest"); @@ -459,7 +490,7 @@ mod tests { &aws.mounts, &env_vars, &["aether".to_string()], - false, + TerminalMode::NonInteractive, ); assert!(args.contains(&format!("{}:/root/.aws/config:ro", home.path().join(".aws/config").display()))); assert!(args.contains(&"AWS_WEB_IDENTITY_TOKEN_FILE=/run/aether-aws/AWS_WEB_IDENTITY_TOKEN_FILE".to_string())); diff --git a/crates/aether-cli/src/settings/mod.rs b/crates/aether-cli/src/settings/mod.rs index 80b3fdf9a..c8e35e2e9 100644 --- a/crates/aether-cli/src/settings/mod.rs +++ b/crates/aether-cli/src/settings/mod.rs @@ -1,4 +1,4 @@ -use crate::init::{HarnessIntegration, InitRequest, InitTargetRequest, Preset}; +use crate::init::{HarnessIntegration, InitRequest, InitTargetRequest, OverwriteMode, Preset}; use clap::{Args, Subcommand}; use llm::catalog::Provider; use std::path::PathBuf; @@ -48,7 +48,13 @@ pub struct SettingsInitArgs { impl From for InitRequest { fn from(args: SettingsInitArgs) -> Self { let target = if args.user { InitTargetRequest::User } else { InitTargetRequest::Project { path: args.path } }; - Self { target, provider: args.provider, preset: args.preset, harnesses: args.harnesses, force: args.force } + Self { + target, + provider: args.provider, + preset: args.preset, + harnesses: args.harnesses, + overwrite: if args.force { OverwriteMode::OverwriteExisting } else { OverwriteMode::PreserveExisting }, + } } } diff --git a/crates/aether-cli/tests/integration/run_init.rs b/crates/aether-cli/tests/integration/run_init.rs index 80d2f5c6e..c0d145b2e 100644 --- a/crates/aether-cli/tests/integration/run_init.rs +++ b/crates/aether-cli/tests/integration/run_init.rs @@ -1,4 +1,4 @@ -use aether_cli::init::{HarnessIntegration, InitError, InitOutcome, InitTarget, Preset, apply_init}; +use aether_cli::init::{HarnessIntegration, InitError, InitOutcome, InitTarget, OverwriteMode, Preset, apply_init}; use aether_core::agent_spec::ToolMatcher; use aether_core::core::Prompt; use aether_project::{AetherSettings, AgentCatalog, McpSourceSpec, PromptSource}; @@ -35,8 +35,14 @@ fn inline_servers(spec: &McpSourceSpec) -> &BTreeMap { #[test] fn writes_user_minimal_preset_for_codex() { let dir = tempfile::tempdir().unwrap(); - let outcome = - apply_init(InitTarget::user(dir.path()), Provider::Codex, Preset::Minimal, &[], false).expect("apply_init"); + let outcome = apply_init( + InitTarget::user(dir.path()), + Provider::Codex, + Preset::Minimal, + &[], + OverwriteMode::PreserveExisting, + ) + .expect("apply_init"); assert!(matches!(outcome, InitOutcome::Applied { .. }), "{outcome:?}"); assert!(dir.path().join("settings.json").is_file()); @@ -60,8 +66,14 @@ fn writes_user_minimal_preset_for_codex() { #[test] fn writes_project_minimal_preset_for_codex() { let dir = tempfile::tempdir().unwrap(); - let outcome = - apply_init(InitTarget::project(dir.path()), Provider::Codex, Preset::Minimal, &[], false).expect("apply_init"); + let outcome = apply_init( + InitTarget::project(dir.path()), + Provider::Codex, + Preset::Minimal, + &[], + OverwriteMode::PreserveExisting, + ) + .expect("apply_init"); assert!(matches!(outcome, InitOutcome::Applied { .. }), "{outcome:?}"); assert!(dir.path().join(".aether/settings.json").is_file()); @@ -90,9 +102,14 @@ fn writes_project_minimal_preset_for_codex() { #[test] fn writes_project_batteries_preset_for_anthropic() { let dir = tempfile::tempdir().unwrap(); - let outcome = - apply_init(InitTarget::project(dir.path()), Provider::Anthropic, Preset::BatteriesIncluded, &[], false) - .expect("apply_init"); + let outcome = apply_init( + InitTarget::project(dir.path()), + Provider::Anthropic, + Preset::BatteriesIncluded, + &[], + OverwriteMode::PreserveExisting, + ) + .expect("apply_init"); assert!(matches!(outcome, InitOutcome::Applied { .. }), "{outcome:?}"); assert!(dir.path().join(".aether/agents/codebase-explorer/AGENTS.md").is_file()); @@ -138,7 +155,7 @@ fn batteries_included_harness_configurations() { Provider::Anthropic, Preset::BatteriesIncluded, &case.harnesses, - false, + OverwriteMode::PreserveExisting, ) .unwrap_or_else(|e| panic!("{}: apply_init failed: {e}", case.name)); @@ -179,7 +196,7 @@ fn batteries_included_harness_skills_dirs_are_ordered_before_project_skills() { Provider::Anthropic, Preset::BatteriesIncluded, &[HarnessIntegration::Agents], - false, + OverwriteMode::PreserveExisting, ) .expect("apply_init"); @@ -206,8 +223,14 @@ fn refuses_to_overwrite_existing_user_settings_without_force() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("settings.json"), "{}").unwrap(); - let outcome = - apply_init(InitTarget::user(dir.path()), Provider::Codex, Preset::Minimal, &[], false).expect("apply_init"); + let outcome = apply_init( + InitTarget::user(dir.path()), + Provider::Codex, + Preset::Minimal, + &[], + OverwriteMode::PreserveExisting, + ) + .expect("apply_init"); assert!(matches!(outcome, InitOutcome::AlreadyInitialized { .. }), "{outcome:?}"); assert_eq!(std::fs::read_to_string(dir.path().join("settings.json")).unwrap(), "{}"); @@ -219,8 +242,14 @@ fn refuses_to_overwrite_existing_project_settings_without_force() { std::fs::create_dir_all(dir.path().join(".aether")).unwrap(); std::fs::write(dir.path().join(".aether/settings.json"), "{}").unwrap(); - let outcome = - apply_init(InitTarget::project(dir.path()), Provider::Codex, Preset::Minimal, &[], false).expect("apply_init"); + let outcome = apply_init( + InitTarget::project(dir.path()), + Provider::Codex, + Preset::Minimal, + &[], + OverwriteMode::PreserveExisting, + ) + .expect("apply_init"); assert!(matches!(outcome, InitOutcome::AlreadyInitialized { .. }), "{outcome:?}"); assert_eq!(std::fs::read_to_string(dir.path().join(".aether/settings.json")).unwrap(), "{}"); @@ -233,8 +262,14 @@ fn force_overwrites_selected_target_only() { std::fs::create_dir_all(dir.path().join(".aether")).unwrap(); std::fs::write(dir.path().join(".aether/settings.json"), "{}").unwrap(); - let outcome = - apply_init(InitTarget::user(dir.path()), Provider::Anthropic, Preset::Minimal, &[], true).expect("apply_init"); + let outcome = apply_init( + InitTarget::user(dir.path()), + Provider::Anthropic, + Preset::Minimal, + &[], + OverwriteMode::OverwriteExisting, + ) + .expect("apply_init"); assert!(matches!(outcome, InitOutcome::Applied { .. }), "{outcome:?}"); let settings = load(&dir.path().join("settings.json")); @@ -249,8 +284,14 @@ fn every_inline_mcp_in_init_presets_parses_its_args() { [vec![], vec![HarnessIntegration::Claude], vec![HarnessIntegration::Claude, HarnessIntegration::Agents]] { let dir = tempfile::tempdir().unwrap(); - apply_init(InitTarget::user(dir.path()), Provider::Anthropic, preset, &harnesses, false) - .expect("apply_init"); + apply_init( + InitTarget::user(dir.path()), + Provider::Anthropic, + preset, + &harnesses, + OverwriteMode::PreserveExisting, + ) + .expect("apply_init"); let settings = load(&dir.path().join("settings.json")); for agent in &settings.agents { @@ -285,8 +326,14 @@ fn every_inline_mcp_in_init_presets_parses_its_args() { #[test] fn unsupported_provider_returns_error_without_writing_files() { let dir = tempfile::tempdir().unwrap(); - let err = apply_init(InitTarget::user(dir.path()), Provider::Gemini, Preset::Minimal, &[], false) - .expect_err("Gemini has no preset"); + let err = apply_init( + InitTarget::user(dir.path()), + Provider::Gemini, + Preset::Minimal, + &[], + OverwriteMode::PreserveExisting, + ) + .expect_err("Gemini has no preset"); assert!(matches!(err, InitError::UnsupportedProvider { provider: Provider::Gemini, .. }), "{err:?}"); assert!(!dir.path().join("settings.json").exists(), "no settings.json should be written"); diff --git a/crates/mcp-servers/src/lsp/tools/rename/mod.rs b/crates/mcp-servers/src/lsp/tools/rename/mod.rs index 345a90ce0..4e01b9543 100644 --- a/crates/mcp-servers/src/lsp/tools/rename/mod.rs +++ b/crates/mcp-servers/src/lsp/tools/rename/mod.rs @@ -112,7 +112,7 @@ pub async fn execute_lsp_rename(input: LspRenameInput, registry: &LspRegistry) - files_affected, changes, success: true, - meta: Some(rename_display_meta(&input, true, total_edits, files_affected).into()), + meta: Some(rename_success_meta(&input, total_edits, files_affected).into()), ..Default::default() }) } @@ -123,7 +123,7 @@ fn rename_failure(input: &LspRenameInput, error: String) -> LspRenameOutput { new_name: input.new_name.clone(), success: false, error: Some(error), - meta: Some(rename_display_meta(input, false, 0, 0).into()), + meta: Some(rename_failure_meta(input).into()), ..Default::default() } } @@ -299,21 +299,16 @@ fn convert_text_edit(edit: &lsp_types::TextEdit) -> TextEdit { } } -fn rename_display_meta( - input: &LspRenameInput, - success: bool, - total_edits: usize, - files_affected: usize, -) -> ToolDisplayMeta { +fn rename_success_meta(input: &LspRenameInput, total_edits: usize, files_affected: usize) -> ToolDisplayMeta { + ToolDisplayMeta::new( + "LSP rename", + format!("{} → {} ({} edits in {} files)", input.symbol, input.new_name, total_edits, files_affected), + ) +} + +fn rename_failure_meta(input: &LspRenameInput) -> ToolDisplayMeta { let file = basename(&input.file_path); - if success { - ToolDisplayMeta::new( - "LSP rename", - format!("{} → {} ({} edits in {} files)", input.symbol, input.new_name, total_edits, files_affected), - ) - } else { - ToolDisplayMeta::new("LSP rename failed", format!("{} in {}", input.symbol, file)) - } + ToolDisplayMeta::new("LSP rename failed", format!("{} in {}", input.symbol, file)) } #[cfg(test)] diff --git a/crates/wisp/src/components/model_selector.rs b/crates/wisp/src/components/model_selector.rs index 0be282460..2034ad000 100644 --- a/crates/wisp/src/components/model_selector.rs +++ b/crates/wisp/src/components/model_selector.rs @@ -15,7 +15,18 @@ pub struct ModelEntry { pub disabled_reason: Option, } +/// The modality capabilities of a [`ModelEntry`], used to render capability tags. +#[derive(Debug, Clone, Copy)] +struct ModelCapabilities { + image: bool, + audio: bool, +} + impl ModelEntry { + fn capabilities(&self) -> ModelCapabilities { + ModelCapabilities { image: self.supports_image, audio: self.supports_audio } + } + pub fn is_disabled(&self) -> bool { self.disabled_reason.is_some() } @@ -62,8 +73,8 @@ fn compare_model_entries(a: &ModelEntry, b: &ModelEntry) -> Ordering { .then_with(|| a.value.cmp(&b.value)) } -fn capability_tags(supports_image: bool, supports_audio: bool) -> &'static str { - match (supports_image, supports_audio) { +fn capability_tags(caps: ModelCapabilities) -> &'static str { + match (caps.image, caps.audio) { (true, true) => "img audio", (true, false) => "img", (false, true) => "audio", @@ -360,7 +371,7 @@ impl Component for ModelSelector { let bar = reasoning_bar(self.reasoning_effort, &entry.reasoning_levels); line.push_with_style(format!(" {bar}"), indicator_style); } - let caps = capability_tags(entry.supports_image, entry.supports_audio); + let caps = capability_tags(entry.capabilities()); if !caps.is_empty() { line.push_with_style(format!(" {caps}"), indicator_style); } @@ -700,22 +711,22 @@ mod tests { #[test] fn capability_tags_empty_when_no_support() { - assert_eq!(capability_tags(false, false), ""); + assert_eq!(capability_tags(ModelCapabilities { image: false, audio: false }), ""); } #[test] fn capability_tags_image_only() { - assert_eq!(capability_tags(true, false), "img"); + assert_eq!(capability_tags(ModelCapabilities { image: true, audio: false }), "img"); } #[test] fn capability_tags_audio_only() { - assert_eq!(capability_tags(false, true), "audio"); + assert_eq!(capability_tags(ModelCapabilities { image: false, audio: true }), "audio"); } #[test] fn capability_tags_both() { - assert_eq!(capability_tags(true, true), "img audio"); + assert_eq!(capability_tags(ModelCapabilities { image: true, audio: true }), "img audio"); } #[test]