@if (!allHidden)
{
@@ -95,13 +115,16 @@
else if (item is ILeafNavigationItem leaf)
{
var hasSameTopLevelGroup = true;
+ var (leafBadge, leafLabel) = ParseNavTitle(leaf.NavigationTitle);
}
diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs
new file mode 100644
index 0000000000..4438f1cccc
--- /dev/null
+++ b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs
@@ -0,0 +1,61 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Toc.CliReference;
+using Elastic.Markdown.Myst;
+using Markdig.Syntax;
+
+namespace Elastic.Markdown.Extensions.CliReference;
+
+public record CliCommandFile : IO.MarkdownFile
+{
+ private readonly CliCommandSchema _command;
+ private readonly IFileInfo? _supplementalDoc;
+ private readonly string? _binaryName;
+
+ private readonly string[] _fullPath;
+
+ public CliCommandFile(
+ IFileInfo sourceFile,
+ IDirectoryInfo rootPath,
+ MarkdownParser parser,
+ BuildContext build,
+ CliCommandSchema command,
+ IFileInfo? supplementalDoc,
+ string[]? fullPath = null,
+ string? binaryName = null
+ ) : base(sourceFile, rootPath, parser, build)
+ {
+ _command = command;
+ _supplementalDoc = supplementalDoc;
+ _fullPath = fullPath ?? [command.Name];
+ _binaryName = binaryName;
+ Title = command.Name;
+ }
+
+ public override string NavigationTitle => $"[cmd]{_command.Name}";
+
+ protected override Task GetMinimalParseDocumentAsync(Cancel ctx)
+ {
+ Title = _command.Name;
+ var markdown = BuildMarkdown();
+ return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null));
+ }
+
+ protected override Task GetParseDocumentAsync(Cancel ctx)
+ {
+ var markdown = BuildMarkdown();
+ return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null));
+ }
+
+ private string BuildMarkdown()
+ {
+ var supplemental = _supplementalDoc?.Exists == true
+ ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName)
+ : null;
+ return CliMarkdownGenerator.CommandPage(_command, supplemental, _fullPath, _binaryName);
+ }
+}
diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs
new file mode 100644
index 0000000000..6e3b189041
--- /dev/null
+++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs
@@ -0,0 +1,661 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.Text;
+using System.Text.RegularExpressions;
+using Elastic.Documentation.Configuration.Toc.CliReference;
+
+namespace Elastic.Markdown.Extensions.CliReference;
+
+internal static partial class CliMarkdownGenerator
+{
+ public static string RootPage(CliSchema schema, string? supplementalContent)
+ {
+ var sb = new StringBuilder();
+ _ = sb.AppendLine($"# {schema.Name}");
+ _ = sb.AppendLine();
+
+ if (supplementalContent is not null)
+ _ = sb.AppendLine(supplementalContent.Trim());
+ else if (!string.IsNullOrWhiteSpace(schema.Description))
+ _ = sb.AppendLine(schema.Description.Trim());
+
+ _ = sb.AppendLine();
+
+ if (schema.GlobalOptions.Count > 0)
+ {
+ _ = sb.AppendLine("## Global Options");
+ _ = sb.AppendLine();
+ AppendParameters(sb, schema.GlobalOptions);
+ }
+
+ var visibleCommands = schema.Commands.Where(c => !c.Hidden).ToList();
+ if (visibleCommands.Count > 0)
+ {
+ _ = sb.AppendLine("## Commands");
+ _ = sb.AppendLine();
+ foreach (var cmd in visibleCommands)
+ AppendPageCard(sb, cmd.Name, $"./{CommandPath(cmd.Name)}.md", cmd.Summary);
+ }
+
+ if (schema.Namespaces.Count > 0)
+ {
+ _ = sb.AppendLine("## Namespaces");
+ _ = sb.AppendLine();
+ foreach (var ns in schema.Namespaces)
+ AppendPageCard(sb, ns.Segment, $"./{ns.Segment}/index.md", ns.Summary);
+ }
+
+ if (schema.Environment?.Variables is { Count: > 0 } envVars)
+ {
+ _ = sb.AppendLine("## Environment Variables");
+ _ = sb.AppendLine();
+ foreach (var v in envVars)
+ {
+ var required = v.Required ? " **required**" : string.Empty;
+ _ = sb.AppendLine($"`{v.Name}`{required}");
+ _ = sb.AppendLine($": {v.Description?.Trim() ?? string.Empty}");
+ if (!string.IsNullOrWhiteSpace(v.DefaultValue))
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine($" **Default:** `{v.DefaultValue.Trim()}`");
+ }
+ _ = sb.AppendLine();
+ }
+ }
+
+ if (schema.Environment?.ConfigFiles is { Count: > 0 } configFiles)
+ {
+ _ = sb.AppendLine("## Configuration Files");
+ _ = sb.AppendLine();
+ foreach (var f in configFiles)
+ {
+ var required = f.Required ? " **required**" : string.Empty;
+ _ = sb.AppendLine($"`{f.Path}`{required}");
+ _ = sb.AppendLine($": {f.Description?.Trim() ?? string.Empty}");
+ _ = sb.AppendLine();
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ public static string NamespacePage(CliNamespaceSchema ns, string? supplementalContent, string[]? fullPath = null, string? binaryName = null)
+ {
+ var sb = new StringBuilder();
+ var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : ns.Segment;
+ _ = sb.AppendLine($"# {heading} cli namespace");
+ _ = sb.AppendLine();
+
+ // Usage codeblock: binary full-path --help
+ _ = sb.AppendLine("```bash");
+ _ = sb.AppendLine($"{binaryName ?? heading} {heading} --help");
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine();
+
+ if (supplementalContent is not null)
+ _ = sb.AppendLine(supplementalContent.Trim());
+ else if (!string.IsNullOrWhiteSpace(ns.Summary))
+ _ = sb.AppendLine(ns.Summary.Trim());
+
+ _ = sb.AppendLine();
+
+ if (ns.DefaultCommand is { Hidden: false } defaultCmd)
+ AppendDefaultCommand(sb, defaultCmd, fullPath, binaryName);
+
+ var visibleCmds = ns.Commands.Where(c => !c.Hidden).ToList();
+ if (visibleCmds.Count > 0)
+ {
+ _ = sb.AppendLine("## Commands");
+ _ = sb.AppendLine();
+ foreach (var cmd in visibleCmds)
+ AppendPageCard(sb, cmd.Name, $"./{CommandPath(cmd.Name)}.md", cmd.Summary);
+ }
+
+ if (ns.Namespaces.Count > 0)
+ {
+ _ = sb.AppendLine("## Sub-namespaces");
+ _ = sb.AppendLine();
+ foreach (var sub in ns.Namespaces)
+ AppendPageCard(sb, sub.Segment, $"./{sub.Segment}/index.md", sub.Summary);
+ }
+
+ if (ns.Options.Count > 0)
+ {
+ _ = sb.AppendLine("## Namespace Flags");
+ _ = sb.AppendLine();
+ AppendParameters(sb, ns.Options);
+ }
+
+ if (!string.IsNullOrWhiteSpace(ns.Notes))
+ {
+ _ = sb.AppendLine("## Notes");
+ _ = sb.AppendLine();
+ _ = sb.AppendLine(ns.Notes.Trim());
+ _ = sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ public static string CommandPage(CliCommandSchema cmd, string? supplementalContent, string[]? fullPath = null, string? binaryName = null)
+ {
+ var sb = new StringBuilder();
+ var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : cmd.Name;
+ _ = sb.AppendLine($"# {heading} cli command");
+ _ = sb.AppendLine();
+
+ var usage = !string.IsNullOrWhiteSpace(cmd.Usage)
+ ? cmd.Usage
+ : GenerateUsage(cmd, fullPath, binaryName);
+
+ _ = sb.AppendLine("```bash");
+ _ = sb.AppendLine(FormatUsage(usage));
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine();
+
+ AppendCommandCallouts(sb, cmd);
+
+ if (supplementalContent is not null)
+ _ = sb.AppendLine(supplementalContent.Trim());
+ else if (!string.IsNullOrWhiteSpace(cmd.Summary))
+ _ = sb.AppendLine(CleanSummary(cmd.Summary).description.Trim());
+
+ _ = sb.AppendLine();
+
+ if (cmd.Parameters.Count > 0)
+ {
+ var positionals = cmd.Parameters.Where(p => p.Role == "positional").ToList();
+ var flags = cmd.Parameters.Where(p => p.Role != "positional").ToList();
+
+ if (positionals.Count > 0)
+ {
+ _ = sb.AppendLine("## Arguments");
+ _ = sb.AppendLine();
+ AppendParameters(sb, positionals);
+ }
+
+ if (flags.Count > 0)
+ {
+ _ = sb.AppendLine("## Options");
+ _ = sb.AppendLine();
+ AppendParameters(sb, flags);
+ }
+ }
+
+ if (cmd.Examples is { Length: > 0 })
+ {
+ _ = sb.AppendLine("## Examples");
+ _ = sb.AppendLine();
+ foreach (var example in cmd.Examples)
+ {
+ if (string.IsNullOrWhiteSpace(example))
+ continue;
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine(example.Trim());
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine();
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(cmd.Notes))
+ {
+ _ = sb.AppendLine("## Notes");
+ _ = sb.AppendLine();
+ _ = sb.AppendLine(cmd.Notes.Trim());
+ _ = sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ private static void AppendCommandCallouts(StringBuilder sb, CliCommandSchema cmd)
+ {
+ if (cmd.Deprecated is not null)
+ {
+ var parts = new List { "**Deprecated**" };
+ if (!string.IsNullOrWhiteSpace(cmd.Deprecated.Since))
+ parts.Add($"since {cmd.Deprecated.Since}");
+ if (!string.IsNullOrWhiteSpace(cmd.Deprecated.Message))
+ parts.Add(cmd.Deprecated.Message.Trim().TrimEnd('.'));
+ if (!string.IsNullOrWhiteSpace(cmd.Deprecated.RemovedIn))
+ parts.Add($"Removed in: {cmd.Deprecated.RemovedIn}");
+ _ = sb.AppendLine(":::{warning}");
+ _ = sb.AppendLine(string.Join(". ", parts) + ".");
+ _ = sb.AppendLine(":::");
+ _ = sb.AppendLine();
+ }
+
+ if (cmd.Intent?.Destructive == true)
+ {
+ _ = sb.AppendLine(":::{warning}");
+ _ = sb.AppendLine("**Destructive operation** — changes made by this command cannot be undone.");
+ _ = sb.AppendLine(":::");
+ _ = sb.AppendLine();
+ }
+
+ var notes = new List();
+ if (cmd.LongRunning)
+ notes.Add("This command may take a long time to complete.");
+ if (cmd.Streaming)
+ notes.Add("This command streams output continuously until stopped.");
+ if (cmd.Intent?.RequiresAuth == true)
+ notes.Add("Authentication is required.");
+ if (cmd.Intent?.RequiresConfirmation == true)
+ notes.Add("This command prompts for confirmation before proceeding.");
+ if (!string.IsNullOrWhiteSpace(cmd.Intent?.Scope))
+ notes.Add($"Scope: `{cmd.Intent.Scope}`.");
+
+ foreach (var note in notes)
+ {
+ _ = sb.AppendLine($"> {note}");
+ _ = sb.AppendLine();
+ }
+
+ if (cmd.Output?.Formats is { Length: > 0 } formats)
+ {
+ _ = sb.AppendLine($"**Output formats:** {string.Join(", ", formats)}");
+ _ = sb.AppendLine();
+ }
+ }
+
+ private static void AppendDefaultCommand(StringBuilder sb, CliDefaultSchema defaultCmd, string[]? fullPath, string? binaryName)
+ {
+ _ = sb.AppendLine("## Running without a subcommand");
+ _ = sb.AppendLine();
+
+ if (!string.IsNullOrWhiteSpace(defaultCmd.Summary))
+ {
+ _ = sb.AppendLine(defaultCmd.Summary.Trim());
+ _ = sb.AppendLine();
+ }
+
+ var usageParts = new List();
+ if (!string.IsNullOrWhiteSpace(binaryName))
+ usageParts.Add(binaryName);
+ if (fullPath is { Length: > 0 })
+ usageParts.AddRange(fullPath);
+
+ var usageLine = !string.IsNullOrWhiteSpace(defaultCmd.Usage)
+ ? defaultCmd.Usage
+ : string.Join(" ", usageParts) + " [options]";
+
+ _ = sb.AppendLine("```bash");
+ _ = sb.AppendLine(FormatUsage(usageLine));
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine();
+
+ if (defaultCmd.Parameters.Count > 0)
+ {
+ var positionals = defaultCmd.Parameters.Where(p => p.Role == "positional").ToList();
+ var flags = defaultCmd.Parameters.Where(p => p.Role != "positional").ToList();
+
+ if (positionals.Count > 0)
+ {
+ _ = sb.AppendLine("### Arguments");
+ _ = sb.AppendLine();
+ AppendParameters(sb, positionals);
+ }
+
+ if (flags.Count > 0)
+ {
+ _ = sb.AppendLine("### Options");
+ _ = sb.AppendLine();
+ AppendParameters(sb, flags);
+ }
+ }
+
+ if (defaultCmd.Examples is { Length: > 0 })
+ {
+ _ = sb.AppendLine("### Examples");
+ _ = sb.AppendLine();
+ foreach (var example in defaultCmd.Examples)
+ {
+ if (string.IsNullOrWhiteSpace(example))
+ continue;
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine(example.Trim());
+ _ = sb.AppendLine("```");
+ _ = sb.AppendLine();
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(defaultCmd.Notes))
+ {
+ _ = sb.AppendLine("### Notes");
+ _ = sb.AppendLine();
+ _ = sb.AppendLine(defaultCmd.Notes.Trim());
+ _ = sb.AppendLine();
+ }
+ }
+
+ // Commands named "index" keep cmd- prefix to avoid collision with namespace index.md pages
+ private static string CommandPath(string name) =>
+ name.Equals("index", StringComparison.OrdinalIgnoreCase) ? $"cmd-{name}" : name;
+
+ private static void AppendPageCard(StringBuilder sb, string title, string url, string? summary)
+ {
+ var description = string.IsNullOrWhiteSpace(summary) ? string.Empty : CleanSummary(summary).description.Trim();
+ _ = sb.AppendLine(":::{page-card} [" + title + "](" + url + ")");
+ if (!string.IsNullOrEmpty(description))
+ _ = sb.AppendLine(description);
+ _ = sb.AppendLine(":::");
+ _ = sb.AppendLine();
+ }
+
+ private static void AppendParameters(StringBuilder sb, IEnumerable parameters)
+ {
+ foreach (var p in parameters.Where(p => p.Name != "_" && !p.Hidden))
+ {
+ var isBool = IsBoolFlag(p.Type);
+ var flagName = FormatFlagName(p);
+ var typeHint = isBool ? string.Empty : $" `{FormatTypeHint(p)}`";
+ var requiredMarker = p.Required ? " **required**" : string.Empty;
+
+ _ = sb.AppendLine($"{flagName}{typeHint}{requiredMarker}");
+
+ // v2: summary may still embed "Values:" / "Default:" for legacy generators;
+ // prefer dedicated fields (EnumValues, DefaultValue) when present.
+ var (description, legacyValues, legacySummaryDefault) = CleanSummary(p.Summary);
+
+ var descLine = description.Trim();
+
+ // Annotate special roles inline
+ if (p.Role == "confirmationSkip")
+ descLine = string.IsNullOrEmpty(descLine)
+ ? "Pass to skip the confirmation prompt."
+ : descLine + " (pass to skip the confirmation prompt)";
+ else if (p.Role == "dryRun")
+ descLine = string.IsNullOrEmpty(descLine)
+ ? "Preview changes without applying them."
+ : descLine + " (preview changes without applying them)";
+
+ _ = sb.AppendLine($": {descLine}");
+
+ // Deprecated parameter
+ if (p.Deprecated is not null)
+ {
+ var parts = new List { "**Deprecated**" };
+ if (!string.IsNullOrWhiteSpace(p.Deprecated.Since))
+ parts.Add($"since {p.Deprecated.Since}");
+ if (!string.IsNullOrWhiteSpace(p.Deprecated.Message))
+ parts.Add(p.Deprecated.Message.Trim().TrimEnd('.'));
+ if (!string.IsNullOrWhiteSpace(p.Deprecated.RemovedIn))
+ parts.Add($"Removed in: {p.Deprecated.RemovedIn}");
+ _ = sb.AppendLine();
+ _ = sb.AppendLine($" {string.Join(". ", parts)}.");
+ }
+
+ // Enum values: prefer schema EnumValues, fall back to legacy embedded text
+ var values = p.EnumValues is { Length: > 0 }
+ ? string.Join(", ", p.EnumValues)
+ : legacyValues;
+
+ if (!string.IsNullOrWhiteSpace(values))
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine($" **Values:** {values.Trim()}");
+ }
+
+ // Default: prefer dedicated schema field, fall back to legacy embedded text
+ // Skip "default" as a literal value — argh emits this for nullable booleans with no meaningful default
+ var defaultValue = (!string.IsNullOrWhiteSpace(p.DefaultValue) && !p.DefaultValue.Equals("default", StringComparison.OrdinalIgnoreCase))
+ ? p.DefaultValue
+ : legacySummaryDefault;
+ if (!string.IsNullOrWhiteSpace(defaultValue))
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine($" **Default:** `{defaultValue.Trim()}`");
+ }
+
+ // Constraints from validations
+ var constraints = FormatConstraints(p.Validations);
+ if (!string.IsNullOrEmpty(constraints))
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine($" **Constraints:** {constraints}");
+ }
+
+ // Repeatable / variadic hints
+ if (p.Repeatable)
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine($" **Repeatable:** pass `--{p.Name}` multiple times to supply more than one value");
+ }
+ else if (p.Variadic)
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine(" **Variadic:** accepts multiple values");
+ }
+
+ _ = sb.AppendLine();
+ }
+ }
+
+ private static string FormatConstraints(List? validations)
+ {
+ if (validations is not { Count: > 0 })
+ return string.Empty;
+
+ var parts = new List();
+ foreach (var v in validations)
+ {
+ var phrase = v.Kind.ToLowerInvariant() switch
+ {
+ "existing" => "must exist",
+ "rejectsymboliclinks" => "symbolic links not allowed",
+ "expanduserprofile" => "supports `~` home expansion",
+ "urischeme" when v.Values is { Length: > 0 } =>
+ $"must be a {string.Join(" or ", v.Values)} URI",
+ "range" when v.Min is not null && v.Max is not null =>
+ $"between {v.Min} and {v.Max}",
+ "range" when v.Min is not null => $"minimum {v.Min}",
+ "range" when v.Max is not null => $"maximum {v.Max}",
+ "timespanrange" when v.Min is not null && v.Max is not null =>
+ $"duration between {v.Min} and {v.Max}",
+ "fileextensions" when v.Values is { Length: > 0 } =>
+ $"extensions: {string.Join(", ", v.Values)}",
+ "pattern" when v.Pattern is not null => $"must match `{v.Pattern}`",
+ _ => null
+ };
+ if (phrase is not null)
+ parts.Add(phrase);
+ }
+
+ return parts.Count > 0 ? string.Join(", ", parts) : string.Empty;
+ }
+
+ private static string GenerateUsage(CliCommandSchema cmd, string[]? fullPath, string? binaryName)
+ {
+ var parts = new List();
+ if (!string.IsNullOrWhiteSpace(binaryName))
+ parts.Add(binaryName);
+ if (fullPath is { Length: > 0 })
+ parts.AddRange(fullPath);
+ else
+ parts.Add(cmd.Name);
+
+ var visible = cmd.Parameters.Where(p => p.Name != "_" && !p.Hidden).ToList();
+ var positionals = visible.Where(p => p.Role == "positional").ToList();
+ var requiredFlags = visible.Where(p => p.Role != "positional" && p.Required).ToList();
+ var optionalFlags = visible.Where(p => p.Role != "positional" && !p.Required).ToList();
+
+ foreach (var p in positionals)
+ parts.Add(p.Required ? $"<{p.Name}>" : $"[<{p.Name}>]");
+
+ foreach (var p in requiredFlags)
+ {
+ if (IsBoolFlag(p.Type))
+ parts.Add($"--{p.Name}");
+ else
+ parts.Add($"--{p.Name} <{p.Name}>");
+ }
+
+ if (optionalFlags.Count > 0)
+ parts.Add("[options]");
+
+ return string.Join(" ", parts);
+ }
+
+ private static string FormatFlagName(CliParamSchema p)
+ {
+ if (p.Role == "positional")
+ return $"`<{p.Name}>`";
+
+ var isBool = IsBoolFlag(p.Type);
+ var prefix = isBool ? "`--[no-]" : "`--";
+ var shortPart = p.ShortName is not null ? $"`-{p.ShortName}` " : string.Empty;
+
+ return $"{shortPart}{prefix}{p.Name}`";
+ }
+
+ // Parses optional "Values: ..." and "Default: ..." lines that argh embeds in summary text.
+ private static (string description, string values, string defaultValue) CleanSummary(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return (string.Empty, string.Empty, string.Empty);
+
+ // Collapse whitespace produced by XML doc indentation (newlines + leading spaces)
+ var normalized = WhitespaceRegex().Replace(raw.Trim(), " ");
+
+ // Argh embeds "Values: X, Y. Default: A, B." at the end of summary text.
+ // Split on " Values: " first, then on " Default: " within the remainder.
+ const string valuesSep = " Values: ";
+ const string defaultSep = " Default: ";
+
+ var valuesIdx = normalized.IndexOf(valuesSep, StringComparison.OrdinalIgnoreCase);
+ if (valuesIdx < 0)
+ {
+ // No Values/Default section; check for standalone Default
+ var defIdx = normalized.IndexOf(defaultSep, StringComparison.OrdinalIgnoreCase);
+ if (defIdx < 0)
+ return (normalized, string.Empty, string.Empty);
+
+ return (
+ normalized[..defIdx].Trim(),
+ string.Empty,
+ normalized[(defIdx + defaultSep.Length)..].Trim().TrimEnd('.')
+ );
+ }
+
+ var description = normalized[..valuesIdx].Trim();
+ var remainder = normalized[(valuesIdx + valuesSep.Length)..];
+
+ var defInRemainder = remainder.IndexOf(defaultSep, StringComparison.OrdinalIgnoreCase);
+ if (defInRemainder < 0)
+ return (description, remainder.Trim().TrimEnd('.'), string.Empty);
+
+ var values = remainder[..defInRemainder].Trim().TrimEnd('.');
+ var defaultValue = remainder[(defInRemainder + defaultSep.Length)..].Trim().TrimEnd('.');
+ return (description, values, defaultValue);
+ }
+
+ // Schema v2 uses JSON Schema primitives: "boolean", "string", "integer", "number", "array", "enum"
+ // Schema v1 used "Primitive:bool", "Primitive:bool?", "Primitive" for booleans
+ private static bool IsBoolFlag(string type) =>
+ type.Equals("boolean", StringComparison.OrdinalIgnoreCase) ||
+ type.StartsWith("Primitive:bool", StringComparison.OrdinalIgnoreCase) ||
+ type.Equals("Primitive", StringComparison.OrdinalIgnoreCase);
+
+ private static string FormatTypeHint(CliParamSchema p)
+ {
+ var type = p.Type;
+
+ // v2 JSON Schema primitives
+ return type.ToLowerInvariant() switch
+ {
+ "string" => "string",
+ "integer" => "int",
+ "number" => "number",
+ "boolean" => string.Empty, // shown as --[no-] prefix instead
+ "enum" => "enum",
+ "array" => p.ElementType switch
+ {
+ "enum" => "enum[]",
+ "integer" => "int[]",
+ _ => "string[]"
+ },
+ // v1 fallback (kind-style strings)
+ _ => FormatKindV1(type)
+ };
+ }
+
+ private static string FormatKindV1(string kind)
+ {
+ var colon = kind.IndexOf(':');
+ var left = colon >= 0 ? kind[..colon] : kind;
+ var right = colon >= 0 ? kind[(colon + 1)..] : string.Empty;
+
+ return left switch
+ {
+ "Collection" or "Collection" => "enum[]",
+ "Collection" => "string[]",
+ "Collection" or "Collection" => "int[]",
+ "Enum" => right.Contains('.') ? right[(right.LastIndexOf('.') + 1)..] : right,
+ "Primitive" => right switch
+ {
+ "string" or "string?" => "string",
+ "int" or "int?" or "Int32" or "Int32?" => "int",
+ _ => string.Empty
+ },
+ "FileInfo" => "path",
+ "DirectoryInfo" => "path",
+ _ when left.StartsWith("Collection<") => left["Collection<".Length..].TrimEnd('>') + "[]",
+ _ => left
+ };
+ }
+
+ // Wraps a usage line to multiline bash continuation format when it exceeds 80 chars.
+ // Groups flag+value pairs ("--flag ") together on the same line.
+ private static string FormatUsage(string usage)
+ {
+ if (usage.Length <= 80)
+ return usage;
+
+ var tokens = usage.Split(' ');
+ var groups = new List();
+ var i = 0;
+
+ // Collect the command prefix (everything before the first flag or bracket)
+ var prefixParts = new List();
+ while (i < tokens.Length && !tokens[i].StartsWith('-') && !tokens[i].StartsWith('[') && !tokens[i].StartsWith('<'))
+ {
+ prefixParts.Add(tokens[i]);
+ i++;
+ }
+ groups.Add(string.Join(" ", prefixParts));
+
+ // Group remaining tokens: --flag pairs stay together
+ while (i < tokens.Length)
+ {
+ var token = tokens[i];
+ if ((token.StartsWith("--") || (token.StartsWith('-') && token.Length == 2))
+ && i + 1 < tokens.Length
+ && (tokens[i + 1].StartsWith('<') || tokens[i + 1].StartsWith("[<")))
+ {
+ groups.Add(token + " " + tokens[i + 1]);
+ i += 2;
+ }
+ else
+ {
+ groups.Add(token);
+ i++;
+ }
+ }
+
+ var result = new StringBuilder();
+ _ = result.Append(groups[0]);
+ for (var g = 1; g < groups.Count; g++)
+ {
+ _ = result.Append(" \\");
+ _ = result.AppendLine();
+ _ = result.Append(" ");
+ _ = result.Append(groups[g]);
+ }
+ return result.ToString();
+ }
+
+ [GeneratedRegex(@"\s{2,}")]
+ private static partial Regex WhitespaceRegex();
+}
diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs
new file mode 100644
index 0000000000..dea2f84eb6
--- /dev/null
+++ b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs
@@ -0,0 +1,61 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Toc.CliReference;
+using Elastic.Markdown.Myst;
+using Markdig.Syntax;
+
+namespace Elastic.Markdown.Extensions.CliReference;
+
+public record CliNamespaceFile : IO.MarkdownFile
+{
+ private readonly CliNamespaceSchema _namespace;
+ private readonly IFileInfo? _supplementalDoc;
+ private readonly string? _binaryName;
+
+ private readonly string[] _fullPath;
+
+ public CliNamespaceFile(
+ IFileInfo sourceFile,
+ IDirectoryInfo rootPath,
+ MarkdownParser parser,
+ BuildContext build,
+ CliNamespaceSchema @namespace,
+ IFileInfo? supplementalDoc,
+ string[]? fullPath = null,
+ string? binaryName = null
+ ) : base(sourceFile, rootPath, parser, build)
+ {
+ _namespace = @namespace;
+ _supplementalDoc = supplementalDoc;
+ _fullPath = fullPath ?? [@namespace.Segment];
+ _binaryName = binaryName;
+ Title = @namespace.Segment;
+ }
+
+ public override string NavigationTitle => $"[ns]{_namespace.Segment}";
+
+ protected override Task GetMinimalParseDocumentAsync(Cancel ctx)
+ {
+ Title = _namespace.Segment;
+ var markdown = BuildMarkdown();
+ return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null));
+ }
+
+ protected override Task GetParseDocumentAsync(Cancel ctx)
+ {
+ var markdown = BuildMarkdown();
+ return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null));
+ }
+
+ private string BuildMarkdown()
+ {
+ var supplemental = _supplementalDoc?.Exists == true
+ ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName)
+ : null;
+ return CliMarkdownGenerator.NamespacePage(_namespace, supplemental, _fullPath, _binaryName);
+ }
+}
diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs
new file mode 100644
index 0000000000..aaac32349e
--- /dev/null
+++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs
@@ -0,0 +1,389 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.Configuration.Toc.CliReference;
+using Elastic.Documentation.Navigation;
+using Elastic.Markdown.Exporters;
+using Elastic.Markdown.IO;
+using Elastic.Markdown.Myst;
+
+namespace Elastic.Markdown.Extensions.CliReference;
+
+internal sealed record CliEntityInfo(
+ CliSchema Schema,
+ object Entity, // CliSchema | CliNamespaceSchema | CliCommandSchema
+ IFileInfo? SupplementalDoc,
+ /// The clean synthetic file (no cmd- prefix) — used as the MarkdownFile source for correct URL generation.
+ IFileInfo? CleanSyntheticFile = null,
+ /// Full path segments used to build the page heading (e.g. ["assembler", "bloom-filter"]).
+ string[]? FullPath = null
+);
+
+public class CliReferenceDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension
+{
+ private BuildContext Build { get; } = build;
+
+ private Dictionary? _syntheticFiles;
+ private List? _syntheticFileInfos;
+ // Maps physical supplemental file paths (cmd-*.md, index.md) → entity info with clean synthetic path
+ private Dictionary? _supplementalFiles;
+ // Cache of created MarkdownFile instances keyed by clean synthetic path — ensures the same instance
+ // is returned from both CreateMarkdownFile (supplemental) and CreateDocumentationFile (synthetic),
+ // so NavigationDocumentationFileLookup can find the file regardless of which key is used.
+ private readonly Dictionary _createdFiles = [];
+
+ // Must be called before CreateMarkdownFile or CreateDocumentationFile can match anything.
+ // ScanDocumentationFiles calls this; CreateMarkdownFile also triggers it because the main
+ // directory scan runs before ScanDocumentationFiles, so index.md files are encountered first.
+ private void EnsureSyntheticFilesBuilt()
+ {
+ if (_syntheticFiles is not null)
+ return;
+ _syntheticFiles = [];
+ _supplementalFiles = [];
+ _syntheticFileInfos = BuildSyntheticFiles();
+ }
+
+ public IDocumentationFileExporter? FileExporter => null;
+
+ public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser)
+ {
+ EnsureSyntheticFilesBuilt();
+ if (!_syntheticFiles!.TryGetValue(file.FullName, out var info))
+ return null;
+ // Use the clean synthetic file as source if available (commands registered under clean path)
+ var sourceFile = info.CleanSyntheticFile ?? file;
+ // Return cached instance if CreateMarkdownFile already created it for this path
+ if (_createdFiles.TryGetValue(sourceFile.FullName, out var cached))
+ return cached;
+ var result = CreateCliFileFromInfo(sourceFile, markdownParser, info);
+ if (result != null)
+ _createdFiles[sourceFile.FullName] = result;
+ return result;
+ }
+
+ public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser)
+ {
+ // Physical CLI supplemental docs (index.md for namespaces, cmd-*.md for commands) need to be
+ // intercepted before the factory creates a plain MarkdownFile for them.
+ // EnsureSyntheticFilesBuilt() is called here because CreateMarkdownFile runs during the main
+ // directory scan, before ScanDocumentationFiles populates the lookups.
+ var name = file.Name;
+ if (name != "index.md" && !name.StartsWith("cmd-", StringComparison.OrdinalIgnoreCase))
+ return null;
+ EnsureSyntheticFilesBuilt();
+ var fullPath = Path.GetFullPath(file.FullName);
+
+ // index.md: file path IS the synthetic path (namespace pages)
+ if (_syntheticFiles!.TryGetValue(fullPath, out var info))
+ {
+ if (_createdFiles.TryGetValue(fullPath, out var cached))
+ return cached;
+ var result = CreateCliFileFromInfo(file, markdownParser, info);
+ if (result != null)
+ _createdFiles[fullPath] = result;
+ return result;
+ }
+
+ // cmd-*.md: physical supplemental file — render as the associated CLI command page
+ // using the clean synthetic path (no cmd- prefix) as the source file so RelativePath
+ // and thus the output URL are both clean (e.g. apply.md → /cli/.../apply).
+ if (_supplementalFiles!.TryGetValue(fullPath, out var suppInfo) && suppInfo.CleanSyntheticFile is not null)
+ {
+ var cleanPath = suppInfo.CleanSyntheticFile.FullName;
+ if (_createdFiles.TryGetValue(cleanPath, out var cached))
+ return cached;
+ var result = CreateCliFileFromInfo(suppInfo.CleanSyntheticFile, markdownParser, suppInfo);
+ if (result != null)
+ _createdFiles[cleanPath] = result;
+ return result;
+ }
+
+ return null;
+ }
+
+ private MarkdownFile? CreateCliFileFromInfo(IFileInfo sourceFile, MarkdownParser markdownParser, CliEntityInfo info) =>
+ info.Entity switch
+ {
+ CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc),
+ CliNamespaceSchema ns => new CliNamespaceFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, ns, info.SupplementalDoc, info.FullPath ?? [ns.Segment], info.Schema.Name),
+ CliCommandSchema cmd => new CliCommandFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, cmd, info.SupplementalDoc, info.FullPath ?? [cmd.Name], info.Schema.Name),
+ _ => null
+ };
+
+ public void VisitNavigation(INavigationItem navigation, IDocumentationFile model) { }
+
+ public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile)
+ {
+ documentationFile = null;
+ return false;
+ }
+
+ public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling)
+ {
+ EnsureSyntheticFilesBuilt();
+ if (_syntheticFileInfos is not { Count: > 0 })
+ return [];
+
+ var results = new List<(IFileInfo, DocumentationFile)>();
+ foreach (var fileInfo in _syntheticFileInfos)
+ {
+ // When a supplemental index.md physically exists at the synthetic path (e.g. changelog/index.md),
+ // skip it here — the factory's directory scan will find the real file and call CreateMarkdownFile,
+ // which picks up the CliNamespaceFile from _syntheticFiles. Registering both would cause duplicate keys.
+ if (fileInfo.Exists)
+ continue;
+
+ // defaultFileHandling calls extension.CreateDocumentationFile(file, markdownParser)
+ // which routes back to our CreateDocumentationFile above — now with the MarkdownParser available
+ var doc = defaultFileHandling(fileInfo, Build.DocumentationSourceDirectory);
+ results.Add((fileInfo, doc));
+ }
+ return results;
+ }
+
+ private List BuildSyntheticFiles()
+ {
+ var cliRefs = FindCliReferenceRefs(Build.ConfigurationYaml.TableOfContents);
+ var fileInfos = new List