owui-cli 0.1.0__tar.gz → 0.3.0__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,16 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v6
15
+ - run: uv build
16
+ - run: uv publish --trusted-publishing always
@@ -1,4 +1,3 @@
1
- HANDOFF.md
2
1
  __pycache__/
3
2
  *.pyc
4
3
  dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owui-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Admin CLI for Open WebUI instances
5
5
  Project-URL: Homepage, https://github.com/rndmcnlly/owui-cli
6
6
  Project-URL: Repository, https://github.com/rndmcnlly/owui-cli
@@ -30,9 +30,13 @@ uvx owui-cli models show gpt-4o
30
30
  uvx owui-cli schema knowledge
31
31
  ```
32
32
 
33
- ## Status
33
+ ## Install
34
34
 
35
- Under construction. Coming soon to PyPI.
35
+ ```bash
36
+ uvx owui-cli help # run without installing
37
+ uv tool install owui-cli # or install globally
38
+ pip install owui-cli # or via pip
39
+ ```
36
40
 
37
41
  ## Auth
38
42
 
@@ -11,9 +11,13 @@ uvx owui-cli models show gpt-4o
11
11
  uvx owui-cli schema knowledge
12
12
  ```
13
13
 
14
- ## Status
14
+ ## Install
15
15
 
16
- Under construction. Coming soon to PyPI.
16
+ ```bash
17
+ uvx owui-cli help # run without installing
18
+ uv tool install owui-cli # or install globally
19
+ pip install owui-cli # or via pip
20
+ ```
17
21
 
18
22
  ## Auth
19
23
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "owui-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Admin CLI for Open WebUI instances"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -10,6 +10,7 @@ Commands:
10
10
  Use --json for machine-readable output on any command.
