xmemcli 0.0.12__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 0.0.12
3
+ Version: 1.0.0
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.12**.
100
+ Version: **1.0.0**.
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 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.
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 that sibling beats `$XMEM_CONSOLE_URL` on the same call — and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url` or set `$XMEM_CONSOLE_URL` when inference does not apply). 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
 
@@ -163,7 +163,7 @@ Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking
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
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.
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; query, fragment, and userinfo on a base URL refuse normalisation rather than being stripped, so sibling inference cannot run on a shape the caller did not supply. Console resolution for login: `--console-url` → sibling inference from the call's `--api-url` when it applies → `$XMEM_CONSOLE_URL` → sibling inference from `$XMEM_API_URL` → default — when `--api-url` is present, `$XMEM_API_URL` is not consulted for Console inference. 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 the effective API base (`--api-url` → `$XMEM_API_URL` → rc → default) → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
167
167
 
168
168
  ## Identity — `/whoami`
169
169
 
@@ -193,7 +193,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
193
193
 
194
194
  # `xmemcli` — capabilities reference
195
195
 
196
- **Version:** `0.0.12` (see `xmemcli version`)
196
+ **Version:** `1.0.0` (see `xmemcli version`)
197
197
 
198
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.
199
199
 
@@ -517,6 +517,14 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
517
517
 
518
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
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 `--console-url` / `--mcp-url` and `$XMEM_CONSOLE_URL` / `$XMEM_MCP_URL` still win when inference does not apply; on a call that names `--api-url …/xmem`, sibling inference beats `$XMEM_CONSOLE_URL`; 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. Connect instructions from the API assume **1.0.0** or newer everywhere. On a single-origin deployment they also rely on path inference — they render `auth login --api-url https://host/xmem` with **no** `--console-url` — and an older CLI given that command signs in at the hosted Console instead. 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
+ **Console instance URLs (1.0.0+):** `xmemcli auth login https://host/console/instance/<uuid>` infers the API base (`/xmem`) and Console (`/console`) from that page URL on a single-origin host — no extra flags or env vars. The Connect tab paste prompt uses this form, and the CLI writes the inferred ``api_url`` into ``.xmemrc.json`` as soon as login starts.
525
+
526
+ **Compatibility change in 1.0.0:** console instance page URLs for login (above), immediate ``api_url`` staging in ``.xmemrc.json``, and **1.0.0** as the minimum version named by connect setup instructions.
527
+
520
528
  See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
521
529
 
522
530
  ---
@@ -89,7 +89,7 @@ xmemcli version
89
89
  xmemcli auth login
90
90
  ```
91
91
 
92
- Version: **0.0.12**.
92
+ Version: **1.0.0**.
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 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.
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 that sibling beats `$XMEM_CONSOLE_URL` on the same call — and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url` or set `$XMEM_CONSOLE_URL` when inference does not apply). 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
 
@@ -155,7 +155,7 @@ Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking
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
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.
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; query, fragment, and userinfo on a base URL refuse normalisation rather than being stripped, so sibling inference cannot run on a shape the caller did not supply. Console resolution for login: `--console-url` → sibling inference from the call's `--api-url` when it applies → `$XMEM_CONSOLE_URL` → sibling inference from `$XMEM_API_URL` → default — when `--api-url` is present, `$XMEM_API_URL` is not consulted for Console inference. 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 the effective API base (`--api-url` → `$XMEM_API_URL` → rc → default) → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
159
159
 
160
160
  ## Identity — `/whoami`
161
161
 
@@ -185,7 +185,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
185
185
 
186
186
  # `xmemcli` — capabilities reference
187
187
 
188
- **Version:** `0.0.12` (see `xmemcli version`)
188
+ **Version:** `1.0.0` (see `xmemcli version`)
189
189
 
190
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.
191
191
 
@@ -509,6 +509,14 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
509
509
 
510
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
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 `--console-url` / `--mcp-url` and `$XMEM_CONSOLE_URL` / `$XMEM_MCP_URL` still win when inference does not apply; on a call that names `--api-url …/xmem`, sibling inference beats `$XMEM_CONSOLE_URL`; 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. Connect instructions from the API assume **1.0.0** or newer everywhere. On a single-origin deployment they also rely on path inference — they render `auth login --api-url https://host/xmem` with **no** `--console-url` — and an older CLI given that command signs in at the hosted Console instead. 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
+ **Console instance URLs (1.0.0+):** `xmemcli auth login https://host/console/instance/<uuid>` infers the API base (`/xmem`) and Console (`/console`) from that page URL on a single-origin host — no extra flags or env vars. The Connect tab paste prompt uses this form, and the CLI writes the inferred ``api_url`` into ``.xmemrc.json`` as soon as login starts.
517
+
518
+ **Compatibility change in 1.0.0:** console instance page URLs for login (above), immediate ``api_url`` staging in ``.xmemrc.json``, and **1.0.0** as the minimum version named by connect setup instructions.
519
+
512
520
  See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
