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_project::{AetherSettings, AgentConfig, McpSourceSpec, PromptSource};
5use llm::{ReasoningEffort, catalog::Provider};
6use mcp_utils::client::{InMemoryServerConfig, InMemoryType, McpServerConfig, ToolExposure};
7use mcp_utils::client::{ToolFilter, ToolMatcher};
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 AETHER_SKILL_MD: &str = include_str!("templates/skills/aether/SKILL.md");
16const AETHER_SKILL_PATH: &str = "skills/aether/SKILL.md";
17const PLAN_SKILL_MD: &str = include_str!("templates/skills/plan.md");
18const PLAN_SKILL_PATH: &str = "skills/plan.md";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
21#[clap(rename_all = "kebab-case")]
22pub enum Preset {
23    /// Single agent with bash and skills tools only.
24    Minimal,
25    /// Plan + Build + Explore agents wired to the full built-in MCP set.
26    BatteriesIncluded,
27}
28
29pub(crate) struct ResolvedPreset {
30    pub settings: AetherSettings,
31    pub files: &'static [TemplateFile],
32}
33
34pub(crate) struct TemplateFile {
35    pub path: &'static str,
36    pub body: &'static str,
37}
38
39pub fn supported_providers() -> impl Iterator<Item = Provider> {
40    Provider::ALL.iter().copied().filter(|p| recommended_for_provider(*p).is_some())
41}
42
43pub fn build_batteries_included_settings(
44    display: &str,
45    model: String,
46    reasoning_effort: Option<ReasoningEffort>,
47    scope: InitScope,
48    harnesses: &[HarnessIntegration],
49) -> AetherSettings {
50    let plan = build_batteries_included_plan_agent(display, model.clone(), reasoning_effort, harnesses);
51    let build = build_batteries_included_build_agent(display, model.clone(), reasoning_effort, harnesses);
52    let explore = build_batteries_included_explore_agent(model, reasoning_effort, scope, harnesses);
53
54    AetherSettings {
55        prompts: batteries_prompts(scope, harnesses),
56        agents: vec![plan, build, explore],
57        ..AetherSettings::default()
58    }
59}
60
61fn build_batteries_included_plan_agent(
62    display: &str,
63    model: String,
64    reasoning_effort: Option<ReasoningEffort>,
65    harnesses: &[HarnessIntegration],
66) -> AgentConfig {
67    let coding = coding_args(harnesses);
68    let skills = skills_args(harnesses);
69    AgentConfig {
70        name: "Plan".to_string(),
71        description: format!("{display} planner (may write plan artifacts and request review)"),
72        model,
73        reasoning_effort,
74        user_invocable: true,
75        mcps: vec![mcps(vec![
76            ("review", vec![]),
77            ("coding", coding),
78            ("skills", skills),
79            ("subagents", vec![]),
80            ("tasks", vec![]),
81        ])],
82        tools: plan_tools(),
83        ..AgentConfig::default()
84    }
85}
86
87fn build_batteries_included_build_agent(
88    display: &str,
89    model: String,
90    reasoning_effort: Option<ReasoningEffort>,
91    harnesses: &[HarnessIntegration],
92) -> AgentConfig {
93    let coding = coding_args(harnesses);
94    let skills = skills_args(harnesses);
95    AgentConfig {
96        name: "Build".to_string(),
97        description: format!("{display} implementor"),
98        model,
99        reasoning_effort,
100        user_invocable: true,
101        mcps: vec![mcps(vec![
102            ("coding", coding),
103            ("skills", skills),
104            ("subagents", vec![]),
105            ("tasks", vec![]),
106            ("review", vec![]),
107        ])],
108        ..AgentConfig::default()
109    }
110}
111
112fn build_batteries_included_explore_agent(
113    model: String,
114    reasoning_effort: Option<ReasoningEffort>,
115    scope: InitScope,
116    harnesses: &[HarnessIntegration],
117) -> AgentConfig {
118    let coding = coding_args(harnesses);
119    AgentConfig {
120        name: "Explore".to_string(),
121        description: "Explores codebases to find relevant files, patterns, and integration points".to_string(),
122        model,
123        reasoning_effort,
124        agent_invocable: true,
125        prompts: vec![PromptSource::file(scope.asset_path(EXPLORER_AGENTS_PATH))],
126        mcps: vec![mcps(vec![("coding", coding)])],
127        tools: read_only_coding_tools(),
128        ..AgentConfig::default()
129    }
130}
131
132pub(crate) fn build_preset(
133    preset: Preset,
134    provider: Provider,
135    recs: &ProviderRecommendations,
136    scope: InitScope,
137    harnesses: &[HarnessIntegration],
138) -> ResolvedPreset {
139    match preset {
140        Preset::Minimal => minimal_preset(provider, recs, scope),
141        Preset::BatteriesIncluded => batteries_included_preset(provider, recs, scope, harnesses),
142    }
143}
144
145fn minimal_preset(provider: Provider, recs: &ProviderRecommendations, scope: InitScope) -> ResolvedPreset {
146    let display = provider.display_name();
147    let agent = AgentConfig {
148        name: "Default".to_string(),
149        description: format!("{display} A minimal agent with only a bash tool and skills"),
150        model: recs.plan.model.to_string(),
151        reasoning_effort: recs.plan.reasoning_effort,
152        user_invocable: true,
153        mcps: vec![mcps(vec![("coding", vec![]), ("skills", skills_args(&[]))])],
154        tools: ToolFilter {
155            allow: vec![ToolMatcher::name("coding__bash"), ToolMatcher::name("skills__*")],
156            deny: vec![],
157        },
158        ..AgentConfig::default()
159    };
160
161    ResolvedPreset {
162        files: &[TemplateFile { path: SYSTEM_PATH, body: SYSTEM_MD }],
163        settings: AetherSettings { prompts: default_prompts(scope), agents: vec![agent], ..AetherSettings::default() },
164    }
165}
166
167fn batteries_included_preset(
168    provider: Provider,
169    recs: &ProviderRecommendations,
170    scope: InitScope,
171    harnesses: &[HarnessIntegration],
172) -> ResolvedPreset {
173    let display = provider.display_name();
174    let plan = build_batteries_included_plan_agent(
175        display,
176        recs.plan.model.to_string(),
177        recs.plan.reasoning_effort,
178        harnesses,
179    );
180
181    let build = build_batteries_included_build_agent(
182        display,
183        recs.build.model.to_string(),
184        recs.build.reasoning_effort,
185        harnesses,
186    );
187
188    let explore = build_batteries_included_explore_agent(
189        recs.explore.model.to_string(),
190        recs.explore.reasoning_effort,
191        scope,
192        harnesses,
193    );
194
195    ResolvedPreset {
196        files: &[
197            TemplateFile { path: SYSTEM_PATH, body: SYSTEM_MD },
198            TemplateFile { path: EXPLORER_AGENTS_PATH, body: EXPLORER_AGENTS_MD },
199            TemplateFile { path: AETHER_SKILL_PATH, body: AETHER_SKILL_MD },
200            TemplateFile { path: PLAN_SKILL_PATH, body: PLAN_SKILL_MD },
201        ],
202        settings: AetherSettings {
203            prompts: batteries_prompts(scope, harnesses),
204            agents: vec![plan, build, explore],
205            ..AetherSettings::default()
206        },
207    }
208}
209
210fn skills_args(harnesses: &[HarnessIntegration]) -> Vec<String> {
211    let mut args = vec!["--dir".to_string(), "${AETHER_HOME}/skills".to_string()];
212    for harness in harnesses {
213        for dir in harness.skills_dirs() {
214            args.extend(["--dir".to_string(), dir]);
215        }
216    }
217
218    args.extend(["--dir".to_string(), "${WORKSPACE}/.aether/skills".to_string()]);
219
220    args
221}
222
223fn coding_args(harnesses: &[HarnessIntegration]) -> Vec<String> {
224    let mut args = Vec::new();
225    for harness in harnesses {
226        for dir in harness.rules_dirs() {
227            args.extend(["--rules-dir".to_string(), dir]);
228        }
229    }
230    args
231}
232
233fn batteries_prompts(scope: InitScope, harnesses: &[HarnessIntegration]) -> Vec<PromptSource> {
234    let mut prompts = vec![PromptSource::file(scope.asset_path(SYSTEM_PATH))];
235    for harness in harnesses {
236        if let Some(source) = harness.prompt_source() {
237            prompts.push(source);
238        }
239    }
240
241    prompts
242}
243
244fn default_prompts(scope: InitScope) -> Vec<PromptSource> {
245    let mut prompts = vec![PromptSource::file(scope.asset_path(SYSTEM_PATH))];
246    prompts.extend(HarnessIntegration::Agents.prompt_source());
247    prompts
248}
249
250fn mcps(servers: Vec<(&str, Vec<String>)>) -> McpSourceSpec {
251    let servers = servers
252        .into_iter()
253        .map(|(name, args)| {
254            (
255                name.to_string(),
256                McpServerConfig::InMemory(InMemoryServerConfig {
257                    type_: InMemoryType::InMemory,
258                    args,
259                    input: None,
260                    defer_tools: ToolExposure::ModelVisible,
261                }),
262            )
263        })
264        .collect();
265    McpSourceSpec::Inline { servers }
266}
267
268fn plan_tools() -> ToolFilter {
269    let mut tools = read_only_coding_tools();
270    tools.allow.extend([
271        ToolMatcher::name("coding__write_file"),
272        ToolMatcher::name("coding__edit_file"),
273        ToolMatcher::name("review__review_artifact"),
274    ]);
275    tools
276}
277
278fn read_only_coding_tools() -> ToolFilter {
279    ToolFilter {
280        allow: vec![
281            ToolMatcher::read_only(),
282            ToolMatcher::name("skills__*"),
283            ToolMatcher::name("subagents__*"),
284            ToolMatcher::name("tasks__*"),
285        ],
286        deny: vec![],
287    }
288}