@hardfin/cli 0.0.2-dev.15 → 0.0.2-dev.17

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.
Files changed (3) hide show
  1. package/README.md +91 -66
  2. package/dist/cli.js +152 -45
  3. package/package.json +13 -2
package/README.md CHANGED
@@ -26,37 +26,37 @@ npm install -g @hardfin/cli@dev
26
26
 
27
27
  Most commands are generated from the published API document rather than written by hand.
28
28
 
29
- | Piece | Holds |
30
- | --- | --- |
31
- | `hardfin api` | A call to any endpoint, written by hand, and the escape hatch when no generated command fits |
32
- | `src/command/surface.generated.ts` | Every endpoint as a command, rewritten by the generator |
33
- | `surface-overrides.json` | The operations whose generated name is wrong |
34
- | `spec/core.openapi.yaml` | The API document the generator reads, vendored here |
35
- | `scripts/generate-surface.mjs` | The generator |
29
+ | Piece | Holds |
30
+ | ---------------------------------- | -------------------------------------------------------------------------------------------- |
31
+ | `hardfin api` | A call to any endpoint, written by hand, and the escape hatch when no generated command fits |
32
+ | `src/command/surface.generated.ts` | Every endpoint as a command, rewritten by the generator |
33
+ | `surface-overrides.json` | The operations whose generated name is wrong |
34
+ | `spec/core.openapi.yaml` | The API document the generator reads, vendored here |
35
+ | `scripts/generate-surface.mjs` | The generator |
36
36
 
37
37
  ### How an endpoint becomes a command
38
38
 
39
39
  The first path segment is the noun. Every later literal segment nests under it. The method
40
40
  and the shape of the last segment decide the verb.
41
41
 
42
- | Endpoint | Command |
43
- | --- | --- |
44
- | `GET /asset` | `hardfin asset list` |
45
- | `POST /asset` | `hardfin asset create` |
46
- | `GET /asset/{assetKey}` | `hardfin asset get <assetKey>` |
47
- | `PATCH /asset/{assetKey}` | `hardfin asset update <assetKey>` |
42
+ | Endpoint | Command |
43
+ | ------------------------------------ | -------------------------------------------- |
44
+ | `GET /asset` | `hardfin asset list` |
45
+ | `POST /asset` | `hardfin asset create` |
46
+ | `GET /asset/{assetKey}` | `hardfin asset get <assetKey>` |
47
+ | `PATCH /asset/{assetKey}` | `hardfin asset update <assetKey>` |
48
48
  | `PATCH /asset/{assetKey}/accounting` | `hardfin asset accounting update <assetKey>` |
49
49
 
50
50
  Everything an endpoint takes becomes a flag or an argument. No command asks for JSON.
51
51
 
52
- | The document says | You type |
53
- | --- | --- |
54
- | A path parameter | a positional argument |
55
- | A query or body field | `--field-name`, in kebab case |
56
- | A field inside a nested object | `--address-city`, the path joined |
57
- | An array of values | the flag, repeated |
58
- | An array of objects | the flag, repeated, each carrying that element's fields as `key=value` |
59
- | A file upload | `--file <path>`, sent as multipart |
52
+ | The document says | You type |
53
+ | ------------------------------ | ---------------------------------------------------------------------- |
54
+ | A path parameter | a positional argument |
55
+ | A query or body field | `--field-name`, in kebab case |
56
+ | A field inside a nested object | `--address-city`, the path joined |
57
+ | An array of values | the flag, repeated |
58
+ | An array of objects | the flag, repeated, each carrying that element's fields as `key=value` |
59
+ | A file upload | `--file <path>`, sent as multipart |
60
60
 
61
61
  ```sh
62
62
  hardfin asset ownership create ast_4f9xk2mq7plr8stz \
@@ -71,6 +71,13 @@ A value is sent as the type the document names. `--useful-life 36` sends the num
71
71
  decimal such as `--salvage-value 1500.00` stays a string, which is how the API takes money
72
72
  without losing precision.
73
73
 
74
+ A download is written where you name it, and a terminal is never filled with a file's bytes.
75
+
76
+ ```sh
77
+ hardfin file get file_7hq2mx9pkr4stz8w --output photo.jpg
78
+ hardfin file get file_7hq2mx9pkr4stz8w --output - | wc -c
79
+ ```
80
+
74
81
  Clear a field with `--unset`, naming the flag:
75
82
 
76
83
  ```sh
@@ -118,10 +125,10 @@ what the api-spec bridge publishes. Commit the document and the generated comman
118
125
 
119
126
  Two checks keep the pair honest.
120
127
 
121
- | Check | Refuses |
122
- | --- | --- |
128
+ | Check | Refuses |
129
+ | ------------- | ----------------------------------------------------------------------------- |
123
130
  | The generator | a document whose `info.version` is not a date, which means an earlier release |
124
- | CI | a vendored document that was updated without regenerating the commands |
131
+ | CI | a vendored document that was updated without regenerating the commands |
125
132
 
126
133
  The first one matters because `hardfinhq/api-spec` can sit a release behind the monorepo
127
134
  while its bridge pull request is open. Generating from that document would replace the
@@ -151,9 +158,9 @@ so CI and an agent sandbox need no browser.
151
158
 
152
159
  While it waits, the terminal takes two things.
153
160
 
