Hmbown/CodeWhale · error · ConversionError

Configuration and skill entrypoints must be UTF-8.

Error message

Configuration and skill entrypoints must be UTF-8.

What it means

Guard in the plugin converter's text_file() helper: a configuration or skill entrypoint source file failed UTF-8 decoding (invalid byte sequences). The converter requires these files to be valid UTF-8 so plugin/skill content is safe to embed and hash; a binary or wrong-encoding file is the input at fault.

Solutions

  1. Convert the file to UTF-8 (e.g. iconv -f UTF-16 -t UTF-8 file > file)
  2. Re-save the file in the editor with UTF-8 encoding
  3. Verify the entrypoint is actually a text Markdown/config file, not a binary

Example fix

# before
iconv detect: file is UTF-16LE
// after
iconv -f UTF-16LE -t UTF-8 SKILL.md > SKILL.md
Defensive patterns

Strategy: validation

Validate before calling

raw = path.read_bytes()
raw.decode("utf-8")  # raises UnicodeDecodeError before calling the converter

Type guard

def is_utf8_file(p) -> bool:
    try:
        p.read_bytes().decode("utf-8")
        return True
    except UnicodeDecodeError:
        return False

Prevention

When it happens

Trigger: Calling text_file() (via skill_files or mcp_config) on a SKILL.md, plugin config, or MCP config file that is not UTF-8, e.g. saved as UTF-16, Latin-1, or containing binary bytes.

Common situations: Files edited on Windows with UTF-16 default encoding; files downloaded with wrong charset; images or binaries accidentally used as skill entrypoints.

Understand the failure class

Background: "Invalid value" and "allowed values are" config errors: what your library rejected and how to fix it — this error's family across 41 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/33f9d25b9032e8e7. Report an issue: GitHub.

Appendix: source

Thrown at scripts/convert-plugin.py:127

    path = plain_path(path)
    info = path.stat()
    require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "Only regular, non-linked source files are supported.")
    require(info.st_size <= limit, "Source file exceeds the conversion size limit.")
    # O_NOFOLLOW protects the final component against replacement after lstat.
    fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
    with os.fdopen(fd, "rb") as source:
        opened = os.fstat(source.fileno())
        require((info.st_dev, info.st_ino) == (opened.st_dev, opened.st_ino), "Source changed during conversion.")
        content = source.read(limit + 1)
    require(len(content) <= limit, "Source file exceeds the conversion size limit.")
    return content


def text_file(path):
    try:
        return read_file(path).decode("utf-8")
    except UnicodeError:
        raise ConversionError("Configuration and skill entrypoints must be UTF-8.") from None


def skill_files(path, max_files=MAX_FILES, max_bytes=MAX_BYTES):
    source = plain_path(path)
    entry = source / "SKILL.md" if source.is_dir() else source
    require(entry.name == "SKILL.md" or entry.suffix == ".md", "Select a skill directory or Markdown skill file.")
    text = text_file(entry)
    parts = re.split(r"^---\s*$", text, maxsplit=2, flags=re.MULTILINE)
    require(len(parts) == 3 and not parts[0].strip(), "Skills need YAML frontmatter with name and description.")
    meta = mapping(data(parts[1]), {"name", "description", "license", "compatibility", "metadata",
                                  "disable-model-invocation", "user-invocable"})
    name = meta.get("name")
    require(isinstance(name, str) and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name)
            and len(name) <= 64, "Skill name must be a kebab-case identifier of at most 64 characters.")
    description = meta.get("description")
    require(isinstance(description, str) and description.strip(), "Skills need a non-empty description.")
    require("---" not in description, "Skill description contains a delimiter the native reader cannot preserve.")
    require(type(meta.get("user-invocable", True)) is bool and meta.get("user-invocable", True),

View on GitHub (pinned to 433685b202)