V3 features - #4
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Askfamily of methods for interactive user prompts, includingAskText,AskSecret,AskYesNo,AskNumber,AskChoice, andAskMultiChoice, along withRichPromptsandReadKeyproperties to control prompt behavior. [1] [2]CliAPI for declarative command-line argument parsing with generated help, supporting arguments, switches, options, and more. [1] [2]Breaking changes and dependency updates:
AsJson()now returns aJsonDynamic(backed byJsonNode), andAsJson<T>()uses System.Text.Json for deserialization. [1] [2] [3] [4] [5] [6]Documentation and usage updates:
README.mdwith detailed documentation and examples for the newAskandCliAPIs, revised JSON parsing sections, and updated code samples to reference version 3.0.0. [1] [2] [3] [4] [5] [6] [7] [8]Test updates:
JsonNodeinstead of Newtonsoft.Json andJObject, and added coverage for case-insensitive property matching and missing properties. [1] [2] [3] [4] [5]Automation and permissions:
.claude/settings.jsonto 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.