xmemcli 0.0.10__tar.gz → 0.0.12__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 0.0.10
3
+ Version: 0.0.12
4
4
  Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
5
  Keywords: api,cli,llm,memory,xmemory
6
6
  Requires-Python: <4,>=3.12
@@ -15,7 +15,7 @@ Full command reference is included below.
15
15
  ## Usage
16
16
 
17
17
  ```text
18
- xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
18
+ xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
19
19
  xmemcli auth logout
20
20
  xmemcli auth status
21
21
  xmemcli read <query>
@@ -97,7 +97,7 @@ xmemcli version
97
97
  xmemcli auth login
98
98
  ```
99
99
 
100
- Version: **0.0.10**.
100
+ Version: **0.0.12**.
101
101
 
102
102
 
103
103
  ---
@@ -108,7 +108,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
108
108
 
109
109
  Two acquisition paths share `xmemcli auth login`:
110
110
 
111
- - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on **`127.0.0.1:9636/callback`**, and the Console redirects a signed short-lived **code** (not the key) to that callback.
111
+ - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
112
112
  - **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
113
113
 
114
114
  > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
@@ -129,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
129
129
 
130
130
  1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
131
131
  2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
132
- 3. Browser navigates to **`http://127.0.0.1:9636/callback?code=…&state=<challenge>`** — key never in the URL.
132
+ 3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
133
133
  4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
134
134
 
135
135
  ### Sequence
@@ -137,7 +137,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
137
137
  ```
138
138
  AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
139
139
  Human ── opens Console URL ──► creates key → "Use this key in the CLI"
140
- CLI ◄── localhost redirect ── redeems code + verifier → writes .xmemrc.json
140
+ CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
141
141
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
142
142
  ```
143
143
 
@@ -161,6 +161,8 @@ A credential that already resolves (via `/whoami`) to the requested address at t
161
161
 
162
162
  Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
163
163
 
164
+ Those three keys are required and must be strings. Anything else in the file is **ignored, not rejected** (since 0.0.12): the rc is the only durable credential store, so a file this CLI cannot load is a CLI that cannot run any command at all — which would make adding a field to the file a breaking change far wider than the feature that added it.
165
+
164
166
  Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin.
165
167
 
166
168
  ## Identity — `/whoami`
@@ -181,7 +183,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
181
183
  | Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
182
184
  | Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
183
185
 
184
- Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding; security rests on PKCE, not port secrecy.
186
+ Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
185
187
 
186
188
  ## Quota (observe vs escalate)
187
189
 
@@ -191,7 +193,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
191
193
 
192
194
  # `xmemcli` — capabilities reference
193
195
 
194
- **Version:** `0.0.10` (see `xmemcli version`)
196
+ **Version:** `0.0.12` (see `xmemcli version`)
195
197
 
196
198
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
197
199
 
@@ -410,7 +412,7 @@ so a typo fails loudly rather than being ignored.
410
412
 
411
413
  | Command | Description |
412
414
  |---------|-------------|
413
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
415
+ | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
414
416
  | **`auth logout`** | Remove active `.xmemrc.json` |
415
417
  | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
416
418
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
@@ -513,7 +515,9 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
513
515
 
514
516
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
515
517
 
