Skip to content

Commit fce0cfc

Browse files
committed
chore: add dist recipe for skills
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0e59bbf commit fce0cfc

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

scripts/build_skills_dist.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env python3
2+
"""Package Basic Memory SKILL.md sources into distributable archives.
3+
4+
The canonical skill source lives in ``skills/memory-*/`` as pure markdown. This
5+
script stages and zips those skills into ``dist/`` in two flavors:
6+
7+
dist/skills/<name>.zip Agent Skill (SKILL.md + any resources)
8+
dist/skills/basic-memory-skills.zip all skills bundled together
9+
dist/skills-openai/<name>.zip same skill + agents/openai.yaml metadata
10+
11+
The plain flavor is the open Agent Skills format (agentskills.io) — the zip
12+
Claude Desktop, Cursor, and the `skills` CLI consume. The OpenAI flavor adds an
13+
``agents/openai.yaml`` interface block so the skill shows up with a name, blurb,
14+
and brand color in ChatGPT/Codex (see https://learn.chatgpt.com/docs/build-skills).
15+
16+
Dependency-free (stdlib only) so it runs under bare ``python3`` in CI, matching
17+
scripts/validate_skills.py — which owns the frontmatter parser we reuse here.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import shutil
24+
import zipfile
25+
from pathlib import Path
26+
27+
from validate_skills import parse_frontmatter
28+
29+
REPO_ROOT = Path(__file__).resolve().parent.parent
30+
31+
# Basic Memory brand blue — matches the Codex plugin's authored openai.yaml files.
32+
BRAND_COLOR = "#2563EB"
33+
BUNDLE_NAME = "basic-memory-skills"
34+
35+
36+
def discover_skills(skills_root: Path) -> list[Path]:
37+
"""Return sorted memory-* skill directories, or fail loudly if none exist."""
38+
skill_dirs = sorted(p for p in skills_root.glob("memory-*") if p.is_dir())
39+
if not skill_dirs:
40+
raise SystemExit(f"No memory-* skill directories found in {skills_root}")
41+
return skill_dirs
42+
43+
44+
# --- OpenAI / ChatGPT metadata generation ---
45+
46+
47+
def display_name(name: str) -> str:
48+
"""`memory-tasks` -> `Memory Tasks` for the ChatGPT skill card."""
49+
return " ".join(word.capitalize() for word in name.split("-"))
50+
51+
52+
def short_description(description: str) -> str:
53+
"""First sentence of the frontmatter description, capped for the UI blurb."""
54+
# Split on the first sentence terminator followed by whitespace so mid-word
55+
# colons and abbreviations don't truncate early.
56+
first = description.strip()
57+
for terminator in (". ", "! ", "? "):
58+
idx = first.find(terminator)
59+
if idx != -1:
60+
first = first[: idx + 1]
61+
break
62+
first = first.rstrip()
63+
# ChatGPT surfaces this inline; keep it short but never cut mid-word.
64+
if len(first) > 140:
65+
first = first[:140].rsplit(" ", 1)[0].rstrip(",;:") + "…"
66+
return first
67+
68+
69+
def yaml_quote(value: str) -> str:
70+
"""Double-quote a scalar and escape backslashes/quotes for safe YAML."""
71+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
72+
return f'"{escaped}"'
73+
74+
75+
def generate_openai_yaml(name: str, frontmatter: dict[str, str]) -> str:
76+
"""Build an agents/openai.yaml interface block from the skill frontmatter.
77+
78+
We emit only the ``interface`` section: the MCP dependency that these skills
79+
need (the basic-memory server) is provided by the host at runtime — the
80+
plugin's .mcp.json locally, or ChatGPT's connected MCP remotely — not pinned
81+
in per-skill metadata. Hand-tune via a source agents/openai.yaml to override.
82+
"""
83+
description = frontmatter.get("description", "")
84+
return (
85+
f"# Generated from skills/{name}/SKILL.md by scripts/build_skills_dist.py\n"
86+
"# ChatGPT / Codex skill metadata — https://learn.chatgpt.com/docs/build-skills\n"
87+
"interface:\n"
88+
f" display_name: {yaml_quote(display_name(name))}\n"
89+
f" short_description: {yaml_quote(short_description(description))}\n"
90+
f' brand_color: "{BRAND_COLOR}"\n'
91+
)
92+
93+
94+
# --- Staging and archiving ---
95+
96+
97+
def stage_skill(skill_dir: Path, dest: Path, *, openai: bool) -> None:
98+
"""Copy a skill directory into `dest/<name>`, optionally adding openai.yaml."""
99+
out = dest / skill_dir.name
100+
shutil.copytree(skill_dir, out)
101+
102+
if openai:
103+
# Respect a hand-authored source agents/openai.yaml; generate otherwise.
104+
if not (out / "agents" / "openai.yaml").exists():
105+
frontmatter = parse_frontmatter(skill_dir / "SKILL.md")
106+
agents_dir = out / "agents"
107+
agents_dir.mkdir(exist_ok=True)
108+
(agents_dir / "openai.yaml").write_text(
109+
generate_openai_yaml(skill_dir.name, frontmatter)
110+
)
111+
112+
113+
def zip_tree(source_root: Path, zip_path: Path, arc_base: Path | None = None) -> None:
114+
"""Zip every file under `source_root`, with arc paths relative to `arc_base`.
115+
116+
`arc_base` defaults to `source_root`. Passing the staging root as `arc_base`
117+
keeps the `<name>/` folder prefix inside a single-skill zip, so unzipping it
118+
(or dropping it into an uploader) lands a `<name>/SKILL.md` skill directory —
119+
the layout the Agent Skills spec and Claude Desktop / ChatGPT uploaders expect.
120+
"""
121+
base = arc_base or source_root
122+
zip_path.parent.mkdir(parents=True, exist_ok=True)
123+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
124+
for path in sorted(source_root.rglob("*")):
125+
if path.is_file():
126+
archive.write(path, path.relative_to(base).as_posix())
127+
128+
129+
def build_flavor(skill_dirs: list[Path], dist_dir: Path, *, openai: bool) -> int:
130+
"""Stage + zip each skill (and the combined bundle) for one flavor.
131+
132+
Returns the per-skill zip count. Staging happens in a sibling ``.stage``
133+
directory so the zips contain a clean ``<name>/SKILL.md`` root.
134+
"""
135+
stage = dist_dir / ".stage"
136+
if stage.exists():
137+
shutil.rmtree(stage)
138+
stage.mkdir(parents=True)
139+
140+
for skill_dir in skill_dirs:
141+
stage_skill(skill_dir, stage, openai=openai)
142+
# arc_base=stage keeps the `<name>/` prefix inside the single-skill zip.
143+
zip_tree(stage / skill_dir.name, dist_dir / f"{skill_dir.name}.zip", arc_base=stage)
144+
145+
# Combined bundle: all staged skill dirs under one zip root.
146+
zip_tree(stage, dist_dir / f"{BUNDLE_NAME}.zip")
147+
148+
shutil.rmtree(stage)
149+
return len(skill_dirs)
150+
151+
152+
def main() -> None:
153+
parser = argparse.ArgumentParser(description=__doc__)
154+
parser.add_argument(
155+
"--skills-root",
156+
type=Path,
157+
default=REPO_ROOT / "skills",
158+
help="Directory holding memory-* skill folders (default: <repo>/skills)",
159+
)
160+
parser.add_argument(
161+
"--dist-root",
162+
type=Path,
163+
default=REPO_ROOT / "dist",
164+
help="Output directory for archives (default: <repo>/dist)",
165+
)
166+
args = parser.parse_args()
167+
168+
skills_root = args.skills_root.resolve()
169+
if not skills_root.exists():
170+
raise SystemExit(f"Skills directory not found: {skills_root}")
171+
skill_dirs = discover_skills(skills_root)
172+
173+
# Rebuild both flavor directories from scratch so stale skills don't linger.
174+
plain_dir = args.dist_root / "skills"
175+
openai_dir = args.dist_root / "skills-openai"
176+
for directory in (plain_dir, openai_dir):
177+
if directory.exists():
178+
shutil.rmtree(directory)
179+
180+
count = build_flavor(skill_dirs, plain_dir, openai=False)
181+
build_flavor(skill_dirs, openai_dir, openai=True)
182+
183+
print(f"packaged {count} skills")
184+
print(f" {plain_dir}/ (Agent Skills format)")
185+
print(f" {openai_dir}/ (ChatGPT/Codex format, with agents/openai.yaml)")
186+
187+
188+
if __name__ == "__main__":
189+
main()

skills/DEVELOPMENT.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,40 @@ npx skills add basicmachines-co/basic-memory/skills --agent claude
7171
4. Update `README.md` with the new skill's summary
7272
5. Commit and push
7373

74+
## Packaging for Distribution
75+
76+
`just dist` (from `skills/`) validates the skills, then zips them into
77+
`dist/` (at the monorepo root) in two flavors:
78+
79+
```
80+
dist/skills/<name>.zip # Agent Skills format (SKILL.md + resources)
81+
dist/skills/basic-memory-skills.zip # all skills bundled
82+
dist/skills-openai/<name>.zip # same skill + agents/openai.yaml
83+
dist/skills-openai/basic-memory-skills.zip
84+
```
85+
86+
Each zip contains a `<name>/SKILL.md` folder at its root, so unzipping (or
87+
dropping it into an uploader) lands a valid skill directory — the layout the
88+
[Agent Skills spec](https://agentskills.io/specification), Claude Desktop, and
89+
the ChatGPT plugin builder all expect. `dist/` is gitignored; the archives are
90+
build artifacts, so the `skills/` source stays pure markdown.
91+
92+
### ChatGPT / Codex (openai.yaml)
93+
94+
A ChatGPT/Codex skill is the *same* SKILL.md plus an optional
95+
[`agents/openai.yaml`](https://learn.chatgpt.com/docs/build-skills) holding
96+
OpenAI-specific display metadata. `just dist` generates that file for each skill
97+
from the SKILL.md frontmatter (`interface.display_name`, `short_description`,
98+
`brand_color`) — see `scripts/build_skills_dist.py`. To hand-tune a skill, add a
99+
source `skills/<name>/agents/openai.yaml`; the builder copies it verbatim
100+
instead of generating one.
101+
102+
No MCP dependency is pinned in `openai.yaml`: these skills need the basic-memory
103+
MCP server, but that is wired at the host/plugin level (the ChatGPT plugin
104+
builder's **MCP** step, or a local `.mcp.json`), not per skill. To upload into a
105+
ChatGPT plugin's **Skills** step, drag one `dist/skills-openai/<name>.zip` per
106+
skill.
107+
74108
## OpenClaw Plugin Integration
75109

76110
These skills are also bundled in the [`@basicmemory/openclaw-basic-memory`](../integrations/openclaw) plugin. When updating skills here, refresh the generated OpenClaw bundle:

skills/justfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ validate:
99
# Skills are markdown-only, so validation is the build check.
1010
check: validate
1111

12+
# Package skills into zips under ../dist: dist/skills/*.zip (Agent Skills) +
13+
# dist/skills-openai/*.zip (adds agents/openai.yaml for ChatGPT/Codex).
14+
dist: validate
15+
python3 {{repo_root}}/scripts/build_skills_dist.py
16+
1217
# Show available recipes
1318
default:
1419
@just --list

0 commit comments

Comments
 (0)