Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>` |
| `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
Expand Down
70 changes: 69 additions & 1 deletion src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +89,14 @@ function normalizeOptions(options: Options): NormalizedOptions {
return normalized;
}

async function readStdin(): Promise<string> {
let input = "";
for await (const chunk of process.stdin) {
input += chunk;
}
return input;
}

/**
* Represents a CLI command with type-safe arguments and options.
*
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -419,7 +435,21 @@ export class Command<
private runLeaf(argv: string[], inheritedOptions: Record<string, unknown>): void | Promise<void> {
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<string, unknown>,
): void | Promise<void> {
const args = this.extractArgs(parsed);
const ownOptions = this.extractOptionsFromDefs(parsed, this.options);
const inheritedFromArgv = this.extractOptionsFromDefs(parsed, this.inherits);
Expand All @@ -437,6 +467,42 @@ export class Command<
return this.handler(args, options as OptionsToValues<MergeOptions<I, O>>);
}

private resolveStdinArgs(parsed: mri.Argv): mri.Argv | Promise<mri.Argv> {
let stdin: Promise<string> | undefined;
const replacements: Promise<void>[] = [];
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.
*
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`;
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
178 changes: 178 additions & 0 deletions test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = "";
Expand Down