513
521
 
514
522
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.12"
3
+ version = "1.0.0"
4
4
  description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4"
@@ -4,5 +4,5 @@ requires-python = ">=3.12, <4"
4
4
 
5
5
  [[package]]
6
6
  name = "xmemcli"
7
- version = "0.0.12"
7
+ version = "1.0.0"
8
8
  source = { editable = "." }
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "0.0.12"
3
+ __version__ = "1.0.0"
@@ -22,6 +22,7 @@ import errno
22
22
  import hashlib
23
23
  import json
24
24
  import os
25
+ import re
25
26
  import secrets
26
27
  import select
27
28
  import sys
@@ -77,6 +78,62 @@ CLI_LOGIN_PATH = "/xmemcli"
77
78
  CLI_TOKEN_PROXY_PATH = "/api/cli/token"
78
79
  CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
79
80
 
81
+ #: Mount paths of a single-origin deployment, where one hostname serves every surface under
82
+ #: fixed path prefixes. An API base ending in ``/xmem`` therefore names its Console and MCP
83
+ #: siblings by construction — the grammar ``single_origin_sibling_url`` implements.
84
+ SINGLE_ORIGIN_API_PATH = "/xmem"
85
+ SINGLE_ORIGIN_CONSOLE_PATH = "/console"
86
+ SINGLE_ORIGIN_MCP_PATH = "/mcp"
87
+
88
+ _CONSOLE_INSTANCE_PATH_PREFIX = f"{SINGLE_ORIGIN_CONSOLE_PATH}/instance/"
89
+ _INSTANCE_ID_RE = re.compile(
90
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
91
+ re.IGNORECASE,
92
+ )
93
+
94
+
95
+ class ConsoleInstanceLink(NamedTuple):
96
+ """A Console instance detail page on a single-origin deployment."""
97
+
98
+ console_url: str
99
+ api_url: str
100
+ instance_id: str
101
+
102
+
103
+ def parse_console_instance_url(url: str) -> ConsoleInstanceLink | None:
104
+ """Derive login targets from ``…/console/instance/<uuid>`` on one hostname.
105
+
106
+ Query and fragment are ignored. Only the conventional single-origin mount
107
+ ``/console/instance/<uuid>`` is recognised — a five-host Console URL must
108
+ still name ``--api-url`` and ``--console-url`` explicitly.
109
+ """
110
+ raw = url.strip()
111
+ if not raw:
112
+ return None
113
+ try:
114
+ parsed = urlsplit(raw)
115
+ _ = parsed.port
116
+ except ValueError:
117
+ return None
118
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
119
+ return None
120
+ if parsed.username or parsed.password:
121
+ return None
122
+ path = parsed.path.rstrip("/")
123
+ if not path.startswith(_CONSOLE_INSTANCE_PATH_PREFIX):
124
+ return None
125
+ instance_id = path[len(_CONSOLE_INSTANCE_PATH_PREFIX) :]
126
+ if not instance_id or "/" in instance_id:
127
+ return None
128
+ if _INSTANCE_ID_RE.fullmatch(instance_id) is None:
129
+ return None
130
+ origin = f"{parsed.scheme}://{parsed.netloc}"
131
+ return ConsoleInstanceLink(
132
+ console_url=f"{origin}{SINGLE_ORIGIN_CONSOLE_PATH}",
133
+ api_url=f"{origin}{SINGLE_ORIGIN_API_PATH}",
134
+ instance_id=instance_id,
135
+ )
136
+
80
137
  API_KEY_PREFIX = "xmem_"
81
138
 
82
139
  #: Hostnames treated as loopback wherever the CLI relaxes an https-only rule or infers the
@@ -130,8 +187,43 @@ def challenge_for(verifier: str) -> str:
130
187
  return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
131
188
 
132
189
 
