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.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/assets/cloudq.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +3405 -0
- package/lib/index.js +910 -0
- package/lib/types/client/index.d.ts +4 -0
- package/lib/types/index.d.ts +13 -0
- package/package.json +119 -0
- package/skills/cloudq/SKILL.md +359 -0
- package/skills/cloudq/references/api/CloudQChatCompletions.md +178 -0
- package/skills/cloudq/scripts/check_env.py +811 -0
- package/skills/cloudq/scripts/cleanup.py +419 -0
- package/skills/cloudq/scripts/create_role.py +281 -0
- package/skills/cloudq/scripts/credential_manager.py +554 -0
- package/skills/cloudq/scripts/login.py +410 -0
- package/skills/cloudq/scripts/login_url.py +404 -0
- package/skills/cloudq/scripts/logout.py +36 -0
- package/skills/cloudq/scripts/save_ak.py +156 -0
- package/skills/cloudq/scripts/tcloud_api.py +329 -0
- package/skills/cloudq/scripts/tcloud_async_task.py +345 -0
- package/skills/cloudq/scripts/tcloud_sse_api.py +765 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
腾讯云智能顾问环境检测脚本
|
|
4
|
+
|
|
5
|
+
功能:检测 Python 版本、Skill 版本更新(含 changelog)、AK/SK、OAuth、Connector 凭证、智能顾问开通状态、角色配置状态,输出检测结果
|
|
6
|
+
支持 AK/SK 环境变量、OAuth 浏览器授权和企业 OneID Connector 三种鉴权方式
|
|
7
|
+
支持 --enable-advisor 参数开通智能顾问(写入操作,需用户明确同意)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python3 check_env.py # 标准模式:输出详细检测结果
|
|
11
|
+
python3 check_env.py --quiet # 静默模式:仅输出错误信息(供其他脚本调用)
|
|
12
|
+
python3 check_env.py --skip-update # 跳过版本更新检查
|
|
13
|
+
python3 check_env.py --enable-advisor # 开通智能顾问(写入操作,需用户明确同意)
|
|
14
|
+
python3 check_env.py --list-console-roles # 列出支持控制台登录的角色(JSON)
|
|
15
|
+
python3 check_env.py --check-role <name> # 检查指定角色是否支持控制台登录(JSON)
|
|
16
|
+
|
|
17
|
+
返回码:
|
|
18
|
+
0 - 环境就绪(凭证 + 智能顾问已开通 + 角色全部正常)/ 查询成功
|
|
19
|
+
1 - Python 版本不满足 / 查询失败
|
|
20
|
+
2 - 凭证未配置或无效(AK/SK、OAuth 和 Connector 均不可用)
|
|
21
|
+
3 - 角色未配置(需要执行角色创建步骤,可选)
|
|
22
|
+
4 - 智能顾问未开通(需要开通智能顾问后才能使用 CloudQ)
|
|
23
|
+
|
|
24
|
+
跨平台支持: Windows / Linux / macOS
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import platform
|
|
30
|
+
import stat
|
|
31
|
+
import sys
|
|
32
|
+
import urllib.parse
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
# scripts 目录(当前脚本所在目录)
|
|
38
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
39
|
+
# 项目根目录(SKILL.md / _meta.json 所在位置)
|
|
40
|
+
ROOT_DIR = SCRIPT_DIR.parent
|
|
41
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
42
|
+
|
|
43
|
+
from tcloud_api import call_api # noqa: E402
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============== 配置 ==============
|
|
47
|
+
CONFIG_DIR = Path.home() / ".tencent-cloudq"
|
|
48
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
49
|
+
VERSION_CACHE_FILE = CONFIG_DIR / "version_check_cache.json"
|
|
50
|
+
ADVISOR_ROLE_NAME = "advisor"
|
|
51
|
+
|
|
52
|
+
# 角色需要关联的策略列表(用于检测和自动补充)
|
|
53
|
+
REQUIRED_ROLE_POLICIES = [
|
|
54
|
+
"QcloudTAGFullAccess",
|
|
55
|
+
"QcloudAdvisorFullAccess",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# 版本检查配置(_meta.json 在项目根目录)
|
|
59
|
+
META_FILE = ROOT_DIR / "_meta.json"
|
|
60
|
+
VERSION_CHECK_TIMEOUT = 15 # 秒
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ============== 输出函数 ==============
|
|
64
|
+
QUIET_MODE = "--quiet" in sys.argv
|
|
65
|
+
SKIP_UPDATE = "--skip-update" in sys.argv
|
|
66
|
+
ENABLE_ADVISOR = "--enable-advisor" in sys.argv
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def log_info(msg: str):
|
|
70
|
+
if not QUIET_MODE:
|
|
71
|
+
print(msg)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def log_ok(msg: str):
|
|
75
|
+
if not QUIET_MODE:
|
|
76
|
+
print(f" [OK] {msg}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def log_warn(msg: str):
|
|
80
|
+
if not QUIET_MODE:
|
|
81
|
+
print(f" [WARN] {msg}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def log_fail(msg: str):
|
|
85
|
+
print(f" [FAIL] {msg}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def log_section(title: str):
|
|
89
|
+
if not QUIET_MODE:
|
|
90
|
+
print(f"\n=== {title} ===")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_config(account_uin: str, role_name: str, role_arn: str,
|
|
94
|
+
auto_created: bool = False, role_id: str = ""):
|
|
95
|
+
"""保存配置文件(跨平台兼容)"""
|
|
96
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
# 设置目录权限(非 Windows)
|
|
99
|
+
if platform.system() != "Windows":
|
|
100
|
+
try:
|
|
101
|
+
os.chmod(str(CONFIG_DIR), stat.S_IRWXU) # 700
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
config = {
|
|
106
|
+
"accountUin": account_uin,
|
|
107
|
+
"roleName": role_name,
|
|
108
|
+
"roleArn": role_arn,
|
|
109
|
+
"configuredAt": datetime.now(timezone.utc).isoformat(),
|
|
110
|
+
"autoCreated": auto_created,
|
|
111
|
+
"version": "1.0",
|
|
112
|
+
}
|
|
113
|
+
if role_id:
|
|
114
|
+
config["roleId"] = role_id
|
|
115
|
+
|
|
116
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
# 设置文件权限(非 Windows)
|
|
119
|
+
if platform.system() != "Windows":
|
|
120
|
+
try:
|
|
121
|
+
os.chmod(str(CONFIG_FILE), stat.S_IRUSR | stat.S_IWUSR) # 600
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ensure_role_policies(role_name: str) -> list:
|
|
127
|
+
"""为角色补充缺失的必需策略(幂等操作)。
|
|
128
|
+
|
|
129
|
+
对 REQUIRED_ROLE_POLICIES 中的每个策略执行 AttachRolePolicy,
|
|
130
|
+
已关联的策略会返回 PolicyAlreadyAttached 错误码,视为成功。
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
list: 关联失败的警告信息列表(空列表表示全部成功)
|
|
134
|
+
"""
|
|
135
|
+
warnings = []
|
|
136
|
+
for policy_name in REQUIRED_ROLE_POLICIES:
|
|
137
|
+
attach_result = call_api(
|
|
138
|
+
"cam", "cam.tencentcloudapi.com",
|
|
139
|
+
"AttachRolePolicy", "2019-01-16",
|
|
140
|
+
{"AttachRoleName": role_name, "PolicyName": policy_name},
|
|
141
|
+
)
|
|
142
|
+
if not attach_result.get("success"):
|
|
143
|
+
err_code = attach_result.get("error", {}).get("code", "")
|
|
144
|
+
if "AlreadyAttached" not in err_code:
|
|
145
|
+
err_msg = attach_result.get("error", {}).get("message", "未知错误")
|
|
146
|
+
warnings.append(f"策略 {policy_name} 关联失败: {err_msg}")
|
|
147
|
+
return warnings
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def list_console_login_roles() -> dict:
|
|
151
|
+
"""查询账号下所有支持控制台登录的用户自定义角色(只读)。"""
|
|
152
|
+
result = call_api(
|
|
153
|
+
"cam", "cam.tencentcloudapi.com",
|
|
154
|
+
"DescribeRoleList", "2019-01-16",
|
|
155
|
+
{"Page": 1, "Rp": 200},
|
|
156
|
+
)
|
|
157
|
+
if not result.get("success"):
|
|
158
|
+
return {"success": False, "roles": [], "error": result.get("error", {})}
|
|
159
|
+
role_list = result.get("data", {}).get("List", [])
|
|
160
|
+
console_roles = [
|
|
161
|
+
r for r in role_list
|
|
162
|
+
if r.get("ConsoleLogin") == 1 and r.get("RoleType") == "user"
|
|
163
|
+
]
|
|
164
|
+
return {"success": True, "roles": console_roles, "total": len(console_roles)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_role_console_login(role_name: str) -> dict:
|
|
168
|
+
"""检查指定角色是否存在及是否支持控制台登录(只读)。"""
|
|
169
|
+
result = call_api(
|
|
170
|
+
"cam", "cam.tencentcloudapi.com",
|
|
171
|
+
"GetRole", "2019-01-16",
|
|
172
|
+
{"RoleName": role_name},
|
|
173
|
+
)
|
|
174
|
+
if not result.get("success"):
|
|
175
|
+
return {
|
|
176
|
+
"success": False, "role_name": role_name,
|
|
177
|
+
"exists": False, "console_login": False,
|
|
178
|
+
"error": result.get("error", {}),
|
|
179
|
+
}
|
|
180
|
+
data = result.get("data", {})
|
|
181
|
+
console_login = data.get("ConsoleLogin", 0) == 1
|
|
182
|
+
return {
|
|
183
|
+
"success": True, "role_name": role_name,
|
|
184
|
+
"exists": True, "console_login": console_login, "data": data,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_version(version_str: str) -> tuple:
|
|
189
|
+
"""解析语义化版本号字符串为可比较的元组"""
|
|
190
|
+
try:
|
|
191
|
+
parts = version_str.strip().lstrip("v").split(".")
|
|
192
|
+
return tuple(int(p) for p in parts)
|
|
193
|
+
except (ValueError, AttributeError):
|
|
194
|
+
return (0, 0, 0)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_local_version() -> tuple:
|
|
198
|
+
"""获取本地版本信息,多源降级:_meta.json → SKILL.md front-matter。"""
|
|
199
|
+
# L1: _meta.json
|
|
200
|
+
if META_FILE.exists():
|
|
201
|
+
try:
|
|
202
|
+
meta = json.loads(META_FILE.read_text(encoding="utf-8"))
|
|
203
|
+
slug, ver = meta.get("slug"), meta.get("version")
|
|
204
|
+
if slug and ver:
|
|
205
|
+
return slug, ver
|
|
206
|
+
except (json.JSONDecodeError, IOError):
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# L2: SKILL.md YAML front-matter
|
|
210
|
+
skill_md = ROOT_DIR / "SKILL.md"
|
|
211
|
+
if skill_md.exists():
|
|
212
|
+
try:
|
|
213
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
214
|
+
if text.startswith("---"):
|
|
215
|
+
end = text.index("---", 3)
|
|
216
|
+
fm = text[3:end]
|
|
217
|
+
props = {}
|
|
218
|
+
for line in fm.strip().splitlines():
|
|
219
|
+
if ":" in line:
|
|
220
|
+
k, v = line.split(":", 1)
|
|
221
|
+
props[k.strip()] = v.strip().strip('"').strip("'")
|
|
222
|
+
name = props.get("name", "")
|
|
223
|
+
ver = props.get("version")
|
|
224
|
+
if name and ver:
|
|
225
|
+
slug = name.lower().replace(" ", "-")
|
|
226
|
+
return slug, ver
|
|
227
|
+
except (IOError, ValueError):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
return None, None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _extract_version(data: dict) -> Optional[str]:
|
|
234
|
+
return data.get("latestVersion", {}).get("version")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _get_info_via_urllib(api_url: str) -> Optional[dict]:
|
|
238
|
+
import urllib.request
|
|
239
|
+
import ssl
|
|
240
|
+
try:
|
|
241
|
+
ctx = ssl.create_default_context()
|
|
242
|
+
resp = urllib.request.urlopen(api_url, timeout=VERSION_CHECK_TIMEOUT, context=ctx)
|
|
243
|
+
if resp.status != 200:
|
|
244
|
+
return None
|
|
245
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
246
|
+
except Exception:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _get_info_via_clawhub(slug: str) -> Optional[dict]:
|
|
251
|
+
import subprocess
|
|
252
|
+
result = subprocess.run(
|
|
253
|
+
["clawhub", "inspect", slug, "--versions", "--json"],
|
|
254
|
+
capture_output=True, text=True, timeout=VERSION_CHECK_TIMEOUT,
|
|
255
|
+
)
|
|
256
|
+
if result.returncode != 0:
|
|
257
|
+
return None
|
|
258
|
+
return json.loads(result.stdout)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_remote_info(slug: str) -> Optional[dict]:
|
|
262
|
+
api_url = f"https://clawhub.ai/api/v1/skills/{urllib.parse.quote(slug, safe='')}"
|
|
263
|
+
strategies = [
|
|
264
|
+
lambda: _get_info_via_urllib(api_url),
|
|
265
|
+
lambda: _get_info_via_clawhub(slug),
|
|
266
|
+
]
|
|
267
|
+
for strategy in strategies:
|
|
268
|
+
try:
|
|
269
|
+
data = strategy()
|
|
270
|
+
if data and _extract_version(data):
|
|
271
|
+
return data
|
|
272
|
+
except Exception:
|
|
273
|
+
continue
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _save_version_cache(result: dict):
|
|
278
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
cache = {
|
|
280
|
+
"checked_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
281
|
+
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
282
|
+
"status": result.get("status"),
|
|
283
|
+
"local_version": result.get("local_version"),
|
|
284
|
+
"remote_version": result.get("remote_version"),
|
|
285
|
+
"changelog": result.get("changelog", []),
|
|
286
|
+
"message": result.get("message"),
|
|
287
|
+
}
|
|
288
|
+
try:
|
|
289
|
+
VERSION_CACHE_FILE.write_text(
|
|
290
|
+
json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
291
|
+
)
|
|
292
|
+
except IOError:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def check_version_update() -> dict:
|
|
297
|
+
slug, local_ver = get_local_version()
|
|
298
|
+
local_error = None
|
|
299
|
+
if not slug or not local_ver:
|
|
300
|
+
local_error = "未找到 _meta.json 或版本信息缺失"
|
|
301
|
+
log_warn(f"本地版本检查: {local_error}")
|
|
302
|
+
|
|
303
|
+
remote_ver = None
|
|
304
|
+
remote_data = None
|
|
305
|
+
remote_error = None
|
|
306
|
+
|
|
307
|
+
slugs_to_try = [slug] if slug else ["cloudq", "CloudQ", "advisor", "tencent-cloudq"]
|
|
308
|
+
slugs_to_try = [s for s in slugs_to_try if s]
|
|
309
|
+
|
|
310
|
+
for try_slug in slugs_to_try:
|
|
311
|
+
try:
|
|
312
|
+
remote_data = get_remote_info(try_slug)
|
|
313
|
+
if remote_data and _extract_version(remote_data):
|
|
314
|
+
remote_ver = _extract_version(remote_data)
|
|
315
|
+
if not slug:
|
|
316
|
+
slug = try_slug
|
|
317
|
+
break
|
|
318
|
+
except Exception:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
if not remote_ver:
|
|
322
|
+
remote_error = "无法获取远端版本信息"
|
|
323
|
+
|
|
324
|
+
result = {"local_version": local_ver, "remote_version": remote_ver, "slug": slug}
|
|
325
|
+
if local_error:
|
|
326
|
+
result["local_error"] = local_error
|
|
327
|
+
if remote_error:
|
|
328
|
+
result["remote_error"] = remote_error
|
|
329
|
+
|
|
330
|
+
has_local = bool(local_ver)
|
|
331
|
+
has_remote = bool(remote_ver)
|
|
332
|
+
|
|
333
|
+
if has_local and has_remote:
|
|
334
|
+
local_parsed = parse_version(local_ver)
|
|
335
|
+
remote_parsed = parse_version(remote_ver)
|
|
336
|
+
if remote_parsed <= local_parsed:
|
|
337
|
+
result.update({"status": "up_to_date", "message": f"当前已是最新版本: {local_ver}"})
|
|
338
|
+
else:
|
|
339
|
+
changelog = _collect_changelog(remote_data, local_parsed)
|
|
340
|
+
result.update({"status": "update_available", "changelog": changelog,
|
|
341
|
+
"message": f"发现新版本: {local_ver} → {remote_ver}"})
|
|
342
|
+
elif has_local and not has_remote:
|
|
343
|
+
result.update({"status": "local_only", "message": f"本地版本: {local_ver},但无法获取远端版本信息"})
|
|
344
|
+
elif not has_local and has_remote:
|
|
345
|
+
changelog = []
|
|
346
|
+
latest_changelog = (remote_data or {}).get("latestVersion", {}).get("changelog", "")
|
|
347
|
+
if latest_changelog:
|
|
348
|
+
changelog.append(f" {remote_ver}: {latest_changelog}")
|
|
349
|
+
result.update({"status": "remote_only", "changelog": changelog,
|
|
350
|
+
"message": f"本地缺少版本元数据,检测到远端最新版本: {remote_ver}"})
|
|
351
|
+
else:
|
|
352
|
+
result.update({"status": "both_failed", "message": "本地和远端版本信息均无法获取"})
|
|
353
|
+
|
|
354
|
+
_save_version_cache(result)
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _collect_changelog(remote_data: dict, local_parsed: tuple) -> list:
|
|
359
|
+
changelog_lines = []
|
|
360
|
+
versions = remote_data.get("versions", [])
|
|
361
|
+
for v in versions:
|
|
362
|
+
v_str = v.get("version", "")
|
|
363
|
+
v_parsed = parse_version(v_str)
|
|
364
|
+
if v_parsed > local_parsed:
|
|
365
|
+
desc = v.get("changelog") or v.get("description") or ""
|
|
366
|
+
if desc:
|
|
367
|
+
changelog_lines.append(f" {v_str}: {desc}")
|
|
368
|
+
else:
|
|
369
|
+
changelog_lines.append(f" {v_str}")
|
|
370
|
+
if not changelog_lines:
|
|
371
|
+
latest_changelog = remote_data.get("latestVersion", {}).get("changelog", "")
|
|
372
|
+
if latest_changelog:
|
|
373
|
+
remote_ver = _extract_version(remote_data) or "未知"
|
|
374
|
+
changelog_lines.append(f" {remote_ver}: {latest_changelog}")
|
|
375
|
+
return changelog_lines
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main():
|
|
379
|
+
args = sys.argv[1:]
|
|
380
|
+
|
|
381
|
+
if "--list-console-roles" in args:
|
|
382
|
+
result = list_console_login_roles()
|
|
383
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
384
|
+
sys.exit(0 if result.get("success") else 1)
|
|
385
|
+
|
|
386
|
+
if "--check-role" in args:
|
|
387
|
+
idx = args.index("--check-role")
|
|
388
|
+
if idx + 1 >= len(args) or args[idx + 1].startswith("--"):
|
|
389
|
+
print(json.dumps({"success": False, "error": "缺少角色名参数"}, ensure_ascii=False))
|
|
390
|
+
sys.exit(1)
|
|
391
|
+
role_name = args[idx + 1]
|
|
392
|
+
result = check_role_console_login(role_name)
|
|
393
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
394
|
+
sys.exit(0 if result.get("success") else 1)
|
|
395
|
+
|
|
396
|
+
# ============== 1. 检查 Python 版本 ==============
|
|
397
|
+
log_section("1. 检查运行环境")
|
|
398
|
+
|
|
399
|
+
py_ver = sys.version_info
|
|
400
|
+
if py_ver < (3, 7):
|
|
401
|
+
log_fail(f"Python 版本过低: {sys.version},需要 Python 3.7+")
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
log_ok(f"Python {py_ver.major}.{py_ver.minor}.{py_ver.micro} ({platform.system()} {platform.machine()})")
|
|
405
|
+
|
|
406
|
+
# ============== 2. 检查 Skill 版本更新 ==============
|
|
407
|
+
log_section("2. 检查 Skill 版本")
|
|
408
|
+
|
|
409
|
+
if SKIP_UPDATE:
|
|
410
|
+
log_ok("已跳过版本更新检查(--skip-update)")
|
|
411
|
+
else:
|
|
412
|
+
ver_result = check_version_update()
|
|
413
|
+
status = ver_result["status"]
|
|
414
|
+
local_ver = ver_result.get("local_version")
|
|
415
|
+
remote_ver = ver_result.get("remote_version")
|
|
416
|
+
|
|
417
|
+
if status == "up_to_date":
|
|
418
|
+
log_ok(ver_result["message"])
|
|
419
|
+
log_info(f" 本地版本: {local_ver} | 远端版本: {remote_ver}")
|
|
420
|
+
elif status == "update_available":
|
|
421
|
+
log_warn(ver_result["message"])
|
|
422
|
+
log_info("")
|
|
423
|
+
log_info(f" 当前版本: {local_ver}")
|
|
424
|
+
log_info(f" 最新版本: {remote_ver}")
|
|
425
|
+
changelog = ver_result.get("changelog", [])
|
|
426
|
+
if changelog:
|
|
427
|
+
log_info("")
|
|
428
|
+
log_info(" === Changelog(变更日志)===")
|
|
429
|
+
for line in changelog:
|
|
430
|
+
log_info(line)
|
|
431
|
+
log_info("")
|
|
432
|
+
log_info(" 请前往 SkillHub 或 ClawHub 更新此 Skill")
|
|
433
|
+
log_info("")
|
|
434
|
+
elif status == "local_only":
|
|
435
|
+
log_warn(ver_result["message"])
|
|
436
|
+
log_info(f" 本地版本: {local_ver}")
|
|
437
|
+
if ver_result.get("remote_error"):
|
|
438
|
+
log_info(f" 远端检查: {ver_result['remote_error']}")
|
|
439
|
+
log_info(" 版本比较跳过,继续后续检测...")
|
|
440
|
+
elif status == "remote_only":
|
|
441
|
+
log_warn(ver_result["message"])
|
|
442
|
+
log_info("")
|
|
443
|
+
log_info(f" 远端最新版本: {remote_ver}")
|
|
444
|
+
changelog = ver_result.get("changelog", [])
|
|
445
|
+
if changelog:
|
|
446
|
+
log_info("")
|
|
447
|
+
log_info(" === Changelog(变更日志)===")
|
|
448
|
+
for line in changelog:
|
|
449
|
+
log_info(line)
|
|
450
|
+
log_info("")
|
|
451
|
+
log_info(" 建议前往 SkillHub 或 ClawHub 更新此 Skill")
|
|
452
|
+
log_info("")
|
|
453
|
+
elif status == "both_failed":
|
|
454
|
+
log_warn(ver_result["message"])
|
|
455
|
+
log_info(" 版本检查跳过,继续后续检测...")
|
|
456
|
+
else:
|
|
457
|
+
log_warn(f"版本检查返回未知状态: {status}")
|
|
458
|
+
|
|
459
|
+
# ============== 3. 检查凭证配置 ==============
|
|
460
|
+
log_section("3. 检查凭证配置")
|
|
461
|
+
|
|
462
|
+
secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
|
|
463
|
+
secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")
|
|
464
|
+
|
|
465
|
+
using_oauth = False
|
|
466
|
+
using_connector = False
|
|
467
|
+
|
|
468
|
+
if not secret_id or not secret_key:
|
|
469
|
+
missing = []
|
|
470
|
+
if not secret_id:
|
|
471
|
+
missing.append("TENCENTCLOUD_SECRET_ID")
|
|
472
|
+
if not secret_key:
|
|
473
|
+
missing.append("TENCENTCLOUD_SECRET_KEY")
|
|
474
|
+
log_warn(f"未配置环境变量: {', '.join(missing)}")
|
|
475
|
+
|
|
476
|
+
# ---- 3.1 检查本地凭证(OAuth / Connector)----
|
|
477
|
+
log_info("")
|
|
478
|
+
log_info(" 检查本地凭证...")
|
|
479
|
+
try:
|
|
480
|
+
from credential_manager import (
|
|
481
|
+
load_credential, maybe_refresh_credential,
|
|
482
|
+
get_credential, CredentialNotFoundError, CredentialExpiredError
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
oauth_cred = load_credential()
|
|
486
|
+
if oauth_cred:
|
|
487
|
+
credential_type = oauth_cred.get("type", "oauth")
|
|
488
|
+
credential_label = "Connector 临时密钥" if credential_type == "connector" else "OAuth 凭证"
|
|
489
|
+
log_ok(f"{credential_label}文件已存在")
|
|
490
|
+
|
|
491
|
+
import time as _time
|
|
492
|
+
now = _time.time()
|
|
493
|
+
expires_at = oauth_cred.get("expiresAt", 0)
|
|
494
|
+
remaining = max(0, int(expires_at - now))
|
|
495
|
+
|
|
496
|
+
if remaining > 300:
|
|
497
|
+
log_ok(f"临时密钥有效期剩余: {remaining // 60} 分钟")
|
|
498
|
+
else:
|
|
499
|
+
log_warn("临时密钥即将过期,尝试自动刷新...")
|
|
500
|
+
try:
|
|
501
|
+
maybe_refresh_credential()
|
|
502
|
+
oauth_cred = load_credential()
|
|
503
|
+
if oauth_cred:
|
|
504
|
+
now_after = _time.time()
|
|
505
|
+
new_remaining = max(0, int(oauth_cred.get("expiresAt", 0) - now_after))
|
|
506
|
+
log_ok(f"刷新成功,有效期剩余: {new_remaining // 60} 分钟")
|
|
507
|
+
else:
|
|
508
|
+
log_fail("刷新后凭证文件丢失")
|
|
509
|
+
log_info(f" 请重新登录: python3 {SCRIPT_DIR}/login.py")
|
|
510
|
+
sys.exit(2)
|
|
511
|
+
except CredentialExpiredError:
|
|
512
|
+
log_fail("refreshToken 已过期,需要重新登录")
|
|
513
|
+
log_info(f" 请执行: python3 {SCRIPT_DIR}/login.py")
|
|
514
|
+
sys.exit(2)
|
|
515
|
+
except Exception as e:
|
|
516
|
+
log_warn(f"自动刷新失败: {e},尝试继续使用当前凭证")
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
cred = get_credential()
|
|
520
|
+
secret_id = cred["secretId"]
|
|
521
|
+
secret_key = cred["secretKey"]
|
|
522
|
+
using_oauth = credential_type == "oauth"
|
|
523
|
+
using_connector = credential_type == "connector"
|
|
524
|
+
log_ok(f"将使用{credential_label}继续")
|
|
525
|
+
except Exception as e:
|
|
526
|
+
log_fail(f"获取本地凭证失败: {e}")
|
|
527
|
+
sys.exit(2)
|
|
528
|
+
else:
|
|
529
|
+
log_warn("未找到本地凭证")
|
|
530
|
+
log_info("")
|
|
531
|
+
log_info(" 请选择以下方式之一配置凭证:")
|
|
532
|
+
log_info("")
|
|
533
|
+
log_info(" 方式一:OAuth 浏览器授权(推荐,无需密钥)")
|
|
534
|
+
log_info(f" python3 {SCRIPT_DIR}/login.py")
|
|
535
|
+
log_info("")
|
|
536
|
+
log_info(" 方式二:配置环境变量 AK/SK")
|
|
537
|
+
log_info(' export TENCENTCLOUD_SECRET_ID="your-secret-id"')
|
|
538
|
+
log_info(' export TENCENTCLOUD_SECRET_KEY="your-secret-key"')
|
|
539
|
+
log_info("")
|
|
540
|
+
log_info(" 方式三:企业 OneID 授权")
|
|
541
|
+
log_info(" 前往 CloudQ 控制台 → 拓展 → Channels 集成 → OneID,绑定 WorkBuddy OneID 应用后,")
|
|
542
|
+
log_info(" 复制返回的 MCP 配置,在 WorkBuddy「连接器」→「自定义连接器」→「配置 MCP」中粘贴、保存并点击「连接」。")
|
|
543
|
+
log_info("")
|
|
544
|
+
log_info(" 密钥获取地址: https://console.cloud.tencent.com/cam/capi")
|
|
545
|
+
sys.exit(2)
|
|
546
|
+
|
|
547
|
+
except ImportError:
|
|
548
|
+
log_fail(f"未配置以下环境变量: {', '.join(missing)}")
|
|
549
|
+
log_info("")
|
|
550
|
+
log_info(" 请将腾讯云 API 密钥写入 shell 配置文件:")
|
|
551
|
+
log_info(' echo \'export TENCENTCLOUD_SECRET_ID="your-secret-id"\' >> ~/.bashrc')
|
|
552
|
+
log_info(" source ~/.bashrc")
|
|
553
|
+
log_info("")
|
|
554
|
+
log_info(" 密钥获取地址: https://console.cloud.tencent.com/cam/capi")
|
|
555
|
+
sys.exit(2)
|
|
556
|
+
else:
|
|
557
|
+
masked_id = f"{secret_id[:4]}****{secret_id[-4:]}" if len(secret_id) > 8 else "****"
|
|
558
|
+
log_ok(f"SecretId 已配置: {masked_id}")
|
|
559
|
+
log_ok("SecretKey 已配置: ****")
|
|
560
|
+
|
|
561
|
+
token = os.environ.get("TENCENTCLOUD_TOKEN", "")
|
|
562
|
+
if token:
|
|
563
|
+
log_ok("临时密钥 Token 已配置")
|
|
564
|
+
|
|
565
|
+
# ============== 4. 验证凭证有效性 ==============
|
|
566
|
+
log_section("4. 验证凭证有效性")
|
|
567
|
+
|
|
568
|
+
verify_result = call_api(
|
|
569
|
+
"advisor", "advisor.tencentcloudapi.com",
|
|
570
|
+
"DescribeArchList", "2020-07-21",
|
|
571
|
+
{"PageNumber": 1, "PageSize": 1},
|
|
572
|
+
"ap-guangzhou",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
if verify_result.get("success"):
|
|
576
|
+
log_ok("凭证验证通过,接口调用成功")
|
|
577
|
+
else:
|
|
578
|
+
error_code = verify_result.get("error", {}).get("code", "Unknown")
|
|
579
|
+
auth_failures = [
|
|
580
|
+
"AuthFailure.SecretIdNotFound",
|
|
581
|
+
"AuthFailure.SignatureFailure",
|
|
582
|
+
"AuthFailure.InvalidSecretId",
|
|
583
|
+
]
|
|
584
|
+
if error_code in auth_failures:
|
|
585
|
+
log_fail(f"凭证无效: {error_code}")
|
|
586
|
+
if using_connector:
|
|
587
|
+
log_info(" 请在 WorkBuddy 中重新连接 CloudQ Connector")
|
|
588
|
+
elif using_oauth:
|
|
589
|
+
log_info(f" 请重新登录: python3 {SCRIPT_DIR}/login.py")
|
|
590
|
+
else:
|
|
591
|
+
log_info(" 请检查密钥是否正确: https://console.cloud.tencent.com/cam/capi")
|
|
592
|
+
sys.exit(2)
|
|
593
|
+
elif error_code in ("NetworkError", "HTTPError"):
|
|
594
|
+
log_fail("接口调用失败,请检查网络连接")
|
|
595
|
+
sys.exit(1)
|
|
596
|
+
else:
|
|
597
|
+
log_ok("凭证验证通过(鉴权成功)")
|
|
598
|
+
if not QUIET_MODE:
|
|
599
|
+
log_warn(f"接口返回业务错误: {error_code}(不影响鉴权)")
|
|
600
|
+
|
|
601
|
+
# ============== 5. 检查智能顾问开通状态 ==============
|
|
602
|
+
log_section("5. 检查智能顾问开通状态")
|
|
603
|
+
|
|
604
|
+
advisor_auth_result = call_api(
|
|
605
|
+
"advisor", "advisor.tencentcloudapi.com",
|
|
606
|
+
"DescribeUserAuthorizationStatus", "2020-07-21",
|
|
607
|
+
{}, "ap-guangzhou",
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
advisor_authorized = False
|
|
611
|
+
if advisor_auth_result.get("success"):
|
|
612
|
+
auth_data = advisor_auth_result.get("data", {})
|
|
613
|
+
advisor_authorized = auth_data.get("AdvisorAuthorization", False)
|
|
614
|
+
share_authorized = auth_data.get("ShareAuthorization", False)
|
|
615
|
+
if advisor_authorized:
|
|
616
|
+
log_ok("智能顾问已开通")
|
|
617
|
+
if share_authorized:
|
|
618
|
+
log_ok("架构图共享协作已开启")
|
|
619
|
+
else:
|
|
620
|
+
log_warn("架构图共享协作未开启(不影响 CloudQ 基本功能)")
|
|
621
|
+
else:
|
|
622
|
+
log_fail("智能顾问未开通")
|
|
623
|
+
if ENABLE_ADVISOR:
|
|
624
|
+
log_info(" 正在开通智能顾问...")
|
|
625
|
+
enable_result = call_api(
|
|
626
|
+
"advisor", "advisor.tencentcloudapi.com",
|
|
627
|
+
"CreateAdvisorAuthorization", "2020-07-21",
|
|
628
|
+
{}, "ap-guangzhou",
|
|
629
|
+
)
|
|
630
|
+
if enable_result.get("success"):
|
|
631
|
+
log_ok("智能顾问开通成功!")
|
|
632
|
+
advisor_authorized = True
|
|
633
|
+
else:
|
|
634
|
+
err = enable_result.get("error", {})
|
|
635
|
+
log_fail(f"智能顾问开通失败: {err.get('code', 'Unknown')} - {err.get('message', '未知错误')}")
|
|
636
|
+
sys.exit(4)
|
|
637
|
+
else:
|
|
638
|
+
log_info(" CloudQ 所有功能均依赖智能顾问服务,必须先开通才能使用")
|
|
639
|
+
log_info(" 开通方式:请在对话中同意开通,或运行以下命令:")
|
|
640
|
+
log_info(f" python3 {SCRIPT_DIR}/check_env.py --enable-advisor")
|
|
641
|
+
sys.exit(4)
|
|
642
|
+
else:
|
|
643
|
+
error_code = advisor_auth_result.get("error", {}).get("code", "Unknown")
|
|
644
|
+
if error_code in ("NetworkError", "HTTPError"):
|
|
645
|
+
log_fail("查询智能顾问开通状态失败,请检查网络连接")
|
|
646
|
+
sys.exit(1)
|
|
647
|
+
else:
|
|
648
|
+
log_warn(f"查询智能顾问开通状态失败: {error_code}")
|
|
649
|
+
log_info(" 可能原因:当前凭证无智能顾问相关权限")
|
|
650
|
+
|
|
651
|
+
# ============== 6. 检查角色配置状态(仅 AK/SK 模式) ==============
|
|
652
|
+
role_configured = False
|
|
653
|
+
|
|
654
|
+
if using_oauth or using_connector:
|
|
655
|
+
log_section("6. 免密登录角色")
|
|
656
|
+
log_info(" OAuth / Connector 模式下跳过角色检测(临时密钥无 cam/sts 权限)")
|
|
657
|
+
log_info(" 免密登录链接功能仅在 AK/SK 模式下可用")
|
|
658
|
+
else:
|
|
659
|
+
log_section("6. 检查免密登录角色配置")
|
|
660
|
+
|
|
661
|
+
role_arn = os.environ.get("TENCENTCLOUD_ROLE_ARN", "")
|
|
662
|
+
|
|
663
|
+
if role_arn:
|
|
664
|
+
log_ok("ROLE_ARN 已通过环境变量配置")
|
|
665
|
+
role_configured = True
|
|
666
|
+
|
|
667
|
+
if not role_configured and CONFIG_FILE.exists():
|
|
668
|
+
try:
|
|
669
|
+
config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
670
|
+
saved_arn = config.get("roleArn", "")
|
|
671
|
+
saved_role = config.get("roleName", "")
|
|
672
|
+
if saved_arn:
|
|
673
|
+
log_ok(f"角色已配置(来自配置文件): {saved_role}")
|
|
674
|
+
role_configured = True
|
|
675
|
+
except (json.JSONDecodeError, IOError):
|
|
676
|
+
pass
|
|
677
|
+
|
|
678
|
+
if not role_configured:
|
|
679
|
+
role_name_env = os.environ.get("TENCENTCLOUD_ROLE_NAME", "")
|
|
680
|
+
if role_name_env:
|
|
681
|
+
log_ok(f"ROLE_NAME 已配置: {role_name_env}")
|
|
682
|
+
role_configured = True
|
|
683
|
+
|
|
684
|
+
if not role_configured:
|
|
685
|
+
log_warn("免密登录角色未配置")
|
|
686
|
+
log_info("")
|
|
687
|
+
|
|
688
|
+
uin_result = call_api(
|
|
689
|
+
"sts", "sts.tencentcloudapi.com",
|
|
690
|
+
"GetCallerIdentity", "2018-08-13", {},
|
|
691
|
+
)
|
|
692
|
+
account_uin = str(uin_result.get("data", {}).get("AccountId", ""))
|
|
693
|
+
|
|
694
|
+
if not account_uin or account_uin == "None":
|
|
695
|
+
log_fail("无法获取账号 UIN")
|
|
696
|
+
sys.exit(3)
|
|
697
|
+
|
|
698
|
+
log_info(f" 账号 UIN: {account_uin}")
|
|
699
|
+
|
|
700
|
+
log_info(f" 检查 {ADVISOR_ROLE_NAME} 角色是否存在...")
|
|
701
|
+
role_check = call_api(
|
|
702
|
+
"cam", "cam.tencentcloudapi.com",
|
|
703
|
+
"GetRole", "2019-01-16",
|
|
704
|
+
{"RoleName": ADVISOR_ROLE_NAME},
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
if role_check.get("success"):
|
|
708
|
+
console_login = role_check.get("data", {}).get("ConsoleLogin", 0)
|
|
709
|
+
if console_login == 1:
|
|
710
|
+
log_ok(f"检测到已有角色 {ADVISOR_ROLE_NAME}(支持控制台登录),自动配置")
|
|
711
|
+
computed_arn = f"qcs::cam::uin/{account_uin}:roleName/{ADVISOR_ROLE_NAME}"
|
|
712
|
+
role_id = str(role_check.get("data", {}).get("RoleId", ""))
|
|
713
|
+
save_config(account_uin, ADVISOR_ROLE_NAME, computed_arn,
|
|
714
|
+
auto_created=False, role_id=role_id)
|
|
715
|
+
log_ok(f"配置已保存到 {CONFIG_FILE}")
|
|
716
|
+
role_configured = True
|
|
717
|
+
else:
|
|
718
|
+
log_warn(f"角色 {ADVISOR_ROLE_NAME} 存在但不支持控制台登录")
|
|
719
|
+
log_info(" 尝试查找其他支持控制台登录的角色...")
|
|
720
|
+
else:
|
|
721
|
+
log_warn(f"未检测到 {ADVISOR_ROLE_NAME} 角色")
|
|
722
|
+
log_info(" 尝试查找其他支持控制台登录的角色...")
|
|
723
|
+
|
|
724
|
+
if not role_configured:
|
|
725
|
+
list_result = list_console_login_roles()
|
|
726
|
+
if list_result.get("success") and list_result.get("roles"):
|
|
727
|
+
found_role = list_result["roles"][0]
|
|
728
|
+
found_name = found_role.get("RoleName", "")
|
|
729
|
+
log_ok(f"检测到可用角色 {found_name}(支持控制台登录),自动配置")
|
|
730
|
+
computed_arn = f"qcs::cam::uin/{account_uin}:roleName/{found_name}"
|
|
731
|
+
role_id = str(found_role.get("RoleId", ""))
|
|
732
|
+
save_config(account_uin, found_name, computed_arn,
|
|
733
|
+
auto_created=False, role_id=role_id)
|
|
734
|
+
log_ok(f"配置已保存到 {CONFIG_FILE}")
|
|
735
|
+
role_configured = True
|
|
736
|
+
else:
|
|
737
|
+
log_warn("未找到任何支持控制台登录的角色")
|
|
738
|
+
log_info("")
|
|
739
|
+
log_info(" 免密登录功能需要一个支持控制台登录的 CAM 角色(可选,不影响基本功能)")
|
|
740
|
+
log_info(f" 如需启用免密登录,请执行: python3 {SCRIPT_DIR}/create_role.py")
|
|
741
|
+
|
|
742
|
+
if role_configured:
|
|
743
|
+
policy_warnings = ensure_role_policies(
|
|
744
|
+
os.environ.get("TENCENTCLOUD_ROLE_NAME", ADVISOR_ROLE_NAME)
|
|
745
|
+
)
|
|
746
|
+
if policy_warnings:
|
|
747
|
+
for w in policy_warnings:
|
|
748
|
+
log_warn(w)
|
|
749
|
+
else:
|
|
750
|
+
log_ok("角色策略检查通过")
|
|
751
|
+
|
|
752
|
+
if role_configured:
|
|
753
|
+
log_section("7. 验证角色扮演")
|
|
754
|
+
try:
|
|
755
|
+
login_url_path = SCRIPT_DIR / "login_url.py"
|
|
756
|
+
import subprocess
|
|
757
|
+
test_result = subprocess.run(
|
|
758
|
+
[sys.executable, str(login_url_path),
|
|
759
|
+
"https://console.cloud.tencent.com/advisor"],
|
|
760
|
+
capture_output=True, text=True, timeout=30,
|
|
761
|
+
)
|
|
762
|
+
try:
|
|
763
|
+
result_data = json.loads(test_result.stdout)
|
|
764
|
+
if result_data.get("success"):
|
|
765
|
+
log_ok("角色扮演验证通过,免密登录功能正常")
|
|
766
|
+
else:
|
|
767
|
+
err_msg = result_data.get("error", {}).get("message", "未知错误")
|
|
768
|
+
log_warn(f"角色扮演验证失败: {err_msg}")
|
|
769
|
+
except json.JSONDecodeError:
|
|
770
|
+
log_warn("角色扮演验证返回格式异常")
|
|
771
|
+
except Exception as e:
|
|
772
|
+
log_warn(f"角色扮演验证异常: {e}")
|
|
773
|
+
|
|
774
|
+
# ============== 检测完成 ==============
|
|
775
|
+
log_info("")
|
|
776
|
+
log_info("=== 检测完成 ===")
|
|
777
|
+
if using_connector:
|
|
778
|
+
cred_mode = "Connector 临时密钥"
|
|
779
|
+
elif using_oauth:
|
|
780
|
+
cred_mode = "OAuth 凭证"
|
|
781
|
+
else:
|
|
782
|
+
cred_mode = "AK/SK 密钥"
|
|
783
|
+
if advisor_authorized and role_configured:
|
|
784
|
+
log_ok("环境就绪,所有功能可用(智能顾问已开通 + API 查询 + 免密登录)")
|
|
785
|
+
log_info("")
|
|
786
|
+
log_info(f" [OK] Python {py_ver.major}.{py_ver.minor} ({platform.system()})")
|
|
787
|
+
log_info(f" [OK] {cred_mode}验证通过")
|
|
788
|
+
log_info(" [OK] 智能顾问已开通")
|
|
789
|
+
log_info(" [OK] 免密登录角色已配置")
|
|
790
|
+
sys.exit(0)
|
|
791
|
+
elif advisor_authorized and not role_configured:
|
|
792
|
+
log_ok("环境基本就绪(智能顾问已开通,API 查询可用)")
|
|
793
|
+
log_warn("免密登录角色未配置(仅影响免密登录链接生成,不影响 CloudQ 基本功能)")
|
|
794
|
+
log_info("")
|
|
795
|
+
log_info(f" [OK] Python {py_ver.major}.{py_ver.minor} ({platform.system()})")
|
|
796
|
+
log_info(f" [OK] {cred_mode}验证通过")
|
|
797
|
+
log_info(" [OK] 智能顾问已开通")
|
|
798
|
+
log_info(" [WARN] 免密登录角色未配置")
|
|
799
|
+
log_info("")
|
|
800
|
+
log_info(" 可选:执行角色创建步骤以启用免密登录功能")
|
|
801
|
+
log_info(f" python3 {SCRIPT_DIR}/create_role.py")
|
|
802
|
+
sys.exit(0)
|
|
803
|
+
else:
|
|
804
|
+
log_fail("环境检测未通过")
|
|
805
|
+
log_info("")
|
|
806
|
+
log_info(" 请根据上方提示完成初始化")
|
|
807
|
+
sys.exit(3)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
if __name__ == "__main__":
|
|
811
|
+
main()
|