xmemcli 0.0.8__tar.gz → 0.0.10__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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 0.0.8
3
+ Version: 0.0.10
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]
18
+ xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
19
19
  xmemcli auth logout
20
20
  xmemcli auth status
21
21
  xmemcli read <query>
@@ -97,25 +97,29 @@ xmemcli version
97
97
  xmemcli auth login
98
98
  ```
99
99
 
100
- Version: **0.0.8**.
100
+ Version: **0.0.10**.
101
101
 
102
102
 
103
103
  ---
104
104
 
105
- # `xmemcli` auth flow — PKCE handoff and credentials
105
+ # `xmemcli` auth flow — PKCE handoff, email sign-in, and credentials
106
106
 
107
- The CLI is **human-governed for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, but **acquiring an API key** stays under human control in the browser. A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`.
107
+ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may even *initiate* key acquisition, but a key is only ever issued after **one explicit human approval** — a click in the browser (PKCE handoff) or an approval of a sign-in email (`--email`). A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`, and the CLI prints only its prefix.
108
108
 
109
- **Model:** 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.
109
+ Two acquisition paths share `xmemcli auth login`:
110
110
 
111
- > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`.
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.
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
+
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).
112
115
 
113
116
  ## Acquiring a key
114
117
 
115
118
  The human either:
116
119
 
117
120
  - **Provides a key they already hold** (`$XMEM_API_KEY`, `.xmemrc.json`, or paste at login), or
118
- - **Creates a key** in the Console and clicks **"Use this key in the CLI"** (when the page was opened via `/xmemcli/<challenge>`).
121
+ - **Creates a key** in the Console and clicks **"Use this key in the CLI"** (when the page was opened via `/xmemcli/<challenge>`), or
122
+ - **Approves a sign-in email** for an `auth login --email` run, after which the CLI mints its own key.
119
123
 
120
124
  During **onboarding**, the Console may auto-handoff an onboarding key while a short-lived cookie bridge is active.
121
125
 
