From 12e71e3464c608e591d2bc6ff84fa41350e39c1d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 14 Mar 2026 14:32:00 -0400 Subject: [PATCH] Add `fabro workflow create ` subcommand to scaffold new workflows Writes a starter workflow.fabro (DOT graph) and workflow.toml into the project's workflows directory. Supports --goal flag and derives the digraph name from the workflow name using PascalCase conversion. Also defaults the `graph` field in workflow.toml to "workflow.fabro" so it can be omitted from generated configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/main.rs | 6 + .../fabro-workflows/src/cli/run_config.rs | 17 +- .../fabro-workflows/src/cli/workflow.rs | 282 +++++++++++++++++- 3 files changed, 302 insertions(+), 3 deletions(-) diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index fd10d5062..537644b37 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -168,6 +168,8 @@ enum SkillCommand { enum WorkflowCommand { /// List available workflows List(fabro_workflows::cli::workflow::WorkflowListArgs), + /// Create a new workflow + Create(fabro_workflows::cli::workflow::WorkflowCreateArgs), } #[derive(Subcommand)] @@ -316,6 +318,7 @@ async fn main_inner() -> (String, Result<()>) { Command::Rewind(_) => "rewind", Command::Workflow { command } => match command { WorkflowCommand::List(_) => "workflow list", + WorkflowCommand::Create(_) => "workflow create", }, Command::Skill { command } => match command { SkillCommand::Install(_) => "skill install", @@ -619,6 +622,9 @@ async fn main_inner() -> (String, Result<()>) { WorkflowCommand::List(args) => { fabro_workflows::cli::workflow::workflow_list_command(&args)?; } + WorkflowCommand::Create(args) => { + fabro_workflows::cli::workflow::workflow_create_command(&args)?; + } }, Command::Skill { command } => match command { SkillCommand::Install(args) => { diff --git a/lib/crates/fabro-workflows/src/cli/run_config.rs b/lib/crates/fabro-workflows/src/cli/run_config.rs index 8189d612a..79375cde4 100644 --- a/lib/crates/fabro-workflows/src/cli/run_config.rs +++ b/lib/crates/fabro-workflows/src/cli/run_config.rs @@ -22,6 +22,10 @@ fn default_true() -> bool { true } +fn default_graph() -> String { + "workflow.fabro".to_string() +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct PullRequestConfig { #[serde(default)] @@ -41,6 +45,7 @@ pub struct AssetsConfig { pub struct WorkflowRunConfig { pub version: u32, pub goal: Option, + #[serde(default = "default_graph")] pub graph: String, #[serde(alias = "directory")] pub work_dir: Option, @@ -895,12 +900,13 @@ graph = "p.fabro" } #[test] - fn graph_is_required() { + fn graph_defaults_when_omitted() { let no_graph = r#" version = 1 goal = "x" "#; - assert!(parse_run_config(no_graph).is_err()); + let config = parse_run_config(no_graph).unwrap(); + assert_eq!(config.graph, "workflow.fabro"); } #[test] @@ -2413,4 +2419,11 @@ command = "echo from-workflow" assert_eq!(cfg.hooks.len(), 1); assert_eq!(cfg.hooks[0].event, crate::hook::HookEvent::RunComplete); } + + #[test] + fn graph_defaults_to_workflow_fabro() { + let toml = "version = 1\n"; + let config = parse_run_config(toml).unwrap(); + assert_eq!(config.graph, "workflow.fabro"); + } } diff --git a/lib/crates/fabro-workflows/src/cli/workflow.rs b/lib/crates/fabro-workflows/src/cli/workflow.rs index 4b5069ae2..494d86997 100644 --- a/lib/crates/fabro-workflows/src/cli/workflow.rs +++ b/lib/crates/fabro-workflows/src/cli/workflow.rs @@ -1,4 +1,6 @@ -use anyhow::bail; +use std::path::Path; + +use anyhow::{bail, Context}; use clap::Args; use fabro_util::terminal::Styles; @@ -66,6 +68,131 @@ pub fn workflow_list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> { Ok(()) } +#[derive(Args)] +pub struct WorkflowCreateArgs { + /// Name of the workflow + pub name: String, + + /// Goal description for the workflow + #[arg(short, long)] + goal: Option, +} + +pub fn workflow_create_command(args: &WorkflowCreateArgs) -> anyhow::Result<()> { + let cwd = std::env::current_dir()?; + + let (config_path, config) = match discover_project_config(&cwd)? { + Some(found) => found, + None => bail!( + "No fabro.toml found in {cwd} or any parent directory", + cwd = cwd.display() + ), + }; + + let fabro_root = resolve_fabro_root(&config_path, &config); + write_workflow_scaffold(args, &fabro_root)?; + + let workflows_dir = fabro_root.join("workflows").join(&args.name); + let green = console::Style::new().green(); + let bold = console::Style::new().bold(); + let cyan_bold = console::Style::new().cyan().bold(); + let dim = console::Style::new().dim(); + + let rel_dir = relative_path(&workflows_dir); + eprintln!( + " {} {}", + green.apply_to("✔"), + dim.apply_to(format!("{rel_dir}/workflow.fabro")) + ); + eprintln!( + " {} {}", + green.apply_to("✔"), + dim.apply_to(format!("{rel_dir}/workflow.toml")) + ); + + eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!")); + eprintln!( + " 1. Edit the graph: {}", + cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro")) + ); + eprintln!( + " 2. Validate: {}", + cyan_bold.apply_to(format!("fabro validate {}", args.name)) + ); + eprintln!( + " 3. Run: {}", + cyan_bold.apply_to(format!("fabro run {}", args.name)) + ); + + Ok(()) +} + +/// Create a workflow in a specific project (for testing). +pub fn workflow_create_in(args: &WorkflowCreateArgs, config_path: &Path) -> anyhow::Result<()> { + let config = super::project_config::load_project_config(config_path)?; + let fabro_root = resolve_fabro_root(config_path, &config); + write_workflow_scaffold(args, &fabro_root) +} + +fn write_workflow_scaffold(args: &WorkflowCreateArgs, fabro_root: &Path) -> anyhow::Result<()> { + let workflows_dir = fabro_root.join("workflows").join(&args.name); + + if workflows_dir.exists() { + bail!( + "Workflow '{}' already exists at {}", + args.name, + workflows_dir.display() + ); + } + + std::fs::create_dir_all(&workflows_dir) + .with_context(|| format!("failed to create {}", workflows_dir.display()))?; + + let goal = args.goal.as_deref().unwrap_or("TODO: describe the goal"); + let digraph_name = to_pascal_case(&args.name); + + let fabro_content = format!( + r#"digraph {digraph_name} {{ + graph [goal="{goal}"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + main [label="Main", prompt="TODO: describe what this agent should do"] + + start -> main -> exit +}} +"# + ); + + let dot_path = workflows_dir.join("workflow.fabro"); + std::fs::write(&dot_path, &fabro_content) + .with_context(|| format!("failed to write {}", dot_path.display()))?; + + let toml_path = workflows_dir.join("workflow.toml"); + std::fs::write(&toml_path, "version = 1\n") + .with_context(|| format!("failed to write {}", toml_path.display()))?; + + Ok(()) +} + +fn to_pascal_case(s: &str) -> String { + s.split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let upper: String = first.to_uppercase().collect(); + format!("{upper}{rest}", rest = chars.as_str()) + } + None => String::new(), + } + }) + .collect() +} + fn print_section( title: &str, path: &str, @@ -114,6 +241,159 @@ fn truncate_str(s: &str, max: usize) -> String { #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_project(tmp: &TempDir) -> std::path::PathBuf { + let config_path = tmp.path().join("fabro.toml"); + fs::write(&config_path, "version = 1\n\n[fabro]\nroot = \"fabro/\"\n").unwrap(); + config_path + } + + #[test] + fn creates_workflow_directory_and_files() { + let tmp = TempDir::new().unwrap(); + let config_path = setup_project(&tmp); + + let args = WorkflowCreateArgs { + name: "deploy".to_string(), + goal: None, + }; + workflow_create_in(&args, &config_path).unwrap(); + + let wf_dir = tmp.path().join("fabro/workflows/deploy"); + assert!(wf_dir.join("workflow.fabro").exists()); + assert!(wf_dir.join("workflow.toml").exists()); + } + + #[test] + fn goal_appears_in_generated_fabro() { + let tmp = TempDir::new().unwrap(); + let config_path = setup_project(&tmp); + + let args = WorkflowCreateArgs { + name: "deploy".to_string(), + goal: Some("Deploy the app".to_string()), + }; + workflow_create_in(&args, &config_path).unwrap(); + + let content = + fs::read_to_string(tmp.path().join("fabro/workflows/deploy/workflow.fabro")).unwrap(); + assert!(content.contains(r#"goal="Deploy the app""#)); + } + + #[test] + fn default_goal_is_todo_placeholder() { + let tmp = TempDir::new().unwrap(); + let config_path = setup_project(&tmp); + + let args = WorkflowCreateArgs { + name: "deploy".to_string(), + goal: None, + }; + workflow_create_in(&args, &config_path).unwrap(); + + let content = + fs::read_to_string(tmp.path().join("fabro/workflows/deploy/workflow.fabro")).unwrap(); + assert!(content.contains(r#"goal="TODO:"#)); + } + + #[test] + fn fails_if_workflow_already_exists() { + let tmp = TempDir::new().unwrap(); + let config_path = setup_project(&tmp); + + let wf_dir = tmp.path().join("fabro/workflows/deploy"); + fs::create_dir_all(&wf_dir).unwrap(); + + let args = WorkflowCreateArgs { + name: "deploy".to_string(), + goal: None, + }; + let err = workflow_create_in(&args, &config_path).unwrap_err(); + assert!( + err.to_string().contains("already exists"), + "expected 'already exists' in: {err}" + ); + } + + #[test] + fn fails_if_no_fabro_toml() { + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("fabro.toml"); + + let args = WorkflowCreateArgs { + name: "deploy".to_string(), + goal: None, + }; + let result = workflow_create_in(&args, &config_path); + assert!(result.is_err()); + } + + #[test] + fn digraph_name_derived_from_workflow_name() { + let tmp = TempDir::new().unwrap(); + let config_path = setup_project(&tmp); + + let args = WorkflowCreateArgs { + name: "my-workflow".to_string(), + goal: None, + }; + workflow_create_in(&args, &config_path).unwrap(); + + let content = fs::read_to_string( + tmp.path() + .join("fabro/workflows/my-workflow/workflow.fabro"), + ) + .unwrap(); + assert!( + content.contains("digraph MyWorkflow"), + "expected 'digraph MyWorkflow' in:\n{content}" + ); + } + + #[test] + fn generated_fabro_parses_and_validates() { + let tmp = TempDir::new().unwrap(); + let config_path = setup_project(&tmp); + + let args = WorkflowCreateArgs { + name: "test-wf".to_string(), + goal: Some("Test goal".to_string()), + }; + workflow_create_in(&args, &config_path).unwrap(); + + let content = + fs::read_to_string(tmp.path().join("fabro/workflows/test-wf/workflow.fabro")).unwrap(); + + let graph = crate::parser::parse(&content).expect("generated .fabro should parse"); + let diagnostics = crate::validation::validate(&graph, &[]); + let errors: Vec<_> = diagnostics + .iter() + .filter(|d| d.severity == crate::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "validation errors: {errors:?}"); + } + + #[test] + fn to_pascal_case_simple() { + assert_eq!(to_pascal_case("hello"), "Hello"); + } + + #[test] + fn to_pascal_case_hyphenated() { + assert_eq!(to_pascal_case("my-workflow"), "MyWorkflow"); + } + + #[test] + fn to_pascal_case_underscored() { + assert_eq!(to_pascal_case("my_workflow"), "MyWorkflow"); + } + + #[test] + fn to_pascal_case_mixed() { + assert_eq!(to_pascal_case("my-cool_workflow"), "MyCoolWorkflow"); + } #[test] fn truncate_str_short() {