Skip to content
Marketing
Hook

Hooks

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

Guide
From plugin
claude-seo
17k31 skills18 agents1 hook
Install
> /plugin marketplace add AgriciDaniel/claude-seo
> /plugin install claude-seo@agricidaniel-claude-seo

Ships with claude-seo. Installing the plugin gets these hooks.

What fires, and when

PostToolUse

  • MatchesEdit|Writenode
Read hooks/hooks.json

Where it lives

  • hooks/run-python-hook.jsGitHub
    Read the script
    #!/usr/bin/env node
    "use strict";
    
    const { spawnSync } = require("child_process");
    
    function stripWrappingQuotes(value) {
      return value.replace(/^["']|["']$/g, "");
    }
    
    function pythonCandidates() {
      const candidates = [];
      if (process.env.CLAUDE_SEO_PYTHON) {
        candidates.push({
          label: "CLAUDE_SEO_PYTHON",
          exe: stripWrappingQuotes(process.env.CLAUDE_SEO_PYTHON),
          args: [],
        });
      }
      candidates.push(
        { label: "py -3", exe: "py", args: ["-3"] },
        { label: "python3", exe: "python3", args: [] },
        { label: "python", exe: "python", args: [] },
      );
      return candidates;
    }
    
    function isStoreStubOutput(text) {
      return /Microsoft Store|WindowsApps|App execution alias|was not found/i.test(text);
    }
    
    function probe(candidate) {
      const script = "import sys; print(sys.executable); print(sys.version.split()[0])";
      const result = spawnSync(candidate.exe, [...candidate.args, "-c", script], {
        encoding: "utf8",
      });
      const output = `${result.stdout || ""}\n${result.stderr || ""}`;
      return result.status === 0 && Boolean((result.stdout || "").trim()) && !isStoreStubOutput(output);
    }
    
    function main() {
      const [, , hookScript, ...hookArgs] = process.argv;
      if (!hookScript) {
        process.exit(0);
      }
    
      for (const candidate of pythonCandidates()) {
        if (!probe(candidate)) {
          continue;
        }
        const result = spawnSync(candidate.exe, [...candidate.args, hookScript, ...hookArgs], {
          stdio: "inherit",
        });
        if (result.error) {
          continue;
        }
        process.exit(result.status === null ? 1 : result.status);
      }
    
      console.error(
        "Claude SEO hook could not find Python. Tried CLAUDE_SEO_PYTHON, py -3, python3, python.",
      );
      process.exit(1);
    }
    
    main();
    
  • hooks/validate-schema.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """Post-edit schema validation hook for Claude Code.
    
    Validates JSON-LD schema after file edits. Returns exit code 2 to block
    if critical validation errors found.
    
    Hook configuration in ~/.claude/settings.json:
    {
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "Edit|Write",
            "hooks": [
              {
                "type": "command",
                "command": "node",
                "args": [
                  "${CLAUDE_PLUGIN_ROOT}/hooks/run-python-hook.js",
                  "${CLAUDE_PLUGIN_ROOT}/hooks/validate-schema.py",
                  "${tool_input.file_path}"
                ]
              }
            ]
          }
        ]
      }
    }
    
    Note: matcher filters by tool name only (Edit, Write). The script itself
    checks if the file contains schema markup before validating.
    """
    
    import json
    import os
    import re
    import sys
    from typing import Any, List
    
    BRACKET_PLACEHOLDERS = (
        "[Business Name]",
        "[City]",
        "[State]",
        "[Phone]",
        "[Address]",
        "[Your",
        "[INSERT",
        "[URL]",
        "[Email]",
    )
    BARE_PLACEHOLDER_RE = re.compile(r"\bREPLACE(?:_[A-Z]+)*\b")
    
    # Match every <script ...>...</script> pair, then filter on the type attribute.
    # The previous pattern required ``type`` to be the first and only attribute, so
    # blocks carrying a CSP ``nonce``, an ``id`` or ``data-*`` attributes, or an
    # unquoted type value were skipped without validation.
    #
    # The attribute group is a small tokenizer, not a plain ``[^>]*``: it consumes a
    # double-quoted value, a single-quoted value, or a run of characters that is
    # neither a quote nor ``>``. A ``[^>]*`` scan ends the tag at the first ``>`` it
    # sees, quoted or not, so an attribute value containing ``>`` (``data-cond="a>b"``,
    # a templated nonce) truncated the tag early and fed the remainder of the
    # attributes plus the real body to the JSON parser as garbage. Treating a quoted
    # span as atomic keeps an embedded ``>`` from ending the tag prematurely.
    # The fallback class excludes both quote characters: if it matched an
    # apostrophe, the alternation would be ambiguous and a tag with many
    # apostrophes and no closing tag would backtrack exponentially, hanging the
    # blocking hook.
    _ATTRS_RE = r'(?:"[^"]*"|\'[^\']*\'|[^"\'>])*'
    SCRIPT_TAG_RE = re.compile(
        r"<script\b(" + _ATTRS_RE + r")>(.*?)</script\s*>", re.DOTALL | re.IGNORECASE
    )
    LD_JSON_TYPE_RE = re.compile(
        r"""(?:^|\s)type\s*=\s*"""
        r"""(?:"application/ld\+json"|'application/ld\+json'|application/ld\+json(?=\s|$))""",
        re.IGNORECASE,
    )
    
    # Server- or client-side template expressions that render JSON-LD at runtime.
    # The hook runs on .jsx/.tsx/.vue/.svelte/.php/.ejs sources, where the script
    # body is frequently an expression rather than literal JSON. Those blocks cannot
    # be validated statically and must not be reported as invalid JSON.
    SERVER_TEMPLATE_RE = re.compile(
        r"""^(?:
            <\?(?:php\b|=)            # <?php ... ?> / <?= ... ?>
          | <%                        # EJS / ERB
        )""",
        re.VERBOSE,
    )
    COMPONENT_EXPRESSION_RE = re.compile(
        r"""^(?:
            \{\{                      # Vue / Handlebars / Twig
          | \{@html\b                 # Svelte
          | \$\{                      # JS template literal
          | \{\s*[A-Za-z_$][\w$.]*    # JSX expression: {schema} / {JSON.stringify(...)}
        )""",
        re.VERBOSE,
    )
    COMPONENT_EXTENSIONS = (".jsx", ".tsx", ".vue", ".svelte")
    
    SCHEMA_ORG_CONTEXTS = frozenset(
        {"https://schema.org", "http://schema.org", "https://schema.org/", "http://schema.org/"}
    )
    
    
    def _configure_utf8() -> None:
        """Keep hook diagnostics printable on legacy Windows console encodings."""
        for stream in (sys.stdout, sys.stderr):
            reconfigure = getattr(stream, "reconfigure", None)
            if reconfigure:
                reconfigure(encoding="utf-8", errors="replace")
    
    
    def _extract_ld_json_blocks(content: str) -> List[str]:
        """Return the bodies of every ``<script type="application/ld+json">`` block.
    
        Attribute order, extra attributes (``nonce``, ``id``, ``data-*``), tag case
        and unquoted type values are all accepted; only the type value is decisive.
        """
        blocks = []
        for attributes, body in SCRIPT_TAG_RE.findall(content):
            if LD_JSON_TYPE_RE.search(attributes):
                blocks.append(body)
        return blocks
    
    
    def _is_template_expression(block: str, filepath: str = "") -> bool:
        """True when the script body is rendered at runtime rather than literal JSON.
    
        Server-side markers (PHP, EJS) are never valid JSON and are skipped for
        every file type. Component expressions (JSX, Vue, Svelte, template
        literals) are only skipped in component sources, so a malformed object
        literal in a plain ``.html`` file is still reported.
        """
        if SERVER_TEMPLATE_RE.match(block):
            return True
        if filepath.lower().endswith(COMPONENT_EXTENSIONS):
            return bool(COMPONENT_EXPRESSION_RE.match(block))
        return False
    
    
    def _is_schema_org_context(value: Any) -> bool:
        """Accept the schema.org context in its string, list and object forms."""
        if isinstance(value, str):
            return value in SCHEMA_ORG_CONTEXTS
        if isinstance(value, list):
            return any(_is_schema_org_context(item) for item in value)
        if isinstance(value, dict):
            return _is_schema_org_context(value.get("@vocab"))
        return False
    
    
    def validate_jsonld(content: str, filepath: str = "") -> List[str]:
        """Validate JSON-LD blocks in HTML content."""
        errors = []
        blocks = _extract_ld_json_blocks(content)
    
        if not blocks:
            return []  # No schema found; not an error
    
        for i, block in enumerate(blocks, 1):
            block = block.strip()
            if _is_template_expression(block, filepath):
                continue  # Rendered at runtime; nothing to validate statically
            try:
                data = json.loads(block)
            except json.JSONDecodeError as e:
                errors.append(f"Block {i}: Invalid JSON; {e}")
                continue
    
            if isinstance(data, list):
                f

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 withclaude-seo

Claude SEO is an open-source SEO analysis plugin for Claude Code. It runs 25 sub-skills and 18 specialist agents in parallel across technical SEO, content quality (E-E-A-T), Schema.org markup, AI search optimization (GEO), local SEO, e-commerce, and

Get the whole plugin