Skip to main content

agent_first_data/
help.rs

1use crate::formatting::{render_yaml, serialize_json_output};
2use crate::protocol::build_cli_error;
3use crate::redaction::{
4    OutputOptions, PlainStyle, RedactionContext, RedactionPolicy, Redactor, is_secret_flag_name,
5};
6use serde_json::Value;
7
8// ═══════════════════════════════════════════
9// Public API: CLI Help Rendering (optional)
10// ═══════════════════════════════════════════
11
12/// How much of a command tree a help request should render.
13///
14/// Requires the `cli-help` feature.
15#[cfg(feature = "cli-help")]
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum HelpScope {
18    /// Render only the selected command's own clap-style help.
19    ///
20    /// Clap's normal help still lists direct subcommands in the "Commands"
21    /// section, but descendant command detail is not expanded.
22    OneLevel,
23    /// Render the selected command and all visible descendant subcommands.
24    Recursive,
25}
26
27/// Output format for help rendering.
28///
29/// Requires the `cli-help` feature.
30#[cfg(feature = "cli-help")]
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum HelpFormat {
33    Plain,
34    Markdown,
35    Json,
36    Yaml,
37}
38
39#[cfg(feature = "cli-help")]
40impl HelpFormat {
41    fn parse(s: &str) -> Option<Self> {
42        match s {
43            "plain" => Some(Self::Plain),
44            "markdown" => Some(Self::Markdown),
45            "json" => Some(Self::Json),
46            "yaml" => Some(Self::Yaml),
47            _ => None,
48        }
49    }
50}
51
52/// Options for rendering CLI help.
53///
54/// Requires the `cli-help` feature.
55#[cfg(feature = "cli-help")]
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub struct HelpOptions {
58    pub scope: HelpScope,
59    pub format: HelpFormat,
60}
61
62#[cfg(feature = "cli-help")]
63impl HelpOptions {
64    /// Human-friendly current-level plain help.
65    pub const fn one_level_plain() -> Self {
66        Self {
67            scope: HelpScope::OneLevel,
68            format: HelpFormat::Plain,
69        }
70    }
71
72    /// Agent/doc-friendly recursive plain help.
73    pub const fn recursive_plain() -> Self {
74        Self {
75            scope: HelpScope::Recursive,
76            format: HelpFormat::Plain,
77        }
78    }
79}
80
81/// Configuration for pre-clap help handling.
82///
83/// The handler scans raw argv before `Cli::try_parse()` so applications can
84/// support requests such as `--help --output markdown` without clap exiting
85/// early with `DisplayHelp`.
86///
87/// Requires the `cli-help` feature.
88#[cfg(feature = "cli-help")]
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct HelpConfig {
91    /// Scope used for `--help` when `--recursive` is absent.
92    pub default_scope: HelpScope,
93    /// Fallback format when the command has no valid default for `--output`.
94    ///
95    /// An explicit `--output` always wins. When it is omitted, the handler
96    /// first inherits the command's own `--output` default so help follows the
97    /// same output contract as every other successful result.
98    pub default_format: HelpFormat,
99}
100
101#[cfg(feature = "cli-help")]
102impl HelpConfig {
103    /// The blessed preset for CLIs.
104    ///
105    /// `--help` renders one-level help and inherits the command's own
106    /// `--output` default. Scope and format are orthogonal: `--recursive`
107    /// expands the selected command subtree, while `--output
108    /// plain|json|yaml|markdown` picks the format. Plain is only the fallback
109    /// for commands that do not declare a supported `--output` default.
110    pub const fn output_aware() -> Self {
111        Self::output_aware_with_fallback(HelpFormat::Plain)
112    }
113
114    /// Output-aware help with an explicit fallback format.
115    ///
116    /// Use this for a fixed-format CLI that has no `--output` argument. For
117    /// example, a CLI whose normal output is always protocol JSON should pass
118    /// [`HelpFormat::Json`]. An explicit help `--output`, or a supported
119    /// default declared by the selected command or an ancestor, still wins.
120    pub const fn output_aware_with_fallback(default_format: HelpFormat) -> Self {
121        Self {
122            default_scope: HelpScope::OneLevel,
123            default_format,
124        }
125    }
126
127    /// Compatibility alias for [`Self::output_aware`].
128    ///
129    /// The historical name predates output-default inheritance and is less
130    /// precise: a JSON-default command receives JSON help, not human text.
131    pub const fn human_cli_default() -> Self {
132        Self::output_aware()
133    }
134}
135
136/// Render help for a clap command tree with explicit scope and format.
137///
138/// Walks to the subcommand identified by `subcommand_path` (empty = root),
139/// then renders either the selected command only (`OneLevel`) or the selected
140/// command and all descendants (`Recursive`).
141///
142/// `Plain` and `Markdown` are text. `Json` and `Yaml` are protocol-v1 terminal
143/// results (`result.code:"help"`, model under `result.help`) — the same shape
144/// [`cli_handle_help_or_continue`] emits, so structured help validates against
145/// `cli-help-v1.schema.json` no matter which entry point produced it.
146///
147/// Requires the `cli-help` feature.
148#[cfg(feature = "cli-help")]
149pub fn cli_render_help_with_options(
150    cmd: &clap::Command,
151    subcommand_path: &[&str],
152    options: &HelpOptions,
153) -> String {
154    let selected = scoped_help_command(cmd, subcommand_path);
155    let target = &selected.conventional_command;
156    let mut rendered = match options.format {
157        HelpFormat::Plain => match options.scope {
158            HelpScope::OneLevel => render_help_one_level_plain(target),
159            HelpScope::Recursive => {
160                let mut buf = String::new();
161                render_help_recursive_plain(target, &[], &mut buf);
162                buf
163            }
164        },
165        HelpFormat::Markdown => render_help_markdown(
166            target,
167            std::slice::from_ref(&selected.command_path),
168            options.scope,
169        ),
170        HelpFormat::Json => {
171            serialize_json_output(help_result_event(&selected, options.scope).as_value())
172        }
173        HelpFormat::Yaml => render_yaml(
174            help_result_event(&selected, options.scope).as_value(),
175            &OutputOptions {
176                redaction: Redactor::new().policy(RedactionPolicy::Off),
177                style: PlainStyle::Raw,
178            },
179        ),
180    };
181    // Every format ends with exactly one trailing newline so `print!`-ing the
182    // result is clean across plain/markdown/json/yaml (JSON and raw YAML would
183    // otherwise have none).
184    while rendered.ends_with('\n') {
185        rendered.pop();
186    }
187    rendered.push('\n');
188    rendered
189}
190
191/// Render recursive plain-text help for a clap command tree.
192///
193/// Walks to the subcommand identified by `subcommand_path` (empty = root),
194/// then recursively expands all descendant subcommands into a single output.
195///
196/// Requires the `cli-help` feature.
197#[cfg(feature = "cli-help")]
198pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
199    cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
200}
201
202/// Render recursive Markdown help for a clap command tree.
203///
204/// Same tree walk as [`cli_render_help`], but outputs Markdown suitable for
205/// documentation generation (`myapp --help --recursive --output markdown > docs/cli.md`).
206///
207/// Requires the `cli-help-markdown` feature.
208#[cfg(feature = "cli-help-markdown")]
209pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
210    cli_render_help_with_options(
211        cmd,
212        subcommand_path,
213        &HelpOptions {
214            scope: HelpScope::Recursive,
215            format: HelpFormat::Markdown,
216        },
217    )
218}
219
220/// Render help from raw argv if a help flag is present; otherwise return `None`.
221///
222/// `raw_args` should be the full argv vector, including argv[0], as produced by
223/// `std::env::args()`. The helper intentionally runs before clap parsing so
224/// `--help --recursive` and `--help --output markdown` can select scope and
225/// format instead of being consumed by clap's built-in help handling. Scope
226/// (`--recursive`) and format (`--output`) are orthogonal.
227///
228/// A bare `--recursive` without `--help` is treated as a non-help request
229/// (`Ok(None)`), leaving the flag for the application's own parser.
230///
231/// Returns a standard [`build_cli_error`] value when the help request is
232/// malformed, for example `--help --output xml`.
233///
234/// Requires the `cli-help` feature.
235#[cfg(feature = "cli-help")]
236pub fn cli_handle_help_or_continue(
237    raw_args: &[String],
238    cmd: &clap::Command,
239    config: &HelpConfig,
240) -> Result<Option<String>, Value> {
241    cli_handle_help_or_continue_inner(raw_args, cmd, config)
242}
243
244/// Handle a top-level version or help request through one early entry point.
245///
246/// This is the preferred CLI entry point when both helpers are enabled. The
247/// caller supplies `name`, `display_name`, `version`, and `build` for the
248/// version branch. Help is derived from `cmd` and intentionally does not repeat
249/// version values. Version takes precedence when both flags are present,
250/// matching the traditional two-call sequence.
251///
252/// Requires the `cli-help` feature.
253#[cfg(feature = "cli-help")]
254#[allow(clippy::too_many_arguments)]
255pub fn cli_handle_version_or_help_or_continue(
256    raw_args: &[String],
257    cmd: &clap::Command,
258    config: &HelpConfig,
259    name: &str,
260    display_name: Option<&str>,
261    version: &str,
262    build: Option<&str>,
263) -> Result<Option<String>, Value> {
264    match crate::cli::cli_handle_version_or_continue(
265        raw_args,
266        cmd,
267        name,
268        display_name,
269        version,
270        build,
271    ) {
272        Ok(Some(rendered)) => return Ok(Some(rendered)),
273        Ok(None) => {}
274        Err(event) => return Err(event.into()),
275    }
276    // This entry point answers `--version` itself, so the command exposes the
277    // flag whether or not the caller declared it on clap — an app has no reason
278    // to, since the pre-parser intercepts the flag before clap ever sees it.
279    // Materializing it on the clone is what makes help advertise `-V, --version`
280    // exactly once; the model still carries only the flag, never its value. The
281    // clone is used for rendering only, so the action is never dispatched.
282    let cmd = version_aware_help_command(cmd);
283    cli_handle_help_or_continue(raw_args, &cmd, config)
284}
285
286/// Clone `cmd` with a `--version` flag it can advertise in help.
287///
288/// Skipped when the command already exposes one (declared as an argument, or
289/// auto-generated by clap from `Command::version`), and `-V` is claimed only
290/// when no other argument owns that short.
291#[cfg(feature = "cli-help")]
292fn version_aware_help_command(cmd: &clap::Command) -> clap::Command {
293    let already_exposed = cmd.get_arguments().any(|arg| {
294        arg.get_long() == Some("version") || arg.get_short().is_some_and(|short| short == 'V')
295    }) || (!cmd.is_disable_version_flag_set()
296        && (cmd.get_version().is_some() || cmd.get_long_version().is_some()));
297    if already_exposed {
298        return cmd.clone();
299    }
300    let version_flag = clap::Arg::new("version")
301        .long("version")
302        .help("Print version")
303        .action(clap::ArgAction::SetTrue);
304    cmd.clone().disable_version_flag(true).arg(version_flag)
305}
306
307#[cfg(feature = "cli-help")]
308fn cli_handle_help_or_continue_inner(
309    raw_args: &[String],
310    cmd: &clap::Command,
311    config: &HelpConfig,
312) -> Result<Option<String>, Value> {
313    let parsed = parse_help_request(raw_args, cmd);
314    if !parsed.help_requested {
315        return Ok(None);
316    }
317    if let Some(error) = parsed.output_error {
318        let event = build_cli_error(
319            &error,
320            Some("valid help output formats: plain, markdown, json, yaml"),
321        );
322        return Err(event.into());
323    }
324
325    let (scope, format) = resolve_help_options(&parsed, cmd, config);
326    let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
327    // The one blessed structured shape: `--help --output json|yaml` wraps the
328    // help schema in a protocol-v1 `kind:"result"` event (`code:"help"`), the
329    // same envelope discipline as the version handler. Plain/Markdown stay text.
330    if matches!(format, HelpFormat::Json | HelpFormat::Yaml) {
331        let event = help_result_event(&scoped_help_command(cmd, &path), scope);
332        let rendered = match format {
333            HelpFormat::Json => serialize_json_output(event.as_value()),
334            HelpFormat::Yaml => render_yaml(
335                event.as_value(),
336                &OutputOptions {
337                    redaction: Redactor::new().policy(RedactionPolicy::Off),
338                    style: PlainStyle::Raw,
339                },
340            ),
341            HelpFormat::Plain | HelpFormat::Markdown => unreachable!(),
342        };
343        return Ok(Some(format!("{rendered}\n")));
344    }
345    let options = HelpOptions { scope, format };
346    Ok(Some(cli_render_help_with_options(cmd, &path, &options)))
347}
348
349/// Wrap a help model in the protocol-v1 terminal result every structured help
350/// path shares.
351#[cfg(feature = "cli-help")]
352fn help_result_event(selected: &ScopedHelpCommand, scope: HelpScope) -> crate::protocol::Event {
353    crate::protocol::json_result(serde_json::json!({
354        "code": "help",
355        "help": build_selected_help_schema(selected, scope),
356    }))
357    .trace(serde_json::json!({}))
358    .build()
359}
360
361#[cfg(feature = "cli-help")]
362fn resolve_help_options(
363    parsed: &ParsedHelpRequest,
364    cmd: &clap::Command,
365    config: &HelpConfig,
366) -> (HelpScope, HelpFormat) {
367    // Scope and format are orthogonal: `--recursive` selects one-level vs
368    // recursive, while `--output` independently decides the format.
369    let scope = if parsed.recursive_requested {
370        HelpScope::Recursive
371    } else {
372        config.default_scope
373    };
374    let format = parsed
375        .output_format
376        .or_else(|| command_output_default(cmd, &parsed.subcommand_path))
377        .unwrap_or(config.default_format);
378    (scope, format)
379}
380
381#[cfg(feature = "cli-help")]
382fn command_output_default(cmd: &clap::Command, path: &[String]) -> Option<HelpFormat> {
383    let mut current = cmd;
384    let mut format = command_output_default_here(current);
385    for name in path {
386        let Some(next) = current.find_subcommand(name) else {
387            break;
388        };
389        current = next;
390        if let Some(selected) = command_output_default_here(current) {
391            format = Some(selected);
392        }
393    }
394    format
395}
396
397#[cfg(feature = "cli-help")]
398fn command_output_default_here(cmd: &clap::Command) -> Option<HelpFormat> {
399    cmd.get_arguments()
400        .find(|arg| arg.get_long() == Some("output"))
401        .and_then(|arg| arg.get_default_values().first())
402        .and_then(|value| value.to_str())
403        .and_then(HelpFormat::parse)
404}
405
406#[cfg(feature = "cli-help")]
407struct ScopedHelpCommand {
408    local_command: clap::Command,
409    conventional_command: clap::Command,
410    command_path: String,
411    inherited_arguments_from: Vec<String>,
412}
413
414#[cfg(feature = "cli-help")]
415fn scoped_help_command(cmd: &clap::Command, path: &[&str]) -> ScopedHelpCommand {
416    let mut current = cmd;
417    let mut command_names = vec![cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string()];
418    let mut inherited_groups = Vec::new();
419
420    for name in path {
421        let Some(next) = current.find_subcommand(name) else {
422            break;
423        };
424        let globals: Vec<clap::Arg> = current
425            .get_arguments()
426            .filter(|arg| arg.is_global_set() && !arg.is_hide_set())
427            .cloned()
428            .collect();
429        if !globals.is_empty() {
430            inherited_groups.push((command_names.join(" "), globals));
431        }
432        current = next;
433        command_names.push(next.get_name().to_string());
434    }
435
436    let command_path = command_names.join(" ");
437    let local_command = current.clone().bin_name(command_path.clone());
438    let mut conventional_command = local_command.clone();
439    let mut inherited_arguments_from = Vec::new();
440    for (source, arguments) in inherited_groups {
441        let mut inherited_from_source = false;
442        for argument in arguments {
443            let already_declared = conventional_command
444                .get_arguments()
445                .any(|candidate| candidate.get_id() == argument.get_id());
446            if !already_declared {
447                conventional_command = conventional_command.arg(argument);
448                inherited_from_source = true;
449            }
450        }
451        if inherited_from_source {
452            inherited_arguments_from.push(source);
453        }
454    }
455
456    ScopedHelpCommand {
457        local_command,
458        conventional_command,
459        command_path,
460        inherited_arguments_from,
461    }
462}
463
464#[cfg(feature = "cli-help")]
465fn render_help_one_level_plain(cmd: &clap::Command) -> String {
466    enriched_help_command(cmd).render_long_help().to_string()
467}
468
469#[cfg(feature = "cli-help")]
470fn redact_secret_help_defaults(mut cmd: clap::Command) -> clap::Command {
471    let context = RedactionContext::default();
472    let ids: Vec<String> = cmd
473        .get_arguments()
474        .filter(|arg| !arg.get_default_values().is_empty())
475        .filter(|arg| help_arg_is_secret(arg, &context))
476        .map(|arg| arg.get_id().to_string())
477        .collect();
478    for id in ids {
479        cmd = cmd.mut_arg(id, |arg| arg.default_value("***"));
480    }
481    cmd
482}
483
484#[cfg(feature = "cli-help")]
485fn help_arg_is_secret(arg: &clap::Arg, context: &RedactionContext) -> bool {
486    is_secret_flag_name(arg.get_id().as_ref(), context)
487        || arg
488            .get_long()
489            .is_some_and(|long| is_secret_flag_name(long, context))
490}
491
492/// Clone `cmd` and fold the afdata-handled help modifiers into clap's own
493/// `-h, --help` description.
494///
495/// Help is rendered by clap, which has no knowledge of the `--recursive` scope
496/// modifier or the `--output` help formats (afdata consumes both before clap
497/// parses). Rather than appending a separate section, we patch the description
498/// of the existing help flag so the help surface is documented in place — in
499/// every format, since plain/markdown render this flag and the JSON/YAML schema
500/// reads it. Commands with subcommands advertise `--recursive`; leaf commands
501/// only advertise the `--output` formats (they have nothing to expand).
502#[cfg(feature = "cli-help")]
503fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
504    let description = if visible_subcommands(cmd).next().is_some() {
505        HELP_FLAG_WITH_SUBCOMMANDS
506    } else {
507        HELP_FLAG_LEAF
508    };
509    // The canonical discovery surface is `--help`; never let clap synthesize
510    // its legacy `help` pseudo-command while rendering an isolated subtree.
511    let cmd = redact_secret_help_defaults(cmd.clone()).disable_help_subcommand(true);
512    let has_explicit_version = cmd.get_arguments().any(|arg| {
513        arg.get_long() == Some("version") || arg.get_short().is_some_and(|short| short == 'V')
514    });
515    let exposes_auto_version = !cmd.is_disable_version_flag_set()
516        && (cmd.get_version().is_some() || cmd.get_long_version().is_some());
517    // `--help` has no short. `-h` would be a byte-identical alias, and the whole
518    // point of this release is that help answers in the command's output format:
519    // a short flag that returns JSON is a trap for the human reaching for it, and
520    // an agent gains nothing from two spellings. `-h` therefore stays the
521    // application's to spend (`--host`, the psql/mysql convention).
522    let help_flag = clap::Arg::new("help")
523        .long("help")
524        .help(description)
525        .long_help(description)
526        .action(clap::ArgAction::Help);
527    // clap auto-generates its help flag lazily during build, so `mut_arg` cannot
528    // reach it yet. The built-in version flag is lazy for the same reason, so
529    // materialize both before the compact schema reads `get_arguments()`.
530    let mut enriched = cmd.disable_help_flag(true).arg(help_flag);
531    if exposes_auto_version && !has_explicit_version {
532        enriched = enriched.disable_version_flag(true).arg(
533            clap::Arg::new("version")
534                .long("version")
535                .help("Print version")
536                .action(clap::ArgAction::Version),
537        );
538    }
539    enriched
540}
541
542/// Description for the `--help` flag on commands that have subcommands.
543#[cfg(feature = "cli-help")]
544const HELP_FLAG_WITH_SUBCOMMANDS: &str = "Print help. Add --recursive to expand every nested subcommand; \
545     add --output plain|json|yaml|markdown to choose the format.";
546
547/// Description for the `--help` flag on leaf commands (no subcommands).
548#[cfg(feature = "cli-help")]
549const HELP_FLAG_LEAF: &str =
550    "Print help. Add --output plain|json|yaml|markdown to choose the format.";
551
552#[cfg(feature = "cli-help")]
553fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
554    use std::fmt::Write;
555
556    let mut cmd_path = parent_path.to_vec();
557    cmd_path.push(if parent_path.is_empty() {
558        cmd.get_bin_name().unwrap_or(cmd.get_name())
559    } else {
560        cmd.get_name()
561    });
562    let path = cmd_path.join(" ");
563    let usage = compact_usage(cmd, &path);
564    if let Some(about) = cmd.get_about() {
565        let _ = writeln!(buf, "{usage} — {about}");
566    } else {
567        let _ = writeln!(buf, "{usage}");
568    }
569
570    let is_target = parent_path.is_empty();
571    let owned = if is_target {
572        Some(enriched_help_command(cmd))
573    } else {
574        None
575    };
576    let source = owned.as_ref().unwrap_or(cmd);
577    for arg in source.get_arguments().filter(|arg| !arg.is_hide_set()) {
578        let _ = writeln!(buf, "  {}", compact_plain_argument(arg));
579    }
580
581    for sub in visible_subcommands(cmd) {
582        if !buf.ends_with('\n') {
583            let _ = writeln!(buf);
584        }
585        render_help_recursive_plain(sub, &cmd_path, buf);
586    }
587}
588
589#[cfg(feature = "cli-help")]
590fn compact_usage(cmd: &clap::Command, path: &str) -> String {
591    let rendered = cmd.clone().render_usage().to_string();
592    let usage = rendered.strip_prefix("Usage: ").unwrap_or(&rendered);
593    let invocation = cmd.get_bin_name().unwrap_or(cmd.get_name());
594    usage.strip_prefix(invocation).map_or_else(
595        || format!("{path} {usage}"),
596        |suffix| format!("{path}{suffix}"),
597    )
598}
599
600#[cfg(feature = "cli-help")]
601fn compact_usage_suffix(cmd: &clap::Command) -> String {
602    let rendered = cmd.clone().render_usage().to_string();
603    let usage = rendered.strip_prefix("Usage: ").unwrap_or(&rendered);
604    usage
605        .strip_prefix(cmd.get_bin_name().unwrap_or(cmd.get_name()))
606        .unwrap_or(usage)
607        .trim()
608        .to_string()
609}
610
611#[cfg(feature = "cli-help")]
612fn compact_plain_argument(arg: &clap::Arg) -> String {
613    use std::fmt::Write;
614
615    let mut rendered = String::new();
616    if let Some(short) = arg.get_short() {
617        let _ = write!(rendered, "-{short}");
618        if arg.get_long().is_some() {
619            rendered.push_str(", ");
620        }
621    }
622    if let Some(long) = arg.get_long() {
623        let _ = write!(rendered, "--{long}");
624    } else if arg.get_short().is_none() {
625        rendered.push_str(
626            &arg.get_value_names()
627                .and_then(|names| names.first())
628                .map_or_else(|| arg.get_id().to_string(), ToString::to_string),
629        );
630    }
631    if argument_takes_values(arg)
632        && let Some(names) = arg.get_value_names()
633        && (arg.get_long().is_some() || arg.get_short().is_some())
634    {
635        for name in names {
636            let _ = write!(rendered, " <{name}>");
637        }
638    }
639    if matches!(
640        arg.get_action(),
641        clap::ArgAction::Append | clap::ArgAction::Count
642    ) {
643        rendered.push_str("...");
644    }
645    let defaults = redacted_default_values(arg);
646    if !defaults.is_empty() {
647        let _ = write!(rendered, " [default={}]", defaults.join(","));
648    }
649    if let Some(help) = arg.get_help() {
650        let _ = write!(rendered, ": {help}");
651    }
652    rendered
653}
654
655#[cfg(feature = "cli-help")]
656fn render_help_markdown(cmd: &clap::Command, names: &[String], scope: HelpScope) -> String {
657    let mut buf = String::new();
658    render_markdown_command(cmd, names, &mut buf, 1, true);
659    if matches!(scope, HelpScope::Recursive) {
660        render_markdown_descendants(cmd, names, &mut buf, 2);
661    }
662    buf
663}
664
665#[cfg(feature = "cli-help")]
666fn render_markdown_descendants(
667    cmd: &clap::Command,
668    parent_names: &[String],
669    buf: &mut String,
670    level: usize,
671) {
672    for sub in cmd.get_subcommands() {
673        if sub.get_name() == "help" || sub.is_hide_set() {
674            continue;
675        }
676        let mut names = parent_names.to_vec();
677        names.push(sub.get_name().to_string());
678        render_markdown_command(sub, &names, buf, level, false);
679        render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
680    }
681}
682
683#[cfg(feature = "cli-help")]
684fn render_markdown_command(
685    cmd: &clap::Command,
686    names: &[String],
687    buf: &mut String,
688    level: usize,
689    enrich: bool,
690) {
691    use std::fmt::Write;
692
693    if !buf.is_empty() {
694        let _ = writeln!(buf);
695    }
696    let heading_level = "#".repeat(level.max(1));
697    // The joined path is the invocable command, not the (possibly branded)
698    // `Command::name` — a reader must be able to copy the heading and the usage
699    // line straight into a shell.
700    let path = names.join(" ");
701    if let Some(about) = cmd.get_about() {
702        let _ = writeln!(buf, "{heading_level} {path} - {about}");
703    } else {
704        let _ = writeln!(buf, "{heading_level} {path}");
705    }
706    // Long-form prose is the one thing Markdown keeps that the compact model
707    // drops: this is the documentation export, so interface policy and examples
708    // belong here. The *surface* below still comes from the shared model.
709    if let Some(long_about) = markdown_long_about(cmd) {
710        let _ = writeln!(buf);
711        write_trimmed_help(buf, &long_about);
712    }
713
714    let usage_suffix = compact_usage_suffix(cmd);
715    let _ = writeln!(buf);
716    if usage_suffix.is_empty() {
717        let _ = writeln!(buf, "```text\n{path}\n```");
718    } else {
719        let _ = writeln!(buf, "```text\n{path} {usage_suffix}\n```");
720    }
721
722    // Arguments come from the same `command_arguments_schema` the JSON model
723    // uses, so `--output markdown` cannot disagree with `--output json` about
724    // what the command accepts. Rendering through a parser's own help writer is
725    // what previously reintroduced flags this CLI does not accept.
726    let arguments = command_arguments_schema(cmd, enrich);
727    if !arguments.is_empty() {
728        let _ = writeln!(buf);
729        let _ = writeln!(buf, "| Argument | Description |");
730        let _ = writeln!(buf, "| --- | --- |");
731        for argument in &arguments {
732            let _ = writeln!(
733                buf,
734                "| {} | {} |",
735                markdown_argument_signature(argument),
736                markdown_argument_description(argument)
737            );
738        }
739    }
740
741    let subcommands: Vec<&clap::Command> = visible_subcommands(cmd).collect();
742    if !subcommands.is_empty() {
743        let _ = writeln!(buf);
744        let _ = writeln!(buf, "| Command | Summary |");
745        let _ = writeln!(buf, "| --- | --- |");
746        for sub in subcommands {
747            let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
748            let _ = writeln!(
749                buf,
750                "| `{} {}` | {} |",
751                path,
752                sub.get_name(),
753                markdown_cell(&about)
754            );
755        }
756    }
757}
758
759/// Render one argument's invocation form from the shared model entry.
760#[cfg(feature = "cli-help")]
761fn markdown_argument_signature(argument: &Value) -> String {
762    let name = argument["name"].as_str().unwrap_or_default();
763    let mut rendered = String::from(name);
764    if let Some(short) = argument.get("short").and_then(Value::as_str) {
765        rendered.push_str(", ");
766        rendered.push_str(short);
767    }
768    if let Some(value) = argument.get("value").and_then(Value::as_str) {
769        rendered.push_str(&format!(" <{value}>"));
770    } else if let Some(values) = argument.get("values").and_then(Value::as_array) {
771        // A positional carries its own placeholder in `name`; a repeated one
772        // lists every placeholder, the first of which is that name.
773        let placeholders: Vec<String> = values
774            .iter()
775            .filter_map(Value::as_str)
776            .filter(|value| *value != name)
777            .map(|value| format!(" <{value}>"))
778            .collect();
779        rendered.push_str(&placeholders.join(""));
780    }
781    if argument.get("repeatable").and_then(Value::as_bool) == Some(true) {
782        rendered.push_str("...");
783    }
784    format!("`{}`", markdown_cell(&rendered))
785}
786
787/// Render one argument's help text plus the metadata the model carries.
788#[cfg(feature = "cli-help")]
789fn markdown_argument_description(argument: &Value) -> String {
790    let mut parts = Vec::new();
791    if let Some(help) = argument.get("help").and_then(Value::as_str) {
792        parts.push(markdown_cell(help));
793    }
794    let mut notes = Vec::new();
795    if argument.get("required").and_then(Value::as_bool) == Some(true) {
796        notes.push("required".to_string());
797    }
798    if argument.get("global").and_then(Value::as_bool) == Some(true) {
799        notes.push("global".to_string());
800    }
801    if let Some(default) = argument.get("default").and_then(Value::as_str) {
802        notes.push(format!("default: `{}`", markdown_cell(default)));
803    } else if let Some(defaults) = argument.get("defaults").and_then(Value::as_array) {
804        let joined: Vec<String> = defaults
805            .iter()
806            .filter_map(Value::as_str)
807            .map(|value| format!("`{}`", markdown_cell(value)))
808            .collect();
809        if !joined.is_empty() {
810            notes.push(format!("defaults: {}", joined.join(", ")));
811        }
812    }
813    if !notes.is_empty() {
814        parts.push(format!("*({})*", notes.join(", ")));
815    }
816    if parts.is_empty() {
817        String::new()
818    } else {
819        parts.join(" ")
820    }
821}
822
823/// Escape the two characters that would break out of a Markdown table cell.
824#[cfg(feature = "cli-help")]
825fn markdown_cell(text: &str) -> String {
826    text.replace('|', "\\|").replace(['\n', '\r'], " ")
827}
828
829#[cfg(feature = "cli-help")]
830fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
831    let long_about = cmd.get_long_about()?.to_string();
832    let rendered = match cmd.get_about() {
833        Some(about) => {
834            let about_str = about.to_string();
835            if long_about.trim() == format!("{} - {}", cmd.get_name(), about_str) {
836                return None;
837            }
838            strip_leading_about_paragraph(&long_about, &about_str)
839        }
840        None => long_about.as_str(),
841    };
842    let rendered = rendered.trim_matches(['\r', '\n']);
843    if rendered.is_empty() {
844        None
845    } else {
846        Some(rendered.to_string())
847    }
848}
849
850#[cfg(feature = "cli-help")]
851fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
852    let long_about = long_about.trim_start_matches(['\r', '\n']);
853    let Some(rest) = long_about.strip_prefix(about) else {
854        return long_about;
855    };
856    if rest.is_empty() {
857        return "";
858    }
859    rest.strip_prefix("\r\n\r\n")
860        .or_else(|| rest.strip_prefix("\n\n"))
861        .unwrap_or(long_about)
862}
863
864#[cfg(feature = "cli-help")]
865fn write_trimmed_help(buf: &mut String, help: &str) {
866    use std::fmt::Write;
867
868    for line in help.lines() {
869        let _ = writeln!(buf, "{}", line.trim_end());
870    }
871}
872
873#[cfg(feature = "cli-help")]
874struct ParsedHelpRequest {
875    help_requested: bool,
876    recursive_requested: bool,
877    output_format: Option<HelpFormat>,
878    output_error: Option<String>,
879    subcommand_path: Vec<String>,
880}
881
882#[cfg(feature = "cli-help")]
883fn parse_help_request(raw_args: &[String], cmd: &clap::Command) -> ParsedHelpRequest {
884    let args = crate::cli::strip_argv0(raw_args);
885    let mut help_requested = false;
886    let mut recursive_requested = false;
887    let mut output_format = None;
888    let mut output_error = None;
889    let mut subcommand_path = Vec::new();
890    let mut current = cmd;
891
892    let mut i = 0usize;
893    while i < args.len() {
894        let arg = args[i].as_str();
895        if arg == "--" {
896            break;
897        }
898
899        let (flag_name, inline_value) = split_flag(arg);
900        // Long form only. `-h` and `--help` would be byte-identical aliases, so
901        // the short buys nothing for an agent and misleads a human (it answers in
902        // the command's output format, which for an agent-first CLI is JSON, not
903        // text). Leaving the short unclaimed also keeps it available to apps that
904        // spend `-h` on `--host`, and removes a whole class of collision.
905        if arg == "--help" {
906            help_requested = true;
907            i += 1;
908            continue;
909        }
910        // `--recursive` is a help *modifier*, not a help trigger: it only
911        // selects recursive scope when `--help` is also present. A bare
912        // `--recursive` leaves help_requested false so the full argv falls
913        // through to the application's own parser untouched.
914        if arg == "--recursive" {
915            recursive_requested = true;
916            i += 1;
917            continue;
918        }
919        if flag_name == Some("output") {
920            let value = inline_value.or_else(|| {
921                args.get(i + 1)
922                    .map(String::as_str)
923                    .filter(|next| !next.starts_with('-'))
924            });
925            if let Some(value) = value {
926                match HelpFormat::parse(value) {
927                    Some(format) => set_help_output_format(
928                        &mut output_format,
929                        format,
930                        &format!("--output {value}"),
931                        &mut output_error,
932                    ),
933                    None => {
934                        output_error = Some(format!(
935                            "invalid --output format '{value}': expected plain, json, yaml, or markdown"
936                        ));
937                    }
938                }
939            } else {
940                output_error = Some(
941                    "missing value for --output: expected plain, json, yaml, or markdown"
942                        .to_string(),
943                );
944            }
945            i += if inline_value.is_some() || value.is_none() {
946                1
947            } else {
948                2
949            };
950            continue;
951        }
952        if arg.starts_with('-') {
953            i += if inline_value.is_none() && flag_takes_value(current, arg) {
954                2
955            } else {
956                1
957            };
958            continue;
959        }
960        if let Some(sub) = current.find_subcommand(arg)
961            && sub.get_name() != "help"
962            && !sub.is_hide_set()
963        {
964            subcommand_path.push(sub.get_name().to_string());
965            current = sub;
966        }
967        i += 1;
968    }
969
970    ParsedHelpRequest {
971        help_requested,
972        recursive_requested,
973        output_format,
974        output_error,
975        subcommand_path,
976    }
977}
978
979#[cfg(feature = "cli-help")]
980fn set_help_output_format(
981    current: &mut Option<HelpFormat>,
982    next: HelpFormat,
983    source: &str,
984    output_error: &mut Option<String>,
985) {
986    if let Some(existing) = current
987        && *existing != next
988    {
989        *output_error = Some(format!(
990            "conflicting output formats: {source} conflicts with previous output format"
991        ));
992        return;
993    }
994    *current = Some(next);
995}
996
997fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
998    if let Some(stripped) = arg.strip_prefix("--") {
999        if let Some((name, value)) = stripped.split_once('=') {
1000            (Some(name), Some(value))
1001        } else {
1002            (Some(stripped), None)
1003        }
1004    } else if let Some(stripped) = arg.strip_prefix('-') {
1005        (Some(stripped), None)
1006    } else {
1007        (None, None)
1008    }
1009}
1010
1011#[cfg(feature = "cli-help")]
1012fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1013    let Some(flag) = raw_flag.strip_prefix('-') else {
1014        return false;
1015    };
1016    let name = flag.trim_start_matches('-');
1017    cmd.get_arguments().any(|arg| {
1018        let long_matches = arg.get_long().is_some_and(|long| long == name);
1019        let short_matches =
1020            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1021        (long_matches || short_matches)
1022            && matches!(
1023                arg.get_action(),
1024                clap::ArgAction::Set | clap::ArgAction::Append
1025            )
1026    })
1027}
1028
1029#[cfg(feature = "cli-help")]
1030fn build_selected_help_schema(selected: &ScopedHelpCommand, scope: HelpScope) -> Value {
1031    let mut schema = command_schema(
1032        &selected.local_command,
1033        matches!(scope, HelpScope::Recursive),
1034        true,
1035    );
1036    if let Value::Object(map) = &mut schema {
1037        map.insert(
1038            "scope".to_string(),
1039            Value::String(help_scope_tag(scope).to_string()),
1040        );
1041        map.insert(
1042            "command_path".to_string(),
1043            Value::String(selected.command_path.clone()),
1044        );
1045        if !selected.inherited_arguments_from.is_empty() {
1046            map.insert(
1047                "inherited_arguments_from".to_string(),
1048                Value::Array(
1049                    selected
1050                        .inherited_arguments_from
1051                        .iter()
1052                        .cloned()
1053                        .map(Value::String)
1054                        .collect(),
1055                ),
1056            );
1057        }
1058    }
1059    schema
1060}
1061
1062#[cfg(feature = "cli-help")]
1063fn help_scope_tag(scope: HelpScope) -> &'static str {
1064    match scope {
1065        HelpScope::OneLevel => "one_level",
1066        HelpScope::Recursive => "recursive",
1067    }
1068}
1069
1070#[cfg(feature = "cli-help")]
1071fn command_schema(cmd: &clap::Command, recursive: bool, enrich: bool) -> Value {
1072    let subcommands: Vec<Value> = visible_subcommands(cmd)
1073        .map(|sub| {
1074            if recursive {
1075                // Descendants never re-advertise the help modifiers (enrich=false).
1076                command_schema(sub, true, false)
1077            } else {
1078                command_summary_schema(sub)
1079            }
1080        })
1081        .collect();
1082
1083    let mut schema = serde_json::Map::new();
1084    schema.insert(
1085        "name".to_string(),
1086        Value::String(cmd.get_name().to_string()),
1087    );
1088    insert_non_empty(
1089        &mut schema,
1090        "about",
1091        cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1092    );
1093    let usage = compact_usage_suffix(cmd);
1094    if !usage.is_empty() {
1095        schema.insert("usage".to_string(), Value::String(usage));
1096    }
1097    let arguments = command_arguments_schema(cmd, enrich);
1098    if !arguments.is_empty() {
1099        schema.insert("arguments".to_string(), Value::Array(arguments));
1100    }
1101    if !subcommands.is_empty() {
1102        schema.insert("subcommands".to_string(), Value::Array(subcommands));
1103    }
1104    Value::Object(schema)
1105}
1106
1107#[cfg(feature = "cli-help")]
1108fn command_summary_schema(cmd: &clap::Command) -> Value {
1109    let mut schema = serde_json::Map::new();
1110    schema.insert(
1111        "name".to_string(),
1112        Value::String(cmd.get_name().to_string()),
1113    );
1114    insert_non_empty(
1115        &mut schema,
1116        "about",
1117        cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1118    );
1119    Value::Object(schema)
1120}
1121
1122#[cfg(feature = "cli-help")]
1123fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1124    cmd.get_subcommands()
1125        .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1126}
1127
1128#[cfg(feature = "cli-help")]
1129fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1130    // For the target command, render through the enriched clone so the schema
1131    // documents the `-h, --help` modifiers (`--recursive`, `--output`) just like
1132    // the plain and markdown formats do (clap adds `--help` lazily during build,
1133    // so the raw command would omit it). Descendants stay un-enriched to avoid
1134    // repeating the same modifier doc on every command in a recursive dump.
1135    let owned = enrich.then(|| enriched_help_command(cmd));
1136    let source = owned.as_ref().unwrap_or(cmd);
1137    source
1138        .get_arguments()
1139        .filter(|arg| !arg.is_hide_set())
1140        .map(argument_schema)
1141        .collect()
1142}
1143
1144/// Insert `key` only when `value` is a non-empty string.
1145///
1146/// `cli-help-v1.schema.json` sets `minLength: 1` on every optional string, and
1147/// the spec requires omitting empty values. Clap happily returns `Some("")` for
1148/// an explicit `about("")` / `help("")` / `value_name("")`, so every optional
1149/// string goes through this gate rather than being inserted on `Some(..)` alone.
1150#[cfg(feature = "cli-help")]
1151fn insert_non_empty(schema: &mut serde_json::Map<String, Value>, key: &str, value: String) {
1152    if !value.is_empty() {
1153        schema.insert(key.to_string(), Value::String(value));
1154    }
1155}
1156
1157#[cfg(feature = "cli-help")]
1158fn argument_schema(arg: &clap::Arg) -> Value {
1159    let mut schema = serde_json::Map::new();
1160    if let Some(long) = arg.get_long() {
1161        schema.insert("name".to_string(), Value::String(format!("--{long}")));
1162    } else if let Some(short) = arg.get_short() {
1163        schema.insert("name".to_string(), Value::String(format!("-{short}")));
1164    } else {
1165        schema.insert(
1166            "name".to_string(),
1167            Value::String(
1168                arg.get_value_names()
1169                    .and_then(|names| names.first())
1170                    .map_or_else(|| arg.get_id().to_string(), ToString::to_string),
1171            ),
1172        );
1173    }
1174    if arg.get_long().is_some()
1175        && let Some(short) = arg.get_short()
1176    {
1177        schema.insert("short".to_string(), Value::String(format!("-{short}")));
1178    }
1179    insert_non_empty(
1180        &mut schema,
1181        "help",
1182        arg.get_help().map(ToString::to_string).unwrap_or_default(),
1183    );
1184    if arg.is_required_set() {
1185        schema.insert("required".to_string(), Value::Bool(true));
1186    }
1187    if arg.is_global_set() {
1188        schema.insert("global".to_string(), Value::Bool(true));
1189    }
1190    if matches!(
1191        arg.get_action(),
1192        clap::ArgAction::Append | clap::ArgAction::Count
1193    ) {
1194        schema.insert("repeatable".to_string(), Value::Bool(true));
1195    }
1196    // A positional's `name` already carries its (single) placeholder, so it says
1197    // nothing more. A positional that takes several values has several
1198    // placeholders, though, and dropping all but the first would leave the model
1199    // disagreeing with its own `usage` — so it lists every one.
1200    if arg.get_long().is_none()
1201        && arg.get_short().is_none()
1202        && let Some(names) = arg.get_value_names()
1203        && names.len() > 1
1204    {
1205        schema.insert(
1206            "values".to_string(),
1207            Value::Array(
1208                names
1209                    .iter()
1210                    .map(ToString::to_string)
1211                    .filter(|name| !name.is_empty())
1212                    .map(Value::String)
1213                    .collect(),
1214            ),
1215        );
1216    }
1217    if (arg.get_long().is_some() || arg.get_short().is_some())
1218        && argument_takes_values(arg)
1219        && let Some(names) = arg.get_value_names()
1220    {
1221        if names.len() == 1 {
1222            insert_non_empty(&mut schema, "value", names[0].to_string());
1223        } else {
1224            let values: Vec<Value> = names
1225                .iter()
1226                .map(ToString::to_string)
1227                .filter(|name| !name.is_empty())
1228                .map(Value::String)
1229                .collect();
1230            if !values.is_empty() {
1231                schema.insert("values".to_string(), Value::Array(values));
1232            }
1233        }
1234    }
1235    let defaults = redacted_default_values(arg);
1236    if !defaults.is_empty() {
1237        if defaults.len() == 1 {
1238            schema.insert("default".to_string(), Value::String(defaults[0].clone()));
1239        } else {
1240            schema.insert(
1241                "defaults".to_string(),
1242                Value::Array(defaults.into_iter().map(Value::String).collect()),
1243            );
1244        }
1245    }
1246    Value::Object(schema)
1247}
1248
1249#[cfg(feature = "cli-help")]
1250fn argument_takes_values(arg: &clap::Arg) -> bool {
1251    matches!(
1252        arg.get_action(),
1253        clap::ArgAction::Set | clap::ArgAction::Append
1254    )
1255}
1256
1257#[cfg(feature = "cli-help")]
1258fn redacted_default_values(arg: &clap::Arg) -> Vec<String> {
1259    // An author who called `hide_default_value(true)` hid it from clap's own
1260    // help; the structured model must not disclose what plain help withholds.
1261    if arg.is_hide_default_value_set() {
1262        return Vec::new();
1263    }
1264    arg.get_default_values()
1265        .iter()
1266        .map(|value| {
1267            if help_arg_is_secret(arg, &RedactionContext::default()) {
1268                "***".to_string()
1269            } else {
1270                value.to_string_lossy().to_string()
1271            }
1272        })
1273        .collect()
1274}