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/__init__.py +12 -0
- cheso_cli/__main__.py +5 -0
- cheso_cli/auth.py +128 -0
- cheso_cli/cli.py +864 -0
- cheso_cli/client.py +534 -0
- cheso_cli/config.py +108 -0
- cheso_cli/credentials.py +235 -0
- cheso_cli/errors.py +56 -0
- cheso_cli/legacy_commands.py +93 -0
- cheso_cli/oauth.py +797 -0
- cheso_cli/skill/SKILL.md +83 -0
- cheso_cli/skill_install.py +119 -0
- cheso_cli/tasks.py +834 -0
- cheso_cli/transfer.py +294 -0
- cheso_cli-0.2.0.dist-info/METADATA +164 -0
- cheso_cli-0.2.0.dist-info/RECORD +18 -0
- cheso_cli-0.2.0.dist-info/WHEEL +4 -0
- cheso_cli-0.2.0.dist-info/entry_points.txt +4 -0
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()
|