@hardfin/cli 0.1.1-dev.39 → 0.1.1-dev.40

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/README.md CHANGED
@@ -373,7 +373,8 @@ asking and ask before anything changes.
373
373
 
374
374
  A command is a read when its endpoint's method is GET, or for `hardfin api` when no other
375
375
  `--method` is given. Anything else is a write, and `hardfin_read` refuses it by naming
376
- `hardfin_write`. Both tools carry a title, the read-only or destructive hint, and a link to
376
+ `hardfin_write`. The server asks the CLI's own parser what a call would run, so a flag whose
377
+ value is `--help`, or a `--method` given twice, is placed by what the command would do. Both tools carry a title, the read-only or destructive hint, and a link to
377
378
  the API reference, which Claude's connector directory requires.
378
379
 
379
380
  A tool call runs the CLI itself, so an agent and a person get identical parsing, output and
@@ -392,6 +393,10 @@ The server uses whatever credential this machine holds, so an agent acts as the
392
393
  ran `hardfin login`. Give an agent an API key through `HARDFIN_API_KEY` when it should act
393
394
  as an integration instead.
394
395
 
396
+ A call cannot set `--api-url`, `--api-key` or `--issuer-url`. Both tools refuse them, because
397
+ a call that chose the host could send the credential to it. Set `HARDFIN_API_URL`,
398
+ `HARDFIN_API_KEY` or `HARDFIN_ISSUER_URL` where the server is started.
399
+
395
400
  ## Diagnosing a problem
396
401
 
397
402
  `hardfin status` prints everything a support request needs, and is the first thing to send.
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ee01278c-9a53-5677-8abd-c28911112932")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b40d6c93-b462-5248-a0a0-bbc0f5456ba1")}catch(e){}}();
4
4
  import module from "node:module";
5
5
  //#region src/cli.ts
6
6
  module.enableCompileCache?.();
@@ -14,7 +14,7 @@ process.on("unhandledRejection", (error) => void toCrash(error));
14
14
  try {
15
15
  const answer = await toQuickAnswer(process.argv.slice(2));
16
16
  if (answer === void 0) {
17
- const { toCli, toParseExitCode, wasReportAttempted } = await import("./program-d7dXAbE4.js");
17
+ const { toCli, toParseExitCode, wasReportAttempted } = await import("./program-C38xnnim.js");
18
18
  try {
19
19
  await toCli().parseAsync(process.argv);
20
20
  } catch (error) {
@@ -33,7 +33,7 @@ async function toCrash(error) {
33
33
  isCrashing = true;
34
34
  process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
35
35
  try {
36
- const { toReported } = await import("./program-d7dXAbE4.js");
36
+ const { toReported } = await import("./program-C38xnnim.js");
37
37
  await toReported(error, false, void 0, "fatal");
38
38
  } finally {
39
39
  process.exit(1);
@@ -51,4 +51,4 @@ async function toQuickAnswer(args) {
51
51
  export {};
52
52
 
53
53
  //# sourceMappingURL=cli.js.map
54
- //# debugId=ee01278c-9a53-5677-8abd-c28911112932
54
+ //# debugId=b40d6c93-b462-5248-a0a0-bbc0f5456ba1
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="18ff3f0f-09fd-501c-9ccd-2c32855efa8b")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="23617f68-b6d8-5249-bca7-f5585fdb94f2")}catch(e){}}();
4
4
  import { a as defineCommand, i as GLOBAL_FLAGS, r as ExitCode, t as toInvocation } from "./scrub-BN3gyBpR.js";
5
5
  import { n as toCompletion, t as toAnswer$1 } from "./completion-tree-rR_Rmeij.js";
6
6
  import { createRequire } from "node:module";
@@ -15,6 +15,114 @@ import { createInterface } from "node:readline/promises";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createServer } from "node:http";
17
17
  import { createInterface as createInterface$1 } from "node:readline";
18
+ //#region src/output/writer.ts
19
+ /**
20
+ * toText reads a value a caller supplied, which arrives typed as unknown. An object would
21
+ * otherwise reach a request as the text "[object Object]".
22
+ */
23
+ function toText(value) {
24
+ if (typeof value === "string") return value;
25
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
26
+ if (value === void 0 || value === null) return "";
27
+ return JSON.stringify(value);
28
+ }
29
+ /** writeData prints a command's result on stdout. */
30
+ function writeData(value) {
31
+ if (typeof value === "string") {
32
+ process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
33
+ return;
34
+ }
35
+ process.stdout.write(`${JSON.stringify(value ?? null, null, 2)}\n`);
36
+ }
37
+ /** writeFailure prints why a command failed on stderr, with a code only a machine reading JSON sees. */
38
+ function writeFailure(code, message, isJSON, errors, requestId, status) {
39
+ if (!isJSON) {
40
+ process.stderr.write(`error: ${message}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
41
+ for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${entry.error} (${entry.statusCode})\n`);
42
+ if (requestId) process.stderr.write(` request ${requestId}\n`);
43
+ return;
44
+ }
45
+ const payload = {
46
+ code,
47
+ message,
48
+ errors: errors ?? [{
49
+ error: message,
50
+ statusCode: 0
51
+ }],
52
+ requestId: requestId ?? null,
53
+ status: status ?? null
54
+ };
55
+ process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
56
+ }
57
+ /** isJSONOutput decides whether this invocation prints machine-readable output. */
58
+ function isJSONOutput(flags) {
59
+ if (flags["json"] === true) return true;
60
+ return !process.stdout.isTTY;
61
+ }
62
+ /** version is what this build reports, read from the published package manifest. */
63
+ const version$1 = createRequire(import.meta.url)("../package.json").version;
64
+ //#endregion
65
+ //#region src/cli/parser.ts
66
+ /** toParser builds the parser from the registry, and hands whatever it parses to onRun. */
67
+ function toParser(entries, onRun, isSilent = false) {
68
+ const program = new Command();
69
+ program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").enablePositionalOptions();
70
+ toQuietParser(program, isSilent);
71
+ for (const flag of GLOBAL_FLAGS) program.option(`--${flag.name} <${flag.valueName}>`, flag.description);
72
+ for (const command of entries) program.addCommand(toProgram(program, command, onRun, isSilent), { hidden: command.hidden });
73
+ return program;
74
+ }
75
+ /** toProgram wires one registry command into the parser. */
76
+ function toProgram(root, command, onRun, isSilent) {
77
+ const program = new Command(command.name).summary(command.summary).description(command.description ?? command.summary);
78
+ toQuietParser(program, isSilent);
79
+ for (const argument of command.arguments) {
80
+ const name = argument.variadic ? `${argument.name}...` : argument.name;
81
+ program.argument(argument.required ? `<${name}>` : `[${name}]`, argument.description);
82
+ }
83
+ for (const flag of command.flags) {
84
+ const short = flag.short ? `-${flag.short}, ` : "";
85
+ const value = flag.valueName ? ` <${flag.valueName}>` : "";
86
+ const option = new Option(`${short}--${flag.name}${value}`, flag.description);
87
+ if (flag.negatable) program.addOption(new Option(`--no-${flag.name}`, `${flag.description}, turned off`));
88
+ if (flag.repeatable) option.argParser(collect);
89
+ if (flag.defaultValue !== void 0) option.default(flag.defaultValue);
90
+ program.addOption(option);
91
+ }
92
+ for (const example of command.examples) program.addHelpText("after", `\n${example.description}:\n $ ${example.command}`);
93
+ for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(root, subcommand, onRun, isSilent), { hidden: subcommand.hidden });
94
+ if (!command.run) return program;
95
+ program.action((...parsed) => {
96
+ const flags = parsed[parsed.length - 2] ?? {};
97
+ return onRun({
98
+ command,
99
+ args: parsed.slice(0, parsed.length - 2).flatMap(toArgumentList),
100
+ flags,
101
+ globals: root.opts()
102
+ });
103
+ });
104
+ return program;
105
+ }
106
+ /**
107
+ * toQuietParser has the parser hand back its mistakes, so they are answered like any other
108
+ * failure. A silent parser also keeps its help and version to itself, for a caller that only
109
+ * wants to know what a command line means.
110
+ */
111
+ function toQuietParser(program, isSilent) {
112
+ program.exitOverride().configureOutput({ outputError: () => void 0 });
113
+ if (isSilent) program.configureOutput({
114
+ writeOut: () => void 0,
115
+ writeErr: () => void 0
116
+ });
117
+ }
118
+ function toArgumentList(value) {
119
+ if (Array.isArray(value)) return value.map(String);
120
+ return value === void 0 ? [] : [toText(value)];
121
+ }
122
+ function collect(value, previous) {
123
+ return [...previous ?? [], value];
124
+ }
125
+ //#endregion
18
126
  //#region src/report/expected.ts
