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