Skip to content

V3 features - #4

Merged
tomlm merged 12 commits into
mainfrom
ask-methods
Sep 2, 2026
Merged

V3 features#4
tomlm merged 12 commits into
mainfrom
ask-methods

Conversation

@tomlm

@tomlm tomlm commented Sep 2, 2026

Copy link
Copy Markdown
Owner

This pull request introduces several major updates to CShell, focusing on new user input and command-line parsing features, a migration from Newtonsoft.Json to System.Text.Json, and updated documentation and tests to reflect these changes. The most significant changes include the addition of rich prompting and CLI parsing APIs, breaking changes to target .NET 8.0 and use System.Text.Json, and updates to the usage examples and tests.

New features and APIs:

  • Added the Ask family of methods for interactive user prompts, including AskText, AskSecret, AskYesNo, AskNumber, AskChoice, and AskMultiChoice, along with RichPrompts and ReadKey properties to control prompt behavior. [1] [2]
  • Introduced the Cli API for declarative command-line argument parsing with generated help, supporting arguments, switches, options, and more. [1] [2]

Breaking changes and dependency updates:

  • Changed the target framework to .NET 8.0 (net8.0), dropping support for .NET Framework and netstandard2.0. [1] [2]
  • Replaced Newtonsoft.Json with System.Text.Json for JSON parsing; AsJson() now returns a JsonDynamic (backed by JsonNode), and AsJson<T>() uses System.Text.Json for deserialization. [1] [2] [3] [4] [5] [6]

Documentation and usage updates:

  • Expanded the README.md with detailed documentation and examples for the new Ask and Cli APIs, revised JSON parsing sections, and updated code samples to reference version 3.0.0. [1] [2] [3] [4] [5] [6] [7] [8]

Test updates:

  • Updated tests to use System.Text.Json and JsonNode instead of Newtonsoft.Json and JObject, and added coverage for case-insensitive property matching and missing properties. [1] [2] [3] [4] [5]

Automation and permissions:

  • Added GitHub Actions workflows for automated code review and code execution via Claude, and introduced .claude/settings.json to specify allowed tool permissions. [1] [2] [3]

These changes collectively modernize CShell, improve its usability for interactive scripting and command-line parsing, and streamline its dependencies and platform support.

tomlm and others added 12 commits August 28, 2026 12:44
Workflows for @claude mentions and PR review, plus the local permission
baseline in .claude/settings.json.

The tool allowlist is in claude_args rather than settings.json because the
Action does not read settings.json: without it every dotnet and gh pr command in
a run is refused, so the build never runs and nothing reported was verified.
AskText, AskSecret, AskYesNo, AskNumber, AskChoice and AskMultiChoice: the
questions a script asks the user, as against Run(), where a process asks the
user something itself.

Each has two modes and chooses between them from Console.IsInputRedirected,
because Console.ReadKey() throws when input is redirected -- piped, scheduled
and CI runs have no keys to read, so a rich prompt needs a typed twin rather
than a degraded version of itself. RichPrompts overrides that choice and
ReadKey supplies the keystrokes, which is what makes the rich paths testable.

AskChoice and AskMultiChoice are generic over the option type and return the
option itself rather than its position, with an optional selector saying what
to show for each. The label is matched before any position number, so a list
whose options are themselves numbers answers the way it reads.

ChoiceStyle labels the list: Auto (nothing when there are arrow keys, numbers
when the answer has to be typed), Numbers, Letters, or None. The prompt only
ever asks for what is printed in front of the options, so None takes the
option's text and refuses a number it never showed.

Every Ask throws at end of stream rather than answering for someone who is not
there -- otherwise the retry loops spin against a console nobody is attached to.

Run() gains remarks for the case it is the wrong method for: a process that
stops to ask the user something waits on a stdin pipe nothing will ever write
to or close, and never exits.

Tests/CShell.Tests/Ask.Tests.cs covers both modes of every method.
askdemo.csx is a guided tour that shows each call in a box, then runs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
Cli declares what a script accepts and reads the command line against it. Three
words, because there are three kinds of thing: an Argument is a positional, a
Switch is on or off, an Option carries a value. The same three words read the
values back off CliResult, so the block that reads a command line can be checked
line for line against the block that declared it.

    var cmd = Cli.For(Args)
        .Argument("file", "File to operate on")
        .Switch("whatif", "What if without execute")
        .Option("out", "where to write the result")
        .Parse();

    if (cmd.ShouldExit) return cmd.ExitCode;

