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..39005a7e 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, @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) + -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..6266fb01 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,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, @file.json, or - for stdin", + ) .option( "--mode ", "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", @@ -630,6 +635,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 +659,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..954ac2ee --- /dev/null +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -0,0 +1,374 @@ +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"; + +const originalIsTTY = process.stdin.isTTY; + +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", () => { + 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); + 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"); + }); + }); + + 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("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"); + }); + + 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 new file mode 100644 index 00000000..bcc37281 --- /dev/null +++ b/packages/cli-core/src/lib/input-json.ts @@ -0,0 +1,166 @@ +import { throwUsageError, ERROR_CODE } from "./errors.ts"; + +const INPUT_JSON_FLAG = "--input-json"; +const FILE_PREFIX = "@"; +const STDIN_MARKER = "-"; + +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(); +} + +/** + * 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; +} + +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. + * - `"@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; +} + +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, + ); +} + +/** + * 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. + * + * 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) { + 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)); + } + + 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..c4362d72 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 `** 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 24203a0e..8ccb990d 100644 --- a/skills/clerk/references/agent-mode.md +++ b/skills/clerk/references/agent-mode.md @@ -67,6 +67,34 @@ 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 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 # 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 | +| ---------------- | --------------------------------------- | +| `"str"` / number | `--flag ` | +| `true` | `--flag` | +| `false` / `null` | omitted | +| `["a","b"]` | `--flag a --flag b` (empty arrays omit) | +| `{…}` (nested) | rejected — `invalid_json`, 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). + +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 | Code | Meaning | 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");