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/credentials.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""保存 OAuth 凭据:每个用户一个 JSON 文件,按基础 URL 区分站点,并使用排他文件锁保护。
|
|
2
|
+
|
|
3
|
+
文件结构(``$XDG_CONFIG_HOME/cheso/credentials.json``,默认 ``~/.config/cheso/credentials.json``)::
|
|
4
|
+
|
|
5
|
+
{"version": 1, "default_base_url": "https://cheso.ai",
|
|
6
|
+
"sites": {"https://cheso.ai": {"client_id": "...", "issuer": "...",
|
|
7
|
+
"token_endpoint": "...", "revocation_endpoint": "...",
|
|
8
|
+
"access_token": "...", "refresh_token": "...", "expires_at": 1767225600.0,
|
|
9
|
+
"pending_device": {...}}}}
|
|
10
|
+
|
|
11
|
+
``pending_device`` 保存等待用户在浏览器输入短码的设备码登录(``cheso login --device``):
|
|
12
|
+
设备码、用户码、页面、过期时间、轮询间隔 ``interval``(slow_down 增加的间隔会持续保留)、
|
|
13
|
+
连续连接超时累计的退避间隔 ``backoff``(令牌端点正常答复后恢复为 ``interval``),以及最早下次轮询时间
|
|
14
|
+
``next_poll_at``。``cheso login --wait`` 按原节奏继续等待;登录完成、失败或被替换时删除此记录。
|
|
15
|
+
|
|
16
|
+
文件在权限为 0700 的目录中以 0600 创建,通过临时文件加重命名实现原子替换。
|
|
17
|
+
刷新令牌每次使用后都会轮换;重复使用旧令牌会撤销整个授权,因此每次读改写都必须持有旁边的
|
|
18
|
+
``credentials.json.lock`` 文件锁。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterator, Mapping
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from cheso_cli.errors import CliError
|
|
35
|
+
|
|
36
|
+
FILE_NAME = "credentials.json"
|
|
37
|
+
LOCK_SUFFIX = ".lock"
|
|
38
|
+
FORMAT_VERSION = 1
|
|
39
|
+
DEFAULT_KEY = "default_base_url"
|
|
40
|
+
# 一次刷新在整个 HTTP 往返期间持锁;超过此时长通常说明操作已经卡住。
|
|
41
|
+
DEFAULT_LOCK_TIMEOUT = 90.0
|
|
42
|
+
LOCK_POLL_INTERVAL = 0.05
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_config_dir(env: Mapping[str, str] | None = None) -> Path:
|
|
46
|
+
env = os.environ if env is None else env
|
|
47
|
+
base = env.get("XDG_CONFIG_HOME", "").strip()
|
|
48
|
+
root = Path(base) if base and Path(base).is_absolute() else Path.home() / ".config"
|
|
49
|
+
return root / "cheso"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class SiteCredentials:
|
|
54
|
+
"""CLI 为单个站点保存的凭据。退出登录后保留 ``client_id``,以便下次登录复用注册信息。"""
|
|
55
|
+
|
|
56
|
+
client_id: str = ""
|
|
57
|
+
issuer: str = ""
|
|
58
|
+
token_endpoint: str = ""
|
|
59
|
+
revocation_endpoint: str = ""
|
|
60
|
+
access_token: str = ""
|
|
61
|
+
refresh_token: str = ""
|
|
62
|
+
expires_at: float = 0.0
|
|
63
|
+
pending_device: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, raw: Any) -> SiteCredentials:
|
|
68
|
+
if not isinstance(raw, dict):
|
|
69
|
+
return cls()
|
|
70
|
+
known = {
|
|
71
|
+
"client_id",
|
|
72
|
+
"issuer",
|
|
73
|
+
"token_endpoint",
|
|
74
|
+
"revocation_endpoint",
|
|
75
|
+
"access_token",
|
|
76
|
+
"refresh_token",
|
|
77
|
+
"expires_at",
|
|
78
|
+
"pending_device",
|
|
79
|
+
}
|
|
80
|
+
try:
|
|
81
|
+
expires_at = float(raw.get("expires_at") or 0)
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
expires_at = 0.0
|
|
84
|
+
pending = raw.get("pending_device")
|
|
85
|
+
return cls(
|
|
86
|
+
client_id=str(raw.get("client_id") or ""),
|
|
87
|
+
issuer=str(raw.get("issuer") or ""),
|
|
88
|
+
token_endpoint=str(raw.get("token_endpoint") or ""),
|
|
89
|
+
revocation_endpoint=str(raw.get("revocation_endpoint") or ""),
|
|
90
|
+
access_token=str(raw.get("access_token") or ""),
|
|
91
|
+
refresh_token=str(raw.get("refresh_token") or ""),
|
|
92
|
+
expires_at=expires_at,
|
|
93
|
+
pending_device=dict(pending) if isinstance(pending, dict) else {},
|
|
94
|
+
extra={k: v for k, v in raw.items() if k not in known},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
data = asdict(self)
|
|
99
|
+
extra = data.pop("extra")
|
|
100
|
+
if not data["pending_device"]:
|
|
101
|
+
data.pop("pending_device")
|
|
102
|
+
return {**extra, **data}
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def logged_in(self) -> bool:
|
|
106
|
+
return bool(self.access_token or self.refresh_token)
|
|
107
|
+
|
|
108
|
+
def clear_tokens(self) -> None:
|
|
109
|
+
self.access_token = ""
|
|
110
|
+
self.refresh_token = ""
|
|
111
|
+
self.expires_at = 0.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CredentialStore:
|
|
115
|
+
"""读写凭据文件;修改文件的调用方必须持有 :meth:`locked` 对应的锁。"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, path: Path | None = None, *, lock_timeout: float = DEFAULT_LOCK_TIMEOUT) -> None:
|
|
118
|
+
self.path = Path(path) if path is not None else default_config_dir() / FILE_NAME
|
|
119
|
+
self.lock_path = self.path.with_name(self.path.name + LOCK_SUFFIX)
|
|
120
|
+
self.lock_timeout = lock_timeout
|
|
121
|
+
|
|
122
|
+
# ---- 读取 ----
|
|
123
|
+
|
|
124
|
+
def read(self) -> dict[str, Any]:
|
|
125
|
+
"""读取完整文档;文件不存在或无法读取时返回空内容。"""
|
|
126
|
+
try:
|
|
127
|
+
raw = self.path.read_text(encoding="utf-8")
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
return {"version": FORMAT_VERSION, "sites": {}}
|
|
130
|
+
except OSError as e:
|
|
131
|
+
raise CliError(f"cannot read {self.path}: {e.strerror or e}") from e
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(raw)
|
|
134
|
+
except ValueError:
|
|
135
|
+
print(f"warning: ignoring unreadable credentials file {self.path}", file=sys.stderr)
|
|
136
|
+
return {"version": FORMAT_VERSION, "sites": {}}
|
|
137
|
+
if not isinstance(data, dict) or not isinstance(data.get("sites"), dict):
|
|
138
|
+
return {"version": FORMAT_VERSION, "sites": {}}
|
|
139
|
+
return data
|
|
140
|
+
|
|
141
|
+
def get(self, base_url: str) -> SiteCredentials:
|
|
142
|
+
return SiteCredentials.from_dict(self.read()["sites"].get(base_url))
|
|
143
|
+
|
|
144
|
+
# ---- 写入(需要持锁)----
|
|
145
|
+
|
|
146
|
+
def default_base_url(self) -> str:
|
|
147
|
+
"""最近一次 ``cheso login`` 的站点;命令未明确指定站点时使用它。"""
|
|
148
|
+
value = self.read().get(DEFAULT_KEY)
|
|
149
|
+
return value if isinstance(value, str) else ""
|
|
150
|
+
|
|
151
|
+
def put(self, base_url: str, creds: SiteCredentials, *, make_default: bool = False) -> None:
|
|
152
|
+
data = self.read()
|
|
153
|
+
data["version"] = FORMAT_VERSION
|
|
154
|
+
data["sites"][base_url] = creds.to_dict()
|
|
155
|
+
if make_default:
|
|
156
|
+
data[DEFAULT_KEY] = base_url
|
|
157
|
+
self._write(data)
|
|
158
|
+
|
|
159
|
+
def _write(self, data: dict[str, Any]) -> None:
|
|
160
|
+
self._ensure_dir()
|
|
161
|
+
payload = json.dumps(data, indent=2, sort_keys=True).encode("utf-8")
|
|
162
|
+
fd, tmp = tempfile.mkstemp(prefix=".credentials-", suffix=".tmp", dir=self.path.parent)
|
|
163
|
+
try:
|
|
164
|
+
with os.fdopen(fd, "wb") as f:
|
|
165
|
+
os.chmod(tmp, 0o600)
|
|
166
|
+
f.write(payload)
|
|
167
|
+
f.flush()
|
|
168
|
+
os.fsync(f.fileno())
|
|
169
|
+
os.replace(tmp, self.path)
|
|
170
|
+
except BaseException:
|
|
171
|
+
with contextlib.suppress(OSError):
|
|
172
|
+
os.unlink(tmp)
|
|
173
|
+
raise
|
|
174
|
+
with contextlib.suppress(OSError):
|
|
175
|
+
os.chmod(self.path, 0o600)
|
|
176
|
+
|
|
177
|
+
def _ensure_dir(self) -> None:
|
|
178
|
+
directory = self.path.parent
|
|
179
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
180
|
+
with contextlib.suppress(OSError):
|
|
181
|
+
os.chmod(directory, 0o700)
|
|
182
|
+
|
|
183
|
+
# ---- 加锁 ----
|
|
184
|
+
|
|
185
|
+
@contextlib.contextmanager
|
|
186
|
+
def locked(self) -> Iterator[None]:
|
|
187
|
+
"""跨进程、跨线程的排他锁;每个持锁者单独打开锁文件。"""
|
|
188
|
+
self._ensure_dir()
|
|
189
|
+
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
190
|
+
try:
|
|
191
|
+
_acquire(fd, self.lock_timeout, self.lock_path)
|
|
192
|
+
try:
|
|
193
|
+
yield
|
|
194
|
+
finally:
|
|
195
|
+
_release(fd)
|
|
196
|
+
finally:
|
|
197
|
+
os.close(fd)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if sys.platform == "win32": # pragma: no cover - 仅在 Windows 上执行
|
|
201
|
+
import msvcrt
|
|
202
|
+
|
|
203
|
+
def _try_lock(fd: int) -> bool:
|
|
204
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
205
|
+
try:
|
|
206
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
207
|
+
except OSError:
|
|
208
|
+
return False
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
def _release(fd: int) -> None:
|
|
212
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
213
|
+
with contextlib.suppress(OSError):
|
|
214
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
215
|
+
|
|
216
|
+
else:
|
|
217
|
+
import fcntl
|
|
218
|
+
|
|
219
|
+
def _try_lock(fd: int) -> bool:
|
|
220
|
+
try:
|
|
221
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
222
|
+
except BlockingIOError:
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def _release(fd: int) -> None:
|
|
227
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _acquire(fd: int, timeout: float, path: Path) -> None:
|
|
231
|
+
deadline = time.monotonic() + timeout
|
|
232
|
+
while not _try_lock(fd):
|
|
233
|
+
if time.monotonic() >= deadline:
|
|
234
|
+
raise CliError(f"timed out waiting for the credentials lock {path}; another cheso command may be stuck")
|
|
235
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
cheso_cli/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""定义退出码,以及各命令用于按指定退出码终止执行的错误类型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExitCode(enum.IntEnum):
|
|
10
|
+
"""进程退出码,属于 README、SKILL.md 约定的公开接口,不能重新编号。"""
|
|
11
|
+
|
|
12
|
+
OK = 0
|
|
13
|
+
ERROR = 1
|
|
14
|
+
USAGE = 2
|
|
15
|
+
NEEDS_INPUT = 3
|
|
16
|
+
LOGIN_REQUIRED = 4
|
|
17
|
+
INSUFFICIENT_CREDITS = 5
|
|
18
|
+
WAIT_TIMEOUT = 6
|
|
19
|
+
RETRYABLE = 7
|
|
20
|
+
DOWNLOAD_FAILED = 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CliError(Exception):
|
|
24
|
+
"""使当前命令以 ``exit_code`` 退出的错误。
|
|
25
|
+
|
|
26
|
+
``kind`` 是稳定的机器可读标签,例如 ``login_required``、``project_busy``;
|
|
27
|
+
``extra`` 为 ``--json`` 输出提供结构化字段,例如 ``running_task_id``、``topup_url``。
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
exit_code: ExitCode = ExitCode.ERROR,
|
|
35
|
+
kind: str = "error",
|
|
36
|
+
extra: dict[str, Any] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.message = message
|
|
40
|
+
self.exit_code = exit_code
|
|
41
|
+
self.kind = kind
|
|
42
|
+
self.extra = dict(extra or {})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LoginRequired(CliError):
|
|
46
|
+
"""没有可用凭据;用户需要运行 ``cheso login`` 或提供令牌。"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str) -> None:
|
|
49
|
+
super().__init__(message, exit_code=ExitCode.LOGIN_REQUIRED, kind="login_required")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UsageError(CliError):
|
|
53
|
+
"""命令行参数错误,例如选项值无效或缺少参数。"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str) -> None:
|
|
56
|
+
super().__init__(message, exit_code=ExitCode.USAGE, kind="usage")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""第一版留下的底层接口命令(profile / project / session / plan):原样输出接口返回的 JSON。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from cheso_cli.errors import ExitCode
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from cheso_cli.cli import Context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _legacy_json(ctx: Context, data: Any) -> None:
|
|
15
|
+
print(json.dumps(data, ensure_ascii=False, indent=2), file=ctx.out)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_profile(ctx: Context) -> int:
|
|
19
|
+
_legacy_json(ctx, ctx.client.profile())
|
|
20
|
+
return ExitCode.OK
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cmd_project_create(ctx: Context) -> int:
|
|
24
|
+
data = ctx.client.project_create(description=ctx.args.description)
|
|
25
|
+
_legacy_json(ctx, data)
|
|
26
|
+
if isinstance(data, dict) and data.get("project_id"):
|
|
27
|
+
print(f"project_id={data['project_id']}", file=ctx.err)
|
|
28
|
+
return ExitCode.OK
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tri_bool(value: str | None) -> bool | None:
|
|
32
|
+
return None if value is None else value == "true"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_project_list(ctx: Context) -> int:
|
|
36
|
+
a = ctx.args
|
|
37
|
+
_legacy_json(
|
|
38
|
+
ctx,
|
|
39
|
+
ctx.client.project_list(
|
|
40
|
+
status=a.status, starred=_tri_bool(a.starred), sort_by=a.sort_by, page=a.page, page_size=a.page_size
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
return ExitCode.OK
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_project_get(ctx: Context) -> int:
|
|
47
|
+
_legacy_json(ctx, ctx.client.project_get(ctx.args.project_id))
|
|
48
|
+
return ExitCode.OK
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_project_update(ctx: Context) -> int:
|
|
52
|
+
a = ctx.args
|
|
53
|
+
_legacy_json(
|
|
54
|
+
ctx,
|
|
55
|
+
ctx.client.project_update(
|
|
56
|
+
a.project_id,
|
|
57
|
+
description=a.description,
|
|
58
|
+
starred=_tri_bool(a.starred),
|
|
59
|
+
cover_file_id=a.cover_file_id,
|
|
60
|
+
cover_file_version=a.cover_file_version,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
return ExitCode.OK
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_project_delete(ctx: Context) -> int:
|
|
67
|
+
data = ctx.client.project_delete(ctx.args.project_id)
|
|
68
|
+
_legacy_json(ctx, data if data is not None else {"ok": True})
|
|
69
|
+
return ExitCode.OK
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_project_fork(ctx: Context) -> int:
|
|
73
|
+
data = ctx.client.project_fork(ctx.args.project_id, description=ctx.args.description)
|
|
74
|
+
_legacy_json(ctx, data)
|
|
75
|
+
if isinstance(data, dict) and data.get("project_id"):
|
|
76
|
+
print(f"project_id={data['project_id']}", file=ctx.err)
|
|
77
|
+
return ExitCode.OK
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_session_create(ctx: Context) -> int:
|
|
81
|
+
_legacy_json(ctx, ctx.client.session_create(project_id=ctx.args.project_id, title=ctx.args.title))
|
|
82
|
+
return ExitCode.OK
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_plan_create(ctx: Context) -> int:
|
|
86
|
+
a = ctx.args
|
|
87
|
+
_legacy_json(ctx, ctx.client.plan_create(prompt=a.prompt, project_id=a.project_id, session_id=a.session_id))
|
|
88
|
+
return ExitCode.OK
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_plan_get(ctx: Context) -> int:
|
|
92
|
+
_legacy_json(ctx, ctx.client.plan_get(ctx.args.plan_id))
|
|
93
|
+
return ExitCode.OK
|