loongclaw-devkit 0.2.2__tar.gz → 0.4.1__tar.gz

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.
@@ -7,7 +7,7 @@
7
7
  - `src/loongclaw_devkit/server.py` — MCP server(提供 AI 可调用的 3 个开发工具)
8
8
  - `src/loongclaw_devkit/publish.py` — 发布脚本(随包分发,也复制到新项目中)
9
9
  - `src/loongclaw_devkit/templates/` — 项目模板文件
10
- - `publish.py` — 根目录副本(向后兼容,开发者直接复制使用)
10
+ - `publish.py` — symlink → `src/loongclaw_devkit/publish.py`(向后兼容,保证单一来源)
11
11
  - `templates/` — 根目录副本(向后兼容)
12
12
 
13
13
  ## 分发方式
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loongclaw-devkit
3
- Version: 0.2.2
3
+ Version: 0.4.1
4
4
  Summary: LoongClaw MCP 开发者工具包 — 一键创建、加密、打包、发布 MCP 插件
5
5
  Author: LoongClaw Team
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ src/loongclaw_devkit/publish.py
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "loongclaw-devkit"
7
- version = "0.2.2"
7
+ version = "0.4.1"
8
8
  description = "LoongClaw MCP 开发者工具包 — 一键创建、加密、打包、发布 MCP 插件"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -13,6 +13,7 @@
13
13
  from __future__ import annotations
14
14
 
15
15
  import argparse
16
+ import hashlib
16
17
  import json
17
18
  import os
18
19
  import re
@@ -23,7 +24,7 @@ import zipfile
23
24
  from pathlib import Path
24
25
 
25
26
  # ── 常量 ──────────────────────────────────────────────────
26
- CLOUD_API_BASE = "https://yun.loongclaw.com"
27
+ CLOUD_API_BASE = "https://api.loongclaw.net.cn"
27
28
  UPLOAD_ENDPOINT = "/v1/store/upload"
28
29
  ENTRYPOINT = "server.py" # 入口文件,始终明文
29
30
  CONFIG_FILE = ".publish.json"
@@ -31,6 +32,7 @@ IGNORE_PATTERNS = {
31
32
  "__pycache__", ".pytest_cache", ".ruff_cache", ".git", ".venv",
32
33
  "logs", "cache", ".coverage", "*.pyc", "*.pyo", ".publish.json",
33
34
  "publish.py", "setup.py", "build", "dist", "*.egg-info",
35
+ "project.zip", "_staging",
34
36
  }
35
37
 
36
38
 
@@ -100,6 +102,76 @@ def validate_project(project_dir: Path) -> list[str]:
100
102
  return errors
101
103
 
102
104
 
105
+ # ── 跨平台兼容性检查 ─────────────────────────────────────
106
+
107
+ # 已知需要 C 编译器的包——严格锁版在无编译器的平台(Windows)容易安装失败
108
+ _C_EXT_PKGS = {
109
+ "greenlet", "numpy", "scipy", "pandas", "lxml", "pillow",
110
+ "cryptography", "grpcio", "psycopg2", "cffi", "pyyaml",
111
+ "markupsafe", "msgpack", "aiohttp", "uvloop",
112
+ }
113
+
114
+
115
+ def check_requirements_compat(project_dir: Path) -> list[str]:
116
+ """检查 requirements.txt 跨平台兼容性,返回警告列表。"""
117
+ warnings = []
118
+ req = project_dir / "requirements.txt"
119
+ if not req.exists():
120
+ return warnings
121
+
122
+ for line in req.read_text(encoding="utf-8").splitlines():
123
+ line = line.strip()
124
+ if not line or line.startswith("#") or line.startswith("-"):
125
+ continue
126
+ m = re.match(r"^([a-zA-Z0-9_.-]+)==(\S+)", line)
127
+ if m:
128
+ pkg = m.group(1).lower().replace("-", "_")
129
+ if pkg in _C_EXT_PKGS:
130
+ warnings.append(
131
+ f"{m.group(1)}=={m.group(2)} 严格锁定版本,"
132
+ f"可能导致部分平台安装失败。建议改为 >={m.group(2)}"
133
+ )
134
+ return warnings
135
+
136
+
137
+ def verify_zip_paths(project_dir: Path, zip_path: Path) -> list[str]:
138
+ """校验 server.py 中引用的子目录是否存在于 zip 中。
139
+
140
+ 防止开发环境有 project/ 子目录但打包时丢失层级的典型错误。
141
+ """
142
+ warnings = []
143
+ entry = project_dir / ENTRYPOINT
144
+ if not entry.exists() or not zip_path.exists():
145
+ return warnings
146
+
147
+ text = entry.read_text(encoding="utf-8", errors="replace")
148
+
149
+ # 收集 zip 中的顶级目录和文件
150
+ with zipfile.ZipFile(zip_path, "r") as zf:
151
+ zip_entries = set(zf.namelist())
152
+ zip_top_dirs = {n.split("/")[0] for n in zip_entries if "/" in n}
153
+ zip_top_files = {n for n in zip_entries if "/" not in n}
154
+ zip_top = zip_top_dirs | zip_top_files
155
+
156
+ # 匹配 PLUGIN_DIR / "xxx" 或 Path(__file__).parent / "xxx" 等模式
157
+ pattern = (
158
+ r"(?:PLUGIN_DIR|PROJECT_DIR|BASE_DIR|ROOT_DIR|"
159
+ r"Path\(__file__\)(?:\.resolve\(\))?\.parent)"
160
+ r'\s*/\s*["\']([^"\']+)["\']'
161
+ )
162
+ for m in re.finditer(pattern, text):
163
+ ref = m.group(1)
164
+ if ref in ("__pycache__", ".venv", "venv", "node_modules"):
165
+ continue
166
+ if ref not in zip_top:
167
+ warnings.append(
168
+ f'{ENTRYPOINT} 引用路径 "{ref}",但 project.zip '
169
+ f"中不存在此路径。安装后 server.py 将无法找到该目录/文件。"
170
+ )
171
+
172
+ return warnings
173
+
174
+
103
175
  # ── 加密(Cython 编译)──────────────────────────────────
104
176
 
105
177
  def encrypt_files(
@@ -265,20 +337,32 @@ def package_project(
265
337
 
266
338
  # ── Manifest 生成 ────────────────────────────────────────
267
339
 
340
+ def _sha256(path: Path) -> str:
341
+ """计算文件的 SHA-256 哈希值。"""
342
+ h = hashlib.sha256()
343
+ with open(path, "rb") as f:
344
+ for chunk in iter(lambda: f.read(65536), b""):
345
+ h.update(chunk)
346
+ return h.hexdigest()
347
+
348
+
268
349
  def generate_manifest(
269
350
  project_dir: Path,
270
351
  staging_dir: Path,
271
352
  config: dict,
272
353
  ) -> dict:
273
- """生成 manifest.json"""
274
- # 收集 staging 中的所有文件
275
- all_files = []
354
+ """生成 manifest.json(带文件哈希,支持增量更新)。"""
355
+ # 收集 staging 中的所有文件,并计算每个文件的 SHA-256 哈希
356
+ files_map: dict[str, dict] = {}
276
357
  for f in sorted(staging_dir.rglob("*")):
277
358
  if f.is_dir():
278
359
  continue
279
360
  rel = str(f.relative_to(staging_dir))
280
361
  if not should_skip(f.name):
281
- all_files.append(rel)
362
+ files_map[rel] = {
363
+ "hash": _sha256(f),
364
+ "size": f.stat().st_size,
365
+ }
282
366
 
283
367
  # 检测 configFields
284
368
  config_fields = []
@@ -299,7 +383,11 @@ def generate_manifest(
299
383
  "required": default == "",
300
384
  })
301
385
 
302
- manifest = {
386
+ # 计算 project.zip 的哈希(zip 在上一步已生成)
387
+ zip_path = project_dir / "project.zip"
388
+ source_archive_hash = _sha256(zip_path) if zip_path.exists() else None
389
+
390
+ manifest: dict = {
303
391
  "id": config["id"],
304
392
  "name": config.get("name", config["id"]),
305
393
  "description": config.get("description", ""),
@@ -308,11 +396,13 @@ def generate_manifest(
308
396
  "icon": config.get("icon", "🔧"),
309
397
  "runtime": "python",
310
398
  "entrypoint": ENTRYPOINT,
311
- "files": [],
399
+ "files": files_map,
312
400
  "env": {},
313
401
  "configFields": config_fields,
314
402
  "sourceArchive": "project.zip",
315
403
  }
404
+ if source_archive_hash:
405
+ manifest["sourceArchiveHash"] = source_archive_hash
316
406
 
317
407
  # 写到 staging
318
408
  manifest_path = staging_dir / "manifest.json"
@@ -522,6 +612,13 @@ def run(config: dict, project_dir: Path, json_output: bool = False) -> dict:
522
612
  print(f" ❌ {e}")
523
613
  return result
524
614
  print(" ✅ 项目结构正确")
615
+
616
+ # 跨平台兼容性警告(不阻断,但在 JSON 中记录)
617
+ compat_warnings = check_requirements_compat(project_dir)
618
+ if compat_warnings:
619
+ result["warnings"] = compat_warnings
620
+ for w in compat_warnings:
621
+ print(f" ⚠ {w}")
525
622
  result["steps"]["validate"] = "ok"
526
623
 
527
624
  # Step 2: 准备 staging
@@ -548,6 +645,27 @@ def run(config: dict, project_dir: Path, json_output: bool = False) -> dict:
548
645
  print(f" ✅ {zip_path.name} ({size_mb:.1f} MB)")
549
646
  result["steps"]["package"] = {"size_mb": round(size_mb, 1)}
550
647
 
648
+ # 路径一致性校验
649
+ zip_warnings = verify_zip_paths(project_dir, zip_path)
650
+ if zip_warnings:
651
+ result["status"] = "error"
652
+ result["step"] = "verify"
653
+ result["errors"] = zip_warnings
654
+ result["fix"] = (
655
+ "server.py 引用的路径在打包后不存在。"
656
+ "请确保 server.py 中的路径引用与 project.zip 的目录结构一致。"
657
+ )
658
+ if json_output:
659
+ print(json.dumps(result, ensure_ascii=False))
660
+ else:
661
+ for w in zip_warnings:
662
+ print(f" ❌ {w}")
663
+ print(" 💡 常见原因:开发环境中 project.zip 从子目录内部打包,"
664
+ "导致 zip 中缺少了一层目录前缀。")
665
+ # 清理 staging 但保留 zip 以便排查
666
+ shutil.rmtree(staging_dir, ignore_errors=True)
667
+ return result
668
+
551
669
  # Step 5: 生成 manifest
552
670
  print_step(4, total_steps, "生成 manifest.json...")
553
671
  manifest = generate_manifest(project_dir, staging_dir, config)
@@ -252,7 +252,7 @@ def check_mcp_status(plugin_id: str = "", project_dir: str = "") -> str:
252
252
  if token:
253
253
  import urllib.request
254
254
  import urllib.error
255
- url = "https://yun.loongclaw.com/v1/store/mcp/registry.json"
255
+ url = "https://api.loongclaw.net.cn/v1/store/mcp/registry.json"
256
256
  req = urllib.request.Request(
257
257
  url, headers={"Authorization": f"Bearer {token}"},
258
258
  )
@@ -5,8 +5,14 @@
5
5
  核心业务逻辑请放在其他 .py 文件中(发布时会自动加密)。
6
6
  """
7
7
 
8
+ from pathlib import Path
9
+
8
10
  from mcp.server.fastmcp import FastMCP
9
11
 
12
+ # 插件根目录(安装后 project.zip 的内容直接解压到这里)
13
+ # ⚠️ 不要在后面拼 / "project" 等子目录,zip 解压不会创建额外层级
14
+ PLUGIN_DIR = Path(__file__).resolve().parent
15
+
10
16
  mcp = FastMCP(
11
17
  "{{PLUGIN_ID}}",
12
18
  instructions=(
@@ -5,8 +5,14 @@
5
5
  核心业务逻辑请放在其他 .py 文件中(发布时会自动加密)。
6
6
  """
7
7
 
8
+ from pathlib import Path
9
+
8
10
  from mcp.server.fastmcp import FastMCP
9
11
 
12
+ # 插件根目录(安装后 project.zip 的内容直接解压到这里)
13
+ # ⚠️ 不要在后面拼 / "project" 等子目录,zip 解压不会创建额外层级
14
+ PLUGIN_DIR = Path(__file__).resolve().parent
15
+
10
16
  mcp = FastMCP(
11
17
  "{{PLUGIN_ID}}",
12
18
  instructions=(
@@ -1,660 +0,0 @@
1
- #!/usr/bin/env python3
2
- """LoongClaw MCP 一键发布工具
3
-
4
- 交互模式(人类用):
5
- python publish.py
6
-
7
- 自动模式(AI 用):
8
- python publish.py --auto --id my-mcp --version 1.0.0 --access public --json-output
9
-
10
- 首次运行后配置保存到 .publish.json,后续 --auto 自动读取。
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import argparse
16
- import json
17
- import os
18
- import re
19
- import shutil
20
- import subprocess
21
- import sys
22
- import zipfile
23
- from pathlib import Path
24
-
25
- # ── 常量 ──────────────────────────────────────────────────
26
- CLOUD_API_BASE = "https://yun.loongclaw.com"
27
- UPLOAD_ENDPOINT = "/v1/store/upload"
28
- ENTRYPOINT = "server.py" # 入口文件,始终明文
29
- CONFIG_FILE = ".publish.json"
30
- IGNORE_PATTERNS = {
31
- "__pycache__", ".pytest_cache", ".ruff_cache", ".git", ".venv",
32
- "logs", "cache", ".coverage", "*.pyc", "*.pyo", ".publish.json",
33
- "publish.py", "setup.py", "build", "dist", "*.egg-info",
34
- }
35
-
36
-
37
- # ── 工具函数 ──────────────────────────────────────────────
38
-
39
- def should_skip(name: str) -> bool:
40
- if name in IGNORE_PATTERNS:
41
- return True
42
- return any(
43
- name.endswith(p[1:]) for p in IGNORE_PATTERNS if p.startswith("*")
44
- )
45
-
46
-
47
- def print_step(n: int, total: int, msg: str) -> None:
48
- print(f"\n[{n}/{total}] {msg}")
49
-
50
-
51
- def detect_project_info(project_dir: Path) -> dict:
52
- """从 server.py 的 FastMCP 实例中提取插件信息。"""
53
- entry = project_dir / ENTRYPOINT
54
- if not entry.exists():
55
- return {}
56
-
57
- text = entry.read_text(encoding="utf-8", errors="replace")
58
- info: dict = {}
59
-
60
- # FastMCP("name", ...) 或 FastMCP(name="...")
61
- m = re.search(r'FastMCP\(\s*["\']([^"\']+)["\']', text)
62
- if m:
63
- info["id"] = m.group(1)
64
-
65
- # instructions=("...") 或 description="..."
66
- m = re.search(r'(?:instructions|description)\s*=\s*["\'\(]+(.*?)[\)"\']', text, re.DOTALL)
67
- if m:
68
- info["description"] = " ".join(m.group(1).split())[:200]
69
-
70
- return info
71
-
72
-
73
- def find_py_files(project_dir: Path) -> list[Path]:
74
- """递归查找所有 .py 文件,排除忽略项。"""
75
- results = []
76
- for p in sorted(project_dir.rglob("*.py")):
77
- if any(should_skip(part) for part in p.relative_to(project_dir).parts):
78
- continue
79
- results.append(p)
80
- return results
81
-
82
-
83
- def validate_project(project_dir: Path) -> list[str]:
84
- """校验项目结构,返回错误列表。"""
85
- errors = []
86
- entry = project_dir / ENTRYPOINT
87
- if not entry.exists():
88
- errors.append(f"缺少入口文件 {ENTRYPOINT}")
89
- else:
90
- text = entry.read_text(encoding="utf-8", errors="replace")
91
- if "FastMCP" not in text:
92
- errors.append(f"{ENTRYPOINT} 中未找到 FastMCP 实例")
93
- if "mcp.run(" not in text and ".run(" not in text:
94
- errors.append(f"{ENTRYPOINT} 中未找到 mcp.run() 调用")
95
-
96
- req = project_dir / "requirements.txt"
97
- if not req.exists():
98
- errors.append("缺少 requirements.txt")
99
-
100
- return errors
101
-
102
-
103
- # ── 加密(Cython 编译)──────────────────────────────────
104
-
105
- def encrypt_files(
106
- project_dir: Path,
107
- py_files: list[Path],
108
- skip_encrypt: list[str],
109
- staging_dir: Path,
110
- ) -> tuple[list[str], list[str]]:
111
- """编译 .py → .so/.pyd,返回 (加密文件列表, 明文文件列表)。"""
112
- always_plain = {ENTRYPOINT, "publish.py", "setup.py", "__init__.py"}
113
- skip_set = set(skip_encrypt) | always_plain
114
-
115
- to_encrypt = []
116
- plain_files = []
117
-
118
- for f in py_files:
119
- rel = str(f.relative_to(project_dir))
120
- name = f.name
121
- if name in skip_set or rel in skip_set:
122
- plain_files.append(rel)
123
- else:
124
- to_encrypt.append(f)
125
-
126
- if not to_encrypt:
127
- print(" ℹ 无需加密的文件")
128
- return [], [str(f.relative_to(project_dir)) for f in py_files]
129
-
130
- # 检查 Cython 是否安装
131
- try:
132
- import Cython # noqa: F401
133
- except ImportError:
134
- print(" ⚠ Cython 未安装,正在安装...")
135
- subprocess.check_call(
136
- [sys.executable, "-m", "pip", "install", "cython", "-q"],
137
- )
138
-
139
- encrypted_names = []
140
- for f in to_encrypt:
141
- rel = f.relative_to(project_dir)
142
- module_name = f.stem
143
- work_dir = staging_dir / rel.parent
144
- work_dir.mkdir(parents=True, exist_ok=True)
145
-
146
- # 复制为 .pyx
147
- pyx_path = work_dir / f"{module_name}.pyx"
148
- shutil.copy2(f, pyx_path)
149
-
150
- # 编译
151
- print(f" 🔒 编译 {rel}...")
152
- setup_code = (
153
- "from setuptools import setup\n"
154
- "from Cython.Build import cythonize\n"
155
- f"setup(ext_modules=cythonize('{module_name}.pyx', "
156
- "compiler_directives={'language_level': '3'}))\n"
157
- )
158
- setup_file = work_dir / "_setup_tmp.py"
159
- setup_file.write_text(setup_code)
160
-
161
- result = subprocess.run(
162
- [sys.executable, str(setup_file), "build_ext", "--inplace"],
163
- cwd=str(work_dir),
164
- capture_output=True,
165
- text=True,
166
- )
167
- if result.returncode != 0:
168
- print(f" ❌ 编译失败: {rel}")
169
- print(f" {result.stderr[:500]}")
170
- # 回退:保留明文
171
- plain_files.append(str(rel))
172
- continue
173
-
174
- # 收集编译产物
175
- so_files = list(work_dir.glob(f"{module_name}.cpython-*.so")) + \
176
- list(work_dir.glob(f"{module_name}.cp*-*.pyd"))
177
- if so_files:
178
- encrypted_names.append(str(rel))
179
- else:
180
- print(f" ⚠ 未找到编译产物: {rel},保留明文")
181
- plain_files.append(str(rel))
182
-
183
- # 清理中间文件
184
- for tmp in [pyx_path, setup_file]:
185
- tmp.unlink(missing_ok=True)
186
- for c_file in work_dir.glob(f"{module_name}.c"):
187
- c_file.unlink(missing_ok=True)
188
- for build_dir in work_dir.glob("build"):
189
- shutil.rmtree(build_dir, ignore_errors=True)
190
-
191
- return encrypted_names, plain_files
192
-
193
-
194
- # ── 打包 ─────────────────────────────────────────────────
195
-
196
- def package_project(
197
- project_dir: Path,
198
- staging_dir: Path,
199
- plain_files: list[str],
200
- ) -> Path:
201
- """收集明文文件 + 编译产物 + vendor wheels → project.zip"""
202
- # 复制明文文件
203
- for rel in plain_files:
204
- src = project_dir / rel
205
- dst = staging_dir / rel
206
- dst.parent.mkdir(parents=True, exist_ok=True)
207
- if src.exists():
208
- shutil.copy2(src, dst)
209
-
210
- # 复制非 .py 文件(配置、模板等)
211
- for f in sorted(project_dir.rglob("*")):
212
- if f.is_dir():
213
- continue
214
- rel = f.relative_to(project_dir)
215
- if any(should_skip(part) for part in rel.parts):
216
- continue
217
- if f.suffix == ".py":
218
- continue # .py 已处理
219
- dst = staging_dir / rel
220
- if not dst.exists():
221
- dst.parent.mkdir(parents=True, exist_ok=True)
222
- shutil.copy2(f, dst)
223
-
224
- # requirements.txt → vendor wheels
225
- req = staging_dir / "requirements.txt"
226
- if req.exists():
227
- vendor_dir = staging_dir / "_vendor"
228
- vendor_dir.mkdir(exist_ok=True)
229
- print(" 📦 下载离线 wheel 包...")
230
- result = subprocess.run(
231
- [
232
- sys.executable, "-m", "pip", "download",
233
- "-r", str(req), "-d", str(vendor_dir),
234
- "--only-binary=:all:",
235
- ],
236
- capture_output=True, text=True,
237
- )
238
- if result.returncode != 0:
239
- # 尝试清华镜像
240
- subprocess.run(
241
- [
242
- sys.executable, "-m", "pip", "download",
243
- "-r", str(req), "-d", str(vendor_dir),
244
- "--only-binary=:all:",
245
- "-i", "https://pypi.tuna.tsinghua.edu.cn/simple",
246
- "--trusted-host", "pypi.tuna.tsinghua.edu.cn",
247
- ],
248
- capture_output=True, text=True,
249
- )
250
-
251
- # 打 zip
252
- zip_path = project_dir / "project.zip"
253
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
254
- for root, dirs, files in os.walk(staging_dir):
255
- dirs[:] = [d for d in dirs if not should_skip(d)]
256
- for fname in sorted(files):
257
- if should_skip(fname):
258
- continue
259
- full = Path(root) / fname
260
- arcname = full.relative_to(staging_dir)
261
- zf.write(full, arcname)
262
-
263
- return zip_path
264
-
265
-
266
- # ── Manifest 生成 ────────────────────────────────────────
267
-
268
- def generate_manifest(
269
- project_dir: Path,
270
- staging_dir: Path,
271
- config: dict,
272
- ) -> dict:
273
- """生成 manifest.json。"""
274
- # 收集 staging 中的所有文件
275
- all_files = []
276
- for f in sorted(staging_dir.rglob("*")):
277
- if f.is_dir():
278
- continue
279
- rel = str(f.relative_to(staging_dir))
280
- if not should_skip(f.name):
281
- all_files.append(rel)
282
-
283
- # 检测 configFields
284
- config_fields = []
285
- entry = project_dir / ENTRYPOINT
286
- if entry.exists():
287
- text = entry.read_text(encoding="utf-8", errors="replace")
288
- for m in re.finditer(
289
- r'os\.environ\.get\(["\'](\w+)["\'](?:,\s*["\']([^"\']*)["\'])?\)',
290
- text,
291
- ):
292
- key = m.group(1)
293
- default = m.group(2) or ""
294
- if key not in ("PATH", "HOME", "USER", "LANG"):
295
- config_fields.append({
296
- "key": key,
297
- "label": key.replace("_", " ").title(),
298
- "default": default,
299
- "required": default == "",
300
- })
301
-
302
- manifest = {
303
- "id": config["id"],
304
- "name": config.get("name", config["id"]),
305
- "description": config.get("description", ""),
306
- "version": config["version"],
307
- "author": config.get("author", ""),
308
- "icon": config.get("icon", "🔧"),
309
- "runtime": "python",
310
- "entrypoint": ENTRYPOINT,
311
- "files": [],
312
- "env": {},
313
- "configFields": config_fields,
314
- "sourceArchive": "project.zip",
315
- }
316
-
317
- # 写到 staging
318
- manifest_path = staging_dir / "manifest.json"
319
- manifest_path.write_text(
320
- json.dumps(manifest, ensure_ascii=False, indent=2),
321
- encoding="utf-8",
322
- )
323
-
324
- return manifest
325
-
326
-
327
- # ── 上传 ──────────────────────────────────────────────────
328
-
329
- def upload_to_cloud(
330
- project_dir: Path,
331
- config: dict,
332
- manifest: dict,
333
- ) -> dict:
334
- """上传到 LoongClaw Cloud API。"""
335
- import urllib.request
336
- import urllib.error
337
-
338
- token = config.get("token", "")
339
- if not token:
340
- token = os.environ.get("LOONGCLAW_STORE_KEY", "")
341
- if not token:
342
- return {"status": "error", "step": "upload",
343
- "error": "缺少 Store Key。设置环境变量 LOONGCLAW_STORE_KEY 或在 .publish.json 中配置 token",
344
- "fix": "前往 https://loongclaw.net.cn/dev/ 申请开发者并生成 Store Key"}
345
-
346
- # 构造上传请求
347
- zip_path = project_dir / "project.zip"
348
- if not zip_path.exists():
349
- return {"status": "error", "step": "upload",
350
- "error": "project.zip 不存在",
351
- "fix": "检查打包步骤是否成功"}
352
-
353
- manifest_path = project_dir / "_staging" / "manifest.json"
354
- if not manifest_path.exists():
355
- manifest_path = project_dir / "manifest.json"
356
-
357
- # multipart/form-data 上传
358
- boundary = "----LoongClawUpload"
359
- body = b""
360
-
361
- # manifest.json 部分
362
- body += f"--{boundary}\r\n".encode()
363
- body += b'Content-Disposition: form-data; name="manifest"; filename="manifest.json"\r\n'
364
- body += b"Content-Type: application/json\r\n\r\n"
365
- body += json.dumps(manifest, ensure_ascii=False).encode("utf-8")
366
- body += b"\r\n"
367
-
368
- # project.zip 部分
369
- body += f"--{boundary}\r\n".encode()
370
- body += b'Content-Disposition: form-data; name="archive"; filename="project.zip"\r\n'
371
- body += b"Content-Type: application/zip\r\n\r\n"
372
- body += zip_path.read_bytes()
373
- body += b"\r\n"
374
-
375
- # accessType 部分
376
- body += f"--{boundary}\r\n".encode()
377
- body += b'Content-Disposition: form-data; name="accessType"\r\n\r\n'
378
- body += config.get("access", "public").encode()
379
- body += b"\r\n"
380
-
381
- body += f"--{boundary}--\r\n".encode()
382
-
383
- url = f"{CLOUD_API_BASE}{UPLOAD_ENDPOINT}"
384
- req = urllib.request.Request(
385
- url,
386
- data=body,
387
- headers={
388
- "Authorization": f"Bearer {token}",
389
- "Content-Type": f"multipart/form-data; boundary={boundary}",
390
- },
391
- method="POST",
392
- )
393
-
394
- try:
395
- with urllib.request.urlopen(req) as resp:
396
- result = json.loads(resp.read().decode("utf-8"))
397
- return {"status": "success", **result}
398
- except urllib.error.HTTPError as e:
399
- err_body = e.read().decode("utf-8", errors="replace")
400
- return {"status": "error", "step": "upload",
401
- "error": f"HTTP {e.code}: {err_body[:300]}",
402
- "fix": "检查 token 是否有效,或联系管理员"}
403
- except urllib.error.URLError as e:
404
- return {"status": "error", "step": "upload",
405
- "error": f"网络错误: {e.reason}",
406
- "fix": "检查网络连接和 CLOUD_API_BASE 地址"}
407
-
408
-
409
- # ── 配置管理 ─────────────────────────────────────────────
410
-
411
- def load_config(project_dir: Path) -> dict | None:
412
- cfg_path = project_dir / CONFIG_FILE
413
- if cfg_path.exists():
414
- try:
415
- return json.loads(cfg_path.read_text(encoding="utf-8"))
416
- except (json.JSONDecodeError, OSError):
417
- pass
418
- return None
419
-
420
-
421
- def save_config(project_dir: Path, config: dict) -> None:
422
- cfg_path = project_dir / CONFIG_FILE
423
- # 不保存 token 到配置文件(安全)
424
- safe = {k: v for k, v in config.items() if k != "token"}
425
- cfg_path.write_text(
426
- json.dumps(safe, ensure_ascii=False, indent=2),
427
- encoding="utf-8",
428
- )
429
-
430
-
431
- # ── 交互式配置 ───────────────────────────────────────────
432
-
433
- def interactive_config(project_dir: Path, detected: dict) -> dict:
434
- """交互式引导配置。"""
435
- print("\n═══ LoongClaw MCP 发布工具 ═══\n")
436
-
437
- config: dict = {}
438
-
439
- # ID
440
- default_id = detected.get("id", project_dir.name)
441
- val = input(f"▶ 插件 ID [{default_id}]: ").strip()
442
- config["id"] = val if val else default_id
443
-
444
- # 名称
445
- default_name = config["id"]
446
- val = input(f"▶ 插件名称 [{default_name}]: ").strip()
447
- config["name"] = val if val else default_name
448
-
449
- # 描述
450
- default_desc = detected.get("description", "")
451
- if default_desc:
452
- print(f" (自动检测到描述:{default_desc[:60]}...)")
453
- val = input(f"▶ 插件描述 [{default_desc[:60] or '无'}]: ").strip()
454
- config["description"] = val if val else default_desc
455
-
456
- # 版本
457
- val = input("▶ 版本号 [1.0.0]: ").strip()
458
- config["version"] = val if val else "1.0.0"
459
-
460
- # 作者
461
- val = input("▶ 作者名: ").strip()
462
- config["author"] = val
463
-
464
- # 图标
465
- val = input("▶ 图标 emoji [🔧]: ").strip()
466
- config["icon"] = val if val else "🔧"
467
-
468
- # 不加密文件
469
- py_files = find_py_files(project_dir)
470
- encryptable = [
471
- str(f.relative_to(project_dir)) for f in py_files
472
- if f.name not in {ENTRYPOINT, "publish.py", "setup.py", "__init__.py"}
473
- ]
474
- if encryptable:
475
- print(f"\n▶ 以下 {len(encryptable)} 个 .py 文件将被加密编译:")
476
- for name in encryptable:
477
- print(f" {name}")
478
- val = input(" 不需要加密的文件(逗号分隔,留空=全加密): ").strip()
479
- config["skipEncrypt"] = [s.strip() for s in val.split(",") if s.strip()] if val else []
480
- else:
481
- config["skipEncrypt"] = []
482
-
483
- # 访问类型
484
- print("\n▶ 访问类型:")
485
- print(" [1] 公开(所有用户免费使用)")
486
- print(" [2] 付费(需联系商务开通)")
487
- val = input(" 选择 [1]: ").strip()
488
- config["access"] = "private" if val == "2" else "public"
489
-
490
- # 上传
491
- val = input("\n▶ 自动上传到 LoongClaw 服务器?[Y/n]: ").strip().lower()
492
- config["upload"] = val != "n"
493
-
494
- if config["upload"]:
495
- val = input("▶ LoongClaw Store Key(环境变量 LOONGCLAW_STORE_KEY 也可,前往 loongclaw.net.cn/dev/ 获取): ").strip()
496
- if val:
497
- config["token"] = val
498
-
499
- return config
500
-
501
-
502
- # ── 主流程 ────────────────────────────────────────────────
503
-
504
- def run(config: dict, project_dir: Path, json_output: bool = False) -> dict:
505
- """执行完整发布流程,返回结果字典。"""
506
- total_steps = 6 if config.get("upload") else 5
507
- result: dict = {"status": "success", "steps": {}}
508
-
509
- # Step 1: 校验
510
- print_step(1, total_steps, "校验项目结构...")
511
- errors = validate_project(project_dir)
512
- if errors:
513
- result = {
514
- "status": "error", "step": "validate",
515
- "errors": errors,
516
- "fix": "; ".join(errors),
517
- }
518
- if json_output:
519
- print(json.dumps(result, ensure_ascii=False))
520
- else:
521
- for e in errors:
522
- print(f" ❌ {e}")
523
- return result
524
- print(" ✅ 项目结构正确")
525
- result["steps"]["validate"] = "ok"
526
-
527
- # Step 2: 准备 staging
528
- staging_dir = project_dir / "_staging"
529
- if staging_dir.exists():
530
- shutil.rmtree(staging_dir)
531
- staging_dir.mkdir()
532
-
533
- # Step 3: 加密编译
534
- print_step(2, total_steps, "Cython 加密编译...")
535
- py_files = find_py_files(project_dir)
536
- encrypted, plain = encrypt_files(
537
- project_dir, py_files,
538
- config.get("skipEncrypt", []),
539
- staging_dir,
540
- )
541
- print(f" ✅ 加密 {len(encrypted)} 个,明文 {len(plain)} 个")
542
- result["steps"]["encrypt"] = {"encrypted": len(encrypted), "plain": len(plain)}
543
-
544
- # Step 4: 打包
545
- print_step(3, total_steps, "打包 project.zip...")
546
- zip_path = package_project(project_dir, staging_dir, plain)
547
- size_mb = zip_path.stat().st_size / 1024 / 1024
548
- print(f" ✅ {zip_path.name} ({size_mb:.1f} MB)")
549
- result["steps"]["package"] = {"size_mb": round(size_mb, 1)}
550
-
551
- # Step 5: 生成 manifest
552
- print_step(4, total_steps, "生成 manifest.json...")
553
- manifest = generate_manifest(project_dir, staging_dir, config)
554
- print(f" ✅ {manifest['id']} v{manifest['version']}")
555
- result["steps"]["manifest"] = manifest
556
-
557
- # Step 6: 上传(可选)
558
- if config.get("upload"):
559
- print_step(5, total_steps, "上传到 LoongClaw 服务器...")
560
- upload_result = upload_to_cloud(project_dir, config, manifest)
561
- if upload_result["status"] == "error":
562
- result["status"] = "error"
563
- result["step"] = "upload"
564
- result["error"] = upload_result["error"]
565
- result["fix"] = upload_result.get("fix", "")
566
- if json_output:
567
- print(json.dumps(result, ensure_ascii=False))
568
- else:
569
- print(f" ❌ {upload_result['error']}")
570
- # 清理 staging 但保留 zip
571
- shutil.rmtree(staging_dir, ignore_errors=True)
572
- return result
573
- print(" ✅ 上传成功")
574
- result["steps"]["upload"] = "ok"
575
-
576
- # 保存配置
577
- save_config(project_dir, config)
578
-
579
- # 清理
580
- shutil.rmtree(staging_dir, ignore_errors=True)
581
-
582
- result["version"] = config["version"]
583
- result["files_encrypted"] = len(encrypted)
584
- result["uploaded"] = config.get("upload", False)
585
-
586
- if json_output:
587
- print(json.dumps(result, ensure_ascii=False))
588
- else:
589
- print(f"\n{'═' * 50}")
590
- print(f" 发布完成! {config['id']} v{config['version']}")
591
- if config.get("access") == "private":
592
- print(" 📌 访问类型: 付费(需商务开通)")
593
- else:
594
- print(" 📌 访问类型: 公开")
595
- print(f"{'═' * 50}")
596
-
597
- return result
598
-
599
-
600
- def main() -> None:
601
- parser = argparse.ArgumentParser(description="LoongClaw MCP 一键发布工具")
602
- parser.add_argument("--auto", action="store_true",
603
- help="自动模式,使用 .publish.json 或命令行参数")
604
- parser.add_argument("--reconfigure", action="store_true",
605
- help="重新配置(忽略 .publish.json)")
606
- parser.add_argument("--json-output", action="store_true",
607
- help="JSON 格式输出(适合 AI 解析)")
608
- parser.add_argument("--id", type=str, help="插件 ID")
609
- parser.add_argument("--name", type=str, help="插件名称")
610
- parser.add_argument("--version", type=str, help="版本号")
611
- parser.add_argument("--access", choices=["public", "private"], help="访问类型")
612
- parser.add_argument("--skip-encrypt", type=str, default="",
613
- help="不加密的文件(逗号分隔)")
614
- parser.add_argument("--upload", action="store_true", help="上传到服务器")
615
- parser.add_argument("--no-upload", action="store_true", help="不上传")
616
- parser.add_argument("--token", type=str, help="LoongClaw Store Key")
617
- parser.add_argument("--dir", type=str, default=".",
618
- help="项目目录(默认当前目录)")
619
- args = parser.parse_args()
620
-
621
- project_dir = Path(args.dir).resolve()
622
- json_output = args.json_output
623
-
624
- if args.auto:
625
- # 自动模式:优先命令行参数 > .publish.json > 自动检测
626
- saved = load_config(project_dir) or {}
627
- detected = detect_project_info(project_dir)
628
-
629
- config = {
630
- "id": args.id or saved.get("id") or detected.get("id") or project_dir.name,
631
- "name": args.name or saved.get("name") or saved.get("id", project_dir.name),
632
- "version": args.version or saved.get("version", "1.0.0"),
633
- "description": saved.get("description") or detected.get("description", ""),
634
- "author": saved.get("author", ""),
635
- "icon": saved.get("icon", "🔧"),
636
- "access": args.access or saved.get("access", "public"),
637
- "skipEncrypt": (
638
- [s.strip() for s in args.skip_encrypt.split(",") if s.strip()]
639
- if args.skip_encrypt
640
- else saved.get("skipEncrypt", [])
641
- ),
642
- "upload": args.upload or (not args.no_upload and saved.get("upload", False)),
643
- "token": args.token or os.environ.get("LOONGCLAW_STORE_KEY", ""),
644
- }
645
- elif args.reconfigure or not load_config(project_dir):
646
- # 交互模式
647
- detected = detect_project_info(project_dir)
648
- config = interactive_config(project_dir, detected)
649
- else:
650
- # 有配置文件 → 直接用
651
- config = load_config(project_dir) or {}
652
- if not config.get("id"):
653
- detected = detect_project_info(project_dir)
654
- config = interactive_config(project_dir, detected)
655
-
656
- run(config, project_dir, json_output)
657
-
658
-
659
- if __name__ == "__main__":
660
- main()