Files
2026-07-13 12:09:03 +08:00

292 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Install course outputs (skills / prompts / agents) into a target directory.
Walks every `phases/**/outputs/{skill,prompt,agent}-*.md` artifact across the
curriculum, parses YAML frontmatter, filters by type / phase / tag, and copies
the matching files into a target directory using one of three layouts.
Usage:
python3 scripts/install_skills.py <target_dir> [options]
Options:
--type {skill,prompt,agent,all} default: skill
--phase N filter to a single phase number
--tag TAG filter to outputs whose tags include TAG
--layout {flat,by-phase,skills} default: skills
flat <target>/<name>.md
by-phase <target>/phase-NN/<name>.md
skills <target>/<name>/SKILL.md
--dry-run preview without writing
--force overwrite existing files
--json write manifest.json only; do not print steps
Always writes <target>/manifest.json with the full inventory (name, type, phase,
lesson, source path, target path, tags, version).
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _lib import parse_frontmatter # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
PHASES_DIR = ROOT / "phases"
VALID_TYPES = ("skill", "prompt", "agent")
LAYOUTS = ("flat", "by-phase", "skills")
@dataclass
class Artifact:
type: str
name: str
phase: int | None
lesson: int | None
version: str
description: str
tags: list[str]
source: Path
def to_dict(self, target: Path | None = None) -> dict:
out: dict[str, object] = {
"type": self.type,
"name": self.name,
"phase": self.phase,
"lesson": self.lesson,
"version": self.version,
"description": self.description,
"tags": self.tags,
"source": self.source.relative_to(ROOT).as_posix(),
}
if target is not None:
out["target"] = target.as_posix()
return out
def derive_phase_lesson(path: Path) -> tuple[int | None, int | None]:
parts = path.parts
phase_num: int | None = None
lesson_num: int | None = None
for part in parts:
if part.startswith(("0", "1", "2")) and "-" in part:
head = part.split("-", 1)[0]
if head.isdigit():
num = int(head)
if phase_num is None:
phase_num = num
elif lesson_num is None:
lesson_num = num
break
return phase_num, lesson_num
def discover_artifacts() -> Iterable[Artifact]:
if not PHASES_DIR.is_dir():
return
for output_dir in sorted(PHASES_DIR.glob("*/[0-9][0-9]-*/outputs")):
for path in sorted(output_dir.iterdir()):
if path.suffix != ".md" or not path.is_file():
continue
stem = path.stem
artifact_type: str | None = None
for t in VALID_TYPES:
if stem.startswith(f"{t}-"):
artifact_type = t
break
if artifact_type is None:
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
meta = parse_frontmatter(text) or {}
default_phase, default_lesson = derive_phase_lesson(path)
phase_raw = meta.get("phase", default_phase)
lesson_raw = meta.get("lesson", default_lesson)
try:
phase = int(phase_raw) if phase_raw is not None else None
except (TypeError, ValueError):
phase = default_phase
try:
lesson = int(lesson_raw) if lesson_raw is not None else None
except (TypeError, ValueError):
lesson = default_lesson
name = str(meta.get("name", "")).strip() or stem
description = str(meta.get("description", "")).strip()
version = str(meta.get("version", "")).strip()
tags_raw = meta.get("tags", [])
tags = list(tags_raw) if isinstance(tags_raw, list) else []
yield Artifact(
type=artifact_type,
name=name,
phase=phase,
lesson=lesson,
version=version,
description=description,
tags=tags,
source=path,
)
def filter_artifacts(
artifacts: Iterable[Artifact],
type_filter: str,
phase_filter: int | None,
tag_filter: str | None,
) -> list[Artifact]:
out: list[Artifact] = []
for a in artifacts:
if type_filter != "all" and a.type != type_filter:
continue
if phase_filter is not None and a.phase != phase_filter:
continue
if tag_filter is not None and tag_filter not in a.tags:
continue
out.append(a)
return out
def target_path(artifact: Artifact, target_root: Path, layout: str) -> Path:
if layout == "flat":
return target_root / f"{artifact.name}.md"
if layout == "by-phase":
phase_dir = f"phase-{artifact.phase:02d}" if artifact.phase is not None else "phase-unknown"
return target_root / phase_dir / f"{artifact.name}.md"
if layout == "skills":
return target_root / artifact.name / "SKILL.md"
raise ValueError(f"unknown layout: {layout}")
@dataclass
class Plan:
actions: list[tuple[Artifact, Path]] = field(default_factory=list)
collisions: list[Path] = field(default_factory=list)
def build_plan(
artifacts: list[Artifact], target_root: Path, layout: str, force: bool
) -> Plan:
plan = Plan()
seen_targets: dict[Path, Artifact] = {}
for a in artifacts:
dest = target_path(a, target_root, layout)
if dest in seen_targets:
sys.stderr.write(
f"warn: target collision between {seen_targets[dest].source} "
f"and {a.source} (both map to {dest}); skipping latter\n"
)
continue
seen_targets[dest] = a
if dest.exists() and not force:
plan.collisions.append(dest)
plan.actions.append((a, dest))
return plan
def apply_plan(plan: Plan) -> None:
for artifact, dest in plan.actions:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(artifact.source, dest)
def write_manifest(target_root: Path, artifacts: list[Artifact], layout: str) -> Path:
manifest_path = target_root / "manifest.json"
target_root.mkdir(parents=True, exist_ok=True)
by_type: dict[str, int] = {}
by_phase: dict[str, int] = {}
entries = []
for a in artifacts:
dest_rel = target_path(a, target_root, layout).relative_to(target_root)
entries.append(a.to_dict(target=dest_rel))
by_type[a.type] = by_type.get(a.type, 0) + 1
key = f"phase-{a.phase:02d}" if a.phase is not None else "phase-unknown"
by_phase[key] = by_phase.get(key, 0) + 1
manifest = {
"schema_version": 1,
"layout": layout,
"totals": {
"artifacts": len(entries),
"by_type": dict(sorted(by_type.items())),
"by_phase": dict(sorted(by_phase.items())),
},
"artifacts": entries,
}
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return manifest_path
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target_dir", type=Path)
parser.add_argument("--type", choices=(*VALID_TYPES, "all"), default="skill")
parser.add_argument("--phase", type=int, default=None)
parser.add_argument("--tag", default=None)
parser.add_argument("--layout", choices=LAYOUTS, default="skills")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
parser.add_argument(
"--json",
action="store_true",
help="suppress human-readable output (manifest.json still written unless --dry-run)",
)
args = parser.parse_args(argv)
artifacts = list(discover_artifacts())
selected = filter_artifacts(artifacts, args.type, args.phase, args.tag)
if not selected:
sys.stderr.write("no artifacts matched the given filters\n")
return 1
plan = build_plan(selected, args.target_dir, args.layout, args.force)
if plan.collisions and not args.force:
sys.stderr.write(
f"error: {len(plan.collisions)} target file(s) already exist. "
f"Pass --force to overwrite.\n"
)
if not args.json:
for c in plan.collisions[:10]:
sys.stderr.write(f" {c}\n")
if len(plan.collisions) > 10:
sys.stderr.write(f" ... and {len(plan.collisions) - 10} more\n")
return 1
if args.dry_run:
if not args.json:
sys.stdout.write(
f"dry run: {len(plan.actions)} artifact(s) -> {args.target_dir} "
f"(layout={args.layout})\n"
)
for artifact, _dest in plan.actions[:20]:
sys.stdout.write(
f" [{artifact.type}] {artifact.name} "
f"<- {artifact.source.relative_to(ROOT)}\n"
)
if len(plan.actions) > 20:
sys.stdout.write(f" ... and {len(plan.actions) - 20} more\n")
return 0
apply_plan(plan)
manifest_path = write_manifest(args.target_dir, selected, args.layout)
if not args.json:
sys.stdout.write(
f"installed {len(plan.actions)} artifact(s) into {args.target_dir} "
f"(layout={args.layout})\n"
)
sys.stdout.write(f"manifest: {manifest_path}\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))