feat(actions): add install_skills and format_md

This commit is contained in:
csh 2026-01-23 14:40:08 +08:00
parent 3d1582ce9e
commit 0c4cd0e037
2 changed files with 135 additions and 1 deletions

View File

@ -2,7 +2,7 @@
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from shutil import copy2, copytree from shutil import copy2, copytree, which
import subprocess import subprocess
import tomllib import tomllib
@ -640,6 +640,115 @@ def sync_standards_action(config: dict, context: dict) -> int:
return 0 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: def run_action(name: str, config: dict, context: dict) -> int:
print(f"[action] {name}") print(f"[action] {name}")
if name == "vendor": if name == "vendor":
@ -648,6 +757,10 @@ def run_action(name: str, config: dict, context: dict) -> int:
return sync_templates_action(config, context) return sync_templates_action(config, context)
if name == "sync_standards": if name == "sync_standards":
return sync_standards_action(config, context) 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 return 0

View File

@ -101,5 +101,26 @@ langs = ["tsl"]
self.assertEqual(result.returncode, 0) self.assertEqual(result.returncode, 0)
self.assertTrue(agents_index.is_file()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()