loongclaw-devkit 0.2.3__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.
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/AGENTS.md +1 -1
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/PKG-INFO +1 -1
- loongclaw_devkit-0.4.1/publish.py +1 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/pyproject.toml +1 -1
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1/src/loongclaw_devkit}/publish.py +124 -6
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1/src/loongclaw_devkit}/templates/server_template.py +6 -0
- {loongclaw_devkit-0.2.3/src/loongclaw_devkit → loongclaw_devkit-0.4.1}/templates/server_template.py +6 -0
- loongclaw_devkit-0.2.3/src/loongclaw_devkit/publish.py +0 -660
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/.gitignore +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/README.md +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/src/loongclaw_devkit/__init__.py +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/src/loongclaw_devkit/__main__.py +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/src/loongclaw_devkit/server.py +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/src/loongclaw_devkit/templates/AGENTS_template.md +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/src/loongclaw_devkit/templates/requirements_template.txt +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/templates/AGENTS_template.md +0 -0
- {loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/templates/requirements_template.txt +0 -0
|
@@ -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
|
## 分发方式
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
src/loongclaw_devkit/publish.py
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
{loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1/src/loongclaw_devkit}/templates/server_template.py
RENAMED
|
@@ -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=(
|
{loongclaw_devkit-0.2.3/src/loongclaw_devkit → loongclaw_devkit-0.4.1}/templates/server_template.py
RENAMED
|
@@ -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://api.loongclaw.net.cn"
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{loongclaw_devkit-0.2.3 → loongclaw_devkit-0.4.1}/src/loongclaw_devkit/templates/AGENTS_template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|