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