@@ -137,6 +141,22 @@ CLI ◄── localhost redirect ── redeems code + verifier → writes .xmem
137
141
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
138
142
  ```
139
143
 
144
+ ## Email sign-in — `--email`
145
+
146
+ `xmemcli auth login --email <addr> [--org-name <name>]` acquires the key with **one human action and no browser on this machine** — the flow an agent-driven IDE uses. The CLI takes the waiting-device role of the Console's cross-device email sign-in; every endpoint below already served the Console before this flag existed. **The fully headless path requires the server to have cross-device hand-off enabled**; a server without it only offers the typed six-digit code, so a run with no interactive terminal is refused there (after cancelling the attempt it had to start to find out).
147
+
148
+ 1. `POST /api/kingdom-signin-request` — the server emails a six-digit code + one-click link; the CLI holds the signed pending cookie **in memory**. The reply's **matching code** is consumed only as the hand-off signal and is not printed (the compare UI is retired across all sign-in surfaces for now).
149
+ 2. The human opens the email on any device and Approves. *This is the single human action.*
150
+ 3. The CLI claim-polls `POST /api/kingdom-signin-claim` on the Console's own waiting-tab cadence (a short grace, ten fast polls, then a slow poll; transient network failures count as missed polls, not aborts) and exchanges the proof via `POST /api/kingdom-signin-mint` for the browser-session cookie — held **in memory only, never written to disk**.
151
+ 4. With the session, the CLI runs the Console onboarding's core sequence: `GET /api/check-user`; when the address has no account, `GET /api/suggest-slug` (server-derived org name, `--org-name` overrides; ignored for an existing account) and `POST /api/public-signup` — **note that creating a new organisation requires the deployment to allow public registration, which the hosted service currently does not**: an existing account gets a key, a new address gets the server's "organisation creation is not available" refusal (the Console's admin-fallback signup arm is not implemented here); then `GET /api/accounts/users/by-email`, `GET /api/accounts/api-roles` (the seeded default role, or the org's only role — an ambiguous set is refused, never guessed), and `POST /api/accounts/api-keys` with **no cluster list** — the key attaches to every cluster the org has at mint time. Clusters created later are attached by a server-side backfill that repairs only keys already linked to every other cluster in the org; an org with no clusters at mint time gets a key attached to nothing. The key is named **`CLI email login (<hostname>, <timestamp>)`** so every mint stays individually identifiable and revocable on the Console's API Keys page.
152
+ 5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
153
+
154
+ The wait is bounded by `--timeout` — 600 seconds for this path by default, which is also a **ceiling**: nothing stays claimable past the server's pending window, so larger values are clamped. The emailed one-click link and six-digit code themselves expire at about five minutes; the wait continues past that because an approval given in time stays claimable, and the terminal says when the emailed artifacts have lapsed. The Console origin must be `https` (or a loopback host, for local development). The Console must also match the API origin the key will be used against: with a non-default `--api-url` the CLI refuses to guess the Console (pass `--console-url` or set `$XMEM_CONSOLE_URL`), and an explicitly named Console that provably mismatches the API origin (mixed loopback-ness, or a default-production half paired with a non-default one) is refused rather than trusted.
155
+
156
+ When the server offers no matching code on the request reply (cross-device hand-off disabled, no public Console origin configured, or a transient degradation), there is nothing for a headless starter to claim — the emailed link then signs in the *approving* browser — so the CLI falls back to the emailed **six-digit code typed into the terminal** (`POST /api/kingdom-signin-verify`), still a single human action. That fallback needs an interactive stdin; a non-interactive run cancels the attempt it just started (the mode is only known from the server's reply, after the email is on its way) and refuses with instructions — the typed code dies with that cancel, but on such a server the one-click link has nothing durable to revoke and stays redeemable in a browser until it expires (about five minutes), which the terminal states plainly.
157
+
158
+ A credential that already resolves (via `/whoami`) to the requested address at the requested origin — checked across `--api-key`, `$XMEM_API_KEY`, and the rc, in that order — short-circuits without sending any email, so re-running the command is free (a matching `$XMEM_API_KEY` is copied into `.xmemrc.json` so the project gains a durable credential; an explicit `--api-key` is never persisted); when the check itself cannot be made (the API is unreachable) the CLI refuses to mint rather than stranding a duplicate key. Every abort of a started attempt — Ctrl+C, timeout, or an error mid-wait — best-effort-cancels it (`POST /api/kingdom-signin-cancel`, retried briefly) and reports what the cancel actually achieved: a **confirmed** cancel of a cross-device attempt makes the emailed link unapprovable; an unconfirmed one is reported as such; and on a hand-off-disabled server the link has nothing durable to revoke — it stays redeemable (in the browser that opens it) until it expires on its own, and the terminal says so.
159
+
140
160
  ## Credential store (`.xmemrc.json`)
141
161
 
142
162
  Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
@@ -151,11 +171,15 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
151
171
 
152
172
  | Threat | Mitigation |
153
173
  | --- | --- |
154
- | Key in AI transcript | localhost callback → `0600` store; login stdout shows `key_prefix` only |
174
+ | Key in AI transcript | localhost callback / in-process mint → `0600` store; login stdout shows `key_prefix` only |
155
175
  | Key in URL | URL carries code only; key over TLS on handoff/token |
156
176
  | Captured code replay | PKCE verifier never leaves CLI; code is TTL-bounded |
157
177
  | CSRF on callback | state = PKCE challenge; redeem requires verifier |
158
178
  | Wrong Console page | CLI button only on `/xmemcli/<challenge>` |
179
+ | Session token outliving the login | the email-path session cookie is held in process memory only — never printed, never on disk — and is discarded when the process exits; the token itself stays server-valid until its ~2-day expiry, so the containment is the CLI never letting it out of memory, not revocation |
180
+ | Session cookie over cleartext | the email path refuses non-`https` Console origins (loopback excepted), refuses every redirect on its cookie-carrying requests, and fails fast when a tunnelled Console marks its cookie `Secure` for an `http://` origin |
181
+ | 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
+ | 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. |
159
183
 
160
184
  Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding; security rests on PKCE, not port secrecy.
161
185
 
@@ -167,9 +191,9 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
167
191
 
168
192
  # `xmemcli` — capabilities reference
169
193
 
170
- **Version:** `0.0.8` (see `xmemcli version`)
194
+ **Version:** `0.0.10` (see `xmemcli version`)
171
195
 
172
- `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-governed for key acquisition**; an AI-assisted IDE may run reads, writes, and inventory once credentials exist. Humans acquire API keys and change quota limits in the **Console UI**.
196
+ `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.
173
197
 