190
+ def single_origin_sibling_url(api_url: str, *, sibling_path: str) -> str | None:
191
+ """``https://host/xmem`` → ``https://host<sibling_path>``, or None when the shape says nothing.
192
+
193
+ Exact on purpose: only an API base whose path is exactly ``/xmem`` sits on a single-origin
194
+ deployment, where the siblings are fixed by the routing grammar. A bare origin (a five-host
195
+ deployment) infers nothing, and extra path segments, query, fragment, or credentials make it
196
+ someone else's URL. Hostname surgery (``api.stg`` → ``mcp.stg``) is deliberately not done
197
+ here — deriving one *hostname* from another is what the pairing table in ``_mcp_bridge``
198
+ exists to avoid guessing.
199
+ """
200
+ try:
201
+ parsed = urlsplit(api_url.strip())
202
+ _ = parsed.port # the port is parsed lazily; force it — a malformed URL must refuse, not raise
203
+ except ValueError:
204
+ # `urlsplit` itself raises on an unbracketed-IPv6 shape, and this helper sees origins
205
+ # read from an rc file the user may have edited by hand (see `_host_of` in _mcp_bridge).
206
+ return None
207
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
208
+ return None
209
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
210
+ return None
211
+ if parsed.path.rstrip("/") != SINGLE_ORIGIN_API_PATH:
212
+ return None
213
+ return f"{parsed.scheme}://{parsed.netloc}{sibling_path}"
214
+
215
+
133
216
  def console_url_from_api_url(api_url: str) -> str | None:
134
- """Local dev convention: API on :8000, Console on :8002 (same host)."""
217
+ """Infer the Console base from an API base, where the deployment layout determines it.
218
+
219
+ Two shapes qualify: a single-origin ``/xmem`` base names its ``/console`` sibling on the
220
+ same host, and the local dev convention pairs API :8000 with Console :8002 on the same
221
+ loopback host. A bare non-loopback origin infers nothing — five-host deployments name
222
+ their Console explicitly.
223
+ """
224
+ sibling = single_origin_sibling_url(api_url, sibling_path=SINGLE_ORIGIN_CONSOLE_PATH)
225
+ if sibling is not None:
226
+ return sibling
135
227
  parsed = urlparse(api_url.rstrip("/"))
136
228
  if parsed.hostname not in LOOPBACK_HOSTS:
137
229
  return None
@@ -147,16 +239,31 @@ def console_url_from_api_url(api_url: str) -> str | None:
147
239
  return None
148
240
 
149
241
 
150
- def console_url_for_api(*, console_url: str | None = None) -> str:
151
- """Resolve the Console origin for PKCE login and token/whoami proxies."""
242
+ def console_url_for_api(*, console_url: str | None = None, api_url: str | None = None) -> str:
243
+ """Resolve the Console origin for PKCE login and token/whoami proxies.
244
+
245
+ Explicit ``--console-url`` wins. When the caller names an API base for this login,
246
+ that base alone decides whether a Console can be inferred — ``$XMEM_CONSOLE_URL`` and
247
+ ``$XMEM_API_URL`` are not consulted on that path, so a copy-pasted
248
+ ``--api-url https://host/xmem`` cannot be steered away by the reader's shell profile.
249
+ With no ``api_url``, the environment and then the default apply as before.
250
+ """
152
251
  if console_url:
153
252
  return console_url.rstrip("/")
253
+ if api_url:
254
+ inferred = console_url_from_api_url(api_url)
255
+ if inferred:
256
+ return inferred
257
+ from_env = os.environ.get(_ENV_CONSOLE_URL, "").strip()
258
+ if from_env:
259
+ return from_env.rstrip("/")
260
+ return DEFAULT_CONSOLE_URL
154
261
  from_env = os.environ.get(_ENV_CONSOLE_URL, "").strip()
155
262
  if from_env:
156
263
  return from_env.rstrip("/")
157
- api_from_env = os.environ.get(_ENV_API_URL, "").strip()
158
- if api_from_env:
159
- inferred = console_url_from_api_url(api_from_env)
264
+ env_api = os.environ.get(_ENV_API_URL, "").strip()
265
+ if env_api:
266
+ inferred = console_url_from_api_url(env_api)
160
267
  if inferred:
161
268
  return inferred
162
269
  return DEFAULT_CONSOLE_URL
