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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(input-json): only read stdin with explicit `--input-json -` by rafa-thayto · Pull Request #341 · clerk/cli · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/input-json-explicit-stdin.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
51 changes: 15 additions & 36 deletions packages/cli-core/src/lib/input-json.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,6 @@ import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";

const originalIsTTY = process.stdin.isTTY;

describe("toKebabCase", () => {
test("converts camelCase", () => {
expect(toKebabCase("dryRun")).toBe("dry-run");
Expand DownExpand Up@@ -45,15 +43,6 @@ describe("toKebabCase", () => {
});

describe("expandInputJson", () => {
beforeEach(() => {
// Ensure stdin looks like a TTY so the auto-stdin path is not triggered
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});

test("returns argv unchanged when --input-json is absent", async () => {
const argv = ["clerk", "init", "--yes"];
const result = await expandInputJson(argv);
Expand DownExpand Up@@ -325,25 +314,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 () => {
Comment thread
rafa-thayto marked this conversation as resolved.
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");
// Without an explicit --input-json -, stdin is left untouched.
expect(result.result).toEqual(["clerk", "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"]);
});

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 +336,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 numberDiff line numberDiff 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 DownExpand 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;
}