use crate::engine::agent::{launch_agent, AgentCapabilities, ProcessConfig};
use crate::engine::config::load_config_or_default;
use crate::engine::git::{current_branch, delete_local_branch, get_default_branch, sync_main};
use crate::engine::identity::WorktreeName;
use crate::engine::naming::git_user;
use crate::engine::worktrees::{
create_from_placement_plan, list_worktrees, main_repo_root, plan_placement,
sibling_worktree_name, sibling_worktree_name_with_main, PlacementStrategy, WorktreeSegment,
};
use crate::engine::{
prepare_launch_prompt, sync_skills, ContextSourceOverrides, LaunchPromptInput,
SkillSyncOptions, Surface,
};
use crate::lf::commands::util::find_repo_root;
use crate::lf::discovery::discover_skill;
use crate::lf::output::{column_width, Colors};
use crate::lf::{
CronCommand, PmCommand, PmProjectCommand, PmTaskCommand, PrCommand, ReleaseCommand, WtCommand,
};
use crate::ops::OpsError;
use crate::ops::{
abandon_branch, abort_rebase_for_resolution, commit_workflow, continue_rebase_for_resolution,
create_or_update_pr, current_pr, land, plan_rebase, rebase_class_name, rebase_strategy_name,
rebase_with_recovery, release_bump, release_check, release_notes, release_run, release_status,
release_tag, start_rebase_for_resolution, submit, AbandonOptions, CommitOptions, CronSpec,
LandOptions, PrOptions, Progress, RebaseOptions, SystemLaunchctl,
};
use anyhow::{anyhow, Result};
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use std::process::Command;
use std::time::Instant;
pub fn run_pr(cmd: Option<&PrCommand>, cli_model: Option<&str>) -> Result<()> {
let progress = CliProgress;
match cmd {
None | Some(PrCommand::Status) => pr_status(),
Some(PrCommand::Open { model, title, body }) => open_pr(
title.clone(),
body.clone(),
model.as_deref().or(cli_model),
&progress,
),
Some(PrCommand::Submit {
strict,
create_pr,
worktree,
message,
title,
body,
}) => submit_current(
&LandOptions {
strict: *strict,
local: false,
create_pr: *create_pr,
worktree: worktree.clone(),
commit_message: message.clone(),
pr_title: title.clone(),
pr_body: body.clone(),
agent: cli_model.map(str::to_string),
},
&progress,
),
Some(PrCommand::Land {
strict,
local,
create_pr,
worktree,
message,
title,
body,
}) => land_current(
&LandOptions {
strict: *strict,
local: *local,
create_pr: *create_pr,
worktree: worktree.clone(),
commit_message: message.clone(),
pr_title: title.clone(),
pr_body: body.clone(),
agent: cli_model.map(str::to_string),
},
&progress,
),
Some(PrCommand::Abandon { force, branch }) => {
abandon_current(branch.as_deref(), *force, &progress)
}
}
}
pub fn run_release(cmd: &ReleaseCommand) -> Result<()> {
let progress = CliProgress;
match cmd {
ReleaseCommand::Run { version, target } => {
release_run_cmd(version.as_deref(), target.as_deref(), &progress)
}
ReleaseCommand::Check { target } => release_check_cmd(target.as_deref()),
ReleaseCommand::Notes {
version,
prev_tag,
target,
} => release_notes_cmd(version, prev_tag.as_deref(), target.as_deref(), &progress),
ReleaseCommand::Bump { version, target } => {
release_bump_cmd(version, target.as_deref(), &progress)
}
ReleaseCommand::Tag { version, target } => release_tag_cmd(version, target.as_deref()),
ReleaseCommand::Status { target } => release_status_cmd(target.as_deref()),
}
}
struct CliProgress;
impl Progress for CliProgress {
fn status(&self, msg: &str) {
println!("{}", msg);
}
fn error(&self, msg: &str) {
eprintln!("{}", msg);
}
fn warning(&self, msg: &str) {
eprintln!("{}", msg);
}
fn confirm(&self, msg: &str) -> bool {
print!("{} [y/N]: ", msg);
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
}
pub fn run_rebase(
onto: Option<&str>,
plan_only: bool,
manual: bool,
continue_rebase: bool,
abort: bool,
) -> Result<()> {
let progress = &CliProgress;
let repo_root = find_repo_root()?;
if onto.is_some() && (continue_rebase || abort) {
return Err(anyhow!(
"a rebase target cannot be combined with --continue or --abort"
));
}
if continue_rebase {
continue_rebase_for_resolution(&repo_root)?;
progress.status("Rebase complete; branch remains local.");
return Ok(());
}
if abort {
abort_rebase_for_resolution(&repo_root)?;
progress.status("Rebase aborted.");
return Ok(());
}
let started = Instant::now();
let plan = plan_rebase(&repo_root, onto)?;
let onto_ref = plan.base_ref.clone();
if plan_only {
print_rebase_plan(&plan);
return Ok(());
}
if manual {
return start_rebase_for_resolution(
&repo_root,
&RebaseOptions {
onto: onto_ref,
push: false,
},
progress,
)
.map_err(Into::into);
}
match rebase_with_recovery(
&repo_root,
&RebaseOptions {
onto: onto_ref.clone(),
push: true,
},
progress,
) {
Ok(()) => {
record_ops_metric(
&repo_root,
serde_json::json!({
"op": "rebase",
"branch": plan.branch,
"base_ref": plan.base_ref,
"class": rebase_class_name(&plan.class),
"strategy": rebase_strategy_name(&plan.strategy),
"unique_commits": plan.unique_commits,
"changed_files": plan.changed_files.len(),
"protected": plan.protected,
"scratch_stashed": plan.scratch_stashed,
"agent_launched": false,
"duration_ms": started.elapsed().as_millis(),
"exit_status": "ok",
}),
);
Ok(())
}
Err(OpsError::RebaseConflict { onto, detail }) => {
let context = format!(
"<lf:rebase-conflict>\nRebase onto: {onto}\n{detail}\n</lf:rebase-conflict>"
);
progress.status("Launching rebase agent to resolve conflicts...");
launch_skill_agent(&repo_root, "rebase", Some(&context))
}
Err(err) => Err(err.into()),
}
}
fn print_rebase_plan(plan: &crate::ops::RebasePlan) {
println!("branch: {}", plan.branch);
println!("base: {}", plan.base_ref);
println!("class: {}", rebase_class_name(&plan.class));
println!("strategy: {}", rebase_strategy_name(&plan.strategy));
println!("unique_commits: {}", plan.unique_commits);
println!("changed_files: {}", plan.changed_files.len());
println!("protected: {}", plan.protected);
println!("agent_launched: false");
}
fn with_rebase_retry<T>(
repo_root: &Path,
label: &str,
progress: &impl Progress,
op: impl Fn(&Path) -> Result<T, OpsError>,
) -> Result<T> {
match op(repo_root) {
Ok(value) => Ok(value),
Err(OpsError::RebaseConflict { onto, detail }) => {
let context = format!(
"<lf:rebase-conflict>\nRebase onto: {onto}\n{detail}\n</lf:rebase-conflict>"
);
progress.status("Launching rebase agent to resolve conflicts...");
launch_skill_agent(repo_root, "rebase", Some(&context))?;
progress.status(&format!("Retrying {label} after rebase..."));
op(repo_root).map_err(Into::into)
}
Err(err) => Err(err.into()),
}
}
fn land_current(options: &LandOptions, progress: &impl Progress) -> Result<()> {
let repo_root = find_repo_root()?;
with_rebase_retry(&repo_root, "land", progress, |repo| {
land(repo, options, progress)
})?;
Ok(())
}
fn submit_current(options: &LandOptions, progress: &impl Progress) -> Result<()> {
let repo_root = find_repo_root()?;
with_rebase_retry(&repo_root, "submit", progress, |repo| {
submit(repo, options, progress)
})?;
progress.status("Ready to land — click merge on the PR once checks pass.");
Ok(())
}
fn open_pr(
title: Option<String>,
body: Option<String>,
agent_override: Option<&str>,
progress: &impl Progress,
) -> Result<()> {
let repo_root = find_repo_root()?;
let result = with_rebase_retry(&repo_root, "PR creation", progress, |repo| {
create_or_update_pr(
repo,
&PrOptions {
title: title.clone(),
body: body.clone(),
agent: agent_override.map(str::to_string),
},
progress,
)
})?;
println!("{}", result.url);
Ok(())
}
fn pr_status() -> Result<()> {
let repo_root = find_repo_root()?;
match current_pr(&repo_root)? {
Some(pr) => {
println!("#{} {} {} {}", pr.number, pr.state, pr.branch, pr.url);
}
None => println!("No open PR for the current branch."),
}
Ok(())
}
pub fn run_sync_skills(yes: bool, no_prune: bool) -> Result<()> {
if !yes {
if !std::io::stdin().is_terminal() {
return Err(anyhow!(
"skill sync writes under ~/.claude and ~/.agents; rerun with --yes to confirm"
));
}
let progress = CliProgress;
if !progress
.confirm("Write loopflow-generated skills under ~/.claude/skills and ~/.agents/skills?")
{
return Err(anyhow!("skill sync cancelled"));
}
}
let report = sync_skills(&SkillSyncOptions {
prune: !no_prune,
global_home: None,
})?;
println!(
"synced skills ({} written, {} pruned)",
report.written.len(),
report.pruned.len()
);
Ok(())
}
pub fn run_commit(
message: Option<&str>,
push: bool,
no_add: bool,
agent_override: Option<&str>,
) -> Result<()> {
let repo_root = find_repo_root()?;
let _ = commit_workflow(
&repo_root,
&CommitOptions {
add: !no_add,
push,
create_draft_pr: true,
message: message.map(str::to_string),
agent: agent_override.map(str::to_string),
..CommitOptions::for_task("commit")
},
&CliProgress,
)?;
Ok(())
}
fn abandon_current(branch: Option<&str>, force: bool, progress: &impl Progress) -> Result<()> {
let repo_root = find_repo_root()?;
abandon_branch(
&repo_root,
&AbandonOptions {
branch: branch.map(str::to_string),
force,
},
progress,
)?;
Ok(())
}
pub fn run_pm(cmd: &PmCommand) -> Result<()> {
let progress = &CliProgress;
let repo_root = find_repo_root()?;
let list_all_waves = || -> Result<Vec<String>> {
let wave_dir = repo_root.join("wave");
if !wave_dir.is_dir() {
return Err(anyhow!("no wave/ directory found"));
}
let mut waves = Vec::new();
for entry in std::fs::read_dir(&wave_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
waves.push(name.to_string());
}
}
}
waves.sort();
if waves.is_empty() {
return Err(anyhow!("no waves found in wave/"));
}
Ok(waves)
};
match cmd {
PmCommand::Init {
wave,
wave_flag,
all,
} => {
let targets = if *all {
list_all_waves()?
} else {
vec![wave
.clone()
.or_else(|| wave_flag.clone())
.ok_or_else(|| anyhow!("cannot determine wave; pass --wave <name>"))?]
};
for wave in targets {
let result = crate::ops::pm::pm_init(
&repo_root,
&crate::ops::pm::PmInitOptions { wave: Some(wave) },
progress,
)?;
let state = if result.created {
"created"
} else {
"already linked"
};
println!(
"{}: Linear Initiative {} ({state})",
result.wave, result.initiative_id
);
}
}
PmCommand::Show {
wave,
project,
json,
sync,
no_sync,
} => {
let refresh = if *sync {
crate::ops::pm::PmRefresh::Force
} else if *no_sync {
crate::ops::pm::PmRefresh::Never
} else {
crate::ops::pm::PmRefresh::Auto
};
let options = crate::ops::pm::PmShowOptions {
wave: wave.clone(),
project: project.clone(),
refresh,
};
let result = if *json {
crate::ops::pm::pm_show(&repo_root, &options, &crate::ops::NullProgress)?
} else {
crate::ops::pm::pm_show(&repo_root, &options, progress)?
};
if *json {
println!("{}", serde_json::to_string(&result)?);
} else {
print_pm_show_result(&result);
}
}
PmCommand::Status { wave } => {
let result = crate::ops::pm::pm_status(
&repo_root,
&crate::ops::pm::PmStatusOptions { wave: wave.clone() },
progress,
)?;
if result.waves.is_empty() {
println!("no PM-linked waves");
} else {
for wave in result.waves {
println!(
"{}: Linear Initiative `{}` ({}) — {} open / {} total",
wave.wave, wave.initiative_name, wave.initiative, wave.open, wave.total
);
for (project, open) in wave.open_by_project {
println!(" {project:<28} {open} open");
}
}
}
}
PmCommand::Rename { wave, title } => {
let result = crate::ops::pm::pm_rename(
&repo_root,
&crate::ops::pm::PmRenameOptions {
wave: wave.clone(),
title: title.clone(),
},
progress,
)?;
println!(
"{}: renamed Linear Initiative {} to `{}`",
result.wave, result.initiative, result.title
);
}
PmCommand::Task { cmd } => match cmd {
PmTaskCommand::Create {
wave,
project,
title,
notes,
} => {
let result = crate::ops::pm::pm_update(
&repo_root,
&crate::ops::pm::PmUpdateOptions {
wave: wave.clone(),
project: Some(project.clone()),
id: None,
title: Some(title.clone()),
notes: notes.clone(),
status: None,
pr: None,
},
progress,
)?;
println!(
"{}: created task {} in project:{}",
result.wave, result.id, project
);
}
PmTaskCommand::Update {
id,
wave,
project,
title,
notes,
} => {
let result = crate::ops::pm::pm_update(
&repo_root,
&crate::ops::pm::PmUpdateOptions {
wave: wave.clone(),
project: project.clone(),
id: Some(id.clone()),
title: title.clone(),
notes: notes.clone(),
status: None,
pr: None,
},
progress,
)?;
println!("{}: updated task {}", result.wave, result.id);
}
PmTaskCommand::Done { id, wave, pr } => {
let result = crate::ops::pm::pm_update(
&repo_root,
&crate::ops::pm::PmUpdateOptions {
wave: wave.clone(),
project: None,
id: Some(id.clone()),
title: None,
notes: None,
status: Some("done".to_string()),
pr: pr.clone(),
},
progress,
)?;
let linked = match result.linked_pr {
Some(pr) => format!(", linked {pr}"),
None => String::new(),
};
println!("{}: closed task {}{linked}", result.wave, result.id);
}
PmTaskCommand::Move { id, wave, project } => {
let result = crate::ops::pm::pm_task_move(
&repo_root,
&crate::ops::pm::PmTaskMoveOptions {
id: id.clone(),
wave: wave.clone(),
project: project.clone(),
},
progress,
)?;
println!(
"{}: moved task {} to project:{}",
result.wave, result.id, result.project
);
}
},
PmCommand::Project { cmd } => {
let (wave, project, title, definition, krs) = match cmd {
PmProjectCommand::Create {
wave,
title,
definition,
krs,
} => (
wave.clone(),
None,
Some(title.clone()),
definition.clone(),
krs.clone(),
),
PmProjectCommand::Update {
wave,
project,
title,
definition,
krs,
} => (
wave.clone(),
Some(project.clone()),
title.clone(),
definition.clone(),
krs.clone(),
),
PmProjectCommand::Archive { wave, project } => {
let result = crate::ops::pm::pm_project_archive(
&repo_root,
&crate::ops::pm::PmProjectArchiveOptions {
wave: wave.clone(),
project: project.clone(),
},
progress,
)?;
println!(
"{}: archived project:{} ({})",
result.wave, result.slug, result.id
);
return Ok(());
}
};
let result = crate::ops::pm::pm_project_write(
&repo_root,
&crate::ops::pm::PmProjectWriteOptions {
wave,
project,
title,
definition,
krs,
},
progress,
)?;
let verb = if result.created { "created" } else { "updated" };
println!(
"{}: {verb} project:{} ({})",
result.wave, result.slug, result.id
);
}
PmCommand::Doctor => {
let result = crate::ops::pm::pm_sync(
&repo_root,
&crate::ops::pm::PmSyncOptions {
wave: None,
plan: true,
},
progress,
)?;
print_pm_sync_result(&result);
}
PmCommand::Sync { wave, plan } => {
let result = crate::ops::pm::pm_sync(
&repo_root,
&crate::ops::pm::PmSyncOptions {
wave: wave.clone(),
plan: *plan,
},
progress,
)?;
print_pm_sync_result(&result);
}
}
Ok(())
}
fn print_pm_show_result(result: &crate::ops::pm::PmShowResult) {
if result.items.is_empty() {
let suffix = result
.project
.as_deref()
.map(|project| format!(" project:{project}"))
.unwrap_or_default();
println!("{}{}: no Linear tasks", result.wave, suffix);
print_pm_snapshot_age(result);
return;
}
let colors = Colors::default();
for (index, line) in format_pm_task_table(&result.items).iter().enumerate() {
if index == 0 {
println!("{}{}{}", colors.bold, line, colors.reset);
} else {
println!("{line}");
}
}
print_pm_snapshot_age(result);
}
fn print_pm_snapshot_age(result: &crate::ops::pm::PmShowResult) {
let age = time::OffsetDateTime::now_utc().unix_timestamp() - result.synced_at;
let colors = Colors::default();
let phrase = if age < 60 {
"just now".to_string()
} else {
format!("{} ago", crate::ops::pm::format_age(age))
};
println!("{}snapshot synced {}{}", colors.dim, phrase, colors.reset);
}
#[derive(Debug)]
struct PmTaskRow {
status: &'static str,
title: String,
project: String,
assignee: String,
id: String,
completed: bool,
rank: u32,
}
fn format_pm_task_table(items: &[crate::pm::PmItem]) -> Vec<String> {
let mut rows: Vec<_> = items
.iter()
.map(|item| PmTaskRow {
status: if item.completed { "done" } else { "open" },
title: item.name.split_whitespace().collect::<Vec<_>>().join(" "),
project: item.project.clone().unwrap_or_else(|| "-".to_string()),
assignee: item.assignee.clone().unwrap_or_else(|| "-".to_string()),
id: item.id.clone(),
completed: item.completed,
rank: item.rank,
})
.collect();
rows.sort_by_key(|row| (row.completed, row.rank));
let status_width = column_width("STATUS", rows.iter().map(|row| row.status));
let title_width = column_width("TITLE", rows.iter().map(|row| row.title.as_str()));
let project_width = column_width("PROJECT", rows.iter().map(|row| row.project.as_str()));
let assignee_width = column_width("ASSIGNEE", rows.iter().map(|row| row.assignee.as_str()));
let mut lines = Vec::with_capacity(rows.len() + 1);
lines.push(format!(
"{:<status_width$} {:<title_width$} {:<project_width$} {:<assignee_width$} ID",
"STATUS", "TITLE", "PROJECT", "ASSIGNEE"
));
lines.extend(rows.into_iter().map(|row| {
format!(
"{:<status_width$} {:<title_width$} {:<project_width$} {:<assignee_width$} {}",
row.status, row.title, row.project, row.assignee, row.id
)
}));
lines
}
#[cfg(test)]
mod pm_output_tests {
use super::format_pm_task_table;
use crate::pm::PmItem;
#[test]
fn task_table_is_aligned_complete_and_open_first() {
let lines = format_pm_task_table(&[
PmItem {
id: "done-1".to_string(),
identifier: "INF-1".to_string(),
name: "Done task".to_string(),
description: String::new(),
rank: 0,
completed: true,
project: None,
assignee: None,
},
PmItem {
id: "open-1".to_string(),
identifier: "INF-2".to_string(),
name: "Longer\ntitle".to_string(),
description: String::new(),
rank: 1,
completed: false,
project: Some("wave-chat".to_string()),
assignee: Some("me".to_string()),
},
]);
assert_eq!(
lines,
vec![
"STATUS TITLE PROJECT ASSIGNEE ID",
"open Longer title wave-chat me open-1",
"done Done task - - done-1",
]
);
assert!(lines.iter().all(|line| line.lines().count() == 1));
}
}
fn print_pm_sync_result(result: &crate::ops::pm::PmSyncResult) {
if result.actions.is_empty() && result.diagnostics.is_empty() {
println!("PM state matches local waves and projects");
return;
}
for action in &result.actions {
println!("action: {action}");
}
for diagnostic in &result.diagnostics {
println!("diagnostic: {diagnostic}");
}
}
pub fn cron_cmd(cmd: &CronCommand) -> Result<()> {
let launch_agents_dir = crate::ops::default_launch_agents_dir()?;
match cmd {
CronCommand::Add {
wave,
flow,
schedule,
} => {
let repo_root = find_repo_root()?;
let spec = CronSpec {
wave: wave.clone(),
flow: flow.clone(),
schedule: crate::ops::parse_schedule(schedule)?,
working_directory: repo_root,
lf_path: crate::ops::resolve_lf_path()?,
};
let cron = crate::ops::add_cron(&launch_agents_dir, &spec, &SystemLaunchctl)?;
println!("installed {} at {}", cron.label, cron.path.display());
}
CronCommand::List => {
let crons = crate::ops::list_crons(&launch_agents_dir)?;
if crons.is_empty() {
println!("no loopflow crons installed");
} else {
for cron in crons {
println!("{} {} {}", cron.label, cron.wave, cron.flow);
}
}
}
CronCommand::Remove { wave, flow } => {
match crate::ops::remove_cron(&launch_agents_dir, wave, flow, &SystemLaunchctl)? {
Some(cron) => println!("removed {}", cron.label),
None => println!("not installed"),
}
}
}
Ok(())
}
fn release_check_cmd(target_name: Option<&str>) -> Result<()> {
let repo_root = find_repo_root()?;
let prs = release_check(&repo_root, target_name)?;
if prs.is_empty() {
eprintln!("No PRs merged since last tag.");
std::process::exit(1);
}
let is_tty = std::io::stdout().is_terminal();
if is_tty {
for pr in &prs {
println!(
"#{:<6} {} (+{} -{}, {} files)",
pr.number, pr.title, pr.additions, pr.deletions, pr.changed_files
);
}
println!("\n{} PR(s) merged since last tag.", prs.len());
} else {
let json = serde_json::to_string_pretty(&prs)?;
println!("{}", json);
}
Ok(())
}
fn release_run_cmd(
version_input: Option<&str>,
target_name: Option<&str>,
progress: &impl Progress,
) -> Result<()> {
let repo_root = find_repo_root()?;
let input = version_input.unwrap_or("patch");
let result = release_run(&repo_root, input, target_name, progress)?;
println!("Released {} ({})", result.tag, result.target);
if let Some(url) = result.workflow_url.as_deref() {
println!("Workflow URL: {url}");
}
println!(
"GitHub Release: {}",
if result.release_exists { "yes" } else { "no" }
);
Ok(())
}
fn release_notes_cmd(
version: &str,
prev_tag: Option<&str>,
target_name: Option<&str>,
progress: &impl Progress,
) -> Result<()> {
let repo_root = find_repo_root()?;
release_notes(&repo_root, version, prev_tag, target_name, progress)?;
println!(
"RELEASE_NOTES.md updated for v{}",
version.trim_start_matches('v')
);
Ok(())
}
fn release_bump_cmd(
version: &str,
target_name: Option<&str>,
progress: &impl Progress,
) -> Result<()> {
let repo_root = find_repo_root()?;
release_bump(&repo_root, version, target_name, progress)?;
println!("Manifests bumped to v{}", version.trim_start_matches('v'));
Ok(())
}
fn release_tag_cmd(version: &str, target_name: Option<&str>) -> Result<()> {
let repo_root = find_repo_root()?;
let tag = release_tag(&repo_root, version, target_name)?;
println!("{}", tag);
Ok(())
}
fn release_status_cmd(target_name: Option<&str>) -> Result<()> {
let repo_root = find_repo_root()?;
let status = release_status(&repo_root, target_name)?;
println!("Target: {}", status.target);
match status.latest_tag.as_deref() {
Some(tag) => println!("Latest tag: {tag}"),
None => println!("Latest tag: (none)"),
}
match status.workflow_status.as_deref() {
Some(workflow_status) => {
let conclusion = status.workflow_conclusion.as_deref().unwrap_or("(pending)");
println!("Workflow: {workflow_status} / {conclusion}");
}
None => println!("Workflow: (not found)"),
}
if let Some(url) = status.workflow_url.as_deref() {
println!("Workflow URL: {url}");
}
println!(
"GitHub Release: {}",
if status.release_exists { "yes" } else { "no" }
);
Ok(())
}
pub fn run_wt(cmd: &WtCommand) -> Result<()> {
match cmd {
WtCommand::Create { name, plan } => wt_create(name, *plan),
WtCommand::Switch { name } => wt_switch(name),
WtCommand::List { format, .. } => wt_list(format.as_deref()),
WtCommand::Remove { name, force } => wt_remove(name, *force),
WtCommand::Prune {
dry_run,
include_fresh,
} => wt_prune(*dry_run, *include_fresh),
WtCommand::Ci { watch, logs } => wt_ci(*watch, *logs),
}
}
fn wt_create(name: &str, dry_run: bool) -> Result<()> {
let started = Instant::now();
let repo_root = find_repo_root()?;
let main_repo = main_repo_root(&repo_root)?;
let segment = WorktreeSegment::parse(name)?;
let default_branch = get_default_branch(&main_repo)?;
let _ = sync_main(&main_repo, &default_branch);
let placement = plan_placement(&main_repo, segment)?;
if dry_run {
print_placement_plan(&placement);
return Ok(());
}
let result = create_from_placement_plan(&main_repo, &placement)?;
record_ops_metric(
&repo_root,
serde_json::json!({
"op": "wt.create",
"branch": placement.branch,
"base_ref": placement.base_ref,
"strategy": placement_strategy_name(&placement.strategy),
"duration_ms": started.elapsed().as_millis(),
"exit_status": "ok",
}),
);
if placement.strategy == PlacementStrategy::UseExistingWorktree {
println!("Using existing worktree: {}", result.path.display());
} else {
println!("Created worktree: {}", result.path.display());
}
if result.branch != name {
println!("Branch: {}", result.branch);
}
if let Some(base_branch) = result.base_branch {
println!("Base: {base_branch}");
}
if !write_shell_directive(&format!("cd {}", result.path.display()))? {
println!("cd {}", result.path.display());
println!("Tip: source scripts/dev-lf to apply auto-cd in this shell");
}
Ok(())
}
fn print_placement_plan(plan: &crate::engine::worktrees::PlacementPlan) {
println!("branch: {}", plan.branch);
println!("base: {}", plan.base_ref);
println!("worktree: {}", plan.worktree_path.display());
println!("strategy: {}", placement_strategy_name(&plan.strategy));
}
fn placement_strategy_name(strategy: &PlacementStrategy) -> &'static str {
match strategy {
PlacementStrategy::Create => "create",
PlacementStrategy::CheckoutExisting => "checkout_existing",
PlacementStrategy::UseExistingWorktree => "use_existing_worktree",
}
}
fn record_ops_metric(repo: &Path, mut event: serde_json::Value) {
let Some(object) = event.as_object_mut() else {
return;
};
object.insert(
"ts".to_string(),
serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
);
let path = repo.join(".lf").join("metrics").join("ops.jsonl");
let Some(parent) = path.parent() else {
return;
};
if std::fs::create_dir_all(parent).is_err() {
return;
}
let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
else {
return;
};
if serde_json::to_writer(&mut file, &event).is_ok() {
let _ = writeln!(file);
}
}
fn wt_switch(name: &str) -> Result<()> {
let repo_root = find_repo_root()?;
let main_repo = main_repo_root(&repo_root)?;
let worktrees = list_worktrees(&main_repo)?;
let path = if let Some(exact_branch_match) = worktrees
.iter()
.find(|wt| wt.branch.as_deref() == Some(name))
.map(|wt| wt.path.clone())
{
exact_branch_match
} else {
let user = git_user(&main_repo).unwrap_or_else(|_| "user".to_string());
let mut matches = worktrees
.into_iter()
.filter(|wt| {
let wt_name = sibling_worktree_name_with_main(&wt.path, &main_repo);
let parsed = wt
.branch
.as_deref()
.and_then(|branch| WorktreeName::parse(branch, &user));
wt_name.as_deref() == Some(name)
|| wt
.path
.file_name()
.map(|n| n.to_string_lossy() == name)
.unwrap_or(false)
|| parsed.as_ref().map(|id| id.name() == name).unwrap_or(false)
})
.collect::<Vec<_>>();
if matches.len() == 1 {
matches.remove(0).path
} else if matches.is_empty() {
return Err(anyhow!("no worktree found for '{}'", name));
} else {
return Err(anyhow!("multiple worktrees match '{}'", name));
}
};
cd_directive(&path)
}
fn cd_directive(path: &Path) -> Result<()> {
if !write_shell_directive(&format!("cd {}", path.display()))? {
println!("cd {}", path.display());
}
Ok(())
}
fn wt_list(format: Option<&str>) -> Result<()> {
let repo_root = find_repo_root()?;
let main_repo = main_repo_root(&repo_root)?;
let default_branch = get_default_branch(&main_repo)?;
let _ = sync_main(&main_repo, &default_branch);
let worktrees = list_worktrees(&main_repo)?;
if matches!(format, Some("json")) {
let json = serde_json::to_string_pretty(&worktrees)?;
println!("{}", json);
return Ok(());
}
let c = Colors::new();
let user = git_user(&main_repo).unwrap_or_else(|_| "user".to_string());
struct Row {
label: String,
sort_key: String,
is_current: bool,
is_main: bool,
merged: bool,
squash_merged: bool,
fresh: bool,
dirty: bool,
remote_gone: bool,
diff_stat: String,
}
let mut rows: Vec<Row> = worktrees
.iter()
.map(|wt| {
let is_main = wt.branch.as_deref() == Some(&default_branch);
let parsed = wt
.branch
.as_deref()
.and_then(|branch| WorktreeName::parse(branch, &user));
let (label, sort_key) = if is_main {
(default_branch.clone(), String::new())
} else if let Some(name) = &parsed {
(name.name().to_string(), name.name().to_string())
} else {
let name = sibling_worktree_name(&wt.path).unwrap_or_else(|| {
wt.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "?".to_string())
});
(name.clone(), name)
};
let is_current = wt.path == repo_root;
let diff_stat = if is_main {
String::new()
} else {
wt_diff_stat(&main_repo, wt.branch.as_deref(), &default_branch)
};
Row {
label,
sort_key,
is_current,
is_main,
merged: wt.merged,
squash_merged: wt.squash_merged,
fresh: wt.fresh,
dirty: wt.dirty,
remote_gone: wt.remote_gone,
diff_stat,
}
})
.collect();
rows.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
let display_name = |row: &Row| row.label.clone();
let max_name = column_width("", rows.iter().map(display_name));
for row in &rows {
let marker = if row.is_current { "*" } else { " " };
let any_merged = row.merged || (row.squash_merged && !row.fresh);
let landed_dirty = any_merged && row.dirty;
let name_color = if row.is_main || any_merged || row.fresh {
c.dim
} else {
c.bold
};
let (status_label, status_color) = if landed_dirty {
("landed-dirty", c.red)
} else if row.merged {
("merged", c.green)
} else if row.fresh {
("fresh", c.dim)
} else if row.squash_merged {
("squash-merged", c.green)
} else if row.remote_gone {
("remote-gone", c.yellow)
} else {
("active", c.cyan)
};
let status = format!("{status_color}{status_label}{}", c.reset);
let dirty_flag = if row.dirty && !landed_dirty {
format!(" {}dirty{}", c.yellow, c.reset)
} else {
String::new()
};
let diff = if row.diff_stat.is_empty() {
String::new()
} else {
format!(" {}{}{}", c.dim, row.diff_stat, c.reset)
};
println!(
"{marker} {name_color}{:<width$}{reset} {status}{dirty_flag}{diff}",
display_name(row),
width = max_name,
marker = marker,
name_color = name_color,
reset = c.reset,
status = status,
dirty_flag = dirty_flag,
diff = diff,
);
}
Ok(())
}
fn wt_remove(name: &str, force: bool) -> Result<()> {
let repo_root = find_repo_root()?;
let main_repo = main_repo_root(&repo_root)?;
let worktrees = list_worktrees(&main_repo)?;
let target = worktrees.iter().find(|wt| {
sibling_worktree_name(&wt.path).as_deref() == Some(name)
|| wt
.path
.file_name()
.map(|n| n.to_string_lossy() == name)
.unwrap_or(false)
});
let wt = match target {
Some(wt) => wt,
None => return Err(anyhow!("no worktree found for '{}'", name)),
};
if wt.path == repo_root {
return Err(anyhow!("cannot remove the current worktree"));
}
let default_branch = get_default_branch(&main_repo)?;
if wt.branch.as_deref() == Some(&default_branch) {
return Err(anyhow!("cannot remove the main worktree"));
}
if !force && wt.dirty {
return Err(anyhow!(
"worktree has uncommitted changes (use --force to override)"
));
}
let branch = wt.branch.clone();
crate::engine::git::worktree_remove(&main_repo, &wt.path)?;
if let Some(branch) = branch {
let _ = delete_local_branch(&main_repo, &branch);
}
println!("Removed {}", name);
Ok(())
}
fn wt_diff_stat(repo: &std::path::Path, branch: Option<&str>, default_branch: &str) -> String {
let branch = match branch {
Some(b) => b,
None => return String::new(),
};
let target = format!("origin/{default_branch}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["diff", "--shortstat", &format!("{target}...{branch}")])
.output();
match output {
Ok(o) if o.status.success() => {
let raw = String::from_utf8_lossy(&o.stdout).trim().to_string();
parse_shortstat(&raw)
}
_ => String::new(),
}
}
fn parse_shortstat(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let mut files = "";
let mut insertions = "";
let mut deletions = "";
for part in raw.split(", ") {
let part = part.trim();
if part.contains("file") {
files = part.split_whitespace().next().unwrap_or("0");
} else if part.contains("insertion") {
insertions = part.split_whitespace().next().unwrap_or("0");
} else if part.contains("deletion") {
deletions = part.split_whitespace().next().unwrap_or("0");
}
}
let ins = if insertions.is_empty() {
"0"
} else {
insertions
};
let del = if deletions.is_empty() { "0" } else { deletions };
format!("+{ins} -{del} ({files} files)")
}
fn wt_prune(dry_run: bool, include_fresh: bool) -> Result<()> {
let repo_root = find_repo_root()?;
let main_repo = main_repo_root(&repo_root)?;
let current_path = repo_root;
let default_branch = get_default_branch(&main_repo)?;
let _ = sync_main(&main_repo, &default_branch);
let prune_output = Command::new("git")
.arg("-C")
.arg(&main_repo)
.args(["worktree", "prune"])
.output()?;
if !prune_output.status.success() {
return Err(anyhow!(
"git worktree prune failed: {}",
String::from_utf8_lossy(&prune_output.stderr).trim()
));
}
let worktrees = list_worktrees(&main_repo)?;
let targets: Vec<_> = worktrees
.into_iter()
.filter(|wt| wt.path != current_path)
.filter(|wt| {
if wt.fresh {
return include_fresh && !wt.dirty;
}
if !wt.prunable {
return false;
}
true
})
.collect();
if targets.is_empty() {
println!("No prunable worktrees.");
return Ok(());
}
if dry_run {
for wt in &targets {
let reason = if wt.merged {
"merged"
} else if wt.fresh {
"fresh"
} else if wt.squash_merged {
"squash-merged"
} else if wt.remote_gone {
"remote-gone"
} else {
"prunable"
};
println!(
" {} ({reason}) {}",
wt.branch.as_deref().unwrap_or("detached"),
wt.path.display()
);
}
return Ok(());
}
for wt in targets {
crate::engine::git::worktree_remove(&main_repo, &wt.path)?;
if let Some(branch) = wt.branch {
if branch != default_branch {
let _ = delete_local_branch(&main_repo, &branch);
}
}
println!("Removed {}", wt.path.display());
}
Ok(())
}
fn wt_ci(watch: bool, logs: bool) -> Result<()> {
let repo_root = find_repo_root()?;
let branch = current_branch(&repo_root)?.ok_or_else(|| anyhow!("not on a branch"))?;
let mut args = vec!["pr", "checks", &branch];
if watch {
args.push("--watch");
}
let status = Command::new("gh")
.args(&args)
.current_dir(&repo_root)
.status()?;
if !status.success() && logs {
println!("\n--- Failed check logs ---\n");
let output = Command::new("gh")
.args([
"pr",
"view",
&branch,
"--json",
"statusCheckRollup",
"-q",
".statusCheckRollup[] | select(.conclusion == \"FAILURE\" or .conclusion == \"failure\") | .detailsUrl",
])
.current_dir(&repo_root)
.output()?;
if output.status.success() {
let urls = String::from_utf8_lossy(&output.stdout);
for url in urls.lines().filter(|line| !line.trim().is_empty()) {
let _ = Command::new("gh")
.args(["run", "view", url])
.current_dir(&repo_root)
.status();
}
}
}
if status.success() {
Ok(())
} else {
Err(anyhow!("ci checks failed"))
}
}
fn write_shell_directive(command: &str) -> Result<bool> {
let directive = std::env::var("LOOPFLOW_DIRECTIVE_FILE").ok();
let Some(path) = directive else {
return Ok(false);
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
use std::io::Write;
writeln!(file, "{}", command)?;
Ok(true)
}
fn launch_skill_agent(repo_root: &Path, skill_name: &str, context: Option<&str>) -> Result<()> {
let skill = discover_skill(repo_root, skill_name)?;
let config = load_config_or_default(Some(repo_root));
let message = context.map(|value| value.to_string());
let prepared = prepare_launch_prompt(
&config,
LaunchPromptInput {
repo_root: repo_root.to_path_buf(),
skill: Some(skill_name.to_string()),
resolved_skill: Some(skill),
surface: Surface::Headless,
message,
cwd: Some(repo_root.to_path_buf()),
yolo_mode: config.yolo,
source_overrides: ContextSourceOverrides {
diff_files: Some(false),
diff: Some(false),
..Default::default()
},
..LaunchPromptInput::default()
},
)?;
let effective_system =
crate::engine::agent::system_prompt_with_structured_replies(&prepared.config);
let context = crate::lf::commands::run::attributed_context(
&prepared.components,
&effective_system,
&prepared.config.task_prompt,
&prepared.deduplicated_docs,
);
let agent = prepared.config.agent.as_deref().unwrap_or("claude:opus");
let (provider, model) = crate::engine::parse_agent(agent);
let capture_context =
crate::journal::trace_capture_context(repo_root, None, Some(skill_name.to_string()))
.ok_or_else(|| anyhow!("trace capture identity is unavailable before agent launch"))?;
let capture = crate::trace::CaptureHandle::begin(
capture_context,
context,
crate::trace::CaptureStart {
provider,
model,
surface: "headless".to_string(),
input_op: "initial".to_string(),
gather_ms: 0,
render_ms: 0,
raw_provider: true,
},
)?;
let process = ProcessConfig {
auto: true,
stream: true,
capture: Some(capture.clone()),
..Default::default()
};
let capabilities = AgentCapabilities {
chrome: config.chrome,
};
let result = launch_agent(&prepared.config, &process, &capabilities);
let outcome = match &result {
Ok(result) if result.exit_code == 0 => "completed",
Ok(_) | Err(_) => "failed",
};
capture.finish(outcome, false)?;
let result = result?;
if result.exit_code != 0 {
return Err(anyhow!(
"agent exited with code {} while resolving {}",
result.exit_code,
skill_name,
));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Brew {
Formula(&'static str),
Cask(&'static str),
}
#[derive(Debug, Clone, Copy)]
struct SystemDep {
name: &'static str,
command: &'static str,
required: bool,
macos_only: bool,
brew: Option<Brew>,
fallback: &'static str,
}
impl SystemDep {
fn is_present(&self) -> bool {
which(self.command)
}
fn install_hint(&self, is_macos: bool) -> String {
if is_macos {
if let Some(brew) = self.brew {
return match brew {
Brew::Formula(f) => format!("brew install {f}"),
Brew::Cask(c) => format!("brew install --cask {c}"),
};
}
}
self.fallback.to_string()
}
}
const SYSTEM_DEPS: &[SystemDep] = &[
SystemDep {
name: "git",
command: "git",
required: true,
macos_only: false,
brew: Some(Brew::Formula("git")),
fallback: "https://git-scm.com/downloads",
},
SystemDep {
name: "rust",
command: "cargo",
required: true,
macos_only: false,
brew: Some(Brew::Formula("rust")),
fallback: "https://rustup.rs/",
},
SystemDep {
name: "uv",
command: "uv",
required: true,
macos_only: false,
brew: Some(Brew::Formula("uv")),
fallback: "https://docs.astral.sh/uv/getting-started/installation/",
},
SystemDep {
name: "tmux",
command: "tmux",
required: true,
macos_only: false,
brew: Some(Brew::Formula("tmux")),
fallback: "https://github.com/tmux/tmux/wiki/Installing",
},
SystemDep {
name: "gh",
command: "gh",
required: true,
macos_only: false,
brew: Some(Brew::Formula("gh")),
fallback: "https://cli.github.com/",
},
SystemDep {
name: "doppler",
command: "doppler",
required: true,
macos_only: false,
brew: Some(Brew::Formula("dopplerhq/cli/doppler")),
fallback: "https://docs.doppler.com/docs/install-cli",
},
SystemDep {
name: "npm",
command: "npm",
required: false,
macos_only: false,
brew: Some(Brew::Formula("node")),
fallback: "https://nodejs.org/",
},
SystemDep {
name: "claude",
command: "claude",
required: false,
macos_only: false,
brew: None,
fallback: "lf init",
},
SystemDep {
name: "codex",
command: "codex",
required: false,
macos_only: false,
brew: None,
fallback: "npm install -g @openai/codex",
},
SystemDep {
name: "gemini",
command: "gemini",
required: false,
macos_only: false,
brew: None,
fallback: "npm install -g @google/gemini-cli",
},
SystemDep {
name: "warp",
command: "warp",
required: false,
macos_only: true,
brew: Some(Brew::Cask("warp")),
fallback: "",
},
SystemDep {
name: "cursor",
command: "cursor",
required: false,
macos_only: true,
brew: Some(Brew::Cask("cursor")),
fallback: "",
},
];
fn brewfile_contents() -> String {
let mut out = String::new();
out.push_str("# Generated from the declared SYSTEM_DEPS list in\n");
out.push_str("# rust/loopflow/src/lf/commands/ops/mod.rs — do not edit by hand.\n");
out.push_str("# Keep this file in sync with SYSTEM_DEPS.\n");
out.push_str("# Install everything with: brew bundle\n\n");
for dep in SYSTEM_DEPS {
let Some(brew) = dep.brew else { continue };
let tag = if dep.required { "required" } else { "optional" };
match brew {
Brew::Formula(f) => out.push_str(&format!("brew \"{f}\" # {} ({tag})\n", dep.name)),
Brew::Cask(c) => out.push_str(&format!("cask \"{c}\" # {} ({tag})\n", dep.name)),
}
}
out
}
pub fn run_doctor(brewfile: bool) -> Result<()> {
if brewfile {
print!("{}", brewfile_contents());
return Ok(());
}
let repo_root = find_repo_root().ok();
if let Some(ref root) = repo_root {
let lf_dir = root.join(".lf");
if lf_dir.join("skills").is_dir() || lf_dir.join("flows").is_dir() {
println!("✓ task files found");
} else {
println!("- no task files (run: lf init)");
}
} else {
println!("- not in a git repo");
}
let is_macos = cfg!(target_os = "macos");
let mut missing_required = 0;
for dep in SYSTEM_DEPS {
if dep.macos_only && !is_macos {
continue;
}
if dep.is_present() {
println!("✓ {}", dep.name);
} else {
let tag = if dep.required { " (required)" } else { "" };
println!("- {}: {}{}", dep.name, dep.install_hint(is_macos), tag);
if dep.required {
missing_required += 1;
}
}
}
if missing_required > 0 {
println!("\n{missing_required} required dep(s) missing");
} else {
println!("\nall required deps present");
}
Ok(())
}
fn which(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod doctor_tests {
use super::{brewfile_contents, SYSTEM_DEPS};
use std::fs;
use std::path::Path;
#[test]
fn declared_deps_non_empty_and_well_formed() {
assert!(!SYSTEM_DEPS.is_empty());
for dep in SYSTEM_DEPS {
assert!(!dep.name.is_empty());
assert!(
!dep.command.is_empty(),
"{} needs a check command",
dep.name
);
if dep.required {
assert!(
dep.brew.is_some(),
"{} (required) needs a brew package",
dep.name
);
assert!(
!dep.fallback.is_empty(),
"{} (required) needs a fallback install hint",
dep.name
);
}
}
}
#[test]
fn brewfile_matches_declared_list() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");
let committed =
fs::read_to_string(root.join("Brewfile")).expect("Brewfile exists at repo root");
assert_eq!(
committed,
brewfile_contents(),
"Brewfile is stale; update it alongside SYSTEM_DEPS"
);
}
}