From 4a7a535d94dafd1d89b1097fd6051af30af84a1e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Nov 2026 22:15:08 +0000 Subject: [PATCH] feat: Updated OpenAPI spec --- src/cli/ModernMT.CLI/CliCompletion.cs | 246 ++++++++++++++++++++++++++ src/cli/ModernMT.CLI/Program.cs | 1 + src/cli/ModernMT.CLI/README.md | 12 ++ 3 files changed, 259 insertions(+) create mode 100644 src/cli/ModernMT.CLI/CliCompletion.cs diff --git a/src/cli/ModernMT.CLI/CliCompletion.cs b/src/cli/ModernMT.CLI/CliCompletion.cs new file mode 100644 index 0000000..fc64b73 --- /dev/null +++ b/src/cli/ModernMT.CLI/CliCompletion.cs @@ -0,0 +1,246 @@ +#nullable enable + +using System.CommandLine; +using System.Text; + +namespace ModernMT.CLI; + +/// +/// Generates shell completion scripts from the command tree this CLI actually exposes. +/// +internal static class CliCompletion +{ + /// Shells a script can be emitted for. + public static string[] SupportedShells { get; } = ["bash", "zsh", "fish"]; + + /// + /// Builds the `completion <shell>` command. Pass the root command so the script describes + /// the tree as assembled, including any hand-written commands added alongside the generated ones. + /// + public static Command CreateCommand(Command root, string toolName) + { + var shell = new Argument("shell") + { + Description = "Shell to emit a completion script for: bash, zsh, or fish.", + }; + + var command = new Command( + "completion", + "Print a shell completion script on stdout, generated from this CLI's command tree.") + { + shell, + }; + + command.SetAction(parseResult => + { + var requested = (parseResult.GetValue(shell) ?? string.Empty).Trim().ToLowerInvariant(); + if (!SupportedShells.Contains(requested, StringComparer.Ordinal)) + { + Console.Error.WriteLine( + "Unsupported shell '" + requested + "'. Accepted values: " + string.Join(", ", SupportedShells) + "."); + return 2; + } + + Console.WriteLine(Generate(requested, root, toolName)); + return 0; + }); + + return command; + } + + public static string Generate(string shell, Command root, string toolName) => shell switch + { + "bash" => GenerateBash(root, toolName), + "zsh" => GenerateZsh(root, toolName), + "fish" => GenerateFish(root, toolName), + _ => throw new ArgumentException("Unsupported shell '" + shell + "'.", nameof(shell)), + }; + + private static IEnumerable VisibleSubcommands(Command command) => + command.Subcommands.Where(x => !x.Hidden).OrderBy(x => x.Name, StringComparer.Ordinal); + + private static List OptionNames(Command command) => + [.. command.Options + .Where(option => !option.Hidden) + .SelectMany(option => new[] { option.Name }.Concat(option.Aliases)) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal)]; + + /// + /// Enum-typed options complete their own values, read from the option's value type rather than + /// from the spec, so hand-added options are covered too. + /// + private static List<(string Option, string Values)> EnumOptions(Command command) => + [.. command.Options + .Where(option => !option.Hidden) + .Select(option => (option.Name, Type: Nullable.GetUnderlyingType(option.ValueType) ?? option.ValueType)) + .Where(x => x.Type.IsEnum) + .Select(x => (x.Name, string.Join(" ", Enum.GetNames(x.Type))))]; + + private static IEnumerable<(string Top, Command Command)> Walk(Command command, string top) + { + foreach (var child in VisibleSubcommands(command)) + { + var childTop = string.IsNullOrEmpty(top) ? child.Name : top; + yield return (childTop, child); + foreach (var descendant in Walk(child, childTop)) + { + yield return descendant; + } + } + } + + private static string GenerateBash(Command root, string toolName) + { + var id = Identifier(toolName); + var builder = new StringBuilder(); + builder.AppendLine("# " + toolName + " completion (bash), generated from the CLI command tree."); + builder.AppendLine("_" + id + "_completions() {"); + builder.AppendLine(" local cur prev sub i"); + builder.AppendLine(" COMPREPLY=()"); + builder.AppendLine(" cur=\"${COMP_WORDS[COMP_CWORD]}\""); + builder.AppendLine(" prev=\"${COMP_WORDS[COMP_CWORD-1]}\""); + builder.AppendLine(" sub=\"\""); + builder.AppendLine(" for ((i=1; i 0) + { + builder.AppendLine(" " + command.Name + ") options=\"$options " + string.Join(" ", options) + "\" ;;"); + } + } + + builder.AppendLine(" esac"); + builder.AppendLine(" COMPREPLY=( $(compgen -W \"$options\" -- \"$cur\") ); return 0"); + builder.AppendLine(" fi"); + builder.AppendLine(); + builder.AppendLine(" if [[ -z \"$sub\" ]]; then"); + builder.AppendLine(" COMPREPLY=( $(compgen -W \"" + string.Join(" ", VisibleSubcommands(root).Select(x => x.Name)) + "\" -- \"$cur\") ); return 0"); + builder.AppendLine(" fi"); + builder.AppendLine(); + builder.AppendLine(" case \"$sub\" in"); + foreach (var command in VisibleSubcommands(root)) + { + var children = VisibleSubcommands(command).Select(x => x.Name).ToList(); + if (children.Count > 0) + { + builder.AppendLine(" " + command.Name + ") COMPREPLY=( $(compgen -W \"" + string.Join(" ", children) + "\" -- \"$cur\") ) ;;"); + } + } + + builder.AppendLine(" esac"); + builder.AppendLine("}"); + builder.AppendLine("complete -F _" + id + "_completions " + toolName); + return builder.ToString(); + } + + private static string GenerateZsh(Command root, string toolName) + { + var id = Identifier(toolName); + var builder = new StringBuilder(); + builder.AppendLine("#compdef " + toolName); + builder.AppendLine("# " + toolName + " completion (zsh), generated from the CLI command tree."); + builder.AppendLine("_" + id + "() {"); + builder.AppendLine(" local -a commands"); + builder.Append(" commands=("); + foreach (var command in VisibleSubcommands(root)) + { + builder.Append(" '" + command.Name + ":" + Describe(command.Description) + "'"); + } + + builder.AppendLine(" )"); + builder.AppendLine(" if (( CURRENT == 2 )); then"); + builder.AppendLine(" _describe -t commands 'command' commands && return"); + builder.AppendLine(" fi"); + builder.AppendLine(" local sub=\"${words[2]}\""); + builder.AppendLine(" local previous=\"${words[CURRENT-1]}\""); + + foreach (var (top, command) in Walk(root, string.Empty)) + { + foreach (var (option, values) in EnumOptions(command)) + { + builder.AppendLine(" if [[ \"$previous\" == \"" + option + "\" && \"$sub\" == \"" + top + "\" ]]; then"); + builder.AppendLine(" _values '" + option.TrimStart('-') + "' " + values + " && return"); + builder.AppendLine(" fi"); + } + } + + builder.AppendLine(" case \"$sub\" in"); + foreach (var command in VisibleSubcommands(root)) + { + var words = VisibleSubcommands(command).Select(x => x.Name) + .Concat(OptionNames(command)) + .Concat(OptionNames(root)) + .Distinct(StringComparer.Ordinal) + .Select(x => "'" + x + "'"); + builder.AppendLine(" " + command.Name + ") _values 'argument' " + string.Join(" ", words) + " ;;"); + } + + builder.AppendLine(" esac"); + builder.AppendLine("}"); + builder.AppendLine("_" + id + " \"$@\""); + return builder.ToString(); + } + + private static string GenerateFish(Command root, string toolName) + { + var builder = new StringBuilder(); + builder.AppendLine("# " + toolName + " completion (fish), generated from the CLI command tree."); + builder.AppendLine("complete -c " + toolName + " -f"); + foreach (var command in VisibleSubcommands(root)) + { + builder.AppendLine("complete -c " + toolName + " -n '__fish_use_subcommand' -a '" + command.Name + "' -d '" + Describe(command.Description) + "'"); + foreach (var child in VisibleSubcommands(command)) + { + builder.AppendLine("complete -c " + toolName + " -n '__fish_seen_subcommand_from " + command.Name + "' -a '" + child.Name + "'"); + } + + foreach (var option in OptionNames(command).Where(x => x.StartsWith("--", StringComparison.Ordinal))) + { + builder.AppendLine("complete -c " + toolName + " -n '__fish_seen_subcommand_from " + command.Name + "' -l '" + option.Substring(2) + "'"); + } + } + + foreach (var option in OptionNames(root).Where(x => x.StartsWith("--", StringComparison.Ordinal))) + { + builder.AppendLine("complete -c " + toolName + " -l '" + option.Substring(2) + "'"); + } + + foreach (var (top, command) in Walk(root, string.Empty)) + { + foreach (var (option, values) in EnumOptions(command)) + { + builder.AppendLine("complete -c " + toolName + " -n '__fish_seen_subcommand_from " + top + "' -l " + option.TrimStart('-') + " -x -a '" + values + "'"); + } + } + + return builder.ToString(); + } + + private static string Describe(string? description) => + (description ?? string.Empty).Replace("'", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal); + + private static string Identifier(string toolName) => + new([.. toolName.Select(c => char.IsLetterOrDigit(c) ? c : '_')]); +} \ No newline at end of file diff --git a/src/cli/ModernMT.CLI/Program.cs b/src/cli/ModernMT.CLI/Program.cs index db50078..2c591cd 100644 --- a/src/cli/ModernMT.CLI/Program.cs +++ b/src/cli/ModernMT.CLI/Program.cs @@ -12,5 +12,6 @@ rootCommand.Options.Add(CliOptions.OutputDirectory); rootCommand.Subcommands.Add(AuthCommand.Create()); rootCommand.Subcommands.Add(DefaultApiGroupCommand.Create()); +rootCommand.Subcommands.Add(CliCompletion.CreateCommand(rootCommand, @"modern-mt")); return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); \ No newline at end of file diff --git a/src/cli/ModernMT.CLI/README.md b/src/cli/ModernMT.CLI/README.md index e9a5706..1061a83 100644 --- a/src/cli/ModernMT.CLI/README.md +++ b/src/cli/ModernMT.CLI/README.md @@ -15,6 +15,18 @@ modern-mt --help modern-mt default --help ``` +## Shell completion + +The `completion` command prints a script built from the live command tree, so +subcommands, option names and the values of enum-typed options stay correct after the +spec is refreshed and the CLI regenerated — there is no word list to maintain. + +```bash +modern-mt completion bash > /usr/local/etc/bash_completion.d/modern-mt +modern-mt completion zsh > _modern-mt # then put it on your zsh fpath +modern-mt completion fish > ~/.config/fish/completions/modern-mt.fish +``` + ## Customization Generated operation, tag, and API group command classes are partial. Implement