@hupan56/wlkj 3.1.29 → 3.1.31

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/bin/cli.js CHANGED
@@ -306,12 +306,20 @@ async function doInit(name, roleArg) {
306
306
  // 源: templates/qoder/* 目标: .qoder/*
307
307
  // init 复用 update 的三路快照保护: 新装时目标不存在, 保护不触发;
308
308
  // 重跑 init 时用户改过的引擎文件 (如自定义 skill) 不会被覆盖。
309
+ // ⚠ 但如果快照缺失/损坏(如 git checkout 后), 三路保护会误保护所有文件
310
+ // → 修复: 快照缺失时, init 强制全覆盖(重新建立基线), 不保护。
311
+ const snapshotPath = path.join(cwd, ".qoder", ".engine-snapshot.json");
312
+ const hasSnapshot = fs.existsSync(snapshotPath);
313
+ if (hasExisting && !hasSnapshot) {
314
+ console.log(" [注意] 快照缺失, 引擎文件将强制全量覆盖 (重新建立基线)");
315
+ }
316
+ const useMode = (hasExisting && hasSnapshot) ? "update" : "init";
309
317
  const qoderSrc = path.join(T, "qoder");
310
318
  let copied = 0;
311
319
  if (fs.existsSync(qoderSrc)) {
312
320
  const snap = readSnapshot(cwd);
313
321
  const newSnap = {};
314
- const r = copyDirRecursive(qoderSrc, path.join(cwd, ".qoder"), "update", snap, newSnap);
322
+ const r = copyDirRecursive(qoderSrc, path.join(cwd, ".qoder"), useMode, snap, newSnap);
315
323
  copied = r.copied;
316
324
  writeSnapshot(cwd, newSnap);
317
325
  if (r.protectedN > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hupan56/wlkj",
3
- "version": "3.1.29",
3
+ "version": "3.1.31",
4
4
  "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
5
  "bin": {
6
6
  "wlkj": "bin/cli.js"
@@ -352,11 +352,11 @@ def configure_git_identity(name):
352
352
  return False
353
353
 
354
354
  # 5. 交互式收集 git 账号
355
- print('\n 配置你的团队 git 账号 (用于提交代码和团队协作):')
355
+ print('\n 配置 git 账号 (仅用于标识谁提交了代码, 不需要注册/登录):')
356
356
  while True:
357
357
  try:
358
358
  git_name = input(f' git 用户名 [{existing_name or name}]: ').strip() or existing_name or name
359
- git_email = input(' git 邮箱 (团队邮箱): ').strip()
359
+ git_email = input(f' git 邮箱 (填你的邮箱即可, 不需要注册): ').strip()
360
360
  if not git_email or '@' not in git_email:
361
361
  print(' 邮箱格式不对 (需含 @), 重试。')
362
362
  continue
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ zentao_panel.py - 禅道任务面板数据接口 (供 VS Code 扩展 zentao-panel 调用)
5
+
6
+ 目的: 给侧边栏面板一个稳定的 JSON 数据源, 一次返回"指派给我的【任务+需求+Bug】",
7
+ 跨所有执行/产品全量聚合。逻辑与 my_workbench 工具一致 (只读), 但输出 JSON 而非文本。
8
+
9
+ 为什么不直接调 MCP server:
10
+ MCP server 的数据收集依赖 import-time 全局变量 (CLIENT / MY_ACCOUNT), 由 mcp_launcher
11
+ 注入环境变量才生效。本脚本走独立进程 (child_process.spawn 启动), 没有 launcher 注入,
12
+ 所以照搬 zentao_sync.py 的稳健模式: 自己 ZentaoClient.from_config() + 自己解析归属。
13
+ 收集循环则与 zentao_mcp_server.py:_collect_my_* 逐字一致 (DRY 同款), 保证数据口径统一。
14
+
15
+ 用法:
16
+ python .qoder/scripts/domain/task/zentao_panel.py --json # 三域活动项 JSON
17
+ python .qoder/scripts/domain/task/zentao_panel.py --json --include-closed # 含已关闭历史项
18
+ python .qoder/scripts/domain/task/zentao_panel.py --json --kind tasks # 只看任务
19
+
20
+ 退出码 (对齐项目规范):
21
+ 0 成功 (含空结果); 3 配置/归属错误; 5 网络不通。
22
+ --json 模式下错误也输出 JSON ({"ok": false, "error": "..."}), 扩展端统一解析。
23
+ """
24
+ import os
25
+ import sys
26
+
27
+ # ── 路径自举 (复用 bootstrap 模式, 与 zentao_sync.py 完全一致) ──
28
+ # 本脚本在 scripts/domain/task/ 下 (scripts 下 2 级), dirname 两次才到 scripts/。
29
+ _HERE = os.path.dirname(os.path.abspath(__file__))
30
+ _SCRIPTS = os.path.dirname(os.path.dirname(_HERE)) # .qoder/scripts (上两级)
31
+ _COMMON = os.path.join(_SCRIPTS, 'foundation')
32
+ for _p in (_SCRIPTS, _COMMON):
33
+ if _p not in sys.path:
34
+ sys.path.insert(0, _p)
35
+ try:
36
+ from bootstrap import setup; setup()
37
+ except Exception:
38
+ pass
39
+
40
+ import json
41
+ import argparse
42
+ from datetime import datetime
43
+
44
+ from foundation.core.paths import get_developer
45
+ from foundation.identity.identity import get_member
46
+ from foundation.integrations.zentao_client import ZentaoClient
47
+
48
+
49
+ # ============================================================
50
+ # 配置与归属解析 (照搬 zentao_sync.py, 唯一真相源)
51
+ # ============================================================
52
+
53
+ def _zentao_creds():
54
+ """构建禅道客户端。凭据不全返回 None。
55
+ url 来自 .qoder/config.yaml (团队共享); user/password 来自 secrets/zentao.env (个人)。"""
56
+ return ZentaoClient.from_config()
57
+
58
+
59
+ def _resolve_my_account(developer):
60
+ """解析当前 developer → 禅道 account。返回 (account, 错误原因)。"""
61
+ mem = get_member(developer)
62
+ if not mem:
63
+ return None, '当前开发者 %s 未注册 (无 member.json)。请先跑 /wl-init。' % developer
64
+ acc = (mem.get('zentao_account') or '').strip()
65
+ if not acc:
66
+ return None, ('%s 的 member.json 未配 zentao_account。\n'
67
+ ' 编辑 workspace/members/%s/member.json 加 "zentao_account": "你的禅道账号"。'
68
+ % (developer, developer))
69
+ return acc, None
70
+
71
+
72
+ # ============================================================
73
+ # 收集循环 (与 zentao_mcp_server.py:_collect_my_* 逐字一致)
74
+ # 单独写成函数, 不依赖 self / 全局 CLIENT, 接收 client 做参数 → 可独立调用。
75
+ # ============================================================
76
+
77
+ def _paged_list(client, path, params, list_key, page_size=100, max_pages=20):
78
+ """分页拉全量 (循环 page+limit, 按 id 去重)。照搬 mcp server 同名方法。
79
+
80
+ Returns: (合并去重列表, 是否截断[读满 max_pages 仍未到尾])。
81
+ """
82
+ seen, all_items = set(), []
83
+ truncated = False
84
+ for _page in range(1, max_pages + 1):
85
+ p = dict(params or {})
86
+ p.update({'limit': page_size, 'page': _page})
87
+ r = client.api('GET', path, p)
88
+ if r.status_code not in (200, 201):
89
+ break
90
+ data = r.json()
91
+ items = data.get(list_key) if isinstance(data, dict) else None
92
+ if not isinstance(items, list) or not items:
93
+ break
94
+ new = 0
95
+ for it in items:
96
+ if not isinstance(it, dict):
97
+ continue
98
+ key = it.get('id', id(it))
99
+ if key in seen:
100
+ continue
101
+ seen.add(key)
102
+ all_items.append(it)
103
+ new += 1
104
+ if new == 0:
105
+ break
106
+ return all_items, truncated
107
+
108
+
109
+ def _collect_my_tasks(client, my_account, status_filter=None):
110
+ """扫所有执行, 收集指派给我的任务。返回 (任务列表, None) 或 (None, 错误)。"""
111
+ execs, _ = _paged_list(client, '/executions', None, 'executions')
112
+ if execs is None:
113
+ return None, '查询执行列表失败, 稍后重试。'
114
+ all_t = []
115
+ for e in execs:
116
+ eid = e.get('id')
117
+ if eid is None:
118
+ continue
119
+ ename = str(e.get('name', ''))[:20]
120
+ tasks, _ = _paged_list(client, '/executions/%s/tasks' % eid, None, 'tasks')
121
+ for t in tasks:
122
+ if not isinstance(t, dict):
123
+ continue
124
+ if ZentaoClient.assignee_account(t) != my_account:
125
+ continue
126
+ if status_filter and t.get('status') != status_filter:
127
+ continue
128
+ t2 = dict(t)
129
+ t2['execution'] = eid
130
+ t2['execution_name'] = ename
131
+ all_t.append(t2)
132
+ return all_t, None
133
+
134
+
135
+ def _collect_my_bugs(client, my_account):
136
+ """扫活跃产品, 收集我的 Bug (指派给我 OR 我提的)。"""
137
+ prods, _ = _paged_list(client, '/products', None, 'products')
138
+ mine = []
139
+ for p in prods:
140
+ pid = p.get('id')
141
+ bugs, _ = _paged_list(client, '/products/%s/bugs' % pid, None, 'bugs')
142
+ for b in bugs:
143
+ if not isinstance(b, dict):
144
+ continue
145
+ asg = ZentaoClient.assignee_account(b)
146
+ opn = ZentaoClient.openedby_account(b)
147
+ why = '指派给我' if asg == my_account else ('我提的' if opn == my_account else None)
148
+ if why:
149
+ b2 = dict(b)
150
+ b2['product_id'] = pid
151
+ b2['_why'] = why
152
+ mine.append(b2)
153
+ return mine, None
154
+
155
+
156
+ def _collect_my_stories(client, my_account):
157
+ """扫活跃产品, 收集我的需求 (指派给我 OR 我提的)。"""
158
+ prods, _ = _paged_list(client, '/products', None, 'products')
159
+ mine = []
160
+ for p in prods:
161
+ pid = p.get('id')
162
+ sts, _ = _paged_list(client, '/products/%s/stories' % pid, None, 'stories')
163
+ for s in sts:
164
+ if not isinstance(s, dict):
165
+ continue
166
+ asg = ZentaoClient.assignee_account(s)
167
+ opn = ZentaoClient.openedby_account(s)
168
+ why = '指派给我' if asg == my_account else ('我提的' if opn == my_account else None)
169
+ if why:
170
+ s2 = dict(s)
171
+ s2['product_id'] = pid
172
+ s2['_why'] = why
173
+ mine.append(s2)
174
+ return mine, None
175
+
176
+
177
+ # ============================================================
178
+ # 归一化: 把禅道原始 dict 裁成面板需要的精简字段 (减体积 + 隐藏内部字段)
179
+ # ============================================================
180
+
181
+ def _norm_task(t):
182
+ return {
183
+ 'id': t.get('id'),
184
+ 'name': str(t.get('name', '')),
185
+ 'status': t.get('status', ''),
186
+ 'pri': t.get('pri'),
187
+ 'left': t.get('left'), # 剩余工时
188
+ 'estimate': t.get('estimate'),
189
+ 'consumed': t.get('consumed'),
190
+ 'deadline': t.get('deadline'),
191
+ 'execution': t.get('execution'),
192
+ 'execution_name': t.get('execution_name', ''),
193
+ }
194
+
195
+
196
+ def _norm_bug(b):
197
+ return {
198
+ 'id': b.get('id'),
199
+ 'title': str(b.get('title', '')),
200
+ 'status': b.get('status', ''),
201
+ 'severity': b.get('severity'),
202
+ 'pri': b.get('pri'),
203
+ 'why': b.get('_why', ''), # 指派给我 / 我提的
204
+ }
205
+
206
+
207
+ def _norm_story(s):
208
+ return {
209
+ 'id': s.get('id'),
210
+ 'title': str(s.get('title', '')),
211
+ 'status': s.get('status', ''),
212
+ 'pri': s.get('pri'),
213
+ 'why': s.get('_why', ''),
214
+ }
215
+
216
+
217
+ # 活动态白名单 (与 my_workbench 一致 + 修正: changed 也是活动态)。
218
+ # 说明: 禅道 task 有 changed 状态 (需求变更/任务被改动), 旧版 LIVE_TASK 漏掉它
219
+ # → 用户实测"明明有个进行中的任务却看不到"。changed 不是终态, 应算活动项。
220
+ LIVE_TASK = {'wait', 'doing', 'pause', 'changed'}
221
+ LIVE_BUG = {'active'}
222
+ LIVE_STORY = {'active', 'reviewing', 'changing'}
223
+
224
+
225
+ def _filter_live(tasks, bugs, stories, include_closed):
226
+ """过滤终态 (除非 include_closed)。"""
227
+ if include_closed:
228
+ return tasks, bugs, stories
229
+ return ([t for t in tasks if t.get('status') in LIVE_TASK],
230
+ [b for b in bugs if b.get('status') in LIVE_BUG],
231
+ [s for s in stories if s.get('status') in LIVE_STORY])
232
+
233
+
234
+ # ============================================================
235
+ # 主流程
236
+ # ============================================================
237
+
238
+ def fetch_workbench(include_closed=False, kind=None):
239
+ """收集三域并归一化。返回 (result_dict, exit_code)。
240
+
241
+ Args:
242
+ include_closed: True=含已关闭历史项
243
+ kind: 'tasks'/'bugs'/'stories' 只取一域; None=全取
244
+ Returns:
245
+ ({"ok": True, "data": {...}, "meta": {...}}, 0) 或
246
+ ({"ok": False, "error": "..."}, 3 或 5)
247
+ """
248
+ developer = get_developer()
249
+
250
+ # ① 凭据
251
+ client = _zentao_creds()
252
+ if client is None:
253
+ return {'ok': False,
254
+ 'error': '禅道凭据未配置。请检查 .qoder/config.yaml 的 zentao.url, '
255
+ '及 workspace/members/<我>/.private/secrets/zentao.env 的账号密码。'}, 3
256
+
257
+ ok, msg = client.credentials_ok()
258
+ if not ok:
259
+ return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
260
+
261
+ # ② 内网连通
262
+ ok, msg = client.check_intranet()
263
+ if not ok:
264
+ return {'ok': False, 'error': '连不上禅道内网: %s' % msg}, 5
265
+
266
+ # ③ 归属
267
+ my_account, err = _resolve_my_account(developer) if developer else (None, '未识别当前开发者身份。')
268
+ if not my_account:
269
+ return {'ok': False, 'error': err}, 3
270
+
271
+ # ④ 收集 (按 kind 决定取哪几域)
272
+ tasks, bugs, stories = [], [], []
273
+ if kind in (None, 'tasks'):
274
+ tasks, e = _collect_my_tasks(client, my_account)
275
+ if e:
276
+ return {'ok': False, 'error': e}, 5
277
+ if kind in (None, 'bugs'):
278
+ bugs, _ = _collect_my_bugs(client, my_account)
279
+ if kind in (None, 'stories'):
280
+ stories, _ = _collect_my_stories(client, my_account)
281
+
282
+ # ⑤ 过滤 + 归一
283
+ tasks, bugs, stories = _filter_live(tasks, bugs, stories, include_closed)
284
+ data = {
285
+ 'account': my_account,
286
+ 'developer': developer,
287
+ 'tasks': [_norm_task(t) for t in tasks],
288
+ 'bugs': [_norm_bug(b) for b in bugs],
289
+ 'stories': [_norm_story(s) for s in stories],
290
+ }
291
+ meta = {
292
+ 'fetched_at': datetime.now().isoformat(timespec='seconds'),
293
+ 'counts': {
294
+ 'tasks': len(data['tasks']),
295
+ 'bugs': len(data['bugs']),
296
+ 'stories': len(data['stories']),
297
+ },
298
+ 'include_closed': include_closed,
299
+ }
300
+ return {'ok': True, 'data': data, 'meta': meta}, 0
301
+
302
+
303
+ # ============================================================
304
+ # 写操作 (v0.3): 面板按钮触发的禅道状态流转
305
+ # 设计对齐 MCP server 的 _assert_owner: 写前校验归属, 防误改队友。
306
+ # 任务: start/pause/finish/log_effort; Bug: confirm/resolve
307
+ # ============================================================
308
+
309
+ def _check_owner(client, my_account, kind, item_id):
310
+ """写前归属校验 (对齐 mcp server _assert_owner)。
311
+ task 只认 assignedTo==我; bug 认 assignedTo==我 OR openedBy==我。
312
+ 返回 None=放行, 字符串=拒绝原因。"""
313
+ r = client.api('GET', '/%ss/%s' % (kind, item_id))
314
+ if r.status_code not in (200, 201):
315
+ return None # 查询失败不阻塞 (禅道权限兜底)
316
+ data = r.json()
317
+ asg = ZentaoClient.assignee_account(data)
318
+ allow = (asg == my_account)
319
+ if kind == 'bug' and not allow:
320
+ allow = (ZentaoClient.openedby_account(data) == my_account)
321
+ if not allow:
322
+ return '这条%s指派给 %s, 不是你(%s), 已拒绝改。' % (kind, asg or '空', my_account)
323
+ return None
324
+
325
+
326
+ # 禅道写操作的 HTTP 调度表: action -> (method, path_tpl, payload_builder, ok_msg)
327
+ # payload_builder(args_dict) -> payload_dict
328
+ def _do_action(client, my_account, action, item_id, params):
329
+ """执行单个写操作。返回 (result_dict, exit_code)。"""
330
+ developer = get_developer()
331
+ my_account = my_account or (_resolve_my_account(developer)[0] if developer else None)
332
+ if not my_account:
333
+ return {'ok': False, 'error': '未识别禅道身份, 无法校验归属。请配 member.json 的 zentao_account。'}, 3
334
+
335
+ # action 分派
336
+ ACTIONS = {
337
+ # 任务类 (kind=task)
338
+ 'start_task': ('task', 'POST', '/tasks/%s/start', {'left': 'left'}),
339
+ 'pause_task': ('task', 'POST', '/tasks/%s/pause', {}),
340
+ 'finish_task': ('task', 'POST', '/tasks/%s/finish', {'left': 'left', 'consumed': 'consumed'}),
341
+ 'log_effort': ('task', 'POST', '/tasks/%s/estimate', {'consumed': 'consumed', 'left': 'left'}),
342
+ # Bug 类 (kind=bug)
343
+ 'confirm_bug': ('bug', 'POST', '/bugs/%s/confirm', {}),
344
+ 'resolve_bug': ('bug', 'POST', '/bugs/%s/resolve', {'resolution': 'resolution', 'comment': 'comment'}),
345
+ }
346
+ if action not in ACTIONS:
347
+ return {'ok': False, 'error': '未知操作: %s。支持: %s' % (action, ', '.join(ACTIONS.keys()))}, 3
348
+
349
+ kind, method, path_tpl, fields = ACTIONS[action]
350
+ path = path_tpl % item_id
351
+
352
+ # ① 归属校验 (防误改队友)
353
+ guard = _check_owner(client, my_account, kind, item_id)
354
+ if guard:
355
+ return {'ok': False, 'error': guard}, 4
356
+
357
+ # ② 拼 payload (只取该操作关心的字段, 缺省给合理默认)
358
+ payload = {}
359
+ for pkey, fkey in fields.items():
360
+ v = params.get(fkey)
361
+ if v is not None and v != '':
362
+ try:
363
+ payload[pkey] = float(v) if fkey in ('left', 'consumed') else v
364
+ except (ValueError, TypeError):
365
+ payload[pkey] = v
366
+ # finish 强制 left=0 (完成=没剩余工时); log_effort 的 consumed 必填兜底
367
+ if action == 'finish_task' and 'left' not in payload:
368
+ payload['left'] = 0
369
+ if action == 'resolve_bug' and 'resolution' not in payload:
370
+ payload['resolution'] = 'fixed'
371
+
372
+ # ③ 调禅道
373
+ r = client.api(method, path, payload)
374
+ if r.status_code in (200, 201):
375
+ label = {
376
+ 'start_task': '已开始', 'pause_task': '已暂停', 'finish_task': '已完成',
377
+ 'log_effort': '已记工时', 'confirm_bug': '已确认', 'resolve_bug': '已解决',
378
+ }[action]
379
+ return {'ok': True, 'data': {'action': action, 'id': item_id, 'message': '%s #%s %s' % (kind, item_id, label)}}, 0
380
+ return {'ok': False, 'error': '操作失败 (HTTP %s): %s。可能权限不足或当前状态不允许。' % (
381
+ r.status_code, ZentaoClient.err_text(r))}, 5
382
+
383
+
384
+ def perform_action(action, item_id, params=None):
385
+ """写操作入口。返回 (result_dict, exit_code)。"""
386
+ params = params or {}
387
+ client = _zentao_creds()
388
+ if client is None:
389
+ return {'ok': False, 'error': '禅道凭据未配置。'}, 3
390
+ ok, msg = client.credentials_ok()
391
+ if not ok:
392
+ return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
393
+ ok, msg = client.check_intranet()
394
+ if not ok:
395
+ return {'ok': False, 'error': '连不上禅道内网: %s' % msg}, 5
396
+ developer = get_developer()
397
+ my_account, err = _resolve_my_account(developer) if developer else (None, '未识别开发者身份')
398
+ if err and not my_account:
399
+ return {'ok': False, 'error': err}, 3
400
+ return _do_action(client, my_account, action, item_id, params)
401
+
402
+
403
+ def main():
404
+ parser = argparse.ArgumentParser(description='禅道任务面板数据接口 (输出 JSON 供 VS Code 扩展消费)')
405
+ parser.add_argument('--json', action='store_true', help='JSON 输出 (面板默认用这个)')
406
+ parser.add_argument('--include-closed', action='store_true', help='包含已关闭/取消的历史项 (默认只看活动项)')
407
+ parser.add_argument('--kind', choices=['tasks', 'bugs', 'stories'],
408
+ help='只取某一域 (默认三域全取)')
409
+ parser.add_argument('--pretty', action='store_true', help='JSON 美化缩进 (默认紧凑, 面板解析更快)')
410
+ # 写操作子命令 (v0.3)
411
+ parser.add_argument('--action', choices=['start_task', 'pause_task', 'finish_task',
412
+ 'log_effort', 'confirm_bug', 'resolve_bug'],
413
+ help='执行禅道写操作 (面板按钮触发)')
414
+ parser.add_argument('--id', type=int, help='写操作的目标 task/bug ID')
415
+ parser.add_argument('--left', help='剩余工时 (start/finish/log_effort)')
416
+ parser.add_argument('--consumed', help='消耗工时 (finish/log_effort)')
417
+ parser.add_argument('--resolution', help='Bug 解决方式 (resolve_bug): fixed/bydesign/duplicate/notrepro/postpone')
418
+ parser.add_argument('--comment', help='Bug 解决备注 (resolve_bug)')
419
+ args = parser.parse_args()
420
+
421
+ # 写操作分支
422
+ if args.action:
423
+ if not args.id:
424
+ result = {'ok': False, 'error': '写操作必须提供 --id'}
425
+ code = 3
426
+ else:
427
+ params = {'left': args.left, 'consumed': args.consumed,
428
+ 'resolution': args.resolution, 'comment': args.comment}
429
+ result, code = perform_action(args.action, args.id, params)
430
+ else:
431
+ result, code = fetch_workbench(include_closed=args.include_closed, kind=args.kind)
432
+
433
+ if args.json or not sys.stdout.isatty():
434
+ indent = 2 if args.pretty else None
435
+ print(json.dumps(result, ensure_ascii=False, default=str, indent=indent))
436
+ else:
437
+ if result.get('ok'):
438
+ if args.action:
439
+ print('✅ %s' % result['data'].get('message', '操作成功'))
440
+ else:
441
+ d, m = result['data'], result['meta']
442
+ print('📋 %s 的禅道工作台 — 任务 %d · 需求 %d · Bug %d (更新于 %s)' % (
443
+ d['account'], m['counts']['tasks'], m['counts']['stories'], m['counts']['bugs'],
444
+ m['fetched_at']))
445
+ else:
446
+ print('❌ %s' % result.get('error', '未知错误'))
447
+ sys.exit(code)
448
+
449
+
450
+ if __name__ == '__main__':
451
+ main()