Skip to content

Commit 0362160

Browse files
authored
Merge pull request #34 from tui-cs/release/v0.3.0
Release v0.3.0
2 parents 25bc5fd + b860d3c commit 0362160

9 files changed

Lines changed: 137 additions & 12 deletions

File tree

Directory.Build.props

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
99
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
1010

11-
<Authors>gui-cs</Authors>
12-
<Company>gui-cs</Company>
13-
<Copyright>Copyright (c) gui-cs and contributors</Copyright>
11+
<Authors>tui-cs</Authors>
12+
<Company>tui-cs</Company>
13+
<Copyright>Copyright (c) tui-cs and contributors</Copyright>
1414

1515
<Version>0.2.1-develop</Version>
16-
<PackageProjectUrl>https://github.com/gui-cs/cli</PackageProjectUrl>
17-
<RepositoryUrl>https://github.com/gui-cs/cli</RepositoryUrl>
16+
<PackageProjectUrl>https://github.com/tui-cs/cli</PackageProjectUrl>
17+
<RepositoryUrl>https://github.com/tui-cs/cli</RepositoryUrl>
1818
<RepositoryType>git</RepositoryType>
1919
<PackageLicenseFile>LICENSE</PackageLicenseFile>
2020

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![NuGet](https://img.shields.io/nuget/vpre/Terminal.Gui.Cli)](https://www.nuget.org/packages/Terminal.Gui.Cli)
44
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
55

6-
> A .NET library that turns [Terminal.Gui](https://github.com/gui-cs/Terminal.Gui) apps into scriptable CLI tools — with typed JSON output, POSIX exit codes, and built-in AI-agent discoverability.
6+
> A .NET library that turns [Terminal.Gui](https://github.com/tui-cs/Terminal.Gui) apps into scriptable CLI tools — with typed JSON output, POSIX exit codes, and built-in AI-agent discoverability.
77
88
![Terminal.Gui.Cli in action](docs/images/hero.gif)
99

scripts/HERO-GIF.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Produces `docs/images/hero.gif` — an animated GIF demonstrating the example ap
44

55
## Prerequisites
66

7-
- [tuirec](https://github.com/gui-cs/tuirec) v0.3.4+ on PATH (`go install github.com/gui-cs/tuirec/cmd/tuirec@latest`)
7+
- [tuirec](https://github.com/tui-cs/tuirec) v0.3.4+ on PATH (`go install github.com/tui-cs/tuirec/cmd/tuirec@latest`)
88
- .NET 10 SDK (for building the example app)
99
- PowerShell 7+ (`pwsh`) on PATH
1010
- `agg` is auto-downloaded by tuirec on first use

specs/constitution.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# gui-cs/cli Constitution
1+
# tui-cs/cli Constitution
22

33
**Version**: 1.1 | **Ratified**: 2026-05-23 | **Last Amended**: 2026-05-23
44

5-
This constitution governs all contributions to `gui-cs/cli`. It is the highest-authority engineering document in this repository.
5+
This constitution governs all contributions to `tui-cs/cli`. It is the highest-authority engineering document in this repository.
66

77
## I. Purpose & Scope
88

specs/library-spec.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,13 @@ resolve to a registered command. When set, `CliHost` routes to that command in t
3838
- The leading token is not a recognized command alias.
3939

4040
In each case the host re-parses `[DefaultCommand, ..args]` against the resolved default
41-
command, so bare positional args and unrecognized options are retried as args to it. If
42-
`DefaultCommand` names an alias that is not registered, the host emits
41+
command, so bare positional args and unrecognized options are retried as args to it. The
42+
reparse uses `ArgParser.Parse(args, command, unknownOptionsAsArguments: true)`: dash-prefixed
43+
tokens that match no framework, global, or default-command option pass through verbatim as
44+
positional arguments (e.g. `app --literal` and `app Alice --suffix` reach the default command
45+
as positionals), while recognized options still parse as options. If the default command does
46+
not accept positional args, leftover tokens still produce the usual positional-args usage
47+
error. If `DefaultCommand` names an alias that is not registered, the host emits
4348
`Default command '<name>' is not registered.` and returns a usage error. When
4449
`DefaultCommand` is null, the original parse/usage-error behavior is preserved.
4550

src/Terminal.Gui.Cli/ArgParser.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,22 @@ public ArgParser (List<GlobalOptionDescriptor> globalOptions, int maxInitialChar
2929
}
3030

3131
/// <summary>Parses command-line arguments, optionally validating against a resolved command.</summary>
32+
/// <param name="args">The raw command-line arguments.</param>
33+
/// <param name="command">The resolved command to validate options against, when known.</param>
3234
public ParseResult Parse (string[] args, ICliCommand? command = null)
35+
{
36+
return Parse (args, command, false);
37+
}
38+
39+
/// <summary>Parses command-line arguments, optionally validating against a resolved command.</summary>
40+
/// <param name="args">The raw command-line arguments.</param>
41+
/// <param name="command">The resolved command to validate options against, when known.</param>
42+
/// <param name="unknownOptionsAsArguments">
43+
/// When true, dash-prefixed tokens that match no framework, global, or command option are passed
44+
/// through verbatim as positional arguments instead of failing the parse. Used by the
45+
/// default-command fallback so original tokens reach the default command (issue #30).
46+
/// </param>
47+
public ParseResult Parse (string[] args, ICliCommand? command, bool unknownOptionsAsArguments)
3348
{
3449
ArgumentNullException.ThrowIfNull (args);
3550

@@ -130,6 +145,13 @@ public ParseResult Parse (string[] args, ICliCommand? command = null)
130145
continue;
131146
}
132147

148+
if (unknownOptionsAsArguments)
149+
{
150+
arguments.Add (token);
151+
index++;
152+
continue;
153+
}
154+
133155
return ParseResult.Fail ($"Unknown option '{token}'.");
134156
}
135157

src/Terminal.Gui.Cli/CliHost.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ private async Task<int> RunWithDefaultCommandAsync (
9090
}
9191

9292
string[] adjusted = [_options.DefaultCommand!, .. args];
93-
ArgParser.ParseResult parse = _parser.Parse (adjusted, defaultCmd);
93+
94+
// The fallback fires precisely because the original tokens didn't resolve to a known
95+
// command/options, so unknown dash-prefixed tokens must pass through verbatim as
96+
// positional arguments rather than failing the reparse (issue #30).
97+
ArgParser.ParseResult parse = _parser.Parse (adjusted, defaultCmd, true);
9498

9599
if (!parse.Success || parse.Options is null)
96100
{

tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,54 @@ public async Task DefaultCommand_NoArgs_GreetsWorld ()
9494
Assert.Equal (ExitCodes.Ok, exitCode);
9595
}
9696

97+
[Fact]
98+
public async Task DefaultCommand_DashPrefixedToken_PassesThroughAsPositionalArg ()
99+
{
100+
CliHost host = CreateGreetHost ();
101+
using StringWriter stdout = new ();
102+
using StringWriter stderr = new ();
103+
104+
// "--literal" is not a recognized option anywhere; the default-command fallback
105+
// must pass it through verbatim as a positional argument (issue #30).
106+
var exitCode = await host.RunAsync (["--literal"], TestContext.Current.CancellationToken, stdout, stderr);
107+
108+
Assert.Equal (ExitCodes.Ok, exitCode);
109+
Assert.Contains ("Hello, --literal!", stdout.ToString ());
110+
Assert.Equal (string.Empty, stderr.ToString ());
111+
}
112+
113+
[Fact]
114+
public async Task DefaultCommand_PositionalThenUnknownOption_PassesBothThroughAsArgs ()
115+
{
116+
CliHost host = CreateGreetHost ();
117+
using StringWriter stdout = new ();
118+
using StringWriter stderr = new ();
119+
120+
var exitCode = await host.RunAsync (["Alice", "--suffix"], TestContext.Current.CancellationToken, stdout,
121+
stderr);
122+
123+
Assert.Equal (ExitCodes.Ok, exitCode);
124+
Assert.Contains ("Hello, Alice --suffix!", stdout.ToString ());
125+
Assert.Equal (string.Empty, stderr.ToString ());
126+
}
127+
128+
[Fact]
129+
public async Task DefaultCommand_RecognizedOptionInFallback_StillParsesAsOption ()
130+
{
131+
CliHost host = CreateGreetHost ();
132+
using StringWriter stdout = new ();
133+
using StringWriter stderr = new ();
134+
135+
// "--formal" is a declared option of the default command; the fallback must
136+
// still parse it as an option, not a positional argument.
137+
var exitCode = await host.RunAsync (["Alice", "--formal"], TestContext.Current.CancellationToken, stdout,
138+
stderr);
139+
140+
Assert.Equal (ExitCodes.Ok, exitCode);
141+
Assert.Contains ("Good day, Alice.", stdout.ToString ());
142+
Assert.Equal (string.Empty, stderr.ToString ());
143+
}
144+
97145
[Fact]
98146
public async Task HelpCat_RendersAnsiForRootHelp ()
99147
{

tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,52 @@ public void TryParseTimeout_WithOverflowValue_ReturnsFalse ()
3838
Assert.False (ArgParser.TryParseTimeout ("1e999999h", out _));
3939
}
4040

41+
[Fact]
42+
public void Parse_UnknownDashToken_FailsByDefault ()
43+
{
44+
ArgParser parser = new ([]);
45+
46+
ArgParser.ParseResult result =
47+
parser.Parse (["pick", "--name", "value", "--literal"], new TestCommand (true));
48+
49+
Assert.False (result.Success);
50+
Assert.Contains ("--literal", result.Error);
51+
}
52+
53+
[Fact]
54+
public void Parse_UnknownOptionsAsArguments_TreatsUnknownDashTokensAsPositionals ()
55+
{
56+
ArgParser parser = new ([]);
57+
58+
ArgParser.ParseResult result = parser.Parse (
59+
["pick", "--literal", "Alice", "--name", "value", "--json"],
60+
new TestCommand (true),
61+
true);
62+
63+
Assert.True (result.Success, result.Error);
64+
Assert.NotNull (result.Options);
65+
66+
// Unknown dash tokens pass through verbatim as positionals; recognized
67+
// command and framework options still parse normally.
68+
Assert.Equal (["--literal", "Alice"], result.Options.Arguments);
69+
Assert.Equal ("value", result.Options.CommandOptions["name"]);
70+
Assert.True (result.Options.JsonOutput);
71+
}
72+
73+
[Fact]
74+
public void Parse_UnknownOptionsAsArguments_StillRejectedWhenCommandForbidsPositionals ()
75+
{
76+
ArgParser parser = new ([]);
77+
78+
ArgParser.ParseResult result = parser.Parse (
79+
["pick", "--name", "value", "--literal"],
80+
new TestCommand (false),
81+
true);
82+
83+
Assert.False (result.Success);
84+
Assert.Contains ("positional", result.Error);
85+
}
86+
4187
[Fact]
4288
public void Parse_RejectsMissingRequiredCommandOption ()
4389
{

0 commit comments

Comments
 (0)