xmemcli 0.0.12__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.
@@ -1,3 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: xmemcli
3
+ Version: 0.0.13
4
+ Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
+ Keywords: api,cli,llm,memory,xmemory
6
+ Requires-Python: <4,>=3.12
7
+ Description-Content-Type: text/markdown
8
+
1
9
  # `xmemcli` — xmemory command-line client
2
10
 
3
11
  Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
@@ -89,7 +97,7 @@ xmemcli version
89
97
  xmemcli auth login
90
98
  ```
91
99
 
92
- Version: **0.0.12**.
100
+ Version: **0.0.13**.
93
101
 
94
102
 
95
103
  ---
@@ -143,7 +151,7 @@ AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never hand
143
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.
144
152
  5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
145
153
 
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.
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.
147
155
 
148
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.
149
157
 
@@ -155,7 +163,7 @@ Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking
155
163
 
156
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.
157
165
 
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.
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.
159
167
 
160
168
  ## Identity — `/whoami`
161
169
 
@@ -185,7 +193,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
185
193
 
186
194
  # `xmemcli` — capabilities reference
187
195
 
188
- **Version:** `0.0.12` (see `xmemcli version`)
196
+ **Version:** `0.0.13` (see `xmemcli version`)
189
197
 
190
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.
191
199
 
@@ -509,6 +517,10 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
509
517
 
510
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.
511
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
+
512
524
  See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
513
525
 
514
526
  ---
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: xmemcli
3
- Version: 0.0.12
4
- Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
- Keywords: api,cli,llm,memory,xmemory
6
- Requires-Python: <4,>=3.12
7
- Description-Content-Type: text/markdown
8
-
9
1
  # `xmemcli` — xmemory command-line client
10
2
 
11
3
  Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
@@ -97,7 +89,7 @@ xmemcli version
97
89
  xmemcli auth login
98
90
  ```
99
91
 
100
- Version: **0.0.12**.
92
+ Version: **0.0.13**.
101
93
 
102
94
 
103
95
  ---
@@ -151,7 +143,7 @@ AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never hand
151
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.
152
144
  5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
153
145
 
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.
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.
155
147
 
156
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.
157
149
 
@@ -163,7 +155,7 @@ Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking
163
155
 
164
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.
165
157
 
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.
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.
167
159
 
168
160
  ## Identity — `/whoami`
169
161
 
@@ -193,7 +185,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
193
185
 
194
186
  # `xmemcli` — capabilities reference
195
187
 
196
- **Version:** `0.0.12` (see `xmemcli version`)
188
+ **Version:** `0.0.13` (see `xmemcli version`)
197
189
 
198
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.
199
191
 
@@ -517,6 +509,10 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
517
509
 
518
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.
519
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
+
520
516
  See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
521
517
 
522
518
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.12"
3
+ version = "0.0.13"
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 = "0.0.13"
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__ = "0.0.13"
@@ -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
- """Local dev convention: API on :8000, Console on :8002 (same host)."""
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
- 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)
160
- if inferred:
161
- return inferred
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
- base = api_url.rstrip("/")
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
- base = api_url.rstrip("/")
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(m._mcp_url(args))
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, m._mcp_url(args), m)
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"{m._mcp_url(args).rstrip('/')}/instance/{instance_id}"
663
+ url = f"{mcp_origin.rstrip('/')}/instance/{instance_id}"
632
664
  return _pump(*_utf8_stdio(), url, api_key, unavailable)
633
665
 
634
666
 
@@ -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__ = "0.0.13"
@@ -882,6 +882,21 @@ def _normalize_api_origin(url: str) -> str | None:
882
882
  return f"{scheme}://{host}:{port}"
883
883
 
884
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
+
885
900
  def _origins_match(a: str, b: str) -> bool:
886
901
  norm_a = _normalize_api_origin(a)
887
902
  norm_b = _normalize_api_origin(b)
@@ -955,7 +970,7 @@ def _api_url(args: argparse.Namespace) -> str:
955
970
  # explicit --api-key / $XMEM_API_KEY skips rc api_url (see AUTH-FLOW.md).
956
971
  explicit_url = args.api_url or os.environ.get(ENV_API_URL)
957
972
  if explicit_url:
958
- return _normalize_api_origin(explicit_url) or explicit_url.rstrip("/")
973
+ return _normalize_api_base_url(explicit_url) or explicit_url.rstrip("/")
959
974
  # No explicit origin. The rc supplies the default origin ONLY when the rc is also the
960
975
  # credential source — i.e. when no explicit key was given. With an explicit --api-key /
961
976
  # $XMEM_API_KEY the rc is irrelevant (its api_url binds only the rc's own key), and a
@@ -963,23 +978,36 @@ def _api_url(args: argparse.Namespace) -> str:
963
978
  # default WITHOUT reading the rc. This mirrors the same explicit-key escape hatch in
964
979
  # _require(), so `--api-key` + a broken rc + no --api-url resolves cleanly end to end.
965
980
  if args.api_key or os.environ.get(ENV_API_KEY):
966
- return _normalize_api_origin(DEFAULT_API_URL) or DEFAULT_API_URL.rstrip("/")
981
+ return _normalize_api_base_url(DEFAULT_API_URL) or DEFAULT_API_URL.rstrip("/")
967
982
  rc = _rc_record(args)
968
983
  fallback = (rc.api_url if rc else None) or DEFAULT_API_URL
969
- return _normalize_api_origin(fallback) or fallback.rstrip("/")
984
+ return _normalize_api_base_url(fallback) or fallback.rstrip("/")
970
985
 
971
986
 
972
987
  def _explicit_api_url(args: argparse.Namespace) -> str | None:
973
- """Origin from flags/env only — never from ``.xmemrc.json``."""
988
+ """API base from flags/env only — never from ``.xmemrc.json``."""
974
989
  explicit = args.api_url or os.environ.get(ENV_API_URL)
975
990
  if not explicit:
976
991
  return None
977
- return _normalize_api_origin(explicit) or explicit.rstrip("/")
992
+ return _normalize_api_base_url(explicit) or explicit.rstrip("/")
993
+
978
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.
979
997
 
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()
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
983
1011
 
984
1012
 
985
1013
  def _rc_dir(args: argparse.Namespace) -> Path:
@@ -1470,7 +1498,7 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
1470
1498
  explicit_api = _explicit_api_url(args)
1471
1499
  rc_origin = rc.api_url.strip() if rc is not None and rc.api_url.strip() else None
1472
1500
  api_origin = explicit_api or rc_origin or DEFAULT_API_URL
1473
- base = _normalize_api_origin(api_origin) or api_origin.rstrip("/")
1501
+ base = _normalize_api_base_url(api_origin) or api_origin.rstrip("/")
1474
1502
  console, refusal = _email_console_origin(args, api_origin=api_origin)
1475
1503
  if refusal is not None:
1476
1504
  return refusal
@@ -1706,7 +1734,7 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
1706
1734
  explicit_api = _explicit_api_url(args) or os.environ.get(ENV_API_URL, "").strip()
1707
1735
  console = _console_url_for_args(args)
1708
1736
  api_origin = explicit_api or DEFAULT_API_URL
1709
- base = _normalize_api_origin(api_origin) or api_origin.rstrip("/")
1737
+ base = _normalize_api_base_url(api_origin) or api_origin.rstrip("/")
1710
1738
  verifier = _login.new_verifier()
1711
1739
  interactive = stdout_is_tty()
1712
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