Skip to main content

open_loops/
cli.rs

1//! Command definitions and module orchestration.
2use crate::config::Store;
3use crate::distill::Confidence;
4use crate::ignores::Ignores;
5use crate::scanner::{self, OpenLoop};
6use crate::{cache, distill, output, sessions, worktrees};
7use anyhow::{bail, ensure, Result};
8use clap::{Parser, Subcommand};
9use sessions::{SessionExcerpt, SessionSource};
10use std::path::{Path, PathBuf};
11
12#[derive(Parser)]
13#[command(name = "loops", version, about = "Recover the context of paused work")]
14#[command(args_conflicts_with_subcommands = true)]
15pub struct Cli {
16    #[command(subcommand)]
17    pub command: Option<Command>,
18    /// Filter the inventory (e.g. `loops api idle:>7d`). See ADR 0003 grammar.
19    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
20    pub query: Vec<String>,
21}
22
23#[derive(Subcommand)]
24pub enum Command {
25    /// Register repository roots (e.g. loops init ~/repo)
26    Init { paths: Vec<PathBuf> },
27    /// Distill a loop's context: why, done, remaining, next step
28    Resume {
29        query: String,
30        /// Show matched git commits and AI sessions without calling the LLM
31        #[arg(long)]
32        dry_run: bool,
33    },
34    /// Drop a dead loop from the list (repo/branch format)
35    Ignore { key: String },
36    /// List git worktrees with a cleanup verdict (alias: wt)
37    #[command(visible_alias = "wt")]
38    Worktrees,
39    /// Generate a shell completion script (bash, zsh, fish, ...)
40    Completions { shell: clap_complete::Shell },
41}
42
43struct ResumeEvidence {
44    default_branch: String,
45    commits: String,
46    diffstat: String,
47    excerpts: Vec<SessionExcerpt>,
48    confidence: Confidence,
49}
50
51fn progress(msg: &str) {
52    eprintln!("{msg}");
53}
54
55fn resolve_loop(base: &Path, query: &str) -> Result<OpenLoop> {
56    let store = Store::new(base.to_path_buf());
57    let cfg = store.load()?;
58    ensure!(
59        !cfg.roots.is_empty(),
60        "no roots configured. Run: loops init <dir-with-your-repos>"
61    );
62    let mut plan = crate::query::parse(query)?;
63    plan.include_ignored = true; // resume can target an ignored loop by key
64    let labels = cfg.resolve_labels()?;
65    let (found, warnings) = scanner::scan(&cfg.roots, &labels, cfg.scan_depth);
66    for w in &warnings {
67        eprintln!("warning: {w}");
68    }
69    let now = chrono::Utc::now();
70    let matches: Vec<&OpenLoop> = found
71        .iter()
72        .filter(|l| {
73            let key = l.key();
74            plan.matches(
75                &crate::query::Candidate {
76                    repo_name: &l.repo_name,
77                    branch: &l.branch,
78                    key: &key,
79                    last_commit: l.last_commit,
80                    ahead: Some(l.ahead),
81                    behind: Some(l.behind),
82                    ignored: false,
83                },
84                now,
85            )
86        })
87        .collect();
88    match matches.len() {
89        0 => bail!("no loop matches '{query}'. Run `loops` to see open ones."),
90        1 => Ok(matches[0].clone()),
91        _ => bail!(
92            "ambiguous query, candidates:\n{}",
93            matches
94                .iter()
95                .map(|l| format!("  {}", l.key()))
96                .collect::<Vec<_>>()
97                .join("\n")
98        ),
99    }
100}
101
102fn gather_resume_evidence(base: &Path, lp: &OpenLoop) -> Result<ResumeEvidence> {
103    let store = Store::new(base.to_path_buf());
104    let cfg = store.load()?;
105    let default_branch = scanner::default_branch(&lp.repo_path)?;
106    let commits = scanner::git_log(&lp.repo_path, &default_branch, &lp.branch)?;
107    let diffstat = scanner::diffstat(&lp.repo_path, &default_branch, &lp.branch)?;
108    let window = scanner::commit_window(&lp.repo_path, &default_branch, &lp.branch)?;
109    progress("matching AI sessions…");
110    let source = sessions::claude_code::ClaudeCode {
111        projects_dir: cfg.sessions_dir.clone(),
112    };
113    let excerpts = source.excerpts(
114        &lp.repo_path,
115        &lp.branch,
116        window,
117        cfg.max_sessions,
118        cfg.max_session_kb,
119    )?;
120    let confidence = distill::compute_confidence(&excerpts);
121    Ok(ResumeEvidence {
122        default_branch,
123        commits,
124        diffstat,
125        excerpts,
126        confidence,
127    })
128}
129
130pub fn run_list(base: &Path, query: &str) -> Result<()> {
131    let store = Store::new(base.to_path_buf());
132    let cfg = store.load()?;
133    ensure!(
134        !cfg.roots.is_empty(),
135        "no roots configured. Run: loops init <dir-with-your-repos>"
136    );
137    let plan = crate::query::parse(query)?;
138    let labels = cfg.resolve_labels()?;
139    progress("scanning git repositories…");
140    let (found, warnings) = scanner::scan(&cfg.roots, &labels, cfg.scan_depth);
141    for w in &warnings {
142        eprintln!("warning: {w}");
143    }
144    let ignores = Ignores::load(base)?;
145    let now = chrono::Utc::now();
146    let visible: Vec<OpenLoop> = found
147        .into_iter()
148        .filter(|l| {
149            let key = l.key();
150            plan.matches(
151                &crate::query::Candidate {
152                    repo_name: &l.repo_name,
153                    branch: &l.branch,
154                    key: &key,
155                    last_commit: l.last_commit,
156                    ahead: Some(l.ahead),
157                    behind: Some(l.behind),
158                    ignored: ignores.contains(&key),
159                },
160                now,
161            )
162        })
163        .collect();
164    if visible.is_empty() && !query.trim().is_empty() {
165        eprintln!("No loops match: {query}");
166        eprintln!("(hint: run `loops` to list all)");
167    }
168    print!("{}", output::render_table(&visible, now));
169    Ok(())
170}
171
172pub fn run_init(base: &Path, paths: &[PathBuf]) -> Result<()> {
173    ensure!(!paths.is_empty(), "usage: loops init <dir> [<dir>...]");
174    let store = Store::new(base.to_path_buf());
175    let cfg = store.add_roots(paths)?;
176    println!("roots registered:");
177    for r in &cfg.roots {
178        println!("  {}", r.display());
179    }
180    println!("\nconfig at {}", store.config_path().display());
181    Ok(())
182}
183
184pub fn run_ignore(base: &Path, key: &str) -> Result<()> {
185    ensure!(
186        key.contains('/'),
187        "expected format: repo/branch (run `loops` to see the keys)"
188    );
189    let mut ignores = Ignores::load(base)?;
190    ignores.add(key)?;
191    println!("ignored: {key}");
192    Ok(())
193}
194
195pub fn run_resume(base: &Path, query: &str, dry_run: bool) -> Result<()> {
196    progress("scanning git…");
197    let lp = resolve_loop(base, query)?;
198
199    if dry_run {
200        let evidence = gather_resume_evidence(base, &lp)?;
201        let doc = distill::format_dry_run(
202            &lp,
203            &evidence.default_branch,
204            &evidence.commits,
205            &evidence.diffstat,
206            &evidence.excerpts,
207            evidence.confidence,
208        );
209        print!("{doc}");
210        return Ok(());
211    }
212
213    let cache = cache::Cache::new(base);
214    if let Some(hit) = cache.get(&lp) {
215        println!("{hit}");
216        return Ok(());
217    }
218
219    let evidence = gather_resume_evidence(base, &lp)?;
220    let prompt = distill::build_prompt(
221        &lp,
222        &evidence.default_branch,
223        &evidence.commits,
224        &evidence.diffstat,
225        &evidence.excerpts,
226    );
227    let store = Store::new(base.to_path_buf());
228    let cfg = store.load()?;
229    progress("distilling…");
230    let answer = distill::run_llm(&cfg.llm_command, &prompt)?;
231    let doc = distill::with_sources(&answer, &lp, &evidence.excerpts, evidence.confidence);
232    cache.put(&lp, &doc)?;
233    println!("{doc}");
234    Ok(())
235}
236
237pub fn run_completions(shell: clap_complete::Shell) -> Result<()> {
238    use clap::CommandFactory;
239    let mut cmd = Cli::command();
240    clap_complete::generate(shell, &mut cmd, "loops", &mut std::io::stdout());
241    Ok(())
242}
243
244pub fn run_worktrees(base: &Path) -> Result<()> {
245    let store = Store::new(base.to_path_buf());
246    let cfg = store.load()?;
247    ensure!(
248        !cfg.roots.is_empty(),
249        "no roots configured. Run: loops init <dir-with-your-repos>"
250    );
251    progress("scanning git worktrees…");
252    let (wts, warnings) = worktrees::scan_worktrees(&cfg.roots, cfg.scan_depth);
253    for w in &warnings {
254        eprintln!("warning: {w}");
255    }
256    print!("{}", output::render_worktrees(&wts, chrono::Utc::now()));
257    Ok(())
258}