19
127
  /**
20
128
  * ExpectedFailure is a failure the CLI anticipated: the person's input, their network, their
@@ -474,51 +582,6 @@ function keep(issuer, document) {
474
582
  } catch {}
475
583
  }
476
584
  //#endregion
477
- //#region src/output/writer.ts
478
- /**
479
- * toText reads a value a caller supplied, which arrives typed as unknown. An object would
480
- * otherwise reach a request as the text "[object Object]".
481
- */
482
- function toText(value) {
483
- if (typeof value === "string") return value;
484
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
485
- if (value === void 0 || value === null) return "";
486
- return JSON.stringify(value);
487
- }
488
- /** writeData prints a command's result on stdout. */
489
- function writeData(value) {
490
- if (typeof value === "string") {
491
- process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
492
- return;
493
- }
494
- process.stdout.write(`${JSON.stringify(value ?? null, null, 2)}\n`);
495
- }
496
- /** writeFailure prints why a command failed on stderr, with a code only a machine reading JSON sees. */
497
- function writeFailure(code, message, isJSON, errors, requestId, status) {
498
- if (!isJSON) {
499
- process.stderr.write(`error: ${message}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
500
- for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${entry.error} (${entry.statusCode})\n`);
501
- if (requestId) process.stderr.write(` request ${requestId}\n`);
502
- return;
503
- }
504
- const payload = {
505
- code,
506
- message,
507
- errors: errors ?? [{
508
- error: message,
509
- statusCode: 0
510
- }],
511
- requestId: requestId ?? null,
512
- status: status ?? null
513
- };
514
- process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
515
- }
516
- /** isJSONOutput decides whether this invocation prints machine-readable output. */
517
- function isJSONOutput(flags) {
518
- if (flags["json"] === true) return true;
519
- return !process.stdout.isTTY;
520
- }
521
- //#endregion
522
585
  //#region src/auth/grant.ts
