@hardfin/cli 0.0.2-dev.8 → 0.1.0-dev.19
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 +260 -44
- package/dist/cli.js +4318 -1133
- package/package.json +15 -2
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ It is built for people at a terminal and for agents that call Hardfin on their b
|
|
|
7
7
|
|
|
8
8
|
## Status
|
|
9
9
|
|
|
10
|
-
The CLI is early.
|
|
11
|
-
|
|
10
|
+
The CLI is early. Signing in works against a Hardfin that publishes an authorization
|
|
11
|
+
server, and every command also accepts an API key.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -26,30 +26,67 @@ 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
|
|
30
|
-
|
|
|
31
|
-
| `hardfin api`
|
|
32
|
-
| `src/command/surface.generated.ts` | Every endpoint as a command, rewritten by the generator
|
|
33
|
-
| `surface-overrides.json`
|
|
34
|
-
| `spec/core.openapi.yaml`
|
|
35
|
-
| `scripts/generate-surface.mjs`
|
|
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
|
|
43
|
-
|
|
|
44
|
-
| `GET /asset`
|
|
45
|
-
| `POST /asset`
|
|
46
|
-
| `GET /asset/{assetKey}`
|
|
47
|
-
| `PATCH /asset/{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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
Everything an endpoint takes becomes a flag or an argument. No command asks for JSON.
|
|
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 |
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
hardfin asset ownership create ast_4f9xk2mq7plr8stz \
|
|
63
|
+
--customer-id cst_a1 --date 2026-09-23 --sale-price 1500.00
|
|
64
|
+
|
|
65
|
+
hardfin asset move execute create \
|
|
66
|
+
--move assetId=ast_a1,locationId=loc_x \
|
|
67
|
+
--move assetId=ast_b2,locationId=loc_y
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A value is sent as the type the document names. `--useful-life 36` sends the number 36. A
|
|
71
|
+
decimal such as `--salvage-value 1500.00` stays a string, which is how the API takes money
|
|
72
|
+
without losing precision.
|
|
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
|
+
|
|
81
|
+
Clear a field with `--unset`, naming the flag:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
hardfin asset accounting update ast_4f9xk2mq7plr8stz --unset useful-life
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Three things fail locally with exit code 2, before any request is made: a value outside an
|
|
88
|
+
enum, a required field that is missing, and `--unset` on a field the API will not take null
|
|
89
|
+
for.
|
|
53
90
|
|
|
54
91
|
### Overrides
|
|
55
92
|
|
|
@@ -88,27 +125,189 @@ what the api-spec bridge publishes. Commit the document and the generated comman
|
|
|
88
125
|
|
|
89
126
|
Two checks keep the pair honest.
|
|
90
127
|
|
|
91
|
-
| Check
|
|
92
|
-
|
|
|
128
|
+
| Check | Refuses |
|
|
129
|
+
| ------------- | ----------------------------------------------------------------------------- |
|
|
93
130
|
| The generator | a document whose `info.version` is not a date, which means an earlier release |
|
|
94
|
-
| CI
|
|
131
|
+
| CI | a vendored document that was updated without regenerating the commands |
|
|
95
132
|
|
|
96
133
|
The first one matters because `hardfinhq/api-spec` can sit a release behind the monorepo
|
|
97
134
|
while its bridge pull request is open. Generating from that document would replace the
|
|
98
135
|
current commands with an earlier API's.
|
|
99
136
|
|
|
137
|
+
## Signing in
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
hardfin login # approve this CLI in a browser
|
|
141
|
+
hardfin login --device # approve on another machine, by typing a code
|
|
142
|
+
hardfin status # what the CLI is configured with and signed in as
|
|
143
|
+
hardfin logout # forget this machine, and ask Hardfin to revoke it
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
An API key is still the way to run unattended. `HARDFIN_API_KEY` wins over a stored sign in,
|
|
147
|
+
so CI and an agent sandbox need no browser.
|
|
148
|
+
|
|
149
|
+
### What login does
|
|
150
|
+
|
|
151
|
+
1. Reads `/.well-known/oauth-authorization-server` at the API's host, which names every
|
|
152
|
+
endpoint, including the authorize endpoint on the app host
|
|
153
|
+
2. Opens a loopback listener on `127.0.0.1`, over IPv4, on whatever port the machine hands out
|
|
154
|
+
3. Opens the browser to approve this CLI, with a PKCE challenge and a state value
|
|
155
|
+
4. Prints the URL, and waits at the keyboard as well as on the listener
|
|
156
|
+
5. Checks the state and the `iss` it came back with, then trades the code for tokens
|
|
157
|
+
6. Keeps the refresh token, and nothing else
|
|
158
|
+
|
|
159
|
+
While it waits, the terminal takes two things.
|
|
160
|
+
|
|
161
|
+
| Key | Does |
|
|
162
|
+
| -------------------------- | ----------------------------------------- |
|
|
163
|
+
| `c` | Copies the URL to the clipboard |
|
|
164
|
+
| A pasted value, then enter | Finishes the sign in without the listener |
|
|
165
|
+
|
|
166
|
+
Paste whichever of these you have: the whole redirect URL from the browser's address bar,
|
|
167
|
+
`code#state`, or the code alone. A browser on another machine never reaches this listener,
|
|
168
|
+
and the redirect it failed to follow is still in the address bar.
|
|
169
|
+
|
|
170
|
+
On WSL the browser is opened through Windows, by `wslview`, then PowerShell, then
|
|
171
|
+
`explorer.exe`, because `xdg-open` reaches nothing outside the distribution.
|
|
172
|
+
|
|
173
|
+
The port changes every time, so Hardfin matches a loopback redirect by everything except its
|
|
174
|
+
port, which is what RFC 8252 asks of an authorization server.
|
|
175
|
+
|
|
176
|
+
### Signing in from a machine with no browser
|
|
177
|
+
|
|
178
|
+
`hardfin login --device` prints a short code and a page to enter it on, and waits. Approve
|
|
179
|
+
on your phone or any other machine, and the terminal finishes on its own.
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
Code WDJB-MJHT
|
|
183
|
+
At https://app.hardfin.com/device
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The CLI refuses `--device` when the authorization server does not offer that grant, rather
|
|
187
|
+
than waiting for an endpoint that is not there.
|
|
188
|
+
|
|
189
|
+
### When a sign in stops working
|
|
190
|
+
|
|
191
|
+
A refresh token can die: someone signed out on another machine, the token was presented
|
|
192
|
+
twice, or it aged out. The server answers `invalid_grant`, and the CLI removes the dead
|
|
193
|
+
credential and tells you to run `hardfin login` again.
|
|
194
|
+
|
|
195
|
+
A server that merely cannot be reached does not count, because a failed connection says
|
|
196
|
+
nothing about whether the credential is good.
|
|
197
|
+
|
|
198
|
+
### Where the refresh token is kept
|
|
199
|
+
|
|
200
|
+
| Host | Kept in |
|
|
201
|
+
| --------------------------------------------------------------- | ----------------------------------------------------- |
|
|
202
|
+
| macOS, Windows, and Linux with a secret service | the OS keyring |
|
|
203
|
+
| Everything else, including WSL, containers, and agent sandboxes | `$XDG_STATE_HOME/hardfin/credentials.json`, mode 0600 |
|
|
204
|
+
|
|
205
|
+
`HARDFIN_NO_BROWSER=1` keeps `hardfin login` from opening a tab, which is what a script or a
|
|
206
|
+
headless host wants. `HARDFIN_CREDENTIAL_STORE=file` forces the file. `hardfin status` reports which one holds
|
|
207
|
+
the credential, and where.
|
|
208
|
+
|
|
209
|
+
Access tokens are never written anywhere. One is fetched when a command needs it and lives
|
|
210
|
+
in that process only, so what sits at rest is revocable with `hardfin logout`.
|
|
211
|
+
|
|
212
|
+
A credential is filed under the authorization server it came from, so a local build and
|
|
213
|
+
production never share a sign in.
|
|
214
|
+
|
|
215
|
+
### What it needs configured
|
|
216
|
+
|
|
217
|
+
Nothing. Hardfin registers this CLI as a first-party client, and its key is the same in every
|
|
218
|
+
environment, so `hardfin login` works out of the box.
|
|
219
|
+
|
|
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 |
|
|
224
|
+
|
|
225
|
+
## Completing commands in your shell
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
source <(hardfin completion zsh) # this shell, now
|
|
229
|
+
hardfin completion zsh >> ~/.zshrc # every shell after this one
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`bash`, `zsh`, `fish` and `powershell` each get a script. The script asks the CLI what may
|
|
233
|
+
follow what you have typed, so completions never fall behind the commands, including the ones
|
|
234
|
+
generated from the API document.
|
|
235
|
+
|
|
236
|
+
## Serving an agent
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
hardfin mcp
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
It speaks the Model Context Protocol on standard input and output, so Claude, Codex or any
|
|
243
|
+
other client can run Hardfin commands.
|
|
244
|
+
|
|
245
|
+
```sh
|
|
246
|
+
claude mcp add hardfin -- hardfin mcp
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### One tool, and resources for the rest
|
|
250
|
+
|
|
251
|
+
Every tool a server lists sits in the agent's context for the entire session, so this server
|
|
252
|
+
offers one.
|
|
253
|
+
|
|
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 |
|
|
259
|
+
|
|
260
|
+
Fifty tools would describe the same surface and crowd out the work. An agent that needs the
|
|
261
|
+
list reads a resource, or runs `["--help"]`.
|
|
262
|
+
|
|
263
|
+
A tool call runs the CLI itself, so an agent and a person get identical parsing, output and
|
|
264
|
+
exit codes.
|
|
265
|
+
|
|
266
|
+
### What it signs in as
|
|
267
|
+
|
|
268
|
+
The server uses whatever credential this machine holds, so an agent acts as the person who
|
|
269
|
+
ran `hardfin login`. Give an agent an API key through `HARDFIN_API_KEY` when it should act
|
|
270
|
+
as an integration instead.
|
|
271
|
+
|
|
272
|
+
## Diagnosing a problem
|
|
273
|
+
|
|
274
|
+
`hardfin status` prints everything a support request needs, and is the first thing to send.
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
hardfin status # for you
|
|
278
|
+
hardfin status --json # for a support request
|
|
279
|
+
hardfin status --offline # no network calls at all
|
|
280
|
+
```
|
|
281
|
+
|
|
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 |
|
|
290
|
+
|
|
291
|
+
Nothing secret is printed. An API key and a refresh token are each reported as a
|
|
292
|
+
`sha256:` fingerprint, which identifies a credential across two machines without disclosing
|
|
293
|
+
it.
|
|
294
|
+
|
|
295
|
+
`refreshExpiresAt` is what the server said when it issued the token. A sign in stored before
|
|
296
|
+
the server reported one falls back to Hardfin's 90-day rule, and `refreshExpiryIsEstimated`
|
|
297
|
+
says which of the two you are looking at.
|
|
298
|
+
|
|
100
299
|
## Local configuration
|
|
101
300
|
|
|
102
301
|
A local build reaches a local server without editing code. Four layers supply the same
|
|
103
302
|
settings, and the one nearest the top wins.
|
|
104
303
|
|
|
105
|
-
| Layer
|
|
106
|
-
|
|
|
107
|
-
| Flag
|
|
108
|
-
| Environment | an exported `HARDFIN_*` variable
|
|
109
|
-
| Env file
|
|
110
|
-
| Config file | `config.local.json` in the working directory
|
|
111
|
-
| Default
|
|
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 |
|
|
112
311
|
|
|
113
312
|
An exported variable beats `.env` because Node leaves a variable that is already set alone.
|
|
114
313
|
|
|
@@ -117,13 +316,12 @@ An exported variable beats `.env` because Node leaves a variable that is already
|
|
|
117
316
|
```json
|
|
118
317
|
{
|
|
119
318
|
"apiUrl": "http://localhost:8080/v2",
|
|
120
|
-
"
|
|
319
|
+
"clientId": "https://hardfin.com/cli/client.json"
|
|
121
320
|
}
|
|
122
321
|
```
|
|
123
322
|
|
|
124
|
-
The
|
|
125
|
-
|
|
126
|
-
`clientId`, and `auth` holding `authorizeUrl`, `tokenUrl`, `deviceUrl`, and `revokeUrl`.
|
|
323
|
+
The authorization server sits at the API's host, so pointing at a local server moves signing
|
|
324
|
+
in with it. The keys are `apiUrl`, `apiKey`, `clientId`, and `issuerUrl`.
|
|
127
325
|
|
|
128
326
|
A key the file does not define fails the command with exit code 2. A typo that was silently
|
|
129
327
|
ignored would look like a setting that never applied.
|
|
@@ -145,6 +343,24 @@ The CLI reads whatever `config.local.json` and `.env` sit in the directory you r
|
|
|
145
343
|
A directory you do not control can therefore point the CLI at a server you do not expect, so
|
|
146
344
|
run `hardfin config` when a command reaches somewhere surprising.
|
|
147
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
|
+
|
|
148
364
|
## Releasing
|
|
149
365
|
|
|
150
366
|
This section is for anyone who merges a pull request in this repository. It tells you where
|
|
@@ -167,11 +383,11 @@ package published from a public repository, and this repository is private.
|
|
|
167
383
|
|
|
168
384
|
### What each merge publishes
|
|
169
385
|
|
|
170
|
-
| Merge
|
|
171
|
-
|
|
|
172
|
-
| Pull request into `dev` | always
|
|
173
|
-
| Promotion into `main`
|
|
174
|
-
| Hotfix into `main`
|
|
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` |
|
|
175
391
|
|
|
176
392
|
A preview never moves `latest`, so `npm install @hardfin/cli` keeps returning the released
|
|
177
393
|
version.
|
|
@@ -204,13 +420,13 @@ on `main` until someone brings it back.
|
|
|
204
420
|
A required check reads `version` from `package.json` on every pull request and judges it
|
|
205
421
|
against the branch the pull request targets.
|
|
206
422
|
|
|
207
|
-
| Version
|
|
208
|
-
|
|
|
209
|
-
| Plain, above what `main` holds
|
|
210
|
-
| Plain, equal to what `main` holds
|
|
211
|
-
| Plain, below what `main` holds
|
|
212
|
-
| Carrying a `-dev` or any other prerelease suffix | refused
|
|
213
|
-
| Not a semver version
|
|
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 |
|
|
214
430
|
|
|
215
431
|
A prerelease suffix is refused everywhere because the release workflow appends it at publish
|
|
216
432
|
time. A version equal to `main`'s is fine on `dev`, since the workflow increments the patch
|