cheso-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cheso_cli/cli.py ADDED
@@ -0,0 +1,864 @@
1
+ # pylint: disable=broad-except
2
+ """cheso CLI:在终端或编程 Agent 中使用 Cheso,支持任务、文件、转换、翻译、登录和技能安装。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ import webbrowser
13
+ from collections.abc import Callable, Mapping
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, TextIO
17
+
18
+ import httpx
19
+
20
+ from cheso_cli import oauth, skill_install
21
+ from cheso_cli.auth import TOKEN_SOURCE_ENV, TOKEN_SOURCE_FLAG, OAuthSession, login_hint, resolve_token_source
22
+ from cheso_cli.client import ApiError, ChesoClient, Deadline, TimeLimitReached
23
+ from cheso_cli.config import DEFAULT_SITE, ENV_TOKEN, SITES, VERSION, Settings, resolve_settings
24
+ from cheso_cli.credentials import FILE_NAME as CREDENTIALS_FILE
25
+ from cheso_cli.credentials import CredentialStore, default_config_dir
26
+ from cheso_cli.errors import CliError, ExitCode, LoginRequired, UsageError
27
+ from cheso_cli.legacy_commands import (
28
+ cmd_plan_create,
29
+ cmd_plan_get,
30
+ cmd_profile,
31
+ cmd_project_create,
32
+ cmd_project_delete,
33
+ cmd_project_fork,
34
+ cmd_project_get,
35
+ cmd_project_list,
36
+ cmd_project_update,
37
+ cmd_session_create,
38
+ )
39
+ from cheso_cli.tasks import (
40
+ AGENT_TYPES,
41
+ DEFAULT_WAIT_TIMEOUT,
42
+ KIND_TASK,
43
+ TRANSLATION_LANGUAGES,
44
+ Tasks,
45
+ is_credit_failure,
46
+ succeeded,
47
+ validate_id,
48
+ )
49
+ from cheso_cli.transfer import new_storage_client, upload_file
50
+
51
+ DEFAULT_BASE_URL = SITES[DEFAULT_SITE]
52
+
53
+ UNAUTHORIZED_CODES = {1001, 2401, 2402, 2403, 2404}
54
+ CODE_SCOPE_INSUFFICIENT = 2410
55
+ CODE_RATE_LIMITED = 2600
56
+ CODE_PROJECT_BUSY = 2614
57
+ # 项目忙:有运行中的任务(2614),或其他操作短暂持锁(2620 为项目锁,2621 为文件或目录锁),
58
+ # 或运行中的任务阻止文件变更(2623),或有人正在浏览器中编辑(2624)。这些状态都会自行解除。
59
+ PROJECT_BUSY_MESSAGES = {
60
+ CODE_PROJECT_BUSY: "This project already has a running task; wait for it to finish, then retry.",
61
+ 2620: "Another operation on this project (starting a task, a rollback or a file edit) is in progress; "
62
+ "retry in a few seconds.",
63
+ 2621: "Another operation is changing this file or folder; retry in a few seconds.",
64
+ 2623: "A task is running in this project, so its files can't change right now; retry when it finishes.",
65
+ 2624: "Someone is editing files in this project in the browser; retry when they finish.",
66
+ }
67
+
68
+ EXIT_CODES_HELP = """exit codes:
69
+ 0 success
70
+ 1 error
71
+ 2 usage error
72
+ 3 the task is waiting for your answer (cheso task reply)
73
+ 4 login required (cheso login)
74
+ 5 not enough credits
75
+ 6 still running when --timeout passed (cheso task wait / cheso login --wait again)
76
+ 7 rate limited or project busy; retry later
77
+ 8 the task succeeded, but some result files were not downloaded (run the "next" command)
78
+ """
79
+
80
+
81
+ @dataclass
82
+ class Context:
83
+ """命令运行所需的上下文;测试可注入传输层、时钟和输入输出流。"""
84
+
85
+ args: argparse.Namespace
86
+ env: Mapping[str, str]
87
+ out: TextIO
88
+ err: TextIO
89
+ transport: httpx.BaseTransport | None = None
90
+ storage_transport: httpx.BaseTransport | None = None
91
+ sleep: Callable[[float], None] = time.sleep
92
+ clock: Callable[[], float] = time.monotonic
93
+ now: Callable[[], float] = time.time
94
+ browser_open: Callable[[str], Any] = webbrowser.open
95
+ credentials_path: Path | None = None
96
+ # 命令开始时的 ``clock`` 值;--timeout 从此刻起计时。
97
+ started: float = 0.0
98
+ token_source: str = ""
99
+ _settings: Settings | None = None
100
+ _client: ChesoClient | None = None
101
+ _storage: httpx.Client | None = None
102
+ _store: CredentialStore | None = None
103
+ _closers: list[Callable[[], None]] = field(default_factory=list)
104
+
105
+ @property
106
+ def json(self) -> bool:
107
+ return bool(getattr(self.args, "json", False))
108
+
109
+ @property
110
+ def settings(self) -> Settings:
111
+ if self._settings is None:
112
+ self._settings = resolve_settings(
113
+ site=getattr(self.args, "site", None),
114
+ base_url=getattr(self.args, "base_url", None),
115
+ env=self.env,
116
+ logged_in_default=self.store.default_base_url,
117
+ )
118
+ return self._settings
119
+
120
+ @property
121
+ def store(self) -> CredentialStore:
122
+ if self._store is None:
123
+ path = self.credentials_path or default_config_dir(self.env) / CREDENTIALS_FILE
124
+ self._store = CredentialStore(path)
125
+ return self._store
126
+
127
+ @property
128
+ def client(self) -> ChesoClient:
129
+ if self._client is None:
130
+ source, self.token_source = resolve_token_source(
131
+ self.settings,
132
+ self.store,
133
+ token_flag=getattr(self.args, "token", None),
134
+ env=self.env,
135
+ transport=self.transport,
136
+ now=self.now,
137
+ )
138
+ self._client = ChesoClient(
139
+ self.settings.base_url,
140
+ auth=source,
141
+ transport=self.transport,
142
+ headers=self.settings.extra_headers(),
143
+ )
144
+ self._closers.append(self._client.close)
145
+ return self._client
146
+
147
+ @property
148
+ def storage(self) -> httpx.Client:
149
+ if self._storage is None:
150
+ self._storage = new_storage_client(self.storage_transport)
151
+ self._closers.append(self._storage.close)
152
+ return self._storage
153
+
154
+ def tasks(self) -> Tasks:
155
+ return Tasks(
156
+ self.client,
157
+ self.settings,
158
+ self.storage,
159
+ progress=self.err,
160
+ sleep=self.sleep,
161
+ clock=self.clock,
162
+ now=self.now,
163
+ )
164
+
165
+ def close(self) -> None:
166
+ for closer in reversed(self._closers):
167
+ closer()
168
+
169
+ # ---- 输出 ----
170
+
171
+ def emit(self, data: Any, human: Callable[[], None] | None = None) -> None:
172
+ if self.json or human is None:
173
+ print(json.dumps(data, ensure_ascii=False, indent=2), file=self.out)
174
+ else:
175
+ human()
176
+
177
+ def line(self, text: str = "") -> None:
178
+ print(text, file=self.out)
179
+
180
+
181
+ # ---- 账号 ----
182
+
183
+
184
+ def cmd_login(ctx: Context) -> int:
185
+ args = ctx.args
186
+ if args.wait and args.device:
187
+ raise UsageError("use --device to start a sign-in with a code, or --wait to keep waiting for one, not both")
188
+ if args.no_wait and not args.device:
189
+ raise UsageError("--no-wait only applies to --device")
190
+ if args.device or args.wait:
191
+ return _login_with_code(ctx)
192
+ oauth.login(
193
+ ctx.settings,
194
+ ctx.store,
195
+ open_browser=not args.no_browser,
196
+ timeout=args.timeout,
197
+ port=args.port,
198
+ transport=ctx.transport,
199
+ browser_open=ctx.browser_open,
200
+ out=ctx.err,
201
+ now=ctx.now,
202
+ )
203
+ return _logged_in(ctx)
204
+
205
+
206
+ def _login_with_code(ctx: Context) -> int:
207
+ """设备码登录(RFC 8628):用户在任意设备上打开 Cheso 页面,输入此处显示的短码。"""
208
+ args = ctx.args
209
+ # --timeout 从命令开始计时,约束每次请求,包括读取完整响应的时间。
210
+ deadline = Deadline(ctx.started + max(args.timeout, 0.0), ctx.clock)
211
+ if args.device:
212
+ try:
213
+ login = oauth.start_device_login(
214
+ ctx.settings,
215
+ ctx.store,
216
+ transport=ctx.transport,
217
+ now=ctx.now,
218
+ deadline=deadline,
219
+ )
220
+ except TimeLimitReached as e:
221
+ raise CliError(
222
+ f"--timeout passed before {ctx.settings.base_url} answered; nothing was started. "
223
+ "Retry with a longer --timeout.",
224
+ kind="network",
225
+ ) from e
226
+ else:
227
+ login = oauth.pending_device_login(ctx.settings, ctx.store)
228
+ if login is None:
229
+ raise UsageError(f"no sign-in is waiting; start one with `{ctx.settings.command('login --device')}`")
230
+ view = _device_view(ctx, login)
231
+ _print_device_instructions(ctx, login)
232
+ if args.no_wait:
233
+ ctx.emit(view, lambda: ctx.line(f"Then run: {view['next']}"))
234
+ return ExitCode.OK
235
+ creds = oauth.wait_device_login(
236
+ ctx.settings,
237
+ ctx.store,
238
+ deadline=deadline,
239
+ poll_first=not args.device,
240
+ transport=ctx.transport,
241
+ sleep=ctx.sleep,
242
+ now=ctx.now,
243
+ out=ctx.err,
244
+ )
245
+ if creds is None:
246
+ view["timed_out"] = True
247
+ ctx.emit(view, lambda: ctx.line(f"Still waiting for the code to be entered. Next: {view['next']}"))
248
+ return ExitCode.WAIT_TIMEOUT
249
+ return _logged_in(ctx, deadline=oauth.after_login(deadline))
250
+
251
+
252
+ def _device_view(ctx: Context, login: oauth.DeviceLogin) -> dict[str, Any]:
253
+ expires_in = max(int(login.expires_at - ctx.now()), 0)
254
+ return {
255
+ "logged_in": False,
256
+ "pending": True,
257
+ "base_url": ctx.settings.base_url,
258
+ "verification_uri": login.verification_uri,
259
+ "user_code": login.user_code,
260
+ "expires_in": expires_in,
261
+ "next": ctx.settings.command("login --wait"),
262
+ }
263
+
264
+
265
+ def _print_device_instructions(ctx: Context, login: oauth.DeviceLogin) -> None:
266
+ minutes = max(round((login.expires_at - ctx.now()) / 60), 1)
267
+ print(
268
+ f"To sign in to {ctx.settings.base_url}, open this page on any device:\n\n"
269
+ f" {login.verification_uri}\n\n"
270
+ f"and enter the code: {login.user_code}\n\n"
271
+ f"The code expires in about {minutes} min. Only enter it if you started this sign-in yourself.",
272
+ file=ctx.err,
273
+ )
274
+
275
+
276
+ def _logged_in(ctx: Context, *, deadline: Deadline | None = None) -> int:
277
+ session = OAuthSession(ctx.settings, ctx.store, transport=ctx.transport, now=ctx.now)
278
+ user: dict[str, Any] = {}
279
+ with (
280
+ ChesoClient(
281
+ ctx.settings.base_url, auth=session, transport=ctx.transport, headers=ctx.settings.extra_headers()
282
+ ) as client,
283
+ client.time_limit(deadline) if deadline else contextlib.nullcontext(),
284
+ ):
285
+ try:
286
+ user = (client.whoami() or {}).get("user") or {}
287
+ except (ApiError, CliError, httpx.TransportError) as e:
288
+ print(f"warning: logged in, but looking up the account failed ({e})", file=ctx.err)
289
+ data = {"logged_in": True, "base_url": ctx.settings.base_url, "user": _user_fields(user)}
290
+ ctx.emit(data, lambda: ctx.line(f"Logged in to {ctx.settings.base_url} as {_user_label(user)}."))
291
+ if ctx.env.get(ENV_TOKEN, "").strip():
292
+ print(f"note: {ENV_TOKEN} is set and takes precedence over this login.", file=ctx.err)
293
+ return ExitCode.OK
294
+
295
+
296
+ def cmd_logout(ctx: Context) -> int:
297
+ removed = oauth.logout(ctx.settings, ctx.store, transport=ctx.transport, out=ctx.err)
298
+ data = {"logged_out": removed, "base_url": ctx.settings.base_url}
299
+ message = f"Logged out of {ctx.settings.base_url}." if removed else f"Not logged in to {ctx.settings.base_url}."
300
+ ctx.emit(data, lambda: ctx.line(message))
301
+ if ctx.env.get(ENV_TOKEN, "").strip():
302
+ print(f"note: {ENV_TOKEN} is still set; unset it to stop using that token.", file=ctx.err)
303
+ return ExitCode.OK
304
+
305
+
306
+ def _user_fields(user: dict[str, Any]) -> dict[str, str]:
307
+ return {"user_id": str(user.get("user_id") or ""), "username": str(user.get("username") or "")}
308
+
309
+
310
+ def _user_label(user: dict[str, Any]) -> str:
311
+ name, uid = user.get("username") or "", user.get("user_id") or ""
312
+ return f"{name} ({uid})" if name and uid else (name or uid or "unknown user")
313
+
314
+
315
+ def cmd_whoami(ctx: Context) -> int:
316
+ user = (ctx.client.whoami() or {}).get("user") or {}
317
+ data = {"base_url": ctx.settings.base_url, "user": _user_fields(user), "token_source": ctx.token_source}
318
+ ctx.emit(
319
+ data,
320
+ lambda: ctx.line(f"{_user_label(user)} on {ctx.settings.base_url} (credentials: {ctx.token_source})"),
321
+ )
322
+ return ExitCode.OK
323
+
324
+
325
+ def cmd_credits(ctx: Context) -> int:
326
+ data = ctx.tasks().credits()
327
+
328
+ def human() -> None:
329
+ ctx.line(f"Available credits: {data['available']}")
330
+ if data["available"] <= 0:
331
+ ctx.line(f"Top up at {data['topup_url']}")
332
+
333
+ ctx.emit(data, human)
334
+ return ExitCode.OK
335
+
336
+
337
+ # ---- 任务 ----
338
+
339
+
340
+ def _output_dir(args: argparse.Namespace) -> Path | None:
341
+ if getattr(args, "no_download", False):
342
+ return None
343
+ return Path(getattr(args, "output_dir", None) or ".").expanduser()
344
+
345
+
346
+ def view_exit_code(view: dict[str, Any]) -> ExitCode:
347
+ """返回等待中的任务、转换或翻译对应的退出码。"""
348
+ if view.get("needs_input"):
349
+ return ExitCode.NEEDS_INPUT
350
+ if not view.get("done"):
351
+ return ExitCode.WAIT_TIMEOUT
352
+ # 优先保留任务本身的结果;只有任务成功时,文件下载失败才影响退出码。
353
+ if succeeded(view):
354
+ return ExitCode(view["error"]["exit_code"]) if view.get("error") else ExitCode.OK
355
+ if view.get("kind") == KIND_TASK and is_credit_failure(str(view.get("failure_reason") or "")):
356
+ return ExitCode.INSUFFICIENT_CREDITS
357
+ return ExitCode.ERROR
358
+
359
+
360
+ def render_view(ctx: Context, view: dict[str, Any]) -> None:
361
+ kind = view.get("kind", "task")
362
+ title = f" - {view['title']}" if view.get("title") else ""
363
+ ctx.line(f"{kind.capitalize()} {view.get('task_id')}: {view.get('status') or 'unknown'}{title}")
364
+ if view.get("continued_from"):
365
+ ctx.line(f" (continued from {view['continued_from']})")
366
+ if view.get("project_url"):
367
+ ctx.line(f"Project: {view['project_url']}")
368
+ if view.get("answer"):
369
+ ctx.line()
370
+ ctx.line(view["answer"].rstrip())
371
+ if view.get("answer_truncated"):
372
+ ctx.line("(answer truncated; the full content is in the result files and the project)")
373
+ files = view.get("files") or []
374
+ if files:
375
+ ctx.line()
376
+ ctx.line("Files:")
377
+ for item in files:
378
+ label = item.get("name") or item.get("path") or item["file_id"]
379
+ if item.get("saved_to"):
380
+ ctx.line(f" {label} -> {item['saved_to']}")
381
+ elif item.get("error"):
382
+ ctx.line(f" {label} ({item['file_id']}): download failed: {item['error']}")
383
+ else:
384
+ ctx.line(f" {label} ({item['file_id']})")
385
+ question = view.get("question")
386
+ if view.get("needs_input") and question:
387
+ ctx.line()
388
+ ctx.line("Cheso is asking:")
389
+ for i, q in enumerate(question.get("questions") or [], 1):
390
+ header = f"[{q['header']}] " if q.get("header") else ""
391
+ multi = " (choose one or more)" if q.get("multi_select") else ""
392
+ ctx.line(f" {i}. {header}{q['question']}{multi}")
393
+ for option in q.get("options") or []:
394
+ desc = f" - {option['description']}" if option.get("description") else ""
395
+ ctx.line(f" * {option['label']}{desc}")
396
+ if question.get("raw"):
397
+ ctx.line(f" {json.dumps(question['raw'], ensure_ascii=False)}")
398
+ if view.get("failure_reason"):
399
+ ctx.line(f"Reason: {view['failure_reason']}")
400
+ if view.get("topup_url"):
401
+ ctx.line(f"Top up credits: {view['topup_url']}")
402
+ if view.get("error"):
403
+ ctx.line(f"Error: {view['error']['message']}")
404
+ if view.get("timed_out"):
405
+ ctx.line("Still running; it continues on Cheso.")
406
+ if view.get("next"):
407
+ ctx.line(f"Next: {view['next']}")
408
+
409
+
410
+ def cmd_task_create(ctx: Context) -> int:
411
+ args = ctx.args
412
+ tasks = ctx.tasks()
413
+ view = tasks.create_task(
414
+ prompt=args.prompt,
415
+ paths=[Path(p).expanduser() for p in args.file],
416
+ file_ids=args.file_id,
417
+ project_id=args.project,
418
+ agent_type=args.agent_type,
419
+ )
420
+ if not args.wait:
421
+ ctx.emit(view, lambda: render_view(ctx, view))
422
+ return ExitCode.OK
423
+ tasks.say(f"Started task {view['task_id']} ({view['project_url'] or 'new project'}); waiting ...")
424
+ waited = _wait_started(ctx, tasks, view["task_id"])
425
+ if view.get("uploaded_files"):
426
+ waited["uploaded_files"] = view["uploaded_files"]
427
+ ctx.emit(waited, lambda: render_view(ctx, waited))
428
+ return view_exit_code(waited)
429
+
430
+
431
+ def _wait_started(ctx: Context, tasks: Tasks, task_id: str) -> dict[str, Any]:
432
+ """等待当前命令刚发起的任务;等待失败时,错误中仍包含已发起的任务 ID。"""
433
+ hint = f"It was started as {task_id}; follow it with `{ctx.settings.command(f'task wait {task_id}')}`."
434
+ try:
435
+ return tasks.wait(task_id, timeout=ctx.args.timeout, output_dir=_output_dir(ctx.args), started=ctx.started)
436
+ except KeyboardInterrupt:
437
+ print(f"Stopped waiting. {hint}", file=ctx.err)
438
+ raise
439
+ except (CliError, ApiError, httpx.TransportError) as e:
440
+ if isinstance(e, CliError):
441
+ err = e
442
+ elif isinstance(e, ApiError):
443
+ err = api_error(e, ctx)
444
+ else:
445
+ err = CliError(f"Lost the connection to {ctx.settings.base_url} ({e.__class__.__name__}).", kind="network")
446
+ err.message = f"{err.message} {hint}"
447
+ err.extra.setdefault("task_id", task_id)
448
+ raise err from e
449
+
450
+
451
+ def cmd_task_wait(ctx: Context) -> int:
452
+ args = ctx.args
453
+ view = ctx.tasks().wait(args.task_id, timeout=args.timeout, output_dir=_output_dir(args), started=ctx.started)
454
+ ctx.emit(view, lambda: render_view(ctx, view))
455
+ return view_exit_code(view)
456
+
457
+
458
+ def cmd_task_get(ctx: Context) -> int:
459
+ view = ctx.tasks().get(ctx.args.task_id)
460
+ ctx.emit(view, lambda: render_view(ctx, view))
461
+ return ExitCode.OK
462
+
463
+
464
+ def cmd_task_reply(ctx: Context) -> int:
465
+ args = ctx.args
466
+ view = ctx.tasks().reply(args.task_id, args.answer, args.skip)
467
+
468
+ def human() -> None:
469
+ verb = "Skipped the question" if view["skipped"] else "Sent the answer"
470
+ ctx.line(f"{verb} for task {view['task_id']}. Next: {view['next']}")
471
+
472
+ ctx.emit(view, human)
473
+ return ExitCode.OK
474
+
475
+
476
+ def cmd_task_cancel(ctx: Context) -> int:
477
+ view = ctx.tasks().cancel(ctx.args.task_id)
478
+ if view["cancelled"]:
479
+ message = f"Cancelled task {view['task_id']}. Work done so far stays in the project."
480
+ else:
481
+ message = f"Task {view['task_id']} had already finished ({view['status']}); nothing to cancel."
482
+ ctx.emit(view, lambda: ctx.line(message))
483
+ return ExitCode.OK
484
+
485
+
486
+ # ---- 文件 ----
487
+
488
+
489
+ def cmd_file_upload(ctx: Context) -> int:
490
+ args = ctx.args
491
+ project = validate_id("project id", args.project) if args.project else None
492
+ result = upload_file(ctx.client, ctx.storage, Path(args.path).expanduser(), project_id=project, now=ctx.now)
493
+ data = result.to_dict()
494
+ data["next"] = ctx.settings.command(f'task create --file-id {result.file_id} --prompt "..."')
495
+ where = f" into {result.file_path} of project {result.project_id}" if result.project_id else ""
496
+ ctx.emit(data, lambda: ctx.line(f"Uploaded {result.name}{where}: file_id {result.file_id}"))
497
+ return ExitCode.OK
498
+
499
+
500
+ def cmd_file_list(ctx: Context) -> int:
501
+ data = ctx.tasks().list_files(ctx.args.project)
502
+
503
+ def human() -> None:
504
+ ctx.line(f"{data['total']} file(s) in {data['project_url']}")
505
+ for item in data["files"]:
506
+ size = f"{item['size']:>10}" if isinstance(item.get("size"), int) else " " * 10
507
+ ctx.line(f" {item['file_id']} {size} {item['path']}")
508
+
509
+ ctx.emit(data, human)
510
+ return ExitCode.OK
511
+
512
+
513
+ def cmd_file_download(ctx: Context) -> int:
514
+ args = ctx.args
515
+ item = ctx.tasks().download_file(args.file_id, Path(args.output_dir or ".").expanduser(), version=args.version)
516
+ ctx.emit(item, lambda: ctx.line(f"Saved {item['saved_to']}"))
517
+ return ExitCode.OK
518
+
519
+
520
+ # ---- 转换 / 翻译 ----
521
+
522
+
523
+ def _start_job(ctx: Context, view: dict[str, Any], tasks: Tasks) -> int:
524
+ args = ctx.args
525
+ if not args.wait:
526
+ ctx.emit(view, lambda: render_view(ctx, view))
527
+ return ExitCode.OK
528
+ waited = _wait_started(ctx, tasks, view["task_id"])
529
+ ctx.emit(waited, lambda: render_view(ctx, waited))
530
+ return view_exit_code(waited)
531
+
532
+
533
+ def cmd_convert(ctx: Context) -> int:
534
+ tasks = ctx.tasks()
535
+ return _start_job(ctx, tasks.start_conversion(ctx.args.file_id, ctx.args.to), tasks)
536
+
537
+
538
+ def cmd_translate(ctx: Context) -> int:
539
+ tasks = ctx.tasks()
540
+ return _start_job(ctx, tasks.start_translation(ctx.args.file_id, ctx.args.to, ctx.args.source), tasks)
541
+
542
+
543
+ # ---- 技能 ----
544
+
545
+
546
+ def cmd_skill_install(ctx: Context) -> int:
547
+ args = ctx.args
548
+ home = Path(ctx.env["HOME"]) if ctx.env.get("HOME") else None
549
+ results = skill_install.install(args.target, project=args.project, force=args.force, home=home)
550
+
551
+ def human() -> None:
552
+ for r in results:
553
+ ctx.line(f"{r['target']}: {r['action']} {r['path']}")
554
+
555
+ ctx.emit({"results": results}, human)
556
+ return ExitCode.OK
557
+
558
+
559
+ def cmd_skill_show(ctx: Context) -> int:
560
+ ctx.out.write(skill_install.skill_text())
561
+ return ExitCode.OK
562
+
563
+
564
+ # ---- 错误处理 ----
565
+
566
+
567
+ def api_error(e: ApiError, ctx: Context) -> CliError:
568
+ """将 Manager 错误映射为退出码,并在消息中说明后续操作。"""
569
+ code, status = e.code, e.http_status or 0
570
+ extra = {k: v for k, v in {"api_code": code, "http_status": e.http_status, "span_id": e.span_id}.items() if v}
571
+ if status == 401 or code in UNAUTHORIZED_CODES:
572
+ if ctx.token_source == TOKEN_SOURCE_ENV:
573
+ message = f"The token in {ENV_TOKEN} was rejected (expired or revoked)."
574
+ elif ctx.token_source == TOKEN_SOURCE_FLAG:
575
+ message = "The token given with --token was rejected (expired or revoked)."
576
+ else:
577
+ message = f"The Cheso login has expired or was revoked. Run `{login_hint(ctx.settings)}`."
578
+ err = LoginRequired(message)
579
+ err.extra.update(extra)
580
+ return err
581
+ if e.retry_after:
582
+ extra["retry_after"] = e.retry_after
583
+ if code in PROJECT_BUSY_MESSAGES:
584
+ return CliError(PROJECT_BUSY_MESSAGES[code], exit_code=ExitCode.RETRYABLE, kind="project_busy", extra=extra)
585
+ if code == CODE_RATE_LIMITED or status == 429 or (code is not None and 2610 <= code <= 2619):
586
+ if e.retry_after:
587
+ message = f"Cheso rate limit reached; retry after {e.retry_after} s."
588
+ elif code is not None and 2610 <= code <= 2619:
589
+ message = "Too many Cheso tasks are running at once for this account; retry when one finishes."
590
+ else:
591
+ message = "Cheso rate limit reached; retry in about a minute."
592
+ return CliError(message, exit_code=ExitCode.RETRYABLE, kind="rate_limited", extra=extra)
593
+ if code == CODE_SCOPE_INSUFFICIENT:
594
+ return CliError(
595
+ "This action is not available to the Cheso CLI login; use the Cheso website "
596
+ f"(or a personal API token in {ENV_TOKEN}).",
597
+ kind="not_allowed",
598
+ extra=extra,
599
+ )
600
+ if code is not None and 2700 <= code <= 2799:
601
+ return CliError(f"Cheso usage limit reached: {e.message} (code {code}).", kind="quota", extra=extra)
602
+ if status >= 500:
603
+ return CliError(
604
+ f"Cheso is temporarily unavailable (http {status}); retry later.", kind="unavailable", extra=extra
605
+ )
606
+ if code is None and not isinstance(e.body, dict):
607
+ return CliError(f"The request failed (http {status}).", kind="http_error", extra=extra)
608
+ if status == 404:
609
+ return CliError(f"Not found, or you have no access ({e.message}).", kind="not_found", extra=extra)
610
+ if status == 403:
611
+ return CliError(f"Permission denied ({e.message}).", kind="forbidden", extra=extra)
612
+ suffix = f" (code {code})" if code is not None else ""
613
+ return CliError(f"Cheso rejected the request: {e.message}{suffix}.", kind="api_error", extra=extra)
614
+
615
+
616
+ def report_error(ctx: Context, err: CliError) -> None:
617
+ print(f"error: {err.message}", file=ctx.err)
618
+ if ctx.json:
619
+ body = {"kind": err.kind, "message": err.message, "exit_code": int(err.exit_code), **err.extra}
620
+ print(json.dumps({"error": body}, ensure_ascii=False, indent=2), file=ctx.out)
621
+
622
+
623
+ # ---- 参数解析 ----
624
+
625
+
626
+ def _common_options(parser: argparse.ArgumentParser, *, suppress: bool) -> None:
627
+ default = argparse.SUPPRESS if suppress else None
628
+ parser.add_argument(
629
+ "--site",
630
+ choices=tuple(SITES),
631
+ default=default,
632
+ help=f"Cheso site: global ({SITES['global']}, default) or cn ({SITES['cn']})",
633
+ )
634
+ parser.add_argument("--base-url", default=default, help="API base URL; overrides --site and CHESO_BASE_URL")
635
+ parser.add_argument("--token", default=default, help=f"Cheso token; overrides {ENV_TOKEN} and the stored login")
636
+ parser.add_argument(
637
+ "--json",
638
+ action="store_true",
639
+ default=argparse.SUPPRESS if suppress else False,
640
+ help="print one JSON document to stdout (progress goes to stderr)",
641
+ )
642
+
643
+
644
+ class _Builder:
645
+ """给每个命令添加公共选项,使选项既能放在命令名前,也能放在命令名后。"""
646
+
647
+ def __init__(self) -> None:
648
+ self.common = argparse.ArgumentParser(add_help=False)
649
+ _common_options(self.common, suppress=True)
650
+
651
+ def add(self, subparsers: Any, name: str, func: Callable[[Context], int] | None, **kwargs: Any) -> Any:
652
+ parser = subparsers.add_parser(name, parents=[self.common], **kwargs)
653
+ if func is not None:
654
+ parser.set_defaults(func=func)
655
+ return parser
656
+
657
+
658
+ def _wait_options(parser: argparse.ArgumentParser, *, no_download: bool) -> None:
659
+ parser.add_argument(
660
+ "--timeout",
661
+ type=float,
662
+ default=DEFAULT_WAIT_TIMEOUT,
663
+ metavar="SECONDS",
664
+ help=f"stop waiting after this many seconds (default {int(DEFAULT_WAIT_TIMEOUT)}); the task keeps running",
665
+ )
666
+ parser.add_argument("--output-dir", default=None, metavar="DIR", help="where result files go (default: .)")
667
+ if no_download:
668
+ parser.add_argument("--no-download", action="store_true", help="do not download result files")
669
+
670
+
671
+ def build_parser() -> argparse.ArgumentParser:
672
+ p = argparse.ArgumentParser(
673
+ prog="cheso",
674
+ description="Use Cheso from the terminal: research reports, PPT, web pages, posters, videos, images, "
675
+ "document translation and format conversion.",
676
+ epilog=EXIT_CODES_HELP,
677
+ formatter_class=argparse.RawDescriptionHelpFormatter,
678
+ )
679
+ p.add_argument("--version", action="version", version=f"cheso-cli {VERSION}")
680
+ _common_options(p, suppress=False)
681
+ b = _Builder()
682
+ sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND")
683
+
684
+ c = b.add(sub, "login", cmd_login, help="log in with the browser (OAuth), or with a code (--device)")
685
+ c.add_argument("--no-browser", action="store_true", help="only print the sign-in URL")
686
+ c.add_argument("--port", type=int, default=0, help="loopback callback port (default: random)")
687
+ c.add_argument(
688
+ "--device",
689
+ action="store_true",
690
+ help="sign in with a code entered on any device; for SSH, containers and coding agents",
691
+ )
692
+ c.add_argument("--no-wait", action="store_true", help="with --device: print the page and code, then return")
693
+ c.add_argument("--wait", action="store_true", help="keep waiting for a sign-in started with --device")
694
+ c.add_argument(
695
+ "--timeout", type=float, default=oauth.DEFAULT_LOGIN_TIMEOUT, metavar="SECONDS", help="how long to wait (300)"
696
+ )
697
+ b.add(sub, "logout", cmd_logout, help="revoke the login and delete the stored tokens")
698
+ b.add(sub, "whoami", cmd_whoami, help="show the current account")
699
+ b.add(sub, "credits", cmd_credits, help="show available credits")
700
+
701
+ task = b.add(sub, "task", None, help="create, wait for and manage tasks")
702
+ task_sub = task.add_subparsers(dest="task_cmd", required=True, metavar="ACTION")
703
+ c = b.add(task_sub, "create", cmd_task_create, help="start a task")
704
+ c.add_argument("--prompt", required=True, help="what Cheso should produce")
705
+ c.add_argument("--file", action="append", default=[], metavar="PATH", help="upload a local file (repeatable)")
706
+ c.add_argument("--file-id", action="append", default=[], metavar="ID", help="attach an uploaded file (repeatable)")
707
+ c.add_argument("--project", default=None, metavar="ID", help="continue in this project")
708
+ c.add_argument("--agent-type", choices=AGENT_TYPES, default=None, help="cheso (default) or image_edit")
709
+ c.add_argument("--wait", action="store_true", help="wait for the result and download it")
710
+ _wait_options(c, no_download=False)
711
+ c = b.add(task_sub, "wait", cmd_task_wait, help="wait for a task, conversion or translation")
712
+ c.add_argument("task_id")
713
+ _wait_options(c, no_download=True)
714
+ c = b.add(task_sub, "get", cmd_task_get, help="show a task, conversion or translation")
715
+ c.add_argument("task_id")
716
+ c = b.add(task_sub, "reply", cmd_task_reply, help="answer the question a task is asking")
717
+ c.add_argument("task_id")
718
+ c.add_argument(
719
+ "--answer",
720
+ action="append",
721
+ default=[],
722
+ metavar="QUESTION=ANSWER",
723
+ help="answer keyed by the exact question text, its number (1=...) or header; repeatable",
724
+ )
725
+ c.add_argument("--skip", action="store_true", help="skip the question and let Cheso decide")
726
+ c = b.add(task_sub, "cancel", cmd_task_cancel, help="stop a running task")
727
+ c.add_argument("task_id")
728
+
729
+ file_cmd = b.add(sub, "file", None, help="upload, list and download files")
730
+ file_sub = file_cmd.add_subparsers(dest="file_cmd", required=True, metavar="ACTION")
731
+ c = b.add(file_sub, "upload", cmd_file_upload, help="upload a local file; prints its file_id")
732
+ c.add_argument("path")
733
+ c.add_argument("--project", default=None, metavar="ID", help="put it in this project's attachments folder")
734
+ c = b.add(file_sub, "list", cmd_file_list, help="list a project's files")
735
+ c.add_argument("--project", required=True, metavar="ID")
736
+ c = b.add(file_sub, "download", cmd_file_download, help="download a file")
737
+ c.add_argument("file_id")
738
+ c.add_argument("--version", type=int, default=None, help="this version of the file (default: the latest)")
739
+ c.add_argument("--output-dir", default=None, metavar="DIR", help="where to save it (default: .)")
740
+
741
+ c = b.add(sub, "convert", cmd_convert, help="convert a file to another format")
742
+ c.add_argument("file_id")
743
+ c.add_argument("--to", required=True, metavar="TYPE", help="target format, e.g. pdf, docx, pptx, mp4")
744
+ c.add_argument("--wait", action="store_true", help="wait and download the result")
745
+ _wait_options(c, no_download=False)
746
+ c = b.add(sub, "translate", cmd_translate, help="translate a document")
747
+ c.add_argument("file_id")
748
+ c.add_argument("--to", required=True, metavar="LANG", help=f"target language: {', '.join(TRANSLATION_LANGUAGES)}")
749
+ c.add_argument("--from", dest="source", default=None, metavar="LANG", help="source language (default: detect)")
750
+ c.add_argument("--wait", action="store_true", help="wait and download the result")
751
+ _wait_options(c, no_download=False)
752
+
753
+ skill = b.add(sub, "skill", None, help="install the Agent Skill for Claude Code / Codex")
754
+ skill_sub = skill.add_subparsers(dest="skill_cmd", required=True, metavar="ACTION")
755
+ c = b.add(skill_sub, "install", cmd_skill_install, help="install SKILL.md")
756
+ c.add_argument("--target", choices=("claude", "codex", "all"), default="all")
757
+ c.add_argument("--project", action="store_true", help="install into ./.claude and ./.agents instead of ~")
758
+ c.add_argument("--force", action="store_true", help="replace a modified SKILL.md")
759
+ b.add(skill_sub, "show", cmd_skill_show, help="print SKILL.md")
760
+
761
+ # 保留第一版的底层 API 命令,直接输出原始 JSON。
762
+ b.add(sub, "profile", cmd_profile, help="GET /api/v1/profile")
763
+ proj = b.add(sub, "project", None, help="Project lifecycle (raw API)")
764
+ proj_sub = proj.add_subparsers(dest="project_cmd", required=True)
765
+ c = b.add(proj_sub, "create", cmd_project_create, help="POST /api/v1/projects/create")
766
+ c.add_argument("--description", "-d", default=None)
767
+ c = b.add(proj_sub, "list", cmd_project_list, help="GET /api/v1/projects")
768
+ c.add_argument("--status", default=None, help="active|archived")
769
+ c.add_argument("--starred", choices=("true", "false"), default=None)
770
+ c.add_argument("--sort-by", default=None, help="starred")
771
+ c.add_argument("--page", type=int, default=None)
772
+ c.add_argument("--page-size", type=int, default=None)
773
+ c = b.add(proj_sub, "get", cmd_project_get, help="GET /api/v1/projects/{id}")
774
+ c.add_argument("project_id")
775
+ c = b.add(proj_sub, "update", cmd_project_update, help="PUT /api/v1/projects/{id}")
776
+ c.add_argument("project_id")
777
+ c.add_argument("--description", "-d", default=None)
778
+ c.add_argument("--starred", choices=("true", "false"), default=None)
779
+ c.add_argument("--cover-file-id", default=None)
780
+ c.add_argument("--cover-file-version", type=int, default=None)
781
+ c = b.add(proj_sub, "delete", cmd_project_delete, help="DELETE /api/v1/projects/{id}")
782
+ c.add_argument("project_id")
783
+ c = b.add(proj_sub, "fork", cmd_project_fork, help="POST /api/v1/projects/{id}/fork")
784
+ c.add_argument("project_id")
785
+ c.add_argument("--description", "-d", default=None)
786
+ sess = b.add(sub, "session", None, help="Session (raw API)")
787
+ sess_sub = sess.add_subparsers(dest="session_cmd", required=True)
788
+ c = b.add(sess_sub, "create", cmd_session_create)
789
+ c.add_argument("--project-id", required=True)
790
+ c.add_argument("--title", default="默认会话")
791
+ plan = b.add(sub, "plan", None, help="Plan (raw API)")
792
+ plan_sub = plan.add_subparsers(dest="plan_cmd", required=True)
793
+ c = b.add(plan_sub, "create", cmd_plan_create)
794
+ c.add_argument("--project-id", required=True)
795
+ c.add_argument("--prompt", required=True)
796
+ c.add_argument("--session-id", default=None)
797
+ c = b.add(plan_sub, "get", cmd_plan_get)
798
+ c.add_argument("plan_id")
799
+ return p
800
+
801
+
802
+ def run(
803
+ argv: list[str] | None = None,
804
+ *,
805
+ env: Mapping[str, str] | None = None,
806
+ stdout: TextIO | None = None,
807
+ stderr: TextIO | None = None,
808
+ transport: httpx.BaseTransport | None = None,
809
+ storage_transport: httpx.BaseTransport | None = None,
810
+ sleep: Callable[[float], None] = time.sleep,
811
+ clock: Callable[[], float] = time.monotonic,
812
+ now: Callable[[], float] = time.time,
813
+ browser_open: Callable[[str], Any] = webbrowser.open,
814
+ credentials_path: Path | None = None,
815
+ ) -> int:
816
+ """运行一个命令并返回退出码,含义见 ``EXIT_CODES_HELP``。"""
817
+ parser = build_parser()
818
+ try:
819
+ args = parser.parse_args(argv)
820
+ except SystemExit as e:
821
+ return int(e.code or 0)
822
+ ctx = Context(
823
+ args=args,
824
+ env=os.environ if env is None else env,
825
+ out=stdout or sys.stdout,
826
+ err=stderr or sys.stderr,
827
+ transport=transport,
828
+ storage_transport=storage_transport,
829
+ sleep=sleep,
830
+ clock=clock,
831
+ now=now,
832
+ browser_open=browser_open,
833
+ credentials_path=credentials_path,
834
+ started=clock(),
835
+ )
836
+ try:
837
+ return int(args.func(ctx))
838
+ except CliError as e:
839
+ report_error(ctx, e)
840
+ return int(e.exit_code)
841
+ except ApiError as e:
842
+ err = api_error(e, ctx)
843
+ report_error(ctx, err)
844
+ return int(err.exit_code)
845
+ except httpx.TransportError as e:
846
+ err = CliError(
847
+ f"cannot reach {ctx.settings.base_url} ({e.__class__.__name__}); check the network and retry.",
848
+ kind="network",
849
+ )
850
+ report_error(ctx, err)
851
+ return int(err.exit_code)
852
+ except KeyboardInterrupt:
853
+ print("interrupted", file=ctx.err)
854
+ return 130
855
+ finally:
856
+ ctx.close()
857
+
858
+
859
+ def main(argv: list[str] | None = None) -> None:
860
+ raise SystemExit(run(argv))
861
+
862
+
863
+ if __name__ == "__main__":
864
+ main()