292 lines
10 KiB
Python
Executable File
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:]))
|