@@ -226,22 +333,35 @@ def write_login_handoff_prompt(
226
333
  out.flush()
227
334
 
228
335
 
336
+ def _api_candidates(api_url: str, *, path: str, legacy_path: str) -> list[str]:
337
+ """The API's own route for something, plus the ``/api``-prefixed spelling where it can exist.
338
+
339
+ The second candidate is compatibility only: it answers a deployment served at the origin
340
+ root, which replies under ``/api`` as well. A base that carries a mount cannot serve it —
341
+ on a single-origin deployment the API is at ``https://host/xmem`` and
342
+ ``https://host/xmem/api/whoami`` is a path no layout produces — so it is left out there
343
+ rather than spending a request, and a 404, on a URL known in advance to be wrong.
344
+ """
345
+ base = api_url.rstrip("/")
346
+ if urlsplit(base).path:
347
+ return [f"{base}{path}"]
348
+ return [f"{base}{path}", f"{base}{legacy_path}"]
349
+
350
+
229
351
  def token_redeem_urls(*, api_url: str, console_url: str | None) -> list[str]:
230
352
  """Candidate ``POST /cli/token`` URLs, most likely first."""
231
- console = console_url_for_api(console_url=console_url)
232
353
  if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
354
+ console = console_url_for_api(console_url=console_url)
233
355
  return [f"{console.rstrip('/')}{CLI_TOKEN_PROXY_PATH}"]
234
- base = api_url.rstrip("/")
235
- return [f"{base}/cli/token", f"{base}/api/cli/token"]
356
+ return _api_candidates(api_url, path="/cli/token", legacy_path="/api/cli/token")
236
357
 
237
358
 
238
359
  def whoami_urls(*, api_url: str, console_url: str | None) -> list[str]:
239
360
  """Candidate ``GET /whoami`` URLs for bearer API-key lookup, most likely first."""
240
- console = console_url_for_api(console_url=console_url)
241
361
  if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
362
+ console = console_url_for_api(console_url=console_url)
242
363
  return [f"{console.rstrip('/')}{CLI_WHOAMI_PROXY_PATH}"]
243
- base = api_url.rstrip("/")
244
- return [f"{base}/whoami", f"{base}/api/whoami"]
364
+ return _api_candidates(api_url, path="/whoami", legacy_path="/api/whoami")
245
365
 
246
366
 
247
367
  def origin_of(url: str) -> tuple[str, str, int]:
@@ -516,6 +636,7 @@ def wait_for_callback(
516
636
  callback_port: int = EPHEMERAL_CALLBACK_PORT,
517
637
  stop: threading.Event | None = None,
518
638
  console_url: str | None = None,
639
+ api_url: str | None = None,
519
640
  ) -> dict[str, str]:
520
641
  """Listen for the browser's local callback carrying the signed code.
521
642
 
@@ -548,7 +669,7 @@ def wait_for_callback(
548
669
  bound_port = server.server_address[1]
549
670
  server.timeout = 0.5
550
671
  try:
551
- server.console_origin = serialized_origin(console_url_for_api(console_url=console_url))
672
+ server.console_origin = serialized_origin(console_url_for_api(console_url=console_url, api_url=api_url))
552
673
  on_url(build_url(redirect_uri_for_port(bound_port)))
553
674
  deadline = time.monotonic() + timeout_s
554
675
  while server.captured is None and not (stop and stop.is_set()):
@@ -798,6 +919,7 @@ def wait_for_handoff(
798
919
  callback_port=callback_port,
799
920
  stop=stop,
800
921
  console_url=console_url,
922
+ api_url=api_url,
801
923
  )
802
924
  code = params.get("code")
803
925
  if not code:
@@ -895,7 +1017,7 @@ def run_pkce_login(
895
1017
  notify: Callable[[str], None] | None = None,
896
1018
  ) -> HandoffResult:
897
1019
  """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)
1020
+ console = console_url_for_api(console_url=console_url, api_url=api_url)
899
1021
  challenge = challenge_for(verifier)
