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.
- {xmemcli-0.0.8 → xmemcli-0.0.10}/PKG-INFO +38 -14
- {xmemcli-0.0.8 → xmemcli-0.0.10}/README.md +36 -12
- {xmemcli-0.0.8 → xmemcli-0.0.10}/pyproject.toml +1 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/uv.lock +1 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/__init__.py +1 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_context_cmd.py +150 -5
- xmemcli-0.0.10/xmemcli/_email_login.py +1009 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_help.py +1 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_login.py +98 -10
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_output.py +12 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_pretty.py +25 -3
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_rc.py +1 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_version.py +1 -1
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/cli.py +504 -83
- {xmemcli-0.0.8 → xmemcli-0.0.10}/.gitignore +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_binding.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_binding_cmd.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_envelope.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_instance_schema.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_mcp_bridge.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_text.py +0 -0
- {xmemcli-0.0.8 → xmemcli-0.0.10}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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-
|
|
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
|
-
|
|
109
|
+
Two acquisition paths share `xmemcli auth login`:
|
|
110
110
|
|
|
111
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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-
|
|
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
|
-
|
|
101
|
+
Two acquisition paths share `xmemcli auth login`:
|
|
102
102
|
|
|
103
|
-
|
|
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.
|
|
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-
|
|
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. |
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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,
|