Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Only read stdin as `--input-json` when `--input-json -` is passed explicitly. Previously any piped stdin was consumed and parsed as the options payload, which broke shell loops (`while read … | clerk …`) and commands that read their own stdin (`cat body.json | clerk api …`) with a confusing `invalid_json` error.
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ In agent mode all interactive flows are disabled and the same invocations exit w

Two complementary mechanisms for JSON input work across the users command family:

- **`--input-json <json|@file|->`** (program-level). Expands JSON object keys into argv flags before Commander parses them. Drive the curated flags with structured JSON, from an agent or a pipeline: `clerk users create --input-json '{"email":"alice@example.com","first-name":"Alice","yes":true}'`. Accepts inline JSON, `@path/to/file.json`, or `-` for stdin. Piped stdin is auto-detected when `--input-json` is absent.
- **`--input-json <json|@file|->`** (program-level). Expands JSON object keys into argv flags before Commander parses them. Drive the curated flags with structured JSON, from an agent or a pipeline: `clerk users create --input-json '{"email":"alice@example.com","first-name":"Alice","yes":true}'`. Accepts inline JSON, `@path/to/file.json`, or `-` for stdin. Stdin is only read with an explicit `--input-json -`, so shell loops and commands that read their own stdin are never disturbed.
- **`-d, --data <json>` plus `--file <path>`** (per-command). Send a raw BAPI request body directly to `/v1/users`. Use this when you need a BAPI field the curated flags don't expose (for example, `primary_email_address_id` or `web3_wallets`). Mirrors `clerk api -d` / `--file`.

## Commands
Expand Down
40 changes: 15 additions & 25 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,25 +325,16 @@ describe("expandInputJson", () => {
expect(result.result).toContain("--yes");
});

test("auto-detects piped stdin when --input-json is absent", async () => {
test("ignores piped stdin when --input-json is absent", async () => {
const result = await expandViaStdin(["clerk", "init"], '{"framework":"next","yes":true}');
expect(result.result).toContain("--framework");
expect(result.result).toContain("next");
expect(result.result).toContain("--yes");
// Original argv args are preserved before expanded flags
expect(result.result![0]).toBe("clerk");
expect(result.result![1]).toBe("init");
});

test("auto-stdin appends flags after existing argv", async () => {
const result = await expandViaStdin(["clerk", "init", "--yes"], '{"framework":"next"}');
// Explicit --yes comes first, then expanded --framework next
expect(result.result).toEqual(["clerk", "init", "--yes", "--framework", "next"]);
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "init"]);
});

test("auto-stdin ignores empty stdin", async () => {
const result = await expandViaStdin(["clerk", "init", "--yes"], "");
expect(result.result).toEqual(["clerk", "init", "--yes"]);
test("ignores piped non-JSON stdin (shell loops, command bodies)", async () => {
const result = await expandViaStdin(["clerk", "whoami"], "not json\nmore lines\n");
expect(result.error).toBeUndefined();
expect(result.result).toEqual(["clerk", "whoami"]);
});

test("--input-json - errors on invalid JSON from stdin", async () => {
Expand All @@ -356,18 +347,17 @@ describe("expandInputJson", () => {
expect(result.error).toContain("No JSON received on stdin");
});

test("auto-stdin errors on invalid JSON", async () => {
const result = await expandViaStdin(["clerk", "init"], "{bad}");
expect(result.error).toContain("Invalid JSON");
});

test("auto-stdin errors on JSON array", async () => {
test("ignores piped JSON array when --input-json is absent", async () => {
const result = await expandViaStdin(["clerk", "init"], "[1,2,3]");
expect(result.error).toContain("must be a JSON object");
expect(result.error).toBeUndefined();
expect(result.result).toEqual(["clerk", "init"]);
});

test("auto-stdin handles camelCase keys", async () => {
const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}');
test("--input-json - handles camelCase keys", async () => {
const result = await expandViaStdin(
["clerk", "config", "patch", "--input-json", "-"],
'{"dryRun":true}',
);
expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]);
});
});
Expand Down
47 changes: 11 additions & 36 deletions packages/cli-core/src/lib/input-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,6 @@ async function readStdin(): Promise<string> {
return text;
}

async function readOptionalStdin(): Promise<string | undefined> {
const text = await Bun.stdin.text();
return text.trim() ? text : undefined;
}

/**
* Resolve the raw --input-json value to a JSON string.
* - `"-"` reads from stdin.
Expand Down Expand Up @@ -121,46 +116,26 @@ function requireValue(argv: string[], idx: number): string {
);
}

/**
* Check whether stdin has piped data available (i.e. is not a TTY).
*/
function hasStdinPipe(): boolean {
return !process.stdin.isTTY;
}

/**
* Process an argv array: find `--input-json`, expand JSON to flags, return
* a new argv with the expanded flags spliced in (so explicit CLI flags that
* appear later in argv naturally take precedence).
*
* If `--input-json` is not present but stdin is piped (not a TTY), reads
* JSON from stdin and appends the expanded flags to the end of argv.
* Stdin is only consumed when the value is the explicit `-` marker
* (`--input-json -`). Piped stdin is never read implicitly, so shell loops
* (`while read … | clerk …`) and commands that read their own stdin (e.g.
* `cat body.json | clerk api …`) are left untouched.
*
* If neither `--input-json` nor stdin pipe is present, returns the original
* array unchanged.
* If `--input-json` is not present, returns the original array unchanged.
*/
export async function expandInputJson(argv: string[]): Promise<string[]> {
const idx = argv.indexOf(INPUT_JSON_FLAG);
if (idx === -1) return argv;

if (idx !== -1) {
const rawValue = requireValue(argv, idx);
const jsonStr = await resolveJsonValue(rawValue);
const parsed = parseJsonString(jsonStr);
assertJsonObject(parsed);
argv.splice(idx, 2, ...expandJsonToFlags(parsed));
return argv;
}

// No explicit --input-json flag — check for piped stdin
if (hasStdinPipe()) {
const jsonStr = await readOptionalStdin();
if (jsonStr === undefined) return argv;
const parsed = parseJsonString(jsonStr);
assertJsonObject(parsed);
// Append expanded flags at the end; explicit CLI flags already in argv
// appear before these, so they naturally take precedence (last-flag-wins).
argv.push(...expandJsonToFlags(parsed));
}

const rawValue = requireValue(argv, idx);
const jsonStr = await resolveJsonValue(rawValue);
const parsed = parseJsonString(jsonStr);
assertJsonObject(parsed);
argv.splice(idx, 2, ...expandJsonToFlags(parsed));
return argv;
}
Loading