523
586
  const TokenResponse = z.looseObject({
524
587
  access_token: z.string(),
@@ -954,8 +1017,6 @@ function toErrorCode(error) {
954
1017
  if (error instanceof LockHeld) return "busy";
955
1018
  return "internal_error";
956
1019
  }
957
- /** version is what this build reports, read from the published package manifest. */
958
- const version$1 = createRequire(import.meta.url)("../package.json").version;
959
1020
  //#endregion
960
1021
  //#region src/command/agent-guide.ts
961
1022
  const agentGuideCommand = defineCommand({
@@ -2546,68 +2607,339 @@ async function runLogout(input) {
2546
2607
  });
2547
2608
  return ExitCode.OK;
2548
2609
  }
2549
- const OFF = /* @__PURE__ */ new Set([
2550
- "0",
2551
- "false",
2552
- "off",
2553
- "no"
2554
- ]);
2610
+ //#endregion
2611
+ //#region src/command/operation.ts
2555
2612
  /**
2556
- * toTelemetry decides whether defects are reported. A person's opt out wins over everything,
2557
- * and a CLI run from a checkout reports only when its developer asks it to.
2613
+ * toEnum takes a value in any case, as the API does, and answers the spelling the document
2614
+ * lists, which is what a response always uses.
2558
2615
  */
2559
- function toTelemetry(env = process.env, isCheckout = isRunFromCheckout()) {
2560
- const dsn = env["HARDFIN_SENTRY_DSN"] || "https://a4819e652e9fa63c0ab67e9febe44565@o1388102.ingest.us.sentry.io/4512176532684800";
2561
- const base = {
2562
- dsn,
2563
- environment: isCheckout ? "local" : version$1.includes("-dev.") ? "preview" : "production",
2564
- release: toRelease(version$1)
2565
- };
2566
- if (env["DO_NOT_TRACK"] && !OFF.has(env["DO_NOT_TRACK"].toLowerCase())) return {
2567
- ...base,
2568
- enabled: false,
2569
- from: "DO_NOT_TRACK"
2570
- };
2571
- const asked = env["HARDFIN_TELEMETRY"]?.toLowerCase();
2572
- if (asked !== void 0 && OFF.has(asked)) return {
2573
- ...base,
2574
- enabled: false,
2575
- from: "HARDFIN_TELEMETRY"
2576
- };
2577
- if (!isDsn(dsn)) return {
2578
- ...base,
2579
- enabled: false,
2580
- from: "HARDFIN_SENTRY_DSN is not a valid DSN"
2581
- };
2582
- if (asked !== void 0) return {
2583
- ...base,
2584
- enabled: true,
2585
- from: "HARDFIN_TELEMETRY"
2586
- };
2587
- return isCheckout ? {
2588
- ...base,
2589
- enabled: false,
2590
- from: "run from a checkout"
2591
- } : {
2592
- ...base,
2593
- enabled: true,
2594
- from: "default"
2595
- };
2616
+ function toEnum(values) {
2617
+ return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
2596
2618
  }
2597
- /**
2598
- * isRunFromCheckout answers whether this CLI runs from a clone of its repository, as npm link
2599
- * and node dist/cli.js do. The published package holds dist and nothing beside it.
2600
- */
2601
- function isRunFromCheckout() {
2602
- return existsSync(fileURLToPath(new URL("../src", import.meta.url)));
2619
+ const UNSET_FLAG = {
2620
+ name: "unset",
2621
+ description: "A field to clear, named as its flag is, repeatable",
2622
+ valueName: "field",
2623
+ repeatable: true,
2624
+ schema: z.array(z.string())
2625
+ };
2626
+ const INPUT_FLAG = {
2627
+ name: "input",
2628
+ description: "A file holding the JSON request body, or - for standard input. A flag given as well replaces its field",
2629
+ valueName: "path",
2630
+ schema: z.string()
2631
+ };
2632
+ const FILE_FLAG = {
2633
+ name: "file",
2634
+ description: "The file to upload",
2635
+ valueName: "path",
2636
+ schema: z.string()
2637
+ };
2638
+ const OUTPUT_FLAG = {
2639
+ name: "output",
2640
+ description: "Where to write the file, or - for standard output",
2641
+ valueName: "path",
2642
+ schema: z.string()
2643
+ };
2644
+ const JSON_FLAG = {
2645
+ name: "json",
2646
+ description: "Print machine-readable output, which is the default when stdout is not a terminal",
2647
+ schema: z.boolean()
2648
+ };
2649
+ /** defineOperation turns one endpoint into the command that calls it. */
2650
+ function defineOperation(operation) {
2651
+ const flags = [
2652
+ ...operation.queryFlags,
2653
+ ...operation.bodyFlags,
2654
+ ...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
2655
+ ...operation.bodyFlags.length > 0 ? [INPUT_FLAG] : [],
2656
+ ...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
2657
+ ...operation.downloads ? [OUTPUT_FLAG] : [],
2658
+ JSON_FLAG
2659
+ ];
2660
+ return {
2661
+ name: operation.name,
2662
+ summary: operation.summary,
2663
+ description: operation.description ?? operation.summary,
2664
+ arguments: operation.pathParameters,
2665
+ flags,
2666
+ examples: operation.example ? [{
2667
+ description: operation.summary,
2668
+ command: operation.example
2669
+ }] : [],
2670
+ endpoint: {
2671
+ method: operation.method,
2672
+ path: operation.path
2673
+ },
2674
+ run: (input) => runOperation(operation, input)
2675
+ };
2603
2676
  }
2604
- /** isDsn answers whether a DSN can be read as the URL of a Sentry project. */
2605
- function isDsn(dsn) {
2606
- try {
2607
- return new URL(dsn).host !== "";
2608
- } catch {
2609
- return false;
2610
- }
2677
+ async function runOperation(operation, input) {
2678
+ const path = toPath(operation, input.args);
2679
+ if (path === void 0) {
2680
+ writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
2681
+ return ExitCode.USAGE;
2682
+ }
2683
+ const given = toGivenBody(input.flags["input"]);
2684
+ if (given instanceof InputFailure) {
2685
+ writeFailure("invalid_input", given.message, input.isJSON);
2686
+ return ExitCode.USAGE;
2687
+ }
2688
+ const missing = operation.bodyFlags.concat(operation.upload?.fields ?? []).filter((flag) => flag.required && input.flags[toOptionKey(flag.name)] === void 0 && !hasPath(given, flag.jsonPath));
2689
+ if (missing.length > 0) {
2690
+ writeFailure("missing_flag", `this command needs ${missing.map((flag) => `--${flag.name}`).join(", ")}`, input.isJSON);
2691
+ return ExitCode.USAGE;
2692
+ }
2693
+ const body = toBody(operation, input.flags, given);
2694
+ if (body instanceof Error) {
2695
+ writeFailure("invalid_flag", body.message, input.isJSON);
2696
+ return ExitCode.USAGE;
2697
+ }
2698
+ if (operation.bodyFlags.length > 0 && body === void 0) {
2699
+ writeFailure("empty_request", `this command changes nothing unless a flag is given, such as ${operation.bodyFlags.slice(0, 3).map((flag) => `--${flag.name}`).join(", ")}. Run it with --help for the rest`, input.isJSON);
2700
+ return ExitCode.USAGE;
2701
+ }
2702
+ let form;
2703
+ if (operation.upload) {
2704
+ const built = await toForm(operation.upload, input.flags);
2705
+ if (built instanceof Error) {
2706
+ writeFailure("invalid_input", built.message, input.isJSON);
2707
+ return ExitCode.USAGE;
2708
+ }
2709
+ form = built;
2710
+ }
2711
+ try {
2712
+ const envelope = await request$1({
2713
+ apiUrl: input.resolved.settings.apiUrl,
2714
+ credential: await toRequestCredential(input.resolved.settings, input.resolved.sources),
2715
+ method: operation.method,
2716
+ path,
2717
+ query: toQuery(operation, input.flags),
2718
+ body,
2719
+ form,
2720
+ downloads: operation.downloads
2721
+ });
2722
+ if (operation.downloads) return toWritten(envelope.data, input);
2723
+ writeData(envelope.data);
2724
+ return ExitCode.OK;
2725
+ } catch (error) {
2726
+ if (error instanceof NoCredential) {
2727
+ writeFailure("not_signed_in", error.message, input.isJSON);
2728
+ return ExitCode.NOT_AUTHENTICATED;
2729
+ }
2730
+ if (error instanceof RequestFailure) {
2731
+ writeFailure(toStatusCode(error.status), error.message, input.isJSON, error.errors, error.requestId, error.status);
2732
+ return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
2733
+ }
2734
+ throw error;
2735
+ }
2736
+ }
2737
+ /**
2738
+ * toWritten puts a downloaded file where it was asked for. A terminal is never written to,
2739
+ * because a person would otherwise have their session filled with a file's bytes.
2740
+ */
2741
+ function toWritten(download, input) {
2742
+ const asked = input.flags["output"];
2743
+ const path = typeof asked === "string" ? asked : download.fileName ?? "-";
2744
+ if (path === "-") {
2745
+ if (process.stdout.isTTY) {
2746
+ writeFailure("output_required", "this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
2747
+ return ExitCode.USAGE;
2748
+ }
2749
+ process.stdout.write(download.bytes);
2750
+ return ExitCode.OK;
2751
+ }
2752
+ writeFileSync(path, download.bytes);
2753
+ writeData({
2754
+ written: path,
2755
+ bytes: download.bytes.length,
2756
+ contentType: download.contentType
2757
+ });
2758
+ return ExitCode.OK;
2759
+ }
2760
+ /** toPath fills the path template from the positional arguments, in order. */
2761
+ function toPath(operation, args) {
2762
+ if (args.length !== operation.pathParameters.length) return;
2763
+ let path = operation.path;
2764
+ for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
2765
+ return path;
2766
+ }
2767
+ /** toQuery carries only the flags this invocation actually set. */
2768
+ function toQuery(operation, flags) {
2769
+ const query = new URLSearchParams();
2770
+ for (const flag of operation.queryFlags) {
2771
+ const value = flags[toOptionKey(flag.name)];
2772
+ if (value === void 0) continue;
2773
+ const parsed = flag.schema.safeParse(value);
2774
+ const carried = parsed.success ? parsed.data : value;
2775
+ for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
2776
+ }
2777
+ return query;
2778
+ }
2779
+ /** toOptionKey names the parsed flag, which the parser reports in camel case. */
2780
+ function toOptionKey(name) {
2781
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
2782
+ }
2783
+ /** toForm builds the file part and its fields, which an upload endpoint takes. */
2784
+ async function toForm(upload, flags) {
2785
+ const path = flags["file"];
2786
+ if (typeof path !== "string") return /* @__PURE__ */ new Error("this command needs --file, the file to upload");
2787
+ const form = new FormData();
2788
+ try {
2789
+ form.append(upload.filePart, await openAsBlob(path), basename(path));
2790
+ } catch {
2791
+ return /* @__PURE__ */ new Error(`${path} cannot be read`);
2792
+ }
2793
+ for (const field of upload.fields) {
2794
+ const value = flags[toOptionKey(field.name)];
2795
+ if (value !== void 0) {
2796
+ const parsed = field.schema.safeParse(value);
2797
+ form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
2798
+ }
2799
+ }
2800
+ return form;
2801
+ }
2802
+ /** toGivenBody reads the --input body, which a generated command takes only as a JSON object. */
2803
+ function toGivenBody(source) {
2804
+ if (typeof source !== "string") return;
2805
+ const read = toJSONInput(source);
2806
+ if (read instanceof InputFailure) return read;
2807
+ if (typeof read !== "object" || read === null || Array.isArray(read)) return new InputFailure(`the body in ${source === "-" ? "standard input" : source} must be a JSON object, such as {"name": "Acme"}`);
2808
+ return read;
2809
+ }
2810
+ /** hasPath reports whether a body already holds a value at a field's path. */
2811
+ function hasPath(body, path) {
2812
+ let holder = body;
2813
+ for (const name of path) {
2814
+ if (typeof holder !== "object" || holder === null || !(name in holder)) return false;
2815
+ holder = holder[name];
2816
+ }
2817
+ return true;
2818
+ }
2819
+ /** toBody builds the request body from the --input body and the flags, a flag winning over its field. */
2820
+ function toBody(operation, flags, given) {
2821
+ const body = given ? structuredClone(given) : {};
2822
+ let hasField = given !== void 0;
2823
+ for (const flag of operation.bodyFlags) {
2824
+ const value = flags[toOptionKey(flag.name)];
2825
+ if (value === void 0) continue;
2826
+ if (flag.element) {
2827
+ const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
2828
+ if (elements instanceof Error) return elements;
2829
+ set(body, flag.jsonPath, elements);
2830
+ hasField = true;
2831
+ continue;
2832
+ }
2833
+ const parsed = flag.schema.safeParse(value);
2834
+ set(body, flag.jsonPath, parsed.success ? parsed.data : value);
2835
+ hasField = true;
2836
+ }
2837
+ const cleared = toCleared(operation, flags, body);
2838
+ if (cleared instanceof Error) return cleared;
2839
+ return hasField || cleared ? body : void 0;
2840
+ }
2841
+ /** toCleared sends null for each field named by --unset, which is how a field is cleared. */
2842
+ function toCleared(operation, flags, body) {
2843
+ const named = flags["unset"];
2844
+ const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
2845
+ for (const name of names) {
2846
+ const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
2847
+ if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
2848
+ if (!flag.nullable) return /* @__PURE__ */ new Error(`--${flag.name} cannot be cleared, because the API does not accept null for it`);
2849
+ set(body, flag.jsonPath, null);
2850
+ }
2851
+ return names.length > 0;
2852
+ }
2853
+ /** toElements reads the key=value pairs a repeated flag carries for one array element. */
2854
+ function toElements(flag, values) {
2855
+ const elements = [];
2856
+ for (const value of values) {
2857
+ const element = {};
2858
+ for (const pair of value.split(",")) {
2859
+ const split = pair.indexOf("=");
2860
+ if (split < 1) return /* @__PURE__ */ new Error(`--${flag.name} takes ${flag.element?.join("=, ") ?? ""}=, such as --${flag.name} ${flag.element?.[0] ?? "field"}=value. A value cannot contain a comma`);
2861
+ const key = pair.slice(0, split).trim();
2862
+ if (flag.element && !flag.element.includes(key)) return /* @__PURE__ */ new Error(`--${flag.name} has no field ${key}. It takes ${flag.element.join(", ")}`);
2863
+ if (key in element) return /* @__PURE__ */ new Error(`--${flag.name} names ${key} twice in one element, so which one it takes is not clear`);
2864
+ const value = pair.slice(split + 1).trim();
2865
+ if (flag.elementUuids?.includes(key) && !z.uuid().safeParse(value).success) return /* @__PURE__ */ new Error(`--${flag.name} needs a UUID for ${key}, such as 3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90, and ${value || "nothing"} is not one`);
2866
+ element[key] = value;
2867
+ }
2868
+ elements.push(element);
2869
+ }
2870
+ return elements;
2871
+ }
2872
+ /** set writes a value at its path, building the objects a nested field needs. */
2873
+ function set(body, path, value) {
2874
+ let holder = body;
2875
+ for (const name of path.slice(0, -1)) {
2876
+ holder[name] = holder[name] ?? {};
2877
+ holder = holder[name];
2878
+ }
2879
+ holder[path[path.length - 1] ?? ""] = value;
2880
+ }
2881
+ const OFF = /* @__PURE__ */ new Set([
2882
+ "0",
2883
+ "false",
2884
+ "off",
2885
+ "no"
2886
+ ]);
2887
+ /**
2888
+ * toTelemetry decides whether defects are reported. A person's opt out wins over everything,
2889
+ * and a CLI run from a checkout reports only when its developer asks it to.
2890
+ */
2891
+ function toTelemetry(env = process.env, isCheckout = isRunFromCheckout()) {
2892
+ const dsn = env["HARDFIN_SENTRY_DSN"] || "https://a4819e652e9fa63c0ab67e9febe44565@o1388102.ingest.us.sentry.io/4512176532684800";
2893
+ const base = {
2894
+ dsn,
2895
+ environment: isCheckout ? "local" : version$1.includes("-dev.") ? "preview" : "production",
2896
+ release: toRelease(version$1)
2897
+ };
2898
+ if (env["DO_NOT_TRACK"] && !OFF.has(env["DO_NOT_TRACK"].toLowerCase())) return {
2899
+ ...base,
2900
+ enabled: false,
2901
+ from: "DO_NOT_TRACK"
2902
+ };
2903
+ const asked = env["HARDFIN_TELEMETRY"]?.toLowerCase();
2904
+ if (asked !== void 0 && OFF.has(asked)) return {
2905
+ ...base,
2906
+ enabled: false,
2907
+ from: "HARDFIN_TELEMETRY"
2908
+ };
2909
+ if (!isDsn(dsn)) return {
2910
+ ...base,
2911
+ enabled: false,
2912
+ from: "HARDFIN_SENTRY_DSN is not a valid DSN"
2913
+ };
2914
+ if (asked !== void 0) return {
2915
+ ...base,
2916
+ enabled: true,
2917
+ from: "HARDFIN_TELEMETRY"
2918
+ };
2919
+ return isCheckout ? {
2920
+ ...base,
2921
+ enabled: false,
2922
+ from: "run from a checkout"
2923
+ } : {
2924
+ ...base,
2925
+ enabled: true,
2926
+ from: "default"
2927
+ };
2928
+ }
2929
+ /**
2930
+ * isRunFromCheckout answers whether this CLI runs from a clone of its repository, as npm link
2931
+ * and node dist/cli.js do. The published package holds dist and nothing beside it.
2932
+ */
2933
+ function isRunFromCheckout() {
2934
+ return existsSync(fileURLToPath(new URL("../src", import.meta.url)));
2935
+ }
2936
+ /** isDsn answers whether a DSN can be read as the URL of a Sentry project. */
2937
+ function isDsn(dsn) {
2938
+ try {
2939
+ return new URL(dsn).host !== "";
2940
+ } catch {
2941
+ return false;
2942
+ }
2611
2943
  }
2612
2944
  /**
2613
2945
  * toRelease names a version as Sentry knows it. Sentry refuses a slash in a release name, so
@@ -3178,26 +3510,36 @@ const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
3178
3510
  "logout",
3179
3511
  "mcp"
3180
3512
  ]);
3181
- /** The global options, which sit before a command and take a value of their own. */
3182
- const GLOBAL_OPTIONS_WITH_VALUES = new Set(GLOBAL_FLAGS.filter((flag) => flag.valueName).map((flag) => `--${flag.name}`));
3183
3513
  /**
3184
- * toCommandName reads which command a call names. A global option sits before it, so
3185
- * reading the first argument alone would let --issuer-url x logout through.
3514
+ * toParsed asks the CLI's own parser what a call would run, without running it. The server
3515
+ * decides from this answer and never from the words themselves, because a word such as
3516
+ * --help may be a flag's value, and a flag given twice takes its last value.
3186
3517
  */
3187
- function toCommandName(args) {
3188
- for (let index = 0; index < args.length; index += 1) {
3189
- const argument = args[index] ?? "";
3190
- if (GLOBAL_OPTIONS_WITH_VALUES.has(argument)) {
3191
- index += 1;
3192
- continue;
3193
- }
3194
- if (argument.startsWith("-")) continue;
3195
- return argument;
3518
+ function toParsed(args, commands) {
3519
+ let ran;
3520
+ const parser = toParser(commands, (parsed) => {
3521
+ ran = parsed;
3522
+ }, true);
3523
+ try {
3524
+ parser.parse(args, { from: "user" });
3525
+ } catch (error) {
3526
+ if (!(error instanceof CommanderError)) throw error;
3196
3527
  }
3528
+ return {
3529
+ ran,
3530
+ globals: parser.opts()
3531
+ };
3197
3532
  }
3198
- /** toIsAskingForHelp reports whether a call only wants to read about a command. */
3199
- function toIsAskingForHelp(args) {
3200
- return args.includes("--help") || args.includes("-h") || args[0] === "help";
3533
+ /**
3534
+ * toFixedOption names a global option a call tries to set. Where the CLI calls and what it
3535
+ * calls with are decided where the server is started, or a call could send the credential
3536
+ * to another host.
3537
+ */
3538
+ function toFixedOption(parsed) {
3539
+ const flag = GLOBAL_FLAGS.find((entry) => parsed.globals[toOptionKey(entry.name)] !== void 0);
3540
+ if (!flag) return;
3541
+ const variable = `HARDFIN_${flag.name.toUpperCase().replaceAll("-", "_")}`;
3542
+ return `--${flag.name} cannot be set in a call, because it is fixed where this server is started. Leave it out, or set ${variable} in the server's environment`;
3201
3543
  }
3202
3544
  async function toToolResult(request, commands, run) {
3203
3545
  const params = request.params;
@@ -3210,9 +3552,12 @@ async function toToolResult(request, commands, run) {
3210
3552
  const refused = toRefusedArgument(args);
3211
3553
  if (refused) return toError(refused);
3212
3554
  const asked = args;
3213
- const command = toCommandName(asked) ?? "";
3214
- if (REFUSED_COMMANDS.has(command) && !toIsAskingForHelp(asked)) return toError(`${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`);
3215
- if (name === READ_TOOL.name && toAccess(asked, commands) === "write") return toError(`this command changes data, so it runs through ${WRITE_TOOL.name}`);
3555
+ const parsed = toParsed(asked, commands);
3556
+ const fixed = toFixedOption(parsed);
3557
+ if (fixed) return toError(fixed);
3558
+ const command = parsed.ran?.command.name ?? "";
3559
+ if (REFUSED_COMMANDS.has(command)) return toError(`${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`);
3560
+ if (name === READ_TOOL.name && toAccessOf(parsed) === "write") return toError(`this command changes data, so it runs through ${WRITE_TOOL.name}`);
3216
3561
  const outcome = await run(asked, called?.input);
3217
3562
  return {
3218
3563
  content: [{
@@ -3235,46 +3580,14 @@ function toError(text) {
3235
3580
  const LOCAL_READS = /* @__PURE__ */ new Set([
3236
3581
  "status",
3237
3582
  "config",
3238
- "agent-guide",
3239
- "help"
3583
+ "agent-guide"
3240
3584
  ]);
3241
- /**
3242
- * toAccess decides whether a call reads or changes data. A generated command answers by its
3243
- * endpoint's method, hardfin api by the method it was given, and anything it cannot place is
3244
- * treated as a change, so it is asked about.
3245
- */
3246
- function toAccess(args, commands) {
3247
- if (toIsAskingForHelp(args)) return "read";
3248
- let level = commands;
3249
- let found;
3250
- for (let index = 0; index < args.length; index += 1) {
3251
- const word = args[index] ?? "";
3252
- if (GLOBAL_OPTIONS_WITH_VALUES.has(word)) {
3253
- index += 1;
3254
- continue;
3255
- }
3256
- if (word.startsWith("-")) continue;
3257
- const next = level.find((entry) => entry.name === word);
3258
- if (!next) break;
3259
- found = next;
3260
- level = next.subcommands ?? [];
3261
- if (level.length === 0) break;
3262
- }
3263
- if (!found) return "read";
3264
- if (found.endpoint) return found.endpoint.method === "GET" ? "read" : "write";
3265
- if (found.name === "api") return toApiMethod(args) === "GET" ? "read" : "write";
3266
- if (LOCAL_READS.has(found.name) || (found.subcommands?.length ?? 0) > 0) return "read";
3267
- return "write";
3268
- }
3269
- /** toApiMethod reads the method hardfin api was given, which is GET when none was. */
3270
- function toApiMethod(args) {
3271
- for (let index = 0; index < args.length; index += 1) {
3272
- const word = args[index] ?? "";
3273
- if (word === "-X" || word === "--method") return (args[index + 1] ?? "GET").toUpperCase();
3274
- if (word.startsWith("--method=")) return word.slice(9).toUpperCase();
3275
- if (word.startsWith("-X") && word.length > 2) return word.slice(2).toUpperCase();
3276
- }
3277
- return "GET";
3585
+ function toAccessOf(parsed) {
3586
+ if (!parsed.ran) return "read";
3587
+ const { command, flags } = parsed.ran;
3588
+ if (command.endpoint) return command.endpoint.method === "GET" ? "read" : "write";
3589
+ if (command.name === "api") return toText(flags["method"] ?? "GET").toUpperCase() === "GET" ? "read" : "write";
3590
+ return LOCAL_READS.has(command.name) ? "read" : "write";
3278
3591
  }
3279
3592
  /** The most one argument can hold, which Linux refuses past 128 KiB. */
3280
3593
  const ARGUMENT_BYTES = 131071;
@@ -3376,363 +3689,92 @@ async function serve(commands, run = toCliRunner()) {
3376
3689
  }
3377
3690
  await Promise.all(answering);
3378
3691
  }
3379
- /** toAnswer replies to one line, and never rejects, because a rejection ends the server. */
3380
- async function toAnswer(line, commands, run) {
3381
- let response;
3382
- let failure;
3383
- try {
3384
- response = await toResponse(toRequest(line), commands, run);
3385
- } catch (error) {
3386
- response = error instanceof ProtocolFailure ? toErrorResponse(error.id, error.code, error.message) : toErrorResponse(toRequestId(line), -32603, error instanceof Error ? error.message : String(error));
3387
- failure = error instanceof ProtocolFailure ? void 0 : error;
3388
- }
3389
- if (response) write(`${JSON.stringify(response)}\n`);
3390
- if (failure !== void 0) await report(failure, {
3391
- level: "error",
3392
- mechanism: "mcp.request",
3393
- mode: "mcp",
3394
- invocation: {
3395
- command: "mcp",
3396
- flags: []
3397
- }
3398
- });
3399
- }
3400
- /** toRequest reads one line, refusing what the protocol does not allow to be a request. */
3401
- function toRequest(line) {
3402
- let parsed;
3403
- try {
3404
- parsed = JSON.parse(line);
3405
- } catch {
3406
- throw new ProtocolFailure(-32700, "this line is not JSON", null);
3407
- }
3408
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new ProtocolFailure(-32600, "a request is a JSON object, and this server takes one per line", null);
3409
- const request = parsed;
3410
- if (typeof request.method !== "string") throw new ProtocolFailure(-32600, "a request names a method", toAllowedId(request.id));
3411
- if (request.id !== void 0 && request.id !== null && typeof request.id !== "string" && typeof request.id !== "number") throw new ProtocolFailure(-32600, "an id is a string, a number, or null", null);
3412
- return request;
3413
- }
3414
- function toAllowedId(id) {
3415
- return typeof id === "string" || typeof id === "number" ? id : null;
3416
- }
3417
- function toErrorResponse(id, code, message) {
3418
- return {
3419
- jsonrpc: "2.0",
3420
- id,
3421
- error: {
3422
- code,
3423
- message
3424
- }
3425
- };
3426
- }
3427
- /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
3428
- function toRequestId(line) {
3429
- try {
3430
- return toAllowedId(JSON.parse(line).id);
3431
- } catch {
3432
- return null;
3433
- }
3434
- }
3435
- /** write puts a reply on stdout, and stops quietly when the client has gone. */
3436
- function write(text) {
3437
- try {
3438
- process.stdout.write(text);
3439
- } catch {}
3440
- }
3441
- //#endregion
3442
- //#region src/command/mcp.ts
3443
- const mcpCommand = defineCommand({
3444
- name: "mcp",
3445
- summary: "Serve this CLI to an agent over the Model Context Protocol",
3446
- description: "Speaks the Model Context Protocol on standard input and output. It offers one tool, because every tool an agent is told about occupies its context for the whole session, and serves the commands as resources the agent reads only when it needs them.",
3447
- arguments: [],
3448
- flags: [{
3449
- name: "json",
3450
- description: "Accepted for consistency, and ignored, because the protocol decides the output",
3451
- schema: z.boolean()
3452
- }],
3453
- examples: [{
3454
- description: "Register with an agent",
3455
- command: "hardfin mcp"
3456
- }, {
3457
- description: "Add it to Claude Code",
3458
- command: "claude mcp add hardfin -- hardfin mcp"
3459
- }],
3460
- run: runMcp
3461
- });
3462
- async function runMcp(input) {
3463
- await serve(input.commands);
3464
- return ExitCode.OK;
3465
- }
3466
- //#endregion
3467
- //#region src/command/operation.ts
3468
- /**
3469
- * toEnum takes a value in any case, as the API does, and answers the spelling the document
3470
- * lists, which is what a response always uses.
3471
- */
3472
- function toEnum(values) {
3473
- return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
3474
- }
3475
- const UNSET_FLAG = {
3476
- name: "unset",
3477
- description: "A field to clear, named as its flag is, repeatable",
3478
- valueName: "field",
3479
- repeatable: true,
3480
- schema: z.array(z.string())
3481
- };
3482
- const INPUT_FLAG = {
3483
- name: "input",
3484
- description: "A file holding the JSON request body, or - for standard input. A flag given as well replaces its field",
3485
- valueName: "path",
3486
- schema: z.string()
3487
- };
3488
- const FILE_FLAG = {
3489
- name: "file",
3490
- description: "The file to upload",
3491
- valueName: "path",
3492
- schema: z.string()
3493
- };
3494
- const OUTPUT_FLAG = {
3495
- name: "output",
3496
- description: "Where to write the file, or - for standard output",
3497
- valueName: "path",
3498
- schema: z.string()
3499
- };
3500
- const JSON_FLAG = {
3501
- name: "json",
3502
- description: "Print machine-readable output, which is the default when stdout is not a terminal",
3503
- schema: z.boolean()
3504
- };
3505
- /** defineOperation turns one endpoint into the command that calls it. */
3506
- function defineOperation(operation) {
3507
- const flags = [
3508
- ...operation.queryFlags,
3509
- ...operation.bodyFlags,
3510
- ...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
3511
- ...operation.bodyFlags.length > 0 ? [INPUT_FLAG] : [],
3512
- ...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
3513
- ...operation.downloads ? [OUTPUT_FLAG] : [],
3514
- JSON_FLAG
3515
- ];
3516
- return {
3517
- name: operation.name,
3518
- summary: operation.summary,
3519
- description: operation.description ?? operation.summary,
3520
- arguments: operation.pathParameters,
3521
- flags,
3522
- examples: operation.example ? [{
3523
- description: operation.summary,
3524
- command: operation.example
3525
- }] : [],
3526
- endpoint: {
3527
- method: operation.method,
3528
- path: operation.path
3529
- },
3530
- run: (input) => runOperation(operation, input)
3531
- };
3532
- }
3533
- async function runOperation(operation, input) {
3534
- const path = toPath(operation, input.args);
3535
- if (path === void 0) {
3536
- writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
3537
- return ExitCode.USAGE;
3538
- }
3539
- const given = toGivenBody(input.flags["input"]);
3540
- if (given instanceof InputFailure) {
3541
- writeFailure("invalid_input", given.message, input.isJSON);
3542
- return ExitCode.USAGE;
3543
- }
3544
- const missing = operation.bodyFlags.concat(operation.upload?.fields ?? []).filter((flag) => flag.required && input.flags[toOptionKey(flag.name)] === void 0 && !hasPath(given, flag.jsonPath));
3545
- if (missing.length > 0) {
3546
- writeFailure("missing_flag", `this command needs ${missing.map((flag) => `--${flag.name}`).join(", ")}`, input.isJSON);
3547
- return ExitCode.USAGE;
3548
- }
3549
- const body = toBody(operation, input.flags, given);
3550
- if (body instanceof Error) {
3551
- writeFailure("invalid_flag", body.message, input.isJSON);
3552
- return ExitCode.USAGE;
3553
- }
3554
- if (operation.bodyFlags.length > 0 && body === void 0) {
3555
- writeFailure("empty_request", `this command changes nothing unless a flag is given, such as ${operation.bodyFlags.slice(0, 3).map((flag) => `--${flag.name}`).join(", ")}. Run it with --help for the rest`, input.isJSON);
3556
- return ExitCode.USAGE;
3557
- }
3558
- let form;
3559
- if (operation.upload) {
3560
- const built = await toForm(operation.upload, input.flags);
3561
- if (built instanceof Error) {
3562
- writeFailure("invalid_input", built.message, input.isJSON);
3563
- return ExitCode.USAGE;
3564
- }
3565
- form = built;
3566
- }
3692
+ /** toAnswer replies to one line, and never rejects, because a rejection ends the server. */
3693
+ async function toAnswer(line, commands, run) {
3694
+ let response;
3695
+ let failure;
3567
3696
  try {
3568
- const envelope = await request$1({
3569
- apiUrl: input.resolved.settings.apiUrl,
3570
- credential: await toRequestCredential(input.resolved.settings, input.resolved.sources),
3571
- method: operation.method,
3572
- path,
3573
- query: toQuery(operation, input.flags),
3574
- body,
3575
- form,
3576
- downloads: operation.downloads
3577
- });
3578
- if (operation.downloads) return toWritten(envelope.data, input);
3579
- writeData(envelope.data);
3580
- return ExitCode.OK;
3697
+ response = await toResponse(toRequest(line), commands, run);
3581
3698
  } catch (error) {
3582
- if (error instanceof NoCredential) {
3583
- writeFailure("not_signed_in", error.message, input.isJSON);
3584
- return ExitCode.NOT_AUTHENTICATED;
3585
- }
3586
- if (error instanceof RequestFailure) {
3587
- writeFailure(toStatusCode(error.status), error.message, input.isJSON, error.errors, error.requestId, error.status);
3588
- return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
3589
- }
3590
- throw error;
3699
+ response = error instanceof ProtocolFailure ? toErrorResponse(error.id, error.code, error.message) : toErrorResponse(toRequestId(line), -32603, error instanceof Error ? error.message : String(error));
3700
+ failure = error instanceof ProtocolFailure ? void 0 : error;
3591
3701
  }
3592
- }
3593
- /**
3594
- * toWritten puts a downloaded file where it was asked for. A terminal is never written to,
3595
- * because a person would otherwise have their session filled with a file's bytes.
3596
- */
3597
- function toWritten(download, input) {
3598
- const asked = input.flags["output"];
3599
- const path = typeof asked === "string" ? asked : download.fileName ?? "-";
3600
- if (path === "-") {
3601
- if (process.stdout.isTTY) {
3602
- writeFailure("output_required", "this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
3603
- return ExitCode.USAGE;
3702
+ if (response) write(`${JSON.stringify(response)}\n`);
3703
+ if (failure !== void 0) await report(failure, {
3704
+ level: "error",
3705
+ mechanism: "mcp.request",
3706
+ mode: "mcp",
3707
+ invocation: {
3708
+ command: "mcp",
3709
+ flags: []
3604
3710
  }
3605
- process.stdout.write(download.bytes);
3606
- return ExitCode.OK;
3607
- }
3608
- writeFileSync(path, download.bytes);
3609
- writeData({
3610
- written: path,
3611
- bytes: download.bytes.length,
3612
- contentType: download.contentType
3613
3711
  });
3614
- return ExitCode.OK;
3615
- }
3616
- /** toPath fills the path template from the positional arguments, in order. */
3617
- function toPath(operation, args) {
3618
- if (args.length !== operation.pathParameters.length) return;
3619
- let path = operation.path;
3620
- for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
3621
- return path;
3622
- }
3623
- /** toQuery carries only the flags this invocation actually set. */
3624
- function toQuery(operation, flags) {
3625
- const query = new URLSearchParams();
3626
- for (const flag of operation.queryFlags) {
3627
- const value = flags[toOptionKey(flag.name)];
3628
- if (value === void 0) continue;
3629
- const parsed = flag.schema.safeParse(value);
3630
- const carried = parsed.success ? parsed.data : value;
3631
- for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
3632
- }
3633
- return query;
3634
- }
3635
- /** toOptionKey names the parsed flag, which the parser reports in camel case. */
3636
- function toOptionKey(name) {
3637
- return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
3638
3712
  }
3639
- /** toForm builds the file part and its fields, which an upload endpoint takes. */
3640
- async function toForm(upload, flags) {
3641
- const path = flags["file"];
3642
- if (typeof path !== "string") return /* @__PURE__ */ new Error("this command needs --file, the file to upload");
3643
- const form = new FormData();
3713
+ /** toRequest reads one line, refusing what the protocol does not allow to be a request. */
3714
+ function toRequest(line) {
3715
+ let parsed;
3644
3716
  try {
3645
- form.append(upload.filePart, await openAsBlob(path), basename(path));
3717
+ parsed = JSON.parse(line);
3646
3718
  } catch {
3647
- return /* @__PURE__ */ new Error(`${path} cannot be read`);
3648
- }
3649
- for (const field of upload.fields) {
3650
- const value = flags[toOptionKey(field.name)];
3651
- if (value !== void 0) {
3652
- const parsed = field.schema.safeParse(value);
3653
- form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
3654
- }
3719
+ throw new ProtocolFailure(-32700, "this line is not JSON", null);
3655
3720
  }
3656
- return form;
3657
- }
3658
- /** toGivenBody reads the --input body, which a generated command takes only as a JSON object. */
3659
- function toGivenBody(source) {
3660
- if (typeof source !== "string") return;
3661
- const read = toJSONInput(source);
3662
- if (read instanceof InputFailure) return read;
3663
- if (typeof read !== "object" || read === null || Array.isArray(read)) return new InputFailure(`the body in ${source === "-" ? "standard input" : source} must be a JSON object, such as {"name": "Acme"}`);
3664
- return read;
3721
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new ProtocolFailure(-32600, "a request is a JSON object, and this server takes one per line", null);
3722
+ const request = parsed;
3723
+ if (typeof request.method !== "string") throw new ProtocolFailure(-32600, "a request names a method", toAllowedId(request.id));
3724
+ if (request.id !== void 0 && request.id !== null && typeof request.id !== "string" && typeof request.id !== "number") throw new ProtocolFailure(-32600, "an id is a string, a number, or null", null);
3725
+ return request;
3665
3726
  }
3666
- /** hasPath reports whether a body already holds a value at a field's path. */
3667
- function hasPath(body, path) {
3668
- let holder = body;
3669
- for (const name of path) {
3670
- if (typeof holder !== "object" || holder === null || !(name in holder)) return false;
3671
- holder = holder[name];
3672
- }
3673
- return true;
3727
+ function toAllowedId(id) {
3728
+ return typeof id === "string" || typeof id === "number" ? id : null;
3674
3729
  }
3675
- /** toBody builds the request body from the --input body and the flags, a flag winning over its field. */
3676
- function toBody(operation, flags, given) {
3677
- const body = given ? structuredClone(given) : {};
3678
- let hasField = given !== void 0;
3679
- for (const flag of operation.bodyFlags) {
3680
- const value = flags[toOptionKey(flag.name)];
3681
- if (value === void 0) continue;
3682
- if (flag.element) {
3683
- const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
3684
- if (elements instanceof Error) return elements;
3685
- set(body, flag.jsonPath, elements);
3686
- hasField = true;
3687
- continue;
3730
+ function toErrorResponse(id, code, message) {
3731
+ return {
3732
+ jsonrpc: "2.0",
3733
+ id,
3734
+ error: {
3735
+ code,
3736
+ message
3688
3737
  }
3689
- const parsed = flag.schema.safeParse(value);
3690
- set(body, flag.jsonPath, parsed.success ? parsed.data : value);
3691
- hasField = true;
3692
- }
3693
- const cleared = toCleared(operation, flags, body);
3694
- if (cleared instanceof Error) return cleared;
3695
- return hasField || cleared ? body : void 0;
3738
+ };
3696
3739
  }
3697
- /** toCleared sends null for each field named by --unset, which is how a field is cleared. */
3698
- function toCleared(operation, flags, body) {
3699
- const named = flags["unset"];
3700
- const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
3701
- for (const name of names) {
3702
- const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
3703
- if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
3704
- if (!flag.nullable) return /* @__PURE__ */ new Error(`--${flag.name} cannot be cleared, because the API does not accept null for it`);
3705
- set(body, flag.jsonPath, null);
3740
+ /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
3741
+ function toRequestId(line) {
3742
+ try {
3743
+ return toAllowedId(JSON.parse(line).id);
3744
+ } catch {
3745
+ return null;
3706
3746
  }
3707
- return names.length > 0;
3708
3747
  }
3709
- /** toElements reads the key=value pairs a repeated flag carries for one array element. */
3710
- function toElements(flag, values) {
3711
- const elements = [];
3712
- for (const value of values) {
3713
- const element = {};
3714
- for (const pair of value.split(",")) {
3715
- const split = pair.indexOf("=");
3716
- if (split < 1) return /* @__PURE__ */ new Error(`--${flag.name} takes ${flag.element?.join("=, ") ?? ""}=, such as --${flag.name} ${flag.element?.[0] ?? "field"}=value. A value cannot contain a comma`);
3717
- const key = pair.slice(0, split).trim();
3718
- if (flag.element && !flag.element.includes(key)) return /* @__PURE__ */ new Error(`--${flag.name} has no field ${key}. It takes ${flag.element.join(", ")}`);
3719
- if (key in element) return /* @__PURE__ */ new Error(`--${flag.name} names ${key} twice in one element, so which one it takes is not clear`);
3720
- const value = pair.slice(split + 1).trim();
3721
- if (flag.elementUuids?.includes(key) && !z.uuid().safeParse(value).success) return /* @__PURE__ */ new Error(`--${flag.name} needs a UUID for ${key}, such as 3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90, and ${value || "nothing"} is not one`);
3722
- element[key] = value;
3723
- }
3724
- elements.push(element);
3725
- }
3726
- return elements;
3748
+ /** write puts a reply on stdout, and stops quietly when the client has gone. */
3749
+ function write(text) {
3750
+ try {
3751
+ process.stdout.write(text);
3752
+ } catch {}
3727
3753
  }
3728
- /** set writes a value at its path, building the objects a nested field needs. */
3729
- function set(body, path, value) {
3730
- let holder = body;
3731
- for (const name of path.slice(0, -1)) {
3732
- holder[name] = holder[name] ?? {};
3733
- holder = holder[name];
3734
- }
3735
- holder[path[path.length - 1] ?? ""] = value;
3754
+ //#endregion
3755
+ //#region src/command/mcp.ts
3756
+ const mcpCommand = defineCommand({
3757
+ name: "mcp",
3758
+ summary: "Serve this CLI to an agent over the Model Context Protocol",
3759
+ description: "Speaks the Model Context Protocol on standard input and output. It offers one tool, because every tool an agent is told about occupies its context for the whole session, and serves the commands as resources the agent reads only when it needs them.",
3760
+ arguments: [],
3761
+ flags: [{
3762
+ name: "json",
3763
+ description: "Accepted for consistency, and ignored, because the protocol decides the output",
3764
+ schema: z.boolean()
3765
+ }],
3766
+ examples: [{
3767
+ description: "Register with an agent",
3768
+ command: "hardfin mcp"
3769
+ }, {
3770
+ description: "Add it to Claude Code",
3771
+ command: "claude mcp add hardfin -- hardfin mcp"
3772
+ }],
3773
+ run: runMcp
3774
+ });
3775
+ async function runMcp(input) {
3776
+ await serve(input.commands);
3777
+ return ExitCode.OK;
3736
3778
  }
3737
3779
  //#endregion
3738
3780
  //#region src/command/commands.ts
@@ -6428,43 +6470,9 @@ function toRejectedFlag(command, flags) {
6428
6470
  //#region src/cli/program.ts
6429
6471
  /** toCli builds the parser from the registry, which is what every surface reads. */
6430
6472
  function toCli(entries = commands) {
6431
- const program = new Command();
6432
- program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").enablePositionalOptions();
6433
- toQuietParser(program);
6434
- for (const flag of GLOBAL_FLAGS) program.option(`--${flag.name} <${flag.valueName}>`, flag.description);
6435
- for (const command of entries) program.addCommand(toProgram(program, command), { hidden: command.hidden });
6436
- return program;
6437
- }
6438
- /** toProgram wires one registry command into the parser. */
6439
- function toProgram(root, command) {
6440
- const program = new Command(command.name).summary(command.summary).description(command.description ?? command.summary);
6441
- toQuietParser(program);
6442
- for (const argument of command.arguments) {
6443
- const name = argument.variadic ? `${argument.name}...` : argument.name;
6444
- program.argument(argument.required ? `<${name}>` : `[${name}]`, argument.description);
6445
- }
6446
- for (const flag of command.flags) {
6447
- const short = flag.short ? `-${flag.short}, ` : "";
6448
- const value = flag.valueName ? ` <${flag.valueName}>` : "";
6449
- const option = new Option(`${short}--${flag.name}${value}`, flag.description);
6450
- if (flag.negatable) program.addOption(new Option(`--no-${flag.name}`, `${flag.description}, turned off`));
6451
- if (flag.repeatable) option.argParser(collect);
6452
- if (flag.defaultValue !== void 0) option.default(flag.defaultValue);
6453
- program.addOption(option);
6454
- }
6455
- for (const example of command.examples) program.addHelpText("after", `\n${example.description}:\n $ ${example.command}`);
6456
- for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(root, subcommand), { hidden: subcommand.hidden });
6457
- if (!command.run) return program;
6458
- program.action(async (...parsed) => {
6459
- const flags = parsed[parsed.length - 2] ?? {};
6460
- const args = parsed.slice(0, parsed.length - 2).flatMap(toArgumentList);
6461
- process.exitCode = await toExitCode(root, command, args, flags);
6473
+ return toParser(entries, async ({ command, args, flags, globals }) => {
6474
+ process.exitCode = await toExitCode(globals, command, args, flags);
6462
6475
  });
6463
- return program;
6464
- }
6465
- /** toQuietParser has the parser hand back its mistakes, so they are answered like any other failure. */
6466
- function toQuietParser(program) {
6467
- program.exitOverride().configureOutput({ outputError: () => void 0 });
6468
6476
  }
6469
6477
  /** The parser's codes for a run that only showed help or the version, which is not a failure. */
6470
6478
  const SHOWN = /* @__PURE__ */ new Set([
@@ -6492,7 +6500,7 @@ function toParseCode(code) {
6492
6500
  default: return "invalid_arguments";
6493
6501
  }
6494
6502
  }
6495
- async function toExitCode(root, command, args, flags) {
6503
+ async function toExitCode(globals, command, args, flags) {
6496
6504
  const isJSON = isJSONOutput(flags);
6497
6505
  const rejected = toRejectedFlag(command, flags);
6498
6506
  if (rejected) {
@@ -6502,9 +6510,9 @@ async function toExitCode(root, command, args, flags) {
6502
6510
  let resolved;
6503
6511
  try {
6504
6512
  resolved = toSettings({
6505
- apiUrl: root.opts()["apiUrl"],
6506
- apiKey: root.opts()["apiKey"],
6507
- issuerUrl: root.opts()["issuerUrl"]
6513
+ apiUrl: globals["apiUrl"],
6514
+ apiKey: globals["apiKey"],
6515
+ issuerUrl: globals["issuerUrl"]
6508
6516
  });
6509
6517
  return await command.run?.({
6510
6518
  args,
@@ -6523,13 +6531,6 @@ async function toExitCode(root, command, args, flags) {
6523
6531
  return error instanceof ConfigFailure ? ExitCode.USAGE : ExitCode.ERROR;
6524
6532
  }
6525
6533
  }
6526
- function toArgumentList(value) {
6527
- if (Array.isArray(value)) return value.map(String);
6528
- return value === void 0 ? [] : [toText(value)];
6529
- }
6530
- function collect(value, previous) {
6531
- return [...previous ?? [], value];
6532
- }
6533
6534
  /** toReported reports a defect, and tells a person at a terminal that it was reported. */
6534
6535
  async function toReported(error, isJSON, resolved, level = "error") {
6535
6536
  const invocation = toInvocation(commands, process.argv.slice(2));
@@ -6545,5 +6546,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
6545
6546
  //#endregion
6546
6547
  export { toCli, toParseExitCode, toReported, wasReportAttempted };
6547
6548
 
6548
- //# sourceMappingURL=program-d7dXAbE4.js.map
6549
- //# debugId=18ff3f0f-09fd-501c-9ccd-2c32855efa8b
6549
+ //# sourceMappingURL=program-C38xnnim.js.map
6550
+ //# debugId=23617f68-b6d8-5249-bca7-f5585fdb94f2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.1.1-dev.39",
3
+ "version": "0.1.1-dev.40",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",