174
198
  ```bash
175
199
  xmemcli … # installed CLI
@@ -185,7 +209,7 @@ Every command below has dedicated help — see [Help](#help).
185
209
 
186
210
  | Command | Description |
187
211
  |---------|-------------|
188
- | **`write`** `<text>` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. |
212
+ | **`write`** `<text>` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted, new objects and links to them are allowed, anything else fails; fast extraction only. |
189
213
  | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. |
190
214
  | **`read`** `<query>` | Read from active instance. `--read-mode single\|raw\|xresponse`, `--scope TYPE:ID` (repeatable), `--scope-relations`. |
191
215
 
@@ -386,7 +410,7 @@ so a typo fails loudly rather than being ignored.
386
410
 
387
411
  | Command | Description |
388
412
  |---------|-------------|
389
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`. |
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). |
390
414
  | **`auth logout`** | Remove active `.xmemrc.json` |
391
415
  | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
392
416
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
@@ -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]
10
+ xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
11
11
  xmemcli auth logout
12
12
  xmemcli auth status
13
13
  xmemcli read <query>
@@ -89,25 +89,29 @@ xmemcli version
89
89
  xmemcli auth login
90
90
  ```
91
91
 
92
- Version: **0.0.8**.
92
+ Version: **0.0.10**.
93
93
 
94
94
 
95
95
  ---
96
96
 
97
- # `xmemcli` auth flow — PKCE handoff and credentials
97
+ # `xmemcli` auth flow — PKCE handoff, email sign-in, and credentials
98
98
 
99
- The CLI is **human-governed for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, but **acquiring an API key** stays under human control in the browser. A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`.
99
+ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may even *initiate* key acquisition, but a key is only ever issued after **one explicit human approval** — a click in the browser (PKCE handoff) or an approval of a sign-in email (`--email`). A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`, and the CLI prints only its prefix.
100
100
 
101
- **Model:** 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.
101
+ Two acquisition paths share `xmemcli auth login`:
102
102
 
103
- > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`.
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.
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
+
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).
104
107
 
105
108
  ## Acquiring a key
106
109
 
107
110
  The human either:
108
111
 
109
112
  - **Provides a key they already hold** (`$XMEM_API_KEY`, `.xmemrc.json`, or paste at login), or
110
- - **Creates a key** in the Console and clicks **"Use this key in the CLI"** (when the page was opened via `/xmemcli/<challenge>`).
113
+ - **Creates a key** in the Console and clicks **"Use this key in the CLI"** (when the page was opened via `/xmemcli/<challenge>`), or
114
+ - **Approves a sign-in email** for an `auth login --email` run, after which the CLI mints its own key.
111
115
 
112
116
  During **onboarding**, the Console may auto-handoff an onboarding key while a short-lived cookie bridge is active.
113
117
 