900
1022
  return wait_for_handoff(
901
1023
  api_url,
@@ -29,12 +29,14 @@ import argparse
29
29
  import io
30
30
  import ipaddress
31
31
  import json
32
+ import os
32
33
  import sys
33
34
  import urllib.parse
34
35
  import urllib.error
35
36
  import urllib.request
36
37
  from typing import IO, Any, override
37
38
 
39
+ from . import _login
38
40
  from ._xmem_bridge import xmem_module
39
41
 
40
42
  #: JSON-RPC's reserved range ends at -32000; everything at or below it is free for an
@@ -534,7 +536,34 @@ def _unusable_origin(origin: str) -> str | None:
534
536
  return None
535
537
 
536
538
 
537
- def _refuse_foreign_origin(api_origin: str | None, mcp_origin: str, m: Any) -> str | None:
539
+ def _is_single_origin_pair(api_origin: str, mcp_origin: str, m: Any) -> bool:
540
+ """One host serving the API at ``/xmem`` and MCP at ``/mcp`` — one deployment.
541
+
542
+ On such a deployment the mounts are path siblings of the same origin, so a key bound to
543
+ the ``/xmem`` base stays home when sent to the ``/mcp`` sibling. Path-aware on purpose:
544
+ the canonical forms used by the table strip paths and cannot tell ``/mcp`` from any
545
+ other path on that host, so the MCP side's path is pinned here before origins are
546
+ compared.
547
+ """
548
+ expected = _login.single_origin_sibling_url(api_origin, sibling_path=_login.SINGLE_ORIGIN_MCP_PATH)
549
+ if expected is None:
550
+ return False
551
+ try:
552
+ parsed = urllib.parse.urlsplit(mcp_origin.strip())
553
+ except ValueError:
554
+ return False
555
+ if parsed.path.rstrip("/") != _login.SINGLE_ORIGIN_MCP_PATH or parsed.query or parsed.fragment:
556
+ return False
557
+ return _canonical_origin(expected, m) == _canonical_origin(mcp_origin, m)
558
+
559
+
560
+ def _refuse_foreign_origin(
561
+ api_origin: str | None,
562
+ mcp_origin: str,
563
+ m: Any,
564
+ *,
565
+ effective_api_base: str | None = None,
566
+ ) -> str | None:
538
567
  """Why this credential may not be sent to this MCP origin, or ``None``.
539
568
 
540
569
  The key in ``.xmemrc.json`` records the API origin it belongs to, and the CLI holds
@@ -544,6 +573,9 @@ def _refuse_foreign_origin(api_origin: str | None, mcp_origin: str, m: Any) -> s
544
573
  user did not write — so the config is not allowed to vouch for itself.
545
574
 
546
575
  ``api_origin`` is ``None`` when the operator named the credential on this call.
576
+ ``effective_api_base`` is the flag/env/rc-resolved API base used to infer MCP when
577
+ no explicit MCP was named — an rc that recorded the bare host while ``--api-url
578
+ …/xmem`` names the mount for this call still pairs with the ``/mcp`` sibling.
547
579
  """
548
580
  if api_origin is None:
549
581
  # Named on the command line or in the environment: the operator supplied this
@@ -554,6 +586,15 @@ def _refuse_foreign_origin(api_origin: str | None, mcp_origin: str, m: Any) -> s
554
586
  mcp = _canonical_origin(mcp_origin, m)
555
587
  if _deployments(m).get(api) == mcp:
556
588
  return None
589
+ if _is_single_origin_pair(api_origin, mcp_origin, m):
590
+ return None
591
+ if (
592
+ effective_api_base
593
+ and effective_api_base != api_origin
594
+ and _is_single_origin_pair(effective_api_base, mcp_origin, m)
595
+ and _canonical_origin(effective_api_base, m) == api
596
+ ):
597
+ return None
557
598
  # The originals, not the canonical forms: normalising rebuilds an origin as
558
599
  # `scheme://host:port`, which for IPv6 drops the brackets that made the address
559
600
  # parseable — so `http://[::1]:8000` canonicalises to something no URL parser can
@@ -609,11 +650,23 @@ def cmd_mcp(args: argparse.Namespace) -> int:
609
650
  )
610
651
  _eprint(unavailable)
611
652
 
653
+ # Explicit MCP wins without parsing an API base at all — a malformed `--api-url` must
654
+ # not take down a valid `--mcp-url`. Otherwise the effective API base for sibling
655
+ # inference follows flag → env → rc precedence (`_api_url`), not the credential-bound
656
+ # origin alone: an rc at the bare host plus `--api-url …/xmem` must still reach `/mcp`.
657
+ explicit_mcp = (getattr(args, "mcp_url", None) or os.environ.get(m.ENV_MCP_URL) or "").strip()
658
+ if explicit_mcp:
659
+ mcp_origin = explicit_mcp
660
+ effective_api_base: str | None = None
661
+ else:
662
+ effective_api_base = m._api_url(args)
663
+ mcp_origin = m._mcp_url(args, api_url=effective_api_base)
664
+
612
665
  # Is it a URL at all — asked first, and for every caller. The guard below is skipped
613
666
  # when the operator names the key, and that used to skip the only look this origin
614
667
  # ever got, so a malformed one reached `urllib` and raised out of the loop.
615
668
  if unavailable is None:
616
- unavailable = _unusable_origin(m._mcp_url(args))
669
+ unavailable = _unusable_origin(mcp_origin)
617
670
  if unavailable is not None:
618
671
  _eprint(unavailable)
619
672
 
@@ -624,11 +677,13 @@ def cmd_mcp(args: argparse.Namespace) -> int:
624
677
  # anywhere else needs a credential the operator named on the call. `api_origin` comes
625
678
  # from the same read as `api_key`, so the two always describe one file.
626
679
  if api_key is not None and unavailable is None:
627
- unavailable = _refuse_foreign_origin(api_origin, m._mcp_url(args), m)
680
+ unavailable = _refuse_foreign_origin(
681
+ api_origin, mcp_origin, m, effective_api_base=effective_api_base
682
+ )
628
683
  if unavailable is not None:
629
684
  _eprint(unavailable)
630
685
 
631
- url = f"{m._mcp_url(args).rstrip('/')}/instance/{instance_id}"
686
+ url = f"{mcp_origin.rstrip('/')}/instance/{instance_id}"
632
687
  return _pump(*_utf8_stdio(), url, api_key, unavailable)
