loopflow 0.12.28

Run steps and flows with coding agents
Documentation
use std::collections::BTreeSet;
use std::io::{self, IsTerminal, Write};
use std::path::Path;

use anyhow::Result;
use clap::Command;
use serde::Serialize;

use crate::lf::discovery::{
    definition_source, list_all_skills, resolve_local_definition, DefinitionKind, Target,
};
use crate::lf::navigation::{command_tree, definition_invocation, resolve_path};

#[derive(Debug, Serialize)]
pub struct Entry {
    pub name: String,
    pub kind: String,
    pub source: String,
    pub description: String,
    pub invocation: String,
}

fn definition_entry(tree: &Command, repo: &Path, name: String, kind: DefinitionKind) -> Entry {
    let description = match resolve_local_definition(repo, &name, Some(kind)) {
        Ok(Target::Skill(skill)) => skill
            .content
            .unwrap_or_default()
            .lines()
            .find(|line| !line.trim().is_empty())
            .unwrap_or_default()
            .trim_start_matches('#')
            .trim()
            .to_string(),
        Ok(target) => crate::lf::discovery::format_target(&target),
        Err(error) => format!("unavailable: {error}"),
    };
    Entry {
        source: definition_source(repo, &name, kind),
        invocation: definition_invocation(tree, &name, kind),
        name,
        kind: kind.as_str().to_string(),
        description,
    }
}

pub fn list_children(path: &[String], repo: &Path) -> Result<Vec<Entry>> {
    let mut entries = collect_entries(&command_tree(), path, repo)?;
    entries.sort_by(|a, b| (&a.kind, &a.name).cmp(&(&b.kind, &b.name)));
    Ok(entries)
}

fn collect_entries(tree: &Command, path: &[String], repo: &Path) -> Result<Vec<Entry>> {
    let mut entries = Vec::new();
    if path.is_empty() || path.first().is_some_and(|name| name == "skill") {
        let (local, global, builtin, external) = list_all_skills(Some(repo));
        let names: BTreeSet<_> = local
            .into_iter()
            .chain(global)
            .chain(builtin)
            .chain(external.into_iter().map(|(name, _)| name))
            .collect();
        let namespace = path.get(1).map(|name| format!("{name}/"));
        let mut namespaces = BTreeSet::new();
        for name in names {
            if namespace
                .as_ref()
                .is_some_and(|prefix| !name.starts_with(prefix))
            {
                continue;
            }
            if path.len() == 1 {
                if let Some((namespace, _)) = name.split_once('/') {
                    if namespaces.insert(namespace.to_string()) {
                        entries.push(Entry {
                            name: namespace.to_string(),
                            kind: "namespace".to_string(),
                            source: "skills".to_string(),
                            description: "List skills in this namespace".to_string(),
                            invocation: format!("lf skill list {namespace}"),
                        });
                    }
                    continue;
                }
            }
            entries.push(definition_entry(tree, repo, name, DefinitionKind::Skill));
        }
        if !path.is_empty() {
            return Ok(entries);
        }
    }
    if path.is_empty() || path == ["flow"] {
        for name in crate::engine::available_flow_names(repo) {
            entries.push(definition_entry(tree, repo, name, DefinitionKind::Flow));
        }
        if !path.is_empty() {
            return Ok(entries);
        }
    }
    let (command, canonical) = resolve_path(tree, path)?;
    for child in command
        .get_subcommands()
        .filter(|child| !child.is_hide_set())
    {
        let mut child_path = canonical.clone();
        child_path.push(child.get_name().to_string());
        entries.push(Entry {
            name: child.get_name().to_string(),
            kind: "command".to_string(),
            source: "builtin".to_string(),
            description: child
                .get_about()
                .map(ToString::to_string)
                .unwrap_or_default(),
            invocation: format!("lf {}", child_path.join(" ")),
        });
    }
    Ok(entries)
}

pub fn show(path: &[String], repo: &Path, json: bool) -> Result<()> {
    let entries = list_children(path, repo)?;
    let output = if json {
        format!("{}\n", serde_json::to_string(&entries)?)
    } else {
        let mut output = String::new();
        let mut kind = String::new();
        for entry in entries {
            if kind != entry.kind {
                kind = entry.kind;
                output.push_str(&format!("\n{}\n", kind.to_uppercase()));
            }
            output.push_str(&format!(
                "  {:<26} {}\n    {} ({})\n",
                entry.name,
                entry.description.lines().next().unwrap_or_default(),
                entry.invocation,
                entry.source
            ));
        }
        output
    };
    let stdout = io::stdout();
    let output = if stdout.is_terminal() {
        output.replace('\n', "\r\n")
    } else {
        output
    };
    stdout.lock().write_all(output.as_bytes())?;
    Ok(())
}