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/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}")