@@ -129,6 +133,22 @@ CLI ◄── localhost redirect ── redeems code + verifier → writes .xmem
129
133
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
130
134
  ```
131
135
 
136
+ ## Email sign-in — `--email`
137
+
138
+ `xmemcli auth login --email <addr> [--org-name <name>]` acquires the key with **one human action and no browser on this machine** — the flow an agent-driven IDE uses. The CLI takes the waiting-device role of the Console's cross-device email sign-in; every endpoint below already served the Console before this flag existed. **The fully headless path requires the server to have cross-device hand-off enabled**; a server without it only offers the typed six-digit code, so a run with no interactive terminal is refused there (after cancelling the attempt it had to start to find out).
139
+
140
+ 1. `POST /api/kingdom-signin-request` — the server emails a six-digit code + one-click link; the CLI holds the signed pending cookie **in memory**. The reply's **matching code** is consumed only as the hand-off signal and is not printed (the compare UI is retired across all sign-in surfaces for now).
141
+ 2. The human opens the email on any device and Approves. *This is the single human action.*
142
+ 3. The CLI claim-polls `POST /api/kingdom-signin-claim` on the Console's own waiting-tab cadence (a short grace, ten fast polls, then a slow poll; transient network failures count as missed polls, not aborts) and exchanges the proof via `POST /api/kingdom-signin-mint` for the browser-session cookie — held **in memory only, never written to disk**.
143
+ 4. With the session, the CLI runs the Console onboarding's core sequence: `GET /api/check-user`; when the address has no account, `GET /api/suggest-slug` (server-derived org name, `--org-name` overrides; ignored for an existing account) and `POST /api/public-signup` — **note that creating a new organisation requires the deployment to allow public registration, which the hosted service currently does not**: an existing account gets a key, a new address gets the server's "organisation creation is not available" refusal (the Console's admin-fallback signup arm is not implemented here); then `GET /api/accounts/users/by-email`, `GET /api/accounts/api-roles` (the seeded default role, or the org's only role — an ambiguous set is refused, never guessed), and `POST /api/accounts/api-keys` with **no cluster list** — the key attaches to every cluster the org has at mint time. Clusters created later are attached by a server-side backfill that repairs only keys already linked to every other cluster in the org; an org with no clusters at mint time gets a key attached to nothing. The key is named **`CLI email login (<hostname>, <timestamp>)`** so every mint stays individually identifiable and revocable on the Console's API Keys page.
144
+ 5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
145
+
146
+ The wait is bounded by `--timeout` — 600 seconds for this path by default, which is also a **ceiling**: nothing stays claimable past the server's pending window, so larger values are clamped. The emailed one-click link and six-digit code themselves expire at about five minutes; the wait continues past that because an approval given in time stays claimable, and the terminal says when the emailed artifacts have lapsed. The Console origin must be `https` (or a loopback host, for local development). The Console must also match the API origin the key will be used against: with a non-default `--api-url` the CLI refuses to guess the Console (pass `--console-url` or set `$XMEM_CONSOLE_URL`), and an explicitly named Console that provably mismatches the API origin (mixed loopback-ness, or a default-production half paired with a non-default one) is refused rather than trusted.
147
+
148
+ When the server offers no matching code on the request reply (cross-device hand-off disabled, no public Console origin configured, or a transient degradation), there is nothing for a headless starter to claim — the emailed link then signs in the *approving* browser — so the CLI falls back to the emailed **six-digit code typed into the terminal** (`POST /api/kingdom-signin-verify`), still a single human action. That fallback needs an interactive stdin; a non-interactive run cancels the attempt it just started (the mode is only known from the server's reply, after the email is on its way) and refuses with instructions — the typed code dies with that cancel, but on such a server the one-click link has nothing durable to revoke and stays redeemable in a browser until it expires (about five minutes), which the terminal states plainly.
149
+
150
+ A credential that already resolves (via `/whoami`) to the requested address at the requested origin — checked across `--api-key`, `$XMEM_API_KEY`, and the rc, in that order — short-circuits without sending any email, so re-running the command is free (a matching `$XMEM_API_KEY` is copied into `.xmemrc.json` so the project gains a durable credential; an explicit `--api-key` is never persisted); when the check itself cannot be made (the API is unreachable) the CLI refuses to mint rather than stranding a duplicate key. Every abort of a started attempt — Ctrl+C, timeout, or an error mid-wait — best-effort-cancels it (`POST /api/kingdom-signin-cancel`, retried briefly) and reports what the cancel actually achieved: a **confirmed** cancel of a cross-device attempt makes the emailed link unapprovable; an unconfirmed one is reported as such; and on a hand-off-disabled server the link has nothing durable to revoke — it stays redeemable (in the browser that opens it) until it expires on its own, and the terminal says so.
151
+
132
152
  ## Credential store (`.xmemrc.json`)
133
153
 
134
154
  Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
@@ -143,11 +163,15 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
143
163
 
144
164
  | Threat | Mitigation |
145
165
  | --- | --- |
146
- | Key in AI transcript | localhost callback → `0600` store; login stdout shows `key_prefix` only |
166
+ | Key in AI transcript | localhost callback / in-process mint → `0600` store; login stdout shows `key_prefix` only |
147
167
  | Key in URL | URL carries code only; key over TLS on handoff/token |
148
168
  | Captured code replay | PKCE verifier never leaves CLI; code is TTL-bounded |
149
169
  | CSRF on callback | state = PKCE challenge; redeem requires verifier |
150
170
  | Wrong Console page | CLI button only on `/xmemcli/<challenge>` |
171
+ | Session token outliving the login | the email-path session cookie is held in process memory only — never printed, never on disk — and is discarded when the process exits; the token itself stays server-valid until its ~2-day expiry, so the containment is the CLI never letting it out of memory, not revocation |
172
+ | Session cookie over cleartext | the email path refuses non-`https` Console origins (loopback excepted), refuses every redirect on its cookie-carrying requests, and fails fast when a tunnelled Console marks its cookie `Secure` for an `http://` origin |
173
+ | 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
+ | 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. |
151
175
 