11
11
  """
12
12
 
13
+ import base64
13
14
  import json
14
15
  import os
15
16
  import re
@@ -131,6 +132,34 @@ def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
131
132
  return meta, raw[fm.end():]
132
133
 
133
134
 
135
+ def _write_file(path: str, content: str | bytes):
136
+ """Write content to a file, creating parent directories as needed."""
137
+ os.makedirs(os.path.dirname(path), exist_ok=True)
138
+ mode = "wb" if isinstance(content, bytes) else "w"
139
+ with open(path, mode) as f:
140
+ f.write(content)
141
+
142
+
143
+ def _write_json(path: str, obj):
144
+ """Write JSON to a file, creating parent directories as needed."""
145
+ os.makedirs(os.path.dirname(path), exist_ok=True)
146
+ with open(path, "w") as f:
147
+ json.dump(obj, f, indent=2, ensure_ascii=False)
148
+ f.write("\n")
149
+
150
+
151
+ def _extract_data_uri(data_uri: str) -> tuple[str, bytes] | None:
152
+ """Decode a data:image/*;base64,... URI. Returns (ext, bytes) or None."""
153
+ if not data_uri or not data_uri.startswith("data:image/"):
154
+ return None
155
+ header, _, b64data = data_uri.partition(",")
156
+ if not b64data:
157
+ return None
158
+ mime = header.split(";")[0].replace("data:", "")
159
+ ext = mime.split("/")[1] if "/" in mime else "png"
160
+ return ext, base64.b64decode(b64data)
161
+
162
+
134
163
  def _slugify(title: str) -> str:
135
164
  return re.sub(r"[^a-z0-9]+", title.lower(), "").strip("_") if not title else re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
136
165
 
@@ -162,6 +191,7 @@ class Resource:
162
191
  self._commands["show"] = (self.cmd_show, "<id>", (1, 1))
163
192
  self._commands["deploy"] = (self.cmd_deploy, f"<source{self.deploy_ext}> [id]", (1, 2))
164
193
  self._commands["pull"] = (self.cmd_pull, "<id>", (1, 1))
194
+ self._commands["pull-all"] = (self.cmd_pull_all, "[dir]", (0, 1))
165
195
  self._commands["delete"] = (self.cmd_delete, "<id>", (1, 1))
166
196
 
167
197
  def add_command(self, name, fn, arg_spec, arg_range):
@@ -281,6 +311,36 @@ class Resource:
281
311
  if content and not content.endswith("\n"):
282
312
  sys.stdout.write("\n")
283
313
 
314
+ def cmd_pull_all(self, url: str, token: str, out_dir: str = "."):
315
+ """Pull all items into <out_dir>/<id>/source + <out_dir>/<id>/meta.json."""
316
+ src_name = {"tools": "tool.py", "functions": "function.py", "skills": "skill.md"}.get(self.name, "source")
317
+ with httpx.Client(timeout=TIMEOUT) as c:
318
+ data = _get(c, url, f"{self.prefix}/", token).json()
319
+ items = data if isinstance(data, list) else data.get("items", data.get("data", []))
320
+ ids = sorted(item.get("id", "") for item in items)
321
+ if not ids:
322
+ out("(none)")
323
+ return
324
+ count = 0
325
+ with httpx.Client(timeout=TIMEOUT) as c:
326
+ for item_id in ids:
327
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
328
+ if r.status_code == 404:
329
+ print(f" skip {item_id} (not found)", file=sys.stderr)
330
+ continue
331
+ r.raise_for_status()
332
+ item = r.json()
333
+ item_dir = os.path.join(out_dir, item_id)
334
+ # Write source
335
+ content = item.pop(self.content_key, "")
336
+ _write_file(os.path.join(item_dir, src_name), content if content.endswith("\n") else content + "\n")
337
+ # Write metadata (everything except source content)
338
+ _write_json(os.path.join(item_dir, "meta.json"), item)
339
+ count += 1
340
+ if not JSON_OUTPUT:
341
+ print(f" {item_id}")
342
+ out(f"pulled {count} {self.name}")
343
+
284
344
  def cmd_delete(self, url: str, token: str, item_id: str):
285
345
  with httpx.Client(timeout=TIMEOUT) as c:
286
346
  r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
@@ -356,6 +416,36 @@ class SkillsResource(Resource):
356
416
  if content and not content.endswith("\n"):
357
417
  sys.stdout.write("\n")
358
418
 
419
+ def cmd_pull_all(self, url: str, token: str, out_dir: str = "."):
420
+ """Pull all skills into <out_dir>/<id>/skill.md (with frontmatter) + meta.json."""
421
+ with httpx.Client(timeout=TIMEOUT) as c:
422
+ data = _get(c, url, f"{self.prefix}/", token).json()
423
+ items = data if isinstance(data, list) else data.get("items", data.get("data", []))
424
+ ids = sorted(item.get("id", "") for item in items)
425
+ if not ids:
426
+ out("(none)")
427
+ return
428
+ count = 0
429
+ with httpx.Client(timeout=TIMEOUT) as c:
430
+ for item_id in ids:
431
+ r = c.get(_api(url, self.item_path(item_id)), headers=_headers(token))
432
+ if r.status_code == 404:
433
+ print(f" skip {item_id} (not found)", file=sys.stderr)
434
+ continue
435
+ r.raise_for_status()
436
+ item = r.json()
437
+ item_dir = os.path.join(out_dir, item_id)
438
+ # Write skill.md with frontmatter
439
+ content = item.pop("content", "")
440
+ fm = f"---\nid: {item_id}\nname: {item.get('name','')}\ndescription: {item.get('description','')}\n---\n"
441
+ _write_file(os.path.join(item_dir, "skill.md"), fm + content + ("" if content.endswith("\n") else "\n"))
442
+ # Write metadata
443
+ _write_json(os.path.join(item_dir, "meta.json"), item)
444
+ count += 1
445
+ if not JSON_OUTPUT:
446
+ print(f" {item_id}")
447
+ out(f"pulled {count} skills")
448
+
359
449
  def cmd_toggle(self, url: str, token: str, skill_id: str):
360
450
  with httpx.Client(timeout=TIMEOUT) as c:
361
451
  r = _post(c, url, f"{self.item_path(skill_id)}/toggle", token)
@@ -415,6 +505,7 @@ def models_show(url, token, model_id):
415
505
  ("base", info.get("base_model_id","(none)")),
416
506
  ("active", str(info.get("is_active","?"))),
417
507
  ("tools", ", ".join(meta.get("toolIds") or []) or "(none)"),
508
+ ("filters", ", ".join(params.get("filter_ids") or []) or "(none)"),
418
509
  ("knowledge", ", ".join(k.get("name","?") for k in (meta.get("knowledge") or [])) or "(none)"),
419
510
  ("system", f"{len(params.get('system',''))} chars"),
420
511
  ("grants", str(len(info.get("access_grants") or [])))]
@@ -440,6 +531,85 @@ def models_delete(url, token, model_id):
440
531
  _post(c, url, "/api/v1/models/model/delete", token, {"id": model_id})
441
532
  out(f"deleted {model_id}")
442
533
 
534
+ def _models_fetch(c, url, token, model_id):
535
+ """Fetch a model by ID, returning the parsed JSON."""
536
+ r = _get(c, url, f"/api/v1/models/model?id={model_id}", token)
537
+ return r.json()
538
+
539
+ def models_set_tools(url, token, model_id, *tool_ids):
540
+ """Set the tool bindings for a workspace model (pass no IDs to clear)."""
541
+ with httpx.Client(timeout=TIMEOUT) as c:
542
+ model = _models_fetch(c, url, token, model_id)
543
+ info = model.get("info") or {}
544
+ meta = info.setdefault("meta", {})
545
+ params = info.setdefault("params", {})
546
+ ids = list(tool_ids)
547
+ meta["toolIds"] = ids
548
+ # keep params.tool_ids in sync (used by some OWUI versions)
549
+ params["tool_ids"] = ids
550
+ model["info"] = info
551
+ r = _post(c, url, "/api/v1/models/model/update", token, model)
552
+ label = ", ".join(ids) if ids else "(none)"
553
+ out(f"tools for {model_id}: {label}")
554
+
555
+ def models_set_filters(url, token, model_id, *filter_ids):
556
+ """Set the filter bindings for a workspace model (pass no IDs to clear)."""
557
+ with httpx.Client(timeout=TIMEOUT) as c:
558
+ model = _models_fetch(c, url, token, model_id)
559
+ info = model.get("info") or {}
560
+ params = info.setdefault("params", {})
561
+ ids = list(filter_ids)
562
+ params["filter_ids"] = ids
563
+ model["info"] = info
564
+ r = _post(c, url, "/api/v1/models/model/update", token, model)
565
+ label = ", ".join(ids) if ids else "(none)"
566
+ out(f"filters for {model_id}: {label}")
567
+
568
+
569
+ def models_pull_all(url, token, out_dir="."):
570
+ """Pull all workspace models into <out_dir>/<id>/model.json, extracting profile images."""
571
+ with httpx.Client(timeout=TIMEOUT) as c:
572
+ data = _get(c, url, "/api/v1/models", token).json().get("data", [])
573
+
574
+ # Filter to workspace models: those with a base_model_id in their info
575
+ # (raw connection proxies have no info or no base_model_id)
576
+ workspace = []
577
+ for m in data:
578
+ info = m.get("info") or {}
579
+ base = info.get("base_model_id", "")
580
+ if base:
581
+ workspace.append(m)
582
+
583
+ if not workspace:
584
+ out("(no workspace models)")
585
+ return
586
+
587
+ count = 0
588
+ with httpx.Client(timeout=TIMEOUT) as c:
589
+ for m in sorted(workspace, key=lambda m: m.get("id", "")):
590
+ model_id = m.get("id", "")
591
+ # Fetch full model data
592
+ r = _get(c, url, f"/api/v1/models/model?id={model_id}", token)
593
+ model = r.json()
594
+ model_dir = os.path.join(out_dir, model_id)
595
+
596
+ # Extract profile image from data URI (top-level meta, not info.meta)
597
+ top_meta = model.get("meta") or {}
598
+ img_url = top_meta.get("profile_image_url", "")
599
+ extracted = _extract_data_uri(img_url)
600
+ if extracted:
601
+ ext, img_bytes = extracted
602
+ _write_file(os.path.join(model_dir, f"profile.{ext}"), img_bytes)
603
+ top_meta["profile_image_url"] = f"profile.{ext}"
604
+
605
+ _write_json(os.path.join(model_dir, "model.json"), model)
606
+ count += 1
607
+ if not JSON_OUTPUT:
608
+ img_note = f" +profile.{ext}" if extracted else ""
609
+ print(f" {model_id}{img_note}")
610
+
611
+ out(f"pulled {count} models")
612
+
443
613
 
444
614
  # ── knowledge (special: files subresource, file/remove is destructive) ─
445
615
 
@@ -872,6 +1042,9 @@ COMMANDS.update({
872
1042
  ("models", "create"): (models_create, "<model.json>", (1, 1)),
873
1043
  ("models", "update"): (models_update, "<model.json>", (1, 1)),
874
1044
  ("models", "delete"): (models_delete, "<id>", (1, 1)),
1045
+ ("models", "set-tools"): (models_set_tools, "<id> [tool-id]...", (1, 999)),
1046
+ ("models", "set-filters"): (models_set_filters, "<id> [filter-id]...", (1, 999)),
1047
+ ("models", "pull-all"): (models_pull_all, "[dir]", (0, 1)),
875
1048
  ("knowledge", "list"): (knowledge_list, "", (0, 0)),
876
1049
  ("knowledge", "show"): (knowledge_show, "<id>", (1, 1)),
877
1050
  ("knowledge", "files"): (knowledge_files, "<id>", (1, 1)),
@@ -0,0 +1,121 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["httpx"]
4
+ # ///
5
+ """Fetch Open WebUI router sources and emit an LLM prompt to regenerate api-schema.json.
6
+
7
+ Usage:
8
+ uv run --script update-schema.py [tag-or-branch]
9
+
10
+ Default branch: main. Pass a tag like "v0.8.12" to pin to a release.
11
+
12
+ This fetches all backend router files from GitHub, then prints a self-contained
13
+ prompt to stdout. Pipe it to an LLM (or paste into a session) to produce the
14
+ updated api-schema.json.
15
+
16
+ Example workflow:
17
+ uv run --script update-schema.py v0.9.0 > /tmp/schema-prompt.txt
18
+ # Feed /tmp/schema-prompt.txt to your preferred LLM
19
+ # Save the JSON output to src/owui_cli/data/api-schema.json
20
+ # Update _meta.source and _meta.extracted fields
21
+ """
22
+
23
+ import sys
24
+ import httpx
25
+
26
+ ROUTERS = [
27
+ "configs", "groups", "chats", "prompts", "users", "auths",
28
+ "models", "tools", "functions", "knowledge", "files", "skills",
29
+ "memories", "channels", "folders", "notes", "evaluations",
30
+ ]
31
+
32
+ BASE_URL = "https://raw.githubusercontent.com/open-webui/open-webui"
33
+ ROUTER_PATH = "backend/open_webui/routers"
34
+
35
+ SCHEMA_FORMAT = """\
36
+ {
37
+ "_meta": {
38
+ "source": "open-webui/open-webui <REF>",
39
+ "extracted": "<DATE>",
40
+ "auth_levels": {"A": "admin", "V": "verified user", "C": "any authenticated", "N": "no auth"},
41
+ "format": "[METHOD, path, auth, body_or_null, description]"
42
+ },
43
+ "<resource>": {
44
+ "prefix": "/api/v1/<resource>",
45
+ "note": "optional note about quirks",
46
+ "endpoints": [
47
+ ["GET", "/", "V", null, "List items"],
48
+ ["POST", "/create", "A", "{name,description?}", "Create item"],
49
+ ...
50
+ ]
51
+ },
52
+ ...
53
+ }"""
54
+
55
+
56
+ def fetch_routers(ref: str) -> dict[str, str]:
57
+ """Fetch all router source files. Returns {name: source_code}."""
58
+ sources = {}
59
+ with httpx.Client(timeout=30.0, follow_redirects=True) as client:
60
+ for name in ROUTERS:
61
+ url = f"{BASE_URL}/{ref}/{ROUTER_PATH}/{name}.py"
62
+ print(f" fetching {name}.py ...", file=sys.stderr)
63
+ r = client.get(url)
64
+ if r.status_code == 404:
65
+ print(f" WARNING: {name}.py not found at {ref}", file=sys.stderr)
66
+ continue
67
+ r.raise_for_status()
68
+ sources[name] = r.text
69
+ return sources
70
+
71
+
72
+ def emit_prompt(ref: str, sources: dict[str, str]):
73
+ """Print the LLM prompt to stdout."""
74
+ print(f"""\
75
+ I need you to produce a JSON file called api-schema.json by reading the Open WebUI
76
+ router source files below. This schema is used by a CLI tool for agent-driven API
77
+ introspection.
78
+
79
+ ## Output format
80
+
81
+ Emit ONLY valid JSON matching this structure (no markdown fences, no commentary):
82
+
83
+ {SCHEMA_FORMAT}
84
+
85
+ ## Rules
86
+
87
+ 1. Every @router.get, @router.post, @router.delete, @router.put, @router.patch
88
+ decorator becomes one entry in the endpoints array.
89
+ 2. Path is relative to the router prefix (e.g. "/" not "/api/v1/tools/").
90
+ 3. Auth level is determined by the dependency:
91
+ - get_admin_user -> "A"
92
+ - get_verified_user -> "V"
93
+ - get_current_user -> "C"
94
+ - no auth dependency -> "N"
95
+ 4. Body description is a compact string like "{{name,description?}}" showing the
96
+ Pydantic model fields. Use ? for Optional fields. null if no request body.
97
+ 5. Include a "note" field on resources with path quirks (e.g. /id/{{id}} vs /{{id}},
98
+ query params instead of path params, destructive operations).
99
+ 6. Set _meta.source to "open-webui/open-webui {ref}"
100
+ 7. Be exhaustive. Every endpoint. No omissions.
101
+
102
+ ## Router source files ({len(sources)} files from {ref})
103
+ """)
104
+
105
+ for name, source in sources.items():
106
+ print(f"### {name}.py\n")
107
+ print(f"```python\n{source}\n```\n")
108
+
109
+
110
+ def main():
111
+ ref = sys.argv[1] if len(sys.argv) > 1 else "main"
112
+ print(f"Fetching routers from {ref} ...", file=sys.stderr)
113
+ sources = fetch_routers(ref)
114
+ print(f"Fetched {len(sources)} router files.", file=sys.stderr)
115
+ emit_prompt(ref, sources)
116
+ print(f"\nPrompt written to stdout ({sum(len(s) for s in sources.values())} chars of source).", file=sys.stderr)
117
+ print(f"Feed this to an LLM and save the JSON output to src/owui_cli/data/api-schema.json", file=sys.stderr)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes