Skip to main content

aether_cli/settings/
mod.rs

1use crate::init::{HarnessIntegration, InitRequest, InitTargetRequest, Preset};
2use clap::{Args, Subcommand};
3use llm::catalog::Provider;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Subcommand)]
7pub enum SettingsCommand {
8    /// Initialize Aether settings
9    Init(SettingsInitArgs),
10}
11
12#[derive(Debug, Clone, Args)]
13#[command(group(
14    clap::ArgGroup::new("scope")
15        .required(true)
16        .args(["user", "project"])
17))]
18pub struct SettingsInitArgs {
19    /// Initialize user-level settings in `$AETHER_HOME` or `~/.aether`.
20    #[arg(long, conflicts_with = "project")]
21    pub user: bool,
22
23    /// Initialize project-level settings in <path>/.aether.
24    #[arg(long, conflicts_with = "user")]
25    pub project: bool,
26
27    /// Project directory to initialize when --project is used.
28    #[arg(long, default_value = ".", requires = "project", conflicts_with = "user")]
29    pub path: PathBuf,
30
31    /// Provider to use (e.g. `anthropic`, `codex`, `bedrock`). Skips the prompt when set.
32    #[arg(long)]
33    pub provider: Option<Provider>,
34
35    /// Preset to write (`minimal` or `batteries-included`). Skips the prompt when set.
36    #[arg(long)]
37    pub preset: Option<Preset>,
38
39    /// Harness conventions to auto-load (only with `batteries-included` preset).
40    #[arg(long = "harness", value_enum)]
41    pub harnesses: Vec<HarnessIntegration>,
42
43    /// Overwrite an existing settings file at the selected target.
44    #[arg(long)]
45    pub force: bool,
46}
47
48impl From<SettingsInitArgs> for InitRequest {
49    fn from(args: SettingsInitArgs) -> Self {
50        let target = if args.user { InitTargetRequest::User } else { InitTargetRequest::Project { path: args.path } };
51        Self { target, provider: args.provider, preset: args.preset, harnesses: args.harnesses, force: args.force }
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use clap::Parser;
59
60    #[derive(Debug, Parser)]
61    struct TestCli {
62        #[command(subcommand)]
63        command: SettingsCommand,
64    }
65
66    fn parse(args: &[&str]) -> Result<TestCli, clap::Error> {
67        TestCli::try_parse_from(std::iter::once("test").chain(args.iter().copied()))
68    }
69
70    #[test]
71    fn accepts_user_scope() {
72        let cli = parse(&["init", "--user"]).unwrap();
73        let SettingsCommand::Init(args) = cli.command;
74        assert!(args.user);
75        assert!(!args.project);
76    }
77
78    #[test]
79    fn accepts_project_scope_and_path() {
80        let cli = parse(&["init", "--project", "--path", "/tmp/repo"]).unwrap();
81        let SettingsCommand::Init(args) = cli.command;
82        assert!(args.project);
83        assert_eq!(args.path, PathBuf::from("/tmp/repo"));
84    }
85
86    #[test]
87    fn accepts_provider_preset_and_force() {
88        let cli = parse(&["init", "--user", "--provider", "codex", "--preset", "minimal", "--force"]).unwrap();
89        let SettingsCommand::Init(args) = cli.command;
90        assert_eq!(args.provider, Some(Provider::Codex));
91        assert_eq!(args.preset, Some(Preset::Minimal));
92        assert!(args.force);
93    }
94
95    #[test]
96    fn rejects_missing_scope() {
97        assert!(parse(&["init"]).is_err());
98    }
99
100    #[test]
101    fn rejects_both_scopes() {
102        assert!(parse(&["init", "--user", "--project"]).is_err());
103    }
104
105    #[test]
106    fn rejects_user_path() {
107        assert!(parse(&["init", "--user", "--path", "/tmp/repo"]).is_err());
108    }
109
110    #[test]
111    fn accepts_repeated_harness_flags() {
112        let cli =
113            parse(&["init", "--user", "--preset", "batteries-included", "--harness", "claude", "--harness", "agents"])
114                .unwrap();
115        let SettingsCommand::Init(args) = cli.command;
116        assert_eq!(args.harnesses, vec![HarnessIntegration::Claude, HarnessIntegration::Agents]);
117    }
118}