CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/blog-writer

Write and revise developer blog posts or run a humanizer / humanization pass on existing drafts using the AI anti-pattern catalog, structural audits, and reusable personal and explicitly selected corporate writing identities.

74

Quality

93%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

identity_lib.pyskills/blog-writer/

"""Shared validation and path contracts for writing-identity tools."""

from __future__ import annotations

import json
import os
import re
import stat
from pathlib import Path

NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ROLE_RE = NAME_RE
CURRENT_USER_PREFIXES = tuple(
    f"~{separator}" for separator in (os.sep, os.altsep) if separator
)
CURRENT_SELECTION_SCHEMA = 2
SELECTION_SCHEMAS = {1, CURRENT_SELECTION_SCHEMA}


class IdentityError(ValueError):
    """Selected identity or project configuration is invalid."""


class ToolError(RuntimeError):
    """An identity tool could not inspect or update the requested files."""


def inside(base: Path, candidate: Path) -> bool:
    try:
        candidate.relative_to(base)
        return True
    except ValueError:
        return False


def load_json(path: Path) -> dict:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise IdentityError(
            f"required identity file is missing: {path}; restore it or select a "
            "different identity, then re-run"
        ) from exc
    except (UnicodeError, json.JSONDecodeError) as exc:
        raise IdentityError(f"invalid JSON {path}: {exc}") from exc
    except OSError as exc:
        raise ToolError(f"cannot read {path}: {exc}") from exc
    if not isinstance(value, dict):
        raise IdentityError(f"expected a JSON object: {path}")
    return value


def uses_named_user_path(raw: str) -> bool:
    return (
        raw.startswith("~") and raw != "~" and not raw.startswith(CURRENT_USER_PREFIXES)
    )


def resolve_relative(raw: str, base: Path, *, allow_named_user: bool = False) -> Path:
    try:
        if uses_named_user_path(raw) and not allow_named_user:
            raise IdentityError(
                f"invalid path {raw!r}: named-user expansion is not allowed"
            )
        path = Path(raw).expanduser()
        if not path.is_absolute():
            path = base / path
        return path.resolve()
    except IdentityError:
        raise
    except (RuntimeError, ValueError) as exc:
        raise IdentityError(f"invalid path {raw!r}: {exc}") from exc
    except OSError as exc:
        raise ToolError(f"cannot resolve path {raw!r}: {exc}") from exc


def validate_identity(
    raw_path: str,
    expected_type: str,
    base: Path,
    *,
    allow_named_user: bool = False,
) -> dict:
    root = resolve_relative(raw_path, base, allow_named_user=allow_named_user)
    manifest_path = root / "identity.json"
    manifest = load_json(manifest_path)
    errors: list[str] = []
    if manifest.get("schema_version") != 1:
        errors.append("schema_version must be 1")
    if manifest.get("type") != expected_type:
        errors.append(f"type must be {expected_type!r}")
    name = manifest.get("name")
    if not isinstance(name, str) or not NAME_RE.fullmatch(name):
        errors.append("name must be lowercase kebab-case")
    if manifest.get("status") not in {"draft", "approved"}:
        errors.append("status must be 'draft' or 'approved'")
    if manifest.get("entrypoint") != "identity.md":
        errors.append("entrypoint must be 'identity.md'")
    if manifest.get("sources") != "sources.md":
        errors.append("sources must be 'sources.md'")

    files: list[dict[str, str]] = []

    def add_file(role: str, raw: object) -> None:
        if not isinstance(raw, str) or not raw:
            errors.append(f"{role} path must be a non-empty string")
            return
        if "\x00" in raw:
            errors.append(f"{role} path must not contain a NUL byte")
            return
        if raw.startswith("~"):
            errors.append(f"{role} path must not start with '~': {raw}")
            return
        if Path(raw).is_absolute():
            errors.append(f"{role} path must be relative: {raw}")
            return
        candidate = resolve_relative(raw, root)
        if not inside(root, candidate):
            errors.append(f"{role} path escapes identity directory: {raw}")
        elif not candidate.is_file():
            errors.append(f"missing {role} file: {candidate}")
        else:
            files.append({"role": role, "path": str(candidate)})

    add_file("entrypoint", manifest.get("entrypoint"))
    resources = manifest.get("resources", [])
    if not isinstance(resources, list):
        errors.append("resources must be a list")
    else:
        for index, resource in enumerate(resources):
            if not isinstance(resource, dict):
                errors.append(f"resources[{index}] must be an object")
                continue
            role = resource.get("role")
            if not isinstance(role, str) or not ROLE_RE.fullmatch(role):
                errors.append(f"resources[{index}].role must be lowercase kebab-case")
                continue
            add_file(role, resource.get("path"))
    add_file("sources", manifest.get("sources"))

    if errors:
        raise IdentityError(
            f"invalid {expected_type} identity at {root}: " + "; ".join(errors)
        )
    return {
        "mode": "v1",
        "root": str(root),
        "name": name,
        "status": manifest["status"],
        "files": files,
    }


