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/__init__.py +1 -0
- owui_cli/cli.py +978 -0
- owui_cli/data/api-schema.json +1615 -0
- owui_cli-0.1.0.dist-info/METADATA +47 -0
- owui_cli-0.1.0.dist-info/RECORD +8 -0
- owui_cli-0.1.0.dist-info/WHEEL +4 -0
- owui_cli-0.1.0.dist-info/entry_points.txt +2 -0
- owui_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|