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