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    // The canonical discovery surface is `--help`; never let clap synthesize
505    // its legacy `help` pseudo-command while rendering an isolated subtree.
506    let cmd = redact_secret_help_defaults(cmd.clone()).disable_help_subcommand(true);
507    let description = if visible_subcommands(&cmd).next().is_some() {
508        HELP_FLAG_WITH_SUBCOMMANDS
509    } else {
510        HELP_FLAG_LEAF
511    };
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
699    // fenced `Usage:` 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    if let Some(long_about) = markdown_long_about(cmd) {
707        let _ = writeln!(buf);
708        write_trimmed_help(buf, &long_about);
709    }
710    let _ = writeln!(buf);
711    let _ = writeln!(buf, "```text");
712    let help = markdown_help_block_command(cmd, enrich)
713        .bin_name(path.clone())
714        .render_long_help();
715    write_trimmed_help(buf, &help.to_string());
716    if !buf.ends_with('\n') {
717        let _ = writeln!(buf);
718    }
719    let _ = writeln!(buf, "```");
720}
721
722#[cfg(feature = "cli-help")]
723fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
724    let long_about = cmd.get_long_about()?.to_string();
725    let rendered = match cmd.get_about() {
726        Some(about) => {
727            let about_str = about.to_string();
728            if long_about.trim() == format!("{} - {}", cmd.get_name(), about_str) {
729                return None;
730            }
731            strip_leading_about_paragraph(&long_about, &about_str)
732        }
733        None => long_about.as_str(),
734    };
735    let rendered = rendered.trim_matches(['\r', '\n']);
736    if rendered.is_empty() {
737        None
738    } else {
739        Some(rendered.to_string())
740    }
741}
742
743#[cfg(feature = "cli-help")]
744fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
745    let long_about = long_about.trim_start_matches(['\r', '\n']);
746    let Some(rest) = long_about.strip_prefix(about) else {
747        return long_about;
748    };
749    if rest.is_empty() {
750        return "";
751    }
752    rest.strip_prefix("\r\n\r\n")
753        .or_else(|| rest.strip_prefix("\n\n"))
754        .unwrap_or(long_about)
755}
756
757#[cfg(feature = "cli-help")]
758fn markdown_help_block_command(cmd: &clap::Command, enrich: bool) -> clap::Command {
759    let cmd = if enrich {
760        enriched_help_command(cmd)
761    } else {
762        redact_secret_help_defaults(cmd.clone()).disable_help_subcommand(true)
763    };
764    cmd.about(None::<&str>).long_about(None::<&str>)
765}
766
767#[cfg(feature = "cli-help")]
768fn write_trimmed_help(buf: &mut String, help: &str) {
769    use std::fmt::Write;
770
771    for line in help.lines() {
772        let _ = writeln!(buf, "{}", line.trim_end());
773    }
774}
775
776#[cfg(feature = "cli-help")]
777struct ParsedHelpRequest {
778    help_requested: bool,
779    recursive_requested: bool,
780    output_format: Option<HelpFormat>,
781    output_error: Option<String>,
782    subcommand_path: Vec<String>,
783}
784
785#[cfg(feature = "cli-help")]
786fn parse_help_request(raw_args: &[String], cmd: &clap::Command) -> ParsedHelpRequest {
787    let args = crate::cli::strip_argv0(raw_args);
788    let mut help_requested = false;
789    let mut recursive_requested = false;
790    let mut output_format = None;
791    let mut output_error = None;
792    let mut subcommand_path = Vec::new();
793    let mut current = cmd;
794
795    let mut i = 0usize;
796    while i < args.len() {
797        let arg = args[i].as_str();
798        if arg == "--" {
799            break;
800        }
801
802        let (flag_name, inline_value) = split_flag(arg);
803        // Long form only. `-h` and `--help` would be byte-identical aliases, so
804        // the short buys nothing for an agent and misleads a human (it answers in
805        // the command's output format, which for an agent-first CLI is JSON, not
806        // text). Leaving the short unclaimed also keeps it available to apps that
807        // spend `-h` on `--host`, and removes a whole class of collision.
808        if arg == "--help" {
809            help_requested = true;
810            i += 1;
811            continue;
812        }
813        // `--recursive` is a help *modifier*, not a help trigger: it only
814        // selects recursive scope when `--help` is also present. A bare
815        // `--recursive` leaves help_requested false so the full argv falls
816        // through to the application's own parser untouched.
817        if arg == "--recursive" {
818            recursive_requested = true;
819            i += 1;
820            continue;
821        }
822        if flag_name == Some("output") {
823            let value = inline_value.or_else(|| {
824                args.get(i + 1)
825                    .map(String::as_str)
826                    .filter(|next| !next.starts_with('-'))
827            });
828            if let Some(value) = value {
829                match HelpFormat::parse(value) {
830                    Some(format) => set_help_output_format(
831                        &mut output_format,
832                        format,
833                        &format!("--output {value}"),
834                        &mut output_error,
835                    ),
836                    None => {
837                        output_error = Some(format!(
838                            "invalid --output format '{value}': expected plain, json, yaml, or markdown"
839                        ));
840                    }
841                }
842            } else {
843                output_error = Some(
844                    "missing value for --output: expected plain, json, yaml, or markdown"
845                        .to_string(),
846                );
847            }
848            i += if inline_value.is_some() || value.is_none() {
849                1
850            } else {
851                2
852            };
853            continue;
854        }
855        if arg.starts_with('-') {
856            i += if inline_value.is_none() && flag_takes_value(current, arg) {
857                2
858            } else {
859                1
860            };
861            continue;
862        }
863        if let Some(sub) = current.find_subcommand(arg)
864            && sub.get_name() != "help"
865            && !sub.is_hide_set()
866        {
867            subcommand_path.push(sub.get_name().to_string());
868            current = sub;
869        }
870        i += 1;
871    }
872
873    ParsedHelpRequest {
874        help_requested,
875        recursive_requested,
876        output_format,
877        output_error,
878        subcommand_path,
879    }
880}
881
882#[cfg(feature = "cli-help")]
883fn set_help_output_format(
884    current: &mut Option<HelpFormat>,
885    next: HelpFormat,
886    source: &str,
887    output_error: &mut Option<String>,
888) {
889    if let Some(existing) = current
890        && *existing != next
891    {
892        *output_error = Some(format!(
893            "conflicting output formats: {source} conflicts with previous output format"
894        ));
895        return;
896    }
897    *current = Some(next);
898}
899
900fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
901    if let Some(stripped) = arg.strip_prefix("--") {
902        if let Some((name, value)) = stripped.split_once('=') {
903            (Some(name), Some(value))
904        } else {
905            (Some(stripped), None)
906        }
907    } else if let Some(stripped) = arg.strip_prefix('-') {
908        (Some(stripped), None)
909    } else {
910        (None, None)
911    }
912}
913
914#[cfg(feature = "cli-help")]
915fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
916    let Some(flag) = raw_flag.strip_prefix('-') else {
917        return false;
918    };
919    let name = flag.trim_start_matches('-');
920    cmd.get_arguments().any(|arg| {
921        let long_matches = arg.get_long().is_some_and(|long| long == name);
922        let short_matches =
923            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
924        (long_matches || short_matches)
925            && matches!(
926                arg.get_action(),
927                clap::ArgAction::Set | clap::ArgAction::Append
928            )
929    })
930}
931
932#[cfg(feature = "cli-help")]
933fn build_selected_help_schema(selected: &ScopedHelpCommand, scope: HelpScope) -> Value {
934    let mut schema = command_schema(
935        &selected.local_command,
936        matches!(scope, HelpScope::Recursive),
937        true,
938    );
939    if let Value::Object(map) = &mut schema {
940        map.insert(
941            "scope".to_string(),
942            Value::String(help_scope_tag(scope).to_string()),
943        );
944        map.insert(
945            "command_path".to_string(),
946            Value::String(selected.command_path.clone()),
947        );
948        if !selected.inherited_arguments_from.is_empty() {
949            map.insert(
950                "inherited_arguments_from".to_string(),
951                Value::Array(
952                    selected
953                        .inherited_arguments_from
954                        .iter()
955                        .cloned()
956                        .map(Value::String)
957                        .collect(),
958                ),
959            );
960        }
961    }
962    schema
963}
964
965#[cfg(feature = "cli-help")]
966fn help_scope_tag(scope: HelpScope) -> &'static str {
967    match scope {
968        HelpScope::OneLevel => "one_level",
969        HelpScope::Recursive => "recursive",
970    }
971}
972
973#[cfg(feature = "cli-help")]
974fn command_schema(cmd: &clap::Command, recursive: bool, enrich: bool) -> Value {
975    let subcommands: Vec<Value> = visible_subcommands(cmd)
976        .map(|sub| {
977            if recursive {
978                // Descendants never re-advertise the help modifiers (enrich=false).
979                command_schema(sub, true, false)
980            } else {
981                command_summary_schema(sub)
982            }
983        })
984        .collect();
985
986    let mut schema = serde_json::Map::new();
987    schema.insert(
988        "name".to_string(),
989        Value::String(cmd.get_name().to_string()),
990    );
991    insert_non_empty(
992        &mut schema,
993        "about",
994        cmd.get_about().map(ToString::to_string).unwrap_or_default(),
995    );
996    let usage = compact_usage_suffix(cmd);
997    if !usage.is_empty() {
998        schema.insert("usage".to_string(), Value::String(usage));
999    }
1000    let arguments = command_arguments_schema(cmd, enrich);
1001    if !arguments.is_empty() {
1002        schema.insert("arguments".to_string(), Value::Array(arguments));
1003    }
1004    if !subcommands.is_empty() {
1005        schema.insert("subcommands".to_string(), Value::Array(subcommands));
1006    }
1007    Value::Object(schema)
1008}
1009
1010#[cfg(feature = "cli-help")]
1011fn command_summary_schema(cmd: &clap::Command) -> Value {
1012    let mut schema = serde_json::Map::new();
1013    schema.insert(
1014        "name".to_string(),
1015        Value::String(cmd.get_name().to_string()),
1016    );
1017    insert_non_empty(
1018        &mut schema,
1019        "about",
1020        cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1021    );
1022    Value::Object(schema)
1023}
1024
1025#[cfg(feature = "cli-help")]
1026fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1027    cmd.get_subcommands()
1028        .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1029}
1030
1031#[cfg(feature = "cli-help")]
1032fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1033    // For the target command, render through the enriched clone so the schema
1034    // documents the `-h, --help` modifiers (`--recursive`, `--output`) just like
1035    // the plain and markdown formats do (clap adds `--help` lazily during build,
1036    // so the raw command would omit it). Descendants stay un-enriched to avoid
1037    // repeating the same modifier doc on every command in a recursive dump.
1038    let owned = enrich.then(|| enriched_help_command(cmd));
1039    let source = owned.as_ref().unwrap_or(cmd);
1040    source
1041        .get_arguments()
1042        .filter(|arg| !arg.is_hide_set())
1043        .map(argument_schema)
1044        .collect()
1045}
1046
1047/// Insert `key` only when `value` is a non-empty string.
1048///
1049/// `cli-help-v1.schema.json` sets `minLength: 1` on every optional string, and
1050/// the spec requires omitting empty values. Clap happily returns `Some("")` for
1051/// an explicit `about("")` / `help("")` / `value_name("")`, so every optional
1052/// string goes through this gate rather than being inserted on `Some(..)` alone.
1053#[cfg(feature = "cli-help")]
1054fn insert_non_empty(schema: &mut serde_json::Map<String, Value>, key: &str, value: String) {
1055    if !value.is_empty() {
1056        schema.insert(key.to_string(), Value::String(value));
1057    }
1058}
1059
1060#[cfg(feature = "cli-help")]
1061fn argument_schema(arg: &clap::Arg) -> Value {
1062    let mut schema = serde_json::Map::new();
1063    if let Some(long) = arg.get_long() {
1064        schema.insert("name".to_string(), Value::String(format!("--{long}")));
1065    } else if let Some(short) = arg.get_short() {
1066        schema.insert("name".to_string(), Value::String(format!("-{short}")));
1067    } else {
1068        schema.insert(
1069            "name".to_string(),
1070            Value::String(
1071                arg.get_value_names()
1072                    .and_then(|names| names.first())
1073                    .map_or_else(|| arg.get_id().to_string(), ToString::to_string),
1074            ),
1075        );
1076    }
1077    if arg.get_long().is_some()
1078        && let Some(short) = arg.get_short()
1079    {
1080        schema.insert("short".to_string(), Value::String(format!("-{short}")));
1081    }
1082    insert_non_empty(
1083        &mut schema,
1084        "help",
1085        arg.get_help().map(ToString::to_string).unwrap_or_default(),
1086    );
1087    if arg.is_required_set() {
1088        schema.insert("required".to_string(), Value::Bool(true));
1089    }
1090    if arg.is_global_set() {
1091        schema.insert("global".to_string(), Value::Bool(true));
1092    }
1093    if matches!(
1094        arg.get_action(),
1095        clap::ArgAction::Append | clap::ArgAction::Count
1096    ) {
1097        schema.insert("repeatable".to_string(), Value::Bool(true));
1098    }
1099    // A positional's `name` already carries its (single) placeholder, so it says
1100    // nothing more. A positional that takes several values has several
1101    // placeholders, though, and dropping all but the first would leave the model
1102    // disagreeing with its own `usage` — so it lists every one.
1103    if arg.get_long().is_none()
1104        && arg.get_short().is_none()
1105        && let Some(names) = arg.get_value_names()
1106        && names.len() > 1
1107    {
1108        schema.insert(
1109            "values".to_string(),
1110            Value::Array(
1111                names
1112                    .iter()
1113                    .map(ToString::to_string)
1114                    .filter(|name| !name.is_empty())
1115                    .map(Value::String)
1116                    .collect(),
1117            ),
1118        );
1119    }
1120    if (arg.get_long().is_some() || arg.get_short().is_some())
1121        && argument_takes_values(arg)
1122        && let Some(names) = arg.get_value_names()
1123    {
1124        if names.len() == 1 {
1125            insert_non_empty(&mut schema, "value", names[0].to_string());
1126        } else {
1127            let values: Vec<Value> = names
1128                .iter()
1129                .map(ToString::to_string)
1130                .filter(|name| !name.is_empty())
1131                .map(Value::String)
1132                .collect();
1133            if !values.is_empty() {
1134                schema.insert("values".to_string(), Value::Array(values));
1135            }
1136        }
1137    }
1138    let defaults = redacted_default_values(arg);
1139    if !defaults.is_empty() {
1140        if defaults.len() == 1 {
1141            schema.insert("default".to_string(), Value::String(defaults[0].clone()));
1142        } else {
1143            schema.insert(
1144                "defaults".to_string(),
1145                Value::Array(defaults.into_iter().map(Value::String).collect()),
1146            );
1147        }
1148    }
1149    Value::Object(schema)
1150}
1151
1152#[cfg(feature = "cli-help")]
1153fn argument_takes_values(arg: &clap::Arg) -> bool {
1154    matches!(
1155        arg.get_action(),
1156        clap::ArgAction::Set | clap::ArgAction::Append
1157    )
1158}
1159
1160#[cfg(feature = "cli-help")]
1161fn redacted_default_values(arg: &clap::Arg) -> Vec<String> {
1162    // An author who called `hide_default_value(true)` hid it from clap's own
1163    // help; the structured model must not disclose what plain help withholds.
1164    if arg.is_hide_default_value_set() {
1165        return Vec::new();
1166    }
1167    arg.get_default_values()
1168        .iter()
1169        .map(|value| {
1170            if help_arg_is_secret(arg, &RedactionContext::default()) {
1171                "***".to_string()
1172            } else {
1173                value.to_string_lossy().to_string()
1174            }
1175        })
1176        .collect()
1177}