@hardfin/cli 0.1.0 → 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.
@@ -432,6 +437,11 @@ Only defects are reported. None of these are:
432
437
  - a mistake on the command line or in a settings file
433
438
  - being signed out, or a sign in that was cancelled or refused
434
439
  - the machine refusing a file, such as a full disk or a missing permission
440
+ - anything from a run with no API key and no stored sign in, except a defect in `hardfin login`
441
+
442
+ The last rule is there because package scanners install the CLI and run it in sandboxes. They
443
+ never sign in, and their reports would bury the real ones. A defect while signing in is still
444
+ reported, because that is the one thing a signed-out person is doing.
435
445
 
436
446
  A report carries the error, its stack trace, and what `hardfin status` would print at that
437
447
  moment. The command is named, and its flags are listed by name without their values.
@@ -447,7 +457,7 @@ organization appear only as their Hardfin keys.
447
457
  | Send reports to another Sentry project | `HARDFIN_SENTRY_DSN` |
448
458
 
449
459
  `hardfin status` shows which of these is in effect. `hardfin __crash` fails on purpose, to
450
- check that a report arrives.
460
+ check that a report arrives. It reports only when signed in or given an API key.
451
461
 
452
462
  ## Local configuration
453
463
 
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]="b1580db0-6822-5a25-b000-03705726eaf6")}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-CjKwnPeY.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-CjKwnPeY.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=b1580db0-6822-5a25-b000-03705726eaf6
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]="5a591d32-0d31-5357-b447-e33420753351")}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
@@ -2939,9 +3271,10 @@ async function report(error, occasion, resolved) {
2939
3271
  if (!isReportable(error)) return;
2940
3272
  const telemetry = toTelemetry();
2941
3273
  if (!telemetry.enabled) return;
3274
+ const settings = resolved ?? toResolved();
3275
+ if (!isSignedIn(settings) && occasion.invocation.command !== "login") return;
2942
3276
  isAttempted = true;
2943
3277
  try {
2944
- const settings = resolved ?? toResolved();
2945
3278
  const status = settings ? await toStatus(settings) : void 0;
2946
3279
  const secrets = [settings?.settings.apiKey, settings ? toCredential(settings.settings.issuerUrl)?.refreshToken : void 0];
2947
3280
  return await (await import("./sentry-BM9Rvhhy.js")).send({
@@ -2955,6 +3288,16 @@ async function report(error, occasion, resolved) {
2955
3288
  return;
2956
3289
  }
2957
3290
  }
3291
+ /** isSignedIn reports whether this run holds an API key or a stored sign in. */
3292
+ function isSignedIn(resolved) {
3293
+ if (!resolved) return false;
3294
+ if (resolved.settings.apiKey) return true;
3295
+ try {
3296
+ return toCredential(resolved.settings.issuerUrl) !== void 0;
3297
+ } catch {
3298
+ return false;
3299
+ }
3300
+ }
2958
3301
  /**
2959
3302
  * toResolved reads the settings again for a crash that happened before a command held them.
2960
3303
  * The global options are read from the command line, so the report describes the host the
@@ -3167,26 +3510,36 @@ const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
3167
3510
  "logout",
3168
3511
  "mcp"
3169
3512
  ]);
3170
- /** The global options, which sit before a command and take a value of their own. */
3171
- const GLOBAL_OPTIONS_WITH_VALUES = new Set(GLOBAL_FLAGS.filter((flag) => flag.valueName).map((flag) => `--${flag.name}`));
3172
3513
  /**
3173
- * toCommandName reads which command a call names. A global option sits before it, so
3174
- * 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.
3175
3517
  */
3176
- function toCommandName(args) {
3177
- for (let index = 0; index < args.length; index += 1) {
3178
- const argument = args[index] ?? "";
3179
- if (GLOBAL_OPTIONS_WITH_VALUES.has(argument)) {
3180
- index += 1;
3181
- continue;
3182
- }
3183
- if (argument.startsWith("-")) continue;
3184
- 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;
3185
3527
  }
3528
+ return {
3529
+ ran,
3530
+ globals: parser.opts()
3531
+ };
3186
3532
  }
3187
- /** toIsAskingForHelp reports whether a call only wants to read about a command. */
3188
- function toIsAskingForHelp(args) {
3189
- 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`;
3190
3543
  }
3191
3544
  async function toToolResult(request, commands, run) {
3192
3545
  const params = request.params;
@@ -3199,9 +3552,12 @@ async function toToolResult(request, commands, run) {
3199
3552
  const refused = toRefusedArgument(args);
3200
3553
  if (refused) return toError(refused);
3201
3554
  const asked = args;
3202
- const command = toCommandName(asked) ?? "";
3203
- 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`);
3204
- 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}`);
3205
3561
  const outcome = await run(asked, called?.input);
3206
3562
  return {
3207
3563
  content: [{
@@ -3224,46 +3580,14 @@ function toError(text) {
3224
3580
  const LOCAL_READS = /* @__PURE__ */ new Set([
3225
3581
  "status",
3226
3582
  "config",
3227
- "agent-guide",
3228
- "help"
3583
+ "agent-guide"
3229
3584
  ]);
3230
- /**
3231
- * toAccess decides whether a call reads or changes data. A generated command answers by its
3232
- * endpoint's method, hardfin api by the method it was given, and anything it cannot place is
3233
- * treated as a change, so it is asked about.
3234
- */
3235
- function toAccess(args, commands) {
3236
- if (toIsAskingForHelp(args)) return "read";
3237
- let level = commands;
3238
- let found;
3239
- for (let index = 0; index < args.length; index += 1) {
3240
- const word = args[index] ?? "";
3241
- if (GLOBAL_OPTIONS_WITH_VALUES.has(word)) {
3242
- index += 1;
3243
- continue;
3244
- }
3245
- if (word.startsWith("-")) continue;
3246
- const next = level.find((entry) => entry.name === word);
3247
- if (!next) break;
3248
- found = next;
3249
- level = next.subcommands ?? [];
3250
- if (level.length === 0) break;
3251
- }
3252
- if (!found) return "read";
3253
- if (found.endpoint) return found.endpoint.method === "GET" ? "read" : "write";
3254
- if (found.name === "api") return toApiMethod(args) === "GET" ? "read" : "write";
3255
- if (LOCAL_READS.has(found.name) || (found.subcommands?.length ?? 0) > 0) return "read";
3256
- return "write";
3257
- }
3258
- /** toApiMethod reads the method hardfin api was given, which is GET when none was. */
3259
- function toApiMethod(args) {
3260
- for (let index = 0; index < args.length; index += 1) {
3261
- const word = args[index] ?? "";
3262
- if (word === "-X" || word === "--method") return (args[index + 1] ?? "GET").toUpperCase();
3263
- if (word.startsWith("--method=")) return word.slice(9).toUpperCase();
3264
- if (word.startsWith("-X") && word.length > 2) return word.slice(2).toUpperCase();
3265
- }
3266
- 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";
3267
3591
  }
3268
3592
  /** The most one argument can hold, which Linux refuses past 128 KiB. */
3269
3593
  const ARGUMENT_BYTES = 131071;
@@ -3365,363 +3689,92 @@ async function serve(commands, run = toCliRunner()) {
3365
3689
  }
3366
3690
  await Promise.all(answering);
3367
3691
  }
3368
- /** toAnswer replies to one line, and never rejects, because a rejection ends the server. */
3369
- async function toAnswer(line, commands, run) {
3370
- let response;
3371
- let failure;
3372
- try {
3373
- response = await toResponse(toRequest(line), commands, run);
3374
- } catch (error) {
3375
- response = error instanceof ProtocolFailure ? toErrorResponse(error.id, error.code, error.message) : toErrorResponse(toRequestId(line), -32603, error instanceof Error ? error.message : String(error));
3376
- failure = error instanceof ProtocolFailure ? void 0 : error;
3377
- }
3378
- if (response) write(`${JSON.stringify(response)}\n`);
3379
- if (failure !== void 0) await report(failure, {
3380
- level: "error",
3381
- mechanism: "mcp.request",
3382
- mode: "mcp",
3383
- invocation: {
3384
- command: "mcp",
3385
- flags: []
3386
- }
3387
- });
3388
- }
3389
- /** toRequest reads one line, refusing what the protocol does not allow to be a request. */
3390
- function toRequest(line) {
3391
- let parsed;
3392
- try {
3393
- parsed = JSON.parse(line);
3394
- } catch {
3395
- throw new ProtocolFailure(-32700, "this line is not JSON", null);
3396
- }
3397
- 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);
3398
- const request = parsed;
3399
- if (typeof request.method !== "string") throw new ProtocolFailure(-32600, "a request names a method", toAllowedId(request.id));
3400
- 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);
3401
- return request;
3402
- }
3403
- function toAllowedId(id) {
3404
- return typeof id === "string" || typeof id === "number" ? id : null;
3405
- }
3406
- function toErrorResponse(id, code, message) {
3407
- return {
3408
- jsonrpc: "2.0",
3409
- id,
3410
- error: {
3411
- code,
3412
- message
3413
- }
3414
- };
3415
- }
3416
- /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
3417
- function toRequestId(line) {
3418
- try {
3419
- return toAllowedId(JSON.parse(line).id);
3420
- } catch {
3421
- return null;
3422
- }
3423
- }
3424
- /** write puts a reply on stdout, and stops quietly when the client has gone. */
3425
- function write(text) {
3426
- try {
3427
- process.stdout.write(text);
3428
- } catch {}
3429
- }
3430
- //#endregion
3431
- //#region src/command/mcp.ts
3432
- const mcpCommand = defineCommand({
3433
- name: "mcp",
3434
- summary: "Serve this CLI to an agent over the Model Context Protocol",
3435
- 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.",
3436
- arguments: [],
3437
- flags: [{
3438
- name: "json",
3439
- description: "Accepted for consistency, and ignored, because the protocol decides the output",
3440
- schema: z.boolean()
3441
- }],
3442
- examples: [{
3443
- description: "Register with an agent",
3444
- command: "hardfin mcp"
3445
- }, {
3446
- description: "Add it to Claude Code",
3447
- command: "claude mcp add hardfin -- hardfin mcp"
3448
- }],
3449
- run: runMcp
3450
- });
3451
- async function runMcp(input) {
3452
- await serve(input.commands);
3453
- return ExitCode.OK;
3454
- }
3455
- //#endregion
3456
- //#region src/command/operation.ts
3457
- /**
3458
- * toEnum takes a value in any case, as the API does, and answers the spelling the document
3459
- * lists, which is what a response always uses.
3460
- */
3461
- function toEnum(values) {
3462
- return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
3463
- }
3464
- const UNSET_FLAG = {
3465
- name: "unset",
3466
- description: "A field to clear, named as its flag is, repeatable",
3467
- valueName: "field",
3468
- repeatable: true,
3469
- schema: z.array(z.string())
3470
- };
3471
- const INPUT_FLAG = {
3472
- name: "input",
3473
- description: "A file holding the JSON request body, or - for standard input. A flag given as well replaces its field",
3474
- valueName: "path",
3475
- schema: z.string()
3476
- };
3477
- const FILE_FLAG = {
3478
- name: "file",
3479
- description: "The file to upload",
3480
- valueName: "path",
3481
- schema: z.string()
3482
- };
3483
- const OUTPUT_FLAG = {
3484
- name: "output",
3485
- description: "Where to write the file, or - for standard output",
3486
- valueName: "path",
3487
- schema: z.string()
3488
- };
3489
- const JSON_FLAG = {
3490
- name: "json",
3491
- description: "Print machine-readable output, which is the default when stdout is not a terminal",
3492
- schema: z.boolean()
3493
- };
3494
- /** defineOperation turns one endpoint into the command that calls it. */
3495
- function defineOperation(operation) {
3496
- const flags = [
3497
- ...operation.queryFlags,
3498
- ...operation.bodyFlags,
3499
- ...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
3500
- ...operation.bodyFlags.length > 0 ? [INPUT_FLAG] : [],
3501
- ...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
3502
- ...operation.downloads ? [OUTPUT_FLAG] : [],
3503
- JSON_FLAG
3504
- ];
3505
- return {
3506
- name: operation.name,
3507
- summary: operation.summary,
3508
- description: operation.description ?? operation.summary,
3509
- arguments: operation.pathParameters,
3510
- flags,
3511
- examples: operation.example ? [{
3512
- description: operation.summary,
3513
- command: operation.example
3514
- }] : [],
3515
- endpoint: {
3516
- method: operation.method,
3517
- path: operation.path
3518
- },
3519
- run: (input) => runOperation(operation, input)
3520
- };
3521
- }
3522
- async function runOperation(operation, input) {
3523
- const path = toPath(operation, input.args);
3524
- if (path === void 0) {
3525
- writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
3526
- return ExitCode.USAGE;
3527
- }
3528
- const given = toGivenBody(input.flags["input"]);
3529
- if (given instanceof InputFailure) {
3530
- writeFailure("invalid_input", given.message, input.isJSON);
3531
- return ExitCode.USAGE;
3532
- }
3533
- const missing = operation.bodyFlags.concat(operation.upload?.fields ?? []).filter((flag) => flag.required && input.flags[toOptionKey(flag.name)] === void 0 && !hasPath(given, flag.jsonPath));
3534
- if (missing.length > 0) {
3535
- writeFailure("missing_flag", `this command needs ${missing.map((flag) => `--${flag.name}`).join(", ")}`, input.isJSON);
3536
- return ExitCode.USAGE;
3537
- }
3538
- const body = toBody(operation, input.flags, given);
3539
- if (body instanceof Error) {
3540
- writeFailure("invalid_flag", body.message, input.isJSON);
3541
- return ExitCode.USAGE;
3542
- }
3543
- if (operation.bodyFlags.length > 0 && body === void 0) {
3544
- 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);
3545
- return ExitCode.USAGE;
3546
- }
3547
- let form;
3548
- if (operation.upload) {
3549
- const built = await toForm(operation.upload, input.flags);
3550
- if (built instanceof Error) {
3551
- writeFailure("invalid_input", built.message, input.isJSON);
3552
- return ExitCode.USAGE;
3553
- }
3554
- form = built;
3555
- }
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;
3556
3696
  try {
3557
- const envelope = await request$1({
3558
- apiUrl: input.resolved.settings.apiUrl,
3559
- credential: await toRequestCredential(input.resolved.settings, input.resolved.sources),
3560
- method: operation.method,
3561
- path,
3562
- query: toQuery(operation, input.flags),
3563
- body,
3564
- form,
3565
- downloads: operation.downloads
3566
- });
3567
- if (operation.downloads) return toWritten(envelope.data, input);
3568
- writeData(envelope.data);
3569
- return ExitCode.OK;
3697
+ response = await toResponse(toRequest(line), commands, run);
3570
3698
  } catch (error) {
3571
- if (error instanceof NoCredential) {
3572
- writeFailure("not_signed_in", error.message, input.isJSON);
3573
- return ExitCode.NOT_AUTHENTICATED;
3574
- }
3575
- if (error instanceof RequestFailure) {
3576
- writeFailure(toStatusCode(error.status), error.message, input.isJSON, error.errors, error.requestId, error.status);
3577
- return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
3578
- }
3579
- 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;
3580
3701
  }
3581
- }
3582
- /**
3583
- * toWritten puts a downloaded file where it was asked for. A terminal is never written to,
3584
- * because a person would otherwise have their session filled with a file's bytes.
3585
- */
3586
- function toWritten(download, input) {
3587
- const asked = input.flags["output"];
3588
- const path = typeof asked === "string" ? asked : download.fileName ?? "-";
3589
- if (path === "-") {
3590
- if (process.stdout.isTTY) {
3591
- 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);
3592
- 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: []
3593
3710
  }
3594
- process.stdout.write(download.bytes);
3595
- return ExitCode.OK;
3596
- }
3597
- writeFileSync(path, download.bytes);
3598
- writeData({
3599
- written: path,
3600
- bytes: download.bytes.length,
3601
- contentType: download.contentType
3602
3711
  });
3603
- return ExitCode.OK;
3604
- }
3605
- /** toPath fills the path template from the positional arguments, in order. */
3606
- function toPath(operation, args) {
3607
- if (args.length !== operation.pathParameters.length) return;
3608
- let path = operation.path;
3609
- for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
3610
- return path;
3611
- }
3612
- /** toQuery carries only the flags this invocation actually set. */
3613
- function toQuery(operation, flags) {
3614
- const query = new URLSearchParams();
3615
- for (const flag of operation.queryFlags) {
3616
- const value = flags[toOptionKey(flag.name)];
3617
- if (value === void 0) continue;
3618
- const parsed = flag.schema.safeParse(value);
3619
- const carried = parsed.success ? parsed.data : value;
3620
- for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
3621
- }
3622
- return query;
3623
- }
3624
- /** toOptionKey names the parsed flag, which the parser reports in camel case. */
3625
- function toOptionKey(name) {
3626
- return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
3627
3712
  }
3628
- /** toForm builds the file part and its fields, which an upload endpoint takes. */
3629
- async function toForm(upload, flags) {
3630
- const path = flags["file"];
3631
- if (typeof path !== "string") return /* @__PURE__ */ new Error("this command needs --file, the file to upload");
3632
- 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;
3633
3716
  try {
3634
- form.append(upload.filePart, await openAsBlob(path), basename(path));
3717
+ parsed = JSON.parse(line);
3635
3718
  } catch {
3636
- return /* @__PURE__ */ new Error(`${path} cannot be read`);
3637
- }
3638
- for (const field of upload.fields) {
3639
- const value = flags[toOptionKey(field.name)];
3640
- if (value !== void 0) {
3641
- const parsed = field.schema.safeParse(value);
3642
- form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
3643
- }
3719
+ throw new ProtocolFailure(-32700, "this line is not JSON", null);
3644
3720
  }
3645
- return form;
3646
- }
3647
- /** toGivenBody reads the --input body, which a generated command takes only as a JSON object. */
3648
- function toGivenBody(source) {
3649
- if (typeof source !== "string") return;
3650
- const read = toJSONInput(source);
3651
- if (read instanceof InputFailure) return read;
3652
- 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"}`);
3653
- 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;
3654
3726
  }
