@hardfin/cli 0.1.0-dev.33 → 0.1.0-dev.34

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
@@ -320,22 +320,33 @@ other client can run Hardfin commands.
320
320
  claude mcp add hardfin -- hardfin mcp
321
321
  ```
322
322
 
323
- ### One tool, and resources for the rest
323
+ ### Two tools, and resources for the rest
324
324
 
325
325
  Every tool a server lists sits in the agent's context for the entire session, so this server
326
- offers one.
326
+ offers two. They differ only in what they may run, which lets a client run a read without
327
+ asking and ask before anything changes.
327
328
 
328
- | Offered as | Holds | Costs context |
329
- | -------------------- | ---------------------------------------------------------------- | --------------------------------- |
330
- | The `hardfin` tool | A list of arguments, such as `["asset", "list", "--limit", "5"]` | Always, and it is about 400 bytes |
331
- | `hardfin://guide` | Every command, its flags, and the exit codes | Only when the agent reads it |
332
- | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
329
+ | Offered as | Holds | Costs context |
330
+ | -------------------- | ---------------------------------------------------------------------- | ---------------------------- |
331
+ | `hardfin_read` | A command that only reads, such as `["asset", "list", "--limit", "5"]` | Always, about 800 bytes |
332
+ | `hardfin_write` | A command that creates, changes or deletes, with an optional JSON body | Always, about 800 bytes |
333
+ | `hardfin://guide` | Every command, its flags, the exit codes and the error codes | Only when the agent reads it |
334
+ | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
333
335
 
334
- Fifty tools would describe the same surface and crowd out the work. An agent that needs the
335
- list reads a resource, or runs `["--help"]`.
336
+ A command is a read when its endpoint's method is GET, or for `hardfin api` when no other
337
+ `--method` is given. Anything else is a write, and `hardfin_read` refuses it by naming
338
+ `hardfin_write`. Both tools carry a title, the read-only or destructive hint, and a link to
339
+ the API reference, which Claude's connector directory requires.
336
340
 
337
341
  A tool call runs the CLI itself, so an agent and a person get identical parsing, output and
338
- exit codes.
342
+ exit codes. Each call has these limits:
343
+
344
+ | Limit | Value | Past it |
345
+ | ---------------- | --------------------------------------- | ------------------------------------------ |
346
+ | Time | 120 seconds | The command is stopped, and the call fails |
347
+ | Commands running | 4 at once | The next call waits for one to finish |
348
+ | One argument | 128 KiB | Refused, pointing at the `input` argument |
349
+ | Standard input | The call's `input` argument, or nothing | Always closed, so `--input -` never waits |
339
350
 
340
351
  ### What it signs in as