633
688
 
634
689
 
@@ -153,6 +153,24 @@ def write_rc(target_dir: Path, *, email: str, api_url: str, api_key: str) -> Pat
153
153
  return path
154
154
 
155
155
 
156
+ def stage_login_api_url(start_dir: Path, api_url: str) -> Path:
157
+ """Record ``api_url`` in the project rc before PKCE or email approval finishes.
158
+
159
+ When a rc already exists, its ``api_url`` is updated in place and the credential
160
+ is left untouched. Otherwise a placeholder rc is written (empty email/key) so
161
+ ``_api_url`` names this deployment while login is still in progress.
162
+ """
163
+ start_dir.mkdir(parents=True, exist_ok=True)
164
+ found = find_rc(start_dir)
165
+ if found is not None:
166
+ try:
167
+ record = load_rc(found)
168
+ except RcError:
169
+ return write_rc(start_dir, email="", api_url=api_url, api_key="")
170
+ return write_rc(found.parent, email=record.email, api_url=api_url, api_key=record.api_key)
171
+ return write_rc(start_dir, email="", api_url=api_url, api_key="")
172
+
173
+
156
174
  def remove_rc(path: Path) -> bool:
157
175
  """Delete the ``.xmemrc.json`` at ``path``. Returns ``True`` iff it existed."""
158
176
  try:
@@ -1,4 +1,4 @@
1
1
  """Release metadata for the xmemcli CLI (copied into standalone builds)."""
2
2
 
3
3
  CLI_NAME = "xmemcli"
4
- __version__ = "0.0.12"
4
+ __version__ = "1.0.0"
@@ -141,6 +141,7 @@ from ._rc import ( # noqa: E402 (sibling-script import)
141
141
  load_rc,
142
142
  remove_rc,
143
143
  resolve_rc,
144
+ stage_login_api_url,
144
145
  write_rc,
145
146
  )
146
147
  from ._text import oneline
@@ -653,8 +654,68 @@ def _get(url: str, api_key: str, timeout_s: float = 30.0) -> dict[str, Any]:
653
654
  return _read_json_response(_build_request(url, "GET", api_key), timeout_s)
654
655
 
655
656
 
657
+ def _connect_url_from_login_args(args: argparse.Namespace) -> str | None:
658
+ """Console instance page URL from a positional arg or a mistaken ``--api-url``."""
659
+ connect = getattr(args, "connect_url", None)
660
+ if isinstance(connect, str) and connect.strip():
661
+ return connect.strip()
662
+ flag_api = getattr(args, "api_url", None)
663
+ if isinstance(flag_api, str) and flag_api.strip():
664
+ candidate = flag_api.strip()
665
+ if _login.parse_console_instance_url(candidate) is not None:
666
+ return candidate
667
+ return None
668
+
669
+
670
+ def _auth_login_api_base(
671
+ args: argparse.Namespace,
672
+ *,
673
+ rc_origin: str | None = None,
674
+ ) -> tuple[str, str | None, tuple[CliOutput, int] | None]:
675
+ """Resolve the API base (and optional instance id) for ``auth login``."""
676
+ connect = _connect_url_from_login_args(args)
677
+ if connect:
678
+ link = _login.parse_console_instance_url(connect)
679
+ if link is None:
680
+ return (
681
+ "",
682
+ None,
683
+ (
684
+ make_error(
685
+ f"not a single-origin Console instance URL ({connect!r}); expected "
686
+ f"…{_login.SINGLE_ORIGIN_CONSOLE_PATH}/instance/<uuid>",
687
+ stage="usage",
688
+ ),
689
+ EXIT_USAGE,
690
+ ),
691
+ )
692
+ return link.api_url, link.instance_id, None
693
+ explicit_api = _explicit_api_url(args) or os.environ.get(ENV_API_URL, "").strip()
694
+ api_origin = explicit_api or rc_origin or DEFAULT_API_URL
695
+ base = _normalize_api_base_url(api_origin) or api_origin.rstrip("/")
696
+ return base, None, None
697
+
698
+
699
+ def _stage_login_api_url_if_connecting(args: argparse.Namespace, api_url: str) -> None:
700
+ """When login was aimed by a Console instance page URL, persist ``api_url`` immediately."""
701
+ if _connect_url_from_login_args(args) is None:
702
+ return
703
+ stage_login_api_url(_rc_dir(args), api_url)
704
+
705
+
706
+ def _console_url_flag_for_args(args: argparse.Namespace) -> str | None:
707
+ """``--console-url`` only — not ``$XMEM_CONSOLE_URL``.
708
+
709
+ ``console_url_for_api`` applies the environment after sibling inference from
710
+ ``--api-url``; pre-merging the env here would defeat that ordering on a
711
+ copy-pasted single-origin login command.
712
+ """
713
+ explicit = getattr(args, "console_url", None)
714
+ return explicit.strip() if isinstance(explicit, str) and explicit.strip() else None
715
+
716
+
656
717
  def _console_url_for_args(args: argparse.Namespace) -> str | None:
657
- explicit = getattr(args, "console_url", None) or os.environ.get(ENV_CONSOLE_URL)
718
+ explicit = _console_url_flag_for_args(args) or os.environ.get(ENV_CONSOLE_URL)
658
719
  return explicit.strip() if isinstance(explicit, str) and explicit.strip() else None
659
720
 
660
721
 
@@ -882,6 +943,29 @@ def _normalize_api_origin(url: str) -> str | None:
882
943
  return f"{scheme}://{host}:{port}"
883
944
 
884
945
 
946
+ def _normalize_api_base_url(url: str) -> str | None:
947
+ """Canonical *request* base: the normalized origin plus the URL's own path, sans trailing slash.
948
+
949
+ ``_normalize_api_origin`` exists for origin comparison and deliberately drops the path;
950
+ this form exists for building requests, where a single-origin deployment's ``/xmem``
951
+ mount is load-bearing — stripping it would aim every call at the Console's root instead
952
+ of the API. Query, fragment, and userinfo refuse here rather than being stripped: the
953
+ single-origin sibling helpers treat those components as making a URL non-inferable, and
954
+ normalizing them away would let inference run on a shape the caller never supplied.
955
+ """
956
+ raw = url.strip()
957
+ if not raw:
958
+ return None
959
+ parsed = urlsplit(raw)
960
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
961
+ return None
962
+ origin = _normalize_api_origin(raw)
963
+ if origin is None:
964
+ return None
965
+ path = parsed.path.rstrip("/")
966
+ return f"{origin}{path}" if path else origin
967
+
968
+
885
969
  def _origins_match(a: str, b: str) -> bool:
886
970
  norm_a = _normalize_api_origin(a)
887
971
  norm_b = _normalize_api_origin(b)
@@ -955,7 +1039,7 @@ def _api_url(args: argparse.Namespace) -> str:
955
1039
  # explicit --api-key / $XMEM_API_KEY skips rc api_url (see AUTH-FLOW.md).
956
1040
  explicit_url = args.api_url or os.environ.get(ENV_API_URL)
957
1041
  if explicit_url:
958
- return _normalize_api_origin(explicit_url) or explicit_url.rstrip("/")
1042
+ return _normalize_api_base_url(explicit_url) or explicit_url.rstrip("/")
959
1043
  # No explicit origin. The rc supplies the default origin ONLY when the rc is also the
960
1044
  # credential source — i.e. when no explicit key was given. With an explicit --api-key /
961
1045
  # $XMEM_API_KEY the rc is irrelevant (its api_url binds only the rc's own key), and a
@@ -963,23 +1047,36 @@ def _api_url(args: argparse.Namespace) -> str:
963
1047
  # default WITHOUT reading the rc. This mirrors the same explicit-key escape hatch in
964
1048
  # _require(), so `--api-key` + a broken rc + no --api-url resolves cleanly end to end.
965
1049
  if args.api_key or os.environ.get(ENV_API_KEY):
966
- return _normalize_api_origin(DEFAULT_API_URL) or DEFAULT_API_URL.rstrip("/")
1050
+ return _normalize_api_base_url(DEFAULT_API_URL) or DEFAULT_API_URL.rstrip("/")
967
1051
  rc = _rc_record(args)
968
1052
  fallback = (rc.api_url if rc else None) or DEFAULT_API_URL
969
- return _normalize_api_origin(fallback) or fallback.rstrip("/")
1053
+ return _normalize_api_base_url(fallback) or fallback.rstrip("/")
970
1054
 
971
1055
 
972
1056
  def _explicit_api_url(args: argparse.Namespace) -> str | None:
973
- """Origin from flags/env only — never from ``.xmemrc.json``."""
1057
+ """API base from flags/env only — never from ``.xmemrc.json``."""
974
1058
  explicit = args.api_url or os.environ.get(ENV_API_URL)
975
1059
  if not explicit:
976
1060
  return None
977
- return _normalize_api_origin(explicit) or explicit.rstrip("/")
1061
+ return _normalize_api_base_url(explicit) or explicit.rstrip("/")
978
1062
 
979
1063
 