516
- See also [`ACCOUNTS_AUTH.md`](../ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
518
+ **Compatibility change in 0.0.12:** an `.xmemrc.json` carrying a key this release does not recognise is now **accepted**, and the unknown key ignored; `email`, `api_url`, and `api_key` are still required and still have to be strings. Until 0.0.12 the key set was closed, which made adding a field to the file a breaking change for **every** command rather than for the one that would read it — a CLI that cannot load the rc cannot resolve a credential for anything. Nothing writes an extra key yet; this release exists so that a later one can.
519
+
520
+ See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
517
521
 
518
522
  ---
519
523
 
@@ -594,6 +598,7 @@ These apply **before** any subcommand (`xmemcli help options`).
594
598
  | `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
595
599
  | `--api-key` | `XMEM_API_KEY` | Bearer API key |
596
600
  | `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
601
+
597
602
  | `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
598
603
  | `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
599
604
  | `--json` | — | Force one JSON document on stdout |
@@ -601,6 +606,8 @@ These apply **before** any subcommand (`xmemcli help options`).
601
606
 
602
607
  **Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
603
608
 
609
+ **`.xmemrc.json` shape:** `{email, api_url, api_key}`, all three required and all three strings. Keys beyond those are ignored rather than rejected (see the 0.0.12 note above), so a file written by a newer CLI still loads here.
610
+
604
611
  **`context` is the one exception, and it is a security boundary.** Every other command walks up
605
612
  from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
606
613
  that directory and ran the command in it. `context` has neither — a session-start hook runs it
@@ -7,7 +7,7 @@ Full command reference is included below.
7
7
  ## Usage
8
8
 
9
9
  ```text
10
- xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
10
+ xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
11
11
  xmemcli auth logout
12
12
  xmemcli auth status
13
13
  xmemcli read <query>
@@ -89,7 +89,7 @@ xmemcli version
89
89
  xmemcli auth login
90
90
  ```
91
91
 
92
- Version: **0.0.10**.
92
+ Version: **0.0.12**.
93
93
 
94
94
 
95
95
  ---
@@ -100,7 +100,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
100
100
 
101
101
  Two acquisition paths share `xmemcli auth login`:
102
102
 
103
- - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on **`127.0.0.1:9636/callback`**, and the Console redirects a signed short-lived **code** (not the key) to that callback.
103
+ - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
104
104
  - **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
105
105
 
106
106
  > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
@@ -121,7 +121,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
121
121
 
122
122
  1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
123
123
  2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
124
- 3. Browser navigates to **`http://127.0.0.1:9636/callback?code=…&state=<challenge>`** — key never in the URL.
124
+ 3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
125
125
  4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
126
126
 
127
127
  ### Sequence
@@ -129,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
129
129
  ```
130
130
  AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
131
131
  Human ── opens Console URL ──► creates key → "Use this key in the CLI"
132
- CLI ◄── localhost redirect ── redeems code + verifier → writes .xmemrc.json
132
+ CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
133
133
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
134
134
  ```
135
135
 
@@ -153,6 +153,8 @@ A credential that already resolves (via `/whoami`) to the requested address at t
153
153
 
154
154
  Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
155
155
 
156
+ Those three keys are required and must be strings. Anything else in the file is **ignored, not rejected** (since 0.0.12): the rc is the only durable credential store, so a file this CLI cannot load is a CLI that cannot run any command at all — which would make adding a field to the file a breaking change far wider than the feature that added it.
157
+
156
158
  Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin.
157
159
 
158
160
  ## Identity — `/whoami`
@@ -173,7 +175,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
173
175
  | Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
174
176
  | Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
175
177
 
176
- Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding; security rests on PKCE, not port secrecy.
178
+ Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
177
179
 
178
180
  ## Quota (observe vs escalate)
179
181
 
@@ -183,7 +185,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
183
185
 
184
186
  # `xmemcli` — capabilities reference
185
187
 
186
- **Version:** `0.0.10` (see `xmemcli version`)
188
+ **Version:** `0.0.12` (see `xmemcli version`)
187
189
 
188
190
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
189
191
 
@@ -402,7 +404,7 @@ so a typo fails loudly rather than being ignored.
402
404
 
403
405
  | Command | Description |
404
406
  |---------|-------------|
405
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
407
+ | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
406
408
  | **`auth logout`** | Remove active `.xmemrc.json` |
407
409
  | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
408
410
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
@@ -505,7 +507,9 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
505
507
 
506
508
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
507
509
 
508
- See also [`ACCOUNTS_AUTH.md`](../ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
510
+ **Compatibility change in 0.0.12:** an `.xmemrc.json` carrying a key this release does not recognise is now **accepted**, and the unknown key ignored; `email`, `api_url`, and `api_key` are still required and still have to be strings. Until 0.0.12 the key set was closed, which made adding a field to the file a breaking change for **every** command rather than for the one that would read it — a CLI that cannot load the rc cannot resolve a credential for anything. Nothing writes an extra key yet; this release exists so that a later one can.
511
+
512
+ See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
509
513
 
510
514
  ---
511
515
 
@@ -586,6 +590,7 @@ These apply **before** any subcommand (`xmemcli help options`).
586
590
  | `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
587
591
  | `--api-key` | `XMEM_API_KEY` | Bearer API key |
588
592
  | `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
593
+
589
594
  | `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
590
595
  | `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
591
596
  | `--json` | — | Force one JSON document on stdout |
@@ -593,6 +598,8 @@ These apply **before** any subcommand (`xmemcli help options`).
593
598
 
594
599
  **Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
595
600
 
601
+ **`.xmemrc.json` shape:** `{email, api_url, api_key}`, all three required and all three strings. Keys beyond those are ignored rather than rejected (see the 0.0.12 note above), so a file written by a newer CLI still loads here.
602
+
596
603
  **`context` is the one exception, and it is a security boundary.** Every other command walks up
597
604
  from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
598
605
  that directory and ran the command in it. `context` has neither — a session-start hook runs it
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.10"
3
+ version = "0.0.12"
4
4
  description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4"
@@ -4,5 +4,5 @@ requires-python = ">=3.12, <4"
4
4
 
5
5
  [[package]]
6
6
  name = "xmemcli"
7
- version = "0.0.10"
7
+ version = "0.0.12"
8
8
  source = { editable = "." }
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "0.0.10"
3
+ __version__ = "0.0.12"
@@ -1,11 +1,16 @@
1
1
  """The browser handoff behind ``xmem auth login`` (PKCE + loopback callback).
2
2
 
3
3
  The CLI mints a PKCE ``verifier`` / ``challenge`` and opens ``/xmemcli/<challenge>``.
4
- It listens on ``127.0.0.1:9636`` while the human creates a key and clicks **Use this key
5
- in the CLI**. The Console POSTs the key to ``/cli/handoff``, receives a signed short-lived
6
- ``code``, then redirects the browser to the local callback with ``code`` + ``state``.
4
+ It listens on an ephemeral ``127.0.0.1`` port, named in the login URL, while the human
5
+ creates a key and clicks **Use this key in the CLI**. A port per run means two logins never
6
+ contend for one listener. The Console POSTs the key to ``/cli/handoff``, receives a signed
7
+ short-lived ``code``, then delivers it to that callback with ``code`` + ``state`` — in the
8
+ background where the browser allows that, otherwise through a link the human clicks.
7
9
  The CLI redeems with ``verifier`` + ``code`` over TLS; only it holds the verifier.
8
10
 
11
+ Delivery reaching the CLI's own machine is what binds the key to the human who approved it:
12
+ the code is only ever handed to a listener on their loopback interface.
13
+
9
14
  This loopback callback is a conscious multi-node design choice: the API remains stateless
10
15
  for sign-in, because any node can mint/verify the signed code and no shared relay table,
11
16
  cache, sticky session, or cross-node pub/sub is required.
@@ -13,6 +18,7 @@ Standard-library only, like the rest of the CLI.
13
18
  """
14
19
 
15
20
  import base64
21
+ import errno
16
22
  import hashlib
17
23
  import json
18
24
  import os
@@ -23,7 +29,7 @@ import threading
23
29
  import time
24
30
  import urllib.error
25
31
  import urllib.request
26
- from http.server import BaseHTTPRequestHandler, HTTPServer
32
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
33
  from typing import IO, Any, Callable, NamedTuple, cast, override
28
34
  from urllib.parse import parse_qs, quote, urlparse, urlsplit
29
35
 
@@ -46,8 +52,27 @@ _ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
46
52
  _ENV_API_URL = "XMEM_API_URL"
47
53
  DEFAULT_CONSOLE_URL = "https://console.xmemory.ai"
48
54
 
49
- CALLBACK_PORT = 9636
55
+ #: Every login binds its own ephemeral port and tells the Console which one in the login URL, so
56
+ #: two ``auth login`` runs never contend for one listener. This value is only the legacy default a
57
+ #: pre-ephemeral client used; it is what ``build_login_page_url`` compares against to decide the
58
+ #: URL still needs a ``callback_url``.
59
+ LEGACY_CALLBACK_PORT = 9636
60
+ #: Bind an ephemeral port: the OS picks a free one and the listener reports it back.
61
+ EPHEMERAL_CALLBACK_PORT = 0
50
62
  CALLBACK_PATH = "/callback"
63
+ # Chrome restricts a public HTTPS page (the Console) reaching a loopback address behind a
64
+ # Local Network Access permission. Measured on Chrome 150: the same request issued straight
65
+ # off the human's click prompts for that permission, while one issued after an awaited POST
66
+ # is refused outright ("Permission was denied for this request to access the `loopback`
67
+ # address space") with no prompt at all. No Chrome/WICG/MDN document explains the difference
68
+ # — spending the click's transient user activation is the likeliest cause but is inference,
69
+ # not a documented contract. The Console therefore hits this path first, before it awaits
70
+ # anything else. Nothing is captured here, so a permission request is never mistaken for a
71
+ # callback.
72
+ PERMISSION_PROBE_PATH = "/permission-probe"
73
+ #: Answered on the probe so the Console can tell a real xmemcli listener from any other local
74
+ #: server on the port the login URL named. Must match cli_handoff.js XMEMCLI_MARKER_HEADER.
75
+ XMEMCLI_MARKER_HEADER = "X-Xmemcli"
51
76
  CLI_LOGIN_PATH = "/xmemcli"
52
77
  CLI_TOKEN_PROXY_PATH = "/api/cli/token"
53
78
  CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
@@ -58,10 +83,15 @@ API_KEY_PREFIX = "xmem_"
58
83
  #: local-dev Console. One list, shared — three copies had already grown two different values.
59
84
  LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
60
85
 
86
+ # Deliberately does NOT claim success: this page is written when the code arrives, before the
87
+ # CLI has redeemed it, and redemption can still fail (an expired code, most plausibly when a
88
+ # human clicks the fallback link minutes after it was minted). Reporting the outcome here would
89
+ # mean holding the response open across the redeem; pointing at the terminal, which does know,
90
+ # is honest and keeps the handler a single non-blocking write.
61
91
  _DONE_PAGE = (
62
92
  f"<!doctype html><html><head><meta charset='utf-8'><title>{CLI_NAME}</title></head>"
63
93
  "<body style='font-family:system-ui;margin:3rem'>"
64
- "<h2>You're all set.</h2><p>You can close this tab and return to your terminal.</p>"
94
+ "<h2>Code received.</h2><p>Return to your terminal — it reports whether the login completed.</p>"
65
95
  "</body></html>"
66
96
  ).encode("utf-8")
67
97
 
@@ -136,13 +166,14 @@ def build_login_page_url(console_url: str, *, challenge: str, callback_url: str
136
166
  """Console CLI login URL: ``/xmemcli/<challenge>`` (PKCE S256, also the callback state)."""
137
167
  segment = quote(challenge, safe="-_")
138
168
  url = f"{console_url.rstrip('/')}{CLI_LOGIN_PATH}/{segment}"
139
- if callback_url and callback_url != redirect_uri_for_port(CALLBACK_PORT):
169
+ if callback_url and callback_url != redirect_uri_for_port(LEGACY_CALLBACK_PORT):
140
170
  url += "?callback_url=" + quote(callback_url, safe=":/?&=-_.")
141
171
  return url
142
172
 
143
173
 
144
174
  def _login_prompt_line(label: str, body: str) -> str:
145
- return f" {label:<10}{body}"
175
+ # Width fits the longest label below; anything longer would run into its own body text.
176
+ return f" {label:<12}{body}"
146
177
 
147
178
 
148
179
  def write_login_handoff_prompt(
@@ -153,7 +184,7 @@ def write_login_handoff_prompt(
153
184
  ) -> None:
154
185
  """Human-facing instructions while ``auth login`` waits for a credential."""
155
186
  out = stream or sys.stderr
156
- body_indent = " "
187
+ body_indent = " " * 14 # 2-space margin + the 12-wide label column, so bodies line up
157
188
  lines = [
158
189
  "",
159
190
  f"Authorize {CLI_NAME} on this machine.",
@@ -163,12 +194,16 @@ def write_login_handoff_prompt(
163
194
  "",
164
195
  _login_prompt_line(
165
196
  "Onboarding",
166
- "During first-instance setup the Console auto-delivers the key — no extra clicks.",
197
+ "During first-instance setup the Console delivers the key for you where it can.",
167
198
  ),
168
199
  _login_prompt_line(
169
- "After onboarding",
200
+ "Otherwise",
170
201
  'Create a key on API Keys and click "Use this key in the CLI".',
171
202
  ),
203
+ _login_prompt_line(
204
+ "New tab",
205
+ "Some browsers (Safari) finish the login in a new tab — follow the link if one appears.",
206
+ ),
172
207
  ]
173
208
  if offer_paste:
174
209
  lines.extend(
@@ -287,21 +322,183 @@ def validate_api_key_shape(key: str) -> str | None:
287
322
  return None
288
323
 
289
324
 
290
- class _LoopbackServer(HTTPServer):
325
+ class _LoopbackServer(ThreadingHTTPServer):
326
+ # Threading, not the plain server: browsers open speculative preconnects that send nothing,
327
+ # and a single-threaded server serves them one at a time — each costing the full per-request
328
+ # timeout before the real callback is even accepted. CPython documents ThreadingHTTPServer as
329
+ # existing for exactly this. Handlers run concurrently, so ``captured`` is written under a
330
+ # lock and only once: first delivery wins. Without that, two callbacks racing would both
331
+ # write and the later one — which may carry a stale or mismatched ``state`` — would replace
332
+ # the one already accepted.
333
+ daemon_threads = True
291
334
  captured: dict[str, str] | None = None
335
+ # Serialized origin of the Console this login is talking to, per RFC 6454. Carried on the
336
+ # server so the handler compares against the origin actually in play — including one passed
337
+ # via ``--console-url`` — instead of re-deriving it from the environment.
338
+ console_origin: str = ""
339
+
340
+ @override
341
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
342
+ # Owned here rather than by the caller: every construction site needs it, including the
343
+ # tests that build a listener directly, and a missing lock fails as a dropped connection
344
+ # rather than anything that names the cause.
345
+ self.capture_lock = threading.Lock()
346
+ super().__init__(*args, **kwargs)
347
+
348
+ @override
349
+ def handle_error(self, request: Any, client_address: Any) -> None:
350
+ """Swallow the disconnects this listener expects; let anything else surface.
351
+
352
+ Delivery is up to three connections plus browser preconnects, two of them fire-and-forget
353
+ ``no-cors`` fetches whose sockets the browser may reset. The default handler would dump
354
+ those tracebacks onto stderr, interleaved with the login prompt.
355
+
356
+ Scoped deliberately: swallowing everything would hide a bug raised inside a handler, and
357
+ the only symptom would be a login that waits out its full timeout with nothing printed.
358
+ (Socket timeouts are quiet for a different reason — the ``log_message`` override.)
359
+ """
360
+ exc = sys.exc_info()[1]
361
+ if isinstance(exc, (ConnectionResetError, BrokenPipeError, TimeoutError)):
362
+ return
363
+ super().handle_error(request, client_address)
364
+
365
+
366
+ def _same_console_origin(actual: str, expected: str) -> bool:
367
+ """Whether two serialized origins name the same Console.
368
+
369
+ Exact equality except among the loopback spellings, which are the same host reached by
370
+ different names — the equivalence ``LOOPBACK_HOSTS`` exists for. A developer whose CLI
371
+ derives ``http://localhost:8002`` but who opens ``http://127.0.0.1:8002`` is at their own
372
+ Console; refusing the marker there disables background delivery and blames a permission
373
+ the browser never asked about.
374
+
375
+ Deliberately not a general relaxation: scheme and port must still match exactly, so this
376
+ cannot bridge a loopback origin to a public one.
377
+ """
378
+ if not actual or not expected:
379
+ return False
380
+ if actual == expected:
381
+ return True
382
+ parsed_actual, parsed_expected = urlsplit(actual), urlsplit(expected)
383
+ if parsed_actual.scheme != parsed_expected.scheme:
384
+ return False
385
+ if (parsed_actual.hostname or "") not in LOOPBACK_HOSTS:
386
+ return False
387
+ if (parsed_expected.hostname or "") not in LOOPBACK_HOSTS:
388
+ return False
389
+ try:
390
+ return parsed_actual.port == parsed_expected.port
391
+ except ValueError:
392
+ return False
393
+
394
+
395
+ def serialized_origin(url: str) -> str:
396
+ """``scheme://host[:port]``, lowercased, default port elided — comparable to an ``Origin``."""
397
+ parsed = urlsplit(url.strip())
398
+ scheme = parsed.scheme.lower()
399
+ host = (parsed.hostname or "").lower()
400
+ if not scheme or not host:
401
+ return ""
402
+ try:
403
+ port = parsed.port
404
+ except ValueError:
405
+ # A non-numeric or out-of-range port is a malformed origin, and this value is only ever
406
+ # compared against a request's Origin header — an unparseable one matches nothing, which
407
+ # is the safe answer. Raising here would escape before the listener can be closed.
408
+ return ""
409
+ if port is None or (scheme, port) in (("http", 80), ("https", 443)):
410
+ return f"{scheme}://{host}"
411
+ return f"{scheme}://{host}:{port}"
292
412
 
293
413
 
294
414
  class _CallbackHandler(BaseHTTPRequestHandler):
415
+ # A connection that opens and then says nothing must not wedge the listener: HTTPServer is
416
+ # threaded, so a silent peer gets its own thread rather than starving the listener — but
417
+ # without this that thread would live for the whole login, so
418
+ # Threading keeps accept() moving, so a silent peer no longer delays the real callback. This
419
+ # timeout is what bounds how long that peer's worker thread sits in rfile.readline() — without
420
+ # it the thread would live for the whole login. Do not delete it as "redundant now we thread":
421
+ # the two solve different halves.
422
+ timeout = 5
423
+
424
+ def _console_origin_or_none(self) -> str | None:
425
+ """The request's ``Origin`` when it is this login's Console, else ``None``.
426
+
427
+ One predicate for both the preflight and the probe. Written twice it drifted: the probe
428
+ branch echoed the raw header, so an obs-folded value carrying CR/LF round-tripped into
429
+ ``Access-Control-Allow-Origin`` while the identical input on ``OPTIONS`` was refused.
430
+ """
431
+ origin = self.headers.get("Origin")
432
+ if not origin or "\r" in origin or "\n" in origin:
433
+ return None
434
+ expected = cast(_LoopbackServer, self.server).console_origin
435
+ if not expected:
436
+ return None
437
+ return origin if _same_console_origin(serialized_origin(origin), expected) else None
438
+
439
+ def do_OPTIONS(self) -> None: # noqa: N802
440
+ """Answer a CORS/Private-Network preflight, should one ever be sent.
441
+
442
+ Defensive rather than load-bearing: both Console requests are ``no-cors`` GETs, which are
443
+ never CORS-preflighted, and the only mechanism that would have preflighted them —
444
+ Private Network Access — is on hold, with its Local Network Access successor gating on a
445
+ permission instead. Left in place because ``BaseHTTPRequestHandler`` would otherwise
446
+ answer 501, which fails such a preflight and silently drops the callback.
447
+ """
448
+ origin = self._console_origin_or_none()
449
+ self.send_response(204)
450
+ self.send_header("Vary", "Origin")
451
+ if origin:
452
+ self.send_header("Access-Control-Allow-Origin", origin)
453
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
454
+ # Private Network Access. Its Local Network Access successor dropped the header
455
+ # mechanism for a permission prompt, so there is no second header to send.
456
+ if self.headers.get("Access-Control-Request-Private-Network") == "true":
457
+ self.send_header("Access-Control-Allow-Private-Network", "true")
458
+ self.send_header("Access-Control-Max-Age", "60")
459
+ self.end_headers()
460
+
295
461
  def do_GET(self) -> None: # noqa: N802
462
+ # The callback carries no CORS headers: the Console fetches it with ``mode: 'no-cors'``
463
+ # and never reads the response, so letting arbitrary pages read it buys nothing. The
464
+ # probe above is the deliberate exception — it is fetched with ``mode: 'cors'`` precisely
465
+ # so the marker is readable, which an opaque response would prevent.
296
466
  parsed = urlparse(self.path)
467
+ if parsed.path == PERMISSION_PROBE_PATH:
468
+ # Captures nothing — its job is to let the Console tell this listener apart from an
469
+ # unrelated service that happens to hold the port named in the URL, before it mints a
470
+ # key toward it. The marker is a fixed public value, so it proves cooperation with
471
+ # this protocol, not identity: malicious local code can send the same headers, which
472
+ # is out of scope — a process on the user's loopback already implies code execution
473
+ # on the machine. What it does buy is that a benign service answering there gets no
474
+ # key. The CORS headers
475
+ # are what let the Console read the marker cross-origin; without a matching Origin we
476
+ # send neither, so nothing here leaks to an arbitrary page. Reaching it at all is also
477
+ # still what makes the browser prompt for local-network access.
478
+ origin = self._console_origin_or_none()
479
+ self.send_response(204)
480
+ self.send_header("Vary", "Origin")
481
+ if origin:
482
+ self.send_header("Access-Control-Allow-Origin", origin)
483
+ self.send_header("Access-Control-Expose-Headers", XMEMCLI_MARKER_HEADER)
484
+ self.send_header(XMEMCLI_MARKER_HEADER, "1")
485
+ self.end_headers()
486
+ return
297
487
  if parsed.path != CALLBACK_PATH:
298
488
  self.send_response(404)
489
+ self.send_header("Content-Length", "0")
299
490
  self.end_headers()
300
491
  return
301
492
  params = {k: v[0] for k, v in parse_qs(parsed.query).items() if v}
302
- cast(_LoopbackServer, self.server).captured = params
493
+ server = cast(_LoopbackServer, self.server)
494
+ # First delivery wins. Handlers are concurrent now, so an unconditional write would let a
495
+ # second callback replace one already accepted.
496
+ with server.capture_lock:
497
+ if server.captured is None:
498
+ server.captured = params
303
499
  self.send_response(200)
304
500
  self.send_header("Content-Type", "text/html; charset=utf-8")
501
+ self.send_header("Content-Length", str(len(_DONE_PAGE)))
305
502
  self.end_headers()
306
503
  self.wfile.write(_DONE_PAGE)
307
504
 
@@ -316,8 +513,9 @@ def wait_for_callback(
316
513
  on_url: Callable[[str], None],
317
514
  timeout_s: float,
318
515
  *,
319
- callback_port: int = CALLBACK_PORT,
516
+ callback_port: int = EPHEMERAL_CALLBACK_PORT,
320
517
  stop: threading.Event | None = None,
518
+ console_url: str | None = None,
321
519
  ) -> dict[str, str]:
322
520
  """Listen for the browser's local callback carrying the signed code.
323
521
 
@@ -327,13 +525,30 @@ def wait_for_callback(
327
525
  try:
328
526
  server = _LoopbackServer(("127.0.0.1", callback_port), _CallbackHandler)
329
527
  except OSError as e:
330
- label = callback_port if callback_port != 0 else CALLBACK_PORT
331
- raise LoginError(
332
- f"could not bind 127.0.0.1:{label} — is another `{CLI_NAME} auth login` in progress? ({e})"
333
- ) from None
528
+ # Say what actually happened. Naming a cause the errno does not support sends people
529
+ # hunting the wrong thing — a permission failure is not a port conflict — and the raw
530
+ # exception text ("[Errno 98] ...") is not something to put in front of a user.
531
+ if callback_port == EPHEMERAL_CALLBACK_PORT:
532
+ raise LoginError("could not bind a local port for the login callback") from None
533
+ if e.errno == errno.EACCES:
534
+ # Reachable through direct library use with a privileged port, and via the CLI only
535
+ # when something other than the port number denied the bind — `_callback_port` has
536
+ # already required >= 1024 there. So it names both causes rather than telling a CLI
537
+ # user to do what they just did.
538
+ raise LoginError(
539
+ f"not allowed to bind 127.0.0.1:{callback_port} — ports below 1024 need elevated "
540
+ f"privileges, and a host firewall or security product can deny a bind at any port."
541
+ ) from None
542
+ if e.errno == errno.EADDRINUSE:
543
+ raise LoginError(
544
+ f"could not bind 127.0.0.1:{callback_port} — something is already using that port, "
545
+ f"possibly another `{CLI_NAME} auth login`."
546
+ ) from None
547
+ raise LoginError(f"could not bind 127.0.0.1:{callback_port} for the login callback") from None
334
548
  bound_port = server.server_address[1]
335
549
  server.timeout = 0.5
336
550
  try:
551
+ server.console_origin = serialized_origin(console_url_for_api(console_url=console_url))
337
552
  on_url(build_url(redirect_uri_for_port(bound_port)))
338
553
  deadline = time.monotonic() + timeout_s
339
554
  while server.captured is None and not (stop and stop.is_set()):
@@ -548,7 +763,7 @@ def wait_for_handoff(
548
763
  build_url: Callable[[str], str],
549
764
  on_url: Callable[[str], None],
550
765
  timeout_s: float,
551
- callback_port: int = CALLBACK_PORT,
766
+ callback_port: int = EPHEMERAL_CALLBACK_PORT,
552
767
  interactive: bool = False,
553
768
  notify: Callable[[str], None] | None = None,
554
769
  ) -> HandoffResult:
@@ -582,6 +797,7 @@ def wait_for_handoff(
582
797
  timeout_s,
583
798
  callback_port=callback_port,
584
799
  stop=stop,
800
+ console_url=console_url,
585
801
  )
586
802
  code = params.get("code")
587
803
  if not code:
@@ -674,7 +890,7 @@ def run_pkce_login(
674
890
  verifier: str,
675
891
  on_url: Callable[[str], None],
676
892
  timeout_s: float,
677
- callback_port: int = CALLBACK_PORT,
893
+ callback_port: int = EPHEMERAL_CALLBACK_PORT,
678
894
  interactive: bool = False,
679
895
  notify: Callable[[str], None] | None = None,
680
896
  ) -> HandoffResult:
@@ -84,9 +84,12 @@ class OutputKind(StrEnum):
84
84
  #: payload: ``write_id``; the caller must poll.
85
85
  WRITE_ASYNC = "write_async"
86
86
 
87
- #: Terminal result of an ``async_write_status`` poll. Curated payload:
88
- #: ``write_id`` + ``write_status`` in {``completed``, ``failed``,
89
- #: ``not_found``} (+ ``error_detail`` / ``timed_out`` when applicable).
87
+ #: Result of an ``async_write_status`` poll. Curated payload: ``write_id`` + ``write_status``
88
+ #: — any ``WriteQueueStatus`` value, not only the terminal ones, plus the CLI's own
89
+ #: ``"error"`` / ``"unknown"`` fallbacks — whichever of ``submitted_at`` / ``started_at`` /
90
+ #: ``completed_at`` is set, ``restart_count`` once non-zero (``--verbose`` keeps a zero count and
91
+ #: drops only the null an unknown ``write_id`` reports), and ``error_detail`` / ``error``
92
+ #: / ``http_status`` / ``timed_out`` when applicable.
90
93
  WRITE_STATUS = "write_status"
91
94
 
92
95
  #: Read in ``mode=single-answer``. Curated payload: ``answer`` (falls
@@ -78,7 +78,7 @@ def _render_write_async(payload: Any, stream: IO[str]) -> None:
78
78
 
79
79
 
80
80
  def _render_write_status(payload: Any, stream: IO[str]) -> None:
81
- """Terminal poll result — short status line, no body."""
81
+ """Poll result — short status line, no body; the timing fields stay JSON-only."""
82
82
  if not isinstance(payload, dict):
83
83
  _default_handler(payload, stream)
84
84
  return
@@ -5,9 +5,11 @@ The CLI resolves its API key and API origin from a **project-local**
5
5
  (or the current directory). The search does **not** stop at the repository root
6
6
  and is **ceilinged at ``$HOME``** — ``~/.xmemrc.json`` is the last fallback.
7
7
 
8
- The file is stdlib JSON, strongly typed ``{email, api_url, api_key}`` (one
9
- identity per file; extras / missing / non-string keys are rejected). The ``0600``
10
- atomic writer is used by ``xmem auth login``; see ``cli/AUTH-FLOW.md``.
8
+ The file is stdlib JSON, one identity per file: ``{email, api_url, api_key}``.
9
+ Those three are required and must be strings; a key this release does not know is
10
+ ignored rather than rejected, so a later release can record something new in the
11
+ file without every older CLI refusing to run. The ``0600`` atomic writer is used
12
+ by ``xmem auth login``; see ``cli/AUTH-FLOW.md``.
11
13
 
12
14
  Resolution precedence (applied by ``cli/xmem.py``): ``--api-key`` → ``$XMEM_API_KEY`` → ``.xmemrc.json``; and
13
15
  ``--api-url`` → ``$XMEM_API_URL`` → the rc's ``api_url`` → the built-in default — **with one escape hatch**: when the
@@ -26,9 +28,10 @@ from typing import NamedTuple
26
28
  RC_FILENAME = ".xmemrc.json"
27
29
  ENV_RC_DIR = "XMEM_RC_DIR"
28
30
 
29
- # The exact, complete set of keys an `.xmemrc.json` must contain — no more, no
30
- # fewer. Keeping it closed means a typo'd or extended file fails loudly instead
31
- # of silently ignoring a field.
31
+ # The keys an `.xmemrc.json` must contain. Required, not exhaustive: anything else in the file is ignored.
32
+ # The set was closed until 0.0.12, on the grounds that a typo'd or extended file should fail loudly.
33
+ # It made adding a field a breaking change for every command rather than for the one that would read it,
34
+ # because a CLI that cannot load the rc cannot resolve a credential for anything.
32
35
  _RC_KEYS = ("email", "api_url", "api_key")
33
36
 
34
37
 
@@ -90,27 +93,22 @@ def find_rc(start_dir: Path) -> Path | None:
90
93
 
91
94
 
92
95
  def load_rc(path: Path) -> RcRecord:
93
- """Parse and validate an ``.xmemrc.json``; raise ``RcError`` if malformed."""
96
+ """Parse and validate an ``.xmemrc.json``; raise ``RcError`` if malformed.
97
+
98
+ "Required keys present", not "exactly these keys": an unrecognised key is ignored
99
+ whatever its type. Type-checking a field nothing here reads is how a later release's
100
+ richer value becomes this release's hard failure — having no opinion about it is the
101
+ whole point of ignoring it.
102
+ """
94
103
  try:
95
104
  raw = json.loads(path.read_text(encoding="utf-8"))
96
105
  except (OSError, json.JSONDecodeError) as exc:
97
106
  raise RcError(f"{path}: could not read JSON ({exc})") from exc
98
107
  if not isinstance(raw, dict):
99
108
  raise RcError(f"{path}: expected a JSON object, got {type(raw).__name__}")
100
- keys = set(raw.keys())
101
- expected = set(_RC_KEYS)
102
- if keys != expected:
103
- missing = sorted(expected - keys)
104
- extra = sorted(keys - expected)
105
- detail = ", ".join(
106
- part
107
- for part in (
108
- f"missing {missing}" if missing else "",
109
- f"unexpected {extra}" if extra else "",
110
- )
111
- if part
112
- )
113
- raise RcError(f"{path}: must contain exactly {list(_RC_KEYS)} ({detail})")
109
+ missing = sorted(set(_RC_KEYS) - set(raw.keys()))
110
+ if missing:
111
+ raise RcError(f"{path}: must contain {list(_RC_KEYS)} (missing {missing})")
114
112
  for key in _RC_KEYS:
115
113
  if not isinstance(raw[key], str):
116
114
  raise RcError(f"{path}: '{key}' must be a string")
@@ -1,4 +1,4 @@
1
1
  """Release metadata for the xmemcli CLI (copied into standalone builds)."""
2
2
 
3
3
  CLI_NAME = "xmemcli"
4
- __version__ = "0.0.10"
4
+ __version__ = "0.0.12"
@@ -30,13 +30,16 @@ Curated output
30
30
  The CLI does NOT echo the raw API payload. Every subcommand reduces the
31
31
  server response to the fields the user actually asked for (see the
32
32
  ``_curate_*`` helpers): a read returns the answer / rows, a write returns
33
- its ``write_id``, a status poll returns ``write_id`` + ``write_status``.
33
+ its ``write_id``, a status poll returns ``write_id`` + ``write_status``
34
+ plus whichever timing fields the server set (and the restart count once
35
+ it is non-zero).
34
36
  Server internals — ``sql``, ``diff_plan``, the raw ``cleaned_objects``
35
37
  tree — are never emitted, in either output mode. ``pending_suggestions``
36
38
  (a count of schema-evolution proposals waiting for review) is kept on
37
39
  read responses so agents know when to run ``schema suggestions review``.
38
40
  ``--verbose`` adds *extra* (still non-internal) context: ``trace_id`` and
39
- ``console_url``, when the server provided them.
41
+ ``console_url``, when the server provided them, and keeps a zero
42
+ ``restart_count`` a status poll would otherwise curate away.
40
43
 
41
44
  Sync vs async writes
42
45
  --------------------
@@ -149,6 +152,12 @@ ENV_INSTANCE_ID = "XMEM_INSTANCE_ID"
149
152
  ENV_API_URL = "XMEM_API_URL"
150
153
  ENV_CLUSTER_ID = "XMEM_CLUSTER_ID"
151
154
  ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
155
+ #: Pin the loopback callback port. Unset means any free port, which is what lets concurrent
156
+ #: logins coexist; pinning is for when the port has to be known before the login starts.
157
+ ENV_CALLBACK_PORT = "XMEM_CALLBACK_PORT"
158
+ #: Ports below this need privileges the CLI never takes, and the Console refuses them too.
159
+ #: Must match cli_handoff.js CLI_MIN_CALLBACK_PORT.
160
+ MIN_CALLBACK_PORT = 1024
152
161
  DEFAULT_API_URL = "https://api.xmemory.ai"
153
162
 
154
163
  #: Where MCP connections go. A separate origin from the API rather than a path on it,
@@ -743,10 +752,26 @@ def _curate_write_status(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
743
752
  "write_status": item.get("write_status", "unknown"),
744
753
  }
745
754
  # Carry the failure / bookkeeping fields only when they say something.
746
- for key in ("error_detail", "completed_at", "error", "http_status", "timed_out"):
755
+ for key in (
756
+ "error_detail",
757
+ "submitted_at",
758
+ "started_at",
759
+ "completed_at",
760
+ "error",
761
+ "http_status",
762
+ "timed_out",
763
+ ):
747
764
  value = item.get(key)
748
765
  if value is not None:
749
766
  curated[key] = value
767
+ # Never-restarted is the norm, so a zero count is noise on the default output; show it there only when
768
+ # the write was restarted. Under --verbose the 0 is kept and only the null is dropped: the server sends
769
+ # null exactly for an unknown write_id, and printing that as 0 would let a caller aggregating polls count
770
+ # a row that does not exist as a write that ran clean.
771
+ restart_count = item.get("restart_count")
772
+ show_restart_count = restart_count is not None if verbose else bool(restart_count)
773
+ if show_restart_count:
774
+ curated["restart_count"] = restart_count
750
775
  return _add_verbose_extras(curated, item, verbose)
751
776
 
752
777
 
@@ -1116,6 +1141,9 @@ def cmd_write_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
1116
1141
  }
1117
1142
  last_status = item.get("write_status", "unknown")
1118
1143
  last_body = item
1144
+ # Mirrors the terminal tail of WriteQueueStatus (completed, failed, not_found). Hand-written
1145
+ # rather than derived: this CLI is stdlib-only by design and imports nothing from the server
1146
+ # packages, so the enum is out of reach here.
1119
1147
  if last_status in ("completed", "failed", "not_found"):
1120
1148
  return item
1121
1149
  if time.monotonic() >= deadline:
@@ -1217,6 +1245,35 @@ def cmd_auth_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
1217
1245
  )
1218
1246
 
1219
1247
 
1248
+ def _callback_port(args: argparse.Namespace) -> int:
1249
+ """The port to bind the login callback to; ``0`` means "any free port".
1250
+
1251
+ Any free port is the default because it lets two logins run at once — one fixed port meant
1252
+ the second could not bind. Pinning exists for the case where the port must be known before
1253
+ the login starts, which an ``ssh -L`` tunnel to a remote dev box needs: the tunnel has to be
1254
+ up first, so the port cannot be chosen after.
1255
+ """
1256
+ explicit = getattr(args, "callback_port", None)
1257
+ if explicit is None:
1258
+ from_env = os.environ.get(ENV_CALLBACK_PORT, "").strip()
1259
+ if not from_env:
1260
+ return _login.EPHEMERAL_CALLBACK_PORT
1261
+ try:
1262
+ explicit = int(from_env)
1263
+ except ValueError:
1264
+ # Bounded: this value is echoed back in a JSON error field and comes from the
1265
+ # environment, so it is not something to reproduce in full.
1266
+ shown = from_env if len(from_env) <= 32 else from_env[:32] + "…"
1267
+ raise UsageError(f"${ENV_CALLBACK_PORT} must be a port number, got {shown!r}") from None
1268
+ port = int(explicit)
1269
+ # Below 1024 is refused rather than merely unbindable: the Console rejects such a callback
1270
+ # too (WHATWG URL elides :80 entirely, so it never arrives as a port at all), and the login
1271
+ # would fail later with nothing pointing at the cause. 0 stays legal — it means ephemeral.
1272
+ if port != _login.EPHEMERAL_CALLBACK_PORT and not MIN_CALLBACK_PORT <= port <= 65535:
1273
+ raise UsageError(f"callback port must be 0 or between {MIN_CALLBACK_PORT} and 65535, got {port}")
1274
+ return port
1275
+
1276
+
1220
1277
  def _login_timeout(args: argparse.Namespace, *, default: float) -> float:
1221
1278
  """The ``--timeout`` value, or the per-path default when the flag was not passed.
1222
1279
 
@@ -1409,6 +1466,7 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
1409
1466
  ),
1410
1467
  EXIT_HTTP_ERROR,
1411
1468
  )
1469
+
1412
1470
  explicit_api = _explicit_api_url(args)
1413
1471
  rc_origin = rc.api_url.strip() if rc is not None and rc.api_url.strip() else None
1414
1472
  api_origin = explicit_api or rc_origin or DEFAULT_API_URL
@@ -1606,7 +1664,7 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
1606
1664
  detail = getattr(exc, "strerror", None) or type(exc).__name__
1607
1665
  return (
1608
1666
  make_error(
1609
- f"could not write {RC_FILENAME} ({detail}). The minted key \"{result.key_name}\" "
1667
+ f'could not write {RC_FILENAME} ({detail}). The minted key "{result.key_name}" '
1610
1668
  f"(prefix {result.key_prefix}…) is active — revoke it in the Console if it should not stay.",
1611
1669
  stage="login",
1612
1670
  ),
@@ -1668,6 +1726,7 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
1668
1726
  verifier=verifier,
1669
1727
  on_url=on_url,
1670
1728
  timeout_s=_login_timeout(args, default=300.0),
1729
+ callback_port=_callback_port(args),
1671
1730
  interactive=interactive,
1672
1731
  notify=_eprint,
1673
1732
  )
@@ -2553,6 +2612,16 @@ def _build_parser() -> argparse.ArgumentParser:
2553
2612
  add_help=False,
2554
2613
  )
2555
2614
  pal.add_argument("--no-browser", action="store_true", help="Print the URL only; do not open a browser")
2615
+ pal.add_argument(
2616
+ "--callback-port",
2617
+ type=int,
2618
+ default=None,
2619
+ help=(
2620
+ "Bind the login callback to this port instead of any free one "
2621
+ f"(or ${ENV_CALLBACK_PORT}). Use when the port must be known in advance, e.g. an "
2622
+ "ssh -L tunnel for remote development."
2623
+ ),
2624
+ )
2556
2625
  pal.add_argument(
2557
2626
  "--timeout",
2558
2627
  type=float,
File without changes
File without changes
File without changes
File without changes
File without changes