cortexctl 0.1.0.dev20260811135705__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.
- cortex_cli/__init__.py +1 -0
- cortex_cli/__main__.py +4 -0
- cortex_cli/client.py +1222 -0
- cortex_cli/config.py +88 -0
- cortex_cli/main.py +4878 -0
- cortex_cli/registry.py +862 -0
- cortexctl-0.1.0.dev20260811135705.dist-info/METADATA +213 -0
- cortexctl-0.1.0.dev20260811135705.dist-info/RECORD +10 -0
- cortexctl-0.1.0.dev20260811135705.dist-info/WHEEL +4 -0
- cortexctl-0.1.0.dev20260811135705.dist-info/entry_points.txt +2 -0
cortex_cli/main.py
ADDED
|
@@ -0,0 +1,4878 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
import zipfile
|
|
12
|
+
from base64 import urlsafe_b64encode
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import asdict
|
|
15
|
+
from datetime import UTC, date, datetime, timedelta
|
|
16
|
+
from hashlib import sha256
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, Any
|
|
20
|
+
from urllib.parse import parse_qs, urlparse
|
|
21
|
+
from xml.etree import ElementTree
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from typer._click.exceptions import ClickException, Exit, NoArgsIsHelpError
|
|
25
|
+
|
|
26
|
+
from cortex_cli.client import CliAPIError, CortexClient
|
|
27
|
+
from cortex_cli.config import CliState, StateStore, default_api_url
|
|
28
|
+
from cortex_cli.registry import COMMAND_GROUPS, CommandSpec
|
|
29
|
+
|
|
30
|
+
EXIT_SUCCESS = 0
|
|
31
|
+
EXIT_USAGE = 2
|
|
32
|
+
EXIT_AUTH = 10
|
|
33
|
+
EXIT_FORBIDDEN = 11
|
|
34
|
+
EXIT_NOT_FOUND = 12
|
|
35
|
+
EXIT_CONFLICT = 13
|
|
36
|
+
EXIT_ERROR = 20
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
help="Cortex knowledge base management CLI.",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
)
|
|
42
|
+
auth_app = typer.Typer(help="Login and inspect identity.", no_args_is_help=True)
|
|
43
|
+
workspace_app = typer.Typer(
|
|
44
|
+
help="Manage workspaces, invites, and members.",
|
|
45
|
+
no_args_is_help=True,
|
|
46
|
+
)
|
|
47
|
+
invite_app = typer.Typer(help="Manage workspace invites.", no_args_is_help=True)
|
|
48
|
+
invite_link_app = typer.Typer(
|
|
49
|
+
help="Manage workspace invite links.",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
)
|
|
52
|
+
member_app = typer.Typer(help="Manage workspace members.", no_args_is_help=True)
|
|
53
|
+
kb_app = typer.Typer(help="Manage knowledge bases.", no_args_is_help=True)
|
|
54
|
+
kb_order_app = typer.Typer(
|
|
55
|
+
help="Manage per-user knowledge base ordering.",
|
|
56
|
+
no_args_is_help=True,
|
|
57
|
+
)
|
|
58
|
+
kb_permissions_app = typer.Typer(
|
|
59
|
+
help="Manage knowledge base edit permissions.",
|
|
60
|
+
no_args_is_help=True,
|
|
61
|
+
)
|
|
62
|
+
folder_app = typer.Typer(help="Manage folders.", no_args_is_help=True)
|
|
63
|
+
item_app = typer.Typer(help="Manage knowledge items.", no_args_is_help=True)
|
|
64
|
+
trash_app = typer.Typer(
|
|
65
|
+
help="Manage deleted knowledge item locations.",
|
|
66
|
+
no_args_is_help=True,
|
|
67
|
+
)
|
|
68
|
+
file_app = typer.Typer(
|
|
69
|
+
help="Upload, attach, delete, download, and read files.",
|
|
70
|
+
no_args_is_help=True,
|
|
71
|
+
)
|
|
72
|
+
pipeline_app = typer.Typer(
|
|
73
|
+
help="Manage processing pipelines.",
|
|
74
|
+
no_args_is_help=True,
|
|
75
|
+
)
|
|
76
|
+
processing_app = typer.Typer(
|
|
77
|
+
help="Trigger and inspect processing.",
|
|
78
|
+
no_args_is_help=True,
|
|
79
|
+
)
|
|
80
|
+
search_app = typer.Typer(
|
|
81
|
+
help="Search knowledge items and chunks.",
|
|
82
|
+
no_args_is_help=True,
|
|
83
|
+
)
|
|
84
|
+
chunk_app = typer.Typer(help="Fetch processed chunks.", no_args_is_help=True)
|
|
85
|
+
source_app = typer.Typer(help="Explore source anchors and pages.", no_args_is_help=True)
|
|
86
|
+
mcp_app = typer.Typer(help="Manage MCP gateway interfaces.", no_args_is_help=True)
|
|
87
|
+
mcp_key_app = typer.Typer(help="Manage MCP API keys.", no_args_is_help=True)
|
|
88
|
+
vector_index_app = typer.Typer(
|
|
89
|
+
help="Inspect and rebuild workspace vector indexes.",
|
|
90
|
+
no_args_is_help=True,
|
|
91
|
+
)
|
|
92
|
+
gc_app = typer.Typer(
|
|
93
|
+
help="Clean physical resources after deletion.",
|
|
94
|
+
no_args_is_help=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
app.add_typer(auth_app, name="auth")
|
|
98
|
+
app.add_typer(workspace_app, name="workspace")
|
|
99
|
+
workspace_app.add_typer(invite_app, name="invite")
|
|
100
|
+
workspace_app.add_typer(invite_link_app, name="invite-link")
|
|
101
|
+
workspace_app.add_typer(member_app, name="member")
|
|
102
|
+
app.add_typer(kb_app, name="kb")
|
|
103
|
+
kb_app.add_typer(kb_order_app, name="order")
|
|
104
|
+
kb_app.add_typer(kb_permissions_app, name="permissions")
|
|
105
|
+
app.add_typer(folder_app, name="folder")
|
|
106
|
+
app.add_typer(item_app, name="item")
|
|
107
|
+
app.add_typer(trash_app, name="trash")
|
|
108
|
+
app.add_typer(file_app, name="file")
|
|
109
|
+
app.add_typer(pipeline_app, name="pipeline")
|
|
110
|
+
app.add_typer(processing_app, name="processing")
|
|
111
|
+
app.add_typer(search_app, name="search")
|
|
112
|
+
app.add_typer(chunk_app, name="chunk")
|
|
113
|
+
app.add_typer(source_app, name="source")
|
|
114
|
+
app.add_typer(mcp_app, name="mcp")
|
|
115
|
+
mcp_app.add_typer(mcp_key_app, name="key")
|
|
116
|
+
app.add_typer(vector_index_app, name="vector-index")
|
|
117
|
+
app.add_typer(gc_app, name="gc")
|
|
118
|
+
|
|
119
|
+
GLOBAL_JSON = False
|
|
120
|
+
GLOBAL_API_URL: str | None = None
|
|
121
|
+
GLOBAL_PROFILE = "full"
|
|
122
|
+
|
|
123
|
+
PROFILE_FULL = "full"
|
|
124
|
+
PROFILE_AGENT_READONLY = "agent-readonly"
|
|
125
|
+
ROOT_OPTIONS_WITH_VALUE = {"--api-url", "--profile"}
|
|
126
|
+
ROOT_FLAGS = {"--json"}
|
|
127
|
+
TOP_LEVEL_COMMANDS = {"version", "doctor", "commands", "help", "recall"}
|
|
128
|
+
NESTED_GROUPS = {
|
|
129
|
+
("workspace", "invite"),
|
|
130
|
+
("workspace", "invite-link"),
|
|
131
|
+
("workspace", "member"),
|
|
132
|
+
("kb", "order"),
|
|
133
|
+
("kb", "permissions"),
|
|
134
|
+
("mcp", "key"),
|
|
135
|
+
}
|
|
136
|
+
READONLY_ALLOWED_COMMANDS = {
|
|
137
|
+
("version",),
|
|
138
|
+
("doctor",),
|
|
139
|
+
("commands",),
|
|
140
|
+
("help",),
|
|
141
|
+
("auth", "login"),
|
|
142
|
+
("auth", "whoami"),
|
|
143
|
+
("auth", "logout"),
|
|
144
|
+
("workspace", "list"),
|
|
145
|
+
("workspace", "use"),
|
|
146
|
+
("workspace", "get"),
|
|
147
|
+
("workspace", "invite-link", "enter"),
|
|
148
|
+
("kb", "list"),
|
|
149
|
+
("kb", "get"),
|
|
150
|
+
("folder", "tree"),
|
|
151
|
+
("item", "list"),
|
|
152
|
+
("item", "get"),
|
|
153
|
+
("file", "list"),
|
|
154
|
+
("file", "preview"),
|
|
155
|
+
("file", "read"),
|
|
156
|
+
("search", "grep"),
|
|
157
|
+
("search", "advanced"),
|
|
158
|
+
("search", "semantic"),
|
|
159
|
+
("recall",),
|
|
160
|
+
("chunk", "get"),
|
|
161
|
+
("chunk", "context"),
|
|
162
|
+
("source", "locate"),
|
|
163
|
+
("source", "expand"),
|
|
164
|
+
("source", "page"),
|
|
165
|
+
("source", "region-image"),
|
|
166
|
+
("processing", "status"),
|
|
167
|
+
("processing", "get"),
|
|
168
|
+
("vector-index", "status"),
|
|
169
|
+
}
|
|
170
|
+
MCP_TOOL_NAMES = {
|
|
171
|
+
"grep",
|
|
172
|
+
"recall",
|
|
173
|
+
"get_chunk",
|
|
174
|
+
"source_locate",
|
|
175
|
+
"source_expand",
|
|
176
|
+
"read_page",
|
|
177
|
+
"read_region_image",
|
|
178
|
+
"list_kb",
|
|
179
|
+
"get_item",
|
|
180
|
+
"get_file",
|
|
181
|
+
}
|
|
182
|
+
KNOWN_COMMAND_PATHS = {
|
|
183
|
+
(group.name, *leaf.name.split())
|
|
184
|
+
for group in COMMAND_GROUPS
|
|
185
|
+
for leaf in group.commands
|
|
186
|
+
}
|
|
187
|
+
KNOWN_COMMAND_PATHS.update((command,) for command in TOP_LEVEL_COMMANDS)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
191
|
+
global GLOBAL_PROFILE
|
|
192
|
+
args = list(argv) if argv is not None else sys.argv[1:]
|
|
193
|
+
profile_or_error = resolve_cli_profile(args)
|
|
194
|
+
if isinstance(profile_or_error, CliAPIError):
|
|
195
|
+
emit_cli_error(json_requested(args), profile_or_error)
|
|
196
|
+
return EXIT_USAGE
|
|
197
|
+
GLOBAL_PROFILE = profile_or_error
|
|
198
|
+
guard_error = profile_guard_error(args, profile_or_error)
|
|
199
|
+
if guard_error is not None:
|
|
200
|
+
emit_cli_error(json_requested(args), guard_error)
|
|
201
|
+
return EXIT_FORBIDDEN
|
|
202
|
+
|
|
203
|
+
command = typer.main.get_command(app)
|
|
204
|
+
try:
|
|
205
|
+
result = command.main(
|
|
206
|
+
args=args,
|
|
207
|
+
prog_name="cortex",
|
|
208
|
+
standalone_mode=False,
|
|
209
|
+
)
|
|
210
|
+
except Exit as exc:
|
|
211
|
+
return int(exc.exit_code)
|
|
212
|
+
except NoArgsIsHelpError:
|
|
213
|
+
return EXIT_SUCCESS
|
|
214
|
+
except ClickException as exc:
|
|
215
|
+
exc.show(file=sys.stderr)
|
|
216
|
+
return EXIT_USAGE
|
|
217
|
+
|
|
218
|
+
return result if isinstance(result, int) else EXIT_SUCCESS
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_cli_profile(args: Sequence[str]) -> str | CliAPIError:
|
|
222
|
+
explicit = profile_from_args(args)
|
|
223
|
+
raw = (
|
|
224
|
+
explicit
|
|
225
|
+
or os.environ.get("CORTEX_CLI_PROFILE")
|
|
226
|
+
or os.environ.get("CORTEX_CLI_MODE")
|
|
227
|
+
or PROFILE_FULL
|
|
228
|
+
)
|
|
229
|
+
profile = normalize_cli_profile(raw)
|
|
230
|
+
if profile is None:
|
|
231
|
+
return CliAPIError(
|
|
232
|
+
code="cli.invalid_profile",
|
|
233
|
+
message=(
|
|
234
|
+
"CLI profile must be full or agent-readonly "
|
|
235
|
+
"(readonly is accepted as an alias)."
|
|
236
|
+
),
|
|
237
|
+
status_code=400,
|
|
238
|
+
details={"profile": raw},
|
|
239
|
+
)
|
|
240
|
+
return profile
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def normalize_cli_profile(value: str | None) -> str | None:
|
|
244
|
+
if value is None:
|
|
245
|
+
return None
|
|
246
|
+
normalized = value.strip().casefold().replace("_", "-")
|
|
247
|
+
if normalized in {"", "full", "default"}:
|
|
248
|
+
return PROFILE_FULL
|
|
249
|
+
if normalized in {"readonly", "read-only", "agent-readonly"}:
|
|
250
|
+
return PROFILE_AGENT_READONLY
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def profile_from_args(args: Sequence[str]) -> str | None:
|
|
255
|
+
for index, arg in enumerate(args):
|
|
256
|
+
if arg == "--profile" and index + 1 < len(args):
|
|
257
|
+
return args[index + 1]
|
|
258
|
+
if arg.startswith("--profile="):
|
|
259
|
+
return arg.split("=", 1)[1]
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def json_requested(args: Sequence[str]) -> bool:
|
|
264
|
+
return "--json" in args
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def profile_guard_error(args: Sequence[str], profile: str) -> CliAPIError | None:
|
|
268
|
+
if profile != PROFILE_AGENT_READONLY:
|
|
269
|
+
return None
|
|
270
|
+
if any(arg in {"--help", "-h"} for arg in args):
|
|
271
|
+
return None
|
|
272
|
+
command_path = command_path_from_args(args)
|
|
273
|
+
if not command_path:
|
|
274
|
+
return None
|
|
275
|
+
if command_path in READONLY_ALLOWED_COMMANDS:
|
|
276
|
+
# Retrieval-only: allow image bytes in JSON, forbid local filesystem writes.
|
|
277
|
+
if command_path in {("source", "page"), ("source", "region-image")} and (
|
|
278
|
+
args_request_output_file(args)
|
|
279
|
+
):
|
|
280
|
+
return CliAPIError(
|
|
281
|
+
code="cli.profile_forbidden",
|
|
282
|
+
message=(
|
|
283
|
+
"CLI profile agent-readonly forbids --output/-o "
|
|
284
|
+
"(local file write). Use --include-bytes for vision pixels."
|
|
285
|
+
),
|
|
286
|
+
status_code=403,
|
|
287
|
+
details={
|
|
288
|
+
"profile": profile,
|
|
289
|
+
"command": "cortex " + " ".join(command_path),
|
|
290
|
+
"flag": "--output",
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
return None
|
|
294
|
+
if command_is_incomplete_group(command_path):
|
|
295
|
+
return None
|
|
296
|
+
if command_path not in KNOWN_COMMAND_PATHS:
|
|
297
|
+
return None
|
|
298
|
+
command = "cortex " + " ".join(command_path)
|
|
299
|
+
return CliAPIError(
|
|
300
|
+
code="cli.profile_forbidden",
|
|
301
|
+
message=(
|
|
302
|
+
"CLI profile agent-readonly only allows discovery, auth, workspace "
|
|
303
|
+
"selection, and read-only knowledge retrieval commands."
|
|
304
|
+
),
|
|
305
|
+
status_code=403,
|
|
306
|
+
details={
|
|
307
|
+
"profile": profile,
|
|
308
|
+
"command": command,
|
|
309
|
+
"allowed": sorted(" ".join(path) for path in READONLY_ALLOWED_COMMANDS),
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def args_request_output_file(args: Sequence[str]) -> bool:
|
|
315
|
+
"""True when argv requests CLI --output/-o (local path write)."""
|
|
316
|
+
return any(arg in {"-o", "--output"} or arg.startswith("--output=") for arg in args)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def command_is_incomplete_group(command_path: tuple[str, ...]) -> bool:
|
|
320
|
+
return any(
|
|
321
|
+
known[: len(command_path)] == command_path and len(command_path) < len(known)
|
|
322
|
+
for known in KNOWN_COMMAND_PATHS
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def command_path_from_args(args: Sequence[str]) -> tuple[str, ...]:
|
|
327
|
+
tokens = command_tokens(args)
|
|
328
|
+
if not tokens:
|
|
329
|
+
return ()
|
|
330
|
+
first = tokens[0]
|
|
331
|
+
if first in TOP_LEVEL_COMMANDS:
|
|
332
|
+
return (first,)
|
|
333
|
+
if len(tokens) == 1 or tokens[1].startswith("-"):
|
|
334
|
+
return (first,)
|
|
335
|
+
if len(tokens) >= 2 and (first, tokens[1]) in NESTED_GROUPS:
|
|
336
|
+
if len(tokens) == 2 or tokens[2].startswith("-"):
|
|
337
|
+
return (first, tokens[1])
|
|
338
|
+
return (first, tokens[1], tokens[2])
|
|
339
|
+
return (first, tokens[1])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def command_tokens(args: Sequence[str]) -> list[str]:
|
|
343
|
+
tokens = list(args)
|
|
344
|
+
index = 0
|
|
345
|
+
while index < len(tokens):
|
|
346
|
+
arg = tokens[index]
|
|
347
|
+
if (
|
|
348
|
+
arg in ROOT_FLAGS
|
|
349
|
+
or arg.startswith("--api-url=")
|
|
350
|
+
or arg.startswith("--profile=")
|
|
351
|
+
):
|
|
352
|
+
index += 1
|
|
353
|
+
continue
|
|
354
|
+
if arg in ROOT_OPTIONS_WITH_VALUE:
|
|
355
|
+
index += 2
|
|
356
|
+
continue
|
|
357
|
+
if arg.startswith("-"):
|
|
358
|
+
index += 1
|
|
359
|
+
continue
|
|
360
|
+
return tokens[index:]
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.callback()
|
|
365
|
+
def app_callback(
|
|
366
|
+
json_output: bool = typer.Option(
|
|
367
|
+
False,
|
|
368
|
+
"--json",
|
|
369
|
+
help="Output machine-readable JSON where supported.",
|
|
370
|
+
),
|
|
371
|
+
api_url: str | None = typer.Option(
|
|
372
|
+
None,
|
|
373
|
+
"--api-url",
|
|
374
|
+
help="Backend API base URL. Defaults to CORTEX_API_URL or local backend.",
|
|
375
|
+
),
|
|
376
|
+
profile: str | None = typer.Option(
|
|
377
|
+
None,
|
|
378
|
+
"--profile",
|
|
379
|
+
help="Execution profile: full or agent-readonly.",
|
|
380
|
+
),
|
|
381
|
+
) -> None:
|
|
382
|
+
global GLOBAL_API_URL, GLOBAL_JSON, GLOBAL_PROFILE
|
|
383
|
+
GLOBAL_JSON = json_output
|
|
384
|
+
GLOBAL_API_URL = api_url.rstrip("/") if api_url else None
|
|
385
|
+
if profile is not None:
|
|
386
|
+
GLOBAL_PROFILE = normalize_cli_profile(profile) or PROFILE_FULL
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.command()
|
|
390
|
+
def version(
|
|
391
|
+
json_output: bool = typer.Option(
|
|
392
|
+
False,
|
|
393
|
+
"--json",
|
|
394
|
+
help="Output version as JSON.",
|
|
395
|
+
),
|
|
396
|
+
) -> int:
|
|
397
|
+
_emit({"cli": "cortex", "version": "0.1.0"}, json_for(json_output))
|
|
398
|
+
return EXIT_SUCCESS
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.command()
|
|
402
|
+
def doctor(
|
|
403
|
+
json_output: bool = typer.Option(
|
|
404
|
+
False,
|
|
405
|
+
"--json",
|
|
406
|
+
help="Output checks as JSON.",
|
|
407
|
+
),
|
|
408
|
+
) -> int:
|
|
409
|
+
store = StateStore()
|
|
410
|
+
state = load_state(store)
|
|
411
|
+
client = client_from_state(store, state)
|
|
412
|
+
checks = [
|
|
413
|
+
{
|
|
414
|
+
"name": "cli_package",
|
|
415
|
+
"status": "ok",
|
|
416
|
+
"message": "Python CLI package is importable.",
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"name": "api_url",
|
|
420
|
+
"status": "ok" if state.api_url else "error",
|
|
421
|
+
"message": state.api_url or "API URL is not configured.",
|
|
422
|
+
},
|
|
423
|
+
doctor_api_check("health", lambda: client.health()),
|
|
424
|
+
doctor_api_check("ready", lambda: client.ready()),
|
|
425
|
+
{
|
|
426
|
+
"name": "state_file",
|
|
427
|
+
"status": "ok" if store.path.exists() else "warn",
|
|
428
|
+
"message": str(store.path),
|
|
429
|
+
},
|
|
430
|
+
doctor_token_check(state, client),
|
|
431
|
+
doctor_workspace_check(state, client),
|
|
432
|
+
]
|
|
433
|
+
status = "error" if any(item["status"] == "error" for item in checks) else "ok"
|
|
434
|
+
payload = {"status": status, "checks": checks}
|
|
435
|
+
_emit(payload, json_for(json_output))
|
|
436
|
+
return EXIT_SUCCESS if status == "ok" else EXIT_ERROR
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.command("commands")
|
|
440
|
+
def list_commands(
|
|
441
|
+
json_output: bool = typer.Option(
|
|
442
|
+
False,
|
|
443
|
+
"--json",
|
|
444
|
+
help="Output the command tree as JSON.",
|
|
445
|
+
),
|
|
446
|
+
) -> int:
|
|
447
|
+
payload = {"groups": [group_to_dict(group) for group in COMMAND_GROUPS]}
|
|
448
|
+
|
|
449
|
+
if json_for(json_output):
|
|
450
|
+
_emit(payload, json_output=True)
|
|
451
|
+
return EXIT_SUCCESS
|
|
452
|
+
|
|
453
|
+
for group in COMMAND_GROUPS:
|
|
454
|
+
print(f"{group.name}: {group.description}")
|
|
455
|
+
for command in group.commands:
|
|
456
|
+
print(f" cortex {group.name} {command.name} - {command.description}")
|
|
457
|
+
|
|
458
|
+
return EXIT_SUCCESS
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@app.command("help")
|
|
462
|
+
def structured_help(
|
|
463
|
+
topic: Annotated[
|
|
464
|
+
list[str] | None,
|
|
465
|
+
typer.Argument(help="Command group or command path."),
|
|
466
|
+
] = None,
|
|
467
|
+
json_output: bool = typer.Option(
|
|
468
|
+
False,
|
|
469
|
+
"--json",
|
|
470
|
+
help="Output help as JSON.",
|
|
471
|
+
),
|
|
472
|
+
) -> int:
|
|
473
|
+
selected_topic = topic[0] if topic else None
|
|
474
|
+
group = next((item for item in COMMAND_GROUPS if item.name == selected_topic), None)
|
|
475
|
+
|
|
476
|
+
if selected_topic and group is None:
|
|
477
|
+
payload = {
|
|
478
|
+
"error": {
|
|
479
|
+
"code": "cli.topic_not_found",
|
|
480
|
+
"message": f"Unknown help topic: {selected_topic}",
|
|
481
|
+
"details": {"topic": selected_topic},
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
_emit(payload, json_output=True)
|
|
485
|
+
return EXIT_USAGE
|
|
486
|
+
|
|
487
|
+
groups = [group] if group else COMMAND_GROUPS
|
|
488
|
+
payload = {"groups": [group_to_dict(item) for item in groups]}
|
|
489
|
+
|
|
490
|
+
if json_for(json_output):
|
|
491
|
+
_emit(payload, json_output=True)
|
|
492
|
+
return EXIT_SUCCESS
|
|
493
|
+
|
|
494
|
+
for item in groups:
|
|
495
|
+
print(f"{item.name}: {item.description}")
|
|
496
|
+
print(f" permission: {item.permission}")
|
|
497
|
+
for command in item.commands:
|
|
498
|
+
print(f" cortex {item.name} {command.name}")
|
|
499
|
+
print(f" {command.description}")
|
|
500
|
+
|
|
501
|
+
return EXIT_SUCCESS
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@auth_app.command("login")
|
|
505
|
+
def auth_login(
|
|
506
|
+
email: str | None = typer.Option(None, "--email", help="Debug login email."),
|
|
507
|
+
method: str = typer.Option(
|
|
508
|
+
"device",
|
|
509
|
+
"--method",
|
|
510
|
+
help="Login method: device or loopback. Ignored when --email is provided.",
|
|
511
|
+
),
|
|
512
|
+
password: str | None = typer.Option(
|
|
513
|
+
None,
|
|
514
|
+
"--password",
|
|
515
|
+
help="Debug login password.",
|
|
516
|
+
),
|
|
517
|
+
display_name: str | None = typer.Option(
|
|
518
|
+
None,
|
|
519
|
+
"--display-name",
|
|
520
|
+
help="Display name for first debug login.",
|
|
521
|
+
),
|
|
522
|
+
no_browser: bool = typer.Option(
|
|
523
|
+
False,
|
|
524
|
+
"--no-browser",
|
|
525
|
+
help="Print the authorization URL instead of opening a browser.",
|
|
526
|
+
),
|
|
527
|
+
timeout_seconds: int = typer.Option(
|
|
528
|
+
180,
|
|
529
|
+
"--timeout-seconds",
|
|
530
|
+
min=1,
|
|
531
|
+
help="Seconds to wait for browser authorization.",
|
|
532
|
+
),
|
|
533
|
+
json_output: bool = typer.Option(
|
|
534
|
+
False,
|
|
535
|
+
"--json",
|
|
536
|
+
help="Output machine-readable JSON.",
|
|
537
|
+
),
|
|
538
|
+
) -> int:
|
|
539
|
+
store = StateStore()
|
|
540
|
+
state = load_state(store)
|
|
541
|
+
client = CortexClient(base_url=state.api_url)
|
|
542
|
+
return run_cli_action(
|
|
543
|
+
json_for(json_output),
|
|
544
|
+
lambda: auth_login_action(
|
|
545
|
+
email=email,
|
|
546
|
+
method=method,
|
|
547
|
+
password=password,
|
|
548
|
+
display_name=display_name,
|
|
549
|
+
no_browser=no_browser,
|
|
550
|
+
timeout_seconds=timeout_seconds,
|
|
551
|
+
json_output=json_for(json_output),
|
|
552
|
+
store=store,
|
|
553
|
+
state=state,
|
|
554
|
+
client=client,
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@auth_app.command("whoami")
|
|
560
|
+
def auth_whoami(
|
|
561
|
+
json_output: bool = typer.Option(
|
|
562
|
+
False,
|
|
563
|
+
"--json",
|
|
564
|
+
help="Output machine-readable JSON.",
|
|
565
|
+
),
|
|
566
|
+
) -> int:
|
|
567
|
+
return run_cli_action(json_for(json_output), lambda: client_for().me())
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@auth_app.command("logout")
|
|
571
|
+
def auth_logout(
|
|
572
|
+
json_output: bool = typer.Option(
|
|
573
|
+
False,
|
|
574
|
+
"--json",
|
|
575
|
+
help="Output machine-readable JSON.",
|
|
576
|
+
),
|
|
577
|
+
) -> int:
|
|
578
|
+
def action() -> dict[str, str]:
|
|
579
|
+
store = StateStore()
|
|
580
|
+
state = load_state(store)
|
|
581
|
+
client = client_from_state(store, state)
|
|
582
|
+
try:
|
|
583
|
+
if state.refresh_token:
|
|
584
|
+
client.revoke(state.refresh_token)
|
|
585
|
+
finally:
|
|
586
|
+
store.clear_token()
|
|
587
|
+
return {"status": "ok"}
|
|
588
|
+
|
|
589
|
+
return run_cli_action(json_for(json_output), action)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@workspace_app.command("list")
|
|
593
|
+
def workspace_list(
|
|
594
|
+
json_output: bool = typer.Option(
|
|
595
|
+
False,
|
|
596
|
+
"--json",
|
|
597
|
+
help="Output machine-readable JSON.",
|
|
598
|
+
),
|
|
599
|
+
) -> int:
|
|
600
|
+
return run_cli_action(
|
|
601
|
+
json_for(json_output),
|
|
602
|
+
lambda: client_for().workspace_panel(),
|
|
603
|
+
text_renderer=render_workspace_panel,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@workspace_app.command("create")
|
|
608
|
+
def workspace_create(
|
|
609
|
+
name: str = typer.Option(..., "--name", help="Workspace name."),
|
|
610
|
+
description: str | None = typer.Option(
|
|
611
|
+
None,
|
|
612
|
+
"--description",
|
|
613
|
+
help="Workspace description.",
|
|
614
|
+
),
|
|
615
|
+
json_output: bool = typer.Option(
|
|
616
|
+
False,
|
|
617
|
+
"--json",
|
|
618
|
+
help="Output machine-readable JSON.",
|
|
619
|
+
),
|
|
620
|
+
) -> int:
|
|
621
|
+
return run_cli_action(
|
|
622
|
+
json_for(json_output),
|
|
623
|
+
lambda: client_for().create_workspace(
|
|
624
|
+
name=name,
|
|
625
|
+
description=description,
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@workspace_app.command("use")
|
|
631
|
+
def workspace_use(
|
|
632
|
+
workspace_id: str,
|
|
633
|
+
json_output: bool = typer.Option(
|
|
634
|
+
False,
|
|
635
|
+
"--json",
|
|
636
|
+
help="Output machine-readable JSON.",
|
|
637
|
+
),
|
|
638
|
+
) -> int:
|
|
639
|
+
def action() -> dict[str, Any]:
|
|
640
|
+
store = StateStore()
|
|
641
|
+
state = load_state(store)
|
|
642
|
+
client = client_from_state(store, state)
|
|
643
|
+
workspace = client.get_workspace(workspace_id)
|
|
644
|
+
state.default_workspace_id = workspace_id
|
|
645
|
+
store.save(state)
|
|
646
|
+
return {
|
|
647
|
+
"status": "ok",
|
|
648
|
+
"default_workspace_id": workspace_id,
|
|
649
|
+
"workspace": workspace,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return run_cli_action(json_for(json_output), action)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@workspace_app.command("get")
|
|
656
|
+
def workspace_get(
|
|
657
|
+
workspace_id: str | None = typer.Argument(None),
|
|
658
|
+
json_output: bool = typer.Option(
|
|
659
|
+
False,
|
|
660
|
+
"--json",
|
|
661
|
+
help="Output machine-readable JSON.",
|
|
662
|
+
),
|
|
663
|
+
) -> int:
|
|
664
|
+
return run_cli_action(
|
|
665
|
+
json_for(json_output),
|
|
666
|
+
lambda: client_for().get_workspace(resolve_workspace_id(workspace_id)),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@workspace_app.command("update")
|
|
671
|
+
def workspace_update(
|
|
672
|
+
workspace_id: str | None = typer.Argument(None),
|
|
673
|
+
name: str | None = typer.Option(None, "--name", help="Workspace name."),
|
|
674
|
+
description: str | None = typer.Option(
|
|
675
|
+
None,
|
|
676
|
+
"--description",
|
|
677
|
+
help="Workspace description.",
|
|
678
|
+
),
|
|
679
|
+
json_output: bool = typer.Option(
|
|
680
|
+
False,
|
|
681
|
+
"--json",
|
|
682
|
+
help="Output machine-readable JSON.",
|
|
683
|
+
),
|
|
684
|
+
) -> int:
|
|
685
|
+
if name is None and description is None:
|
|
686
|
+
return emit_usage_error(
|
|
687
|
+
json_for(json_output),
|
|
688
|
+
"workspace.update_requires_change",
|
|
689
|
+
"Provide --name or --description.",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return run_cli_action(
|
|
693
|
+
json_for(json_output),
|
|
694
|
+
lambda: client_for().update_workspace(
|
|
695
|
+
resolve_workspace_id(workspace_id),
|
|
696
|
+
name=name,
|
|
697
|
+
description=description,
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@workspace_app.command("delete")
|
|
703
|
+
def workspace_delete(
|
|
704
|
+
workspace_id: str | None = typer.Argument(None),
|
|
705
|
+
json_output: bool = typer.Option(
|
|
706
|
+
False,
|
|
707
|
+
"--json",
|
|
708
|
+
help="Output machine-readable JSON.",
|
|
709
|
+
),
|
|
710
|
+
) -> int:
|
|
711
|
+
return run_cli_action(
|
|
712
|
+
json_for(json_output),
|
|
713
|
+
lambda: no_content_payload(
|
|
714
|
+
"workspace.deleted",
|
|
715
|
+
client_for().delete_workspace(resolve_workspace_id(workspace_id)),
|
|
716
|
+
),
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@workspace_app.command("leave")
|
|
721
|
+
def workspace_leave(
|
|
722
|
+
workspace_id: str | None = typer.Argument(None),
|
|
723
|
+
json_output: bool = typer.Option(
|
|
724
|
+
False,
|
|
725
|
+
"--json",
|
|
726
|
+
help="Output machine-readable JSON.",
|
|
727
|
+
),
|
|
728
|
+
) -> int:
|
|
729
|
+
return run_cli_action(
|
|
730
|
+
json_for(json_output),
|
|
731
|
+
lambda: no_content_payload(
|
|
732
|
+
"workspace.left",
|
|
733
|
+
client_for().leave_workspace(resolve_workspace_id(workspace_id)),
|
|
734
|
+
),
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@workspace_app.command("transfer-owner")
|
|
739
|
+
def workspace_transfer_owner(
|
|
740
|
+
workspace_id: str | None = typer.Argument(None),
|
|
741
|
+
new_owner_id: str = typer.Option(..., "--new-owner-id", help="New owner user id."),
|
|
742
|
+
json_output: bool = typer.Option(
|
|
743
|
+
False,
|
|
744
|
+
"--json",
|
|
745
|
+
help="Output machine-readable JSON.",
|
|
746
|
+
),
|
|
747
|
+
) -> int:
|
|
748
|
+
return run_cli_action(
|
|
749
|
+
json_for(json_output),
|
|
750
|
+
lambda: client_for().transfer_owner(
|
|
751
|
+
resolve_workspace_id(workspace_id),
|
|
752
|
+
new_owner_id=new_owner_id,
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@invite_app.command("list")
|
|
758
|
+
def invite_list(
|
|
759
|
+
workspace_id: str | None = typer.Argument(None),
|
|
760
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
761
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
762
|
+
query: str | None = typer.Option(None, "--query", help="Search invite email."),
|
|
763
|
+
status: str = typer.Option(
|
|
764
|
+
"pending",
|
|
765
|
+
"--status",
|
|
766
|
+
help="Invite status: pending, accepted, revoked or rejected.",
|
|
767
|
+
),
|
|
768
|
+
json_output: bool = typer.Option(
|
|
769
|
+
False,
|
|
770
|
+
"--json",
|
|
771
|
+
help="Output machine-readable JSON.",
|
|
772
|
+
),
|
|
773
|
+
) -> int:
|
|
774
|
+
if status not in {"pending", "accepted", "revoked", "rejected"}:
|
|
775
|
+
return emit_usage_error(
|
|
776
|
+
json_for(json_output),
|
|
777
|
+
"validation.invalid",
|
|
778
|
+
"--status must be pending, accepted, revoked or rejected.",
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
return run_cli_action(
|
|
782
|
+
json_for(json_output),
|
|
783
|
+
lambda: client_for().list_invites(
|
|
784
|
+
resolve_workspace_id(workspace_id),
|
|
785
|
+
page=page,
|
|
786
|
+
page_size=page_size,
|
|
787
|
+
query=query,
|
|
788
|
+
status=status,
|
|
789
|
+
),
|
|
790
|
+
text_renderer=render_invite_list,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
@invite_app.command("create")
|
|
795
|
+
def invite_create(
|
|
796
|
+
workspace_id: str | None = typer.Argument(None),
|
|
797
|
+
email: str = typer.Option(..., "--email", help="Invitee email."),
|
|
798
|
+
role: str = typer.Option(..., "--role", help="Invite role: editor or viewer."),
|
|
799
|
+
json_output: bool = typer.Option(
|
|
800
|
+
False,
|
|
801
|
+
"--json",
|
|
802
|
+
help="Output machine-readable JSON.",
|
|
803
|
+
),
|
|
804
|
+
) -> int:
|
|
805
|
+
if role not in {"editor", "viewer"}:
|
|
806
|
+
return emit_usage_error(
|
|
807
|
+
json_for(json_output),
|
|
808
|
+
"validation.invalid",
|
|
809
|
+
"--role must be editor or viewer.",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
return run_cli_action(
|
|
813
|
+
json_for(json_output),
|
|
814
|
+
lambda: client_for().create_invite(
|
|
815
|
+
resolve_workspace_id(workspace_id),
|
|
816
|
+
email=email,
|
|
817
|
+
role=role,
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@invite_app.command("revoke")
|
|
823
|
+
def invite_revoke(
|
|
824
|
+
invite_id: str,
|
|
825
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
826
|
+
json_output: bool = typer.Option(
|
|
827
|
+
False,
|
|
828
|
+
"--json",
|
|
829
|
+
help="Output machine-readable JSON.",
|
|
830
|
+
),
|
|
831
|
+
) -> int:
|
|
832
|
+
return run_cli_action(
|
|
833
|
+
json_for(json_output),
|
|
834
|
+
lambda: no_content_payload(
|
|
835
|
+
"invite.revoked",
|
|
836
|
+
client_for().revoke_invite(resolve_workspace_id(workspace_id), invite_id),
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@invite_app.command("enter")
|
|
842
|
+
def invite_enter(
|
|
843
|
+
invite_id: str,
|
|
844
|
+
json_output: bool = typer.Option(
|
|
845
|
+
False,
|
|
846
|
+
"--json",
|
|
847
|
+
help="Output machine-readable JSON.",
|
|
848
|
+
),
|
|
849
|
+
) -> int:
|
|
850
|
+
return run_cli_action(
|
|
851
|
+
json_for(json_output),
|
|
852
|
+
lambda: client_for().enter_invite(invite_id),
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@invite_app.command("reject")
|
|
857
|
+
def invite_reject(
|
|
858
|
+
invite_id: str,
|
|
859
|
+
json_output: bool = typer.Option(
|
|
860
|
+
False,
|
|
861
|
+
"--json",
|
|
862
|
+
help="Output machine-readable JSON.",
|
|
863
|
+
),
|
|
864
|
+
) -> int:
|
|
865
|
+
return run_cli_action(
|
|
866
|
+
json_for(json_output),
|
|
867
|
+
lambda: client_for().reject_invite(invite_id),
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
@invite_link_app.command("list")
|
|
872
|
+
def invite_link_list(
|
|
873
|
+
workspace_id: str | None = typer.Argument(None),
|
|
874
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
875
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
876
|
+
json_output: bool = typer.Option(
|
|
877
|
+
False,
|
|
878
|
+
"--json",
|
|
879
|
+
help="Output machine-readable JSON.",
|
|
880
|
+
),
|
|
881
|
+
) -> int:
|
|
882
|
+
return run_cli_action(
|
|
883
|
+
json_for(json_output),
|
|
884
|
+
lambda: client_for().list_invite_links(
|
|
885
|
+
resolve_workspace_id(workspace_id),
|
|
886
|
+
page=page,
|
|
887
|
+
page_size=page_size,
|
|
888
|
+
),
|
|
889
|
+
text_renderer=render_invite_link_list,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
@invite_link_app.command("create")
|
|
894
|
+
def invite_link_create(
|
|
895
|
+
workspace_id: str | None = typer.Argument(None),
|
|
896
|
+
name: str = typer.Option("公共入口链接", "--name", help="Invite link name."),
|
|
897
|
+
json_output: bool = typer.Option(
|
|
898
|
+
False,
|
|
899
|
+
"--json",
|
|
900
|
+
help="Output machine-readable JSON.",
|
|
901
|
+
),
|
|
902
|
+
) -> int:
|
|
903
|
+
return run_cli_action(
|
|
904
|
+
json_for(json_output),
|
|
905
|
+
lambda: client_for().create_invite_link(
|
|
906
|
+
resolve_workspace_id(workspace_id),
|
|
907
|
+
name=name,
|
|
908
|
+
),
|
|
909
|
+
text_renderer=render_invite_link_created,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
@invite_link_app.command("revoke")
|
|
914
|
+
def invite_link_revoke(
|
|
915
|
+
link_id: str,
|
|
916
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
917
|
+
json_output: bool = typer.Option(
|
|
918
|
+
False,
|
|
919
|
+
"--json",
|
|
920
|
+
help="Output machine-readable JSON.",
|
|
921
|
+
),
|
|
922
|
+
) -> int:
|
|
923
|
+
return run_cli_action(
|
|
924
|
+
json_for(json_output),
|
|
925
|
+
lambda: no_content_payload(
|
|
926
|
+
"invite_link.revoked",
|
|
927
|
+
client_for().revoke_invite_link(
|
|
928
|
+
resolve_workspace_id(workspace_id),
|
|
929
|
+
link_id,
|
|
930
|
+
),
|
|
931
|
+
),
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@invite_link_app.command("enter")
|
|
936
|
+
def invite_link_enter(
|
|
937
|
+
token_or_url: str,
|
|
938
|
+
json_output: bool = typer.Option(
|
|
939
|
+
False,
|
|
940
|
+
"--json",
|
|
941
|
+
help="Output machine-readable JSON.",
|
|
942
|
+
),
|
|
943
|
+
) -> int:
|
|
944
|
+
return run_cli_action(
|
|
945
|
+
json_for(json_output),
|
|
946
|
+
lambda: client_for().enter_invite_link(token_or_url),
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
@member_app.command("list")
|
|
951
|
+
def member_list(
|
|
952
|
+
workspace_id: str | None = typer.Argument(None),
|
|
953
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
954
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
955
|
+
query: str | None = typer.Option(None, "--query", help="Search name or email."),
|
|
956
|
+
json_output: bool = typer.Option(
|
|
957
|
+
False,
|
|
958
|
+
"--json",
|
|
959
|
+
help="Output machine-readable JSON.",
|
|
960
|
+
),
|
|
961
|
+
) -> int:
|
|
962
|
+
return run_cli_action(
|
|
963
|
+
json_for(json_output),
|
|
964
|
+
lambda: client_for().list_members(
|
|
965
|
+
resolve_workspace_id(workspace_id),
|
|
966
|
+
page=page,
|
|
967
|
+
page_size=page_size,
|
|
968
|
+
query=query,
|
|
969
|
+
),
|
|
970
|
+
text_renderer=render_member_list,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
@member_app.command("set-role")
|
|
975
|
+
def member_set_role(
|
|
976
|
+
user_id: str,
|
|
977
|
+
role: str = typer.Option(..., "--role", help="Member role: editor or viewer."),
|
|
978
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
979
|
+
json_output: bool = typer.Option(
|
|
980
|
+
False,
|
|
981
|
+
"--json",
|
|
982
|
+
help="Output machine-readable JSON.",
|
|
983
|
+
),
|
|
984
|
+
) -> int:
|
|
985
|
+
if role not in {"editor", "viewer"}:
|
|
986
|
+
return emit_usage_error(
|
|
987
|
+
json_for(json_output),
|
|
988
|
+
"validation.invalid",
|
|
989
|
+
"--role must be editor or viewer.",
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
return run_cli_action(
|
|
993
|
+
json_for(json_output),
|
|
994
|
+
lambda: client_for().update_member_role(
|
|
995
|
+
resolve_workspace_id(workspace_id),
|
|
996
|
+
user_id,
|
|
997
|
+
role=role,
|
|
998
|
+
),
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@member_app.command("remove")
|
|
1003
|
+
def member_remove(
|
|
1004
|
+
user_id: str,
|
|
1005
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1006
|
+
json_output: bool = typer.Option(
|
|
1007
|
+
False,
|
|
1008
|
+
"--json",
|
|
1009
|
+
help="Output machine-readable JSON.",
|
|
1010
|
+
),
|
|
1011
|
+
) -> int:
|
|
1012
|
+
return run_cli_action(
|
|
1013
|
+
json_for(json_output),
|
|
1014
|
+
lambda: no_content_payload(
|
|
1015
|
+
"member.removed",
|
|
1016
|
+
client_for().remove_member(resolve_workspace_id(workspace_id), user_id),
|
|
1017
|
+
),
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
@kb_app.command("list")
|
|
1022
|
+
def kb_list(
|
|
1023
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1024
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
1025
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
1026
|
+
query: str | None = typer.Option(
|
|
1027
|
+
None,
|
|
1028
|
+
"--query",
|
|
1029
|
+
help="Search name or description.",
|
|
1030
|
+
),
|
|
1031
|
+
json_output: bool = typer.Option(
|
|
1032
|
+
False,
|
|
1033
|
+
"--json",
|
|
1034
|
+
help="Output machine-readable JSON.",
|
|
1035
|
+
),
|
|
1036
|
+
) -> int:
|
|
1037
|
+
return run_cli_action(
|
|
1038
|
+
json_for(json_output),
|
|
1039
|
+
lambda: client_for().list_knowledge_bases(
|
|
1040
|
+
resolve_workspace_id(workspace_id),
|
|
1041
|
+
page=page,
|
|
1042
|
+
page_size=page_size,
|
|
1043
|
+
query=query,
|
|
1044
|
+
),
|
|
1045
|
+
text_renderer=render_kb_list,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@kb_app.command("create")
|
|
1050
|
+
def kb_create(
|
|
1051
|
+
name: str = typer.Option(..., "--name", help="Knowledge base name."),
|
|
1052
|
+
description: str | None = typer.Option(None, "--description"),
|
|
1053
|
+
pipeline_id: str | None = typer.Option(None, "--pipeline-id"),
|
|
1054
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1055
|
+
json_output: bool = typer.Option(
|
|
1056
|
+
False,
|
|
1057
|
+
"--json",
|
|
1058
|
+
help="Output machine-readable JSON.",
|
|
1059
|
+
),
|
|
1060
|
+
) -> int:
|
|
1061
|
+
return run_cli_action(
|
|
1062
|
+
json_for(json_output),
|
|
1063
|
+
lambda: client_for().create_knowledge_base(
|
|
1064
|
+
resolve_workspace_id(workspace_id),
|
|
1065
|
+
name=name,
|
|
1066
|
+
description=description,
|
|
1067
|
+
pipeline_id=pipeline_id,
|
|
1068
|
+
),
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
@kb_app.command("get")
|
|
1073
|
+
def kb_get(
|
|
1074
|
+
kb_id: str,
|
|
1075
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1076
|
+
json_output: bool = typer.Option(
|
|
1077
|
+
False,
|
|
1078
|
+
"--json",
|
|
1079
|
+
help="Output machine-readable JSON.",
|
|
1080
|
+
),
|
|
1081
|
+
) -> int:
|
|
1082
|
+
return run_cli_action(
|
|
1083
|
+
json_for(json_output),
|
|
1084
|
+
lambda: client_for().get_knowledge_base(
|
|
1085
|
+
resolve_workspace_id(workspace_id),
|
|
1086
|
+
kb_id,
|
|
1087
|
+
),
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
@kb_app.command("update")
|
|
1092
|
+
def kb_update(
|
|
1093
|
+
kb_id: str,
|
|
1094
|
+
name: str | None = typer.Option(None, "--name"),
|
|
1095
|
+
description: str | None = typer.Option(None, "--description"),
|
|
1096
|
+
pipeline_id: str | None = typer.Option(None, "--pipeline-id"),
|
|
1097
|
+
clear_pipeline: bool = typer.Option(False, "--clear-pipeline"),
|
|
1098
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1099
|
+
json_output: bool = typer.Option(
|
|
1100
|
+
False,
|
|
1101
|
+
"--json",
|
|
1102
|
+
help="Output machine-readable JSON.",
|
|
1103
|
+
),
|
|
1104
|
+
) -> int:
|
|
1105
|
+
if pipeline_id and clear_pipeline:
|
|
1106
|
+
return emit_usage_error(
|
|
1107
|
+
json_for(json_output),
|
|
1108
|
+
"validation.invalid",
|
|
1109
|
+
"Use either --pipeline-id or --clear-pipeline.",
|
|
1110
|
+
)
|
|
1111
|
+
payload = compact_payload(
|
|
1112
|
+
{
|
|
1113
|
+
"name": name,
|
|
1114
|
+
"description": description,
|
|
1115
|
+
"pipeline_id": None if clear_pipeline else pipeline_id,
|
|
1116
|
+
},
|
|
1117
|
+
force_keys={"pipeline_id"} if clear_pipeline else set(),
|
|
1118
|
+
)
|
|
1119
|
+
if not payload:
|
|
1120
|
+
return emit_usage_error(
|
|
1121
|
+
json_for(json_output),
|
|
1122
|
+
"kb.update_requires_change",
|
|
1123
|
+
"Provide --name, --description, --pipeline-id or --clear-pipeline.",
|
|
1124
|
+
)
|
|
1125
|
+
return run_cli_action(
|
|
1126
|
+
json_for(json_output),
|
|
1127
|
+
lambda: client_for().update_knowledge_base(
|
|
1128
|
+
resolve_workspace_id(workspace_id),
|
|
1129
|
+
kb_id,
|
|
1130
|
+
payload,
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@kb_app.command("delete")
|
|
1136
|
+
def kb_delete(
|
|
1137
|
+
kb_id: str,
|
|
1138
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1139
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
1140
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
1141
|
+
json_output: bool = typer.Option(
|
|
1142
|
+
False,
|
|
1143
|
+
"--json",
|
|
1144
|
+
help="Output machine-readable JSON.",
|
|
1145
|
+
),
|
|
1146
|
+
) -> int:
|
|
1147
|
+
return destructive_action(
|
|
1148
|
+
json_for(json_output),
|
|
1149
|
+
dry_run=dry_run,
|
|
1150
|
+
yes=yes,
|
|
1151
|
+
dry_run_payload={"status": "dry_run", "action": "kb.delete", "kb_id": kb_id},
|
|
1152
|
+
action=lambda: no_content_payload(
|
|
1153
|
+
"kb.deleted",
|
|
1154
|
+
client_for().delete_knowledge_base(
|
|
1155
|
+
resolve_workspace_id(workspace_id),
|
|
1156
|
+
kb_id,
|
|
1157
|
+
),
|
|
1158
|
+
),
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
@kb_order_app.command("set")
|
|
1163
|
+
def kb_order_set(
|
|
1164
|
+
kb_order: Annotated[
|
|
1165
|
+
list[str] | None,
|
|
1166
|
+
typer.Option(
|
|
1167
|
+
"--kb-order",
|
|
1168
|
+
help="Knowledge base order entry as <kb-id>:<sort-order>.",
|
|
1169
|
+
),
|
|
1170
|
+
] = None,
|
|
1171
|
+
clear: bool = typer.Option(
|
|
1172
|
+
False,
|
|
1173
|
+
"--clear",
|
|
1174
|
+
help="Clear the current user's custom knowledge base order.",
|
|
1175
|
+
),
|
|
1176
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1177
|
+
json_output: bool = typer.Option(
|
|
1178
|
+
False,
|
|
1179
|
+
"--json",
|
|
1180
|
+
help="Output machine-readable JSON.",
|
|
1181
|
+
),
|
|
1182
|
+
) -> int:
|
|
1183
|
+
json_enabled = json_for(json_output)
|
|
1184
|
+
parsed_order = parse_kb_order_options(kb_order, json_enabled)
|
|
1185
|
+
if isinstance(parsed_order, int):
|
|
1186
|
+
return parsed_order
|
|
1187
|
+
if clear and parsed_order:
|
|
1188
|
+
return emit_usage_error(
|
|
1189
|
+
json_enabled,
|
|
1190
|
+
"kb.order_clear_conflict",
|
|
1191
|
+
"Use either --clear or --kb-order entries, not both.",
|
|
1192
|
+
)
|
|
1193
|
+
if not clear and not parsed_order:
|
|
1194
|
+
return emit_usage_error(
|
|
1195
|
+
json_enabled,
|
|
1196
|
+
"kb.order_requires_items",
|
|
1197
|
+
"Provide at least one --kb-order <kb-id>:<sort-order> entry or --clear.",
|
|
1198
|
+
)
|
|
1199
|
+
order_items = [] if clear else parsed_order
|
|
1200
|
+
return run_cli_action(
|
|
1201
|
+
json_enabled,
|
|
1202
|
+
lambda: client_for().update_knowledge_base_order(
|
|
1203
|
+
resolve_workspace_id(workspace_id),
|
|
1204
|
+
order_items,
|
|
1205
|
+
),
|
|
1206
|
+
text_renderer=render_kb_order,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
@kb_permissions_app.command("get")
|
|
1211
|
+
def kb_permissions_get(
|
|
1212
|
+
kb_id: str,
|
|
1213
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1214
|
+
json_output: bool = typer.Option(
|
|
1215
|
+
False,
|
|
1216
|
+
"--json",
|
|
1217
|
+
help="Output machine-readable JSON.",
|
|
1218
|
+
),
|
|
1219
|
+
) -> int:
|
|
1220
|
+
return run_cli_action(
|
|
1221
|
+
json_for(json_output),
|
|
1222
|
+
lambda: client_for().get_knowledge_base_permissions(
|
|
1223
|
+
resolve_workspace_id(workspace_id),
|
|
1224
|
+
kb_id,
|
|
1225
|
+
),
|
|
1226
|
+
text_renderer=render_kb_permissions,
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
@kb_permissions_app.command("set")
|
|
1231
|
+
def kb_permissions_set(
|
|
1232
|
+
kb_id: str,
|
|
1233
|
+
edit_policy: str = typer.Option(
|
|
1234
|
+
...,
|
|
1235
|
+
"--edit-policy",
|
|
1236
|
+
help="all_editors or selected_editors.",
|
|
1237
|
+
),
|
|
1238
|
+
editor_user_id: Annotated[
|
|
1239
|
+
list[str] | None,
|
|
1240
|
+
typer.Option("--editor-user-id"),
|
|
1241
|
+
] = None,
|
|
1242
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1243
|
+
json_output: bool = typer.Option(
|
|
1244
|
+
False,
|
|
1245
|
+
"--json",
|
|
1246
|
+
help="Output machine-readable JSON.",
|
|
1247
|
+
),
|
|
1248
|
+
) -> int:
|
|
1249
|
+
if edit_policy not in {
|
|
1250
|
+
"all_editors",
|
|
1251
|
+
"selected_editors",
|
|
1252
|
+
}:
|
|
1253
|
+
return emit_usage_error(
|
|
1254
|
+
json_for(json_output),
|
|
1255
|
+
"validation.invalid",
|
|
1256
|
+
"--edit-policy must be all_editors or selected_editors.",
|
|
1257
|
+
)
|
|
1258
|
+
return run_cli_action(
|
|
1259
|
+
json_for(json_output),
|
|
1260
|
+
lambda: client_for().update_knowledge_base_permissions(
|
|
1261
|
+
resolve_workspace_id(workspace_id),
|
|
1262
|
+
kb_id,
|
|
1263
|
+
edit_policy=edit_policy,
|
|
1264
|
+
editor_user_ids=editor_user_id or [],
|
|
1265
|
+
),
|
|
1266
|
+
text_renderer=render_kb_permissions,
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
@folder_app.command("tree")
|
|
1271
|
+
def folder_tree(
|
|
1272
|
+
kb_id: str = typer.Option(..., "--kb", help="Knowledge base id."),
|
|
1273
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1274
|
+
json_output: bool = typer.Option(
|
|
1275
|
+
False,
|
|
1276
|
+
"--json",
|
|
1277
|
+
help="Output machine-readable JSON.",
|
|
1278
|
+
),
|
|
1279
|
+
) -> int:
|
|
1280
|
+
return run_cli_action(
|
|
1281
|
+
json_for(json_output),
|
|
1282
|
+
lambda: client_for().get_folder_tree(resolve_workspace_id(workspace_id), kb_id),
|
|
1283
|
+
text_renderer=render_folder_tree,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
@folder_app.command("mkdir")
|
|
1288
|
+
def folder_mkdir(
|
|
1289
|
+
kb_id: str = typer.Option(..., "--kb", help="Knowledge base id."),
|
|
1290
|
+
name: str = typer.Option(..., "--name", help="Folder name."),
|
|
1291
|
+
parent_id: str | None = typer.Option(None, "--parent-id"),
|
|
1292
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1293
|
+
json_output: bool = typer.Option(
|
|
1294
|
+
False,
|
|
1295
|
+
"--json",
|
|
1296
|
+
help="Output machine-readable JSON.",
|
|
1297
|
+
),
|
|
1298
|
+
) -> int:
|
|
1299
|
+
return run_cli_action(
|
|
1300
|
+
json_for(json_output),
|
|
1301
|
+
lambda: client_for().create_folder(
|
|
1302
|
+
resolve_workspace_id(workspace_id),
|
|
1303
|
+
kb_id,
|
|
1304
|
+
name=name,
|
|
1305
|
+
parent_id=parent_id,
|
|
1306
|
+
),
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
@folder_app.command("mv")
|
|
1311
|
+
def folder_mv(
|
|
1312
|
+
folder_id: str,
|
|
1313
|
+
kb_id: str = typer.Option(..., "--kb", help="Knowledge base id."),
|
|
1314
|
+
name: str | None = typer.Option(None, "--name"),
|
|
1315
|
+
parent_id: str | None = typer.Option(None, "--parent-id"),
|
|
1316
|
+
root: bool = typer.Option(False, "--root", help="Move folder to KB root."),
|
|
1317
|
+
sort_order: int | None = typer.Option(None, "--sort-order"),
|
|
1318
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1319
|
+
json_output: bool = typer.Option(
|
|
1320
|
+
False,
|
|
1321
|
+
"--json",
|
|
1322
|
+
help="Output machine-readable JSON.",
|
|
1323
|
+
),
|
|
1324
|
+
) -> int:
|
|
1325
|
+
if parent_id and root:
|
|
1326
|
+
return emit_usage_error(
|
|
1327
|
+
json_for(json_output),
|
|
1328
|
+
"validation.invalid",
|
|
1329
|
+
"Use either --parent-id or --root.",
|
|
1330
|
+
)
|
|
1331
|
+
payload = compact_payload(
|
|
1332
|
+
{"name": name, "parent_id": parent_id, "sort_order": sort_order},
|
|
1333
|
+
force_keys={"parent_id"} if root else set(),
|
|
1334
|
+
)
|
|
1335
|
+
if not payload:
|
|
1336
|
+
return emit_usage_error(
|
|
1337
|
+
json_for(json_output),
|
|
1338
|
+
"folder.mv_requires_change",
|
|
1339
|
+
"Provide --name, --parent-id, --root or --sort-order.",
|
|
1340
|
+
)
|
|
1341
|
+
return run_cli_action(
|
|
1342
|
+
json_for(json_output),
|
|
1343
|
+
lambda: client_for().update_folder(
|
|
1344
|
+
resolve_workspace_id(workspace_id),
|
|
1345
|
+
kb_id,
|
|
1346
|
+
folder_id,
|
|
1347
|
+
payload,
|
|
1348
|
+
),
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
@folder_app.command("rm")
|
|
1353
|
+
def folder_rm(
|
|
1354
|
+
folder_id: str,
|
|
1355
|
+
kb_id: str = typer.Option(..., "--kb", help="Knowledge base id."),
|
|
1356
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1357
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
1358
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
1359
|
+
json_output: bool = typer.Option(
|
|
1360
|
+
False,
|
|
1361
|
+
"--json",
|
|
1362
|
+
help="Output machine-readable JSON.",
|
|
1363
|
+
),
|
|
1364
|
+
) -> int:
|
|
1365
|
+
return destructive_action(
|
|
1366
|
+
json_for(json_output),
|
|
1367
|
+
dry_run=dry_run,
|
|
1368
|
+
yes=yes,
|
|
1369
|
+
dry_run_payload={
|
|
1370
|
+
"status": "dry_run",
|
|
1371
|
+
"action": "folder.rm",
|
|
1372
|
+
"folder_id": folder_id,
|
|
1373
|
+
"kb_id": kb_id,
|
|
1374
|
+
},
|
|
1375
|
+
action=lambda: no_content_payload(
|
|
1376
|
+
"folder.deleted",
|
|
1377
|
+
client_for().delete_folder(
|
|
1378
|
+
resolve_workspace_id(workspace_id),
|
|
1379
|
+
kb_id,
|
|
1380
|
+
folder_id,
|
|
1381
|
+
),
|
|
1382
|
+
),
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
@item_app.command("list")
|
|
1387
|
+
def item_list(
|
|
1388
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1389
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
1390
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
1391
|
+
query: str | None = typer.Option(None, "--query", help="Search title."),
|
|
1392
|
+
available: bool = typer.Option(
|
|
1393
|
+
False,
|
|
1394
|
+
"--available",
|
|
1395
|
+
help="Filter available items.",
|
|
1396
|
+
),
|
|
1397
|
+
unavailable: bool = typer.Option(
|
|
1398
|
+
False,
|
|
1399
|
+
"--unavailable",
|
|
1400
|
+
help="Filter unavailable items.",
|
|
1401
|
+
),
|
|
1402
|
+
locked: bool = typer.Option(False, "--locked", help="Filter locked items."),
|
|
1403
|
+
unlocked: bool = typer.Option(False, "--unlocked", help="Filter unlocked items."),
|
|
1404
|
+
kb_id: str | None = typer.Option(None, "--kb"),
|
|
1405
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1406
|
+
unclassified: bool = typer.Option(False, "--unclassified"),
|
|
1407
|
+
json_output: bool = typer.Option(
|
|
1408
|
+
False,
|
|
1409
|
+
"--json",
|
|
1410
|
+
help="Output machine-readable JSON.",
|
|
1411
|
+
),
|
|
1412
|
+
) -> int:
|
|
1413
|
+
json_enabled = json_for(json_output)
|
|
1414
|
+
is_available = resolve_bool_filter(
|
|
1415
|
+
positive=available,
|
|
1416
|
+
negative=unavailable,
|
|
1417
|
+
positive_name="--available",
|
|
1418
|
+
negative_name="--unavailable",
|
|
1419
|
+
json_output=json_enabled,
|
|
1420
|
+
)
|
|
1421
|
+
if type(is_available) is int:
|
|
1422
|
+
return is_available
|
|
1423
|
+
is_locked = resolve_bool_filter(
|
|
1424
|
+
positive=locked,
|
|
1425
|
+
negative=unlocked,
|
|
1426
|
+
positive_name="--locked",
|
|
1427
|
+
negative_name="--unlocked",
|
|
1428
|
+
json_output=json_enabled,
|
|
1429
|
+
)
|
|
1430
|
+
if type(is_locked) is int:
|
|
1431
|
+
return is_locked
|
|
1432
|
+
return run_cli_action(
|
|
1433
|
+
json_enabled,
|
|
1434
|
+
lambda: client_for().list_items(
|
|
1435
|
+
resolve_workspace_id(workspace_id),
|
|
1436
|
+
page=page,
|
|
1437
|
+
page_size=page_size,
|
|
1438
|
+
query=query,
|
|
1439
|
+
is_available=is_available,
|
|
1440
|
+
is_locked=is_locked,
|
|
1441
|
+
kb_id=kb_id,
|
|
1442
|
+
folder_id=folder_id,
|
|
1443
|
+
unclassified=unclassified,
|
|
1444
|
+
),
|
|
1445
|
+
text_renderer=render_item_list,
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
@item_app.command("get")
|
|
1450
|
+
def item_get(
|
|
1451
|
+
item_id: str,
|
|
1452
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1453
|
+
json_output: bool = typer.Option(
|
|
1454
|
+
False,
|
|
1455
|
+
"--json",
|
|
1456
|
+
help="Output machine-readable JSON.",
|
|
1457
|
+
),
|
|
1458
|
+
) -> int:
|
|
1459
|
+
return run_cli_action(
|
|
1460
|
+
json_for(json_output),
|
|
1461
|
+
lambda: client_for().get_item(resolve_workspace_id(workspace_id), item_id),
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
@item_app.command("create")
|
|
1466
|
+
def item_create(
|
|
1467
|
+
title: str = typer.Option(..., "--title"),
|
|
1468
|
+
unavailable: bool = typer.Option(
|
|
1469
|
+
False,
|
|
1470
|
+
"--unavailable",
|
|
1471
|
+
help="Create item outside default search and recall.",
|
|
1472
|
+
),
|
|
1473
|
+
locked: bool = typer.Option(
|
|
1474
|
+
False,
|
|
1475
|
+
"--locked",
|
|
1476
|
+
"--lock",
|
|
1477
|
+
help="Create locked item.",
|
|
1478
|
+
),
|
|
1479
|
+
pipeline_id: str | None = typer.Option(None, "--pipeline-id"),
|
|
1480
|
+
processing_config: str | None = typer.Option(None, "--processing-config-json"),
|
|
1481
|
+
metadata: Annotated[list[str] | None, typer.Option("--metadata")] = None,
|
|
1482
|
+
kb_id: str | None = typer.Option(None, "--kb"),
|
|
1483
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1484
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1485
|
+
json_output: bool = typer.Option(
|
|
1486
|
+
False,
|
|
1487
|
+
"--json",
|
|
1488
|
+
help="Output machine-readable JSON.",
|
|
1489
|
+
),
|
|
1490
|
+
) -> int:
|
|
1491
|
+
json_enabled = json_for(json_output)
|
|
1492
|
+
payload_or_error = build_item_payload(
|
|
1493
|
+
title=title,
|
|
1494
|
+
is_available=not unavailable,
|
|
1495
|
+
is_locked=locked,
|
|
1496
|
+
pipeline_id=pipeline_id,
|
|
1497
|
+
processing_config=processing_config,
|
|
1498
|
+
metadata=metadata,
|
|
1499
|
+
kb_id=kb_id,
|
|
1500
|
+
folder_id=folder_id,
|
|
1501
|
+
json_output=json_enabled,
|
|
1502
|
+
)
|
|
1503
|
+
if isinstance(payload_or_error, int):
|
|
1504
|
+
return payload_or_error
|
|
1505
|
+
return run_cli_action(
|
|
1506
|
+
json_enabled,
|
|
1507
|
+
lambda: client_for().create_item(
|
|
1508
|
+
resolve_workspace_id(workspace_id),
|
|
1509
|
+
payload_or_error,
|
|
1510
|
+
),
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
@item_app.command("update")
|
|
1515
|
+
def item_update(
|
|
1516
|
+
item_id: str,
|
|
1517
|
+
title: str | None = typer.Option(None, "--title"),
|
|
1518
|
+
available: bool = typer.Option(
|
|
1519
|
+
False,
|
|
1520
|
+
"--available",
|
|
1521
|
+
help="Mark item available.",
|
|
1522
|
+
),
|
|
1523
|
+
unavailable: bool = typer.Option(
|
|
1524
|
+
False,
|
|
1525
|
+
"--unavailable",
|
|
1526
|
+
help="Mark item unavailable.",
|
|
1527
|
+
),
|
|
1528
|
+
lock_item: bool = typer.Option(False, "--lock", help="Lock item writes."),
|
|
1529
|
+
unlock_item: bool = typer.Option(False, "--unlock", help="Unlock item writes."),
|
|
1530
|
+
pipeline_id: str | None = typer.Option(None, "--pipeline-id"),
|
|
1531
|
+
clear_pipeline: bool = typer.Option(False, "--clear-pipeline"),
|
|
1532
|
+
processing_config: str | None = typer.Option(None, "--processing-config-json"),
|
|
1533
|
+
metadata: Annotated[list[str] | None, typer.Option("--metadata")] = None,
|
|
1534
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1535
|
+
json_output: bool = typer.Option(
|
|
1536
|
+
False,
|
|
1537
|
+
"--json",
|
|
1538
|
+
help="Output machine-readable JSON.",
|
|
1539
|
+
),
|
|
1540
|
+
) -> int:
|
|
1541
|
+
json_enabled = json_for(json_output)
|
|
1542
|
+
if pipeline_id and clear_pipeline:
|
|
1543
|
+
return emit_usage_error(
|
|
1544
|
+
json_enabled,
|
|
1545
|
+
"validation.invalid",
|
|
1546
|
+
"Use either --pipeline-id or --clear-pipeline.",
|
|
1547
|
+
)
|
|
1548
|
+
is_available = resolve_bool_filter(
|
|
1549
|
+
positive=available,
|
|
1550
|
+
negative=unavailable,
|
|
1551
|
+
positive_name="--available",
|
|
1552
|
+
negative_name="--unavailable",
|
|
1553
|
+
json_output=json_enabled,
|
|
1554
|
+
)
|
|
1555
|
+
if type(is_available) is int:
|
|
1556
|
+
return is_available
|
|
1557
|
+
is_locked = resolve_bool_filter(
|
|
1558
|
+
positive=lock_item,
|
|
1559
|
+
negative=unlock_item,
|
|
1560
|
+
positive_name="--lock",
|
|
1561
|
+
negative_name="--unlock",
|
|
1562
|
+
json_output=json_enabled,
|
|
1563
|
+
)
|
|
1564
|
+
if type(is_locked) is int:
|
|
1565
|
+
return is_locked
|
|
1566
|
+
parsed_config = parse_json_object(processing_config, json_enabled)
|
|
1567
|
+
if isinstance(parsed_config, int):
|
|
1568
|
+
return parsed_config
|
|
1569
|
+
parsed_metadata = parse_metadata_options(metadata, json_enabled)
|
|
1570
|
+
if isinstance(parsed_metadata, int):
|
|
1571
|
+
return parsed_metadata
|
|
1572
|
+
payload = compact_payload(
|
|
1573
|
+
{
|
|
1574
|
+
"title": title,
|
|
1575
|
+
"is_available": is_available,
|
|
1576
|
+
"is_locked": is_locked,
|
|
1577
|
+
"pipeline_id": None if clear_pipeline else pipeline_id,
|
|
1578
|
+
"processing_config": parsed_config,
|
|
1579
|
+
},
|
|
1580
|
+
force_keys={"pipeline_id"} if clear_pipeline else set(),
|
|
1581
|
+
)
|
|
1582
|
+
if not payload and metadata is None:
|
|
1583
|
+
return emit_usage_error(
|
|
1584
|
+
json_enabled,
|
|
1585
|
+
"item.update_requires_change",
|
|
1586
|
+
"Provide an item field or --metadata.",
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
def action() -> dict[str, Any]:
|
|
1590
|
+
workspace = resolve_workspace_id(workspace_id)
|
|
1591
|
+
client = client_for()
|
|
1592
|
+
item = (
|
|
1593
|
+
client.update_item(workspace, item_id, payload)
|
|
1594
|
+
if payload
|
|
1595
|
+
else client.get_item(workspace, item_id)
|
|
1596
|
+
)
|
|
1597
|
+
if metadata is not None:
|
|
1598
|
+
item = client.replace_item_metadata(workspace, item_id, parsed_metadata)
|
|
1599
|
+
return item
|
|
1600
|
+
|
|
1601
|
+
return run_cli_action(json_enabled, action)
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
@item_app.command("delete")
|
|
1605
|
+
def item_delete(
|
|
1606
|
+
item_id: str,
|
|
1607
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1608
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
1609
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
1610
|
+
json_output: bool = typer.Option(
|
|
1611
|
+
False,
|
|
1612
|
+
"--json",
|
|
1613
|
+
help="Output machine-readable JSON.",
|
|
1614
|
+
),
|
|
1615
|
+
) -> int:
|
|
1616
|
+
return destructive_action(
|
|
1617
|
+
json_for(json_output),
|
|
1618
|
+
dry_run=dry_run,
|
|
1619
|
+
yes=yes,
|
|
1620
|
+
dry_run_payload={
|
|
1621
|
+
"status": "dry_run",
|
|
1622
|
+
"action": "item.delete",
|
|
1623
|
+
"item_id": item_id,
|
|
1624
|
+
},
|
|
1625
|
+
action=lambda: no_content_payload(
|
|
1626
|
+
"item.deleted",
|
|
1627
|
+
client_for().delete_item(resolve_workspace_id(workspace_id), item_id),
|
|
1628
|
+
),
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
@item_app.command("mount")
|
|
1633
|
+
def item_mount(
|
|
1634
|
+
item_id: str,
|
|
1635
|
+
kb_id: str = typer.Option(..., "--kb"),
|
|
1636
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1637
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1638
|
+
json_output: bool = typer.Option(
|
|
1639
|
+
False,
|
|
1640
|
+
"--json",
|
|
1641
|
+
help="Output machine-readable JSON.",
|
|
1642
|
+
),
|
|
1643
|
+
) -> int:
|
|
1644
|
+
return run_cli_action(
|
|
1645
|
+
json_for(json_output),
|
|
1646
|
+
lambda: client_for().create_item_mount(
|
|
1647
|
+
resolve_workspace_id(workspace_id),
|
|
1648
|
+
item_id,
|
|
1649
|
+
kb_id=kb_id,
|
|
1650
|
+
folder_id=folder_id,
|
|
1651
|
+
),
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
@item_app.command("unmount")
|
|
1656
|
+
def item_unmount(
|
|
1657
|
+
item_id: str,
|
|
1658
|
+
kb_id: str = typer.Option(..., "--kb"),
|
|
1659
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1660
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1661
|
+
json_output: bool = typer.Option(
|
|
1662
|
+
False,
|
|
1663
|
+
"--json",
|
|
1664
|
+
help="Output machine-readable JSON.",
|
|
1665
|
+
),
|
|
1666
|
+
) -> int:
|
|
1667
|
+
return run_cli_action(
|
|
1668
|
+
json_for(json_output),
|
|
1669
|
+
lambda: client_for().delete_item_mount(
|
|
1670
|
+
resolve_workspace_id(workspace_id),
|
|
1671
|
+
item_id,
|
|
1672
|
+
kb_id=kb_id,
|
|
1673
|
+
folder_id=folder_id,
|
|
1674
|
+
),
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
@trash_app.command("list")
|
|
1679
|
+
def trash_list(
|
|
1680
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1681
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
1682
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
1683
|
+
json_output: bool = typer.Option(
|
|
1684
|
+
False,
|
|
1685
|
+
"--json",
|
|
1686
|
+
help="Output machine-readable JSON.",
|
|
1687
|
+
),
|
|
1688
|
+
) -> int:
|
|
1689
|
+
return run_cli_action(
|
|
1690
|
+
json_for(json_output),
|
|
1691
|
+
lambda: client_for().list_trash_entries(
|
|
1692
|
+
resolve_workspace_id(workspace_id),
|
|
1693
|
+
page=page,
|
|
1694
|
+
page_size=page_size,
|
|
1695
|
+
),
|
|
1696
|
+
text_renderer=render_trash_list,
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
@trash_app.command("restore")
|
|
1701
|
+
def trash_restore(
|
|
1702
|
+
entry_id: str,
|
|
1703
|
+
kb_id: str | None = typer.Option(None, "--kb"),
|
|
1704
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1705
|
+
item_id: str | None = typer.Option(None, "--item"),
|
|
1706
|
+
unclassified: bool = typer.Option(False, "--unclassified"),
|
|
1707
|
+
original: bool = typer.Option(False, "--original"),
|
|
1708
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1709
|
+
json_output: bool = typer.Option(
|
|
1710
|
+
False,
|
|
1711
|
+
"--json",
|
|
1712
|
+
help="Output machine-readable JSON.",
|
|
1713
|
+
),
|
|
1714
|
+
) -> int:
|
|
1715
|
+
json_enabled = json_for(json_output)
|
|
1716
|
+
selected_targets = [
|
|
1717
|
+
bool(kb_id),
|
|
1718
|
+
bool(folder_id),
|
|
1719
|
+
bool(item_id),
|
|
1720
|
+
unclassified,
|
|
1721
|
+
original,
|
|
1722
|
+
]
|
|
1723
|
+
if sum(selected_targets) != 1:
|
|
1724
|
+
return emit_usage_error(
|
|
1725
|
+
json_enabled,
|
|
1726
|
+
"trash.restore_target_required",
|
|
1727
|
+
"Provide exactly one of --kb, --folder, --item, "
|
|
1728
|
+
"--unclassified, or --original.",
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
def action() -> dict[str, Any]:
|
|
1732
|
+
workspace = resolve_workspace_id(workspace_id)
|
|
1733
|
+
client = client_for()
|
|
1734
|
+
target = trash_restore_target(
|
|
1735
|
+
kb_id=kb_id,
|
|
1736
|
+
folder_id=folder_id,
|
|
1737
|
+
item_id=item_id,
|
|
1738
|
+
unclassified=unclassified,
|
|
1739
|
+
original=original,
|
|
1740
|
+
)
|
|
1741
|
+
return client.restore_trash_entry(
|
|
1742
|
+
workspace,
|
|
1743
|
+
entry_id,
|
|
1744
|
+
target=target,
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
return run_cli_action(
|
|
1748
|
+
json_enabled,
|
|
1749
|
+
action,
|
|
1750
|
+
)
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
@trash_app.command("purge")
|
|
1754
|
+
def trash_purge(
|
|
1755
|
+
entry_id: str,
|
|
1756
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1757
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
1758
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
1759
|
+
json_output: bool = typer.Option(
|
|
1760
|
+
False,
|
|
1761
|
+
"--json",
|
|
1762
|
+
help="Output machine-readable JSON.",
|
|
1763
|
+
),
|
|
1764
|
+
) -> int:
|
|
1765
|
+
return destructive_action(
|
|
1766
|
+
json_for(json_output),
|
|
1767
|
+
dry_run=dry_run,
|
|
1768
|
+
yes=yes,
|
|
1769
|
+
dry_run_payload={
|
|
1770
|
+
"status": "dry_run",
|
|
1771
|
+
"action": "trash.purge",
|
|
1772
|
+
"entry_id": entry_id,
|
|
1773
|
+
},
|
|
1774
|
+
action=lambda: no_content_payload(
|
|
1775
|
+
"trash.purged",
|
|
1776
|
+
client_for().purge_trash_entry(
|
|
1777
|
+
resolve_workspace_id(workspace_id),
|
|
1778
|
+
entry_id,
|
|
1779
|
+
),
|
|
1780
|
+
),
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
@trash_app.command("discard")
|
|
1785
|
+
def trash_discard(
|
|
1786
|
+
entry_id: str,
|
|
1787
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1788
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
1789
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
1790
|
+
json_output: bool = typer.Option(
|
|
1791
|
+
False,
|
|
1792
|
+
"--json",
|
|
1793
|
+
help="Output machine-readable JSON.",
|
|
1794
|
+
),
|
|
1795
|
+
) -> int:
|
|
1796
|
+
return destructive_action(
|
|
1797
|
+
json_for(json_output),
|
|
1798
|
+
dry_run=dry_run,
|
|
1799
|
+
yes=yes,
|
|
1800
|
+
dry_run_payload={
|
|
1801
|
+
"status": "dry_run",
|
|
1802
|
+
"action": "trash.discard",
|
|
1803
|
+
"entry_id": entry_id,
|
|
1804
|
+
},
|
|
1805
|
+
action=lambda: no_content_payload(
|
|
1806
|
+
"trash.discarded",
|
|
1807
|
+
client_for().discard_trash_entry(
|
|
1808
|
+
resolve_workspace_id(workspace_id),
|
|
1809
|
+
entry_id,
|
|
1810
|
+
),
|
|
1811
|
+
),
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
@item_app.command("export")
|
|
1816
|
+
def item_export(
|
|
1817
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1818
|
+
query: str | None = typer.Option(None, "--query", help="Search title."),
|
|
1819
|
+
available: bool = typer.Option(
|
|
1820
|
+
False,
|
|
1821
|
+
"--available",
|
|
1822
|
+
help="Filter available items.",
|
|
1823
|
+
),
|
|
1824
|
+
unavailable: bool = typer.Option(
|
|
1825
|
+
False,
|
|
1826
|
+
"--unavailable",
|
|
1827
|
+
help="Filter unavailable items.",
|
|
1828
|
+
),
|
|
1829
|
+
locked: bool = typer.Option(False, "--locked", help="Filter locked items."),
|
|
1830
|
+
unlocked: bool = typer.Option(False, "--unlocked", help="Filter unlocked items."),
|
|
1831
|
+
kb_id: str | None = typer.Option(None, "--kb"),
|
|
1832
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1833
|
+
unclassified: bool = typer.Option(False, "--unclassified"),
|
|
1834
|
+
page_size: int = typer.Option(100, "--page-size", min=1, max=100),
|
|
1835
|
+
jsonl: bool = typer.Option(False, "--jsonl", help="Output one item per line."),
|
|
1836
|
+
json_output: bool = typer.Option(
|
|
1837
|
+
False,
|
|
1838
|
+
"--json",
|
|
1839
|
+
help="Output machine-readable JSON.",
|
|
1840
|
+
),
|
|
1841
|
+
) -> int:
|
|
1842
|
+
json_enabled = json_for(json_output)
|
|
1843
|
+
is_available = resolve_bool_filter(
|
|
1844
|
+
positive=available,
|
|
1845
|
+
negative=unavailable,
|
|
1846
|
+
positive_name="--available",
|
|
1847
|
+
negative_name="--unavailable",
|
|
1848
|
+
json_output=json_enabled,
|
|
1849
|
+
)
|
|
1850
|
+
if type(is_available) is int:
|
|
1851
|
+
return is_available
|
|
1852
|
+
is_locked = resolve_bool_filter(
|
|
1853
|
+
positive=locked,
|
|
1854
|
+
negative=unlocked,
|
|
1855
|
+
positive_name="--locked",
|
|
1856
|
+
negative_name="--unlocked",
|
|
1857
|
+
json_output=json_enabled,
|
|
1858
|
+
)
|
|
1859
|
+
if type(is_locked) is int:
|
|
1860
|
+
return is_locked
|
|
1861
|
+
|
|
1862
|
+
def action() -> dict[str, Any]:
|
|
1863
|
+
items = collect_items(
|
|
1864
|
+
workspace_id=resolve_workspace_id(workspace_id),
|
|
1865
|
+
query=query,
|
|
1866
|
+
is_available=is_available,
|
|
1867
|
+
is_locked=is_locked,
|
|
1868
|
+
kb_id=kb_id,
|
|
1869
|
+
folder_id=folder_id,
|
|
1870
|
+
unclassified=unclassified,
|
|
1871
|
+
page_size=page_size,
|
|
1872
|
+
)
|
|
1873
|
+
return {"items": items, "total": len(items)}
|
|
1874
|
+
|
|
1875
|
+
if not jsonl:
|
|
1876
|
+
return run_cli_action(json_enabled, action)
|
|
1877
|
+
|
|
1878
|
+
try:
|
|
1879
|
+
for item in action()["items"]:
|
|
1880
|
+
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
1881
|
+
except CliAPIError as exc:
|
|
1882
|
+
emit_cli_error(True, exc)
|
|
1883
|
+
return exit_code_for_api_error(exc)
|
|
1884
|
+
return EXIT_SUCCESS
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
@item_app.command("import")
|
|
1888
|
+
def item_import(
|
|
1889
|
+
path: Path,
|
|
1890
|
+
kb_id: str | None = typer.Option(None, "--kb"),
|
|
1891
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1892
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1893
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
1894
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm write action."),
|
|
1895
|
+
json_output: bool = typer.Option(
|
|
1896
|
+
False,
|
|
1897
|
+
"--json",
|
|
1898
|
+
help="Output machine-readable JSON.",
|
|
1899
|
+
),
|
|
1900
|
+
) -> int:
|
|
1901
|
+
json_enabled = json_for(json_output)
|
|
1902
|
+
if folder_id and not kb_id:
|
|
1903
|
+
return emit_usage_error(
|
|
1904
|
+
json_enabled,
|
|
1905
|
+
"validation.invalid",
|
|
1906
|
+
"--folder requires --kb.",
|
|
1907
|
+
)
|
|
1908
|
+
if not dry_run and not yes:
|
|
1909
|
+
return emit_usage_error(
|
|
1910
|
+
json_enabled,
|
|
1911
|
+
"confirmation.required",
|
|
1912
|
+
"Pass --yes to import items or use --dry-run.",
|
|
1913
|
+
)
|
|
1914
|
+
rows_or_error = read_import_rows(path, json_enabled)
|
|
1915
|
+
if isinstance(rows_or_error, int):
|
|
1916
|
+
return rows_or_error
|
|
1917
|
+
payloads_or_error = import_payloads(
|
|
1918
|
+
rows_or_error,
|
|
1919
|
+
kb_id=kb_id,
|
|
1920
|
+
folder_id=folder_id,
|
|
1921
|
+
json_output=json_enabled,
|
|
1922
|
+
)
|
|
1923
|
+
if isinstance(payloads_or_error, int):
|
|
1924
|
+
return payloads_or_error
|
|
1925
|
+
if dry_run:
|
|
1926
|
+
_emit(
|
|
1927
|
+
{
|
|
1928
|
+
"status": "dry_run",
|
|
1929
|
+
"items": payloads_or_error,
|
|
1930
|
+
"total": len(payloads_or_error),
|
|
1931
|
+
},
|
|
1932
|
+
json_enabled,
|
|
1933
|
+
)
|
|
1934
|
+
return EXIT_SUCCESS
|
|
1935
|
+
|
|
1936
|
+
def action() -> dict[str, Any]:
|
|
1937
|
+
workspace = resolve_workspace_id(workspace_id)
|
|
1938
|
+
client = client_for()
|
|
1939
|
+
created = [
|
|
1940
|
+
client.create_item(workspace, payload) for payload in payloads_or_error
|
|
1941
|
+
]
|
|
1942
|
+
return {"status": "ok", "items": created, "total": len(created)}
|
|
1943
|
+
|
|
1944
|
+
return run_cli_action(json_enabled, action)
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
@file_app.command("upload")
|
|
1948
|
+
def file_upload(
|
|
1949
|
+
path: Path,
|
|
1950
|
+
title: str | None = typer.Option(None, "--title"),
|
|
1951
|
+
kb_id: str | None = typer.Option(None, "--kb"),
|
|
1952
|
+
folder_id: str | None = typer.Option(None, "--folder"),
|
|
1953
|
+
relation_type: str = typer.Option("primary", "--relation-type"),
|
|
1954
|
+
pipeline_id: str | None = typer.Option(None, "--pipeline-id"),
|
|
1955
|
+
metadata: Annotated[list[str] | None, typer.Option("--metadata")] = None,
|
|
1956
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
1957
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
1958
|
+
json_output: bool = typer.Option(
|
|
1959
|
+
False,
|
|
1960
|
+
"--json",
|
|
1961
|
+
help="Output machine-readable JSON.",
|
|
1962
|
+
),
|
|
1963
|
+
) -> int:
|
|
1964
|
+
json_enabled = json_for(json_output)
|
|
1965
|
+
validation = validate_file_command(
|
|
1966
|
+
path=path,
|
|
1967
|
+
relation_type=relation_type,
|
|
1968
|
+
folder_id=folder_id,
|
|
1969
|
+
kb_id=kb_id,
|
|
1970
|
+
json_output=json_enabled,
|
|
1971
|
+
)
|
|
1972
|
+
if validation is not None:
|
|
1973
|
+
return validation
|
|
1974
|
+
parsed_metadata = parse_metadata_options(metadata, json_enabled)
|
|
1975
|
+
if isinstance(parsed_metadata, int):
|
|
1976
|
+
return parsed_metadata
|
|
1977
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
1978
|
+
if isinstance(workspace, int):
|
|
1979
|
+
return workspace
|
|
1980
|
+
return run_cli_action(
|
|
1981
|
+
json_enabled,
|
|
1982
|
+
lambda: client_for().upload_knowledge_item(
|
|
1983
|
+
workspace,
|
|
1984
|
+
path=path,
|
|
1985
|
+
title=title,
|
|
1986
|
+
kb_id=kb_id,
|
|
1987
|
+
folder_id=folder_id,
|
|
1988
|
+
relation_type=relation_type,
|
|
1989
|
+
pipeline_id=pipeline_id,
|
|
1990
|
+
metadata=parsed_metadata,
|
|
1991
|
+
idempotency_key=idempotency_key,
|
|
1992
|
+
),
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
@file_app.command("add")
|
|
1997
|
+
def file_add(
|
|
1998
|
+
item_id: str,
|
|
1999
|
+
path: Path,
|
|
2000
|
+
relation_type: str = typer.Option("attachment", "--relation-type"),
|
|
2001
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
2002
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2003
|
+
json_output: bool = typer.Option(
|
|
2004
|
+
False,
|
|
2005
|
+
"--json",
|
|
2006
|
+
help="Output machine-readable JSON.",
|
|
2007
|
+
),
|
|
2008
|
+
) -> int:
|
|
2009
|
+
json_enabled = json_for(json_output)
|
|
2010
|
+
validation = validate_file_command(
|
|
2011
|
+
path=path,
|
|
2012
|
+
relation_type=relation_type,
|
|
2013
|
+
folder_id=None,
|
|
2014
|
+
kb_id=None,
|
|
2015
|
+
json_output=json_enabled,
|
|
2016
|
+
)
|
|
2017
|
+
if validation is not None:
|
|
2018
|
+
return validation
|
|
2019
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2020
|
+
if isinstance(workspace, int):
|
|
2021
|
+
return workspace
|
|
2022
|
+
return run_cli_action(
|
|
2023
|
+
json_enabled,
|
|
2024
|
+
lambda: client_for().add_file(
|
|
2025
|
+
workspace,
|
|
2026
|
+
item_id,
|
|
2027
|
+
path=path,
|
|
2028
|
+
relation_type=relation_type,
|
|
2029
|
+
idempotency_key=idempotency_key,
|
|
2030
|
+
),
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
@file_app.command("list")
|
|
2035
|
+
def file_list(
|
|
2036
|
+
item_id: str,
|
|
2037
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2038
|
+
json_output: bool = typer.Option(
|
|
2039
|
+
False,
|
|
2040
|
+
"--json",
|
|
2041
|
+
help="Output machine-readable JSON.",
|
|
2042
|
+
),
|
|
2043
|
+
) -> int:
|
|
2044
|
+
json_enabled = json_for(json_output)
|
|
2045
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2046
|
+
if isinstance(workspace, int):
|
|
2047
|
+
return workspace
|
|
2048
|
+
return run_cli_action(
|
|
2049
|
+
json_enabled,
|
|
2050
|
+
lambda: client_for().list_files(workspace, item_id),
|
|
2051
|
+
text_renderer=render_file_list,
|
|
2052
|
+
)
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
@file_app.command("download")
|
|
2056
|
+
def file_download(
|
|
2057
|
+
file_id: str,
|
|
2058
|
+
output: Annotated[Path | None, typer.Option("--output")] = None,
|
|
2059
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2060
|
+
json_output: bool = typer.Option(
|
|
2061
|
+
False,
|
|
2062
|
+
"--json",
|
|
2063
|
+
help="Output machine-readable JSON.",
|
|
2064
|
+
),
|
|
2065
|
+
) -> int:
|
|
2066
|
+
json_enabled = json_for(json_output)
|
|
2067
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2068
|
+
if isinstance(workspace, int):
|
|
2069
|
+
return workspace
|
|
2070
|
+
|
|
2071
|
+
def action() -> dict[str, Any]:
|
|
2072
|
+
target = output or Path(file_id)
|
|
2073
|
+
content = client_for().download_file(
|
|
2074
|
+
workspace,
|
|
2075
|
+
file_id,
|
|
2076
|
+
)
|
|
2077
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2078
|
+
target.write_bytes(content)
|
|
2079
|
+
return {"status": "ok", "path": str(target), "bytes": len(content)}
|
|
2080
|
+
|
|
2081
|
+
return run_cli_action(json_enabled, action)
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
@file_app.command("preview")
|
|
2085
|
+
def file_preview(
|
|
2086
|
+
file_id: str,
|
|
2087
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2088
|
+
json_output: bool = typer.Option(
|
|
2089
|
+
False,
|
|
2090
|
+
"--json",
|
|
2091
|
+
help="Output machine-readable JSON.",
|
|
2092
|
+
),
|
|
2093
|
+
) -> int:
|
|
2094
|
+
json_enabled = json_for(json_output)
|
|
2095
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2096
|
+
if isinstance(workspace, int):
|
|
2097
|
+
return workspace
|
|
2098
|
+
return run_cli_action(
|
|
2099
|
+
json_enabled,
|
|
2100
|
+
lambda: client_for().preview_file(workspace, file_id),
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
@file_app.command("read")
|
|
2105
|
+
def file_read(
|
|
2106
|
+
file_id: str,
|
|
2107
|
+
format_: str = typer.Option("text", "--format"),
|
|
2108
|
+
range_: str | None = typer.Option(None, "--range"),
|
|
2109
|
+
source: str = typer.Option("auto", "--source"),
|
|
2110
|
+
max_chars: int | None = typer.Option(None, "--max-chars", min=1),
|
|
2111
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2112
|
+
json_output: bool = typer.Option(
|
|
2113
|
+
False,
|
|
2114
|
+
"--json",
|
|
2115
|
+
help="Output machine-readable JSON.",
|
|
2116
|
+
),
|
|
2117
|
+
) -> int:
|
|
2118
|
+
json_enabled = json_for(json_output)
|
|
2119
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2120
|
+
if isinstance(workspace, int):
|
|
2121
|
+
return workspace
|
|
2122
|
+
return run_cli_action(
|
|
2123
|
+
json_enabled,
|
|
2124
|
+
lambda: client_for().read_file(
|
|
2125
|
+
workspace,
|
|
2126
|
+
file_id,
|
|
2127
|
+
format_=format_,
|
|
2128
|
+
range_=range_,
|
|
2129
|
+
source=source,
|
|
2130
|
+
max_chars=max_chars,
|
|
2131
|
+
),
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
@file_app.command("delete")
|
|
2136
|
+
def file_delete(
|
|
2137
|
+
item_id: str,
|
|
2138
|
+
file_id: str,
|
|
2139
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2140
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
2141
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
2142
|
+
json_output: bool = typer.Option(
|
|
2143
|
+
False,
|
|
2144
|
+
"--json",
|
|
2145
|
+
help="Output machine-readable JSON.",
|
|
2146
|
+
),
|
|
2147
|
+
) -> int:
|
|
2148
|
+
json_enabled = json_for(json_output)
|
|
2149
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2150
|
+
if isinstance(workspace, int):
|
|
2151
|
+
return workspace
|
|
2152
|
+
return destructive_action(
|
|
2153
|
+
json_enabled,
|
|
2154
|
+
dry_run=dry_run,
|
|
2155
|
+
yes=yes,
|
|
2156
|
+
dry_run_payload={
|
|
2157
|
+
"status": "dry_run",
|
|
2158
|
+
"action": "file.delete",
|
|
2159
|
+
"item_id": item_id,
|
|
2160
|
+
"file_id": file_id,
|
|
2161
|
+
},
|
|
2162
|
+
action=lambda: no_content_payload(
|
|
2163
|
+
"file.deleted",
|
|
2164
|
+
delete_file_action(
|
|
2165
|
+
workspace_id=workspace,
|
|
2166
|
+
item_id=item_id,
|
|
2167
|
+
file_id=file_id,
|
|
2168
|
+
),
|
|
2169
|
+
),
|
|
2170
|
+
)
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
@pipeline_app.command("list")
|
|
2174
|
+
def pipeline_list(
|
|
2175
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2176
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2177
|
+
) -> int:
|
|
2178
|
+
json_enabled = json_for(json_output)
|
|
2179
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2180
|
+
if isinstance(workspace, int):
|
|
2181
|
+
return workspace
|
|
2182
|
+
return run_cli_action(
|
|
2183
|
+
json_enabled,
|
|
2184
|
+
lambda: client_for().list_pipelines(workspace),
|
|
2185
|
+
text_renderer=render_pipeline_list,
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
@pipeline_app.command("create")
|
|
2190
|
+
def pipeline_create(
|
|
2191
|
+
name: str = typer.Option(..., "--name"),
|
|
2192
|
+
goal: str = typer.Option("semantic_search", "--goal"),
|
|
2193
|
+
config: str | None = typer.Option(None, "--config"),
|
|
2194
|
+
is_default: bool = typer.Option(False, "--default"),
|
|
2195
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2196
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2197
|
+
) -> int:
|
|
2198
|
+
json_enabled = json_for(json_output)
|
|
2199
|
+
if goal not in {"storage_only", "semantic_search", "semantic_search_plus"}:
|
|
2200
|
+
return emit_usage_error(
|
|
2201
|
+
json_enabled,
|
|
2202
|
+
"validation.invalid",
|
|
2203
|
+
"--goal must be storage_only, semantic_search or semantic_search_plus.",
|
|
2204
|
+
)
|
|
2205
|
+
parsed_config = parse_json_object(config, json_enabled)
|
|
2206
|
+
if isinstance(parsed_config, int):
|
|
2207
|
+
return parsed_config
|
|
2208
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2209
|
+
if isinstance(workspace, int):
|
|
2210
|
+
return workspace
|
|
2211
|
+
return run_cli_action(
|
|
2212
|
+
json_enabled,
|
|
2213
|
+
lambda: client_for().create_pipeline(
|
|
2214
|
+
workspace,
|
|
2215
|
+
name=name,
|
|
2216
|
+
goal=goal,
|
|
2217
|
+
config=parsed_config or {},
|
|
2218
|
+
is_default=is_default,
|
|
2219
|
+
),
|
|
2220
|
+
)
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
@pipeline_app.command("get")
|
|
2224
|
+
def pipeline_get(
|
|
2225
|
+
pipeline_id: str,
|
|
2226
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2227
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2228
|
+
) -> int:
|
|
2229
|
+
json_enabled = json_for(json_output)
|
|
2230
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2231
|
+
if isinstance(workspace, int):
|
|
2232
|
+
return workspace
|
|
2233
|
+
return run_cli_action(
|
|
2234
|
+
json_enabled,
|
|
2235
|
+
lambda: client_for().get_pipeline(workspace, pipeline_id),
|
|
2236
|
+
)
|
|
2237
|
+
|
|
2238
|
+
|
|
2239
|
+
@pipeline_app.command("update")
|
|
2240
|
+
def pipeline_update(
|
|
2241
|
+
pipeline_id: str,
|
|
2242
|
+
name: str | None = typer.Option(None, "--name"),
|
|
2243
|
+
goal: str | None = typer.Option(None, "--goal"),
|
|
2244
|
+
config: str | None = typer.Option(None, "--config"),
|
|
2245
|
+
set_default: bool = typer.Option(False, "--default"),
|
|
2246
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2247
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2248
|
+
) -> int:
|
|
2249
|
+
json_enabled = json_for(json_output)
|
|
2250
|
+
if goal is not None and goal not in {
|
|
2251
|
+
"storage_only",
|
|
2252
|
+
"semantic_search",
|
|
2253
|
+
"semantic_search_plus",
|
|
2254
|
+
}:
|
|
2255
|
+
return emit_usage_error(
|
|
2256
|
+
json_enabled,
|
|
2257
|
+
"validation.invalid",
|
|
2258
|
+
"--goal must be storage_only, semantic_search or semantic_search_plus.",
|
|
2259
|
+
)
|
|
2260
|
+
parsed_config = parse_json_object(config, json_enabled)
|
|
2261
|
+
if isinstance(parsed_config, int):
|
|
2262
|
+
return parsed_config
|
|
2263
|
+
payload = compact_payload(
|
|
2264
|
+
{
|
|
2265
|
+
"name": name,
|
|
2266
|
+
"goal": goal,
|
|
2267
|
+
"config": parsed_config,
|
|
2268
|
+
"is_default": True if set_default else None,
|
|
2269
|
+
}
|
|
2270
|
+
)
|
|
2271
|
+
if not payload:
|
|
2272
|
+
return emit_usage_error(
|
|
2273
|
+
json_enabled,
|
|
2274
|
+
"pipeline.update_requires_change",
|
|
2275
|
+
"Provide --name, --goal, --config or --default.",
|
|
2276
|
+
)
|
|
2277
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2278
|
+
if isinstance(workspace, int):
|
|
2279
|
+
return workspace
|
|
2280
|
+
return run_cli_action(
|
|
2281
|
+
json_enabled,
|
|
2282
|
+
lambda: client_for().update_pipeline(workspace, pipeline_id, payload),
|
|
2283
|
+
)
|
|
2284
|
+
|
|
2285
|
+
|
|
2286
|
+
@pipeline_app.command("delete")
|
|
2287
|
+
def pipeline_delete(
|
|
2288
|
+
pipeline_id: str,
|
|
2289
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2290
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2291
|
+
) -> int:
|
|
2292
|
+
json_enabled = json_for(json_output)
|
|
2293
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2294
|
+
if isinstance(workspace, int):
|
|
2295
|
+
return workspace
|
|
2296
|
+
return run_cli_action(
|
|
2297
|
+
json_enabled,
|
|
2298
|
+
lambda: no_content_payload(
|
|
2299
|
+
"pipeline.deleted",
|
|
2300
|
+
client_for().delete_pipeline(workspace, pipeline_id),
|
|
2301
|
+
),
|
|
2302
|
+
)
|
|
2303
|
+
|
|
2304
|
+
|
|
2305
|
+
@processing_app.command("trigger")
|
|
2306
|
+
def processing_trigger(
|
|
2307
|
+
item_id: str,
|
|
2308
|
+
pipeline_id: str | None = typer.Option(
|
|
2309
|
+
None,
|
|
2310
|
+
"--pipeline-id",
|
|
2311
|
+
help="Must match the item's current pipeline_id; not a temporary override.",
|
|
2312
|
+
),
|
|
2313
|
+
reason: str = typer.Option("manual", "--reason"),
|
|
2314
|
+
idempotency_key: str | None = typer.Option(None, "--idempotency-key"),
|
|
2315
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2316
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2317
|
+
) -> int:
|
|
2318
|
+
json_enabled = json_for(json_output)
|
|
2319
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2320
|
+
if isinstance(workspace, int):
|
|
2321
|
+
return workspace
|
|
2322
|
+
effective_idempotency_key = idempotency_key or (
|
|
2323
|
+
f"processing-trigger-{secrets.token_urlsafe(18)}"
|
|
2324
|
+
)
|
|
2325
|
+
return run_cli_action(
|
|
2326
|
+
json_enabled,
|
|
2327
|
+
lambda: client_for().trigger_processing(
|
|
2328
|
+
workspace,
|
|
2329
|
+
item_id,
|
|
2330
|
+
pipeline_id=pipeline_id,
|
|
2331
|
+
reason=reason,
|
|
2332
|
+
idempotency_key=effective_idempotency_key,
|
|
2333
|
+
),
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
|
|
2337
|
+
@processing_app.command("status")
|
|
2338
|
+
def processing_status(
|
|
2339
|
+
item_id: str,
|
|
2340
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2341
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2342
|
+
) -> int:
|
|
2343
|
+
json_enabled = json_for(json_output)
|
|
2344
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2345
|
+
if isinstance(workspace, int):
|
|
2346
|
+
return workspace
|
|
2347
|
+
return run_cli_action(
|
|
2348
|
+
json_enabled,
|
|
2349
|
+
lambda: client_for().list_processing_runs(workspace, item_id),
|
|
2350
|
+
text_renderer=render_processing_runs,
|
|
2351
|
+
)
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
@processing_app.command("get")
|
|
2355
|
+
def processing_get(
|
|
2356
|
+
run_id: str,
|
|
2357
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2358
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2359
|
+
) -> int:
|
|
2360
|
+
json_enabled = json_for(json_output)
|
|
2361
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2362
|
+
if isinstance(workspace, int):
|
|
2363
|
+
return workspace
|
|
2364
|
+
return run_cli_action(
|
|
2365
|
+
json_enabled,
|
|
2366
|
+
lambda: client_for().get_processing_run(workspace, run_id),
|
|
2367
|
+
)
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
@processing_app.command("retry")
|
|
2371
|
+
def processing_retry(
|
|
2372
|
+
run_id: str,
|
|
2373
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2374
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2375
|
+
) -> int:
|
|
2376
|
+
json_enabled = json_for(json_output)
|
|
2377
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2378
|
+
if isinstance(workspace, int):
|
|
2379
|
+
return workspace
|
|
2380
|
+
return run_cli_action(
|
|
2381
|
+
json_enabled,
|
|
2382
|
+
lambda: client_for().retry_processing_run(workspace, run_id),
|
|
2383
|
+
)
|
|
2384
|
+
|
|
2385
|
+
|
|
2386
|
+
@processing_app.command("cancel")
|
|
2387
|
+
def processing_cancel(
|
|
2388
|
+
run_id: str,
|
|
2389
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2390
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2391
|
+
) -> int:
|
|
2392
|
+
json_enabled = json_for(json_output)
|
|
2393
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2394
|
+
if isinstance(workspace, int):
|
|
2395
|
+
return workspace
|
|
2396
|
+
return run_cli_action(
|
|
2397
|
+
json_enabled,
|
|
2398
|
+
lambda: client_for().cancel_processing_run(workspace, run_id),
|
|
2399
|
+
)
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
@processing_app.command("reconcile")
|
|
2403
|
+
def processing_reconcile(
|
|
2404
|
+
status: Annotated[list[str] | None, typer.Option("--status")] = None,
|
|
2405
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2406
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2407
|
+
) -> int:
|
|
2408
|
+
json_enabled = json_for(json_output)
|
|
2409
|
+
statuses = status or ["pending", "running", "cancelling", "cancel_failed"]
|
|
2410
|
+
allowed = {
|
|
2411
|
+
"pending",
|
|
2412
|
+
"running",
|
|
2413
|
+
"cancelling",
|
|
2414
|
+
"done",
|
|
2415
|
+
"cancelled",
|
|
2416
|
+
"cancel_failed",
|
|
2417
|
+
"failed",
|
|
2418
|
+
}
|
|
2419
|
+
invalid = set(statuses) - allowed
|
|
2420
|
+
if invalid:
|
|
2421
|
+
return emit_usage_error(
|
|
2422
|
+
json_enabled,
|
|
2423
|
+
"validation.invalid",
|
|
2424
|
+
"--status must be one of: pending, running, cancelling, done, "
|
|
2425
|
+
"cancelled, cancel_failed or failed.",
|
|
2426
|
+
)
|
|
2427
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2428
|
+
if isinstance(workspace, int):
|
|
2429
|
+
return workspace
|
|
2430
|
+
return run_cli_action(
|
|
2431
|
+
json_enabled,
|
|
2432
|
+
lambda: client_for().reconcile_processing_runs(workspace, status=statuses),
|
|
2433
|
+
text_renderer=render_processing_reconcile,
|
|
2434
|
+
)
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
@search_app.command("grep")
|
|
2438
|
+
def search_grep(
|
|
2439
|
+
query: str,
|
|
2440
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
2441
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
2442
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2443
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2444
|
+
) -> int:
|
|
2445
|
+
json_enabled = json_for(json_output)
|
|
2446
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2447
|
+
if isinstance(workspace, int):
|
|
2448
|
+
return workspace
|
|
2449
|
+
return run_cli_action(
|
|
2450
|
+
json_enabled,
|
|
2451
|
+
lambda: with_search_suggestions(
|
|
2452
|
+
client_for().search_grep(
|
|
2453
|
+
workspace,
|
|
2454
|
+
query=query,
|
|
2455
|
+
page=page,
|
|
2456
|
+
page_size=page_size,
|
|
2457
|
+
),
|
|
2458
|
+
workspace_id=workspace,
|
|
2459
|
+
command="search grep",
|
|
2460
|
+
),
|
|
2461
|
+
text_renderer=render_search_list,
|
|
2462
|
+
)
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
@search_app.command("advanced")
|
|
2466
|
+
def search_advanced(
|
|
2467
|
+
query: str | None = typer.Option(None, "--query"),
|
|
2468
|
+
kb_id: Annotated[list[str] | None, typer.Option("--kb-id")] = None,
|
|
2469
|
+
folder_id: str | None = typer.Option(None, "--folder-id"),
|
|
2470
|
+
locked: bool = typer.Option(False, "--locked", help="Filter locked items."),
|
|
2471
|
+
unlocked: bool = typer.Option(False, "--unlocked", help="Filter unlocked items."),
|
|
2472
|
+
mime_type: Annotated[list[str] | None, typer.Option("--mime-type")] = None,
|
|
2473
|
+
metadata_json: str | None = typer.Option(None, "--metadata-json"),
|
|
2474
|
+
sort: str = typer.Option("relevance", "--sort"),
|
|
2475
|
+
page: int = typer.Option(1, "--page", min=1),
|
|
2476
|
+
page_size: int = typer.Option(20, "--page-size", min=1, max=100),
|
|
2477
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2478
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2479
|
+
) -> int:
|
|
2480
|
+
json_enabled = json_for(json_output)
|
|
2481
|
+
if sort not in {"relevance", "updated_at_desc", "created_at_desc"}:
|
|
2482
|
+
return emit_usage_error(
|
|
2483
|
+
json_enabled,
|
|
2484
|
+
"validation.invalid",
|
|
2485
|
+
"--sort must be relevance, updated_at_desc or created_at_desc.",
|
|
2486
|
+
)
|
|
2487
|
+
is_locked = resolve_bool_filter(
|
|
2488
|
+
positive=locked,
|
|
2489
|
+
negative=unlocked,
|
|
2490
|
+
positive_name="--locked",
|
|
2491
|
+
negative_name="--unlocked",
|
|
2492
|
+
json_output=json_enabled,
|
|
2493
|
+
)
|
|
2494
|
+
if type(is_locked) is int:
|
|
2495
|
+
return is_locked
|
|
2496
|
+
metadata = parse_json_array(metadata_json, json_enabled)
|
|
2497
|
+
if isinstance(metadata, int):
|
|
2498
|
+
return metadata
|
|
2499
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2500
|
+
if isinstance(workspace, int):
|
|
2501
|
+
return workspace
|
|
2502
|
+
payload = {
|
|
2503
|
+
"query": query,
|
|
2504
|
+
"kb_ids": kb_id,
|
|
2505
|
+
"folder_id": folder_id,
|
|
2506
|
+
"is_locked": is_locked,
|
|
2507
|
+
"mime_types": mime_type,
|
|
2508
|
+
"metadata": metadata or [],
|
|
2509
|
+
"sort": sort,
|
|
2510
|
+
"page": page,
|
|
2511
|
+
"page_size": page_size,
|
|
2512
|
+
}
|
|
2513
|
+
return run_cli_action(
|
|
2514
|
+
json_enabled,
|
|
2515
|
+
lambda: with_search_suggestions(
|
|
2516
|
+
client_for().search_advanced(
|
|
2517
|
+
workspace,
|
|
2518
|
+
payload=compact_payload(payload),
|
|
2519
|
+
),
|
|
2520
|
+
workspace_id=workspace,
|
|
2521
|
+
command="search advanced",
|
|
2522
|
+
),
|
|
2523
|
+
text_renderer=render_search_list,
|
|
2524
|
+
)
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
@search_app.command("semantic")
|
|
2528
|
+
def search_semantic(
|
|
2529
|
+
query: str,
|
|
2530
|
+
top_k: int = typer.Option(10, "--top-k", min=1, max=50),
|
|
2531
|
+
kb_id: Annotated[list[str] | None, typer.Option("--kb-id")] = None,
|
|
2532
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2533
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2534
|
+
) -> int:
|
|
2535
|
+
json_enabled = json_for(json_output)
|
|
2536
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2537
|
+
if isinstance(workspace, int):
|
|
2538
|
+
return workspace
|
|
2539
|
+
return run_cli_action(
|
|
2540
|
+
json_enabled,
|
|
2541
|
+
lambda: with_search_suggestions(
|
|
2542
|
+
client_for().search_semantic(
|
|
2543
|
+
workspace,
|
|
2544
|
+
query=query,
|
|
2545
|
+
kb_ids=kb_id,
|
|
2546
|
+
top_k=top_k,
|
|
2547
|
+
),
|
|
2548
|
+
workspace_id=workspace,
|
|
2549
|
+
command="search semantic",
|
|
2550
|
+
),
|
|
2551
|
+
text_renderer=render_search_rows,
|
|
2552
|
+
)
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
@app.command("recall")
|
|
2556
|
+
def recall(
|
|
2557
|
+
query: str,
|
|
2558
|
+
top_k: int = typer.Option(10, "--top-k", min=1, max=50),
|
|
2559
|
+
kb_id: Annotated[list[str] | None, typer.Option("--kb-id")] = None,
|
|
2560
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2561
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2562
|
+
) -> int:
|
|
2563
|
+
json_enabled = json_for(json_output)
|
|
2564
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2565
|
+
if isinstance(workspace, int):
|
|
2566
|
+
return workspace
|
|
2567
|
+
return run_cli_action(
|
|
2568
|
+
json_enabled,
|
|
2569
|
+
lambda: with_search_suggestions(
|
|
2570
|
+
client_for().search_semantic(
|
|
2571
|
+
workspace,
|
|
2572
|
+
query=query,
|
|
2573
|
+
kb_ids=kb_id,
|
|
2574
|
+
top_k=top_k,
|
|
2575
|
+
recall=True,
|
|
2576
|
+
),
|
|
2577
|
+
workspace_id=workspace,
|
|
2578
|
+
command="recall",
|
|
2579
|
+
),
|
|
2580
|
+
text_renderer=render_search_rows,
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
@chunk_app.command("get")
|
|
2585
|
+
def chunk_get(
|
|
2586
|
+
file_id: str,
|
|
2587
|
+
chunk_idx: int = typer.Option(..., "--chunk", min=0),
|
|
2588
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2589
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2590
|
+
) -> int:
|
|
2591
|
+
json_enabled = json_for(json_output)
|
|
2592
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2593
|
+
if isinstance(workspace, int):
|
|
2594
|
+
return workspace
|
|
2595
|
+
return run_cli_action(
|
|
2596
|
+
json_enabled,
|
|
2597
|
+
lambda: client_for().get_chunk(workspace, file_id, chunk_idx),
|
|
2598
|
+
text_renderer=render_chunk,
|
|
2599
|
+
)
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
@chunk_app.command("context")
|
|
2603
|
+
def chunk_context(
|
|
2604
|
+
file_id: str,
|
|
2605
|
+
chunk_idx: int = typer.Option(..., "--chunk", min=0),
|
|
2606
|
+
window: int = typer.Option(1, "--window", min=0, max=10),
|
|
2607
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2608
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2609
|
+
) -> int:
|
|
2610
|
+
json_enabled = json_for(json_output)
|
|
2611
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2612
|
+
if isinstance(workspace, int):
|
|
2613
|
+
return workspace
|
|
2614
|
+
return run_cli_action(
|
|
2615
|
+
json_enabled,
|
|
2616
|
+
lambda: client_for().get_chunk_context(
|
|
2617
|
+
workspace,
|
|
2618
|
+
file_id,
|
|
2619
|
+
chunk_idx,
|
|
2620
|
+
window=window,
|
|
2621
|
+
),
|
|
2622
|
+
text_renderer=render_chunk_context,
|
|
2623
|
+
)
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
@source_app.command("locate")
|
|
2627
|
+
def source_locate(
|
|
2628
|
+
file_id: str,
|
|
2629
|
+
chunk_idx: int = typer.Option(..., "--chunk", min=0),
|
|
2630
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2631
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2632
|
+
) -> int:
|
|
2633
|
+
json_enabled = json_for(json_output)
|
|
2634
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2635
|
+
if isinstance(workspace, int):
|
|
2636
|
+
return workspace
|
|
2637
|
+
return run_cli_action(
|
|
2638
|
+
json_enabled,
|
|
2639
|
+
lambda: client_for().locate_source(workspace, file_id, chunk_idx),
|
|
2640
|
+
text_renderer=render_source_location,
|
|
2641
|
+
)
|
|
2642
|
+
|
|
2643
|
+
|
|
2644
|
+
@source_app.command("expand")
|
|
2645
|
+
def source_expand(
|
|
2646
|
+
file_id: str,
|
|
2647
|
+
chunk_idx: int = typer.Option(..., "--chunk", min=0),
|
|
2648
|
+
mode: str = typer.Option("page", "--mode"),
|
|
2649
|
+
window: int = typer.Option(3, "--window", min=0, max=50),
|
|
2650
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2651
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2652
|
+
) -> int:
|
|
2653
|
+
json_enabled = json_for(json_output)
|
|
2654
|
+
if mode not in {"before", "after", "page"}:
|
|
2655
|
+
return emit_usage_error(
|
|
2656
|
+
json_enabled,
|
|
2657
|
+
"cli.invalid_source_mode",
|
|
2658
|
+
"source expand --mode must be one of: before, after, page.",
|
|
2659
|
+
)
|
|
2660
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2661
|
+
if isinstance(workspace, int):
|
|
2662
|
+
return workspace
|
|
2663
|
+
return run_cli_action(
|
|
2664
|
+
json_enabled,
|
|
2665
|
+
lambda: client_for().expand_source(
|
|
2666
|
+
workspace,
|
|
2667
|
+
file_id,
|
|
2668
|
+
chunk_idx,
|
|
2669
|
+
mode=mode,
|
|
2670
|
+
window=window,
|
|
2671
|
+
),
|
|
2672
|
+
text_renderer=render_source_expand,
|
|
2673
|
+
)
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
@source_app.command("page")
|
|
2677
|
+
def source_page(
|
|
2678
|
+
file_id: str,
|
|
2679
|
+
page_index: int = typer.Option(..., "--page-index", min=0),
|
|
2680
|
+
include_image: bool = typer.Option(False, "--include-image/--no-include-image"),
|
|
2681
|
+
include_bytes: bool = typer.Option(
|
|
2682
|
+
False,
|
|
2683
|
+
"--include-bytes/--no-include-bytes",
|
|
2684
|
+
help="Include PNG base64 for Agent vision (sets include_image).",
|
|
2685
|
+
),
|
|
2686
|
+
output: Annotated[
|
|
2687
|
+
Path | None,
|
|
2688
|
+
typer.Option(
|
|
2689
|
+
"--output",
|
|
2690
|
+
"-o",
|
|
2691
|
+
help="Write rendered PNG to this path (implies image fetch).",
|
|
2692
|
+
file_okay=True,
|
|
2693
|
+
dir_okay=False,
|
|
2694
|
+
writable=True,
|
|
2695
|
+
),
|
|
2696
|
+
] = None,
|
|
2697
|
+
max_width: int = typer.Option(1200, "--max-width", min=64, max=4096),
|
|
2698
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2699
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2700
|
+
) -> int:
|
|
2701
|
+
json_enabled = json_for(json_output)
|
|
2702
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2703
|
+
if isinstance(workspace, int):
|
|
2704
|
+
return workspace
|
|
2705
|
+
want_bytes = include_bytes or output is not None
|
|
2706
|
+
return run_cli_action(
|
|
2707
|
+
json_enabled,
|
|
2708
|
+
lambda: enrich_source_image_for_agent(
|
|
2709
|
+
client_for().read_source_page(
|
|
2710
|
+
workspace,
|
|
2711
|
+
file_id,
|
|
2712
|
+
page_index,
|
|
2713
|
+
include_image=include_image or want_bytes,
|
|
2714
|
+
max_width=max_width,
|
|
2715
|
+
include_bytes=want_bytes,
|
|
2716
|
+
),
|
|
2717
|
+
output_path=output,
|
|
2718
|
+
),
|
|
2719
|
+
text_renderer=render_source_page,
|
|
2720
|
+
)
|
|
2721
|
+
|
|
2722
|
+
|
|
2723
|
+
@source_app.command("region-image")
|
|
2724
|
+
def source_region_image(
|
|
2725
|
+
file_id: str,
|
|
2726
|
+
page_index: int = typer.Option(..., "--page-index", min=0),
|
|
2727
|
+
bbox: str = typer.Option(..., "--bbox", help="PDF points: x1,y1,x2,y2."),
|
|
2728
|
+
include_bytes: bool = typer.Option(
|
|
2729
|
+
True,
|
|
2730
|
+
"--include-bytes/--no-include-bytes",
|
|
2731
|
+
help="Include PNG base64 for Agent vision (default: true).",
|
|
2732
|
+
),
|
|
2733
|
+
output: Annotated[
|
|
2734
|
+
Path | None,
|
|
2735
|
+
typer.Option(
|
|
2736
|
+
"--output",
|
|
2737
|
+
"-o",
|
|
2738
|
+
help="Write rendered PNG to this path.",
|
|
2739
|
+
file_okay=True,
|
|
2740
|
+
dir_okay=False,
|
|
2741
|
+
writable=True,
|
|
2742
|
+
),
|
|
2743
|
+
] = None,
|
|
2744
|
+
max_width: int = typer.Option(1200, "--max-width", min=64, max=4096),
|
|
2745
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2746
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2747
|
+
) -> int:
|
|
2748
|
+
json_enabled = json_for(json_output)
|
|
2749
|
+
parsed_bbox = parse_bbox_option(bbox)
|
|
2750
|
+
if parsed_bbox is None:
|
|
2751
|
+
return emit_usage_error(
|
|
2752
|
+
json_enabled,
|
|
2753
|
+
"cli.invalid_bbox",
|
|
2754
|
+
"--bbox must be four numeric PDF point coordinates: x1,y1,x2,y2.",
|
|
2755
|
+
)
|
|
2756
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2757
|
+
if isinstance(workspace, int):
|
|
2758
|
+
return workspace
|
|
2759
|
+
want_bytes = include_bytes or output is not None
|
|
2760
|
+
return run_cli_action(
|
|
2761
|
+
json_enabled,
|
|
2762
|
+
lambda: enrich_source_image_for_agent(
|
|
2763
|
+
client_for().read_region_image(
|
|
2764
|
+
workspace,
|
|
2765
|
+
file_id,
|
|
2766
|
+
page_index=page_index,
|
|
2767
|
+
bbox=parsed_bbox,
|
|
2768
|
+
max_width=max_width,
|
|
2769
|
+
include_bytes=want_bytes,
|
|
2770
|
+
),
|
|
2771
|
+
output_path=output,
|
|
2772
|
+
),
|
|
2773
|
+
text_renderer=render_source_region_image,
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
@mcp_app.command("list")
|
|
2778
|
+
def mcp_list(
|
|
2779
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2780
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2781
|
+
) -> int:
|
|
2782
|
+
json_enabled = json_for(json_output)
|
|
2783
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2784
|
+
if isinstance(workspace, int):
|
|
2785
|
+
return workspace
|
|
2786
|
+
return run_cli_action(
|
|
2787
|
+
json_enabled,
|
|
2788
|
+
lambda: client_for().list_mcp_interfaces(workspace),
|
|
2789
|
+
text_renderer=render_mcp_interfaces,
|
|
2790
|
+
)
|
|
2791
|
+
|
|
2792
|
+
|
|
2793
|
+
@mcp_app.command("create")
|
|
2794
|
+
def mcp_create(
|
|
2795
|
+
name: str = typer.Option(..., "--name"),
|
|
2796
|
+
slug: str = typer.Option(..., "--slug"),
|
|
2797
|
+
description: str | None = typer.Option(None, "--description"),
|
|
2798
|
+
prompt_template: str | None = typer.Option(None, "--prompt-template"),
|
|
2799
|
+
tool: Annotated[list[str] | None, typer.Option("--tool")] = None,
|
|
2800
|
+
kb_id: Annotated[list[str] | None, typer.Option("--kb-id")] = None,
|
|
2801
|
+
auth_type: str = typer.Option("api_key", "--auth-type"),
|
|
2802
|
+
enabled: bool = typer.Option(False, "--enabled"),
|
|
2803
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2804
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2805
|
+
) -> int:
|
|
2806
|
+
json_enabled = json_for(json_output)
|
|
2807
|
+
payload_or_error = mcp_payload(
|
|
2808
|
+
name=name,
|
|
2809
|
+
description=description,
|
|
2810
|
+
prompt_template=prompt_template,
|
|
2811
|
+
tool=tool,
|
|
2812
|
+
kb_id=kb_id,
|
|
2813
|
+
auth_type=auth_type,
|
|
2814
|
+
enabled=enabled,
|
|
2815
|
+
json_output=json_enabled,
|
|
2816
|
+
)
|
|
2817
|
+
if isinstance(payload_or_error, int):
|
|
2818
|
+
return payload_or_error
|
|
2819
|
+
payload_or_error["slug"] = slug
|
|
2820
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2821
|
+
if isinstance(workspace, int):
|
|
2822
|
+
return workspace
|
|
2823
|
+
return run_cli_action(
|
|
2824
|
+
json_enabled,
|
|
2825
|
+
lambda: client_for().create_mcp_interface(workspace, payload_or_error),
|
|
2826
|
+
)
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
@mcp_app.command("get")
|
|
2830
|
+
def mcp_get(
|
|
2831
|
+
mcp_id: str,
|
|
2832
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2833
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2834
|
+
) -> int:
|
|
2835
|
+
json_enabled = json_for(json_output)
|
|
2836
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2837
|
+
if isinstance(workspace, int):
|
|
2838
|
+
return workspace
|
|
2839
|
+
return run_cli_action(
|
|
2840
|
+
json_enabled,
|
|
2841
|
+
lambda: client_for().get_mcp_interface(workspace, mcp_id),
|
|
2842
|
+
)
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
@mcp_app.command("update")
|
|
2846
|
+
def mcp_update(
|
|
2847
|
+
mcp_id: str,
|
|
2848
|
+
name: str | None = typer.Option(None, "--name"),
|
|
2849
|
+
description: str | None = typer.Option(None, "--description"),
|
|
2850
|
+
prompt_template: str | None = typer.Option(None, "--prompt-template"),
|
|
2851
|
+
tool: Annotated[list[str] | None, typer.Option("--tool")] = None,
|
|
2852
|
+
kb_id: Annotated[list[str] | None, typer.Option("--kb-id")] = None,
|
|
2853
|
+
auth_type: str | None = typer.Option(None, "--auth-type"),
|
|
2854
|
+
enable: bool = typer.Option(False, "--enable"),
|
|
2855
|
+
disable: bool = typer.Option(False, "--disable"),
|
|
2856
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2857
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2858
|
+
) -> int:
|
|
2859
|
+
json_enabled = json_for(json_output)
|
|
2860
|
+
if enable and disable:
|
|
2861
|
+
return emit_usage_error(
|
|
2862
|
+
json_enabled,
|
|
2863
|
+
"validation.invalid",
|
|
2864
|
+
"Use either --enable or --disable.",
|
|
2865
|
+
)
|
|
2866
|
+
payload = compact_payload(
|
|
2867
|
+
{
|
|
2868
|
+
"name": name,
|
|
2869
|
+
"description": description,
|
|
2870
|
+
"prompt_template": prompt_template,
|
|
2871
|
+
"enabled_tools": tool,
|
|
2872
|
+
"kb_ids": kb_id,
|
|
2873
|
+
"auth_type": auth_type,
|
|
2874
|
+
"enabled": True if enable else False if disable else None,
|
|
2875
|
+
}
|
|
2876
|
+
)
|
|
2877
|
+
if not payload:
|
|
2878
|
+
return emit_usage_error(
|
|
2879
|
+
json_enabled,
|
|
2880
|
+
"mcp.update_requires_change",
|
|
2881
|
+
"Provide an MCP field or --enable/--disable.",
|
|
2882
|
+
)
|
|
2883
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2884
|
+
if isinstance(workspace, int):
|
|
2885
|
+
return workspace
|
|
2886
|
+
return run_cli_action(
|
|
2887
|
+
json_enabled,
|
|
2888
|
+
lambda: client_for().update_mcp_interface(workspace, mcp_id, payload),
|
|
2889
|
+
)
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
@mcp_app.command("enable")
|
|
2893
|
+
def mcp_enable(
|
|
2894
|
+
mcp_id: str,
|
|
2895
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2896
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2897
|
+
) -> int:
|
|
2898
|
+
json_enabled = json_for(json_output)
|
|
2899
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2900
|
+
if isinstance(workspace, int):
|
|
2901
|
+
return workspace
|
|
2902
|
+
return run_cli_action(
|
|
2903
|
+
json_enabled,
|
|
2904
|
+
lambda: client_for().enable_mcp_interface(workspace, mcp_id),
|
|
2905
|
+
)
|
|
2906
|
+
|
|
2907
|
+
|
|
2908
|
+
@mcp_app.command("disable")
|
|
2909
|
+
def mcp_disable(
|
|
2910
|
+
mcp_id: str,
|
|
2911
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2912
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2913
|
+
) -> int:
|
|
2914
|
+
json_enabled = json_for(json_output)
|
|
2915
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2916
|
+
if isinstance(workspace, int):
|
|
2917
|
+
return workspace
|
|
2918
|
+
return run_cli_action(
|
|
2919
|
+
json_enabled,
|
|
2920
|
+
lambda: client_for().disable_mcp_interface(workspace, mcp_id),
|
|
2921
|
+
)
|
|
2922
|
+
|
|
2923
|
+
|
|
2924
|
+
@mcp_app.command("delete")
|
|
2925
|
+
def mcp_delete(
|
|
2926
|
+
mcp_id: str,
|
|
2927
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2928
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
2929
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
2930
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2931
|
+
) -> int:
|
|
2932
|
+
json_enabled = json_for(json_output)
|
|
2933
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2934
|
+
if isinstance(workspace, int):
|
|
2935
|
+
return workspace
|
|
2936
|
+
return destructive_action(
|
|
2937
|
+
json_enabled,
|
|
2938
|
+
dry_run=dry_run,
|
|
2939
|
+
yes=yes,
|
|
2940
|
+
dry_run_payload={"status": "dry_run", "action": "mcp.delete", "id": mcp_id},
|
|
2941
|
+
action=lambda: no_content_payload(
|
|
2942
|
+
"mcp.deleted",
|
|
2943
|
+
client_for().delete_mcp_interface(workspace, mcp_id),
|
|
2944
|
+
),
|
|
2945
|
+
)
|
|
2946
|
+
|
|
2947
|
+
|
|
2948
|
+
@mcp_key_app.command("list")
|
|
2949
|
+
def mcp_key_list(
|
|
2950
|
+
mcp_id: str,
|
|
2951
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2952
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2953
|
+
) -> int:
|
|
2954
|
+
json_enabled = json_for(json_output)
|
|
2955
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2956
|
+
if isinstance(workspace, int):
|
|
2957
|
+
return workspace
|
|
2958
|
+
return run_cli_action(
|
|
2959
|
+
json_enabled,
|
|
2960
|
+
lambda: client_for().list_mcp_api_keys(workspace, mcp_id),
|
|
2961
|
+
text_renderer=render_mcp_api_keys,
|
|
2962
|
+
)
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
@mcp_key_app.command("create")
|
|
2966
|
+
def mcp_key_create(
|
|
2967
|
+
mcp_id: str,
|
|
2968
|
+
name: str | None = typer.Option(None, "--name"),
|
|
2969
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2970
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2971
|
+
) -> int:
|
|
2972
|
+
json_enabled = json_for(json_output)
|
|
2973
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2974
|
+
if isinstance(workspace, int):
|
|
2975
|
+
return workspace
|
|
2976
|
+
return run_cli_action(
|
|
2977
|
+
json_enabled,
|
|
2978
|
+
lambda: client_for().create_mcp_api_key(workspace, mcp_id, name=name),
|
|
2979
|
+
)
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
@mcp_key_app.command("revoke")
|
|
2983
|
+
def mcp_key_revoke(
|
|
2984
|
+
mcp_id: str,
|
|
2985
|
+
key_id: str,
|
|
2986
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
2987
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
2988
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm destructive action."),
|
|
2989
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
2990
|
+
) -> int:
|
|
2991
|
+
json_enabled = json_for(json_output)
|
|
2992
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
2993
|
+
if isinstance(workspace, int):
|
|
2994
|
+
return workspace
|
|
2995
|
+
return destructive_action(
|
|
2996
|
+
json_enabled,
|
|
2997
|
+
dry_run=dry_run,
|
|
2998
|
+
yes=yes,
|
|
2999
|
+
dry_run_payload={
|
|
3000
|
+
"status": "dry_run",
|
|
3001
|
+
"action": "mcp.key.revoke",
|
|
3002
|
+
"mcp_id": mcp_id,
|
|
3003
|
+
"key_id": key_id,
|
|
3004
|
+
},
|
|
3005
|
+
action=lambda: no_content_payload(
|
|
3006
|
+
"mcp.key.revoked",
|
|
3007
|
+
client_for().revoke_mcp_api_key(workspace, mcp_id, key_id),
|
|
3008
|
+
),
|
|
3009
|
+
)
|
|
3010
|
+
|
|
3011
|
+
|
|
3012
|
+
@vector_index_app.command("status")
|
|
3013
|
+
def vector_index_status(
|
|
3014
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
3015
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
3016
|
+
) -> int:
|
|
3017
|
+
json_enabled = json_for(json_output)
|
|
3018
|
+
workspace = resolve_workspace_or_error(workspace_id, json_enabled)
|
|
3019
|
+
if isinstance(workspace, int):
|
|
3020
|
+
return workspace
|
|
3021
|
+
return run_cli_action(
|
|
3022
|
+
json_enabled,
|
|
3023
|
+
lambda: client_for().get_vector_index_status(workspace),
|
|
3024
|
+
)
|
|
3025
|
+
|
|
3026
|
+
|
|
3027
|
+
@vector_index_app.command("rebuild")
|
|
3028
|
+
def vector_index_rebuild(
|
|
3029
|
+
workspace_id: str = typer.Option(..., "--workspace-id"),
|
|
3030
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
3031
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm vector index rebuild."),
|
|
3032
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
3033
|
+
) -> int:
|
|
3034
|
+
json_enabled = json_for(json_output)
|
|
3035
|
+
return destructive_action(
|
|
3036
|
+
json_enabled,
|
|
3037
|
+
dry_run=dry_run,
|
|
3038
|
+
yes=yes,
|
|
3039
|
+
dry_run_payload=lambda: client_for().rebuild_vector_index(
|
|
3040
|
+
workspace_id,
|
|
3041
|
+
dry_run=True,
|
|
3042
|
+
),
|
|
3043
|
+
action=lambda: client_for().rebuild_vector_index(
|
|
3044
|
+
workspace_id,
|
|
3045
|
+
dry_run=False,
|
|
3046
|
+
),
|
|
3047
|
+
)
|
|
3048
|
+
|
|
3049
|
+
|
|
3050
|
+
@gc_app.command("workspace")
|
|
3051
|
+
def gc_workspace(
|
|
3052
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
3053
|
+
deleted_before: str | None = typer.Option(
|
|
3054
|
+
None,
|
|
3055
|
+
"--deleted-before",
|
|
3056
|
+
help="Garbage collect deleted workspaces before this date or timestamp.",
|
|
3057
|
+
),
|
|
3058
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
3059
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm physical cleanup."),
|
|
3060
|
+
include_vectors: bool = typer.Option(True, "--include-vectors/--skip-vectors"),
|
|
3061
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
3062
|
+
) -> int:
|
|
3063
|
+
json_enabled = json_for(json_output)
|
|
3064
|
+
if (workspace_id is None) == (deleted_before is None):
|
|
3065
|
+
return emit_usage_error(
|
|
3066
|
+
json_enabled,
|
|
3067
|
+
"gc.workspace_target_required",
|
|
3068
|
+
"Provide exactly one of --workspace-id or --deleted-before.",
|
|
3069
|
+
)
|
|
3070
|
+
if deleted_before is not None:
|
|
3071
|
+
cutoff = parse_gc_cutoff(deleted_before, json_enabled)
|
|
3072
|
+
if isinstance(cutoff, int):
|
|
3073
|
+
return cutoff
|
|
3074
|
+
return destructive_action(
|
|
3075
|
+
json_enabled,
|
|
3076
|
+
dry_run=dry_run,
|
|
3077
|
+
yes=yes,
|
|
3078
|
+
dry_run_payload=lambda: client_for().gc_workspaces_deleted_before(
|
|
3079
|
+
deleted_before=cutoff,
|
|
3080
|
+
dry_run=True,
|
|
3081
|
+
include_vectors=include_vectors,
|
|
3082
|
+
),
|
|
3083
|
+
action=lambda: client_for().gc_workspaces_deleted_before(
|
|
3084
|
+
deleted_before=cutoff,
|
|
3085
|
+
dry_run=False,
|
|
3086
|
+
include_vectors=include_vectors,
|
|
3087
|
+
),
|
|
3088
|
+
)
|
|
3089
|
+
|
|
3090
|
+
assert workspace_id is not None
|
|
3091
|
+
return destructive_action(
|
|
3092
|
+
json_enabled,
|
|
3093
|
+
dry_run=dry_run,
|
|
3094
|
+
yes=yes,
|
|
3095
|
+
dry_run_payload=lambda: client_for().gc_workspace(
|
|
3096
|
+
workspace_id,
|
|
3097
|
+
dry_run=True,
|
|
3098
|
+
include_vectors=include_vectors,
|
|
3099
|
+
),
|
|
3100
|
+
action=lambda: client_for().gc_workspace(
|
|
3101
|
+
workspace_id,
|
|
3102
|
+
dry_run=False,
|
|
3103
|
+
include_vectors=include_vectors,
|
|
3104
|
+
),
|
|
3105
|
+
)
|
|
3106
|
+
|
|
3107
|
+
|
|
3108
|
+
@gc_app.command("mcp-logs")
|
|
3109
|
+
def gc_mcp_logs(
|
|
3110
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
3111
|
+
deleted_before: str | None = typer.Option(
|
|
3112
|
+
None,
|
|
3113
|
+
"--deleted-before",
|
|
3114
|
+
help="Delete MCP call logs before this date or timestamp.",
|
|
3115
|
+
),
|
|
3116
|
+
retention_days: int = typer.Option(
|
|
3117
|
+
30,
|
|
3118
|
+
"--retention-days",
|
|
3119
|
+
min=1,
|
|
3120
|
+
help="Retention window in days when --deleted-before is omitted.",
|
|
3121
|
+
),
|
|
3122
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
3123
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm log cleanup."),
|
|
3124
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
3125
|
+
) -> int:
|
|
3126
|
+
json_enabled = json_for(json_output)
|
|
3127
|
+
cutoff = (
|
|
3128
|
+
parse_gc_cutoff(deleted_before, json_enabled)
|
|
3129
|
+
if deleted_before is not None
|
|
3130
|
+
else (datetime.now(UTC) - timedelta(days=retention_days)).isoformat()
|
|
3131
|
+
)
|
|
3132
|
+
if isinstance(cutoff, int):
|
|
3133
|
+
return cutoff
|
|
3134
|
+
return destructive_action(
|
|
3135
|
+
json_enabled,
|
|
3136
|
+
dry_run=dry_run,
|
|
3137
|
+
yes=yes,
|
|
3138
|
+
dry_run_payload=lambda: client_for().gc_mcp_call_logs(
|
|
3139
|
+
workspace_id=workspace_id,
|
|
3140
|
+
deleted_before=cutoff,
|
|
3141
|
+
dry_run=True,
|
|
3142
|
+
),
|
|
3143
|
+
action=lambda: client_for().gc_mcp_call_logs(
|
|
3144
|
+
workspace_id=workspace_id,
|
|
3145
|
+
deleted_before=cutoff,
|
|
3146
|
+
dry_run=False,
|
|
3147
|
+
),
|
|
3148
|
+
)
|
|
3149
|
+
|
|
3150
|
+
|
|
3151
|
+
@gc_app.command("item")
|
|
3152
|
+
def gc_item(
|
|
3153
|
+
item_id: str,
|
|
3154
|
+
workspace_id: str = typer.Option(..., "--workspace-id"),
|
|
3155
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
3156
|
+
yes: bool = typer.Option(False, "--yes", help="Confirm physical cleanup."),
|
|
3157
|
+
include_vectors: bool = typer.Option(True, "--include-vectors/--skip-vectors"),
|
|
3158
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
3159
|
+
) -> int:
|
|
3160
|
+
json_enabled = json_for(json_output)
|
|
3161
|
+
return destructive_action(
|
|
3162
|
+
json_enabled,
|
|
3163
|
+
dry_run=dry_run,
|
|
3164
|
+
yes=yes,
|
|
3165
|
+
dry_run_payload=lambda: client_for().gc_item(
|
|
3166
|
+
workspace_id,
|
|
3167
|
+
item_id,
|
|
3168
|
+
dry_run=True,
|
|
3169
|
+
include_vectors=include_vectors,
|
|
3170
|
+
),
|
|
3171
|
+
action=lambda: client_for().gc_item(
|
|
3172
|
+
workspace_id,
|
|
3173
|
+
item_id,
|
|
3174
|
+
dry_run=False,
|
|
3175
|
+
include_vectors=include_vectors,
|
|
3176
|
+
),
|
|
3177
|
+
)
|
|
3178
|
+
|
|
3179
|
+
|
|
3180
|
+
def auth_login_action(
|
|
3181
|
+
*,
|
|
3182
|
+
email: str | None,
|
|
3183
|
+
method: str,
|
|
3184
|
+
password: str | None,
|
|
3185
|
+
display_name: str | None,
|
|
3186
|
+
no_browser: bool,
|
|
3187
|
+
timeout_seconds: int,
|
|
3188
|
+
json_output: bool,
|
|
3189
|
+
store: StateStore,
|
|
3190
|
+
state: CliState,
|
|
3191
|
+
client: CortexClient,
|
|
3192
|
+
) -> dict[str, Any]:
|
|
3193
|
+
if email is not None:
|
|
3194
|
+
payload = client.debug_login(
|
|
3195
|
+
email=email,
|
|
3196
|
+
password=password,
|
|
3197
|
+
display_name=display_name,
|
|
3198
|
+
)
|
|
3199
|
+
elif method == "device":
|
|
3200
|
+
payload = device_login(
|
|
3201
|
+
client=client,
|
|
3202
|
+
no_browser=no_browser,
|
|
3203
|
+
timeout_seconds=timeout_seconds,
|
|
3204
|
+
json_output=json_output,
|
|
3205
|
+
)
|
|
3206
|
+
elif method == "loopback":
|
|
3207
|
+
payload = feishu_loopback_login(
|
|
3208
|
+
client=client,
|
|
3209
|
+
no_browser=no_browser,
|
|
3210
|
+
timeout_seconds=timeout_seconds,
|
|
3211
|
+
json_output=json_output,
|
|
3212
|
+
)
|
|
3213
|
+
else:
|
|
3214
|
+
raise CliAPIError(
|
|
3215
|
+
code="cli.invalid_login_method",
|
|
3216
|
+
message="Login method must be device or loopback.",
|
|
3217
|
+
status_code=400,
|
|
3218
|
+
)
|
|
3219
|
+
state.access_token = payload["access_token"]
|
|
3220
|
+
state.refresh_token = payload["refresh_token"]
|
|
3221
|
+
store.save(state)
|
|
3222
|
+
return {
|
|
3223
|
+
"status": "ok",
|
|
3224
|
+
"api_url": state.api_url,
|
|
3225
|
+
"user": payload["user"],
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
|
|
3229
|
+
def device_login(
|
|
3230
|
+
*,
|
|
3231
|
+
client: CortexClient,
|
|
3232
|
+
no_browser: bool,
|
|
3233
|
+
timeout_seconds: int,
|
|
3234
|
+
json_output: bool,
|
|
3235
|
+
) -> dict[str, Any]:
|
|
3236
|
+
start = client.device_login_start()
|
|
3237
|
+
verification_uri_complete = str(start["verification_uri_complete"])
|
|
3238
|
+
user_code = str(start["user_code"])
|
|
3239
|
+
device_code = str(start["device_code"])
|
|
3240
|
+
interval = int(start.get("interval") or 2)
|
|
3241
|
+
|
|
3242
|
+
if not json_output:
|
|
3243
|
+
print("Open this URL to login:", file=sys.stderr)
|
|
3244
|
+
print(verification_uri_complete, file=sys.stderr)
|
|
3245
|
+
print(f"Code: {user_code}", file=sys.stderr)
|
|
3246
|
+
if not no_browser:
|
|
3247
|
+
webbrowser.open(verification_uri_complete)
|
|
3248
|
+
|
|
3249
|
+
deadline = time.monotonic() + timeout_seconds
|
|
3250
|
+
while True:
|
|
3251
|
+
if time.monotonic() >= deadline:
|
|
3252
|
+
raise CliAPIError(
|
|
3253
|
+
code="auth.login_timeout",
|
|
3254
|
+
message="Timed out waiting for browser authorization.",
|
|
3255
|
+
status_code=408,
|
|
3256
|
+
)
|
|
3257
|
+
try:
|
|
3258
|
+
return client.device_login_token(device_code=device_code)
|
|
3259
|
+
except CliAPIError as exc:
|
|
3260
|
+
if exc.code != "auth.authorization_pending":
|
|
3261
|
+
raise
|
|
3262
|
+
time.sleep(interval)
|
|
3263
|
+
|
|
3264
|
+
|
|
3265
|
+
def feishu_loopback_login(
|
|
3266
|
+
*,
|
|
3267
|
+
client: CortexClient,
|
|
3268
|
+
no_browser: bool,
|
|
3269
|
+
timeout_seconds: int,
|
|
3270
|
+
json_output: bool,
|
|
3271
|
+
) -> dict[str, Any]:
|
|
3272
|
+
cli_state = secrets.token_urlsafe(24)
|
|
3273
|
+
code_verifier = secrets.token_urlsafe(48)
|
|
3274
|
+
code_challenge = pkce_s256_challenge(code_verifier)
|
|
3275
|
+
|
|
3276
|
+
server = OAuthCallbackServer(("127.0.0.1", 0), OAuthCallbackHandler)
|
|
3277
|
+
cli_redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
|
|
3278
|
+
|
|
3279
|
+
authorize = client.feishu_cli_authorize(
|
|
3280
|
+
cli_redirect_uri=cli_redirect_uri,
|
|
3281
|
+
state=cli_state,
|
|
3282
|
+
code_challenge=code_challenge,
|
|
3283
|
+
)
|
|
3284
|
+
authorization_url = str(authorize["authorization_url"])
|
|
3285
|
+
|
|
3286
|
+
if no_browser:
|
|
3287
|
+
if not json_output:
|
|
3288
|
+
print(f"Open this URL to login:\n{authorization_url}", file=sys.stderr)
|
|
3289
|
+
else:
|
|
3290
|
+
webbrowser.open(authorization_url)
|
|
3291
|
+
|
|
3292
|
+
deadline = time.monotonic() + timeout_seconds
|
|
3293
|
+
while server.callback_query is None:
|
|
3294
|
+
remaining = deadline - time.monotonic()
|
|
3295
|
+
if remaining <= 0:
|
|
3296
|
+
raise CliAPIError(
|
|
3297
|
+
code="auth.login_timeout",
|
|
3298
|
+
message="Timed out waiting for Feishu OAuth callback.",
|
|
3299
|
+
status_code=408,
|
|
3300
|
+
)
|
|
3301
|
+
server.socket.settimeout(remaining)
|
|
3302
|
+
try:
|
|
3303
|
+
server.handle_request()
|
|
3304
|
+
except OSError:
|
|
3305
|
+
continue
|
|
3306
|
+
|
|
3307
|
+
returned_state = first_query_value(server.callback_query, "state")
|
|
3308
|
+
cli_auth_code = first_query_value(server.callback_query, "cli_auth_code")
|
|
3309
|
+
if returned_state != cli_state or cli_auth_code is None:
|
|
3310
|
+
raise CliAPIError(
|
|
3311
|
+
code="auth.oauth_callback_invalid",
|
|
3312
|
+
message="OAuth callback state or code is invalid.",
|
|
3313
|
+
status_code=400,
|
|
3314
|
+
)
|
|
3315
|
+
|
|
3316
|
+
return client.feishu_cli_token(
|
|
3317
|
+
cli_auth_code=cli_auth_code,
|
|
3318
|
+
code_verifier=code_verifier,
|
|
3319
|
+
)
|
|
3320
|
+
|
|
3321
|
+
|
|
3322
|
+
class OAuthCallbackServer(HTTPServer):
|
|
3323
|
+
callback_query: dict[str, list[str]] | None = None
|
|
3324
|
+
|
|
3325
|
+
|
|
3326
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
3327
|
+
def do_GET(self) -> None:
|
|
3328
|
+
parsed = urlparse(self.path)
|
|
3329
|
+
if parsed.path != "/callback":
|
|
3330
|
+
self.send_error(404)
|
|
3331
|
+
return
|
|
3332
|
+
self.server.callback_query = parse_qs(parsed.query) # type: ignore[attr-defined]
|
|
3333
|
+
body = b"Cortex CLI login completed. You can close this window."
|
|
3334
|
+
self.send_response(200)
|
|
3335
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
3336
|
+
self.send_header("Content-Length", str(len(body)))
|
|
3337
|
+
self.end_headers()
|
|
3338
|
+
self.wfile.write(body)
|
|
3339
|
+
|
|
3340
|
+
def log_message(self, _format: str, *_args: object) -> None:
|
|
3341
|
+
return
|
|
3342
|
+
|
|
3343
|
+
|
|
3344
|
+
def pkce_s256_challenge(code_verifier: str) -> str:
|
|
3345
|
+
digest = sha256(code_verifier.encode("ascii")).digest()
|
|
3346
|
+
return urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
3347
|
+
|
|
3348
|
+
|
|
3349
|
+
def first_query_value(query: dict[str, list[str]], key: str) -> str | None:
|
|
3350
|
+
values = query.get(key)
|
|
3351
|
+
return values[0] if values else None
|
|
3352
|
+
|
|
3353
|
+
|
|
3354
|
+
def compact_payload(
|
|
3355
|
+
payload: dict[str, Any],
|
|
3356
|
+
*,
|
|
3357
|
+
force_keys: set[str] | None = None,
|
|
3358
|
+
) -> dict[str, Any]:
|
|
3359
|
+
forced = force_keys or set()
|
|
3360
|
+
return {
|
|
3361
|
+
key: value
|
|
3362
|
+
for key, value in payload.items()
|
|
3363
|
+
if value is not None or key in forced
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
|
|
3367
|
+
def resolve_bool_filter(
|
|
3368
|
+
*,
|
|
3369
|
+
positive: bool,
|
|
3370
|
+
negative: bool,
|
|
3371
|
+
positive_name: str,
|
|
3372
|
+
negative_name: str,
|
|
3373
|
+
json_output: bool,
|
|
3374
|
+
) -> bool | None | int:
|
|
3375
|
+
if positive and negative:
|
|
3376
|
+
return emit_usage_error(
|
|
3377
|
+
json_output,
|
|
3378
|
+
"validation.invalid",
|
|
3379
|
+
f"Use either {positive_name} or {negative_name}.",
|
|
3380
|
+
)
|
|
3381
|
+
if positive:
|
|
3382
|
+
return True
|
|
3383
|
+
if negative:
|
|
3384
|
+
return False
|
|
3385
|
+
return None
|
|
3386
|
+
|
|
3387
|
+
|
|
3388
|
+
def parse_bool_cell(
|
|
3389
|
+
value: str | None,
|
|
3390
|
+
*,
|
|
3391
|
+
default: bool,
|
|
3392
|
+
field: str,
|
|
3393
|
+
json_output: bool,
|
|
3394
|
+
) -> bool | int:
|
|
3395
|
+
if value is None or not value.strip():
|
|
3396
|
+
return default
|
|
3397
|
+
normalized = value.strip().casefold()
|
|
3398
|
+
if normalized in {"true", "1", "yes", "y"}:
|
|
3399
|
+
return True
|
|
3400
|
+
if normalized in {"false", "0", "no", "n"}:
|
|
3401
|
+
return False
|
|
3402
|
+
return emit_usage_error(
|
|
3403
|
+
json_output,
|
|
3404
|
+
"validation.invalid_bool",
|
|
3405
|
+
f"{field} must be true or false.",
|
|
3406
|
+
)
|
|
3407
|
+
|
|
3408
|
+
|
|
3409
|
+
def parse_json_object(
|
|
3410
|
+
value: str | None,
|
|
3411
|
+
json_output: bool,
|
|
3412
|
+
) -> dict[str, Any] | None | int:
|
|
3413
|
+
if value is None:
|
|
3414
|
+
return None
|
|
3415
|
+
try:
|
|
3416
|
+
payload = json.loads(value)
|
|
3417
|
+
except json.JSONDecodeError:
|
|
3418
|
+
return emit_usage_error(
|
|
3419
|
+
json_output,
|
|
3420
|
+
"validation.invalid_json",
|
|
3421
|
+
"JSON value is invalid.",
|
|
3422
|
+
)
|
|
3423
|
+
if not isinstance(payload, dict):
|
|
3424
|
+
return emit_usage_error(
|
|
3425
|
+
json_output,
|
|
3426
|
+
"validation.invalid_json",
|
|
3427
|
+
"JSON value must be an object.",
|
|
3428
|
+
)
|
|
3429
|
+
return payload
|
|
3430
|
+
|
|
3431
|
+
|
|
3432
|
+
def parse_json_array(
|
|
3433
|
+
value: str | None,
|
|
3434
|
+
json_output: bool,
|
|
3435
|
+
) -> list[Any] | None | int:
|
|
3436
|
+
if value is None:
|
|
3437
|
+
return None
|
|
3438
|
+
try:
|
|
3439
|
+
payload = json.loads(value)
|
|
3440
|
+
except json.JSONDecodeError:
|
|
3441
|
+
return emit_usage_error(
|
|
3442
|
+
json_output,
|
|
3443
|
+
"validation.invalid_json",
|
|
3444
|
+
"JSON value is invalid.",
|
|
3445
|
+
)
|
|
3446
|
+
if not isinstance(payload, list):
|
|
3447
|
+
return emit_usage_error(
|
|
3448
|
+
json_output,
|
|
3449
|
+
"validation.invalid_json",
|
|
3450
|
+
"JSON value must be an array.",
|
|
3451
|
+
)
|
|
3452
|
+
return payload
|
|
3453
|
+
|
|
3454
|
+
|
|
3455
|
+
def parse_metadata_options(
|
|
3456
|
+
values: list[str] | None,
|
|
3457
|
+
json_output: bool,
|
|
3458
|
+
) -> list[dict[str, Any]] | int:
|
|
3459
|
+
entries: list[dict[str, Any]] = []
|
|
3460
|
+
for raw in values or []:
|
|
3461
|
+
if "=" not in raw:
|
|
3462
|
+
return emit_usage_error(
|
|
3463
|
+
json_output,
|
|
3464
|
+
"validation.invalid_metadata",
|
|
3465
|
+
"--metadata must use key=value or key:type=value.",
|
|
3466
|
+
)
|
|
3467
|
+
key_part, value = raw.split("=", 1)
|
|
3468
|
+
key, separator, value_type = key_part.partition(":")
|
|
3469
|
+
value_type = value_type if separator else "string"
|
|
3470
|
+
key = key.strip()
|
|
3471
|
+
if not key or value_type not in {"string", "time"}:
|
|
3472
|
+
return emit_usage_error(
|
|
3473
|
+
json_output,
|
|
3474
|
+
"validation.invalid_metadata",
|
|
3475
|
+
"Metadata type must be string or time.",
|
|
3476
|
+
)
|
|
3477
|
+
entries.append({"key": key, "value_type": value_type, "value": value})
|
|
3478
|
+
return entries
|
|
3479
|
+
|
|
3480
|
+
|
|
3481
|
+
def parse_kb_order_options(
|
|
3482
|
+
values: list[str] | None,
|
|
3483
|
+
json_output: bool,
|
|
3484
|
+
) -> list[dict[str, Any]] | int:
|
|
3485
|
+
entries: list[dict[str, Any]] = []
|
|
3486
|
+
seen: set[str] = set()
|
|
3487
|
+
for raw in values or []:
|
|
3488
|
+
if ":" in raw:
|
|
3489
|
+
kb_id, sort_order_text = raw.split(":", 1)
|
|
3490
|
+
elif "=" in raw:
|
|
3491
|
+
kb_id, sort_order_text = raw.split("=", 1)
|
|
3492
|
+
else:
|
|
3493
|
+
return emit_usage_error(
|
|
3494
|
+
json_output,
|
|
3495
|
+
"validation.invalid_kb_order",
|
|
3496
|
+
"--kb-order must use <kb-id>:<sort-order>.",
|
|
3497
|
+
)
|
|
3498
|
+
kb_id = kb_id.strip()
|
|
3499
|
+
sort_order_text = sort_order_text.strip()
|
|
3500
|
+
if not kb_id:
|
|
3501
|
+
return emit_usage_error(
|
|
3502
|
+
json_output,
|
|
3503
|
+
"validation.invalid_kb_order",
|
|
3504
|
+
"--kb-order requires a knowledge base id.",
|
|
3505
|
+
)
|
|
3506
|
+
if kb_id in seen:
|
|
3507
|
+
return emit_usage_error(
|
|
3508
|
+
json_output,
|
|
3509
|
+
"validation.invalid_kb_order",
|
|
3510
|
+
"--kb-order cannot include the same knowledge base twice.",
|
|
3511
|
+
)
|
|
3512
|
+
try:
|
|
3513
|
+
sort_order = int(sort_order_text)
|
|
3514
|
+
except ValueError:
|
|
3515
|
+
return emit_usage_error(
|
|
3516
|
+
json_output,
|
|
3517
|
+
"validation.invalid_kb_order",
|
|
3518
|
+
"--kb-order sort order must be an integer.",
|
|
3519
|
+
)
|
|
3520
|
+
seen.add(kb_id)
|
|
3521
|
+
entries.append({"kb_id": kb_id, "sort_order": sort_order})
|
|
3522
|
+
return entries
|
|
3523
|
+
|
|
3524
|
+
|
|
3525
|
+
def build_item_payload(
|
|
3526
|
+
*,
|
|
3527
|
+
title: str,
|
|
3528
|
+
is_available: bool,
|
|
3529
|
+
is_locked: bool,
|
|
3530
|
+
pipeline_id: str | None,
|
|
3531
|
+
processing_config: str | None,
|
|
3532
|
+
metadata: list[str] | None,
|
|
3533
|
+
kb_id: str | None,
|
|
3534
|
+
folder_id: str | None,
|
|
3535
|
+
json_output: bool,
|
|
3536
|
+
) -> dict[str, Any] | int:
|
|
3537
|
+
if folder_id and not kb_id:
|
|
3538
|
+
return emit_usage_error(
|
|
3539
|
+
json_output,
|
|
3540
|
+
"validation.invalid",
|
|
3541
|
+
"--folder requires --kb.",
|
|
3542
|
+
)
|
|
3543
|
+
parsed_config = parse_json_object(processing_config, json_output)
|
|
3544
|
+
if isinstance(parsed_config, int):
|
|
3545
|
+
return parsed_config
|
|
3546
|
+
parsed_metadata = parse_metadata_options(metadata, json_output)
|
|
3547
|
+
if isinstance(parsed_metadata, int):
|
|
3548
|
+
return parsed_metadata
|
|
3549
|
+
payload: dict[str, Any] = {
|
|
3550
|
+
"title": title,
|
|
3551
|
+
"is_available": is_available,
|
|
3552
|
+
"is_locked": is_locked,
|
|
3553
|
+
"metadata": parsed_metadata,
|
|
3554
|
+
}
|
|
3555
|
+
if pipeline_id is not None:
|
|
3556
|
+
payload["pipeline_id"] = pipeline_id
|
|
3557
|
+
if parsed_config is not None:
|
|
3558
|
+
payload["processing_config"] = parsed_config
|
|
3559
|
+
if kb_id is not None:
|
|
3560
|
+
payload["mount"] = {"kb_id": kb_id, "folder_id": folder_id}
|
|
3561
|
+
return payload
|
|
3562
|
+
|
|
3563
|
+
|
|
3564
|
+
def mcp_payload(
|
|
3565
|
+
*,
|
|
3566
|
+
name: str,
|
|
3567
|
+
description: str | None,
|
|
3568
|
+
prompt_template: str | None,
|
|
3569
|
+
tool: list[str] | None,
|
|
3570
|
+
kb_id: list[str] | None,
|
|
3571
|
+
auth_type: str,
|
|
3572
|
+
enabled: bool,
|
|
3573
|
+
json_output: bool,
|
|
3574
|
+
) -> dict[str, Any] | int:
|
|
3575
|
+
tools = list(dict.fromkeys(tool or []))
|
|
3576
|
+
kb_ids = list(dict.fromkeys(kb_id or []))
|
|
3577
|
+
invalid_tools = set(tools) - MCP_TOOL_NAMES
|
|
3578
|
+
if invalid_tools:
|
|
3579
|
+
return emit_usage_error(
|
|
3580
|
+
json_output,
|
|
3581
|
+
"validation.invalid",
|
|
3582
|
+
"--tool contains unsupported MCP tools.",
|
|
3583
|
+
)
|
|
3584
|
+
if auth_type not in {"api_key", "none"}:
|
|
3585
|
+
return emit_usage_error(
|
|
3586
|
+
json_output,
|
|
3587
|
+
"validation.invalid",
|
|
3588
|
+
"--auth-type must be api_key or none.",
|
|
3589
|
+
)
|
|
3590
|
+
return {
|
|
3591
|
+
"name": name,
|
|
3592
|
+
"description": description,
|
|
3593
|
+
"prompt_template": prompt_template,
|
|
3594
|
+
"enabled_tools": tools,
|
|
3595
|
+
"kb_ids": kb_ids,
|
|
3596
|
+
"auth_type": auth_type,
|
|
3597
|
+
"enabled": enabled,
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
|
|
3601
|
+
def validate_file_command(
|
|
3602
|
+
*,
|
|
3603
|
+
path: Path,
|
|
3604
|
+
relation_type: str,
|
|
3605
|
+
folder_id: str | None,
|
|
3606
|
+
kb_id: str | None,
|
|
3607
|
+
json_output: bool,
|
|
3608
|
+
) -> int | None:
|
|
3609
|
+
if not path.is_file():
|
|
3610
|
+
return emit_usage_error(
|
|
3611
|
+
json_output,
|
|
3612
|
+
"file.not_found",
|
|
3613
|
+
f"File does not exist: {path}",
|
|
3614
|
+
)
|
|
3615
|
+
if relation_type not in {"primary", "attachment"}:
|
|
3616
|
+
return emit_usage_error(
|
|
3617
|
+
json_output,
|
|
3618
|
+
"validation.invalid",
|
|
3619
|
+
"--relation-type must be primary or attachment.",
|
|
3620
|
+
)
|
|
3621
|
+
if folder_id and not kb_id:
|
|
3622
|
+
return emit_usage_error(
|
|
3623
|
+
json_output,
|
|
3624
|
+
"validation.invalid",
|
|
3625
|
+
"--folder requires --kb.",
|
|
3626
|
+
)
|
|
3627
|
+
return None
|
|
3628
|
+
|
|
3629
|
+
|
|
3630
|
+
def delete_file_action(*, workspace_id: str, item_id: str, file_id: str) -> None:
|
|
3631
|
+
client = client_for()
|
|
3632
|
+
try:
|
|
3633
|
+
client.delete_file(workspace_id, item_id, file_id)
|
|
3634
|
+
except CliAPIError as exc:
|
|
3635
|
+
if exc.code != "resource.not_found":
|
|
3636
|
+
raise
|
|
3637
|
+
client.delete_file(workspace_id, file_id, item_id)
|
|
3638
|
+
|
|
3639
|
+
|
|
3640
|
+
def parse_gc_cutoff(value: str, json_output: bool) -> str | int:
|
|
3641
|
+
try:
|
|
3642
|
+
if "T" in value or " " in value:
|
|
3643
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
3644
|
+
else:
|
|
3645
|
+
parsed = datetime.combine(
|
|
3646
|
+
date.fromisoformat(value),
|
|
3647
|
+
datetime.min.time(),
|
|
3648
|
+
tzinfo=UTC,
|
|
3649
|
+
)
|
|
3650
|
+
if parsed.tzinfo is None:
|
|
3651
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
3652
|
+
except ValueError:
|
|
3653
|
+
return emit_usage_error(
|
|
3654
|
+
json_output,
|
|
3655
|
+
"validation.invalid_datetime",
|
|
3656
|
+
"--deleted-before must be an ISO date or timestamp.",
|
|
3657
|
+
)
|
|
3658
|
+
return parsed.astimezone(UTC).isoformat()
|
|
3659
|
+
|
|
3660
|
+
|
|
3661
|
+
def destructive_action(
|
|
3662
|
+
json_output: bool,
|
|
3663
|
+
*,
|
|
3664
|
+
dry_run: bool,
|
|
3665
|
+
yes: bool,
|
|
3666
|
+
dry_run_payload: dict[str, Any] | Callable[[], Any],
|
|
3667
|
+
action: Callable[[], Any],
|
|
3668
|
+
) -> int:
|
|
3669
|
+
if dry_run:
|
|
3670
|
+
if callable(dry_run_payload):
|
|
3671
|
+
return run_cli_action(json_output, dry_run_payload)
|
|
3672
|
+
_emit(dry_run_payload, json_output)
|
|
3673
|
+
return EXIT_SUCCESS
|
|
3674
|
+
if not yes:
|
|
3675
|
+
return emit_usage_error(
|
|
3676
|
+
json_output,
|
|
3677
|
+
"confirmation.required",
|
|
3678
|
+
"Pass --yes to execute this command or use --dry-run.",
|
|
3679
|
+
)
|
|
3680
|
+
return run_cli_action(json_output, action)
|
|
3681
|
+
|
|
3682
|
+
|
|
3683
|
+
def collect_items(
|
|
3684
|
+
*,
|
|
3685
|
+
workspace_id: str,
|
|
3686
|
+
query: str | None,
|
|
3687
|
+
is_available: bool | None,
|
|
3688
|
+
is_locked: bool | None,
|
|
3689
|
+
kb_id: str | None,
|
|
3690
|
+
folder_id: str | None,
|
|
3691
|
+
unclassified: bool,
|
|
3692
|
+
page_size: int,
|
|
3693
|
+
) -> list[dict[str, Any]]:
|
|
3694
|
+
client = client_for()
|
|
3695
|
+
page = 1
|
|
3696
|
+
items: list[dict[str, Any]] = []
|
|
3697
|
+
while True:
|
|
3698
|
+
payload = client.list_items(
|
|
3699
|
+
workspace_id,
|
|
3700
|
+
page=page,
|
|
3701
|
+
page_size=page_size,
|
|
3702
|
+
query=query,
|
|
3703
|
+
is_available=is_available,
|
|
3704
|
+
is_locked=is_locked,
|
|
3705
|
+
kb_id=kb_id,
|
|
3706
|
+
folder_id=folder_id,
|
|
3707
|
+
unclassified=unclassified,
|
|
3708
|
+
)
|
|
3709
|
+
page_items = payload.get("items", [])
|
|
3710
|
+
items.extend(page_items)
|
|
3711
|
+
if len(items) >= int(payload.get("total") or 0) or len(page_items) < page_size:
|
|
3712
|
+
return items
|
|
3713
|
+
page += 1
|
|
3714
|
+
|
|
3715
|
+
|
|
3716
|
+
def with_search_suggestions(
|
|
3717
|
+
payload: dict[str, Any] | list[dict[str, Any]],
|
|
3718
|
+
*,
|
|
3719
|
+
workspace_id: str,
|
|
3720
|
+
command: str,
|
|
3721
|
+
) -> dict[str, Any]:
|
|
3722
|
+
if isinstance(payload, dict):
|
|
3723
|
+
items = payload.get("items") if isinstance(payload.get("items"), list) else []
|
|
3724
|
+
result = dict(payload)
|
|
3725
|
+
else:
|
|
3726
|
+
items = payload
|
|
3727
|
+
result = {"items": payload}
|
|
3728
|
+
result["next"] = search_next_steps(
|
|
3729
|
+
[item for item in items if isinstance(item, dict)],
|
|
3730
|
+
workspace_id=workspace_id,
|
|
3731
|
+
command=command,
|
|
3732
|
+
)
|
|
3733
|
+
return result
|
|
3734
|
+
|
|
3735
|
+
|
|
3736
|
+
def search_next_steps(
|
|
3737
|
+
items: list[dict[str, Any]],
|
|
3738
|
+
*,
|
|
3739
|
+
workspace_id: str,
|
|
3740
|
+
command: str,
|
|
3741
|
+
) -> list[dict[str, str]]:
|
|
3742
|
+
if not items:
|
|
3743
|
+
return [
|
|
3744
|
+
{
|
|
3745
|
+
"label": "broaden_search",
|
|
3746
|
+
"command": (
|
|
3747
|
+
f"cortex search advanced --workspace-id {workspace_id} --json"
|
|
3748
|
+
),
|
|
3749
|
+
}
|
|
3750
|
+
]
|
|
3751
|
+
steps: list[dict[str, str]] = []
|
|
3752
|
+
for item in items[:3]:
|
|
3753
|
+
item_id = item.get("item_id")
|
|
3754
|
+
file_id = item.get("file_id")
|
|
3755
|
+
chunk_idx = item.get("chunk_idx")
|
|
3756
|
+
if item_id:
|
|
3757
|
+
steps.append(
|
|
3758
|
+
{
|
|
3759
|
+
"label": "inspect_item",
|
|
3760
|
+
"command": (
|
|
3761
|
+
f"cortex item get {item_id} "
|
|
3762
|
+
f"--workspace-id {workspace_id} --json"
|
|
3763
|
+
),
|
|
3764
|
+
}
|
|
3765
|
+
)
|
|
3766
|
+
if file_id and chunk_idx is not None:
|
|
3767
|
+
steps.append(
|
|
3768
|
+
{
|
|
3769
|
+
"label": "read_context",
|
|
3770
|
+
"command": (
|
|
3771
|
+
f"cortex chunk context {file_id} --chunk {chunk_idx} "
|
|
3772
|
+
f"--workspace-id {workspace_id} --json"
|
|
3773
|
+
),
|
|
3774
|
+
}
|
|
3775
|
+
)
|
|
3776
|
+
if file_id:
|
|
3777
|
+
steps.append(
|
|
3778
|
+
{
|
|
3779
|
+
"label": "read_file",
|
|
3780
|
+
"command": (
|
|
3781
|
+
f"cortex file read {file_id} "
|
|
3782
|
+
f"--workspace-id {workspace_id} --json"
|
|
3783
|
+
),
|
|
3784
|
+
}
|
|
3785
|
+
)
|
|
3786
|
+
if command != "recall":
|
|
3787
|
+
steps.append(
|
|
3788
|
+
{
|
|
3789
|
+
"label": "recall_chunks",
|
|
3790
|
+
"command": (
|
|
3791
|
+
f"cortex recall <query> --workspace-id {workspace_id} --json"
|
|
3792
|
+
),
|
|
3793
|
+
}
|
|
3794
|
+
)
|
|
3795
|
+
return dedupe_next_steps(steps)
|
|
3796
|
+
|
|
3797
|
+
|
|
3798
|
+
def dedupe_next_steps(steps: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
3799
|
+
result: list[dict[str, str]] = []
|
|
3800
|
+
seen: set[str] = set()
|
|
3801
|
+
for step in steps:
|
|
3802
|
+
command = step["command"]
|
|
3803
|
+
if command in seen:
|
|
3804
|
+
continue
|
|
3805
|
+
seen.add(command)
|
|
3806
|
+
result.append(step)
|
|
3807
|
+
return result[:8]
|
|
3808
|
+
|
|
3809
|
+
|
|
3810
|
+
def read_import_rows(path: Path, json_output: bool) -> list[dict[str, str]] | int:
|
|
3811
|
+
if not path.exists():
|
|
3812
|
+
return emit_usage_error(
|
|
3813
|
+
json_output,
|
|
3814
|
+
"file.not_found",
|
|
3815
|
+
f"Import file does not exist: {path}",
|
|
3816
|
+
)
|
|
3817
|
+
suffix = path.suffix.casefold()
|
|
3818
|
+
if suffix == ".csv":
|
|
3819
|
+
with path.open(newline="", encoding="utf-8-sig") as file:
|
|
3820
|
+
return [
|
|
3821
|
+
{key: value for key, value in row.items() if key is not None}
|
|
3822
|
+
for row in csv.DictReader(file)
|
|
3823
|
+
]
|
|
3824
|
+
if suffix == ".xlsx":
|
|
3825
|
+
try:
|
|
3826
|
+
return read_xlsx_rows(path)
|
|
3827
|
+
except CliAPIError as exc:
|
|
3828
|
+
emit_cli_error(json_output, exc)
|
|
3829
|
+
return EXIT_USAGE
|
|
3830
|
+
except (
|
|
3831
|
+
ElementTree.ParseError,
|
|
3832
|
+
KeyError,
|
|
3833
|
+
ValueError,
|
|
3834
|
+
zipfile.BadZipFile,
|
|
3835
|
+
):
|
|
3836
|
+
return emit_usage_error(
|
|
3837
|
+
json_output,
|
|
3838
|
+
"validation.invalid_xlsx",
|
|
3839
|
+
"XLSX file cannot be read.",
|
|
3840
|
+
)
|
|
3841
|
+
return emit_usage_error(
|
|
3842
|
+
json_output,
|
|
3843
|
+
"validation.unsupported_file",
|
|
3844
|
+
"Import supports .csv and .xlsx files.",
|
|
3845
|
+
)
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def import_payloads(
|
|
3849
|
+
rows: list[dict[str, str]],
|
|
3850
|
+
*,
|
|
3851
|
+
kb_id: str | None,
|
|
3852
|
+
folder_id: str | None,
|
|
3853
|
+
json_output: bool,
|
|
3854
|
+
) -> list[dict[str, Any]] | int:
|
|
3855
|
+
payloads: list[dict[str, Any]] = []
|
|
3856
|
+
for index, row in enumerate(rows, start=2):
|
|
3857
|
+
title = (row.get("title") or "").strip()
|
|
3858
|
+
if not title:
|
|
3859
|
+
return emit_usage_error(
|
|
3860
|
+
json_output,
|
|
3861
|
+
"validation.missing_title",
|
|
3862
|
+
f"Row {index} is missing title.",
|
|
3863
|
+
)
|
|
3864
|
+
is_available = parse_bool_cell(
|
|
3865
|
+
row.get("is_available"),
|
|
3866
|
+
default=True,
|
|
3867
|
+
field="is_available",
|
|
3868
|
+
json_output=json_output,
|
|
3869
|
+
)
|
|
3870
|
+
if type(is_available) is int:
|
|
3871
|
+
return is_available
|
|
3872
|
+
is_locked = parse_bool_cell(
|
|
3873
|
+
row.get("is_locked"),
|
|
3874
|
+
default=False,
|
|
3875
|
+
field="is_locked",
|
|
3876
|
+
json_output=json_output,
|
|
3877
|
+
)
|
|
3878
|
+
if type(is_locked) is int:
|
|
3879
|
+
return is_locked
|
|
3880
|
+
processing_config = (
|
|
3881
|
+
row.get("processing_config_json") or row.get("processing_config") or None
|
|
3882
|
+
)
|
|
3883
|
+
metadata_options = row_metadata_options(row)
|
|
3884
|
+
payload = build_item_payload(
|
|
3885
|
+
title=title,
|
|
3886
|
+
is_available=is_available,
|
|
3887
|
+
is_locked=is_locked,
|
|
3888
|
+
pipeline_id=(row.get("pipeline_id") or None),
|
|
3889
|
+
processing_config=processing_config,
|
|
3890
|
+
metadata=metadata_options,
|
|
3891
|
+
kb_id=kb_id,
|
|
3892
|
+
folder_id=folder_id,
|
|
3893
|
+
json_output=json_output,
|
|
3894
|
+
)
|
|
3895
|
+
if isinstance(payload, int):
|
|
3896
|
+
return payload
|
|
3897
|
+
payloads.append(payload)
|
|
3898
|
+
return payloads
|
|
3899
|
+
|
|
3900
|
+
|
|
3901
|
+
def row_metadata_options(row: dict[str, str]) -> list[str]:
|
|
3902
|
+
reserved = {
|
|
3903
|
+
"title",
|
|
3904
|
+
"is_available",
|
|
3905
|
+
"is_locked",
|
|
3906
|
+
"pipeline_id",
|
|
3907
|
+
"processing_config_json",
|
|
3908
|
+
}
|
|
3909
|
+
reserved.add("processing_config")
|
|
3910
|
+
result: list[str] = []
|
|
3911
|
+
for key, value in row.items():
|
|
3912
|
+
if key in reserved or value in (None, ""):
|
|
3913
|
+
continue
|
|
3914
|
+
metadata_key = key.removeprefix("metadata.")
|
|
3915
|
+
result.append(f"{metadata_key}={value}")
|
|
3916
|
+
return result
|
|
3917
|
+
|
|
3918
|
+
|
|
3919
|
+
def read_xlsx_rows(path: Path) -> list[dict[str, str]]:
|
|
3920
|
+
with zipfile.ZipFile(path) as archive:
|
|
3921
|
+
shared_strings = xlsx_shared_strings(archive)
|
|
3922
|
+
sheet_path = xlsx_first_sheet_path(archive)
|
|
3923
|
+
root = ElementTree.fromstring(archive.read(sheet_path))
|
|
3924
|
+
|
|
3925
|
+
rows: list[list[str]] = []
|
|
3926
|
+
namespace = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
|
3927
|
+
for row in root.findall(f".//{namespace}sheetData/{namespace}row"):
|
|
3928
|
+
values: dict[int, str] = {}
|
|
3929
|
+
for cell in row.findall(f"{namespace}c"):
|
|
3930
|
+
ref = cell.attrib.get("r", "")
|
|
3931
|
+
column = xlsx_column_index(ref)
|
|
3932
|
+
values[column] = xlsx_cell_value(cell, shared_strings)
|
|
3933
|
+
if values:
|
|
3934
|
+
rows.append([values.get(index, "") for index in range(max(values) + 1)])
|
|
3935
|
+
|
|
3936
|
+
if not rows:
|
|
3937
|
+
return []
|
|
3938
|
+
headers = [header.strip() for header in rows[0]]
|
|
3939
|
+
return [
|
|
3940
|
+
{
|
|
3941
|
+
headers[index]: value
|
|
3942
|
+
for index, value in enumerate(row)
|
|
3943
|
+
if index < len(headers) and headers[index]
|
|
3944
|
+
}
|
|
3945
|
+
for row in rows[1:]
|
|
3946
|
+
]
|
|
3947
|
+
|
|
3948
|
+
|
|
3949
|
+
def xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]:
|
|
3950
|
+
try:
|
|
3951
|
+
root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
|
|
3952
|
+
except KeyError:
|
|
3953
|
+
return []
|
|
3954
|
+
namespace = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
|
3955
|
+
strings: list[str] = []
|
|
3956
|
+
for item in root.findall(f"{namespace}si"):
|
|
3957
|
+
strings.append("".join(node.text or "" for node in item.iter(f"{namespace}t")))
|
|
3958
|
+
return strings
|
|
3959
|
+
|
|
3960
|
+
|
|
3961
|
+
def xlsx_first_sheet_path(archive: zipfile.ZipFile) -> str:
|
|
3962
|
+
package_rel = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
3963
|
+
spreadsheet = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
|
3964
|
+
workbook = ElementTree.fromstring(archive.read("xl/workbook.xml"))
|
|
3965
|
+
first_sheet = workbook.find(f".//{spreadsheet}sheet")
|
|
3966
|
+
if first_sheet is None:
|
|
3967
|
+
raise CliAPIError(
|
|
3968
|
+
code="validation.invalid_xlsx",
|
|
3969
|
+
message="Workbook contains no sheets.",
|
|
3970
|
+
status_code=400,
|
|
3971
|
+
)
|
|
3972
|
+
rel_id = first_sheet.attrib[
|
|
3973
|
+
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
|
|
3974
|
+
]
|
|
3975
|
+
rels = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
|
3976
|
+
targets = {
|
|
3977
|
+
rel.attrib["Id"]: rel.attrib["Target"]
|
|
3978
|
+
for rel in rels.findall(f"{package_rel}Relationship")
|
|
3979
|
+
}
|
|
3980
|
+
target = targets[rel_id]
|
|
3981
|
+
return f"xl/{target}" if not target.startswith("/") else target.removeprefix("/")
|
|
3982
|
+
|
|
3983
|
+
|
|
3984
|
+
def xlsx_column_index(cell_ref: str) -> int:
|
|
3985
|
+
letters = "".join(char for char in cell_ref if char.isalpha())
|
|
3986
|
+
index = 0
|
|
3987
|
+
for char in letters:
|
|
3988
|
+
index = index * 26 + (ord(char.upper()) - ord("A") + 1)
|
|
3989
|
+
return max(index - 1, 0)
|
|
3990
|
+
|
|
3991
|
+
|
|
3992
|
+
def xlsx_cell_value(cell: ElementTree.Element, shared_strings: list[str]) -> str:
|
|
3993
|
+
namespace = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
|
3994
|
+
if cell.attrib.get("t") == "inlineStr":
|
|
3995
|
+
return "".join(node.text or "" for node in cell.iter(f"{namespace}t"))
|
|
3996
|
+
value = cell.find(f"{namespace}v")
|
|
3997
|
+
if value is None or value.text is None:
|
|
3998
|
+
return ""
|
|
3999
|
+
if cell.attrib.get("t") == "s":
|
|
4000
|
+
return shared_strings[int(value.text)]
|
|
4001
|
+
return value.text
|
|
4002
|
+
|
|
4003
|
+
|
|
4004
|
+
def run_cli_action(
|
|
4005
|
+
json_output: bool,
|
|
4006
|
+
action: Callable[[], Any],
|
|
4007
|
+
*,
|
|
4008
|
+
text_renderer: Callable[[Any], None] | None = None,
|
|
4009
|
+
) -> int:
|
|
4010
|
+
try:
|
|
4011
|
+
payload = action()
|
|
4012
|
+
except CliAPIError as exc:
|
|
4013
|
+
emit_cli_error(json_output, exc)
|
|
4014
|
+
return exit_code_for_api_error(exc)
|
|
4015
|
+
|
|
4016
|
+
if text_renderer is not None and not json_output:
|
|
4017
|
+
text_renderer(payload)
|
|
4018
|
+
return EXIT_SUCCESS
|
|
4019
|
+
_emit(payload, json_output)
|
|
4020
|
+
return EXIT_SUCCESS
|
|
4021
|
+
|
|
4022
|
+
|
|
4023
|
+
def exit_code_for_api_error(exc: CliAPIError) -> int:
|
|
4024
|
+
if exc.status_code == 401:
|
|
4025
|
+
return EXIT_AUTH
|
|
4026
|
+
if exc.status_code == 403:
|
|
4027
|
+
return EXIT_FORBIDDEN
|
|
4028
|
+
if exc.status_code == 404:
|
|
4029
|
+
return EXIT_NOT_FOUND
|
|
4030
|
+
if exc.status_code == 409 or exc.code in {
|
|
4031
|
+
"resource.conflict",
|
|
4032
|
+
"resource.locked",
|
|
4033
|
+
"idempotency.conflict",
|
|
4034
|
+
}:
|
|
4035
|
+
return EXIT_CONFLICT
|
|
4036
|
+
if exc.status_code >= 500 or exc.status_code == 0:
|
|
4037
|
+
return EXIT_ERROR
|
|
4038
|
+
return EXIT_USAGE
|
|
4039
|
+
|
|
4040
|
+
|
|
4041
|
+
def client_for() -> CortexClient:
|
|
4042
|
+
store = StateStore()
|
|
4043
|
+
state = load_state(store)
|
|
4044
|
+
return client_from_state(store, state)
|
|
4045
|
+
|
|
4046
|
+
|
|
4047
|
+
def client_from_state(store: StateStore, state: CliState) -> CortexClient:
|
|
4048
|
+
def update_tokens(payload: dict[str, Any]) -> None:
|
|
4049
|
+
state.access_token = payload["access_token"]
|
|
4050
|
+
state.refresh_token = payload["refresh_token"]
|
|
4051
|
+
store.save(state)
|
|
4052
|
+
|
|
4053
|
+
return CortexClient(
|
|
4054
|
+
base_url=state.api_url,
|
|
4055
|
+
access_token=state.access_token,
|
|
4056
|
+
refresh_token=state.refresh_token,
|
|
4057
|
+
token_updater=update_tokens,
|
|
4058
|
+
)
|
|
4059
|
+
|
|
4060
|
+
|
|
4061
|
+
def doctor_api_check(name: str, action: Callable[[], Any]) -> dict[str, str]:
|
|
4062
|
+
try:
|
|
4063
|
+
payload = action()
|
|
4064
|
+
except CliAPIError as exc:
|
|
4065
|
+
return {
|
|
4066
|
+
"name": name,
|
|
4067
|
+
"status": "error",
|
|
4068
|
+
"message": f"{exc.code}: {exc.message}",
|
|
4069
|
+
}
|
|
4070
|
+
status = payload.get("status") if isinstance(payload, dict) else None
|
|
4071
|
+
expected = "ok" if name == "health" else "ready"
|
|
4072
|
+
return {
|
|
4073
|
+
"name": name,
|
|
4074
|
+
"status": "ok" if status == expected else "error",
|
|
4075
|
+
"message": str(status or "unexpected response"),
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
|
|
4079
|
+
def doctor_token_check(state: CliState, client: CortexClient) -> dict[str, str]:
|
|
4080
|
+
if not state.access_token:
|
|
4081
|
+
return {
|
|
4082
|
+
"name": "token",
|
|
4083
|
+
"status": "warn",
|
|
4084
|
+
"message": "No local access token.",
|
|
4085
|
+
}
|
|
4086
|
+
try:
|
|
4087
|
+
client.me()
|
|
4088
|
+
except CliAPIError as exc:
|
|
4089
|
+
return {
|
|
4090
|
+
"name": "token",
|
|
4091
|
+
"status": "error",
|
|
4092
|
+
"message": f"{exc.code}: {exc.message}",
|
|
4093
|
+
}
|
|
4094
|
+
return {"name": "token", "status": "ok", "message": "Access token is valid."}
|
|
4095
|
+
|
|
4096
|
+
|
|
4097
|
+
def doctor_workspace_check(state: CliState, client: CortexClient) -> dict[str, str]:
|
|
4098
|
+
if not state.default_workspace_id:
|
|
4099
|
+
return {
|
|
4100
|
+
"name": "default_workspace",
|
|
4101
|
+
"status": "warn",
|
|
4102
|
+
"message": "No default Workspace configured.",
|
|
4103
|
+
}
|
|
4104
|
+
if not state.access_token:
|
|
4105
|
+
return {
|
|
4106
|
+
"name": "default_workspace",
|
|
4107
|
+
"status": "warn",
|
|
4108
|
+
"message": "Cannot validate Workspace without an access token.",
|
|
4109
|
+
}
|
|
4110
|
+
try:
|
|
4111
|
+
client.get_workspace(state.default_workspace_id)
|
|
4112
|
+
except CliAPIError as exc:
|
|
4113
|
+
return {
|
|
4114
|
+
"name": "default_workspace",
|
|
4115
|
+
"status": "error",
|
|
4116
|
+
"message": f"{exc.code}: {exc.message}",
|
|
4117
|
+
}
|
|
4118
|
+
return {
|
|
4119
|
+
"name": "default_workspace",
|
|
4120
|
+
"status": "ok",
|
|
4121
|
+
"message": state.default_workspace_id,
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
|
|
4125
|
+
def load_state(store: StateStore) -> CliState:
|
|
4126
|
+
state = store.load()
|
|
4127
|
+
if GLOBAL_API_URL:
|
|
4128
|
+
state.api_url = GLOBAL_API_URL
|
|
4129
|
+
elif not state.api_url:
|
|
4130
|
+
state.api_url = default_api_url()
|
|
4131
|
+
return state
|
|
4132
|
+
|
|
4133
|
+
|
|
4134
|
+
def resolve_workspace_id(workspace_id: str | None) -> str:
|
|
4135
|
+
resolved = workspace_id or StateStore().load().default_workspace_id
|
|
4136
|
+
if not resolved:
|
|
4137
|
+
raise CliAPIError(
|
|
4138
|
+
code="workspace.required",
|
|
4139
|
+
message="Provide a workspace id or run `cortex workspace use <id>`.",
|
|
4140
|
+
status_code=400,
|
|
4141
|
+
)
|
|
4142
|
+
return resolved
|
|
4143
|
+
|
|
4144
|
+
|
|
4145
|
+
def resolve_workspace_or_error(
|
|
4146
|
+
workspace_id: str | None,
|
|
4147
|
+
json_output: bool,
|
|
4148
|
+
) -> str | int:
|
|
4149
|
+
try:
|
|
4150
|
+
return resolve_workspace_id(workspace_id)
|
|
4151
|
+
except CliAPIError as exc:
|
|
4152
|
+
emit_cli_error(json_output, exc)
|
|
4153
|
+
return exit_code_for_api_error(exc)
|
|
4154
|
+
|
|
4155
|
+
|
|
4156
|
+
def parse_bbox_option(value: str) -> list[float] | None:
|
|
4157
|
+
parts = [part.strip() for part in value.split(",")]
|
|
4158
|
+
if len(parts) != 4:
|
|
4159
|
+
return None
|
|
4160
|
+
try:
|
|
4161
|
+
bbox = [float(part) for part in parts]
|
|
4162
|
+
except ValueError:
|
|
4163
|
+
return None
|
|
4164
|
+
x1, y1, x2, y2 = bbox
|
|
4165
|
+
if x2 <= x1 or y2 <= y1:
|
|
4166
|
+
return None
|
|
4167
|
+
return bbox
|
|
4168
|
+
|
|
4169
|
+
|
|
4170
|
+
def no_content_payload(status: str, _result: object) -> dict[str, str]:
|
|
4171
|
+
return {"status": status}
|
|
4172
|
+
|
|
4173
|
+
|
|
4174
|
+
def trash_restore_target(
|
|
4175
|
+
*,
|
|
4176
|
+
kb_id: str | None,
|
|
4177
|
+
folder_id: str | None,
|
|
4178
|
+
item_id: str | None,
|
|
4179
|
+
unclassified: bool,
|
|
4180
|
+
original: bool,
|
|
4181
|
+
) -> dict[str, str]:
|
|
4182
|
+
if original:
|
|
4183
|
+
return {"kind": "original"}
|
|
4184
|
+
if kb_id:
|
|
4185
|
+
return {"kind": "kb_root", "kb_id": kb_id}
|
|
4186
|
+
if folder_id:
|
|
4187
|
+
return {"kind": "folder", "folder_id": folder_id}
|
|
4188
|
+
if item_id:
|
|
4189
|
+
return {"kind": "knowledge_item", "knowledge_item_id": item_id}
|
|
4190
|
+
if unclassified:
|
|
4191
|
+
return {"kind": "unclassified"}
|
|
4192
|
+
raise CliAPIError(
|
|
4193
|
+
code="trash.restore_target_required",
|
|
4194
|
+
message="Provide a restore target.",
|
|
4195
|
+
status_code=400,
|
|
4196
|
+
)
|
|
4197
|
+
|
|
4198
|
+
|
|
4199
|
+
def emit_usage_error(json_output: bool, code: str, message: str) -> int:
|
|
4200
|
+
emit_cli_error(
|
|
4201
|
+
json_output,
|
|
4202
|
+
CliAPIError(code=code, message=message, status_code=400),
|
|
4203
|
+
)
|
|
4204
|
+
return EXIT_USAGE
|
|
4205
|
+
|
|
4206
|
+
|
|
4207
|
+
def emit_cli_error(json_output: bool, exc: CliAPIError) -> None:
|
|
4208
|
+
payload = {
|
|
4209
|
+
"error": {
|
|
4210
|
+
"code": exc.code,
|
|
4211
|
+
"message": exc.message,
|
|
4212
|
+
"details": exc.details,
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
if json_output:
|
|
4216
|
+
print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr)
|
|
4217
|
+
return
|
|
4218
|
+
print(f"error: {exc.code}: {exc.message}", file=sys.stderr)
|
|
4219
|
+
|
|
4220
|
+
|
|
4221
|
+
def render_item_list(payload: Any) -> None:
|
|
4222
|
+
render_paged_table(
|
|
4223
|
+
payload,
|
|
4224
|
+
["ID", "AVAIL", "LOCK", "MOUNTS", "TITLE"],
|
|
4225
|
+
lambda item: [
|
|
4226
|
+
short_id(item.get("id")),
|
|
4227
|
+
bool_mark(item.get("is_available")),
|
|
4228
|
+
bool_mark(item.get("is_locked")),
|
|
4229
|
+
str(len(item.get("mounts") or [])),
|
|
4230
|
+
str(item.get("title") or ""),
|
|
4231
|
+
],
|
|
4232
|
+
empty_message="No knowledge items.",
|
|
4233
|
+
)
|
|
4234
|
+
|
|
4235
|
+
|
|
4236
|
+
def render_trash_list(payload: Any) -> None:
|
|
4237
|
+
render_paged_table(
|
|
4238
|
+
payload,
|
|
4239
|
+
["ID", "TYPE", "STATUS", "LOCATION", "TITLE"],
|
|
4240
|
+
lambda item: [
|
|
4241
|
+
short_id(item.get("id")),
|
|
4242
|
+
str(item.get("object_type") or "-"),
|
|
4243
|
+
trash_status_label(item),
|
|
4244
|
+
trash_location_label(item),
|
|
4245
|
+
str(item.get("title") or ""),
|
|
4246
|
+
],
|
|
4247
|
+
empty_message="No trash entries.",
|
|
4248
|
+
)
|
|
4249
|
+
|
|
4250
|
+
|
|
4251
|
+
def trash_status_label(item: dict[str, Any]) -> str:
|
|
4252
|
+
original_location = item.get("original_location")
|
|
4253
|
+
if isinstance(original_location, dict):
|
|
4254
|
+
return str(original_location.get("status") or "-")
|
|
4255
|
+
return "-"
|
|
4256
|
+
|
|
4257
|
+
|
|
4258
|
+
def trash_location_label(item: dict[str, Any]) -> str:
|
|
4259
|
+
original_location = item.get("original_location")
|
|
4260
|
+
if isinstance(original_location, dict) and original_location.get("label"):
|
|
4261
|
+
return str(original_location["label"])
|
|
4262
|
+
return "-"
|
|
4263
|
+
|
|
4264
|
+
|
|
4265
|
+
def render_workspace_panel(payload: Any) -> None:
|
|
4266
|
+
if not isinstance(payload, dict):
|
|
4267
|
+
_emit(payload, json_output=False)
|
|
4268
|
+
return
|
|
4269
|
+
user = payload.get("user") or {}
|
|
4270
|
+
if isinstance(user, dict):
|
|
4271
|
+
display = user.get("display_name") or user.get("email") or user.get("id") or "-"
|
|
4272
|
+
print(f"User: {display}")
|
|
4273
|
+
workspaces = payload.get("workspaces")
|
|
4274
|
+
invites = payload.get("pending_invites")
|
|
4275
|
+
if isinstance(workspaces, list):
|
|
4276
|
+
print("Workspaces")
|
|
4277
|
+
render_sequence_table(
|
|
4278
|
+
workspaces,
|
|
4279
|
+
["ID", "ROLE", "NAME", "OWNER"],
|
|
4280
|
+
lambda item: [
|
|
4281
|
+
short_id(item.get("id")),
|
|
4282
|
+
str(item.get("role") or "-"),
|
|
4283
|
+
str(item.get("name") or ""),
|
|
4284
|
+
short_id(item.get("owner_id")),
|
|
4285
|
+
],
|
|
4286
|
+
empty_message="No workspaces.",
|
|
4287
|
+
)
|
|
4288
|
+
if isinstance(invites, list):
|
|
4289
|
+
print("Pending invites")
|
|
4290
|
+
render_sequence_table(
|
|
4291
|
+
invites,
|
|
4292
|
+
["ID", "ROLE", "WORKSPACE", "INVITED_BY"],
|
|
4293
|
+
lambda item: [
|
|
4294
|
+
short_id(item.get("id")),
|
|
4295
|
+
str(item.get("role") or "-"),
|
|
4296
|
+
str(item.get("workspace_name") or ""),
|
|
4297
|
+
str(item.get("invited_by_name") or "-"),
|
|
4298
|
+
],
|
|
4299
|
+
empty_message="No pending invites.",
|
|
4300
|
+
)
|
|
4301
|
+
|
|
4302
|
+
|
|
4303
|
+
def render_invite_list(payload: Any) -> None:
|
|
4304
|
+
render_paged_table(
|
|
4305
|
+
payload,
|
|
4306
|
+
["ID", "STATUS", "ROLE", "EMAIL/WORKSPACE", "INVITED_BY"],
|
|
4307
|
+
lambda item: [
|
|
4308
|
+
short_id(item.get("id")),
|
|
4309
|
+
str(item.get("status") or "-"),
|
|
4310
|
+
str(item.get("role") or "-"),
|
|
4311
|
+
str(item.get("invite_email") or item.get("workspace_name") or ""),
|
|
4312
|
+
str(item.get("invited_by_name") or "-"),
|
|
4313
|
+
],
|
|
4314
|
+
empty_message="No invites.",
|
|
4315
|
+
)
|
|
4316
|
+
|
|
4317
|
+
|
|
4318
|
+
def render_invite_link_list(payload: Any) -> None:
|
|
4319
|
+
render_paged_table(
|
|
4320
|
+
payload,
|
|
4321
|
+
["ID", "STATE", "ROLE", "USES", "PREFIX", "NAME"],
|
|
4322
|
+
lambda item: [
|
|
4323
|
+
short_id(item.get("id")),
|
|
4324
|
+
"revoked" if item.get("revoked_at") else "active",
|
|
4325
|
+
str(item.get("role") or "-"),
|
|
4326
|
+
str(item.get("used_count") or 0),
|
|
4327
|
+
str(item.get("token_prefix") or "-"),
|
|
4328
|
+
str(item.get("name") or ""),
|
|
4329
|
+
],
|
|
4330
|
+
empty_message="No invite links.",
|
|
4331
|
+
)
|
|
4332
|
+
|
|
4333
|
+
|
|
4334
|
+
def render_invite_link_created(payload: Any) -> None:
|
|
4335
|
+
if not isinstance(payload, dict):
|
|
4336
|
+
_emit(payload, json_output=False)
|
|
4337
|
+
return
|
|
4338
|
+
print(f"Invite link: {payload.get('invite_url')}")
|
|
4339
|
+
print(f"Token: {payload.get('token')}")
|
|
4340
|
+
print("The token is only shown once. Store or share this URL now.")
|
|
4341
|
+
|
|
4342
|
+
|
|
4343
|
+
def render_member_list(payload: Any) -> None:
|
|
4344
|
+
render_paged_table(
|
|
4345
|
+
payload,
|
|
4346
|
+
["USER", "ROLE", "NAME", "EMAIL"],
|
|
4347
|
+
lambda item: [
|
|
4348
|
+
short_id(item.get("user_id")),
|
|
4349
|
+
str(item.get("role") or "-"),
|
|
4350
|
+
str(item.get("display_name") or ""),
|
|
4351
|
+
str(item.get("email") or "-"),
|
|
4352
|
+
],
|
|
4353
|
+
empty_message="No members.",
|
|
4354
|
+
)
|
|
4355
|
+
|
|
4356
|
+
|
|
4357
|
+
def render_kb_list(payload: Any) -> None:
|
|
4358
|
+
render_paged_table(
|
|
4359
|
+
payload,
|
|
4360
|
+
["ID", "POLICY", "EDIT", "PIPELINE", "NAME", "DESCRIPTION"],
|
|
4361
|
+
lambda item: [
|
|
4362
|
+
short_id(item.get("id")),
|
|
4363
|
+
str(item.get("edit_policy") or "-"),
|
|
4364
|
+
"yes" if dict(item.get("capabilities") or {}).get("can_edit") else "no",
|
|
4365
|
+
short_id(item.get("pipeline_id")),
|
|
4366
|
+
str(item.get("name") or ""),
|
|
4367
|
+
truncate(item.get("description"), 48),
|
|
4368
|
+
],
|
|
4369
|
+
empty_message="No knowledge bases.",
|
|
4370
|
+
)
|
|
4371
|
+
|
|
4372
|
+
|
|
4373
|
+
def render_kb_order(payload: Any) -> None:
|
|
4374
|
+
if not isinstance(payload, dict):
|
|
4375
|
+
_emit(payload, json_output=False)
|
|
4376
|
+
return
|
|
4377
|
+
render_sequence_table(
|
|
4378
|
+
payload.get("items") or [],
|
|
4379
|
+
["ID", "POLICY", "EDIT", "PIPELINE", "NAME", "DESCRIPTION"],
|
|
4380
|
+
lambda item: [
|
|
4381
|
+
short_id(item.get("id")),
|
|
4382
|
+
str(item.get("edit_policy") or "-"),
|
|
4383
|
+
"yes" if dict(item.get("capabilities") or {}).get("can_edit") else "no",
|
|
4384
|
+
short_id(item.get("pipeline_id")),
|
|
4385
|
+
str(item.get("name") or ""),
|
|
4386
|
+
truncate(item.get("description"), 48),
|
|
4387
|
+
],
|
|
4388
|
+
empty_message="No knowledge bases.",
|
|
4389
|
+
)
|
|
4390
|
+
|
|
4391
|
+
|
|
4392
|
+
def render_kb_permissions(payload: Any) -> None:
|
|
4393
|
+
if not isinstance(payload, dict):
|
|
4394
|
+
_emit(payload, json_output=False)
|
|
4395
|
+
return
|
|
4396
|
+
print(f"KB: {short_id(payload.get('kb_id'))}")
|
|
4397
|
+
print(f"Edit policy: {payload.get('edit_policy') or '-'}")
|
|
4398
|
+
editors = payload.get("granted_editors") or []
|
|
4399
|
+
render_sequence_table(
|
|
4400
|
+
editors,
|
|
4401
|
+
["USER", "NAME", "EMAIL", "GRANTED_BY"],
|
|
4402
|
+
lambda item: [
|
|
4403
|
+
short_id(item.get("user_id")),
|
|
4404
|
+
str(item.get("display_name") or ""),
|
|
4405
|
+
str(item.get("email") or "-"),
|
|
4406
|
+
short_id(item.get("granted_by")),
|
|
4407
|
+
],
|
|
4408
|
+
empty_message="No granted editors.",
|
|
4409
|
+
)
|
|
4410
|
+
|
|
4411
|
+
|
|
4412
|
+
def render_folder_tree(payload: Any) -> None:
|
|
4413
|
+
if not isinstance(payload, list):
|
|
4414
|
+
_emit(payload, json_output=False)
|
|
4415
|
+
return
|
|
4416
|
+
if not payload:
|
|
4417
|
+
print("No folders.")
|
|
4418
|
+
return
|
|
4419
|
+
for line in folder_tree_lines(payload):
|
|
4420
|
+
print(line)
|
|
4421
|
+
|
|
4422
|
+
|
|
4423
|
+
def render_file_list(payload: Any) -> None:
|
|
4424
|
+
render_sequence_table(
|
|
4425
|
+
payload,
|
|
4426
|
+
["ID", "REL", "PREVIEW", "SIZE", "NAME"],
|
|
4427
|
+
lambda item: [
|
|
4428
|
+
short_id(item.get("id")),
|
|
4429
|
+
str(item.get("relation_type") or "-"),
|
|
4430
|
+
str(item.get("preview_status") or "-"),
|
|
4431
|
+
format_bytes(item.get("file_size")),
|
|
4432
|
+
str(item.get("file_name") or ""),
|
|
4433
|
+
],
|
|
4434
|
+
empty_message="No files.",
|
|
4435
|
+
)
|
|
4436
|
+
|
|
4437
|
+
|
|
4438
|
+
def render_pipeline_list(payload: Any) -> None:
|
|
4439
|
+
render_sequence_table(
|
|
4440
|
+
payload,
|
|
4441
|
+
["ID", "DEFAULT", "GOAL", "NAME"],
|
|
4442
|
+
lambda item: [
|
|
4443
|
+
short_id(item.get("id")),
|
|
4444
|
+
bool_mark(item.get("is_default")),
|
|
4445
|
+
str(item.get("goal") or "-"),
|
|
4446
|
+
str(item.get("name") or ""),
|
|
4447
|
+
],
|
|
4448
|
+
empty_message="No processing pipelines.",
|
|
4449
|
+
)
|
|
4450
|
+
|
|
4451
|
+
|
|
4452
|
+
def render_processing_runs(payload: Any) -> None:
|
|
4453
|
+
if isinstance(payload, dict) and isinstance(payload.get("items"), list):
|
|
4454
|
+
render_paged_table(
|
|
4455
|
+
payload,
|
|
4456
|
+
["ID", "STATUS", "STEP", "PIPELINE", "ERROR"],
|
|
4457
|
+
lambda item: processing_run_row(item, run_id_field="id"),
|
|
4458
|
+
empty_message="No processing runs.",
|
|
4459
|
+
)
|
|
4460
|
+
return
|
|
4461
|
+
render_sequence_table(
|
|
4462
|
+
payload,
|
|
4463
|
+
["ID", "STATUS", "STEP", "PIPELINE", "ERROR"],
|
|
4464
|
+
lambda item: processing_run_row(item, run_id_field="id"),
|
|
4465
|
+
empty_message="No processing runs.",
|
|
4466
|
+
)
|
|
4467
|
+
|
|
4468
|
+
|
|
4469
|
+
def render_processing_reconcile(payload: Any) -> None:
|
|
4470
|
+
render_sequence_table(
|
|
4471
|
+
payload,
|
|
4472
|
+
["RUN", "OLD", "NEW", "TEMPORAL", "CHANGED", "ERROR"],
|
|
4473
|
+
lambda item: [
|
|
4474
|
+
short_id(item.get("run_id")),
|
|
4475
|
+
str(item.get("old_status") or "-"),
|
|
4476
|
+
str(item.get("new_status") or "-"),
|
|
4477
|
+
str(item.get("temporal_status") or "-"),
|
|
4478
|
+
bool_mark(item.get("changed")),
|
|
4479
|
+
truncate(item.get("error"), 40),
|
|
4480
|
+
],
|
|
4481
|
+
empty_message="No reconcile candidates.",
|
|
4482
|
+
)
|
|
4483
|
+
|
|
4484
|
+
|
|
4485
|
+
def render_search_list(payload: Any) -> None:
|
|
4486
|
+
render_paged_table(
|
|
4487
|
+
payload,
|
|
4488
|
+
search_headers(),
|
|
4489
|
+
search_result_row,
|
|
4490
|
+
empty_message="No search results.",
|
|
4491
|
+
)
|
|
4492
|
+
render_next_steps(payload)
|
|
4493
|
+
|
|
4494
|
+
|
|
4495
|
+
def render_search_rows(payload: Any) -> None:
|
|
4496
|
+
rows = payload.get("items") if isinstance(payload, dict) else payload
|
|
4497
|
+
render_sequence_table(
|
|
4498
|
+
rows,
|
|
4499
|
+
search_headers(),
|
|
4500
|
+
search_result_row,
|
|
4501
|
+
empty_message="No search results.",
|
|
4502
|
+
)
|
|
4503
|
+
render_next_steps(payload)
|
|
4504
|
+
|
|
4505
|
+
|
|
4506
|
+
def render_chunk(payload: Any) -> None:
|
|
4507
|
+
if not isinstance(payload, dict):
|
|
4508
|
+
_emit(payload, json_output=False)
|
|
4509
|
+
return
|
|
4510
|
+
print(f"file: {payload.get('file_id')}")
|
|
4511
|
+
print(f"chunk: {payload.get('chunk_idx')}")
|
|
4512
|
+
text = str(payload.get("text") or "")
|
|
4513
|
+
if text:
|
|
4514
|
+
print(text)
|
|
4515
|
+
|
|
4516
|
+
|
|
4517
|
+
def render_chunk_context(payload: Any) -> None:
|
|
4518
|
+
if not isinstance(payload, dict):
|
|
4519
|
+
_emit(payload, json_output=False)
|
|
4520
|
+
return
|
|
4521
|
+
print(f"file: {payload.get('file_id')}")
|
|
4522
|
+
print(f"target_chunk: {payload.get('chunk_idx')}")
|
|
4523
|
+
chunks = payload.get("chunks")
|
|
4524
|
+
if not isinstance(chunks, list) or not chunks:
|
|
4525
|
+
print("No chunks.")
|
|
4526
|
+
return
|
|
4527
|
+
for chunk in chunks:
|
|
4528
|
+
if not isinstance(chunk, dict):
|
|
4529
|
+
continue
|
|
4530
|
+
print(f"--- chunk {chunk.get('chunk_idx')} ---")
|
|
4531
|
+
print(str(chunk.get("text") or ""))
|
|
4532
|
+
|
|
4533
|
+
|
|
4534
|
+
def render_source_location(payload: Any) -> None:
|
|
4535
|
+
if not isinstance(payload, dict):
|
|
4536
|
+
_emit(payload, json_output=False)
|
|
4537
|
+
return
|
|
4538
|
+
print(f"file: {payload.get('file_id')}")
|
|
4539
|
+
print(f"chunk: {payload.get('chunk_idx')}")
|
|
4540
|
+
anchors = payload.get("anchors")
|
|
4541
|
+
print(f"anchors: {len(anchors) if isinstance(anchors, list) else 0}")
|
|
4542
|
+
regions = payload.get("regions")
|
|
4543
|
+
print(f"regions: {len(regions) if isinstance(regions, list) else 0}")
|
|
4544
|
+
artifact = payload.get("artifact")
|
|
4545
|
+
if isinstance(artifact, dict):
|
|
4546
|
+
print(f"artifact: {artifact.get('kind')} {short_id(artifact.get('id'))}")
|
|
4547
|
+
blocks = payload.get("blocks")
|
|
4548
|
+
if isinstance(blocks, list) and blocks:
|
|
4549
|
+
for block in blocks:
|
|
4550
|
+
if not isinstance(block, dict):
|
|
4551
|
+
continue
|
|
4552
|
+
print(f"--- block {block.get('block_id')} ---")
|
|
4553
|
+
print(str(block.get("text") or ""))
|
|
4554
|
+
|
|
4555
|
+
|
|
4556
|
+
def render_source_expand(payload: Any) -> None:
|
|
4557
|
+
if not isinstance(payload, dict):
|
|
4558
|
+
_emit(payload, json_output=False)
|
|
4559
|
+
return
|
|
4560
|
+
print(f"file: {payload.get('file_id')}")
|
|
4561
|
+
print(f"chunk: {payload.get('chunk_idx')}")
|
|
4562
|
+
print(f"mode: {payload.get('mode')}")
|
|
4563
|
+
print(f"window: {payload.get('window')}")
|
|
4564
|
+
blocks = payload.get("blocks")
|
|
4565
|
+
regions = payload.get("regions")
|
|
4566
|
+
print(f"blocks: {len(blocks) if isinstance(blocks, list) else 0}")
|
|
4567
|
+
print(f"regions: {len(regions) if isinstance(regions, list) else 0}")
|
|
4568
|
+
text = str(payload.get("text") or "")
|
|
4569
|
+
if text:
|
|
4570
|
+
print(text)
|
|
4571
|
+
|
|
4572
|
+
|
|
4573
|
+
def render_source_page(payload: Any) -> None:
|
|
4574
|
+
if not isinstance(payload, dict):
|
|
4575
|
+
_emit(payload, json_output=False)
|
|
4576
|
+
return
|
|
4577
|
+
print(f"file: {payload.get('file_id')}")
|
|
4578
|
+
print(f"page: {payload.get('page_label') or payload.get('page_index')}")
|
|
4579
|
+
blocks = payload.get("blocks")
|
|
4580
|
+
regions = payload.get("regions")
|
|
4581
|
+
print(f"blocks: {len(blocks) if isinstance(blocks, list) else 0}")
|
|
4582
|
+
print(f"regions: {len(regions) if isinstance(regions, list) else 0}")
|
|
4583
|
+
image = payload.get("image")
|
|
4584
|
+
if isinstance(image, dict):
|
|
4585
|
+
print(f"image: {image.get('mime_type')} {image.get('object_name')}")
|
|
4586
|
+
if image.get("data_base64"):
|
|
4587
|
+
print(f"image_bytes: base64 len={len(str(image.get('data_base64')))}")
|
|
4588
|
+
if payload.get("saved_image_path"):
|
|
4589
|
+
print(f"saved: {payload.get('saved_image_path')}")
|
|
4590
|
+
text = str(payload.get("text") or "")
|
|
4591
|
+
if text:
|
|
4592
|
+
print(text)
|
|
4593
|
+
|
|
4594
|
+
|
|
4595
|
+
def render_source_region_image(payload: Any) -> None:
|
|
4596
|
+
if not isinstance(payload, dict):
|
|
4597
|
+
_emit(payload, json_output=False)
|
|
4598
|
+
return
|
|
4599
|
+
print(f"file: {payload.get('file_id')}")
|
|
4600
|
+
print(f"page_index: {payload.get('page_index')}")
|
|
4601
|
+
print(f"bbox: {payload.get('bbox')}")
|
|
4602
|
+
image = payload.get("image")
|
|
4603
|
+
if isinstance(image, dict):
|
|
4604
|
+
print(f"image: {image.get('mime_type')} {image.get('object_name')}")
|
|
4605
|
+
if image.get("data_base64"):
|
|
4606
|
+
print(f"image_bytes: base64 len={len(str(image.get('data_base64')))}")
|
|
4607
|
+
if payload.get("saved_image_path"):
|
|
4608
|
+
print(f"saved: {payload.get('saved_image_path')}")
|
|
4609
|
+
|
|
4610
|
+
|
|
4611
|
+
def enrich_source_image_for_agent(
|
|
4612
|
+
payload: Any,
|
|
4613
|
+
*,
|
|
4614
|
+
output_path: Path | None = None,
|
|
4615
|
+
) -> Any:
|
|
4616
|
+
"""Attach MCP-aligned vision content and optionally write PNG for Agents.
|
|
4617
|
+
|
|
4618
|
+
JSON shape (when bytes present)::
|
|
4619
|
+
|
|
4620
|
+
{
|
|
4621
|
+
"image": {..., "data_base64": "..."},
|
|
4622
|
+
"content": [
|
|
4623
|
+
{"type": "text", "text": "..."},
|
|
4624
|
+
{"type": "image", "mimeType": "image/png", "data": "<base64>"}
|
|
4625
|
+
],
|
|
4626
|
+
"saved_image_path": "/optional/path.png"
|
|
4627
|
+
}
|
|
4628
|
+
|
|
4629
|
+
``mimeType`` matches MCP resource spelling for schema-validating clients.
|
|
4630
|
+
"""
|
|
4631
|
+
if not isinstance(payload, dict):
|
|
4632
|
+
return payload
|
|
4633
|
+
|
|
4634
|
+
result = dict(payload)
|
|
4635
|
+
image = result.get("image")
|
|
4636
|
+
if not isinstance(image, dict):
|
|
4637
|
+
return result
|
|
4638
|
+
|
|
4639
|
+
data_b64 = image.get("data_base64")
|
|
4640
|
+
if isinstance(data_b64, str) and data_b64:
|
|
4641
|
+
if output_path is not None:
|
|
4642
|
+
raw = base64.b64decode(data_b64)
|
|
4643
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4644
|
+
output_path.write_bytes(raw)
|
|
4645
|
+
result["saved_image_path"] = str(output_path.resolve())
|
|
4646
|
+
|
|
4647
|
+
text_parts: list[str] = []
|
|
4648
|
+
page_label = result.get("page_label") or result.get("page_index")
|
|
4649
|
+
if page_label is not None:
|
|
4650
|
+
text_parts.append(f"page={page_label}")
|
|
4651
|
+
if result.get("bbox") is not None:
|
|
4652
|
+
text_parts.append(f"bbox={result.get('bbox')}")
|
|
4653
|
+
body_text = str(result.get("text") or "").strip()
|
|
4654
|
+
if body_text:
|
|
4655
|
+
text_parts.append(body_text)
|
|
4656
|
+
mime = str(image.get("mime_type") or image.get("mimeType") or "image/png")
|
|
4657
|
+
result["content"] = [
|
|
4658
|
+
{
|
|
4659
|
+
"type": "text",
|
|
4660
|
+
"text": "\n".join(text_parts) if text_parts else "source image",
|
|
4661
|
+
},
|
|
4662
|
+
{
|
|
4663
|
+
"type": "image",
|
|
4664
|
+
"mimeType": mime,
|
|
4665
|
+
"data": data_b64,
|
|
4666
|
+
},
|
|
4667
|
+
]
|
|
4668
|
+
return result
|
|
4669
|
+
|
|
4670
|
+
|
|
4671
|
+
def render_mcp_interfaces(payload: Any) -> None:
|
|
4672
|
+
render_sequence_table(
|
|
4673
|
+
payload,
|
|
4674
|
+
["ID", "ENABLED", "AUTH", "KBS", "TOOLS", "SLUG", "NAME"],
|
|
4675
|
+
lambda item: [
|
|
4676
|
+
short_id(item.get("id")),
|
|
4677
|
+
bool_mark(item.get("enabled")),
|
|
4678
|
+
str(item.get("auth_type") or "-"),
|
|
4679
|
+
str(len(item.get("kb_ids") or [])),
|
|
4680
|
+
",".join(item.get("enabled_tools") or []),
|
|
4681
|
+
str(item.get("slug") or ""),
|
|
4682
|
+
str(item.get("name") or ""),
|
|
4683
|
+
],
|
|
4684
|
+
empty_message="No MCP interfaces.",
|
|
4685
|
+
)
|
|
4686
|
+
|
|
4687
|
+
|
|
4688
|
+
def render_mcp_api_keys(payload: Any) -> None:
|
|
4689
|
+
render_sequence_table(
|
|
4690
|
+
payload,
|
|
4691
|
+
["ID", "PREFIX", "REVOKED", "EXPIRES", "NAME"],
|
|
4692
|
+
lambda item: [
|
|
4693
|
+
short_id(item.get("id")),
|
|
4694
|
+
str(item.get("key_prefix") or "-"),
|
|
4695
|
+
bool_mark(item.get("revoked_at") is not None),
|
|
4696
|
+
str(item.get("expires_at") or "-"),
|
|
4697
|
+
str(item.get("name") or ""),
|
|
4698
|
+
],
|
|
4699
|
+
empty_message="No MCP API keys.",
|
|
4700
|
+
)
|
|
4701
|
+
|
|
4702
|
+
|
|
4703
|
+
def render_next_steps(payload: Any) -> None:
|
|
4704
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("next"), list):
|
|
4705
|
+
return
|
|
4706
|
+
steps = [step for step in payload["next"] if isinstance(step, dict)]
|
|
4707
|
+
if not steps:
|
|
4708
|
+
return
|
|
4709
|
+
print("Next:")
|
|
4710
|
+
for step in steps:
|
|
4711
|
+
label = step.get("label") or "next"
|
|
4712
|
+
command = step.get("command") or ""
|
|
4713
|
+
print(f" {label}: {command}")
|
|
4714
|
+
|
|
4715
|
+
|
|
4716
|
+
def render_paged_table(
|
|
4717
|
+
payload: Any,
|
|
4718
|
+
headers: list[str],
|
|
4719
|
+
row_builder: Callable[[dict[str, Any]], list[str]],
|
|
4720
|
+
*,
|
|
4721
|
+
empty_message: str,
|
|
4722
|
+
) -> None:
|
|
4723
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
|
|
4724
|
+
_emit(payload, json_output=False)
|
|
4725
|
+
return
|
|
4726
|
+
render_sequence_table(
|
|
4727
|
+
payload["items"],
|
|
4728
|
+
headers,
|
|
4729
|
+
row_builder,
|
|
4730
|
+
empty_message=empty_message,
|
|
4731
|
+
)
|
|
4732
|
+
print(pagination_line(payload))
|
|
4733
|
+
|
|
4734
|
+
|
|
4735
|
+
def render_sequence_table(
|
|
4736
|
+
payload: Any,
|
|
4737
|
+
headers: list[str],
|
|
4738
|
+
row_builder: Callable[[dict[str, Any]], list[str]],
|
|
4739
|
+
*,
|
|
4740
|
+
empty_message: str,
|
|
4741
|
+
) -> None:
|
|
4742
|
+
if not isinstance(payload, list):
|
|
4743
|
+
_emit(payload, json_output=False)
|
|
4744
|
+
return
|
|
4745
|
+
rows = [row_builder(item) for item in payload if isinstance(item, dict)]
|
|
4746
|
+
if not rows:
|
|
4747
|
+
print(empty_message)
|
|
4748
|
+
return
|
|
4749
|
+
render_table(headers, rows)
|
|
4750
|
+
|
|
4751
|
+
|
|
4752
|
+
def folder_tree_lines(nodes: list[dict[str, Any]], prefix: str = "") -> list[str]:
|
|
4753
|
+
lines: list[str] = []
|
|
4754
|
+
for index, node in enumerate(nodes):
|
|
4755
|
+
is_last = index == len(nodes) - 1
|
|
4756
|
+
marker = "`- " if is_last else "+- "
|
|
4757
|
+
name = str(node.get("name") or "")
|
|
4758
|
+
lines.append(f"{prefix}{marker}{name} {short_id(node.get('id'))}")
|
|
4759
|
+
child_prefix = f"{prefix}{' ' if is_last else '| '}"
|
|
4760
|
+
children = node.get("children")
|
|
4761
|
+
if isinstance(children, list):
|
|
4762
|
+
lines.extend(folder_tree_lines(children, child_prefix))
|
|
4763
|
+
return lines
|
|
4764
|
+
|
|
4765
|
+
|
|
4766
|
+
def search_headers() -> list[str]:
|
|
4767
|
+
return ["SCORE", "ITEM", "FILE", "CHUNK", "SOURCE", "TITLE", "SNIPPET"]
|
|
4768
|
+
|
|
4769
|
+
|
|
4770
|
+
def search_result_row(item: dict[str, Any]) -> list[str]:
|
|
4771
|
+
return [
|
|
4772
|
+
format_score(item.get("score")),
|
|
4773
|
+
short_id(item.get("item_id")),
|
|
4774
|
+
short_id(item.get("file_id")),
|
|
4775
|
+
str(item.get("chunk_idx") if item.get("chunk_idx") is not None else "-"),
|
|
4776
|
+
str(item.get("source") or "-"),
|
|
4777
|
+
truncate(item.get("title"), 32),
|
|
4778
|
+
truncate(item.get("snippet"), 72),
|
|
4779
|
+
]
|
|
4780
|
+
|
|
4781
|
+
|
|
4782
|
+
def processing_run_row(item: dict[str, Any], *, run_id_field: str) -> list[str]:
|
|
4783
|
+
return [
|
|
4784
|
+
short_id(item.get(run_id_field)),
|
|
4785
|
+
str(item.get("status") or "-"),
|
|
4786
|
+
str(item.get("current_step") or "-"),
|
|
4787
|
+
short_id(item.get("pipeline_id")),
|
|
4788
|
+
truncate(item.get("error_message"), 40),
|
|
4789
|
+
]
|
|
4790
|
+
|
|
4791
|
+
|
|
4792
|
+
def render_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
4793
|
+
widths = [
|
|
4794
|
+
max(len(header), *(len(row[index]) for row in rows))
|
|
4795
|
+
for index, header in enumerate(headers)
|
|
4796
|
+
]
|
|
4797
|
+
print(
|
|
4798
|
+
" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
|
|
4799
|
+
)
|
|
4800
|
+
print(" ".join("-" * width for width in widths))
|
|
4801
|
+
for row in rows:
|
|
4802
|
+
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
|
4803
|
+
|
|
4804
|
+
|
|
4805
|
+
def short_id(value: object) -> str:
|
|
4806
|
+
text = str(value or "")
|
|
4807
|
+
return text[:8] if len(text) > 8 else text
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
def bool_mark(value: object) -> str:
|
|
4811
|
+
if value is True:
|
|
4812
|
+
return "yes"
|
|
4813
|
+
if value is False:
|
|
4814
|
+
return "no"
|
|
4815
|
+
return "-"
|
|
4816
|
+
|
|
4817
|
+
|
|
4818
|
+
def truncate(value: object, limit: int) -> str:
|
|
4819
|
+
text = " ".join(str(value or "").split())
|
|
4820
|
+
if len(text) <= limit:
|
|
4821
|
+
return text
|
|
4822
|
+
return f"{text[: max(0, limit - 3)]}..."
|
|
4823
|
+
|
|
4824
|
+
|
|
4825
|
+
def format_bytes(value: object) -> str:
|
|
4826
|
+
if not isinstance(value, int):
|
|
4827
|
+
return "-"
|
|
4828
|
+
units = ["B", "KB", "MB", "GB"]
|
|
4829
|
+
amount = float(value)
|
|
4830
|
+
for unit in units:
|
|
4831
|
+
if amount < 1024 or unit == units[-1]:
|
|
4832
|
+
if unit == "B":
|
|
4833
|
+
return f"{int(amount)}{unit}"
|
|
4834
|
+
return f"{amount:.1f}{unit}"
|
|
4835
|
+
amount /= 1024
|
|
4836
|
+
|
|
4837
|
+
|
|
4838
|
+
def format_score(value: object) -> str:
|
|
4839
|
+
if value is None:
|
|
4840
|
+
return "-"
|
|
4841
|
+
try:
|
|
4842
|
+
return f"{float(value):.3f}"
|
|
4843
|
+
except (TypeError, ValueError):
|
|
4844
|
+
return str(value)
|
|
4845
|
+
|
|
4846
|
+
|
|
4847
|
+
def pagination_line(payload: dict[str, Any]) -> str:
|
|
4848
|
+
page = payload.get("page", 1)
|
|
4849
|
+
page_size = payload.get("page_size", len(payload.get("items") or []))
|
|
4850
|
+
total = payload.get("total", len(payload.get("items") or []))
|
|
4851
|
+
return f"page {page} page_size {page_size} total {total}"
|
|
4852
|
+
|
|
4853
|
+
|
|
4854
|
+
def json_for(command_json: bool) -> bool:
|
|
4855
|
+
return command_json or GLOBAL_JSON
|
|
4856
|
+
|
|
4857
|
+
|
|
4858
|
+
def group_to_dict(group: CommandSpec) -> dict[str, object]:
|
|
4859
|
+
return asdict(group)
|
|
4860
|
+
|
|
4861
|
+
|
|
4862
|
+
def _emit(payload: Any, json_output: bool) -> None:
|
|
4863
|
+
if json_output:
|
|
4864
|
+
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
4865
|
+
return
|
|
4866
|
+
|
|
4867
|
+
if isinstance(payload, list):
|
|
4868
|
+
for item in payload:
|
|
4869
|
+
print(json.dumps(item, ensure_ascii=False, sort_keys=True))
|
|
4870
|
+
return
|
|
4871
|
+
if not isinstance(payload, dict):
|
|
4872
|
+
print(payload)
|
|
4873
|
+
return
|
|
4874
|
+
for key, value in payload.items():
|
|
4875
|
+
if isinstance(value, dict | list):
|
|
4876
|
+
print(f"{key}: {json.dumps(value, ensure_ascii=False, sort_keys=True)}")
|
|
4877
|
+
else:
|
|
4878
|
+
print(f"{key}: {value}")
|