341
352
 
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]="3f5ddeb4-6ae9-5ded-b447-15c8483fc583")}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]="f2529e6e-d7d4-5fe2-b460-7e86ca865c25")}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-ByvcKNri.js");
17
+ const { toCli, toParseExitCode, wasReportAttempted } = await import("./program-DTsxnjRj.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-ByvcKNri.js");
36
+ const { toReported } = await import("./program-DTsxnjRj.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=3f5ddeb4-6ae9-5ded-b447-15c8483fc583
54
+ //# debugId=f2529e6e-d7d4-5fe2-b460-7e86ca865c25
@@ -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]="93b4bc8d-ebeb-59ca-967b-9fd10484e4a5")}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]="c7f8ce05-d638-5d0f-a088-655ddade6f45")}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";
@@ -2797,23 +2797,52 @@ function wasReportAttempted() {
2797
2797
  const PROTOCOL_VERSION = "2025-06-18";
2798
2798
  const GUIDE_URI = "hardfin://guide";
2799
2799
  const COMMANDS_URI = "hardfin://commands";
2800
- /**
2801
- * One tool, because every tool a server lists sits in the agent's context for the whole
2802
- * session. The commands themselves are resources, which cost nothing until one is read.
2803
- */
2804
- const TOOL = {
2805
- name: "hardfin",
2806
- description: "Run a Hardfin CLI command against the Hardfin API. Pass the arguments as a list, such as [\"asset\", \"list\", \"--limit\", \"5\"]. Run [\"--help\"] for the commands, or read the hardfin://guide resource.",
2807
- inputSchema: {
2808
- type: "object",
2809
- properties: { args: {
2800
+ /** The public reference for the API every command calls. */
2801
+ const API_REFERENCE = "https://docs.hardfin.com";
2802
+ const ARGUMENTS_SCHEMA = {
2803
+ type: "object",
2804
+ properties: {
2805
+ args: {
2810
2806
  type: "array",
2811
2807
  items: { type: "string" },
2812
2808
  description: "The arguments to hardfin, without the program name"
2813
- } },
2814
- required: ["args"]
2809
+ },
2810
+ input: {
2811
+ type: "string",
2812
+ description: "JSON sent to the command on standard input, which it reads when given --input -"
2813
+ }
2814
+ },
2815
+ required: ["args"]
2816
+ };
2817
+ /**
2818
+ * Two tools, so a client can let a read run without asking and ask before anything changes.
2819
+ * The commands themselves are resources, which cost nothing until one is read.
2820
+ */
2821
+ const READ_TOOL = {
2822
+ name: "hardfin_read",
2823
+ title: "Read Hardfin data",
2824
+ description: `Runs a Hardfin CLI command that only reads, against the Hardfin API (${API_REFERENCE}). Takes the arguments as a list, such as ["asset", "list", "--limit", "5"]. ["--help"] lists the commands, and the hardfin://guide resource describes each one.`,
2825
+ inputSchema: ARGUMENTS_SCHEMA,
2826
+ annotations: {
2827
+ title: "Read Hardfin data",
2828
+ readOnlyHint: true,
2829
+ destructiveHint: false,
2830
+ openWorldHint: true
2815
2831
  }
2816
2832
  };
2833
+ const WRITE_TOOL = {
2834
+ name: "hardfin_write",
2835
+ title: "Change Hardfin data",
2836
+ description: `Runs a Hardfin CLI command that creates, changes or deletes records through the Hardfin API (${API_REFERENCE}). Takes the arguments as a list, such as ["customer", "create", "--name", "Acme"]. A JSON body goes in the input argument, with --input - in the list.`,
2837
+ inputSchema: ARGUMENTS_SCHEMA,
2838
+ annotations: {
2839
+ title: "Change Hardfin data",
2840
+ readOnlyHint: false,
2841
+ destructiveHint: true,
2842
+ openWorldHint: true
2843
+ }
2844
+ };
2845
+ const INSTRUCTIONS = "hardfin_read looks records up and hardfin_write changes them. Both run Hardfin CLI commands, so the arguments are what a person would type after hardfin. The hardfin://guide resource lists every command, flag and error code.";
2817
2846
  /** ProtocolFailure is a line this server answers with a JSON-RPC error rather than a result. */
2818
2847
  var ProtocolFailure = class extends Error {
2819
2848
  code;
@@ -2841,10 +2870,12 @@ async function toResponse(request, commands, run) {
2841
2870
  },
2842
2871
  serverInfo: {
2843
2872
  name: "hardfin",
2873
+ title: "Hardfin",
2844
2874
  version: version$1
2845
- }
2875
+ },
2876
+ instructions: INSTRUCTIONS
2846
2877
  });
2847
- case "tools/list": return answer({ tools: [TOOL] });
2878
+ case "tools/list": return answer({ tools: [READ_TOOL, WRITE_TOOL] });
2848
2879
  case "resources/list": return answer({ resources: [{
2849
2880
  uri: GUIDE_URI,
2850
2881
  name: "Hardfin CLI guide",
@@ -2857,7 +2888,8 @@ async function toResponse(request, commands, run) {
2857
2888
  mimeType: "application/json"
2858
2889
  }] });
2859
2890
  case "resources/read": {
2860
- const uri = toText(request.params?.["uri"] ?? "");
2891
+ const uri = request.params?.["uri"];
2892
+ if (typeof uri !== "string" || uri === "") throw new ProtocolFailure(-32602, "resources/read needs a uri, such as hardfin://guide", request.id ?? null);
2861
2893
  const resource = toResource(uri, commands);
2862
2894
  if (!resource) return {
2863
2895
  jsonrpc: "2.0",
@@ -2869,7 +2901,7 @@ async function toResponse(request, commands, run) {
2869
2901
  };
2870
2902
  return answer(resource);
2871
2903
  }
2872
- case "tools/call": return answer(await toToolResult(request.params, run));
2904
+ case "tools/call": return answer(await toToolResult(request, commands, run));
2873
2905
  case "ping": return answer({});
2874
2906
  default:
2875
2907
  if (request.id === void 0 || request.id === null) return;
@@ -2943,80 +2975,170 @@ function toCommandName(args) {
2943
2975
  function toIsAskingForHelp(args) {
2944
2976
  return args.includes("--help") || args.includes("-h") || args[0] === "help";
2945
2977
  }
2946
- async function toToolResult(params, run) {
2947
- if (params?.["name"] !== TOOL.name) return {
2948
- content: [{
2949
- type: "text",
2950
- text: `a call names its tool, and this server offers one, ${TOOL.name}`
2951
- }],
2952
- isError: true
2953
- };
2954
- const args = (params?.["arguments"])?.args;
2955
- if (!Array.isArray(args) || args.some((entry) => typeof entry !== "string")) return {
2956
- content: [{
2957
- type: "text",
2958
- text: "args must be a list of strings, such as [\"asset\", \"list\"]"
2959
- }],
2960
- isError: true
2961
- };
2978
+ async function toToolResult(request, commands, run) {
2979
+ const params = request.params;
2980
+ const name = params?.["name"];
2981
+ if (name !== READ_TOOL.name && name !== WRITE_TOOL.name) throw new ProtocolFailure(-32602, `a call names its tool, and this server offers ${READ_TOOL.name} and ${WRITE_TOOL.name}`, request.id ?? null);
2982
+ const called = params?.["arguments"];
2983
+ const args = called?.args;
2984
+ if (!Array.isArray(args) || args.some((entry) => typeof entry !== "string")) return toError("args must be a list of strings, such as [\"asset\", \"list\"]");
2985
+ if (called?.input !== void 0 && typeof called.input !== "string") return toError("input must be a string holding JSON, such as {\"name\": \"Acme\"}");
2962
2986
  const refused = toRefusedArgument(args);
2963
- if (refused) return {
2964
- content: [{
2965
- type: "text",
2966
- text: refused
2967
- }],
2968
- isError: true
2969
- };
2987
+ if (refused) return toError(refused);
2970
2988
  const asked = args;
2971
2989
  const command = toCommandName(asked) ?? "";
2972
- if (REFUSED_COMMANDS.has(command) && !toIsAskingForHelp(asked)) return {
2990
+ 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`);
2991
+ if (name === READ_TOOL.name && toAccess(asked, commands) === "write") return toError(`this command changes data, so it runs through ${WRITE_TOOL.name}`);
2992
+ const outcome = await run(asked, called?.input);
2993
+ return {
2973
2994
  content: [{
2974
2995
  type: "text",
2975
- text: `${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`
2996
+ text: [outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n") || `hardfin exited ${outcome.code}`
2976
2997
  }],
2977
- isError: true
2998
+ isError: outcome.code !== 0
2978
2999
  };
2979
- const outcome = await run(asked);
3000
+ }
3001
+ function toError(text) {
2980
3002
  return {
2981
3003
  content: [{
2982
3004
  type: "text",
2983
- text: [outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n") || `hardfin exited ${outcome.code}`
3005
+ text
2984
3006
  }],
2985
- isError: outcome.code !== 0
3007
+ isError: true
2986
3008
  };
2987
3009
  }
2988
- /** The most a command line can carry, below what the operating system refuses outright. */
2989
- const ARGUMENT_LIMIT = 1e5;
3010
+ /** Commands with no endpoint that only show something, which a read may run. */
3011
+ const LOCAL_READS = /* @__PURE__ */ new Set([
3012
+ "status",
3013
+ "config",
3014
+ "agent-guide",
3015
+ "help"
3016
+ ]);
3017
+ /**
3018
+ * toAccess decides whether a call reads or changes data. A generated command answers by its
3019
+ * endpoint's method, hardfin api by the method it was given, and anything it cannot place is
3020
+ * treated as a change, so it is asked about.
3021
+ */
3022
+ function toAccess(args, commands) {
3023
+ if (toIsAskingForHelp(args)) return "read";
3024
+ let level = commands;
3025
+ let found;
3026
+ for (let index = 0; index < args.length; index += 1) {
3027
+ const word = args[index] ?? "";
3028
+ if (GLOBAL_OPTIONS_WITH_VALUES.has(word)) {
3029
+ index += 1;
3030
+ continue;
3031
+ }
3032
+ if (word.startsWith("-")) continue;
3033
+ const next = level.find((entry) => entry.name === word);
3034
+ if (!next) break;
3035
+ found = next;
3036
+ level = next.subcommands ?? [];
3037
+ if (level.length === 0) break;
3038
+ }
3039
+ if (!found) return "read";
3040
+ if (found.endpoint) return found.endpoint.method === "GET" ? "read" : "write";
3041
+ if (found.name === "api") return toApiMethod(args) === "GET" ? "read" : "write";
3042
+ if (LOCAL_READS.has(found.name) || (found.subcommands?.length ?? 0) > 0) return "read";
3043
+ return "write";
3044
+ }
3045
+ /** toApiMethod reads the method hardfin api was given, which is GET when none was. */
3046
+ function toApiMethod(args) {
3047
+ for (let index = 0; index < args.length; index += 1) {
3048
+ const word = args[index] ?? "";
3049
+ if (word === "-X" || word === "--method") return (args[index + 1] ?? "GET").toUpperCase();
3050
+ if (word.startsWith("--method=")) return word.slice(9).toUpperCase();
3051
+ if (word.startsWith("-X") && word.length > 2) return word.slice(2).toUpperCase();
3052
+ }
3053
+ return "GET";
3054
+ }
3055
+ /** The most one argument can hold, which Linux refuses past 128 KiB. */
3056
+ const ARGUMENT_BYTES = 131071;
3057
+ /** The most a whole command line can carry, below what the operating system refuses outright. */
3058
+ const COMMAND_LINE_BYTES = 1e6;
2990
3059
  /** toRefusedArgument names an argument a command line cannot carry, rather than failing later. */
2991
3060
  function toRefusedArgument(args) {
2992
3061
  const withNul = args.findIndex((argument) => argument.includes("\0"));
2993
3062
  if (withNul >= 0) return `args[${withNul}] holds a NUL byte, which a command line cannot carry`;
2994
- const total = args.reduce((carried, argument) => carried + argument.length, 0);
2995
- if (total > ARGUMENT_LIMIT) return `these arguments are ${total} characters, and a command line carries at most ${ARGUMENT_LIMIT}`;
2996
- }
3063
+ const oversized = args.findIndex((argument) => Buffer.byteLength(argument) > ARGUMENT_BYTES);
3064
+ if (oversized >= 0) return `args[${oversized}] is ${Buffer.byteLength(args[oversized] ?? "")} bytes, and one argument carries at most ${ARGUMENT_BYTES}. Send a large body in the input argument instead`;
3065
+ const total = args.reduce((carried, argument) => carried + Buffer.byteLength(argument), 0);
3066
+ if (total > COMMAND_LINE_BYTES) return `these arguments are ${total} bytes, and a command line carries at most ${COMMAND_LINE_BYTES}`;
3067
+ }
3068
+ /** How long one command may run before it is stopped, so a hung call cannot hold its client. */
3069
+ const CALL_TIMEOUT_MS = 12e4;
3070
+ /** How many commands run at once, so a burst of calls cannot start a process each. */
3071
+ const MAX_RUNNING = 4;
2997
3072
  /** toCliRunner runs the CLI itself, so a tool call parses exactly as a terminal would. */
2998
- function toCliRunner() {
2999
- return (args) => new Promise((resolve) => {
3000
- const child = spawn(process.execPath, [process.argv[1] ?? "", ...args], { env: {
3001
- ...process.env,
3002
- HARDFIN_NO_BROWSER: "1",
3003
- HARDFIN_MCP: "1"
3004
- } });
3073
+ function toCliRunner(timeoutMs = CALL_TIMEOUT_MS, entry = process.argv[1] ?? "") {
3074
+ const waiting = [];
3075
+ let running = 0;
3076
+ const acquire = async () => {
3077
+ if (running < MAX_RUNNING) {
3078
+ running += 1;
3079
+ return;
3080
+ }
3081
+ await new Promise((resolve) => waiting.push(resolve));
3082
+ };
3083
+ const release = () => {
3084
+ const next = waiting.shift();
3085
+ if (next) next();
3086
+ else running -= 1;
3087
+ };
3088
+ return async (args, input) => {
3089
+ await acquire();
3090
+ try {
3091
+ return await toRun(entry, args, input, timeoutMs);
3092
+ } finally {
3093
+ release();
3094
+ }
3095
+ };
3096
+ }
3097
+ /** toRun runs one command, feeding it input on stdin and stopping it at the deadline. */
3098
+ function toRun(entry, args, input, timeoutMs) {
3099
+ return new Promise((resolve) => {
3100
+ let child;
3101
+ try {
3102
+ child = spawn(process.execPath, [entry, ...args], { env: {
3103
+ ...process.env,
3104
+ HARDFIN_NO_BROWSER: "1",
3105
+ HARDFIN_MCP: "1"
3106
+ } });
3107
+ } catch (error) {
3108
+ resolve({
3109
+ stdout: "",
3110
+ stderr: `the command could not start: ${error instanceof Error ? error.message : String(error)}`,
3111
+ code: 1
3112
+ });
3113
+ return;
3114
+ }
3005
3115
  let stdout = "";
3006
3116
  let stderr = "";
3117
+ let isTimedOut = false;
3118
+ const timer = setTimeout(() => {
3119
+ isTimedOut = true;
3120
+ child.kill("SIGKILL");
3121
+ }, timeoutMs);
3007
3122
  child.stdout.on("data", (chunk) => {
3008
3123
  stdout += chunk.toString();
3009
3124
  });
3010
3125
  child.stderr.on("data", (chunk) => {
3011
3126
  stderr += chunk.toString();
3012
3127
  });
3128
+ child.on("error", (error) => {
3129
+ stderr += `the command could not start: ${error.message}\n`;
3130
+ });
3013
3131
  child.on("close", (code) => {
3132
+ clearTimeout(timer);
3133
+ if (isTimedOut) stderr += `this command took longer than ${timeoutMs / 1e3} seconds, so it was stopped\n`;
3014
3134
  resolve({
3015
3135
  stdout,
3016
3136
  stderr,
3017
- code: code ?? 1
3137
+ code: isTimedOut ? 1 : code ?? 1
3018
3138
  });
3019
3139
  });
3140
+ child.stdin.on("error", () => void 0);
3141
+ child.stdin.end(input ?? "");
3020
3142
  });
3021
3143
  }
3022
3144
  /** serve answers requests on stdin until the client closes it. */
@@ -6187,5 +6309,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
6187
6309
  //#endregion
6188
6310
  export { toCli, toParseExitCode, toReported, wasReportAttempted };
6189
6311
 
6190
- //# sourceMappingURL=program-ByvcKNri.js.map
6191
- //# debugId=93b4bc8d-ebeb-59ca-967b-9fd10484e4a5
6312
+ //# sourceMappingURL=program-DTsxnjRj.js.map
6313
+ //# debugId=c7f8ce05-d638-5d0f-a088-655ddade6f45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.1.0-dev.33",
3
+ "version": "0.1.0-dev.34",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",