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