Anything undeclared is an error. Every script in the scripts repo bar one
silently ignores an unknown switch, and that is how a typo'd --api-key runs
against a live feed with the wrong key and a typo'd --dryrun becomes a path.
Bare words are positionals rather than unknown switches, which is what lets a
script take a path without every path being rejected.

Values ATTACH -- -out:file, never -out file. The separated form is what lets a
trailing -out silently become a positional and -out -whatif silently eat the
next switch as its value; an attached value is one token, so neither is
possible. The name half is normalized and the value half is not, so
-source:https://... and -out:C:\temp\My-Folder survive intact.

Switches are spelled with dashes only. '/' would make every absolute path on
Linux look like a switch, and -- is the standard now.

Help is generated from the declarations, so it cannot drift from what is
accepted, and -help, -h and -? work without being asked for. The program name
comes from the calling script's file name via [CallerFilePath], because under
dotnet-script the entry assembly is "dotnet-script" rather than the script.

Parse() never exits the process and never throws for a bad command line -- a
stack trace is the wrong way to say "you typed --dryrun", and a library that
exits cannot be tested. It reports and sets ShouldExit. Forgetting to check that
is caught rather than ignored: every value on the result throws once the command
line was bad, so a missed check fails loudly instead of running on with defaults
it never earned. Reading WhatIf without declaring it throws for the same reason.

Option error messages name the declared switch and never its value, and
switch-level errors stop before the positional list, so a secret typed in the
separated form is never echoed to stderr.

75 tests in Tests/CShell.Tests/Cli.Tests.cs. Version bumped to 3.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
Both shipped without docs -- the Ask family in a366d0c and Cli in d797b9a -- so
the README described a 2.1.0 that no longer exists.

Adds an "Asking the user" section and a "Command line" section, each with the
method tables the rest of the file uses, plus RichPrompts and ReadKey in the
properties table and a v3.0.0 changelog entry. The nuget references move to
3.0.0.

Both sections carry the reasoning that is not guessable from a signature: that
the Ask methods pick between an arrow-key mode and a typed-line mode because
Console.ReadKey() throws on redirected input; that AskChoice returns the option
rather than its position; that Cli rejects anything undeclared; that its values
attach rather than following as a separate token, and why that is a safety
property; and that Parse() reports rather than exiting so the result can poison
its own accessors when the check is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
Every script had to write the same two lines:

    if (cmd.ShouldExit) return cmd.ExitCode;

and the only real argument for charging them that was that the library's own
tests cannot run against a Parse() that exits. That is the library's problem to
solve, not something to put in every caller.

Parse() now prints and exits, so what it returns is always usable and a command
line that was not understood never reaches the script body. TryParse() is the
same work reported rather than acted on, for the tests and for a command line
parsed inside a larger program that means to handle the failure itself. The
poison rule stays on that path: every value throws until ShouldExit is checked.

A second argument had been in my head and was simply wrong -- that
Environment.Exit does not propagate an exit code under dotnet-script. Tested
both ways: Environment.Exit(3) and `return 3` each give 3. The earlier reading
was a shell artifact, not a behaviour.

Also restores two paragraphs the README section lost when it was first written
-- the Parse/TryParse contract and the stated ceiling -- which I had not checked
past the tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
MedallionShell is now the only dependency, with nothing transitive behind it.
The two changes go together: on netstandard2.0 System.Text.Json needs a package
that drags nine more, so swapping readers there would have taken the graph from
two packages to ten. On net8.0 it is in-box and the graph is one.

BREAKING, twice over:

  * netstandard2.0 is gone, so .NET Framework consumers are gone with it. Every
    real consumer here already runs modern .NET -- dotnet-script, dotnet run
    --file, the tests, the Sample.
  * AsJson() returns a JsonNode rather than a dynamic JObject. Indexing still
    works, json["owner"]["login"]; member access, json.owner.login, does not,
    because System.Text.Json has no equivalent. AsJson<T>() is unchanged.

