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#[derive(Args)]
8pub struct CreateArgs {
9    /// Blueprint name
10    #[arg(value_name = "NAME")]
11    pub name: String,
12
13    /// Starting template (software-engineer, coder, researcher)
14    #[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
22/// Core of `execute()`, parameterized over the file-write primitive so tests
23/// can force any individual write's error arm deterministically - without a
24/// process-global umask mutation (which is rejected here, for good reason:
25/// `cargo test`'s default thread-based parallelism means a restrictive umask
26/// can't be scoped to one test the way an env var or CWD lock can, so ANY
27/// other test creating a file/directory on another thread during that window
28/// would silently get the same zero-permission treatment). Each real call site
29/// still goes through the exact same
30/// `std::fs::write` in production (`execute` above passes it directly, with
31/// zero indirection cost); only tests substitute a fake.
32fn 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
73/// Escapes a string for embedding inside a TOML basic (double-quoted)
74/// string literal. Without this, a blueprint name containing a backslash
75/// (e.g. a Windows path like `C:\Users\...\my-agent`, which `lev create`
76/// accepts directly as the blueprint name/directory) breaks TOML parsing:
77/// `\U` is interpreted as the start of an 8-digit-hex unicode escape, not a
78/// literal backslash-U.
79fn 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        // Regression test: `lev create` accepts a full path as the blueprint
261        // name (used directly as the target directory), and on Windows that
262        // path contains backslashes - e.g. `C:\Users\RUNNER~1\...\my-agent`.
263        // Before escaping, `\U` in the raw TOML string was parsed as the
264        // start of an (invalid) 8-digit-hex unicode escape, breaking every
265        // template. Confirmed this exact failure on real Windows CI.
266        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        // Every generated template ships percentage budgets and must parse under
308        // the real manifest parser (which validates `budget`/`compact_at`).
309        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            // Explicit conversation sliding_window. (matches! is the FIRST operand
333            // so it's evaluated for every region - non-sliding regions exercise its
334            // false arm, the conversation region its true arm.)
335            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            // No routing targets a non-conversation sliding_window.
344            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            // Every compacting region has a compact_history pair.
364            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        // Default template has a single "main" stage
389        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    // ─── execute ─────────────────────────────────────────────────────────
441    //
442    // `args.name` is used directly as a Path - passing an absolute tempdir
443    // path makes this testable without touching the real CWD.
444
445    #[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        // `blueprint_dir.exists()` (the early bail check) returns `false` for
501        // this path - `Path::exists()` can't stat through a non-directory
502        // path component - so execution reaches `fs::create_dir_all(...)?`,
503        // which then genuinely fails (ancestor isn't a directory).
504        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    // ─── execute_with: injected write-failure arms ─────────────────────────
519    //
520    // These exercise the 3 `write_file(...)?` error arms deterministically,
521    // without any process-global umask mutation - each test injects a plain
522    // local closure that fails for one specific target filename, leaving the
523    // others to succeed exactly as production would.
524
525    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        // `agent.leviath` is unconditionally the *first* write `execute_with`
538        // attempts, so failing on every call (rather than branching on the
539        // path) is sufficient here and avoids an else-arm that could never
540        // actually run: the `?` on this first failure returns before any
541        // other path is ever passed to this closure.
542        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        // The manifest write before it genuinely happened.
574        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        // The two writes before it genuinely happened.
601        let created = dir.path().join("env-example-write-fails");
602        assert!(created.join("agent.leviath").exists());
603        assert!(created.join(".gitignore").exists());
604    }
605}