Skip to content

Subcommand is only recognized as the first positional token (trailing subcommand falls through to DefaultCommand) #37

Description

@tig

Summary

CliHost/ArgParser only treat a token as a command alias when it is the first non-option token. If a registered subcommand appears after a positional argument, it is never recognized as a command — instead the host falls back to DefaultCommand and the real subcommand is swallowed as just another positional argument.

This makes argument order rigid in a way that surprises users: tool sub file works, but tool file sub silently does the wrong thing.

Version

Terminal.Gui.Cli 0.3.0.

Repro

Given a host with a default command (tui) and a registered gui command that accepts positional args:

var host = new CliHost(o => { o.DefaultCommand = "tui"; });
host.Registry.Register(new TuiCommand());   // default
host.Registry.Register(new GuiCommand());   // PrimaryAlias "gui", AcceptsPositionalArgs = true
Command line Resolved command Positional args
tool gui ./file.cpp gui ["./file.cpp"]
tool ./file.cpp gui tui (default) ❌ ["./file.cpp", "gui"]

In the second case the user clearly intended the gui subcommand, but it is treated as a filename and the default command runs instead.

Expected

A registered subcommand should be recognized regardless of whether a positional argument precedes it (or at least there should be a documented/opt-in way to allow it), so tool file sub and tool sub file behave the same.

Root cause

In ArgParser.Parse, the first non-- token is unconditionally captured as the command alias, and every subsequent non-- token is appended to the positional list:

if (text == null && !text2.StartsWith('-'))   // first bare token -> command alias
{
    text = text2;
    index++;
    continue;
}
if (text != null && !text2.StartsWith('-'))   // any later bare token -> positional arg
{
    list.Add(text2);
    index++;
    continue;
}

Then in CliHost.RunAsync, when Registry.TryResolve(alias) fails (because alias was actually a filename), it calls RunWithDefaultCommandAsync, which prepends the default command and re-parses with all original tokens — including the genuine subcommand — as positional args. There is no point at which a non-first token is matched against the registry.

Impact / workaround

Downstream (winprint's wp CLI) we currently just document "subcommand must come first." Filing this upstream because the asymmetry is a parser-level concern rather than something each consumer should special-case.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions