mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
79c602cc7f
commit
12e71e3464
3 changed files with 302 additions and 3 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue