owui-cli 0.3.0__tar.gz → 0.5.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.
@@ -1,3 +1,4 @@
1
+ .env
1
2
  __pycache__/
2
3
  *.pyc
3
4
  dist/
@@ -0,0 +1,49 @@
1
+ # AGENTS.md — owui-cli
2
+
3
+ ## Version management
4
+
5
+ The version number appears in two places and **must be kept in sync**:
6
+
7
+ - `pyproject.toml` → `version = "X.Y.Z"`
8
+ - `src/owui_cli/__init__.py` → `__version__ = "X.Y.Z"`
9
+
10
+ Bump both when cutting a release.
11
+
12
+ ## Publishing
13
+
14
+ Every push to `main` auto-publishes to PyPI via GitHub Actions trusted publishing. Bump the version before pushing, and don't push broken code to main.
15
+
16
+ ## Local development install
17
+
18
+ After making changes, reinstall locally with `uv tool install --force .` so your modified `owui-cli` is the one on your PATH.
19
+
20
+ ## Testing before committing
21
+
22
+ Always verify changes work against a **production Open WebUI server** before committing. Set `OWUI_URL` and `OWUI_TOKEN` and run the relevant commands to confirm they succeed with real API responses — don't rely on local-only reasoning.
23
+
24
+ ## Architecture
25
+
26
+ Single-file CLI in `src/owui_cli/cli.py`. The `Resource` base class handles generic CRUD (list, show, deploy, pull, pull-all, delete) for tools, functions, and skills. Special resources (models, knowledge, files, groups, users, chats, configs, prompts) use standalone functions. All commands are registered in the `COMMANDS` dispatch table at the bottom of the file.
27
+
28
+ ## API endpoint patterns
29
+
30
+ The Open WebUI API is not fully consistent across resources:
31
+
32
+ - **Tools/functions/skills:** `/api/v1/{resource}/id/{id}`
33
+ - **Models:** `/api/v1/models/model?id={id}`
34
+ - **Knowledge/users:** `/api/v1/{resource}/{id}`
35
+ - **Groups:** `/api/v1/groups/id/{id}`
36
+
37
+ Always verify the actual endpoint against a running instance when adding new commands.
38
+
39
+ ## Output conventions
40
+
41
+ All commands support `--json` for machine-readable output. New commands should use the existing output helpers which handle JSON mode automatically:
42
+
43
+ - `out(data)` — generic output (JSON in `--json` mode, pretty-printed otherwise)
44
+ - `out_table(rows, cols)` — aligned columnar table
45
+ - `out_kv(pairs)` — key-value display
46
+
47
+ ## Schema updates
48
+
49
+ `update-schema.py` fetches OWUI router sources from GitHub and generates a prompt to feed an LLM. The LLM output goes in `src/owui_cli/data/api-schema.json`. See the script header for the full workflow.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owui-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "owui-cli"
3
- version = "0.3.0"
3
+ version = "0.5.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.5.0"
@@ -43,8 +43,8 @@ def _api(url: str, path: str) -> str:
43
43
  return f"{url}{path}"
44
44
 
45
45
 
46
- def _get(c: httpx.Client, url: str, path: str, token: str) -> httpx.Response:
47
- r = c.get(_api(url, path), headers=_headers(token))
46
+ def _get(c: httpx.Client, url: str, path: str, token: str, params=None) -> httpx.Response:
47
+ r = c.get(_api(url, path), headers=_headers(token), params=params)
48
48
  r.raise_for_status()
49
49
  return r
50
50
 
@@ -369,6 +369,81 @@ functions_res = Resource("functions", "/api/v1/functions",
369
369
  workspace_path="/workspace/functions")
370
370
 
371
371
 
372
+ # ── valves (admin/global and per-user, for tools and functions) ──────
373
+ #
374
+ # OWUI plugins can declare two valve schemas:
375
+ # class Valves(BaseModel) -> /api/v1/{kind}/id/{id}/valves[/spec|/update]
376
+ # class UserValves(BaseModel) -> /api/v1/{kind}/id/{id}/valves/user[/spec|/update]
377
+ #
378
+ # The unprefixed `valves*` commands target the admin/global path (most common
379
+ # case: an admin-set API key). The `valves-user*` commands target the per-user
380
+ # path (the calling user's personal valves on the plugin).
381
+
382
+ def _valves_get(url, token, kind, item_id, scope=""):
383
+ with httpx.Client(timeout=TIMEOUT) as c:
384
+ r = _get(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}", token)
385
+ out(r.json())
386
+
387
+ def _valves_spec(url, token, kind, item_id, scope=""):
388
+ with httpx.Client(timeout=TIMEOUT) as c:
389
+ r = _get(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}/spec", token)
390
+ out(r.json())
391
+
392
+ def _valves_set(url, token, kind, item_id, json_path, scope=""):
393
+ with open(json_path) as f:
394
+ payload = json.load(f)
395
+ with httpx.Client(timeout=TIMEOUT) as c:
396
+ r = _post(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}/update", token, payload)
397
+ out(r.json())
398
+
399
+ def _valves_set_field(url, token, kind, item_id, key, value, scope=""):
400
+ """Set a single field. Value is parsed as JSON; falls back to string."""
401
+ try:
402
+ parsed = json.loads(value)
403
+ except (json.JSONDecodeError, ValueError):
404
+ parsed = value
405
+ with httpx.Client(timeout=TIMEOUT) as c:
406
+ current = _get(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}", token).json()
407
+ current[key] = parsed
408
+ r = _post(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}/update", token, current)
409
+ out(r.json())
410
+
411
+ def _valves_unset_field(url, token, kind, item_id, key, scope=""):
412
+ with httpx.Client(timeout=TIMEOUT) as c:
413
+ current = _get(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}", token).json()
414
+ if key not in current:
415
+ die(f"key '{key}' not found in valves")
416
+ del current[key]
417
+ r = _post(c, url, f"/api/v1/{kind}/id/{item_id}/valves{scope}/update", token, current)
418
+ out(r.json())
419
+
420
+ # Wrappers — admin/global valves (unprefixed: the common case)
421
+ def tools_valves_get(url, token, item_id): _valves_get(url, token, "tools", item_id)
422
+ def tools_valves_spec(url, token, item_id): _valves_spec(url, token, "tools", item_id)
423
+ def tools_valves_set(url, token, item_id, json_path): _valves_set(url, token, "tools", item_id, json_path)
424
+ def tools_valves_set_field(url, token, item_id, key, value):_valves_set_field(url, token, "tools", item_id, key, value)
425
+ def tools_valves_unset_field(url, token, item_id, key): _valves_unset_field(url, token, "tools", item_id, key)
426
+
427
+ def functions_valves_get(url, token, item_id): _valves_get(url, token, "functions", item_id)
428
+ def functions_valves_spec(url, token, item_id): _valves_spec(url, token, "functions", item_id)
429
+ def functions_valves_set(url, token, item_id, json_path): _valves_set(url, token, "functions", item_id, json_path)
430
+ def functions_valves_set_field(url, token, item_id, key, value):_valves_set_field(url, token, "functions", item_id, key, value)
431
+ def functions_valves_unset_field(url, token, item_id, key): _valves_unset_field(url, token, "functions", item_id, key)
432
+
433
+ # Wrappers — per-user valves (`valves-user*`)
434
+ def tools_valves_user_get(url, token, item_id): _valves_get(url, token, "tools", item_id, "/user")
435
+ def tools_valves_user_spec(url, token, item_id): _valves_spec(url, token, "tools", item_id, "/user")
436
+ def tools_valves_user_set(url, token, item_id, json_path): _valves_set(url, token, "tools", item_id, json_path, "/user")
437
+ def tools_valves_user_set_field(url, token, item_id, key, value):_valves_set_field(url, token, "tools", item_id, key, value, "/user")
438
+ def tools_valves_user_unset_field(url, token, item_id, key): _valves_unset_field(url, token, "tools", item_id, key, "/user")
439
+
440
+ def functions_valves_user_get(url, token, item_id): _valves_get(url, token, "functions", item_id, "/user")
441
+ def functions_valves_user_spec(url, token, item_id): _valves_spec(url, token, "functions", item_id, "/user")
442
+ def functions_valves_user_set(url, token, item_id, json_path): _valves_set(url, token, "functions", item_id, json_path, "/user")
443
+ def functions_valves_user_set_field(url, token, item_id, key, value):_valves_set_field(url, token, "functions", item_id, key, value, "/user")
444
+ def functions_valves_user_unset_field(url, token, item_id, key): _valves_unset_field(url, token, "functions", item_id, key, "/user")
445
+
446
+
372
447
  class SkillsResource(Resource):
373
448
  """Skills use frontmatter and have grant/revoke commands."""
374
449
 
@@ -614,9 +689,20 @@ def models_pull_all(url, token, out_dir="."):
614
689
  # ── knowledge (special: files subresource, file/remove is destructive) ─
615
690
 
616
691
  def knowledge_list(url, token):
692
+ kbs = []
693
+ page = 1
617
694
  with httpx.Client(timeout=TIMEOUT) as c:
618
- data = _get(c, url, "/api/v1/knowledge/", token).json()
619
- kbs = data if isinstance(data, list) else data.get("items", [])
695
+ while True:
696
+ data = _get(c, url, "/api/v1/knowledge/", token, params={"page": page}).json()
697
+ batch = data["items"] if isinstance(data, dict) else data
698
+ if not batch:
699
+ break
700
+ kbs.extend(batch)
701
+ if isinstance(data, dict):
702
+ if len(kbs) >= data.get("total", 0): break
703
+ else:
704
+ break
705
+ page += 1
620
706
  rows = [{"id": k.get("id",""), "name": k.get("name",""),
621
707
  "desc": (k.get("description") or "")[:50]}
622
708
  for k in sorted(kbs, key=lambda k: k.get("name",""))]
@@ -667,12 +753,25 @@ def knowledge_remove_file(url, token, kb_id, file_id):
667
753
  # ── files ─────────────────────────────────────────────────────────────
668
754
 
669
755
  def files_list(url, token):
756
+ rows = []
757
+ page = 1
670
758
  with httpx.Client(timeout=TIMEOUT) as c:
671
- data = _get(c, url, "/api/v1/files/", token).json()
672
- rows = [{"id": f.get("id",""),
673
- "name": (f.get("meta") or {}).get("name") or f.get("filename",""),
674
- "size": str((f.get("meta") or {}).get("size","?"))}
675
- for f in data]
759
+ while True:
760
+ data = _get(c, url, "/api/v1/files/", token,
761
+ params={"page": page, "content": "false"}).json()
762
+ # OWUI >= 0.9 returns {items, total}; older versions return a bare list.
763
+ batch = data["items"] if isinstance(data, dict) else data
764
+ if not batch:
765
+ break
766
+ rows.extend({"id": f.get("id",""),
767
+ "name": (f.get("meta") or {}).get("name") or f.get("filename",""),
768
+ "size": str((f.get("meta") or {}).get("size","?"))}
769
+ for f in batch)
770
+ if isinstance(data, dict):
771
+ if len(rows) >= data.get("total", 0): break
772
+ else:
773
+ break # bare-list response is unpaginated
774
+ page += 1
676
775
  out_table(rows, [("FILE_ID","id",36), ("NAME","name",30), ("SIZE","size",8)])
677
776
 
678
777
  def files_show(url, token, file_id):
@@ -1037,6 +1136,26 @@ for res in [tools_res, functions_res, skills_res]:
1037
1136
 
1038
1137
  # Register special resources
