urun-cli 0.5.2__tar.gz → 0.5.4__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.
@@ -0,0 +1,123 @@
1
+ # Changelog
2
+
3
+ ## 0.5.4
4
+
5
+ - Ship the serving-engine dependency map (`urun.catalog.ENGINE_DEPS`) additions
6
+ and pin syncs that landed on `main` after `v0.5.3` but were never released:
7
+ the `kyutai-moshi`, `moshi-vis`, `stt`, `tts`, `echo-stt`, and `echo-speak`
8
+ engine shapes (#98), the sphn 0.2.x / `moshi==0.2.13` pin syncs (#100, #101),
9
+ and the platform `llamacpp-diffusion` wheel pin (#104). The installed
10
+ `v0.5.3` CLI's strict fail-loud guard therefore refused live dev deploys of
11
+ `urun serve hibiki-zero-3b:bf16` / `urun serve moshivis:bf16` ("serve engine
12
+ 'kyutai-moshi' is not in the CLI's known serving-engine set ...") even though
13
+ `main`'s map was already in sync with urun-python — the drift was the stale
14
+ RELEASE, not the source. This release carries the synced map to PyPI.
15
+ - Make that strict refusal self-diagnosing: the error now names the RUNNING
16
+ CLI version and tells the operator to upgrade first (`uv tool upgrade
17
+ urun-cli`), and only then to suspect genuine source drift from urun-python's
18
+ `urun.serve.engines.extras.ENGINE_DEPS` (which the cross-repo parity test
19
+ guards in CI). The old text sent operators to edit a map that was already
20
+ correct at `origin/main`.
21
+
22
+ - Make the org-secrets store the canonical source for app-declared
23
+ `Credentials(env=..., optional_env=...)`. On `urun deploy`, each declared name
24
+ is resolved against the org-secrets store FIRST — org-level secrets plus this
25
+ app's app-level secrets, exactly the set the control plane injects into the
26
+ runtime pod env. A name already in the store is used as-is and is NEVER read
27
+ from or overridden by the deployer's shell (no silent shadowing of an org
28
+ secret by a stray local value). Only declared names the store does not yet hold
29
+ are filled from the local environment and delivered through the documented
30
+ app-scoped fallback channel. A REQUIRED credential resolvable from NEITHER the
31
+ store NOR the local env fails loud at deploy (never a silent drop that
32
+ resurfaces as a 401 inside the cluster); a missing OPTIONAL credential is
33
+ skipped silently (its contract). Previously the deploy read declared
34
+ credentials ONLY from the deployer's shell and failed if they were unset there
35
+ even when the org secret existed — and pushed the local value as an app secret
36
+ that shadowed the canonical org secret. Secret values are never printed.
37
+ - Add `urun get secret <NAME>` (metadata only — the store never returns values)
38
+ to round out the secret CRUD alongside `urun set secret` / `urun list secrets`
39
+ / `urun delete secret`. `urun set secret <NAME>` with neither `--value` nor
40
+ `--from-file` now reads the value from a non-echoing secure prompt so it never
41
+ lands in shell history / argv.
42
+
43
+ - Make `urun deploy` resilient to transient network blips. The manifest/blob
44
+ push and the "Waiting for build to complete" build-status poll now retry on
45
+ transient failures (read/connect timeouts, connection resets, 5xx, 429) with
46
+ exponential backoff + jitter (base 1s, factor 2, cap 30s) instead of fatally
47
+ exiting on the first `network error: The read operation timed out`. A single
48
+ failed status read backs off and re-polls — the build continues server-side —
49
+ while a real build failure or a 4xx auth/validation error still fails fast and
50
+ loud. Every retry is logged (`transient ...; retrying in Ns`) so backoff is
51
+ visible, never silent. New `urun.retry` module centralizes the
52
+ transient-vs-terminal classification and backoff policy.
53
+
54
+ - Add experimental `urun list apps` subcommand. Reads from a new control-plane
55
+ endpoint `GET /apps` (server-side edge function not yet shipped). The STATUS
56
+ column projects the app's current lifecycle phase to one of `provisioning`,
57
+ `ready`, `pending`, `paused`, or `failed`; see the README or
58
+ `urun list apps --help` for the full mapping. Supports `--json` for the raw
59
+ payload.
60
+ - Add experimental `urun list sessions` subcommand. Lists live and historical
61
+ sessions for the org, with per-row STARTED / DURATION / STATE / DETAIL.
62
+ Newest sessions print at the bottom of the table (tail-friendly). Supports
63
+ `--limit` (default 100), `--state` (filter to a single backend
64
+ status), and `--json`. Reads from a new control-plane endpoint
65
+ `GET /sessions` (server-side edge function not yet shipped).
66
+ - Add experimental `urun list compute` subcommand. Lists the compute slices
67
+ the org currently has provisioned, broken down per (app, function,
68
+ compute_shape) with INSTANCES (allocated/ready), GPU UNITS
69
+ (allocated/ready), live SESSIONS count, and snapshot AGE. Slices with no
70
+ provisioned GPU units are omitted server-side so the listing reflects
71
+ what is running right now (`urun list apps` for the full deployment
72
+ catalogue; `urun list sessions` for history). Supports `--limit`
73
+ (default 100) and `--json`. Reads from a new control-plane
74
+ endpoint `GET /compute` (server-side edge function not yet shipped).
75
+ - Add experimental `urun app status|scale|disable|enable` lifecycle commands
76
+ for managing a single deployed app (addressed by slug, org-scoped via the API
77
+ key). `app status` shows the build/deployment/capacity/live-session rollup;
78
+ `app scale --replicas N` sets the deployment's desired replica count (use 0 to
79
+ drain; GPU/shape stay fixed at deploy time, so only `--replicas` is exposed);
80
+ `app disable` disables an app — driving it to `paused`/
81
+ `disabled` so the control-plane materializer stops recreating its runtime —
82
+ with an interactive confirmation (`--yes` to skip); `app enable` reverses it.
83
+ All accept `--environment` (default `prod`) and `--json`. Backed by a
84
+ new server-side `app` edge function (`GET /app/<slug>`,
85
+ `POST /app/<slug>/scale`, `DELETE /app/<slug>`, `POST /app/<slug>/activate`).
86
+ - Remove duplicate command surfaces in favor of one canonical spelling:
87
+ `urun app status` replaces `urun get app`, `urun app scale` replaces
88
+ `urun scale app`, and bare `urun org` replaces `urun org id` /
89
+ `urun get org-id`. `urun get org` remains for the richer org-config summary.
90
+
91
+ ## 0.5.3
92
+
93
+ - Publish the previously-unreleased `urun app purge` and `urun build purge`
94
+ subcommands. Both landed on `main` after the `v0.5.2` tag was cut, and the
95
+ release workflow skips re-publishing when the `pyproject` version is unchanged,
96
+ so they were never available on PyPI. This is a version-bump-only release that
97
+ carries the existing, already-tested commands to PyPI so CI/operators can run
98
+ `uvx --from urun-cli==0.5.3 urun app purge <slug>` (hard-delete orphaned app
99
+ metadata after `urun app delete`) without a private git install. No CLI
100
+ behavior changes beyond making these commands installable.
101
+
102
+ ## 0.3.0
103
+
104
+ - Implement the `urun org` / `urun org id` command: print the caller's org id
105
+ (resolved via the control-plane org-config endpoint).
106
+ - Implement `urun auth jwks set`: register the org's trusted JWKS for federated
107
+ identity, via `--jwks-url` or `--jwks-json` (file or stdin), with optional
108
+ `--issuer`/`--audience`. This registers a trust relationship only; JWTs are
109
+ issued out of band and the CLI never mints, fetches, or stores one.
110
+ - Add `ApiClient.register_trusted_jwks`, calling `POST /org-config/trusted-jwks`.
111
+
112
+ ## 0.2.0
113
+
114
+ - Bumped past 0.1.1/0.1.2 because both filenames were occupied by previously-deleted PyPI artifacts.
115
+ - Drop the org-id requirement from `urun deploy`; authentication now relies solely on the API key.
116
+ - Add `urun login`, `urun run`, and `urun org`/`urun auth` subcommands.
117
+
118
+ ## 0.1.0
119
+
120
+ - Initial public `urun deploy` CLI.
121
+ - Deploy from a Python app file with local Python imports included automatically.
122
+ - Source manifest generation, dependency declaration upload, and API deploy flow.
123
+ - Public docs for the config-free v1 CLI surface.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: End-user CLI for deploying apps to urun
5
5
  Project-URL: Homepage, https://urun.sh
6
6
  Project-URL: Repository, https://github.com/urun-sh/urun-cli
@@ -51,21 +51,26 @@ uvx urun-cli --version
51
51
 
52
52
  ## Quick start
53
53
 
54
- Today, an operator manually vends an org-scoped deploy API key. Save it locally
55
- with `urun login`:
54
+ Save an org-scoped deploy API key locally with `urun login`:
56
55
 
57
56
  ```bash
58
- urun login --api-key urun_<32hex>
57
+ urun login
59
58
  ```
60
59
 
61
- `urun login` verifies the key with the urun API and stores credentials for later
62
- commands. The future browser-based login flow is not available in this CLI
63
- release.
60
+ If `URUN_API_KEY` is not already set, `urun login` opens the urun console API
61
+ keys page, prompts you to paste the generated `urun_sk_...` key, verifies it
62
+ with the urun API, and stores credentials for later commands.
63
+
64
+ If you already have a key, you can pass it directly:
65
+
66
+ ```bash
67
+ urun login --api-key urun_sk_<secret>
68
+ ```
64
69
 
65
70
  For CI or one-off commands, you can still use the environment variable:
66
71
 
67
72
  ```bash
68
- export URUN_API_KEY=urun_<32hex>
73
+ export URUN_API_KEY=urun_sk_<secret>
69
74
  ```
70
75
 
71
76
  Create `app.py`:
@@ -93,6 +98,24 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
93
98
  `deploy` remains available as the lower-level command while the full
94
99
  deploy/run/monitor workflow is being built.
95
100
 
101
+ ### Launch the prompt-canary browser demo
102
+
103
+ For the official `urun-examples` prompt-canary app, the shortest browser stream
104
+ path is:
105
+
106
+ ```bash
107
+ cd ~/workspace/urun-examples
108
+ urun deploy prompt_canary/backend/app.py --name prompt-canary --timeout 900
109
+ urun demo prompt-canary
110
+ ```
111
+
112
+ `urun demo` currently supports `prompt-canary` only. It uses your stored API key
113
+ server-side to request a short-lived browser JWT, sets the required
114
+ `NEXT_PUBLIC_URUN_*` environment variables, starts the local Next.js frontend,
115
+ and opens it in your browser. Override the examples checkout with
116
+ `--examples-root` or `URUN_EXAMPLES_ROOT`; override the session API with
117
+ `--session-url` or `URUN_SESSION_BASE_URL`.
118
+
96
119
  ## Serve a model from the catalog
97
120
 
98
121
  `urun serve` deploys a model straight from the urun model catalog — no app code
@@ -274,20 +297,26 @@ urun app scale lingbot --replicas 0 # drain to zero without retiring
274
297
  GPU count and compute shape are fixed at deploy time per release (set via
275
298
  `@app.function`), so `scale` intentionally exposes only `--replicas`.
276
299
 
277
- Retire an app so the control plane stops running it (drives the deployment
300
+ Disable an app so the control plane stops running it (drives the deployment
278
301
  to `paused` and the app to `disabled`, so the materializer stops recreating
279
302
  its runtime). This is the clean, reversible, API-driven alternative to a
280
303
  manual database edit:
281
304
 
282
305
  ```bash
283
- urun app delete lingbot-handle # prompts for confirmation
284
- urun app delete lingbot-handle --yes # or urun app rm lingbot-handle --yes
306
+ urun app disable lingbot-handle # prompts for confirmation
307
+ urun app disable lingbot-handle --yes # skip confirmation
308
+ ```
309
+
310
+ Enable a disabled app and bring it back online:
311
+
312
+ ```bash
313
+ urun app enable lingbot-handle
285
314
  ```
286
315
 
287
- Reverse a retire and bring the app back online:
316
+ Stop a queued, starting, or live session:
288
317
 
289
318
  ```bash
290
- urun app activate lingbot-handle
319
+ urun stop session sess_123 --yes
291
320
  ```
292
321
 
293
322
  All `app` subcommands accept `--environment` (default `prod`) and `--json`.
@@ -29,21 +29,26 @@ uvx urun-cli --version
29
29
 
30
30
  ## Quick start
31
31
 
32
- Today, an operator manually vends an org-scoped deploy API key. Save it locally
33
- with `urun login`:
32
+ Save an org-scoped deploy API key locally with `urun login`:
34
33
 
35
34
  ```bash
36
- urun login --api-key urun_<32hex>
35
+ urun login
37
36
  ```
38
37
 
39
- `urun login` verifies the key with the urun API and stores credentials for later
40
- commands. The future browser-based login flow is not available in this CLI
41
- release.
38
+ If `URUN_API_KEY` is not already set, `urun login` opens the urun console API
39
+ keys page, prompts you to paste the generated `urun_sk_...` key, verifies it
40
+ with the urun API, and stores credentials for later commands.
41
+
42
+ If you already have a key, you can pass it directly:
43
+
44
+ ```bash
45
+ urun login --api-key urun_sk_<secret>
46
+ ```
42
47
 
43
48
  For CI or one-off commands, you can still use the environment variable:
44
49
 
45
50
  ```bash
46
- export URUN_API_KEY=urun_<32hex>
51
+ export URUN_API_KEY=urun_sk_<secret>
47
52
  ```
48
53
 
49
54
  Create `app.py`:
@@ -71,6 +76,24 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
71
76
  `deploy` remains available as the lower-level command while the full
72
77
  deploy/run/monitor workflow is being built.
73
78
 
79
+ ### Launch the prompt-canary browser demo
80
+
81
+ For the official `urun-examples` prompt-canary app, the shortest browser stream
82
+ path is:
83
+
84
+ ```bash
85
+ cd ~/workspace/urun-examples
86
+ urun deploy prompt_canary/backend/app.py --name prompt-canary --timeout 900
87
+ urun demo prompt-canary
88
+ ```
89
+
90
+ `urun demo` currently supports `prompt-canary` only. It uses your stored API key
91
+ server-side to request a short-lived browser JWT, sets the required
92
+ `NEXT_PUBLIC_URUN_*` environment variables, starts the local Next.js frontend,
93
+ and opens it in your browser. Override the examples checkout with
94
+ `--examples-root` or `URUN_EXAMPLES_ROOT`; override the session API with
95
+ `--session-url` or `URUN_SESSION_BASE_URL`.
96
+
74
97
  ## Serve a model from the catalog
75
98
 
76
99
  `urun serve` deploys a model straight from the urun model catalog — no app code
@@ -252,20 +275,26 @@ urun app scale lingbot --replicas 0 # drain to zero without retiring
252
275
  GPU count and compute shape are fixed at deploy time per release (set via
253
276
  `@app.function`), so `scale` intentionally exposes only `--replicas`.
254
277
 
255
- Retire an app so the control plane stops running it (drives the deployment
278
+ Disable an app so the control plane stops running it (drives the deployment
256
279
  to `paused` and the app to `disabled`, so the materializer stops recreating
257
280
  its runtime). This is the clean, reversible, API-driven alternative to a
258
281
  manual database edit:
259
282
 
260
283
  ```bash
261
- urun app delete lingbot-handle # prompts for confirmation
262
- urun app delete lingbot-handle --yes # or urun app rm lingbot-handle --yes
284
+ urun app disable lingbot-handle # prompts for confirmation
285
+ urun app disable lingbot-handle --yes # skip confirmation
286
+ ```
287
+
288
+ Enable a disabled app and bring it back online:
289
+
290
+ ```bash
291
+ urun app enable lingbot-handle
263
292
  ```
264
293
 
265
- Reverse a retire and bring the app back online:
294
+ Stop a queued, starting, or live session:
266
295
 
267
296
  ```bash
268
- urun app activate lingbot-handle
297
+ urun stop session sess_123 --yes
269
298
  ```
270
299
 
271
300
  All `app` subcommands accept `--environment` (default `prod`) and `--json`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.5.2"
7
+ version = "0.5.4"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -117,6 +117,12 @@ class ApiClient:
117
117
  {"reason": reason} if reason else {},
118
118
  )
119
119
 
120
+ def create_demo_token(self, app: str, function_name: str | None = None) -> dict[str, Any]:
121
+ body: dict[str, Any] = {"app": app}
122
+ if function_name:
123
+ body["function"] = function_name
124
+ return self._post("/cli/demo-token", body)
125
+
120
126
  def list_compute(self, limit: int | None = None) -> dict[str, Any]:
121
127
  """List currently-provisioned compute slices for the authenticated org.
122
128
 
@@ -163,12 +169,6 @@ class ApiClient:
163
169
  def get_capacity_lease(self, lease_id: str) -> dict[str, Any]:
164
170
  return self._get(f"/cli/capacity-leases/{urllib.parse.quote(lease_id, safe='')}")
165
171
 
166
- def create_capacity_lease(self, payload: dict[str, Any]) -> dict[str, Any]:
167
- return self._post("/cli/capacity-leases", payload)
168
-
169
- def cancel_capacity_lease(self, lease_id: str) -> dict[str, Any]:
170
- return self._post(f"/cli/capacity-leases/{urllib.parse.quote(lease_id, safe='')}/cancel")
171
-
172
172
  def list_builds(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
173
173
  return self._get("/cli/builds", params)
174
174
 
@@ -180,6 +180,78 @@ class ApiClient:
180
180
  f"/cli/builds/{urllib.parse.quote(build_id, safe='')}/logs", {"tail": tail}
181
181
  )
182
182
 
183
+ def purge_build(self, manifest_hash_or_prefix: str) -> dict[str, Any]:
184
+ return self._delete(
185
+ f"/cli/builds/{urllib.parse.quote(manifest_hash_or_prefix, safe='')}/purge"
186
+ )
187
+
188
+ def build_app(
189
+ self, prompt: str, app: str | None = None, visibility: str | None = None
190
+ ) -> dict[str, Any]:
191
+ """Enqueue a steerable builder-agent run.
192
+
193
+ Mirrors the uRun MCP builder surface (``build_app`` / ``iterate_app`` /
194
+ ``debug_app`` plus the builder steering tools). The MCP tools enqueue by
195
+ INSERTing rows directly into Supabase (``public.builder_runs`` /
196
+ ``public.builder_steering``) with a service-role key — there is no
197
+ costly-agents HTTP service. A CLI must never hold a service-role key, so
198
+ the CLI mirrors the same logical operations and the same request-body
199
+ field names but routes them through the org-API-key control-plane REST
200
+ plane (``/cli/builders...``), which performs the privileged insert
201
+ server-side (edge function TBD, same convention as
202
+ ``list_apps``/``list_sessions``).
203
+ """
204
+ body: dict[str, Any] = {"mode": "build", "prompt": prompt}
205
+ if app:
206
+ body["app"] = app
207
+ if visibility:
208
+ body["visibility"] = visibility
209
+ return self._post("/cli/builders", body)
210
+
211
+ def iterate_app(self, app: str, request: str, repo: str | None = None) -> dict[str, Any]:
212
+ body: dict[str, Any] = {"mode": "feature", "app": app, "request": request}
213
+ if repo:
214
+ body["repo"] = repo
215
+ return self._post("/cli/builders", body)
216
+
217
+ def debug_app(
218
+ self, app: str, symptom: str | None = None, repo: str | None = None
219
+ ) -> dict[str, Any]:
220
+ body: dict[str, Any] = {"mode": "debug", "app": app}
221
+ if symptom:
222
+ body["symptom"] = symptom
223
+ if repo:
224
+ body["repo"] = repo
225
+ return self._post("/cli/builders", body)
226
+
227
+ def list_builders(self, limit: int | None = None) -> dict[str, Any]:
228
+ return self._get("/cli/builders", {"limit": limit})
229
+
230
+ def get_builder(self, agent_id: str) -> dict[str, Any]:
231
+ return self._get(f"/cli/builders/{urllib.parse.quote(agent_id, safe='')}")
232
+
233
+ def message_builder(self, agent_id: str, message: str) -> dict[str, Any]:
234
+ return self._post(
235
+ f"/cli/builders/{urllib.parse.quote(agent_id, safe='')}/message", {"message": message}
236
+ )
237
+
238
+ def resolve_builder_checkpoint(
239
+ self,
240
+ agent_id: str,
241
+ decision: str,
242
+ amend: dict[str, Any] | None = None,
243
+ reason: str | None = None,
244
+ ) -> dict[str, Any]:
245
+ body: dict[str, Any] = {"decision": decision}
246
+ if amend is not None:
247
+ body["amend"] = amend
248
+ if reason is not None:
249
+ body["reason"] = reason
250
+ return self._post(f"/cli/builders/{urllib.parse.quote(agent_id, safe='')}/resolve", body)
251
+
252
+ def stop_builder(self, agent_id: str) -> dict[str, Any]:
253
+ return self._post(f"/cli/builders/{urllib.parse.quote(agent_id, safe='')}/stop")
254
+
183
255
  def list_releases(self, app: str, environment: str | None = None) -> dict[str, Any]:
184
256
  return self._get("/cli/releases", {"app": app, "environment": environment})
185
257
 
@@ -238,35 +310,15 @@ class ApiClient:
238
310
  def list_api_keys(self) -> dict[str, Any]:
239
311
  return self._get("/cli/api-keys")
240
312
 
241
- def create_api_key(self, label: str, service_account: str | None = None) -> dict[str, Any]:
242
- return self._post("/cli/api-keys", {"label": label, "service_account": service_account})
243
-
244
- def revoke_api_key(self, key_id: str) -> dict[str, Any]:
245
- return self._post(f"/cli/api-keys/{urllib.parse.quote(key_id, safe='')}/revoke")
246
-
247
313
  def list_service_accounts(self) -> dict[str, Any]:
248
314
  return self._get("/cli/service-accounts")
249
315
 
250
- def create_service_account(self, name: str) -> dict[str, Any]:
251
- return self._post("/cli/service-accounts", {"name": name})
252
-
253
- def delete_service_account(self, account_id: str) -> dict[str, Any]:
254
- return self._delete(f"/cli/service-accounts/{urllib.parse.quote(account_id, safe='')}")
255
-
256
316
  def list_members(self) -> dict[str, Any]:
257
317
  return self._get("/cli/members")
258
318
 
259
319
  def get_member(self, actor_id: str) -> dict[str, Any]:
260
320
  return self._get(f"/cli/members/{urllib.parse.quote(actor_id, safe='')}")
261
321
 
262
- def invite_member(self, email: str, role: str) -> dict[str, Any]:
263
- return self._post("/cli/members/invite", {"email": email, "role": role})
264
-
265
- def set_member_role(self, actor_id: str, role: str) -> dict[str, Any]:
266
- return self._post(
267
- f"/cli/members/{urllib.parse.quote(actor_id, safe='')}/role", {"role": role}
268
- )
269
-
270
322
  def list_activity(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
271
323
  return self._get("/cli/activity", params)
272
324
 
@@ -279,74 +331,27 @@ class ApiClient:
279
331
  def get_issue(self, issue: str) -> dict[str, Any]:
280
332
  return self._get(f"/cli/issues/{urllib.parse.quote(issue, safe='')}")
281
333
 
282
- def set_issue_status(self, issue: str, status: str, note: str | None = None) -> dict[str, Any]:
283
- return self._post(
284
- f"/cli/issues/{urllib.parse.quote(issue, safe='')}/status",
285
- {"status": status, "note": note},
286
- )
287
-
288
334
  def list_alert_rules(self) -> dict[str, Any]:
289
335
  return self._get("/cli/alert-rules")
290
336
 
291
337
  def get_alert_rule(self, rule_id: str) -> dict[str, Any]:
292
338
  return self._get(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
293
339
 
294
- def create_alert_rule(self, payload: dict[str, Any]) -> dict[str, Any]:
295
- return self._post("/cli/alert-rules", payload)
296
-
297
- def update_alert_rule(self, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
298
- return self._post(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}", payload)
299
-
300
- def delete_alert_rule(self, rule_id: str) -> dict[str, Any]:
301
- return self._delete(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
302
-
303
340
  def list_alert_channels(self) -> dict[str, Any]:
304
341
  return self._get("/cli/alert-channels")
305
342
 
306
- def create_alert_channel(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
307
- return self._post(f"/cli/alert-channels/{urllib.parse.quote(kind, safe='')}", payload)
308
-
309
- def set_alert_channel_enabled(self, channel_id: str, enabled: bool) -> dict[str, Any]:
310
- return self._post(
311
- f"/cli/alert-channels/{urllib.parse.quote(channel_id, safe='')}/enabled",
312
- {"enabled": enabled},
313
- )
314
-
315
- def delete_alert_channel(self, channel_id: str) -> dict[str, Any]:
316
- return self._delete(f"/cli/alert-channels/{urllib.parse.quote(channel_id, safe='')}")
317
-
318
343
  def list_git_installations(self) -> dict[str, Any]:
319
344
  return self._get("/cli/git-installations")
320
345
 
321
346
  def list_git_repos(self) -> dict[str, Any]:
322
347
  return self._get("/cli/git-repos")
323
348
 
324
- def link_app(self, app: str, payload: dict[str, Any]) -> dict[str, Any]:
325
- return self._post(f"/cli/apps/{urllib.parse.quote(app, safe='')}/git", payload)
326
-
327
- def unlink_app(self, app: str) -> dict[str, Any]:
328
- return self._delete(f"/cli/apps/{urllib.parse.quote(app, safe='')}/git")
329
-
330
- def deploy_app_from_git(self, app: str, branch: str | None = None) -> dict[str, Any]:
331
- body: dict[str, Any] = {}
332
- if branch is not None:
333
- body["branch"] = branch
334
- return self._post(f"/cli/apps/{urllib.parse.quote(app, safe='')}/deploy", body)
335
-
336
349
  def list_marketplace_apps(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
337
350
  return self._get("/cli/marketplace-apps", params)
338
351
 
339
352
  def get_marketplace_app(self, slug: str) -> dict[str, Any]:
340
353
  return self._get(f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}")
341
354
 
342
- def install_app(self, slug: str, environment: str | None = None) -> dict[str, Any]:
343
- body: dict[str, Any] = {}
344
- if environment is not None:
345
- body["environment"] = environment
346
- return self._post(
347
- f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}/install", body
348
- )
349
-
350
355
  def app_status(self, app: str, environment: str | None = None) -> dict[str, Any]:
351
356
  """Fetch the detailed lifecycle status for a single app.
352
357
 
@@ -378,19 +383,19 @@ class ApiClient:
378
383
  return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/scale", body)
379
384
 
380
385
  def delete_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
381
- """Retire (deactivate) an app deployment.
386
+ """Disable an app deployment.
382
387
 
383
388
  Hits ``DELETE /app/<slug>``, which drives the deployment to ``paused``
384
389
  (and the app to ``disabled``) so the control-plane materializer stops
385
390
  recreating the app's runtime StatefulSet. Reversible with
386
391
  :meth:`activate_app`. The org is derived from the API key. Returns
387
- ``{"app": {...}}`` with the retired deployment.
392
+ ``{"app": {...}}`` with the disabled deployment.
388
393
  """
389
394
  body: dict[str, Any] | None = {"environment": environment} if environment else None
390
395
  return self._json("DELETE", f"/app/{urllib.parse.quote(app, safe='')}", body)
391
396
 
392
397
  def activate_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
393
- """Reverse a retire: reactivate a previously-deleted app deployment.
398
+ """Enable a previously disabled app deployment.
394
399
 
395
400
  Hits ``POST /app/<slug>/activate`` (status back to ``active``), so the
396
401
  materializer resumes recreating the app's StatefulSet. The org is
@@ -399,6 +404,15 @@ class ApiClient:
399
404
  body: dict[str, Any] | None = {"environment": environment} if environment else None
400
405
  return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/activate", body)
401
406
 
407
+ def purge_app(self, app: str) -> dict[str, Any]:
408
+ """Permanently purge orphaned app metadata for a partially-deleted app.
409
+
410
+ Hits ``DELETE /cli/apps/<slug>/purge``. The backend scopes the purge to
411
+ the API key's org and refuses to act while deployment rows still exist,
412
+ so ordinary deployed apps must use lifecycle commands first.
413
+ """
414
+ return self._delete(f"/cli/apps/{urllib.parse.quote(app, safe='')}/purge")
415
+
402
416
  def org_config(self) -> dict[str, Any]:
403
417
  return self._json("GET", "/org-config", None)
404
418
 
@@ -415,6 +429,73 @@ class ApiClient:
415
429
  """
416
430
  return self._json("POST", "/org-config/auth-connections", payload)
417
431
 
432
+ # --- MCP-parity reads -------------------------------------------------- #
433
+ # The uRun MCP reads GPU metrics straight from cube and runtime/deploy state
434
+ # straight from Supabase PostgREST with privileged server-only credentials
435
+ # (cube API secret / service-role key). The CLI cannot and must not hold
436
+ # those, so — exactly like the existing query_logs -> /cli/logs and
437
+ # list_failure_groups -> /cli/failure-groups mirrors — it routes the SAME
438
+ # semantics through the org-API-key control-plane edge surface (/cli/...).
439
+
440
+ def get_gpu_metrics(self, app: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
441
+ return self._get(f"/cli/gpu-metrics/{urllib.parse.quote(app, safe='')}", params)
442
+
443
+ def get_runtime_health(self, app: str, function_name: str | None = None) -> dict[str, Any]:
444
+ return self._get(
445
+ f"/cli/runtime-health/{urllib.parse.quote(app, safe='')}",
446
+ {"function": function_name},
447
+ )
448
+
449
+ def get_deploy_status(self, app: str, environment: str | None = None) -> dict[str, Any]:
450
+ return self._get(
451
+ f"/cli/deploy-status/{urllib.parse.quote(app, safe='')}",
452
+ {"environment": environment},
453
+ )
454
+
455
+ # --- Platform feedback ------------------------------------------------- #
456
+ def raise_feedback(self, payload: dict[str, Any]) -> dict[str, Any]:
457
+ return self._post("/cli/feedback", payload)
458
+
459
+ def comment_feedback(self, feedback_id: str, body: str) -> dict[str, Any]:
460
+ return self._post(
461
+ f"/cli/feedback/{urllib.parse.quote(feedback_id, safe='')}/comments",
462
+ {"body": body},
463
+ )
464
+
465
+ def get_feedback(self, feedback_id: str) -> dict[str, Any]:
466
+ return self._get(f"/cli/feedback/{urllib.parse.quote(feedback_id, safe='')}")
467
+
468
+ def list_feedback(self) -> dict[str, Any]:
469
+ return self._get("/cli/feedback")
470
+
471
+ # --- Dev gate ---------------------------------------------------------- #
472
+ def run_dev_gate(self, payload: dict[str, Any]) -> dict[str, Any]:
473
+ return self._post("/cli/dev-gate", payload)
474
+
475
+ # --- Console: dashboards + view defaults ------------------------------- #
476
+ def create_dashboard(self, payload: dict[str, Any]) -> dict[str, Any]:
477
+ return self._post("/cli/dashboards", payload)
478
+
479
+ def save_dashboard_config(self, dashboard_ref: str, payload: dict[str, Any]) -> dict[str, Any]:
480
+ return self._post(
481
+ f"/cli/dashboards/{urllib.parse.quote(dashboard_ref, safe='')}/config", payload
482
+ )
483
+
484
+ def set_view_default(self, surface: str, payload: dict[str, Any]) -> dict[str, Any]:
485
+ return self._post(f"/cli/view-defaults/{urllib.parse.quote(surface, safe='')}", payload)
486
+
487
+ def clear_view_default(self, surface: str, scope: str) -> dict[str, Any]:
488
+ return self._delete(
489
+ f"/cli/view-defaults/{urllib.parse.quote(surface, safe='')}", {"scope": scope}
490
+ )
491
+
492
+ # --- Org config deltas ------------------------------------------------- #
493
+ def set_org_limits(self, payload: dict[str, Any]) -> dict[str, Any]:
494
+ return self._post("/org-config/limits", payload)
495
+
496
+ def remove_auth_connection(self, issuer: str) -> dict[str, Any]:
497
+ return self._delete("/org-config/auth-connections", {"issuer": issuer})
498
+
418
499
  def _json(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
419
500
  data = (
420
501
  None