def legacy_persona(path: Path) -> dict | None:
    voice = path / "voice.md"
    try:
        if not voice.is_file():
            return None
        if not voice.read_text(encoding="utf-8").strip():
            return None
    except UnicodeError as exc:
        raise IdentityError(f"legacy voice is not UTF-8: {voice}") from exc
    except OSError as exc:
        raise ToolError(f"cannot read legacy voice {voice}: {exc}") from exc

    files: list[dict[str, str]] = []
    for role, name in (
        ("voice", "voice.md"),
        ("composition", "framework.md"),
        ("examples", "examples.md"),
        ("bio", "bio.md"),
        ("legacy-product-context", "product.md"),
    ):
        candidate = path / name
        try:
            if candidate.is_file() and candidate.stat().st_size:
                files.append({"role": role, "path": str(candidate.resolve())})
        except OSError as exc:
            raise ToolError(f"cannot inspect legacy file {candidate}: {exc}") from exc
    return {
        "mode": "legacy",
        "root": str(path.resolve()),
        "name": "legacy-persona",
        "status": "approved",
        "files": files,
    }


def load_selection_config(path: Path) -> dict:
    config = load_json(path)
    errors: list[str] = []
    schema_version = config.get("schema_version")
    if schema_version not in SELECTION_SCHEMAS:
        errors.append("schema_version must be 1 or 2")
    for key in ("personal", "corporate"):
        if key in config and (not isinstance(config[key], str) or not config[key]):
            errors.append(f"{key} must be a non-empty path string when present")
        elif (
            schema_version == CURRENT_SELECTION_SCHEMA
            and key in config
            and uses_named_user_path(config[key])
        ):
            errors.append(f"{key} must not use named-user expansion")
    if errors:
        raise IdentityError(f"invalid identity config {path}: " + "; ".join(errors))
    return config


def selection_target(config_path: Path, blog_home: Path) -> Path | None:
    """Resolve an existing selection file without crossing the project boundary."""
    try:
        config_path.lstat()
    except FileNotFoundError:
        try:
            prospective = config_path.resolve(strict=False)
        except RuntimeError as exc:
            raise IdentityError(
                f"identity config path has a symlink loop: {config_path}"
            ) from exc
        except OSError as exc:
            raise ToolError(
                f"cannot resolve identity config path {config_path}: {exc}"
            ) from exc
        if not inside(blog_home, prospective):
            raise IdentityError(
                f"identity config path escapes blog home: {prospective}"
            )
        return None
    except OSError as exc:
        raise ToolError(f"cannot inspect {config_path}: {exc}") from exc

    try:
        target = config_path.resolve(strict=True)
    except FileNotFoundError as exc:
        raise IdentityError(
            f"identity config is a dangling symlink: {config_path}"
        ) from exc
    except RuntimeError as exc:
        raise IdentityError(
            f"identity config has a symlink loop: {config_path}"
        ) from exc
    except OSError as exc:
        raise ToolError(f"cannot resolve identity config {config_path}: {exc}") from exc
    if not inside(blog_home, target):
        raise IdentityError(f"identity config path escapes blog home: {target}")

    try:
        target_mode = target.stat().st_mode
    except OSError as exc:
        raise ToolError(f"cannot inspect identity config {target}: {exc}") from exc
    if not stat.S_ISREG(target_mode):
        raise IdentityError(f"identity config is not a regular file: {target}")
    return target


def read_paths(*identities: dict | None) -> list[str]:
    ordered: list[str] = []
    seen: set[str] = set()
    for identity in identities:
        if not identity:
            continue
        for item in identity["files"]:
            path = item["path"]
            if path not in seen:
                ordered.append(path)
                seen.add(path)
    return ordered

README.md

tile.json