urun-cli 0.5.0__tar.gz → 0.5.2__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.
@@ -16,3 +16,4 @@ build/
16
16
  # Local development environment
17
17
  .devcontainer.env
18
18
  .cache/
19
+ .claude/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -93,6 +93,40 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
93
93
  `deploy` remains available as the lower-level command while the full
94
94
  deploy/run/monitor workflow is being built.
95
95
 
96
+ ## Serve a model from the catalog
97
+
98
+ `urun serve` deploys a model straight from the urun model catalog — no app code
99
+ required. It is `urun deploy` with a serve app templated from the resolved
100
+ catalog row (engine, HF repo, GPU, engine args).
101
+
102
+ List the enumerable model matrix (models, variants, the GPU each fits, engine
103
+ shape, and whether the placement is dev-testable or prod-only):
104
+
105
+ ```bash
106
+ urun serve catalog
107
+ urun serve catalog --json
108
+ ```
109
+
110
+ Serve a model. With no variant the model's default (first) variant is used; with
111
+ no `--gpu` the variant's first placement is used:
112
+
113
+ ```bash
114
+ urun serve qwen-coder # default variant + first placement
115
+ urun serve glm-5.2:UD-IQ2_M # explicit <id>:<variant>
116
+ urun serve qwen-coder-480b:fp8 --gpu b200:8
117
+ ```
118
+
119
+ The catalog is anon-readable reference data published by a urun-infra migration.
120
+ Reads go over PostgREST on the same control-plane host as the API URL; supply the
121
+ public Supabase anon key via `--anon-key` or `URUN_CATALOG_ANON_KEY`
122
+ (`URUN_SUPABASE_ANON_KEY` is also honored). Override the PostgREST base with
123
+ `--catalog-url` / `URUN_CATALOG_URL` if needed.
124
+
125
+ By default `urun serve <id>` renders a templated serve app and deploys it. If you
126
+ maintain your own `_serve` app, point `--entrypoint` at it; the CLI deploys that
127
+ app with `URUN_SERVE_CONFIG` and `URUN_SERVE_CATALOG_ROW` set to the resolved
128
+ row instead of rendering one.
129
+
96
130
  ## Inspect apps
97
131
 
98
132
  List every app deployed in your org and its current status:
@@ -71,6 +71,40 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
71
71
  `deploy` remains available as the lower-level command while the full
72
72
  deploy/run/monitor workflow is being built.
73
73
 
74
+ ## Serve a model from the catalog
75
+
76
+ `urun serve` deploys a model straight from the urun model catalog — no app code
77
+ required. It is `urun deploy` with a serve app templated from the resolved
78
+ catalog row (engine, HF repo, GPU, engine args).
79
+
80
+ List the enumerable model matrix (models, variants, the GPU each fits, engine
81
+ shape, and whether the placement is dev-testable or prod-only):
82
+
83
+ ```bash
84
+ urun serve catalog
85
+ urun serve catalog --json
86
+ ```
87
+
88
+ Serve a model. With no variant the model's default (first) variant is used; with
89
+ no `--gpu` the variant's first placement is used:
90
+
91
+ ```bash
92
+ urun serve qwen-coder # default variant + first placement
93
+ urun serve glm-5.2:UD-IQ2_M # explicit <id>:<variant>
94
+ urun serve qwen-coder-480b:fp8 --gpu b200:8
95
+ ```
96
+
97
+ The catalog is anon-readable reference data published by a urun-infra migration.
98
+ Reads go over PostgREST on the same control-plane host as the API URL; supply the
99
+ public Supabase anon key via `--anon-key` or `URUN_CATALOG_ANON_KEY`
100
+ (`URUN_SUPABASE_ANON_KEY` is also honored). Override the PostgREST base with
101
+ `--catalog-url` / `URUN_CATALOG_URL` if needed.
102
+
103
+ By default `urun serve <id>` renders a templated serve app and deploys it. If you
104
+ maintain your own `_serve` app, point `--entrypoint` at it; the CLI deploys that
105
+ app with `URUN_SERVE_CONFIG` and `URUN_SERVE_CATALOG_ROW` set to the resolved
106
+ row instead of rendering one.
107
+
74
108
  ## Inspect apps
75
109
 
76
110
  List every app deployed in your org and its current status:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -43,6 +43,21 @@ class ApiClient:
43
43
  def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
44
44
  return self._json("GET", f"/deployment-status/{manifest_hash}", None)
45
45
 
46
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
47
+ return self._json("GET", self._path(path, params), None)
48
+
49
+ def _post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
50
+ return self._json("POST", path, body or {})
51
+
52
+ def _delete(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
53
+ return self._json("DELETE", path, body)
54
+
55
+ def _path(self, path: str, params: dict[str, Any] | None = None) -> str:
56
+ clean = {k: v for k, v in (params or {}).items() if v is not None and v is not False}
57
+ if not clean:
58
+ return path
59
+ return path + "?" + urllib.parse.urlencode(clean, doseq=True)
60
+
46
61
  def list_apps(self) -> dict[str, Any]:
47
62
  """List all deployed apps for the authenticated org.
48
63
 
@@ -58,6 +73,15 @@ class ApiClient:
58
73
  """
59
74
  return self._json("GET", "/apps", None)
60
75
 
76
+ def get_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
77
+ return self.app_status(app, environment=environment)
78
+
79
+ def diagnose_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
80
+ return self._get(
81
+ f"/cli/apps/{urllib.parse.quote(app, safe='')}/diagnose",
82
+ {"environment": environment},
83
+ )
84
+
61
85
  def list_sessions(self, limit: int | None = None, state: str | None = None) -> dict[str, Any]:
62
86
  """List sessions for the authenticated org.
63
87
 
@@ -84,6 +108,15 @@ class ApiClient:
84
108
  path += "?" + urllib.parse.urlencode(params)
85
109
  return self._json("GET", path, None)
86
110
 
111
+ def get_session(self, session_id: str) -> dict[str, Any]:
112
+ return self._get(f"/cli/sessions/{urllib.parse.quote(session_id, safe='')}")
113
+
114
+ def cancel_session(self, session_id: str, reason: str | None = None) -> dict[str, Any]:
115
+ return self._post(
116
+ f"/cli/sessions/{urllib.parse.quote(session_id, safe='')}/cancel",
117
+ {"reason": reason} if reason else {},
118
+ )
119
+
87
120
  def list_compute(self, limit: int | None = None) -> dict[str, Any]:
88
121
  """List currently-provisioned compute slices for the authenticated org.
89
122
 
@@ -110,6 +143,210 @@ class ApiClient:
110
143
  path += "?" + urllib.parse.urlencode({"limit": str(limit)})
111
144
  return self._json("GET", path, None)
112
145
 
146
+ def list_capacity(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
147
+ return self._get("/cli/capacity", params)
148
+
149
+ def get_capacity(self, app: str, environment: str | None = None) -> dict[str, Any]:
150
+ return self._get(
151
+ f"/cli/capacity/{urllib.parse.quote(app, safe='')}", {"environment": environment}
152
+ )
153
+
154
+ def list_shapes(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
155
+ return self._get("/cli/shapes", params)
156
+
157
+ def get_shape(self, shape: str) -> dict[str, Any]:
158
+ return self._get(f"/cli/shapes/{urllib.parse.quote(shape, safe='')}")
159
+
160
+ def list_capacity_leases(self, status: str | None = None) -> dict[str, Any]:
161
+ return self._get("/cli/capacity-leases", {"status": status})
162
+
163
+ def get_capacity_lease(self, lease_id: str) -> dict[str, Any]:
164
+ return self._get(f"/cli/capacity-leases/{urllib.parse.quote(lease_id, safe='')}")
165
+
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
+ def list_builds(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
173
+ return self._get("/cli/builds", params)
174
+
175
+ def get_build(self, build_id: str) -> dict[str, Any]:
176
+ return self._get(f"/cli/builds/{urllib.parse.quote(build_id, safe='')}")
177
+
178
+ def logs_build(self, build_id: str, tail: int | None = None) -> dict[str, Any]:
179
+ return self._get(
180
+ f"/cli/builds/{urllib.parse.quote(build_id, safe='')}/logs", {"tail": tail}
181
+ )
182
+
183
+ def list_releases(self, app: str, environment: str | None = None) -> dict[str, Any]:
184
+ return self._get("/cli/releases", {"app": app, "environment": environment})
185
+
186
+ def get_release(self, release_id: str) -> dict[str, Any]:
187
+ return self._get(f"/cli/releases/{urllib.parse.quote(release_id, safe='')}")
188
+
189
+ def query_logs(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
190
+ return self._get("/cli/logs", params)
191
+
192
+ def list_events(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
193
+ return self._get("/cli/events", params)
194
+
195
+ def list_environments(self) -> dict[str, Any]:
196
+ return self._get("/cli/environments")
197
+
198
+ def get_environment(self, environment: str) -> dict[str, Any]:
199
+ return self._get(f"/cli/environments/{urllib.parse.quote(environment, safe='')}")
200
+
201
+ def create_environment(
202
+ self, environment: str, display_name: str | None = None
203
+ ) -> dict[str, Any]:
204
+ body: dict[str, Any] = {"environment": environment}
205
+ if display_name:
206
+ body["display_name"] = display_name
207
+ return self._post("/cli/environments", body)
208
+
209
+ def get_usage(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
210
+ return self._get("/cli/usage", params)
211
+
212
+ def get_credits(self) -> dict[str, Any]:
213
+ return self._get("/cli/credits")
214
+
215
+ def list_credit_ledger(self, limit: int | None = None) -> dict[str, Any]:
216
+ return self._get("/cli/credit-ledger", {"limit": limit})
217
+
218
+ def get_quota(self, shape: str | None = None) -> dict[str, Any]:
219
+ return self._get("/cli/quota", {"shape": shape})
220
+
221
+ def get_billing(self) -> dict[str, Any]:
222
+ return self._get("/cli/billing")
223
+
224
+ def list_invoices(self) -> dict[str, Any]:
225
+ return self._get("/cli/invoices")
226
+
227
+ def list_secrets(self, app: str | None = None) -> dict[str, Any]:
228
+ return self._get("/cli/secrets", {"app": app})
229
+
230
+ def set_secret(self, name: str, payload: dict[str, Any]) -> dict[str, Any]:
231
+ return self._post(f"/cli/secrets/{urllib.parse.quote(name, safe='')}", payload)
232
+
233
+ def delete_secret(self, name: str, app: str | None = None) -> dict[str, Any]:
234
+ return self._delete(
235
+ f"/cli/secrets/{urllib.parse.quote(name, safe='')}", {"app": app} if app else None
236
+ )
237
+
238
+ def list_api_keys(self) -> dict[str, Any]:
239
+ return self._get("/cli/api-keys")
240
+
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
+ def list_service_accounts(self) -> dict[str, Any]:
248
+ return self._get("/cli/service-accounts")
249
+
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
+ def list_members(self) -> dict[str, Any]:
257
+ return self._get("/cli/members")
258
+
259
+ def get_member(self, actor_id: str) -> dict[str, Any]:
260
+ return self._get(f"/cli/members/{urllib.parse.quote(actor_id, safe='')}")
261
+
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
+ def list_activity(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
271
+ return self._get("/cli/activity", params)
272
+
273
+ def list_failure_groups(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
274
+ return self._get("/cli/failure-groups", params)
275
+
276
+ def list_issues(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
277
+ return self._get("/cli/issues", params)
278
+
279
+ def get_issue(self, issue: str) -> dict[str, Any]:
280
+ return self._get(f"/cli/issues/{urllib.parse.quote(issue, safe='')}")
281
+
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
+ def list_alert_rules(self) -> dict[str, Any]:
289
+ return self._get("/cli/alert-rules")
290
+
291
+ def get_alert_rule(self, rule_id: str) -> dict[str, Any]:
292
+ return self._get(f"/cli/alert-rules/{urllib.parse.quote(rule_id, safe='')}")
293
+
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
+ def list_alert_channels(self) -> dict[str, Any]:
304
+ return self._get("/cli/alert-channels")
305
+
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
+ def list_git_installations(self) -> dict[str, Any]:
319
+ return self._get("/cli/git-installations")
320
+
321
+ def list_git_repos(self) -> dict[str, Any]:
322
+ return self._get("/cli/git-repos")
323
+
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
+ def list_marketplace_apps(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
337
+ return self._get("/cli/marketplace-apps", params)
338
+
339
+ def get_marketplace_app(self, slug: str) -> dict[str, Any]:
340
+ return self._get(f"/cli/marketplace-apps/{urllib.parse.quote(slug, safe='')}")
341
+
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
+
113
350
  def app_status(self, app: str, environment: str | None = None) -> dict[str, Any]:
114
351
  """Fetch the detailed lifecycle status for a single app.
115
352