src/Json.cs holds the reader settings, which relax three System.Text.Json
defaults that would otherwise break scripts on output they did not write: names
match case insensitively, so camelCase JSON still fills PascalCase properties
rather than silently leaving every field at its default; trailing commas and
comments are tolerated; and a byte order mark is trimmed, since it is not valid
JSON and Windows tools emit it constantly. Command.Tests covers the first with a
record carrying no attributes at all.

Also trims the README's Ask and Cli sections to what they do rather than why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
AsJson() returns dynamic again, backed by a JsonDynamic wrapping the parsed
JsonNode, so json.owner.login works as it did under Newtonsoft. It also indexes,
enumerates arrays, compares, and converts implicitly to JsonNode, JsonObject and
JsonArray, so the typed System.Text.Json API stays one assignment away.

    var json = await Cmd("gh api repos/tomlm/CShell").AsJson();
    json.owner.login
    json["stargazers_count"]
    JsonObject o = await Cmd("...").AsJson();

It is a wrapper rather than a subclass because JsonObject is sealed and
JsonNode's only constructor is internal -- neither can be derived from.

System.Text.Json has no dynamic story of its own largely because dynamic
dispatch cannot be trimmed or compiled ahead of time. That is a real constraint
for a shipped application and none at all for a utility script.

Prototyping first turned up three things worth having in the tests:
DynamicObject binds members and conversions but NOT operators, so json.age == 42
throws RuntimeBinderException without TryBinaryOperation; IEnumerable<dynamic>
is not a legal interface to implement, so enumeration goes through
IEnumerable<object>; and returning JsonDynamic rather than dynamic would mean
`var json = ...` no longer dots, since var infers the declared type.

A missing property reads as null rather than throwing, matching the JObject this
replaces, which keeps `if (json.optional != null)` working. The cost is that a
typo reads as null too.

14 tests in Tests/CShell.Tests/JsonDynamic.Tests.cs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
The template is what every new script is copied from, and it was printing its
arguments back. It now declares them: an Argument, an OptionalArgument, a Switch,
an Option and WhatIf, with a comment naming what each one is for, so a new script
gets generated --help and rejection of unknown switches on the first line it
writes.

Also shows AskYesNo guarding the overwrite, since a script that asks before
doing something irreversible is the convention these are written to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
CI runs on ubuntu-latest and has never passed: 50 of 224 tests failed there, all
of them the older Test_* and Test_Global_* ones. Nothing to do with any recent
change -- the only earlier run, in April, failed the same way.

The cause was one line repeated in four ClassInits:

    Path.Combine(dir, @"..\..\..\test")

On Linux that is not three levels up, it is a single directory named
"..\..\..\test". Every test in the class then died on the first cd() to it, which
is why the failure count looked catastrophic for what is a path separator.

  * the fixture path and the relative cd() calls now build with Path.Combine
    segments, which is right on either platform
  * Cmd() runs cmd.exe on Windows and bash elsewhere, so "dir /b TestA.txt" only
    ever worked on one of them. It picks per platform now
  * echo() writes with File.WriteAllLines, so the expected output is
    Environment.NewLine rather than a hard-coded \r\n
  * the "must fail to start" test no longer names a .cmd file, since the point is
    that the program does not exist, not that it is a batch file

Still 224 passing on Windows. Linux is unverified from here -- Start() uses
UseShellExecute, which behaves differently there, so Test_Start and
Test_StartExecute are the two to watch on the next CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
Verified in WSL rather than guessed at, which turned up four things the path fix
had left behind:

  * Run("cmd", "/c", ...) names cmd.exe directly. Run() launches a program rather
    than a shell, so the shell has to be chosen: cmd /c, or bash -c.
  * File.ReadAllText(... "TestA.Txt") -- the file is TestA.txt. A plain bug that
    only a case-insensitive filesystem ever forgave.
  * "The system cannot find the file specified." is the Windows wording for a
    program that will not start. Both platforms name the program in the message,
    so the assertion checks for that instead.
  * ReadFile shells out to `type` on Windows and `cat` elsewhere, which word a
    missing file differently. What the test means is that it fails and says so on
    stderr, so it asserts that.

Start() was the thing I expected to break on Linux and it did not: UseShellExecute
launches dotnet there fine, and Test_Start and Test_StartExecute both pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb
@tomlm
tomlm merged commit 429a75c into main Sep 2, 2026
1 check passed
@tomlm
tomlm deleted the ask-methods branch September 2, 2026 20:13
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