leviath_cli/commands/setup/
plan.rs1use std::path::{Path, PathBuf};
14
15use crate::bundled::BundledAgent;
16use crate::config::Config;
17
18pub struct SetupPlan {
20 pub config: Config,
23 pub agents: Vec<&'static BundledAgent>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Applied {
30 pub config_path: PathBuf,
31 pub agents_installed: Vec<String>,
32 pub warnings: Vec<String>,
34}
35
36pub fn apply(plan: &SetupPlan, config_path: &Path, agents_dir: &Path) -> anyhow::Result<Applied> {
43 plan.config.save_to_path_public(config_path)?;
44
45 let mut agents_installed = Vec::new();
46 let mut warnings = Vec::new();
47 for agent in &plan.agents {
48 match crate::bundled::install_bundled(agent, agents_dir) {
49 Ok(()) => agents_installed.push(agent.name.to_string()),
50 Err(e) => warnings.push(format!("could not install {}: {e}", agent.name)),
51 }
52 }
53
54 Ok(Applied {
55 config_path: config_path.to_path_buf(),
56 agents_installed,
57 warnings,
58 })
59}
60
61pub fn changes(before: &Config, plan: &SetupPlan) -> Vec<String> {
69 let after = &plan.config;
70 let mut out = Vec::new();
71
72 for provider in super::catalog::providers() {
73 let old = super::catalog::stored_credential(before, provider.id);
74 let new = super::catalog::stored_credential(after, provider.id);
75 let label = provider.display;
76 match (old, new) {
77 (None, Some(_)) => out.push(format!("{label}: credential set")),
78 (Some(_), None) => out.push(format!("{label}: credential cleared")),
79 (Some(a), Some(b)) if a != b => out.push(format!("{label}: credential changed")),
80 _ => {}
81 }
82 }
83
84 if before.providers.claude_code_enabled != after.providers.claude_code_enabled {
85 out.push(format!(
86 "Claude Code transport: {}",
87 if after.providers.claude_code_enabled {
88 "enabled"
89 } else {
90 "disabled"
91 }
92 ));
93 }
94 push_if_changed(
95 &mut out,
96 "default provider",
97 Some(&before.default_provider),
98 Some(&after.default_provider),
99 );
100 push_if_changed(
101 &mut out,
102 "default model",
103 before.default_model.as_ref(),
104 after.default_model.as_ref(),
105 );
106 push_if_changed(
107 &mut out,
108 "max concurrent inferences",
109 before.limits.max_concurrent_inferences.as_ref(),
110 after.limits.max_concurrent_inferences.as_ref(),
111 );
112 push_if_changed(
113 &mut out,
114 "max concurrent tools",
115 Some(&before.limits.max_concurrent_tools),
116 Some(&after.limits.max_concurrent_tools),
117 );
118 push_if_changed(
119 &mut out,
120 "default max iterations",
121 before.limits.default_max_iterations.as_ref(),
122 after.limits.default_max_iterations.as_ref(),
123 );
124 push_if_changed(
125 &mut out,
126 "exact token counting",
127 Some(&before.limits.exact_token_counting),
128 Some(&after.limits.exact_token_counting),
129 );
130 push_if_changed(
131 &mut out,
132 "batch tool hint",
133 Some(&before.batch_tool_hint),
134 Some(&after.batch_tool_hint),
135 );
136 push_if_changed(
137 &mut out,
138 "platform shell hint",
139 Some(&before.shell_hint),
140 Some(&after.shell_hint),
141 );
142 push_if_changed(
143 &mut out,
144 "stall timeout (seconds)",
145 Some(&before.limits.stall_timeout_secs),
146 Some(&after.limits.stall_timeout_secs),
147 );
148 push_if_changed(
149 &mut out,
150 "dead cycles before relief",
151 Some(&before.limits.dead_cycles_before_relief),
152 Some(&after.limits.dead_cycles_before_relief),
153 );
154 push_if_changed(
155 &mut out,
156 "finished run retention (seconds)",
157 Some(&before.limits.finished_retention_secs),
158 Some(&after.limits.finished_retention_secs),
159 );
160 push_if_changed(
161 &mut out,
162 "wedge timeout (seconds)",
163 Some(&before.limits.wedge_timeout_secs),
164 Some(&after.limits.wedge_timeout_secs),
165 );
166
167 let added = after
168 .mcp_servers
169 .len()
170 .saturating_sub(before.mcp_servers.len());
171 if added > 0 {
172 out.push(format!("MCP servers: {added} imported"));
173 }
174 if !plan.agents.is_empty() {
175 out.push(format!("agents: {} to install", plan.agents.len()));
176 }
177 out
178}
179
180fn push_if_changed<T: PartialEq + std::fmt::Display>(
182 out: &mut Vec<String>,
183 label: &str,
184 before: Option<&T>,
185 after: Option<&T>,
186) {
187 let describe = |v: Option<&T>| match v {
188 Some(v) => v.to_string(),
189 None => "(unset)".to_string(),
190 };
191 if before != after {
192 out.push(format!(
193 "{label}: {} → {}",
194 describe(before),
195 describe(after)
196 ));
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::bundled::BUNDLED_AGENTS;
204
205 fn plan_of(config: Config) -> SetupPlan {
206 SetupPlan {
207 config,
208 agents: Vec::new(),
209 }
210 }
211
212 #[test]
215 fn apply_writes_the_config_and_installs_the_chosen_agents() {
216 let dir = tempfile::tempdir().unwrap();
217 let config_path = dir.path().join("config.toml");
218 let agents_dir = dir.path().join("agents");
219 let mut config = Config::default();
220 config.providers.anthropic_api_key = Some("sk-ant-x".to_string());
221 let plan = SetupPlan {
222 config,
223 agents: vec![&BUNDLED_AGENTS[0]],
224 };
225
226 let applied = apply(&plan, &config_path, &agents_dir).unwrap();
227
228 assert_eq!(applied.config_path, config_path);
229 assert_eq!(applied.agents_installed, vec![BUNDLED_AGENTS[0].name]);
230 assert!(applied.warnings.is_empty());
231 let written = Config::load_from_path_public(&config_path).unwrap();
232 assert_eq!(
233 written.providers.anthropic_api_key.as_deref(),
234 Some("sk-ant-x")
235 );
236 assert!(
237 agents_dir
238 .join(BUNDLED_AGENTS[0].name)
239 .join("agent.leviath")
240 .exists()
241 );
242 }
243
244 #[test]
245 fn apply_with_nothing_to_install_still_writes_the_config() {
246 let dir = tempfile::tempdir().unwrap();
247 let config_path = dir.path().join("config.toml");
248
249 let applied = apply(
250 &plan_of(Config::default()),
251 &config_path,
252 &dir.path().join("agents"),
253 )
254 .unwrap();
255
256 assert!(applied.agents_installed.is_empty());
257 assert!(config_path.exists());
258 }
259
260 #[test]
261 fn a_blueprint_that_fails_to_install_warns_rather_than_aborting() {
262 let dir = tempfile::tempdir().unwrap();
265 let config_path = dir.path().join("config.toml");
266 let agents_dir = dir.path().join("blocked");
268 std::fs::write(&agents_dir, "").unwrap();
269 let plan = SetupPlan {
270 config: Config::default(),
271 agents: vec![&BUNDLED_AGENTS[0]],
272 };
273
274 let applied = apply(&plan, &config_path, &agents_dir).unwrap();
275
276 assert!(applied.agents_installed.is_empty());
277 assert_eq!(applied.warnings.len(), 1);
278 assert!(applied.warnings[0].contains(BUNDLED_AGENTS[0].name));
279 assert!(config_path.exists(), "the config was still written");
280 }
281
282 #[test]
283 fn a_config_that_cannot_be_written_is_a_hard_error() {
284 let dir = tempfile::tempdir().unwrap();
286 let blocked = dir.path().join("not-a-dir");
287 std::fs::write(&blocked, "").unwrap();
288
289 let result = apply(
290 &plan_of(Config::default()),
291 &blocked.join("config.toml"),
292 &dir.path().join("agents"),
293 );
294
295 assert!(result.is_err());
296 }
297
298 #[test]
301 fn an_unchanged_plan_lists_nothing() {
302 assert!(changes(&Config::default(), &plan_of(Config::default())).is_empty());
303 }
304
305 #[test]
306 fn credential_changes_are_described_but_never_printed() {
307 let mut before = Config::default();
309 before.providers.openai_api_key = Some("sk-old-secret".to_string());
310 before.openrouter_api_key = Some("sk-or-doomed".to_string());
311 let mut after = before.clone();
312 after.providers.anthropic_api_key = Some("sk-ant-brand-new".to_string());
313 after.providers.openai_api_key = Some("sk-new-secret".to_string());
314 after.openrouter_api_key = None;
315
316 let lines = changes(&before, &plan_of(after));
317
318 assert!(lines.contains(&"Anthropic: credential set".to_string()));
319 assert!(lines.contains(&"OpenAI: credential changed".to_string()));
320 assert!(lines.contains(&"OpenRouter: credential cleared".to_string()));
321 for line in &lines {
322 assert!(!line.contains("secret"), "a credential leaked: {line}");
323 assert!(!line.contains("sk-"), "a credential leaked: {line}");
324 }
325 }
326
327 #[test]
328 fn an_unchanged_credential_is_not_listed() {
329 let mut before = Config::default();
330 before.providers.anthropic_api_key = Some("sk-ant-same".to_string());
331
332 assert!(changes(&before, &plan_of(before.clone())).is_empty());
333 }
334
335 #[test]
336 fn the_claude_code_toggle_is_reported_both_ways() {
337 let before = Config::default();
338 let mut on = before.clone();
339 on.providers.claude_code_enabled = true;
340
341 assert!(
342 changes(&before, &plan_of(on.clone()))
343 .contains(&"Claude Code transport: enabled".to_string())
344 );
345 assert!(
346 changes(&on, &plan_of(before)).contains(&"Claude Code transport: disabled".to_string())
347 );
348 }
349
350 #[test]
351 fn scalar_settings_are_shown_as_old_to_new() {
352 let before = Config::default();
353 let mut after = before.clone();
354 after.default_provider = "ollama".to_string();
355 after.default_model = Some("llama3".to_string());
356 after.limits.max_concurrent_inferences = Some(1);
357 after.limits.max_concurrent_tools = 4;
358 after.limits.default_max_iterations = None;
359 after.limits.exact_token_counting = true;
360 after.batch_tool_hint = false;
361 after.shell_hint = false;
362
363 let lines = changes(&before, &plan_of(after));
364
365 assert!(lines.contains(&"default provider: anthropic → ollama".to_string()));
366 assert!(lines.contains(&"default model: (unset) → llama3".to_string()));
367 assert!(lines.contains(&"max concurrent inferences: 8 → 1".to_string()));
368 assert!(lines.contains(&"max concurrent tools: 8 → 4".to_string()));
369 assert!(lines.contains(&"default max iterations: 50 → (unset)".to_string()));
370 assert!(lines.contains(&"exact token counting: false → true".to_string()));
371 assert!(lines.contains(&"batch tool hint: true → false".to_string()));
372 assert!(lines.contains(&"platform shell hint: true → false".to_string()));
373 }
374
375 #[test]
376 fn imported_servers_and_pending_agents_are_counted() {
377 let before = Config::default();
378 let mut after = before.clone();
379 after.mcp_servers = vec![
380 leviath_mcp::MCPServerConfig::stdio("a", "x", vec![]),
381 leviath_mcp::MCPServerConfig::stdio("b", "y", vec![]),
382 ];
383 let plan = SetupPlan {
384 config: after,
385 agents: vec![&BUNDLED_AGENTS[0], &BUNDLED_AGENTS[1]],
386 };
387
388 let lines = changes(&before, &plan);
389
390 assert!(lines.contains(&"MCP servers: 2 imported".to_string()));
391 assert!(lines.contains(&"agents: 2 to install".to_string()));
392 }
393
394 #[test]
395 fn removing_servers_is_not_reported_as_an_import() {
396 let before = Config {
398 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("a", "x", vec![])],
399 ..Config::default()
400 };
401 let after = Config::default();
402
403 let lines = changes(&before, &plan_of(after));
404
405 assert!(
406 lines.is_empty(),
407 "a shrink must not be reported as an import"
408 );
409 }
410}