viatris-exam-cli 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/args.js ADDED
@@ -0,0 +1,89 @@
1
+ import { CliError } from "./errors.js";
2
+
3
+ const BOOLEAN_OPTIONS = new Set(["help", "compact", "version"]);
4
+ const GLOBAL_OPTIONS = [
5
+ "base_url",
6
+ "key_header",
7
+ "key_prefix",
8
+ "timeout",
9
+ "retries",
10
+ "compact",
11
+ ];
12
+
13
+ export function parseArgs(argv) {
14
+ const positionals = [];
15
+ const options = {};
16
+
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const token = argv[index];
19
+ if (token === "--") {
20
+ positionals.push(...argv.slice(index + 1));
21
+ break;
22
+ }
23
+ if (!token.startsWith("--")) {
24
+ positionals.push(token);
25
+ continue;
26
+ }
27
+
28
+ const equalsAt = token.indexOf("=");
29
+ const rawName = token.slice(2, equalsAt === -1 ? undefined : equalsAt);
30
+ const name = rawName.replaceAll("-", "_");
31
+ if (BOOLEAN_OPTIONS.has(rawName)) {
32
+ if (equalsAt !== -1) {
33
+ throw new CliError("usage_error", `--${rawName} does not accept a value.`);
34
+ }
35
+ options[name] = true;
36
+ continue;
37
+ }
38
+
39
+ const value = equalsAt === -1 ? argv[index + 1] : token.slice(equalsAt + 1);
40
+ if (value === undefined || (equalsAt === -1 && value.startsWith("--"))) {
41
+ throw new CliError("usage_error", `--${rawName} requires a value.`);
42
+ }
43
+ options[name] = value;
44
+ if (equalsAt === -1) index += 1;
45
+ }
46
+
47
+ return { positionals, options };
48
+ }
49
+
50
+ export function requiredId(positionals, index, label) {
51
+ const id = positionals[index];
52
+ if (!id) throw new CliError("usage_error", `Missing required ${label}.`);
53
+ return encodeURIComponent(id);
54
+ }
55
+
56
+ export function integerOption(value, name, min, max) {
57
+ if (value === undefined) return undefined;
58
+ if (!/^\d+$/.test(value)) {
59
+ throw new CliError("validation_error", `--${name} must be an integer.`);
60
+ }
61
+ const number = Number(value);
62
+ if (number < min || number > max) {
63
+ throw new CliError(
64
+ "validation_error",
65
+ `--${name} must be between ${min} and ${max}.`,
66
+ );
67
+ }
68
+ return number;
69
+ }
70
+
71
+ export function assertNoExtraPositionals(positionals, count) {
72
+ if (positionals.length > count) {
73
+ throw new CliError(
74
+ "usage_error",
75
+ `Unexpected argument: ${positionals[count]}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ export function assertOptions(options, commandOptions = []) {
81
+ const allowed = new Set([...GLOBAL_OPTIONS, ...commandOptions]);
82
+ const unknown = Object.keys(options).find((name) => !allowed.has(name));
83
+ if (unknown) {
84
+ throw new CliError(
85
+ "usage_error",
86
+ `Unknown or unsupported option for this command: --${unknown.replaceAll("_", "-")}`,
87
+ );
88
+ }
89
+ }
package/src/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "./app.js";
4
+ import { normalizeError } from "./errors.js";
5
+ import { writeError } from "./output.js";
6
+
7
+ run(process.argv.slice(2)).catch((error) => {
8
+ const cliError = normalizeError(error);
9
+ writeError(process.stderr, cliError);
10
+ process.exitCode = cliError.exitCode;
11
+ });
@@ -0,0 +1,43 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ export async function resolveContent(positionals, options) {
9
+ const [resource, action] = positionals;
10
+
11
+ if (resource === "exams" && action === "create") {
12
+ assertNoExtraPositionals(positionals, 2);
13
+ assertOptions(options, ["input", "json"]);
14
+ return {
15
+ method: "POST",
16
+ path: "/openapi/exams",
17
+ body: await readJsonInput(options),
18
+ };
19
+ }
20
+
21
+ if (resource === "questions" && action === "create") {
22
+ assertNoExtraPositionals(positionals, 2);
23
+ assertOptions(options, ["input", "json"]);
24
+ return {
25
+ method: "POST",
26
+ path: "/openapi/questions",
27
+ body: await readJsonInput(options),
28
+ };
29
+ }
30
+
31
+ if (resource === "questions" && action === "version") {
32
+ assertNoExtraPositionals(positionals, 3);
33
+ assertOptions(options, ["input", "json"]);
34
+ const id = requiredId(positionals, 2, "question ID");
35
+ return {
36
+ method: "POST",
37
+ path: `/openapi/questions/${id}/versions`,
38
+ body: await readJsonInput(options),
39
+ };
40
+ }
41
+
42
+ return null;
43
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+
7
+ export async function resolveExamRecords(positionals, options) {
8
+ const [resource, action] = positionals;
9
+ if (resource !== "exam-records") return null;
10
+
11
+ if (action === "get") {
12
+ assertNoExtraPositionals(positionals, 3);
13
+ assertOptions(options);
14
+ const id = requiredId(positionals, 2, "exam record ID");
15
+ return { method: "GET", path: `/openapi/exam-records/${id}` };
16
+ }
17
+
18
+ if (action === "scoring-rules") {
19
+ assertNoExtraPositionals(positionals, 3);
20
+ assertOptions(options);
21
+ const id = requiredId(positionals, 2, "exam record ID");
22
+ return {
23
+ method: "GET",
24
+ path: `/openapi/exam-records/${id}/scoring-rules`,
25
+ };
26
+ }
27
+
28
+ return null;
29
+ }
@@ -0,0 +1,53 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ integerOption,
5
+ requiredId,
6
+ } from "../args.js";
7
+ import { CliError } from "../errors.js";
8
+ import { readJsonInput } from "../input.js";
9
+
10
+ export async function resolveGenTasks(positionals, options) {
11
+ const [resource, action] = positionals;
12
+ if (resource !== "gen-tasks") return null;
13
+
14
+ if (action === "list") {
15
+ assertNoExtraPositionals(positionals, 2);
16
+ assertOptions(options, ["status", "limit"]);
17
+ const allowedStatuses = new Set(["generating", "partial_failed", "failed"]);
18
+ if (options.status && !allowedStatuses.has(options.status)) {
19
+ throw new CliError(
20
+ "validation_error",
21
+ "--status must be generating, partial_failed, or failed.",
22
+ );
23
+ }
24
+ const query = new URLSearchParams();
25
+ if (options.status) query.set("status", options.status);
26
+ const limit = integerOption(options.limit, "limit", 1, 50);
27
+ if (limit !== undefined) query.set("limit", String(limit));
28
+ return {
29
+ method: "GET",
30
+ path: `/openapi/gen-tasks${query.size ? `?${query}` : ""}`,
31
+ };
32
+ }
33
+
34
+ if (action === "get") {
35
+ assertNoExtraPositionals(positionals, 3);
36
+ assertOptions(options);
37
+ const id = requiredId(positionals, 2, "task ID");
38
+ return { method: "GET", path: `/openapi/gen-tasks/${id}` };
39
+ }
40
+
41
+ if (action === "submit") {
42
+ assertNoExtraPositionals(positionals, 3);
43
+ assertOptions(options, ["input", "json"]);
44
+ const id = requiredId(positionals, 2, "task ID");
45
+ return {
46
+ method: "POST",
47
+ path: `/openapi/gen-tasks/${id}/questions`,
48
+ body: await readJsonInput(options),
49
+ };
50
+ }
51
+
52
+ return null;
53
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ integerOption,
5
+ } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { hasPipedInput, readJsonInput } from "../input.js";
8
+
9
+ export async function resolveMedicalSearch(positionals, options) {
10
+ const [resource, action] = positionals;
11
+ if (resource !== "medical-search" || action !== "search") return null;
12
+
13
+ assertNoExtraPositionals(positionals, 3);
14
+ assertOptions(options, [
15
+ "input",
16
+ "json",
17
+ "file_type",
18
+ "limit",
19
+ "max_length",
20
+ ]);
21
+ const hasStructuredInput =
22
+ options.input !== undefined ||
23
+ options.json !== undefined ||
24
+ (hasPipedInput() && positionals[2] === undefined);
25
+ if (hasStructuredInput) {
26
+ if (
27
+ positionals[2] !== undefined ||
28
+ options.file_type !== undefined ||
29
+ options.limit !== undefined ||
30
+ options.max_length !== undefined
31
+ ) {
32
+ throw new CliError(
33
+ "usage_error",
34
+ "Do not combine JSON input with medical-search query flags.",
35
+ );
36
+ }
37
+ return {
38
+ method: "POST",
39
+ path: "/openapi/medical-search",
40
+ body: await readJsonInput(options),
41
+ };
42
+ }
43
+
44
+ const query = positionals[2];
45
+ if (!query) {
46
+ throw new CliError("usage_error", "Missing medical search query.");
47
+ }
48
+ const body = { query };
49
+ if (options.file_type !== undefined) body.fileType = options.file_type;
50
+ const limit = integerOption(options.limit, "limit", 1, 20);
51
+ if (limit !== undefined) body.limit = limit;
52
+ const maxLength = integerOption(options.max_length, "max-length", 100, 5000);
53
+ if (maxLength !== undefined) body.maxLength = maxLength;
54
+ return { method: "POST", path: "/openapi/medical-search", body };
55
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ export async function resolveProducts(positionals, options) {
9
+ const [resource, action] = positionals;
10
+ if (resource !== "products") return null;
11
+
12
+ if (action === "get") {
13
+ assertNoExtraPositionals(positionals, 3);
14
+ assertOptions(options);
15
+ const id = requiredId(positionals, 2, "product ID");
16
+ return { method: "GET", path: `/openapi/products/${id}` };
17
+ }
18
+
19
+ if (action === "update-knowledge-tree") {
20
+ assertNoExtraPositionals(positionals, 3);
21
+ assertOptions(options, ["input", "json"]);
22
+ const id = requiredId(positionals, 2, "product ID");
23
+ return {
24
+ method: "PUT",
25
+ path: `/openapi/products/${id}/knowledge-tree`,
26
+ body: await readJsonInput(options),
27
+ };
28
+ }
29
+
30
+ return null;
31
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ export async function resolveScoring(positionals, options) {
9
+ const [resource, action] = positionals;
10
+ if (resource !== "scoring") return null;
11
+
12
+ if (action === "get") {
13
+ assertNoExtraPositionals(positionals, 3);
14
+ assertOptions(options);
15
+ const id = requiredId(positionals, 2, "task ID");
16
+ return { method: "GET", path: `/openapi/scoring/${id}` };
17
+ }
18
+
19
+ if (action === "submit") {
20
+ assertNoExtraPositionals(positionals, 3);
21
+ assertOptions(options, ["input", "json"]);
22
+ const id = requiredId(positionals, 2, "task ID");
23
+ return {
24
+ method: "POST",
25
+ path: `/openapi/scoring/${id}/results`,
26
+ body: await readJsonInput(options),
27
+ };
28
+ }
29
+
30
+ return null;
31
+ }
@@ -0,0 +1,28 @@
1
+ import { resolveContent } from "./command-handlers/content.js";
2
+ import { resolveExamRecords } from "./command-handlers/exam-records.js";
3
+ import { resolveGenTasks } from "./command-handlers/gen-tasks.js";
4
+ import { resolveMedicalSearch } from "./command-handlers/medical-search.js";
5
+ import { resolveProducts } from "./command-handlers/products.js";
6
+ import { resolveScoring } from "./command-handlers/scoring.js";
7
+ import { CliError } from "./errors.js";
8
+
9
+ const resolvers = [
10
+ resolveProducts,
11
+ resolveGenTasks,
12
+ resolveExamRecords,
13
+ resolveScoring,
14
+ resolveContent,
15
+ resolveMedicalSearch,
16
+ ];
17
+
18
+ export async function resolveCommand(positionals, options) {
19
+ for (const resolve of resolvers) {
20
+ const command = await resolve(positionals, options);
21
+ if (command) return command;
22
+ }
23
+
24
+ throw new CliError(
25
+ "usage_error",
26
+ "Unknown command. Run `viatris-exam --help` or `viatris-exam describe`.",
27
+ );
28
+ }
package/src/config.js ADDED
@@ -0,0 +1,85 @@
1
+ import { integerOption } from "./args.js";
2
+ import { CliError } from "./errors.js";
3
+ import {
4
+ API_ENVIRONMENT_ALIASES,
5
+ API_ENVIRONMENTS,
6
+ DEFAULT_RETRIES,
7
+ DEFAULT_TIMEOUT_MS,
8
+ } from "./metadata.js";
9
+
10
+ export function configFrom(options, environmentVariables = process.env) {
11
+ const baseUrlOverride =
12
+ options.base_url ?? environmentVariables.VIATRIS_API_BASE_URL;
13
+ let environment = null;
14
+ let baseUrl = baseUrlOverride;
15
+ if (baseUrlOverride === undefined) {
16
+ const requestedEnvironment = (
17
+ environmentVariables.VIATRIS_API_ENV ?? "production"
18
+ ).toLowerCase();
19
+ environment =
20
+ API_ENVIRONMENT_ALIASES[requestedEnvironment] ?? requestedEnvironment;
21
+ if (!Object.hasOwn(API_ENVIRONMENTS, environment)) {
22
+ throw new CliError(
23
+ "config_error",
24
+ "VIATRIS_API_ENV must be production, development, prod, or dev.",
25
+ { exitCode: 3 },
26
+ );
27
+ }
28
+ baseUrl = API_ENVIRONMENTS[environment];
29
+ }
30
+
31
+ const apiKey = environmentVariables.VIATRIS_API_KEY;
32
+ const keyHeader =
33
+ options.key_header ??
34
+ environmentVariables.VIATRIS_API_KEY_HEADER ??
35
+ "X-API-Key";
36
+ const keyPrefix =
37
+ options.key_prefix ?? environmentVariables.VIATRIS_API_KEY_PREFIX ?? "";
38
+ const timeoutMs = integerOption(
39
+ options.timeout ?? String(DEFAULT_TIMEOUT_MS),
40
+ "timeout",
41
+ 1,
42
+ 600_000,
43
+ );
44
+ const retries = integerOption(
45
+ options.retries ?? String(DEFAULT_RETRIES),
46
+ "retries",
47
+ 0,
48
+ 5,
49
+ );
50
+
51
+ if (!baseUrl) {
52
+ throw new CliError(
53
+ "config_error",
54
+ "The API base URL override cannot be empty.",
55
+ { exitCode: 3 },
56
+ );
57
+ }
58
+ if (!/^https?:\/\//.test(baseUrl)) {
59
+ throw new CliError(
60
+ "config_error",
61
+ "VIATRIS_API_BASE_URL must start with http:// or https://.",
62
+ { exitCode: 3 },
63
+ );
64
+ }
65
+ if (!apiKey) {
66
+ throw new CliError("config_error", "VIATRIS_API_KEY is required.", {
67
+ exitCode: 3,
68
+ });
69
+ }
70
+ if (!/^[A-Za-z0-9-]+$/.test(keyHeader)) {
71
+ throw new CliError("config_error", "API key header name is invalid.", {
72
+ exitCode: 3,
73
+ });
74
+ }
75
+
76
+ return {
77
+ baseUrl: baseUrl.replace(/\/+$/, ""),
78
+ environment,
79
+ apiKey,
80
+ keyHeader,
81
+ keyPrefix,
82
+ timeoutMs,
83
+ retries,
84
+ };
85
+ }
package/src/errors.js ADDED
@@ -0,0 +1,14 @@
1
+ export class CliError extends Error {
2
+ constructor(type, message, { exitCode = 2, details } = {}) {
3
+ super(message);
4
+ this.name = "CliError";
5
+ this.type = type;
6
+ this.exitCode = exitCode;
7
+ this.details = details;
8
+ }
9
+ }
10
+
11
+ export function normalizeError(error) {
12
+ if (error instanceof CliError) return error;
13
+ return new CliError("internal_error", error.message, { exitCode: 1 });
14
+ }
package/src/help.js ADDED
@@ -0,0 +1,52 @@
1
+ import { VERSION } from "./metadata.js";
2
+
3
+ export function helpText() {
4
+ return `viatris-exam ${VERSION}
5
+
6
+ Usage:
7
+ viatris-exam <resource> <action> [id|query] [options]
8
+ viatris-exam describe
9
+
10
+ Commands:
11
+ products get <product-id>
12
+ products update-knowledge-tree <product-id> --input <file|->
13
+ gen-tasks list [--status STATUS] [--limit N]
14
+ gen-tasks get <task-id>
15
+ gen-tasks submit <task-id> --input <file|->
16
+ exam-records get <record-id>
17
+ exam-records scoring-rules <record-id>
18
+ scoring get <task-id>
19
+ scoring submit <task-id> --input <file|->
20
+ exams create --input <file|->
21
+ questions create --input <file|->
22
+ questions version <question-id> --input <file|->
23
+ medical-search search [query] [--file-type TYPE] [--limit N] [--max-length N]
24
+
25
+ Input:
26
+ --input <file|-> Read a JSON object from a file or stdin.
27
+ --json <object> Pass a JSON object inline.
28
+ Piped stdin Used automatically by commands that require JSON.
29
+
30
+ Global options:
31
+ --base-url <url> Override the selected environment URL.
32
+ --key-header <name> Override VIATRIS_API_KEY_HEADER (default: X-API-Key).
33
+ --key-prefix <value> Override VIATRIS_API_KEY_PREFIX (default: empty).
34
+ --timeout <ms> Request timeout (default: 30000).
35
+ --retries <n> GET retries for 429/5xx/network errors (default: 2).
36
+ --compact Emit compact JSON.
37
+ --help Show help.
38
+ --version Show CLI version.
39
+
40
+ Environment:
41
+ VIATRIS_API_ENV API environment: production (default) or development.
42
+ VIATRIS_API_BASE_URL Optional URL override with highest priority.
43
+ VIATRIS_API_KEY Required API key. It cannot be passed as a CLI argument.
44
+ VIATRIS_API_KEY_HEADER Optional authentication header name.
45
+ VIATRIS_API_KEY_PREFIX Optional header value prefix, for example "Bearer ".
46
+
47
+ Agent contract:
48
+ Successful API JSON is written to stdout. Structured errors are written to
49
+ stderr. Exit codes: 0 success, 1 API error, 2 usage/input error,
50
+ 3 configuration error, 4 network/timeout error, 5 authentication error.
51
+ `;
52
+ }
package/src/http.js ADDED
@@ -0,0 +1,92 @@
1
+ import { CliError } from "./errors.js";
2
+ import { VERSION } from "./metadata.js";
3
+
4
+ export function requestUrl(baseUrl, path) {
5
+ if (baseUrl.endsWith("/openapi") && path.startsWith("/openapi/")) {
6
+ return `${baseUrl}${path.slice("/openapi".length)}`;
7
+ }
8
+ return `${baseUrl}${path}`;
9
+ }
10
+
11
+ function sleep(milliseconds) {
12
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
13
+ }
14
+
15
+ function retryDelay(response, attempt) {
16
+ const retryAfter = response.headers.get("retry-after");
17
+ if (retryAfter && /^\d+$/.test(retryAfter)) {
18
+ return Math.min(Number(retryAfter) * 1000, 10_000);
19
+ }
20
+ return 250 * 2 ** attempt;
21
+ }
22
+
23
+ export async function request(command, config) {
24
+ const url = requestUrl(config.baseUrl, command.path);
25
+ const headers = {
26
+ Accept: "application/json",
27
+ "User-Agent": `viatris-exam-cli/${VERSION}`,
28
+ [config.keyHeader]: `${config.keyPrefix}${config.apiKey}`,
29
+ };
30
+ if (command.body !== undefined) headers["Content-Type"] = "application/json";
31
+
32
+ const maxAttempts = command.method === "GET" ? config.retries + 1 : 1;
33
+ let response;
34
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
35
+ try {
36
+ response = await fetch(url, {
37
+ method: command.method,
38
+ headers,
39
+ body:
40
+ command.body === undefined ? undefined : JSON.stringify(command.body),
41
+ signal: AbortSignal.timeout(config.timeoutMs),
42
+ });
43
+ } catch (error) {
44
+ if (attempt + 1 < maxAttempts && error.name !== "TimeoutError") {
45
+ await sleep(250 * 2 ** attempt);
46
+ continue;
47
+ }
48
+ const type = error.name === "TimeoutError" ? "timeout_error" : "network_error";
49
+ throw new CliError(type, error.message, {
50
+ exitCode: 4,
51
+ details: { method: command.method, path: command.path },
52
+ });
53
+ }
54
+
55
+ const retryable =
56
+ response.status === 429 || (response.status >= 500 && response.status <= 599);
57
+ if (!retryable || attempt + 1 >= maxAttempts) break;
58
+ await response.arrayBuffer();
59
+ await sleep(retryDelay(response, attempt));
60
+ }
61
+
62
+ const text = await response.text();
63
+ let data = null;
64
+ if (text) {
65
+ try {
66
+ data = JSON.parse(text);
67
+ } catch {
68
+ data = { raw: text };
69
+ }
70
+ }
71
+
72
+ if (!response.ok) {
73
+ throw new CliError(
74
+ "http_error",
75
+ `API request failed with HTTP ${response.status}.`,
76
+ {
77
+ exitCode: response.status === 401 || response.status === 403 ? 5 : 1,
78
+ details: {
79
+ status: response.status,
80
+ method: command.method,
81
+ path: command.path,
82
+ requestId:
83
+ response.headers.get("x-request-id") ??
84
+ response.headers.get("trace-id") ??
85
+ undefined,
86
+ response: data,
87
+ },
88
+ },
89
+ );
90
+ }
91
+ return data;
92
+ }
package/src/input.js ADDED
@@ -0,0 +1,53 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { CliError } from "./errors.js";
3
+
4
+ async function readStdin() {
5
+ const chunks = [];
6
+ for await (const chunk of process.stdin) chunks.push(chunk);
7
+ return Buffer.concat(chunks).toString("utf8");
8
+ }
9
+
10
+ export function hasPipedInput() {
11
+ return !process.stdin.isTTY;
12
+ }
13
+
14
+ export async function readJsonInput(options) {
15
+ const canReadStdin = hasPipedInput();
16
+ if (options.input !== undefined && options.json !== undefined) {
17
+ throw new CliError("usage_error", "Use only one of --input and --json.");
18
+ }
19
+
20
+ let source;
21
+ let text;
22
+ if (options.input !== undefined) {
23
+ source = options.input;
24
+ text = source === "-" ? await readStdin() : await readFile(source, "utf8");
25
+ } else if (options.json !== undefined) {
26
+ source = "--json";
27
+ text = options.json;
28
+ } else if (canReadStdin) {
29
+ source = "stdin";
30
+ text = await readStdin();
31
+ } else {
32
+ throw new CliError(
33
+ "usage_error",
34
+ "JSON input is required. Use --input <file>, --input -, --json <value>, or stdin.",
35
+ );
36
+ }
37
+
38
+ if (!text.trim()) {
39
+ throw new CliError("input_error", `JSON input from ${source} is empty.`);
40
+ }
41
+ try {
42
+ const value = JSON.parse(text);
43
+ if (value === null || Array.isArray(value) || typeof value !== "object") {
44
+ throw new Error("top-level value must be an object");
45
+ }
46
+ return value;
47
+ } catch (error) {
48
+ throw new CliError(
49
+ "input_error",
50
+ `Invalid JSON from ${source}: ${error.message}`,
51
+ );
52
+ }
53
+ }