use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use clap::{CommandFactory, Parser};
use tracing::debug;
use tracing_subscriber::EnvFilter;
use loopflow::journal::{self, LfEventFields, LfEventType, LfNode};
use loopflow::lf::{Cli, Commands};
struct ArgTables {
commands: HashSet<String>,
value_flags: HashSet<String>,
bool_flags: HashSet<String>,
}
fn arg_tables() -> &'static ArgTables {
static TABLES: OnceLock<ArgTables> = OnceLock::new();
TABLES.get_or_init(|| {
let mut cli = Cli::command();
cli.build();
let mut commands = HashSet::new();
for sub in cli.get_subcommands() {
commands.insert(sub.get_name().to_string());
commands.extend(sub.get_all_aliases().map(String::from));
}
let mut value_flags = HashSet::new();
let mut bool_flags = HashSet::new();
for arg in cli.get_arguments() {
let flags = if arg.get_action().takes_values() {
&mut value_flags
} else {
&mut bool_flags
};
if let Some(short) = arg.get_short() {
flags.insert(format!("-{short}"));
}
for alias in arg.get_all_short_aliases().into_iter().flatten() {
flags.insert(format!("-{alias}"));
}
if let Some(long) = arg.get_long() {
flags.insert(format!("--{long}"));
}
for alias in arg.get_all_aliases().into_iter().flatten() {
flags.insert(format!("--{alias}"));
}
}
ArgTables {
commands,
value_flags,
bool_flags,
}
})
}
fn is_value_flag(arg: &str) -> bool {
arg_tables().value_flags.contains(arg)
}
fn is_known_flag(arg: &str) -> bool {
arg_tables().bool_flags.contains(arg) || is_value_flag(arg)
}
fn reorder_args(args: Vec<String>) -> Vec<String> {
if args.len() <= 1 {
return args;
}
let program = args[0].clone();
let rest = &args[1..];
if let Some(first) = rest.first() {
if arg_tables().commands.contains(first.as_str()) {
return args;
}
}
let mut flags_before: Vec<String> = Vec::new();
let mut step_and_args: Vec<String> = Vec::new();
let mut flags_after: Vec<String> = Vec::new();
let mut i = 0;
let mut found_step = false;
while i < rest.len() {
let arg = &rest[i];
if !found_step {
if arg.starts_with('-') {
flags_before.push(arg.clone());
if is_value_flag(arg) && i + 1 < rest.len() {
i += 1;
flags_before.push(rest[i].clone());
}
} else {
found_step = true;
step_and_args.push(arg.clone());
}
} else {
if arg.starts_with('-') {
if is_known_flag(arg) {
flags_after.push(arg.clone());
if is_value_flag(arg) && i + 1 < rest.len() {
i += 1;
flags_after.push(rest[i].clone());
}
} else {
step_and_args.push(arg.clone());
}
} else {
step_and_args.push(arg.clone());
}
}
i += 1;
}
let mut result = vec![program];
result.extend(flags_before);
result.extend(flags_after);
result.extend(step_and_args);
result
}
fn join_args(args: &[String]) -> Option<String> {
if args.is_empty() {
None
} else {
Some(args.join(" "))
}
}
fn with_runtime<T>(
repo_root: &std::path::Path,
command: &[String],
run: impl FnOnce() -> anyhow::Result<T>,
) -> anyhow::Result<T> {
journal::emit(
repo_root,
LfNode::Run,
LfEventType::Started,
LfEventFields {
wave_name: loopflow::engine::worktrees::main_repo_root(repo_root)
.ok()
.and_then(|main_repo| {
loopflow::engine::worktrees::wave_name_from_worktree_and_main(
repo_root, &main_repo,
)
}),
worktree: Some(repo_root.display().to_string()),
command: Some(command.to_vec()),
..LfEventFields::default()
},
);
let result = run();
match &result {
Ok(_) => journal::emit(
repo_root,
LfNode::Run,
LfEventType::Completed,
LfEventFields::default(),
),
Err(err) => journal::emit(
repo_root,
LfNode::Run,
LfEventType::Errored,
LfEventFields {
error: Some(err.to_string()),
..LfEventFields::default()
},
),
}
result
}
fn in_repo_runtime<T>(
command: &[String],
run: impl FnOnce(&std::path::Path) -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let repo_root = loopflow::lf::commands::util::find_repo_root()?;
with_runtime(&repo_root, command, || run(&repo_root))
}
fn with_step_runtime<T>(
repo_root: &std::path::Path,
step_name: &str,
run: impl FnOnce() -> anyhow::Result<T>,
) -> anyhow::Result<T> {
journal::emit(
repo_root,
LfNode::Step,
LfEventType::Started,
LfEventFields {
step: Some(step_name.to_string()),
index: Some(0),
..LfEventFields::default()
},
);
let result = run();
match &result {
Ok(_) => journal::emit(
repo_root,
LfNode::Step,
LfEventType::Completed,
LfEventFields {
step: Some(step_name.to_string()),
index: Some(0),
..LfEventFields::default()
},
),
Err(err) => journal::emit(
repo_root,
LfNode::Step,
LfEventType::Errored,
LfEventFields {
step: Some(step_name.to_string()),
index: Some(0),
error: Some(err.to_string()),
..LfEventFields::default()
},
),
}
result
}
fn run_target(
name: &str,
message: Option<&str>,
cli: &Cli,
command: &[String],
) -> anyhow::Result<()> {
let repo_root = loopflow::lf::commands::util::find_repo_root()?;
if placement_requested(cli) {
return run_placed_target(&repo_root, name, message, cli, command);
}
run_target_in_repo(&repo_root, name, message, cli, command)
}
fn run_target_in_repo(
repo_root: &Path,
name: &str,
message: Option<&str>,
cli: &Cli,
command: &[String],
) -> anyhow::Result<()> {
match loopflow::lf::discovery::discover_target(repo_root, name)? {
loopflow::lf::discovery::Target::Step(_) => with_runtime(repo_root, command, || {
with_step_runtime(repo_root, name, || {
loopflow::lf::commands::run::run(Some(name), message, cli)?;
let options = loopflow::ops::CommitOptions {
add: true,
message: Some(format!("lf commit: {name}")),
..loopflow::ops::CommitOptions::for_task(name)
};
loopflow::ops::commit_workflow(repo_root, &options, &loopflow::ops::NullProgress)?;
Ok(())
})
}),
loopflow::lf::discovery::Target::Flow(flow) => with_runtime(repo_root, command, || {
loopflow::lf::commands::flow::run(&flow, message, cli, repo_root)
}),
}
}
fn placement_requested(cli: &Cli) -> bool {
cli.dispatch || cli.stack.is_some() || cli.fork
}
fn inner_placement_invocation(command: &[String]) -> anyhow::Result<(Cli, Vec<String>)> {
let inner_command = strip_placement_args(command);
let inner_cli = Cli::try_parse_from(inner_command.clone())?;
if placement_requested(&inner_cli) {
return Err(anyhow::anyhow!(
"placement flags leaked into placed invocation"
));
}
Ok((inner_cli, inner_command))
}
fn strip_placement_args(command: &[String]) -> Vec<String> {
let mut stripped = Vec::with_capacity(command.len());
let mut args = command.iter().peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"--dispatch" | "--fork" => {}
"--stack" => {
let _ = args.next();
}
value if value.starts_with("--stack=") => {}
_ => stripped.push(arg.clone()),
}
}
stripped
}
fn run_placed_target(
repo_root: &Path,
name: &str,
message: Option<&str>,
cli: &Cli,
command: &[String],
) -> anyhow::Result<()> {
let wave_name = resolve_placement_wave(repo_root, cli)?;
let placement = if let Some(parent) = cli.stack.as_deref() {
loopflow::lfd::executor::Placement::Stack {
parent_run_id: parent
.parse()
.map_err(|_| anyhow::anyhow!("invalid --stack run id: '{parent}'"))?,
}
} else {
loopflow::lfd::executor::Placement::Fresh
};
let target_branch = if cli.dispatch {
loopflow::engine::git::current_branch(repo_root)?
} else {
None
};
let runtime = tokio::runtime::Runtime::new()?;
let store: loopflow::lfdb::SharedStore = std::sync::Arc::new(runtime.block_on(async {
loopflow::lfdb::open_existing_store().await.ok_or_else(|| {
anyhow::anyhow!(
"no run registry on this machine — nothing has created ~/.lf/lfd.db yet"
)
})
})?);
let mut run = runtime.block_on(async {
let wave = store
.get_wave_by_name(&wave_name)
.await?
.ok_or_else(|| anyhow::anyhow!("wave '{wave_name}' not found in the registry"))?;
let run_id = loopflow::lfd::id::LfdId::new();
let mut run = loopflow::lfd::executor::create_run_for_placement(
&store,
&wave,
&run_id,
&placement,
target_branch.as_deref(),
)
.await?;
run.flow = name.trim_end_matches(':').to_string();
run.task = message.map(str::to_string);
store.update_run(&run).await?;
anyhow::Ok(run)
})?;
eprintln!("placed {name} in {}", run.worktree);
let _cwd = CurrentDirGuard::set(&run.worktree)?;
let _wave_env = EnvGuard::set("LFD_WAVE_ID", run.wave_id.to_string());
let _run_env = EnvGuard::set("LFD_RUN_ID", run.id.to_string());
let _lf_run_env = EnvGuard::set("LF_RUN_ID", run.id.to_string());
let (inner_cli, inner_command) = inner_placement_invocation(command)?;
let result = run_target_in_repo(
Path::new(&run.worktree),
name,
message,
&inner_cli,
&inner_command,
);
let succeeded = result.is_ok();
let error = result.as_ref().err().map(ToString::to_string);
runtime.block_on(async {
run.status = if succeeded {
loopflow::lfd::types::RunStatus::Completed
} else {
loopflow::lfd::types::RunStatus::Failed
};
run.ended_at = Some(time::OffsetDateTime::now_utc());
run.error = error;
store.update_run(&run).await
})?;
result
}
fn resolve_placement_wave(repo_root: &Path, cli: &Cli) -> anyhow::Result<String> {
if let Some(wave) = cli.wave.as_ref().filter(|value| !value.trim().is_empty()) {
return Ok(wave.clone());
}
let main_repo = loopflow::engine::worktrees::main_repo_root(repo_root)?;
loopflow::engine::worktrees::wave_name_from_worktree_and_main(repo_root, &main_repo)
.ok_or_else(|| anyhow::anyhow!("placement requires --wave or an ambient wave worktree"))
}
struct CurrentDirGuard {
previous: PathBuf,
}
impl CurrentDirGuard {
fn set(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let previous = std::env::current_dir()?;
std::env::set_current_dir(path)?;
Ok(Self { previous })
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
if let Err(err) = std::env::set_current_dir(&self.previous) {
eprintln!("failed to restore cwd {}: {err}", self.previous.display());
}
}
}
struct EnvGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: impl Into<String>) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value.into());
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = &self.previous {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
fn main() -> anyhow::Result<()> {
ctrlc::set_handler(|| {
loopflow::engine::agent::run_interrupt_cleanups();
loopflow::engine::agent::kill_child_if_running();
std::process::exit(130);
})
.expect("failed to set Ctrl+C handler");
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("lf=info,loopflow=info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.without_time()
.init();
let raw_args: Vec<String> = std::env::args().collect();
let args = reorder_args(raw_args.clone());
let cli = Cli::parse_from(args.clone());
debug!(?cli, "parsed CLI arguments");
let registration = match run_label(&cli) {
Some(step) => loopflow::lf::session::register_run(
&step,
cli.model.as_deref().unwrap_or("lf"),
&raw_args,
),
None => {
loopflow::lf::session::mark_child_sessions_inherited();
None
}
};
let result = if cli.list {
in_repo_runtime(&args, |_| loopflow::lf::commands::list::show_all())
} else {
match &cli.command {
Some(Commands::Inline { prompt }) => {
let text = prompt.join(" ");
in_repo_runtime(&args, |_| {
loopflow::lf::commands::run::run(None, Some(&text), &cli)
})
}
Some(Commands::Op { op }) => in_repo_runtime(&args, |_| {
loopflow::lf::commands::ops::run(op, cli.model.as_deref())
}),
Some(Commands::Wave {
name,
force,
no_mind,
mind_only,
}) => in_repo_runtime(&args, |_| {
loopflow::wave::run(name, *force, *no_mind, *mind_only)
}),
Some(Commands::Usage) => loopflow::lf::commands::usage::run(),
Some(Commands::Runs) => loopflow::lf::commands::runs::list(),
Some(Commands::Trace { run_id }) => loopflow::lf::commands::runs::trace(run_id),
Some(Commands::Chat { text, from, target }) => {
loopflow::lf::commands::chat::run(text, from.as_deref(), target)
}
Some(Commands::Sub { wave, json }) => {
loopflow::lf::commands::sub::run(wave.as_deref(), *json)
}
Some(Commands::Memory { cmd, target }) => {
loopflow::lf::commands::memory::run(cmd.as_ref(), target)
}
Some(Commands::External(external_args)) => {
match loopflow::lf::commands::run::split_step_args(external_args) {
Ok((name, step_args)) => {
let message = join_args(&step_args);
run_target(&name, message.as_deref(), &cli, &args)
}
Err(err) => Err(err),
}
}
None => in_repo_runtime(&args, |_| {
loopflow::lf::commands::run::run(None, None, &cli)
}),
}
};
if let Some(registration) = registration {
registration.complete(if result.is_ok() { 0 } else { 1 });
}
result
}
fn run_label(cli: &Cli) -> Option<String> {
if cli.list {
return None;
}
if placement_requested(cli) {
return None;
}
match &cli.command {
Some(Commands::Inline { .. }) => Some("inline".to_string()),
Some(Commands::External(args)) => args
.first()
.map(|step| step.trim_end_matches(':').to_string()),
None => Some("interactive".to_string()),
Some(Commands::Op { .. })
| Some(Commands::Wave { .. })
| Some(Commands::Usage)
| Some(Commands::Runs)
| Some(Commands::Trace { .. })
| Some(Commands::Chat { .. })
| Some(Commands::Sub { .. })
| Some(Commands::Memory { .. }) => None,
}
}
#[cfg(test)]
mod tests {
use super::{arg_tables, inner_placement_invocation, placement_requested, reorder_args};
use clap::Parser;
use loopflow::lf::{Cli, Commands};
#[test]
fn derived_tables_cover_commands_flags_and_aliases() {
let tables = arg_tables();
for command in [
":", "op", "wave", "chat", "memory", "usage", "runs", "trace", "help",
] {
assert!(tables.commands.contains(command), "command {command}");
}
for flag in [
"-d",
"-D",
"--direction",
"--docs",
"-m",
"-M",
"--model",
"-w",
"-W",
"--wave",
"--stack",
] {
assert!(tables.value_flags.contains(flag), "value flag {flag}");
}
for flag in [
"-l",
"--list",
"-c",
"-C",
"--clipboard",
"--no-direction",
"--yolo",
"-i",
"-I",
"-b",
"-B",
"--tui",
"--ide",
"--chrome",
"--no-chrome",
"--diff-files",
"--no-diff-files",
"--diff",
"--no-diff",
"--no-loopflow",
"--dispatch",
"--fork",
"-h",
"--help",
"-V",
"--version",
] {
assert!(tables.bool_flags.contains(flag), "bool flag {flag}");
}
}
#[test]
fn reorder_args_uppercase_value_alias_after_step() {
let args = vec![
"lf".to_string(),
"debug".to_string(),
"-M".to_string(),
"codex".to_string(),
];
assert_eq!(reorder_args(args), vec!["lf", "-M", "codex", "debug"]);
}
#[test]
fn reorder_args_uppercase_bool_alias_after_step() {
let args = vec!["lf".to_string(), "debug".to_string(), "-C".to_string()];
assert_eq!(reorder_args(args), vec!["lf", "-C", "debug"]);
}
#[test]
fn reorder_args_flag_after_step() {
let args = vec!["lf".to_string(), "debug".to_string(), "-c".to_string()];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-c", "debug"]);
}
#[test]
fn reorder_args_placement_flags_after_step() {
let args = vec![
"lf".to_string(),
"implement".to_string(),
"ship it".to_string(),
"--dispatch".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "--dispatch", "implement", "ship it"]);
}
#[test]
fn placed_inner_invocation_strips_dispatch() {
let command: Vec<String> = [
"lf",
"--dispatch",
"-b",
"--wave",
"goals",
"implement",
"ship it",
]
.map(String::from)
.to_vec();
let cli = Cli::parse_from(command.clone());
assert!(placement_requested(&cli));
let (inner_cli, inner_command) = inner_placement_invocation(&command).unwrap();
assert!(!placement_requested(&inner_cli));
assert_eq!(
inner_command,
vec!["lf", "-b", "--wave", "goals", "implement", "ship it"]
);
assert!(inner_cli.batch);
assert_eq!(inner_cli.wave.as_deref(), Some("goals"));
assert!(matches!(inner_cli.command, Some(Commands::External(_))));
}
#[test]
fn placed_inner_invocation_strips_stack_value_forms() {
let spaced: Vec<String> = ["lf", "--stack", "01JSTACK", "implement"]
.map(String::from)
.to_vec();
let (inner_cli, inner_command) = inner_placement_invocation(&spaced).unwrap();
assert!(!placement_requested(&inner_cli));
assert_eq!(inner_command, vec!["lf", "implement"]);
let equals: Vec<String> = ["lf", "--stack=01JSTACK", "implement"]
.map(String::from)
.to_vec();
let (inner_cli, inner_command) = inner_placement_invocation(&equals).unwrap();
assert!(!placement_requested(&inner_cli));
assert_eq!(inner_command, vec!["lf", "implement"]);
}
#[test]
fn reorder_args_flag_before_step() {
let args = vec!["lf".to_string(), "-c".to_string(), "debug".to_string()];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-c", "debug"]);
}
#[test]
fn reorder_args_value_flag_before_step() {
let args = vec![
"lf".to_string(),
"-m".to_string(),
"codex".to_string(),
"implement".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-m", "codex", "implement"]);
}
#[test]
fn reorder_args_value_flag_after_step() {
let args = vec![
"lf".to_string(),
"debug".to_string(),
"-m".to_string(),
"codex".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-m", "codex", "debug"]);
}
#[test]
fn reorder_args_mixed_flags() {
let args = vec![
"lf".to_string(),
"-i".to_string(),
"implement".to_string(),
"-c".to_string(),
"-m".to_string(),
"claude".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-i", "-c", "-m", "claude", "implement"]);
}
#[test]
fn reorder_args_no_direction_flag_after_step() {
let args = vec![
"lf".to_string(),
"implement".to_string(),
"--no-direction".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "--no-direction", "implement"]);
}
#[test]
fn reorder_args_no_loopflow_flag_after_step() {
let args = vec![
"lf".to_string(),
"gate".to_string(),
"--no-loopflow".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "--no-loopflow", "gate"]);
}
#[test]
fn reorder_args_step_with_args() {
let args = vec![
"lf".to_string(),
"implement:".to_string(),
"add".to_string(),
"logout".to_string(),
"-c".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-c", "implement:", "add", "logout"]);
}
#[test]
fn reorder_args_known_command_unchanged() {
let args = vec![
"lf".to_string(),
"op".to_string(),
"commit".to_string(),
"-m".to_string(),
"msg".to_string(),
];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "op", "commit", "-m", "msg"]);
}
#[test]
fn reorder_args_leaves_chat_and_memory_targeting_alone() {
let args: Vec<String> = ["lf", "chat", "--wave", "systems", "shipped it"]
.map(String::from)
.to_vec();
assert_eq!(
reorder_args(args),
vec!["lf", "chat", "--wave", "systems", "shipped it"]
);
let args: Vec<String> = ["lf", "memory", "add", "fact", "--wave", "systems"]
.map(String::from)
.to_vec();
assert_eq!(
reorder_args(args),
vec!["lf", "memory", "add", "fact", "--wave", "systems"]
);
}
#[test]
fn reorder_args_no_step() {
let args = vec!["lf".to_string(), "-l".to_string()];
let result = reorder_args(args);
assert_eq!(result, vec!["lf", "-l"]);
}
}