diff --git a/.github/workflows/skill-eval.yml b/.github/workflows/skill-eval.yml index cb89e26..6283fbd 100644 --- a/.github/workflows/skill-eval.yml +++ b/.github/workflows/skill-eval.yml @@ -64,6 +64,7 @@ jobs: python tools/atomize.py --selftest python tools/mcp_server.py --selftest python tools/vlm_review.py --selftest + python tools/pack_plugin.py --selftest python evals/real_trigger.py --selftest - name: 插件清单与资源存在性 run: python tools/check_plugin.py diff --git a/.gitignore b/.gitignore index 0f9180f..a4967dc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ _work/ # ZCode 平台项目规则与本机会话缓存 .zcode/ + +# 插件打包产物(tools/pack_plugin.py 生成,供本地市场安装) +dist/ +marketplace.json diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index e23b881..13a45d1 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -12,6 +12,5 @@ "./ecom-detail-page-prompts" ], "commands": "./commands", - "hooks": "./hooks/hooks.json", "mcpServers": "./.mcp.json" } diff --git a/README.en.md b/README.en.md index 4a0cb9f..5d88620 100644 --- a/README.en.md +++ b/README.en.md @@ -14,15 +14,17 @@ Each subdirectory is a complete skill: copy it into your local skills directory ### As a ZCode plugin (recommended) -The repo root is a plugin. Add the repo directory as a local marketplace, then install: +The plugin bundles all three skills, four slash commands, the post-generation check hook, and the MCP server in one install. ```bash git clone https://github.com/ADC1920/Ecom-Prompt-Studio.git +cd Ecom-Prompt-Studio +python tools/pack_plugin.py ``` -In ZCode: **Plugin Marketplace → Add → Add Plugin Marketplace**, paste the cloned directory, then install `ecom-prompt-studio` from **Personal**. +This writes a marketplace manifest and a plugin directory into `dist/`. In ZCode: **Plugin Marketplace → Add → Add Plugin Marketplace**, paste the absolute path to `dist/`, then install `ecom-prompt-studio` from **Personal**. -This installs all three skills, four slash commands, the post-generation check hook, and the MCP server in one step. +Why pack instead of pointing at the repo root: ZCode installs a plugin by copying the whole source directory and does not consult `.gitignore`. Using the repo root directly would carry locally created `docs/`, `memory/`, and `AGENTS.md` into the install cache. `pack_plugin.py` copies only git-tracked files, so local-only content cannot leak. ### As plain skills @@ -73,6 +75,8 @@ The only routing rule: **category, action, or scenario cue present → SOP atom | `tools/corpus.py` | positive-sample corpus: add, similarity search, compile few-shot blocks | | `tools/lint_skills.py` | full static check, the first CI gate | | `tools/mcp_server.py` | MCP server exposing the atom library and checks as four tools | +| `tools/check_plugin.py` | validates manifest fields, declared resources, path containment, event names | +| `tools/pack_plugin.py` | builds a clean plugin bundle from git-tracked files only | Machine checks run against images from **any** channel. This repo deliberately does not bundle an image-generation CLI: bring your own endpoint, then verify the output here. diff --git a/README.md b/README.md index 2d8b5ad..4bff085 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,17 @@ ### 推荐:作为 ZCode 插件安装 -仓库根就是插件,三个技能、四条斜杠命令、出图后自动机检、MCP server 一次装齐: +插件形态下,三个技能、四条斜杠命令、出图后自动机检、MCP server 一次装齐。 ```bash git clone https://github.com/ADC1920/Ecom-Prompt-Studio.git +cd Ecom-Prompt-Studio +python tools/pack_plugin.py ``` -在 ZCode 里打开 **插件市场 → 添加 → 添加插件市场**,粘贴克隆下来的目录,然后在**个人**里安装 `ecom-prompt-studio`。 +打包会在 `dist/` 生成市场清单与插件目录。在 ZCode 里打开 **插件市场 → 添加 → 添加插件市场**,粘贴 `dist/` 的**绝对路径**,然后在**个人**里安装 `ecom-prompt-studio`。 + +为什么要打包而不是直接把仓库根当市场:ZCode 的插件安装是整目录拷贝,不问 `.gitignore`。仓库根直接作插件源时,克隆下来自建的 `docs/`、`memory/`、`AGENTS.md` 会一起进安装缓存。`pack_plugin.py` 只复制 git 跟踪的文件,本机内容天然被排除。 只拷单个技能目录也能用,但那会丢掉 `tools/`:机检、规则引擎、语料检索、MCP 工具都不在技能目录里,SKILL.md 提到脚本的地方只能人工代劳。要完整能力就走插件。 @@ -120,6 +124,7 @@ atomize compose slop check visual fix_stats | `tools/mcp_server.py` | MCP server:把原子库与判据暴露成四个可调用工具 | | `tools/lint_skills.py` | 静态检查全集,CI 的第一道门 | | `tools/check_plugin.py` | 插件清单自校验:字段、资源存在性、路径越界、事件名合法性 | +| `tools/pack_plugin.py` | 打干净的插件包:只复制 git 跟踪文件,本机内容不进安装缓存 | 除 `check_image.py`、`visual_audit.py`、`vlm_review.py` 用到 Pillow 外全是标准库;带 `--selftest` 的都能离线验,不依赖密钥与网络。 diff --git a/tools/pack_plugin.py b/tools/pack_plugin.py new file mode 100644 index 0000000..ae0b809 --- /dev/null +++ b/tools/pack_plugin.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""把仓库打成干净的插件包,供本地市场安装。 + +为什么需要它:ZCode 插件安装是整目录拷贝,不问 .gitignore。以仓库根直接作插件源时, +AGENTS.md、docs/、memory/、.v2c 这些本机专属文件会一起进安装缓存,含本机路径的 +project-info.md 也会被带走。 + +做法:用 git ls-files 取已跟踪文件——本机专属内容本来就不在版本控制里,过滤天然成立, +不需要维护白名单,仓库演进时也不会失准。 + +输出结构(市场根与插件根分开,与真实分发一致): + + dist/ + marketplace.json source 指向 ./ecom-prompt-studio + ecom-prompt-studio/ 全部已跟踪的分发内容 + +用法: + python tools/pack_plugin.py # 打包到 dist/ + python tools/pack_plugin.py --out <目录> # 指定输出目录 + python tools/pack_plugin.py --selftest +""" +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +PLUGIN_NAME = "ecom-prompt-studio" +DEFAULT_OUT = ROOT / "dist" + +# 仓库元数据,不是插件内容:CI 配置、git 自身的忽略与换行规则 +REPO_ONLY = {".github", ".gitignore", ".gitattributes"} + +# 打包后必须不存在的顶层项。命中就是过滤失效 +FORBIDDEN = {"docs", "memory", "AGENTS.md", ".v2c", ".tmp", "out", "dist", "marketplace.json"} + + +def tracked_files(): + p = subprocess.run( + ["git", "ls-files", "-z"], cwd=ROOT, + capture_output=True, text=True, encoding="utf-8", + ) + if p.returncode != 0: + raise RuntimeError(f"git ls-files 失败:{(p.stderr or '').strip()}") + return [f for f in p.stdout.split("\0") if f] + + +def top_of(rel): + return rel.split("/")[0] + + +def pack(out_dir): + out_dir = Path(out_dir).resolve() + plugin_dir = out_dir / PLUGIN_NAME + + if out_dir.exists(): + shutil.rmtree(out_dir) + plugin_dir.mkdir(parents=True) + + copied, skipped = 0, [] + for rel in tracked_files(): + if top_of(rel) in REPO_ONLY: + skipped.append(rel) + continue + src = ROOT / rel + dst = plugin_dir / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + copied += 1 + + manifest = json.loads((plugin_dir / ".zcode-plugin" / "plugin.json").read_text(encoding="utf-8")) + marketplace = { + "name": "dev-ecom-prompt-studio", + "plugins": [{ + "name": manifest["name"], + "source": f"./{PLUGIN_NAME}", + "version": manifest["version"], + "description": manifest["description"], + "displayName": "Ecom Prompt Studio", + "displayName_i18n": {"zh-CN": "电商提示词五工作室"}, + "description_i18n": {"zh-CN": "电商视觉五工作室提示词 SOP,含原子库、判据引擎、出图后机检与 MCP 工具"}, + "category": "productivity", + }], + } + (out_dir / "marketplace.json").write_text( + json.dumps(marketplace, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + return {"out": str(out_dir), "plugin": str(plugin_dir), + "copied": copied, "skipped": len(skipped), "version": manifest["version"]} + + +def verify(out_dir): + """打包结果自校验:本机内容不得进包,关键资源必须齐。""" + out_dir = Path(out_dir).resolve() + plugin_dir = out_dir / PLUGIN_NAME + fails = [] + + if not plugin_dir.is_dir(): + return [f"插件目录不存在:{plugin_dir}"] + + tops = {p.name for p in plugin_dir.iterdir()} + leaked = tops & FORBIDDEN + if leaked: + fails.append(f"本机专属内容进了包:{sorted(leaked)}") + + for need in (".zcode-plugin/plugin.json", "tools/mcp_server.py", "hooks/hooks.json", + "ecom-prompt-sop/SKILL.md", "commands", "README.md"): + if not (plugin_dir / need).exists(): + fails.append(f"缺少必需内容:{need}") + + mc = out_dir / "marketplace.json" + if not mc.exists(): + fails.append("缺少 marketplace.json") + else: + data = json.loads(mc.read_text(encoding="utf-8")) + src = (data.get("plugins") or [{}])[0].get("source", "") + if src != f"./{PLUGIN_NAME}": + fails.append(f"市场 source 应为 ./{PLUGIN_NAME},实际 {src!r}") + + # 本机路径检测:取当前用户主目录动态比对,不硬编码盘符与用户名。 + # 只检主目录而非盘符根,避免任何提到该盘符的文本被误判。 + home = str(Path.home()) + # 同时检反斜杠形式,仓库文本可能是 Windows 路径 + markers = {home, home.replace("\\", "/"), home.replace("/", "\\")} + + for p in plugin_dir.rglob("*"): + if p.is_file() and p.suffix in (".md", ".py", ".json", ".yaml", ".yml"): + text = p.read_text(encoding="utf-8", errors="ignore") + if any(m and m in text for m in markers): + fails.append(f"含本机路径:{p.relative_to(plugin_dir)}") + + return fails + + +def main(): + ap = argparse.ArgumentParser(description="打干净的插件包供本地市场安装") + ap.add_argument("--out", default=str(DEFAULT_OUT), help=f"输出目录,默认 {DEFAULT_OUT}") + ap.add_argument("--selftest", action="store_true") + args = ap.parse_args() + + if args.selftest: + got = pack(ROOT / ".tmp" / "pack-selftest") + fails = verify(got["out"]) + shutil.rmtree(ROOT / ".tmp" / "pack-selftest", ignore_errors=True) + if fails: + for f in fails: + print("FAIL", f) + return 1 + print(f"selftest: 打包 {got['copied']} 文件、排除 {got['skipped']} 个仓库元数据," + f"本机内容零泄漏,关键资源齐全") + return 0 + + got = pack(args.out) + fails = verify(got["out"]) + print(f"打包完成:{got['copied']} 个文件,跳过 {got['skipped']} 个仓库元数据") + print(f"市场根:{got['out']}") + print(f"插件根:{got['plugin']}") + if fails: + for f in fails: + print(f"错误: {f}") + return 1 + print("校验通过:无本机内容、关键资源齐全、市场清单正确") + return 0 + + +if __name__ == "__main__": + sys.exit(main())