154
- | Key | Does |
155
- | --- | --- |
156
- | `c` | Copies the URL to the clipboard |
161
+ | Key | Does |
162
+ | -------------------------- | ----------------------------------------- |
163
+ | `c` | Copies the URL to the clipboard |
157
164
  | A pasted value, then enter | Finishes the sign in without the listener |
158
165
 
159
166
  Paste whichever of these you have: the whole redirect URL from the browser's address bar,
@@ -190,9 +197,9 @@ nothing about whether the credential is good.
190
197
 
191
198
  ### Where the refresh token is kept
192
199
 
193
- | Host | Kept in |
194
- | --- | --- |
195
- | macOS, Windows, and Linux with a secret service | the OS keyring |
200
+ | Host | Kept in |
201
+ | --------------------------------------------------------------- | ----------------------------------------------------- |
202
+ | macOS, Windows, and Linux with a secret service | the OS keyring |
196
203
  | Everything else, including WSL, containers, and agent sandboxes | `$XDG_STATE_HOME/hardfin/credentials.json`, mode 0600 |
197
204
 
198
205
  `HARDFIN_NO_BROWSER=1` keeps `hardfin login` from opening a tab, which is what a script or a
@@ -210,10 +217,10 @@ production never share a sign in.
210
217
  Nothing. Hardfin registers this CLI as a first-party client, and its key is the same in every
211
218
  environment, so `hardfin login` works out of the box.
212
219
 
213
- | Setting | Holds | Default |
214
- | --- | --- | --- |
215
- | `clientId`, or `HARDFIN_CLIENT_ID` | the client this CLI names itself as | the seeded Hardfin CLI client |
216
- | `issuerUrl`, or `HARDFIN_ISSUER_URL` | the authorization server | the API's host root |
220
+ | Setting | Holds | Default |
221
+ | ------------------------------------ | ----------------------------------- | ----------------------------- |
222
+ | `clientId`, or `HARDFIN_CLIENT_ID` | the client this CLI names itself as | the seeded Hardfin CLI client |
223
+ | `issuerUrl`, or `HARDFIN_ISSUER_URL` | the authorization server | the API's host root |
217
224
 
218
225
  ## Completing commands in your shell
219
226
 
@@ -244,11 +251,11 @@ claude mcp add hardfin -- hardfin mcp
244
251
  Every tool a server lists sits in the agent's context for the entire session, so this server
245
252
  offers one.
246
253
 
247
- | Offered as | Holds | Costs context |
248
- | --- | --- | --- |
249
- | The `hardfin` tool | A list of arguments, such as `["asset", "list", "--limit", "5"]` | Always, and it is about 400 bytes |
250
- | `hardfin://guide` | Every command, its flags, and the exit codes | Only when the agent reads it |
251
- | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
254
+ | Offered as | Holds | Costs context |
255
+ | -------------------- | ---------------------------------------------------------------- | --------------------------------- |
256
+ | The `hardfin` tool | A list of arguments, such as `["asset", "list", "--limit", "5"]` | Always, and it is about 400 bytes |
257
+ | `hardfin://guide` | Every command, its flags, and the exit codes | Only when the agent reads it |
258
+ | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
252
259
 
253
260
  Fifty tools would describe the same surface and crowd out the work. An agent that needs the
254
261
  list reads a resource, or runs `["--help"]`.
@@ -272,14 +279,14 @@ hardfin status --json # for a support request
272
279
  hardfin status --offline # no network calls at all
