"""prompt-patterns-coding-agents-plan-mode-skills.py — validate a SKILL.md and write one saved prompt out in three harness formats. What it does: validate checks SKILL.md frontmatter against the Agent Skills specification as read at https://agentskills.io/specification on 2026-09-10: `name` 1-64 chars, lowercase a-z 0-9 and hyphens, no leading/trailing/consecutive hyphen, must match the directory name; `description` 1-1024 chars; `compatibility` 1-500 chars when present; and the spec's advice that the body stay under 500 lines. Exit 0 when clean, 1 when any error. emit writes the same prompt as /claude/.claude/skills//SKILL.md (Claude Code skill) /codex/.agents/skills//SKILL.md (Codex skill) /gemini/.gemini/commands/.toml (Gemini CLI custom command) The prompt file may use $ARGUMENTS; the Gemini TOML gets {{args}} instead. The Claude and Codex files are the same format (both follow the Agent Skills spec); the Claude copy adds `disable-model-invocation: true` so the workflow only runs when you type /. Input: a skill directory, or a name + description + prompt file + output directory. Run: python prompt-patterns-coding-agents-plan-mode-skills.py validate .claude/skills/fix-issue python prompt-patterns-coding-agents-plan-mode-skills.py emit fix-issue "Fix a GitHub issue by number" prompt.md out/ Python 3.13, standard library only. No harness is invoked; this only reads and writes files. """ from __future__ import annotations import os import re import sys NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: """Return (frontmatter dict, body). Only flat `key: value` lines are read; that is all the spec's required fields need.""" if not text.startswith("---"): return {}, text parts = text.split("\n---", 2) if len(parts) < 2: return {}, text head = parts[0][3:] body = parts[1] if len(parts) == 2 else parts[1] + "\n---" + parts[2] fm: dict[str, str] = {} for line in head.splitlines(): if ":" in line and not line.startswith(" "): k, v = line.split(":", 1) fm[k.strip()] = v.strip().strip('"').strip("'") return fm, body.lstrip("\n") def validate(skill_dir: str) -> list[str]: errors: list[str] = [] path = os.path.join(skill_dir, "SKILL.md") if not os.path.isfile(path): return [f"{path}: missing"] text = open(path, encoding="utf-8").read() fm, body = parse_frontmatter(text) if not fm: return [f"{path}: no YAML frontmatter"] name = fm.get("name", "") dirname = os.path.basename(os.path.abspath(skill_dir)) if not name: errors.append("name: required") else: if not 1 <= len(name) <= 64: errors.append(f"name: {len(name)} chars (spec: 1-64)") if not NAME_RE.match(name): errors.append(f"name: {name!r} must be lowercase a-z, 0-9 and single hyphens, not at the ends") if name != dirname: errors.append(f"name: {name!r} does not match directory {dirname!r} (spec: must match)") desc = fm.get("description", "") if not desc: errors.append("description: required") elif len(desc) > 1024: errors.append(f"description: {len(desc)} chars (spec: 1-1024)") compat = fm.get("compatibility") if compat is not None and not 1 <= len(compat) <= 500: errors.append(f"compatibility: {len(compat)} chars (spec: 1-500 when present)") lines = body.count("\n") + 1 if lines > 500: errors.append(f"body: {lines} lines (spec recommends under 500; move reference material to files)") return errors def emit(name: str, description: str, prompt_file: str, out_dir: str) -> list[str]: prompt = open(prompt_file, encoding="utf-8").read().rstrip("\n") written: list[str] = [] # Quoted so a colon or a hash in the description cannot break the YAML or TOML parse. q = '"' + description.replace("\\", "\\\\").replace('"', '\\"') + '"' def put(rel: str, content: str) -> None: p = os.path.join(out_dir, rel) os.makedirs(os.path.dirname(p), exist_ok=True) with open(p, "w", encoding="utf-8", newline="\n") as f: f.write(content) written.append(p) # Claude Code: a skill is a SKILL.md in .claude/skills//. $ARGUMENTS is the documented # placeholder. disable-model-invocation keeps it manual (Claude Code docs, "Skills", read 2026-09-10). claude = (f"---\nname: {name}\ndescription: {q}\ndisable-model-invocation: true\n" f"argument-hint: [arguments]\n---\n\n{prompt}\n") put(os.path.join("claude", ".claude", "skills", name, "SKILL.md"), claude) # Codex: same file shape, discovered from .agents/skills (Codex docs, "Build skills", read # 2026-09-10). Codex skills have no documented argument placeholder, so $ARGUMENTS becomes a # sentence asking for the argument in the prompt text. codex_prompt = prompt.replace("$ARGUMENTS", "the argument given after the skill name") codex = f"---\nname: {name}\ndescription: {q}\n---\n\n{codex_prompt}\n" put(os.path.join("codex", ".agents", "skills", name, "SKILL.md"), codex) # Gemini CLI: a TOML file whose `prompt` is sent to the model; {{args}} is replaced with what # the user typed (Gemini CLI docs, "Custom commands", read 2026-09-10). gem_prompt = prompt.replace("$ARGUMENTS", "{{args}}").replace('"""', '\\"\\"\\"') gemini = f'description = {q}\nprompt = """\n{gem_prompt}\n"""\n' put(os.path.join("gemini", ".gemini", "commands", f"{name}.toml"), gemini) return written def main(argv: list[str]) -> int: if len(argv) >= 3 and argv[1] == "validate": errs = validate(argv[2]) for e in errs: print("error:", e) print(f"{argv[2]}: {'OK' if not errs else str(len(errs)) + ' error(s)'}") return 1 if errs else 0 if len(argv) == 6 and argv[1] == "emit": for p in emit(argv[2], argv[3], argv[4], argv[5]): print("wrote", p) return 0 print(__doc__) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))