urun-cli 0.5.3__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.
@@ -1,17 +1,44 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.3
3
+ ## 0.5.4
4
4
 
5
- - Publish the previously-unreleased `urun app purge` and `urun build purge`
6
- subcommands. Both landed on `main` after the `v0.5.2` tag was cut, and the
7
- release workflow skips re-publishing when the `pyproject` version is unchanged,
8
- so they were never available on PyPI. This is a version-bump-only release that
9
- carries the existing, already-tested commands to PyPI so CI/operators can run
10
- `uvx --from urun-cli==0.5.3 urun app purge <slug>` (hard-delete orphaned app
11
- metadata after `urun app delete`) without a private git install. No CLI
12
- behavior changes beyond making these commands installable.
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`.
13
21
 
14
- ## Unreleased
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.
15
42
 
16
43
  - Make `urun deploy` resilient to transient network blips. The manifest/blob
17
44
  push and the "Waiting for build to complete" build-status poll now retry on
@@ -45,17 +72,32 @@
45
72
  catalogue; `urun list sessions` for history). Supports `--limit`
46
73
  (default 100) and `--json`. Reads from a new control-plane
47
74
  endpoint `GET /compute` (server-side edge function not yet shipped).
48
- - Add experimental `urun app status|scale|delete|activate` lifecycle commands
75
+ - Add experimental `urun app status|scale|disable|enable` lifecycle commands
49
76
  for managing a single deployed app (addressed by slug, org-scoped via the API
50
77
  key). `app status` shows the build/deployment/capacity/live-session rollup;
51
78
  `app scale --replicas N` sets the deployment's desired replica count (use 0 to
52
79
  drain; GPU/shape stay fixed at deploy time, so only `--replicas` is exposed);
53
- `app delete` (alias `app rm`) retires an app — driving it to `paused`/
80
+ `app disable` disables an app — driving it to `paused`/
54
81
  `disabled` so the control-plane materializer stops recreating its runtime —
55
- with an interactive confirmation (`--yes` to skip); `app activate` reverses a
56
- retire. All accept `--environment` (default `prod`) and `--json`. Backed by a
82
+ with an interactive confirmation (`--yes` to skip); `app enable` reverses it.
83
+ All accept `--environment` (default `prod`) and `--json`. Backed by a
57
84
  new server-side `app` edge function (`GET /app/<slug>`,
58
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.
59
101
 
60
102
  ## 0.3.0
61
103
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.5.3
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
@@ -98,6 +98,24 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
98
98
  `deploy` remains available as the lower-level command while the full
99
99
  deploy/run/monitor workflow is being built.
100
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
+
101
119
  ## Serve a model from the catalog
102
120
 
103
121
  `urun serve` deploys a model straight from the urun model catalog — no app code
@@ -279,20 +297,26 @@ urun app scale lingbot --replicas 0 # drain to zero without retiring
279
297
  GPU count and compute shape are fixed at deploy time per release (set via
280
298
  `@app.function`), so `scale` intentionally exposes only `--replicas`.
281
299
 
282
- 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
283
301
  to `paused` and the app to `disabled`, so the materializer stops recreating
284
302
  its runtime). This is the clean, reversible, API-driven alternative to a
285
303
  manual database edit:
286
304
 
287
305
  ```bash
288
- urun app delete lingbot-handle # prompts for confirmation
289
- 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
290
314
  ```
291
315
 
292
- Reverse a retire and bring the app back online:
316
+ Stop a queued, starting, or live session:
293
317
 
294
318
  ```bash
295
- urun app activate lingbot-handle
319
+ urun stop session sess_123 --yes
296
320
  ```
297
321
 
298
322
  All `app` subcommands accept `--environment` (default `prod`) and `--json`.
@@ -76,6 +76,24 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
76
76
  `deploy` remains available as the lower-level command while the full
77
77
  deploy/run/monitor workflow is being built.
78
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
+
79
97
  ## Serve a model from the catalog
80
98
 
81
99
  `urun serve` deploys a model straight from the urun model catalog — no app code
@@ -257,20 +275,26 @@ urun app scale lingbot --replicas 0 # drain to zero without retiring
257
275
  GPU count and compute shape are fixed at deploy time per release (set via
258
276
  `@app.function`), so `scale` intentionally exposes only `--replicas`.
259
277
 
260
- 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
261
279
  to `paused` and the app to `disabled`, so the materializer stops recreating
262
280
  its runtime). This is the clean, reversible, API-driven alternative to a
263
281
  manual database edit:
264
282
 
265
283
  ```bash
266
- urun app delete lingbot-handle # prompts for confirmation
267
- 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
268
292
  ```
269
293
 
270
- Reverse a retire and bring the app back online:
294
+ Stop a queued, starting, or live session:
271
295
 
272
296
  ```bash
273
- urun app activate lingbot-handle
297
+ urun stop session sess_123 --yes
274
298
  ```
275
299
 
276
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.3"
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
 
@@ -185,6 +185,73 @@ class ApiClient:
185
185
  f"/cli/builds/{urllib.parse.quote(manifest_hash_or_prefix, safe='')}/purge"
186
186
  )
187
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
+
188
255
  def list_releases(self, app: str, environment: str | None = None) -> dict[str, Any]:
189
256
  return self._get("/cli/releases", {"app": app, "environment": environment})
190
257
 
@@ -243,35 +310,15 @@ class ApiClient:
243
310
  def list_api_keys(self) -> dict[str, Any]:
244
311
  return self._get("/cli/api-keys")
245
312
 
246
- def create_api_key(self, label: str, service_account: str | None = None) -> dict[str, Any]:
247
- return self._post("/cli/api-keys", {"label": label, "service_account": service_account})
248
-
249
- def revoke_api_key(self, key_id: str) -> dict[str, Any]:
250
- return self._post(f"/cli/api-keys/{urllib.parse.quote(key_id, safe='')}/revoke")
251
-
252
313
  def list_service_accounts(self) -> dict[str, Any]:
253
314
  return self._get("/cli/service-accounts")
254
315
 
255
- def create_service_account(self, name: str) -> dict[str, Any]:
256
- return self._post("/cli/service-accounts", {"name": name})
257
-
258
- def delete_service_account(self, account_id: str) -> dict[str, Any]:
259
- return self._delete(f"/cli/service-accounts/{urllib.parse.quote(account_id, safe='')}")
260
-
261
316
  def list_members(self) -> dict[str, Any]:
262
317
  return self._get("/cli/members")
263
318
 
264
319
  def get_member(self, actor_id: str) -> dict[str, Any]:
265
320
  return self._get(f"/cli/members/{urllib.parse.quote(actor_id, safe='')}")
266
321
 
267
- def invite_member(self, email: str, role: str) -> dict[str, Any]:
268
- return self._post("/cli/members/invite", {"email": email, "role": role})
269
-
270
- def set_member_role(self, actor_id: str, role: str) -> dict[str, Any]:
271
- return self._post(
272
- f"/cli/members/{urllib.parse.quote(actor_id, safe='')}/role", {"role": role}
273
- )
274
-
275
322
  def list_activity(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
276
323
  return self._get("/cli/activity", params)
277
324
 
@@ -284,74 +331,27 @@ class ApiClient:
284
331
  def get_issue(self, issue: str) -> dict[str, Any]:
285
332
  return self._get(f"/cli/issues/{urllib.parse.quote(issue, safe='')}")
286
333
 
287
- def set_issue_status(self, issue: str, status: str, note: str | None = None) -> dict[str, Any]:
288
- return self._post(
289
- f"/cli/issues/{urllib.parse.quote(issue, safe='')}/status",
290
- {"status": status, "note": note},
291
- )
292
-
293
334
  def list_alert_rules(self) -> dict[str, Any]:
294
335
  return self._get("/cli/alert-rules")
295
336
 
296
337
  def get_alert_rule(self, rule_id: str) -> dict[str, Any]:
297
338
  return self._get(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
298
339
 
299
- def create_alert_rule(self, payload: dict[str, Any]) -> dict[str, Any]:
300
- return self._post("/cli/alert-rules", payload)
301
-
302
- def update_alert_rule(self, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
303
- return self._post(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}", payload)
304
-
305
- def delete_alert_rule(self, rule_id: str) -> dict[str, Any]:
306
- return self._delete(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
307
-
308
340
  def list_alert_channels(self) -> dict[str, Any]:
309
341
  return self._get("/cli/alert-channels")
310
342
 
311
- def create_alert_channel(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
312
- return self._post(f"/cli/alert-channels/{urllib.parse.quote(kind, safe='')}", payload)
313
-
314
- def set_alert_channel_enabled(self, channel_id: str, enabled: bool) -> dict[str, Any]:
315
- return self._post(
316
- f"/cli/alert-channels/{urllib.parse.quote(channel_id, safe='')}/enabled",
317
- {"enabled": enabled},
318
- )
319
-
320
- def delete_alert_channel(self, channel_id: str) -> dict[str, Any]:
321
- return self._delete(f"/cli/alert-channels/{urllib.parse.quote(channel_id, safe='')}")
322
-
323
343
  def list_git_installations(self) -> dict[str, Any]:
324
344
  return self._get("/cli/git-installations")
325
345
 
326
346
  def list_git_repos(self) -> dict[str, Any]:
327
347
  return self._get("/cli/git-repos")
328
348
 
329
- def link_app(self, app: str, payload: dict[str, Any]) -> dict[str, Any]:
330
- return self._post(f"/cli/apps/{urllib.parse.quote(app, safe='')}/git", payload)
331
-
332
- def unlink_app(self, app: str) -> dict[str, Any]:
333
- return self._delete(f"/cli/apps/{urllib.parse.quote(app, safe='')}/git")
334
-
335
- def deploy_app_from_git(self, app: str, branch: str | None = None) -> dict[str, Any]:
336
- body: dict[str, Any] = {}
337
- if branch is not None:
338
- body["branch"] = branch
339
- return self._post(f"/cli/apps/{urllib.parse.quote(app, safe='')}/deploy", body)
340
-
341
349
  def list_marketplace_apps(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
342
350
  return self._get("/cli/marketplace-apps", params)
343
351
 
344
352
  def get_marketplace_app(self, slug: str) -> dict[str, Any]:
345
353
  return self._get(f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}")
346
354
 
347
- def install_app(self, slug: str, environment: str | None = None) -> dict[str, Any]:
348
- body: dict[str, Any] = {}
349
- if environment is not None:
350
- body["environment"] = environment
351
- return self._post(
352
- f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}/install", body
353
- )
354
-
355
355
  def app_status(self, app: str, environment: str | None = None) -> dict[str, Any]:
356
356
  """Fetch the detailed lifecycle status for a single app.
357
357
 
@@ -383,19 +383,19 @@ class ApiClient:
383
383
  return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/scale", body)
384
384
 
385
385
  def delete_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
386
- """Retire (deactivate) an app deployment.
386
+ """Disable an app deployment.
387
387
 
388
388
  Hits ``DELETE /app/<slug>``, which drives the deployment to ``paused``
389
389
  (and the app to ``disabled``) so the control-plane materializer stops
390
390
  recreating the app's runtime StatefulSet. Reversible with
391
391
  :meth:`activate_app`. The org is derived from the API key. Returns
392
- ``{"app": {...}}`` with the retired deployment.
392
+ ``{"app": {...}}`` with the disabled deployment.
393
393
  """
394
394
  body: dict[str, Any] | None = {"environment": environment} if environment else None
395
395
  return self._json("DELETE", f"/app/{urllib.parse.quote(app, safe='')}", body)
396
396
 
397
397
  def activate_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
398
- """Reverse a retire: reactivate a previously-deleted app deployment.
398
+ """Enable a previously disabled app deployment.
399
399
 
400
400
  Hits ``POST /app/<slug>/activate`` (status back to ``active``), so the
401
401
  materializer resumes recreating the app's StatefulSet. The org is
@@ -429,6 +429,73 @@ class ApiClient:
429
429
  """
430
430
  return self._json("POST", "/org-config/auth-connections", payload)
431
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
+
432
499
  def _json(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
433
500
  data = (
434
501
  None