273
280
  ```
274
281
 
275
- | Section | Holds |
276
- | --- | --- |
277
- | `cli` | The version, the API version it speaks, Node, and whether a terminal is attached |
278
- | `host` | The operating system, kernel, build, processor, memory, timezone, shell, and the distribution and WSL details on Linux |
279
- | `configuration` | Every setting, its value, and the layer it came from |
280
- | `credential` | Whether you are signed in, where the refresh token is kept, and when it was issued and last renewed |
281
- | `authorizationServer` | Whether discovery worked, and every endpoint it named |
282
- | `api` | Whether the API accepted the credential, when the access token expires, and its scopes |
282
+ | Section | Holds |
283
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------- |
284
+ | `cli` | The version, the API version it speaks, Node, and whether a terminal is attached |
285
+ | `host` | The operating system, kernel, build, processor, memory, timezone, shell, and the distribution and WSL details on Linux |
286
+ | `configuration` | Every setting, its value, and the layer it came from |
287
+ | `credential` | Whether you are signed in, where the refresh token is kept, and when it was issued and last renewed |
288
+ | `authorizationServer` | Whether discovery worked, and every endpoint it named |
289
+ | `api` | Whether the API accepted the credential, when the access token expires, and its scopes |
283
290
 
284
291
  Nothing secret is printed. An API key and a refresh token are each reported as a
285
292
  `sha256:` fingerprint, which identifies a credential across two machines without disclosing
@@ -294,13 +301,13 @@ says which of the two you are looking at.
294
301
  A local build reaches a local server without editing code. Four layers supply the same
295
302
  settings, and the one nearest the top wins.
296
303
 
297
- | Layer | Where | Beats |
298
- | --- | --- | --- |
299
- | Flag | `--api-url` | everything below |
300
- | Environment | an exported `HARDFIN_*` variable | the files below |
301
- | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
302
- | Config file | `config.local.json` in the working directory | the defaults |
303
- | Default | the published API | nothing |
304
+ | Layer | Where | Beats |
305
+ | ----------- | --------------------------------------------------------------------- | ---------------- |
306
+ | Flag | `--api-url` | everything below |
307
+ | Environment | an exported `HARDFIN_*` variable | the files below |
308
+ | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
309
+ | Config file | `config.local.json` in the working directory | the defaults |
310
+ | Default | the published API | nothing |
304
311
 
305
312
  An exported variable beats `.env` because Node leaves a variable that is already set alone.
306
313
 
@@ -336,6 +343,24 @@ The CLI reads whatever `config.local.json` and `.env` sit in the directory you r
336
343
  A directory you do not control can therefore point the CLI at a server you do not expect, so
337
344
  run `hardfin config` when a command reaches somewhere surprising.
338
345
 
346
+ ## Working on the CLI
347
+
348
+ ```sh
349
+ npm run check-types # tsc
350
+ npm run lint # eslint, with type-aware rules
351
+ npm run prettier:write # formatting
352
+ npm test # vitest
353
+ npm run build # tsdown, into dist/cli.js
354
+ ```
355
+
356
+ CI runs all five on every pull request, plus a check that the generated commands still match
357
+ the vendored API document. A pull request that fails any of them cannot merge.
358
+
359
+ Two lint rules exist because of defects that reached the repository. `no-floating-promises`
360
+ covers a promise nobody waits for, which once killed the process when a sign in was
361
+ cancelled. `no-base-to-string` covers a value stringified as `[object Object]`, which a
362
+ request would have carried to the API.
363
+
339
364
  ## Releasing
340
365
 
341
366
  This section is for anyone who merges a pull request in this repository. It tells you where
@@ -358,11 +383,11 @@ package published from a public repository, and this repository is private.
358
383
 
359
384
  ### What each merge publishes
360
385
 
361
- | Merge | Publishes | Version | Dist-tag |
362
- | --- | --- | --- | --- |
363
- | Pull request into `dev` | always | the next version, suffixed `-dev.<run number>` | `dev` |
364
- | Promotion into `main` | when `package.json` names a version that is not yet on npm | that version | `latest` |
365
- | Hotfix into `main` | same rule as a promotion | that version | `latest` |
386
+ | Merge | Publishes | Version | Dist-tag |
387
+ | ----------------------- | ---------------------------------------------------------- | ---------------------------------------------- | -------- |
388
+ | Pull request into `dev` | always | the next version, suffixed `-dev.<run number>` | `dev` |
389
+ | Promotion into `main` | when `package.json` names a version that is not yet on npm | that version | `latest` |
390
+ | Hotfix into `main` | same rule as a promotion | that version | `latest` |
366
391
 
367
392
  A preview never moves `latest`, so `npm install @hardfin/cli` keeps returning the released
368
393
  version.
@@ -395,13 +420,13 @@ on `main` until someone brings it back.
395
420
  A required check reads `version` from `package.json` on every pull request and judges it
396
421
  against the branch the pull request targets.
397
422
 
398
- | Version | Into `dev` | Into `main` |
399
- | --- | --- | --- |
400
- | Plain, above what `main` holds | passes | passes |
401
- | Plain, equal to what `main` holds | passes | refused, because npm already serves it |
402
- | Plain, below what `main` holds | refused | refused |
403
- | Carrying a `-dev` or any other prerelease suffix | refused | refused |
404
- | Not a semver version | refused | refused |
423
+ | Version | Into `dev` | Into `main` |
424
+ | ------------------------------------------------ | ---------- | -------------------------------------- |
425
+ | Plain, above what `main` holds | passes | passes |
426
+ | Plain, equal to what `main` holds | passes | refused, because npm already serves it |
427
+ | Plain, below what `main` holds | refused | refused |
428
+ | Carrying a `-dev` or any other prerelease suffix | refused | refused |
429
+ | Not a semver version | refused | refused |
405
430
 
406
431
  A prerelease suffix is refused everywhere because the release workflow appends it at publish
407
432
  time. A version equal to `main`'s is fine on `dev`, since the workflow increments the patch
package/dist/cli.js CHANGED
@@ -120,6 +120,16 @@ function toOrigin(apiUrl) {
120
120
  }
121
121
  //#endregion
122
122
  //#region src/output/writer.ts
123
+ /**
124
+ * toText reads a value a caller supplied, which arrives typed as unknown. An object would
125
+ * otherwise reach a request as the text "[object Object]".
126
+ */
127
+ function toText(value) {
128
+ if (typeof value === "string") return value;
129
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
130
+ if (value === void 0 || value === null) return "";
131
+ return JSON.stringify(value);
132
+ }
123
133
  /** writeData prints a command's result on stdout. */
124
134
  function writeData(value) {
125
135
  if (typeof value === "string") {
@@ -248,7 +258,8 @@ function toSummary(command) {
248
258
  valueName: flag.valueName ?? null,
249
259
  repeatable: flag.repeatable ?? false
250
260
  })),
251
- examples: command.examples
261
+ examples: command.examples,
262
+ subcommands: command.subcommands?.filter((entry) => !entry.hidden).map(toSummary)
252
263
  };
253
264
  }
254
265
  //#endregion
@@ -372,7 +383,7 @@ async function toTokens(url, form) {
372
383
  body: new URLSearchParams(form)
373
384
  });
374
385
  const body = await response.json().catch(() => void 0);
375
- if (!response.ok) throw new GrantFailure(String(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : String(body["error_description"]));
386
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
376
387
  const parsed = TokenResponse.safeParse(body);
377
388
  if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
378
389
  return {
@@ -477,12 +488,12 @@ function forgetKeyring(issuer) {
477
488
  function forgetFile(issuer) {
478
489
  const held = readFile();
479
490
  if (held[issuer] === void 0) return;
480
- delete held[issuer];
481
- if (Object.keys(held).length === 0) {
491
+ const remaining = Object.fromEntries(Object.entries(held).filter(([name]) => name !== issuer));
492
+ if (Object.keys(remaining).length === 0) {
482
493
  rmSync(toCredentialPath(), { force: true });
483
494
  return;
484
495
  }
485
- writeFile(held);
496
+ writeFile(remaining);
486
497
  }
487
498
  /** toKeyring opens the OS keyring, or answers undefined where the platform has none. */
488
499
  function toKeyring(issuer) {
@@ -709,11 +720,11 @@ var RequestFailure = class extends Error {
709
720
  /** request calls one /v2 endpoint and returns the envelope it answered with. */
710
721
  async function request(options) {
711
722
  const url = new URL(`${options.apiUrl}${toLeadingSlash(options.path)}`);
712
- if (options.query) url.search = options.query.toString();
723
+ for (const [name, value] of options.query ?? []) url.searchParams.append(name, value);
713
724
  const headers = {
714
725
  [options.credential.header]: options.credential.value,
715
726
  "X-API-Version": API_VERSION,
716
- Accept: "application/json"
727
+ Accept: options.downloads ? "*/*" : "application/json"
717
728
  };
718
729
  if (options.body !== void 0 && options.form === void 0) headers["Content-Type"] = "application/json";
719
730
  const response = await fetch(url, {
@@ -721,6 +732,11 @@ async function request(options) {
721
732
  headers,
722
733
  body: options.form ?? (options.body === void 0 ? void 0 : JSON.stringify(options.body))
723
734
  });
735
+ if (options.downloads && response.ok) return { data: {
736
+ bytes: Buffer.from(await response.arrayBuffer()),
737
+ contentType: response.headers.get("content-type"),
738
+ fileName: toFileName(response.headers.get("content-disposition"))
739
+ } };
724
740
  const envelope = toEnvelope(await response.text());
725
741
  if (!response.ok && envelope === void 0) throw new RequestFailure(response.status, [{
726
742
  error: toStatusMessage(response.status),
@@ -735,6 +751,11 @@ async function request(options) {
735
751
  }
736
752
  return envelope ?? { data: null };
737
753
  }
754
+ /** toFileName reads the name a download was offered under, when the server names one. */
755
+ function toFileName(disposition) {
756
+ const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition ?? "");
757
+ return matched?.[1] ? decodeURIComponent(matched[1]) : void 0;
758
+ }
738
759
  function toLeadingSlash(path) {
739
760
  return path.startsWith("/") ? path : `/${path}`;
740
761
  }
@@ -834,7 +855,7 @@ async function runApi(input) {
834
855
  writeData((await request({
835
856
  apiUrl: input.resolved.settings.apiUrl,
836
857
  credential: await toRequestCredential(input.resolved.settings),
837
- method: String(input.flags["method"] ?? "GET").toUpperCase(),
858
+ method: toText(input.flags["method"] ?? "GET").toUpperCase(),
838
859
  path,
839
860
  query,
840
861
  body
@@ -962,7 +983,7 @@ function toFlagNames(command) {
962
983
  return [...(command?.flags ?? []).map((flag) => `--${flag.name}`), "--help"];
963
984
  }
964
985
  async function runCompletion(input) {
965
- const shell = String(input.args[0] ?? "");
986
+ const shell = input.args[0] ?? "";
966
987
  if (!SHELLS.includes(shell)) {
967
988
  writeFailure(`${shell || "no shell"} is not one this CLI writes for. Choose ${SHELLS.join(", ")}`, input.isJSON);
968
989
  return ExitCode.USAGE;
@@ -1116,7 +1137,7 @@ async function toDeviceAuthorization(endpoint, clientId, scope) {
1116
1137
  })
1117
1138
  });
1118
1139
  const body = await response.json().catch(() => void 0);
1119
- if (!response.ok) throw new GrantFailure(String(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : String(body["error_description"]));
1140
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
1120
1141
  const parsed = DeviceResponse.safeParse(body);
1121
1142
  if (!parsed.success) throw new GrantFailure("invalid_response", "the device endpoint did not answer with a code");
1122
1143
  return {
@@ -1192,8 +1213,12 @@ async function toListener(timeoutMs) {
1192
1213
  }, timeoutMs);
1193
1214
  return {
1194
1215
  redirectUri: `http://${HOST}:${server.address().port}${CALLBACK_PATH}`,
