Skip to content
Content
Hook

Hooks

What mojiemoji-github runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
mojiemoji-github
44 skills1 agent2 hooks
Install
> /plugin marketplace add jozobeer/mojiemoji-plugin
> /plugin install mojiemoji-github@mojiemoji-plugin

Ships with mojiemoji-github. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesBash|mcp__.*github.*"${CLAUDE_PLUGIN_ROOT}/hooks/mojiemoji_japanese_gate.py"

PostToolUse

  • MatchesEdit|Write|MultiEdit"${CLAUDE_PLUGIN_ROOT}/hooks/mojiemoji_md_edit_warn.py"
Read hooks/hooks.json

Where it lives

  • hooks/mojiemoji_japanese_gate.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PreToolUse hook: gate Japanese (and opt-in English/Latin #148) GitHub body submissions without properly-styled mojiemoji stamps.
    
    Fires on two posting paths:
      1. Bash tool with `gh` posting the body:
         - `gh (issue|pr|release) (create|comment|review)` (high-level), OR
         - `gh api .../reviews|comments|issues|releases ...` (raw REST POST,
           used by skills like cross-repo-review that batch-publish reviews).
      2. MCP GitHub tools whose `tool_input` carries a Japanese `body`
         field, including nested review `comments[].body` fields. The MCP
         matcher uses both server-alias signals (anything
         with `github` in the namespace) AND known GitHub-specific tool
         name patterns (`*pull_request*`, `*issue_write`, `add_issue_comment`,
         `*release*`, etc.) so installations that aliased the GitHub MCP
         server to a non-`github` name are still covered. Title /
         commit_message / file content / description are intentionally NOT
         inspected — only the `body` posting-prose field is inspected, and
         each body value must be decorated on its own.
    
    And EITHER:
      1. inspected text has zero `mojiemoji.jozo.beer` URLs, OR
      2. at least one mojiemoji URL is missing any of the required style
         parameters (`background=transparent`, `font=*`, `color=*`,
         `animation=*`, `outline=darker`, `outline_width=2`), OR
      3. a URL uses a non-canonical font/animation, an invalid outline
         value, pairs a color-shifting animation with an outline, uses a
         Tailwind 600+ color (invisible on dark mode), or contains a
         3-kanji single-stamp text (must split as 2+1).
    
    The Bash path also reads referenced body files (`--body-file PATH` /
    `--input PATH` / `-F body=@PATH`) and interpreter-invoked scripts so
    file-routed / dynamically-built bodies are covered too.
    
    When triggered, blocks the tool call (exit 2) and prints reminder to
    stderr so Claude sees it before submission. Bypass: include
    `MOJIEMOJI_HOOK_DISABLED=1` anywhere in the inspected text — for Bash
    that's the command line (prefix idiom matches the git pre-commit hook),
    for MCP that's the body itself.
    
    Implementation lives in `hooks/gate/` — this script is the thin entry
    pipeline that wires the routing / validator modules together. See
    https://github.com/jozobeer/mojiemoji-plugin/issues/101.
    """
    from __future__ import annotations
    
    import json
    import sys
    from pathlib import Path
    
    # `hooks/gate/__init__.py` already splices the skill scripts dir onto
    # sys.path, but Claude Code invokes this file as `python3 hooks/...`
    # rather than as a package member, so `gate` itself isn't importable
    # until `hooks/` is on the path. Splice both — hooks/ for `import gate`,
    # scripts/ for any direct `from lib.X import Y` consumers downstream.
    _HOOKS_DIR = Path(__file__).resolve().parent
    if str(_HOOKS_DIR) not in sys.path:
        sys.path.insert(0, str(_HOOKS_DIR))
    
    from gate.extract import JP_RE, LATIN_RE, MOJI_URL_RE, extract_inspect_text  # noqa: E402
    from gate.extract import forces_pr_body, is_pr_body_submission, pr_body_target_repo  # noqa: E402
    from lib.plugin_root import plugin_root  # noqa: E402
    from gate.validators import (  # noqa: E402
        PIPELINE,
        validate_catalog_leftovers,
        validate_schema_version,
    )
    from lib.repo_policy import POLICY_LEAKS, POLICY_UNKNOWN, repo_policy_state  # noqa: E402
    
    _PR_BODY_LEAK_REMINDER = (
        "🚫 このリポジトリは PR body を commit message にコピーする設定です\n"
        "\n"
        "検出: squash / merge commit message が `PR_BODY` のリポジトリに、mojiemoji\n"
        "stamp を含む PR body を投稿しようとしています。GitHub が PR body を squash /\n"
        "merge commit に転記するため、`<img src=\"https://mojiemoji.jozo.beer/...\">`\n"
        "の HTML が commit 履歴に恒久的に残ってしまいます (issue #138)。\n"
        "\n"
        "## 対処\n"
        "1. PR body から mojiemoji stamp を外して投稿する (推奨。本文の装飾は不要)\n"
        "2. どうしても装飾したいなら、投稿経路に応じて FORCE を明示する:\n"
        "   - Bash: command 先頭に `MOJIEMOJI_FORCE_PR_BODY=1` を付ける\n"
        "   - MCP: body 内に `MOJIEMOJI_FORCE_PR_BODY=1` を含める\n"
        "\n"
        "issue / review / comment など他の surface は通常どおり装飾して構いません。\n"
        f"詳細: {plugin_root()}/skills/mojiemoji-github/SKILL.md\n"
    )
    
    
    def main() -> int:
        try:
            data = json.load(sys.stdin)
        except Exception:
            return 0
    
        inspect_texts = extract_inspect_text(data)
        if inspect_texts is None:
            return 0
        jp_texts = [text for text in inspect_texts if JP_RE.search(text)]
        en_texts = [text for text in inspect_texts if LATIN_RE.search(text)]
    
        # English/Latin opt-in support (#148). Pure English bodies are NOT gated
        # by default (to avoid forcing stamps on English-first users).
        # Opt in for a submission by including the marker:
        #   MOJIEMOJI_ENGLISH_GATE=1   (in Bash command or inside the body text)
        english_enabled = any(
            "MOJIEMOJI_ENGLISH_GATE=1" in (t or "")
            for t in (inspect_texts or [])
        ) or "MOJIEMOJI_ENGLISH_GATE=1" in (
            (data.get("tool_input", {}) or {}).get("command", "")
        )
    
        if not jp_texts and not (english_enabled and en_texts):
            return 0
    
        # Determine which bodies to enforce stamping rules on. Japanese and
        # opt-in English bodies are both active when a payload mixes them.
        active = [
            text
            for text in inspect_texts
            if JP_RE.search(text) or (english_enabled and LATIN_RE.search(text))
        ]
        cwd = data.get("cwd", "")
        if is_pr_body_submission(data) and not forces_pr_body(data):
            owner, repo = pr_body_target_repo(data) or (None, None)
            state = repo_policy_state(
                owner=owner,
                repo=repo,
                cwd=Path(cwd) if cwd else None,
            )
            has_stamp = any(MOJI_URL_RE.search(text) for text in active)
            if not has_stamp and state in (POLICY_LEAKS, POLICY_UNKNOWN):
                return 0
            if has_stamp and state == POLICY_LEAKS:
                sys.stderr.write(_PR_BODY_LEAK_REMINDER)
                return 2
    
        for inspect_text in active:
            urls = MOJI_URL_RE.findall(inspect_text)
            for stage in PIPELINE:
                rc 
  • hooks/mojiemoji_md_edit_warn.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook: warn (not block) when an Edit/Write/MultiEdit on a
    documentation `*.md` file leaves prestamp drift.
    
    Local file edits never flow through the gh / MCP gate, so README /
    docs / SKILL.md / agents prompts can silently drift away from the
    catalog without anyone noticing — exactly the dogfood-followup problem
    the project keeps re-discovering (#91). This hook reads the file after
    the edit lands, runs `prestamp.py` over it in a subprocess, and if the
    output differs from the current contents, emits a unified diff to
    stderr with the suggested transform.
    
    It never blocks — the goal is awareness, not enforcement. The CI
    drift check (#91 / catalog-drift-check sibling) is the hard gate.
    
    Matched file paths (anything else is silently ignored):
      - any `*.md` under the repo root, restricted to:
        - `README.md`
        - `docs/**/*.md`
        - `agents/**/*.md`
        - `skills/**/SKILL.md`
        - `CHANGELOG.md`
    
    Authors who want a clean before/after region can wrap it with
    `<!-- mojiemoji:off -->` / `<!-- mojiemoji:on -->` — prestamp respects
    the markers, so this hook will be silent for those segments.
    
    Exit code is always 0. Output on stderr only fires when there is
    something the author should look at.
    """
    import json
    import os
    import re
    import subprocess
    import sys
    from pathlib import Path
    
    SKILL_MD_SUFFIX = "SKILL.md"
    JP_RE = re.compile(r"[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff]")
    # Also consider Latin for English/i18n drift warnings (#148)
    LATIN_RE = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
    
    
    def _is_documentation_md(path: Path, repo_root: Path) -> bool:
        try:
            rel = path.resolve().relative_to(repo_root.resolve())
        except (ValueError, OSError):
            return False
        rel_str = str(rel).replace(os.sep, "/")
        if rel_str == "README.md" or rel_str == "CHANGELOG.md":
            return True
        if rel_str.startswith("docs/") and rel_str.endswith(".md"):
            return True
        if rel_str.startswith("agents/") and rel_str.endswith(".md"):
            return True
        if rel_str.startswith("skills/") and rel_str.endswith(SKILL_MD_SUFFIX):
            return True
        return False
    
    
    def _has_japanese(text: str) -> bool:
        return JP_RE.search(text) is not None
    
    
    def _has_latin(text: str) -> bool:
        return LATIN_RE.search(text) is not None
    
    
    def _find_repo_root(start: Path) -> Path | None:
        cur = start.resolve()
        for parent in (cur, *cur.parents):
            if (parent / ".git").exists() or (parent / ".claude-plugin").exists():
                return parent
        return None
    
    
    def _file_path_from_payload(payload: dict) -> Path | None:
        tool_input = payload.get("tool_input") or {}
        raw = tool_input.get("file_path")
        if isinstance(raw, str) and raw:
            return Path(raw)
        return None
    
    
    def _run_prestamp(text: str, plugin_root: Path) -> tuple[str, int]:
        script = plugin_root / "skills" / "mojiemoji-github" / "scripts" / "prestamp.py"
        if not script.exists():
            return "", -1
        proc = subprocess.run(
            [sys.executable, str(script)],
            input=text,
            capture_output=True,
            text=True,
            timeout=15,
        )
        return proc.stdout, proc.returncode
    
    
    def _unified_diff(original: str, transformed: str, label: str) -> str:
        import difflib
        diff_lines = difflib.unified_diff(
            original.splitlines(keepends=True),
            transformed.splitlines(keepends=True),
            fromfile=f"a/{label}",
            tofile=f"b/{label}",
            n=2,
        )
        return "".join(diff_lines)
    
    
    def main() -> int:
        try:
            payload = json.loads(sys.stdin.read() or "{}")
        except json.JSONDecodeError:
            return 0
    
        tool_name = payload.get("tool_name") or ""
        if tool_name not in {"Edit", "Write", "MultiEdit"}:
            return 0
    
        file_path = _file_path_from_payload(payload)
        if file_path is None:
            return 0
        if file_path.suffix != ".md":
            return 0
    
        plugin_root_env = os.environ.get("CLAUDE_PLUGIN_ROOT")
        plugin_root = Path(plugin_root_env) if plugin_root_env else None
        repo_root = _find_repo_root(file_path.parent) or plugin_root
        if repo_root is None:
            return 0
        if not _is_documentation_md(file_path, repo_root):
            return 0
        if not file_path.exists():
            return 0
    
        try:
            current = file_path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            return 0
        if not (_has_japanese(current) or _has_latin(current)):
            return 0
    
        prestamp_root = plugin_root or repo_root
        transformed, rc = _run_prestamp(current, prestamp_root)
        if rc != 0:
            return 0
        if transformed == current:
            return 0
    
        try:
            rel_label = str(file_path.resolve().relative_to(repo_root.resolve()))
        except (ValueError, OSError):
            rel_label = file_path.name
    
        diff = _unified_diff(current, transformed, rel_label)
        if not diff.strip():
            return 0
    
        sys.stderr.write(
            "📎 mojiemoji prestamp drift detected on doc edit "
            f"({rel_label}). The catalog would transform this further "
            "— run `prestamp.py` on the file if you want to apply, or "
            "wrap the affected region in `<!-- mojiemoji:off -->` … "
            "`<!-- mojiemoji:on -->` if it should stay raw. "
            "Suggested diff:\n\n"
        )
        sys.stderr.write(diff)
        sys.stderr.write("\n")
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withmojiemoji-github

mojiemoji.jozo.beer のスタンプ画像で本語の GitHub Markdown (issue / / レビュー / リプライ / リリースノート) を に — これは【マジで】やばい【バグ】ですね のように ワードだけスタンプ化 する mid-sentence インライン強調を でに埋め込む Claude Code 🚀 的にはこの README 自体が dogfooding なので、上から下までスタンプまみれ

Get the whole plugin
Stats
4
Stars
0
Forks
Active
Maintenance
Python
Language
MIT
License
25d ago
Last commit
4mo ago
Created

Repo: jozobeer/mojiemoji-plugin