From 87ffd9041f02aa0f1d69b62f715f69bc7e4bb9c2 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Fri, 28 Aug 2026 12:44:28 -0700 Subject: [PATCH 01/12] Enable Claude Code for this repo 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. --- .claude/settings.json | 9 +++++ .github/workflows/claude-code-review.yml | 45 ++++++++++++++++++++++++ .github/workflows/claude.yml | 41 +++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ae48191 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet:*)", + "Bash(git:*)", + "Bash(xargs grep:*)" + ] + } +} diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e86c203 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,45 @@ +name: Claude Code Review + +on: + pull_request: + # No `synchronize`. That fires on every push to a PR, so a branch pushed five times gets + # five reviews of overlapping diffs. Once when it opens and once when it leaves draft is + # when a review is actually worth reading; push a fresh @claude comment for anything else. + types: [opened, ready_for_review, reopened] + +jobs: + claude-review: + # Reviewable pull requests only. Three cases this cannot or should not review: + # head.repo.fork -- a FORK pull request. GitHub gives these a read-only token, no + # id-token: write and NO SECRETS, so ANTHROPIC_API_KEY arrives + # empty and the action dies on OIDC. Not fixable by permissions; + # pull_request_target would fix it by running fork code WITH the + # secrets, which is worse than no review. + # user.type -- the PR was OPENED by a bot (Copilot and friends). + # github.actor -- a human's PR that Claude then PUSHED to. The push fires + # `synchronize`, and Claude would review its own commit. + # All three are still reviewable on demand: comment @claude on the PR. That runs on + # issue_comment, which is a base-repo event and does get the secrets. + if: github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.type != 'Bot' && github.actor != 'claude[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + # The Action does NOT read .claude/settings.json -- claude_args is what gates it. + claude_args: '--allowedTools "Bash(dotnet:*),Bash(gh pr edit:*),Bash(gh pr ready:*),Bash(gh pr view:*)"' + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..382dbb7 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,41 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + # The Action does NOT read .claude/settings.json -- claude_args is what gates it. + claude_args: '--allowedTools "Bash(dotnet:*),Bash(gh pr edit:*),Bash(gh pr ready:*),Bash(gh pr view:*)"' + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + additional_permissions: | + actions: read From a366d0cdd0d3839fb041aaa52d128373768cf093 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 09:55:41 -0700 Subject: [PATCH 02/12] Add the Ask family of console prompts 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- Tests/CShell.Tests/Ask.Tests.cs | 828 +++++++++++++++++++++++++++++ askdemo.csx | 311 +++++++++++ src/CShell.cs | 911 ++++++++++++++++++++++++++++++++ src/Globals.cs | 167 ++++++ 4 files changed, 2217 insertions(+) create mode 100644 Tests/CShell.Tests/Ask.Tests.cs create mode 100644 askdemo.csx diff --git a/Tests/CShell.Tests/Ask.Tests.cs b/Tests/CShell.Tests/Ask.Tests.cs new file mode 100644 index 0000000..0a062dc --- /dev/null +++ b/Tests/CShell.Tests/Ask.Tests.cs @@ -0,0 +1,828 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace CShellLibTests +{ + /// + /// The Ask family, in both of the modes it has. + /// + /// + /// Every test sets RichPrompts explicitly rather than letting it decide for itself. Left to + /// auto it reads Console.IsInputRedirected, which is a property of whoever is running the + /// tests -- these would then pass under one runner and pick the other mode under the next. + /// + [TestClass] + public class AskTests + { + private TextWriter originalOut; + private TextReader originalIn; + private StringWriter captured; + + [TestInitialize] + public void Capture() + { + this.originalOut = Console.Out; + this.originalIn = Console.In; + this.captured = new StringWriter(); + Console.SetOut(this.captured); + } + + [TestCleanup] + public void Restore() + { + Console.SetOut(this.originalOut); + Console.SetIn(this.originalIn); + } + + private string Screen => this.captured.ToString(); + + /// A shell reading typed lines. + private static CShell Typing(params string[] lines) + { + Console.SetIn(new StringReader(String.Join(Environment.NewLine, lines) + Environment.NewLine)); + return new CShell { RichPrompts = false }; + } + + /// A shell reading the given keystrokes, and nothing after them. + private static CShell Pressing(params ConsoleKeyInfo[] keys) + { + var queue = new Queue(keys); + return new CShell + { + RichPrompts = true, + ReadKey = () => queue.Count > 0 + ? queue.Dequeue() + : throw new InvalidOperationException("the prompt asked for more keys than the test scripted"), + }; + } + + private static ConsoleKeyInfo Key(ConsoleKey key) => new ConsoleKeyInfo('\0', key, false, false, false); + + private static ConsoleKeyInfo Ch(char c) => new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false); + + private static readonly ConsoleKeyInfo Enter = Key(ConsoleKey.Enter); + private static readonly ConsoleKeyInfo Up = Key(ConsoleKey.UpArrow); + private static readonly ConsoleKeyInfo Down = Key(ConsoleKey.DownArrow); + private static readonly ConsoleKeyInfo Left = Key(ConsoleKey.LeftArrow); + private static readonly ConsoleKeyInfo Right = Key(ConsoleKey.RightArrow); + private static readonly ConsoleKeyInfo Space = new ConsoleKeyInfo(' ', ConsoleKey.Spacebar, false, false, false); + + private static readonly string[] YesNoMaybe = new[] { "yes", "no", "maybe" }; + + // ------------------------------------------------------------------ AskText + + [TestMethod] + public void AskText_ReturnsWhatWasTyped() + { + Assert.AreEqual("Tom", Typing("Tom").AskText("Name?")); + } + + [TestMethod] + public void AskText_TrimsAndAsksAsWritten() + { + Assert.AreEqual("Tom", Typing(" Tom ").AskText("Name?")); + StringAssert.Contains(this.Screen, "Name?"); + } + + [TestMethod] + public void AskText_EmptyAnswerIsAnAnswer() + { + Assert.AreEqual(String.Empty, Typing("").AskText("Name?")); + } + + [TestMethod] + public void AskText_ThrowsAtEndOfStream() + { + Console.SetIn(new StringReader(String.Empty)); + var shell = new CShell { RichPrompts = false }; + + var thrown = Assert.Throws(() => shell.AskText("Name?")); + StringAssert.Contains(thrown.Message, "Name?"); + StringAssert.Contains(thrown.Message, "end of stream"); + } + + // ------------------------------------------------------------------ AskSecret + + [TestMethod] + public void AskSecret_ReadsKeysWithoutEchoingThem() + { + var shell = Pressing(Ch('h'), Ch('u'), Ch('n'), Ch('t'), Ch('2'), Enter); + + Assert.AreEqual("hunt2", shell.AskSecret("Password?")); + StringAssert.Contains(this.Screen, "Password?"); + Assert.IsFalse(this.Screen.Contains("hunt2"), "the secret must never reach the screen"); + } + + [TestMethod] + public void AskSecret_BackspaceErases() + { + var shell = Pressing(Ch('a'), Ch('b'), Key(ConsoleKey.Backspace), Ch('c'), Enter); + + Assert.AreEqual("ac", shell.AskSecret("Password?")); + } + + [TestMethod] + public void AskSecret_IgnoresKeysThatCarryNoCharacter() + { + var shell = Pressing(Ch('a'), Key(ConsoleKey.F1), Up, Ch('b'), Enter); + + Assert.AreEqual("ab", shell.AskSecret("Password?")); + } + + [TestMethod] + public void AskSecret_FallsBackToALineWhenThereAreNoKeys() + { + // The case that matters: piped or CI input, where Console.ReadKey() would throw. + Assert.AreEqual("sk-ant-oat01", Typing(" sk-ant-oat01 ").AskSecret("Token?")); + } + + // ------------------------------------------------------------------ AskChoice, typed + + [TestMethod] + public void AskChoice_TypedNumberChoosesByPosition() + { + Assert.AreEqual("no", Typing("2").AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_TypedTextChoosesByName() + { + Assert.AreEqual("maybe", Typing("MAYBE").AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_RejectedAnswerAsksAgain() + { + Assert.AreEqual("yes", Typing("nope", "1").AskChoice("Continue?", YesNoMaybe)); + StringAssert.Contains(this.Screen, "'nope' is not one of the above."); + } + + [TestMethod] + public void AskChoice_EmptyAnswerAsksAgain() + { + Assert.AreEqual("yes", Typing("", "1").AskChoice("Continue?", YesNoMaybe)); + StringAssert.Contains(this.Screen, "Pick one of the above."); + } + + [TestMethod] + public void AskChoice_OptionTextBeatsPositionNumber() + { + // The list reads 1) 3 2) 1 3) 2. Typing 3 must pick the option LABELLED 3, which + // is the first, not the third. Position-first matching silently picked "2" here. + var numbered = new[] { "3", "1", "2" }; + + Assert.AreEqual("3", Typing("3").AskChoice("Pick:", numbered)); + } + + [TestMethod] + public void AskChoice_LettersAreAnsweredByLetter() + { + Assert.AreEqual("maybe", Typing("c").AskChoice("Continue?", ChoiceStyle.Letters, YesNoMaybe)); + StringAssert.Contains(this.Screen, "c) maybe"); + StringAssert.Contains(this.Screen, "[a-c]"); + } + + [TestMethod] + public void AskChoice_LettersDoNotAnswerToNumbers() + { + // Under Letters a bare 2 names nothing, so it is rejected rather than quietly + // taken as a position. + Assert.AreEqual("no", Typing("2", "b").AskChoice("Continue?", ChoiceStyle.Letters, YesNoMaybe)); + StringAssert.Contains(this.Screen, "'2' is not one of the above."); + } + + [TestMethod] + public void AskChoice_NoneIsAnsweredByTextAlone() + { + // Nothing is printed in front of the options, so a position number names nothing: + // it is refused, and the prompt asks with "> " rather than advertising "[1-3]" over + // a list with no numbers in it. + Assert.AreEqual("no", Typing("2", "no").AskChoice("Continue?", ChoiceStyle.None, YesNoMaybe)); + + StringAssert.Contains(this.Screen, " yes"); + StringAssert.Contains(this.Screen, "> "); + Assert.IsFalse(this.Screen.Contains("[1-3]"), "None must not ask for numbers it never showed"); + StringAssert.Contains(this.Screen, "'2' is not one of the above."); + } + + [TestMethod] + public void AskChoice_NoOptionsIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskChoice("Pick:", new string[0])); + } + + [TestMethod] + public void AskChoice_TooManyToLetterIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + var tooMany = new string[27]; + for (int i = 0; i < tooMany.Length; i++) + { + tooMany[i] = "option" + i; + } + + var thrown = Assert.Throws( + () => shell.AskChoice("Pick:", ChoiceStyle.Letters, tooMany)); + StringAssert.Contains(thrown.Message, "26 letters"); + } + + // ------------------------------------------------------------------ ChoiceStyle.Auto + + [TestMethod] + public void Auto_NumbersTheListWhenTheAnswerMustBeTyped() + { + // Without keys the label is the only thing saying what to type. A bare list under a + // "[1-3]" prompt would leave you counting rows. + Assert.AreEqual("no", Typing("2").AskChoice("Continue?", ChoiceStyle.Auto, YesNoMaybe)); + + StringAssert.Contains(this.Screen, "1) yes"); + StringAssert.Contains(this.Screen, "[1-3]"); + } + + [TestMethod] + public void Auto_LeavesTheListBareWhenThereAreArrowKeys() + { + // The selection is the affordance; a label on every row is noise. + Assert.AreEqual("no", Pressing(Down, Enter).AskChoice("Continue?", ChoiceStyle.Auto, YesNoMaybe)); + + Assert.IsFalse(this.Screen.Contains("1) yes"), "Auto should not number a list you can arrow through"); + StringAssert.Contains(this.Screen, "[no]"); + } + + [TestMethod] + public void Auto_IsWhatTheShortOverloadPasses() + { + Pressing(Enter).AskChoice("Continue?", YesNoMaybe); + Assert.IsFalse(this.Screen.Contains("1) yes"), "the short overload should be Auto, not Numbers"); + + Capture(); + Typing("1").AskChoice("Continue?", YesNoMaybe); + StringAssert.Contains(this.Screen, "1) yes"); + } + + [TestMethod] + public void Auto_DoesNotChangeWhatATypedAnswerMeans() + { + // Position numbers still answer, and option text still beats them. + Assert.AreEqual("maybe", Typing("3").AskChoice("Continue?", ChoiceStyle.Auto, YesNoMaybe)); + + Capture(); + Assert.AreEqual("3", Typing("3").AskChoice("Pick:", ChoiceStyle.Auto, new[] { "3", "1", "2" })); + } + + [TestMethod] + public void Auto_AppliesToMultiChoiceToo() + { + CollectionAssert.AreEqual( + new[] { "yes", "maybe" }, + Typing("1,3").AskMultiChoice("Pick some:", ChoiceStyle.Auto, YesNoMaybe)); + StringAssert.Contains(this.Screen, "1) yes"); + + Capture(); + var picked = Pressing(Space, Enter).AskMultiChoice("Pick some:", YesNoMaybe); + CollectionAssert.AreEqual(new[] { "yes" }, picked); + Assert.IsFalse(this.Screen.Contains("1) "), "the short overload should be Auto here as well"); + } + + [TestMethod] + public void None_StaysBareEvenWhenTheAnswerMustBeTyped() + { + // Auto is the one that adapts. None is the explicit "I really do want a bare list", + // and answering it means naming an option. + Assert.AreEqual("no", Typing("no").AskChoice("Continue?", ChoiceStyle.None, YesNoMaybe)); + + Assert.IsFalse(this.Screen.Contains("1) yes")); + StringAssert.Contains(this.Screen, " yes"); + } + + // ------------------------------------------------------------------ generic options + + private record Repo(string Name, int Stars); + + private static Repo[] Repos => new[] + { + new Repo("cshell", 42), + new Repo("scripts", 7), + new Repo("crazor", 99), + }; + + [TestMethod] + public void AskChoice_ReturnsTheOptionItselfNotItsPosition() + { + var chosen = Typing("scripts").AskChoice("Pick a repo:", Repos, r => r.Name); + + Assert.AreEqual("scripts", chosen.Name); + Assert.AreEqual(7, chosen.Stars); + } + + [TestMethod] + public void AskChoice_LabelIsWhatIsShownAndWhatIsTyped() + { + // Without the selector these would be shown as "Repo { Name = ... }" and would have + // to be answered that way too. The label governs both. + var chosen = Typing("crazor").AskChoice("Pick a repo:", Repos, r => r.Name); + + Assert.AreEqual("crazor", chosen.Name); + StringAssert.Contains(this.Screen, "1) cshell"); + Assert.IsFalse(this.Screen.Contains("Stars ="), "the selector should decide what is shown"); + } + + [TestMethod] + public void AskChoice_WithoutASelectorItUsesToString() + { + Assert.AreEqual(7, Typing("7").AskChoice("Pick a number:", new[] { 42, 7, 99 })); + } + + [TestMethod] + public void AskChoice_TakesAnyEnumerableNotJustAnArray() + { + // A List would have collapsed into a single option under a params signature. + var names = new List { "alpha", "beta" }; + + Assert.AreEqual("beta", Typing("beta").AskChoice("Pick:", names)); + + Capture(); + var lazy = Repos.Where(r => r.Stars > 10); + Assert.AreEqual("crazor", Typing("crazor").AskChoice("Pick:", lazy, r => r.Name).Name); + } + + [TestMethod] + public void AskMultiChoice_ReturnsTheOptionsThemselves() + { + var chosen = Typing("cshell, crazor").AskMultiChoice("Pick repos:", Repos, r => r.Name); + + CollectionAssert.AreEqual(new[] { "cshell", "crazor" }, chosen.Select(r => r.Name).ToArray()); + Assert.AreEqual(42, chosen[0].Stars); + } + + [TestMethod] + public void AskChoice_NullOptionsIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskChoice("Pick:", (IEnumerable)null)); + } + + [TestMethod] + public void AskChoice_ANullOptionLabelsAsEmptyRatherThanThrowing() + { + // A hole in the list is something to see on screen, not a reason to take the prompt + // down mid-question. + Assert.AreEqual("b", Typing("2").AskChoice("Pick:", new[] { null, "b" })); + } + + [TestMethod] + public void AskChoice_NoneOffersNothingToJumpToWithAKey() + { + // The consequence of the rule, in the mode Auto picks when there are keys: with no + // numbers on screen, a digit names nothing and the selection stays put. The arrow + // keys are the way around a bare list. + Assert.AreEqual("yes", Pressing(Ch('3'), Enter).AskChoice("Continue?", ChoiceStyle.None, YesNoMaybe)); + } + + // ------------------------------------------------------------------ AskChoice, keys + + [TestMethod] + public void AskChoice_EnterTakesTheFirstOption() + { + Assert.AreEqual("yes", Pressing(Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_DownArrowMovesTheSelection() + { + Assert.AreEqual("maybe", Pressing(Down, Down, Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_SelectionWraps() + { + Assert.AreEqual("maybe", Pressing(Up, Enter).AskChoice("Continue?", YesNoMaybe)); + Assert.AreEqual("yes", Pressing(Down, Down, Down, Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_HomeAndEndJumpToTheEnds() + { + Assert.AreEqual("maybe", Pressing(Key(ConsoleKey.End), Enter).AskChoice("Continue?", YesNoMaybe)); + Assert.AreEqual("yes", Pressing(Down, Key(ConsoleKey.Home), Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_SelectionIsDrawnInBrackets() + { + Pressing(Down, Enter).AskChoice("Continue?", YesNoMaybe); + + StringAssert.Contains(this.Screen, "[no]"); + } + + [TestMethod] + public void AskChoice_TypingAMarkerJumpsButStillWaitsForEnter() + { + // '3' moves to the third option; without the enter this would not return at all. + // Explicitly Numbers: under a bare list there is no "3" on screen to jump to. + Assert.AreEqual("maybe", Pressing(Ch('3'), Enter).AskChoice("Continue?", ChoiceStyle.Numbers, YesNoMaybe)); + } + + // ------------------------------------------------------------------ AskMultiChoice, typed + + [TestMethod] + public void AskMultiChoice_TakesACommaSeparatedList() + { + CollectionAssert.AreEqual(new[] { "yes", "maybe" }, Typing("1,3").AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_MixesTextAndNumbers() + { + CollectionAssert.AreEqual(new[] { "no", "maybe" }, Typing("no, 3").AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_ResultIsInListOrderAndDistinct() + { + CollectionAssert.AreEqual(new[] { "yes", "no" }, Typing("2,1,2").AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_BlankChoosesNothing() + { + Assert.AreEqual(0, Typing("").AskMultiChoice("Pick some:", YesNoMaybe).Length); + } + + [TestMethod] + public void AskMultiChoice_OneBadPartRejectsTheWholeAnswer() + { + // Not "select the ones I understood" -- a partial selection nobody asked for is + // worse than asking again. + CollectionAssert.AreEqual(new[] { "yes" }, Typing("1,nope", "1").AskMultiChoice("Pick some:", YesNoMaybe)); + StringAssert.Contains(this.Screen, "'nope' is not one of the above."); + } + + [TestMethod] + public void AskMultiChoice_SplitsOnCommasOnlySoNamesMayHaveSpaces() + { + var cities = new[] { "New York", "San Jose" }; + + CollectionAssert.AreEqual(new[] { "New York", "San Jose" }, Typing("New York, San Jose").AskMultiChoice("Where?", cities)); + } + + [TestMethod] + public void AskMultiChoice_OptionTextBeatsPositionNumber() + { + var numbered = new[] { "3", "1", "2" }; + + CollectionAssert.AreEqual(new[] { "3" }, Typing("3").AskMultiChoice("Pick some:", numbered)); + } + + [TestMethod] + public void AskMultiChoice_LettersAreAnsweredByLetter() + { + CollectionAssert.AreEqual( + new[] { "yes", "maybe" }, + Typing("a,c").AskMultiChoice("Pick some:", ChoiceStyle.Letters, YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_NoOptionsIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskMultiChoice("Pick some:", new string[0])); + } + + // ------------------------------------------------------------------ AskMultiChoice, keys + + [TestMethod] + public void AskMultiChoice_SpaceChecksTheOptionUnderTheCursor() + { + var shell = Pressing(Space, Down, Down, Space, Enter); + + CollectionAssert.AreEqual(new[] { "yes", "maybe" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_SpaceTogglesBackOff() + { + var shell = Pressing(Space, Space, Down, Space, Enter); + + CollectionAssert.AreEqual(new[] { "no" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_EnterWithNothingCheckedChoosesNothing() + { + Assert.AreEqual(0, Pressing(Enter).AskMultiChoice("Pick some:", YesNoMaybe).Length); + } + + [TestMethod] + public void AskMultiChoice_CursorAndChecksAreDrawnSeparately() + { + Pressing(Space, Down, Enter).AskMultiChoice("Pick some:", YesNoMaybe); + + // The checked first option, and the cursor now resting on the unchecked second. + StringAssert.Contains(this.Screen, "[x] yes"); + StringAssert.Contains(this.Screen, "> "); + StringAssert.Contains(this.Screen, "[ ] no"); + } + + [TestMethod] + public void AskMultiChoice_CursorWraps() + { + var shell = Pressing(Up, Space, Enter); + + CollectionAssert.AreEqual(new[] { "maybe" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_TypingAMarkerMovesTheCursorWithoutChecking() + { + // '3' jumps to the third option; only the space that follows checks it. + var shell = Pressing(Ch('3'), Space, Enter); + + CollectionAssert.AreEqual( + new[] { "maybe" }, + shell.AskMultiChoice("Pick some:", ChoiceStyle.Numbers, YesNoMaybe)); + } + + // ------------------------------------------------------------------ AskNumber, typed + + [TestMethod] + public void AskNumber_ReturnsTheNumberTyped() + { + Assert.AreEqual(42, Typing("42").AskNumber("How many?")); + } + + [TestMethod] + public void AskNumber_UnboundedStillShowsAPrompt() + { + Typing("-42").AskNumber("How many?"); + + // A bare cursor under a question reads as a hang rather than a prompt. + StringAssert.Contains(this.Screen, "> "); + } + + [TestMethod] + public void AskNumber_OutOfRangeAsksAgain() + { + Assert.AreEqual(3, Typing("9", "3").AskNumber("How many?", 1, 5)); + StringAssert.Contains(this.Screen, "9 is outside 1 to 5."); + } + + [TestMethod] + public void AskNumber_NotANumberAsksAgain() + { + Assert.AreEqual(3, Typing("three", "3").AskNumber("How many?", 1, 5)); + StringAssert.Contains(this.Screen, "'three' is not a number."); + } + + [TestMethod] + public void AskNumber_EmptyRangeIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskNumber("How many?", 5, 1)); + } + + // ------------------------------------------------------------------ AskNumber, keys + + [TestMethod] + public void AskNumber_ArrowsStepTheValue() + { + Assert.AreEqual(3, Pressing(Up, Up, Up, Enter).AskNumber("How many?", 0, 10)); + Assert.AreEqual(1, Pressing(Up, Up, Down, Enter).AskNumber("How many?", 0, 10)); + } + + [TestMethod] + public void AskNumber_ArrowsAreHeldToTheRange() + { + // Starts clamped into range at 1, and cannot be stepped below it. + Assert.AreEqual(1, Pressing(Down, Down, Down, Enter).AskNumber("How many?", 1, 5)); + Assert.AreEqual(5, Pressing(Up, Up, Up, Up, Up, Up, Up, Enter).AskNumber("How many?", 1, 5)); + } + + [TestMethod] + public void AskNumber_DigitsAreTyped() + { + Assert.AreEqual(12, Pressing(Key(ConsoleKey.Backspace), Ch('1'), Ch('2'), Enter).AskNumber("How many?", 0, 99)); + } + + [TestMethod] + public void AskNumber_EnterIsRefusedWhileTheValueIsOutOfRange() + { + // 7 is outside 1-5, so the first enter does nothing; backspace then 4 makes it valid. + var shell = Pressing(Ch('7'), Enter, Key(ConsoleKey.Backspace), Key(ConsoleKey.Backspace), Ch('4'), Enter); + + Assert.AreEqual(4, shell.AskNumber("How many?", 1, 5)); + } + + // ------------------------------------------------------------------ AskYesNo, typed + + [TestMethod] + public void AskYesNo_AnswersYesAndNo() + { + Assert.IsTrue(Typing("y").AskYesNo("Sure?")); + Assert.IsTrue(Typing("YES").AskYesNo("Sure?")); + Assert.IsFalse(Typing("n").AskYesNo("Sure?")); + Assert.IsFalse(Typing("No").AskYesNo("Sure?")); + } + + [TestMethod] + public void AskYesNo_EnterTakesTheDefault() + { + Assert.IsFalse(Typing("").AskYesNo("Push to main?", false)); + Assert.IsTrue(Typing("").AskYesNo("Keep the backup?", true)); + } + + [TestMethod] + public void AskYesNo_ShowsTheDefaultCapitalised() + { + Typing("").AskYesNo("Push to main?", false); + StringAssert.Contains(this.Screen, "[y/N]"); + + Capture(); + Typing("").AskYesNo("Keep the backup?", true); + StringAssert.Contains(this.Screen, "[Y/n]"); + } + + [TestMethod] + public void AskYesNo_WithNoDefaultEnterAsksAgain() + { + Assert.IsTrue(Typing("", "y").AskYesNo("Sure?")); + StringAssert.Contains(this.Screen, "[y/n]"); + StringAssert.Contains(this.Screen, "Answer y or n."); + } + + // ------------------------------------------------------------------ AskYesNo, keys + + [TestMethod] + public void AskYesNo_KeysMoveTheSelectionAndEnterTakesIt() + { + Assert.IsTrue(Pressing(Ch('y'), Enter).AskYesNo("Sure?")); + Assert.IsFalse(Pressing(Ch('n'), Enter).AskYesNo("Sure?")); + } + + [TestMethod] + public void AskYesNo_AKeyOnItsOwnDoesNotAnswer() + { + // Pressing() throws once the scripted keys run out, which is only reachable if 'y' + // did not answer on its own. One keystroke is never enough to answer a question. + var thrown = Assert.Throws(() => Pressing(Ch('y')).AskYesNo("Sure?")); + + StringAssert.Contains(thrown.Message, "more keys than the test scripted"); + } + + [TestMethod] + public void AskYesNo_KeysCanBeChangedBeforeEnter() + { + // Reached for y, thought better of it. Nothing was committed on the way. + Assert.IsFalse(Pressing(Ch('y'), Ch('n'), Enter).AskYesNo("Delete everything?", false)); + } + + [TestMethod] + public void AskYesNo_ArrowsMoveBetweenThem() + { + Assert.IsFalse(Pressing(Right, Enter).AskYesNo("Push to main?", true)); + Assert.IsTrue(Pressing(Left, Enter).AskYesNo("Push to main?", false)); + } + + [TestMethod] + public void AskYesNo_EnterTakesTheSelectedSide() + { + Assert.IsTrue(Pressing(Enter).AskYesNo("Keep the backup?", true)); + Assert.IsFalse(Pressing(Enter).AskYesNo("Push to main?", false)); + } + + [TestMethod] + public void AskYesNo_SelectionIsDrawnInBrackets() + { + Pressing(Enter).AskYesNo("Push to main?", false); + + StringAssert.Contains(this.Screen, "[No]"); + } + + [TestMethod] + public void AskYesNo_IgnoresKeysThatMeanNothing() + { + Assert.IsTrue(Pressing(Key(ConsoleKey.F1), Ch('q'), Enter).AskYesNo("Sure?", true)); + } + + // ------------------------------------------------------------------ the rich-path gaps + + [TestMethod] + public void AskNumber_MinusSignTypesANegative() + { + // The value starts at 0, so the minus has to follow a backspace. askdemo tells people + // to try this, and nothing was checking it worked. + var shell = Pressing(Key(ConsoleKey.Backspace), Ch('-'), Ch('4'), Ch('2'), Enter); + + Assert.AreEqual(-42, shell.AskNumber("Any whole number?")); + } + + [TestMethod] + public void AskNumber_MinusSignOnlyLeads() + { + // '4', then '-', then '2'. The minus arrives with digits already typed and is + // dropped rather than landing in the middle of the number. + var shell = Pressing(Ch('4'), Ch('-'), Ch('2'), Enter); + + Assert.AreEqual(42, shell.AskNumber("Any whole number?")); + } + + [TestMethod] + public void AskNumber_UnboundedTakesKeysAndShowsNoRange() + { + Assert.AreEqual(2, Pressing(Up, Up, Enter).AskNumber("Any whole number?")); + + StringAssert.Contains(this.Screen, "Any whole number?"); + Assert.IsFalse(this.Screen.Contains("["), "an unbounded ask has no range to advertise"); + } + + [TestMethod] + public void AskNumber_UnboundedArrowsGoNegative() + { + Assert.AreEqual(-2, Pressing(Down, Down, Enter).AskNumber("Any whole number?")); + } + + [TestMethod] + public void AskMultiChoice_HomeAndEndJumpToTheEnds() + { + var shell = Pressing(Key(ConsoleKey.End), Space, Key(ConsoleKey.Home), Space, Enter); + + CollectionAssert.AreEqual(new[] { "yes", "maybe" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskYesNo_TabMovesBetweenThemToo() + { + Assert.IsFalse(Pressing(Key(ConsoleKey.Tab), Enter).AskYesNo("Push to main?", true)); + Assert.IsTrue(Pressing(Key(ConsoleKey.Tab), Enter).AskYesNo("Push to main?", false)); + } + + [TestMethod] + public void AskChoice_LettersJumpByLetterWhenThereAreKeys() + { + Assert.AreEqual("maybe", Pressing(Ch('c'), Enter).AskChoice("Continue?", ChoiceStyle.Letters, YesNoMaybe)); + + // An explicit style labels the list even in the mode Auto would have left bare. + StringAssert.Contains(this.Screen, "c) "); + } + + [TestMethod] + public void AskMultiChoice_TakesAStyleWhenThereAreKeys() + { + var shell = Pressing(Ch('b'), Space, Enter); + + CollectionAssert.AreEqual( + new[] { "no" }, + shell.AskMultiChoice("Pick some:", ChoiceStyle.Letters, YesNoMaybe)); + StringAssert.Contains(this.Screen, "b) "); + } + + [TestMethod] + public void AskSecret_TrimsWhatWasTypedToo() + { + // The line-reading fallback trims; so does the key path, so a pasted token with a + // stray space either side behaves the same whichever mode caught it. + var shell = Pressing(Ch(' '), Ch('a'), Ch('b'), Ch(' '), Enter); + + Assert.AreEqual("ab", shell.AskSecret("Token?")); + } + + [TestMethod] + public void AskChoice_ASelectorReturningNullLabelsAsEmpty() + { + // Blank rows on screen, but still answerable by position, and no exception out of + // the middle of a prompt. + Assert.AreEqual(2, Typing("2").AskChoice("Pick:", new[] { 1, 2 }, x => null)); + } + + [TestMethod] + public void AskMultiChoice_SaysItsOwnNameWhenTheOptionsAreImpossible() + { + // Shared validation, but the message names the method the caller actually called. + var shell = new CShell { RichPrompts = false }; + + var nothing = Assert.Throws( + () => shell.AskMultiChoice("Pick some:", (IEnumerable)null)); + StringAssert.Contains(nothing.Message, "AskMultiChoice"); + + var tooMany = new string[27]; + for (int i = 0; i < tooMany.Length; i++) + { + tooMany[i] = "option" + i; + } + + var lettered = Assert.Throws( + () => shell.AskMultiChoice("Pick some:", ChoiceStyle.Letters, tooMany)); + StringAssert.Contains(lettered.Message, "AskMultiChoice"); + StringAssert.Contains(lettered.Message, "26 letters"); + } + } +} diff --git a/askdemo.csx b/askdemo.csx new file mode 100644 index 0000000..66fe7c9 --- /dev/null +++ b/askdemo.csx @@ -0,0 +1,311 @@ +#!/usr/bin/env dotnet-script +#r "nuget: MedallionShell, 1.6.2" +#r "src/bin/Debug/netstandard2.0/CShell.dll" + +using CShellNet; +using static CShellNet.Globals; + +// askdemo -- a quick tour of every AskXXX() method. Each one shows you the code, then +// runs exactly that code so you can answer it. +// +// dotnet script askdemo.csx arrow keys, if you're at a terminal +// dotnet script askdemo.csx -- -plain typed answers instead +// echo ... | dotnet script askdemo.csx typed, because it has no choice +// +// Build the library first: dotnet build src + +if (Args.Any(a => a is "-h" or "-?" or "--help")) +{ + Console.WriteLine("askdemo [-plain|-rich]"); + Console.WriteLine(" A tour of AskText, AskSecret, AskYesNo, AskNumber,"); + Console.WriteLine(" AskChoice and AskMultiChoice."); + Console.WriteLine(); + Console.WriteLine(" -plain typed answers"); + Console.WriteLine(" -rich arrow keys"); + return; +} + +if (Args.Any(a => a is "-plain" or "--plain")) RichPrompts = false; +if (Args.Any(a => a is "-rich" or "--rich")) RichPrompts = true; + +var rich = RichPrompts ?? !Console.IsInputRedirected; + +// Strip the leading indentation the verbatim snippets below carry, keeping the relative +// indent inside a snippet so a wrapped argument still lines up. +string[] Dedent(string text) +{ + var lines = text.Replace("\r\n", "\n").Split('\n') + .SkipWhile(l => l.Trim().Length == 0).ToList(); + while (lines.Count > 0 && lines[lines.Count - 1].Trim().Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + var indent = lines.Where(l => l.Trim().Length > 0) + .Select(l => l.Length - l.TrimStart().Length) + .DefaultIfEmpty(0).Min(); + + return lines.Select(l => l.Length >= indent ? l.Substring(indent) : l.Trim()).ToArray(); +} + +// The code you're about to run, in a box. Every section below keeps the box and the real +// call next to each other, so the two can't quietly drift apart. +void Box(string code) +{ + var lines = Dedent(code); + var width = Math.Max(68, lines.Max(l => l.Length)); + + Console.WriteLine(" ┌─" + new string('─', width) + "─┐"); + foreach (var line in lines) + { + Console.WriteLine(" │ " + line.PadRight(width) + " │"); + } + + Console.WriteLine(" └─" + new string('─', width) + "─┘"); +} + +void Lesson(string title, string description, string code, string richHint, string plainHint) +{ + Console.WriteLine(); + Console.WriteLine("==== " + title + " " + new string('=', Math.Max(4, 78 - title.Length - 6))); + foreach (var line in description.Replace("\r\n", "\n").Split('\n')) + { + Console.WriteLine(" " + line.Trim()); + } + + Console.WriteLine(); + Box(code); + Console.WriteLine(); + Console.WriteLine(" TRY: " + (rich ? richHint : plainHint)); + Console.WriteLine(); +} + +Console.WriteLine("══ The Ask Methods ═══════════════════════════════════════════════════════════"); +Console.WriteLine(); +Console.WriteLine(" The Ask() methods are prompts for asking whoever's running your script a question."); +Console.WriteLine(); +Console.WriteLine(); + +// Guarded on IsInputRedirected rather than on `rich`, because that is the thing ReadKey() +// actually needs. Piping answers in would otherwise eat one of them here. +if (!Console.IsInputRedirected) +{ + Console.Write(" Hit any key to start."); + Console.ReadKey(intercept: true); + Console.WriteLine(); + Console.WriteLine(); +} + +// If the input runs out we stop here, naming the question nobody answered. +try +{ + // ---------------------------------------------------------------- AskText + + Lesson("AskText(string question) -> string", + @"Grab a line of text. Whatever they type, trimmed. + Blank counts as an answer, so check for it if you care.", + @"var name = AskText(""What should I call you?"");", + "type a name and hit enter.", + "type a name and hit enter."); + + var name = AskText("What should I call you?"); + Console.WriteLine($" -> \"{name}\"{(name.Length == 0 ? " (nothing is a valid answer)" : "")}"); + + // ---------------------------------------------------------------- AskSecret + + Lesson("AskSecret(string question) -> string", + @"Same, but nothing appears as they type -- for tokens and passwords you'd + rather not leave sitting on the screen. Backspace still works. + If input is piped it just reads a line; there's no screen to leak onto.", + @"var secret = AskSecret(""Paste a token (nothing will appear):"");", + "type something. You won't see it. Enter when you're done.", + "type or paste a value and hit enter."); + + var secret = AskSecret("Paste a token (nothing will appear):"); + Console.WriteLine(secret.Length > 0 + ? $" -> {secret.Length} characters, starting {secret.Substring(0, Math.Min(4, secret.Length))}... (never printed in full)" + : " -> nothing entered"); + + // ---------------------------------------------------------------- AskYesNo + + Lesson("AskYesNo(string question) -> bool", + @"A yes/no question. + Enter is what answers. y and n just move the highlight, so a stray + keypress can't commit you to anything.", + @"var sure = AskYesNo(""Ready to see the rest?"");", + "left/right, tab, or y/n to move. Enter to answer.", + "type y, yes, n or no. Enter on its own just asks again."); + + var sure = AskYesNo("Ready to see the rest?"); + Console.WriteLine($" -> {sure}"); + + Lesson("AskYesNo(string question, bool defaultAnswer) -> bool", + @"Pass a default and enter takes it. + The capital in [y/N] tells you which one that is. Make it the safe + answer -- enter is what people press without reading.", + @" + var push = AskYesNo(""Push straight to main?"", false); + var backup = AskYesNo(""Keep a backup first?"", true);", + "hit enter for the default, or move off it first.", + "hit enter for the default, or type y/n to override."); + + var push = AskYesNo("Push straight to main?", false); + Console.WriteLine($" -> {push} (enter would have meant No)"); + + var backup = AskYesNo("Keep a backup first?", true); + Console.WriteLine($" -> {backup} (enter would have meant Yes)"); + + // ---------------------------------------------------------------- AskNumber + + Lesson("AskNumber(string question, int min, int max) -> int", + @"A whole number, kept inside the range you give it. + Arrows nudge it up and down, digits type it. It won't let the value + wander outside min..max, so you can't be handed one you'd refuse.", + @"var retries = AskNumber(""How many retries?"", 1, 5);", + "up/down to step, or type digits. Backspace edits. Enter accepts.", + "type a number from 1 to 5. Try 9 first and watch it say no."); + + var retries = AskNumber("How many retries?", 1, 5); + Console.WriteLine($" -> {retries}"); + + Lesson("AskNumber(string question) -> int", + @"Same thing without a range. Any whole number, negatives included.", + @"var anything = AskNumber(""Any whole number at all?"");", + "arrows and digits, same as before. Try a minus sign.", + "type any whole number."); + + var anything = AskNumber("Any whole number at all?"); + Console.WriteLine($" -> {anything}"); + + // ---------------------------------------------------------------- AskChoice + + Lesson("AskChoice(string question, IEnumerable options) -> T", + @"Pick one from a list. You get the option itself back, not its position. + Anything enumerable will do -- an array, a List, a LINQ query.", + @" + string[] fruits = [""apple"", ""banana"", ""cherry""]; + + var fruit = AskChoice(""Pick a fruit:"", fruits);", + "up/down to move (it wraps), home/end to jump, enter to pick.", + "type the number, or the option itself -- 'banana' works as well as 2."); + + string[] fruits = ["apple", "banana", "cherry"]; + + var fruit = AskChoice("Pick a fruit:", fruits); + Console.WriteLine($" -> {fruit}"); + + Lesson("AskChoice(..., ChoiceStyle style, ...) -> T", + @"ChoiceStyle sets the labels: Auto, Numbers, Letters or None. + Auto is the default -- no labels when there are arrow keys, numbers when + the answer has to be typed. Letters also changes what they can type: + 'b' picks the second one, and a bare '2' means nothing.", + @" + var lettered = AskChoice(""Pick again, by letter:"", + ChoiceStyle.Letters, fruits);", + "arrows as before. Typing 'c' jumps there, but enter still picks.", + "type a, b or c. Try 2 first and watch it bounce."); + + var lettered = AskChoice("Pick again, by letter:", ChoiceStyle.Letters, fruits); + Console.WriteLine($" -> {lettered}"); + + Lesson("ChoiceStyle.None", + @"None puts nothing in front of the options. With nothing on screen to + reference, you answer with the option's own text -- a number would be + naming something the list never showed.", + @" + var colour = AskChoice(""Pick a colour:"", ChoiceStyle.None, + [""red"", ""green"", ""blue""]);", + "arrows and enter, as ever.", + "type the colour itself -- 'green'. A number gets bounced."); + + var colour = AskChoice("Pick a colour:", ChoiceStyle.None, ["red", "green", "blue"]); + Console.WriteLine($" -> {colour}"); + + Lesson("AskChoice(..., Func label) -> T", + @"Options don't have to be strings. Hand it your own objects and a selector + saying what to show for each, and you get the object back -- no lookup. + The label is also what they type, so they answer with what they can see.", + @" + var repos = new[] + { + (Name: ""cshell"", Stars: 42), + (Name: ""scripts"", Stars: 7), + (Name: ""crazor"", Stars: 99), + }; + + var repo = AskChoice(""Pick a repo:"", repos, r => r.Name);", + "arrows and enter, same as always.", + "type a repo name, or its number."); + + var repos = new[] + { + (Name: "cshell", Stars: 42), + (Name: "scripts", Stars: 7), + (Name: "crazor", Stars: 99), + }; + + var repo = AskChoice("Pick a repo:", repos, r => r.Name); + Console.WriteLine($" -> {repo.Name}, which has {repo.Stars} stars (a whole tuple back, not an index)"); + + // ---------------------------------------------------------------- AskMultiChoice + + Lesson("AskMultiChoice(string question, IEnumerable options) -> T[]", + @"Pick as many as you like. You get the options themselves back. + The > shows where you are, [x] shows what's checked -- two marks, because + they're two different things. + Picking nothing is a real answer: you get an empty array rather than being + asked again. If you need at least one, say so yourself, like the loop below.", + @" + string[] toppings = [""cheese"", ""tomato"", ""basil"", ""olives""]; + + string[] chosen; + do + { + chosen = AskMultiChoice(""Choose your toppings:"", toppings); + } + while (chosen.Length == 0);", + "up/down to move, SPACE to check, enter when done. Try enter with nothing checked.", + "a comma separated list: '1,3' or 'cheese, basil'. Only commas split, so names can have spaces."); + + string[] toppings = ["cheese", "tomato", "basil", "olives"]; + + string[] chosen; + do + { + chosen = AskMultiChoice("Choose your toppings:", toppings); + if (chosen.Length == 0) + { + Console.WriteLine(" -> nothing chosen, which is allowed -- this demo is the one"); + Console.WriteLine(" asking for at least one. Go again."); + } + } + while (chosen.Length == 0); + + Console.WriteLine($" -> {string.Join(", ", chosen)}"); + + // ---------------------------------------------------------------- summary + + Console.WriteLine(); + Console.WriteLine("══ What you said ════════════════════════════════════════════════════════════"); + Console.WriteLine(); + Console.WriteLine($" AskText {name}"); + Console.WriteLine($" AskSecret {secret.Length} characters (never printed)"); + Console.WriteLine($" AskYesNo {sure}"); + Console.WriteLine($" AskYesNo(false) {push}"); + Console.WriteLine($" AskYesNo(true) {backup}"); + Console.WriteLine($" AskNumber(1,5) {retries}"); + Console.WriteLine($" AskNumber {anything}"); + Console.WriteLine($" AskChoice {fruit}"); + Console.WriteLine($" ..Letters {lettered}"); + Console.WriteLine($" ..None {colour}"); + Console.WriteLine($" ..selector {repo.Name}"); + Console.WriteLine($" AskMultiChoice {string.Join(", ", chosen)}"); + Console.WriteLine(); +} +catch (InvalidOperationException e) +{ + // The input ran out -- a pipe with too few lines in it, most likely. + Console.WriteLine(); + Console.WriteLine(e.Message); + Environment.Exit(1); +} diff --git a/src/CShell.cs b/src/CShell.cs index 0576c95..95a57a5 100644 --- a/src/CShell.cs +++ b/src/CShell.cs @@ -7,6 +7,30 @@ namespace CShellNet { + /// + /// How AskChoice() labels the options it offers. + /// + public enum ChoiceStyle + { + /// + /// Whatever the mode can afford: nothing at all when there are arrow keys to pick with, + /// numbers when the answer has to be typed. The default, and usually the right one. + /// + Auto, + + /// 1) 2) 3) -- and a typed answer may be the number. + Numbers, + + /// a) b) c) -- and a typed answer may be the letter. + Letters, + + /// + /// nothing before each. With no label on screen to reference, a typed answer is the + /// option's own text -- a position number names nothing and is refused. + /// + None, + } + /// /// CShell is class which provides the environmental equivelent of a CMD or BASH environment /// * current directory @@ -44,10 +68,63 @@ public CShell(string startingFolder = null) public bool Echo { get; set; } = true; + /// + /// Where the Ask methods get their keystrokes when they are reading keys rather than + /// lines. Null reads the console; set it to drive the rich prompts from somewhere else. + /// + public Func ReadKey { get; set; } + + /// + /// Whether the Ask methods draw their rich prompts -- a selection moved with the arrow + /// keys -- or fall back to reading a typed line. + /// + /// + /// Null, the default, decides by asking whether standard input is redirected, because + /// Console.ReadKey() throws outright when it is: piped, scheduled and CI runs have no + /// keys to read. Worth setting explicitly anywhere the answer matters, since the two + /// modes accept different input and print different things -- a script that works by + /// hand and fails under CI has usually just changed mode without being told. + /// + public bool? RichPrompts { get; set; } + + bool UseKeys + { + get { return this.RichPrompts.HasValue ? this.RichPrompts.Value : !Console.IsInputRedirected; } + } + + ConsoleKeyInfo NextKey() + { + var reader = this.ReadKey; + return reader != null ? reader() : Console.ReadKey(true); + } + /// /// Run a process /// + /// + /// All three streams are redirected, which is what makes StandardOutput readable, and + /// also what makes this the wrong method for a process that stops to ask the user + /// something -- `claude setup-token`, `gh auth login`, ssh, anything with a terminal UI. + /// Such a process ends up waiting on a stdin pipe that nothing will ever write to and + /// nothing will ever close. It never exits, nothing is printed while it waits, and there + /// is no way to answer the question it is stuck on. + /// + /// To run one of those, leave stdin and stderr on the console this shell is itself + /// attached to and capture stdout alone. That is the `program | cat` shape: the question + /// reaches the user and the answer reaches the process. + /// + /// var result = await Run(opt => opt.StartInfo(psi => + /// { + /// psi.RedirectStandardInput = false; + /// psi.RedirectStandardError = false; + /// }), "claude", "setup-token").AsResult(); + /// + /// Captured still means unseen: a terminal UI draws itself on stdout, so it shows nothing + /// at all while it waits, which looks exactly like the hang above. Print what to expect + /// before calling it. Nothing is feeding stdin either, so RedirectFrom() and piping in do + /// not apply to a call shaped like this. + /// /// /// /// @@ -687,6 +764,840 @@ public Command echo(TextReader textReader) /// public void Write(string format, object arg0, object arg1, object arg2) => Console.Write(format, arg0, arg1, arg2); + /// + /// Ask the user a question and return what they typed. + /// + /// + /// The Ask family is the script asking the user. For the other direction -- a process + /// that asks the user something itself -- see the remarks on Run(). + /// None of them will answer themselves: see ReadAnswer. + /// + /// the question, asked as written + /// what the user typed, trimmed; empty if they just pressed enter + /// standard input is at end of stream + public string AskText(string question) + { + Console.Write($"{question.TrimEnd()} "); + return ReadAnswer(question); + } + + /// + /// Ask the user for something that should not be looked at, and read it without echoing. + /// + /// + /// For tokens, passwords and keys. AskText() would put the answer on the screen, into the + /// scrollback, and into whatever is recording the terminal -- a long-lived credential is + /// worth one method to keep out of all three. Nothing is echoed at all, not even stars, + /// which is what a console password prompt conventionally does; backspace still works. + /// + /// With no keys to read this falls back to reading a line. That is not a downgrade: piped + /// input was never being echoed to a terminal, which is the only thing being avoided. + /// + /// A string, not a SecureString: SecureString does not protect its contents outside + /// Windows and .NET now advises against it, so this would be security theatre. Treat the + /// return like any other secret -- do not log it, and hand it on through stdin rather + /// than as an argument, where it would show up in the process list. + /// + /// the question, asked as written + /// what the user typed, trimmed + /// standard input is at end of stream + public string AskSecret(string question) + { + Console.Write($"{question.TrimEnd()} "); + + if (!this.UseKeys) + { + return ReadAnswer(question); + } + + var secret = new System.Text.StringBuilder(); + while (true) + { + var key = NextKey(); + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return secret.ToString().Trim(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (secret.Length > 0) + { + secret.Length--; + } + + continue; + } + + // Arrows, function keys and the like arrive with no character to append. + if (key.KeyChar != '\0') + { + secret.Append(key.KeyChar); + } + } + } + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// Labelled ChoiceStyle.Auto -- nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + public T AskChoice(string question, IEnumerable options, Func label = null) + { + return AskChoice(question, ChoiceStyle.Auto, options, label); + } + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// With keys to read, the list is drawn with the current option in brackets and moved + /// with the arrow keys, enter choosing it. Typing an option's own marker jumps to it but + /// still waits for enter, so a mistyped key costs nothing. + /// + /// Without them the list is printed once and the answer is typed: the option's LABEL -- + /// what it is shown as, not what ToString() says -- or whatever is printed in front of + /// it. The label is matched FIRST, so a list whose options are themselves numbers -- + /// "3", "1", "2" -- answers the way it reads, and typing 3 picks the option labelled 3 + /// rather than the third one. + /// + /// The prompt asks for what is on screen and takes nothing else: numbers over a numbered + /// list, letters over a lettered one, and under ChoiceStyle.None -- which prints no + /// labels at all -- the option's text and only that. + /// + /// What comes back is the option itself, not where it sat. Two options that label the + /// same are therefore indistinguishable in the answer, though a reference type still + /// hands back the instance that was chosen. + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + /// options is null + /// no options were given, or too many to letter + /// standard input is at end of stream + public T AskChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + { + var items = Materialise("AskChoice", options, style); + var labels = Labels(items, label); + var resolved = Resolve(style); + + var picked = this.UseKeys + ? ChooseByKey(question, resolved, labels) + : ChooseByLine(question, resolved, labels); + + return items[picked - 1]; + } + + // The options as an array, with the two ways of asking for an impossible list refused up + // front. Everything below the public methods works in labels and 1-based positions; only + // AskChoice and AskMultiChoice know there is a T at all. + static T[] Materialise(string caller, IEnumerable options, ChoiceStyle style) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options), $"{caller}() was given no options at all."); + } + + var items = options.ToArray(); + + if (items.Length == 0) + { + throw new ArgumentException($"{caller}() needs at least one option to choose between.", nameof(options)); + } + + if (style == ChoiceStyle.Letters && items.Length > 26) + { + throw new ArgumentException($"{caller}() cannot letter {items.Length} options; there are 26 letters.", nameof(options)); + } + + return items; + } + + // What each option is shown as, and -- in the typed mode -- what it answers to. A null + // option labels as empty rather than throwing: a hole in a list is the caller's problem + // to see on screen, not a reason to take the prompt down. + static string[] Labels(T[] items, Func label) + { + var labels = new string[items.Length]; + for (int i = 0; i < items.Length; i++) + { + labels[i] = label != null + ? (label(items[i]) ?? "") + : (items[i] == null ? "" : items[i].ToString()); + } + + return labels; + } + + // Auto asks what the mode can afford. With arrow keys the selection IS the affordance and + // a label in front of every row is noise; without them the label is the only thing saying + // what to type, and a bare list under a "[1-3]" prompt makes you count rows yourself. + ChoiceStyle Resolve(ChoiceStyle style) + { + if (style != ChoiceStyle.Auto) + { + return style; + } + + return this.UseKeys ? ChoiceStyle.None : ChoiceStyle.Numbers; + } + + static string Marker(ChoiceStyle style, int index) + { + if (style == ChoiceStyle.Numbers) { return (index + 1) + ") "; } + if (style == ChoiceStyle.Letters) { return (char)('a' + index) + ") "; } + + return ""; + } + + // Which option a typed answer names, or 0 for none. Option TEXT is matched before any + // marker, which is what keeps a list of numbers honest. + static int FromAnswer(ChoiceStyle style, string[] options, string answer) + { + for (int i = 0; i < options.Length; i++) + { + if (String.Equals(options[i], answer, StringComparison.OrdinalIgnoreCase)) + { + return i + 1; + } + } + + if (style == ChoiceStyle.Letters) + { + if (answer.Length == 1) + { + var index = Char.ToLowerInvariant(answer[0]) - 'a'; + if (index >= 0 && index < options.Length) + { + return index + 1; + } + } + + return 0; + } + + // Under None the label is all there is. Taking a position number here would mean + // answering with something the list never showed -- "[1-3]" over an unnumbered list + // leaves you counting rows -- so the option's own text is the only answer. + if (style == ChoiceStyle.None) + { + return 0; + } + + int number; + if (int.TryParse(answer, out number) && number >= 1 && number <= options.Length) + { + return number; + } + + return 0; + } + + static void RenderChoices(ChoiceStyle style, string[] options, int selected) + { + for (int i = 0; i < options.Length; i++) + { + var item = i == selected ? "[" + options[i] + "]" : " " + options[i] + " "; + Console.WriteLine(Fill(" " + Marker(style, i) + item)); + } + } + + int ChooseByKey(string question, ChoiceStyle style, string[] options) + { + var selected = 0; + + Console.WriteLine(question); + RenderChoices(style, options, selected); + + while (true) + { + var key = NextKey(); + + if (key.Key == ConsoleKey.Enter) + { + return selected + 1; + } + + if (key.Key == ConsoleKey.UpArrow || key.Key == ConsoleKey.LeftArrow) + { + selected = (selected - 1 + options.Length) % options.Length; + } + else if (key.Key == ConsoleKey.DownArrow || key.Key == ConsoleKey.RightArrow) + { + selected = (selected + 1) % options.Length; + } + else if (key.Key == ConsoleKey.Home) + { + selected = 0; + } + else if (key.Key == ConsoleKey.End) + { + selected = options.Length - 1; + } + else if (key.KeyChar != '\0') + { + var named = FromAnswer(style, options, key.KeyChar.ToString()); + if (named > 0) + { + selected = named - 1; + } + } + + Rewind(options.Length); + RenderChoices(style, options, selected); + } + } + + int ChooseByLine(string question, ChoiceStyle style, string[] options) + { + // Written once, outside the loop. A rejected answer reprints the input line only -- + // repeating the whole question every time buries the list it refers to. + Console.WriteLine(question); + for (int i = 0; i < options.Length; i++) + { + Console.WriteLine(" " + Marker(style, i) + options[i]); + } + + // Whatever is in front of the options is what the prompt asks for: numbers over a + // numbered list, letters over a lettered one, and nothing to reference at all over + // a bare one, which just takes the text. + string hint; + if (style == ChoiceStyle.Letters) + { + hint = options.Length == 1 ? "[a] " : $"[a-{(char)('a' + options.Length - 1)}] "; + } + else if (style == ChoiceStyle.None) + { + hint = "> "; + } + else + { + hint = options.Length == 1 ? "[1] " : $"[1-{options.Length}] "; + } + + while (true) + { + Console.Write(hint); + var answer = ReadAnswer(question); + + var chosen = FromAnswer(style, options, answer); + if (chosen > 0) + { + return chosen; + } + + Console.WriteLine(answer.Length == 0 + ? "Pick one of the above." + : $"'{answer}' is not one of the above."); + } + } + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// Labelled ChoiceStyle.Auto -- nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + public T[] AskMultiChoice(string question, IEnumerable options, Func label = null) + { + return AskMultiChoice(question, ChoiceStyle.Auto, options, label); + } + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// AskChoice() with a checkbox. With keys to read, up and down move a `>` down the list + /// and space checks the option under it, enter finishing. The cursor and the checkmarks + /// are two different things, so they get two different marks: reusing the brackets for + /// both -- as AskChoice() can afford to, having only one -- leaves a line whose state + /// nobody can read. + /// + /// Without keys the answer is typed as a comma separated list, each part being an + /// option's label, number, or letter under ChoiceStyle.Letters. Commas alone separate + /// them, so options labelled with spaces in them still answer to their labels. One part + /// that names nothing rejects the whole answer rather than silently selecting the rest. + /// + /// Choosing nothing is an answer: enter on an unchecked list, or a blank line, returns + /// an empty array rather than asking again. A caller that needs at least one has to say + /// so itself -- there is no way for this to tell an empty answer from a deliberate one. + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + /// options is null + /// no options were given, or too many to letter + /// standard input is at end of stream + public T[] AskMultiChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + { + var items = Materialise("AskMultiChoice", options, style); + var labels = Labels(items, label); + var resolved = Resolve(style); + + var picked = this.UseKeys + ? ChooseManyByKey(question, resolved, labels) + : ChooseManyByLine(question, resolved, labels); + + var chosen = new T[picked.Length]; + for (int i = 0; i < picked.Length; i++) + { + chosen[i] = items[picked[i] - 1]; + } + + return chosen; + } + + static int[] Checked(bool[] chosen) + { + var picked = new List(); + for (int i = 0; i < chosen.Length; i++) + { + if (chosen[i]) + { + picked.Add(i + 1); + } + } + + return picked.ToArray(); + } + + static void RenderChecks(ChoiceStyle style, string[] options, bool[] chosen, int cursor) + { + for (int i = 0; i < options.Length; i++) + { + var pointer = i == cursor ? "> " : " "; + var box = chosen[i] ? "[x] " : "[ ] "; + Console.WriteLine(Fill(pointer + Marker(style, i) + box + options[i])); + } + } + + int[] ChooseManyByKey(string question, ChoiceStyle style, string[] options) + { + var chosen = new bool[options.Length]; + var cursor = 0; + + Console.WriteLine(question); + RenderChecks(style, options, chosen, cursor); + + while (true) + { + var key = NextKey(); + + if (key.Key == ConsoleKey.Enter) + { + return Checked(chosen); + } + + // Tested before the marker jump below, so space is never read as a label. + if (key.Key == ConsoleKey.Spacebar || key.KeyChar == ' ') + { + chosen[cursor] = !chosen[cursor]; + } + else if (key.Key == ConsoleKey.UpArrow || key.Key == ConsoleKey.LeftArrow) + { + cursor = (cursor - 1 + options.Length) % options.Length; + } + else if (key.Key == ConsoleKey.DownArrow || key.Key == ConsoleKey.RightArrow) + { + cursor = (cursor + 1) % options.Length; + } + else if (key.Key == ConsoleKey.Home) + { + cursor = 0; + } + else if (key.Key == ConsoleKey.End) + { + cursor = options.Length - 1; + } + else if (key.KeyChar != '\0') + { + var named = FromAnswer(style, options, key.KeyChar.ToString()); + if (named > 0) + { + cursor = named - 1; + } + } + + Rewind(options.Length); + RenderChecks(style, options, chosen, cursor); + } + } + + int[] ChooseManyByLine(string question, ChoiceStyle style, string[] options) + { + Console.WriteLine(question); + for (int i = 0; i < options.Length; i++) + { + Console.WriteLine(" " + Marker(style, i) + options[i]); + } + + while (true) + { + Console.Write("[comma separated, blank for none] "); + var answer = ReadAnswer(question); + + if (answer.Length == 0) + { + return new int[0]; + } + + var chosen = new bool[options.Length]; + string unknown = null; + + foreach (var part in answer.Split(',')) + { + var token = part.Trim(); + if (token.Length == 0) + { + continue; + } + + var named = FromAnswer(style, options, token); + if (named == 0) + { + unknown = token; + break; + } + + chosen[named - 1] = true; + } + + if (unknown == null) + { + return Checked(chosen); + } + + Console.WriteLine($"'{unknown}' is not one of the above."); + } + } + + /// + /// Ask the user for a whole number, asking again until they give one. + /// + /// the question, asked as written + /// the number they typed + public int AskNumber(string question) + { + return AskNumber(question, int.MinValue, int.MaxValue); + } + + /// + /// Ask the user for a whole number within a range, asking again until they give one. + /// + /// + /// With keys to read, up and down step the number and digits type it, both held to the + /// range so it can never show a value it would then refuse. Without them the number is + /// typed as a line, and one outside the range is rejected the same way an unparseable + /// one is -- a number the caller cannot use is not an answer. + /// + /// the question, asked as written + /// smallest acceptable answer, inclusive + /// largest acceptable answer, inclusive + /// the number they typed, between min and max + /// min is greater than max + /// standard input is at end of stream + public int AskNumber(string question, int min, int max) + { + if (min > max) + { + throw new ArgumentException($"AskNumber() was given an empty range: {min} to {max}.", nameof(min)); + } + + return this.UseKeys ? NumberByKey(question, min, max) : NumberByLine(question, min, max); + } + + static int Clamp(int value, int min, int max) + { + return value < min ? min : (value > max ? max : value); + } + + static string Range(int min, int max) + { + return min == int.MinValue && max == int.MaxValue ? "" : $"[{min}-{max}] "; + } + + int NumberByKey(string question, int min, int max) + { + var prefix = question.TrimEnd() + " " + Range(min, max); + var typed = Clamp(0, min, max).ToString(); + + Console.Write("\r" + Fill(prefix + typed)); + + while (true) + { + var key = NextKey(); + int current; + + if (key.Key == ConsoleKey.Enter) + { + if (int.TryParse(typed, out current) && current >= min && current <= max) + { + Console.WriteLine(); + return current; + } + } + else if (key.Key == ConsoleKey.UpArrow) + { + int.TryParse(typed, out current); + typed = Clamp(current + 1, min, max).ToString(); + } + else if (key.Key == ConsoleKey.DownArrow) + { + int.TryParse(typed, out current); + typed = Clamp(current - 1, min, max).ToString(); + } + else if (key.Key == ConsoleKey.Backspace) + { + if (typed.Length > 0) + { + typed = typed.Substring(0, typed.Length - 1); + } + } + else if (Char.IsDigit(key.KeyChar) || (key.KeyChar == '-' && typed.Length == 0)) + { + typed = typed + key.KeyChar; + } + + Console.Write("\r" + Fill(prefix + typed)); + } + } + + int NumberByLine(string question, int min, int max) + { + var range = Range(min, max); + + // An unbounded ask has no range to show, and a bare cursor under a question reads + // as a hang rather than a prompt. + var hint = range.Length > 0 ? range : "> "; + + Console.WriteLine(question); + while (true) + { + Console.Write(hint); + var answer = ReadAnswer(question); + + int number; + if (int.TryParse(answer, out number)) + { + if (number >= min && number <= max) + { + return number; + } + + Console.WriteLine($"{number} is outside {min} to {max}."); + continue; + } + + Console.WriteLine(answer.Length == 0 + ? "Type a number." + : $"'{answer}' is not a number."); + } + } + + /// + /// Ask the user a yes or no question, asking again until they answer one or the other. + /// + /// the question, asked as written + /// true for yes, false for no + public bool AskYesNo(string question) + { + return AskYesNo(question, (bool?)null); + } + + /// + /// Ask the user a yes or no question, with an answer that pressing enter accepts. + /// + /// + /// With keys to read, Yes and No sit side by side with the current one in brackets. The + /// arrow keys move between them and so do y and n -- but only enter answers, the same + /// way typing an option's marker in AskChoice() moves to it without choosing it. Without + /// keys the answer is typed as y, yes, n or no, case insensitively. + /// + /// The default is shown capitalised the way a shell script does it -- `[Y/n]` for yes, + /// `[y/N]` for no -- which makes the capital a promise. Pass the SAFE answer as the + /// default, because enter is what gets pressed by someone who is not reading. + /// + /// the question, asked as written + /// what pressing enter answers + /// true for yes, false for no + /// standard input is at end of stream + public bool AskYesNo(string question, bool defaultAnswer) + { + return AskYesNo(question, (bool?)defaultAnswer); + } + + bool AskYesNo(string question, bool? defaultAnswer) + { + return this.UseKeys ? YesNoByKey(question, defaultAnswer) : YesNoByLine(question, defaultAnswer); + } + + static string YesNoBar(bool yes) + { + return yes ? "[Yes] No " : " Yes [No]"; + } + + bool YesNoByKey(string question, bool? defaultAnswer) + { + // With no default there is still a side the selection has to start on. Starting on + // Yes and requiring enter is not the same promise as a default: nothing is accepted + // until a key says so. + var yes = defaultAnswer.HasValue ? defaultAnswer.Value : true; + var prefix = question.TrimEnd() + " "; + + Console.Write("\r" + Fill(prefix + YesNoBar(yes))); + + while (true) + { + var key = NextKey(); + + // Enter is the only thing that answers. y and n MOVE the selection rather than + // committing it, which is the same rule AskChoice() plays by when you type an + // option's marker: one key is never enough to answer a question, so a mistyped + // one costs nothing. The alternative -- y answering outright -- makes the two + // halves of the family disagree, and surprises anyone who reached for y meaning + // to look before they leapt. + if (key.Key == ConsoleKey.Enter) + { + Console.Write("\r" + Fill(prefix + YesNoBar(yes))); + Console.WriteLine(); + return yes; + } + + if (key.KeyChar == 'y' || key.KeyChar == 'Y') + { + yes = true; + } + else if (key.KeyChar == 'n' || key.KeyChar == 'N') + { + yes = false; + } + else if (key.Key == ConsoleKey.LeftArrow || key.Key == ConsoleKey.RightArrow || key.Key == ConsoleKey.Tab) + { + yes = !yes; + } + else + { + continue; + } + + Console.Write("\r" + Fill(prefix + YesNoBar(yes))); + } + } + + bool YesNoByLine(string question, bool? defaultAnswer) + { + var choices = !defaultAnswer.HasValue ? "[y/n]" + : defaultAnswer.Value ? "[Y/n]" + : "[y/N]"; + + while (true) + { + Console.Write($"{question.TrimEnd()} {choices} "); + var answer = ReadAnswer(question); + + if (answer.Length == 0 && defaultAnswer.HasValue) + { + return defaultAnswer.Value; + } + + if (String.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) || + String.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (String.Equals(answer, "n", StringComparison.OrdinalIgnoreCase) || + String.Equals(answer, "no", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + Console.WriteLine("Answer y or n."); + } + } + + // Move back over lines already drawn so the next render replaces them. Without a console + // there is nothing to move around in, and the renders stack up instead -- ugly on screen, + // but exactly what a captured transcript wants. + static void Rewind(int lines) + { + if (lines <= 0 || Console.IsOutputRedirected) + { + return; + } + + try + { + var top = Console.CursorTop - lines; + Console.SetCursorPosition(0, top < 0 ? 0 : top); + } + catch (IOException) + { + } + } + + // Pad a redrawn line out to the width so whatever the last render left there is erased. + static string Fill(string text) + { + if (Console.IsOutputRedirected) + { + return text; + } + + try + { + var width = Console.WindowWidth - 1; + return text.Length < width ? text.PadRight(width) : text; + } + catch (IOException) + { + return text; + } + } + + /// + /// Read one answer, refusing to treat "there is nobody there" as an answer. + /// + /// + /// ReadLine() returns null at end of stream rather than blocking, which a script run + /// non-interactively -- piped, scheduled, under CI -- hits immediately. Left unchecked + /// that is an empty answer the caller acts on, or, in the loops above, a spin that reasks + /// a question nobody can hear forever. Throwing says which question went unanswered. + /// + string ReadAnswer(string question) + { + var answer = Console.ReadLine(); + if (answer == null) + { + throw new InvalidOperationException( + $"\"{question}\" could not be answered: standard input is at end of stream, " + + "so there is no one to ask."); + } + + return answer.Trim(); + } + /// /// Write value as line to standard out /// diff --git a/src/Globals.cs b/src/Globals.cs index 0af6306..9adfbc8 100644 --- a/src/Globals.cs +++ b/src/Globals.cs @@ -18,6 +18,19 @@ public static class Globals public static bool Echo { get => _shell.Echo; set => _shell.Echo = value; } + /// + /// Where the Ask methods get their keystrokes when reading keys rather than lines. + /// Null reads the console. See CShell.ReadKey. + /// + public static Func ReadKey { get => _shell.ReadKey; set => _shell.ReadKey = value; } + + /// + /// Whether the Ask methods draw their rich prompts or fall back to reading a typed line. + /// Null, the default, decides by asking whether standard input is redirected, because + /// Console.ReadKey() throws when it is. See CShell.RichPrompts. + /// + public static bool? RichPrompts { get => _shell.RichPrompts; set => _shell.RichPrompts = value; } + /// /// Reset global shell state. /// @@ -30,6 +43,29 @@ public static void ResetShell(string startFolder=null) /// /// Run a process /// + /// + /// All three streams are redirected, which is what makes StandardOutput readable, and + /// also what makes this the wrong method for a process that stops to ask the user + /// something -- `claude setup-token`, `gh auth login`, ssh, anything with a terminal UI. + /// Such a process ends up waiting on a stdin pipe that nothing will ever write to and + /// nothing will ever close. It never exits, nothing is printed while it waits, and there + /// is no way to answer the question it is stuck on. + /// + /// To run one of those, leave stdin and stderr on the console this shell is itself + /// attached to and capture stdout alone. That is the `program | cat` shape: the question + /// reaches the user and the answer reaches the process. + /// + /// var result = await Run(opt => opt.StartInfo(psi => + /// { + /// psi.RedirectStandardInput = false; + /// psi.RedirectStandardError = false; + /// }), "claude", "setup-token").AsResult(); + /// + /// Captured still means unseen: a terminal UI draws itself on stdout, so it shows nothing + /// at all while it waits, which looks exactly like the hang above. Print what to expect + /// before calling it. Nothing is feeding stdin either, so RedirectFrom() and piping in do + /// not apply to a call shaped like this. + /// /// /// /// @@ -64,6 +100,137 @@ public static Command Start(string executable, params Object[] arguments) public static Command Start(Action options, string executable, params Object[] arguments) => _shell.Start(options, executable, arguments); + /// + /// Ask the user a question and return what they typed. + /// + /// + /// The Ask family is the script asking the user. For the other direction -- a process + /// that asks the user something itself -- see the remarks on Run(). All of them throw if + /// standard input is at end of stream, rather than answering for someone who is not + /// there. See CShell.AskText(). + /// + /// the question, asked as written + /// what the user typed, trimmed; empty if they just pressed enter + public static string AskText(string question) + => _shell.AskText(question); + + /// + /// Ask the user for something that should not be looked at, and read it without echoing. + /// + /// + /// For tokens, passwords and keys -- AskText() would leave the answer on the screen and + /// in the scrollback. Falls back to reading a line when standard input is redirected, + /// where there is no terminal echoing it anyway. See CShell.AskSecret(). + /// + /// the question, asked as written + /// what the user typed, trimmed + public static string AskSecret(string question) + => _shell.AskSecret(question); + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// Labelled ChoiceStyle.Auto: nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. See CShell.AskChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + public static T AskChoice(string question, IEnumerable options, Func label = null) + => _shell.AskChoice(question, options, label); + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// With keys to read the list is moved with the arrow keys, the current option shown in + /// brackets. Without them the answer is typed: the option's label, its number, or its + /// letter under ChoiceStyle.Letters -- label matched first. See CShell.AskChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + public static T AskChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + => _shell.AskChoice(question, style, options, label); + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// Labelled ChoiceStyle.Auto: nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. See CShell.AskMultiChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + public static T[] AskMultiChoice(string question, IEnumerable options, Func label = null) + => _shell.AskMultiChoice(question, options, label); + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// With keys to read, up and down move a `>` down the list and space checks the option + /// under it. Without them the answer is a comma separated list of labels, numbers or + /// letters. Choosing nothing is an answer and returns an empty array. + /// See CShell.AskMultiChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + public static T[] AskMultiChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + => _shell.AskMultiChoice(question, style, options, label); + + /// + /// Ask the user for a whole number, asking again until they give one. + /// + /// the question, asked as written + /// the number they typed + public static int AskNumber(string question) + => _shell.AskNumber(question); + + /// + /// Ask the user for a whole number within a range, asking again until they give one. + /// + /// the question, asked as written + /// smallest acceptable answer, inclusive + /// largest acceptable answer, inclusive + /// the number they typed, between min and max + public static int AskNumber(string question, int min, int max) + => _shell.AskNumber(question, min, max); + + /// + /// Ask the user a yes or no question, asking again until they answer one or the other. + /// + /// the question, asked as written + /// true for yes, false for no + public static bool AskYesNo(string question) + => _shell.AskYesNo(question); + + /// + /// Ask the user a yes or no question, with an answer that pressing enter accepts. + /// + /// + /// Shown `[Y/n]` or `[y/N]`, so the capital is a promise -- pass the SAFE answer as the + /// default, because enter is what gets pressed by someone who is not reading. + /// + /// the question, asked as written + /// what pressing enter answers + /// true for yes, false for no + public static bool AskYesNo(string question, bool defaultAnswer) + => _shell.AskYesNo(question, defaultAnswer); + /// /// Run a cmd/bash command /// From d797b9a0141209d5da2c250a6f9d75cd400171b9 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 10:57:38 -0700 Subject: [PATCH 03/12] Add Cli, a declarative command line parser 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- Tests/CShell.Tests/Cli.Tests.cs | 802 +++++++++++++++++++++++++++++++ src/CShell.csproj | 6 +- src/Cli.cs | 828 ++++++++++++++++++++++++++++++++ src/CliResult.cs | 243 ++++++++++ 4 files changed, 1876 insertions(+), 3 deletions(-) create mode 100644 Tests/CShell.Tests/Cli.Tests.cs create mode 100644 src/Cli.cs create mode 100644 src/CliResult.cs diff --git a/Tests/CShell.Tests/Cli.Tests.cs b/Tests/CShell.Tests/Cli.Tests.cs new file mode 100644 index 0000000..830510d --- /dev/null +++ b/Tests/CShell.Tests/Cli.Tests.cs @@ -0,0 +1,802 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace CShellLibTests +{ + /// + /// Cli -- what a script declares it accepts, and how a command line is read against it. + /// + /// + /// Every test names the program explicitly. Left alone, Cli.For() takes the name from the + /// calling file via [CallerFilePath], which here would be this test file -- so assertions + /// about messages would be asserting on "Cli.Tests". + /// + [TestClass] + public class CliTests + { + private TextWriter originalOut; + private TextWriter originalError; + private StringWriter captured; + private StringWriter capturedErrors; + + [TestInitialize] + public void Capture() + { + this.originalOut = Console.Out; + this.originalError = Console.Error; + this.captured = new StringWriter(); + this.capturedErrors = new StringWriter(); + Console.SetOut(this.captured); + Console.SetError(this.capturedErrors); + } + + [TestCleanup] + public void Restore() + { + Console.SetOut(this.originalOut); + Console.SetError(this.originalError); + } + + private string Screen => this.captured.ToString(); + + private string Errors => this.capturedErrors.ToString(); + + private static Cli Given(params string[] args) => Cli.For(args).Program("demo"); + + // ------------------------------------------------------------------ switches + + [TestMethod] + public void Switch_IsTrueWhenGivenAndFalseWhenAbsent() + { + Assert.IsTrue(Given("-whatif").Switch("whatif", "touch nothing").Parse().Switch("whatif")); + Assert.IsFalse(Given().Switch("whatif", "touch nothing").Parse().Switch("whatif")); + } + + [TestMethod] + public void Switch_AcceptsEitherDashPrefix() + { + foreach (var spelling in new[] { "-whatif", "--whatif" }) + { + Assert.IsTrue(Given(spelling).Switch("whatif", "touch nothing").Parse().Switch("whatif"), spelling); + } + } + + [TestMethod] + public void Switch_IgnoresCaseHyphensAndUnderscores() + { + foreach (var spelling in new[] { "--DRY-RUN", "--dryrun", "-Dry_Run", "--d-r-y-r-u-n" }) + { + Assert.IsTrue(Given(spelling).Switch("dry-run", "print only").Parse().Switch("dry-run"), spelling); + } + } + + [TestMethod] + public void Switch_AliasesAfterThePipeSetTheSameSwitch() + { + foreach (var spelling in new[] { "-whatif", "--dry-run", "-n" }) + { + Assert.IsTrue(Given(spelling).Switch("whatif|dry-run|n", "touch nothing").Parse().Switch("whatif"), spelling); + } + + // and it reads back under any of its names + var cmd = Given("-n").Switch("whatif|dry-run|n", "touch nothing").Parse(); + Assert.IsTrue(cmd.Switch("dry-run")); + } + + [TestMethod] + public void Switch_RepeatedIsStillJustTrue() + { + Assert.IsTrue(Given("-whatif", "--whatif").Switch("whatif", "touch nothing").Parse().Switch("whatif")); + } + + [TestMethod] + public void Switch_GivenAValueIsAnError() + { + var cmd = Given("-whatif:true").Switch("whatif", "touch nothing").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "takes no value"); + } + + [TestMethod] + public void Switch_ReadingAnUndeclaredNameThrowsAndSaysWhatWasDeclared() + { + var cmd = Given().Switch("whatif", "touch nothing").Parse(); + + var thrown = Assert.Throws(() => cmd.Switch("nopush")); + StringAssert.Contains(thrown.Message, "nopush"); + StringAssert.Contains(thrown.Message, "whatif"); + } + + // ------------------------------------------------------------------ declaration mistakes + + [TestMethod] + public void Declaring_AHelpTextThatLooksLikeAnAliasThrows() + { + // Switch("whatif", "n") -- the classic. Silently making "n" the help text is exactly + // the quiet mistake this type exists to prevent, so it is refused. + var thrown = Assert.Throws(() => Given().Switch("whatif", "n")); + + StringAssert.Contains(thrown.Message, "help text"); + StringAssert.Contains(thrown.Message, "whatif|n"); + } + + [TestMethod] + public void Declaring_ANameWithItsPrefixThrows() + { + var thrown = Assert.Throws(() => Given().Switch("--whatif", "touch nothing")); + StringAssert.Contains(thrown.Message, "without a prefix"); + } + + [TestMethod] + public void Declaring_BlankHelpThrows() + { + Assert.Throws(() => Given().Switch("whatif", " ")); + } + + [TestMethod] + public void Declaring_TwoNamesThatNormaliseTheSameThrows() + { + // "nopush" and "no-push" are one switch once hyphens go. Better to refuse at + // declaration than to silently have them share a value. + var thrown = Assert.Throws( + () => Given().Switch("nopush", "leave the push").Switch("no-push", "something else")); + + StringAssert.Contains(thrown.Message, "collides"); + } + + [TestMethod] + public void Declaring_ASwitchAndAnArgumentWithOneNameThrows() + { + Assert.Throws( + () => Given().Argument("out", "where to write").Switch("out", "something else")); + } + + [TestMethod] + public void Declaring_ARequiredArgumentAfterAnOptionalOneThrows() + { + var thrown = Assert.Throws( + () => Given().OptionalArgument("repo", "the repo").Argument("branch", "the branch")); + + StringAssert.Contains(thrown.Message, "must be last"); + } + + [TestMethod] + public void Declaring_AnythingAfterARestThrows() + { + Assert.Throws( + () => Given().Rest("args", "passed through").Argument("file", "a file")); + } + + [TestMethod] + public void Declaring_AnAliasOnAnArgumentThrows() + { + var thrown = Assert.Throws(() => Given().Argument("file|f", "a file")); + StringAssert.Contains(thrown.Message, "matched by position"); + } + + // ------------------------------------------------------------------ options + + [TestMethod] + public void Option_TakesItsValueAfterAColonOrAnEquals() + { + Assert.AreEqual("test", Given("-folder:test").Option("folder", "the folder").Parse().Option("folder")); + Assert.AreEqual("test", Given("-folder=test").Option("folder", "the folder").Parse().Option("folder")); + Assert.AreEqual("test", Given("--folder:test").Option("folder", "the folder").Parse().Option("folder")); + + } + + [TestMethod] + public void Option_IsNullWhenNotGiven() + { + Assert.IsNull(Given().Option("folder", "the folder").Parse().Option("folder")); + } + + [TestMethod] + public void Option_NameIsNormalisedButTheValueIsNot() + { + // The whole point of splitting before normalising: --API-KEY finds the option, and + // the key it carries is untouched. + var cmd = Given("--API-KEY:sk-ant-AbC123").Option("api-key", "the key").Parse(); + + Assert.AreEqual("sk-ant-AbC123", cmd.Option("api-key")); + } + + [TestMethod] + public void Option_ValueKeepsItsCaseAndHyphens() + { + var cmd = Given(@"-out:C:\temp\My-Folder").Option("out", "where to write").Parse(); + + Assert.AreEqual(@"C:\temp\My-Folder", cmd.Option("out")); + } + + [TestMethod] + public void Option_SplitsOnTheFirstSeparatorOnlySoAValueMayContainMore() + { + Assert.AreEqual("https://api.nuget.org/v3/index.json", + Given("-source:https://api.nuget.org/v3/index.json").Option("source", "the feed").Parse().Option("source")); + + Assert.AreEqual("a=b=c", Given("-q:a=b=c").Option("q", "a query").Parse().Option("q")); + Assert.AreEqual(@"C:\temp", Given(@"-out=C:\temp").Option("out", "where to write").Parse().Option("out")); + } + + [TestMethod] + public void Option_ApiKeyAndApikeyAreTheSameOption() + { + foreach (var spelling in new[] { "--api-key:x", "--apikey:x", "-API_KEY:x" }) + { + Assert.AreEqual("x", Given(spelling).Option("api-key", "the key").Parse().Option("api-key"), spelling); + } + } + + [TestMethod] + public void Option_GivenBareIsAnErrorNamingTheAttachedForm() + { + // This is what catches someone typing the separated "--folder test" habit, instead of + // letting "test" slide through as a positional. + var cmd = Given("-folder").Option("folder", "the folder").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "needs a value"); + StringAssert.Contains(this.Errors, "--folder:value"); + } + + [TestMethod] + public void Option_GivenAnEmptyValueIsAnError() + { + Assert.IsTrue(Given("-folder:").Option("folder", "the folder").Parse().ShouldExit); + StringAssert.Contains(this.Errors, "needs a value"); + } + + [TestMethod] + public void Option_GivenTwiceIsAnError() + { + var cmd = Given("-source:a", "-source:b").Option("source", "the feed").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "more than once"); + } + + [TestMethod] + public void Option_ErrorMessagesNeverEchoTheValue() + { + // A secret must not reach stderr because the user typed it twice, or typed the + // separated form and left it dangling. + Given("--api-key:sk-ant-SECRET", "--api-key:sk-ant-OTHER").Option("api-key", "the key").Parse(); + Assert.IsFalse(this.Errors.Contains("SECRET"), "an option's value must never be echoed"); + Assert.IsFalse(this.Errors.Contains("OTHER"), "an option's value must never be echoed"); + + Capture(); + Given("--api-key", "sk-ant-SECRET").Option("api-key", "the key").Parse(); + Assert.IsFalse(this.Errors.Contains("SECRET"), + "the token after a bare option must not be echoed as an unexpected argument either"); + } + + // ------------------------------------------------------------------ unknown switches + + [TestMethod] + public void Unknown_SwitchIsAnErrorNamingTheRawToken() + { + var cmd = Given("--dryrun").Switch("nopush", "leave the push").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "unknown switch '--dryrun'"); + } + + [TestMethod] + public void Unknown_SwitchGoesToStandardErrorNotStandardOut() + { + Given("--nope").Switch("whatif", "touch nothing").Parse(); + + StringAssert.Contains(this.Errors, "unknown switch"); + Assert.AreEqual(String.Empty, this.Screen, "an error is not output"); + } + + [TestMethod] + public void Unknown_SwitchPointsAtHelpRatherThanPrintingIt() + { + Given("--nope").Switch("whatif", "touch nothing").Parse(); + + StringAssert.Contains(this.Errors, "Try 'demo --help'"); + Assert.IsFalse(this.Errors.Contains("Switches:"), "the full usage is noise here"); + } + + [TestMethod] + public void Unknown_SwitchesAreAllReportedAtOnce() + { + Given("--nope", "--alsonope").Switch("whatif", "touch nothing").Parse(); + + StringAssert.Contains(this.Errors, "'--nope'"); + StringAssert.Contains(this.Errors, "'--alsonope'"); + } + + [TestMethod] + public void Unknown_ATypoThatNormalisesToADeclaredSwitchIsNotUnknown() + { + // "--dryrun" for "--dry-run" is the bug this closes: today it becomes a path. + Assert.IsTrue(Given("--dryrun").Switch("dry-run", "print only").Parse().Switch("dry-run")); + } + + [TestMethod] + public void Unknown_SwitchErrorsSuppressPositionalErrors() + { + var cmd = Given("--nope", "extra1", "extra2").Switch("whatif", "touch nothing").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "unknown switch"); + Assert.IsFalse(this.Errors.Contains("unexpected"), + "once the switches were misread the positional list means nothing"); + } + + // ------------------------------------------------------------------ positionals + + [TestMethod] + public void Argument_FillsInDeclarationOrder() + { + var cmd = Given("in.txt", "out").Argument("file", "the file").Argument("output", "the folder").Parse(); + + Assert.AreEqual("in.txt", cmd.Argument("file")); + Assert.AreEqual("out", cmd.Argument("output")); + } + + [TestMethod] + public void Argument_MayBeInterspersedWithSwitches() + { + var cmd = Given("repo", "-whatif").OptionalArgument("repo", "the repo").Switch("whatif", "touch nothing").Parse(); + + Assert.AreEqual("repo", cmd.Argument("repo")); + Assert.IsTrue(cmd.Switch("whatif")); + } + + [TestMethod] + public void Argument_MissingRequiredIsAnErrorNamingIt() + { + var cmd = Given().Argument("file", "the file").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "missing "); + } + + [TestMethod] + public void Argument_OptionalMayBeOmittedAndReadsNull() + { + Assert.IsNull(Given().OptionalArgument("path", "the path").Parse().Argument("path")); + Assert.AreEqual("x", Given("x").OptionalArgument("path", "the path").Parse().Argument("path")); + } + + [TestMethod] + public void Argument_TooManyIsAnErrorNamingTheUnexpectedOnes() + { + var one = Given("a", "b").OptionalArgument("path", "the path").Parse(); + Assert.IsTrue(one.ShouldExit); + StringAssert.Contains(this.Errors, "unexpected argument 'b'"); + + Capture(); + Given("a", "b", "c").OptionalArgument("path", "the path").Parse(); + StringAssert.Contains(this.Errors, "unexpected arguments: 'b' 'c'"); + } + + [TestMethod] + public void Argument_PathsAreNotMistakenForSwitches() + { + // The reason '/' is recognised rather than demanded: an absolute path on Linux starts + // with one. + Assert.AreEqual("/home/tom/file", + Given("/home/tom/file").OptionalArgument("path", "the path").Parse().Argument("path")); + + Capture(); + Assert.AreEqual(@"C:\temp", + Given(@"C:\temp").OptionalArgument("path", "the path").Parse().Argument("path")); + + Capture(); + Assert.AreEqual("/tmp/x:y", + Given("/tmp/x:y").OptionalArgument("path", "the path").Parse().Argument("path")); + + Capture(); + Assert.AreEqual("/usr/local/bin", + Given("/usr/local/bin").OptionalArgument("path", "the path").Parse().Argument("path")); + } + + [TestMethod] + public void Argument_ASlashTokenIsAlwaysAPositional() + { + // '/' is not a switch prefix. Dashes are the standard, and treating '/' as a prefix + // would make every absolute path on Linux something the parser had to recognise. + Assert.AreEqual("/nope", Given("/nope").OptionalArgument("path", "the path").Parse().Argument("path")); + + Capture(); + var cmd = Given("/whatif").OptionalArgument("path", "the path").Switch("whatif", "touch nothing").Parse(); + Assert.AreEqual("/whatif", cmd.Argument("path"), "a slash token is a value, not the switch it resembles"); + Assert.IsFalse(cmd.Switch("whatif")); + } + + [TestMethod] + public void Argument_ADashTokenIsAnUnknownSwitchNotAPositional() + { + Assert.IsTrue(Given("-nope").OptionalArgument("path", "the path").Parse().ShouldExit); + StringAssert.Contains(this.Errors, "unknown switch"); + } + + [TestMethod] + public void Argument_NegativeNumbersAndABareDashArePositionals() + { + Assert.AreEqual("-9", Given("-9").OptionalArgument("n", "a number").Parse().Argument("n")); + + Capture(); + Assert.AreEqual("-", Given("-").OptionalArgument("n", "stdin").Parse().Argument("n")); + } + + [TestMethod] + public void Argument_AfterTheTerminatorMayLookLikeASwitch() + { + var cmd = Given("--", "-weird-name").OptionalArgument("path", "the path").Parse(); + + Assert.AreEqual("-weird-name", cmd.Argument("path")); + } + + [TestMethod] + public void Argument_TheTerminatorIsNotItselfAPositional() + { + var cmd = Given("a", "--", "b").Argument("one", "first").OptionalArgument("two", "second").Parse(); + + Assert.AreEqual("a", cmd.Argument("one")); + Assert.AreEqual("b", cmd.Argument("two")); + } + + [TestMethod] + public void Argument_ReadingAnUndeclaredNameThrows() + { + var cmd = Given("x").OptionalArgument("path", "the path").Parse(); + + Assert.Throws(() => cmd.Argument("nope")); + } + + [TestMethod] + public void Argument_ArgumentsListsEveryPositionalInOrder() + { + var cmd = Given("a", "b").Argument("one", "first").Argument("two", "second").Parse(); + + CollectionAssert.AreEqual(new[] { "a", "b" }, cmd.Arguments.ToArray()); + } + + // ------------------------------------------------------------------ rest + + [TestMethod] + public void Rest_CollectsWhatIsLeftVerbatim() + { + var cmd = Given("cmd.exe", "/k", "dir").Argument("program", "what to run").Rest("args", "passed through").Parse(); + + Assert.AreEqual("cmd.exe", cmd.Argument("program")); + CollectionAssert.AreEqual(new[] { "/k", "dir" }, cmd.Rest.ToArray()); + } + + [TestMethod] + public void Rest_StopsSwitchParsingAtTheFirstPositional() + { + // -whatif is ours because it comes first; --help belongs to the child. + var cmd = Given("-whatif", "cmd.exe", "--help") + .Switch("whatif", "touch nothing") + .Argument("program", "what to run") + .Rest("args", "passed through") + .Parse(); + + Assert.IsFalse(cmd.ShouldExit, "--help after the program name is the child's, not ours"); + Assert.IsTrue(cmd.Switch("whatif")); + CollectionAssert.AreEqual(new[] { "--help" }, cmd.Rest.ToArray()); + } + + [TestMethod] + public void Rest_StillRejectsAMistypedSwitchBeforeTheFirstPositional() + { + // The reason the boundary is the first positional rather than the first unrecognised + // switch: otherwise a typo is silently handed to the child. + var cmd = Given("--whatf", "cmd.exe") + .Switch("whatif", "touch nothing") + .Argument("program", "what to run") + .Rest("args", "passed through") + .Parse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "unknown switch '--whatf'"); + } + + [TestMethod] + public void Rest_IsEmptyWhenNothingIsLeft() + { + var cmd = Given("cmd.exe").Argument("program", "what to run").Rest("args", "passed through").Parse(); + + Assert.AreEqual(0, cmd.Rest.Count); + } + + [TestMethod] + public void Rest_ReadingItUndeclaredThrows() + { + var cmd = Given().Switch("whatif", "touch nothing").Parse(); + + Assert.Throws(() => { var ignored = cmd.Rest; }); + } + + // ------------------------------------------------------------------ help + + [TestMethod] + public void Help_IsUnderstoodWithoutBeingDeclared() + { + foreach (var spelling in new[] { "--help", "-h", "-?" }) + { + Capture(); + var cmd = Given(spelling).Switch("whatif", "touch nothing").Parse(); + + Assert.IsTrue(cmd.ShouldExit, spelling); + Assert.IsTrue(cmd.HelpRequested, spelling); + Assert.AreEqual(0, cmd.ExitCode, spelling); + StringAssert.Contains(this.Screen, "Usage:", spelling); + } + } + + [TestMethod] + public void Help_GoesToStandardOutNotStandardError() + { + Given("--help").Switch("whatif", "touch nothing").Parse(); + + StringAssert.Contains(this.Screen, "Usage:"); + Assert.AreEqual(String.Empty, this.Errors); + } + + [TestMethod] + public void Help_WinsOverAnUnknownSwitchAndAMissingArgument() + { + var cmd = Given("--nope", "--help").Argument("file", "the file").Parse(); + + Assert.IsTrue(cmd.HelpRequested); + Assert.AreEqual(0, cmd.ExitCode); + } + + [TestMethod] + public void Help_ListsEveryDeclaredArgumentSwitchAndOption() + { + // The anti-drift guarantee: the help cannot fall out of step with what is accepted, + // because it is rendered from the same declarations. + Given("--help") + .Description("Does a thing.") + .Argument("file", "File to operate on") + .OptionalArgument("output", "output folder") + .Switch("whatif", "What if without execute") + .Option("source", "the feed to use") + .Parse(); + + StringAssert.Contains(this.Screen, "Does a thing."); + StringAssert.Contains(this.Screen, "file"); + StringAssert.Contains(this.Screen, "File to operate on"); + StringAssert.Contains(this.Screen, "output folder"); + StringAssert.Contains(this.Screen, "--whatif"); + StringAssert.Contains(this.Screen, "What if without execute"); + StringAssert.Contains(this.Screen, "--source:"); + StringAssert.Contains(this.Screen, "--help"); + } + + [TestMethod] + public void Help_ShowsRequiredAndOptionalArgumentsDifferently() + { + Given("--help").Argument("file", "the file").OptionalArgument("output", "the folder").Parse(); + + StringAssert.Contains(this.Screen, ""); + StringAssert.Contains(this.Screen, "[output]"); + } + + [TestMethod] + public void Help_ShowsARestWithAnEllipsis() + { + Given("--help").Argument("program", "what to run").Rest("args", "passed through").Parse(); + + StringAssert.Contains(this.Screen, "[args...]"); + } + + [TestMethod] + public void Help_ListsAliasesBesideTheirSwitch() + { + Given("--help").Switch("whatif|dry-run|n", "touch nothing").Parse(); + + StringAssert.Contains(this.Screen, "--whatif, --dry-run, -n"); + } + + [TestMethod] + public void Help_NamesTheProgram() + { + Given("--help").Switch("whatif", "touch nothing").Parse(); + + StringAssert.Contains(this.Screen, "demo"); + } + + [TestMethod] + public void Help_NamesTheProgramWithoutBeingToldWhoItIs() + { + // A .csx or .csrun is named after its own file -- verified by hand under dotnet-script, + // and untestable from here because this caller is a compiled .cs. What IS testable is + // that the fallback never leaves the usage line blank, and that Program() wins. + var inferred = Cli.For(new string[0]).Switch("whatif", "touch nothing").Parse(); + Assert.IsFalse(String.IsNullOrWhiteSpace(inferred.ProgramName)); + + var told = Cli.For(new string[0]).Program("gho").Switch("whatif", "touch nothing").Parse(); + Assert.AreEqual("gho", told.ProgramName); + } + + [TestMethod] + public void Help_DedentsTheDescription() + { + Given("--help").Description(@" + First line. + Indented under it.").Parse(); + + StringAssert.Contains(this.Screen, "First line."); + StringAssert.Contains(this.Screen, " Indented under it."); + Assert.IsFalse(this.Screen.Contains(" First line.")); + } + + [TestMethod] + public void Help_IncludesExamples() + { + Given("--help").Switch("whatif", "touch nothing") + .Example("demo -whatif", "show what would happen") + .Parse(); + + StringAssert.Contains(this.Screen, "Examples:"); + StringAssert.Contains(this.Screen, "demo -whatif"); + StringAssert.Contains(this.Screen, "show what would happen"); + } + + [TestMethod] + public void Help_IsReadableAsAStringWithoutTouchingTheConsole() + { + var cmd = Given().Switch("whatif", "touch nothing").Parse(); + + StringAssert.Contains(cmd.UsageText, "Usage:"); + Assert.AreEqual(String.Empty, this.Screen); + } + + [TestMethod] + public void Help_CanBeReplacedByTheScriptsOwn() + { + Given("--help").Switch("help|h", "show the help my way").Parse(); + + StringAssert.Contains(this.Screen, "show the help my way"); + Assert.IsFalse(this.Screen.Contains("show this help")); + } + + // ------------------------------------------------------------------ usage when empty + + [TestMethod] + public void UsageWhenEmpty_PrintsUsageAndExitsZeroForNoArguments() + { + var cmd = Given().UsageWhenEmpty().Argument("file", "the file").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(0, cmd.ExitCode, "being shown the usage is not a failure"); + StringAssert.Contains(this.Screen, "Usage:"); + } + + [TestMethod] + public void UsageWhenEmpty_IsOffUnlessAskedFor() + { + var cmd = Given().Argument("file", "the file").Parse(); + + Assert.AreEqual(1, cmd.ExitCode, "without it, a missing required argument is still an error"); + StringAssert.Contains(this.Errors, "missing "); + } + + [TestMethod] + public void UsageWhenEmpty_IsNotTriggeredWhenAnythingIsGiven() + { + var cmd = Given("x").UsageWhenEmpty().Argument("file", "the file").Parse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual("x", cmd.Argument("file")); + } + + // ------------------------------------------------------------------ whatif + + [TestMethod] + public void WhatIf_AcceptsAllThreeSpellings() + { + foreach (var spelling in new[] { "-whatif", "--dry-run", "--dryrun", "-n" }) + { + Assert.IsTrue(Given(spelling).WhatIf().Parse().WhatIf, spelling); + } + } + + [TestMethod] + public void WhatIf_IsFalseWhenNotGiven() + { + Assert.IsFalse(Given().WhatIf().Parse().WhatIf); + } + + [TestMethod] + public void WhatIf_ReadingItUndeclaredThrowsRatherThanAnsweringFalse() + { + // Answering false would mean a script that forgot .WhatIf() silently never rehearses. + var cmd = Given().Switch("nopush", "leave the push").Parse(); + + var thrown = Assert.Throws(() => { var ignored = cmd.WhatIf; }); + StringAssert.Contains(thrown.Message, "never declared"); + } + + [TestMethod] + public void WhatIf_ShowsWhatIfAsItsPrimarySpelling() + { + Given("--help").WhatIf().Parse(); + + StringAssert.Contains(this.Screen, "--whatif"); + } + + // ------------------------------------------------------------------ the result contract + + [TestMethod] + public void Parse_IsQuietAndReadableForACleanCommandLine() + { + var cmd = Given("-whatif").Switch("whatif", "touch nothing").Parse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual(0, cmd.ExitCode); + Assert.IsNull(cmd.Error); + Assert.IsFalse(cmd.HelpRequested); + Assert.AreEqual(String.Empty, this.Screen); + Assert.AreEqual(String.Empty, this.Errors); + } + + [TestMethod] + public void Parse_ReadingAnythingAfterAnErrorThrows() + { + // The guard under the ShouldExit contract: a script that forgets the check fails + // loudly instead of running on with defaults it never earned. + var cmd = Given("--nope").Switch("whatif", "touch nothing").Argument("file", "the file").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.Throws(() => cmd.Switch("whatif")); + Assert.Throws(() => cmd.Argument("file")); + } + + [TestMethod] + public void Parse_TheDiagnosticsStayReadableAfterAnError() + { + var cmd = Given("--nope").Switch("whatif", "touch nothing").Parse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(cmd.Error, "unknown switch"); + StringAssert.Contains(cmd.UsageText, "Usage:"); + Assert.AreEqual("demo", cmd.ProgramName); + } + + [TestMethod] + public void Parse_TakesAnArrayOrAList() + { + Assert.IsTrue(Cli.For(new List { "-whatif" }).Program("demo") + .Switch("whatif", "touch nothing").Parse().Switch("whatif")); + + Assert.IsTrue(Cli.For(new[] { "-whatif" }).Program("demo") + .Switch("whatif", "touch nothing").Parse().Switch("whatif")); + } + + [TestMethod] + public void Parse_NullArgumentsThrows() + { + Assert.Throws(() => Cli.For(null)); + } + + [TestMethod] + public void Parse_AnEmptyCommandLineIsFineWhenNothingIsRequired() + { + var cmd = Given().Switch("whatif", "touch nothing").Parse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.IsFalse(cmd.Switch("whatif")); + } + } +} diff --git a/src/CShell.csproj b/src/CShell.csproj index 4f51048..c65bd26 100644 --- a/src/CShell.csproj +++ b/src/CShell.csproj @@ -16,9 +16,9 @@ git scripting dotnet csharp CShell - 2.1.0.0 - 2.1.0.0 - 2.1.0 + 3.0.0.0 + 3.0.0.0 + 3.0.0 true snupkg diff --git a/src/Cli.cs b/src/Cli.cs new file mode 100644 index 0000000..fae29f0 --- /dev/null +++ b/src/Cli.cs @@ -0,0 +1,828 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; + +namespace CShellNet +{ + /// + /// Declares what a script accepts on its command line, and parses 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. + /// + /// 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; + /// + /// string file = cmd.Argument("file"); + /// bool whatIf = cmd.Switch("whatif"); + /// + /// Anything undeclared is an ERROR rather than something to skip past. Silently ignoring a + /// switch is how a mistyped dry-run does the real thing and a mistyped credential runs with + /// the wrong one -- both of which were live bugs in scripts this replaces. + /// + /// Help is generated from the declarations, so it cannot drift from what the script accepts, + /// and `-help`, `-h` and `-?` are always understood without asking. + /// + /// The ceiling, stated so nobody has to discover it: no subcommands, no repeated options, no + /// typed binding, and values ATTACH (`-out:file`, never `-out file` -- see Option). A script + /// that needs more than this should reference System.CommandLine directly rather than growing + /// this into a half-framework. + /// + public class Cli + { + private readonly List tokens; + private readonly List switches = new List(); + private readonly List arguments = new List(); + private readonly List> examples = new List>(); + + private string program; + private string description; + private bool usageWhenEmpty; + private bool whatIfDeclared; + + private Cli(List tokens, string program) + { + this.tokens = tokens; + this.program = program; + + // Help always exists. No script is better off without it when it is generated free, + // and a script wanting different wording just declares its own, which replaces this. + this.switches.Add(new SwitchSpec(new[] { "help", "h", "?" }, new[] { "help", "h", "?" }, + "show this help", false, true)); + } + + /// + /// Begin declaring what this script accepts. + /// + /// + /// The program name shown in the usage line is worked out from the calling script's file + /// name, which is why scriptPath is filled in by the compiler and should not be passed. + /// Under dotnet-script the entry assembly is `dotnet-script` rather than the script, so + /// inferring it any other way would put the wrong name in every usage line. Program() + /// overrides it. + /// + /// the command line, `Args` in a .csx or `args` in a .cs + /// filled in by the compiler; do not pass it + /// the builder, to go on declaring + /// args is null + public static Cli For(IEnumerable args, [CallerFilePath] string scriptPath = null) + { + if (args == null) + { + throw new ArgumentNullException(nameof(args), "Cli.For() needs the command line, not null."); + } + + return new Cli(args.ToList(), ProgramFrom(scriptPath)); + } + + static string ProgramFrom(string scriptPath) + { + if (!String.IsNullOrEmpty(scriptPath)) + { + var name = Path.GetFileNameWithoutExtension(scriptPath); + + // A SCRIPT is invoked by its own file name -- a .csx once .csx is on PATHEXT, a + // .csrun through `dotnet run --file`. The entry assembly is no help for either: + // under dotnet-script it is "dotnet-script", and under a test runner it is + // whatever is hosting. A compiled app is the other way round, so it falls through. + if (!String.IsNullOrEmpty(name) && + (scriptPath.EndsWith(".csx", StringComparison.OrdinalIgnoreCase) || + scriptPath.EndsWith(".csrun", StringComparison.OrdinalIgnoreCase))) + { + return name; + } + + var entry = Assembly.GetEntryAssembly(); + if (entry != null && !String.IsNullOrEmpty(entry.GetName().Name)) + { + return entry.GetName().Name; + } + + if (!String.IsNullOrEmpty(name)) + { + return name; + } + } + + var fallback = Assembly.GetEntryAssembly(); + return fallback != null && !String.IsNullOrEmpty(fallback.GetName().Name) + ? fallback.GetName().Name + : "script"; + } + + /// + /// Name the program in the generated usage, overriding the script's file name. + /// + /// what the user types to run this + /// the builder, to go on declaring + public Cli Program(string name) + { + if (String.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Program() needs a name.", nameof(name)); + } + + this.program = name.Trim(); + return this; + } + + /// + /// The paragraph shown above the usage line, saying what the script is for. + /// + /// + /// Rendered as written apart from having its common leading whitespace removed, so a + /// verbatim string indented inside a script still comes out flush left. The line breaks + /// are the author's and are not re-wrapped. + /// + /// one or more lines of prose + /// the builder, to go on declaring + public Cli Description(string text) + { + this.description = text; + return this; + } + + /// + /// Declare a required positional argument. + /// + /// + /// Positionals fill in declaration order. A bare word on the command line is a positional + /// and never a candidate for the unknown-switch error -- which is what lets a script take + /// a path without every path being rejected as a switch it does not know. + /// + /// what it is called in the usage + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli Argument(string name, string help) + { + return AddArgument(name, help, true, false); + } + + /// + /// Declare a positional argument that may be left out. + /// + /// + /// Reads back null when omitted, so `cmd.Argument("path") ?? Directory.GetCurrentDirectory()` + /// is the idiom. Its own method rather than a `required: false` argument, because a bare + /// `false` in the third position reads as nothing at the call site, and because the + /// declaration chain should read down the page the way the usage line reads across it. + /// + /// what it is called in the usage + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli OptionalArgument(string name, string help) + { + return AddArgument(name, help, false, false); + } + + /// + /// Declare a tail that collects every positional left over. + /// + /// + /// Declaring a Rest STOPS switch parsing at the first positional: everything from there on + /// is collected verbatim, switches and all, so a wrapper can pass `/k dir` to the program + /// it launches. Switches before that first positional are still the script's own. + /// + /// The boundary is the first positional rather than the first unrecognised switch, so that + /// a mistyped switch before it is still rejected instead of being quietly handed to a + /// child process. + /// + /// what it is called in the usage + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli Rest(string name, string help) + { + return AddArgument(name, help, false, true); + } + + Cli AddArgument(string name, string help, bool required, bool isRest) + { + CheckName(name, help, "Argument"); + + if (name.IndexOf('|') >= 0) + { + throw new ArgumentException( + $"Argument(\"{name}\") cannot have aliases -- positionals are matched by position, not by name.", + nameof(name)); + } + + if (this.arguments.Any(a => a.IsRest)) + { + throw new InvalidOperationException( + $"\"{name}\" cannot be declared after a Rest -- a rest collects everything left, so nothing can follow it."); + } + + if (required && this.arguments.Any(a => !a.Required)) + { + var optional = this.arguments.First(a => !a.Required).Name; + throw new InvalidOperationException( + $"Argument(\"{name}\") cannot follow OptionalArgument(\"{optional}\") -- an optional argument must be last."); + } + + if (this.arguments.Any(a => String.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"\"{name}\" is already declared as an argument."); + } + + if (Find(Normalize(name)) != null) + { + throw new InvalidOperationException( + $"\"{name}\" is already declared as a switch -- one name cannot mean both."); + } + + this.arguments.Add(new ArgSpec(name, help, required, isRest)); + return this; + } + + /// + /// Declare a switch that is either on or off. + /// + /// + /// Aliases go in the name after a pipe -- `Switch("whatif|n", "...")` -- so that the second + /// argument is ALWAYS the help text. An overload taking aliases after the help would let + /// `Switch("whatif", "n")` compile and silently make "n" the help, which is the class of + /// quiet mistake this whole type exists to prevent. + /// + /// `-whatif`, `--whatif` and `--what-if` are all the same switch: the leading dashes come + /// off, inner hyphens and underscores go, and case is ignored. + /// + /// Dashes only. `/whatif` is a positional, not a switch: `--` is the near-universal + /// standard now, and treating `/` as a prefix would make every absolute path on Linux + /// look like a switch it had to recognise. + /// + /// the name, optionally followed by |aliases + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it collides with something already declared + public Cli Switch(string name, string help) + { + return AddSwitch(name, help, false); + } + + /// + /// Declare a switch that carries a value, written attached: `-out:file` or `-out=file`. + /// + /// + /// The value ATTACHES. `-out file` is not accepted, and that is a safety property rather + /// than a shortcut: the separated form is what lets a trailing `-out` silently become a + /// positional, and `-out -whatif` silently eat the next switch as its value. Both were + /// live bugs in the scripts this replaces. An attached value is one token, so neither is + /// possible, and someone typing the separated form is told so instead of being misread. + /// + /// Only the NAME is normalized. The value is kept exactly as typed, which is what keeps + /// `-source:https://api.nuget.org/v3/index.json` and `-out:C:\temp\My-Folder` intact. + /// + /// Reads back null when not supplied, so the script writes `cmd.Option("source") ?? "..."`. + /// There is no default parameter here because real defaults are usually computed -- an + /// environment variable, the current directory -- and a parameter serving only constants + /// would be two ways to say one thing. + /// + /// the name, optionally followed by |aliases + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it collides with something already declared + public Cli Option(string name, string help) + { + return AddSwitch(name, help, true); + } + + Cli AddSwitch(string name, string help, bool takesValue) + { + CheckName(name, help, takesValue ? "Option" : "Switch"); + + var parts = name.Split('|').Select(p => p.Trim()).ToArray(); + if (parts.Any(p => p.Length == 0)) + { + throw new ArgumentException($"\"{name}\" has an empty name or alias between its pipes.", nameof(name)); + } + + if (parts.Any(p => p.Any(Char.IsWhiteSpace))) + { + throw new ArgumentException($"\"{name}\" has whitespace inside a name or alias.", nameof(name)); + } + + var keys = parts.Select(Normalize).ToArray(); + if (keys.Distinct().Count() != keys.Length) + { + throw new ArgumentException($"\"{name}\" names the same thing twice.", nameof(name)); + } + + foreach (var key in keys) + { + var clash = Find(key); + if (clash != null && !clash.BuiltIn) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" collides with \"{clash.Primary}\" -- they are the same once case, hyphens and underscores are ignored."); + } + } + + if (this.arguments.Any(a => Normalize(a.Name) == keys[0])) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" is already declared as an argument -- one name cannot mean both."); + } + + // A user declaration REPLACES a built-in of the same name. That is how a script gives + // -help its own wording without having to opt out of anything. + foreach (var key in keys) + { + var builtIn = Find(key); + if (builtIn != null) + { + this.switches.Remove(builtIn); + } + } + + this.switches.Add(new SwitchSpec(parts, keys, help, takesValue, false)); + return this; + } + + static void CheckName(string name, string help, string what) + { + if (String.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"{what}() needs a name.", nameof(name)); + } + + if (name[0] == '-' || name[0] == '/') + { + throw new ArgumentException( + $"{what}(\"{name}\") should be declared without a prefix -- write \"{name.TrimStart('-', '/')}\". " + + "Switches are written with dashes; '/' is not a prefix.", + nameof(name)); + } + + if (String.IsNullOrWhiteSpace(help)) + { + throw new ArgumentException( + $"{what}(\"{name}\") needs the one-line help text shown in --help.", nameof(help)); + } + + // The second argument is ALWAYS the help text; aliases live in the name after a pipe. + // Something short and word-like in that position is almost certainly an alias written + // in the wrong place, and saying so is better than silently printing it as the help. + if (help[0] == '-' || help[0] == '/' || (help.Trim().Length <= 4 && !help.Any(Char.IsWhiteSpace))) + { + throw new ArgumentException( + $"{what}(\"{name}\", \"{help}\") -- the second argument is the help text shown in --help, not an alias. " + + $"Aliases go in the name: \"{name}|{help.Trim().TrimStart('-', '/')}\".", + nameof(help)); + } + } + + /// + /// Declare the conventional dry-run switch: -whatif, also spelled --dry-run or -n. + /// + /// + /// Opt-in on purpose. A dry-run that is accepted and then ignored is worse than none at + /// all -- it is the failure where someone asks for a rehearsal and gets the real thing. + /// So the library declares the switch and nothing more; what a dry run MEANS is the + /// script's to implement, and reading CliResult.WhatIf without having declared it throws + /// rather than quietly answering false. + /// + /// the builder, to go on declaring + public Cli WhatIf() + { + this.whatIfDeclared = true; + return Switch("whatif|dry-run|n", "show what would happen, without doing it"); + } + + /// + /// Print the usage and stop when the script is run with no arguments at all. + /// + /// + /// Opt-in, because a script whose no-argument case is the real work must not print help + /// instead of doing it. Exits 0 -- being asked for help is not a failure. + /// + /// the builder, to go on declaring + public Cli UsageWhenEmpty() + { + this.usageWhenEmpty = true; + return this; + } + + /// + /// Add a worked example to the bottom of the generated help. + /// + /// the command as it would be typed + /// what it does + /// the builder, to go on declaring + public Cli Example(string commandLine, string help) + { + if (String.IsNullOrWhiteSpace(commandLine)) + { + throw new ArgumentException("Example() needs the command line to show.", nameof(commandLine)); + } + + this.examples.Add(new KeyValuePair(commandLine.Trim(), (help ?? "").Trim())); + return this; + } + + SwitchSpec Find(string key) + { + return this.switches.FirstOrDefault(s => s.Keys.Contains(key)); + } + + // Lower-cased with inner hyphens and underscores removed, so --dry-run, --dryrun and + // -Dry_Run are one switch and --api-key and --apikey are one option. + internal static string Normalize(string name) + { + var text = new StringBuilder(name.Length); + foreach (var c in name) + { + if (c != '-' && c != '_') + { + text.Append(Char.ToLowerInvariant(c)); + } + } + + return text.ToString(); + } + + /// + /// Read the command line against everything declared above. + /// + /// + /// Never throws for a bad command line and never exits the process -- a stack trace is the + /// wrong way to say "you typed --dryrun", and a library that exits cannot be tested. The + /// message is written to standard error, help to standard output, and the script does: + /// + /// if (cmd.ShouldExit) return cmd.ExitCode; + /// + /// Forgetting that line is caught rather than ignored: every value on the result throws + /// once the command line was bad. See CliResult. + /// + /// the parsed command line + public CliResult Parse() + { + var values = new Dictionary(StringComparer.Ordinal); + var flags = new HashSet(StringComparer.Ordinal); + var positionals = new List(); + + var unknown = new List(); + var badValues = new List(); + var terminated = false; + var stopSwitches = false; + var restDeclared = this.arguments.Any(a => a.IsRest); + + foreach (var raw in this.tokens) + { + if (terminated || stopSwitches) + { + positionals.Add(raw); + continue; + } + + if (raw == "--") + { + terminated = true; + continue; + } + + if (raw.Length == 0 || raw == "-" || raw[0] != '-') + { + positionals.Add(raw); + + // A declared Rest hands everything from the first positional onward to whatever + // the script is wrapping, switches included. + if (restDeclared) + { + stopSwitches = true; + } + + continue; + } + + var prefix = raw.StartsWith("--", StringComparison.Ordinal) ? 2 : 1; + var body = raw.Substring(prefix); + + // Split BEFORE normalizing, on the first separator only: the name half is + // normalized and the value half is not. The other order corrupts every value that + // contains a hyphen, a capital, or a second colon. + var sep = body.IndexOfAny(new[] { ':', '=' }); + var namePart = sep >= 0 ? body.Substring(0, sep) : body; + var valuePart = sep >= 0 ? body.Substring(sep + 1) : null; + + var spec = Find(Normalize(namePart)); + + if (spec == null) + { + // A negative number is a value, not a mistake. Anything else starting with a + // dash was meant as a switch, so say that it is not one. + if (namePart.Length > 0 && Char.IsDigit(namePart[0])) + { + positionals.Add(raw); + if (restDeclared) { stopSwitches = true; } + } + else + { + unknown.Add(raw); + } + + continue; + } + + if (spec.TakesValue) + { + if (valuePart == null || valuePart.Length == 0) + { + // Never echo what followed: someone typing the separated form may well have + // put a secret in the next token. + badValues.Add($"{Dash(spec.Primary)} needs a value, attached to the switch: '{Dash(spec.Primary)}:value'."); + } + else if (values.ContainsKey(spec.Keys[0])) + { + badValues.Add($"{Dash(spec.Primary)} was given more than once."); + } + else + { + values[spec.Keys[0]] = valuePart; + } + } + else + { + if (valuePart != null) + { + badValues.Add($"{Dash(spec.Primary)} is a switch and takes no value -- write it as '{Dash(spec.Primary)}'."); + } + else + { + flags.Add(spec.Keys[0]); + } + } + } + + var usage = RenderUsage(); + var helpKey = this.switches.First(s => s.Keys.Contains("help")).Keys[0]; + var helpAsked = flags.Contains(helpKey); + + // Being asked for help wins over anything wrong with the rest of the line: someone + // fumbling the syntax and reaching for --help should get --help. + if (this.usageWhenEmpty && this.tokens.Count == 0) + { + Console.Out.WriteLine(usage); + return CliResult.Exiting(this.program, 0, null, true, usage); + } + + if (helpAsked) + { + Console.Out.WriteLine(usage); + return CliResult.Exiting(this.program, 0, null, true, usage); + } + + // Switch-level trouble is reported on its own. Once the switches were misread the + // positional list means nothing, and reporting it as well would echo tokens -- possibly + // a secret -- that the user never meant as arguments. + if (unknown.Count > 0 || badValues.Count > 0) + { + var lines = new List(); + if (unknown.Count == 1) + { + lines.Add($"{this.program}: unknown switch '{unknown[0]}'"); + } + else if (unknown.Count > 1) + { + lines.Add($"{this.program}: unknown switches: {String.Join(" ", unknown.Select(u => "'" + u + "'"))}"); + } + + foreach (var bad in badValues) + { + lines.Add($"{this.program}: {bad}"); + } + + return Failed(String.Join(Environment.NewLine, lines), usage); + } + + // Fill the declared positionals in order, then the rest. + var taken = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tail = new List(); + var next = 0; + + foreach (var arg in this.arguments) + { + if (arg.IsRest) + { + while (next < positionals.Count) + { + tail.Add(positionals[next++]); + } + + break; + } + + if (next < positionals.Count) + { + taken[arg.Name] = positionals[next++]; + } + } + + var missing = this.arguments.FirstOrDefault(a => a.Required && !taken.ContainsKey(a.Name)); + if (missing != null) + { + return Failed($"{this.program}: missing <{missing.Name}>.", usage); + } + + var extra = positionals.Skip(next).ToList(); + if (extra.Count == 1) + { + return Failed($"{this.program}: unexpected argument '{extra[0]}'.", usage); + } + + if (extra.Count > 1) + { + return Failed( + $"{this.program}: unexpected arguments: {String.Join(" ", extra.Select(e => "'" + e + "'"))}", + usage); + } + + return CliResult.Parsed(this.program, usage, flags, values, taken, tail, + this.switches, this.arguments, this.whatIfDeclared); + } + + CliResult Failed(string error, string usage) + { + Console.Error.WriteLine(error); + Console.Error.WriteLine($"Try '{this.program} --help' for the switches it takes."); + return CliResult.Exiting(this.program, 1, error, false, usage); + } + + static string Dash(string name) + { + return name.Length == 1 ? "-" + name : "--" + name; + } + + internal string RenderUsage() + { + var text = new StringBuilder(); + + if (!String.IsNullOrWhiteSpace(this.description)) + { + foreach (var prose in Dedent(this.description)) + { + text.AppendLine(prose); + } + + text.AppendLine(); + } + + var spelled = this.switches.Select(Spelling).ToList(); + var line = new StringBuilder(" " + this.program); + foreach (var arg in this.arguments) + { + line.Append(arg.IsRest ? $" [{arg.Name}...]" : arg.Required ? $" <{arg.Name}>" : $" [{arg.Name}]"); + } + + var withSwitches = new StringBuilder(line.ToString()); + foreach (var s in this.switches) + { + withSwitches.Append(" [" + Spelling(s) + "]"); + } + + text.AppendLine("Usage:"); + text.AppendLine(withSwitches.Length <= 78 ? withSwitches.ToString() : line + " [switches]"); + + // One column across both sections, so the two lists line up as one block. + var widest = 0; + foreach (var a in this.arguments) { widest = Math.Max(widest, a.Name.Length); } + foreach (var s in spelled) { widest = Math.Max(widest, s.Length); } + var column = Math.Min(2 + widest + 2, 30); + + if (this.arguments.Count > 0) + { + text.AppendLine(); + text.AppendLine("Arguments:"); + foreach (var a in this.arguments) + { + Row(text, a.Name, a.Help, column); + } + } + + text.AppendLine(); + text.AppendLine("Switches:"); + for (int i = 0; i < this.switches.Count; i++) + { + Row(text, spelled[i], this.switches[i].Help, column); + } + + if (this.examples.Count > 0) + { + text.AppendLine(); + text.AppendLine("Examples:"); + foreach (var e in this.examples) + { + text.AppendLine(" " + e.Key); + if (e.Value.Length > 0) + { + text.AppendLine(" " + e.Value); + } + } + } + + return text.ToString().TrimEnd(); + } + + static string Spelling(SwitchSpec spec) + { + // Every spelling the user may type, primary first, so the help teaches the aliases + // instead of hiding them. + var text = String.Join(", ", spec.Spellings.Select(Dash)); + return spec.TakesValue ? text + ":" : text; + } + + static void Row(StringBuilder text, string left, string help, int column) + { + var padded = " " + left; + if (padded.Length + 2 <= column) + { + text.AppendLine(padded.PadRight(column) + help); + } + else + { + // Too wide to share a line; the help goes underneath, still in the column. + text.AppendLine(padded); + text.AppendLine(new string(' ', column) + help); + } + } + + // Strip the indentation a verbatim string literal carries, so an indented declaration in a + // script still renders flush left. The author's line breaks are left alone. + internal static IEnumerable Dedent(string text) + { + var lines = text.Replace("\r\n", "\n").Split('\n').Select(l => l.TrimEnd()).ToList(); + while (lines.Count > 0 && lines[0].Length == 0) { lines.RemoveAt(0); } + while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) { lines.RemoveAt(lines.Count - 1); } + + var indent = lines.Where(l => l.Length > 0) + .Select(l => l.Length - l.TrimStart().Length) + .DefaultIfEmpty(0) + .Min(); + + return lines.Select(l => l.Length >= indent ? l.Substring(indent) : l.TrimStart()); + } + } + + internal class SwitchSpec + { + public SwitchSpec(string[] spellings, string[] keys, string help, bool takesValue, bool builtIn) + { + this.Spellings = spellings; + this.Primary = spellings[0]; + this.Keys = keys; + this.Help = help; + this.TakesValue = takesValue; + this.BuiltIn = builtIn; + } + + public string Primary { get; private set; } + + // The spellings as the author wrote them. Keys are normalized for matching; these are + // what help shows, so a switch declared "dry-run" is not advertised as "--dryrun". + public string[] Spellings { get; private set; } + + public string[] Keys { get; private set; } + + public string Help { get; private set; } + + public bool TakesValue { get; private set; } + + public bool BuiltIn { get; private set; } + } + + internal class ArgSpec + { + public ArgSpec(string name, string help, bool required, bool isRest) + { + this.Name = name; + this.Help = help; + this.Required = required; + this.IsRest = isRest; + } + + public string Name { get; private set; } + + public string Help { get; private set; } + + public bool Required { get; private set; } + + public bool IsRest { get; private set; } + } +} diff --git a/src/CliResult.cs b/src/CliResult.cs new file mode 100644 index 0000000..f9c8621 --- /dev/null +++ b/src/CliResult.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CShellNet +{ + /// + /// A command line that has been read against what a script declared. + /// + /// + /// The same words that declared each thing read it back -- Argument, Switch and Option mean + /// "declare" on Cli and "read" here -- so the block that reads the command line can be checked + /// line for line against the block that declared it. + /// + /// Check ShouldExit first, always: + /// + /// if (cmd.ShouldExit) return cmd.ExitCode; + /// + /// Forgetting it is caught rather than ignored. Every value below THROWS once the command line + /// turned out to be bad, because the alternative -- handing back defaults for a line that was + /// never understood -- is the silent-wrong-behaviour this type exists to prevent. The error + /// itself has already been written to standard error by then, so what the user sees is the + /// real message first and a loud failure second. + /// + public class CliResult + { + private readonly HashSet flags; + private readonly Dictionary values; + private readonly Dictionary args; + private readonly List rest; + private readonly List switches; + private readonly List arguments; + private readonly bool whatIfDeclared; + + private CliResult(string program, int exitCode, string error, bool helpRequested, string usage) + { + this.ProgramName = program; + this.ExitCode = exitCode; + this.Error = error; + this.HelpRequested = helpRequested; + this.UsageText = usage; + this.ShouldExit = true; + } + + private CliResult(string program, string usage, HashSet flags, Dictionary values, + Dictionary args, List rest, List switches, + List arguments, bool whatIfDeclared) + { + this.ProgramName = program; + this.UsageText = usage; + this.flags = flags; + this.values = values; + this.args = args; + this.rest = rest; + this.switches = switches; + this.arguments = arguments; + this.whatIfDeclared = whatIfDeclared; + } + + internal static CliResult Exiting(string program, int exitCode, string error, bool helpRequested, string usage) + { + return new CliResult(program, exitCode, error, helpRequested, usage); + } + + internal static CliResult Parsed(string program, string usage, HashSet flags, + Dictionary values, Dictionary args, + List rest, List switches, List arguments, + bool whatIfDeclared) + { + return new CliResult(program, usage, flags, values, args, rest, switches, arguments, whatIfDeclared); + } + + /// The name shown in the usage line. + public string ProgramName { get; private set; } + + /// + /// True when the script should stop -- help was shown, or the command line was not valid. + /// + /// + /// Whatever it reports has already been printed: help to standard output, an error to + /// standard error. The script only has to stop. + /// + public bool ShouldExit { get; private set; } + + /// What to return: 0 for help, 1 for a command line that was not valid. + public int ExitCode { get; private set; } + + /// What was wrong with the command line, or null when nothing was. + public string Error { get; private set; } + + /// True when the user asked for help rather than getting something wrong. + public bool HelpRequested { get; private set; } + + /// The generated help, whether or not it was shown. + public string UsageText { get; private set; } + + /// + /// What was given for a declared positional, or null when an optional one was left out. + /// + /// the name it was declared with + /// the value, or null + /// the command line was not valid + /// nothing was declared by that name + public string Argument(string name) + { + Readable(); + + if (!this.arguments.Any(a => String.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase))) + { + throw Undeclared("argument", name, this.arguments.Select(a => a.Name)); + } + + string value; + return this.args.TryGetValue(name, out value) ? value : null; + } + + /// + /// Whether a declared switch was given. + /// + /// the name it was declared with, or any of its aliases + /// true when it was given + /// the command line was not valid + /// nothing was declared by that name + public bool Switch(string name) + { + Readable(); + var spec = Spec(name, false); + return this.flags.Contains(spec.Keys[0]); + } + + /// + /// The value given for a declared option, or null when it was not supplied. + /// + /// + /// Null rather than a default, so the script says what its default is: + /// `cmd.Option("source") ?? "https://api.nuget.org/v3/index.json"`. + /// + /// the name it was declared with, or any of its aliases + /// the value as typed, or null + /// the command line was not valid + /// nothing was declared by that name + public string Option(string name) + { + Readable(); + var spec = Spec(name, true); + + string value; + return this.values.TryGetValue(spec.Keys[0], out value) ? value : null; + } + + /// + /// Whether the dry-run switch was given. + /// + /// + /// Throws when the script never called Cli.WhatIf(). Answering false would mean a script + /// that forgot to declare it silently never rehearses -- someone asks for a dry run and + /// gets the real thing, which is the worst failure this whole type is guarding against. + /// + /// the command line was not valid, or WhatIf() was never declared + public bool WhatIf + { + get + { + Readable(); + + if (!this.whatIfDeclared) + { + throw new InvalidOperationException( + "WhatIf was never declared -- add .WhatIf() to the Cli chain, or this script has no dry run to report."); + } + + return this.flags.Contains("whatif"); + } + } + + /// Everything the declared Rest collected, or empty when it collected nothing. + /// the command line was not valid, or no Rest was declared + public IReadOnlyList Rest + { + get + { + Readable(); + + if (!this.arguments.Any(a => a.IsRest)) + { + throw new InvalidOperationException("No Rest was declared -- add .Rest(name, help) to the Cli chain."); + } + + return this.rest; + } + } + + /// Every positional given, in the order it was given. + /// the command line was not valid + public IReadOnlyList Arguments + { + get + { + Readable(); + + var all = this.arguments.Where(a => !a.IsRest) + .Select(a => this.args.ContainsKey(a.Name) ? this.args[a.Name] : null) + .Where(v => v != null) + .ToList(); + all.AddRange(this.rest); + return all; + } + } + + void Readable() + { + if (this.ShouldExit) + { + throw new InvalidOperationException( + "The command line was not valid, so there is nothing to read from it -- check ShouldExit before reading anything."); + } + } + + SwitchSpec Spec(string name, bool wantValue) + { + var key = Cli.Normalize(name ?? ""); + var spec = this.switches.FirstOrDefault(s => s.Keys.Contains(key) && s.TakesValue == wantValue); + + if (spec == null) + { + throw Undeclared(wantValue ? "option" : "switch", name, + this.switches.Where(s => s.TakesValue == wantValue).Select(s => s.Primary)); + } + + return spec; + } + + static ArgumentException Undeclared(string what, string name, IEnumerable declared) + { + var known = declared.ToList(); + var list = known.Count > 0 ? String.Join(", ", known.Select(k => "\"" + k + "\"")) : "nothing"; + + // Name what WAS declared: a typo in the script is as easy to make as one on the + // command line, and as quiet. + return new ArgumentException($"No {what} \"{name}\" was declared. Declared: {list}.", nameof(name)); + } + } +} From 1697cd775909f0c7ef38354104fac97ee3012903 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 10:59:35 -0700 Subject: [PATCH 04/12] Document the Ask methods and Cli in the README 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- README.md | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eee6163..ac3c5be 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ most methods, so if you call **MoveFile(@"..\foo.txt", @"..\..\bar")** it will r | **FolderStack** | current stack from Push/Pop operations | | **Echo** | Controls whether commands are echoed to output | | **ThrowOnError** | Controls whether to throw exception when commands have non-sucess error code | +| **RichPrompts** | Whether the Ask methods use arrow keys or read a typed line. Null (the default) decides by asking whether standard input is redirected | +| **ReadKey** | Where the Ask methods get their keystrokes. Null reads the console | ### Folder Methods CShell defines a number of methods which work relative to the current folder to make it easy @@ -69,6 +71,147 @@ print("Hello world!"); error("ohoh!"); ``` +### Asking the user +The **Ask** methods are the questions a *script* asks the *user*. (For the other direction, a +process that asks the user something itself, see the remarks on **Run()**.) + +| Method | Description | +|------------------|--------------------------------------------------------------------------------------------------| +| **AskText(question)** | read a line of text, trimmed | +| **AskSecret(question)** | read without echoing anything, for tokens and passwords | +| **AskYesNo(question)** | a yes/no question, returning bool | +| **AskYesNo(question, default)** | the same, where enter accepts the default | +| **AskNumber(question)** | a whole number | +| **AskNumber(question, min, max)** | a whole number held inside a range | +| **AskChoice(question, options, label)** | pick one from a list; returns the option itself | +| **AskChoice(question, style, options, label)** | the same, choosing how the options are labelled | +| **AskMultiChoice(question, options, label)** | pick any number of them; returns an array | +| **AskMultiChoice(question, style, options, label)** | the same, choosing how the options are labelled | + +```CSharp +var name = AskText("What should I call you?"); +var token = AskSecret("Paste a token:"); // nothing appears as it is typed +var retries = AskNumber("How many retries?", 1, 5); +var push = AskYesNo("Push straight to main?", false); + +string[] fruits = ["apple", "banana", "cherry"]; +var fruit = AskChoice("Pick a fruit:", fruits); // returns "banana", not 2 + +var repo = AskChoice("Pick a repo:", repos, r => r.Name); // returns the Repo itself +var extra = AskMultiChoice("Choose your toppings:", toppings); +``` + +**AskChoice** and **AskMultiChoice** are generic. They return the option itself rather than its +position, and an optional selector says what to show for each, so you can hand them your own +objects and get one back with no lookup. Without a selector they use `ToString()`. + +Every Ask method has **two modes and picks between them itself**. With a console it draws a rich +prompt -- a selection you move with the arrow keys, redrawn as it changes. With standard input +redirected it reads a typed line instead. That is not cosmetic: `Console.ReadKey()` throws when +input is redirected, so a script that is piped, scheduled or running under CI has no keys to read +and needs a typed twin rather than a degraded version of the same thing. `RichPrompts` overrides +the choice and `ReadKey` supplies the keystrokes. + +`ChoiceStyle` decides how the options are labelled, and under `Letters` it also decides what may +be typed: + +| Style | Renders | A typed answer may be | +|------------|--------------------|------------------------------------| +| **Auto** | nothing when there are arrow keys, numbers when the answer must be typed | the option's text, or its number | +| **Numbers**| `1) 2) 3)` | the option's text, or its number | +| **Letters**| `a) b) c)` | the option's text, or its letter -- a bare `2` names nothing | +| **None** | nothing | the option's text only | + +The option's own text is matched **before** its position, so a list whose options are themselves +numbers -- `"3", "1", "2"` -- answers the way it reads: typing `3` picks the option labelled 3 +rather than the third one. + +Every Ask method throws rather than answering for someone who is not there: at end of stream it +says which question went unanswered, instead of taking an empty answer or spinning forever on a +console nobody is attached to. + +See **askdemo.csx** in this repo for a guided tour that shows each call and then runs it. + +### Command line +**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, and an +**Option** carries a value. The same three words read the values back, so the block that reads a +command line can be checked line for line against the block that declared it. + +```CSharp +var cmd = Cli.For(Args) + .Description("Opens a repository in GitHub Desktop.") + .OptionalArgument("path", "the repository to open; defaults to the current directory") + .WhatIf() + .Option("source", "the feed to use; defaults to nuget.org") + .Parse(); + +if (cmd.ShouldExit) + return cmd.ExitCode; + +var path = cmd.Argument("path") ?? Directory.GetCurrentDirectory(); +var source = cmd.Option("source") ?? "https://api.nuget.org/v3/index.json"; +var whatIf = cmd.WhatIf; +``` + +| Declare on Cli | Description | +|------------------|--------------------------------------------------------------------------------------------------| +| **Argument(name, help)** | a required positional, filled in declaration order | +| **OptionalArgument(name, help)** | a positional that may be left out; reads back null | +| **Rest(name, help)** | a tail collecting everything left, verbatim | +| **Switch(name, help)** | a switch that is on or off; aliases go in the name after a pipe: `"whatif\|n"` | +| **Option(name, help)** | a switch carrying a value, written attached: `-out:file` | +| **WhatIf()** | declares the conventional dry run: `-whatif`, also `--dry-run` or `-n` | +| **Description(text)** | the paragraph shown above the usage line | +| **Example(commandLine, help)** | a worked example for the bottom of the help | +| **Program(name)** | override the name in the usage line | +| **UsageWhenEmpty()** | print the usage when run with no arguments at all | +| **Parse()** | read the command line and return a CliResult | + +| Read on CliResult | Description | +|------------------|--------------------------------------------------------------------------------------------------| +| **ShouldExit** | true when the script should stop -- help was shown, or the command line was not valid | +| **ExitCode** | what to return: 0 for help, 1 for a command line that was not valid | +| **Argument(name)** | what was given for a positional, or null when an optional one was omitted | +| **Switch(name)** | whether a switch was given | +| **Option(name)** | the value given for an option, or null | +| **WhatIf** | whether the dry-run switch was given | +| **Rest** | everything the declared Rest collected | +| **Arguments** | every positional given, in order | +| **Error** | what was wrong with the command line, or null | +| **UsageText** | the generated help, whether or not it was shown | +| **ProgramName** | the name shown in the usage line | + +**Anything undeclared is an error.** Silently ignoring an unknown switch is how a mistyped +`--dry-run` does the real thing and a mistyped `--api-key` runs with the wrong one. Bare words are +positionals rather than unknown switches, which is what lets a script take a path without every +path being rejected as a switch it does not know. + +**Values attach.** `-out:file` and `-out=file`, never `-out file`. That is a safety property +rather than a shortcut: the separated form is what lets a trailing `-out` silently become a +positional, and `-out --whatif` silently swallow the next switch as its value. An attached value +is one token, so neither is possible, and someone typing the separated form is told so. Only the +name is normalized -- the value is kept exactly as typed, so +`-source:https://api.nuget.org/v3/index.json` and `-out:C:\temp\My-Folder` arrive intact. + +**Switches are spelled with dashes.** `-whatif`, `--whatif` and `--what-if` are one switch: the +leading dashes come off, inner hyphens and underscores go, and case is ignored. `/` is not a +prefix -- it would make every absolute path on Linux look like a switch. + +**Help is generated from the declarations**, so it cannot drift from what the script accepts. +`-help`, `-h` and `-?` work without being asked for, and the program name comes from the calling +script's file name. + +**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 writes the +message to standard error, help to standard output, 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. + +The ceiling, stated so nobody has to discover it: no subcommands, no repeated options, no typed +binding, and no separated values. A script that needs more than this can reference +[System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. + ### Process Methods CShell is built using [MedallionShell](https://github.com/madelson/MedallionShell), which provides a great set of functionality for easily invoking processes and piping data between them. CShell adds on location awareness and helper methods @@ -169,7 +312,7 @@ To invoke the template > NOTE: If you want debug support from visual studio code simply run **dotnet script init** in the same folder. ```csharp -#r "nuget: CShell, 2.1.0" +#r "nuget: CShell, 3.0.0" global using static CShellNet.Globals; using CShellNet; @@ -206,7 +349,7 @@ On Linux/Mac you can make a .csx file executable by ```bash #!/usr/bin/env dotnet-script -#r "nuget: CShell, 1.5.0" +#r "nuget: CShell, 3.0.0" global using static CShellNet.Globals; using CShellNet; ``` @@ -235,6 +378,16 @@ chmod +x example.csx ``` ## CHANGELOG +### v3.0.0 +* Added **Cli**, a declarative command line parser with generated --help + * Argument()/OptionalArgument()/Rest() for positionals, Switch() for booleans, Option() for attached values + * anything undeclared is an error; Parse() reports and sets ShouldExit rather than exiting the process +* Added the **Ask** methods: AskText, AskSecret, AskYesNo, AskNumber, AskChoice, AskMultiChoice + * AskChoice/AskMultiChoice are generic and return the option itself rather than its position + * each has a rich arrow-key mode and a typed-line mode, chosen from whether input is redirected +* Added **RichPrompts** and **ReadKey** to control how the Ask methods read input +* Nothing was removed or renamed -- 3.0.0 marks the size of the release, not a break + ### v2.1.0 * Added Write/WriteLine/print/error methods for writing to standard out and standard error From 6af07439a969a19351f4a107afbed9093e37bdcf Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:01:05 -0700 Subject: [PATCH 05/12] new image readme --- README.md | 9 --------- turtle.png | Bin 12179 -> 39827 bytes 2 files changed, 9 deletions(-) diff --git a/README.md b/README.md index ac3c5be..bd91ae0 100644 --- a/README.md +++ b/README.md @@ -202,15 +202,6 @@ prefix -- it would make every absolute path on Linux look like a switch. `-help`, `-h` and `-?` work without being asked for, and the program name comes from the calling script's file name. -**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 writes the -message to standard error, help to standard output, 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. - -The ceiling, stated so nobody has to discover it: no subcommands, no repeated options, no typed -binding, and no separated values. A script that needs more than this can reference -[System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. ### Process Methods CShell is built using [MedallionShell](https://github.com/madelson/MedallionShell), which provides a great set of functionality for easily invoking diff --git a/turtle.png b/turtle.png index 77e6682c6f870ea2c1afa5c37e0ba8c728c15728..82e70834fdbbc91a31e944d536b1d18b5e5f0ba1 100644 GIT binary patch literal 39827 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ν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 From 72d76e1605df2cc9a8ea5d152f7e99247816d12f Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:05:38 -0700 Subject: [PATCH 06/12] Make Cli.Parse() stop the script, and add TryParse() for everyone else 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- README.md | 30 ++++-- Tests/CShell.Tests/Cli.Tests.cs | 164 ++++++++++++++++---------------- src/Cli.cs | 46 +++++++-- src/CliResult.cs | 13 +-- 4 files changed, 150 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index bd91ae0..437403f 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,6 @@ var cmd = Cli.For(Args) .Option("source", "the feed to use; defaults to nuget.org") .Parse(); -if (cmd.ShouldExit) - return cmd.ExitCode; - var path = cmd.Argument("path") ?? Directory.GetCurrentDirectory(); var source = cmd.Option("source") ?? "https://api.nuget.org/v3/index.json"; var whatIf = cmd.WhatIf; @@ -166,12 +163,13 @@ var whatIf = cmd.WhatIf; | **Example(commandLine, help)** | a worked example for the bottom of the help | | **Program(name)** | override the name in the usage line | | **UsageWhenEmpty()** | print the usage when run with no arguments at all | -| **Parse()** | read the command line and return a CliResult | +| **Parse()** | read the command line; prints and exits if it was not valid or help was asked for | +| **TryParse()** | the same, reported rather than acted on -- for tests, and for handling it yourself | | Read on CliResult | Description | |------------------|--------------------------------------------------------------------------------------------------| -| **ShouldExit** | true when the script should stop -- help was shown, or the command line was not valid | -| **ExitCode** | what to return: 0 for help, 1 for a command line that was not valid | +| **ShouldExit** | *(TryParse only)* true when the script should stop -- help was shown, or the line was not valid | +| **ExitCode** | *(TryParse only)* 0 for help, 1 for a command line that was not valid | | **Argument(name)** | what was given for a positional, or null when an optional one was omitted | | **Switch(name)** | whether a switch was given | | **Option(name)** | the value given for an option, or null | @@ -202,6 +200,24 @@ prefix -- it would make every absolute path on Linux look like a switch. `-help`, `-h` and `-?` work without being asked for, and the program name comes from the calling script's file name. +**Parse() stops the script itself** when the command line was not valid or help was asked for, so +there is nothing to check: what it returns is always usable, and a line that was not understood +never reaches the script body. The message has already gone to standard error, or the help to +standard output, and the process has exited 1 or 0. It never throws for a bad command line -- a +stack trace is the wrong way to say "you typed --dryrun" -- though it still throws for a mistake +in the script itself, at the declaration that caused it. + +Use **TryParse()** where exiting is not acceptable: a test, or a command line parsed inside a +larger program that means to handle the failure itself. It reports through `ShouldExit` and +`ExitCode` instead, and forgetting to check them is caught rather than ignored -- every value on +the result throws until you do, so a missed check fails loudly instead of running on with defaults +it never earned. + +The ceiling, stated so nobody has to discover it: no subcommands, no repeated options, no typed +binding, and no separated values. A script that needs more than this can reference +[System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly -- it targets +netstandard2.0, so a `.csx` can `#r` it without CShell being involved. + ### Process Methods CShell is built using [MedallionShell](https://github.com/madelson/MedallionShell), which provides a great set of functionality for easily invoking @@ -372,7 +388,7 @@ chmod +x example.csx ### v3.0.0 * Added **Cli**, a declarative command line parser with generated --help * Argument()/OptionalArgument()/Rest() for positionals, Switch() for booleans, Option() for attached values - * anything undeclared is an error; Parse() reports and sets ShouldExit rather than exiting the process + * anything undeclared is an error; Parse() reports it and exits, TryParse() hands it back instead * Added the **Ask** methods: AskText, AskSecret, AskYesNo, AskNumber, AskChoice, AskMultiChoice * AskChoice/AskMultiChoice are generic and return the option itself rather than its position * each has a rich arrow-key mode and a typed-line mode, chosen from whether input is redirected diff --git a/Tests/CShell.Tests/Cli.Tests.cs b/Tests/CShell.Tests/Cli.Tests.cs index 830510d..7063232 100644 --- a/Tests/CShell.Tests/Cli.Tests.cs +++ b/Tests/CShell.Tests/Cli.Tests.cs @@ -52,8 +52,8 @@ public void Restore() [TestMethod] public void Switch_IsTrueWhenGivenAndFalseWhenAbsent() { - Assert.IsTrue(Given("-whatif").Switch("whatif", "touch nothing").Parse().Switch("whatif")); - Assert.IsFalse(Given().Switch("whatif", "touch nothing").Parse().Switch("whatif")); + Assert.IsTrue(Given("-whatif").Switch("whatif", "touch nothing").TryParse().Switch("whatif")); + Assert.IsFalse(Given().Switch("whatif", "touch nothing").TryParse().Switch("whatif")); } [TestMethod] @@ -61,7 +61,7 @@ public void Switch_AcceptsEitherDashPrefix() { foreach (var spelling in new[] { "-whatif", "--whatif" }) { - Assert.IsTrue(Given(spelling).Switch("whatif", "touch nothing").Parse().Switch("whatif"), spelling); + Assert.IsTrue(Given(spelling).Switch("whatif", "touch nothing").TryParse().Switch("whatif"), spelling); } } @@ -70,7 +70,7 @@ public void Switch_IgnoresCaseHyphensAndUnderscores() { foreach (var spelling in new[] { "--DRY-RUN", "--dryrun", "-Dry_Run", "--d-r-y-r-u-n" }) { - Assert.IsTrue(Given(spelling).Switch("dry-run", "print only").Parse().Switch("dry-run"), spelling); + Assert.IsTrue(Given(spelling).Switch("dry-run", "print only").TryParse().Switch("dry-run"), spelling); } } @@ -79,24 +79,24 @@ public void Switch_AliasesAfterThePipeSetTheSameSwitch() { foreach (var spelling in new[] { "-whatif", "--dry-run", "-n" }) { - Assert.IsTrue(Given(spelling).Switch("whatif|dry-run|n", "touch nothing").Parse().Switch("whatif"), spelling); + Assert.IsTrue(Given(spelling).Switch("whatif|dry-run|n", "touch nothing").TryParse().Switch("whatif"), spelling); } // and it reads back under any of its names - var cmd = Given("-n").Switch("whatif|dry-run|n", "touch nothing").Parse(); + var cmd = Given("-n").Switch("whatif|dry-run|n", "touch nothing").TryParse(); Assert.IsTrue(cmd.Switch("dry-run")); } [TestMethod] public void Switch_RepeatedIsStillJustTrue() { - Assert.IsTrue(Given("-whatif", "--whatif").Switch("whatif", "touch nothing").Parse().Switch("whatif")); + Assert.IsTrue(Given("-whatif", "--whatif").Switch("whatif", "touch nothing").TryParse().Switch("whatif")); } [TestMethod] public void Switch_GivenAValueIsAnError() { - var cmd = Given("-whatif:true").Switch("whatif", "touch nothing").Parse(); + var cmd = Given("-whatif:true").Switch("whatif", "touch nothing").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.AreEqual(1, cmd.ExitCode); @@ -106,7 +106,7 @@ public void Switch_GivenAValueIsAnError() [TestMethod] public void Switch_ReadingAnUndeclaredNameThrowsAndSaysWhatWasDeclared() { - var cmd = Given().Switch("whatif", "touch nothing").Parse(); + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); var thrown = Assert.Throws(() => cmd.Switch("nopush")); StringAssert.Contains(thrown.Message, "nopush"); @@ -185,16 +185,16 @@ public void Declaring_AnAliasOnAnArgumentThrows() [TestMethod] public void Option_TakesItsValueAfterAColonOrAnEquals() { - Assert.AreEqual("test", Given("-folder:test").Option("folder", "the folder").Parse().Option("folder")); - Assert.AreEqual("test", Given("-folder=test").Option("folder", "the folder").Parse().Option("folder")); - Assert.AreEqual("test", Given("--folder:test").Option("folder", "the folder").Parse().Option("folder")); + Assert.AreEqual("test", Given("-folder:test").Option("folder", "the folder").TryParse().Option("folder")); + Assert.AreEqual("test", Given("-folder=test").Option("folder", "the folder").TryParse().Option("folder")); + Assert.AreEqual("test", Given("--folder:test").Option("folder", "the folder").TryParse().Option("folder")); } [TestMethod] public void Option_IsNullWhenNotGiven() { - Assert.IsNull(Given().Option("folder", "the folder").Parse().Option("folder")); + Assert.IsNull(Given().Option("folder", "the folder").TryParse().Option("folder")); } [TestMethod] @@ -202,7 +202,7 @@ public void Option_NameIsNormalisedButTheValueIsNot() { // The whole point of splitting before normalising: --API-KEY finds the option, and // the key it carries is untouched. - var cmd = Given("--API-KEY:sk-ant-AbC123").Option("api-key", "the key").Parse(); + var cmd = Given("--API-KEY:sk-ant-AbC123").Option("api-key", "the key").TryParse(); Assert.AreEqual("sk-ant-AbC123", cmd.Option("api-key")); } @@ -210,7 +210,7 @@ public void Option_NameIsNormalisedButTheValueIsNot() [TestMethod] public void Option_ValueKeepsItsCaseAndHyphens() { - var cmd = Given(@"-out:C:\temp\My-Folder").Option("out", "where to write").Parse(); + var cmd = Given(@"-out:C:\temp\My-Folder").Option("out", "where to write").TryParse(); Assert.AreEqual(@"C:\temp\My-Folder", cmd.Option("out")); } @@ -219,10 +219,10 @@ public void Option_ValueKeepsItsCaseAndHyphens() public void Option_SplitsOnTheFirstSeparatorOnlySoAValueMayContainMore() { Assert.AreEqual("https://api.nuget.org/v3/index.json", - Given("-source:https://api.nuget.org/v3/index.json").Option("source", "the feed").Parse().Option("source")); + Given("-source:https://api.nuget.org/v3/index.json").Option("source", "the feed").TryParse().Option("source")); - Assert.AreEqual("a=b=c", Given("-q:a=b=c").Option("q", "a query").Parse().Option("q")); - Assert.AreEqual(@"C:\temp", Given(@"-out=C:\temp").Option("out", "where to write").Parse().Option("out")); + Assert.AreEqual("a=b=c", Given("-q:a=b=c").Option("q", "a query").TryParse().Option("q")); + Assert.AreEqual(@"C:\temp", Given(@"-out=C:\temp").Option("out", "where to write").TryParse().Option("out")); } [TestMethod] @@ -230,7 +230,7 @@ public void Option_ApiKeyAndApikeyAreTheSameOption() { foreach (var spelling in new[] { "--api-key:x", "--apikey:x", "-API_KEY:x" }) { - Assert.AreEqual("x", Given(spelling).Option("api-key", "the key").Parse().Option("api-key"), spelling); + Assert.AreEqual("x", Given(spelling).Option("api-key", "the key").TryParse().Option("api-key"), spelling); } } @@ -239,7 +239,7 @@ public void Option_GivenBareIsAnErrorNamingTheAttachedForm() { // This is what catches someone typing the separated "--folder test" habit, instead of // letting "test" slide through as a positional. - var cmd = Given("-folder").Option("folder", "the folder").Parse(); + var cmd = Given("-folder").Option("folder", "the folder").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.AreEqual(1, cmd.ExitCode); @@ -250,14 +250,14 @@ public void Option_GivenBareIsAnErrorNamingTheAttachedForm() [TestMethod] public void Option_GivenAnEmptyValueIsAnError() { - Assert.IsTrue(Given("-folder:").Option("folder", "the folder").Parse().ShouldExit); + Assert.IsTrue(Given("-folder:").Option("folder", "the folder").TryParse().ShouldExit); StringAssert.Contains(this.Errors, "needs a value"); } [TestMethod] public void Option_GivenTwiceIsAnError() { - var cmd = Given("-source:a", "-source:b").Option("source", "the feed").Parse(); + var cmd = Given("-source:a", "-source:b").Option("source", "the feed").TryParse(); Assert.IsTrue(cmd.ShouldExit); StringAssert.Contains(this.Errors, "more than once"); @@ -268,12 +268,12 @@ public void Option_ErrorMessagesNeverEchoTheValue() { // A secret must not reach stderr because the user typed it twice, or typed the // separated form and left it dangling. - Given("--api-key:sk-ant-SECRET", "--api-key:sk-ant-OTHER").Option("api-key", "the key").Parse(); + Given("--api-key:sk-ant-SECRET", "--api-key:sk-ant-OTHER").Option("api-key", "the key").TryParse(); Assert.IsFalse(this.Errors.Contains("SECRET"), "an option's value must never be echoed"); Assert.IsFalse(this.Errors.Contains("OTHER"), "an option's value must never be echoed"); Capture(); - Given("--api-key", "sk-ant-SECRET").Option("api-key", "the key").Parse(); + Given("--api-key", "sk-ant-SECRET").Option("api-key", "the key").TryParse(); Assert.IsFalse(this.Errors.Contains("SECRET"), "the token after a bare option must not be echoed as an unexpected argument either"); } @@ -283,7 +283,7 @@ public void Option_ErrorMessagesNeverEchoTheValue() [TestMethod] public void Unknown_SwitchIsAnErrorNamingTheRawToken() { - var cmd = Given("--dryrun").Switch("nopush", "leave the push").Parse(); + var cmd = Given("--dryrun").Switch("nopush", "leave the push").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.AreEqual(1, cmd.ExitCode); @@ -293,7 +293,7 @@ public void Unknown_SwitchIsAnErrorNamingTheRawToken() [TestMethod] public void Unknown_SwitchGoesToStandardErrorNotStandardOut() { - Given("--nope").Switch("whatif", "touch nothing").Parse(); + Given("--nope").Switch("whatif", "touch nothing").TryParse(); StringAssert.Contains(this.Errors, "unknown switch"); Assert.AreEqual(String.Empty, this.Screen, "an error is not output"); @@ -302,7 +302,7 @@ public void Unknown_SwitchGoesToStandardErrorNotStandardOut() [TestMethod] public void Unknown_SwitchPointsAtHelpRatherThanPrintingIt() { - Given("--nope").Switch("whatif", "touch nothing").Parse(); + Given("--nope").Switch("whatif", "touch nothing").TryParse(); StringAssert.Contains(this.Errors, "Try 'demo --help'"); Assert.IsFalse(this.Errors.Contains("Switches:"), "the full usage is noise here"); @@ -311,7 +311,7 @@ public void Unknown_SwitchPointsAtHelpRatherThanPrintingIt() [TestMethod] public void Unknown_SwitchesAreAllReportedAtOnce() { - Given("--nope", "--alsonope").Switch("whatif", "touch nothing").Parse(); + Given("--nope", "--alsonope").Switch("whatif", "touch nothing").TryParse(); StringAssert.Contains(this.Errors, "'--nope'"); StringAssert.Contains(this.Errors, "'--alsonope'"); @@ -321,13 +321,13 @@ public void Unknown_SwitchesAreAllReportedAtOnce() public void Unknown_ATypoThatNormalisesToADeclaredSwitchIsNotUnknown() { // "--dryrun" for "--dry-run" is the bug this closes: today it becomes a path. - Assert.IsTrue(Given("--dryrun").Switch("dry-run", "print only").Parse().Switch("dry-run")); + Assert.IsTrue(Given("--dryrun").Switch("dry-run", "print only").TryParse().Switch("dry-run")); } [TestMethod] public void Unknown_SwitchErrorsSuppressPositionalErrors() { - var cmd = Given("--nope", "extra1", "extra2").Switch("whatif", "touch nothing").Parse(); + var cmd = Given("--nope", "extra1", "extra2").Switch("whatif", "touch nothing").TryParse(); Assert.IsTrue(cmd.ShouldExit); StringAssert.Contains(this.Errors, "unknown switch"); @@ -340,7 +340,7 @@ public void Unknown_SwitchErrorsSuppressPositionalErrors() [TestMethod] public void Argument_FillsInDeclarationOrder() { - var cmd = Given("in.txt", "out").Argument("file", "the file").Argument("output", "the folder").Parse(); + var cmd = Given("in.txt", "out").Argument("file", "the file").Argument("output", "the folder").TryParse(); Assert.AreEqual("in.txt", cmd.Argument("file")); Assert.AreEqual("out", cmd.Argument("output")); @@ -349,7 +349,7 @@ public void Argument_FillsInDeclarationOrder() [TestMethod] public void Argument_MayBeInterspersedWithSwitches() { - var cmd = Given("repo", "-whatif").OptionalArgument("repo", "the repo").Switch("whatif", "touch nothing").Parse(); + var cmd = Given("repo", "-whatif").OptionalArgument("repo", "the repo").Switch("whatif", "touch nothing").TryParse(); Assert.AreEqual("repo", cmd.Argument("repo")); Assert.IsTrue(cmd.Switch("whatif")); @@ -358,7 +358,7 @@ public void Argument_MayBeInterspersedWithSwitches() [TestMethod] public void Argument_MissingRequiredIsAnErrorNamingIt() { - var cmd = Given().Argument("file", "the file").Parse(); + var cmd = Given().Argument("file", "the file").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.AreEqual(1, cmd.ExitCode); @@ -368,19 +368,19 @@ public void Argument_MissingRequiredIsAnErrorNamingIt() [TestMethod] public void Argument_OptionalMayBeOmittedAndReadsNull() { - Assert.IsNull(Given().OptionalArgument("path", "the path").Parse().Argument("path")); - Assert.AreEqual("x", Given("x").OptionalArgument("path", "the path").Parse().Argument("path")); + Assert.IsNull(Given().OptionalArgument("path", "the path").TryParse().Argument("path")); + Assert.AreEqual("x", Given("x").OptionalArgument("path", "the path").TryParse().Argument("path")); } [TestMethod] public void Argument_TooManyIsAnErrorNamingTheUnexpectedOnes() { - var one = Given("a", "b").OptionalArgument("path", "the path").Parse(); + var one = Given("a", "b").OptionalArgument("path", "the path").TryParse(); Assert.IsTrue(one.ShouldExit); StringAssert.Contains(this.Errors, "unexpected argument 'b'"); Capture(); - Given("a", "b", "c").OptionalArgument("path", "the path").Parse(); + Given("a", "b", "c").OptionalArgument("path", "the path").TryParse(); StringAssert.Contains(this.Errors, "unexpected arguments: 'b' 'c'"); } @@ -390,19 +390,19 @@ public void Argument_PathsAreNotMistakenForSwitches() // The reason '/' is recognised rather than demanded: an absolute path on Linux starts // with one. Assert.AreEqual("/home/tom/file", - Given("/home/tom/file").OptionalArgument("path", "the path").Parse().Argument("path")); + Given("/home/tom/file").OptionalArgument("path", "the path").TryParse().Argument("path")); Capture(); Assert.AreEqual(@"C:\temp", - Given(@"C:\temp").OptionalArgument("path", "the path").Parse().Argument("path")); + Given(@"C:\temp").OptionalArgument("path", "the path").TryParse().Argument("path")); Capture(); Assert.AreEqual("/tmp/x:y", - Given("/tmp/x:y").OptionalArgument("path", "the path").Parse().Argument("path")); + Given("/tmp/x:y").OptionalArgument("path", "the path").TryParse().Argument("path")); Capture(); Assert.AreEqual("/usr/local/bin", - Given("/usr/local/bin").OptionalArgument("path", "the path").Parse().Argument("path")); + Given("/usr/local/bin").OptionalArgument("path", "the path").TryParse().Argument("path")); } [TestMethod] @@ -410,10 +410,10 @@ public void Argument_ASlashTokenIsAlwaysAPositional() { // '/' is not a switch prefix. Dashes are the standard, and treating '/' as a prefix // would make every absolute path on Linux something the parser had to recognise. - Assert.AreEqual("/nope", Given("/nope").OptionalArgument("path", "the path").Parse().Argument("path")); + Assert.AreEqual("/nope", Given("/nope").OptionalArgument("path", "the path").TryParse().Argument("path")); Capture(); - var cmd = Given("/whatif").OptionalArgument("path", "the path").Switch("whatif", "touch nothing").Parse(); + var cmd = Given("/whatif").OptionalArgument("path", "the path").Switch("whatif", "touch nothing").TryParse(); Assert.AreEqual("/whatif", cmd.Argument("path"), "a slash token is a value, not the switch it resembles"); Assert.IsFalse(cmd.Switch("whatif")); } @@ -421,23 +421,23 @@ public void Argument_ASlashTokenIsAlwaysAPositional() [TestMethod] public void Argument_ADashTokenIsAnUnknownSwitchNotAPositional() { - Assert.IsTrue(Given("-nope").OptionalArgument("path", "the path").Parse().ShouldExit); + Assert.IsTrue(Given("-nope").OptionalArgument("path", "the path").TryParse().ShouldExit); StringAssert.Contains(this.Errors, "unknown switch"); } [TestMethod] public void Argument_NegativeNumbersAndABareDashArePositionals() { - Assert.AreEqual("-9", Given("-9").OptionalArgument("n", "a number").Parse().Argument("n")); + Assert.AreEqual("-9", Given("-9").OptionalArgument("n", "a number").TryParse().Argument("n")); Capture(); - Assert.AreEqual("-", Given("-").OptionalArgument("n", "stdin").Parse().Argument("n")); + Assert.AreEqual("-", Given("-").OptionalArgument("n", "stdin").TryParse().Argument("n")); } [TestMethod] public void Argument_AfterTheTerminatorMayLookLikeASwitch() { - var cmd = Given("--", "-weird-name").OptionalArgument("path", "the path").Parse(); + var cmd = Given("--", "-weird-name").OptionalArgument("path", "the path").TryParse(); Assert.AreEqual("-weird-name", cmd.Argument("path")); } @@ -445,7 +445,7 @@ public void Argument_AfterTheTerminatorMayLookLikeASwitch() [TestMethod] public void Argument_TheTerminatorIsNotItselfAPositional() { - var cmd = Given("a", "--", "b").Argument("one", "first").OptionalArgument("two", "second").Parse(); + var cmd = Given("a", "--", "b").Argument("one", "first").OptionalArgument("two", "second").TryParse(); Assert.AreEqual("a", cmd.Argument("one")); Assert.AreEqual("b", cmd.Argument("two")); @@ -454,7 +454,7 @@ public void Argument_TheTerminatorIsNotItselfAPositional() [TestMethod] public void Argument_ReadingAnUndeclaredNameThrows() { - var cmd = Given("x").OptionalArgument("path", "the path").Parse(); + var cmd = Given("x").OptionalArgument("path", "the path").TryParse(); Assert.Throws(() => cmd.Argument("nope")); } @@ -462,7 +462,7 @@ public void Argument_ReadingAnUndeclaredNameThrows() [TestMethod] public void Argument_ArgumentsListsEveryPositionalInOrder() { - var cmd = Given("a", "b").Argument("one", "first").Argument("two", "second").Parse(); + var cmd = Given("a", "b").Argument("one", "first").Argument("two", "second").TryParse(); CollectionAssert.AreEqual(new[] { "a", "b" }, cmd.Arguments.ToArray()); } @@ -472,7 +472,7 @@ public void Argument_ArgumentsListsEveryPositionalInOrder() [TestMethod] public void Rest_CollectsWhatIsLeftVerbatim() { - var cmd = Given("cmd.exe", "/k", "dir").Argument("program", "what to run").Rest("args", "passed through").Parse(); + var cmd = Given("cmd.exe", "/k", "dir").Argument("program", "what to run").Rest("args", "passed through").TryParse(); Assert.AreEqual("cmd.exe", cmd.Argument("program")); CollectionAssert.AreEqual(new[] { "/k", "dir" }, cmd.Rest.ToArray()); @@ -486,7 +486,7 @@ public void Rest_StopsSwitchParsingAtTheFirstPositional() .Switch("whatif", "touch nothing") .Argument("program", "what to run") .Rest("args", "passed through") - .Parse(); + .TryParse(); Assert.IsFalse(cmd.ShouldExit, "--help after the program name is the child's, not ours"); Assert.IsTrue(cmd.Switch("whatif")); @@ -502,7 +502,7 @@ public void Rest_StillRejectsAMistypedSwitchBeforeTheFirstPositional() .Switch("whatif", "touch nothing") .Argument("program", "what to run") .Rest("args", "passed through") - .Parse(); + .TryParse(); Assert.IsTrue(cmd.ShouldExit); StringAssert.Contains(this.Errors, "unknown switch '--whatf'"); @@ -511,7 +511,7 @@ public void Rest_StillRejectsAMistypedSwitchBeforeTheFirstPositional() [TestMethod] public void Rest_IsEmptyWhenNothingIsLeft() { - var cmd = Given("cmd.exe").Argument("program", "what to run").Rest("args", "passed through").Parse(); + var cmd = Given("cmd.exe").Argument("program", "what to run").Rest("args", "passed through").TryParse(); Assert.AreEqual(0, cmd.Rest.Count); } @@ -519,7 +519,7 @@ public void Rest_IsEmptyWhenNothingIsLeft() [TestMethod] public void Rest_ReadingItUndeclaredThrows() { - var cmd = Given().Switch("whatif", "touch nothing").Parse(); + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); Assert.Throws(() => { var ignored = cmd.Rest; }); } @@ -532,7 +532,7 @@ public void Help_IsUnderstoodWithoutBeingDeclared() foreach (var spelling in new[] { "--help", "-h", "-?" }) { Capture(); - var cmd = Given(spelling).Switch("whatif", "touch nothing").Parse(); + var cmd = Given(spelling).Switch("whatif", "touch nothing").TryParse(); Assert.IsTrue(cmd.ShouldExit, spelling); Assert.IsTrue(cmd.HelpRequested, spelling); @@ -544,7 +544,7 @@ public void Help_IsUnderstoodWithoutBeingDeclared() [TestMethod] public void Help_GoesToStandardOutNotStandardError() { - Given("--help").Switch("whatif", "touch nothing").Parse(); + Given("--help").Switch("whatif", "touch nothing").TryParse(); StringAssert.Contains(this.Screen, "Usage:"); Assert.AreEqual(String.Empty, this.Errors); @@ -553,7 +553,7 @@ public void Help_GoesToStandardOutNotStandardError() [TestMethod] public void Help_WinsOverAnUnknownSwitchAndAMissingArgument() { - var cmd = Given("--nope", "--help").Argument("file", "the file").Parse(); + var cmd = Given("--nope", "--help").Argument("file", "the file").TryParse(); Assert.IsTrue(cmd.HelpRequested); Assert.AreEqual(0, cmd.ExitCode); @@ -570,7 +570,7 @@ public void Help_ListsEveryDeclaredArgumentSwitchAndOption() .OptionalArgument("output", "output folder") .Switch("whatif", "What if without execute") .Option("source", "the feed to use") - .Parse(); + .TryParse(); StringAssert.Contains(this.Screen, "Does a thing."); StringAssert.Contains(this.Screen, "file"); @@ -585,7 +585,7 @@ public void Help_ListsEveryDeclaredArgumentSwitchAndOption() [TestMethod] public void Help_ShowsRequiredAndOptionalArgumentsDifferently() { - Given("--help").Argument("file", "the file").OptionalArgument("output", "the folder").Parse(); + Given("--help").Argument("file", "the file").OptionalArgument("output", "the folder").TryParse(); StringAssert.Contains(this.Screen, ""); StringAssert.Contains(this.Screen, "[output]"); @@ -594,7 +594,7 @@ public void Help_ShowsRequiredAndOptionalArgumentsDifferently() [TestMethod] public void Help_ShowsARestWithAnEllipsis() { - Given("--help").Argument("program", "what to run").Rest("args", "passed through").Parse(); + Given("--help").Argument("program", "what to run").Rest("args", "passed through").TryParse(); StringAssert.Contains(this.Screen, "[args...]"); } @@ -602,7 +602,7 @@ public void Help_ShowsARestWithAnEllipsis() [TestMethod] public void Help_ListsAliasesBesideTheirSwitch() { - Given("--help").Switch("whatif|dry-run|n", "touch nothing").Parse(); + Given("--help").Switch("whatif|dry-run|n", "touch nothing").TryParse(); StringAssert.Contains(this.Screen, "--whatif, --dry-run, -n"); } @@ -610,7 +610,7 @@ public void Help_ListsAliasesBesideTheirSwitch() [TestMethod] public void Help_NamesTheProgram() { - Given("--help").Switch("whatif", "touch nothing").Parse(); + Given("--help").Switch("whatif", "touch nothing").TryParse(); StringAssert.Contains(this.Screen, "demo"); } @@ -621,10 +621,10 @@ public void Help_NamesTheProgramWithoutBeingToldWhoItIs() // A .csx or .csrun is named after its own file -- verified by hand under dotnet-script, // and untestable from here because this caller is a compiled .cs. What IS testable is // that the fallback never leaves the usage line blank, and that Program() wins. - var inferred = Cli.For(new string[0]).Switch("whatif", "touch nothing").Parse(); + var inferred = Cli.For(new string[0]).Switch("whatif", "touch nothing").TryParse(); Assert.IsFalse(String.IsNullOrWhiteSpace(inferred.ProgramName)); - var told = Cli.For(new string[0]).Program("gho").Switch("whatif", "touch nothing").Parse(); + var told = Cli.For(new string[0]).Program("gho").Switch("whatif", "touch nothing").TryParse(); Assert.AreEqual("gho", told.ProgramName); } @@ -633,7 +633,7 @@ public void Help_DedentsTheDescription() { Given("--help").Description(@" First line. - Indented under it.").Parse(); + Indented under it.").TryParse(); StringAssert.Contains(this.Screen, "First line."); StringAssert.Contains(this.Screen, " Indented under it."); @@ -645,7 +645,7 @@ public void Help_IncludesExamples() { Given("--help").Switch("whatif", "touch nothing") .Example("demo -whatif", "show what would happen") - .Parse(); + .TryParse(); StringAssert.Contains(this.Screen, "Examples:"); StringAssert.Contains(this.Screen, "demo -whatif"); @@ -655,7 +655,7 @@ public void Help_IncludesExamples() [TestMethod] public void Help_IsReadableAsAStringWithoutTouchingTheConsole() { - var cmd = Given().Switch("whatif", "touch nothing").Parse(); + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); StringAssert.Contains(cmd.UsageText, "Usage:"); Assert.AreEqual(String.Empty, this.Screen); @@ -664,7 +664,7 @@ public void Help_IsReadableAsAStringWithoutTouchingTheConsole() [TestMethod] public void Help_CanBeReplacedByTheScriptsOwn() { - Given("--help").Switch("help|h", "show the help my way").Parse(); + Given("--help").Switch("help|h", "show the help my way").TryParse(); StringAssert.Contains(this.Screen, "show the help my way"); Assert.IsFalse(this.Screen.Contains("show this help")); @@ -675,7 +675,7 @@ public void Help_CanBeReplacedByTheScriptsOwn() [TestMethod] public void UsageWhenEmpty_PrintsUsageAndExitsZeroForNoArguments() { - var cmd = Given().UsageWhenEmpty().Argument("file", "the file").Parse(); + var cmd = Given().UsageWhenEmpty().Argument("file", "the file").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.AreEqual(0, cmd.ExitCode, "being shown the usage is not a failure"); @@ -685,7 +685,7 @@ public void UsageWhenEmpty_PrintsUsageAndExitsZeroForNoArguments() [TestMethod] public void UsageWhenEmpty_IsOffUnlessAskedFor() { - var cmd = Given().Argument("file", "the file").Parse(); + var cmd = Given().Argument("file", "the file").TryParse(); Assert.AreEqual(1, cmd.ExitCode, "without it, a missing required argument is still an error"); StringAssert.Contains(this.Errors, "missing "); @@ -694,7 +694,7 @@ public void UsageWhenEmpty_IsOffUnlessAskedFor() [TestMethod] public void UsageWhenEmpty_IsNotTriggeredWhenAnythingIsGiven() { - var cmd = Given("x").UsageWhenEmpty().Argument("file", "the file").Parse(); + var cmd = Given("x").UsageWhenEmpty().Argument("file", "the file").TryParse(); Assert.IsFalse(cmd.ShouldExit); Assert.AreEqual("x", cmd.Argument("file")); @@ -707,21 +707,21 @@ public void WhatIf_AcceptsAllThreeSpellings() { foreach (var spelling in new[] { "-whatif", "--dry-run", "--dryrun", "-n" }) { - Assert.IsTrue(Given(spelling).WhatIf().Parse().WhatIf, spelling); + Assert.IsTrue(Given(spelling).WhatIf().TryParse().WhatIf, spelling); } } [TestMethod] public void WhatIf_IsFalseWhenNotGiven() { - Assert.IsFalse(Given().WhatIf().Parse().WhatIf); + Assert.IsFalse(Given().WhatIf().TryParse().WhatIf); } [TestMethod] public void WhatIf_ReadingItUndeclaredThrowsRatherThanAnsweringFalse() { // Answering false would mean a script that forgot .WhatIf() silently never rehearses. - var cmd = Given().Switch("nopush", "leave the push").Parse(); + var cmd = Given().Switch("nopush", "leave the push").TryParse(); var thrown = Assert.Throws(() => { var ignored = cmd.WhatIf; }); StringAssert.Contains(thrown.Message, "never declared"); @@ -730,7 +730,7 @@ public void WhatIf_ReadingItUndeclaredThrowsRatherThanAnsweringFalse() [TestMethod] public void WhatIf_ShowsWhatIfAsItsPrimarySpelling() { - Given("--help").WhatIf().Parse(); + Given("--help").WhatIf().TryParse(); StringAssert.Contains(this.Screen, "--whatif"); } @@ -740,7 +740,7 @@ public void WhatIf_ShowsWhatIfAsItsPrimarySpelling() [TestMethod] public void Parse_IsQuietAndReadableForACleanCommandLine() { - var cmd = Given("-whatif").Switch("whatif", "touch nothing").Parse(); + var cmd = Given("-whatif").Switch("whatif", "touch nothing").TryParse(); Assert.IsFalse(cmd.ShouldExit); Assert.AreEqual(0, cmd.ExitCode); @@ -755,7 +755,7 @@ public void Parse_ReadingAnythingAfterAnErrorThrows() { // The guard under the ShouldExit contract: a script that forgets the check fails // loudly instead of running on with defaults it never earned. - var cmd = Given("--nope").Switch("whatif", "touch nothing").Argument("file", "the file").Parse(); + var cmd = Given("--nope").Switch("whatif", "touch nothing").Argument("file", "the file").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.Throws(() => cmd.Switch("whatif")); @@ -765,7 +765,7 @@ public void Parse_ReadingAnythingAfterAnErrorThrows() [TestMethod] public void Parse_TheDiagnosticsStayReadableAfterAnError() { - var cmd = Given("--nope").Switch("whatif", "touch nothing").Parse(); + var cmd = Given("--nope").Switch("whatif", "touch nothing").TryParse(); Assert.IsTrue(cmd.ShouldExit); Assert.AreEqual(1, cmd.ExitCode); @@ -778,10 +778,10 @@ public void Parse_TheDiagnosticsStayReadableAfterAnError() public void Parse_TakesAnArrayOrAList() { Assert.IsTrue(Cli.For(new List { "-whatif" }).Program("demo") - .Switch("whatif", "touch nothing").Parse().Switch("whatif")); + .Switch("whatif", "touch nothing").TryParse().Switch("whatif")); Assert.IsTrue(Cli.For(new[] { "-whatif" }).Program("demo") - .Switch("whatif", "touch nothing").Parse().Switch("whatif")); + .Switch("whatif", "touch nothing").TryParse().Switch("whatif")); } [TestMethod] @@ -793,7 +793,7 @@ public void Parse_NullArgumentsThrows() [TestMethod] public void Parse_AnEmptyCommandLineIsFineWhenNothingIsRequired() { - var cmd = Given().Switch("whatif", "touch nothing").Parse(); + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); Assert.IsFalse(cmd.ShouldExit); Assert.IsFalse(cmd.Switch("whatif")); diff --git a/src/Cli.cs b/src/Cli.cs index fae29f0..685e6e0 100644 --- a/src/Cli.cs +++ b/src/Cli.cs @@ -459,20 +459,50 @@ internal static string Normalize(string name) } /// - /// Read the command line against everything declared above. + /// Read the command line, and stop the script if it was not valid or help was asked for. /// /// - /// Never throws for a bad command line and never exits the process -- a stack trace is the - /// wrong way to say "you typed --dryrun", and a library that exits cannot be tested. The - /// message is written to standard error, help to standard output, and the script does: + /// What comes back is always usable, so a script goes straight on to reading it: /// - /// if (cmd.ShouldExit) return cmd.ExitCode; + /// var cmd = Cli.For(Args).Switch("whatif", "touch nothing").Parse(); + /// bool whatIf = cmd.Switch("whatif"); /// - /// Forgetting that line is caught rather than ignored: every value on the result throws - /// once the command line was bad. See CliResult. + /// There is nothing to check, because a command line that was not understood never gets + /// this far. The message has already gone to standard error, or the help to standard + /// output, and the process has exited 1 or 0 accordingly. + /// + /// It never throws for a BAD COMMAND LINE -- a stack trace is the wrong way to say "you + /// typed --dryrun". It still throws for a mistake in the script itself, at the declaration + /// that caused it. + /// + /// Use TryParse() where exiting is not acceptable: a test, or a Cli parsed inside a + /// larger program that means to handle the failure itself. /// - /// the parsed command line + /// the parsed command line, always readable public CliResult Parse() + { + var cmd = TryParse(); + + if (cmd.ShouldExit) + { + Environment.Exit(cmd.ExitCode); + } + + return cmd; + } + + /// + /// Read the command line without ever exiting the process. + /// + /// + /// The same work as Parse(), reported rather than acted on: check ShouldExit and use + /// ExitCode. Everything else on the result throws until you do, so a skipped check fails + /// loudly instead of running on with defaults it never earned. + /// + /// This is what Parse() is built on, and what the tests use. A script wants Parse(). + /// + /// the parsed command line, which may be one that should not be used + public CliResult TryParse() { var values = new Dictionary(StringComparer.Ordinal); var flags = new HashSet(StringComparer.Ordinal); diff --git a/src/CliResult.cs b/src/CliResult.cs index f9c8621..59043c7 100644 --- a/src/CliResult.cs +++ b/src/CliResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -12,11 +12,11 @@ namespace CShellNet /// "declare" on Cli and "read" here -- so the block that reads the command line can be checked /// line for line against the block that declared it. /// - /// Check ShouldExit first, always: + /// From Cli.Parse() this is always usable, because a command line that was not understood + /// exited the process instead of arriving here. Read it and get on with the script. /// - /// if (cmd.ShouldExit) return cmd.ExitCode; - /// - /// Forgetting it is caught rather than ignored. Every value below THROWS once the command line + /// From Cli.TryParse() it may be one that should not be used, so check ShouldExit first. + /// Forgetting is caught rather than ignored: every value below THROWS once the command line /// turned out to be bad, because the alternative -- handing back defaults for a line that was /// never understood -- is the silent-wrong-behaviour this type exists to prevent. The error /// itself has already been written to standard error by then, so what the user sees is the @@ -77,8 +77,9 @@ internal static CliResult Parsed(string program, string usage, HashSet f /// True when the script should stop -- help was shown, or the command line was not valid. /// /// + /// Always false from Parse(), which will have exited instead. This is for TryParse(). /// Whatever it reports has already been printed: help to standard output, an error to - /// standard error. The script only has to stop. + /// standard error. /// public bool ShouldExit { get; private set; } From 2d910db12338313e35fd8ce662a23b792a538584 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:26:31 -0700 Subject: [PATCH 07/12] Replace Newtonsoft.Json with System.Text.Json, and target net8.0 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() 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- README.md | 117 ++++++++-------------- Tests/CShell.Tests/Command.Tests.cs | 38 +++++-- Tests/CShell.Tests/CommandGlobal.Tests.cs | 18 ++-- src/CShell.csproj | 3 +- src/CommandExtensions.cs | 18 ++-- src/CommandResultExtensions.cs | 18 ++-- src/Json.cs | 47 +++++++++ 7 files changed, 149 insertions(+), 110 deletions(-) create mode 100644 src/Json.cs diff --git a/README.md b/README.md index 437403f..65c0d32 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ CShell provides: By maintaining the concept of a current folder all file and folder commands can be take absolute or relative paths just like a normal shell. +CShell targets **net8.0** and depends only on +[MedallionShell](https://github.com/madelson/MedallionShell). JSON is read with the in-box +System.Text.Json. + ### Properties CShell exposes 3 properties which are the working environment of your script. The CurrentFolder is used to resolve relative paths for most methods, so if you call **MoveFile(@"..\foo.txt", @"..\..\bar")** it will resolve the paths and execute just like a normal shell. @@ -72,8 +76,7 @@ error("ohoh!"); ``` ### Asking the user -The **Ask** methods are the questions a *script* asks the *user*. (For the other direction, a -process that asks the user something itself, see the remarks on **Run()**.) +The **Ask** methods ask the user a question and return the answer. | Method | Description | |------------------|--------------------------------------------------------------------------------------------------| @@ -95,48 +98,34 @@ var retries = AskNumber("How many retries?", 1, 5); var push = AskYesNo("Push straight to main?", false); string[] fruits = ["apple", "banana", "cherry"]; -var fruit = AskChoice("Pick a fruit:", fruits); // returns "banana", not 2 - +var fruit = AskChoice("Pick a fruit:", fruits); // returns "banana", not 2 var repo = AskChoice("Pick a repo:", repos, r => r.Name); // returns the Repo itself var extra = AskMultiChoice("Choose your toppings:", toppings); ``` -**AskChoice** and **AskMultiChoice** are generic. They return the option itself rather than its -position, and an optional selector says what to show for each, so you can hand them your own -objects and get one back with no lookup. Without a selector they use `ToString()`. +* **AskChoice** and **AskMultiChoice** are generic and return the option itself, not its position. + The optional selector says what to show for each; without one they use `ToString()`. +* With a console they draw arrow-key prompts; with input redirected they read a typed line. + `RichPrompts` forces either mode, `ReadKey` supplies the keystrokes. +* An option's own text is matched before its position, so a list of `"3", "1", "2"` answers the + way it reads. +* At end of stream they throw, naming the question, rather than returning an empty answer. -Every Ask method has **two modes and picks between them itself**. With a console it draws a rich -prompt -- a selection you move with the arrow keys, redrawn as it changes. With standard input -redirected it reads a typed line instead. That is not cosmetic: `Console.ReadKey()` throws when -input is redirected, so a script that is piped, scheduled or running under CI has no keys to read -and needs a typed twin rather than a degraded version of the same thing. `RichPrompts` overrides -the choice and `ReadKey` supplies the keystrokes. - -`ChoiceStyle` decides how the options are labelled, and under `Letters` it also decides what may -be typed: +`ChoiceStyle` sets the labels, and under `Letters` also what may be typed: | Style | Renders | A typed answer may be | |------------|--------------------|------------------------------------| -| **Auto** | nothing when there are arrow keys, numbers when the answer must be typed | the option's text, or its number | +| **Auto** | nothing with arrow keys, numbers when typed | the option's text, or its number | | **Numbers**| `1) 2) 3)` | the option's text, or its number | -| **Letters**| `a) b) c)` | the option's text, or its letter -- a bare `2` names nothing | +| **Letters**| `a) b) c)` | the option's text, or its letter | | **None** | nothing | the option's text only | -The option's own text is matched **before** its position, so a list whose options are themselves -numbers -- `"3", "1", "2"` -- answers the way it reads: typing `3` picks the option labelled 3 -rather than the third one. - -Every Ask method throws rather than answering for someone who is not there: at end of stream it -says which question went unanswered, instead of taking an empty answer or spinning forever on a -console nobody is attached to. - -See **askdemo.csx** in this repo for a guided tour that shows each call and then runs it. +See **askdemo.csx** for a guided tour that shows each call and then runs it. ### Command line -**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, and an -**Option** carries a value. The same three words read the values back, so the block that reads a -command line can be checked line for line against the block that declared it. +**Cli** declares what a script accepts and reads the command line against it. An **Argument** is a +positional, a **Switch** is on or off, an **Option** carries a value. The same three words read +the values back. ```CSharp var cmd = Cli.For(Args) @@ -164,12 +153,10 @@ var whatIf = cmd.WhatIf; | **Program(name)** | override the name in the usage line | | **UsageWhenEmpty()** | print the usage when run with no arguments at all | | **Parse()** | read the command line; prints and exits if it was not valid or help was asked for | -| **TryParse()** | the same, reported rather than acted on -- for tests, and for handling it yourself | +| **TryParse()** | the same, reported through ShouldExit rather than acted on | | Read on CliResult | Description | |------------------|--------------------------------------------------------------------------------------------------| -| **ShouldExit** | *(TryParse only)* true when the script should stop -- help was shown, or the line was not valid | -| **ExitCode** | *(TryParse only)* 0 for help, 1 for a command line that was not valid | | **Argument(name)** | what was given for a positional, or null when an optional one was omitted | | **Switch(name)** | whether a switch was given | | **Option(name)** | the value given for an option, or null | @@ -179,45 +166,20 @@ var whatIf = cmd.WhatIf; | **Error** | what was wrong with the command line, or null | | **UsageText** | the generated help, whether or not it was shown | | **ProgramName** | the name shown in the usage line | +| **ShouldExit** | *(TryParse only)* true when the script should stop | +| **ExitCode** | *(TryParse only)* 0 for help, 1 for a command line that was not valid | -**Anything undeclared is an error.** Silently ignoring an unknown switch is how a mistyped -`--dry-run` does the real thing and a mistyped `--api-key` runs with the wrong one. Bare words are -positionals rather than unknown switches, which is what lets a script take a path without every -path being rejected as a switch it does not know. - -**Values attach.** `-out:file` and `-out=file`, never `-out file`. That is a safety property -rather than a shortcut: the separated form is what lets a trailing `-out` silently become a -positional, and `-out --whatif` silently swallow the next switch as its value. An attached value -is one token, so neither is possible, and someone typing the separated form is told so. Only the -name is normalized -- the value is kept exactly as typed, so -`-source:https://api.nuget.org/v3/index.json` and `-out:C:\temp\My-Folder` arrive intact. - -**Switches are spelled with dashes.** `-whatif`, `--whatif` and `--what-if` are one switch: the -leading dashes come off, inner hyphens and underscores go, and case is ignored. `/` is not a -prefix -- it would make every absolute path on Linux look like a switch. - -**Help is generated from the declarations**, so it cannot drift from what the script accepts. -`-help`, `-h` and `-?` work without being asked for, and the program name comes from the calling -script's file name. - -**Parse() stops the script itself** when the command line was not valid or help was asked for, so -there is nothing to check: what it returns is always usable, and a line that was not understood -never reaches the script body. The message has already gone to standard error, or the help to -standard output, and the process has exited 1 or 0. It never throws for a bad command line -- a -stack trace is the wrong way to say "you typed --dryrun" -- though it still throws for a mistake -in the script itself, at the declaration that caused it. - -Use **TryParse()** where exiting is not acceptable: a test, or a command line parsed inside a -larger program that means to handle the failure itself. It reports through `ShouldExit` and -`ExitCode` instead, and forgetting to check them is caught rather than ignored -- every value on -the result throws until you do, so a missed check fails loudly instead of running on with defaults -it never earned. - -The ceiling, stated so nobody has to discover it: no subcommands, no repeated options, no typed -binding, and no separated values. A script that needs more than this can reference -[System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly -- it targets -netstandard2.0, so a `.csx` can `#r` it without CShell being involved. - +* Anything undeclared is an error. Bare words are positionals, not unknown switches. +* Values attach: `-out:file` or `-out=file`, never `-out file`. Only the name is normalized, so + `-source:https://api.nuget.org/v3/index.json` arrives intact. +* `-whatif`, `--whatif` and `--what-if` are one switch -- dashes come off, inner hyphens and + underscores go, case is ignored. `/` is not a prefix. +* `-help`, `-h` and `-?` work without being declared, and the help is generated from the + declarations. The program name comes from the calling script's file name. +* `Parse()` exits on a bad command line, so what it returns is always usable. `TryParse()` reports + through `ShouldExit`/`ExitCode` instead, and every other value on it throws until you check. +* No subcommands, repeated options, typed binding, or separated values. For more, + reference [System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. ### Process Methods CShell is built using [MedallionShell](https://github.com/madelson/MedallionShell), which provides a great set of functionality for easily invoking @@ -262,7 +224,7 @@ CShell adds on helper methods to make it even easier to work with the result of |------------------|------------------------------------------------------------------------------| | **Execute(log)** | get the CommandResult (with stdout/stderr) of the last command | | **AsString(log)** | get the standard out of the last command a string | -| **AsJson(log)** | JSON Deserialize the standard out of the last command into a JObject/dynamic | +| **AsJson(log)** | Parse the standard out of the last command as JSON, navigated by indexer: `json["owner"]["login"]` | | **AsJson\(log)** | JSON Deserialize the standard out of the last command into a typed T | | **AsXml\(log)** | XML Deserialize the standard out of the last command intoa typed T | | **AsFile()** | Write the stdout/stderr of the last command to a file | @@ -388,12 +350,15 @@ chmod +x example.csx ### v3.0.0 * Added **Cli**, a declarative command line parser with generated --help * Argument()/OptionalArgument()/Rest() for positionals, Switch() for booleans, Option() for attached values - * anything undeclared is an error; Parse() reports it and exits, TryParse() hands it back instead + * anything undeclared is an error; Parse() exits on a bad command line, TryParse() reports instead * Added the **Ask** methods: AskText, AskSecret, AskYesNo, AskNumber, AskChoice, AskMultiChoice * AskChoice/AskMultiChoice are generic and return the option itself rather than its position - * each has a rich arrow-key mode and a typed-line mode, chosen from whether input is redirected + * each has an arrow-key mode and a typed-line mode, chosen from whether input is redirected * Added **RichPrompts** and **ReadKey** to control how the Ask methods read input -* Nothing was removed or renamed -- 3.0.0 marks the size of the release, not a break +* **BREAKING** now targets net8.0 rather than netstandard2.0. .NET Framework is no longer supported +* **BREAKING** replaced Newtonsoft.Json with System.Text.Json; MedallionShell is now the only dependency + * `AsJson()` returns a `JsonNode`, not a dynamic `JObject` -- use `json["owner"]["login"]`. `AsJson()` is unchanged + * property names still match case insensitively; trailing commas, comments and a byte order mark are tolerated ### v2.1.0 * Added Write/WriteLine/print/error methods for writing to standard out and standard error diff --git a/Tests/CShell.Tests/Command.Tests.cs b/Tests/CShell.Tests/Command.Tests.cs index 67ccda6..9c476ef 100644 --- a/Tests/CShell.Tests/Command.Tests.cs +++ b/Tests/CShell.Tests/Command.Tests.cs @@ -1,7 +1,8 @@ -using CShellNet; +using CShellNet; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System; using System.IO; using System.Reflection; @@ -16,10 +17,18 @@ public TestRecord() } - [JsonProperty("name")] + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("age")] + public int Age { get; set; } + } + + /// The same shape with no attributes, so nothing but case-insensitive matching can fill it. + public class UnmappedRecord + { public string Name { get; set; } - [JsonProperty("age")] public int Age { get; set; } } @@ -62,13 +71,20 @@ public async Task Test_AsJson() Assert.AreEqual("Joe Smith", record.Name, "name is wrong"); Assert.AreEqual(42, record.Age, "age is wrong"); - JObject record2 = (JObject)await shell.ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record2["name"], "JOBject name is wrong"); - Assert.AreEqual(42, (int)record2["age"], "JOBject age is wrong"); + JsonNode record2 = await shell.ReadFile("TestA.txt").AsJson(); + Assert.AreEqual("Joe Smith", (string)record2["name"], "JsonNode name is wrong"); + Assert.AreEqual(42, (int)record2["age"], "JsonNode age is wrong"); + + // Nested indexing is how a JsonNode is navigated. Member access -- record.name -- + // came from the Newtonsoft JObject and is gone with it. + Assert.IsNull(record2["nope"], "a missing property reads as null"); - dynamic record3 = await shell.ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record3.name, "dynamic name is wrong"); - Assert.AreEqual(42, (int)record3.age, "dynamic age is wrong"); + // System.Text.Json matches property names case sensitively by default, which would + // leave both of these at their defaults rather than failing. CShell turns that off, + // because CLI tools emit camelCase and the C# modelling them is PascalCase. + var unmapped = await shell.ReadFile("TestA.txt").AsJson(); + Assert.AreEqual("Joe Smith", unmapped.Name, "lowercase json must still fill a PascalCase property"); + Assert.AreEqual(42, unmapped.Age, "lowercase json must still fill a PascalCase property"); } diff --git a/Tests/CShell.Tests/CommandGlobal.Tests.cs b/Tests/CShell.Tests/CommandGlobal.Tests.cs index 425e959..1192136 100644 --- a/Tests/CShell.Tests/CommandGlobal.Tests.cs +++ b/Tests/CShell.Tests/CommandGlobal.Tests.cs @@ -1,8 +1,8 @@ -global using static CShellNet.Globals; +global using static CShellNet.Globals; global using CShellNet; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using System; using System.IO; using System.Reflection; @@ -48,13 +48,13 @@ public async Task Test_Global_AsJson() Assert.AreEqual("Joe Smith", record.Name, "name is wrong"); Assert.AreEqual(42, record.Age, "age is wrong"); - JObject record2 = (JObject)await ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record2["name"], "JOBject name is wrong"); - Assert.AreEqual(42, (int)record2["age"], "JOBject age is wrong"); + JsonNode record2 = await ReadFile("TestA.txt").AsJson(); + Assert.AreEqual("Joe Smith", (string)record2["name"], "JsonNode name is wrong"); + Assert.AreEqual(42, (int)record2["age"], "JsonNode age is wrong"); - dynamic record3 = await ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record3.name, "dynamic name is wrong"); - Assert.AreEqual(42, (int)record3.age, "dynamic age is wrong"); + // Nested indexing is how a JsonNode is navigated. Member access -- record.name -- + // came from the Newtonsoft JObject and is gone with it. + Assert.IsNull(record2["nope"], "a missing property reads as null"); } diff --git a/src/CShell.csproj b/src/CShell.csproj index c65bd26..a0b09a0 100644 --- a/src/CShell.csproj +++ b/src/CShell.csproj @@ -1,7 +1,7 @@  - netstandard2.0 + net8.0 true Tom Laird-McConnell @@ -30,7 +30,6 @@ - diff --git a/src/CommandExtensions.cs b/src/CommandExtensions.cs index 241b9ea..71899e1 100644 --- a/src/CommandExtensions.cs +++ b/src/CommandExtensions.cs @@ -1,5 +1,6 @@ using Medallion.Shell; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; using System; using System.Diagnostics; using System.IO; @@ -34,12 +35,17 @@ public async static Task AsString(this Command cmd, bool log = false) } /// - /// Convert StandardOutput of command to dynamic object using Json deserialization (JObject) + /// Parse the StandardOutput of a command as JSON. /// + /// + /// Navigate it by indexer: `json["owner"]["login"]`. Member access -- `json.owner.login` + /// -- worked against the Newtonsoft JObject this used to return and does not against a + /// JsonNode; use AsJson<T>() where a shape is known. + /// /// /// if true the output to standardout/error - /// JObject - public async static Task AsJson(this Command cmd, bool log = false) + /// the parsed JSON + public async static Task AsJson(this Command cmd, bool log = false) { var cmdResult = await cmd.Task.ConfigureAwait(false); if (log) @@ -52,7 +58,7 @@ public async static Task AsJson(this Command cmd, bool log = false) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions); } /// @@ -75,7 +81,7 @@ public async static Task AsJson(this Command cmd, bool log = false) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return JsonSerializer.Deserialize(Json.Clean(cmdResult.StandardOutput), Json.Options); } /// diff --git a/src/CommandResultExtensions.cs b/src/CommandResultExtensions.cs index 6436e57..013da65 100644 --- a/src/CommandResultExtensions.cs +++ b/src/CommandResultExtensions.cs @@ -1,5 +1,6 @@ using Medallion.Shell; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; using System.IO; using System.Xml.Serialization; @@ -23,18 +24,23 @@ public static string AsString(this CommandResult cmdResult) } /// - /// Convert StandardOutput of command to dynamic object using Json deserialization (JObject) + /// Parse the StandardOutput of a command as JSON. /// + /// + /// Navigate it by indexer: `json["owner"]["login"]`. Member access -- `json.owner.login` + /// -- worked against the Newtonsoft JObject this used to return and does not against a + /// JsonNode; use AsJson<T>() where a shape is known. + /// /// - /// - public static dynamic AsJson(this CommandResult cmdResult) + /// the parsed JSON + public static JsonNode AsJson(this CommandResult cmdResult) { if (!cmdResult.Success) { throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions); } /// @@ -50,7 +56,7 @@ public static T AsJson(this CommandResult cmdResult) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return JsonSerializer.Deserialize(Json.Clean(cmdResult.StandardOutput), Json.Options); } /// diff --git a/src/Json.cs b/src/Json.cs new file mode 100644 index 0000000..bb2c490 --- /dev/null +++ b/src/Json.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json; + +namespace CShellNet +{ + /// + /// How CShell reads the JSON that command line tools produce. + /// + /// + /// System.Text.Json is stricter out of the box than the JSON real tools emit, and stricter + /// than the Newtonsoft reader this replaced. Three of its defaults are relaxed here, each + /// because a script would otherwise fail on output it had no hand in producing: + /// + /// * property names are matched case insensitively, so `{"name":"..."}` still fills a + /// `Name` property. Nearly every CLI emits camelCase or snake_case JSON while the C# + /// record modelling it is PascalCase, and the strict default would not error -- it would + /// hand back an object with every field left at its default, which is the worst way to + /// fail. + /// * trailing commas are allowed. + /// * comments are skipped rather than rejected. + /// + /// A byte order mark is trimmed for the same reason: a UTF-8 BOM is not valid JSON, tools + /// and files on Windows produce it constantly, and the resulting error names a character + /// nobody can see. + /// + internal static class Json + { + internal static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + internal static readonly JsonDocumentOptions DocumentOptions = new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + /// Whatever a tool wrote, made safe to hand to the parser. + internal static string Clean(string json) + { + return json == null ? null : json.TrimStart('', '​').Trim(); + } + } +} From a0b90760dd1cb3ab146fe79c91b0e467df71c121 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:42:29 -0700 Subject: [PATCH 08/12] Add JsonDynamic so AsJson() keeps its dot access 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 is not a legal interface to implement, so enumeration goes through IEnumerable; 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- README.md | 19 ++- Tests/CShell.Tests/JsonDynamic.Tests.cs | 170 +++++++++++++++++++++ src/CommandExtensions.cs | 12 +- src/CommandResultExtensions.cs | 12 +- src/JsonDynamic.cs | 188 ++++++++++++++++++++++++ 5 files changed, 387 insertions(+), 14 deletions(-) create mode 100644 Tests/CShell.Tests/JsonDynamic.Tests.cs create mode 100644 src/JsonDynamic.cs diff --git a/README.md b/README.md index 65c0d32..c1c37e7 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ CShell adds on helper methods to make it even easier to work with the result of |------------------|------------------------------------------------------------------------------| | **Execute(log)** | get the CommandResult (with stdout/stderr) of the last command | | **AsString(log)** | get the standard out of the last command a string | -| **AsJson(log)** | Parse the standard out of the last command as JSON, navigated by indexer: `json["owner"]["login"]` | +| **AsJson(log)** | Parse the standard out of the last command as JSON: `json.owner.login`, `json["owner"]`, or assign it to a JsonObject | | **AsJson\(log)** | JSON Deserialize the standard out of the last command into a typed T | | **AsXml\(log)** | XML Deserialize the standard out of the last command intoa typed T | | **AsFile()** | Write the stdout/stderr of the last command to a file | @@ -235,6 +235,21 @@ To call a program you await on: 2. call any chaining commands 3. end with a result call like Execute()/AsJson()/AsString()/AsXml()etc. +`AsJson()` gives you a **JsonDynamic**, which reads whichever way suits the script: + +```CSharp +var json = await Cmd("gh api repos/tomlm/CShell").AsJson(); + +Console.WriteLine(json.owner.login); // walk it with a dot +Console.WriteLine(json["stargazers_count"]); +foreach (var topic in json.topics) { } // arrays enumerate + +JsonObject o = await Cmd("gh api repos/tomlm/CShell").AsJson(); // or take the typed API +``` + +A property that is not there reads as null, so `if (json.optional != null)` is how you test for +one. Use `AsJson()` where the shape is known. + The result methods all take a log argument is passed set to true then the commands output will be written to standard out. ```CSharp @@ -357,7 +372,7 @@ chmod +x example.csx * Added **RichPrompts** and **ReadKey** to control how the Ask methods read input * **BREAKING** now targets net8.0 rather than netstandard2.0. .NET Framework is no longer supported * **BREAKING** replaced Newtonsoft.Json with System.Text.Json; MedallionShell is now the only dependency - * `AsJson()` returns a `JsonNode`, not a dynamic `JObject` -- use `json["owner"]["login"]`. `AsJson()` is unchanged + * `AsJson()` returns a **JsonDynamic** as `dynamic`, so `json.owner.login` still works. It also indexes, enumerates, and converts to JsonNode/JsonObject/JsonArray. `AsJson()` is unchanged * property names still match case insensitively; trailing commas, comments and a byte order mark are tolerated ### v2.1.0 diff --git a/Tests/CShell.Tests/JsonDynamic.Tests.cs b/Tests/CShell.Tests/JsonDynamic.Tests.cs new file mode 100644 index 0000000..191fbc4 --- /dev/null +++ b/Tests/CShell.Tests/JsonDynamic.Tests.cs @@ -0,0 +1,170 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace CShellLibTests +{ + /// + /// What AsJson() hands back: JSON walked with a dot, and still a JsonNode underneath. + /// + [TestClass] + public class JsonDynamicTests + { + private const string Sample = @"{ + ""name"": ""Joe Smith"", + ""age"": 42, + ""active"": true, + ""missingIsNull"": null, + ""owner"": { ""login"": ""tomlm"" }, + ""tags"": [ ""a"", ""b"" ], + ""repos"": [ { ""name"": ""CShell"" }, { ""name"": ""scripts"" } ] + }"; + + private static dynamic Json() => new JsonDynamic(JsonNode.Parse(Sample)); + + [TestMethod] + public void Member_ReadsAProperty() + { + Assert.AreEqual("Joe Smith", (string)Json().name); + } + + [TestMethod] + public void Member_NestsAsDeepAsYouLike() + { + Assert.AreEqual("tomlm", (string)Json().owner.login); + Assert.AreEqual("scripts", (string)Json().repos[1].name); + } + + [TestMethod] + public void Member_ThatIsNotThereReadsAsNull() + { + // What makes `if (json.optional != null)` the way to test for a field. The cost is + // that a typo reads as null too. + Assert.IsNull(Json().nope); + Assert.IsNull(Json().missingIsNull); + } + + [TestMethod] + public void Indexer_WorksOnObjectsAndArrays() + { + Assert.AreEqual("tomlm", (string)Json()["owner"]["login"]); + Assert.AreEqual("b", (string)Json().tags[1]); + } + + [TestMethod] + public void Indexer_OutOfRangeReadsAsNull() + { + Assert.IsNull(Json().tags[99]); + } + + [TestMethod] + public void Convert_AssignsToTypedVariables() + { + string name = Json().name; + int age = Json().age; + bool active = Json().active; + + Assert.AreEqual("Joe Smith", name); + Assert.AreEqual(42, age); + Assert.IsTrue(active); + } + + [TestMethod] + public void Convert_GivesBackTheJsonNodeItself() + { + // The escape hatch to the typed API, and the reason a wrapper is enough even though + // JsonObject is sealed and cannot be derived from. + JsonNode node = Json().owner; + Assert.AreEqual("tomlm", (string)node["login"]); + + JsonObject o = Json(); + Assert.AreEqual("Joe Smith", (string)o["name"]); + + JsonArray a = Json().tags; + Assert.AreEqual(2, a.Count); + } + + [TestMethod] + public void Convert_DeserializesAWholeShape() + { + var owner = (Owner)Json().owner; + Assert.AreEqual("tomlm", owner.Login); + } + + [TestMethod] + public void Operators_CompareAndArithmetic() + { + // DynamicObject binds members and conversions but not operators; without + // TryBinaryOperation every one of these throws RuntimeBinderException. + Assert.IsTrue(Json().age == 42); + Assert.IsTrue(Json().age != 7); + Assert.IsTrue(Json().age > 41); + Assert.IsTrue(Json().age <= 42); + Assert.IsTrue(Json().name == "Joe Smith"); + Assert.AreEqual(43, Json().age + 1); + } + + [TestMethod] + public void Enumeration_WalksAnArray() + { + var seen = new List(); + foreach (var tag in Json().tags) + { + seen.Add((string)tag); + } + + CollectionAssert.AreEqual(new[] { "a", "b" }, seen); + + var names = new List(); + foreach (var repo in Json().repos) + { + names.Add((string)repo.name); + } + + CollectionAssert.AreEqual(new[] { "CShell", "scripts" }, names); + } + + [TestMethod] + public void Enumeration_OfSomethingThatIsNotAnArrayIsEmpty() + { + var count = 0; + foreach (var nothing in Json().owner) + { + count++; + } + + Assert.AreEqual(0, count); + } + + [TestMethod] + public void MemberNames_AreDiscoverable() + { + var names = (IEnumerable)((JsonDynamic)Json()).GetDynamicMemberNames(); + + CollectionAssert.Contains(new List(names), "owner"); + } + + [TestMethod] + public void ToString_GivesTheValueWithoutItsQuotes() + { + Assert.AreEqual("Joe Smith", Json().name.ToString()); + } + + [TestMethod] + public void WrappingNullIsSafe() + { + var empty = new JsonDynamic(null); + + Assert.IsNull(empty.Node); + Assert.AreEqual(String.Empty, empty.ToString()); + Assert.IsNull((JsonNode)empty); + } + + public class Owner + { + public string Login { get; set; } + } + } +} diff --git a/src/CommandExtensions.cs b/src/CommandExtensions.cs index 71899e1..7c8dd30 100644 --- a/src/CommandExtensions.cs +++ b/src/CommandExtensions.cs @@ -38,14 +38,14 @@ public async static Task AsString(this Command cmd, bool log = false) /// Parse the StandardOutput of a command as JSON. /// /// - /// Navigate it by indexer: `json["owner"]["login"]`. Member access -- `json.owner.login` - /// -- worked against the Newtonsoft JObject this used to return and does not against a - /// JsonNode; use AsJson<T>() where a shape is known. + /// Walk it with a dot -- `json.owner.login` -- or with an indexer, or assign it to a + /// JsonNode, JsonObject or JsonArray for the typed System.Text.Json API. The object behind + /// it is a JsonDynamic either way. Use AsJson<T>() where the shape is known. /// /// /// if true the output to standardout/error - /// the parsed JSON - public async static Task AsJson(this Command cmd, bool log = false) + /// the parsed JSON, as a JsonDynamic + public async static Task AsJson(this Command cmd, bool log = false) { var cmdResult = await cmd.Task.ConfigureAwait(false); if (log) @@ -58,7 +58,7 @@ public async static Task AsJson(this Command cmd, bool log = false) throw new CommandResultException(cmdResult); } - return JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions); + return new JsonDynamic(JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions)); } /// diff --git a/src/CommandResultExtensions.cs b/src/CommandResultExtensions.cs index 013da65..1dba163 100644 --- a/src/CommandResultExtensions.cs +++ b/src/CommandResultExtensions.cs @@ -27,20 +27,20 @@ public static string AsString(this CommandResult cmdResult) /// Parse the StandardOutput of a command as JSON. /// /// - /// Navigate it by indexer: `json["owner"]["login"]`. Member access -- `json.owner.login` - /// -- worked against the Newtonsoft JObject this used to return and does not against a - /// JsonNode; use AsJson<T>() where a shape is known. + /// Walk it with a dot -- `json.owner.login` -- or with an indexer, or assign it to a + /// JsonNode, JsonObject or JsonArray for the typed System.Text.Json API. The object behind + /// it is a JsonDynamic either way. Use AsJson<T>() where the shape is known. /// /// - /// the parsed JSON - public static JsonNode AsJson(this CommandResult cmdResult) + /// the parsed JSON, as a JsonDynamic + public static dynamic AsJson(this CommandResult cmdResult) { if (!cmdResult.Success) { throw new CommandResultException(cmdResult); } - return JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions); + return new JsonDynamic(JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions)); } /// diff --git a/src/JsonDynamic.cs b/src/JsonDynamic.cs new file mode 100644 index 0000000..d80eb56 --- /dev/null +++ b/src/JsonDynamic.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CShellNet +{ + /// + /// JSON you can walk with a dot: `json.owner.login`. + /// + /// + /// This is what AsJson() hands back, as `dynamic`, so a script can read the output of a tool + /// without declaring a type for it: + /// + /// var json = await Cmd("gh api repos/tomlm/CShell").AsJson(); + /// Console.WriteLine(json.owner.login); + /// Console.WriteLine(json["stargazers_count"]); + /// + /// System.Text.Json has no equivalent of its own, mostly because dynamic dispatch cannot be + /// trimmed or compiled ahead of time -- a real constraint for a shipped application and no + /// constraint at all for a utility script, which is the only thing running this. + /// + /// It is a wrapper rather than a subclass because JsonObject is sealed and JsonNode's only + /// constructor is internal, so neither can be derived from. Conversions cover the difference: + /// assign it to a JsonNode, JsonObject or JsonArray and you get the node underneath, which + /// keeps every typed System.Text.Json API available. + /// + /// A member that is not there reads as null rather than throwing, which is what makes + /// `if (json.optional != null)` the way to test for a field. The cost is that a typo reads as + /// null too, and only announces itself one hop later. + /// + public class JsonDynamic : DynamicObject, IEnumerable + { + private readonly JsonNode node; + + /// Wrap a parsed JSON node. + /// the node to wrap, which may be null + public JsonDynamic(JsonNode node) + { + this.node = node; + } + + /// The node underneath, for anything that wants the typed API. + public JsonNode Node + { + get { return this.node; } + } + + internal static object Wrap(JsonNode node) + { + return node == null ? null : new JsonDynamic(node); + } + + /// json.owner -- a property that is not there reads as null. + public override bool TryGetMember(GetMemberBinder binder, out object result) + { + result = Property(binder.Name); + return true; + } + + /// json["owner"] for an object, json[0] for an array. + public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object result) + { + var index = indexes != null && indexes.Length > 0 ? indexes[0] : null; + + if (index is string name) + { + result = Property(name); + return true; + } + + if (index is int position && this.node is JsonArray array) + { + result = position >= 0 && position < array.Count ? Wrap(array[position]) : null; + return true; + } + + result = null; + return true; + } + + /// Assigning to a typed variable: string, int, JsonNode, a record, anything. + public override bool TryConvert(ConvertBinder binder, out object result) + { + // Asking for a JsonNode, JsonObject or JsonArray gets the node itself rather than a + // copy, so the typed API keeps working on the same instance. + if (this.node != null && binder.Type.IsInstanceOfType(this.node)) + { + result = this.node; + return true; + } + + if (binder.Type == typeof(string)) + { + result = this.node == null ? null : this.node.ToString(); + return true; + } + + result = this.node == null ? null : JsonSerializer.Deserialize(this.node, binder.Type, Json.Options); + return true; + } + + /// + /// json.age == 42, json.name == "Joe", json.age + 1. + /// + /// + /// Without this every comparison throws RuntimeBinderException, because DynamicObject + /// binds members and conversions but not operators. The value is read as whatever the + /// other side is, then the operator is applied to that. + /// + public override bool TryBinaryOperation(BinaryOperationBinder binder, object arg, out object result) + { + dynamic left = arg == null || this.node == null + ? (object)null + : JsonSerializer.Deserialize(this.node, arg.GetType(), Json.Options); + dynamic right = arg; + + switch (binder.Operation) + { + case ExpressionType.Equal: result = left == right; return true; + case ExpressionType.NotEqual: result = left != right; return true; + case ExpressionType.LessThan: result = left < right; return true; + case ExpressionType.LessThanOrEqual: result = left <= right; return true; + case ExpressionType.GreaterThan: result = left > right; return true; + case ExpressionType.GreaterThanOrEqual: result = left >= right; return true; + case ExpressionType.Add: result = left + right; return true; + case ExpressionType.Subtract: result = left - right; return true; + case ExpressionType.Multiply: result = left * right; return true; + case ExpressionType.Divide: result = left / right; return true; + default: result = null; return false; + } + } + + /// The property names, so a debugger and `foreach` over an object can see them. + public override IEnumerable GetDynamicMemberNames() + { + return this.node is JsonObject o ? o.Select(p => p.Key) : Enumerable.Empty(); + } + + /// foreach over an array. Declared as object because IEnumerable<dynamic> is not a legal interface to implement. + public IEnumerator GetEnumerator() + { + var array = this.node as JsonArray; + return array == null + ? Enumerable.Empty().GetEnumerator() + : array.Select(Wrap).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// The value as text: a string without its quotes, anything else as JSON. + public override string ToString() + { + return this.node == null ? String.Empty : this.node.ToString(); + } + + /// The node underneath, for typed System.Text.Json code. + public static implicit operator JsonNode(JsonDynamic json) + { + return json == null ? null : json.node; + } + + /// The node underneath as an object, or null if it is not one. + public static implicit operator JsonObject(JsonDynamic json) + { + return json == null ? null : json.node as JsonObject; + } + + /// The node underneath as an array, or null if it is not one. + public static implicit operator JsonArray(JsonDynamic json) + { + return json == null ? null : json.node as JsonArray; + } + + object Property(string name) + { + JsonNode value; + return this.node is JsonObject o && o.TryGetPropertyValue(name, out value) ? Wrap(value) : null; + } + } +} From feb70968fa69501dc734bce69e38813eb25a1692 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:44:51 -0700 Subject: [PATCH 09/12] tweaks --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c1c37e7..68514d8 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ print("Hello world!"); error("ohoh!"); ``` -### Asking the user -The **Ask** methods ask the user a question and return the answer. +### Prompting the user +The **Ask**() family methods ask the user a question and return the answer. | Method | Description | |------------------|--------------------------------------------------------------------------------------------------| @@ -178,8 +178,7 @@ var whatIf = cmd.WhatIf; declarations. The program name comes from the calling script's file name. * `Parse()` exits on a bad command line, so what it returns is always usable. `TryParse()` reports through `ShouldExit`/`ExitCode` instead, and every other value on it throws until you check. -* No subcommands, repeated options, typed binding, or separated values. For more, - reference [System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. +* No subcommands, repeated options, typed binding, or separated values. For more complex cli support use something like [System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. ### Process Methods CShell is built using [MedallionShell](https://github.com/madelson/MedallionShell), which provides a great set of functionality for easily invoking @@ -377,7 +376,7 @@ chmod +x example.csx ### v2.1.0 * Added Write/WriteLine/print/error methods for writing to standard out and standard error - + ### V1.5.2 * Added Exists() methods to global From 74e1a6fd3ebb62dbb8b0a3bc88fb97d6ca234153 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:52:41 -0700 Subject: [PATCH 10/12] Start new scripts from Cli rather than a foreach over Args 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- .../content/CShellTemplate.csx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/template/CShell.Template/content/CShellTemplate.csx b/template/CShell.Template/content/CShellTemplate.csx index 45aa6f8..ee23ab6 100644 --- a/template/CShell.Template/content/CShellTemplate.csx +++ b/template/CShell.Template/content/CShellTemplate.csx @@ -1,5 +1,5 @@ #!/usr/bin/env dotnet-script -#r "nuget: CShell, 2.1.0" +#r "nuget: CShell, 3.0.0" global using static CShellNet.Globals; using CShellNet; @@ -12,11 +12,37 @@ using CShellNet; // MAC/LINUX you need to mark the script file as executable // chmod +x filename.csx // -// To debug this I HIGHLY recommend LinqPad9 https://linqpad.net +// To debug this I HIGHLY recommend LinqPad9 https://linqpad.net -foreach (var arg in Args) +var cmd = Cli.For(Args) + .Description("..description.") + .Argument("file", "the file to work on") + .OptionalArgument("output", "where to write the result; defaults to the input name") + .Switch("force", "overwrite the output if it is already there") + .Option("format", "the output format; defaults to json") + .WhatIf() + .Example("CShellTemplate report.txt", "write report.json beside it") + .Parse(); + +var file = cmd.Argument("file"); +var format = cmd.Option("format") ?? "json"; +var output = cmd.Argument("output") ?? Path.ChangeExtension(file, format); + +if (cmd.WhatIf) +{ + print($"would write {file} to {output} as {format}"); + return 0; +} + +// Ask the user anything the command line did not settle. AskYesNo, AskText, AskSecret, +// AskNumber, AskChoice and AskMultiChoice all read arrow keys at a terminal and a typed +// line when input is piped. +if (!cmd.Switch("force") && exists(output) && !AskYesNo($"{output} already exists. Overwrite?", false)) { - WriteLine(arg); - print(arg); + return 1; } +// your script goes here +print($"writing {file} to {output} as {format}"); + +return 0; From 4200796a6e7ba6c96d7cc9cf62084a0a7c9f804b Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 11:54:59 -0700 Subject: [PATCH 11/12] Make the legacy tests run on Linux 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- Tests/CShell.Tests/CShell.Tests.cs | 12 ++++++------ Tests/CShell.Tests/CShellGlobal.Tests.cs | 12 ++++++------ Tests/CShell.Tests/Command.Tests.cs | 14 ++++++++++---- Tests/CShell.Tests/CommandGlobal.Tests.cs | 14 ++++++++++---- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/Tests/CShell.Tests/CShell.Tests.cs b/Tests/CShell.Tests/CShell.Tests.cs index 121a869..f00071c 100644 --- a/Tests/CShell.Tests/CShell.Tests.cs +++ b/Tests/CShell.Tests/CShell.Tests.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; using System.Linq; @@ -17,7 +17,7 @@ public class CShellTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -97,12 +97,12 @@ public void Test_ChangeFolder() Environment.CurrentDirectory = testFolder; var shell = new CShell(); - shell.cd(@"subfolder\subfolder2"); + shell.cd(Path.Combine("subfolder", "subfolder2")); Assert.AreEqual(subFolder2, shell.CurrentFolder.FullName, "currentFolder relative path failed"); - shell.cd(@"..\subfolder2"); + shell.cd(Path.Combine("..", "subfolder2")); Assert.AreEqual(subFolder2, shell.CurrentFolder.FullName, "currentFolder relative path failed2"); Assert.AreEqual(2, shell.FolderHistory.Count, "relative non-navigation shouldn't have created history record"); - shell.cd(@"..\.."); + shell.cd(Path.Combine("..", "..")); Assert.AreEqual(testFolder, shell.CurrentFolder.FullName, "currentFolder relative path failed3"); Assert.AreEqual(3, shell.FolderHistory.Count, "history ignored on relative path"); shell.cd(subFolder2); @@ -224,7 +224,7 @@ public async Task Test_echo() Assert.AreEqual("test", result); var result2 = shell.echo(new string[] { "test1", "test2", "test3" }); var x = await result2.StandardOutput.ReadToEndAsync(); - Assert.AreEqual("test1\r\ntest2\r\ntest3\r\n", x); + Assert.AreEqual($"test1{Environment.NewLine}test2{Environment.NewLine}test3{Environment.NewLine}", x); } [TestMethod] diff --git a/Tests/CShell.Tests/CShellGlobal.Tests.cs b/Tests/CShell.Tests/CShellGlobal.Tests.cs index e4d37a2..c636e1a 100644 --- a/Tests/CShell.Tests/CShellGlobal.Tests.cs +++ b/Tests/CShell.Tests/CShellGlobal.Tests.cs @@ -1,4 +1,4 @@ -global using static CShellNet.Globals; +global using static CShellNet.Globals; global using CShellNet; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; @@ -19,7 +19,7 @@ public class CShellGlobalsTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -77,12 +77,12 @@ public void Test_Global_ChangeFolder() { ResetShell(testFolder); - cd(@"subfolder\subfolder2"); + cd(Path.Combine("subfolder", "subfolder2")); Assert.AreEqual(subFolder2, CurrentFolder.FullName, "currentFolder relative path failed"); - cd(@"..\subfolder2"); + cd(Path.Combine("..", "subfolder2")); Assert.AreEqual(subFolder2, CurrentFolder.FullName, "currentFolder relative path failed2"); Assert.AreEqual(2, FolderHistory.Count, "relative non-navigation shouldn't have created history record"); - cd(@"..\.."); + cd(Path.Combine("..", "..")); Assert.AreEqual(testFolder, CurrentFolder.FullName, "currentFolder relative path failed3"); Assert.AreEqual(3, FolderHistory.Count, "history ignored on relative path"); cd(subFolder2); @@ -99,7 +99,7 @@ public async Task Test_Global_echo() Assert.AreEqual("test", result); var result2 = shell.echo(new string[] { "test1", "test2", "test3" }); var x = await result2.StandardOutput.ReadToEndAsync(); - Assert.AreEqual("test1\r\ntest2\r\ntest3\r\n", x); + Assert.AreEqual($"test1{Environment.NewLine}test2{Environment.NewLine}test3{Environment.NewLine}", x); } [TestMethod] diff --git a/Tests/CShell.Tests/Command.Tests.cs b/Tests/CShell.Tests/Command.Tests.cs index 9c476ef..9e05c9c 100644 --- a/Tests/CShell.Tests/Command.Tests.cs +++ b/Tests/CShell.Tests/Command.Tests.cs @@ -6,6 +6,7 @@ using System; using System.IO; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading.Tasks; namespace CShellLibTests @@ -42,7 +43,7 @@ public class CommandTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -189,12 +190,17 @@ public async Task Test_Throw_AsString() } } + + /// List one file, in whichever shell Cmd() runs: cmd.exe on Windows, bash elsewhere. + private static string ListTestA => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dir /b TestA.txt" : "ls TestA.txt"; + [TestMethod] public async Task Test_Cmd() { CShell shell = new CShell(); shell.cd(testFolder); - var result = await shell.Cmd("dir /b TestA.txt").AsString(); + var result = await shell.Cmd(ListTestA).AsString(); Assert.AreEqual("TestA.txt", result.Trim(), "AsString"); } @@ -227,7 +233,7 @@ public async Task Test_StartExecute() Assert.IsTrue(result.Success); try { - result = await shell.Start("xxxxxtest.cmd").Execute(); + result = await shell.Start("xxxxx-no-such-program").Execute(); Assert.Fail("Should have thrown execption)"); } catch @@ -253,7 +259,7 @@ public async Task Test_Log() { CShell shell = new CShell(); shell.cd(testFolder); - var commandResult = await shell.Cmd("dir /b TestA.txt").Execute(true); + var commandResult = await shell.Cmd(ListTestA).Execute(true); } } diff --git a/Tests/CShell.Tests/CommandGlobal.Tests.cs b/Tests/CShell.Tests/CommandGlobal.Tests.cs index 1192136..f816e31 100644 --- a/Tests/CShell.Tests/CommandGlobal.Tests.cs +++ b/Tests/CShell.Tests/CommandGlobal.Tests.cs @@ -6,6 +6,7 @@ using System; using System.IO; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading.Tasks; namespace CShellLibTests @@ -20,7 +21,7 @@ public class CommandGlobalTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -158,12 +159,17 @@ public async Task Test_Global_Throw_AsString() } } + + /// List one file, in whichever shell Cmd() runs: cmd.exe on Windows, bash elsewhere. + private static string ListTestA => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dir /b TestA.txt" : "ls TestA.txt"; + [TestMethod] public async Task Test_Global_Cmd() { ResetShell(testFolder); - var result = await Cmd("dir /b TestA.txt").AsString(); + var result = await Cmd(ListTestA).AsString(); Assert.AreEqual("TestA.txt", result.Trim(), "AsString"); } @@ -195,7 +201,7 @@ public async Task Test_Global_StartExecute() Assert.IsTrue(result.Success); try { - result = await Start("xxxxxtest.cmd").Execute(); + result = await Start("xxxxx-no-such-program").Execute(); Assert.Fail("Should have thrown execption)"); } catch @@ -223,7 +229,7 @@ public async Task Test_Global_Log() { ResetShell(testFolder); - var commandResult = await Cmd("dir /b TestA.txt").Execute(true); + var commandResult = await Cmd(ListTestA).Execute(true); } } From ae9837f4709b6e2764aa516875e4a4f8170592a0 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Wed, 2 Sep 2026 12:11:23 -0700 Subject: [PATCH 12/12] Finish the cross-platform tests: 224 green on Linux and Windows 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) Claude-Session: https://claude.ai/code/session_01VKj2mNwaGdaRSSxUikksgb --- Tests/CShell.Tests/Command.Tests.cs | 25 +++++++++++++++-------- Tests/CShell.Tests/CommandGlobal.Tests.cs | 25 +++++++++++++++-------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/Tests/CShell.Tests/Command.Tests.cs b/Tests/CShell.Tests/Command.Tests.cs index 9e05c9c..c937e11 100644 --- a/Tests/CShell.Tests/Command.Tests.cs +++ b/Tests/CShell.Tests/Command.Tests.cs @@ -54,11 +54,11 @@ public async Task Test_AsString() CShell shell = new CShell(); shell.cd(testFolder); - var result = await shell.Run("cmd", "/c", "echo this is a yo yo").AsString(); + var result = await shell.Run(ShellExe, ShellFlag, "echo this is a yo yo").AsString(); Assert.AreEqual("this is a yo yo", result.Trim(), "AsString"); var record = await shell.ReadFile("TestA.txt").AsString(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(record, text, "AsString"); } @@ -108,14 +108,15 @@ public async Task Test_AsResult() shell.cd(testFolder); var result = await shell.ReadFile("TestA.txt").AsResult(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(text, result.StandardOutput, "result stdout"); Assert.AreEqual("", result.StandardError, "result stderr"); var badResult = await shell.ReadFile("sdfsdffd.txt").AsResult(); Assert.AreEqual("", badResult.StandardOutput, "result stdout"); - Assert.AreEqual("The system cannot find the file specified.", badResult.StandardError.Trim(), "result stderr"); + Assert.IsFalse(badResult.Success, "reading a file that is not there should fail"); + Assert.AreNotEqual(String.Empty, badResult.StandardError.Trim(), "and should say so on stderr"); } [TestMethod] @@ -152,7 +153,7 @@ public async Task Test_Throw_AsJson() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -169,7 +170,7 @@ public async Task Test_Throw_AsXml() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -186,14 +187,20 @@ public async Task Test_Throw_AsString() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } + private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// The shell to hand a command line to, and the flag that says "run this". + private static string ShellExe => IsWindows ? "cmd" : "bash"; + + private static string ShellFlag => IsWindows ? "/c" : "-c"; + /// List one file, in whichever shell Cmd() runs: cmd.exe on Windows, bash elsewhere. - private static string ListTestA => - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dir /b TestA.txt" : "ls TestA.txt"; + private static string ListTestA => IsWindows ? "dir /b TestA.txt" : "ls TestA.txt"; [TestMethod] public async Task Test_Cmd() diff --git a/Tests/CShell.Tests/CommandGlobal.Tests.cs b/Tests/CShell.Tests/CommandGlobal.Tests.cs index f816e31..c47145e 100644 --- a/Tests/CShell.Tests/CommandGlobal.Tests.cs +++ b/Tests/CShell.Tests/CommandGlobal.Tests.cs @@ -31,11 +31,11 @@ public async Task Test_Global_AsString() { ResetShell(testFolder); - var result = await Run("cmd", "/c", "echo this is a yo yo").AsString(); + var result = await Run(ShellExe, ShellFlag, "echo this is a yo yo").AsString(); Assert.AreEqual("this is a yo yo", result.Trim(), "AsString"); var record = await ReadFile("TestA.txt").AsString(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(record, text, "AsString"); } @@ -77,14 +77,15 @@ public async Task Test_Global_AsResult() ResetShell(testFolder); var result = await ReadFile("TestA.txt").AsResult(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(text, result.StandardOutput, "result stdout"); Assert.AreEqual("", result.StandardError, "result stderr"); var badResult = await ReadFile("sdfsdffd.txt").AsResult(); Assert.AreEqual("", badResult.StandardOutput, "result stdout"); - Assert.AreEqual("The system cannot find the file specified.", badResult.StandardError.Trim(), "result stderr"); + Assert.IsFalse(badResult.Success, "reading a file that is not there should fail"); + Assert.AreNotEqual(String.Empty, badResult.StandardError.Trim(), "and should say so on stderr"); } [TestMethod] @@ -121,7 +122,7 @@ public async Task Test_Global_Throw_AsJson() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -138,7 +139,7 @@ public async Task Test_Global_Throw_AsXml() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -155,14 +156,20 @@ public async Task Test_Global_Throw_AsString() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } + private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// The shell to hand a command line to, and the flag that says "run this". + private static string ShellExe => IsWindows ? "cmd" : "bash"; + + private static string ShellFlag => IsWindows ? "/c" : "-c"; + /// List one file, in whichever shell Cmd() runs: cmd.exe on Windows, bash elsewhere. - private static string ListTestA => - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dir /b TestA.txt" : "ls TestA.txt"; + private static string ListTestA => IsWindows ? "dir /b TestA.txt" : "ls TestA.txt"; [TestMethod] public async Task Test_Global_Cmd()