clawshire-cli 0.1.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.
- clawshire_cli/__init__.py +1 -0
- clawshire_cli/commands/__init__.py +1 -0
- clawshire_cli/commands/annual_analysis.py +132 -0
- clawshire_cli/commands/annual_report.py +43 -0
- clawshire_cli/commands/auth.py +121 -0
- clawshire_cli/commands/notice.py +77 -0
- clawshire_cli/commands/update.py +171 -0
- clawshire_cli/commands/user.py +21 -0
- clawshire_cli/config.py +61 -0
- clawshire_cli/context.py +19 -0
- clawshire_cli/main.py +140 -0
- clawshire_cli/output.py +127 -0
- clawshire_cli-0.1.0.dist-info/METADATA +564 -0
- clawshire_cli-0.1.0.dist-info/RECORD +23 -0
- clawshire_cli-0.1.0.dist-info/WHEEL +5 -0
- clawshire_cli-0.1.0.dist-info/entry_points.txt +3 -0
- clawshire_cli-0.1.0.dist-info/top_level.txt +2 -0
- clawshire_sdk/__init__.py +15 -0
- clawshire_sdk/client.py +153 -0
- clawshire_sdk/domains/__init__.py +4 -0
- clawshire_sdk/domains/annual_reports.py +145 -0
- clawshire_sdk/domains/filings.py +64 -0
- clawshire_sdk/errors.py +22 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
6
|
+
|
|
7
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
8
|
+
from clawshire_cli.output import render
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
12
|
+
parser = subparsers.add_parser("annual-analysis", help="年报分析")
|
|
13
|
+
annual_analysis_subparsers = parser.add_subparsers(dest="annual_analysis_command", required=True)
|
|
14
|
+
|
|
15
|
+
pdf_file = annual_analysis_subparsers.add_parser("pdf-file", help="通过本地 PDF 文件提交分析")
|
|
16
|
+
pdf_file.add_argument("pdf_path", help="本地 PDF 路径")
|
|
17
|
+
_add_job_submit_args(pdf_file)
|
|
18
|
+
pdf_file.set_defaults(handler=_handle_pdf_file)
|
|
19
|
+
|
|
20
|
+
pdf_url = annual_analysis_subparsers.add_parser("pdf-url", help="通过 PDF 链接提交分析")
|
|
21
|
+
pdf_url.add_argument("pdf_url", help="PDF 直链")
|
|
22
|
+
_add_job_submit_args(pdf_url)
|
|
23
|
+
pdf_url.set_defaults(handler=_handle_pdf_url)
|
|
24
|
+
|
|
25
|
+
company = annual_analysis_subparsers.add_parser("company", help="通过公司代码或简称分析最新年报")
|
|
26
|
+
company.add_argument("keyword", help="公司证券代码或简称")
|
|
27
|
+
company.add_argument("--year", type=int, help="年份,如 2025")
|
|
28
|
+
company.add_argument("--exchange", choices=["sz", "sh", "bj"], help="交易所过滤")
|
|
29
|
+
company.add_argument("--notify-email", help="分析完成后通知邮箱")
|
|
30
|
+
company.set_defaults(handler=_handle_company)
|
|
31
|
+
|
|
32
|
+
get_cmd = annual_analysis_subparsers.add_parser("get", help="查询年报分析任务")
|
|
33
|
+
get_cmd.add_argument("task_or_job_id", help="分析任务 ID。支持 direct job_id 或 company task_id")
|
|
34
|
+
get_cmd.add_argument("--save-report-to", help="已完成时将 HTML 报告保存到指定路径")
|
|
35
|
+
get_cmd.set_defaults(handler=_handle_get)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _add_job_submit_args(parser: ArgumentParser) -> None:
|
|
39
|
+
parser.add_argument("--lang", default="zh", choices=["zh", "en"], help="报告语言")
|
|
40
|
+
parser.add_argument("--wait", action="store_true", help="阻塞等待任务完成")
|
|
41
|
+
parser.add_argument("--poll-interval", type=int, default=10, help="轮询间隔秒数")
|
|
42
|
+
parser.add_argument("--max-polls", type=int, default=60, help="最大轮询次数")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _handle_pdf_file(args: Namespace) -> int:
|
|
46
|
+
client = build_client(args)
|
|
47
|
+
data = client.annual.analyze_submit(args.pdf_path, lang=args.lang)
|
|
48
|
+
data = _attach_follow_up_command(data, id_key="job_id")
|
|
49
|
+
return _render_or_wait_job(client, data, args)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _handle_pdf_url(args: Namespace) -> int:
|
|
53
|
+
client = build_client(args)
|
|
54
|
+
data = client.annual.analyze_submit_pdf_url(args.pdf_url, lang=args.lang)
|
|
55
|
+
data = _attach_follow_up_command(data, id_key="job_id")
|
|
56
|
+
return _render_or_wait_job(client, data, args)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _handle_company(args: Namespace) -> int:
|
|
60
|
+
client = build_client(args)
|
|
61
|
+
data = client.annual.analyze_company(
|
|
62
|
+
args.keyword,
|
|
63
|
+
year=args.year,
|
|
64
|
+
exchange=args.exchange,
|
|
65
|
+
notify_email=args.notify_email,
|
|
66
|
+
)
|
|
67
|
+
data = _attach_follow_up_command(data, id_key="task_id")
|
|
68
|
+
render(data, output=resolve_output(args))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _handle_get(args: Namespace) -> int:
|
|
73
|
+
client = build_client(args)
|
|
74
|
+
if str(args.task_or_job_id).isdigit():
|
|
75
|
+
data = client.annual.get_analysis_task(int(args.task_or_job_id))
|
|
76
|
+
data = _maybe_download_report(client, data, args)
|
|
77
|
+
else:
|
|
78
|
+
data = client.annual.analyze_get(args.task_or_job_id)
|
|
79
|
+
render(data, output=resolve_output(args))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _maybe_download_report(client, data: dict, args: Namespace) -> dict:
|
|
84
|
+
if data.get("status") != "completed":
|
|
85
|
+
return data
|
|
86
|
+
report_url = data.get("report_url")
|
|
87
|
+
met_uuid = data.get("met_uuid")
|
|
88
|
+
if not report_url or not met_uuid:
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
dest = args.save_report_to
|
|
92
|
+
saved_path = client.annual.download_report(
|
|
93
|
+
met_uuid=met_uuid,
|
|
94
|
+
report_url=report_url,
|
|
95
|
+
company_name=data.get("company_name"),
|
|
96
|
+
dest=dest,
|
|
97
|
+
)
|
|
98
|
+
enriched = dict(data)
|
|
99
|
+
enriched["saved_report_path"] = str(Path(saved_path).resolve())
|
|
100
|
+
return enriched
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _attach_follow_up_command(data: dict, *, id_key: str) -> dict:
|
|
104
|
+
task_id = data.get(id_key)
|
|
105
|
+
if not task_id:
|
|
106
|
+
return data
|
|
107
|
+
enriched = dict(data)
|
|
108
|
+
enriched["next_command"] = f"clawshire annual-analysis get {task_id}"
|
|
109
|
+
return enriched
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _render_or_wait_job(client, data: dict, args: Namespace) -> int:
|
|
113
|
+
if not args.wait:
|
|
114
|
+
render(data, output=resolve_output(args))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
job_id = data.get("job_id")
|
|
118
|
+
if not job_id:
|
|
119
|
+
render(data, output=resolve_output(args))
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
for attempt in range(args.max_polls):
|
|
123
|
+
result = client.annual.analyze_get(job_id)
|
|
124
|
+
status = result.get("status", "unknown")
|
|
125
|
+
print(f"[{attempt + 1}/{args.max_polls}] status={status}")
|
|
126
|
+
if status in {"completed", "failed"}:
|
|
127
|
+
render(result, output=resolve_output(args))
|
|
128
|
+
return 0
|
|
129
|
+
time.sleep(args.poll_interval)
|
|
130
|
+
|
|
131
|
+
print(f"轮询超时,job_id={job_id}")
|
|
132
|
+
return 1
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
6
|
+
from clawshire_cli.output import render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("annual-report", help="年报查询")
|
|
11
|
+
annual_report_subparsers = parser.add_subparsers(dest="annual_report_command", required=True)
|
|
12
|
+
|
|
13
|
+
latest = annual_report_subparsers.add_parser("latest", help="获取最新年报列表")
|
|
14
|
+
latest.add_argument("--page", type=int, default=1, help="页码")
|
|
15
|
+
latest.add_argument("--page-size", type=int, default=20, help="每页数量")
|
|
16
|
+
latest.add_argument("--year", type=int, help="年份,如 2025")
|
|
17
|
+
latest.add_argument("--exchange", help="交易所,如 bj")
|
|
18
|
+
latest.add_argument("--keyword", help="关键词或公司代码")
|
|
19
|
+
latest.set_defaults(handler=_handle_latest)
|
|
20
|
+
|
|
21
|
+
data = annual_report_subparsers.add_parser("data", help="获取年报结构化数据")
|
|
22
|
+
data.add_argument("met_uuid", help="年报 met_uuid")
|
|
23
|
+
data.set_defaults(handler=_handle_data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _handle_latest(args: Namespace) -> int:
|
|
27
|
+
client = build_client(args)
|
|
28
|
+
data = client.annual.latest(
|
|
29
|
+
page=args.page,
|
|
30
|
+
page_size=args.page_size,
|
|
31
|
+
year=args.year,
|
|
32
|
+
exchange=args.exchange,
|
|
33
|
+
keyword=args.keyword,
|
|
34
|
+
)
|
|
35
|
+
render(data, output=resolve_output(args))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _handle_data(args: Namespace) -> int:
|
|
40
|
+
client = build_client(args)
|
|
41
|
+
data = client.annual.data(args.met_uuid)
|
|
42
|
+
render(data, output=resolve_output(args))
|
|
43
|
+
return 0
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.config import CONFIG_PATH, load_config, mask_api_key, save_config
|
|
6
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
7
|
+
from clawshire_cli.output import render
|
|
8
|
+
from clawshire_sdk import ClawShireApiError, ClawShireAuthError, ClawShireNetworkError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
12
|
+
parser = subparsers.add_parser("auth", help="认证与连通性检查")
|
|
13
|
+
auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
|
|
14
|
+
|
|
15
|
+
set_key = auth_subparsers.add_parser("set-key", help="保存 API Key 到本地配置")
|
|
16
|
+
set_key.add_argument("api_key", help="ClawShire API Key")
|
|
17
|
+
set_key.set_defaults(handler=_handle_set_key)
|
|
18
|
+
|
|
19
|
+
clear_key = auth_subparsers.add_parser("clear-key", help="清除本地保存的 API Key")
|
|
20
|
+
clear_key.set_defaults(handler=_handle_clear_key)
|
|
21
|
+
|
|
22
|
+
logout = auth_subparsers.add_parser("logout", help="退出登录并清除本地保存的 API Key")
|
|
23
|
+
logout.set_defaults(handler=_handle_clear_key)
|
|
24
|
+
|
|
25
|
+
show = auth_subparsers.add_parser("show", help="查看当前认证配置")
|
|
26
|
+
show.set_defaults(handler=_handle_show)
|
|
27
|
+
|
|
28
|
+
status = auth_subparsers.add_parser("status", help="查看当前认证状态")
|
|
29
|
+
status.set_defaults(handler=_handle_status)
|
|
30
|
+
|
|
31
|
+
check = auth_subparsers.add_parser("check", help="检查当前认证是否可用")
|
|
32
|
+
check.set_defaults(handler=_handle_check)
|
|
33
|
+
|
|
34
|
+
whoami = auth_subparsers.add_parser("whoami", help="检查当前 API Key")
|
|
35
|
+
whoami.set_defaults(handler=_handle_whoami)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _handle_set_key(args: Namespace) -> int:
|
|
39
|
+
config = load_config()
|
|
40
|
+
config.api_key = args.api_key
|
|
41
|
+
path = save_config(config)
|
|
42
|
+
print(f"API Key 已保存到 {path}")
|
|
43
|
+
print(f"当前 Key: {mask_api_key(args.api_key)}")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _handle_clear_key(args: Namespace) -> int:
|
|
48
|
+
config = load_config()
|
|
49
|
+
config.api_key = None
|
|
50
|
+
path = save_config(config)
|
|
51
|
+
print(f"API Key 已清除: {path}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _handle_show(args: Namespace) -> int:
|
|
56
|
+
config = load_config()
|
|
57
|
+
render(
|
|
58
|
+
{
|
|
59
|
+
"base_url": config.base_url,
|
|
60
|
+
"api_key": mask_api_key(config.api_key),
|
|
61
|
+
"output": config.output,
|
|
62
|
+
"timeout": config.timeout,
|
|
63
|
+
},
|
|
64
|
+
output=resolve_output(args),
|
|
65
|
+
)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _handle_status(args: Namespace) -> int:
|
|
70
|
+
source = _resolve_api_key_source()
|
|
71
|
+
config = load_config()
|
|
72
|
+
payload = {
|
|
73
|
+
"base_url": config.base_url,
|
|
74
|
+
"config_path": str(CONFIG_PATH),
|
|
75
|
+
"api_key_source": source,
|
|
76
|
+
"api_key": mask_api_key(config.api_key),
|
|
77
|
+
"configured": bool(config.api_key),
|
|
78
|
+
"authenticated": False,
|
|
79
|
+
"message": "未配置 API Key",
|
|
80
|
+
}
|
|
81
|
+
if not config.api_key:
|
|
82
|
+
render(payload, output=resolve_output(args))
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
client = build_client(args)
|
|
86
|
+
try:
|
|
87
|
+
client.get("/api/v1/api-key/info", auth_required=True)
|
|
88
|
+
payload["authenticated"] = True
|
|
89
|
+
payload["message"] = "认证可用"
|
|
90
|
+
except (ClawShireAuthError, ClawShireApiError, ClawShireNetworkError) as exc:
|
|
91
|
+
payload["message"] = str(exc)
|
|
92
|
+
|
|
93
|
+
render(payload, output=resolve_output(args))
|
|
94
|
+
return 0 if payload["authenticated"] else 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _handle_check(args: Namespace) -> int:
|
|
98
|
+
client = build_client(args)
|
|
99
|
+
try:
|
|
100
|
+
client.get("/api/v1/api-key/info", auth_required=True)
|
|
101
|
+
print("ok")
|
|
102
|
+
return 0
|
|
103
|
+
except (ClawShireAuthError, ClawShireApiError, ClawShireNetworkError):
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _handle_whoami(args: Namespace) -> int:
|
|
108
|
+
client = build_client(args)
|
|
109
|
+
data = client.get("/api/v1/api-key/info", auth_required=True)
|
|
110
|
+
render(data, output=resolve_output(args))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_api_key_source() -> str:
|
|
115
|
+
import os
|
|
116
|
+
|
|
117
|
+
if os.getenv("CLAWSHIRE_API_KEY"):
|
|
118
|
+
return "env"
|
|
119
|
+
if CONFIG_PATH.exists():
|
|
120
|
+
return "config"
|
|
121
|
+
return "none"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
6
|
+
from clawshire_cli.output import render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("notice", help="公告查询")
|
|
11
|
+
notice_subparsers = parser.add_subparsers(dest="notice_command", required=True)
|
|
12
|
+
|
|
13
|
+
search = notice_subparsers.add_parser("search", help="按日期范围查询公告")
|
|
14
|
+
_add_search_args(search)
|
|
15
|
+
search.set_defaults(handler=_handle_search)
|
|
16
|
+
|
|
17
|
+
stock = notice_subparsers.add_parser("stock", help="按证券代码查询公告")
|
|
18
|
+
stock.add_argument("sec_code", help="证券代码")
|
|
19
|
+
_add_search_args(stock)
|
|
20
|
+
stock.set_defaults(handler=_handle_stock)
|
|
21
|
+
|
|
22
|
+
link = notice_subparsers.add_parser("link", help="按公告原文链接查询")
|
|
23
|
+
link.add_argument("--met-link", required=True, help="公告原文链接")
|
|
24
|
+
link.set_defaults(handler=_handle_link)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_search_args(parser: ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument("--start-date", required=True, help="开始日期 YYYY-MM-DD")
|
|
29
|
+
parser.add_argument("--end-date", required=True, help="结束日期 YYYY-MM-DD")
|
|
30
|
+
parser.add_argument("--keyword", help="关键词(证券代码或公司名称)")
|
|
31
|
+
parser.add_argument("--infotype", help="公告类别")
|
|
32
|
+
parser.add_argument("--page", type=int, default=1, help="页码")
|
|
33
|
+
parser.add_argument("--page-size", type=int, default=20, help="每页数量")
|
|
34
|
+
parser.add_argument("--page-all", action="store_true", help="自动翻页获取全部结果")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fetch_all(fetch_fn, **kwargs) -> dict:
|
|
38
|
+
page, items = 1, []
|
|
39
|
+
while True:
|
|
40
|
+
data = fetch_fn(**kwargs, page=page, page_size=100)
|
|
41
|
+
batch = data.get("items") or data.get("data") or []
|
|
42
|
+
items.extend(batch)
|
|
43
|
+
if len(batch) < 100:
|
|
44
|
+
break
|
|
45
|
+
page += 1
|
|
46
|
+
return {"items": items, "total": len(items)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _handle_search(args: Namespace) -> int:
|
|
50
|
+
client = build_client(args)
|
|
51
|
+
kw = dict(start_date=args.start_date, end_date=args.end_date,
|
|
52
|
+
keyword=args.keyword, infotype=args.infotype)
|
|
53
|
+
if args.page_all:
|
|
54
|
+
data = _fetch_all(client.filings.search, **kw)
|
|
55
|
+
else:
|
|
56
|
+
data = client.filings.search(**kw, page=args.page, page_size=args.page_size)
|
|
57
|
+
render(data, output=resolve_output(args))
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _handle_stock(args: Namespace) -> int:
|
|
62
|
+
client = build_client(args)
|
|
63
|
+
kw = dict(start_date=args.start_date, end_date=args.end_date,
|
|
64
|
+
keyword=args.keyword, infotype=args.infotype)
|
|
65
|
+
if args.page_all:
|
|
66
|
+
data = _fetch_all(lambda **k: client.filings.stock(args.sec_code, **k), **kw)
|
|
67
|
+
else:
|
|
68
|
+
data = client.filings.stock(args.sec_code, **kw, page=args.page, page_size=args.page_size)
|
|
69
|
+
render(data, output=resolve_output(args))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _handle_link(args: Namespace) -> int:
|
|
74
|
+
client = build_client(args)
|
|
75
|
+
data = client.filings.link(args.met_link)
|
|
76
|
+
render(data, output=resolve_output(args))
|
|
77
|
+
return 0
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from packaging.version import Version
|
|
11
|
+
|
|
12
|
+
from clawshire_cli.context import resolve_output
|
|
13
|
+
from clawshire_cli.output import render
|
|
14
|
+
from clawshire_sdk import ClawShireApiError, ClawShireNetworkError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PACKAGE_NAME = "clawshire-cli"
|
|
18
|
+
PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
|
19
|
+
PYPI_PROJECT_URL = f"https://pypi.org/project/{PACKAGE_NAME}/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
23
|
+
parser = subparsers.add_parser(
|
|
24
|
+
"update",
|
|
25
|
+
aliases=["upgrade"],
|
|
26
|
+
help="升级当前 CLI 到最新版本",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--manager",
|
|
30
|
+
choices=["auto", "uv", "pipx", "pip"],
|
|
31
|
+
default="auto",
|
|
32
|
+
help="指定升级方式,默认自动检测",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--dry-run",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="只显示将要执行的升级命令,不实际执行",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"-y",
|
|
41
|
+
"--yes",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="跳过确认提示,直接执行升级",
|
|
44
|
+
)
|
|
45
|
+
parser.set_defaults(handler=_handle_update)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _handle_update(args: Namespace) -> int:
|
|
49
|
+
current_version = _read_current_version()
|
|
50
|
+
latest_version, version_source = _read_latest_version()
|
|
51
|
+
manager = _resolve_manager(args.manager)
|
|
52
|
+
command = _build_update_command(manager)
|
|
53
|
+
payload = {
|
|
54
|
+
"current_version": current_version,
|
|
55
|
+
"latest_version": latest_version,
|
|
56
|
+
"version_source": version_source,
|
|
57
|
+
"project_url": PYPI_PROJECT_URL,
|
|
58
|
+
"manager": manager,
|
|
59
|
+
"command": " ".join(command),
|
|
60
|
+
"dry_run": args.dry_run,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if latest_version is not None and Version(current_version) >= Version(latest_version):
|
|
64
|
+
payload["message"] = "当前已经是最新版本"
|
|
65
|
+
render(payload, output=resolve_output(args))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
if args.dry_run:
|
|
69
|
+
if latest_version is None:
|
|
70
|
+
payload["message"] = "暂时无法从 PyPI 确认最新版本,可执行以下命令尝试升级"
|
|
71
|
+
else:
|
|
72
|
+
payload["message"] = "检测到新版本,可执行以下命令升级当前 CLI"
|
|
73
|
+
render(payload, output=resolve_output(args))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
if latest_version is None:
|
|
77
|
+
payload["message"] = "暂时无法从 PyPI 确认最新版本,准备尝试升级 clawshire-cli"
|
|
78
|
+
else:
|
|
79
|
+
payload["message"] = "检测到新版本,准备升级 clawshire-cli"
|
|
80
|
+
render(payload, output=resolve_output(args))
|
|
81
|
+
|
|
82
|
+
if not args.yes and not _confirm_upgrade(command):
|
|
83
|
+
print("已取消升级。")
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
result = subprocess.run(command, check=False)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
raise ClawShireApiError(f"升级失败,请手动执行: {' '.join(command)}")
|
|
89
|
+
|
|
90
|
+
print("升级命令执行完成。建议重新运行 `clawshire version` 确认版本。")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_manager(preferred: str) -> str:
|
|
95
|
+
if preferred != "auto":
|
|
96
|
+
if not _manager_available(preferred):
|
|
97
|
+
raise ClawShireApiError(f"未检测到 {preferred},请先安装或改用其他 manager")
|
|
98
|
+
return preferred
|
|
99
|
+
|
|
100
|
+
for candidate in ("uv", "pipx", "pip"):
|
|
101
|
+
if _manager_available(candidate):
|
|
102
|
+
return candidate
|
|
103
|
+
raise ClawShireApiError("未检测到可用的升级工具,请安装 uv、pipx 或 pip 后重试")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _manager_available(manager: str) -> bool:
|
|
107
|
+
if manager == "pip":
|
|
108
|
+
return True
|
|
109
|
+
return shutil.which(manager) is not None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_update_command(manager: str) -> list[str]:
|
|
113
|
+
if manager == "uv":
|
|
114
|
+
return ["uv", "tool", "upgrade", PACKAGE_NAME]
|
|
115
|
+
if manager == "pipx":
|
|
116
|
+
return ["pipx", "upgrade", PACKAGE_NAME]
|
|
117
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _confirm_upgrade(command: list[str]) -> bool:
|
|
121
|
+
answer = input(f"将执行升级命令: {' '.join(command)}\n继续? [y/N] ").strip().lower()
|
|
122
|
+
return answer in {"y", "yes"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_current_version() -> str:
|
|
126
|
+
try:
|
|
127
|
+
from clawshire_cli.main import get_cli_version
|
|
128
|
+
|
|
129
|
+
return get_cli_version()
|
|
130
|
+
except Exception:
|
|
131
|
+
return "unknown"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _read_latest_version() -> tuple[str | None, str]:
|
|
135
|
+
try:
|
|
136
|
+
payload = _fetch_pypi_metadata()
|
|
137
|
+
except ClawShireApiError:
|
|
138
|
+
return None, "pypi-not-published"
|
|
139
|
+
except ClawShireNetworkError:
|
|
140
|
+
return None, "pypi-unreachable"
|
|
141
|
+
|
|
142
|
+
info = payload.get("info")
|
|
143
|
+
if not isinstance(info, dict):
|
|
144
|
+
return None, "pypi-invalid-response"
|
|
145
|
+
version = info.get("version")
|
|
146
|
+
if not isinstance(version, str) or not version.strip():
|
|
147
|
+
return None, "pypi-invalid-response"
|
|
148
|
+
return version.strip(), "pypi"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fetch_pypi_metadata() -> dict[str, Any]:
|
|
152
|
+
try:
|
|
153
|
+
with httpx.Client(timeout=10.0) as client:
|
|
154
|
+
response = client.get(PYPI_JSON_URL, headers={"Accept": "application/json"})
|
|
155
|
+
except httpx.HTTPError as exc:
|
|
156
|
+
raise ClawShireNetworkError(f"访问 PyPI 失败: {exc}") from exc
|
|
157
|
+
|
|
158
|
+
if response.status_code == 404:
|
|
159
|
+
raise ClawShireApiError(f"PyPI 上暂未发布 {PACKAGE_NAME}", status_code=404)
|
|
160
|
+
if response.status_code >= 400:
|
|
161
|
+
raise ClawShireApiError(
|
|
162
|
+
f"读取 PyPI 版本失败: HTTP {response.status_code}",
|
|
163
|
+
status_code=response.status_code,
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
payload = response.json()
|
|
167
|
+
except ValueError as exc:
|
|
168
|
+
raise ClawShireApiError("PyPI 返回了非 JSON 响应") from exc
|
|
169
|
+
if not isinstance(payload, dict):
|
|
170
|
+
raise ClawShireApiError("PyPI 返回了无效响应")
|
|
171
|
+
return payload
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
6
|
+
from clawshire_cli.output import render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("user", help="用户信息与额度查询")
|
|
11
|
+
user_subparsers = parser.add_subparsers(dest="user_command", required=True)
|
|
12
|
+
|
|
13
|
+
info = user_subparsers.add_parser("info", help="查询余额、免费次数、配额等用户信息")
|
|
14
|
+
info.set_defaults(handler=_handle_info)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _handle_info(args: Namespace) -> int:
|
|
18
|
+
client = build_client(args)
|
|
19
|
+
data = client.get("/api/v1/api-key/info", auth_required=True)
|
|
20
|
+
render(data, output=resolve_output(args))
|
|
21
|
+
return 0
|
clawshire_cli/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tomli_w
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.clawshire.cn"
|
|
11
|
+
CONFIG_PATH = Path.home() / ".config" / "clawshire" / "config.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CliConfig:
|
|
16
|
+
base_url: str = DEFAULT_BASE_URL
|
|
17
|
+
api_key: str | None = None
|
|
18
|
+
output: str = "table"
|
|
19
|
+
timeout: float = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config() -> CliConfig:
|
|
23
|
+
config = CliConfig()
|
|
24
|
+
|
|
25
|
+
if CONFIG_PATH.exists():
|
|
26
|
+
data = tomllib.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
27
|
+
config.base_url = str(data.get("base_url") or config.base_url)
|
|
28
|
+
config.api_key = data.get("api_key") or config.api_key
|
|
29
|
+
config.output = str(data.get("output") or config.output)
|
|
30
|
+
timeout = data.get("timeout")
|
|
31
|
+
if timeout is not None:
|
|
32
|
+
config.timeout = float(timeout)
|
|
33
|
+
|
|
34
|
+
config.base_url = os.getenv("CLAWSHIRE_BASE_URL", config.base_url)
|
|
35
|
+
config.api_key = os.getenv("CLAWSHIRE_API_KEY", config.api_key)
|
|
36
|
+
config.output = os.getenv("CLAWSHIRE_OUTPUT", config.output)
|
|
37
|
+
|
|
38
|
+
env_timeout = os.getenv("CLAWSHIRE_TIMEOUT")
|
|
39
|
+
if env_timeout:
|
|
40
|
+
config.timeout = float(env_timeout)
|
|
41
|
+
return config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_config(config: CliConfig) -> Path:
|
|
45
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
data = {
|
|
47
|
+
"base_url": config.base_url,
|
|
48
|
+
"api_key": config.api_key,
|
|
49
|
+
"output": config.output,
|
|
50
|
+
"timeout": config.timeout,
|
|
51
|
+
}
|
|
52
|
+
CONFIG_PATH.write_text(tomli_w.dumps(data), encoding="utf-8")
|
|
53
|
+
return CONFIG_PATH
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mask_api_key(api_key: str | None) -> str:
|
|
57
|
+
if not api_key:
|
|
58
|
+
return ""
|
|
59
|
+
if len(api_key) <= 8:
|
|
60
|
+
return "*" * len(api_key)
|
|
61
|
+
return f"{api_key[:6]}...{api_key[-4:]}"
|
clawshire_cli/context.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.config import load_config
|
|
6
|
+
from clawshire_sdk import ClawShireClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_client(args: Namespace) -> ClawShireClient:
|
|
10
|
+
config = load_config()
|
|
11
|
+
base_url = args.base_url or config.base_url
|
|
12
|
+
api_key = args.api_key if args.api_key is not None else config.api_key
|
|
13
|
+
timeout = args.timeout if args.timeout is not None else config.timeout
|
|
14
|
+
return ClawShireClient(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_output(args: Namespace) -> str:
|
|
18
|
+
config = load_config()
|
|
19
|
+
return args.output or config.output
|