xmemcli 0.0.11__tar.gz → 0.0.13__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.11 → xmemcli-0.0.13}/PKG-INFO +16 -6
- {xmemcli-0.0.11 → xmemcli-0.0.13}/README.md +15 -5
- {xmemcli-0.0.11 → xmemcli-0.0.13}/pyproject.toml +1 -1
- {xmemcli-0.0.11 → xmemcli-0.0.13}/uv.lock +1 -1
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/__init__.py +1 -1
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_login.py +80 -16
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_mcp_bridge.py +35 -3
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_output.py +6 -3
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_pretty.py +1 -1
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_rc.py +19 -21
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_version.py +1 -1
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/cli.py +65 -14
- {xmemcli-0.0.11 → xmemcli-0.0.13}/.gitignore +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_binding.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_binding_cmd.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_context_cmd.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_email_login.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_envelope.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_help.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_instance_schema.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_text.py +0 -0
- {xmemcli-0.0.11 → xmemcli-0.0.13}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
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
|
|
@@ -97,7 +97,7 @@ xmemcli version
|
|
|
97
97
|
xmemcli auth login
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Version: **0.0.
|
|
100
|
+
Version: **0.0.13**.
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
---
|
|
@@ -151,7 +151,7 @@ AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never hand
|
|
|
151
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
152
|
5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
|
|
153
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
|
|
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 only infers a Console where the layout determines one — a single-origin `/xmem` base names its `/console` sibling on the same host, and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url` or set `$XMEM_CONSOLE_URL`). Because that inference exists, a single-origin deployment's rendered sign-in command names the API alone; every other layout still renders the `--api-url`/`--console-url` pair, since naming one without the other is worse than naming neither (`api/agent_setup_renderer.py`), 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
155
|
|
|
156
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
157
|
|
|
@@ -161,7 +161,9 @@ 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
|
-
|
|
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
|
+
|
|
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. A base URL's path is preserved, never stripped — on a single-origin deployment the API lives at `https://host/xmem`, and that mount is part of every request the CLI builds. Because a mounted base cannot also serve the `/api`-prefixed spelling of a route, the compatibility candidate in `whoami_urls` / `token_redeem_urls` (`{base}/api/whoami`, `{base}/api/cli/token` — a root-served deployment's second form) is offered only for a path-less base. The MCP bridge resolves its endpoint as `--mcp-url` → `$XMEM_MCP_URL` → the `/mcp` sibling of a single-origin API base → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
|
|
165
167
|
|
|
166
168
|
## Identity — `/whoami`
|
|
167
169
|
|
|
@@ -191,7 +193,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
|
|
|
191
193
|
|
|
192
194
|
# `xmemcli` — capabilities reference
|
|
193
195
|
|
|
194
|
-
**Version:** `0.0.
|
|
196
|
+
**Version:** `0.0.13` (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
|
|
|
@@ -513,7 +515,13 @@ 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
|
-
|
|
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
|
+
**Compatibility change in 0.0.13 (single-origin deployments):** an API base URL's path is now **preserved**, never stripped — `--api-url https://host/xmem` builds requests under `/xmem` instead of reducing to the bare origin. Where the base's path is exactly `/xmem`, the CLI infers its single-origin siblings on the same host: `auth login` finds the Console at `/console` without `--console-url`, and `xmemcli mcp` reaches `/mcp` without `--mcp-url` (explicit flags and `$XMEM_CONSOLE_URL` / `$XMEM_MCP_URL` still win, and the MCP origin guard accepts the `/xmem` ↔ `/mcp` pair as one deployment). Bare origins and every other path shape behave exactly as before. See `cli/`.
|
|
521
|
+
|
|
522
|
+
Two consequences worth knowing. A single-origin deployment's own setup instructions rely on this inference — they render `auth login --api-url https://host/xmem` with **no** `--console-url` — so **0.0.13 is the minimum on such a deployment**; an older CLI given that command signs in at the hosted Console instead. And the inference is keyed on the conventional mounts (`/xmem`, `/console`, `/mcp`). A deployment that mounts its services under other prefixes is not inferable: name `--console-url` and `--mcp-url` explicitly there, which is what its own rendered commands do.
|
|
523
|
+
|
|
524
|
+
See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
|
|
517
525
|
|
|
518
526
|
---
|
|
519
527
|
|
|
@@ -602,6 +610,8 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
602
610
|
|
|
603
611
|
**Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
|
|
604
612
|
|
|
613
|
+
**`.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.
|
|
614
|
+
|
|
605
615
|
**`context` is the one exception, and it is a security boundary.** Every other command walks up
|
|
606
616
|
from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
|
|
607
617
|
that directory and ran the command in it. `context` has neither — a session-start hook runs it
|
|
@@ -89,7 +89,7 @@ xmemcli version
|
|
|
89
89
|
xmemcli auth login
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Version: **0.0.
|
|
92
|
+
Version: **0.0.13**.
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
---
|
|
@@ -143,7 +143,7 @@ AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never hand
|
|
|
143
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
144
|
5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
|
|
145
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
|
|
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 only infers a Console where the layout determines one — a single-origin `/xmem` base names its `/console` sibling on the same host, and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url` or set `$XMEM_CONSOLE_URL`). Because that inference exists, a single-origin deployment's rendered sign-in command names the API alone; every other layout still renders the `--api-url`/`--console-url` pair, since naming one without the other is worse than naming neither (`api/agent_setup_renderer.py`), 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
147
|
|
|
148
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
149
|
|
|
@@ -153,7 +153,9 @@ 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
|
-
|
|
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
|
+
|
|
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. A base URL's path is preserved, never stripped — on a single-origin deployment the API lives at `https://host/xmem`, and that mount is part of every request the CLI builds. Because a mounted base cannot also serve the `/api`-prefixed spelling of a route, the compatibility candidate in `whoami_urls` / `token_redeem_urls` (`{base}/api/whoami`, `{base}/api/cli/token` — a root-served deployment's second form) is offered only for a path-less base. The MCP bridge resolves its endpoint as `--mcp-url` → `$XMEM_MCP_URL` → the `/mcp` sibling of a single-origin API base → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
|
|
157
159
|
|
|
158
160
|
## Identity — `/whoami`
|
|
159
161
|
|
|
@@ -183,7 +185,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
|
|
|
183
185
|
|
|
184
186
|
# `xmemcli` — capabilities reference
|
|
185
187
|
|
|
186
|
-
**Version:** `0.0.
|
|
188
|
+
**Version:** `0.0.13` (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
|
|
|
@@ -505,7 +507,13 @@ 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
|
-
|
|
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
|
+
**Compatibility change in 0.0.13 (single-origin deployments):** an API base URL's path is now **preserved**, never stripped — `--api-url https://host/xmem` builds requests under `/xmem` instead of reducing to the bare origin. Where the base's path is exactly `/xmem`, the CLI infers its single-origin siblings on the same host: `auth login` finds the Console at `/console` without `--console-url`, and `xmemcli mcp` reaches `/mcp` without `--mcp-url` (explicit flags and `$XMEM_CONSOLE_URL` / `$XMEM_MCP_URL` still win, and the MCP origin guard accepts the `/xmem` ↔ `/mcp` pair as one deployment). Bare origins and every other path shape behave exactly as before. See `cli/`.
|
|
513
|
+
|
|
514
|
+
Two consequences worth knowing. A single-origin deployment's own setup instructions rely on this inference — they render `auth login --api-url https://host/xmem` with **no** `--console-url` — so **0.0.13 is the minimum on such a deployment**; an older CLI given that command signs in at the hosted Console instead. And the inference is keyed on the conventional mounts (`/xmem`, `/console`, `/mcp`). A deployment that mounts its services under other prefixes is not inferable: name `--console-url` and `--mcp-url` explicitly there, which is what its own rendered commands do.
|
|
515
|
+
|
|
516
|
+
See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
|
|
509
517
|
|
|
510
518
|
---
|
|
511
519
|
|
|
@@ -594,6 +602,8 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
594
602
|
|
|
595
603
|
**Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
|
|
596
604
|
|
|
605
|
+
**`.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.
|
|
606
|
+
|
|
597
607
|
**`context` is the one exception, and it is a security boundary.** Every other command walks up
|
|
598
608
|
from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
|
|
599
609
|
that directory and ran the command in it. `context` has neither — a session-start hook runs it
|
|
@@ -77,6 +77,13 @@ CLI_LOGIN_PATH = "/xmemcli"
|
|
|
77
77
|
CLI_TOKEN_PROXY_PATH = "/api/cli/token"
|
|
78
78
|
CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
|
|
79
79
|
|
|
80
|
+
#: Mount paths of a single-origin deployment, where one hostname serves every surface under
|
|
81
|
+
#: fixed path prefixes. An API base ending in ``/xmem`` therefore names its Console and MCP
|
|
82
|
+
#: siblings by construction — the grammar ``single_origin_sibling_url`` implements.
|
|
83
|
+
SINGLE_ORIGIN_API_PATH = "/xmem"
|
|
84
|
+
SINGLE_ORIGIN_CONSOLE_PATH = "/console"
|
|
85
|
+
SINGLE_ORIGIN_MCP_PATH = "/mcp"
|
|
86
|
+
|
|
80
87
|
API_KEY_PREFIX = "xmem_"
|
|
81
88
|
|
|
82
89
|
#: Hostnames treated as loopback wherever the CLI relaxes an https-only rule or infers the
|
|
@@ -130,8 +137,43 @@ def challenge_for(verifier: str) -> str:
|
|
|
130
137
|
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
131
138
|
|
|
132
139
|
|
|
140
|
+
def single_origin_sibling_url(api_url: str, *, sibling_path: str) -> str | None:
|
|
141
|
+
"""``https://host/xmem`` → ``https://host<sibling_path>``, or None when the shape says nothing.
|
|
142
|
+
|
|
143
|
+
Exact on purpose: only an API base whose path is exactly ``/xmem`` sits on a single-origin
|
|
144
|
+
deployment, where the siblings are fixed by the routing grammar. A bare origin (a five-host
|
|
145
|
+
deployment) infers nothing, and extra path segments, query, fragment, or credentials make it
|
|
146
|
+
someone else's URL. Hostname surgery (``api.stg`` → ``mcp.stg``) is deliberately not done
|
|
147
|
+
here — deriving one *hostname* from another is what the pairing table in ``_mcp_bridge``
|
|
148
|
+
exists to avoid guessing.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
parsed = urlsplit(api_url.strip())
|
|
152
|
+
_ = parsed.port # the port is parsed lazily; force it — a malformed URL must refuse, not raise
|
|
153
|
+
except ValueError:
|
|
154
|
+
# `urlsplit` itself raises on an unbracketed-IPv6 shape, and this helper sees origins
|
|
155
|
+
# read from an rc file the user may have edited by hand (see `_host_of` in _mcp_bridge).
|
|
156
|
+
return None
|
|
157
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
158
|
+
return None
|
|
159
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
160
|
+
return None
|
|
161
|
+
if parsed.path.rstrip("/") != SINGLE_ORIGIN_API_PATH:
|
|
162
|
+
return None
|
|
163
|
+
return f"{parsed.scheme}://{parsed.netloc}{sibling_path}"
|
|
164
|
+
|
|
165
|
+
|
|
133
166
|
def console_url_from_api_url(api_url: str) -> str | None:
|
|
134
|
-
"""
|
|
167
|
+
"""Infer the Console base from an API base, where the deployment layout determines it.
|
|
168
|
+
|
|
169
|
+
Two shapes qualify: a single-origin ``/xmem`` base names its ``/console`` sibling on the
|
|
170
|
+
same host, and the local dev convention pairs API :8000 with Console :8002 on the same
|
|
171
|
+
loopback host. A bare non-loopback origin infers nothing — five-host deployments name
|
|
172
|
+
their Console explicitly.
|
|
173
|
+
"""
|
|
174
|
+
sibling = single_origin_sibling_url(api_url, sibling_path=SINGLE_ORIGIN_CONSOLE_PATH)
|
|
175
|
+
if sibling is not None:
|
|
176
|
+
return sibling
|
|
135
177
|
parsed = urlparse(api_url.rstrip("/"))
|
|
136
178
|
if parsed.hostname not in LOOPBACK_HOSTS:
|
|
137
179
|
return None
|
|
@@ -147,18 +189,25 @@ def console_url_from_api_url(api_url: str) -> str | None:
|
|
|
147
189
|
return None
|
|
148
190
|
|
|
149
191
|
|
|
150
|
-
def console_url_for_api(*, console_url: str | None = None) -> str:
|
|
151
|
-
"""Resolve the Console origin for PKCE login and token/whoami proxies.
|
|
192
|
+
def console_url_for_api(*, console_url: str | None = None, api_url: str | None = None) -> str:
|
|
193
|
+
"""Resolve the Console origin for PKCE login and token/whoami proxies.
|
|
194
|
+
|
|
195
|
+
Explicit wins (flag, then ``$XMEM_CONSOLE_URL``); then inference from the API base the
|
|
196
|
+
caller is actually signing in against, then from ``$XMEM_API_URL``; then the default.
|
|
197
|
+
``api_url`` outranks the environment because it is the resolved target of this very call
|
|
198
|
+
— an exported ``$XMEM_API_URL`` must not steer the Console away from an explicit
|
|
199
|
+
``--api-url``.
|
|
200
|
+
"""
|
|
152
201
|
if console_url:
|
|
153
202
|
return console_url.rstrip("/")
|
|
154
203
|
from_env = os.environ.get(_ENV_CONSOLE_URL, "").strip()
|
|
155
204
|
if from_env:
|
|
156
205
|
return from_env.rstrip("/")
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
206
|
+
for candidate in (api_url, os.environ.get(_ENV_API_URL, "").strip()):
|
|
207
|
+
if candidate:
|
|
208
|
+
inferred = console_url_from_api_url(candidate)
|
|
209
|
+
if inferred:
|
|
210
|
+
return inferred
|
|
162
211
|
return DEFAULT_CONSOLE_URL
|
|
163
212
|
|
|
164
213
|
|
|
@@ -226,22 +275,35 @@ def write_login_handoff_prompt(
|
|
|
226
275
|
out.flush()
|
|
227
276
|
|
|
228
277
|
|
|
278
|
+
def _api_candidates(api_url: str, *, path: str, legacy_path: str) -> list[str]:
|
|
279
|
+
"""The API's own route for something, plus the ``/api``-prefixed spelling where it can exist.
|
|
280
|
+
|
|
281
|
+
The second candidate is compatibility only: it answers a deployment served at the origin
|
|
282
|
+
root, which replies under ``/api`` as well. A base that carries a mount cannot serve it —
|
|
283
|
+
on a single-origin deployment the API is at ``https://host/xmem`` and
|
|
284
|
+
``https://host/xmem/api/whoami`` is a path no layout produces — so it is left out there
|
|
285
|
+
rather than spending a request, and a 404, on a URL known in advance to be wrong.
|
|
286
|
+
"""
|
|
287
|
+
base = api_url.rstrip("/")
|
|
288
|
+
if urlsplit(base).path:
|
|
289
|
+
return [f"{base}{path}"]
|
|
290
|
+
return [f"{base}{path}", f"{base}{legacy_path}"]
|
|
291
|
+
|
|
292
|
+
|
|
229
293
|
def token_redeem_urls(*, api_url: str, console_url: str | None) -> list[str]:
|
|
230
294
|
"""Candidate ``POST /cli/token`` URLs, most likely first."""
|
|
231
|
-
console = console_url_for_api(console_url=console_url)
|
|
232
295
|
if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
|
|
296
|
+
console = console_url_for_api(console_url=console_url)
|
|
233
297
|
return [f"{console.rstrip('/')}{CLI_TOKEN_PROXY_PATH}"]
|
|
234
|
-
|
|
235
|
-
return [f"{base}/cli/token", f"{base}/api/cli/token"]
|
|
298
|
+
return _api_candidates(api_url, path="/cli/token", legacy_path="/api/cli/token")
|
|
236
299
|
|
|
237
300
|
|
|
238
301
|
def whoami_urls(*, api_url: str, console_url: str | None) -> list[str]:
|
|
239
302
|
"""Candidate ``GET /whoami`` URLs for bearer API-key lookup, most likely first."""
|
|
240
|
-
console = console_url_for_api(console_url=console_url)
|
|
241
303
|
if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
|
|
304
|
+
console = console_url_for_api(console_url=console_url)
|
|
242
305
|
return [f"{console.rstrip('/')}{CLI_WHOAMI_PROXY_PATH}"]
|
|
243
|
-
|
|
244
|
-
return [f"{base}/whoami", f"{base}/api/whoami"]
|
|
306
|
+
return _api_candidates(api_url, path="/whoami", legacy_path="/api/whoami")
|
|
245
307
|
|
|
246
308
|
|
|
247
309
|
def origin_of(url: str) -> tuple[str, str, int]:
|
|
@@ -516,6 +578,7 @@ def wait_for_callback(
|
|
|
516
578
|
callback_port: int = EPHEMERAL_CALLBACK_PORT,
|
|
517
579
|
stop: threading.Event | None = None,
|
|
518
580
|
console_url: str | None = None,
|
|
581
|
+
api_url: str | None = None,
|
|
519
582
|
) -> dict[str, str]:
|
|
520
583
|
"""Listen for the browser's local callback carrying the signed code.
|
|
521
584
|
|
|
@@ -548,7 +611,7 @@ def wait_for_callback(
|
|
|
548
611
|
bound_port = server.server_address[1]
|
|
549
612
|
server.timeout = 0.5
|
|
550
613
|
try:
|
|
551
|
-
server.console_origin = serialized_origin(console_url_for_api(console_url=console_url))
|
|
614
|
+
server.console_origin = serialized_origin(console_url_for_api(console_url=console_url, api_url=api_url))
|
|
552
615
|
on_url(build_url(redirect_uri_for_port(bound_port)))
|
|
553
616
|
deadline = time.monotonic() + timeout_s
|
|
554
617
|
while server.captured is None and not (stop and stop.is_set()):
|
|
@@ -798,6 +861,7 @@ def wait_for_handoff(
|
|
|
798
861
|
callback_port=callback_port,
|
|
799
862
|
stop=stop,
|
|
800
863
|
console_url=console_url,
|
|
864
|
+
api_url=api_url,
|
|
801
865
|
)
|
|
802
866
|
code = params.get("code")
|
|
803
867
|
if not code:
|
|
@@ -895,7 +959,7 @@ def run_pkce_login(
|
|
|
895
959
|
notify: Callable[[str], None] | None = None,
|
|
896
960
|
) -> HandoffResult:
|
|
897
961
|
"""Open the Console login page and wait for loopback callback and/or hidden stdin paste."""
|
|
898
|
-
console = console_url_for_api(console_url=console_url)
|
|
962
|
+
console = console_url_for_api(console_url=console_url, api_url=api_url)
|
|
899
963
|
challenge = challenge_for(verifier)
|
|
900
964
|
return wait_for_handoff(
|
|
901
965
|
api_url,
|
|
@@ -35,6 +35,7 @@ import urllib.error
|
|
|
35
35
|
import urllib.request
|
|
36
36
|
from typing import IO, Any, override
|
|
37
37
|
|
|
38
|
+
from . import _login
|
|
38
39
|
from ._xmem_bridge import xmem_module
|
|
39
40
|
|
|
40
41
|
#: JSON-RPC's reserved range ends at -32000; everything at or below it is free for an
|
|
@@ -534,6 +535,27 @@ def _unusable_origin(origin: str) -> str | None:
|
|
|
534
535
|
return None
|
|
535
536
|
|
|
536
537
|
|
|
538
|
+
def _is_single_origin_pair(api_origin: str, mcp_origin: str, m: Any) -> bool:
|
|
539
|
+
"""One host serving the API at ``/xmem`` and MCP at ``/mcp`` — one deployment.
|
|
540
|
+
|
|
541
|
+
On such a deployment the mounts are path siblings of the same origin, so a key bound to
|
|
542
|
+
the ``/xmem`` base stays home when sent to the ``/mcp`` sibling. Path-aware on purpose:
|
|
543
|
+
the canonical forms used by the table strip paths and cannot tell ``/mcp`` from any
|
|
544
|
+
other path on that host, so the MCP side's path is pinned here before origins are
|
|
545
|
+
compared.
|
|
546
|
+
"""
|
|
547
|
+
expected = _login.single_origin_sibling_url(api_origin, sibling_path=_login.SINGLE_ORIGIN_MCP_PATH)
|
|
548
|
+
if expected is None:
|
|
549
|
+
return False
|
|
550
|
+
try:
|
|
551
|
+
parsed = urllib.parse.urlsplit(mcp_origin.strip())
|
|
552
|
+
except ValueError:
|
|
553
|
+
return False
|
|
554
|
+
if parsed.path.rstrip("/") != _login.SINGLE_ORIGIN_MCP_PATH or parsed.query or parsed.fragment:
|
|
555
|
+
return False
|
|
556
|
+
return _canonical_origin(expected, m) == _canonical_origin(mcp_origin, m)
|
|
557
|
+
|
|
558
|
+
|
|
537
559
|
def _refuse_foreign_origin(api_origin: str | None, mcp_origin: str, m: Any) -> str | None:
|
|
538
560
|
"""Why this credential may not be sent to this MCP origin, or ``None``.
|
|
539
561
|
|
|
@@ -554,6 +576,8 @@ def _refuse_foreign_origin(api_origin: str | None, mcp_origin: str, m: Any) -> s
|
|
|
554
576
|
mcp = _canonical_origin(mcp_origin, m)
|
|
555
577
|
if _deployments(m).get(api) == mcp:
|
|
556
578
|
return None
|
|
579
|
+
if _is_single_origin_pair(api_origin, mcp_origin, m):
|
|
580
|
+
return None
|
|
557
581
|
# The originals, not the canonical forms: normalising rebuilds an origin as
|
|
558
582
|
# `scheme://host:port`, which for IPv6 drops the brackets that made the address
|
|
559
583
|
# parseable — so `http://[::1]:8000` canonicalises to something no URL parser can
|
|
@@ -609,11 +633,19 @@ def cmd_mcp(args: argparse.Namespace) -> int:
|
|
|
609
633
|
)
|
|
610
634
|
_eprint(unavailable)
|
|
611
635
|
|
|
636
|
+
# Resolved once, with the API base as inference context: on a single-origin
|
|
637
|
+
# deployment (an rc minted against `https://host/xmem`, or that base named on the
|
|
638
|
+
# call) the MCP endpoint is the `/mcp` sibling of the same host, and no flag or
|
|
639
|
+
# variable should be needed to say so. `_explicit_api_url` is the rc-free reading
|
|
640
|
+
# on purpose — a malformed rc already produced `unavailable` above and must not
|
|
641
|
+
# take this resolution down with it.
|
|
642
|
+
mcp_origin = m._mcp_url(args, api_url=api_origin or m._explicit_api_url(args))
|
|
643
|
+
|
|
612
644
|
# Is it a URL at all — asked first, and for every caller. The guard below is skipped
|
|
613
645
|
# when the operator names the key, and that used to skip the only look this origin
|
|
614
646
|
# ever got, so a malformed one reached `urllib` and raised out of the loop.
|
|
615
647
|
if unavailable is None:
|
|
616
|
-
unavailable = _unusable_origin(
|
|
648
|
+
unavailable = _unusable_origin(mcp_origin)
|
|
617
649
|
if unavailable is not None:
|
|
618
650
|
_eprint(unavailable)
|
|
619
651
|
|
|
@@ -624,11 +656,11 @@ def cmd_mcp(args: argparse.Namespace) -> int:
|
|
|
624
656
|
# anywhere else needs a credential the operator named on the call. `api_origin` comes
|
|
625
657
|
# from the same read as `api_key`, so the two always describe one file.
|
|
626
658
|
if api_key is not None and unavailable is None:
|
|
627
|
-
unavailable = _refuse_foreign_origin(api_origin,
|
|
659
|
+
unavailable = _refuse_foreign_origin(api_origin, mcp_origin, m)
|
|
628
660
|
if unavailable is not None:
|
|
629
661
|
_eprint(unavailable)
|
|
630
662
|
|
|
631
|
-
url = f"{
|
|
663
|
+
url = f"{mcp_origin.rstrip('/')}/instance/{instance_id}"
|
|
632
664
|
return _pump(*_utf8_stdio(), url, api_key, unavailable)
|
|
633
665
|
|
|
634
666
|
|
|
@@ -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
|
-
#:
|
|
88
|
-
#:
|
|
89
|
-
#: ``
|
|
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
|
-
"""
|
|
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,
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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")
|
|
@@ -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
|
--------------------
|
|
@@ -749,10 +752,26 @@ def _curate_write_status(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
|
|
|
749
752
|
"write_status": item.get("write_status", "unknown"),
|
|
750
753
|
}
|
|
751
754
|
# Carry the failure / bookkeeping fields only when they say something.
|
|
752
|
-
for key in (
|
|
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
|
+
):
|
|
753
764
|
value = item.get(key)
|
|
754
765
|
if value is not None:
|
|
755
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
|
|
756
775
|
return _add_verbose_extras(curated, item, verbose)
|
|
757
776
|
|
|
758
777
|
|
|
@@ -863,6 +882,21 @@ def _normalize_api_origin(url: str) -> str | None:
|
|
|
863
882
|
return f"{scheme}://{host}:{port}"
|
|
864
883
|
|
|
865
884
|
|
|
885
|
+
def _normalize_api_base_url(url: str) -> str | None:
|
|
886
|
+
"""Canonical *request* base: the normalized origin plus the URL's own path, sans trailing slash.
|
|
887
|
+
|
|
888
|
+
``_normalize_api_origin`` exists for origin comparison and deliberately drops the path;
|
|
889
|
+
this form exists for building requests, where a single-origin deployment's ``/xmem``
|
|
890
|
+
mount is load-bearing — stripping it would aim every call at the Console's root instead
|
|
891
|
+
of the API. Query and fragment are still dropped: a base URL carries neither.
|
|
892
|
+
"""
|
|
893
|
+
origin = _normalize_api_origin(url)
|
|
894
|
+
if origin is None:
|
|
895
|
+
return None
|
|
896
|
+
path = urlsplit(url.strip()).path.rstrip("/")
|
|
897
|
+
return f"{origin}{path}" if path else origin
|
|
898
|
+
|
|
899
|
+
|
|
866
900
|
def _origins_match(a: str, b: str) -> bool:
|
|
867
901
|
norm_a = _normalize_api_origin(a)
|
|
868
902
|
norm_b = _normalize_api_origin(b)
|
|
@@ -936,7 +970,7 @@ def _api_url(args: argparse.Namespace) -> str:
|
|
|
936
970
|
# explicit --api-key / $XMEM_API_KEY skips rc api_url (see AUTH-FLOW.md).
|
|
937
971
|
explicit_url = args.api_url or os.environ.get(ENV_API_URL)
|
|
938
972
|
if explicit_url:
|
|
939
|
-
return
|
|
973
|
+
return _normalize_api_base_url(explicit_url) or explicit_url.rstrip("/")
|
|
940
974
|
# No explicit origin. The rc supplies the default origin ONLY when the rc is also the
|
|
941
975
|
# credential source — i.e. when no explicit key was given. With an explicit --api-key /
|
|
942
976
|
# $XMEM_API_KEY the rc is irrelevant (its api_url binds only the rc's own key), and a
|
|
@@ -944,23 +978,36 @@ def _api_url(args: argparse.Namespace) -> str:
|
|
|
944
978
|
# default WITHOUT reading the rc. This mirrors the same explicit-key escape hatch in
|
|
945
979
|
# _require(), so `--api-key` + a broken rc + no --api-url resolves cleanly end to end.
|
|
946
980
|
if args.api_key or os.environ.get(ENV_API_KEY):
|
|
947
|
-
return
|
|
981
|
+
return _normalize_api_base_url(DEFAULT_API_URL) or DEFAULT_API_URL.rstrip("/")
|
|
948
982
|
rc = _rc_record(args)
|
|
949
983
|
fallback = (rc.api_url if rc else None) or DEFAULT_API_URL
|
|
950
|
-
return
|
|
984
|
+
return _normalize_api_base_url(fallback) or fallback.rstrip("/")
|
|
951
985
|
|
|
952
986
|
|
|
953
987
|
def _explicit_api_url(args: argparse.Namespace) -> str | None:
|
|
954
|
-
"""
|
|
988
|
+
"""API base from flags/env only — never from ``.xmemrc.json``."""
|
|
955
989
|
explicit = args.api_url or os.environ.get(ENV_API_URL)
|
|
956
990
|
if not explicit:
|
|
957
991
|
return None
|
|
958
|
-
return
|
|
992
|
+
return _normalize_api_base_url(explicit) or explicit.rstrip("/")
|
|
993
|
+
|
|
959
994
|
|
|
995
|
+
def _mcp_url(args: argparse.Namespace, *, api_url: str | None = None) -> str:
|
|
996
|
+
"""The MCP base: flag, then environment, then the API base's single-origin sibling, then the default.
|
|
960
997
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
998
|
+
The sibling step is what lets an rc minted against ``https://host/xmem`` reach
|
|
999
|
+
``https://host/mcp`` with no extra flags — on a single-origin deployment the two are one
|
|
1000
|
+
host by construction. It fires only on that exact shape; every other API base leaves the
|
|
1001
|
+
default in place.
|
|
1002
|
+
"""
|
|
1003
|
+
explicit = (getattr(args, "mcp_url", None) or os.environ.get(ENV_MCP_URL) or "").strip()
|
|
1004
|
+
if explicit:
|
|
1005
|
+
return explicit
|
|
1006
|
+
if api_url:
|
|
1007
|
+
inferred = _login.single_origin_sibling_url(api_url, sibling_path=_login.SINGLE_ORIGIN_MCP_PATH)
|
|
1008
|
+
if inferred:
|
|
1009
|
+
return inferred
|
|
1010
|
+
return DEFAULT_MCP_URL
|
|
964
1011
|
|
|
965
1012
|
|
|
966
1013
|
def _rc_dir(args: argparse.Namespace) -> Path:
|
|
@@ -1122,6 +1169,9 @@ def cmd_write_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1122
1169
|
}
|
|
1123
1170
|
last_status = item.get("write_status", "unknown")
|
|
1124
1171
|
last_body = item
|
|
1172
|
+
# Mirrors the terminal tail of WriteQueueStatus (completed, failed, not_found). Hand-written
|
|
1173
|
+
# rather than derived: this CLI is stdlib-only by design and imports nothing from the server
|
|
1174
|
+
# packages, so the enum is out of reach here.
|
|
1125
1175
|
if last_status in ("completed", "failed", "not_found"):
|
|
1126
1176
|
return item
|
|
1127
1177
|
if time.monotonic() >= deadline:
|
|
@@ -1444,10 +1494,11 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1444
1494
|
),
|
|
1445
1495
|
EXIT_HTTP_ERROR,
|
|
1446
1496
|
)
|
|
1497
|
+
|
|
1447
1498
|
explicit_api = _explicit_api_url(args)
|
|
1448
1499
|
rc_origin = rc.api_url.strip() if rc is not None and rc.api_url.strip() else None
|
|
1449
1500
|
api_origin = explicit_api or rc_origin or DEFAULT_API_URL
|
|
1450
|
-
base =
|
|
1501
|
+
base = _normalize_api_base_url(api_origin) or api_origin.rstrip("/")
|
|
1451
1502
|
console, refusal = _email_console_origin(args, api_origin=api_origin)
|
|
1452
1503
|
if refusal is not None:
|
|
1453
1504
|
return refusal
|
|
@@ -1641,7 +1692,7 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1641
1692
|
detail = getattr(exc, "strerror", None) or type(exc).__name__
|
|
1642
1693
|
return (
|
|
1643
1694
|
make_error(
|
|
1644
|
-
f
|
|
1695
|
+
f'could not write {RC_FILENAME} ({detail}). The minted key "{result.key_name}" '
|
|
1645
1696
|
f"(prefix {result.key_prefix}…) is active — revoke it in the Console if it should not stay.",
|
|
1646
1697
|
stage="login",
|
|
1647
1698
|
),
|
|
@@ -1683,7 +1734,7 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1683
1734
|
explicit_api = _explicit_api_url(args) or os.environ.get(ENV_API_URL, "").strip()
|
|
1684
1735
|
console = _console_url_for_args(args)
|
|
1685
1736
|
api_origin = explicit_api or DEFAULT_API_URL
|
|
1686
|
-
base =
|
|
1737
|
+
base = _normalize_api_base_url(api_origin) or api_origin.rstrip("/")
|
|
1687
1738
|
verifier = _login.new_verifier()
|
|
1688
1739
|
interactive = stdout_is_tty()
|
|
1689
1740
|
offer_paste = interactive and _login.paste_from_stdin_supported()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|