diff --git a/scripts/playbook.py b/scripts/playbook.py index 7166fa6..bc04219 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -2,7 +2,7 @@ import sys from datetime import datetime, timezone from pathlib import Path -from shutil import copy2, copytree +from shutil import copy2, copytree, which import subprocess import tomllib @@ -640,6 +640,115 @@ def sync_standards_action(config: dict, context: dict) -> int: return 0 +def normalize_names(raw: object, label: str) -> list[str]: + if raw is None: + raise ValueError(f"{label} is required") + if isinstance(raw, str): + items = [raw] + else: + items = list(raw) + cleaned: list[str] = [] + for item in items: + name = str(item).strip() + if not name: + continue + if "/" in name or "\\" in name or ".." in name: + raise ValueError(f"invalid {label}: {name}") + cleaned.append(name) + if not cleaned: + raise ValueError(f"{label} is empty") + return cleaned + + +def normalize_globs(raw: object) -> list[str]: + if raw is None: + return ["**/*.md"] + if isinstance(raw, str): + items = [raw] + else: + items = list(raw) + cleaned = [str(item).strip() for item in items if str(item).strip()] + return cleaned or ["**/*.md"] + + +def install_skills_action(config: dict, context: dict) -> int: + mode = str(config.get("mode", "list")).lower() + codex_home = Path(config.get("codex_home", "~/.codex")).expanduser() + if not codex_home.is_absolute(): + codex_home = (context["project_root"] / codex_home).resolve() + + skills_src_root = PLAYBOOK_ROOT / "codex/skills" + if not skills_src_root.is_dir(): + print(f"ERROR: skills source not found: {skills_src_root}", file=sys.stderr) + return 2 + + skills_dst_root = codex_home / "skills" + ensure_dir(skills_dst_root) + + if mode == "all": + skills = [ + path.name + for path in skills_src_root.iterdir() + if path.is_dir() and not path.name.startswith(".") + ] + elif mode == "list": + try: + skills = normalize_names(config.get("skills"), "skills") + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + else: + print("ERROR: mode must be list or all", file=sys.stderr) + return 2 + + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + for name in skills: + src = skills_src_root / name + if not src.is_dir(): + print(f"ERROR: skill not found: {name}", file=sys.stderr) + return 2 + dst = skills_dst_root / name + if dst.exists(): + backup = skills_dst_root / f"{name}.bak.{timestamp}" + dst.rename(backup) + log(f"Backed up existing skill: {name} -> {backup.name}") + copytree(src, dst) + log(f"Installed: {name}") + + return 0 + + +def format_md_action(config: dict, context: dict) -> int: + tool = str(config.get("tool", "prettier")).lower() + if tool != "prettier": + print("ERROR: format_md.tool only supports prettier", file=sys.stderr) + return 2 + + project_root: Path = context["project_root"] + prettier = project_root / "node_modules/.bin/prettier" + if not prettier.is_file(): + prettier = PLAYBOOK_ROOT / "node_modules/.bin/prettier" + if not prettier.is_file(): + resolved = which("prettier") + if resolved: + prettier = Path(resolved) + else: + log("Skip: prettier not found.") + return 0 + + globs_raw = config.get("globs", ["**/*.md"]) + globs = normalize_globs(globs_raw) + result = subprocess.run( + [str(prettier), "-w", *globs], + cwd=project_root, + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + return result.returncode + + def run_action(name: str, config: dict, context: dict) -> int: print(f"[action] {name}") if name == "vendor": @@ -648,6 +757,10 @@ def run_action(name: str, config: dict, context: dict) -> int: return sync_templates_action(config, context) if name == "sync_standards": return sync_standards_action(config, context) + if name == "install_skills": + return install_skills_action(config, context) + if name == "format_md": + return format_md_action(config, context) return 0 diff --git a/tests/cli/test_playbook_cli.py b/tests/cli/test_playbook_cli.py index b7cb3a6..eec82c0 100644 --- a/tests/cli/test_playbook_cli.py +++ b/tests/cli/test_playbook_cli.py @@ -101,5 +101,26 @@ langs = ["tsl"] self.assertEqual(result.returncode, 0) self.assertTrue(agents_index.is_file()) + def test_install_skills(self): + with tempfile.TemporaryDirectory() as tmp_dir: + target = Path(tmp_dir) / "codex" + config_body = f""" +[playbook] +project_root = "{tmp_dir}" + +[install_skills] +codex_home = "{target}" +mode = "list" +skills = ["brainstorming"] +""" + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + result = run_cli("-config", str(config_path)) + + skill_file = target / "skills/brainstorming/SKILL.md" + self.assertEqual(result.returncode, 0) + self.assertTrue(skill_file.is_file()) + if __name__ == "__main__": unittest.main()