152
176
  Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding; security rests on PKCE, not port secrecy.
153
177
 
@@ -159,9 +183,9 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
159
183
 
160
184
  # `xmemcli` — capabilities reference
161
185
 
162
- **Version:** `0.0.8` (see `xmemcli version`)
186
+ **Version:** `0.0.10` (see `xmemcli version`)
163
187
 
164
- `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-governed for key acquisition**; an AI-assisted IDE may run reads, writes, and inventory once credentials exist. Humans acquire API keys and change quota limits in the **Console UI**.
188
+ `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.
165
189
 
166
190
  ```bash
167
191
  xmemcli … # installed CLI
@@ -177,7 +201,7 @@ Every command below has dedicated help — see [Help](#help).
177
201
 
178
202
  | Command | Description |
179
203
  |---------|-------------|
180
- | **`write`** `<text>` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. |
204
+ | **`write`** `<text>` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted, new objects and links to them are allowed, anything else fails; fast extraction only. |
181
205
  | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. |
182
206
  | **`read`** `<query>` | Read from active instance. `--read-mode single\|raw\|xresponse`, `--scope TYPE:ID` (repeatable), `--scope-relations`. |
183
207
 
@@ -378,7 +402,7 @@ so a typo fails loudly rather than being ignored.
378
402
 
379
403
  | Command | Description |
380
404
  |---------|-------------|
381
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`. |
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). |
382
406
  | **`auth logout`** | Remove active `.xmemrc.json` |
383
407
  | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
384
408
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.8"
3
+ version = "0.0.10"
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.8"
7
+ version = "0.0.10"
8
8
  source = { editable = "." }
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "0.0.8"
3
+ __version__ = "0.0.10"
@@ -94,6 +94,12 @@ _CONSUMER_HOOK_OUTPUT_LIMIT = 10_000
94
94
  #: just fits over the edge.
95
95
  MAX_PACK_CHARS = _CONSUMER_HOOK_OUTPUT_LIMIT - 500
96
96
 
97
+ #: Sections the server renders per pack (identity, owner, rules, schema, live state).
98
+ #: The footprint is a diagnostic, so a server sending more is not describing a pack this
99
+ #: client received; publishing them unbounded would let a small response build a large
100
+ #: document. Mirrors the renderer's section list rather than any transport limit.
101
+ _MAX_SECTIONS_PER_PACK = 5
102
+
97
103
 
98
104
  def _requestable_ids(autoload: tuple[Any, ...], listed: AbstractSet[str]) -> tuple[list[str], list[str]]:
