Add fabro workflow create <name> 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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-14 14:32:00 -04:00
parent 79c602cc7f
commit 12e71e3464
No known key found for this signature in database
3 changed files with 302 additions and 3 deletions

View file

@ -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) => {

View file

@ -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<String>,
#[serde(default = "default_graph")]
pub graph: String,
#[serde(alias = "directory")]
pub work_dir: Option<String>,
@ -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");
}
}

View file

@ -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<String>,
}
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() {