init_skill.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # ///
  5. """init_skill — deterministic scaffolder for a new skill.
  6. Creates the skill directory and writes SKILL.md from the builder's template, which
  7. carries the embedded archetype guidance and the delete-when-done marker. The name
  8. is normalized to hyphen-case and capped at 64 chars. Only the resource directories
  9. the build flow asked for are stubbed, so the skill starts as small as it can. A
  10. customize.toml is emitted only when customization was accepted, never by default.
  11. This script does the mechanical scaffolding so the model spends its turns on the
  12. content, not on mkdir and string substitution.
  13. Usage:
  14. init_skill.py --name "My New Skill" --dest /path/to/skills
  15. init_skill.py --name foo --dest DIR --dirs references,scripts,assets
  16. init_skill.py --name foo --dest DIR --customizable
  17. init_skill.py --name foo --dest DIR \
  18. --template /abs/SKILL-template.md --customize-template /abs/customize-template.toml
  19. Output: one JSON object on stdout describing what was created.
  20. Exit code 0 on success, 1 on failure (e.g. the target already exists).
  21. """
  22. from __future__ import annotations
  23. import argparse
  24. import json
  25. import re
  26. import sys
  27. from pathlib import Path
  28. KNOWN_DIRS = ("references", "scripts", "assets", "agents")
  29. SCRIPT_DIR = Path(__file__).resolve().parent
  30. DEFAULT_TEMPLATE = SCRIPT_DIR.parent / "assets" / "SKILL-template.md"
  31. DEFAULT_CUSTOMIZE = SCRIPT_DIR.parent / "assets" / "customize-template.toml"
  32. def normalize_name(raw: str, max_len: int = 64) -> str:
  33. """Lowercase, collapse non-alphanumerics to single hyphens, trim, cap at max_len."""
  34. s = raw.strip().lower()
  35. s = re.sub(r"[^a-z0-9]+", "-", s)
  36. s = s.strip("-")
  37. if len(s) > max_len:
  38. s = s[:max_len].rstrip("-")
  39. return s
  40. def fill_template(template: str, skill_name: str) -> str:
  41. return template.replace("{skill-name}", skill_name)
  42. def scaffold(args) -> dict:
  43. skill_name = normalize_name(args.name)
  44. if not skill_name:
  45. raise ValueError(f"name {args.name!r} normalized to an empty string")
  46. skill_dir = Path(args.dest) / skill_name
  47. if skill_dir.exists():
  48. raise FileExistsError(f"{skill_dir} already exists")
  49. template_path = Path(args.template) if args.template else DEFAULT_TEMPLATE
  50. if not template_path.is_file():
  51. raise FileNotFoundError(f"template not found: {template_path}")
  52. requested = []
  53. for d in (args.dirs or "").split(","):
  54. d = d.strip()
  55. if not d:
  56. continue
  57. if d not in KNOWN_DIRS:
  58. raise ValueError(f"unknown resource dir {d!r}; known: {', '.join(KNOWN_DIRS)}")
  59. requested.append(d)
  60. skill_dir.mkdir(parents=True)
  61. created = [str(skill_dir)]
  62. skill_md = skill_dir / "SKILL.md"
  63. skill_md.write_text(fill_template(template_path.read_text(encoding="utf-8"), skill_name), encoding="utf-8")
  64. created.append(str(skill_md))
  65. for d in requested:
  66. sub = skill_dir / d
  67. sub.mkdir()
  68. created.append(str(sub))
  69. customize_emitted = False
  70. if args.customizable:
  71. ct_path = Path(args.customize_template) if args.customize_template else DEFAULT_CUSTOMIZE
  72. if not ct_path.is_file():
  73. raise FileNotFoundError(f"customize template not found: {ct_path}")
  74. target = skill_dir / "customize.toml"
  75. target.write_text(
  76. ct_path.read_text(encoding="utf-8").replace("{skill-name}", skill_name),
  77. encoding="utf-8",
  78. )
  79. created.append(str(target))
  80. customize_emitted = True
  81. return {
  82. "ok": True,
  83. "skill_name": skill_name,
  84. "skill_dir": str(skill_dir),
  85. "dirs_stubbed": requested,
  86. "customize_toml": customize_emitted,
  87. "created": created,
  88. }
  89. def main(argv: list[str] | None = None) -> int:
  90. p = argparse.ArgumentParser(description="Deterministic scaffolder for a new skill")
  91. p.add_argument("--name", required=True, help="raw skill name; normalized to hyphen-case <=64")
  92. p.add_argument("--dest", required=True, help="parent directory the skill folder is created under")
  93. p.add_argument("--dirs", default="", help="comma-separated resource dirs to stub (references,scripts,assets,agents)")
  94. p.add_argument("--customizable", action="store_true", help="emit customize.toml (only when customization was accepted)")
  95. p.add_argument("--template", help="override path to the SKILL.md template")
  96. p.add_argument("--customize-template", help="override path to the customize.toml template")
  97. args = p.parse_args(argv)
  98. try:
  99. result = scaffold(args)
  100. except (FileExistsError, FileNotFoundError, ValueError) as e:
  101. print(json.dumps({"ok": False, "error": str(e)}))
  102. return 1
  103. print(json.dumps(result, indent=2))
  104. return 0
  105. if __name__ == "__main__":
  106. sys.exit(main())