dsh-cloudq 0.1.0

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.
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CloudQ OAuth 登录脚本 (Authorization Code 模式)
4
+
5
+ 提供三个非交互式子命令,供 AI Agent 分步调用:
6
+
7
+ python3 login.py --authorize-url # 步骤1:获取授权 URL(JSON 输出)
8
+ printf '%s' '{"code":"..."}' | python3 login.py --save --stdin
9
+ # 步骤2:用授权码换取凭证并保存
10
+ python3 login.py --status # 查看当前凭证状态(JSON 输出)
11
+ python3 login.py --refresh # 主动刷新凭证(JSON 输出)
12
+
13
+ 交互式登录(仅供用户终端手动使用,Skill 中禁止调用):
14
+ python3 login.py # 打开浏览器 + 交互式粘贴授权码
15
+ python3 login.py --no-browser # 手动模式(仅输出链接)
16
+ python3 login.py --site=intl # 国际站
17
+ """
18
+
19
+ import base64
20
+ import json
21
+ import os
22
+ import sys
23
+ import time
24
+ import webbrowser
25
+ from pathlib import Path
26
+
27
+ # 将 scripts 目录加入搜索路径
28
+ SCRIPT_DIR = Path(__file__).resolve().parent
29
+ sys.path.insert(0, str(SCRIPT_DIR))
30
+
31
+ from credential_manager import ( # noqa: E402
32
+ get_authorize_url, exchange_token, get_tmp_cred,
33
+ save_credential, load_credential, maybe_refresh_credential,
34
+ OAUTH_ENDPOINT, CREDENTIAL_FILE,
35
+ CredentialExpiredError,
36
+ )
37
+
38
+
39
+ # ============== JSON 输出工具 ==============
40
+
41
+ def _json_ok(data: dict) -> str:
42
+ return json.dumps({"success": True, **data}, ensure_ascii=False)
43
+
44
+
45
+ def _json_err(code: str, message: str) -> str:
46
+ return json.dumps({"success": False, "error": {"code": code, "message": message}},
47
+ ensure_ascii=False)
48
+
49
+
50
+ # ============== 解析用户输入的授权码 ==============
51
+
52
+ def _parse_code(user_input: str) -> str:
53
+ """解析用户粘贴的内容,可能是纯 code、Base64 或 JSON"""
54
+ code = user_input
55
+ try:
56
+ decoded = base64.b64decode(user_input).decode("utf-8")
57
+ parsed = json.loads(decoded)
58
+ code = parsed.get("code", user_input)
59
+ except Exception:
60
+ try:
61
+ parsed = json.loads(user_input)
62
+ code = parsed.get("code", user_input)
63
+ except Exception:
64
+ pass # 当做纯 code 字符串
65
+ return code
66
+
67
+
68
+ def _mask(s: str, visible: int = 4) -> str:
69
+ if len(s) <= visible:
70
+ return "*" * len(s)
71
+ return "*" * (len(s) - visible) + s[-visible:]
72
+
73
+
74
+ # ============== 子命令:--authorize-url ==============
75
+
76
+ def cmd_authorize_url(site: str = "cn") -> int:
77
+ """获取授权 URL(非交互式,JSON 输出)"""
78
+ try:
79
+ auth_info = get_authorize_url("", site)
80
+ authorize_url = auth_info.get("authorize_url", "")
81
+ if not authorize_url:
82
+ print(_json_err("NoAuthorizeUrl", "服务端未返回有效的授权 URL"))
83
+ return 1
84
+ print(_json_ok({
85
+ "authorize_url": authorize_url,
86
+ "state": auth_info.get("state", ""),
87
+ }))
88
+ return 0
89
+ except Exception as e:
90
+ print(_json_err("AuthorizeUrlError", str(e)))
91
+ return 1
92
+
93
+
94
+ # ============== 子命令:--save ==============
95
+
96
+ def cmd_save(raw_code: str, site: str = "cn") -> int:
97
+ """用授权码换取凭证并保存(非交互式,JSON 输出)"""
98
+ code = _parse_code(raw_code.strip())
99
+ if not code:
100
+ print(_json_err("EmptyCode", "授权码为空"))
101
+ return 1
102
+
103
+ try:
104
+ # exchange_token
105
+ token_info = exchange_token(code)
106
+ access_token = token_info["user_access_token"]
107
+
108
+ # get_tmp_cred
109
+ cred = get_tmp_cred(access_token, site)
110
+
111
+ # 保存凭证
112
+ oauth_info = {
113
+ "accessToken": token_info["user_access_token"],
114
+ "refreshToken": token_info["refresh_token"],
115
+ "userOpenId": token_info.get("user_open_id", ""),
116
+ "expiresAt": token_info.get("expires_at", 0),
117
+ "site": site,
118
+ }
119
+ save_credential(cred, oauth_info)
120
+
121
+ print(_json_ok({
122
+ "message": "登录成功",
123
+ "credential_file": str(CREDENTIAL_FILE),
124
+ "secret_id_masked": _mask(cred.get("secretId", "")),
125
+ "expires_at": cred.get("expiresAt", 0),
126
+ }))
127
+ return 0
128
+
129
+ except Exception as e:
130
+ print(_json_err("LoginFailed", str(e)))
131
+ return 1
132
+
133
+
134
+ # ============== 子命令:--status ==============
135
+
136
+ def cmd_status() -> int:
137
+ """查看当前凭证状态(非交互式,JSON 输出)。临时密钥过期时自动尝试刷新。"""
138
+ cred_data = load_credential()
139
+ if cred_data is None:
140
+ print(_json_ok({
141
+ "logged_in": False,
142
+ "message": "未找到 OAuth 凭证",
143
+ }))
144
+ return 0
145
+
146
+ # AK 长期密钥(设置页手工填写)没有有效期,不能走下面的过期/刷新判定,
147
+ # 否则 expiresAt=0 会被当成「已过期」。
148
+ if cred_data.get("type") == "ak":
149
+ print(_json_ok({
150
+ "logged_in": True,
151
+ "auth_type": "ak",
152
+ "credential_file": str(CREDENTIAL_FILE),
153
+ "secret_id_masked": _mask(cred_data.get("secretId", "")),
154
+ }))
155
+ return 0
156
+
157
+ now = time.time()
158
+ expires_at = cred_data.get("expiresAt", 0)
159
+ refresh_error = None
160
+
161
+ # 临时密钥已过期或即将过期,尝试自动刷新
162
+ if expires_at - now <= 300:
163
+ try:
164
+ maybe_refresh_credential(force=True)
165
+ cred_data = load_credential()
166
+ if cred_data is None:
167
+ print(_json_ok({
168
+ "logged_in": False,
169
+ "message": "刷新后未找到凭证",
170
+ }))
171
+ return 0
172
+ expires_at = cred_data.get("expiresAt", 0)
173
+ except CredentialExpiredError as e:
174
+ refresh_error = {
175
+ "code": "CredentialExpired",
176
+ "message": f"refreshToken 已过期,需要重新登录: {e}",
177
+ "action": "请执行 python3 login.py --authorize-url 重新授权",
178
+ }
179
+ except Exception as e:
180
+ refresh_error = {
181
+ "code": type(e).__name__,
182
+ "message": str(e),
183
+ "action": "请检查网络连接,或执行 python3 login.py --authorize-url 重新授权",
184
+ }
185
+
186
+ now = time.time()
187
+ remaining = max(0, int(expires_at - now))
188
+
189
+ oauth = cred_data.get("oauth", {})
190
+ access_expires = oauth.get("expiresAt", 0)
191
+ access_remaining = max(0, int(access_expires - now))
192
+
193
+ status_info = {
194
+ "logged_in": True,
195
+ "credential_file": str(CREDENTIAL_FILE),
196
+ "secret_id_masked": _mask(cred_data.get("secretId", "")),
197
+ "tmp_key_expires_at": expires_at,
198
+ "tmp_key_remaining_minutes": remaining // 60,
199
+ "access_token_remaining_minutes": access_remaining // 60,
200
+ }
201
+
202
+ if refresh_error:
203
+ status_info["expired"] = True
204
+ status_info["refresh_error"] = refresh_error
205
+ elif remaining == 0:
206
+ status_info["expired"] = True
207
+ status_info["refresh_error"] = {
208
+ "code": "Unknown",
209
+ "message": "临时密钥已过期,自动刷新未触发",
210
+ "action": "请执行 python3 login.py --refresh 手动刷新,或重新登录",
211
+ }
212
+
213
+ print(_json_ok(status_info))
214
+ return 0
215
+
216
+
217
+ # ============== 子命令:--refresh ==============
218
+
219
+ def cmd_refresh() -> int:
220
+ """主动刷新 OAuth 凭证(非交互式,JSON 输出)"""
221
+ cred_data = load_credential()
222
+ if cred_data is None:
223
+ print(_json_err("NotLoggedIn", "未找到 OAuth 凭证,请先登录"))
224
+ return 1
225
+
226
+ try:
227
+ maybe_refresh_credential(force=True)
228
+ # 重新读取刷新后的凭证
229
+ refreshed = load_credential()
230
+ if refreshed is None:
231
+ print(_json_err("RefreshFailed", "刷新后未找到凭证"))
232
+ return 1
233
+
234
+ now = time.time()
235
+ expires_at = refreshed.get("expiresAt", 0)
236
+ remaining = max(0, int(expires_at - now))
237
+
238
+ print(_json_ok({
239
+ "message": "凭证刷新成功",
240
+ "secret_id_masked": _mask(refreshed.get("secretId", "")),
241
+ "tmp_key_expires_at": expires_at,
242
+ "tmp_key_remaining_minutes": remaining // 60,
243
+ }))
244
+ return 0
245
+
246
+ except CredentialExpiredError as e:
247
+ print(_json_err("CredentialExpired", f"refreshToken 已过期,请重新登录: {e}"))
248
+ return 1
249
+ except Exception as e:
250
+ print(_json_err("RefreshFailed", str(e)))
251
+ return 1
252
+
253
+
254
+ # ============== 交互式登录(仅供终端手动使用) ==============
255
+
256
+ def _interactive_login(open_browser: bool = True, site: str = "cn"):
257
+ """交互式登录流程(Skill 中禁止调用,仅供用户终端手动使用)"""
258
+ print()
259
+ print(" ☁️ CloudQ OAuth 登录")
260
+ print(" " + "─" * 36)
261
+ print()
262
+
263
+ # 检查是否已登录
264
+ existing = load_credential()
265
+ if existing:
266
+ print(" ℹ️ 检测到已有 OAuth 凭证。")
267
+ try:
268
+ answer = input(" 是否重新登录?(y/N) > ").strip().lower()
269
+ except (EOFError, KeyboardInterrupt):
270
+ print()
271
+ return
272
+ if answer not in ("y", "yes"):
273
+ print(" 已取消。")
274
+ return
275
+ print()
276
+
277
+ try:
278
+ # 获取授权 URL
279
+ print(" ◦ 获取授权链接...")
280
+ auth_info = get_authorize_url("", site)
281
+ authorize_url = auth_info["authorize_url"]
282
+ if not authorize_url:
283
+ raise RuntimeError("服务端未返回有效的授权 URL")
284
+
285
+ # 打开浏览器或输出链接
286
+ if open_browser:
287
+ print(" ◦ 正在打开浏览器...")
288
+ if not webbrowser.open(authorize_url):
289
+ print(" ⚠️ 无法自动打开浏览器,请手动访问以下链接:")
290
+ print()
291
+ print(f" {authorize_url}")
292
+ else:
293
+ print()
294
+ print(" 请在浏览器中打开以下链接完成授权:")
295
+ print()
296
+ print(f" {authorize_url}")
297
+
298
+ # 等待用户输入授权码
299
+ print()
300
+ print(" 完成授权后,页面会显示一段授权码。")
301
+ print(" 请复制并粘贴到下方:")
302
+ print()
303
+ user_input = input(" 授权码 > ").strip()
304
+ if not user_input:
305
+ raise RuntimeError("未输入授权码")
306
+
307
+ code = _parse_code(user_input)
308
+
309
+ # exchange_token + get_tmp_cred + 保存
310
+ print()
311
+ print(" ◦ 用授权码换取 token...")
312
+ token_info = exchange_token(code)
313
+
314
+ print(" ◦ 获取临时密钥...")
315
+ access_token = token_info["user_access_token"]
316
+ cred = get_tmp_cred(access_token, site)
317
+
318
+ oauth_info = {
319
+ "accessToken": token_info["user_access_token"],
320
+ "refreshToken": token_info["refresh_token"],
321
+ "userOpenId": token_info.get("user_open_id", ""),
322
+ "expiresAt": token_info.get("expires_at", 0),
323
+ "site": site,
324
+ }
325
+ save_credential(cred, oauth_info)
326
+
327
+ print()
328
+ print(" ✅ 登录成功!")
329
+ print()
330
+ print(" 凭证已保存到 ~/.tencent-cloudq/credential.json")
331
+ print(" 临时密钥将在过期前自动刷新,您无需重复登录。")
332
+ print()
333
+
334
+ except KeyboardInterrupt:
335
+ print("\n 已取消登录。")
336
+ except Exception as e:
337
+ print()
338
+ print(f" ❌ 登录失败:{e}")
339
+ print()
340
+ sys.exit(1)
341
+
342
+
343
+ # ============== CLI 入口 ==============
344
+
345
+ def _read_code_from_stdin() -> str:
346
+ raw = sys.stdin.buffer.read(64 * 1024 + 1)
347
+ if len(raw) > 64 * 1024:
348
+ raise ValueError("授权码输入过大")
349
+ payload = json.loads(raw.decode("utf-8"))
350
+ if not isinstance(payload, dict) or not isinstance(payload.get("code"), str):
351
+ raise ValueError("授权码输入必须是包含 code 的 JSON 对象")
352
+ return payload["code"].strip()
353
+
354
+
355
+ def main():
356
+ args = sys.argv[1:]
357
+ site = "cn"
358
+
359
+ # 解析 --site 参数
360
+ for arg in args:
361
+ if arg.startswith("--site="):
362
+ site = arg.split("=", 1)[1]
363
+
364
+ # 子命令路由(非交互式,供 Agent 调用)
365
+ if "--authorize-url" in args:
366
+ sys.exit(cmd_authorize_url(site))
367
+
368
+ if "--save" in args:
369
+ if "--stdin" not in args:
370
+ print(_json_err("MissingCode", "授权码必须通过标准输入传入"))
371
+ sys.exit(1)
372
+ try:
373
+ code = _read_code_from_stdin()
374
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
375
+ print(_json_err("InvalidCodeInput", str(exc)))
376
+ sys.exit(1)
377
+ if not code:
378
+ print(_json_err("MissingCode", "授权码为空"))
379
+ sys.exit(1)
380
+ sys.exit(cmd_save(code, site))
381
+
382
+ if "--status" in args:
383
+ sys.exit(cmd_status())
384
+
385
+ if "--refresh" in args:
386
+ sys.exit(cmd_refresh())
387
+
388
+ if "--help" in args or "-h" in args:
389
+ print("CloudQ OAuth 登录")
390
+ print()
391
+ print("非交互式子命令(供 Agent 调用):")
392
+ print(" python3 login.py --authorize-url # 获取授权 URL")
393
+ print(" <授权码 JSON> | python3 login.py --save --stdin # 用授权码换取凭证")
394
+ print(" python3 login.py --status # 查看凭证状态")
395
+ print(" python3 login.py --refresh # 主动刷新凭证")
396
+ print()
397
+ print("交互式登录(仅供终端手动使用):")
398
+ print(" python3 login.py # 打开浏览器授权")
399
+ print(" python3 login.py --no-browser # 手动模式")
400
+ print(" python3 login.py --site=intl # 国际站")
401
+ print()
402
+ return
403
+
404
+ # 默认:交互式登录(终端手动使用)
405
+ open_browser = "--no-browser" not in args and "--manual" not in args
406
+ _interactive_login(open_browser=open_browser, site=site)
407
+
408
+
409
+ if __name__ == "__main__":
410
+ main()