1195
- callback: callback.finally(() => close(server, timer)),
1196
- close: () => close(server, timer)
1216
+ callback: callback.finally(() => {
1217
+ close(server, timer);
1218
+ }),
1219
+ close: () => {
1220
+ close(server, timer);
1221
+ }
1197
1222
  };
1198
1223
  }
1199
1224
  function toCallback(request) {
@@ -1395,7 +1420,7 @@ async function runLogin(input) {
1395
1420
  try {
1396
1421
  const clientId = settings.clientId;
1397
1422
  const metadata = await toMetadata(settings.issuerUrl);
1398
- const scope = String(input.flags["scope"] ?? DEFAULT_SCOPES);
1423
+ const scope = toText(input.flags["scope"] ?? DEFAULT_SCOPES);
1399
1424
  if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
1400
1425
  const listener = await toListener(WAIT_MS);
1401
1426
  const pkce = toPkce();
@@ -1551,7 +1576,9 @@ async function runLogout(input) {
1551
1576
  } catch {
1552
1577
  revoked = false;
1553
1578
  }
1554
- await withLock(() => forget(settings.issuerUrl));
1579
+ await withLock(() => {
1580
+ forget(settings.issuerUrl);
1581
+ });
1555
1582
  forgetHeldTokens();
1556
1583
  writeData({
1557
1584
  signedOut: true,
@@ -1613,7 +1640,7 @@ async function toResponse(request, commands, run) {
1613
1640
  description: "The command tree as JSON",
1614
1641
  mimeType: "application/json"
1615
1642
  }] });
1616
- case "resources/read": return answer(toResource(String(request.params?.["uri"] ?? ""), commands));
1643
+ case "resources/read": return answer(toResource(toText(request.params?.["uri"] ?? ""), commands));
1617
1644
  case "tools/call": return answer(await toToolResult(request.params, run));
1618
1645
  case "ping": return answer({});
1619
1646
  default:
@@ -1658,7 +1685,21 @@ function toTree(commands) {
1658
1685
  subcommands: command.subcommands ? toTree(command.subcommands) : void 0
1659
1686
  }));
