1use clap::Args;
4use std::fs;
5use std::path::Path;
6
7#[derive(Args)]
8pub struct CreateArgs {
9 #[arg(value_name = "NAME")]
11 pub name: String,
12
13 #[arg(short, long, default_value = "software-engineer")]
15 pub template: String,
16}
17
18pub async fn execute(args: CreateArgs) -> anyhow::Result<()> {
19 execute_with(args, &|path, contents| fs::write(path, contents))
20}
21
22fn execute_with(
33 args: CreateArgs,
34 write_file: &dyn Fn(&Path, &[u8]) -> std::io::Result<()>,
35) -> anyhow::Result<()> {
36 tracing::info!("Creating agent blueprint");
37
38 let blueprint_dir = Path::new(&args.name);
39
40 if blueprint_dir.exists() {
41 anyhow::bail!("Directory '{}' already exists", args.name);
42 }
43
44 fs::create_dir_all(blueprint_dir)?;
45
46 let manifest = create_manifest(&args.name, &args.template);
47 write_file(&blueprint_dir.join("agent.leviath"), manifest.as_bytes())?;
48
49 let gitignore_content = ".env\n*.leviath-bundle\n.leviath/\n";
50 write_file(
51 &blueprint_dir.join(".gitignore"),
52 gitignore_content.as_bytes(),
53 )?;
54
55 let env_example_content = "# Copy this to .env and fill in your API key\n# ANTHROPIC_API_KEY=sk-ant-...\n# OPENAI_API_KEY=sk-...\n# OPENROUTER_API_KEY=sk-or-...\n";
56 write_file(
57 &blueprint_dir.join(".env.example"),
58 env_example_content.as_bytes(),
59 )?;
60
61 println!("Created blueprint: {}", args.name);
62 println!("\nNext steps:");
63 println!(" cd {}", args.name);
64 println!(" lev run . --task \"Your task here\"");
65 println!(
66 " lev add . && lev run {} --task \"Your task here\"",
67 args.name
68 );
69
70 Ok(())
71}
72
73fn toml_escape(s: &str) -> String {
80 s.replace('\\', "\\\\").replace('"', "\\\"")
81}
82
83fn create_manifest(name: &str, template: &str) -> String {
84 let name = &toml_escape(name);
85 match template {
86 "coder" => format!(
87 r#"[agent]
88name = "{name}"
89version = "0.1.0"
90description = "A coding assistant blueprint"
91
92# Global tool permissions: write/exec require approval unless overridden.
93[tool_permissions]
94read_file = "allow"
95list_dir = "allow"
96write_file = "ask"
97edit_file = "ask"
98bash = "ask"
99
100[stages.analyze]
101mode = "autonomous"
102model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
103description = "Understand the task and plan the implementation"
104available_tools = ["read_file", "list_dir"]
105max_iterations = 15
106system_prompt = """
107Analyze the coding task in the `task` region and produce a concise implementation
108plan: which files to create/modify, what each does, and the key decisions.
109"""
110# Large file reads persist in the `codebase` region (a short pointer stays in the
111# conversation); action-tool results stay inline. Never route to a sliding_window
112# other than `conversation`.
113[stages.analyze.tool_routing]
114default_region = "conversation"
115[stages.analyze.tool_routing.overrides]
116read_file = "codebase"
117list_dir = "codebase"
118[stages.analyze.transitions.implement]
119transform = "direct"
120
121[stages.implement]
122mode = "autonomous"
123model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
124description = "Write code according to the plan"
125available_tools = ["write_file", "read_file", "edit_file", "list_dir", "bash"]
126max_iterations = 50
127system_prompt = """
128Implement the plan. Create all necessary files, then use bash to run tests and
129verify the build. Read existing code from the `codebase` region.
130"""
131[stages.implement.tool_routing]
132default_region = "conversation"
133[stages.implement.tool_routing.overrides]
134read_file = "codebase"
135list_dir = "codebase"
136
137# Region budgets are percentages of the model's context window (ceilings, may sum
138# past 100%); the absolute max_tokens is an optional guard-rail cap. Every
139# blueprint needs an explicit `conversation` sliding_window - it holds the message
140# stream and is carried across stage transitions.
141[context.regions]
142task = {{ kind = "pinned", budget = "2%", max_tokens = 2000, required = true, seed = "task", required_message = "Describe the coding task via --task." }}
143codebase = {{ kind = "temporary", budget = "20%", max_tokens = 30000 }}
144conversation = {{ kind = "sliding_window", max_items = 20, budget = "15%", max_tokens = 15000, strategy = "bulk", overflow = 10 }}
145scratch = {{ kind = "clearable", budget = "8%", max_tokens = 10000 }}
146"#,
147 name = name
148 ),
149
150 "researcher" => format!(
151 r#"[agent]
152name = "{name}"
153version = "0.1.0"
154description = "A research assistant blueprint"
155
156[tool_permissions]
157read_file = "allow"
158list_dir = "allow"
159bash = "ask"
160
161[stages.gather]
162mode = "autonomous"
163model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
164description = "Gather relevant information"
165available_tools = ["read_file", "list_dir", "bash"]
166max_iterations = 20
167system_prompt = """
168Gather source material on the topic in the `query` region. Use read_file/list_dir
169for local material and bash for anything else; raw content lands in `sources`.
170Note where each item came from and the claims it supports.
171"""
172# (Tip: drop web_search.rhai / web_fetch.rhai into a `tools/` dir beside this file
173# and add them to available_tools for real web research - see the researcher agent.)
174[stages.gather.tool_routing]
175default_region = "conversation"
176[stages.gather.tool_routing.overrides]
177read_file = "sources"
178list_dir = "sources"
179bash = "sources"
180[stages.gather.transitions.synthesize]
181transform = "compact"
182
183[stages.synthesize]
184mode = "interactive"
185model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
186description = "Synthesize findings and discuss with user"
187available_tools = ["read_file", "list_dir"]
188max_iterations = 15
189system_prompt = """
190Synthesize the `sources` into `findings`: themes, agreements/disagreements, and
191well-supported vs speculative claims. Cite specific sources.
192"""
193
194# Region budgets are percentages of the model's context window (ceilings, may sum
195# past 100%); absolute max_tokens / threshold_tokens are guard-rail caps. A
196# `compacting` region needs a paired `compact_history` region for its summaries.
197[context.regions]
198query = {{ kind = "pinned", budget = "2%", max_tokens = 2000, required = true, seed = "task", required_message = "State the research question via --task." }}
199sources = {{ kind = "temporary", budget = "25%", max_tokens = 40000 }}
200findings = {{ kind = "compacting", budget = "12%", compact_at = "80%", threshold_tokens = 12000, max_tokens = 15000 }}
201findings_history = {{ kind = "compact_history", source_region = "findings", budget = "3%", max_tokens = 6000 }}
202conversation = {{ kind = "sliding_window", max_items = 15, budget = "12%", max_tokens = 12000, strategy = "bulk", overflow = 10 }}
203scratch = {{ kind = "clearable", budget = "6%", max_tokens = 8000 }}
204"#,
205 name = name
206 ),
207
208 _ => format!(
209 r#"[agent]
210name = "{name}"
211version = "0.1.0"
212description = "A simple agent blueprint"
213
214[tool_permissions]
215read_file = "allow"
216list_dir = "allow"
217write_file = "ask"
218bash = "ask"
219
220[stages.main]
221mode = "autonomous"
222model = {{ provider = "anthropic", model = "claude-sonnet-4-6" }}
223description = "Main execution stage"
224available_tools = ["read_file", "list_dir", "write_file", "bash"]
225max_iterations = 30
226system_prompt = """
227You are a helpful agent. Complete the task described in the `task` region
228thoroughly.
229"""
230
231# Region budgets are percentages of the model's context window (ceilings, may sum
232# past 100%); the absolute max_tokens is an optional guard-rail cap. Every
233# blueprint needs an explicit `conversation` sliding_window region.
234[context.regions]
235task = {{ kind = "pinned", budget = "2%", max_tokens = 2000, required = true, seed = "task", required_message = "Describe the task via --task." }}
236conversation = {{ kind = "sliding_window", max_items = 10, budget = "12%", max_tokens = 10000, strategy = "bulk", overflow = 10 }}
237scratch = {{ kind = "clearable", budget = "6%", max_tokens = 5000 }}
238"#,
239 name = name
240 ),
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::test_support::with_tracing;
248
249 #[test]
250 fn default_template_is_valid_toml() {
251 let manifest = create_manifest("test-agent", "software-engineer");
252 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
253 let agent = parsed.get("agent").expect("should have [agent] section");
254 assert_eq!(agent.get("name").unwrap().as_str().unwrap(), "test-agent");
255 assert_eq!(agent.get("version").unwrap().as_str().unwrap(), "0.1.0");
256 }
257
258 #[test]
259 fn name_with_windows_style_backslashes_produces_valid_toml() {
260 let name = r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpmAlPt3\default-template-agent";
267 for template in ["software-engineer", "coder", "researcher"] {
268 let manifest = create_manifest(name, template);
269 let parsed: toml::Value =
270 toml::from_str(&manifest).expect("template produced invalid TOML");
271 let agent = parsed.get("agent").unwrap();
272 assert_eq!(agent.get("name").unwrap().as_str().unwrap(), name);
273 }
274 }
275
276 #[test]
277 fn name_with_embedded_quote_produces_valid_toml() {
278 let name = r#"my"agent"#;
279 let manifest = create_manifest(name, "software-engineer");
280 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
281 let agent = parsed.get("agent").unwrap();
282 assert_eq!(agent.get("name").unwrap().as_str().unwrap(), name);
283 }
284
285 #[test]
286 fn coder_template_is_valid_toml() {
287 let manifest = create_manifest("my-coder", "coder");
288 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
289 let agent = parsed.get("agent").unwrap();
290 assert_eq!(agent.get("name").unwrap().as_str().unwrap(), "my-coder");
291 assert!(parsed.get("stages").is_some());
292 }
293
294 #[test]
295 fn researcher_template_is_valid_toml() {
296 let manifest = create_manifest("my-researcher", "researcher");
297 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
298 let agent = parsed.get("agent").unwrap();
299 assert_eq!(
300 agent.get("name").unwrap().as_str().unwrap(),
301 "my-researcher"
302 );
303 }
304
305 #[test]
306 fn templates_use_percentage_budgets_and_parse_via_manifest() {
307 for template in ["software-engineer", "coder", "researcher", "other"] {
310 let manifest = create_manifest("pct-agent", template);
311 assert!(
312 manifest.contains("budget = \""),
313 "{template} template should use percentage budgets"
314 );
315 let bp = leviath_core::manifest::parse_manifest(&manifest)
316 .expect("generated template should parse");
317 assert!(
318 bp.context_layout.has_percent_budgets(),
319 "{template} layout should have percentage budgets"
320 );
321 }
322 }
323
324 #[test]
325 fn every_template_satisfies_context_layout_invariants() {
326 use leviath_core::RegionKind;
327 for template in ["software-engineer", "coder", "researcher", "other"] {
328 let manifest = create_manifest("inv-agent", template);
329 let bp = leviath_core::manifest::parse_manifest(&manifest).unwrap();
330 let regions = &bp.context_layout.regions;
331
332 let has_conv_sliding = regions.iter().any(|r| {
336 matches!(r.kind, RegionKind::SlidingWindow { .. }) && r.name == "conversation"
337 });
338 assert!(
339 has_conv_sliding,
340 "{template} template needs an explicit conversation sliding_window"
341 );
342
343 let sliding: std::collections::HashSet<&str> = regions
345 .iter()
346 .filter(|r| matches!(r.kind, RegionKind::SlidingWindow { .. }))
347 .map(|r| r.name.as_str())
348 .collect();
349 for stage in &bp.stages {
350 if let Some(routing) = &stage.tool_result_routing {
351 let mut targets = vec![routing.default_region.as_str()];
352 targets.extend(routing.tool_overrides.values().map(String::as_str));
353 for t in targets {
354 assert!(
355 t == "conversation" || !sliding.contains(t),
356 "{template} stage '{}' routes to non-conversation sliding_window '{t}'",
357 stage.name
358 );
359 }
360 }
361 }
362
363 let hist: std::collections::HashSet<&str> = regions
365 .iter()
366 .filter_map(|r| match &r.kind {
367 RegionKind::CompactHistory { source_region } => Some(source_region.as_str()),
368 _ => None,
369 })
370 .collect();
371 for r in regions {
372 if matches!(r.kind, RegionKind::Compacting { .. }) {
373 assert!(
374 hist.contains(r.name.as_str()),
375 "{template} compacting region '{}' has no compact_history pair",
376 r.name
377 );
378 }
379 }
380 }
381 }
382
383 #[test]
384 fn unknown_template_falls_back_to_default() {
385 let manifest = create_manifest("x", "nonexistent-template");
386 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
387 let stages = parsed.get("stages").unwrap().as_table().unwrap();
388 assert!(stages.contains_key("main"));
390 }
391
392 #[test]
393 fn coder_template_has_analyze_and_implement_stages() {
394 let manifest = create_manifest("x", "coder");
395 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
396 let stages = parsed.get("stages").unwrap().as_table().unwrap();
397 assert!(stages.contains_key("analyze"));
398 assert!(stages.contains_key("implement"));
399 }
400
401 #[test]
402 fn researcher_template_has_gather_and_synthesize_stages() {
403 let manifest = create_manifest("x", "researcher");
404 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
405 let stages = parsed.get("stages").unwrap().as_table().unwrap();
406 assert!(stages.contains_key("gather"));
407 assert!(stages.contains_key("synthesize"));
408 }
409
410 #[test]
411 fn template_embeds_agent_name() {
412 let manifest = create_manifest("special-name-123", "coder");
413 assert!(manifest.contains("special-name-123"));
414 }
415
416 fn assert_has_context(template: &str, parsed: &toml::Value) {
417 assert!(
418 parsed.get("context").is_some(),
419 "template '{}' missing [context]",
420 template
421 );
422 }
423
424 #[test]
425 fn all_templates_have_context_regions() {
426 for template in &["software-engineer", "coder", "researcher"] {
427 let manifest = create_manifest("test", template);
428 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
429 assert_has_context(template, &parsed);
430 }
431 }
432
433 #[test]
434 #[should_panic(expected = "template 'bogus' missing [context]")]
435 fn all_templates_have_context_regions_panics_when_missing() {
436 let parsed: toml::Value = toml::from_str("").unwrap();
437 assert_has_context("bogus", &parsed);
438 }
439
440 #[tokio::test]
446 async fn execute_creates_blueprint_dir_with_expected_files() {
447 let dir = tempfile::tempdir().unwrap();
448 let blueprint_path = dir.path().join("my-new-agent");
449 let args = CreateArgs {
450 name: blueprint_path.to_str().unwrap().to_string(),
451 template: "coder".to_string(),
452 };
453
454 with_tracing(|| execute(args)).await.unwrap();
455
456 assert!(blueprint_path.join("agent.leviath").exists());
457 assert!(blueprint_path.join(".gitignore").exists());
458 assert!(blueprint_path.join(".env.example").exists());
459
460 let manifest = fs::read_to_string(blueprint_path.join("agent.leviath")).unwrap();
461 assert!(manifest.contains("analyze"));
462 }
463
464 #[tokio::test]
465 async fn execute_default_template_is_software_engineer_shape() {
466 let dir = tempfile::tempdir().unwrap();
467 let blueprint_path = dir.path().join("default-template-agent");
468 let args = CreateArgs {
469 name: blueprint_path.to_str().unwrap().to_string(),
470 template: "software-engineer".to_string(),
471 };
472
473 with_tracing(|| execute(args)).await.unwrap();
474
475 let manifest = fs::read_to_string(blueprint_path.join("agent.leviath")).unwrap();
476 let parsed: toml::Value = toml::from_str(&manifest).unwrap();
477 assert_eq!(
478 parsed["agent"]["name"].as_str().unwrap(),
479 blueprint_path.to_str().unwrap()
480 );
481 }
482
483 #[tokio::test]
484 async fn execute_existing_directory_errors() {
485 let dir = tempfile::tempdir().unwrap();
486 let blueprint_path = dir.path().join("already-exists");
487 fs::create_dir_all(&blueprint_path).unwrap();
488
489 let args = CreateArgs {
490 name: blueprint_path.to_str().unwrap().to_string(),
491 template: "coder".to_string(),
492 };
493
494 let err = with_tracing(|| execute(args)).await.unwrap_err();
495 assert!(err.to_string().contains("already exists"));
496 }
497
498 #[tokio::test]
499 async fn execute_create_dir_all_fails_when_ancestor_is_a_file() {
500 let dir = tempfile::tempdir().unwrap();
505 let blocking_file = dir.path().join("not-a-directory");
506 fs::write(&blocking_file, "x").unwrap();
507 let blueprint_path = blocking_file.join("nested-blueprint");
508
509 let args = CreateArgs {
510 name: blueprint_path.to_str().unwrap().to_string(),
511 template: "coder".to_string(),
512 };
513
514 let result = with_tracing(|| execute(args)).await;
515 assert!(result.is_err());
516 }
517
518 fn args_for(dir: &std::path::Path, name: &str) -> CreateArgs {
526 CreateArgs {
527 name: dir.join(name).to_str().unwrap().to_string(),
528 template: "coder".to_string(),
529 }
530 }
531
532 #[test]
533 fn execute_with_agent_manifest_write_failure_propagates() {
534 let dir = tempfile::tempdir().unwrap();
535 let args = args_for(dir.path(), "manifest-write-fails");
536
537 let result = execute_with(args, &|_path, _contents| {
543 Err(std::io::Error::other(
544 "injected agent.leviath write failure",
545 ))
546 });
547
548 let err = result.unwrap_err();
549 assert!(
550 err.to_string()
551 .contains("injected agent.leviath write failure")
552 );
553 }
554
555 #[test]
556 fn execute_with_gitignore_write_failure_propagates() {
557 let dir = tempfile::tempdir().unwrap();
558 let args = args_for(dir.path(), "gitignore-write-fails");
559
560 let result = execute_with(args, &|path, contents| {
561 if path.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
562 Err(std::io::Error::other("injected .gitignore write failure"))
563 } else {
564 fs::write(path, contents)
565 }
566 });
567
568 let err = result.unwrap_err();
569 assert!(
570 err.to_string()
571 .contains("injected .gitignore write failure")
572 );
573 assert!(
575 dir.path()
576 .join("gitignore-write-fails")
577 .join("agent.leviath")
578 .exists()
579 );
580 }
581
582 #[test]
583 fn execute_with_env_example_write_failure_propagates() {
584 let dir = tempfile::tempdir().unwrap();
585 let args = args_for(dir.path(), "env-example-write-fails");
586
587 let result = execute_with(args, &|path, contents| {
588 if path.file_name().and_then(|n| n.to_str()) == Some(".env.example") {
589 Err(std::io::Error::other("injected .env.example write failure"))
590 } else {
591 fs::write(path, contents)
592 }
593 });
594
595 let err = result.unwrap_err();
596 assert!(
597 err.to_string()
598 .contains("injected .env.example write failure")
599 );
600 let created = dir.path().join("env-example-write-fails");
602 assert!(created.join("agent.leviath").exists());
603 assert!(created.join(".gitignore").exists());
604 }
605}