1039
1138
  COMMANDS.update({
1139
+ ("tools", "valves"): (tools_valves_get, "<id>", (1, 1)),
1140
+ ("tools", "valves-spec"): (tools_valves_spec, "<id>", (1, 1)),
1141
+ ("tools", "valves-set"): (tools_valves_set, "<id> <valves.json>", (2, 2)),
1142
+ ("tools", "valves-set-field"): (tools_valves_set_field, "<id> <key> <value>", (3, 3)),
1143
+ ("tools", "valves-unset-field"):(tools_valves_unset_field, "<id> <key>", (2, 2)),
1144
+ ("tools", "valves-user"): (tools_valves_user_get, "<id>", (1, 1)),
1145
+ ("tools", "valves-user-spec"): (tools_valves_user_spec, "<id>", (1, 1)),
1146
+ ("tools", "valves-user-set"): (tools_valves_user_set, "<id> <valves.json>", (2, 2)),
1147
+ ("tools", "valves-user-set-field"): (tools_valves_user_set_field, "<id> <key> <value>", (3, 3)),
1148
+ ("tools", "valves-user-unset-field"):(tools_valves_user_unset_field, "<id> <key>", (2, 2)),
1149
+ ("functions", "valves"): (functions_valves_get, "<id>", (1, 1)),
1150
+ ("functions", "valves-spec"): (functions_valves_spec, "<id>", (1, 1)),
1151
+ ("functions", "valves-set"): (functions_valves_set, "<id> <valves.json>", (2, 2)),
1152
+ ("functions", "valves-set-field"): (functions_valves_set_field, "<id> <key> <value>", (3, 3)),
1153
+ ("functions", "valves-unset-field"):(functions_valves_unset_field, "<id> <key>", (2, 2)),
1154
+ ("functions", "valves-user"): (functions_valves_user_get, "<id>", (1, 1)),
1155
+ ("functions", "valves-user-spec"): (functions_valves_user_spec, "<id>", (1, 1)),
1156
+ ("functions", "valves-user-set"): (functions_valves_user_set, "<id> <valves.json>", (2, 2)),
1157
+ ("functions", "valves-user-set-field"): (functions_valves_user_set_field, "<id> <key> <value>", (3, 3)),
1158
+ ("functions", "valves-user-unset-field"):(functions_valves_user_unset_field, "<id> <key>", (2, 2)),
1040
1159
  ("models", "list"): (models_list, "", (0, 0)),
1041
1160
  ("models", "show"): (models_show, "<id>", (1, 1)),
1042
1161
  ("models", "create"): (models_create, "<model.json>", (1, 1)),
@@ -1130,7 +1249,12 @@ def main():
1130
1249
  resource, command = args[0], args[1]
1131
1250
  key = (resource, command)
1132
1251
 
1252
+ # Subcommands are plural (chats, models, etc.) to mirror the OWUI API
1253
+ # paths (/api/v1/chats/, /api/v1/models, ...), not typical CLI convention.
1133
1254
  if key not in COMMANDS:
1255
+ plural = resource + "s"
1256
+ if (plural, command) in COMMANDS:
1257
+ die(f"resources are plural: use '{plural} {command}' not '{resource} {command}'")
1134
1258
  die(f"unknown: {resource} {command}")
1135
1259
 
1136
1260
  fn, arg_spec, (min_args, max_args) = COMMANDS[key]
owui_cli-0.5.0/uv.lock ADDED
@@ -0,0 +1,91 @@
1
+ version = 1
2
+ revision = 2
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.13.0"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "idna" },
11
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
12
+ ]
13
+ sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
14
+ wheels = [
15
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
16
+ ]
17
+
18
+ [[package]]
19
+ name = "certifi"
20
+ version = "2026.2.25"
21
+ source = { registry = "https://pypi.org/simple" }
22
+ sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
23
+ wheels = [
24
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
25
+ ]
26
+
27
+ [[package]]
28
+ name = "h11"
29
+ version = "0.16.0"
30
+ source = { registry = "https://pypi.org/simple" }
31
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
32
+ wheels = [
33
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
34
+ ]
35
+
36
+ [[package]]
37
+ name = "httpcore"
38
+ version = "1.0.9"
39
+ source = { registry = "https://pypi.org/simple" }
40
+ dependencies = [
41
+ { name = "certifi" },
42
+ { name = "h11" },
43
+ ]
44
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
45
+ wheels = [
46
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
47
+ ]
48
+
49
+ [[package]]
50
+ name = "httpx"
51
+ version = "0.28.1"
52
+ source = { registry = "https://pypi.org/simple" }
53
+ dependencies = [
54
+ { name = "anyio" },
55
+ { name = "certifi" },
56
+ { name = "httpcore" },
57
+ { name = "idna" },
58
+ ]
59
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
60
+ wheels = [
61
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
62
+ ]
63
+
64
+ [[package]]
65
+ name = "idna"
66
+ version = "3.11"
67
+ source = { registry = "https://pypi.org/simple" }
68
+ sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
69
+ wheels = [
70
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
71
+ ]
72
+
73
+ [[package]]
74
+ name = "owui-cli"
75
+ version = "0.2.0"
76
+ source = { editable = "." }
77
+ dependencies = [
78
+ { name = "httpx" },
79
+ ]
80
+
81
+ [package.metadata]
82
+ requires-dist = [{ name = "httpx", specifier = ">=0.27" }]
83
+
84
+ [[package]]
85
+ name = "typing-extensions"
86
+ version = "4.15.0"
87
+ source = { registry = "https://pypi.org/simple" }
88
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
89
+ wheels = [
90
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
91
+ ]
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes
File without changes
File without changes