#!/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/.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/-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/.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 = "" # 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/`, 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 `/` to `references//`, and its SKILL.md becomes `references/.md`. Markdown links resolve relative to the file, so they gain a `/` prefix. Backticked paths are read by the agent from the skill root, so they gain `references//`. """ 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"\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 `/` folder prefix inside a single-skill zip, so unzipping it (or dropping it into an uploader) lands a `/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 `/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 ``/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 `/` 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: /skills)", ) parser.add_argument( "--dist-root", type=Path, default=REPO_ROOT / "dist", help="Output directory for archives (default: /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()