Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions crates/aether-cli/src/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Provider>,
pub preset: Option<Preset>,
pub harnesses: Vec<HarnessIntegration>,
pub force: bool,
pub overwrite: OverwriteMode,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -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,
}
}
}

Expand All @@ -98,9 +113,9 @@ pub fn apply_init(
provider: Provider,
preset: Preset,
harnesses: &[HarnessIntegration],
force: bool,
overwrite: OverwriteMode,
) -> Result<InitOutcome, InitError> {
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 });
}

Expand Down Expand Up @@ -132,7 +147,7 @@ pub fn apply_init(
pub async fn run_init(request: InitRequest) -> Result<InitOutcome, InitError> {
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 });
}

Expand All @@ -142,7 +157,7 @@ pub async fn run_init(request: InitRequest) -> Result<InitOutcome, InitError> {
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<String> {
Expand Down
49 changes: 40 additions & 9 deletions crates/aether-cli/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -80,8 +87,8 @@ fn try_exec_in_container(image: &str) -> Result<ExitCode, SandboxError> {
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)
}
Expand Down Expand Up @@ -191,10 +198,10 @@ fn build_docker_args(
mounts: &[BindMount],
env_vars: &[(String, String)],
inner_args: &[String],
tty: bool,
terminal: TerminalMode,
) -> Vec<String> {
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(
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -397,7 +412,7 @@ mod tests {
&[],
&[],
&["aether".to_string(), "headless".to_string()],
false,
TerminalMode::NonInteractive,
);

assert!(args.contains(&"my-go-sandbox:v2".to_string()));
Expand All @@ -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()));
Expand All @@ -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");
Expand Down Expand Up @@ -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()));
Expand Down
10 changes: 8 additions & 2 deletions crates/aether-cli/src/settings/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,7 +48,13 @@ pub struct SettingsInitArgs {
impl From<SettingsInitArgs> 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 },
}
}
}

Expand Down
87 changes: 67 additions & 20 deletions crates/aether-cli/tests/integration/run_init.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -35,8 +35,14 @@ fn inline_servers(spec: &McpSourceSpec) -> &BTreeMap<String, McpServerConfig> {
#[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());
Expand All @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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");

Expand All @@ -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(), "{}");
Expand All @@ -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(), "{}");
Expand All @@ -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"));
Expand All @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading