Skip to main content

skiff_cli/cli/
list.rs

1//! --list output formatting.
2
3use crate::model::{CommandDef, ListDetail};
4use crate::output::{output_result, OutputOptions};
5use crate::usage::{resolve_sort_mode, sort_commands};
6
7pub fn apply_list_options(
8    commands: Vec<CommandDef>,
9    source_hash: &str,
10    sort_mode: Option<&str>,
11    top: Option<usize>,
12) -> Vec<CommandDef> {
13    let mode = resolve_sort_mode(sort_mode, source_hash);
14    let mut commands = sort_commands(commands, &mode, source_hash);
15    if let Some(n) = top {
16        commands.truncate(n);
17    }
18    commands
19}
20
21pub fn filter_by_search(commands: Vec<CommandDef>, pattern: &str) -> Vec<CommandDef> {
22    let p = pattern.to_lowercase();
23    commands
24        .into_iter()
25        .filter(|c| c.name.to_lowercase().contains(&p) || c.description.to_lowercase().contains(&p))
26        .collect()
27}
28
29pub fn list_commands(commands: &[CommandDef], opts: &ListOptions) -> crate::error::Result<()> {
30    if opts.json_output || opts.toon {
31        let detail = if opts.compact {
32            ListDetail::Names
33        } else {
34            opts.detail
35        };
36        let payload = crate::model::commands_to_json(commands, detail);
37        output_result(
38            payload,
39            &OutputOptions {
40                pretty: opts.pretty,
41                json_output: opts.json_output || !opts.toon,
42                toon: opts.toon,
43                max_bytes: opts.max_bytes,
44                inline: opts.inline,
45                ..Default::default()
46            },
47        )?;
48        return Ok(());
49    }
50
51    if opts.compact || opts.detail == ListDetail::Names {
52        let names: Vec<_> = commands.iter().map(|c| c.name.as_str()).collect();
53        println!("{}", names.join(" "));
54        return Ok(());
55    }
56
57    match opts.style {
58        ListStyle::Mcp => {
59            for cmd in commands {
60                let desc = truncate_desc(&cmd.description, if opts.verbose { 10_000 } else { 70 });
61                if desc.is_empty() {
62                    println!("  {:<40}", cmd.name);
63                } else {
64                    println!("  {:<40}  {desc}", cmd.name);
65                }
66            }
67        }
68        ListStyle::OpenApi => {
69            let mut groups: std::collections::BTreeMap<String, Vec<&CommandDef>> =
70                std::collections::BTreeMap::new();
71            for cmd in commands {
72                let prefix = cmd
73                    .name
74                    .split_once('-')
75                    .map(|(a, _)| a.to_string())
76                    .unwrap_or_else(|| "other".into());
77                groups.entry(prefix).or_default().push(cmd);
78            }
79            for (group, cmds) in groups {
80                println!("\n{group}:");
81                for cmd in cmds {
82                    let method = cmd.method.as_deref().unwrap_or("").to_uppercase();
83                    let desc =
84                        truncate_desc(&cmd.description, if opts.verbose { 10_000 } else { 60 });
85                    if desc.is_empty() {
86                        println!("  {:<45} {:<6}", cmd.name, method);
87                    } else {
88                        println!("  {:<45} {:<6} {desc}", cmd.name, method);
89                    }
90                }
91            }
92        }
93        ListStyle::Graphql => {
94            let mut groups: std::collections::BTreeMap<&str, Vec<&CommandDef>> =
95                std::collections::BTreeMap::new();
96            for cmd in commands {
97                let key = cmd.graphql_operation_type.as_deref().unwrap_or("other");
98                groups.entry(key).or_default().push(cmd);
99            }
100            for group in ["query", "mutation"] {
101                let Some(cmds) = groups.get(group) else {
102                    continue;
103                };
104                if cmds.is_empty() {
105                    continue;
106                }
107                let label = if group == "query" {
108                    "queries"
109                } else {
110                    "mutations"
111                };
112                println!("\n{label}:");
113                for cmd in cmds {
114                    let desc =
115                        truncate_desc(&cmd.description, if opts.verbose { 10_000 } else { 60 });
116                    if desc.is_empty() {
117                        println!("  {:<40}", cmd.name);
118                    } else {
119                        println!("  {:<40}  {desc}", cmd.name);
120                    }
121                }
122            }
123        }
124    }
125    Ok(())
126}
127
128#[derive(Debug, Clone, Copy)]
129pub enum ListStyle {
130    Mcp,
131    OpenApi,
132    Graphql,
133}
134
135#[derive(Debug, Clone)]
136pub struct ListOptions {
137    pub verbose: bool,
138    pub compact: bool,
139    pub json_output: bool,
140    pub pretty: bool,
141    pub toon: bool,
142    pub detail: ListDetail,
143    pub max_bytes: Option<usize>,
144    pub inline: bool,
145    pub style: ListStyle,
146}
147
148impl ListOptions {
149    pub fn from_global(pre: &crate::cli::args::GlobalArgs, style: ListStyle) -> Self {
150        Self {
151            verbose: pre.verbose,
152            compact: pre.compact,
153            // Agent defaults already applied to `json_output` when appropriate.
154            json_output: pre.json_output,
155            pretty: pre.pretty,
156            toon: pre.toon,
157            detail: pre.list_detail(),
158            max_bytes: pre.max_bytes,
159            inline: pre.inline,
160            style,
161        }
162    }
163}
164
165fn truncate_desc(description: &str, max_len: usize) -> String {
166    crate::model::truncate_at_word(description, max_len)
167}
168
169/// Emit one-tool schema (JSON/TOON) or human help.
170pub fn emit_command_help(
171    cmd: &CommandDef,
172    pre: &crate::cli::args::GlobalArgs,
173) -> crate::error::Result<()> {
174    if pre.json_output || pre.toon || pre.agent {
175        output_result(crate::model::command_to_json(cmd), &pre.output_options())?;
176    } else {
177        crate::cli::dynamic::print_command_help(cmd);
178    }
179    Ok(())
180}
181
182/// If remaining is `TOOL --help`/`-h`, emit help and return true.
183pub fn maybe_tool_help(
184    commands: &[CommandDef],
185    remaining: &[String],
186    pre: &crate::cli::args::GlobalArgs,
187) -> crate::error::Result<bool> {
188    if remaining.len() >= 2 && (remaining[1] == "--help" || remaining[1] == "-h") {
189        if let Some(cmd) = commands.iter().find(|c| c.name == remaining[0]) {
190            emit_command_help(cmd, pre)?;
191            return Ok(true);
192        }
193    }
194    Ok(false)
195}
196
197pub fn describe_tool(
198    commands: &[CommandDef],
199    name: &str,
200    pre: &crate::cli::args::GlobalArgs,
201) -> crate::error::Result<()> {
202    let cmd = commands
203        .iter()
204        .find(|c| c.name == name || c.tool_name.as_deref() == Some(name))
205        .ok_or_else(|| crate::error::Error::usage(format!("unknown tool: {name}")))?;
206    // describe always structured
207    let mut opts = pre.output_options();
208    opts.json_output = true;
209    output_result(crate::model::command_to_json(cmd), &opts)?;
210    Ok(())
211}