99
105
  """Ids the server will accept, and the ones dropped with a reason.
@@ -456,9 +462,94 @@ def _curated_unavailable(entry: Any) -> dict[str, Any]:
456
462
  return curated
457
463
 
458
464
 
465
+ def _curated_pack(entry: Any) -> dict[str, Any]:
466
+ """One pack's footprint: what it cost, whether it was cut, and where the cost sits.
467
+
468
+ Sizes and identity only. The pack's own text is already in ``text``, and republishing
469
+ section bodies here would hand a caller a second copy of content that the clamp and
470
+ scrub below bound exactly once.
471
+
472
+ Section titles are the server's own headings rather than anything an owner wrote, but
473
+ they arrive over the wire like everything else here, so they are scrubbed and length-
474
+ bounded. Not the UTF-16 clamp the pack text also gets — a title is capped at 200 code
475
+ points, so it cannot reach the limit that clamp exists for. A count that is not a
476
+ number is published as ``None``: a caller doing arithmetic on it should see a gap, not
477
+ a zero it cannot distinguish from a section that genuinely cost nothing.
478
+ """
479
+ curated: dict[str, Any] = {
480
+ "instance_id": scrub(entry["instance_id"], limit=200) if isinstance(entry.get("instance_id"), str) else None,
481
+ "estimated_tokens": _count(entry.get("estimated_tokens")),
482
+ "truncated": entry["truncated"] if isinstance(entry.get("truncated"), bool) else None,
483
+ }
484
+ raw_sections = entry.get("sections")
485
+ # Filtered before the cap, not after: slicing first let junk consume the slots, so a
486
+ # response whose first five entries were nulls published none of the real ones.
487
+ sections = [s for s in raw_sections if isinstance(s, dict)] if isinstance(raw_sections, list) else []
488
+ # Bounded as well as filtered. The server renders at most five sections per pack, so
489
+ # anything beyond that is not a breakdown of anything, and an unbounded array on a
490
+ # payload whose text is hard-capped is a way for a small response to build a large
491
+ # document. Not the only one — ``unavailable`` above is still uncapped — but the cap
492
+ # is cheap where the shape is known.
493
+ curated["sections"] = [
494
+ {
495
+ "title": scrub(section["title"], limit=200) if isinstance(section.get("title"), str) else None,
496
+ "estimated_tokens": _count(section.get("estimated_tokens")),
497
+ }
498
+ for section in sections[:_MAX_SECTIONS_PER_PACK]
499
+ ]
500
+ return curated
501
+
502
+
503
+ def _curated_universal_rules(entry: Any) -> dict[str, Any] | None:
504
+ """The response-level rules block's footprint, or ``None`` when the server sent none.
505
+
506
+ Published for the same reason the per-pack sections are: this is the one part of the response
507
+ that belongs to no pack, so a caller summing ``packs`` to account for ``estimated_tokens``
508
+ comes up short by exactly it. It used to be inside every pack and is now charged once, which
509
+ is a change someone reading the breakdown to explain a budget needs to see.
510
+
511
+ Title and size only, like ``_curated_pack`` — the body is already in ``text``.
512
+ """
513
+ if not isinstance(entry, dict):
514
+ return None
515
+ return {
516
+ "title": scrub(entry["title"], limit=200) if isinstance(entry.get("title"), str) else None,
517
+ "estimated_tokens": _count(entry.get("estimated_tokens")),
518
+ }
519
+
520
+
521
+ def _count(value: Any) -> int | None:
522
+ """A published token count, or ``None`` when the server sent something that is not one.
523
+
524
+ ``bool`` is excluded explicitly because it is a subclass of ``int``: without that,
525
+ ``"estimated_tokens": true`` published ``true`` and a caller summing the sections
526
+ added one, which is the arithmetic the ``None`` exists to keep honest. Spelled the
527
+ same way in ``_pretty.py`` and ``xmem.py``; ``_binding.py`` reaches the same result
528
+ through the negated form. The idiom appears at several sites in ``cli/`` and each
529
+ checks something different afterwards (``> 0``, ``>= 0``, ``< 1``), which is why this
530
+ stays local rather than becoming a shared helper.
531
+ """
532
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
533
+
534
+
459
535
  def _nothing_to_inject() -> dict[str, Any]:
460
- """The payload for "there is no context to add", used by every such path."""
461
- return {"text": "", "instance_ids": [], "unavailable": [], "estimated_tokens": 0}
536
+ """The payload for "there is no context to add", used by every such path.
537
+
538
+ ``max_tokens`` is ``None`` rather than absent: the success path always carries it,
539
+ and a caller told the shape is stable should not have to test for the key before
540
+ reading it on the one branch that omitted it.
541
+ """
542
+ return {
543
+ "text": "",
544
+ "instance_ids": [],
545
+ "unavailable": [],
546
+ "estimated_tokens": 0,
547
+ "max_tokens": None,
548
+ "packs": [],
549
+ # Present and null for the same reason ``max_tokens`` is: the success path always carries
550
+ # the key, and a caller told the shape is stable should not have to test for it.
551
+ "universal_rules": None,
552
+ }
462
553
 
463
554
 
464
555
  def _command_budget(args: argparse.Namespace) -> float:
@@ -743,6 +834,23 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
743
834
  # `diff_plan`, anything an internal renderer attaches) cannot arrive here and be
744
835
  # published; it is dropped until someone decides it belongs.
745
836
  unavailable = [_curated_unavailable(entry) for entry in unavailable]
837
+ # Shape-checked before iteration for the same reason `items` and `unavailable` above
838
+ # are: `result.get("packs") or []` keeps whatever truthy value arrived, so
839
+ # `{"packs": 7}` raised `TypeError` out of the comprehension and the blanket guard
840
+ # turned a perfectly good, already-clamped pack into an empty payload — the whole
841
+ # session's context discarded because an optional diagnostic field had the wrong
842
+ # type. A dict or a string would not even raise: they iterate, and publish phantom
843
+ # entries. The footprint is a diagnostic, so a bad one costs itself and nothing else.
844
+ raw_packs = result.get("packs")
845
+ if raw_packs is not None and not isinstance(raw_packs, list):
846
+ m._note_cli_warning(args, "xmemory returned an unreadable context footprint; sizes omitted")
847
+ raw_packs = []
848
+ # Non-dict elements are skipped rather than curated into all-null entries: a
849
+ # phantom pack is the outcome the guard above warns about, one level down, and a
850
+ # caller cannot tell it from an instance that genuinely reported nothing. Capped at
851
+ # the number of instances the server will accept in one request, for the same reason
852
+ # the sections are capped.
853
+ packs = [_curated_pack(p) for p in [p for p in (raw_packs or []) if isinstance(p, dict)][:MAX_REQUESTED_INSTANCES]]
746
854
  delivered = [i for i in requested if i not in missing]
747
855
  if unavailable:
748
856
  m._note_cli_warning(
@@ -769,7 +877,30 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
769
877
  # passes the first at 9,500 and measures 19,000 to the second, and Claude Code
770
878
  # writes it to a file and injects a preview instead of the context. Bounding the
771
879
  # unit the consumer actually counts is what the cap was for.
772
- text = clamp_utf16(scrub(text, limit=MAX_PACK_CHARS), limit=MAX_PACK_CHARS)
880
+ # Sanitised first, with a limit that cannot bite, so the two jobs `scrub` performs are
881
+ # separated: it strips Cc/Cf/Cs characters *and* clamps. Measuring the final text
882
+ # against the raw server text conflated them — a fully delivered pack carrying one
883
+ # zero-width joiner, soft hyphen, BOM or bidi mark came out shorter and was reported as
884
+ # cut, which is a false alarm aimed at exactly the reader who asked why their context
885
+ # shrank. Only a length cut is a length cut.
886
+ sanitised = scrub(text, limit=len(text))
887
+ injected = clamp_utf16(scrub(sanitised, limit=MAX_PACK_CHARS), limit=MAX_PACK_CHARS)
888
+ # The footprint below describes what the *server* rendered. When this clamp bites, part
889
+ # of that never reaches the model, and a breakdown still reporting the full sizes with
890
+ # `truncated: false` misleads precisely the reader it exists for — someone asking why
891
+ # their live state disappeared, being shown a section that advertises 4,900 tokens and
892
+ # arrived as nothing. So a client-side cut marks every pack too. The flag means "what
893
+ # you are reading is not the whole of it", whichever side did the cutting; the warning
894
+ # names which side, because the fixes are different — retier a binding, or lower
895
+ # `pack_max_tokens`.
896
+ if len(injected) < len(sanitised):
897
+ m._note_cli_warning(
898
+ args,
899
+ "the rendered context was longer than this client can inject and was cut; "
900
+ "the sizes reported describe what xmemory rendered, not what was injected",
901
+ )
902
+ packs = [{**pack, "truncated": True} for pack in packs]
903
+ text = injected
773
904
  return (
774
905
  CliOutput(
775
906
  OutputKind.AGENT_CONTEXT,
@@ -777,8 +908,22 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
777
908
  "text": text,
778
909
  "instance_ids": delivered,
779
910
  "unavailable": unavailable,
780
- "estimated_tokens": result.get("estimated_tokens") or 0,
781
- "max_tokens": result.get("max_tokens"),
911
+ # Through the same guard as the parts: publishing `true` as a total while
912
+ # the sections it explains correctly read null is the inconsistency the
913
+ # breakdown exists to avoid.
914
+ "estimated_tokens": _count(result.get("estimated_tokens")) or 0,
915
+ "max_tokens": _count(result.get("max_tokens")),
916
+ # Where the total sits, per instance and per section. The total alone says a
917
+ # pack was expensive or was cut; only the breakdown says which instance spent
918
+ # the budget and what a shrink took, which is what someone asking "why is my
919
+ # context so big" or "why did my live state disappear" is actually asking.
920
+ # Curated the way `unavailable` is, so a section body cannot arrive by this
921
+ # route and bypass the clamp the pack text goes through.
922
+ "packs": packs,
923
+ # The part of the total that belongs to no pack: the operating rules, carried once
924
+ # for the response rather than repeated in each pack. Without it the breakdown
925
+ # cannot account for its own total.
926
+ "universal_rules": _curated_universal_rules(result.get("universal_rules")),
782
927
  },
783
928
  ),
784
929
  m.EXIT_OK,