@getantidote/cli 0.0.0-stage → 0.1.1-rc.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -2
  3. package/dist/antidote.mjs +17442 -0
  4. package/package.json +39 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duku AI Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,349 @@
1
- # Temporary Holding Version
1
+ # antidote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Antidote command-line interface. It signs in to the Antidote platform and runs the
4
+ platform's tools; every tool runs server-side.
5
+
6
+ ## Install
7
+
8
+ Requires Node.js 22.4 or later.
9
+
10
+ ```bash
11
+ npm i -g @getantidote/cli
12
+ ```
13
+
14
+ ## Sign in
15
+
16
+ ```bash
17
+ antidote login # opens your browser
18
+ antidote login --device # prints a link and a code to approve on any other device
19
+ ```
20
+
21
+ `antidote login` uses `--device` automatically over SSH, or on Linux/Unix with no
22
+ `DISPLAY` or `WAYLAND_DISPLAY`. Browser login listens for the redirect on the
23
+ first free port of `127.0.0.1:48211`–`48215`. Logging in again replaces the
24
+ previous session, which is revoked on the server first (if that fails, you get
25
+ a warning and the login goes ahead); if the new sign-in then fails, you are
26
+ signed out. When you approve a `--device` sign-in, the page shows where and
27
+ when it was started: deny any you didn't start yourself.
28
+
29
+ For CI, set an API key from the dashboard (Settings → API keys) instead of
30
+ logging in:
31
+
32
+ ```bash
33
+ ANTIDOTE_API_KEY=<key> antidote whoami
34
+ ```
35
+
36
+ `ANTIDOTE_API_KEY` takes precedence over any stored login, and nothing is written to
37
+ disk in this mode. `antidote login` refuses to run while it holds a key.
38
+
39
+ ### CI without a stored secret: service identities
40
+
41
+ A service identity lets a CI job act for your organisation with no secret stored
42
+ in the CI: the CLI exchanges the job's OIDC token for a short-lived Antidote token
43
+ that acts as the identity. There are two ways to say which identity.
44
+
45
+ **Naming it.** Set `ANTIDOTE_SERVICE_IDENTITY` to the id of a service identity from
46
+ the dashboard (Settings → Service identities). One of the identity's trust rules
47
+ must accept the job's token: its issuer, its audience and the rule's conditions.
48
+ The dashboard shows a ready-made job for each CI platform. On GitHub Actions:
49
+
50
+ ```yaml
51
+ jobs:
52
+ antidote:
53
+ runs-on: ubuntu-latest
54
+ permissions:
55
+ id-token: write # lets the CLI request the job's OIDC token
56
+ steps:
57
+ - run: antidote whoami # with the CLI installed
58
+ env:
59
+ ANTIDOTE_API_URL: https://platform.duku.ai
60
+ ANTIDOTE_SERVICE_IDENTITY: svc_…
61
+ ```
62
+
63
+ **Letting the platform find it.** Set only the job's OIDC token
64
+ (`ANTIDOTE_OIDC_TOKEN` or `ANTIDOTE_OIDC_TOKEN_FILE`), without `ANTIDOTE_SERVICE_IDENTITY`.
65
+ The platform finds the identity from the token itself: the one that belongs to
66
+ your organisation's connection to that CI platform (Settings → Integrations →
67
+ Source control & CI/CD), so there is nothing to create or name. No CI
68
+ platform's connection provides one yet; until one does, the platform refuses
69
+ these tokens. The token's audience must be the platform's URL (the origin of
70
+ `ANTIDOTE_API_URL`). When the platform refuses a token, the CLI prints its reason as
71
+ it gave it.
72
+
73
+ The CLI takes the job's OIDC token from the first of these that is set (a
74
+ variable that is set but empty is an error):
75
+
76
+ 1. `ANTIDOTE_OIDC_TOKEN`, the token itself (GitLab's `id_tokens`, or the output of
77
+ `circleci run oidc get` or `buildkite-agent oidc request-token`);
78
+ 2. `ANTIDOTE_OIDC_TOKEN_FILE`, a file holding it;
79
+ 3. with `ANTIDOTE_SERVICE_IDENTITY` only, on GitHub Actions, a token the CLI
80
+ requests itself, for the audience `ANTIDOTE_OIDC_AUDIENCE` (by default the
81
+ origin of `ANTIDOTE_API_URL`, such as `https://platform.duku.ai`). The job needs
82
+ `permissions: id-token: write`. `ANTIDOTE_OIDC_AUDIENCE` has no effect on a
83
+ token from 1 or 2: its audience is set where it is issued.
84
+
85
+ The Antidote token lives in memory only, and the CLI exchanges again shortly before
86
+ it expires. Each exchange reads `ANTIDOTE_OIDC_TOKEN_FILE` again or requests a new
87
+ token from GitHub, but sends `ANTIDOTE_OIDC_TOKEN` as it is. The platform refuses a
88
+ CI token that has expired, and, naming an identity, one issued over an hour
89
+ ago, so:
90
+
91
+ - Naming an identity, exchange GitLab's `id_tokens` in the job's first hour:
92
+ GitLab issues them when the job starts. Without `ANTIDOTE_SERVICE_IDENTITY`, the
93
+ CLI leaves the token's age to the platform.
94
+ - Buildkite's tokens last 5 minutes unless `--lifetime` says otherwise.
95
+ - A job that outlives its CI token needs a fresh `ANTIDOTE_OIDC_TOKEN` for each
96
+ command, or `ANTIDOTE_OIDC_TOKEN_FILE`.
97
+
98
+ Before sending a token from `ANTIDOTE_OIDC_TOKEN` or the file, the CLI checks it
99
+ itself, because the platform's refusal doesn't say why: it must have an `exp`
100
+ and an `iat`, must not have expired and, naming an identity, must have been
101
+ issued in the last hour. If the platform's token endpoint answers HTTP 429
102
+ `temporarily_unavailable` or 503 `server_error`, or GitHub's token service fails
103
+ or can't be reached, the CLI tries the exchange twice more, waiting as long as
104
+ the platform's `Retry-After` says (5 seconds without one, at most a minute).
105
+ Other failures of the exchange are final, as are a failed discovery and a 503
106
+ from `/mcp`. Nothing is written to disk in either mode.
107
+
108
+ ### Which credentials the CLI uses
109
+
110
+ | Set | The CLI acts as |
111
+ |---|---|
112
+ | `ANTIDOTE_API_KEY` | the API key's identity |
113
+ | `ANTIDOTE_SERVICE_IDENTITY`, with a CI token from `ANTIDOTE_OIDC_TOKEN`, `ANTIDOTE_OIDC_TOKEN_FILE` or GitHub Actions | that service identity |
114
+ | `ANTIDOTE_OIDC_TOKEN` or `ANTIDOTE_OIDC_TOKEN_FILE`, without `ANTIDOTE_SERVICE_IDENTITY` | the service identity the platform finds for the token |
115
+ | none of these | whoever ran `antidote login` |
116
+
117
+ Any of these wins over a stored login. An empty `ANTIDOTE_API_KEY` is ignored, as
118
+ if unset: the CLI uses the next row that applies. An `ANTIDOTE_API_KEY` that isn't
119
+ empty can't be set together with `ANTIDOTE_SERVICE_IDENTITY`, `ANTIDOTE_OIDC_TOKEN` or
120
+ `ANTIDOTE_OIDC_TOKEN_FILE` (the CLI exits with a usage error), and
121
+ `ANTIDOTE_SERVICE_IDENTITY`, `ANTIDOTE_OIDC_TOKEN` or `ANTIDOTE_OIDC_TOKEN_FILE` set but
122
+ empty is an error, never a reason to fall back to a stored login. `antidote login`
123
+ refuses to run while any of them is in use.
124
+
125
+ | Variable | Purpose |
126
+ |---|---|
127
+ | `ANTIDOTE_API_URL` | Platform URL, default `https://platform.duku.ai`. Must be https, except `http://localhost` / `127.0.0.1` / `[::1]` for a local stack. |
128
+ | `ANTIDOTE_API_KEY` | An API key, used instead of any login. |
129
+ | `ANTIDOTE_SERVICE_IDENTITY` | A service identity's id (`svc_…`), used instead of any login. |
130
+ | `ANTIDOTE_OIDC_TOKEN` | The CI job's OIDC token: for `ANTIDOTE_SERVICE_IDENTITY`, or on its own to let the platform find the identity. |
131
+ | `ANTIDOTE_OIDC_TOKEN_FILE` | A file holding the job's OIDC token, read again on every exchange. |
132
+ | `ANTIDOTE_OIDC_AUDIENCE` | The audience the CLI requests from GitHub Actions for `ANTIDOTE_SERVICE_IDENTITY` (default: the origin of `ANTIDOTE_API_URL`). |
133
+ | `XDG_CONFIG_HOME` | Parent of the config directory (default `~/.config`). |
134
+
135
+ Each `ANTIDOTE_*` variable was `DUKU_*` before the CLI was renamed. The old name
136
+ still works when the new one is unset; a set `ANTIDOTE_*` variable wins, even an
137
+ empty one.
138
+
139
+ ## Commands
140
+
141
+ ```bash
142
+ antidote whoami # the signed-in user (or service identity), organisation and auth mode (a tool command, below)
143
+ antidote tools # the tools available to the CLI
144
+ antidote tools --refresh # re-fetch the tool list
145
+ antidote logout # revoke the session and delete this machine's credentials
146
+ antidote --version
147
+ ```
148
+
149
+ `antidote logout` removes the stored login only; if `ANTIDOTE_API_KEY`,
150
+ `ANTIDOTE_SERVICE_IDENTITY`, `ANTIDOTE_OIDC_TOKEN` or `ANTIDOTE_OIDC_TOKEN_FILE` is set,
151
+ commands keep using it. If the server can't revoke the session, logout warns and still
152
+ deletes the local credentials. Add `--json` to any command for machine-readable output.
153
+
154
+ ## Tool commands
155
+
156
+ Each tool the platform exposes to the CLI is a command, `antidote <tool>`, built from
157
+ the tool's input schema. `antidote <tool> --help` lists its flags.
158
+
159
+ - Each property becomes a kebab-case flag: `maxResults` or `max_results` becomes
160
+ `--max-results`.
161
+ - Strings, numbers, integers, booleans and enums take plain values; object and
162
+ array properties take a JSON value: `--filters '{"status":"failed"}'`.
163
+ - A boolean flag sends `true`; `--no-<flag>` sends `false`.
164
+ - Give a negative number as `--limit=-5`.
165
+ - A property named like a CLI flag (`json`, `help`, `version`, `device`,
166
+ `refresh`) becomes `--arg-<flag>` (e.g. `--arg-json`); a property whose flag
167
+ would clash with another property's, or is not typable, becomes
168
+ `--arg-<property>` with its raw name. A tool whose properties still lack
169
+ usable flags is not offered as a command.
170
+ - Required properties are enforced; the server validates everything else.
171
+ - Tool flags go after the tool name; only `--json` and `--help` may come before
172
+ it.
173
+ - `--json` prints the tool's structured result as JSON.
174
+ - A tool's snake_case name also works in kebab-case: `antidote record-preview` runs
175
+ `record_preview`.
176
+ - An input you don't give as a flag is read from the environment variable named
177
+ `ANTIDOTE_INPUT_` plus the property in capitals (`release_label` is
178
+ `ANTIDOTE_INPUT_RELEASE_LABEL`). A flag wins over the variable, and an empty
179
+ variable counts as not set. Use it to keep a value out of a command line and
180
+ a job log. The prefix keeps every other
181
+ variable out of reach: the CLI's own variables in the table above and the
182
+ `ANTIDOTE_<KEY>` values an earlier job's dotenv file passes on are never read
183
+ as inputs. There is no `DUKU_INPUT_` name. Two
184
+ properties that map to the same variable (`foo_bar` and `fooBar`) are not read
185
+ from the environment; give them as flags.
186
+ - `--dotenv <path>` writes the result's top-level strings, numbers and booleans
187
+ to that file as `ANTIDOTE_<KEY>=<value>` lines (`target_id` becomes
188
+ `ANTIDOTE_TARGET_ID`, `runStatus` becomes `ANTIDOTE_RUN_STATUS`); objects, arrays
189
+ and nulls are left out, and line breaks in a value become a space. A key that
190
+ would make a line starting `ANTIDOTE_INPUT_`, or one naming a variable in the
191
+ table above under either name (`ANTIDOTE_API_KEY`, `ANTIDOTE_OIDC_TOKEN`, ...),
192
+ is left out too, so a result can never set an input or the CLI's own variables
193
+ for a later job. The file is written to a temporary file in the same directory and
194
+ renamed over `<path>`, so a symbolic link at `<path>` is replaced, never
195
+ written through. It is emptied as soon as the command starts, so any failure
196
+ (a usage mistake, no login, a failed call) leaves no values from an earlier
197
+ run. The exception is a path written directly after another flag
198
+ (`--dry-run --dotenv antidote.env`), which could be that flag's value: it is
199
+ emptied once the tool's flags have been read, so write `--dotenv=antidote.env`
200
+ to have it emptied at the start. The result is printed first; if writing the
201
+ file then fails, the CLI says the tool succeeded and writing the file failed,
202
+ and exits 1.
203
+ - Pass free text such as a title as `--title=...` or as `ANTIDOTE_INPUT_TITLE`. A
204
+ value that starts with `-` after a space (`--title "-x"`) is refused as
205
+ ambiguous, so a title such as `--dotenv=/path` can never be taken as a flag
206
+ and nothing is written.
207
+ - `--json`, and the indented JSON printed for a result with nested values, write
208
+ bidi overrides, zero-width characters and control characters as `\uXXXX`
209
+ escapes, so the output stays valid JSON.
210
+ - A call that takes longer than about a minute fails in deployed environments,
211
+ because the load balancer in front of the platform cuts it off. The server may
212
+ still have finished: the dotenv file is then empty, and running the command again
213
+ finds the same target by its build key but starts a second run.
214
+
215
+ ### In CI
216
+
217
+ A job that needs a tool's result in a later job writes it as a dotenv file. In
218
+ GitLab, with `ANTIDOTE_API_KEY` set as a masked CI/CD variable and the product's
219
+ id given to the tool as `ANTIDOTE_INPUT_PRODUCT_ID`:
220
+
221
+ ```yaml
222
+ antidote-preview:
223
+ stage: test
224
+ variables:
225
+ ANTIDOTE_INPUT_PRODUCT_ID: prod_123
226
+ rules:
227
+ - if: $CI_MERGE_REQUEST_IID
228
+ script:
229
+ - npx -y @getantidote/cli@0.1 record-preview --provider gitlab --host "$CI_SERVER_FQDN" --repository "$CI_PROJECT_PATH" --repository-id "$CI_PROJECT_ID" --number "$CI_MERGE_REQUEST_IID" --head-sha "$CI_COMMIT_SHA" --branch "$CI_COMMIT_REF_NAME" --pipeline-id "$CI_PIPELINE_ID" --dotenv antidote.env
230
+ artifacts:
231
+ when: always
232
+ reports:
233
+ dotenv: antidote.env
234
+ ```
235
+
236
+ `CI_MERGE_REQUEST_IID` is empty on a branch pipeline, so the `rules` line keeps
237
+ the job to merge request pipelines. `--host` takes `$CI_SERVER_FQDN`, not
238
+ `$CI_SERVER_HOST`, because it keeps the port of a GitLab served on one. Jobs in a
239
+ later stage, or jobs that `needs` this one, then receive the variables in the
240
+ table below. Exit codes are 0 on success, 1 when the tool fails and 2 for a usage
241
+ mistake.
242
+
243
+ `record-preview`, and `record-release` when it is given build fields, refuse a
244
+ signed-in user: a CI job uses `ANTIDOTE_API_KEY` or a CI sign-in
245
+ (`ANTIDOTE_OIDC_TOKEN`). To record the build a pipeline
246
+ deployed to a named environment, send the commit and the repository that make
247
+ its build key (`gitlab:project=acme/web:env=staging:sha=<sha>`; the rule is in
248
+ [the tools README](../../api/platform/src/modules/tools/README.md#build-keys)):
249
+
250
+ ```yaml
251
+ antidote-release:
252
+ stage: verify
253
+ needs: [deploy-staging]
254
+ variables:
255
+ ANTIDOTE_INPUT_PRODUCT_ID: prod_123
256
+ script:
257
+ - npx -y @getantidote/cli@0.1 record-release --environment staging --build-sha "$CI_COMMIT_SHA" --provider gitlab --host "$CI_SERVER_FQDN" --repository "$CI_PROJECT_PATH" --repository-id "$CI_PROJECT_ID" --dotenv antidote.env
258
+ artifacts:
259
+ when: always
260
+ reports:
261
+ dotenv: antidote.env
262
+ ```
263
+
264
+ On GitLab, `--repository-id` (the numeric `CI_PROJECT_ID`) is required. On
265
+ GitHub, `--repository` is `owner/repo` and the host can only be `github.com`. A
266
+ variable CI leaves unset arrives as an empty string: `--release-label ""` counts
267
+ as no label, while an empty `--environment` or `--environment-id` is refused.
268
+
269
+ `record-release` starts the exploration first and the product's tests second. If
270
+ the tests then fail to start, the command still exits 0, because the exploration
271
+ is running: `ANTIDOTE_TEST_RUN_STATUS` is `failed` and `--json` shows why in
272
+ `warnings`. A job that must fail in that case checks the variable, for example
273
+ `test "$ANTIDOTE_TEST_RUN_STATUS" != failed`.
274
+
275
+ `record-release` records a build that an older pipeline deployed, then run again
276
+ (a re-run of the `staging` job after `1.5.0` has gone out), and the product's
277
+ tests run on it, but no exploration starts: an exploration tests what the
278
+ environment serves now. `ANTIDOTE_RUN_STATUS` is `skipped`, `ANTIDOTE_RUN_ID` is empty,
279
+ and `warnings` names both builds. `--test-paths-per-goal` is at most 10 and
280
+ `--test-runs-per-path` at most 5; a larger value is lowered and `warnings` says
281
+ so.
282
+
283
+ `record-preview` refuses an environment that is persistent when the organisation
284
+ has `version-axis`, because the environment name comes from the branch: a branch
285
+ pipeline on `production` is told to use `record-release`. `--url` must be an
286
+ `http` or `https` URL.
287
+
288
+ A person can record a release by hand with the two label inputs instead,
289
+ `antidote record-release --environment-id <id> --label 1.4.2`. The product's
290
+ "Runs from API keys" limit applies to it, and to every other caller except the
291
+ web app, including a script that calls the GraphQL `recordManualRelease`
292
+ mutation with an API key: 10 runs in any 10 minutes, counting every run on that
293
+ product that was started with an API key or an `antidote login` session. Duku staff
294
+ can raise it in the product's settings. When it is reached the next call is
295
+ refused ("Release run limit reached for this product (10 of 10 in the last 10
296
+ minutes). Wait a few minutes and try again"). The exploration that a call with
297
+ build fields starts is recorded as CI and does not count.
298
+
299
+ A tool that starts a run returns `follow_up`, which names the batch to poll with
300
+ `get_batch`. There is no `--wait`: a job that must wait for the result runs
301
+ `antidote get-batch --batch-id "$ANTIDOTE_RUN_ID" --json` every `poll_seconds` (15)
302
+ until `verdict.final` is `true`, and reads `verdict.outcome` (`passed`,
303
+ `new_issues`, `unverified` or `timed_out`) from it; what each outcome means is
304
+ in [AUTH_ARCHITECTURE.md](../../../docs/AUTH_ARCHITECTURE.md#6-mcp-remote-server-oauth-21-facade)
305
+ under *Follow-up contract*. `get_batch` gives the same answer as the check Duku
306
+ posts on the merge or pull request.
307
+
308
+ When no run started, `ANTIDOTE_RUN_ID` is empty, so poll only when it is not empty
309
+ and `ANTIDOTE_RUN_STATUS` is not `completed` (`record-preview` reports `completed`
310
+ when Duku tests the change with an agentic test instead of an exploration, which
311
+ has no batch to poll). On a product in agentic mode, Duku starts an agentic
312
+ execution instead of an exploration: `ANTIDOTE_RUN_ID` is empty,
313
+ `ANTIDOTE_RUN_STATUS` is `skipped` and `warnings` names the execution.
314
+ `--json` shows the same thing directly: `follow_up` is null when there is
315
+ nothing to poll.
316
+
317
+ The variables each tool writes with `--dotenv`. A null value is left out of
318
+ the file. `ANTIDOTE_COMMENT_ID` is always written empty (`ANTIDOTE_COMMENT_ID=`),
319
+ `ANTIDOTE_PREVIEW_URL_SOURCE` is empty unless `--url` was sent, and `ANTIDOTE_RUN_ID` is
320
+ empty when no run started:
321
+
322
+ | Tool | Variables |
323
+ |---|---|
324
+ | `whoami` | `ANTIDOTE_USER_ID`, `ANTIDOTE_EMAIL`, `ANTIDOTE_ORG_ID`, `ANTIDOTE_AUTH_MODE` |
325
+ | `get_product` | `ANTIDOTE_ID`, `ANTIDOTE_BASE_URL`, `ANTIDOTE_DASHBOARD_URL`, `ANTIDOTE_ENVIRONMENTS_TRUNCATED` |
326
+ | `get_batch` | `ANTIDOTE_ID`, `ANTIDOTE_STATUS`, `ANTIDOTE_RUNS_TRUNCATED`, `ANTIDOTE_DASHBOARD_URL` |
327
+ | `record_release` | `ANTIDOTE_RELEASE_ID`, `ANTIDOTE_TARGET_ID`, `ANTIDOTE_TARGET_NAME`, `ANTIDOTE_TARGET_VERSION`, `ANTIDOTE_RUN_ID`, `ANTIDOTE_RUN_STATUS`, `ANTIDOTE_TEST_RUN_ID`, `ANTIDOTE_TEST_RUN_STATUS`, `ANTIDOTE_DASHBOARD_URL` |
328
+ | `record_preview` | `ANTIDOTE_TARGET_ID`, `ANTIDOTE_TARGET_NAME`, `ANTIDOTE_TARGET_VERSION`, `ANTIDOTE_RUN_ID`, `ANTIDOTE_RUN_STATUS`, `ANTIDOTE_EXPLORATION_BATCH_ID`, `ANTIDOTE_PREVIEW_URL_SOURCE`, `ANTIDOTE_COMMENT_ID` |
329
+
330
+ `ANTIDOTE_RUN_ID` is the batch id. `get_batch`'s verdict and a result's `follow_up`,
331
+ `warnings` and (for `record_release`) `landings` are objects or arrays, so they are not written; use `--json` to
332
+ read them. `list_products` writes only `ANTIDOTE_TRUNCATED`, for the same reason.
333
+
334
+ A built-in command (`login`, `logout`, `tools`) wins over a tool of the same
335
+ name. Tool commands need a login, `ANTIDOTE_API_KEY`, `ANTIDOTE_SERVICE_IDENTITY` or a CI token:
336
+ logged out, the CLI can't read the tool list, so `antidote whoami` tells you to log in.
337
+
338
+ ## Where state is kept
339
+
340
+ A signed-in profile per platform URL, in
341
+ `$XDG_CONFIG_HOME/antidote/profiles/<host>[_<port>].json` (default
342
+ `~/.config/antidote/profiles/`): the CLI's client registration, its tokens, and the
343
+ last tool list (so `--help` works offline). Files are mode 0600 in a 0700
344
+ directory. Delete a profile with `antidote logout`.
345
+
346
+ ## Releasing
347
+
348
+ Publishing is a `cli-v<version>` tag on main; see the header of
349
+ [`.github/workflows/publish-cli.yml`](../../../.github/workflows/publish-cli.yml).