Skip to main content

leviath_cli/commands/
create.rs

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