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