1660
1687
  }
1688
+ /** Signing in needs a browser and a person, neither of which an agent's session has. */
1689
+ const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
1690
+ "login",
1691
+ "logout",
1692
+ "mcp"
1693
+ ]);
1661
1694
  async function toToolResult(params, run) {
1695
+ const name = params?.["name"];
1696
+ if (name !== void 0 && name !== TOOL.name) return {
1697
+ content: [{
1698
+ type: "text",
1699
+ text: `this server offers one tool, ${TOOL.name}`
1700
+ }],
1701
+ isError: true
1702
+ };
1662
1703
  const args = (params?.["arguments"])?.args;
1663
1704
  if (!Array.isArray(args) || args.some((entry) => typeof entry !== "string")) return {
1664
1705
  content: [{
@@ -1667,7 +1708,15 @@ async function toToolResult(params, run) {
1667
1708
  }],
1668
1709
  isError: true
1669
1710
  };
1670
- const outcome = await run(args);
1711
+ const asked = args;
1712
+ if (REFUSED_COMMANDS.has(asked[0] ?? "")) return {
1713
+ content: [{
1714
+ type: "text",
1715
+ text: `${asked[0] ?? ""} is run by a person at a terminal, not through this server. Run hardfin ${asked[0] ?? ""} yourself, then call this tool again`
1716
+ }],
1717
+ isError: true
1718
+ };
1719
+ const outcome = await run(asked);
1671
1720
  return {
1672
1721
  content: [{
1673
1722
  type: "text",
@@ -1676,6 +1725,14 @@ async function toToolResult(params, run) {
1676
1725
  isError: outcome.code !== 0
1677
1726
  };
1678
1727
  }
1728
+ /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
1729
+ function toRequestId(line) {
1730
+ try {
1731
+ return JSON.parse(line).id ?? null;
1732
+ } catch {
1733
+ return null;
1734
+ }
1735
+ }
1679
1736
  /** toCliRunner runs the CLI itself, so a tool call parses exactly as a terminal would. */
1680
1737
  function toCliRunner() {
1681
1738
  return (args) => new Promise((resolve) => {
@@ -1691,11 +1748,13 @@ function toCliRunner() {
1691
1748
  child.stderr.on("data", (chunk) => {
1692
1749
  stderr += chunk.toString();
1693
1750
  });
1694
- child.on("close", (code) => resolve({
1695
- stdout,
1696
- stderr,
1697
- code: code ?? 1
1698
- }));
1751
+ child.on("close", (code) => {
1752
+ resolve({
1753
+ stdout,
1754
+ stderr,
1755
+ code: code ?? 1
1756
+ });
1757
+ });
1699
1758
  });
1700
1759
  }
1701
1760
  /** serve answers requests on stdin until the client closes it. */
@@ -1709,9 +1768,9 @@ async function serve(commands, run = toCliRunner()) {
1709
1768
  } catch (error) {
1710
1769
  response = {
1711
1770
  jsonrpc: "2.0",
1712
- id: null,
1771
+ id: toRequestId(line),
1713
1772
  error: {
1714
- code: -32700,
1773
+ code: -32603,
1715
1774
  message: error instanceof Error ? error.message : String(error)
1716
1775
  }
1717
1776
  };
@@ -1766,6 +1825,12 @@ const FILE_FLAG = {
1766
1825
  valueName: "path",
1767
1826
  schema: z.string()
1768
1827
  };
1828
+ const OUTPUT_FLAG = {
1829
+ name: "output",
1830
+ description: "Where to write the file, or - for standard output",
1831
+ valueName: "path",
1832
+ schema: z.string()
1833
+ };
1769
1834
  const JSON_FLAG = {
1770
1835
  name: "json",
1771
1836
  description: "Print machine-readable output, which is the default when stdout is not a terminal",
@@ -1778,6 +1843,7 @@ function defineOperation(operation) {
1778
1843
  ...operation.bodyFlags,
1779
1844
  ...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
1780
1845
  ...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
1846
+ ...operation.downloads ? [OUTPUT_FLAG] : [],
1781
1847
  JSON_FLAG
1782
1848
  ];
1783
1849
  return {
@@ -1823,15 +1889,18 @@ async function runOperation(operation, input) {
1823
1889
  form = built;
1824
1890
  }
1825
1891
  try {
1826
- writeData((await request({
1892
+ const envelope = await request({
1827
1893
  apiUrl: input.resolved.settings.apiUrl,
1828
1894
  credential: await toRequestCredential(input.resolved.settings),
1829
1895
  method: operation.method,
1830
1896
  path,
1831
1897
  query: toQuery(operation, input.flags),
1832
1898
  body,
1833
- form
1834
- })).data);
1899
+ form,
1900
+ downloads: operation.downloads
1901
+ });
1902
+ if (operation.downloads) return toWritten(envelope.data, input);
1903
+ writeData(envelope.data);
1835
1904
  return ExitCode.OK;
1836
1905
  } catch (error) {
1837
1906
  if (error instanceof NoCredential) {
@@ -1846,6 +1915,29 @@ async function runOperation(operation, input) {
1846
1915
  return ExitCode.ERROR;
1847
1916
  }
1848
1917
  }
1918
+ /**
1919
+ * toWritten puts a downloaded file where it was asked for. A terminal is never written to,
1920
+ * because a person would otherwise have their session filled with a file's bytes.
1921
+ */
1922
+ function toWritten(download, input) {
1923
+ const asked = input.flags["output"];
1924
+ const path = typeof asked === "string" ? asked : download.fileName ?? "-";
1925
+ if (path === "-") {
1926
+ if (process.stdout.isTTY) {
1927
+ writeFailure("this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
1928
+ return ExitCode.USAGE;
1929
+ }
1930
+ process.stdout.write(download.bytes);
1931
+ return ExitCode.OK;
1932
+ }
1933
+ writeFileSync(path, download.bytes);
1934
+ writeData({
1935
+ written: path,
1936
+ bytes: download.bytes.length,
1937
+ contentType: download.contentType
1938
+ });
1939
+ return ExitCode.OK;
1940
+ }
1849
1941
  /** toPath fills the path template from the positional arguments, in order. */
1850
1942
  function toPath(operation, args) {
1851
1943
  if (args.length !== operation.pathParameters.length) return;
@@ -1861,7 +1953,7 @@ function toQuery(operation, flags) {
1861
1953
  if (value === void 0) continue;
1862
1954
  const parsed = flag.schema.safeParse(value);
1863
1955
  const carried = parsed.success ? parsed.data : value;
1864
- for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, String(entry));
1956
+ for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
1865
1957
  }
1866
1958
  return query;
1867
1959
  }
@@ -1883,7 +1975,7 @@ async function toForm(upload, flags) {
1883
1975
  const value = flags[toOptionKey(field.name)];
1884
1976
  if (value !== void 0) {
1885
1977
  const parsed = field.schema.safeParse(value);
1886
- form.append(field.jsonPath[0] ?? field.name, String(parsed.success ? parsed.data : value));
1978
+ form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
1887
1979
  }
1888
1980
  }
1889
1981
  return form;
@@ -1896,7 +1988,7 @@ function toBody(operation, flags) {
1896
1988
  const value = flags[toOptionKey(flag.name)];
1897
1989
  if (value === void 0) continue;
1898
1990
  if (flag.element) {
1899
- const elements = toElements(flag, Array.isArray(value) ? value.map(String) : [String(value)]);
1991
+ const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
1900
1992
  if (elements instanceof Error) return elements;
1901
1993
  set(body, flag.jsonPath, elements);
1902
1994
  hasField = true;
@@ -1913,7 +2005,7 @@ function toBody(operation, flags) {
1913
2005
  /** toCleared sends null for each field named by --unset, which is how a field is cleared. */
1914
2006
  function toCleared(operation, flags, body) {
1915
2007
  const named = flags["unset"];
1916
- const names = Array.isArray(named) ? named.map(String) : named === void 0 ? [] : [String(named)];
2008
+ const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
1917
2009
  for (const name of names) {
1918
2010
  const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
1919
2011
  if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
@@ -2575,6 +2667,7 @@ const surfaceCommands = [
2575
2667
  name: "is-in-service-date-managed-automatically",
2576
2668
  jsonPath: ["isInServiceDateManagedAutomatically"],
2577
2669
  description: "Whether Hardfin sets the in-service date itself, which sending an in-service date turns off",
2670
+ negatable: true,
2578
2671
  nullable: true,
2579
2672
  schema: z.boolean()
2580
2673
  },
@@ -2641,6 +2734,7 @@ const surfaceCommands = [
2641
2734
  name: "automatic",
2642
2735
  jsonPath: ["automatic"],
2643
2736
  description: "Whether Hardfin sets the asset's in-service date itself",
2737
+ negatable: true,
2644
2738
  required: true,
2645
2739
  schema: z.boolean()
2646
2740
  }]
@@ -3343,12 +3437,14 @@ const surfaceCommands = [
3343
3437
  name: "is-customer",
3344
3438
  jsonPath: ["isCustomer"],
3345
3439
  description: "Whether the company is a customer",
3440
+ negatable: true,
3346
3441
  schema: z.boolean()
3347
3442
  },
3348
3443
  {
3349
3444
  name: "is-supplier",
3350
3445
  jsonPath: ["isSupplier"],
3351
3446
  description: "Whether the company is a supplier",
3447
+ negatable: true,
3352
3448
  schema: z.boolean()
3353
3449
  },
3354
3450
  {
@@ -3448,6 +3544,7 @@ const surfaceCommands = [
3448
3544
  name: "is-archived",
3449
3545
  jsonPath: ["isArchived"],
3450
3546
  description: "Whether the customer is archived",
3547
+ negatable: true,
3451
3548
  nullable: true,
3452
3549
  schema: z.boolean()
3453
3550
  },
@@ -3455,6 +3552,7 @@ const surfaceCommands = [
3455
3552
  name: "is-customer",
3456
3553
  jsonPath: ["isCustomer"],
3457
3554
  description: "Whether the company is a customer",
3555
+ negatable: true,
3458
3556
  nullable: true,
3459
3557
  schema: z.boolean()
3460
3558
  },
@@ -3462,6 +3560,7 @@ const surfaceCommands = [
3462
3560
  name: "is-supplier",
3463
3561
  jsonPath: ["isSupplier"],
3464
3562
  description: "Whether the company is a supplier",
3563
+ negatable: true,
3465
3564
  nullable: true,
3466
3565
  schema: z.boolean()
3467
3566
  },
@@ -3536,7 +3635,8 @@ const surfaceCommands = [
3536
3635
  description: "True when the file downloads as an attachment rather than opening inline",
3537
3636
  schema: z.boolean()
3538
3637
  }],
3539
- bodyFlags: []
3638
+ bodyFlags: [],
3639
+ downloads: true
3540
3640
  })]
3541
3641
  },
3542
3642
  {
@@ -3639,6 +3739,7 @@ const surfaceCommands = [
3639
3739
  name: "accepts-bulk-serials",
3640
3740
  jsonPath: ["acceptsBulkSerials"],
3641
3741
  description: "Whether a BULK item records serial numbers on its units, which SERVICE and DEVICE items ignore",
3742
+ negatable: true,
3642
3743
  schema: z.boolean()
3643
3744
  },
3644
3745
  {
@@ -3775,6 +3876,7 @@ const surfaceCommands = [
3775
3876
  name: "accepts-bulk-serials",
3776
3877
  jsonPath: ["acceptsBulkSerials"],
3777
3878
  description: "Whether a BULK item records serial numbers on its units, read only beside type",
3879
+ negatable: true,
3778
3880
  nullable: true,
3779
3881
  schema: z.boolean()
3780
3882
  },
@@ -3803,6 +3905,7 @@ const surfaceCommands = [
3803
3905
  name: "is-archived",
3804
3906
  jsonPath: ["isArchived"],
3805
3907
  description: "Whether the item is archived, which cannot be null",
3908
+ negatable: true,
3806
3909
  nullable: true,
3807
3910
  schema: z.boolean()
3808
3911
  },
@@ -4272,18 +4375,21 @@ const surfaceCommands = [
4272
4375
  name: "is-inventory",
4273
4376
  jsonPath: ["isInventory"],
4274
4377
  description: "Whether assets at the location count as inventory for reporting",
4378
+ negatable: true,
4275
4379
  schema: z.boolean()
4276
4380
  },
4277
4381
  {
4278
4382
  name: "is-inventory-override",
4279
4383
  jsonPath: ["isInventoryOverride"],
4280
4384
  description: "Whether a zone sets its own isInventory rather than inheriting its parent site's, which a site refuses",
4385
+ negatable: true,
4281
4386
  schema: z.boolean()
4282
4387
  },
4283
4388
  {
4284
4389
  name: "is-transient",
4285
4390
  jsonPath: ["isTransient"],
4286
4391
  description: "Whether assets make only occasional or temporary stops at the location, which hides it from location lists by default",
4392
+ negatable: true,
4287
4393
  schema: z.boolean()
4288
4394
  },
4289
4395
  {
@@ -4425,6 +4531,7 @@ const surfaceCommands = [
4425
4531
  name: "is-archived",
4426
4532
  jsonPath: ["isArchived"],
4427
4533
  description: "Whether the location is archived, and archiving a site archives its zones",
4534
+ negatable: true,
4428
4535
  nullable: true,
4429
4536
  schema: z.boolean()
4430
4537
  },
@@ -4432,6 +4539,7 @@ const surfaceCommands = [
4432
4539
  name: "is-inventory",
4433
4540
  jsonPath: ["isInventory"],
4434
4541
  description: "Whether assets at the location count as inventory for reporting",
4542
+ negatable: true,
4435
4543
  nullable: true,
4436
4544
  schema: z.boolean()
4437
4545
  },
@@ -4439,6 +4547,7 @@ const surfaceCommands = [
4439
4547
  name: "is-inventory-override",
4440
4548
  jsonPath: ["isInventoryOverride"],
4441
4549
  description: "Whether a zone sets its own isInventory rather than inheriting its parent site's, which a site refuses",
4550
+ negatable: true,
4442
4551
  nullable: true,
4443
4552
  schema: z.boolean()
4444
4553
  },
@@ -4446,6 +4555,7 @@ const surfaceCommands = [
4446
4555
  name: "is-transient",
4447
4556
  jsonPath: ["isTransient"],
4448
4557
  description: "Whether assets make only occasional or temporary stops at the location, which hides it from location lists by default",
4558
+ negatable: true,
4449
4559
  nullable: true,
4450
4560
  schema: z.boolean()
4451
4561
  },
@@ -4694,27 +4804,24 @@ function toEnvironmentReport() {
4694
4804
  apiVersion: API_VERSION,
4695
4805
  node: process.version,
4696
4806
  platform: process.platform,
4697
- interactive: Boolean(process.stdin.isTTY)
4807
+ interactive: process.stdin.isTTY
4698
4808
  };
4699
4809
  }
4700
4810
  /** toConfigurationReport names every setting, its source, and never a secret's value. */
4701
4811
  function toConfigurationReport(resolved) {
4702
- const entries = Object.entries(resolved.settings).map(([key, value]) => {
4812
+ const report = { configFile: CONFIG_FILE };
4813
+ for (const [key, value] of Object.entries(resolved.settings)) {
4703
4814
  const from = resolved.sources[key];
4704
- if (key === "apiKey") return [key, {
4815
+ report[key] = key === "apiKey" ? {
4705
4816
  set: value !== void 0,
4706
4817
  fingerprint: toFingerprint(value),
4707
4818
  from
4708
- }];
4709
- return [key, {
4819
+ } : {
4710
4820
  value: value ?? null,
4711
4821
  from
4712
- }];
4713
- });
4714
- return {
4715
- ...Object.fromEntries(entries),
4716
- configFile: CONFIG_FILE
4717
- };
4822
+ };
4823
+ }
4824
+ return report;
4718
4825
  }
4719
4826
  function toCredentialReport(apiKey, stored) {
4720
4827
  if (apiKey) return {
@@ -4810,8 +4917,7 @@ function toFlattened(value) {
4810
4917
  if (value === null || typeof value !== "object" || Array.isArray(value)) return;
4811
4918
  const holder = value;
4812
4919
  if (holder.from === void 0) return;
4813
- const shown = holder.value ?? (holder.set ? holder.fingerprint ?? "set" : "not set");
4814
- return `${String(shown)} (${holder.from})`;
4920
+ return `${toText(holder.value ?? (holder.set ? holder.fingerprint ?? "set" : "not set"))} (${holder.from})`;
4815
4921
  }
4816
4922
  /** toLines lays the report out for a person, one indented line per value. */
4817
4923
  function toLines(report, depth = 0) {
@@ -4873,6 +4979,7 @@ function toProgram(command) {
4873
4979
  const short = flag.short ? `-${flag.short}, ` : "";
4874
4980
  const value = flag.valueName ? ` <${flag.valueName}>` : "";
4875
4981
  const option = new Option(`${short}--${flag.name}${value}`, flag.description);
4982
+ if (flag.negatable) program.addOption(new Option(`--no-${flag.name}`, `${flag.description}, turned off`));
4876
4983
  if (flag.repeatable) option.argParser(collect);
4877
4984
  if (flag.defaultValue !== void 0) option.default(flag.defaultValue);
4878
4985
  program.addOption(option);
@@ -4913,7 +5020,7 @@ async function toExitCode(command, args, flags) {
4913
5020
  }
4914
5021
  function toArgumentList(value) {
4915
5022
  if (Array.isArray(value)) return value.map(String);
4916
- return value === void 0 ? [] : [String(value)];
5023
+ return value === void 0 ? [] : [toText(value)];
4917
5024
  }
4918
5025
  function collect(value, previous) {
4919
5026
  return [...previous ?? [], value];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.0.2-dev.15",
3
+ "version": "0.0.2-dev.17",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",
@@ -37,7 +37,12 @@
37
37
  "check-types": "tsc --noEmit",
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
- "prepublishOnly": "npm run build"
40
+ "prepublishOnly": "npm run build",
41
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
42
+ "lint:fix": "eslint . --fix",
43
+ "prettier:check": "prettier --check .",
44
+ "prettier:write": "prettier --write .",
45
+ "test:coverage": "vitest run --coverage"
41
46
  },
42
47
  "dependencies": {
43
48
  "@napi-rs/keyring": "^2.1.0",
@@ -45,10 +50,16 @@
45
50
  "zod": "^4.6.5"
46
51
  },
47
52
  "devDependencies": {
53
+ "@eslint/js": "^10.0.1",
48
54
  "@types/node": "^22.15.0",
49
55
  "@vitest/coverage-v8": "^5.0.1",
56
+ "eslint": "^10.11.0",
57
+ "eslint-plugin-unused-imports": "^4.4.1",
58
+ "globals": "^17.12.0",
59
+ "prettier": "^3.9.9",
50
60
  "tsdown": "^0.23.0",
51
61
  "typescript": "^5.9.0",
62
+ "typescript-eslint": "^8.70.1",
52
63
  "unrun": "^0.3.1",
53
64
  "vitest": "^5.0.1",
54
65
  "yaml": "^2.9.1"