@szc-ft/mcp-szcd-client 0.24.0 → 0.26.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.
Files changed (34) hide show
  1. package/agents/build.js +22 -12
  2. package/agents/opencode-extension/agents/szcd-component-expert.md +235 -49
  3. package/agents/qwen-extension/agents/szcd-component-expert.md +235 -49
  4. package/agents/src/szcd-component-expert.md +262 -49
  5. package/agents/src/tools.json +1 -1
  6. package/agents/szcd-component-expert.md +235 -49
  7. package/agents/szcd-component-expert.qoder.md +261 -50
  8. package/agents/szcd-component-expert.trae.md +260 -49
  9. package/opencode-extension/agents/szcd-component-expert.md +235 -49
  10. package/opencode-extension/skills/local-api-tool/SKILL.md +296 -60
  11. package/opencode-extension/skills/local-api-tool/scripts/extract-swagger.mjs +625 -0
  12. package/opencode-extension/skills/local-api-tool/scripts/extract_swagger.py +593 -0
  13. package/opencode-extension/skills/szcd-component-helper/SKILL.md +7 -4
  14. package/opencode-extension/skills/szcd-design-to-code/SKILL.md +267 -25
  15. package/package.json +1 -1
  16. package/qwen-extension/agents/szcd-component-expert.md +235 -49
  17. package/qwen-extension/qwen-extension.json +1 -1
  18. package/qwen-extension/skills/local-api-tool/SKILL.md +296 -60
  19. package/qwen-extension/skills/local-api-tool/scripts/extract-swagger.mjs +625 -0
  20. package/qwen-extension/skills/local-api-tool/scripts/extract_swagger.py +593 -0
  21. package/qwen-extension/skills/szcd-component-helper/SKILL.md +7 -4
  22. package/qwen-extension/skills/szcd-design-to-code/SKILL.md +267 -25
  23. package/scripts/lib/claude-code.js +1 -1
  24. package/scripts/lib/common.js +44 -0
  25. package/scripts/lib/opencode.js +1 -1
  26. package/scripts/lib/qoder.js +1 -1
  27. package/scripts/lib/trae-cli.js +1 -1
  28. package/scripts/lib/trae-ide.js +1 -1
  29. package/scripts/postinstall.js +1 -0
  30. package/standard-skill/local-api-tool/SKILL.md +296 -60
  31. package/standard-skill/local-api-tool/scripts/extract-swagger.mjs +625 -0
  32. package/standard-skill/local-api-tool/scripts/extract_swagger.py +593 -0
  33. package/standard-skill/szcd-component-helper/SKILL.md +7 -4
  34. package/standard-skill/szcd-design-to-code/SKILL.md +267 -25
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ extract_swagger.py — 本地 Swagger 文档提取与解析(与服务端 parseApiDocs 一比一对齐)
4
+
5
+ 设计目标:
6
+ - 一步完成"下载 → 解析 → 按 tag/path 过滤 → 关联 DTO definitions → 输出"
7
+ - 避免 AI 在客户端用 curl|head 截断 JSON / 写临时 python 脚本踩花括号坑
8
+ - 输出结构与服务端 api_tool(action="parse_swagger_json") 完全一致,可平替
9
+
10
+ 参考实现:szcd-mcp-server/src/lib/swagger.js 的 parseApiDocs + server.js 的 filterApis
11
+ 递归深度 5,两层 definitions(summary + detail),引用树传递闭包裁剪。
12
+
13
+ 用法:
14
+ # 方式 A:脚本内部 curl 下载(自动缓存到 /tmp/swagger-cache/,TTL 10 分钟)
15
+ extract_swagger.py --url http://10.x.x.x/svc/v2/api-docs [--cookie 'JSESSIONID=xxx']
16
+ extract_swagger.py --url ... --no-cache # 强制重新下载
17
+ extract_swagger.py --url ... --cache-ttl 3600 # 自定义 TTL(秒),0 关闭缓存
18
+
19
+ # 方式 B:读取本地已下载的 JSON 文件
20
+ extract_swagger.py --file /tmp/swagger.json
21
+
22
+ # 过滤(对 url/summary/operationId/tag/description/dtoRef/responseVoName 模糊匹配,大小写不敏感)
23
+ extract_swagger.py --file /tmp/swagger.json --filter tag
24
+ extract_swagger.py --file /tmp/swagger.json --filter tag --filter user
25
+
26
+ # 仅查询单个 definition(兜底单 VO 查询,等价于服务端 get_definition)
27
+ extract_swagger.py --file /tmp/swagger.json --definition KnowledgeOperLogVO
28
+
29
+ # 输出到文件而非 stdout
30
+ extract_swagger.py --file /tmp/swagger.json --filter tag --out /tmp/parsed.json
31
+
32
+ 容错:
33
+ - 自动修复八进制字面量(如 [011,015] → [11,15]),无需手动预处理
34
+ - JSON 解析失败给出错误位置上下文 + 常见原因提示(HTML 跳转/截断/八进制)
35
+
36
+ 跨平台:
37
+ - Linux/macOS:`python3 extract_swagger.py ...`(脚本 shebang 已带 #!/usr/bin/env python3)
38
+ - Windows:`python extract_swagger.py ...`(python3 命令可能不可用)
39
+
40
+ 退出码:0 成功;1 参数错误;2 网络/IO 错误;3 解析错误
41
+ """
42
+
43
+ import argparse
44
+ import hashlib
45
+ import json
46
+ import os
47
+ import re
48
+ import sys
49
+ import subprocess
50
+ import tempfile
51
+ import time
52
+ from pathlib import Path
53
+
54
+ MAX_DEPTH = 5
55
+ METHOD_KEYS = ["post", "get", "put", "delete", "patch"]
56
+
57
+ # 缓存配置(与 Node 版 extract-swagger.mjs 完全一致:共用同一目录 + sha256 key)
58
+ CACHE_DIR = Path(tempfile.gettempdir()) / "swagger-cache"
59
+ DEFAULT_CACHE_TTL = 600 # 10 分钟
60
+
61
+ # 八进制字面量:JSON 标准不允许数字以 0 开头(除 0 本身或小数),
62
+ # 但部分 Java/旧网关序列化会产出 [011,015,022] 这类数组。
63
+ # 匹配 JSON value 位置上的非法前导零数字(不含小数、不含 0 单独存在)。
64
+ OCTAL_LITERAL_RE = re.compile(r'(?<=[\s,\[:])0(\d+)(?=[\s,\]}])')
65
+
66
+
67
+ # ==================== 工具函数 ====================
68
+
69
+ def _ref_name(prop):
70
+ """从 property 定义中提取 $ref/originalRef 名称(不含数组 items 层)。"""
71
+ if not isinstance(prop, dict):
72
+ return None
73
+ if prop.get("originalRef"):
74
+ return prop["originalRef"]
75
+ ref = prop.get("$ref")
76
+ if ref:
77
+ return ref.rsplit("/", 1)[-1]
78
+ return None
79
+
80
+
81
+ def _ref_name_with_items(prop):
82
+ """提取 $ref 名称,包含数组 items 层(对齐 JS 端 expandProperties 第 250-251 行)。"""
83
+ if not isinstance(prop, dict):
84
+ return None
85
+ name = _ref_name(prop)
86
+ if name:
87
+ return name
88
+ items = prop.get("items")
89
+ if isinstance(items, dict):
90
+ return _ref_name(items)
91
+ return None
92
+
93
+
94
+ def _resolve_property_type(prop):
95
+ """对齐 JS 端 resolvePropertyType:数组返回 array<XXX>,否则返回 type 或 $ref 名。"""
96
+ if not isinstance(prop, dict):
97
+ return "string"
98
+ if prop.get("type") == "array":
99
+ items = prop.get("items") or {}
100
+ item_type = (
101
+ items.get("type")
102
+ or items.get("originalRef")
103
+ or (items.get("$ref", "").rsplit("/", 1)[-1] if items.get("$ref") else None)
104
+ or "object"
105
+ )
106
+ return f"array<{item_type}>"
107
+ if prop.get("type"):
108
+ return prop["type"]
109
+ if prop.get("originalRef"):
110
+ return prop["originalRef"]
111
+ if prop.get("$ref"):
112
+ return prop["$ref"].rsplit("/", 1)[-1]
113
+ return "object"
114
+
115
+
116
+ # ==================== 递归展开(对齐 JS 端 expandProperties,深度 5) ====================
117
+
118
+ def expand_properties(properties, definitions, depth=0, max_depth=MAX_DEPTH):
119
+ if not properties:
120
+ return []
121
+ result = []
122
+ for name, prop in properties.items():
123
+ if not isinstance(prop, dict):
124
+ prop = {}
125
+ entry = {
126
+ "name": name,
127
+ "type": _resolve_property_type(prop),
128
+ "description": prop.get("description", ""),
129
+ }
130
+ if "format" in prop:
131
+ entry["format"] = prop["format"]
132
+ if "example" in prop:
133
+ entry["example"] = prop["example"]
134
+ if "enum" in prop:
135
+ entry["enum"] = prop["enum"]
136
+
137
+ ref_name = _ref_name_with_items(prop)
138
+ if ref_name:
139
+ entry["refName"] = ref_name
140
+
141
+ # 数组类型:展开 items 的嵌套属性
142
+ if prop.get("type") == "array" and ref_name and depth < max_depth:
143
+ item_def = (definitions or {}).get(ref_name)
144
+ if isinstance(item_def, dict) and item_def.get("properties"):
145
+ entry["itemProperties"] = expand_properties(
146
+ item_def["properties"], definitions, depth + 1, max_depth
147
+ )
148
+
149
+ # 对象类型(非数组):展开嵌套属性
150
+ if prop.get("type") == "object" and ref_name and depth < max_depth:
151
+ nested_def = (definitions or {}).get(ref_name)
152
+ if isinstance(nested_def, dict) and nested_def.get("properties"):
153
+ entry["nestedProperties"] = expand_properties(
154
+ nested_def["properties"], definitions, depth + 1, max_depth
155
+ )
156
+
157
+ # 无 $ref 的 object 且有自身 properties(内联定义)
158
+ if (
159
+ prop.get("type") == "object"
160
+ and not ref_name
161
+ and prop.get("properties")
162
+ and depth < max_depth
163
+ ):
164
+ entry["nestedProperties"] = expand_properties(
165
+ prop["properties"], definitions, depth + 1, max_depth
166
+ )
167
+
168
+ result.append(entry)
169
+ return result
170
+
171
+
172
+ # ==================== 主解析(对齐 JS 端 parseApiDocs) ====================
173
+
174
+ def parse_api_docs(api_docs, referenced_defs=None):
175
+ paths = api_docs.get("paths") or {}
176
+ tags = api_docs.get("tags") or []
177
+ definitions = api_docs.get("definitions") or {}
178
+ info = api_docs.get("info") or {}
179
+ api_base_path = api_docs.get("basePath") or ""
180
+
181
+ parsed_tags = [
182
+ {"name": t.get("name"), "description": t.get("description", "")} for t in tags
183
+ ]
184
+
185
+ parsed_apis = []
186
+ auto_referenced_defs = set()
187
+
188
+ for url, path_value in paths.items():
189
+ if not isinstance(path_value, dict):
190
+ continue
191
+ for method in METHOD_KEYS:
192
+ api_def = path_value.get(method)
193
+ if not isinstance(api_def, dict):
194
+ continue
195
+ tag = (api_def.get("tags") or ["default"])[0]
196
+ parameters = api_def.get("parameters") or []
197
+
198
+ params = []
199
+ for p in parameters:
200
+ schema = p.get("schema") or {}
201
+ param_info = {
202
+ "name": p.get("name"),
203
+ "in": p.get("in"),
204
+ "required": p.get("required", False),
205
+ "type": p.get("type") or schema.get("type") or "string",
206
+ "description": p.get("description", ""),
207
+ }
208
+ # body 参数引用的 DTO
209
+ ref_name = schema.get("originalRef") or (
210
+ schema.get("$ref", "").rsplit("/", 1)[-1] if schema.get("$ref") else None
211
+ )
212
+ if ref_name:
213
+ param_info["dtoRef"] = ref_name
214
+ auto_referenced_defs.add(ref_name)
215
+ dto = definitions.get(ref_name)
216
+ if isinstance(dto, dict) and dto.get("properties"):
217
+ dto_props = expand_properties(dto["properties"], definitions, 0, MAX_DEPTH)
218
+ required_fields = dto.get("required") or []
219
+ for prop in dto_props:
220
+ if prop["name"] in required_fields:
221
+ prop["required"] = True
222
+ param_info["dtoProperties"] = dto_props
223
+ params.append(param_info)
224
+
225
+ # 响应(递归展开嵌套 VO)
226
+ response_info = None
227
+ responses = api_def.get("responses") or {}
228
+ r200 = responses.get("200") or {}
229
+ r_schema = r200.get("schema") if isinstance(r200, dict) else None
230
+ if isinstance(r_schema, dict):
231
+ ref_name = r_schema.get("originalRef") or (
232
+ r_schema.get("$ref", "").rsplit("/", 1)[-1] if r_schema.get("$ref") else None
233
+ )
234
+ if ref_name:
235
+ auto_referenced_defs.add(ref_name)
236
+ vo = definitions.get(ref_name)
237
+ if isinstance(vo, dict) and vo.get("properties"):
238
+ response_info = {
239
+ "voName": ref_name,
240
+ "properties": expand_properties(
241
+ vo["properties"], definitions, 0, MAX_DEPTH
242
+ ),
243
+ }
244
+ else:
245
+ response_info = {"voName": ref_name}
246
+
247
+ parsed_apis.append({
248
+ "url": url,
249
+ "method": method.upper(),
250
+ "tag": tag,
251
+ "summary": api_def.get("summary", ""),
252
+ "description": api_def.get("description", ""),
253
+ "deprecated": api_def.get("deprecated", False),
254
+ "parameters": params,
255
+ "response": response_info,
256
+ "operationId": api_def.get("operationId"),
257
+ })
258
+
259
+ # 收集被引用 definitions 的传递闭包
260
+ collected_refs = set(auto_referenced_defs)
261
+ queue = list(auto_referenced_defs)
262
+ while queue:
263
+ name = queue.pop(0)
264
+ d = definitions.get(name)
265
+ if not isinstance(d, dict) or not d.get("properties"):
266
+ continue
267
+ for prop in d["properties"].values():
268
+ ref_name = _ref_name_with_items(prop)
269
+ if ref_name and ref_name in definitions and ref_name not in collected_refs:
270
+ collected_refs.add(ref_name)
271
+ queue.append(ref_name)
272
+
273
+ # definitions 摘要层:所有 VO/DTO 的 name + type + description
274
+ definitions_summary = [
275
+ {
276
+ "name": name,
277
+ "type": d.get("type", "object") if isinstance(d, dict) else "object",
278
+ "description": (d.get("description") or d.get("title") or "") if isinstance(d, dict) else "",
279
+ }
280
+ for name, d in definitions.items()
281
+ ]
282
+
283
+ # definitionsDetail 详情层:仅展开被引用的(含传递闭包),递归深度 5
284
+ effective_refs = referenced_defs if referenced_defs is not None else collected_refs
285
+ definitions_detail = []
286
+ for name in effective_refs:
287
+ d = definitions.get(name)
288
+ if isinstance(d, dict):
289
+ definitions_detail.append({
290
+ "name": name,
291
+ "type": d.get("type", "object"),
292
+ "description": d.get("description") or d.get("title") or "",
293
+ "properties": expand_properties(
294
+ d.get("properties") or {}, definitions, 0, MAX_DEPTH
295
+ ),
296
+ })
297
+
298
+ return {
299
+ "info": {
300
+ "title": info.get("title"),
301
+ "version": info.get("version"),
302
+ "description": info.get("description"),
303
+ } if info else None,
304
+ "basePath": api_base_path,
305
+ "tags": parsed_tags,
306
+ "apis": parsed_apis,
307
+ "definitions": definitions_summary,
308
+ "definitionsDetail": definitions_detail,
309
+ }
310
+
311
+
312
+ # ==================== 过滤(对齐 JS 端 filterApis + apiFilter 裁剪 definitionsDetail) ====================
313
+
314
+ def filter_apis(apis, keywords):
315
+ """对齐 JS 端 filterApis:对 url/summary/operationId/tag/description/dtoRef/voName 模糊匹配。"""
316
+ if not keywords or not apis:
317
+ return apis
318
+ lower_keywords = [k.lower() for k in keywords]
319
+ out = []
320
+ for api in apis:
321
+ dto_refs = " ".join(p.get("dtoRef", "") for p in (api.get("parameters") or []) if p.get("dtoRef"))
322
+ response_vo = (api.get("response") or {}).get("voName", "") if api.get("response") else ""
323
+ searchable = " ".join([
324
+ api.get("url", ""),
325
+ api.get("summary", ""),
326
+ api.get("operationId") or "",
327
+ api.get("tag", ""),
328
+ api.get("description", ""),
329
+ dto_refs,
330
+ response_vo,
331
+ ]).lower()
332
+ if any(kw in searchable for kw in lower_keywords):
333
+ out.append(api)
334
+ return out
335
+
336
+
337
+ def trim_definitions_detail(result, keywords):
338
+ """apiFilter 生效时,definitionsDetail 仅保留匹配 API 引用的(含传递闭包)。
339
+ 对齐 JS 端 server.js 第 1581-1604 行的裁剪逻辑。"""
340
+ apis = result.get("apis") or []
341
+ matched_refs = set()
342
+ for api in apis:
343
+ for p in api.get("parameters") or []:
344
+ if p.get("dtoRef"):
345
+ matched_refs.add(p["dtoRef"])
346
+ if api.get("response") and api["response"].get("voName"):
347
+ matched_refs.add(api["response"]["voName"])
348
+
349
+ detail_index = {d["name"]: d for d in result.get("definitionsDetail") or []}
350
+ detail_names = set(matched_refs)
351
+ queue = list(matched_refs)
352
+ while queue:
353
+ name = queue.pop(0)
354
+ detail = detail_index.get(name)
355
+ if not detail or not detail.get("properties"):
356
+ continue
357
+ for prop in detail["properties"]:
358
+ ref = prop.get("refName")
359
+ if ref and ref not in detail_names:
360
+ detail_names.add(ref)
361
+ queue.append(ref)
362
+
363
+ result["definitionsDetail"] = [d for d in result.get("definitionsDetail") or [] if d["name"] in detail_names]
364
+ result["_filter"] = {
365
+ "keywords": keywords,
366
+ "matched": len(apis),
367
+ }
368
+ return result
369
+
370
+
371
+ # ==================== get_definition(单 VO 查询兜底) ====================
372
+
373
+ def get_definition(api_docs, definition_name):
374
+ """对齐 JS 端 get_definition action:按名查询单个 VO,找不到给模糊匹配建议。"""
375
+ definitions = api_docs.get("definitions") or {}
376
+ if definition_name in definitions:
377
+ result = parse_api_docs(api_docs, referenced_defs={definition_name})
378
+ target = next((d for d in result["definitionsDetail"] if d["name"] == definition_name), None)
379
+ return {"found": True, "definition": target}
380
+
381
+ # 模糊匹配
382
+ lower = definition_name.lower()
383
+ suggestions = [n for n in definitions.keys() if lower in n.lower()][:10]
384
+ return {
385
+ "found": False,
386
+ "definitionName": definition_name,
387
+ "suggestions": suggestions,
388
+ "message": f"未找到 definition '{definition_name}'。" + (
389
+ f"相近建议:{', '.join(suggestions)}" if suggestions else "无相近匹配。"
390
+ ),
391
+ }
392
+
393
+
394
+ # ==================== 输入:curl 下载 / 读文件 / 缓存 ====================
395
+
396
+ def _diagnose_json_error(text, err):
397
+ """根据错误位置给出 ±80 字符上下文 + 常见原因建议。"""
398
+ pos = getattr(err, "pos", None)
399
+ if pos is None:
400
+ # 用 lineno/colno 反推 pos
401
+ lines = text.split("\n")
402
+ pos = sum(len(l) + 1 for l in lines[: err.lineno - 1]) + (err.colno - 1)
403
+ start, end = max(0, pos - 80), min(len(text), pos + 80)
404
+ snippet = text[start:end].replace("\n", "\\n")
405
+ marker = " " * (pos - start) + "^"
406
+
407
+ # 常见原因诊断
408
+ hints = []
409
+ low = text[:500].lstrip()
410
+ if low.startswith("<"):
411
+ hints.append("响应以 '<' 开头,可能是 HTML 错误页/登录跳转页,而非 JSON。检查 cookie/auth 是否过期")
412
+ if OCTAL_LITERAL_RE.search(text):
413
+ hints.append("检测到八进制字面量(如 [011,015]),脚本应已自动修复——若仍报错请提交 issue")
414
+ if len(text) < 1024 and text.rstrip().endswith(("}", "]")) is False:
415
+ hints.append("响应可能被截断(curl 管道/终端缓冲),建议改用 --file 模式直接读本地文件")
416
+
417
+ msg = f"JSON 解析失败 at line {err.lineno} col {err.colno}: {err.msg}\n"
418
+ msg += f" 上下文: ...{snippet}...\n"
419
+ msg += f" {' ' * 11}{marker}\n"
420
+ if hints:
421
+ msg += " 可能原因:\n" + "\n".join(f" - {h}" for h in hints)
422
+ return msg
423
+
424
+
425
+ def _fix_octal_literals(text):
426
+ """把 [011,015] 这类八进制字面量改成 [11,15](仅在 array/value 位置)。"""
427
+ return OCTAL_LITERAL_RE.sub(lambda m: m.group(1), text)
428
+
429
+
430
+ def load_json_text(text):
431
+ """解析 JSON;首次失败则尝试八进制修复 fallback,再失败给出诊断信息。"""
432
+ try:
433
+ return json.loads(text)
434
+ except json.JSONDecodeError as e:
435
+ # Fallback: 尝试修复八进制字面量
436
+ if OCTAL_LITERAL_RE.search(text):
437
+ fixed = _fix_octal_literals(text)
438
+ if fixed != text:
439
+ try:
440
+ parsed = json.loads(fixed)
441
+ print(f"[INFO] 自动修复八进制字面量后解析成功", file=sys.stderr)
442
+ return parsed
443
+ except json.JSONDecodeError as e2:
444
+ raise RuntimeError(_diagnose_json_error(fixed, e2)) from None
445
+ raise RuntimeError(_diagnose_json_error(text, e)) from None
446
+
447
+
448
+ def _cache_key(url, cookie, auth):
449
+ h = hashlib.sha256()
450
+ h.update(url.encode("utf-8"))
451
+ if cookie:
452
+ h.update(b"\x00" + cookie.encode("utf-8"))
453
+ if auth:
454
+ h.update(b"\x00" + auth.encode("utf-8"))
455
+ return h.hexdigest()[:16]
456
+
457
+
458
+ def fetch_via_curl(url, cookie=None, auth=None, timeout=30):
459
+ """脚本内部走 curl 下载 Swagger JSON。失败抛 RuntimeError。"""
460
+ cmd = ["curl", "-sS", "--connect-timeout", "10", "--max-time", str(timeout),
461
+ "-H", "Accept: application/json"]
462
+ if cookie:
463
+ cmd += ["-H", f"Cookie: {cookie}"]
464
+ if auth:
465
+ cmd += ["-H", f"Authorization: Basic {auth}"]
466
+ cmd.append(url)
467
+ try:
468
+ r = subprocess.run(cmd, capture_output=True, text=True, check=False)
469
+ except FileNotFoundError:
470
+ raise RuntimeError("找不到 curl 命令,请确认本地已安装 curl") from None
471
+ if r.returncode != 0:
472
+ raise RuntimeError(f"curl 失败 (exit {r.returncode}): {r.stderr.strip() or '无错误输出'}")
473
+ if not r.stdout.strip():
474
+ raise RuntimeError("curl 返回空响应")
475
+ return r.stdout
476
+
477
+
478
+ def fetch_with_cache(url, cookie=None, auth=None, timeout=30, ttl=DEFAULT_CACHE_TTL, no_cache=False):
479
+ """带 TTL 缓存的 fetch:缓存命中直接读 /tmp/swagger-cache/<key>.json,否则 curl 拉取并写入。"""
480
+ if no_cache or ttl <= 0:
481
+ return fetch_via_curl(url, cookie=cookie, auth=auth, timeout=timeout)
482
+
483
+ key = _cache_key(url, cookie, auth)
484
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
485
+ cache_file = CACHE_DIR / f"{key}.json"
486
+ meta_file = CACHE_DIR / f"{key}.meta.json"
487
+
488
+ if cache_file.exists() and meta_file.exists():
489
+ try:
490
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
491
+ age = time.time() - meta.get("cached_at", 0)
492
+ if age < ttl:
493
+ print(f"[CACHE] 命中 {key} (age={int(age)}s, ttl={ttl}s)", file=sys.stderr)
494
+ return cache_file.read_text(encoding="utf-8")
495
+ except Exception:
496
+ pass # 缓存损坏 → 重新拉
497
+
498
+ raw = fetch_via_curl(url, cookie=cookie, auth=auth, timeout=timeout)
499
+ try:
500
+ cache_file.write_text(raw, encoding="utf-8")
501
+ meta_file.write_text(json.dumps({
502
+ "url": url, "cached_at": time.time(), "ttl": ttl, "size": len(raw),
503
+ }), encoding="utf-8")
504
+ print(f"[CACHE] 已写入 {key} ({len(raw)} bytes)", file=sys.stderr)
505
+ except Exception as e:
506
+ print(f"[WARN] 缓存写入失败: {e}", file=sys.stderr)
507
+ return raw
508
+
509
+
510
+ # ==================== CLI ====================
511
+
512
+ def main():
513
+ parser = argparse.ArgumentParser(
514
+ description="本地 Swagger 文档提取与解析(与服务端 parseApiDocs 一比一对齐)",
515
+ formatter_class=argparse.RawDescriptionHelpFormatter,
516
+ epilog=__doc__,
517
+ )
518
+ src = parser.add_mutually_exclusive_group(required=True)
519
+ src.add_argument("--url", help="Swagger api-docs URL(脚本内部走 curl 下载)")
520
+ src.add_argument("--file", help="本地 Swagger JSON 文件路径")
521
+
522
+ parser.add_argument("--cookie", help="--url 模式可选,鉴权后的 Cookie 值(如 'JSESSIONID=xxx')")
523
+ parser.add_argument("--auth", help="--url 模式可选,Basic 鉴权 base64(user:pass)")
524
+ parser.add_argument("--timeout", type=int, default=30, help="--url 模式 curl 超时秒数,默认 30")
525
+ parser.add_argument("--cache-ttl", type=int, default=DEFAULT_CACHE_TTL,
526
+ help=f"--url 模式缓存 TTL 秒数,默认 {DEFAULT_CACHE_TTL}(10 分钟),设为 0 关闭")
527
+ parser.add_argument("--no-cache", action="store_true",
528
+ help="--url 模式强制重新下载,忽略缓存")
529
+
530
+ parser.add_argument(
531
+ "--filter", action="append", default=[],
532
+ help="按关键词过滤 api(可多次指定,对 url/summary/operationId/tag/description/dtoRef/voName 模糊匹配)",
533
+ )
534
+ parser.add_argument(
535
+ "--definition",
536
+ help="仅查询单个 VO/DTO 的完整字段定义(等价于服务端 get_definition action)",
537
+ )
538
+ parser.add_argument("--out", help="输出到文件(默认 stdout)")
539
+ parser.add_argument(
540
+ "--indent", type=int, default=2,
541
+ help="JSON 输出缩进,默认 2;设为 0 输出紧凑 JSON",
542
+ )
543
+
544
+ args = parser.parse_args()
545
+
546
+ # 1. 读取原始 Swagger JSON
547
+ try:
548
+ if args.url:
549
+ raw_text = fetch_with_cache(
550
+ args.url, cookie=args.cookie, auth=args.auth, timeout=args.timeout,
551
+ ttl=args.cache_ttl, no_cache=args.no_cache,
552
+ )
553
+ else:
554
+ raw_text = Path(args.file).read_text(encoding="utf-8")
555
+ except Exception as e:
556
+ print(f"[ERROR] 读取 Swagger 失败: {e}", file=sys.stderr)
557
+ sys.exit(2)
558
+
559
+ # 2. 解析 JSON
560
+ try:
561
+ api_docs = load_json_text(raw_text)
562
+ except Exception as e:
563
+ print(f"[ERROR] {e}", file=sys.stderr)
564
+ sys.exit(3)
565
+
566
+ # 3. 路径分支:--definition 单查 / 默认完整解析
567
+ try:
568
+ if args.definition:
569
+ result = get_definition(api_docs, args.definition)
570
+ else:
571
+ result = parse_api_docs(api_docs)
572
+ if args.filter:
573
+ total = len(result["apis"])
574
+ result["apis"] = filter_apis(result["apis"], args.filter)
575
+ result = trim_definitions_detail(result, args.filter)
576
+ result["_filter"]["total"] = total
577
+ except Exception as e:
578
+ print(f"[ERROR] 解析失败: {e}", file=sys.stderr)
579
+ sys.exit(3)
580
+
581
+ # 4. 输出
582
+ indent = args.indent if args.indent > 0 else None
583
+ out_text = json.dumps(result, ensure_ascii=False, indent=indent)
584
+ if args.out:
585
+ Path(args.out).write_text(out_text, encoding="utf-8")
586
+ print(f"[OK] 已输出到 {args.out} ({len(out_text)} bytes)", file=sys.stderr)
587
+ else:
588
+ sys.stdout.write(out_text)
589
+ sys.stdout.write("\n")
590
+
591
+
592
+ if __name__ == "__main__":
593
+ main()
@@ -211,16 +211,19 @@ sketch-mcp-server 是社区版 MCP Server(npm: `sketch-mcp-server`),通过
211
211
  ### 设计稿分析工具
212
212
 
213
213
  #### 5. analyze_design_image
214
- **功能**:分析 UI 设计稿图片,提取 Token 配置、CSS 覆盖样式、视觉细节。支持 base64/URL/文件路径,pixel_perfect 模式最高还原度。
214
+ **功能**:分析 UI 设计稿图片,提取 Token 配置、CSS 覆盖样式、视觉细节。支持 upload_id/base64/URL/文件路径,pixel_perfect 模式最高还原度。
215
215
  **参数**:
216
- - `imageSource` (string, required): 图片数据(base64/URL/文件路径)
217
- - `sourceType` (enum, optional, default: "file_path"): base64/url/file_path
216
+ - `imageSource` (string, required): 图片数据(upload_id/base64/URL/文件路径)
217
+ - `sourceType` (enum, optional, default: "upload_id"): upload_id/base64/url/file_path
218
218
  - `analysisType` (enum, optional, default: "full"): layout/components/tokens/full/pixel_perfect
219
219
  - `pageContext` (string, optional): 页面业务上下文
220
220
  - `targetLibrary` (enum, optional, default: "auto"): szcd/antd/pro-components/auto
221
221
  - `outputFormat` (enum, optional, default: "markdown"): markdown/json/structured_text/restoration_code
222
222
 
223
- **重要提示**:远程 SSE/HTTP 连接时优先使用 base64;本地 stdio 可用 file_path。
223
+ **重要提示**:
224
+ - 远程 SSE/HTTP 连接时优先使用 upload_id:先调用 `get_upload_endpoint` 获取上传地址,用 curl 上传图片获取 upload_id,再传给本工具
225
+ - **上传重试限制**:curl 上传最多重试 3 次(含首次),超过后停止重试并向用户说明失败原因,不要无限重试浪费 token
226
+ - 本地 stdio 直连可用 file_path
224
227
 
225
228
  ### 样式注入指南工具
226
229