3655
- /** hasPath reports whether a body already holds a value at a field's path. */
3656
- function hasPath(body, path) {
3657
- let holder = body;
3658
- for (const name of path) {
3659
- if (typeof holder !== "object" || holder === null || !(name in holder)) return false;
3660
- holder = holder[name];
3661
- }
3662
- return true;
3727
+ function toAllowedId(id) {
3728
+ return typeof id === "string" || typeof id === "number" ? id : null;
3663
3729
  }
3664
- /** toBody builds the request body from the --input body and the flags, a flag winning over its field. */
3665
- function toBody(operation, flags, given) {
3666
- const body = given ? structuredClone(given) : {};
3667
- let hasField = given !== void 0;
3668
- for (const flag of operation.bodyFlags) {
3669
- const value = flags[toOptionKey(flag.name)];
3670
- if (value === void 0) continue;
3671
- if (flag.element) {
3672
- const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
3673
- if (elements instanceof Error) return elements;
3674
- set(body, flag.jsonPath, elements);
3675
- hasField = true;
3676
- continue;
3730
+ function toErrorResponse(id, code, message) {
3731
+ return {
3732
+ jsonrpc: "2.0",
3733
+ id,
3734
+ error: {
3735
+ code,
3736
+ message
3677
3737
  }
3678
- const parsed = flag.schema.safeParse(value);
3679
- set(body, flag.jsonPath, parsed.success ? parsed.data : value);
3680
- hasField = true;
3681
- }
3682
- const cleared = toCleared(operation, flags, body);
3683
- if (cleared instanceof Error) return cleared;
3684
- return hasField || cleared ? body : void 0;
3738
+ };
3685
3739
  }
3686
- /** toCleared sends null for each field named by --unset, which is how a field is cleared. */
3687
- function toCleared(operation, flags, body) {
3688
- const named = flags["unset"];
3689
- const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
3690
- for (const name of names) {
3691
- const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
3692
- if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
3693
- if (!flag.nullable) return /* @__PURE__ */ new Error(`--${flag.name} cannot be cleared, because the API does not accept null for it`);
3694
- 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;
3695
3746
  }
3696
- return names.length > 0;
3697
3747
  }
3698
- /** toElements reads the key=value pairs a repeated flag carries for one array element. */
3699
- function toElements(flag, values) {
3700
- const elements = [];
3701
- for (const value of values) {
3702
- const element = {};
3703
- for (const pair of value.split(",")) {
3704
- const split = pair.indexOf("=");
3705
- 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`);
3706
- const key = pair.slice(0, split).trim();
3707
- if (flag.element && !flag.element.includes(key)) return /* @__PURE__ */ new Error(`--${flag.name} has no field ${key}. It takes ${flag.element.join(", ")}`);
3708
- if (key in element) return /* @__PURE__ */ new Error(`--${flag.name} names ${key} twice in one element, so which one it takes is not clear`);
3709
- const value = pair.slice(split + 1).trim();
3710
- 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`);
3711
- element[key] = value;
3712
- }
3713
- elements.push(element);
3714
- }
3715
- 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 {}
3716
3753
  }
3717
- /** set writes a value at its path, building the objects a nested field needs. */
3718
- function set(body, path, value) {
3719
- let holder = body;
3720
- for (const name of path.slice(0, -1)) {
3721
- holder[name] = holder[name] ?? {};
3722
- holder = holder[name];
3723
- }
3724
- 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;
3725
3778
  }
3726
3779
  //#endregion
3727
3780
  //#region src/command/commands.ts
@@ -6417,43 +6470,9 @@ function toRejectedFlag(command, flags) {
6417
6470
  //#region src/cli/program.ts
6418
6471
  /** toCli builds the parser from the registry, which is what every surface reads. */
6419
6472
  function toCli(entries = commands) {
6420
- const program = new Command();
6421
- program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").enablePositionalOptions();
6422
- toQuietParser(program);
6423
- for (const flag of GLOBAL_FLAGS) program.option(`--${flag.name} <${flag.valueName}>`, flag.description);
6424
- for (const command of entries) program.addCommand(toProgram(program, command), { hidden: command.hidden });
6425
- return program;
6426
- }
6427
- /** toProgram wires one registry command into the parser. */
6428
- function toProgram(root, command) {
6429
- const program = new Command(command.name).summary(command.summary).description(command.description ?? command.summary);
6430
- toQuietParser(program);
6431
- for (const argument of command.arguments) {
6432
- const name = argument.variadic ? `${argument.name}...` : argument.name;
6433
- program.argument(argument.required ? `<${name}>` : `[${name}]`, argument.description);
6434
- }
6435
- for (const flag of command.flags) {
6436
- const short = flag.short ? `-${flag.short}, ` : "";
6437
- const value = flag.valueName ? ` <${flag.valueName}>` : "";
6438
- const option = new Option(`${short}--${flag.name}${value}`, flag.description);
6439
- if (flag.negatable) program.addOption(new Option(`--no-${flag.name}`, `${flag.description}, turned off`));
6440
- if (flag.repeatable) option.argParser(collect);
6441
- if (flag.defaultValue !== void 0) option.default(flag.defaultValue);
6442
- program.addOption(option);
6443
- }
6444
- for (const example of command.examples) program.addHelpText("after", `\n${example.description}:\n $ ${example.command}`);
6445
- for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(root, subcommand), { hidden: subcommand.hidden });
6446
- if (!command.run) return program;
6447
- program.action(async (...parsed) => {
6448
- const flags = parsed[parsed.length - 2] ?? {};
6449
- const args = parsed.slice(0, parsed.length - 2).flatMap(toArgumentList);
6450
- 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);
6451
6475
  });
6452
- return program;
6453
- }
6454
- /** toQuietParser has the parser hand back its mistakes, so they are answered like any other failure. */
6455
- function toQuietParser(program) {
6456
- program.exitOverride().configureOutput({ outputError: () => void 0 });
6457
6476
  }
6458
6477
  /** The parser's codes for a run that only showed help or the version, which is not a failure. */
6459
6478
  const SHOWN = /* @__PURE__ */ new Set([
@@ -6481,7 +6500,7 @@ function toParseCode(code) {
6481
6500
  default: return "invalid_arguments";
6482
6501
  }
6483
6502
  }
6484
- async function toExitCode(root, command, args, flags) {
6503
+ async function toExitCode(globals, command, args, flags) {
6485
6504
  const isJSON = isJSONOutput(flags);
6486
6505
  const rejected = toRejectedFlag(command, flags);
6487
6506
  if (rejected) {
@@ -6491,9 +6510,9 @@ async function toExitCode(root, command, args, flags) {
6491
6510
  let resolved;
6492
6511
  try {
6493
6512
  resolved = toSettings({
6494
- apiUrl: root.opts()["apiUrl"],
6495
- apiKey: root.opts()["apiKey"],
6496
- issuerUrl: root.opts()["issuerUrl"]
6513
+ apiUrl: globals["apiUrl"],
6514
+ apiKey: globals["apiKey"],
6515
+ issuerUrl: globals["issuerUrl"]
6497
6516
  });
6498
6517
  return await command.run?.({
6499
6518
  args,
@@ -6512,13 +6531,6 @@ async function toExitCode(root, command, args, flags) {
6512
6531
  return error instanceof ConfigFailure ? ExitCode.USAGE : ExitCode.ERROR;
6513
6532
  }
6514
6533
  }
6515
- function toArgumentList(value) {
6516
- if (Array.isArray(value)) return value.map(String);
6517
- return value === void 0 ? [] : [toText(value)];
6518
- }
6519
- function collect(value, previous) {
6520
- return [...previous ?? [], value];
6521
- }
6522
6534
  /** toReported reports a defect, and tells a person at a terminal that it was reported. */
6523
6535
  async function toReported(error, isJSON, resolved, level = "error") {
6524
6536
  const invocation = toInvocation(commands, process.argv.slice(2));
@@ -6534,5 +6546,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
6534
6546
  //#endregion
6535
6547
  export { toCli, toParseExitCode, toReported, wasReportAttempted };
6536
6548
 
6537
- //# sourceMappingURL=program-CjKwnPeY.js.map
6538
- //# debugId=5a591d32-0d31-5357-b447-e33420753351
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.0",
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.",