From da783fc1fb933a6d94377d9b2e60228d74003666 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 23 Apr 2026 17:39:26 -0300 Subject: [PATCH 1/6] feat(cli): add --input-json for passing options as JSON Expands JSON objects into argv flags before Commander parses them, so any command can receive structured options from AI agents or scripts. Accepts inline JSON or `@path/to/file.json`; camelCase/snake_case keys convert to kebab-case; arrays produce repeated flags; false/null are omitted; nested objects are rejected. --- .changeset/input-json-flag.md | 5 + README.md | 11 +- packages/cli-core/src/cli-program.ts | 24 +- packages/cli-core/src/lib/input-json.test.ts | 255 +++++++++++++++++ packages/cli-core/src/lib/input-json.ts | 117 ++++++++ .../src/test/integration/input-json.test.ts | 270 ++++++++++++++++++ skills/clerk/SKILL.md | 1 + skills/clerk/references/agent-mode.md | 41 +++ 8 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 .changeset/input-json-flag.md create mode 100644 packages/cli-core/src/lib/input-json.test.ts create mode 100644 packages/cli-core/src/lib/input-json.ts create mode 100644 packages/cli-core/src/test/integration/input-json.test.ts diff --git a/.changeset/input-json-flag.md b/.changeset/input-json-flag.md new file mode 100644 index 00000000..668d542d --- /dev/null +++ b/.changeset/input-json-flag.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `--input-json` to pass options as JSON for any command. Accepts an inline object or `@path/to/file.json`; keys are converted from camelCase/snake_case to kebab-case flags (e.g. `{"dryRun":true}` → `--dry-run`). Arrays expand to repeated flags, `true` becomes a bare flag, `false`/`null` are omitted. Designed for AI-agent and scripted invocations that prefer passing structured options over composing shell strings. diff --git a/README.md b/README.md index a239cae0..3a83f0b8 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,12 @@ Usage: clerk [options] [command] Clerk CLI Options: - -v, --version Output the version number - --mode Force interaction mode (human or agent). Defaults to - auto-detect based on TTY. - --verbose Show detailed error output - -h, --help Display help for command + -v, --version Output the version number + --input-json Pass command options as a JSON string or @file.json + --mode Force interaction mode (human or agent). Defaults to + auto-detect based on TTY. + --verbose Show detailed output (enables debug messages) + -h, --help Display help for command Commands: init [options] Initialize Clerk in your project diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 1d6b18ef..61fc31e7 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -1,4 +1,5 @@ import { Command, createOption, createArgument } from "@commander-js/extra-typings"; +import { expandInputJson } from "./lib/input-json.ts"; import { setLogLevel } from "./lib/log.ts"; import { setMode, type Mode } from "./mode.ts"; import { init } from "./commands/init/index.ts"; @@ -52,6 +53,7 @@ export function createProgram() { .version(getCurrentVersion(), "-v, --version", "Output the version number") .helpOption("-h, --help", "Display help for command") .addHelpCommand("help [command]", "Display help for command") + .option("--input-json ", "Pass command options as a JSON string or @file.json") .option( "--mode ", "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", @@ -630,6 +632,23 @@ function formatSingleError(err: { return msg; } +type ParseFrom = "user" | "node"; + +/** + * Resolve argv + `from` together so `--input-json` preprocessing always runs, + * whether the caller passed explicit args (tests) or let it default to + * `process.argv` (cli.ts entry point). + */ +async function resolveArgv( + args: string[] | undefined, + from: ParseFrom | undefined, +): Promise<{ argv: string[]; from: ParseFrom }> { + const raw = args ?? process.argv; + const effectiveFrom = from ?? (args === undefined ? "node" : "user"); + const argv = await expandInputJson([...raw]); + return { argv, from: effectiveFrom }; +} + /** * Parse and run a program, handling all typed errors with user-facing messages. * Used by `cli.ts` for real execution and by integration tests. @@ -637,10 +656,11 @@ function formatSingleError(err: { export async function runProgram( program: ReturnType, args?: string[], - options?: { from: "user" | "node" }, + options?: { from: ParseFrom }, ): Promise { try { - await program.parseAsync(args, options); + const { argv, from } = await resolveArgv(args, options?.from); + await program.parseAsync(argv, { from }); } catch (error) { const verbose = program.opts().verbose ?? false; diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts new file mode 100644 index 00000000..a9ddacda --- /dev/null +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -0,0 +1,255 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { expandInputJson, toKebabCase } from "./input-json.ts"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +describe("toKebabCase", () => { + test("converts camelCase", () => { + expect(toKebabCase("dryRun")).toBe("dry-run"); + }); + + test("converts camelCase with no prefix", () => { + expect(toKebabCase("noSkills")).toBe("no-skills"); + }); + + test("converts snake_case", () => { + expect(toKebabCase("dry_run")).toBe("dry-run"); + }); + + test("passes through kebab-case unchanged", () => { + expect(toKebabCase("dry-run")).toBe("dry-run"); + }); + + test("handles single word", () => { + expect(toKebabCase("yes")).toBe("yes"); + }); + + test("handles multiple humps", () => { + expect(toKebabCase("secretKeyId")).toBe("secret-key-id"); + }); + + test("handles key with digits before uppercase", () => { + expect(toKebabCase("v2Name")).toBe("v2-name"); + }); + + test("lowercases all-uppercase word", () => { + expect(toKebabCase("JSON")).toBe("json"); + }); + + test("handles empty string", () => { + expect(toKebabCase("")).toBe(""); + }); +}); + +describe("expandInputJson", () => { + test("returns argv unchanged when --input-json is absent", async () => { + const argv = ["clerk", "init", "--yes"]; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--yes"]); + }); + + test("expands string values to flags", async () => { + const argv = ["clerk", "init", "--input-json", '{"framework":"next"}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--framework", "next"]); + }); + + test("expands boolean true to flag", async () => { + const argv = ["clerk", "init", "--input-json", '{"yes":true}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--yes"]); + }); + + test("skips boolean false values", async () => { + const argv = ["clerk", "init", "--input-json", '{"yes":false}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init"]); + }); + + test("expands negated boolean (noX pattern)", async () => { + const argv = ["clerk", "init", "--input-json", '{"noSkills":true}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--no-skills"]); + }); + + test("converts camelCase keys to kebab-case flags", async () => { + const argv = ["clerk", "config", "patch", "--input-json", '{"dryRun":true}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "config", "patch", "--dry-run"]); + }); + + test("expands array values to repeated flags", async () => { + const argv = ["clerk", "config", "pull", "--input-json", '{"keys":["a","b"]}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "config", "pull", "--keys", "a", "--keys", "b"]); + }); + + test("expands numeric values as strings", async () => { + const argv = ["clerk", "test", "--input-json", '{"timeout":30}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "test", "--timeout", "30"]); + }); + + test("skips null and undefined values", async () => { + const argv = ["clerk", "init", "--input-json", '{"framework":null}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init"]); + }); + + test("empty JSON object is a no-op", async () => { + const argv = ["clerk", "init", "--input-json", "{}"]; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init"]); + }); + + test("expands multiple keys", async () => { + const argv = ["clerk", "init", "--input-json", '{"framework":"next","yes":true}']; + const result = await expandInputJson(argv); + expect(result).toContain("--framework"); + expect(result).toContain("next"); + expect(result).toContain("--yes"); + }); + + test("preserves explicit CLI flags after --input-json", async () => { + const argv = ["clerk", "init", "--input-json", '{"framework":"react"}', "--yes"]; + const result = await expandInputJson(argv); + // Expanded flags come first, explicit --yes comes after + expect(result).toEqual(["clerk", "init", "--framework", "react", "--yes"]); + }); + + test("preserves explicit CLI flags before --input-json", async () => { + const argv = ["clerk", "init", "--yes", "--input-json", '{"framework":"next"}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--yes", "--framework", "next"]); + }); + + test("errors on missing value after --input-json", async () => { + const argv = ["clerk", "init", "--input-json"]; + await expect(expandInputJson(argv)).rejects.toThrow("requires a JSON string"); + }); + + test("errors on invalid JSON", async () => { + const argv = ["clerk", "init", "--input-json", "not-json"]; + await expect(expandInputJson(argv)).rejects.toThrow("Invalid JSON"); + }); + + test("errors on JSON array", async () => { + const argv = ["clerk", "init", "--input-json", "[1,2,3]"]; + await expect(expandInputJson(argv)).rejects.toThrow("must be a JSON object"); + }); + + test("errors on JSON string primitive", async () => { + const argv = ["clerk", "init", "--input-json", '"hello"']; + await expect(expandInputJson(argv)).rejects.toThrow("must be a JSON object"); + }); + + test("errors on JSON number primitive", async () => { + const argv = ["clerk", "init", "--input-json", "42"]; + await expect(expandInputJson(argv)).rejects.toThrow("must be a JSON object"); + }); + + test("errors on JSON boolean primitive", async () => { + const argv = ["clerk", "init", "--input-json", "true"]; + await expect(expandInputJson(argv)).rejects.toThrow("must be a JSON object"); + }); + + test("errors on nested objects", async () => { + const argv = ["clerk", "init", "--input-json", '{"session":{"lifetime":3600}}']; + await expect(expandInputJson(argv)).rejects.toThrow("Nested objects are not supported"); + }); + + test("expands empty string value", async () => { + const argv = ["clerk", "init", "--input-json", '{"framework":""}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--framework", ""]); + }); + + test("expands zero numeric value", async () => { + const argv = ["clerk", "test", "--input-json", '{"count":0}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "test", "--count", "0"]); + }); + + test("expands negative numeric value", async () => { + const argv = ["clerk", "test", "--input-json", '{"offset":-10}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "test", "--offset", "-10"]); + }); + + test("skips empty arrays", async () => { + const argv = ["clerk", "config", "pull", "--input-json", '{"keys":[]}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "config", "pull"]); + }); + + test("stringifies mixed-type array items", async () => { + const argv = ["clerk", "test", "--input-json", '{"tags":["a",1,true]}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "test", "--tags", "a", "--tags", "1", "--tags", "true"]); + }); + + test("expands snake_case keys to kebab-case flags", async () => { + const argv = ["clerk", "api", "--input-json", '{"secret_key":"sk_123"}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "api", "--secret-key", "sk_123"]); + }); + + test("preserves positional args alongside JSON expansion", async () => { + const argv = ["clerk", "api", "/users", "--input-json", '{"method":"POST"}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "api", "/users", "--method", "POST"]); + }); + + test("does not mutate the original argv reference when called standalone", async () => { + const original = ["clerk", "init", "--input-json", '{"yes":true}']; + const copy = [...original]; + await expandInputJson(copy); + // The copy IS mutated (splice), but the original is untouched + expect(original).toEqual(["clerk", "init", "--input-json", '{"yes":true}']); + }); + + describe("@file support", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-input-json-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("reads JSON from a file", async () => { + const filePath = join(tempDir, "opts.json"); + await Bun.write(filePath, '{"framework":"next","yes":true}'); + + const argv = ["clerk", "init", "--input-json", `@${filePath}`]; + const result = await expandInputJson(argv); + expect(result).toContain("--framework"); + expect(result).toContain("next"); + expect(result).toContain("--yes"); + }); + + test("errors when file does not exist", async () => { + const argv = ["clerk", "init", "--input-json", `@${tempDir}/nonexistent.json`]; + await expect(expandInputJson(argv)).rejects.toThrow("File not found"); + }); + + test("errors when file contains invalid JSON", async () => { + const filePath = join(tempDir, "bad.json"); + await Bun.write(filePath, "not valid json"); + + const argv = ["clerk", "init", "--input-json", `@${filePath}`]; + await expect(expandInputJson(argv)).rejects.toThrow("Invalid JSON"); + }); + + test("errors when file is empty", async () => { + const filePath = join(tempDir, "empty.json"); + await Bun.write(filePath, ""); + + const argv = ["clerk", "init", "--input-json", `@${filePath}`]; + await expect(expandInputJson(argv)).rejects.toThrow("Invalid JSON"); + }); + }); +}); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts new file mode 100644 index 00000000..57dcc8a2 --- /dev/null +++ b/packages/cli-core/src/lib/input-json.ts @@ -0,0 +1,117 @@ +import { throwUsageError, ERROR_CODE } from "./errors.ts"; + +const INPUT_JSON_FLAG = "--input-json"; +const FILE_PREFIX = "@"; + +type JsonObject = Record; + +/** + * Convert a camelCase, snake_case, or already-kebab-case key to kebab-case. + * + * "dryRun" → "dry-run" + * "noSkills" → "no-skills" + * "dry_run" → "dry-run" + * "dry-run" → "dry-run" + */ +export function toKebabCase(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/_/g, "-") + .toLowerCase(); +} + +function rejectNested(key: string): never { + throwUsageError( + `Nested objects are not supported in --input-json. Key "${key}" must be a flat value.`, + undefined, + ERROR_CODE.INVALID_JSON, + ); +} + +/** + * Convert a single JSON entry into argv-style flag tokens. + * + * Returns an empty array for entries that should be absent from argv + * (boolean `false`, `null`, `undefined`, empty arrays). + */ +function entryToFlags(key: string, value: unknown): string[] { + const flag = `--${toKebabCase(key)}`; + + if (value === true) return [flag]; + if (value === false || value === null || value === undefined) return []; + if (Array.isArray(value)) return value.flatMap((item) => [flag, String(item)]); + if (typeof value === "object") rejectNested(key); + + return [flag, String(value)]; +} + +function expandJsonToFlags(json: JsonObject): string[] { + return Object.entries(json).flatMap(([key, value]) => entryToFlags(key, value)); +} + +async function readJsonFile(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) { + throwUsageError(`File not found: ${path}`, undefined, ERROR_CODE.FILE_NOT_FOUND); + } + return file.text(); +} + +/** + * Resolve the raw --input-json value to a JSON string. + * If the value starts with `@`, reads from the given file path. + */ +function resolveJsonValue(raw: string): Promise | string { + return raw.startsWith(FILE_PREFIX) ? readJsonFile(raw.slice(1)) : raw; +} + +function parseJsonString(jsonStr: string): unknown { + try { + return JSON.parse(jsonStr); + } catch { + throwUsageError( + "Invalid JSON in --input-json. Please provide valid JSON.", + undefined, + ERROR_CODE.INVALID_JSON, + ); + } +} + +function assertJsonObject(parsed: unknown): asserts parsed is JsonObject { + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return; + throwUsageError( + "--input-json value must be a JSON object, not an array or primitive.", + undefined, + ERROR_CODE.INVALID_JSON, + ); +} + +function requireValue(argv: string[], idx: number): string { + const value = argv[idx + 1]; + if (value !== undefined) return value; + throwUsageError( + "--input-json requires a JSON string or @file.json argument.", + undefined, + ERROR_CODE.USAGE_ERROR, + ); +} + +/** + * 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, returns the original array unchanged. + */ +export async function expandInputJson(argv: string[]): Promise { + const idx = argv.indexOf(INPUT_JSON_FLAG); + if (idx === -1) return argv; + + const rawValue = requireValue(argv, idx); + const jsonStr = await resolveJsonValue(rawValue); + const parsed = parseJsonString(jsonStr); + assertJsonObject(parsed); + + argv.splice(idx, 2, ...expandJsonToFlags(parsed)); + return argv; +} diff --git a/packages/cli-core/src/test/integration/input-json.test.ts b/packages/cli-core/src/test/integration/input-json.test.ts new file mode 100644 index 00000000..93e6a3eb --- /dev/null +++ b/packages/cli-core/src/test/integration/input-json.test.ts @@ -0,0 +1,270 @@ +/** + * --input-json expands JSON objects into argv flags + * Any command can accept options as JSON for agent-friendly invocation. + */ + +import { test, expect, beforeEach } from "bun:test"; +import { + useIntegrationTestHarness, + http, + setProfile, + clerk, + getInstance, + MOCK_APP, +} from "./lib/harness.ts"; +import { join } from "node:path"; + +useIntegrationTestHarness(); + +const devInstance = getInstance(MOCK_APP, "development"); + +beforeEach(async () => { + await setProfile("github.com/test/project", { + workspaceId: "", + appId: MOCK_APP.application_id, + instances: { development: devInstance.instance_id }, + }); +}); + +test("init --input-json passes options through Commander pipeline", async () => { + // {"prompt":true} expands to --prompt, short-circuiting init to log the agent handoff + const { stdout } = await clerk("init", "--input-json", '{"prompt":true}'); + expect(stdout).toContain("clerk init -y"); +}); + +test("explicit CLI flags override --input-json values", async () => { + // --input-json sets mode=human, but --mode agent after it overrides. --prompt on the + // subcommand short-circuits so we don't trigger bootstrap. + const { stdout } = await clerk( + "init", + "--prompt", + "--input-json", + '{"mode":"human"}', + "--mode", + "agent", + ); + expect(stdout).toContain("clerk init -y"); +}); + +test("doctor --input-json with json:true outputs JSON", async () => { + // doctor may exit non-zero if checks fail, so use clerk.raw + const result = await clerk.raw("doctor", "--input-json", '{"json":true}'); + // doctor --json outputs a JSON array of check results to stdout + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); +}); + +test("config patch --input-json with dryRun shows preview", async () => { + // config patch now GETs current config to compute the diff, even on dry runs. + http.stub(async () => { + return new Response(JSON.stringify({ session: { lifetime: 604800 } }), { status: 200 }); + }); + + const { stderr } = await clerk( + "--mode", + "human", + "config", + "patch", + "--json", + '{"session":{"lifetime":3600}}', + "--input-json", + '{"dryRun":true}', + ); + expect(stderr).toContain("[dry-run]"); +}); + +test("rejects invalid JSON", async () => { + const result = await clerk.raw("init", "--input-json", "not-json"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid JSON"); +}); + +test("rejects JSON array", async () => { + const result = await clerk.raw("init", "--input-json", "[1,2,3]"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("must be a JSON object"); +}); + +test("rejects nested objects", async () => { + const result = await clerk.raw("init", "--input-json", '{"nested":{"key":"value"}}'); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Nested objects are not supported"); +}); + +test("rejects missing value after --input-json", async () => { + const result = await clerk.raw("init", "--input-json"); + expect(result.exitCode).not.toBe(0); +}); + +test("rejects unknown options via Commander", async () => { + // Commander's own unknown-option error + const result = await clerk.raw("init", "--input-json", '{"totallyFakeOption":"yes"}'); + expect(result.exitCode).not.toBe(0); +}); + +test("empty JSON object is a no-op", async () => { + const { stdout } = await clerk("init", "--prompt", "--input-json", "{}"); + expect(stdout).toContain("clerk init -y"); +}); + +test("@file.json reads options from a temp file", async () => { + const filePath = join(process.cwd(), "input-opts.json"); + await Bun.write(filePath, '{"json":true}'); + + // doctor may exit non-zero if checks fail, so use clerk.raw + const result = await clerk.raw("doctor", "--input-json", `@${filePath}`); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); +}); + +test("@file.json errors on missing file", async () => { + const result = await clerk.raw("init", "--input-json", "@/tmp/nonexistent-clerk-test-file.json"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("File not found"); +}); + +test("camelCase keys are converted to kebab-case flags", async () => { + // dryRun → --dry-run, which config patch understands + http.stub(async () => { + return new Response(JSON.stringify({ session: { lifetime: 604800 } }), { status: 200 }); + }); + + const { stderr } = await clerk( + "config", + "patch", + "--json", + '{"session":{"lifetime":3600}}', + "--input-json", + '{"dryRun":true,"yes":true}', + ); + expect(stderr).toContain("[dry-run]"); +}); + +test("Commander rejects invalid choice values from --input-json", async () => { + // --framework only accepts known framework names + const result = await clerk.raw( + "--mode", + "agent", + "init", + "--input-json", + '{"framework":"invalid-framework-name","yes":true}', + ); + expect(result.exitCode).not.toBe(0); +}); + +test("api command with positional args + JSON options", async () => { + // Positional arg /users should be preserved alongside expanded flags + const { stderr } = await clerk( + "api", + "/users", + "--secret-key", + devInstance.secret_key!, + "--input-json", + '{"dryRun":true}', + ); + expect(http.requests.length).toBe(0); + expect(stderr).toContain("[dry-run]"); + expect(stderr).toContain("/users"); +}); + +test("apps list --json via --input-json", async () => { + http.mock({ "/applications": [] }); + const { stdout } = await clerk("apps", "list", "--input-json", '{"json":true}'); + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); +}); + +test("config pull with array keys via --input-json", async () => { + // Nested subcommand (config → pull) with variadic --keys option + // Array expansion should produce --keys auth_email --keys session + http.stub(async () => { + return new Response(JSON.stringify({ session: { lifetime: 604800 } }), { status: 200 }); + }); + + const { stdout } = await clerk( + "config", + "pull", + "--input-json", + '{"keys":["auth_email","session"]}', + ); + const parsed = JSON.parse(stdout); + expect(parsed).toBeDefined(); +}); + +test("config schema with array keys via --input-json", async () => { + // Another nested subcommand (config → schema) exercising array expansion + http.stub(async () => { + return new Response(JSON.stringify({ type: "object", properties: {} }), { status: 200 }); + }); + + const { stdout } = await clerk("config", "schema", "--input-json", '{"keys":["session"]}'); + const parsed = JSON.parse(stdout); + expect(parsed).toBeDefined(); +}); + +test("--input-json before nested subcommand errors on non-root flags", async () => { + // Expansion happens at argv position — before the subcommand, flags land on + // the root program. Non-root flags like --json are unknown at the root level. + const result = await clerk.raw("--input-json", '{"json":true}', "apps", "list"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("unknown option"); +}); + +test("--input-json after nested subcommand targets that subcommand", async () => { + // Same JSON placed after the subcommand routes --json to `apps list`, which accepts it. + http.mock({ "/applications": [] }); + const { stdout } = await clerk("apps", "list", "--input-json", '{"json":true}'); + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); +}); + +test("noSkills negated boolean via --input-json", async () => { + // noSkills:true → --no-skills. Commander must accept the negated flag without error. + const { stdout } = await clerk( + "init", + "--prompt", + "--input-json", + '{"prompt":true,"noSkills":true}', + ); + expect(stdout).toContain("clerk init -y"); +}); + +test("--input-json is registered as a global option", async () => { + // --input-json before the subcommand expands to flags at the root-program level. + // --mode is a root-level flag, so this works; subcommand-specific flags would not. + const { stdout } = await clerk("--input-json", '{"mode":"agent"}', "init", "--prompt"); + expect(stdout).toContain("clerk init -y"); +}); + +test("structured JSON error in agent mode for invalid JSON", async () => { + const result = await clerk.raw("--mode", "agent", "init", "--input-json", "{bad}"); + expect(result.exitCode).not.toBe(0); + const parsed = JSON.parse(result.stderr); + expect(parsed.error.code).toBe("invalid_json"); +}); + +test("structured JSON error in agent mode for file not found", async () => { + const result = await clerk.raw( + "--mode", + "agent", + "init", + "--input-json", + "@/tmp/does-not-exist-clerk.json", + ); + expect(result.exitCode).not.toBe(0); + const parsed = JSON.parse(result.stderr); + expect(parsed.error.code).toBe("file_not_found"); +}); + +test("structured JSON error in agent mode for nested objects", async () => { + const result = await clerk.raw( + "--mode", + "agent", + "init", + "--input-json", + '{"nested":{"key":"value"}}', + ); + expect(result.exitCode).not.toBe(0); + const parsed = JSON.parse(result.stderr); + expect(parsed.error.code).toBe("invalid_json"); +}); diff --git a/skills/clerk/SKILL.md b/skills/clerk/SKILL.md index 6685d5b0..56872193 100644 --- a/skills/clerk/SKILL.md +++ b/skills/clerk/SKILL.md @@ -203,6 +203,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. - **`clerk init --prompt`** prints a short agent-oriented handoff telling the agent to run `clerk init -y` (it is NOT a framework-specific integration guide; use the runtime `clerk init` output itself for that). +- **`--input-json ` works on every command.** Pass options as JSON instead of a shell-escaped flag string (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Place it after the leaf subcommand so expanded flags target that subcommand. Full semantics in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk/references/agent-mode.md b/skills/clerk/references/agent-mode.md index 24203a0e..aa9c40fe 100644 --- a/skills/clerk/references/agent-mode.md +++ b/skills/clerk/references/agent-mode.md @@ -67,6 +67,47 @@ per CLI invocation when a host-sensitive operation is blocked. **Rule of thumb:** always pass `--yes` for mutations, `--json` for structured output where available, and `--app` / `--instance` explicitly instead of relying on pickers. +## Passing options as JSON: `--input-json` + +Every Clerk CLI command accepts `--input-json `. The value can be either an inline JSON object or `@path/to/file.json` to read from disk. Keys are converted from camelCase / snake_case to kebab-case and expanded into flags before Commander parses the command line, so anything a command accepts as a flag can be passed via JSON. + +```sh +# Inline JSON — equivalent to `clerk init --framework next --yes` +clerk init --input-json '{"framework":"next","yes":true}' + +# Read the JSON body from a file +clerk init --input-json @init-opts.json + +# Array values expand to repeated flags: --keys auth_email --keys session +clerk config pull --input-json '{"keys":["auth_email","session"]}' +``` + +Expansion rules: + +| JSON value | Expansion | +| ---------------- | -------------------------------------------------- | +| `"str"` / number | `--flag ` (numbers are stringified) | +| `true` | `--flag` | +| `false` / `null` | omitted entirely | +| `["a","b"]` | `--flag a --flag b` (empty arrays are omitted) | +| `{…}` (nested) | **rejected** with a `usage_error` (`invalid_json`) | + +Key conversion: `dryRun` → `--dry-run`, `noSkills` → `--no-skills`, `secret_key` → `--secret-key`. + +Precedence and placement: + +- Explicit flags always win over JSON-sourced flags when both are present. The JSON is expanded at the position of `--input-json` in argv, so any explicit flag that appears **after** it overrides the JSON value (standard Commander "last flag wins" semantics). +- Placement matters for subcommand flags. `clerk apps list --input-json '{"json":true}'` works because the expansion lands after the subcommand. `clerk --input-json '{"json":true}' apps list` expands to `clerk --json apps list`, which errors — `--json` is not a root-level option. +- Rule of thumb: place `--input-json` immediately after the leaf subcommand (e.g., after `apps list`, after `config patch`), so expanded flags target that subcommand. + +Error codes follow the standard agent-mode format: + +- Invalid JSON, primitives, arrays, or nested objects → `{"error":{"code":"invalid_json", ...}}` on stderr, exit `2`. +- Missing `@file` target → `{"error":{"code":"file_not_found", ...}}`, exit `2`. +- Unknown flags produced by key expansion → Commander's usual `unknown option` usage error, exit `2`. + +Use `--input-json` when an agent wants to pass structured options (often from a tool call's JSON args) without building a shell command string. + ## Exit codes | Code | Meaning | From 1a9662cf32e2b408ba270e5180c0a624543ea5aa Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 23 Apr 2026 17:40:38 -0300 Subject: [PATCH 2/6] docs(skills): tighten --input-json section --- skills/clerk/SKILL.md | 2 +- skills/clerk/references/agent-mode.md | 43 ++++++++------------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/skills/clerk/SKILL.md b/skills/clerk/SKILL.md index 56872193..c4044c51 100644 --- a/skills/clerk/SKILL.md +++ b/skills/clerk/SKILL.md @@ -203,7 +203,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. - **`clerk init --prompt`** prints a short agent-oriented handoff telling the agent to run `clerk init -y` (it is NOT a framework-specific integration guide; use the runtime `clerk init` output itself for that). -- **`--input-json ` works on every command.** Pass options as JSON instead of a shell-escaped flag string (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Place it after the leaf subcommand so expanded flags target that subcommand. Full semantics in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). +- **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Place it after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk/references/agent-mode.md b/skills/clerk/references/agent-mode.md index aa9c40fe..013410b9 100644 --- a/skills/clerk/references/agent-mode.md +++ b/skills/clerk/references/agent-mode.md @@ -69,44 +69,25 @@ per CLI invocation when a host-sensitive operation is blocked. ## Passing options as JSON: `--input-json` -Every Clerk CLI command accepts `--input-json `. The value can be either an inline JSON object or `@path/to/file.json` to read from disk. Keys are converted from camelCase / snake_case to kebab-case and expanded into flags before Commander parses the command line, so anything a command accepts as a flag can be passed via JSON. +Every command accepts `--input-json `. Keys convert from camelCase/snake_case to kebab-case and expand into flags before Commander parses argv — so anything a command accepts as a flag can come from JSON instead. ```sh -# Inline JSON — equivalent to `clerk init --framework next --yes` clerk init --input-json '{"framework":"next","yes":true}' - -# Read the JSON body from a file -clerk init --input-json @init-opts.json - -# Array values expand to repeated flags: --keys auth_email --keys session -clerk config pull --input-json '{"keys":["auth_email","session"]}' +clerk config pull --input-json '{"keys":["auth_email","session"]}' # arrays → repeated flags +clerk apps create --input-json @app.json # or read from a file ``` -Expansion rules: - -| JSON value | Expansion | -| ---------------- | -------------------------------------------------- | -| `"str"` / number | `--flag ` (numbers are stringified) | -| `true` | `--flag` | -| `false` / `null` | omitted entirely | -| `["a","b"]` | `--flag a --flag b` (empty arrays are omitted) | -| `{…}` (nested) | **rejected** with a `usage_error` (`invalid_json`) | - -Key conversion: `dryRun` → `--dry-run`, `noSkills` → `--no-skills`, `secret_key` → `--secret-key`. - -Precedence and placement: - -- Explicit flags always win over JSON-sourced flags when both are present. The JSON is expanded at the position of `--input-json` in argv, so any explicit flag that appears **after** it overrides the JSON value (standard Commander "last flag wins" semantics). -- Placement matters for subcommand flags. `clerk apps list --input-json '{"json":true}'` works because the expansion lands after the subcommand. `clerk --input-json '{"json":true}' apps list` expands to `clerk --json apps list`, which errors — `--json` is not a root-level option. -- Rule of thumb: place `--input-json` immediately after the leaf subcommand (e.g., after `apps list`, after `config patch`), so expanded flags target that subcommand. - -Error codes follow the standard agent-mode format: +| JSON | Expansion | +| ---------------- | --------------------------------------- | +| `"str"` / number | `--flag ` | +| `true` | `--flag` | +| `false` / `null` | omitted | +| `["a","b"]` | `--flag a --flag b` (empty arrays omit) | +| `{…}` (nested) | rejected — `invalid_json`, exit `2` | -- Invalid JSON, primitives, arrays, or nested objects → `{"error":{"code":"invalid_json", ...}}` on stderr, exit `2`. -- Missing `@file` target → `{"error":{"code":"file_not_found", ...}}`, exit `2`. -- Unknown flags produced by key expansion → Commander's usual `unknown option` usage error, exit `2`. +**Placement.** Put `--input-json` after the leaf subcommand. Before it, flags land on the root program, so only `--mode` / `--verbose` work there — subcommand flags (`--json`, `--app`, etc.) error as unknown. Explicit flags after `--input-json` override its values (last-flag-wins). -Use `--input-json` when an agent wants to pass structured options (often from a tool call's JSON args) without building a shell command string. +Errors use the standard agent-mode format: bad JSON → `invalid_json`, missing `@file` → `file_not_found`, unknown expanded flags → Commander's `unknown option`. All exit `2`. ## Exit codes From 7b27151d05459663577ed5f5288e379567f6acfe Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 23 Apr 2026 17:42:48 -0300 Subject: [PATCH 3/6] docs(skills): fix misleading @file example in --input-json section --- skills/clerk/references/agent-mode.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/clerk/references/agent-mode.md b/skills/clerk/references/agent-mode.md index 013410b9..fa897907 100644 --- a/skills/clerk/references/agent-mode.md +++ b/skills/clerk/references/agent-mode.md @@ -74,9 +74,11 @@ Every command accepts `--input-json `. Keys convert from camelCase/s ```sh clerk init --input-json '{"framework":"next","yes":true}' clerk config pull --input-json '{"keys":["auth_email","session"]}' # arrays → repeated flags -clerk apps create --input-json @app.json # or read from a file +clerk init --input-json @init-opts.json # or read JSON from a file ``` +Positional arguments (e.g. the `` in `clerk apps create `) cannot come from JSON — only flag-style options can. + | JSON | Expansion | | ---------------- | --------------------------------------- | | `"str"` / number | `--flag ` | From e0ebf01b4bde5e2a2e25fadcd4b5fa93968ca4de Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 24 Apr 2026 09:20:50 -0300 Subject: [PATCH 4/6] feat(cli): support stdin for --input-json Add stdin support so agents can pipe JSON options directly: echo '{"framework":"next","yes":true}' | clerk init Three new input sources: - `--input-json -` explicitly reads from stdin - Piped stdin auto-detected when --input-json flag is absent and stdin is not a TTY - Existing inline JSON and @file paths remain unchanged Adds 8 subprocess-based unit tests that exercise both explicit and auto-detected stdin paths, including error cases. --- README.md | 2 +- packages/cli-core/src/cli-program.ts | 5 +- packages/cli-core/src/lib/input-json.test.ts | 114 +++++++++++++++++++ packages/cli-core/src/lib/input-json.ts | 59 ++++++++-- skills/clerk/SKILL.md | 2 +- skills/clerk/references/agent-mode.md | 8 +- 6 files changed, 177 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3a83f0b8..39005a7e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Clerk CLI Options: -v, --version Output the version number - --input-json Pass command options as a JSON string or @file.json + --input-json Pass command options as a JSON string, @file.json, or - for stdin --mode Force interaction mode (human or agent). Defaults to auto-detect based on TTY. --verbose Show detailed output (enables debug messages) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 61fc31e7..6266fb01 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -53,7 +53,10 @@ export function createProgram() { .version(getCurrentVersion(), "-v, --version", "Output the version number") .helpOption("-h, --help", "Display help for command") .addHelpCommand("help [command]", "Display help for command") - .option("--input-json ", "Pass command options as a JSON string or @file.json") + .option( + "--input-json ", + "Pass command options as a JSON string, @file.json, or - for stdin", + ) .option( "--mode ", "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index a9ddacda..eee9859a 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -4,6 +4,8 @@ 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"); @@ -43,6 +45,15 @@ 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); @@ -252,4 +263,107 @@ describe("expandInputJson", () => { await expect(expandInputJson(argv)).rejects.toThrow("Invalid JSON"); }); }); + + describe("stdin support", () => { + let tempDir: string; + let scriptPath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-stdin-test-")); + scriptPath = join(tempDir, "stdin-test.ts"); + // Write a small helper script that imports expandInputJson and outputs the result. + // The argv to expand is passed as a single JSON argument. + await Bun.write( + scriptPath, + ` + import { expandInputJson } from "${join(import.meta.dir, "input-json.ts")}"; + const argv = JSON.parse(process.argv[2]); + try { + const result = await expandInputJson(argv); + process.stdout.write(JSON.stringify({ result })); + } catch (e) { + process.stdout.write(JSON.stringify({ error: e.message })); + } + `, + ); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + /** + * Spawn a subprocess that calls expandInputJson with the given argv + * and writes the piped JSON to its stdin. Returns the expanded argv + * as parsed JSON. + */ + async function expandViaStdin( + argv: string[], + stdinData: string, + ): Promise<{ result?: string[]; error?: string }> { + const proc = Bun.spawn(["bun", "run", scriptPath, JSON.stringify(argv)], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.write(stdinData); + proc.stdin.end(); + + const stdout = await new Response(proc.stdout).text(); + await proc.exited; + return JSON.parse(stdout); + } + + test("--input-json - reads from stdin", async () => { + const result = await expandViaStdin( + ["clerk", "init", "--input-json", "-"], + '{"framework":"next","yes":true}', + ); + expect(result.result).toContain("--framework"); + expect(result.result).toContain("next"); + expect(result.result).toContain("--yes"); + }); + + test("auto-detects 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"]); + }); + + test("--input-json - errors on invalid JSON from stdin", async () => { + const result = await expandViaStdin(["clerk", "init", "--input-json", "-"], "not-json"); + expect(result.error).toContain("Invalid JSON"); + }); + + test("--input-json - errors on empty stdin", async () => { + const result = await expandViaStdin(["clerk", "init", "--input-json", "-"], ""); + 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 () => { + const result = await expandViaStdin(["clerk", "init"], "[1,2,3]"); + expect(result.error).toContain("must be a JSON object"); + }); + + test("auto-stdin handles camelCase keys", async () => { + const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}'); + expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]); + }); + }); }); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index 57dcc8a2..f1f5501b 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -2,6 +2,7 @@ import { throwUsageError, ERROR_CODE } from "./errors.ts"; const INPUT_JSON_FLAG = "--input-json"; const FILE_PREFIX = "@"; +const STDIN_MARKER = "-"; type JsonObject = Record; @@ -57,11 +58,30 @@ async function readJsonFile(path: string): Promise { return file.text(); } +/** + * Read all of stdin as a UTF-8 string. + * Uses Bun.stdin, which is a `ReadableStream`. + */ +async function readStdin(): Promise { + const text = await Bun.stdin.text(); + if (!text.trim()) { + throwUsageError( + "No JSON received on stdin. Pipe JSON to the command or use --input-json .", + undefined, + ERROR_CODE.USAGE_ERROR, + ); + } + return text; +} + /** * Resolve the raw --input-json value to a JSON string. - * If the value starts with `@`, reads from the given file path. + * - `"-"` reads from stdin. + * - `"@path"` reads from the given file. + * - Anything else is treated as an inline JSON string. */ function resolveJsonValue(raw: string): Promise | string { + if (raw === STDIN_MARKER) return readStdin(); return raw.startsWith(FILE_PREFIX) ? readJsonFile(raw.slice(1)) : raw; } @@ -96,22 +116,45 @@ 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, returns the original array unchanged. + * 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. + * + * If neither `--input-json` nor stdin pipe is present, returns the original + * array unchanged. */ export async function expandInputJson(argv: string[]): Promise { const idx = argv.indexOf(INPUT_JSON_FLAG); - if (idx === -1) return argv; - const rawValue = requireValue(argv, idx); - const jsonStr = await resolveJsonValue(rawValue); - const parsed = parseJsonString(jsonStr); - assertJsonObject(parsed); + 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 readStdin(); + 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)); + } - argv.splice(idx, 2, ...expandJsonToFlags(parsed)); return argv; } diff --git a/skills/clerk/SKILL.md b/skills/clerk/SKILL.md index c4044c51..c4362d72 100644 --- a/skills/clerk/SKILL.md +++ b/skills/clerk/SKILL.md @@ -203,7 +203,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. - **`clerk init --prompt`** prints a short agent-oriented handoff telling the agent to run `clerk init -y` (it is NOT a framework-specific integration guide; use the runtime `clerk init` output itself for that). -- **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Place it after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). +- **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Piped stdin is also accepted: `echo '{"yes":true}' | clerk init`. Place `--input-json` after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk/references/agent-mode.md b/skills/clerk/references/agent-mode.md index fa897907..8ccb990d 100644 --- a/skills/clerk/references/agent-mode.md +++ b/skills/clerk/references/agent-mode.md @@ -69,14 +69,18 @@ per CLI invocation when a host-sensitive operation is blocked. ## Passing options as JSON: `--input-json` -Every command accepts `--input-json `. Keys convert from camelCase/snake_case to kebab-case and expand into flags before Commander parses argv — so anything a command accepts as a flag can come from JSON instead. +Every command accepts `--input-json `. Keys convert from camelCase/snake_case to kebab-case and expand into flags before Commander parses argv — so anything a command accepts as a flag can come from JSON instead. ```sh clerk init --input-json '{"framework":"next","yes":true}' clerk config pull --input-json '{"keys":["auth_email","session"]}' # arrays → repeated flags -clerk init --input-json @init-opts.json # or read JSON from a file +clerk init --input-json @init-opts.json # read JSON from a file +clerk init --input-json - # read JSON from stdin +echo '{"framework":"next","yes":true}' | clerk init # auto-detect piped stdin ``` +When `--input-json` is omitted and stdin is piped (not a TTY), the CLI automatically reads JSON from stdin — no flag needed. This lets agents pipe options directly: `echo '{"yes":true}' | clerk init`. + Positional arguments (e.g. the `` in `clerk apps create `) cannot come from JSON — only flag-style options can. | JSON | Expansion | From 78e94d07bfe6c700e9c81f1e9b13cf218333d9a0 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 13:05:45 -0600 Subject: [PATCH 5/6] fix(cli): ignore empty implicit stdin for --input-json --- packages/cli-core/src/lib/input-json.test.ts | 5 +++++ packages/cli-core/src/lib/input-json.ts | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index eee9859a..954ac2ee 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -341,6 +341,11 @@ describe("expandInputJson", () => { 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("--input-json - errors on invalid JSON from stdin", async () => { const result = await expandViaStdin(["clerk", "init", "--input-json", "-"], "not-json"); expect(result.error).toContain("Invalid JSON"); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index f1f5501b..bcc37281 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -74,6 +74,11 @@ async function readStdin(): Promise { return text; } +async function readOptionalStdin(): Promise { + const text = await Bun.stdin.text(); + return text.trim() ? text : undefined; +} + /** * Resolve the raw --input-json value to a JSON string. * - `"-"` reads from stdin. @@ -148,7 +153,8 @@ export async function expandInputJson(argv: string[]): Promise { // No explicit --input-json flag — check for piped stdin if (hasStdinPipe()) { - const jsonStr = await readStdin(); + 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 From 42b0a887289be75e61f4a3dc86c912ac3b63e312 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 13:05:57 -0600 Subject: [PATCH 6/6] fix(e2e): choose loopback host per framework --- test/e2e/lib/dev-server.ts | 24 +++++++++++++++++++----- test/e2e/lib/fixture-test.ts | 6 ++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/test/e2e/lib/dev-server.ts b/test/e2e/lib/dev-server.ts index 02b9082d..8b6168be 100644 --- a/test/e2e/lib/dev-server.ts +++ b/test/e2e/lib/dev-server.ts @@ -13,6 +13,14 @@ const PORT_CONFLICT = /EADDRINUSE|address already in use|port \S+ is (already )? const READINESS_TIMEOUT_MS = 60_000; const MAX_BIND_ATTEMPTS = 3; +function isNextjsDevCommand(devCmd: string[]): boolean { + return devCmd[0] === "next"; +} + +function getDevServerHost(devCmd: string[]): string { + return isNextjsDevCommand(devCmd) ? "localhost" : "127.0.0.1"; +} + /** Find an available port by binding to port 0 and reading the assigned port. */ async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { @@ -33,9 +41,10 @@ async function getAvailablePort(): Promise { /** Build the full dev server command with the port flag appended. */ export function buildDevCommand(devCmd: string[], port: number): string[] { - const isNextjs = devCmd[0] === "next"; + const isNextjs = isNextjsDevCommand(devCmd); const portFlag = isNextjs ? "-p" : "--port"; - return [...devCmd, portFlag, String(port)]; + const hostFlag = isNextjs ? "-H" : "--host"; + return [...devCmd, portFlag, String(port), hostFlag, getDevServerHost(devCmd)]; } /** @@ -67,6 +76,7 @@ async function canConnect(host: string, port: number, timeoutMs: number): Promis interface ReadyServer { proc: Subprocess; port: number; + host: string; stdout: string[]; stderr: string[]; } @@ -88,6 +98,7 @@ async function tryStart(opts: { }): Promise { const { devCmd, port, projectDir, fixtureName } = opts; const fullCmd = buildDevCommand(devCmd, port); + const host = getDevServerHost(devCmd); const stderrLines: string[] = []; const stdoutLines: string[] = []; @@ -159,9 +170,12 @@ async function tryStart(opts: { ); } - if (await canConnect("127.0.0.1", port, 1000)) { - log(fixtureName, `dev server ready (accepting TCP on 127.0.0.1:${port})`); - return { kind: "ready", value: { proc, port, stdout: stdoutLines, stderr: stderrLines } }; + if (await canConnect(host, port, 1000)) { + log(fixtureName, `dev server ready (accepting TCP on ${host}:${port})`); + return { + kind: "ready", + value: { proc, port, host, stdout: stdoutLines, stderr: stderrLines }, + }; } await Bun.sleep(500); } diff --git a/test/e2e/lib/fixture-test.ts b/test/e2e/lib/fixture-test.ts index 08041bc8..ee733ddf 100644 --- a/test/e2e/lib/fixture-test.ts +++ b/test/e2e/lib/fixture-test.ts @@ -175,6 +175,7 @@ export function runBrowserTest(getFixture: () => FixtureState, config: FixtureCo const fixtureName = config.description; let port: number | undefined; + let host: string | undefined; let proc: import("bun").Subprocess | undefined; let stderrLines: string[] = []; let stdoutLines: string[] = []; @@ -196,6 +197,7 @@ export function runBrowserTest(getFixture: () => FixtureState, config: FixtureCo }); proc = server.proc; port = server.port; + host = server.host; stderrLines = server.stderr; stdoutLines = server.stdout; @@ -225,8 +227,8 @@ export function runBrowserTest(getFixture: () => FixtureState, config: FixtureCo context, options: frontendApiUrl ? { frontendApiUrl } : undefined, }); - log(fixtureName, `navigating to http://localhost:${port}`); - await page.goto(`http://localhost:${port}`, { waitUntil: "load" }); + log(fixtureName, `navigating to http://${host}:${port}`); + await page.goto(`http://${host}:${port}`, { waitUntil: "load" }); // 5. Sign in log(fixtureName, "signing in");