980
- def _mcp_url(args: argparse.Namespace) -> str:
981
- """The MCP origin: flag, then environment, then the default."""
982
- return (getattr(args, "mcp_url", None) or os.environ.get(ENV_MCP_URL) or DEFAULT_MCP_URL).strip()
1064
+ def _mcp_url(args: argparse.Namespace, *, api_url: str | None = None) -> str:
1065
+ """The MCP base: flag, then environment, then the API base's single-origin sibling, then the default.
1066
+
1067
+ The sibling step is what lets an rc minted against ``https://host/xmem`` reach
1068
+ ``https://host/mcp`` with no extra flags — on a single-origin deployment the two are one
1069
+ host by construction. It fires only on that exact shape; every other API base leaves the
1070
+ default in place.
1071
+ """
1072
+ explicit = (getattr(args, "mcp_url", None) or os.environ.get(ENV_MCP_URL) or "").strip()
1073
+ if explicit:
1074
+ return explicit
1075
+ if api_url:
1076
+ inferred = _login.single_origin_sibling_url(api_url, sibling_path=_login.SINGLE_ORIGIN_MCP_PATH)
1077
+ if inferred:
1078
+ return inferred
1079
+ return DEFAULT_MCP_URL
983
1080
 
984
1081
 
985
1082
  def _rc_dir(args: argparse.Namespace) -> Path:
@@ -1467,11 +1564,12 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
1467
1564
  EXIT_HTTP_ERROR,
1468
1565
  )
1469
1566
 
1470
- explicit_api = _explicit_api_url(args)
1471
1567
  rc_origin = rc.api_url.strip() if rc is not None and rc.api_url.strip() else None
1472
- api_origin = explicit_api or rc_origin or DEFAULT_API_URL
1473
- base = _normalize_api_origin(api_origin) or api_origin.rstrip("/")
1474
- console, refusal = _email_console_origin(args, api_origin=api_origin)
1568
+ base, instance_hint, usage_error = _auth_login_api_base(args, rc_origin=rc_origin)
1569
+ if usage_error is not None:
1570
+ return usage_error
1571
+ _stage_login_api_url_if_connecting(args, base)
1572
+ console, refusal = _email_console_origin(args, api_origin=base)
1475
1573
  if refusal is not None:
1476
1574
  return refusal
1477
1575
  if console is None:
@@ -1683,6 +1781,8 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
1683
1781
  }
1684
1782
  if result.org_name:
1685
1783
  payload["org_name"] = result.org_name
1784
+ if instance_hint:
1785
+ payload["instance_id"] = instance_hint
1686
1786
  return (CliOutput(OutputKind.LOGIN, payload), EXIT_OK)
1687
1787
 
1688
1788
 
@@ -1703,10 +1803,11 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
1703
1803
  make_error("--org-name requires --email (it names a new account's organisation)", stage="usage"),
1704
1804
  EXIT_USAGE,
1705
1805
  )
1706
- explicit_api = _explicit_api_url(args) or os.environ.get(ENV_API_URL, "").strip()
1707
- console = _console_url_for_args(args)
1708
- api_origin = explicit_api or DEFAULT_API_URL
1709
- base = _normalize_api_origin(api_origin) or api_origin.rstrip("/")
1806
+ base, instance_hint, usage_error = _auth_login_api_base(args)
1807
+ if usage_error is not None:
1808
+ return usage_error
1809
+ _stage_login_api_url_if_connecting(args, base)
1810
+ console = _console_url_flag_for_args(args)
1710
1811
  verifier = _login.new_verifier()
1711
1812
  interactive = stdout_is_tty()
1712
1813
  offer_paste = interactive and _login.paste_from_stdin_supported()
@@ -1752,6 +1853,8 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
1752
1853
  "key_prefix": result.key_prefix,
1753
1854
  "rc_file": str(rc_path),
1754
1855
  }
1856
+ if instance_hint:
1857
+ payload["instance_id"] = instance_hint
1755
1858
  return (CliOutput(OutputKind.LOGIN, payload), EXIT_OK)
1756
1859
 
1757
1860
 
@@ -2643,6 +2746,14 @@ def _build_parser() -> argparse.ArgumentParser:
2643
2746
  "--org-name",
2644
2747
  help="Organisation name when --email creates a new account (default: derived from the address)",
2645
2748
  )
2749
+ pal.add_argument(
2750
+ "connect_url",
2751
+ nargs="?",
2752
+ help=(
2753
+ "Console instance page URL (…/console/instance/<uuid> on a single-origin host); "
2754
+ "the API and Console origins are inferred from it"
2755
+ ),
2756
+ )
2646
2757
  pal.set_defaults(func=cmd_auth_login)
2647
2758
  palo = pa_sub.add_parser("logout", help="Remove the credential from the active .xmemrc.json", add_help=False)
2648
2759
  palo.set_defaults(func=cmd_auth_logout)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes