owui-cli 0.1.0__py3-none-any.whl

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.
owui_cli/cli.py ADDED
@@ -0,0 +1,978 @@
1
+ """Open WebUI admin CLI. Designed for agent consumption — terse output by default.
2
+
3
+ Usage: OWUI_URL=... OWUI_TOKEN=... owui-cli [--json] <command> [args...]
4
+
5
+ Commands:
6
+ help Show all resources and commands
7
+ schema [resource] [method] Show API schema from bundled reference
8
+ <resource> <command> [args] Run an admin operation
9
+
10
+ Use --json for machine-readable output on any command.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ from importlib.resources import files
18
+
19
+ import httpx
20
+
21
+ import owui_cli
22
+
23
+ # ── globals ───────────────────────────────────────────────────────────
24
+
25
+ TIMEOUT = 60.0
26
+ JSON_OUTPUT = False
27
+ SCHEMA_PATH = files("owui_cli.data").joinpath("api-schema.json")
28
+
29
+ def _env():
30
+ url = os.environ.get("OWUI_URL", "")
31
+ token = os.environ.get("OWUI_TOKEN", "")
32
+ if not url or not token:
33
+ die("OWUI_URL and OWUI_TOKEN env vars required")
34
+ return url.rstrip("/"), token
35
+
36
+
37
+ def _headers(token: str) -> dict:
38
+ return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
39
+
40
+
41
+ def _api(url: str, path: str) -> str:
42
+ return f"{url}{path}"
43
+
44
+
45
+ def _get(c: httpx.Client, url: str, path: str, token: str) -> httpx.Response:
46
+ r = c.get(_api(url, path), headers=_headers(token))
47
+ r.raise_for_status()
48
+ return r
49
+
50
+
51
+ def _post(c: httpx.Client, url: str, path: str, token: str, body=None) -> httpx.Response:
52
+ r = c.post(_api(url, path), headers=_headers(token), json=body or {})
53
+ r.raise_for_status()
54
+ return r
55
+
56
+
57
+ def _delete(c: httpx.Client, url: str, path: str, token: str) -> httpx.Response:
58
+ r = c.delete(_api(url, path), headers=_headers(token))
59
+ r.raise_for_status()
60
+ return r
61
+
62
+
63
+ def die(msg: str, code: int = 1):
64
+ print(msg, file=sys.stderr)
65
+ sys.exit(code)
66
+
67
+ def out(data, fmt_fn=None):
68
+ """Print data. JSON mode emits raw JSON; otherwise use fmt_fn or default."""
69
+ if JSON_OUTPUT:
70
+ print(json.dumps(data, default=str))
71
+ elif fmt_fn:
72
+ fmt_fn(data)
73
+ elif isinstance(data, str):
74
+ print(data)
75
+ elif isinstance(data, list):
76
+ for item in data:
77
+ print(json.dumps(item, default=str))
78
+ else:
79
+ print(json.dumps(data, indent=2, default=str))
80
+
81
+
82
+ def out_table(rows: list[dict], cols: list[tuple[str, str, int]]):
83
+ """Terse aligned table. cols: [(header, key, min_width), ...]"""
84
+ if JSON_OUTPUT:
85
+ print(json.dumps(rows, default=str))
86
+ return
87
+ if not rows:
88
+ print("(none)")
89
+ return
90
+ widths = [max(mw, len(h), max((len(str(r.get(k, ""))) for r in rows), default=0))
91
+ for h, k, mw in cols]
92
+ fmt = " ".join(f"{{:<{w}}}" for w in widths)
93
+ print(fmt.format(*[h for h, _, _ in cols]))
94
+ print(fmt.format(*["-" * w for w in widths]))
95
+ for row in rows:
96
+ print(fmt.format(*[str(row.get(k, "")) for _, k, _ in cols]))
97
+
98
+
99
+ def out_kv(pairs: list[tuple[str, str]]):
100
+ """Key-value display."""
101
+ if JSON_OUTPUT:
102
+ print(json.dumps(dict(pairs), default=str))
103
+ return
104
+ w = max(len(k) for k, _ in pairs) if pairs else 0
105
+ for k, v in pairs:
106
+ print(f"{k:<{w}} {v}")
107
+
108
+ def _parse_docstring_meta(source: str) -> dict[str, str]:
109
+ """Extract key: value pairs from module-level triple-quoted docstring."""
110
+ m = re.match(r'^"""(.*?)"""', source, re.DOTALL) or re.match(r"^'''(.*?)'''", source, re.DOTALL)
111
+ if not m:
112
+ return {}
113
+ meta = {}
114
+ for line in m.group(1).splitlines():
115
+ if ":" in line:
116
+ key, _, value = line.partition(":")
117
+ meta[key.strip().lower()] = value.strip()
118
+ return meta
119
+
120
+
121
+ def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
122
+ """Parse optional YAML-style front-matter. Returns (meta, content)."""
123
+ fm = re.match(r"^---\n(.*?)\n---\n?", raw, re.DOTALL)
124
+ if not fm:
125
+ return {}, raw
126
+ meta = {}
127
+ for line in fm.group(1).splitlines():
128
+ if ":" in line:
129
+ k, _, v = line.partition(":")
130
+ meta[k.strip().lower()] = v.strip()
131
+ return meta, raw[fm.end():]
132
+
133
+
134
+ def _slugify(title: str) -> str:
135
+ return re.sub(r"[^a-z0-9]+", title.lower(), "").strip("_") if not title else re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
136
+
137
+ class Resource:
138
+ """Generic CRUD resource. Subclass or configure to add resource-specific commands."""
139
+
140
+ def __init__(self, name: str, prefix: str, id_pattern: str = "/id/{id}",
141
+ list_cols: list[tuple[str, str, int]] = None,
142
+ show_fields: list[tuple[str, str]] = None,
143
+ content_key: str = "content",
144
+ meta_parser: str = "docstring", # "docstring" or "frontmatter"
145
+ deploy_ext: str = ".py",
146
+ workspace_path: str = None):
147
+ self.name = name
148
+ self.prefix = prefix
149
+ self.id_pattern = id_pattern
150
+ self.list_cols = list_cols or [("ID", "id", 10), ("NAME", "name", 20)]
151
+ self.show_fields = show_fields or []
152
+ self.content_key = content_key
153
+ self.meta_parser = meta_parser
154
+ self.deploy_ext = deploy_ext
155
+ self.workspace_path = workspace_path or f"/workspace/{name}"
156
+ self._commands: dict[str, tuple] = {}
157
+ self._register_defaults()
158
+
159
+ def _register_defaults(self):
160
+ """Register standard CRUD commands."""
161
+ self._commands["list"] = (self.cmd_list, "", (0, 0))
162
+ self._commands["show"] = (self.cmd_show, "<id>", (1, 1))
163
+ self._commands["deploy"] = (self.cmd_deploy, f"<source{self.deploy_ext}> [id]", (1, 2))
164
+ self._commands["pull"] = (self.cmd_pull, "<id>", (1, 1))
165
+ self._commands["delete"] = (self.cmd_delete, "<id>", (1, 1))
166
+
167
+ def add_command(self, name, fn, arg_spec, arg_range):
168
+ self._commands[name] = (fn, arg_spec, arg_range)
169
+
170
+ def item_path(self, item_id: str) -> str:
171
+ return f"{self.prefix}{self.id_pattern.replace('{id}', item_id)}"
172
+
173
+ def cmd_list(self, url: str, token: str):
174
+ with httpx.Client(timeout=TIMEOUT) as c:
175
+ data = _get(c, url, f"{self.prefix}/", token).json()
176
+ items = data if isinstance(data, list) else data.get("items", data.get("data", []))
177
+ if not items:
178
+ out("(none)")
179
+ return
180
+ rows = []
181
+ for item in sorted(items, key=lambda x: x.get("id", "")):
182
+ row = {}
183
+ for _, key, _ in self.list_cols:
184
+ if key == "ver":
185
+ row[key] = (item.get("meta") or {}).get("manifest", {}).get("version", "?")
186
+ elif "." in key:
187
+ parts = key.split(".")
188
+ v = item
189
+ for p in parts:
190
+ v = (v or {}).get(p, "")
191
+ row[key] = str(v)[:60] if v else ""
192
+ else:
193
+ row[key] = str(item.get(key, ""))[:60]
194
+ rows.append(row)
195
+ out_table(rows, self.list_cols)
196
+
197
+ def cmd_show(self, url: str, token: str, item_id: str):
198
+ with httpx.Client(timeout=TIMEOUT) as c:
199
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
200
+ if r.status_code == 404:
201
+ die(f"{self.name} '{item_id}' not found")
202
+ r.raise_for_status()
203
+ item = r.json()
204
+ if JSON_OUTPUT:
205
+ # Strip content for --json show (it's huge), keep everything else
206
+ slim = {k: v for k, v in item.items() if k != self.content_key}
207
+ content = item.get(self.content_key, "")
208
+ slim["content_length"] = len(content)
209
+ out(slim)
210
+ return
211
+ pairs = [("id", item.get("id", "?")), ("name", item.get("name", "?"))]
212
+ for label, key in self.show_fields:
213
+ v = item
214
+ for part in key.split("."):
215
+ v = (v or {}).get(part, "")
216
+ pairs.append((label, str(v) if v else "(none)"))
217
+ content = item.get(self.content_key, "")
218
+ pairs.append(("content", f"{len(content)} chars"))
219
+ grants = item.get("access_grants") or []
220
+ if grants:
221
+ pairs.append(("grants", str(len(grants))))
222
+ out_kv(pairs)
223
+
224
+ def cmd_deploy(self, url: str, token: str, source_path: str, item_id: str = ""):
225
+ with open(source_path) as f:
226
+ content = f.read()
227
+
228
+ if self.meta_parser == "frontmatter":
229
+ meta, content_body = _parse_frontmatter(content)
230
+ else:
231
+ meta = _parse_docstring_meta(content)
232
+ content_body = content # deploy full file for tools/functions
233
+
234
+ title = meta.get("title", meta.get("name",
235
+ source_path.rsplit("/", 1)[-1].removesuffix(self.deploy_ext)))
236
+ version = meta.get("version", "")
237
+ if not item_id:
238
+ item_id = meta.get("id", _slugify(title))
239
+
240
+ with httpx.Client(timeout=TIMEOUT) as c:
241
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
242
+
243
+ if r.status_code == 200:
244
+ existing = r.json()
245
+ payload = self._build_update_payload(existing, item_id, content_body if self.meta_parser == "frontmatter" else content, meta)
246
+ r = _post(c, url, f"{self.item_path(item_id)}/update", token, payload)
247
+ old_ver = (existing.get("meta") or {}).get("manifest", {}).get("version", "")
248
+ label = f"updated {item_id}"
249
+ if old_ver and version:
250
+ label += f" {old_ver} -> {version}"
251
+ out(label)
252
+ else:
253
+ payload = self._build_create_payload(item_id, title, content_body if self.meta_parser == "frontmatter" else content, meta)
254
+ r = _post(c, url, f"{self.prefix}/create", token, payload)
255
+ out(f"created {item_id}" + (f" v{version}" if version else ""))
256
+
257
+ def _build_update_payload(self, existing, item_id, content, meta):
258
+ return {
259
+ "id": item_id,
260
+ "name": existing.get("name", meta.get("title", item_id)),
261
+ "meta": existing.get("meta", {"description": meta.get("description", ""), "manifest": {}}),
262
+ self.content_key: content,
263
+ }
264
+
265
+ def _build_create_payload(self, item_id, title, content, meta):
266
+ return {
267
+ "id": item_id,
268
+ "name": title,
269
+ "meta": {"description": meta.get("description", ""), "manifest": {}},
270
+ self.content_key: content,
271
+ }
272
+
273
+ def cmd_pull(self, url: str, token: str, item_id: str):
274
+ with httpx.Client(timeout=TIMEOUT) as c:
275
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
276
+ if r.status_code == 404:
277
+ die(f"{self.name} '{item_id}' not found")
278
+ r.raise_for_status()
279
+ content = r.json().get(self.content_key, "")
280
+ sys.stdout.write(content)
281
+ if content and not content.endswith("\n"):
282
+ sys.stdout.write("\n")
283
+
284
+ def cmd_delete(self, url: str, token: str, item_id: str):
285
+ with httpx.Client(timeout=TIMEOUT) as c:
286
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
287
+ if r.status_code == 404:
288
+ die(f"{self.name} '{item_id}' not found")
289
+ r.raise_for_status()
290
+ name = r.json().get("name", item_id)
291
+ _delete(c, url, f"{self.item_path(item_id)}/delete", token)
292
+ out(f"deleted {name} ({item_id})")
293
+
294
+ def get_commands(self) -> dict[str, tuple]:
295
+ return self._commands
296
+
297
+ # ── resource instances ────────────────────────────────────────────────
298
+
299
+ tools_res = Resource("tools", "/api/v1/tools",
300
+ list_cols=[("ID", "id", 10), ("NAME", "name", 20), ("VER", "ver", 5)],
301
+ show_fields=[("version", "meta.manifest.version"), ("author", "meta.manifest.author"),
302
+ ("description", "meta.description")],
303
+ workspace_path="/workspace/tools")
304
+
305
+ functions_res = Resource("functions", "/api/v1/functions",
306
+ list_cols=[("ID", "id", 10), ("NAME", "name", 20), ("TYPE", "type", 6), ("VER", "ver", 5)],
307
+ show_fields=[("type", "type"), ("version", "meta.manifest.version"),
308
+ ("active", "is_active"), ("global", "is_global")],
309
+ workspace_path="/workspace/functions")
310
+
311
+
312
+ class SkillsResource(Resource):
313
+ """Skills use frontmatter and have grant/revoke commands."""
314
+
315
+ def __init__(self):
316
+ super().__init__("skills", "/api/v1/skills",
317
+ list_cols=[("ID", "id", 20), ("NAME", "name", 20), ("ACTIVE", "is_active", 6)],
318
+ show_fields=[("description", "description"), ("active", "is_active")],
319
+ meta_parser="frontmatter", deploy_ext=".md",
320
+ workspace_path="/workspace/skills")
321
+ self.add_command("toggle", self.cmd_toggle, "<id>", (1, 1))
322
+ self.add_command("grant", self.cmd_grant, "<id> <user|group> <principal_id> <read|write>", (4, 4))
323
+ self.add_command("revoke", self.cmd_revoke, "<id>", (1, 1))
324
+
325
+ def _build_update_payload(self, existing, item_id, content, meta):
326
+ return {
327
+ "id": item_id,
328
+ "name": existing.get("name", meta.get("name", item_id)),
329
+ "description": existing.get("description", meta.get("description", "")),
330
+ "content": content,
331
+ "meta": existing.get("meta") or {"tags": []},
332
+ "is_active": existing.get("is_active", True),
333
+ }
334
+
335
+ def _build_create_payload(self, item_id, title, content, meta):
336
+ return {
337
+ "id": item_id,
338
+ "name": title,
339
+ "description": meta.get("description", ""),
340
+ "content": content,
341
+ "meta": {"tags": []},
342
+ "is_active": True,
343
+ "access_grants": [],
344
+ }
345
+
346
+ def cmd_pull(self, url: str, token: str, item_id: str):
347
+ with httpx.Client(timeout=TIMEOUT) as c:
348
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
349
+ if r.status_code == 404:
350
+ die(f"skill '{item_id}' not found")
351
+ r.raise_for_status()
352
+ s = r.json()
353
+ sys.stdout.write(f"---\nid: {item_id}\nname: {s.get('name','')}\ndescription: {s.get('description','')}\n---\n")
354
+ content = s.get("content", "")
355
+ sys.stdout.write(content)
356
+ if content and not content.endswith("\n"):
357
+ sys.stdout.write("\n")
358
+
359
+ def cmd_toggle(self, url: str, token: str, skill_id: str):
360
+ with httpx.Client(timeout=TIMEOUT) as c:
361
+ r = _post(c, url, f"{self.item_path(skill_id)}/toggle", token)
362
+ s = r.json()
363
+ out(f"{skill_id} {'active' if s.get('is_active') else 'inactive'}")
364
+
365
+ def cmd_grant(self, url: str, token: str, skill_id: str, ptype: str, pid: str, perm: str):
366
+ if ptype not in ("user", "group"):
367
+ die("principal_type must be user or group")
368
+ if perm not in ("read", "write"):
369
+ die("permission must be read or write")
370
+ with httpx.Client(timeout=TIMEOUT) as c:
371
+ r = c.get(_api(url, self.item_path(skill_id)), headers=_headers(token))
372
+ if r.status_code == 404:
373
+ die(f"skill '{skill_id}' not found")
374
+ r.raise_for_status()
375
+ grants = r.json().get("access_grants") or []
376
+ grants.append({"resource_type": "skill", "resource_id": skill_id,
377
+ "principal_type": ptype, "principal_id": pid, "permission": perm})
378
+ _post(c, url, f"{self.item_path(skill_id)}/access/update", token, {"access_grants": grants})
379
+ out(f"granted {perm} on {skill_id} to {ptype}:{pid}")
380
+
381
+ def cmd_revoke(self, url: str, token: str, skill_id: str):
382
+ with httpx.Client(timeout=TIMEOUT) as c:
383
+ r = c.get(_api(url, self.item_path(skill_id)), headers=_headers(token))
384
+ if r.status_code == 404:
385
+ die(f"skill '{skill_id}' not found")
386
+ r.raise_for_status()
387
+ n = len(r.json().get("access_grants") or [])
388
+ _post(c, url, f"{self.item_path(skill_id)}/access/update", token, {"access_grants": []})
389
+ out(f"revoked {n} grant(s) from {skill_id}")
390
+
391
+
392
+ skills_res = SkillsResource()
393
+
394
+ # ── models (special: uses /model?id= not /{id}, mutations via POST) ───
395
+
396
+ def models_list(url, token):
397
+ with httpx.Client(timeout=TIMEOUT) as c:
398
+ data = _get(c, url, "/api/v1/models", token).json().get("data", [])
399
+ rows = [{"id": m.get("id",""), "name": m.get("name",""),
400
+ "base": (m.get("info") or {}).get("base_model_id", m.get("owned_by",""))}
401
+ for m in sorted(data, key=lambda m: m.get("id",""))]
402
+ out_table(rows, [("ID","id",10), ("NAME","name",20), ("BASE","base",15)])
403
+
404
+ def models_show(url, token, model_id):
405
+ with httpx.Client(timeout=TIMEOUT) as c:
406
+ r = _get(c, url, f"/api/v1/models/model?id={model_id}", token)
407
+ m = r.json()
408
+ if JSON_OUTPUT:
409
+ out(m)
410
+ return
411
+ info = m.get("info") or {}
412
+ meta = info.get("meta") or {}
413
+ params = info.get("params") or {}
414
+ pairs = [("id", m.get("id","")), ("name", m.get("name","")),
415
+ ("base", info.get("base_model_id","(none)")),
416
+ ("active", str(info.get("is_active","?"))),
417
+ ("tools", ", ".join(meta.get("toolIds") or []) or "(none)"),
418
+ ("knowledge", ", ".join(k.get("name","?") for k in (meta.get("knowledge") or [])) or "(none)"),
419
+ ("system", f"{len(params.get('system',''))} chars"),
420
+ ("grants", str(len(info.get("access_grants") or [])))]
421
+ out_kv(pairs)
422
+
423
+ def models_create(url, token, json_path):
424
+ with open(json_path) as f:
425
+ payload = json.load(f)
426
+ with httpx.Client(timeout=TIMEOUT) as c:
427
+ r = _post(c, url, "/api/v1/models/create", token, payload)
428
+ m = r.json()
429
+ out(f"created {m.get('id')}")
430
+
431
+ def models_update(url, token, json_path):
432
+ with open(json_path) as f:
433
+ payload = json.load(f)
434
+ with httpx.Client(timeout=TIMEOUT) as c:
435
+ r = _post(c, url, "/api/v1/models/model/update", token, payload)
436
+ out(f"updated {r.json().get('id')}")
437
+
438
+ def models_delete(url, token, model_id):
439
+ with httpx.Client(timeout=TIMEOUT) as c:
440
+ _post(c, url, "/api/v1/models/model/delete", token, {"id": model_id})
441
+ out(f"deleted {model_id}")
442
+
443
+
444
+ # ── knowledge (special: files subresource, file/remove is destructive) ─
445
+
446
+ def knowledge_list(url, token):
447
+ with httpx.Client(timeout=TIMEOUT) as c:
448
+ data = _get(c, url, "/api/v1/knowledge/", token).json()
449
+ kbs = data if isinstance(data, list) else data.get("items", [])
450
+ rows = [{"id": k.get("id",""), "name": k.get("name",""),
451
+ "desc": (k.get("description") or "")[:50]}
452
+ for k in sorted(kbs, key=lambda k: k.get("name",""))]
453
+ out_table(rows, [("ID","id",36), ("NAME","name",20), ("DESC","desc",20)])
454
+
455
+ def knowledge_show(url, token, kb_id):
456
+ with httpx.Client(timeout=TIMEOUT) as c:
457
+ r = c.get(_api(url, f"/api/v1/knowledge/{kb_id}"), headers=_headers(token))
458
+ if r.status_code == 404: die(f"kb '{kb_id}' not found")
459
+ r.raise_for_status()
460
+ kb = r.json()
461
+ if JSON_OUTPUT: out(kb); return
462
+ out_kv([("id", kb.get("id","")), ("name", kb.get("name","")),
463
+ ("description", kb.get("description") or "(none)"),
464
+ ("grants", str(len(kb.get("access_grants") or [])))])
465
+
466
+ def knowledge_files(url, token, kb_id):
467
+ with httpx.Client(timeout=TIMEOUT) as c:
468
+ r = c.get(_api(url, f"/api/v1/knowledge/{kb_id}/files"), headers=_headers(token))
469
+ if r.status_code == 404: die(f"kb '{kb_id}' not found")
470
+ r.raise_for_status()
471
+ items = r.json().get("items", [])
472
+ rows = [{"id": f.get("id",""), "name": (f.get("meta") or {}).get("name") or f.get("filename","")}
473
+ for f in items]
474
+ out_table(rows, [("FILE_ID","id",36), ("NAME","name",30)])
475
+
476
+ def knowledge_create(url, token, name, description=""):
477
+ with httpx.Client(timeout=TIMEOUT) as c:
478
+ r = _post(c, url, "/api/v1/knowledge/create", token, {"name": name, "description": description})
479
+ out(f"created {r.json().get('id')}")
480
+
481
+ def knowledge_delete(url, token, kb_id):
482
+ with httpx.Client(timeout=TIMEOUT) as c:
483
+ _delete(c, url, f"/api/v1/knowledge/{kb_id}/delete", token)
484
+ out(f"deleted {kb_id}")
485
+
486
+ def knowledge_add_file(url, token, kb_id, file_id):
487
+ with httpx.Client(timeout=TIMEOUT) as c:
488
+ _post(c, url, f"/api/v1/knowledge/{kb_id}/file/add", token, {"file_id": file_id})
489
+ out(f"added {file_id} to {kb_id}")
490
+
491
+ def knowledge_remove_file(url, token, kb_id, file_id):
492
+ with httpx.Client(timeout=TIMEOUT) as c:
493
+ _post(c, url, f"/api/v1/knowledge/{kb_id}/file/remove", token, {"file_id": file_id})
494
+ out(f"removed {file_id} from {kb_id} (file destroyed)")
495
+
496
+
497
+ # ── files ─────────────────────────────────────────────────────────────
498
+
499
+ def files_list(url, token):
500
+ with httpx.Client(timeout=TIMEOUT) as c:
501
+ data = _get(c, url, "/api/v1/files/", token).json()
502
+ rows = [{"id": f.get("id",""),
503
+ "name": (f.get("meta") or {}).get("name") or f.get("filename",""),
504
+ "size": str((f.get("meta") or {}).get("size","?"))}
505
+ for f in data]
506
+ out_table(rows, [("FILE_ID","id",36), ("NAME","name",30), ("SIZE","size",8)])
507
+
508
+ def files_show(url, token, file_id):
509
+ with httpx.Client(timeout=TIMEOUT) as c:
510
+ r = c.get(_api(url, f"/api/v1/files/{file_id}"), headers=_headers(token))
511
+ if r.status_code == 404: die(f"file '{file_id}' not found")
512
+ r.raise_for_status()
513
+ f = r.json()
514
+ if JSON_OUTPUT: out(f); return
515
+ meta = f.get("meta") or {}
516
+ out_kv([("id", f.get("id","")), ("name", meta.get("name") or f.get("filename","")),
517
+ ("size", f"{meta.get('size','?')} bytes"), ("type", meta.get("content_type","?"))])
518
+
519
+ def files_upload(url, token, path, mime_type=""):
520
+ import mimetypes
521
+ if not mime_type:
522
+ mime_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
523
+ filename = path.rsplit("/", 1)[-1]
524
+ with open(path, "rb") as f:
525
+ data = f.read()
526
+ with httpx.Client(timeout=TIMEOUT) as c:
527
+ r = c.post(_api(url, "/api/v1/files/"), headers={"Authorization": f"Bearer {token}"},
528
+ files={"file": (filename, data, mime_type)})
529
+ r.raise_for_status()
530
+ out(f"uploaded {filename} -> {r.json().get('id')}")
531
+
532
+ def files_delete(url, token, file_id):
533
+ with httpx.Client(timeout=TIMEOUT) as c:
534
+ _delete(c, url, f"/api/v1/files/{file_id}", token)
535
+ out(f"deleted {file_id}")
536
+
537
+
538
+ # ── groups (special: /id/{id} pattern, members subresource) ──────────
539
+
540
+ def groups_list(url, token):
541
+ with httpx.Client(timeout=TIMEOUT) as c:
542
+ data = _get(c, url, "/api/v1/groups/", token).json()
543
+ rows = [{"id": g.get("id",""), "name": g.get("name",""),
544
+ "members": str(g.get("member_count","?"))}
545
+ for g in sorted(data, key=lambda g: g.get("name",""))]
546
+ out_table(rows, [("ID","id",36), ("NAME","name",20), ("MEMBERS","members",7)])
547
+
548
+ def groups_show(url, token, group_id):
549
+ with httpx.Client(timeout=TIMEOUT) as c:
550
+ r = c.get(_api(url, f"/api/v1/groups/id/{group_id}"), headers=_headers(token))
551
+ if r.status_code == 404: die(f"group '{group_id}' not found")
552
+ r.raise_for_status()
553
+ g = r.json()
554
+ if JSON_OUTPUT: out(g); return
555
+ out_kv([("id", g.get("id","")), ("name", g.get("name","")),
556
+ ("description", g.get("description") or "(none)"),
557
+ ("members", str(g.get("member_count","?")))])
558
+
559
+ def groups_create(url, token, name, description=""):
560
+ with httpx.Client(timeout=TIMEOUT) as c:
561
+ r = _post(c, url, "/api/v1/groups/create", token, {"name": name, "description": description})
562
+ out(f"created {r.json().get('id')}")
563
+
564
+ def groups_delete(url, token, group_id):
565
+ with httpx.Client(timeout=TIMEOUT) as c:
566
+ _delete(c, url, f"/api/v1/groups/id/{group_id}/delete", token)
567
+ out(f"deleted {group_id}")
568
+
569
+ def groups_members(url, token, group_id):
570
+ with httpx.Client(timeout=TIMEOUT) as c:
571
+ users = _post(c, url, f"/api/v1/groups/id/{group_id}/users", token).json()
572
+ rows = [{"id": u.get("id",""), "name": u.get("name",""), "email": u.get("email","")}
573
+ for u in sorted(users, key=lambda u: u.get("name",""))]
574
+ out_table(rows, [("USER_ID","id",36), ("NAME","name",20), ("EMAIL","email",30)])
575
+
576
+ def groups_add_user(url, token, group_id, user_id):
577
+ with httpx.Client(timeout=TIMEOUT) as c:
578
+ _post(c, url, f"/api/v1/groups/id/{group_id}/users/add", token, {"user_ids": [user_id]})
579
+ out(f"added {user_id} to {group_id}")
580
+
581
+ def groups_remove_user(url, token, group_id, user_id):
582
+ with httpx.Client(timeout=TIMEOUT) as c:
583
+ _post(c, url, f"/api/v1/groups/id/{group_id}/users/remove", token, {"user_ids": [user_id]})
584
+ out(f"removed {user_id} from {group_id}")
585
+
586
+ def groups_update(url, token, group_id, json_path):
587
+ with open(json_path) as f:
588
+ payload = json.load(f)
589
+ with httpx.Client(timeout=TIMEOUT) as c:
590
+ _post(c, url, f"/api/v1/groups/id/{group_id}/update", token, payload)
591
+ out(f"updated {group_id}")
592
+
593
+
594
+ # ── users ─────────────────────────────────────────────────────────────
595
+
596
+ def _users_all_pages(url, token):
597
+ users, page = [], 1
598
+ with httpx.Client(timeout=TIMEOUT) as c:
599
+ while True:
600
+ data = _get(c, url, f"/api/v1/users/?page={page}", token).json()
601
+ batch = data.get("users", [])
602
+ users.extend(batch)
603
+ if len(users) >= data.get("total", 0) or not batch:
604
+ break
605
+ page += 1
606
+ return users
607
+
608
+ def users_list(url, token):
609
+ with httpx.Client(timeout=TIMEOUT) as c:
610
+ data = _get(c, url, "/api/v1/users/?page=1", token).json()
611
+ users = data.get("users", [])
612
+ total = data.get("total", 0)
613
+ rows = [{"id": u.get("id",""), "name": u.get("name",""),
614
+ "email": u.get("email",""), "role": u.get("role","")}
615
+ for u in users]
616
+ out_table(rows, [("USER_ID","id",36), ("NAME","name",20), ("EMAIL","email",30), ("ROLE","role",5)])
617
+ if not JSON_OUTPUT and total > len(users):
618
+ print(f"({len(users)}/{total} — use 'users find' for all)")
619
+
620
+ def users_find(url, token, query):
621
+ all_users = _users_all_pages(url, token)
622
+ q = query.lower()
623
+ matches = [u for u in all_users if q in u.get("email","").lower() or q in u.get("name","").lower()]
624
+ rows = [{"id": u.get("id",""), "name": u.get("name",""), "email": u.get("email",""), "role": u.get("role","")}
625
+ for u in matches]
626
+ out_table(rows, [("USER_ID","id",36), ("NAME","name",20), ("EMAIL","email",30), ("ROLE","role",5)])
627
+ if not JSON_OUTPUT:
628
+ print(f"({len(matches)}/{len(all_users)})")
629
+
630
+ def users_show(url, token, id_or_email):
631
+ with httpx.Client(timeout=TIMEOUT) as c:
632
+ r = c.get(_api(url, f"/api/v1/users/{id_or_email}"), headers=_headers(token))
633
+ if r.status_code == 404 or not r.json().get("id"):
634
+ all_u = _users_all_pages(url, token)
635
+ user = next((u for u in all_u if u.get("email","").lower() == id_or_email.lower()), None)
636
+ if not user: die(f"user '{id_or_email}' not found")
637
+ else:
638
+ r.raise_for_status()
639
+ user = r.json()
640
+ if JSON_OUTPUT: out(user); return
641
+ out_kv([("id", user.get("id","")), ("name", user.get("name","")),
642
+ ("email", user.get("email","")), ("role", user.get("role",""))])
643
+
644
+ def users_add(url, token, email, name, role="user"):
645
+ if role not in ("user", "admin"): die("role must be user or admin")
646
+ with httpx.Client(timeout=TIMEOUT) as c:
647
+ r = _post(c, url, "/api/v1/auths/add", token, {"email": email, "name": name, "role": role, "password": "placeholder-no-login"})
648
+ out(f"created {r.json().get('id')} {email}")
649
+
650
+ def users_delete(url, token, user_id):
651
+ with httpx.Client(timeout=TIMEOUT) as c:
652
+ _delete(c, url, f"/api/v1/users/{user_id}", token)
653
+ out(f"deleted {user_id}")
654
+
655
+ def users_update(url, token, user_id, json_path):
656
+ with open(json_path) as f:
657
+ payload = json.load(f)
658
+ with httpx.Client(timeout=TIMEOUT) as c:
659
+ _post(c, url, f"/api/v1/users/{user_id}/update", token, payload)
660
+ out(f"updated {user_id}")
661
+
662
+
663
+ # ── chats (special: tree structure) ──────────────────────────────────
664
+
665
+ def chats_list(url, token, page="1"):
666
+ with httpx.Client(timeout=TIMEOUT) as c:
667
+ data = _get(c, url, f"/api/v1/chats/?page={page}", token).json()
668
+ items = data if isinstance(data, list) else data.get("data", data.get("items", []))
669
+ rows = [{"id": ch.get("id",""), "title": (ch.get("chat",{}).get("title","") or ch.get("title",""))[:50],
670
+ "updated": str(ch.get("updated_at",""))[:10]}
671
+ for ch in items]
672
+ out_table(rows, [("CHAT_ID","id",36), ("TITLE","title",40), ("UPDATED","updated",10)])
673
+
674
+ def chats_search(url, token, query, page="1"):
675
+ with httpx.Client(timeout=TIMEOUT) as c:
676
+ data = _get(c, url, f"/api/v1/chats/search?text={query}&page={page}", token).json()
677
+ items = data if isinstance(data, list) else data.get("data", data.get("items", []))
678
+ rows = [{"id": ch.get("id",""), "title": (ch.get("chat",{}).get("title","") or ch.get("title",""))[:50]}
679
+ for ch in items]
680
+ out_table(rows, [("CHAT_ID","id",36), ("TITLE","title",50)])
681
+
682
+ def chats_show(url, token, chat_id):
683
+ with httpx.Client(timeout=TIMEOUT) as c:
684
+ r = c.get(_api(url, f"/api/v1/chats/{chat_id}"), headers=_headers(token))
685
+ if r.status_code == 404: die(f"chat '{chat_id}' not found")
686
+ r.raise_for_status()
687
+ chat = r.json()
688
+ if JSON_OUTPUT:
689
+ out(chat)
690
+ return
691
+ inner = chat.get("chat", {})
692
+ history = inner.get("history", {})
693
+ msgs = history.get("messages", {})
694
+ current = history.get("currentId", "")
695
+
696
+ print(f"chat: {inner.get('title','(untitled)')}")
697
+ print(f"id: {chat_id}")
698
+ print(f"models: {', '.join(inner.get('models',[])) or '?'}")
699
+ print(f"nodes: {len(msgs)}")
700
+ if not msgs:
701
+ return
702
+
703
+ children_of = {}
704
+ roots = []
705
+ for mid, msg in msgs.items():
706
+ p = msg.get("parentId")
707
+ if not p:
708
+ roots.append(mid)
709
+ else:
710
+ children_of.setdefault(p, []).append(mid)
711
+
712
+ def walk(nid, depth=0):
713
+ msg = msgs.get(nid, {})
714
+ role = msg.get("role", "?")
715
+ content = msg.get("content", "")
716
+ kids = children_of.get(nid, [])
717
+ # Strip tool call details blocks
718
+ display = re.sub(r"<details[^>]*>.*?</details>", "", content, flags=re.DOTALL).strip()
719
+ display = re.sub(r"\n{3,}", "\n\n", display)
720
+ indent = " " * depth
721
+ marker = " *" if nid == current else ""
722
+ branch = f" [{len(kids)}br]" if len(kids) > 1 else ""
723
+ print(f"{indent}--- {role.upper()}{marker}{branch} ({nid[:8]})")
724
+ if display:
725
+ for line in display[:300].splitlines():
726
+ print(f"{indent} {line}")
727
+ if len(display) > 300:
728
+ print(f"{indent} ...({len(display)} chars)")
729
+ for kid in kids:
730
+ walk(kid, depth + (1 if len(kids) > 1 else 0))
731
+
732
+ for root in roots:
733
+ walk(root)
734
+
735
+ def chats_delete(url, token, chat_id):
736
+ with httpx.Client(timeout=TIMEOUT) as c:
737
+ _delete(c, url, f"/api/v1/chats/{chat_id}", token)
738
+ out(f"deleted {chat_id}")
739
+
740
+
741
+ # ── configs ──────────────────────────────────────────────────────────
742
+
743
+ def configs_show(url, token):
744
+ with httpx.Client(timeout=TIMEOUT) as c:
745
+ r = _get(c, url, "/api/v1/configs/export", token)
746
+ out(r.json())
747
+
748
+ def configs_get(url, token, section):
749
+ """Get a specific config section (connections, code_execution, models, tool_servers, etc.)."""
750
+ with httpx.Client(timeout=TIMEOUT) as c:
751
+ r = _get(c, url, f"/api/v1/configs/{section}", token)
752
+ out(r.json())
753
+
754
+ def configs_set(url, token, section, json_path):
755
+ with open(json_path) as f:
756
+ payload = json.load(f)
757
+ with httpx.Client(timeout=TIMEOUT) as c:
758
+ _post(c, url, f"/api/v1/configs/{section}", token, payload)
759
+ out(f"updated {section}")
760
+
761
+ def configs_admin(url, token):
762
+ with httpx.Client(timeout=TIMEOUT) as c:
763
+ r = _get(c, url, "/api/v1/auths/admin/config", token)
764
+ out(r.json())
765
+
766
+ def configs_admin_set(url, token, json_path):
767
+ with open(json_path) as f:
768
+ payload = json.load(f)
769
+ with httpx.Client(timeout=TIMEOUT) as c:
770
+ _post(c, url, "/api/v1/auths/admin/config", token, payload)
771
+ out("updated admin config")
772
+
773
+
774
+ # ── prompts ──────────────────────────────────────────────────────────
775
+
776
+ def prompts_list(url, token):
777
+ with httpx.Client(timeout=TIMEOUT) as c:
778
+ data = _get(c, url, "/api/v1/prompts/", token).json()
779
+ rows = [{"command": p.get("command",""), "title": p.get("title","")[:40],
780
+ "id": p.get("id","")}
781
+ for p in sorted(data, key=lambda p: p.get("command",""))]
782
+ out_table(rows, [("COMMAND","command",15), ("TITLE","title",30), ("ID","id",36)])
783
+
784
+ def prompts_show(url, token, prompt_id):
785
+ with httpx.Client(timeout=TIMEOUT) as c:
786
+ r = c.get(_api(url, f"/api/v1/prompts/id/{prompt_id}"), headers=_headers(token))
787
+ if r.status_code == 404: die(f"prompt '{prompt_id}' not found")
788
+ r.raise_for_status()
789
+ p = r.json()
790
+ if JSON_OUTPUT: out(p); return
791
+ out_kv([("id", p.get("id","")), ("command", p.get("command","")),
792
+ ("title", p.get("title","")), ("content", f"{len(p.get('content',''))} chars")])
793
+
794
+ def prompts_create(url, token, json_path):
795
+ with open(json_path) as f:
796
+ payload = json.load(f)
797
+ with httpx.Client(timeout=TIMEOUT) as c:
798
+ r = _post(c, url, "/api/v1/prompts/create", token, payload)
799
+ out(f"created {r.json().get('id')}")
800
+
801
+ def prompts_delete(url, token, prompt_id):
802
+ with httpx.Client(timeout=TIMEOUT) as c:
803
+ _delete(c, url, f"/api/v1/prompts/id/{prompt_id}/delete", token)
804
+ out(f"deleted {prompt_id}")
805
+
806
+
807
+ # ── schema introspection ─────────────────────────────────────────────
808
+
809
+ def cmd_schema(args):
810
+ """Query the bundled API schema reference."""
811
+ if not SCHEMA_PATH.is_file():
812
+ die(f"schema not found at {SCHEMA_PATH}")
813
+ with SCHEMA_PATH.open() as f:
814
+ schema = json.load(f)
815
+
816
+ if not args:
817
+ # List all resources
818
+ for key, val in schema.items():
819
+ if key == "_meta":
820
+ continue
821
+ prefix = val.get("prefix", "")
822
+ n = len(val.get("endpoints", []))
823
+ print(f" {key:<14} {prefix:<30} {n} endpoints")
824
+ return
825
+
826
+ resource = args[0]
827
+ if resource not in schema or resource == "_meta":
828
+ die(f"unknown resource '{resource}'. Run 'schema' for list.")
829
+
830
+ res = schema[resource]
831
+ note = res.get("note", "")
832
+ if note:
833
+ print(f"# {note}")
834
+
835
+ if len(args) == 1:
836
+ # Show all endpoints for resource
837
+ print(f"# {res['prefix']}")
838
+ for ep in res["endpoints"]:
839
+ method, path, auth, body, desc = ep[0], ep[1], ep[2], ep[3], ep[4]
840
+ body_str = f" body: {body}" if body else ""
841
+ print(f" {method:<6} {path:<45} [{auth}] {desc}{body_str}")
842
+ return
843
+
844
+ # Filter to matching method/path
845
+ query = args[1].lower()
846
+ matches = [ep for ep in res["endpoints"]
847
+ if query in ep[1].lower() or query in ep[4].lower()]
848
+ if not matches:
849
+ die(f"no endpoints matching '{query}' in {resource}")
850
+ print(f"# {res['prefix']}")
851
+ for ep in matches:
852
+ method, path, auth, body, desc = ep[0], ep[1], ep[2], ep[3], ep[4]
853
+ print(f" {method:<6} {path}")
854
+ print(f" auth: {auth} {desc}")
855
+ if body:
856
+ print(f" body: {body}")
857
+
858
+ # ── dispatch ──────────────────────────────────────────────────────────
859
+
860
+ # Build command table: {(resource, command): (fn, arg_spec, (min, max))}
861
+ COMMANDS: dict[tuple[str, str], tuple] = {}
862
+
863
+ # Register generic resources
864
+ for res in [tools_res, functions_res, skills_res]:
865
+ for cmd_name, (fn, arg_spec, arg_range) in res.get_commands().items():
866
+ COMMANDS[(res.name, cmd_name)] = (fn, arg_spec, arg_range)
867
+
868
+ # Register special resources
869
+ COMMANDS.update({
870
+ ("models", "list"): (models_list, "", (0, 0)),
871
+ ("models", "show"): (models_show, "<id>", (1, 1)),
872
+ ("models", "create"): (models_create, "<model.json>", (1, 1)),
873
+ ("models", "update"): (models_update, "<model.json>", (1, 1)),
874
+ ("models", "delete"): (models_delete, "<id>", (1, 1)),
875
+ ("knowledge", "list"): (knowledge_list, "", (0, 0)),
876
+ ("knowledge", "show"): (knowledge_show, "<id>", (1, 1)),
877
+ ("knowledge", "files"): (knowledge_files, "<id>", (1, 1)),
878
+ ("knowledge", "create"): (knowledge_create, "<name> [desc]", (1, 2)),
879
+ ("knowledge", "delete"): (knowledge_delete, "<id>", (1, 1)),
880
+ ("knowledge", "add-file"): (knowledge_add_file, "<id> <file-id>", (2, 2)),
881
+ ("knowledge", "remove-file"): (knowledge_remove_file,"<id> <file-id>", (2, 2)),
882
+ ("files", "list"): (files_list, "", (0, 0)),
883
+ ("files", "show"): (files_show, "<id>", (1, 1)),
884
+ ("files", "upload"): (files_upload, "<path> [mime]", (1, 2)),
885
+ ("files", "delete"): (files_delete, "<id>", (1, 1)),
886
+ ("groups", "list"): (groups_list, "", (0, 0)),
887
+ ("groups", "show"): (groups_show, "<id>", (1, 1)),
888
+ ("groups", "create"): (groups_create, "<name> [desc]", (1, 2)),
889
+ ("groups", "delete"): (groups_delete, "<id>", (1, 1)),
890
+ ("groups", "members"): (groups_members, "<id>", (1, 1)),
891
+ ("groups", "add-user"): (groups_add_user, "<id> <user-id>", (2, 2)),
892
+ ("groups", "remove-user"): (groups_remove_user, "<id> <user-id>", (2, 2)),
893
+ ("groups", "update"): (groups_update, "<id> <group.json>", (2, 2)),
894
+ ("chats", "list"): (chats_list, "[page]", (0, 1)),
895
+ ("chats", "search"): (chats_search, "<query> [page]", (1, 2)),
896
+ ("chats", "show"): (chats_show, "<id>", (1, 1)),
897
+ ("chats", "delete"): (chats_delete, "<id>", (1, 1)),
898
+ ("users", "list"): (users_list, "", (0, 0)),
899
+ ("users", "find"): (users_find, "<query>", (1, 1)),
900
+ ("users", "show"): (users_show, "<id-or-email>", (1, 1)),
901
+ ("users", "add"): (users_add, "<email> <name> [role]",(2, 3)),
902
+ ("users", "delete"): (users_delete, "<id>", (1, 1)),
903
+ ("users", "update"): (users_update, "<id> <user.json>", (2, 2)),
904
+ ("configs", "show"): (configs_show, "", (0, 0)),
905
+ ("configs", "get"): (configs_get, "<section>", (1, 1)),
906
+ ("configs", "set"): (configs_set, "<section> <data.json>",(2, 2)),
907
+ ("configs", "admin"): (configs_admin, "", (0, 0)),
908
+ ("configs", "admin-set"): (configs_admin_set, "<data.json>", (1, 1)),
909
+ ("prompts", "list"): (prompts_list, "", (0, 0)),
910
+ ("prompts", "show"): (prompts_show, "<id>", (1, 1)),
911
+ ("prompts", "create"): (prompts_create, "<prompt.json>", (1, 1)),
912
+ ("prompts", "delete"): (prompts_delete, "<id>", (1, 1)),
913
+ })
914
+
915
+
916
+ def cmd_help():
917
+ """Print all commands grouped by resource."""
918
+ # Group by resource preserving insertion order
919
+ resources = {}
920
+ for (res, cmd), (_, arg_spec, _) in COMMANDS.items():
921
+ resources.setdefault(res, []).append((cmd, arg_spec))
922
+ for res, cmds in resources.items():
923
+ for cmd, arg_spec in cmds:
924
+ print(f" {res:<12} {cmd:<15} {arg_spec}")
925
+
926
+
927
+ def main():
928
+ global JSON_OUTPUT
929
+ args = sys.argv[1:]
930
+
931
+ # Extract --json flag
932
+ if "--json" in args:
933
+ JSON_OUTPUT = True
934
+ args.remove("--json")
935
+
936
+ if "--version" in args:
937
+ print(f"owui-cli {owui_cli.__version__}")
938
+ return
939
+
940
+ if not args:
941
+ cmd_help()
942
+ sys.exit(1)
943
+
944
+ # Top-level commands
945
+ if args[0] == "help":
946
+ cmd_help()
947
+ return
948
+ if args[0] == "schema":
949
+ cmd_schema(args[1:])
950
+ return
951
+
952
+ if len(args) < 2:
953
+ cmd_help()
954
+ sys.exit(1)
955
+
956
+ url, token = _env()
957
+ resource, command = args[0], args[1]
958
+ key = (resource, command)
959
+
960
+ if key not in COMMANDS:
961
+ die(f"unknown: {resource} {command}")
962
+
963
+ fn, arg_spec, (min_args, max_args) = COMMANDS[key]
964
+ rest = args[2:]
965
+
966
+ if not (min_args <= len(rest) <= max_args):
967
+ die(f"usage: owui-cli {resource} {command} {arg_spec}")
968
+
969
+ try:
970
+ fn(url, token, *rest)
971
+ except httpx.HTTPStatusError as e:
972
+ die(f"HTTP {e.response.status_code}: {e.response.text[:200]}")
973
+ except FileNotFoundError as e:
974
+ die(f"file not found: {e}")
975
+
976
+
977
+ if __name__ == "__main__":
978
+ main()