Ctrl K
ComunidadGuías…

scripts/build_skills_dist.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Package Basic Memory SKILL.md sources into distributable archives.

The canonical skill source lives in ``skills/memory-*/`` as pure markdown. This
script stages and zips those skills into ``dist/`` in two flavors:

  dist/skills/<name>.zip                          Agent Skill (SKILL.md + resources)
  dist/skills/basic-memory.zip                    one combined, uploadable skill
  dist/skills/basic-memory-skills.zip             every skill, for unzipping by hand
  dist/skills-openai/<name>-chatgpt.zip           same skill + agents/openai.yaml
  dist/skills-openai/basic-memory-chatgpt.zip
  dist/skills-openai/basic-memory-skills-chatgpt.zip

The plain flavor is the open Agent Skills format (agentskills.io) — the zip
Claude, Cursor, and the `skills` CLI consume. The ChatGPT flavor adds an
``agents/openai.yaml`` interface block so the skill shows up with a name, blurb,
and brand color in ChatGPT/Codex (see https://learn.chatgpt.com/docs/build-skills).
Every asset name is unique across both flavors, because the publish workflow
uploads them all to one GitHub release (``skills-latest``) whose download URLs
the Basic Memory web app links to.

Claude and ChatGPT accept one skill per upload, so ``basic-memory.zip`` folds the
hosted-friendly skills into a single skill: a hand-written router SKILL.md
(``scripts/skill-bundle/basic-memory.md``) plus one generated
``references/<skill>.md`` per skill, read on demand.

Dependency-free (stdlib only) so it runs under bare ``python3`` in CI, matching
scripts/validate_skills.py — which owns the frontmatter parser we reuse here.
"""

from __future__ import annotations

import argparse
import re
import shutil
import zipfile
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

from validate_skills import (
    distributed_skill_names,
    memory_skill_dirs,
    parse_frontmatter,
    skill_body,
    validate_skill_file,
)

REPO_ROOT = Path(__file__).resolve().parent.parent

# Basic Memory brand blue — matches the Codex plugin's authored openai.yaml files.
BRAND_COLOR = "#2563EB"
BUNDLE_NAME = "basic-memory-skills"
COMBINED_NAME = "basic-memory"
COMBINED_DISPLAY_NAME = "Basic Memory"
COMBINED_SHORT_DESCRIPTION = "Find, read, and save notes in your Basic Memory knowledge base."
# Not named SKILL.md so no skill discovery (the `skills` CLI, even with
# --full-depth; plugin loaders; our memory-* globs) can mistake the template for
# a second, competing copy of the individual skills.
COMBINED_TEMPLATE = REPO_ROOT / "scripts" / "skill-bundle" / "basic-memory.md"
ROUTING_TABLE_MARKER = "<!-- routing-table -->"

# Skills left out of the combined skill. They still ship as individual zips.
COMBINED_EXCLUDED = {
    # Scheduled maintenance of a project's notes, installed on its own rather
    # than routed to from the combined skill.
    "memory-defrag",
    # Scheduled consolidation of recent activity into durable notes; same reason.
    "memory-reflect",
    # Needs the source text copied into the project directory and `bm` CLI verbs.
    "memory-literary-analysis",
}

# Hand-written routing intents. A skill without one routes by its description,
# so a new skill joins the combined skill with no code change.
ROUTING_INTENTS = {
    "memory-quest": (
        "Finish Memory Quest, verify this assistant is set up, or become a Memory Master"
    ),
}

# Skills the router's description promises by name. Chat apps load a skill from
# its description alone, so publishing a router that names a skill it doesn't
# bundle would send users into a skill with none of that skill's instructions.
ROUTER_PROMISES = {"memory-quest": "Memory Quest"}

# evals/ holds test fixtures for skill authors, not instructions for the agent.
EVALS_DIR = "evals"
# agents/ holds OpenAI-only metadata (agents/openai.yaml).
AGENTS_DIR = "agents"
IGNORED_FILES = {".DS_Store"}


@dataclass(frozen=True)
class Flavor:
    dist_subdir: str
    suffix: str
    openai: bool

    def asset(self, base: str) -> str:
        return f"{base}{self.suffix}.zip"


PLAIN = Flavor(dist_subdir="skills", suffix="", openai=False)
CHATGPT = Flavor(dist_subdir="skills-openai", suffix="-chatgpt", openai=True)
FLAVORS = (PLAIN, CHATGPT)


def discover_skills(skills_root: Path) -> list[Path]:
    """Return sorted, distributable memory-* skill directories (internal ones excluded)."""
    distributed = distributed_skill_names(skills_root)
    return [
        skill_dir for skill_dir in memory_skill_dirs(skills_root) if skill_dir.name in distributed
    ]


# --- OpenAI / ChatGPT metadata generation ---


def display_name(name: str) -> str:
    """`memory-tasks` -> `Memory Tasks` for the ChatGPT skill card."""
    return " ".join(word.capitalize() for word in name.split("-"))


def short_description(description: str) -> str:
    """First sentence of the frontmatter description, capped for the UI blurb."""
    # Split on the first sentence terminator followed by whitespace so mid-word
    # colons and abbreviations don't truncate early.
    first = description.strip()
    for terminator in (". ", "! ", "? "):
        idx = first.find(terminator)
        if idx != -1:
            first = first[: idx + 1]
            break
    first = first.rstrip()
    # ChatGPT surfaces this inline; keep it short but never cut mid-word.
    if len(first) > 140:
        first = first[:140].rsplit(" ", 1)[0].rstrip(",;:") + "…"
    return first


def yaml_quote(value: str) -> str:
    """Double-quote a scalar and escape backslashes/quotes for safe YAML."""
    escaped = value.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'


def generate_openai_yaml(source: str, title: str, description: str) -> str:
    """Build an agents/openai.yaml interface block.

    We emit only the ``interface`` section: the MCP dependency that these skills
    need (the basic-memory server) is provided by the host at runtime — the
    plugin's .mcp.json locally, or ChatGPT's connected MCP remotely — not pinned
    in per-skill metadata. Implicit invocation stays at the host default
    (allowed). Hand-tune via a source agents/openai.yaml to override.
    """
    return (
        f"# Generated from {source} by scripts/build_skills_dist.py\n"
        "# ChatGPT / Codex skill metadata — https://learn.chatgpt.com/docs/build-skills\n"
        "interface:\n"
        f"  display_name: {yaml_quote(title)}\n"
        f"  short_description: {yaml_quote(short_description(description))}\n"
        f'  brand_color: "{BRAND_COLOR}"\n'
    )


def write_openai_yaml(skill_out: Path, source: str, title: str, description: str) -> None:
    agents_dir = skill_out / AGENTS_DIR
    agents_dir.mkdir(exist_ok=True)
    (agents_dir / "openai.yaml").write_text(
        generate_openai_yaml(source, title, description), encoding="utf-8", newline="\n"
    )


# --- Staging individual skills ---


def resource_ignore(skill_dir: Path, *, keep_agents: bool) -> Callable[[str, list[str]], set[str]]:
    """copytree ignore hook: drop evals/ (and agents/ unless wanted) at the skill root."""
    top_level = {EVALS_DIR} if keep_agents else {EVALS_DIR, AGENTS_DIR}

    def ignore(directory: str, names: list[str]) -> set[str]:
        ignored = {name for name in names if name in IGNORED_FILES}
        if Path(directory) == skill_dir:
            ignored |= top_level & set(names)
        return ignored

    return ignore


def stage_skill(skill_dir: Path, dest: Path, flavor: Flavor) -> Path:
    """Copy a skill directory into `dest/<name>`, adding openai.yaml for ChatGPT."""
    out = dest / skill_dir.name
    shutil.copytree(skill_dir, out, ignore=resource_ignore(skill_dir, keep_agents=flavor.openai))

    # Respect a hand-authored source agents/openai.yaml; generate otherwise.
    if flavor.openai and not (out / AGENTS_DIR / "openai.yaml").exists():
        frontmatter = parse_frontmatter(skill_dir / "SKILL.md")
        write_openai_yaml(
            out,
            f"skills/{skill_dir.name}/SKILL.md",
            display_name(skill_dir.name),
            frontmatter.get("description", ""),
        )
    return out


# --- The combined `basic-memory` skill ---


def resource_files(skill_dir: Path) -> list[str]:
    """Posix paths of a skill's bundled resources, relative to the skill folder."""
    files: list[str] = []
    for path in sorted(skill_dir.rglob("*")):
        rel = path.relative_to(skill_dir)
        if not path.is_file() or rel.as_posix() == "SKILL.md" or path.name in IGNORED_FILES:
            continue
        if rel.parts[0] in (EVALS_DIR, AGENTS_DIR):
            continue
        files.append(rel.as_posix())
    return files


def rewrite_resource_links(body: str, skill_name: str, resources: list[str]) -> str:
    """Point a skill's references to its own resources at their new home.

    A skill's resources move from `<skill>/<path>` to `references/<skill>/<path>`,
    and its SKILL.md becomes `references/<skill>.md`. Markdown links resolve
    relative to the file, so they gain a `<skill>/` prefix. Backticked paths are
    read by the agent from the skill root, so they gain `references/<skill>/`.
    """
    for resource in resources:
        escaped = re.escape(resource)
        body = re.sub(
            # An optional anchor and an optional "title" are kept as written.
            rf"""\]\((?:\./)?{escaped}(#[^)\s]*)?(\s+(?:"[^"]*"|'[^']*'))?\)""",
            lambda match, target=f"{skill_name}/{resource}": (
                f"]({target}{match.group(1) or ''}{match.group(2) or ''})"
            ),
            body,
        )
        root_relative = f"references/{skill_name}/{resource}"
        body = re.sub(rf"`(?:\./)?{escaped}`", f"`{root_relative}`", body)
        # Link text that spells out the path is read like a backticked path.
        body = re.sub(rf"\[(?:\./)?{escaped}\]\(", f"[{root_relative}](", body)
    return body


def reference_document(skill_dir: Path) -> str:
    """A skill's SKILL.md as a reference: frontmatter swapped for a short header."""
    skill_file = skill_dir / "SKILL.md"
    description = parse_frontmatter(skill_file)["description"]
    body = rewrite_resource_links(
        skill_body(skill_file).lstrip("\n"), skill_dir.name, resource_files(skill_dir)
    )
    header = (
        f"<!-- Generated from skills/{skill_dir.name}/SKILL.md by "
        "scripts/build_skills_dist.py. Edit the source, not this file. -->\n\n"
        f"> **{skill_dir.name}** — {description}\n\n"
    )
    return header + body


def routing_table(skill_dirs: list[Path]) -> str:
    rows = ["| Request | Read in full first |", "|---|---|"]
    for skill_dir in skill_dirs:
        intent = (
            ROUTING_INTENTS.get(skill_dir.name)
            or parse_frontmatter(skill_dir / "SKILL.md")["description"]
        )
        cell = intent.replace("|", "\\|")
        rows.append(f"| {cell} | `references/{skill_dir.name}.md` |")
    return "\n".join(rows)


def combined_skill_dirs(skill_dirs: list[Path]) -> list[Path]:
    return [skill_dir for skill_dir in skill_dirs if skill_dir.name not in COMBINED_EXCLUDED]


def stage_combined_skill(
    skill_dirs: list[Path], dest: Path, flavor: Flavor, template: Path = COMBINED_TEMPLATE
) -> Path:
    """Stage `dest/basic-memory`: router SKILL.md plus generated references."""
    included = combined_skill_dirs(skill_dirs)
    out = dest / COMBINED_NAME
    references = out / "references"
    references.mkdir(parents=True)

    template_text = template.read_text(encoding="utf-8")
    if ROUTING_TABLE_MARKER not in template_text:
        raise SystemExit(f"{template}: missing {ROUTING_TABLE_MARKER} marker")
    router = out / "SKILL.md"
    router.write_text(
        template_text.replace(ROUTING_TABLE_MARKER, routing_table(included)),
        encoding="utf-8",
        newline="\n",
    )

    for skill_dir in included:
        (references / f"{skill_dir.name}.md").write_text(
            reference_document(skill_dir), encoding="utf-8", newline="\n"
        )
        for resource in resource_files(skill_dir):
            target = references / skill_dir.name / resource
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(skill_dir / resource, target)

    # The router is generated, so the source validator never sees it. Check it
    # like any skill (frontmatter, name, description, links) before it is zipped.
    validate_skill_file(router, COMBINED_NAME)
    description = parse_frontmatter(router)["description"]
    bundled = {skill_dir.name for skill_dir in included}
    for skill, phrase in ROUTER_PROMISES.items():
        if phrase in description and skill not in bundled:
            raise SystemExit(
                f"{template}: description promises {phrase} but {skill} is not bundled"
            )

    if flavor.openai:
        write_openai_yaml(
            out,
            "scripts/skill-bundle/basic-memory.md",
            COMBINED_DISPLAY_NAME,
            COMBINED_SHORT_DESCRIPTION,
        )
    return out


# --- Archiving ---


def zip_tree(source_root: Path, zip_path: Path, arc_base: Path | None = None) -> None:
    """Zip every file under `source_root`, with arc paths relative to `arc_base`.

    `arc_base` defaults to `source_root`. Passing the staging root as `arc_base`
    keeps the `<name>/` folder prefix inside a single-skill zip, so unzipping it
    (or dropping it into an uploader) lands a `<name>/SKILL.md` skill directory —
    the layout the Agent Skills spec and Claude / ChatGPT uploaders expect.
    """
    base = arc_base or source_root
    zip_path.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
        for path in sorted(source_root.rglob("*")):
            if path.is_file():
                archive.write(path, path.relative_to(base).as_posix())


def verify_layout(archive: Path, skill_names: set[str]) -> None:
    """Fail the build unless `archive` holds exactly `<name>/SKILL.md` skill folders.

    Claude's uploader requires the skill folder as the zip root; a zip that
    unpacks loose files or test fixtures would upload broken or not at all.
    """
    with zipfile.ZipFile(archive) as zf:
        members = zf.namelist()
    roots = {member.split("/", 1)[0] for member in members}
    missing = sorted(name for name in skill_names if f"{name}/SKILL.md" not in members)
    if roots != skill_names or missing:
        raise SystemExit(
            f"{archive.name}: expected skill folders {sorted(skill_names)} with SKILL.md, "
            f"found roots {sorted(roots)}"
        )
    if any(f"/{EVALS_DIR}/" in member for member in members):
        raise SystemExit(f"{archive.name}: contains {EVALS_DIR}/ test fixtures")


def build_flavor(
    skill_dirs: list[Path], dist_dir: Path, flavor: Flavor, template: Path = COMBINED_TEMPLATE
) -> list[Path]:
    """Stage + zip each skill, the combined skill, and the bundle for one flavor.

    Returns the archives written. Staging happens in a sibling ``.stage``
    directory so the zips contain a clean ``<name>/SKILL.md`` root.
    """
    stage = dist_dir / ".stage"
    if stage.exists():
        shutil.rmtree(stage)
    stage.mkdir(parents=True)
    archives: list[Path] = []

    for skill_dir in skill_dirs:
        staged = stage_skill(skill_dir, stage, flavor)
        archive = dist_dir / flavor.asset(skill_dir.name)
        # arc_base=stage keeps the `<name>/` prefix inside the single-skill zip.
        zip_tree(staged, archive, arc_base=stage)
        verify_layout(archive, {skill_dir.name})
        archives.append(archive)

    # Bundle: every individual skill under one zip root, for unzipping by hand.
    bundle = dist_dir / flavor.asset(BUNDLE_NAME)
    zip_tree(stage, bundle)
    verify_layout(bundle, {skill_dir.name for skill_dir in skill_dirs})
    archives.append(bundle)

    # The combined skill is staged after the bundle so the bundle never holds it.
    combined_stage = dist_dir / ".stage-combined"
    if combined_stage.exists():
        shutil.rmtree(combined_stage)
    staged = stage_combined_skill(skill_dirs, combined_stage, flavor, template)
    combined = dist_dir / flavor.asset(COMBINED_NAME)
    zip_tree(staged, combined, arc_base=combined_stage)
    verify_layout(combined, {COMBINED_NAME})
    archives.append(combined)

    shutil.rmtree(stage)
    shutil.rmtree(combined_stage)
    return archives


def build_all(skills_root: Path, dist_root: Path, template: Path = COMBINED_TEMPLATE) -> list[Path]:
    """Rebuild every flavor from scratch and return all archives written."""
    skill_dirs = discover_skills(skills_root)
    archives: list[Path] = []
    for flavor in FLAVORS:
        flavor_dir = dist_root / flavor.dist_subdir
        # Rebuild from scratch so stale skills don't linger.
        if flavor_dir.exists():
            shutil.rmtree(flavor_dir)
        archives.extend(build_flavor(skill_dirs, flavor_dir, flavor, template))

    # Every asset lands in one GitHub release, so basenames must be unique.
    names = [archive.name for archive in archives]
    duplicates = sorted({name for name in names if names.count(name) > 1})
    if duplicates:
        raise SystemExit(f"duplicate archive names across flavors: {duplicates}")
    return archives


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--skills-root",
        type=Path,
        default=REPO_ROOT / "skills",
        help="Directory holding memory-* skill folders (default: <repo>/skills)",
    )
    parser.add_argument(
        "--dist-root",
        type=Path,
        default=REPO_ROOT / "dist",
        help="Output directory for archives (default: <repo>/dist)",
    )
    args = parser.parse_args()

    archives = build_all(args.skills_root.resolve(), args.dist_root)
    print(f"packaged {len(archives)} archives")
    for archive in archives:
        print(f"  {archive}")


if __name__ == "__main__":
    main()