diff --git a/README.md b/README.md index 3d740ea..f6e1ef0 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,28 @@ Examples can be simple strings or objects with `{ command, description }` for an | `description` | `string` | Shown in help output | | `optional` | `boolean` | Shows as `[name]` instead of `` | | `variadic` | `boolean` | Collect remaining args into array (must be last) | +| `allowStdin` | `boolean` | Allow `-` to read a string arg from stdin | | `choices` | `array` | Restrict value to predefined set | | `validate` | `function` | Custom validation function | +#### Stdin Arguments + +Enable `allowStdin` on string positional args to support the conventional `-` sentinel: + +```typescript +const echo = command({ + name: "echo", + args: [{ name: "input", type: "string", allowStdin: true }] as const, + handler: ([input]) => console.log(input), +}); +``` + +```bash +$ echo "hello world" | my-cli - +``` + +The handler receives the raw stdin content. Without `allowStdin`, `-` remains a normal string argument. + #### Variadic Arguments ```typescript diff --git a/src/command.ts b/src/command.ts index bf78058..37c74ea 100644 --- a/src/command.ts +++ b/src/command.ts @@ -30,6 +30,8 @@ import type { PositionalArg, } from "./types"; +const STDIN_POSITIONAL_SENTINEL = "\u0000@truyman/cli-stdin\u0000"; + function hasBuiltinHelpFlag(argv: string[]): boolean { for (const arg of argv) { if (arg === "--") return false; @@ -87,6 +89,14 @@ function normalizeOptions(options: Options): NormalizedOptions { return normalized; } +async function readStdin(): Promise { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + return input; +} + /** * Represents a CLI command with type-safe arguments and options. * @@ -215,6 +225,12 @@ export class Command< const argName = variadicArg?.name ?? "unknown"; throw new Error(`Variadic argument '${argName}' must be the last argument`); } + + for (const arg of this.args) { + if (arg.allowStdin && arg.type !== "string") { + throw new Error(`Argument '${arg.name}' can only allow stdin when type is 'string'`); + } + } } private validateOptionConflicts(): void { @@ -419,7 +435,21 @@ export class Command< private runLeaf(argv: string[], inheritedOptions: Record): void | Promise { const mergedOptionDefs = { ...this.inherits, ...this.options }; const parsed = this.parseArgvWithOptions(argv, mergedOptionDefs, true); + const resolved = this.resolveStdinArgs(parsed); + + if (resolved instanceof Promise) { + return resolved.then((resolvedParsed) => + this.runLeafWithParsedArgs(resolvedParsed, inheritedOptions), + ); + } + + return this.runLeafWithParsedArgs(resolved, inheritedOptions); + } + private runLeafWithParsedArgs( + parsed: mri.Argv, + inheritedOptions: Record, + ): void | Promise { const args = this.extractArgs(parsed); const ownOptions = this.extractOptionsFromDefs(parsed, this.options); const inheritedFromArgv = this.extractOptionsFromDefs(parsed, this.inherits); @@ -437,6 +467,42 @@ export class Command< return this.handler(args, options as OptionsToValues>); } + private resolveStdinArgs(parsed: mri.Argv): mri.Argv | Promise { + let stdin: Promise | undefined; + const replacements: Promise[] = []; + const positionals = [...parsed._]; + + for (let i = 0; i < this.args.length; i++) { + const arg = this.args[i]; + if (!arg?.allowStdin) continue; + + const replaceAt = (index: number): void => { + if (positionals[index] !== "-") return; + stdin ??= readStdin(); + replacements.push( + stdin.then((input) => { + positionals[index] = input; + }), + ); + }; + + if (arg.variadic) { + for (let j = i; j < positionals.length; j++) { + replaceAt(j); + } + break; + } + + replaceAt(i); + } + + if (replacements.length === 0) { + return parsed; + } + + return Promise.all(replacements).then(() => ({ ...parsed, _: positionals })); + } + /** * Reconstructs an argv array for passing to subcommands. * @@ -599,11 +665,13 @@ export class Command< } } - const result = mri(argv, { + const normalizedArgv = argv.map((arg) => (arg === "-" ? STDIN_POSITIONAL_SENTINEL : arg)); + const result = mri(normalizedArgv, { boolean: booleanFlags, string: stringFlags, alias: aliases, }); + result._ = result._.map((arg) => (arg === STDIN_POSITIONAL_SENTINEL ? "-" : arg)); // Check for unknown flags after parsing by comparing parsed keys against known flags // Only throw for unknown flags in leaf commands (not parent commands) diff --git a/src/help.ts b/src/help.ts index a1799ca..f2dca91 100644 --- a/src/help.ts +++ b/src/help.ts @@ -127,6 +127,9 @@ function formatArguments(args: readonly PositionalArg[]): string[] { for (const arg of args) { const argName = kleur.green(formatArgName(arg)); let description = arg.description ?? ""; + if (arg.allowStdin) { + description = description ? `${description} (use - for stdin)` : "use - for stdin"; + } if (arg.choices && arg.choices.length > 0) { const choicesStr = arg.choices.join("|"); description = description ? `${description} (${choicesStr})` : `(${choicesStr})`; diff --git a/src/types.ts b/src/types.ts index 19462dd..a1086d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,8 @@ export type PositionalArg = { optional?: boolean; /** If true, collects all remaining positional arguments into an array. Must be the last arg. */ variadic?: boolean; + /** If true, a literal "-" value reads this string argument from stdin. */ + allowStdin?: boolean; /** * Restricts the argument value to a predefined set of choices. * Use `as const` for type inference. diff --git a/test/command.test.ts b/test/command.test.ts index d2bf497..a43b0e6 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -15,6 +15,17 @@ import { InvalidChoiceError, } from "../src/errors"; +function mockStdin(input: string): () => void { + const stdin = process.stdin as unknown as { [Symbol.asyncIterator]: unknown }; + const original = stdin[Symbol.asyncIterator]; + stdin[Symbol.asyncIterator] = async function* () { + yield input; + }; + return () => { + stdin[Symbol.asyncIterator] = original; + }; +} + describe("command aliases", () => { it("invokes subcommand by alias", () => { let called = false; @@ -244,6 +255,98 @@ describe("command", () => { expect(() => cmd.run(["abc"])).toThrow(InvalidArgumentError); }); + it("treats '-' as a literal string by default", () => { + let actual = ""; + + const cmd = command({ + name: "my-cli", + args: [{ name: "str", type: "string" }], + handler: ([str]) => { + actual = str; + }, + }); + + cmd.run(["-"]); + + expect(actual).toBe("-"); + }); + + it("reads stdin for string args that allow stdin", async () => { + const restore = mockStdin("hello world\n"); + let actual = ""; + + try { + const cmd = command({ + name: "my-cli", + args: [{ name: "str", type: "string", allowStdin: true }] as const, + handler: ([str]) => { + actual = str; + }, + }); + + await cmd.run(["-"]); + } finally { + restore(); + } + + expect(actual).toBe("hello world\n"); + }); + + it("does not read stdin for normal string values on args that allow stdin", () => { + let actual = ""; + + const cmd = command({ + name: "my-cli", + args: [{ name: "str", type: "string", allowStdin: true }] as const, + handler: ([str]) => { + actual = str; + }, + }); + + cmd.run(["hello world"]); + + expect(actual).toBe("hello world"); + }); + + it("validates stdin content after reading it", async () => { + const restore = mockStdin("valid"); + let validatedValue = ""; + + try { + const cmd = command({ + name: "my-cli", + args: [ + { + name: "str", + type: "string", + allowStdin: true, + validate: (value) => { + validatedValue = value as string; + return value === "valid" || "Invalid stdin"; + }, + }, + ] as const, + handler: () => {}, + }); + + await cmd.run(["-"]); + } finally { + restore(); + } + + expect(validatedValue).toBe("valid"); + }); + + it("rejects allowStdin on non-string args", () => { + expect(() => + command({ + name: "my-cli", + args: [{ name: "count", type: "number", allowStdin: true }] as const, + handler: () => {}, + }), + ).toThrow("Argument 'count' can only allow stdin when type is 'string'"); + }); + describe("variadic", () => { it("collects remaining args into an array", () => { let actual: string[] = []; @@ -347,6 +450,27 @@ describe("command", () => { expect(() => cmd.run(["1", "abc", "3"])).toThrow(InvalidArgumentError); }); + + it("reads stdin for matching values in variadic string args", async () => { + const restore = mockStdin("from stdin"); + let actual: string[] = []; + + try { + const cmd = command({ + name: "my-cli", + args: [{ name: "files", type: "string", variadic: true, allowStdin: true }] as const, + handler: ([files]) => { + actual = files; + }, + }); + + await cmd.run(["a.txt", "-", "b.txt"]); + } finally { + restore(); + } + + expect(actual).toEqual(["a.txt", "from stdin", "b.txt"]); + }); }); describe("validation", () => { @@ -519,6 +643,34 @@ describe("command", () => { expect(() => cmd.run(["pause"])).toThrow("Valid choices: start, stop, restart"); }); + it("validates stdin content against choices", async () => { + const restore = mockStdin("start"); + let receivedValue: string | undefined; + + try { + const cmd = command({ + name: "my-cli", + args: [ + { + name: "action", + type: "string", + allowStdin: true, + choices: ["start", "stop"] as const, + }, + ] as const, + handler: ([action]) => { + receivedValue = action; + }, + }); + + await cmd.run(["-"]); + } finally { + restore(); + } + + expect(receivedValue).toBe("start"); + }); + it("validates each value in variadic args with choices", () => { const cmd = command({ name: "my-cli", @@ -2103,6 +2255,32 @@ describe("subcommands", () => { expect(receivedName).toBe("foo"); }); + it("routes stdin args to a subcommand", async () => { + const restore = mockStdin("from stdin"); + let receivedName = ""; + + try { + const add = command({ + name: "add", + args: [{ name: "name", type: "string", allowStdin: true }] as const, + handler: ([name]) => { + receivedName = name; + }, + }); + + const cli = command({ + name: "cli", + subcommands: [add], + }); + + await cli.run(["add", "-"]); + } finally { + restore(); + } + + expect(receivedName).toBe("from stdin"); + }); + it("routes to a nested subcommand (2 levels)", () => { let executed = false; let receivedUrl = "";