Skip to main content

aether_cli/init/
build_settings.rs

1use super::InitScope;
2use super::harness::HarnessIntegration;
3use super::recommendations::{ProviderRecommendations, recommended_for_provider};
4use aether_core::agent_spec::ToolFilter;
5use aether_project::{AetherSettings, AgentConfig, McpSourceSpec, PromptSource};
6use llm::catalog::Provider;
7use mcp_utils::client::{InMemoryServerConfig, InMemoryType, McpServerConfig};
8
9const SYSTEM_PATH: &str = "SYSTEM.md";
10const SYSTEM_MD: &str = include_str!("templates/SYSTEM.md");
11
12const EXPLORER_AGENTS_MD: &str = include_str!("templates/agents/codebase-explorer/AGENTS.md");
13const EXPLORER_AGENTS_PATH: &str = "agents/codebase-explorer/AGENTS.md";
14
15const READ_ONLY_DENIED_CODING_TOOLS: &[&str] =
16    &["coding__bash", "coding__edit_file", "coding__lsp_rename", "coding__write_file"];
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
19#[clap(rename_all = "kebab-case")]
20pub enum Preset {
21    /// Single agent with bash and skills tools only.
22    Minimal,
23    /// Plan + Build + Explore agents wired to the full built-in MCP set.
24    BatteriesIncluded,
25}
26
27pub(crate) struct ResolvedPreset {
28    pub settings: AetherSettings,
29    pub files: &'static [TemplateFile],
30}
31
32pub(crate) struct TemplateFile {
33    pub path: &'static str,
34    pub body: &'static str,
35}
36
37pub fn supported_providers() -> impl Iterator<Item = Provider> {
38    Provider::ALL.iter().copied().filter(|p| recommended_for_provider(*p).is_some())
39}
40
41pub(crate) fn build_preset(
42    preset: Preset,
43    provider: Provider,
44    recs: &ProviderRecommendations,
45    scope: InitScope,
46    harnesses: &[HarnessIntegration],
47) -> ResolvedPreset {
48    match preset {
49        Preset::Minimal => minimal_preset(provider, recs, scope),
50        Preset::BatteriesIncluded => batteries_included_preset(provider, recs, scope, harnesses),
51    }
52}
53
54fn minimal_preset(provider: Provider, recs: &ProviderRecommendations, scope: InitScope) -> ResolvedPreset {
55    let display = provider.display_name();
56    let agent = AgentConfig {
57        name: "Default".to_string(),
58        description: format!("{display} A minimal agent with only a bash tool and skills"),
59        model: recs.plan.model.to_string(),
60        reasoning_effort: recs.plan.reasoning_effort,
61        user_invocable: true,
62        mcps: vec![mcps(vec![("coding", vec![]), ("skills", skills_args(&[]))])],
63        tools: ToolFilter { allow: vec!["coding__bash".to_string(), "skills__*".to_string()], deny: vec![] },
64        ..AgentConfig::default()
65    };
66
67    ResolvedPreset {
68        files: &[TemplateFile { path: SYSTEM_PATH, body: SYSTEM_MD }],
69        settings: AetherSettings { prompts: default_prompts(scope), agents: vec![agent], ..AetherSettings::default() },
70    }
71}
72
73fn batteries_included_preset(
74    provider: Provider,
75    recs: &ProviderRecommendations,
76    scope: InitScope,
77    harnesses: &[HarnessIntegration],
78) -> ResolvedPreset {
79    let display = provider.display_name();
80    let coding = coding_args(harnesses);
81    let skills = skills_args(harnesses);
82
83    let plan = AgentConfig {
84        name: "Plan".to_string(),
85        description: format!("{display} planner (read-only except plan files)"),
86        model: recs.plan.model.to_string(),
87        reasoning_effort: recs.plan.reasoning_effort,
88        user_invocable: true,
89        mcps: vec![mcps(vec![
90            ("plan", vec![]),
91            ("coding", coding.clone()),
92            ("skills", skills.clone()),
93            ("subagents", vec![]),
94            ("tasks", vec![]),
95            ("survey", vec![]),
96        ])],
97        tools: read_only_coding_tools(),
98        ..AgentConfig::default()
99    };
100
101    let build = AgentConfig {
102        name: "Build".to_string(),
103        description: format!("{display} implementor"),
104        model: recs.build.model.to_string(),
105        reasoning_effort: recs.build.reasoning_effort,
106        user_invocable: true,
107        mcps: vec![mcps(vec![
108            ("coding", coding.clone()),
109            ("skills", skills),
110            ("subagents", vec![]),
111            ("tasks", vec![]),
112            ("survey", vec![]),
113        ])],
114        ..AgentConfig::default()
115    };
116
117    let explore = AgentConfig {
118        name: "Explore".to_string(),
119        description: "Explores codebases to find relevant files, patterns, and integration points".to_string(),
120        model: recs.explore.model.to_string(),
121        reasoning_effort: recs.explore.reasoning_effort,
122        agent_invocable: true,
123        prompts: vec![PromptSource::file(scope.asset_path(EXPLORER_AGENTS_PATH))],
124        mcps: vec![mcps(vec![("coding", coding)])],
125        tools: read_only_coding_tools(),
126        ..AgentConfig::default()
127    };
128
129    ResolvedPreset {
130        files: &[
131            TemplateFile { path: SYSTEM_PATH, body: SYSTEM_MD },
132            TemplateFile { path: EXPLORER_AGENTS_PATH, body: EXPLORER_AGENTS_MD },
133        ],
134        settings: AetherSettings {
135            prompts: batteries_prompts(scope, harnesses),
136            agents: vec![plan, build, explore],
137            ..AetherSettings::default()
138        },
139    }
140}
141
142fn skills_args(harnesses: &[HarnessIntegration]) -> Vec<String> {
143    let mut args = vec!["--dir".to_string(), "${AETHER_HOME}/skills".to_string()];
144    for harness in harnesses {
145        for dir in harness.skills_dirs() {
146            args.extend(["--dir".to_string(), dir]);
147        }
148    }
149
150    args.extend([
151        "--dir".to_string(),
152        "${WORKSPACE}/.aether/skills".to_string(),
153        "--notes-dir".to_string(),
154        "${WORKSPACE}/.aether/notes".to_string(),
155    ]);
156
157    args
158}
159
160fn coding_args(harnesses: &[HarnessIntegration]) -> Vec<String> {
161    let mut args = Vec::new();
162    for harness in harnesses {
163        for dir in harness.rules_dirs() {
164            args.extend(["--rules-dir".to_string(), dir]);
165        }
166    }
167    args
168}
169
170fn batteries_prompts(scope: InitScope, harnesses: &[HarnessIntegration]) -> Vec<PromptSource> {
171    let mut prompts = vec![PromptSource::file(scope.asset_path(SYSTEM_PATH))];
172    for harness in harnesses {
173        if let Some(source) = harness.prompt_source() {
174            prompts.push(source);
175        }
176    }
177
178    prompts
179}
180
181fn default_prompts(scope: InitScope) -> Vec<PromptSource> {
182    let mut prompts = vec![PromptSource::file(scope.asset_path(SYSTEM_PATH))];
183    prompts.extend(HarnessIntegration::Agents.prompt_source());
184    prompts
185}
186
187fn mcps(servers: Vec<(&str, Vec<String>)>) -> McpSourceSpec {
188    let servers = servers
189        .into_iter()
190        .map(|(name, args)| {
191            (
192                name.to_string(),
193                McpServerConfig::InMemory(InMemoryServerConfig {
194                    type_: InMemoryType::InMemory,
195                    args,
196                    input: None,
197                    proxy: false,
198                }),
199            )
200        })
201        .collect();
202    McpSourceSpec::Inline { servers }
203}
204
205fn read_only_coding_tools() -> ToolFilter {
206    ToolFilter { allow: vec![], deny: READ_ONLY_DENIED_CODING_TOOLS.iter().map(|tool| (*tool).to_string()).collect() }
207}