Skip to content

Cli nested commands - #7

Merged
tomlm merged 3 commits into
mainfrom
cli-nested-commands
Sep 11, 2026
Merged

tomlm merged 3 commits into
mainfrom
cli-nested-commands

Conversation

@tomlm

@tomlm tomlm commented Sep 11, 2026

Copy link
Copy Markdown
Owner

This pull request introduces several significant enhancements and new features to the CShell library, most notably the addition of an Exec API for running processes attached to the current console, and major improvements to the Cli command line parser including support for nested commands. The documentation and tests have also been updated to reflect these new capabilities.

New Process Execution API:

  • Added Exec() and ExecAsync() methods to the CShell class to run external processes attached to the current console, returning their exit code and supporting options such as working directory, environment variables, and cancellation. This allows for interactive programs and better shell-like behavior. (src/CShell.cs src/CShell.csR160-R292)
  • Updated the documentation to explain the distinction between Run(), Exec(), and Start(), including when to use each method and their effects on input/output redirection. (README.md README.mdL200-R299)
  • Added comprehensive tests for Exec() and ExecAsync(), covering exit codes, working directory, environment, argument handling, cancellation, and error cases. (Tests/CShell.Tests/Exec.Tests.cs Tests/CShell.Tests/Exec.Tests.csR1-R168)

Command Line Parser Improvements:

  • Enhanced the Cli parser to support nested commands (verbs), allowing for complex command hierarchies similar to tools like dotnet. Each command can have its own options, help, and handlers, and global switches are supported. (README.md [1] src/CliResult.cs [2] [3]
  • Updated the CliResult class to track command hierarchy, parent commands, and handler execution status, enabling better error reporting and help generation. (src/CliResult.cs [1] [2]
  • Documented the new command features, usage patterns, and limitations in the README.md. (README.md README.mdL179-R251)

Versioning and Release Notes:

  • Bumped the library version to 3.1.0 and added a changelog entry describing the new features. (src/CShell.csproj [1] README.md [2]

These changes make CShell more powerful and flexible for scripting and command line tool development, with improved process control and modern CLI patterns.

tomlm and others added 3 commits September 10, 2026 12:58
Run() always captures, which is the inverse of what a shell does. In bash,
`ssh host` or `vim` takes over the terminal and you opt into capture with
$(...). CShell had no way to express the default case, so anything that
owns the terminal - ssh, vim, git rebase -i, gh auth login, an agent CLI -
either hung on a stdin pipe nothing would write to, or drew a full screen
UI into a pipe where nobody could see it. Run()'s own remarks already
described the trap; this gives it a way out.

Exec() returns Task<int> rather than a Command because there is genuinely
nothing to expose: with no redirection, StandardOutput, PipeTo and
RedirectTo would all have to throw. A separate method whose return type
says so beats a Command that is half unusable. ExecOptions is likewise
smaller than Shell.Options - an attached process has nothing to redirect,
pipe or encode - and keeps working directory, environment, cancellation,
and a StartInfo escape hatch.

ConsoleState restores the terminal afterwards. A full screen program turns
off echo and line input on the way in and restores them on the way out,
unless it is killed - leaving the console silently swallowing keystrokes
and the user typing a command they cannot see to fix it. Windows uses
GetConsoleMode/SetConsoleMode; Unix shells out to `stty -g`, which avoids
a pile of termios interop for the one thing needed. Since Exec exists to
run exactly that sort of program, and to be interrupted, the library is
the right place for it rather than every caller.

Verified against a real caller: Airlock's interactive ssh hand-off now
goes through ExecAsync, and a full session including a remote `exit 42`
comes back intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQo1wkkf7nu2n1BZN3Uozm
Cli's stated ceiling was "no subcommands", so a script with verbs had to
take the verb as a positional and hand-roll a second parse per branch --
losing generated help, losing the unknown-switch error, and putting every
verb's switches in one undifferentiated list.

Command(name, help, c => ...) gives a verb a command line of its own, and
because the thing handed to the lambda is a Cli, commands nest: `svc nuget
push` reads the way `dotnet nuget push` does. Every level has its own help,
its own unknown-switch error, and its own name in the message.

The lambda runs only for the command that was actually typed. That is what
makes typed out-variables safe inside it -- nothing binds for a command
nobody asked for -- and it lets --help list the commands without building
any of them.

Run()/RunAsync() declare what a command does, beside the declarations it
uses, because an out variable cannot be read after its lambda returns.
RunAsync is a separate name rather than a Run overload so that
`Run(async () => ...)` cannot bind to the void one as an async void that
nobody awaits; it pairs with ParseAsync(), and Parse() throws and says so.
What a handler returns lands on CliResult.ExitCode without exiting the
process; ShouldExit still tells that apart from a line that could not be read.

The parse boundary is the first token that is not a switch, which is the
same rule Rest() already uses, and it is only unambiguous because an
option's value attaches. Switches at a command level are globals, readable
from the command's own result; a typed one must be declared after the first
Command(), since before then the parser does not know the line splits. One
typed after the command word is refused with the fix rather than a dead end:

  svc start: '--verbose' is a global switch -- write it before the command:
  'svc --verbose start'.

CliResult gains Command, CommandPath, Parent and HandlerRan, and its Switch,
Option and WhatIf walk the parent chain.

Nothing changes for a Cli that declares no commands: all 110 existing Cli
tests pass untouched, alongside 47 new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vga8G5aDrA2qYn8BSCgTsd
@tomlm
tomlm merged commit 8343db7 into main Sep 11, 2026
1 check failed
@tomlm
tomlm deleted the cli-nested-commands branch September 11, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant