From 747124384909a94fbba9b507fc506acf281648dc Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 8 Sep 2026 14:20:56 +0800 Subject: [PATCH] Fix portable skill installation and verify generated deliverables --- .github/workflows/test.yml | 23 +++++++++ .gitignore | 3 ++ README.md | 33 ++++++++---- SKILL.md | 65 +++++++----------------- requirements.txt | 3 ++ scripts/mard221_printable_pattern.py | 19 ++----- tests/test_installation.py | 75 ++++++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 tests/test_installation.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..acf6af9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Test installation +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +concurrency: + group: installation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + smoke-test: + name: Installation smoke test + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + cache: pip + - run: python -m pip install -r requirements.txt + - run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0ccc32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index f6d4972..98e2e60 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,19 @@ git clone git@github.com:rayalexvector/skill-mard.git ~/.hermes/skills/creative/ 安装后,重启 Hermes,或在已有会话里使用 `/reset` 让 skill 生效。 -如果缺少依赖,可以安装: +使用 Python 3.10 或更新版本,在 skill 目录创建独立环境并安装固定版本依赖: ```bash -pip install pillow pymupdf +cd ~/.hermes/skills/creative/mard-pindou-pattern +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements.txt # Linux 环境如果 PDF 中文标签字体缺失,可安装: sudo apt install fonts-noto-cjk ``` +运行 skill 时使用该环境的 Python;其他 agent 或自定义安装目录同样适用。脚本和色板均在仓库内,不需要复制到 `~/.hermes/scripts` 或 `~/.hermes/data`。 + ## 使用方式 在 Hermes 里发送触发语,例如: @@ -68,11 +73,10 @@ Hermes 会按固定回复确认已准备好接收图片: ## 手动脚本用法 -如果不通过 Hermes skill,也可以直接运行脚本: +如果不通过 Hermes skill,也可以直接运行脚本。以下命令在安装目录执行,并先激活上面的虚拟环境。脚本默认加载随包色板,从任意工作目录使用脚本绝对路径也能运行: ```bash python scripts/mard221_printable_pattern.py input.jpg \ - --palette references/mard221_palette.json \ --output-prefix /tmp/mard_pattern ``` @@ -80,7 +84,6 @@ python scripts/mard221_printable_pattern.py input.jpg \ ```bash python scripts/mard221_printable_pattern.py input.jpg \ - --palette references/mard221_palette.json \ --size 80x80 \ --output-prefix /tmp/mard_pattern ``` @@ -89,7 +92,6 @@ python scripts/mard221_printable_pattern.py input.jpg \ ```bash python scripts/mard221_printable_pattern.py input.jpg \ - --palette references/mard221_palette.json \ --long-side 80 \ --output-prefix /tmp/mard_pattern ``` @@ -98,7 +100,6 @@ python scripts/mard221_printable_pattern.py input.jpg \ ```bash python scripts/mard221_printable_pattern.py input.jpg \ - --palette references/mard221_palette.json \ --size 80x80 \ --blank-white \ --output-prefix /tmp/mard_pattern_empty @@ -113,6 +114,16 @@ python scripts/mard221_printable_pattern.py input.jpg \ /tmp/mard_pattern_counts.csv ``` +自定义色板可显式传入 `--palette /absolute/path.json`,或设置 `MARD221_PALETTE` 环境变量;命令行参数优先。自定义路径错误会直接报错。 + +## 验证安装 + +```bash +python -m unittest discover -s tests -v +``` + +测试使用临时合成图片,在安装目录之外执行脚本,检查 PDF 分页、PNG、用量总数、白底模式及色板覆盖,不读取用户图片。 + ## PDF 转 PNG 分页 聊天平台不一定稳定接收 PDF;推荐把 PDF 每页转成 PNG 后分开发送: @@ -120,13 +131,13 @@ python scripts/mard221_printable_pattern.py input.jpg \ ```bash python - <<'PY' from pathlib import Path -import fitz +import pymupdf pdf = Path('/tmp/mard_pattern.pdf') outdir = Path('/tmp/mard_pattern_png_pages') outdir.mkdir(exist_ok=True) -doc = fitz.open(str(pdf)) +doc = pymupdf.open(str(pdf)) for i, page in enumerate(doc, start=1): - pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False) + pix = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), alpha=False) pix.save(str(outdir / f'mard_pattern_page_{i:02d}.png')) PY ``` @@ -137,6 +148,8 @@ PY SKILL.md README.md LICENSE +requirements.txt +tests/test_installation.py scripts/mard221_printable_pattern.py references/mard221_palette.json ``` diff --git a/SKILL.md b/SKILL.md index a60757c..d69239e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,10 +1,10 @@ --- name: mard-pindou-pattern description: Use when the user says “准备拼豆图纸生成”, “准备做拼豆图纸”, “我要生成拼豆底稿”, or sends a bead-pattern generation request for MARD 221 colors. Generates printable perler/bead PDF patterns, previews, section color-code pages, and usage CSV from an image. -version: 1.0.0 -author: Hermes Agent license: MIT metadata: + version: 1.0.0 + author: Hermes Agent hermes: tags: [creative, mard, 拼豆, perler, beads, pdf, pillow] related_skills: [] @@ -17,7 +17,7 @@ metadata: This skill turns a user-supplied image into a printable MARD 221 拼豆图纸. It uses the local script: ```bash -~/.hermes/scripts/mard221_printable_pattern.py +"$SKILL_ROOT/scripts/mard221_printable_pattern.py" ``` The script uses Pillow to: @@ -28,15 +28,9 @@ The script uses Pillow to: 4. Match every bead to the nearest MARD 221 standard RGB color. 5. Export a finished preview PNG, a printable PDF, section color-code pages, and a usage CSV. -The MARD 221 palette is saved at: +Resolve `SKILL_ROOT` to the absolute directory containing this `SKILL.md`, regardless of the host agent or current working directory. Run commands with the Python environment installed from `$SKILL_ROOT/requirements.txt` (see README). Do not assume scripts or data exist outside this skill directory. -- `~/.hermes/data/mard221_palette.json` -- `~/.hermes/data/mard221_palette.csv` - -A portable copy is bundled in this skill folder: - -- `references/mard221_palette.json` -- `scripts/mard221_printable_pattern.py` +The default palette is bundled at `references/mard221_palette.json`, resolved relative to the script, not the working directory. `--palette /absolute/path.json` or `MARD221_PALETTE` can explicitly override it; an invalid override fails instead of falling back to another user's data. ## Trigger / First Reply @@ -69,11 +63,7 @@ The palette must contain exactly 221 unique color tags and fields at least: - `hex` - `rgb` -Primary runtime path: - -```bash -~/.hermes/data/mard221_palette.json -``` +Bundled runtime path: `$SKILL_ROOT/references/mard221_palette.json`. ### 2. Size Selection @@ -143,13 +133,13 @@ PDF-to-PNG conversion recipe: ```bash python - <<'PY' from pathlib import Path -import fitz +import pymupdf pdf = Path('/tmp/mard_x.pdf') outdir = Path('/tmp/mard_x_png_pages') outdir.mkdir(exist_ok=True) -doc = fitz.open(str(pdf)) +doc = pymupdf.open(str(pdf)) for i, page in enumerate(doc, start=1): - pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False) + pix = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), alpha=False) pix.save(str(outdir / f'mard_x_page_{i:02d}.png')) PY ``` @@ -161,7 +151,7 @@ If the user says the result feels low-resolution or blurry, use the original ima ### Auto-size generation ```bash -python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ +python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \ --output-prefix /tmp/mard_pattern ``` @@ -175,7 +165,7 @@ Outputs: ### Strict size ```bash -python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ +python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \ --output-prefix /tmp/mard_pattern \ --size 80x80 ``` @@ -183,7 +173,7 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ ### Strict width or height while preserving ratio ```bash -python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ +python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \ --output-prefix /tmp/mard_pattern \ --width 60 ``` @@ -193,7 +183,7 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ Only when explicitly requested: ```bash -python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ +python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \ --output-prefix /tmp/mard_pattern \ --blank-white ``` @@ -203,7 +193,7 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ Default is 40×40. Use only when needed: ```bash -python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ +python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \ --output-prefix /tmp/mard_pattern \ --section-size 40 ``` @@ -213,31 +203,12 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \ 1. Trigger phrase received → reply with the exact prepared message above. Do not change the user's trigger wording or the prepared reply wording. 2. Wait for the uploaded image. When the image arrives after this trigger, generate the pattern automatically immediately; do not wait for the user to say “生成” again. If the user first asks to describe an image and then says “就是这张图 / 按之前要求生成 / use this image”, treat the most recently uploaded/seen image as the input for this skill. 3. Inspect the user's trigger message and image message for explicit size or blank-white instructions. -4. Locate the image path. In WeChat/Hermes sessions, uploaded images are often cached under `~/.hermes/image_cache/` or profile-specific cache directories such as `~/.hermes/profiles/*/cache/images/`. If no direct path is provided, find the most recent plausible image and verify its dimensions/timestamp before using it: - -```bash -python - <<'PY' -from pathlib import Path -from PIL import Image -import time -roots = [Path.home()/'.hermes/image_cache', Path.home()/'.hermes/profiles'] -paths = [] -for r in roots: - if r.exists(): - paths += list(r.rglob('*.jpg')) + list(r.rglob('*.jpeg')) + list(r.rglob('*.png')) + list(r.rglob('*.webp')) -for p in sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)[:20]: - try: - im = Image.open(p) - print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(p.stat().st_mtime)), im.size, p) - except Exception: - pass -PY -``` +4. Use the image path supplied by the current conversation or its attachment tool. Verify that it is the intended image. Do not search unrelated agent profiles or another application's caches; if the current attachment has no accessible path, ask for it to be attached again. 5. Run the generator with a safe unique prefix, e.g.: ```bash -python ~/.hermes/scripts/mard221_printable_pattern.py "$IMAGE_PATH" \ +python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" "$IMAGE_PATH" \ --output-prefix "/tmp/mard_$(date +%Y%m%d_%H%M%S)" ``` @@ -263,9 +234,9 @@ MEDIA:/tmp/mard_x_png_pages/mard_x_page_02.png ## Verification Checklist -- [ ] Palette JSON/CSV exist under `~/.hermes/data/`. +- [ ] Bundled palette JSON exists at `$SKILL_ROOT/references/mard221_palette.json`. - [ ] Palette contains exactly 221 unique MARD tags. -- [ ] Script exists and compiles: `python -m py_compile ~/.hermes/scripts/mard221_printable_pattern.py`. +- [ ] Script exists and compiles: `python -m py_compile "$SKILL_ROOT/scripts/mard221_printable_pattern.py"`. - [ ] A test run produces PDF, page1 PNG, preview PNG, and counts CSV. - [ ] Default behavior treats white as beads. - [ ] `--blank-white` is used only when explicitly requested. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b49827b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Python >=3.10; generator and PDF-to-PNG delivery dependencies +Pillow==12.3.0 +PyMuPDF==1.28.2 diff --git a/scripts/mard221_printable_pattern.py b/scripts/mard221_printable_pattern.py index 440b63e..d1cacc0 100755 --- a/scripts/mard221_printable_pattern.py +++ b/scripts/mard221_printable_pattern.py @@ -30,20 +30,11 @@ GridCell = Optional[str] def default_palette_path() -> Path: - """Find the installed MARD 221 palette across Hermes profile/home layouts.""" - candidates = [] - env = os.environ.get("MARD221_PALETTE") - if env: - candidates.append(Path(env).expanduser()) - candidates.extend([ - Path.home() / ".hermes" / "data" / "mard221_palette.json", - Path(__file__).resolve().parents[1] / "data" / "mard221_palette.json", - Path("/home/ubuntu/.hermes/data/mard221_palette.json"), - ]) - for p in candidates: - if p.exists(): - return p - return candidates[0] if candidates else Path("mard221_palette.json") + """Use an explicit override or the palette bundled beside this script.""" + override = os.environ.get("MARD221_PALETTE") + if override: + return Path(override).expanduser() + return Path(__file__).resolve().parents[1] / "references" / "mard221_palette.json" DEFAULT_PALETTE = default_palette_path() diff --git a/tests/test_installation.py b/tests/test_installation.py new file mode 100644 index 0000000..df3ee1d --- /dev/null +++ b/tests/test_installation.py @@ -0,0 +1,75 @@ +"""Exercise an installed copy from an unrelated directory using synthetic media.""" +import csv +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +import pymupdf +from PIL import Image + + +class InstallationTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="mard install ") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + source = Path(__file__).resolve().parents[1] + self.installed = self.root / "custom skill location" + for folder in ("scripts", "references"): + shutil.copytree(source / folder, self.installed / folder) + self.work = self.root / "unrelated working directory" + self.work.mkdir() + self.env = os.environ.copy() + self.env.pop("MARD221_PALETTE", None) + self.input = self.work / "white.png" + Image.new("RGB", (12, 8), "white").save(self.input) + + def run_generator(self, *args, success=True): + result = subprocess.run( + [sys.executable, str(self.installed / "scripts/mard221_printable_pattern.py"), + str(self.input), "--size", "12x8", "--section-size", "10", + "--output-prefix", str(self.work / "output/pattern"), *args], + cwd=self.work, env=self.env, capture_output=True, text=True, timeout=90, + ) + if not success: + self.assertNotEqual(result.returncode, 0) + return result + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def test_default_palette_and_deliverables(self): + result = self.run_generator() + self.assertEqual((result["width"], result["height"], result["beads"]), ("12", "8", "96")) + with Path(result["counts"]).open(encoding="utf-8-sig", newline="") as stream: + counts = list(csv.DictReader(stream)) + self.assertEqual(sum(int(row["count"]) for row in counts), 96) + for key in ("page1", "preview"): + with Image.open(result[key]) as image: + image.verify() + with pymupdf.open(result["pdf"]) as document: + self.assertEqual(document.page_count, 3) # Cover plus two sections. + for page in document: + png = page.get_pixmap(matrix=pymupdf.Matrix(0.25, 0.25)).tobytes("png") + self.assertTrue(png.startswith(b"\x89PNG\r\n\x1a\n")) + + def test_blank_white_is_opt_in(self): + result = self.run_generator("--blank-white") + self.assertEqual(result["beads"], "0") + with pymupdf.open(result["pdf"]) as document: + self.assertEqual(document.page_count, 3) + + def test_missing_override_fails_and_cli_takes_precedence(self): + self.env["MARD221_PALETTE"] = str(self.root / "missing.json") + failed = self.run_generator(success=False) + self.assertIn("FileNotFoundError", failed.stderr) + result = self.run_generator("--palette", str(self.installed / "references/mard221_palette.json")) + self.assertEqual(result["beads"], "96") + + +if __name__ == "__main__": + unittest.main()