Skip to main content

agent_first_data/cli_spec/
help.rs

1use super::*;
2use serde::Serialize;
3use std::collections::BTreeMap;
4
5/// Agent-first help-v2 payload.
6///
7/// `defaults` carries [`CliValue`], which admits finite `f64`, so this model is
8/// `PartialEq` but not `Eq`.
9#[derive(Clone, Debug, PartialEq, Serialize)]
10pub struct CliHelpV2 {
11    pub schema: String,
12    pub command_path: String,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub about: Option<String>,
15    /// Every legal shape of this command, each complete.
16    ///
17    /// Help is answered in one round trip rather than two. A second level would
18    /// only pay off by omitting something, and what it omitted — the optional
19    /// arguments — would be undiscoverable to a caller that stopped at the
20    /// first level.
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub shapes: Vec<CliShape>,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub subcommands: Vec<String>,
25    /// Argument meanings. Arguments belong to the command, not to one shape,
26    /// so these are stated once rather than per shape.
27    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28    pub notes: BTreeMap<String, String>,
29    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
30    pub defaults: BTreeMap<String, CliValue>,
31}
32
33/// One legal shape of a call.
34#[derive(Clone, Debug, PartialEq, Serialize)]
35pub struct CliShape {
36    pub id: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub about: Option<String>,
39    pub usage: String,
40}
41
42/// Resolved help response plus its lifecycle output plan.
43#[derive(Clone, Debug, PartialEq)]
44pub struct ResolvedHelp {
45    pub(super) model: CliHelpV2,
46    pub(super) output: OutputPlan,
47}
48
49impl ResolvedHelp {
50    pub fn model(&self) -> &CliHelpV2 {
51        &self.model
52    }
53
54    pub fn output_plan(&self) -> &OutputPlan {
55        &self.output
56    }
57
58    pub fn plain(&self) -> String {
59        let mut output = String::new();
60        if let Some(about) = &self.model.about {
61            output.push_str(about);
62            output.push_str("\n\n");
63        }
64        for shape in &self.model.shapes {
65            if self.model.shapes.len() > 1 {
66                let about = shape.about.as_deref().unwrap_or_default();
67                output.push_str(&format!("{}  {about}\n", shape.id));
68            }
69            output.push_str(&shape.usage);
70            output.push('\n');
71        }
72        // The same facts the structured form carries. Without these the plain
73        // rendering was strictly weaker than the JSON it claims to be a human
74        // catalog of — no argument meanings, no defaults — so a reader who
75        // asked for the readable form got told less.
76        if !self.model.notes.is_empty() {
77            output.push('\n');
78            let width = self
79                .model
80                .notes
81                .keys()
82                .map(String::len)
83                .max()
84                .unwrap_or_default();
85            for (name, note) in &self.model.notes {
86                output.push_str(&format!("  {name:<width$}  {note}\n"));
87            }
88        }
89        if !self.model.defaults.is_empty() {
90            output.push_str("\ndefaults:");
91            for (name, value) in &self.model.defaults {
92                output.push_str(&format!(" {name}={}", plain_value(value)));
93            }
94            output.push('\n');
95        }
96        for (index, subcommand) in self.model.subcommands.iter().enumerate() {
97            output.push_str(if index == 0 {
98                "\nmore:    "
99            } else {
100                "         "
101            });
102            output.push_str(subcommand);
103            output.push('\n');
104        }
105        output
106    }
107}
108
109/// Resolved version response plus its lifecycle output plan.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct ResolvedVersion {
112    pub(super) name: String,
113    pub(super) version: String,
114    pub(super) display_name: Option<String>,
115    pub(super) build: Option<String>,
116    pub(super) output: OutputPlan,
117}
118
119impl ResolvedVersion {
120    pub fn output_plan(&self) -> &OutputPlan {
121        &self.output
122    }
123
124    pub fn name(&self) -> &str {
125        &self.name
126    }
127
128    pub fn version(&self) -> &str {
129        &self.version
130    }
131
132    pub fn display_name(&self) -> Option<&str> {
133        self.display_name.as_deref()
134    }
135
136    pub fn build(&self) -> Option<&str> {
137        self.build.as_deref()
138    }
139}
140
141/// The registry asked to describe itself in full.
142///
143/// Rendering is not the core's business — it carries no Markdown — so this only
144/// says the request was legal and where the bytes go. A host renders the
145/// registry it already holds.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct ResolvedDocs {
148    pub(super) output: OutputPlan,
149}
150
151impl ResolvedDocs {
152    pub fn output_plan(&self) -> &OutputPlan {
153        &self.output
154    }
155}
156
157pub(super) fn combination_usage(
158    command: &CommandSpec,
159    combination: &Combination,
160    command_path: &str,
161    detail: bool,
162) -> (String, BTreeMap<String, String>, BTreeMap<String, CliValue>) {
163    let mut parts = vec![command_path.to_string()];
164    let mut notes = BTreeMap::new();
165    let mut defaults = BTreeMap::new();
166    for argument in &command.arguments {
167        let id = argument.argument_id.as_str();
168        let fixed = combination.fixed.get(id);
169        let required = combination.required.iter().any(|candidate| candidate == id);
170        let optional = combination.optional.iter().any(|candidate| candidate == id);
171        if fixed.is_none() && !required && !optional {
172            continue;
173        }
174        if optional && !detail {
175            continue;
176        }
177        // A fixed argument the caller may leave out: `matches_combination`
178        // accepts the argument's own default in place of an explicit value, so
179        // rendering it as required would describe a stricter call than the one
180        // the parser demands.
181        let default_satisfies_fixed = fixed.is_some_and(|fixed| {
182            argument
183                .default
184                .as_ref()
185                .and_then(CliValue::as_str)
186                .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
187        });
188        let omittable = optional || default_satisfies_fixed;
189        let token = render_argument(argument, fixed, omittable);
190        parts.push(token);
191        if detail {
192            let key = argument_key(argument);
193            if let Some(about) = &argument.about {
194                notes.insert(key.clone(), about.clone());
195            }
196            // A default is only worth stating where leaving the argument out is
197            // legal; on a required argument it describes nothing the caller can
198            // reach.
199            if omittable
200                && !argument.sensitive
201                && let Some(default) = &argument.default
202            {
203                defaults.insert(key, default.clone());
204            }
205        }
206    }
207    if detail {
208        match &combination.output {
209            OutputSpec::Raw { file_sinks } => {
210                render_file_sinks(&mut parts, file_sinks);
211            }
212            OutputSpec::Protocol {
213                formats,
214                destinations,
215                default_format,
216                default_destination,
217                file_sinks,
218                ..
219            } => {
220                parts.push(format!("[--output <{}>]", formats.join("|")));
221                parts.push(format!("[--output-to <{}>]", destinations.join("|")));
222                render_file_sinks(&mut parts, file_sinks);
223                defaults.insert(
224                    "--output".to_string(),
225                    CliValue::String(default_format.clone()),
226                );
227                defaults.insert(
228                    "--output-to".to_string(),
229                    CliValue::String(default_destination.clone()),
230                );
231            }
232        }
233    }
234    (parts.join(" "), notes, defaults)
235}
236
237/// A default rendered for the plain catalog, without JSON's quoting.
238fn plain_value(value: &CliValue) -> String {
239    match value {
240        CliValue::Bool(value) => value.to_string(),
241        CliValue::String(value) | CliValue::Json(value) => value.clone(),
242        CliValue::I64(value) => value.to_string(),
243        CliValue::FiniteF64(value) => value.to_string(),
244        CliValue::List(values) => values.iter().map(plain_value).collect::<Vec<_>>().join(","),
245    }
246}
247
248/// The spelling the caller reads in `usage`, and therefore the key `notes` and
249/// `defaults` use — an agent looks up a token it just read without having to
250/// transform it first.
251///
252/// It also keeps AFDATA's own convention from misfiring on this crate's help:
253/// an argument id like `reveal_secret` names a description, not a secret, and
254/// the `_secret` suffix would redact this command's own help text away.
255pub(crate) fn argument_key(argument: &ArgSpec) -> String {
256    match &argument.syntax {
257        ArgSyntax::Long { name } => name.clone(),
258        ArgSyntax::Positional { .. } => argument
259            .value_name
260            .clone()
261            .unwrap_or_else(|| argument.argument_id.clone()),
262    }
263}
264
265fn render_argument(argument: &ArgSpec, fixed: Option<&FixedValue>, optional: bool) -> String {
266    let value = if let Some(fixed) = fixed {
267        match fixed {
268            FixedValue::Value(value) => value.clone(),
269            FixedValue::OneOf { one_of } => format!("<{}>", one_of.join("|")),
270        }
271    } else if argument.value_type == ArgValueType::Flag {
272        String::new()
273    } else if !argument.enum_values.is_empty() {
274        // A closed value set is the argument's contract. Printing only the
275        // placeholder would leave those values registered but undiscoverable —
276        // the caller would have to provoke an error to be told what they are.
277        format!("<{}>", argument.enum_values.join("|"))
278    } else {
279        format!(
280            "<{}>",
281            argument
282                .value_name
283                .as_deref()
284                .unwrap_or(argument.argument_id.as_str())
285        )
286    };
287    let mut token = match &argument.syntax {
288        ArgSyntax::Long { name } => {
289            if value.is_empty() {
290                name.clone()
291            } else {
292                format!("{name} {value}")
293            }
294        }
295        ArgSyntax::Positional { .. } => value,
296    };
297    if argument.repeatable {
298        token.push_str("...");
299    }
300    if optional {
301        token = format!("[{token}]");
302    }
303    token
304}
305
306fn render_file_sinks(parts: &mut Vec<String>, file_sinks: &[String]) {
307    if file_sinks.iter().any(|sink| sink == "stdout") {
308        parts.push("[--stdout-file <PATH>]".to_string());
309    }
310    if file_sinks.iter().any(|sink| sink == "stderr") {
311        parts.push("[--stderr-file <PATH>]".to_string());
312    }
313}