"""skill_sync.py — validate, normalize, and distribute Agent Skills across tools.
A "skill" is a directory that contains a SKILL.md with YAML frontmatter
(`name` and `description` are required) plus any of references/, scripts/,
assets/, gallery/. Claude Code, Codex, and GitHub Copilot all read this same
format, so moving a skill between them is mostly a clean copy — the value here
is doing it *safely*: validating first, honoring each tool's frontmatter
dialect, and never dragging __pycache__ / .DS_Store / .git into a destination.
list Show the skills in a source tree with their descriptions.
validate Check that one skill folder is portable and well-formed.
sync Validate, then clean-copy skills to tool targets and/or folders.
Stdlib only — no third-party dependencies. Python 3.9+.
from __future__ import annotations
from dataclasses import dataclass, field
HOME = os.path.expanduser("~")
# Global skills directory for each tool. The source of truth is copilot, but any
# of these can be a source or a target.
"claude": os.path.join(HOME, ".claude", "skills"),
"codex": os.path.join(HOME, ".codex", "skills"),
"copilot": os.path.join(HOME, ".copilot", "skills"),
DEFAULT_SOURCE = TOOL_DIRS["copilot"]
# Frontmatter keys each tool officially recognizes. Extra keys are harmless
# (tools ignore what they don't know), so the default is to preserve everything.
# --strict drops keys not in this list for the target, which is what you want
# when handing a skill to a tool or teammate that should not see, say, a
# Copilot-only `triggers:` block.
"claude": {"name", "description", "license", "allowed-tools", "metadata", "compatibility"},
"codex": {"name", "description", "license", "metadata"},
"copilot": {"name", "description", "license", "metadata", "triggers", "requires"},
# Arbitrary folders (e.g. a team docs repo): keep it to the universal core.
"generic": {"name", "description", "license", "metadata"},
REQUIRED_KEYS = {"name", "description"}
# Never copy these into a destination. Build noise, VCS metadata, editor cruft.
"__pycache__", ".git", ".hg", ".svn", ".pytest_cache", ".mypy_cache",
".ruff_cache", ".venv", "venv", "node_modules", ".idea", ".vscode",
IGNORE_FILE_GLOBS = ["*.pyc", "*.pyo", "*.pyd", ".DS_Store", "*.swp", "*.swo",
"Thumbs.db", "*.egg-info", ".coverage"]
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
LINK_RE = re.compile(r"\]\(([^)]+)\)")
INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1", re.S)
def strip_code(md: str) -> str:
"""Remove fenced blocks and inline code spans.
Link integrity must ignore code — a doc that *shows* a link like
`[x](../y.md)` as an example is not actually linking to it, and flagging
that would wrongly block a perfectly good skill from syncing.
for ln in md.splitlines():
if fence is None and (stripped.startswith("```") or stripped.startswith("~~~")):
if stripped.startswith(fence):
return INLINE_CODE_RE.sub("", "\n".join(out))
# ANSI, disabled when not a TTY or NO_COLOR is set.
_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
def c(text: str, code: str) -> str:
return f"\033[{code}m{text}\033[0m" if _COLOR else text
def red(t): return c(t, "31")
def green(t): return c(t, "32")
def yellow(t): return c(t, "33")
def bold(t): return c(t, "1")
# --------------------------------------------------------------------------- #
# Frontmatter: a deliberately small, line-based reader/editor.
# We never round-trip through a YAML library, because we only need two things:
# 1. read the top-level keys and the name/description values, and
# 2. optionally drop whole top-level key blocks (for --strict).
# Operating on raw lines preserves block scalars (`description: >-`) and the
# exact formatting of every key we keep.
# --------------------------------------------------------------------------- #
lines: list[str] # raw lines between the two `---` fences (no fences)
body_start: int # index in the original file where the body begins
raw: list[str] # the whole original file as lines
def split_frontmatter(path: str) -> Frontmatter | None:
with open(path, encoding="utf-8") as fh:
raw = fh.read().splitlines(keepends=True)
if not raw or raw[0].strip() != "---":
for i in range(1, len(raw)):
if raw[i].strip() == "---":
return Frontmatter(lines=raw[1:i], body_start=i + 1, raw=raw)
return None # unterminated frontmatter
def top_level_keys(fm_lines: list[str]) -> dict[str, tuple[int, int]]:
"""Map each top-level key to the (start, end) line range of its block.
A top-level key starts at column 0 as `key:`; its block runs until the next
column-0 key or the end of the frontmatter. This lets us drop `triggers:`
together with its indented list, or `metadata:` with all its children.
spans: dict[str, tuple[int, int]] = {}
key_re = re.compile(r"^([A-Za-z0-9_-]+):")
starts: list[tuple[str, int]] = []
for idx, line in enumerate(fm_lines):
starts.append((m.group(1), idx))
for n, (key, start) in enumerate(starts):
end = starts[n + 1][1] if n + 1 < len(starts) else len(fm_lines)
spans[key] = (start, end)
def scalar_value(fm_lines: list[str], key: str) -> str:
"""Return the (possibly multi-line / block-scalar) value of a top-level key."""
spans = top_level_keys(fm_lines)
after = first.split(":", 1)[1].strip()
if after and after not in (">", "|", ">-", "|-", ">+", "|+"):
return after.strip().strip('"').strip("'")
# Block scalar: gather the indented continuation lines.
parts = [ln.strip() for ln in fm_lines[start + 1:end] if ln.strip()]
def strip_keys(fm: Frontmatter, keep: set[str]) -> tuple[list[str], list[str]]:
"""Return (new_file_lines, dropped_keys) keeping only `keep` top-level keys."""
spans = top_level_keys(fm.lines)
dropped = sorted(k for k in spans if k not in keep)
drop_ranges = [spans[k] for k in dropped]
kept_fm = [ln for i, ln in enumerate(fm.lines)
if not any(s <= i < e for s, e in drop_ranges)]
new = ["---\n", *kept_fm, "---\n", *fm.raw[fm.body_start:]]
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def is_ignored(name: str) -> bool:
from fnmatch import fnmatch
return any(fnmatch(name, g) for g in IGNORE_FILE_GLOBS)
def find_junk(skill_dir: str) -> list[str]:
for root, dirs, files in os.walk(skill_dir):
junk.append(os.path.relpath(os.path.join(root, d), skill_dir))
junk.append(os.path.relpath(os.path.join(root, f), skill_dir))
def validate_skill(skill_dir: str, target: str = "generic") -> Report:
name = os.path.basename(skill_dir.rstrip("/"))
skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.isfile(skill_md):
rep.errors.append("no SKILL.md in this folder")
fm = split_frontmatter(skill_md)
rep.errors.append("SKILL.md has no valid `---` YAML frontmatter block")
keys = set(top_level_keys(fm.lines))
for req in REQUIRED_KEYS:
rep.errors.append(f"frontmatter missing required key `{req}`")
# name must be a valid slug and match the folder name — every tool keys the
# skill by its directory name, so a mismatch installs it under the wrong id.
fm_name = scalar_value(fm.lines, "name")
if not NAME_RE.match(fm_name):
f"name `{fm_name}` must be lowercase letters/digits/hyphens")
rep.errors.append(f"name `{fm_name}` exceeds 64 characters")
f"frontmatter name `{fm_name}` != folder name `{name}`")
desc = scalar_value(fm.lines, "description")
if "description" in keys and not desc:
rep.errors.append("description is empty")
f"description is {len(desc)} chars; keep it under ~1024 for reliable triggering")
# Frontmatter keys the target tool will not recognize.
allowed = ALLOWED_KEYS.get(target, ALLOWED_KEYS["generic"])
foreign = sorted(keys - allowed)
f"keys not native to `{target}`: {', '.join(foreign)} "
f"(kept as-is; use --strict to drop them)")
# Reference-link integrity: every relative link in SKILL.md must resolve
# inside the skill folder, or it breaks the moment the skill is copied.
# Link integrity. A link that does not resolve at the source is broken
# everywhere → error. A link that resolves but points *outside* the skill
# folder (a sibling skill, or the repo's STANDARDS.md) travels only if its
# target is synced too — that is fine for a whole-tree tool mirror but risky
# for an isolated copy, so it is a warning, not a hard failure.
body = strip_code("".join(fm.raw[fm.body_start:]))
for raw_link in LINK_RE.findall(body):
link = raw_link.strip().split("#", 1)[0].strip()
if not link or link.startswith(("http://", "https://", "mailto:", "/", "file:", "#")):
norm = os.path.normpath(os.path.join(skill_dir, link))
inside = norm.startswith(os.path.normpath(skill_dir) + os.sep)
if not os.path.exists(norm):
rep.errors.append(f"broken relative link: {link}")
elif not inside and link not in escaping:
preview = ", ".join(escaping[:3]) + (" …" if len(escaping) > 3 else "")
f"{len(escaping)} link(s) leave the skill folder: {preview} "
f"(resolve only if the target is synced alongside — fine for a full "
f"tool mirror, may break an isolated --dir copy)")
junk = find_junk(skill_dir)
preview = ", ".join(junk[:4]) + (" …" if len(junk) > 4 else "")
rep.warnings.append(f"{len(junk)} junk path(s) present, will be skipped: {preview}")
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
def _ignore(_dir: str, names: list[str]) -> set[str]:
return {n for n in names if is_ignored(n)}
def clean_copy(src: str, dst: str, *, force: bool, dry_run: bool) -> str:
if os.path.abspath(src) == os.path.abspath(dst):
return "skipped (source == destination)"
exists = os.path.exists(dst)
return "EXISTS — pass --force to overwrite"
return f"would {'replace' if exists else 'create'} {dst}"
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copytree(src, dst, ignore=_ignore)
return f"{'replaced' if exists else 'created'} {dst}"
def write_normalized(src: str, dst: str, keep: set[str], *,
force: bool, dry_run: bool) -> tuple[str, list[str]]:
"""Copy, then rewrite the destination SKILL.md to only the kept keys."""
result = clean_copy(src, dst, force=force, dry_run=dry_run)
if dry_run or result.startswith(("skipped", "EXISTS")):
# Still report what *would* be dropped so a dry run is informative.
fm = split_frontmatter(os.path.join(src, "SKILL.md"))
dropped = sorted(set(top_level_keys(fm.lines)) - keep) if fm else []
dst_md = os.path.join(dst, "SKILL.md")
fm = split_frontmatter(dst_md)
new_lines, dropped = strip_keys(fm, keep)
with open(dst_md, "w", encoding="utf-8") as fh:
# --------------------------------------------------------------------------- #
# Portable prompt: flatten a skill into ONE self-contained message a person can
# paste into ChatGPT (or any chat LLM) that has no agent, no filesystem, and no
# way to run scripts or load references on demand.
# --------------------------------------------------------------------------- #
"> **Portable prompt** — generated from the `{name}` skill by skill-sync. "
"Paste this whole message into ChatGPT or any chat assistant to use the "
"skill without an agent. Where it references helper scripts or file paths "
"(`scripts/…`, `references/…`), those can't run in a plain chat — apply the "
"step's intent yourself.\n\n"
"You are acting as a specialist assistant for this conversation. Adopt the "
"instructions below and apply them to everything I ask next.\n\n"
"**What you help with:** {description}\n"
TEXT_EXTS = {".md", ".markdown", ".txt", ".mdx"}
def render_prompt(skill_dir: str, max_inline: int = 300_000) -> tuple[str, list[str]]:
"""Return (prompt_text, warnings). Inlines EVERY text reference the skill
ships — not just the ones SKILL.md links to — because a plain chat can't
lazy-load, so an unlinked style guide or persona roster would otherwise be
lost. Scripts and binary/data files can't run in a chat, so they are listed,
name = os.path.basename(skill_dir.rstrip("/"))
fm = split_frontmatter(os.path.join(skill_dir, "SKILL.md"))
raise ValueError(f"{name}: no valid SKILL.md frontmatter")
desc = scalar_value(fm.lines, "description")
body = "".join(fm.raw[fm.body_start:]).strip()
out = [PROMPT_NOTE.format(name=name, description=desc), "\n---\n", body]
# Walk the whole skill and split its files: text to inline vs. everything
# else (scripts, data, binaries) to merely list.
text_files: list[str] = []
for root, dirs, files in os.walk(skill_dir):
dirs[:] = [d for d in dirs if not is_ignored(d)]
if is_ignored(f) or f == "SKILL.md":
rel = os.path.relpath(os.path.join(root, f), skill_dir)
(text_files if os.path.splitext(f)[1].lower() in TEXT_EXTS else other).append(rel)
# Inline every text reference (sorted so top-level files precede nested
# subfolders), until the size budget runs out.
for rel in sorted(text_files):
path = os.path.join(skill_dir, rel)
size = os.path.getsize(path)
warnings.append(f"{rel} ({size // 1000} KB) not inlined — over budget; listed instead")
out.append(f"\n\n---\n\n## Reference: `{rel}`\n\n"
+ open(path, encoding="utf-8").read().strip())
rows = sorted(f"- `{r}`" + (lambda kb: f" ({kb} KB)" if kb else "")(
os.path.getsize(os.path.join(skill_dir, r)) // 1000) for r in set(other))
out.append("\n\n---\n\n## Bundled resources (not available in a plain chat)\n\n"
"The skill also ships these files — scripts and linters that only run inside an "
"agent, plus data files. Apply their intent from the instructions above; ask me "
"to paste a specific one if you need its contents.\n\n" + "\n".join(rows))
text = "".join(out) + "\n"
f"prompt is {len(text) // 1000} KB (~{len(text) // 4000}k tokens) — may exceed "
f"a chat's paste limit; consider sharing the folder and pasting only the "
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
label: str # human label, e.g. "codex" or the folder path
base_dir: str # directory that will contain <skill-name>/
dialect: str # frontmatter dialect key into ALLOWED_KEYS
is_folder: bool # True for a --dir share, False for a tool's global dir
def resolve_targets(args) -> list[Target]:
targets: list[Target] = []
for tool in args.to or []:
if tool not in TOOL_DIRS:
sys.exit(f"unknown tool `{tool}`; choose from {', '.join(TOOL_DIRS)}")
targets.append(Target(tool, TOOL_DIRS[tool], tool, is_folder=False))
for path in args.dir or []:
base = os.path.abspath(os.path.expanduser(path))
targets.append(Target(base, base, args.tool or "generic", is_folder=True))
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
def cmd_list(args) -> int:
src = os.path.abspath(os.path.expanduser(args.source))
if not os.path.isdir(src):
sys.exit(f"source not found: {src}")
for entry in sorted(os.listdir(src)):
d = os.path.join(src, entry)
if entry.startswith(".") or not os.path.isdir(d):
md = os.path.join(d, "SKILL.md")
if not os.path.isfile(md):
fm = split_frontmatter(md)
desc = scalar_value(fm.lines, "description") if fm else ""
first = re.split(r"(?<=[.!?])\s", desc.strip())[0] if desc else "—"
rows.append((entry, first))
width = max((len(n) for n, _ in rows), default=0)
print(bold(f"{len(rows)} skill(s) in {src}\n"))
print(f" {name.ljust(width)} {first[:96]}")
def cmd_validate(args) -> int:
skill_dir = os.path.abspath(os.path.expanduser(path))
rep = validate_skill(skill_dir, target=args.target)
_print_report(rep, verbose=True)
failed += 0 if rep.ok else 1
return 1 if failed else 0
def cmd_prompt(args) -> int:
src = os.path.abspath(os.path.expanduser(args.source))
skill_dir = skill if (os.path.sep in skill or os.path.isdir(skill)) else os.path.join(src, skill)
skill_dir = os.path.abspath(os.path.expanduser(skill_dir))
if not os.path.isfile(os.path.join(skill_dir, "SKILL.md")):
sys.exit(f"no SKILL.md in {skill_dir}")
text, warns = render_prompt(skill_dir)
print(yellow(f"warning: {w}"), file=sys.stderr)
out = os.path.abspath(os.path.expanduser(args.out))
with open(out, "w", encoding="utf-8") as fh:
print(f"{green('✓')} wrote {out} ({len(text) // 1000} KB)")
def _resolve_skill_dirs(args) -> list[str]:
src = os.path.abspath(os.path.expanduser(args.source))
return [os.path.join(src, e) for e in sorted(os.listdir(src))
if os.path.isdir(os.path.join(src, e))
and os.path.isfile(os.path.join(src, e, "SKILL.md"))]
# Accept either a bare skill name (looked up in --source) or a path.
cand = s if os.path.sep in s or os.path.isdir(s) else os.path.join(src, s)
dirs.append(os.path.abspath(os.path.expanduser(cand)))
def cmd_sync(args) -> int:
targets = resolve_targets(args)
sys.exit("nothing to do: pass --to <tool[,tool]> and/or --dir <path>")
skill_dirs = _resolve_skill_dirs(args)
sys.exit("no skills selected: pass skill names/paths or --all")
print(bold(f"Syncing {len(skill_dirs)} skill(s) → "
f"{', '.join(t.label for t in targets)}"
f"{' [dry run]' if args.dry_run else ''}\n"))
for skill_dir in skill_dirs:
name = os.path.basename(skill_dir.rstrip("/"))
if not os.path.isdir(skill_dir):
print(f"{red('✗')} {name}: not a directory ({skill_dir})")
# Validate once per dialect we're about to write.
dialects = {t.dialect for t in targets}
for dialect in sorted(dialects):
rep = validate_skill(skill_dir, target=dialect)
print(f"{red('✗')} {bold(name)} failed validation for `{dialect}`:")
print(f" {red('•')} {e}")
print(f" {yellow('skipped — fix the errors above')}\n")
print(f"{green('✓')} {bold(name)}")
keep = ALLOWED_KEYS.get(t.dialect, ALLOWED_KEYS["generic"]) if args.strict else None
dst = os.path.join(t.base_dir, name)
result, dropped = write_normalized(
skill_dir, dst, keep, force=args.force, dry_run=args.dry_run)
extra = f" (stripped: {', '.join(dropped)})" if dropped else ""
result = clean_copy(skill_dir, dst, force=args.force, dry_run=args.dry_run)
mark = yellow("→") if result.startswith(("would", "skipped", "EXISTS")) else green("→")
print(f" {mark} {t.label}: {result}{extra}")
# A portable prompt only helps a human sharing folder, not a tool's
# own skills dir (agents read SKILL.md directly).
if args.prompt and t.is_folder:
dst = os.path.join(t.base_dir, name)
pf = os.path.join(dst, f"{name}.prompt.md")
print(f" {yellow('+')} would write {name}.prompt.md (paste-into-ChatGPT prompt)")
text, warns = render_prompt(skill_dir)
with open(pf, "w", encoding="utf-8") as fh:
print(f" {green('+')} wrote {name}.prompt.md ({len(text) // 1000} KB paste-into-ChatGPT prompt)")
print(f" {yellow('•')} {w}")
print(red(f"{problems} skill(s) had problems."))
return 1 if problems else 0
def _print_report(rep: Report, verbose: bool) -> None:
mark = green("✓") if rep.ok else red("✗")
print(f"{mark} {bold(rep.skill)}")
print(f" {red('•')} {e}")
print(f" {yellow('•')} {w}")
def cmd_remove(args) -> int:
"""Remove skill directories from tool targets and shared folders after a deletion.
Used by the post-commit hook to propagate skill deletions: when a skill is
deleted from the source tree (e.g., merged into a front-door skill), this
subcommand cleans up stale copies from ~/.claude, ~/.codex, and team folders.
targets = resolve_targets(args)
sys.exit("no targets specified; use --to claude,codex,copilot and/or --dir PATH")
sys.exit("no skills specified; provide one or more skill names")
# Validate the skill name is a valid slug (no paths, no weird chars).
if not NAME_RE.match(name):
print(f"{red('✗')} {bold(name)} — invalid skill name (must be lowercase, alphanumeric, hyphens only)")
dst = os.path.join(t.base_dir, name)
if not os.path.exists(dst):
print(f" — {t.label}: not present (already removed or never synced)")
print(f" {yellow('−')} would remove {name} from {t.label}")
print(f" {green('−')} removed {name} from {t.label}")
print(f" {red('✗')} failed to remove {name} from {t.label}: {e}")
return 1 if problems else 0
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Validate, normalize, and distribute Agent Skills across "
"Claude Code, Codex, Copilot, and shared folders.")
sub = p.add_subparsers(dest="command", required=True)
pl = sub.add_parser("list", help="list skills in a source tree")
pl.add_argument("--source", default=DEFAULT_SOURCE, help="source skills dir")
pl.set_defaults(func=cmd_list)
pv = sub.add_parser("validate", help="validate skill folder(s)")
pv.add_argument("skill", nargs="+", help="path(s) to skill folder(s)")
pv.add_argument("--target", default="generic",
choices=sorted(ALLOWED_KEYS), help="frontmatter dialect to check against")
pv.set_defaults(func=cmd_validate)
pp = sub.add_parser("prompt", help="flatten a skill into a paste-into-ChatGPT prompt")
pp.add_argument("skill", help="skill name or path")
pp.add_argument("--source", default=DEFAULT_SOURCE, help="source skills dir")
pp.add_argument("--out", help="write to this file instead of stdout")
pp.set_defaults(func=cmd_prompt)
ps = sub.add_parser("sync", help="validate then clean-copy skills to targets")
ps.add_argument("skill", nargs="*", help="skill name(s) or path(s); omit with --all")
ps.add_argument("--all", action="store_true", help="sync every skill in --source")
ps.add_argument("--source", default=DEFAULT_SOURCE, help="source skills dir")
ps.add_argument("--to", type=lambda s: [x for x in s.split(",") if x],
help="tool target(s): claude,codex,copilot")
ps.add_argument("--dir", action="append",
help="arbitrary destination folder (repeatable); skill lands in <dir>/<name>/")
ps.add_argument("--tool", choices=sorted(ALLOWED_KEYS),
help="frontmatter dialect for --dir targets (default: generic)")
ps.add_argument("--strict", action="store_true",
help="drop frontmatter keys the target tool does not recognize")
ps.add_argument("--prompt", action="store_true",
help="also write <name>.prompt.md (paste-into-ChatGPT prompt) into each --dir share")
ps.add_argument("--force", action="store_true", help="overwrite existing destinations")
ps.add_argument("--dry-run", action="store_true", help="show planned actions, write nothing")
ps.set_defaults(func=cmd_sync)
pr = sub.add_parser("remove", help="remove deleted skill(s) from targets")
pr.add_argument("skill", nargs="+", help="skill name(s) to remove from destinations")
pr.add_argument("--to", type=lambda s: [x for x in s.split(",") if x],
help="tool target(s): claude,codex,copilot")
pr.add_argument("--dir", action="append",
help="arbitrary destination folder (repeatable)")
pr.add_argument("--dry-run", action="store_true", help="show planned removals, write nothing")
pr.set_defaults(func=cmd_remove)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if __name__ == "__main__":