#!/usr/bin/env -S uv run # /// script # dependencies = [] # /// """/install — stamp the SSSF factory from the skill into the cwd. Idempotent. Usage: uv run /scripts/install.py [--force] [--harness pi|omp] Stamps: adws/ (modules + starter ADWs), adws/adw_data/prompt_engineering/ (4 starter agents), adws/adw_sssf_config/sssf.config.yaml, .env.sample, .gitignore entries. Prompts for the coding-agent harness (pi or omp) unless --harness is given, writes it into the stamped config's defaults, and registers the repo in the visualizer's repos.json so the multi-repo UI can browse it. Existing files are skipped unless --force. """ import argparse import json import shutil import subprocess import sys from pathlib import Path TEMPLATES = Path(__file__).resolve().parent.parent / "templates" # The skill's own directory — where the visualizer app lives. install.py stamps # it into the justfile's `skill_dir` so `just obs` finds the app no matter # where the skill was installed (user scope, a repo, or via the skills CLI). SKILL_DIR = Path(__file__).resolve().parent.parent # The visualizer's repo registry lives next to the skill (machine-specific: # it maps repo slugs to local db paths). install.py keeps it in sync. REPOS_FILE = SKILL_DIR / "repos.json" GITIGNORE_ENTRIES = [ "adws/adw_data/sessions/", "adws/adw_data/sssf.db*", ".env", # The ADWs are Python, so importing adw_modules writes bytecode next to it. # Chains that end in a commit phase call `git add -A`, so without this a # stamped repo commits its own .pyc files — 15 of them showed up in the # first repo that was ever installed into from scratch. "__pycache__/", "*.pyc", ] HARNESSES = ("pi", "omp") def stamp(src: Path, dest: Path, force: bool, stamped: list, skipped: list) -> None: if src.is_dir(): for child in sorted(src.iterdir()): if child.name == "__pycache__": continue stamp(child, dest / child.name, force, stamped, skipped) return if dest.exists() and not force: skipped.append(str(dest)) return dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) stamped.append(str(dest)) def ensure_gitignore(root: Path, stamped: list) -> None: gitignore = root / ".gitignore" existing = gitignore.read_text().splitlines() if gitignore.exists() else [] missing = [e for e in GITIGNORE_ENTRIES if e not in existing] if missing: with gitignore.open("a") as f: f.write("\n# sssf runtime\n" + "\n".join(missing) + "\n") stamped.append(f"{gitignore} (+{len(missing)} entries)") def stamp_justfile(root: Path, force: bool, stamped: list, skipped: list) -> None: """Stamp the justfile, substituting the skill's real path into `skill_dir`. The `obs` recipe boots the visualizer app, which ships with the skill, so the stamped justfile has to know where the skill lives. The template keeps a `@SSSF_SKILL_DIR@` placeholder; this fills it with the actual install location (user scope, a repo, or wherever the skills CLI put it). """ dest = root / "justfile" if dest.exists() and not force: skipped.append(str(dest)) return text = (TEMPLATES / "justfile").read_text() text = text.replace("@SSSF_SKILL_DIR@", str(SKILL_DIR)) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(text) stamped.append(str(dest)) def choose_harness(arg: str | None) -> str: """Pick the coding-agent harness: --harness wins, else prompt.""" if arg: if arg not in HARNESSES: raise SystemExit(f"--harness must be one of {', '.join(HARNESSES)}, got {arg!r}") return arg while True: choice = input(f"Coding-agent harness [{HARNESSES[0]}/{HARNESSES[1]}] (default {HARNESSES[0]}): ").strip().lower() if not choice: return HARNESSES[0] if choice in HARNESSES: return choice print(f" {choice!r} is not a harness; pick {', '.join(HARNESSES)}.") def set_harness_in_config(config_path: Path, harness: str) -> None: """Rewrite the stamped config's defaults.coding_agent to the chosen harness.""" text = config_path.read_text() # Target the `defaults:` block's coding_agent, not the comment above it # (which also contains the words "coding_agent"). The template ships # ` coding_agent: pi` as the first line under `defaults:`. defaults_marker = "defaults:" d_idx = text.find(defaults_marker) if d_idx == -1: return marker = "coding_agent:" idx = text.find(marker, d_idx) if idx == -1: return line_end = text.find("\n", idx) text = text[:idx] + f"coding_agent: {harness}" + text[line_end:] config_path.write_text(text) def harness_catalog(harness: str) -> list[str]: """List the model selectors the harness can actually run, or [] if unknown. The template config ships with a roster of models (google/gemini-3.6-flash, fireworks/..., openai/...) that only resolve if the harness has them registered. Most machines have a handful of local/cloud models, so the stamped config usually fails validation until every agent is pointed at a model that exists. Reading the harness's own catalog lets install offer to fix that automatically. """ try: if harness == "pi": out = subprocess.run( ["pi", "--list-models"], capture_output=True, text=True, timeout=30, check=False, ) if out.returncode != 0: return [] # provider model context max-out thinking images return [f"{row[0]}/{row[1]}" for row in (line.split() for line in out.stdout.splitlines()[1:]) if len(row) >= 2] if harness == "omp": out = subprocess.run( ["omp", "models", "--json"], capture_output=True, text=True, timeout=30, check=False, ) if out.returncode != 0: return [] data = json.loads(out.stdout) return [m.get("selector") or f"{m['provider']}/{m['id']}" for m in data.get("models", []) if isinstance(m, dict)] except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, KeyError): return [] return [] def fix_models_in_config(config_path: Path, harness: str) -> None: """Point every agent at a model the harness can actually run. The template roster names several providers; if none of them resolve in the harness's catalog, offer to set `defaults.model` to the first registered model and drop the per-agent `model:` overrides — the fastest way to a validating roster on a single model. No-op when the catalog is unknown or the template models already resolve. """ catalog = harness_catalog(harness) if not catalog: return text = config_path.read_text() # Collect the model patterns the template ships (defaults + per-agent). import re patterns = re.findall(r"^\s*model:\s*(\S+)", text, re.M) if not patterns: return # Does any template model resolve? A pattern resolves if it's in the catalog # or is a bare id that matches exactly one catalog entry. def resolves(pattern: str) -> bool: if pattern in catalog: return True matches = [c for c in catalog if pattern == c.split("/")[-1]] return len(matches) == 1 if any(resolves(p) for p in patterns): return # None resolve — offer to point the whole roster at the first model. target = catalog[0] print(f" none of the template models resolve in {harness}'s catalog " f"({len(catalog)} model(s) available)") choice = input(f" point the whole roster at {target}? [Y/n]: ").strip().lower() if choice in ("n", "no"): return # Set defaults.model and drop every per-agent `model:` line. defaults_marker = "defaults:" d_idx = text.find(defaults_marker) if d_idx != -1: m_idx = text.find("model:", d_idx) if m_idx != -1: line_end = text.find("\n", m_idx) text = text[:m_idx] + f"model: {target}" + text[line_end:] # Remove per-agent model overrides (indented `model:` lines in agent # blocks, i.e. after the defaults block ends). The defaults.model line we # just wrote stays. lines = text.splitlines(keepends=True) kept = [] in_defaults = False for line in lines: stripped = line.strip() if stripped.startswith("defaults:"): in_defaults = True elif stripped and not stripped.startswith("#") and not line.startswith((" ", "\t")): in_defaults = False if not in_defaults and stripped.startswith("model:") and line.startswith((" ", "\t")): continue kept.append(line) config_path.write_text("".join(kept)) print(f" defaults.model = {target}; per-agent model overrides removed") def register_repo(root: Path) -> None: """Add/update this repo in the visualizer's repos.json (idempotent).""" slug = root.resolve().name db = str((root / "adws" / "adw_data" / "sssf.db").resolve()) entry = {"slug": slug, "name": slug, "db": db} repos = [] if REPOS_FILE.exists(): try: repos = json.loads(REPOS_FILE.read_text()) except (json.JSONDecodeError, OSError): repos = [] if not isinstance(repos, list): repos = [] # Replace an existing entry for this slug, else append. repos = [r for r in repos if not (isinstance(r, dict) and r.get("slug") == slug)] repos.append(entry) REPOS_FILE.write_text(json.dumps(repos, indent=2) + "\n") print(f" registered {slug} in {REPOS_FILE}") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--force", action="store_true", help="overwrite existing files") parser.add_argument("--harness", choices=HARNESSES, help="coding-agent harness (pi or omp)") args = parser.parse_args() harness = choose_harness(args.harness) root = Path.cwd() stamped, skipped = [], [] stamp(TEMPLATES / "adws", root / "adws", args.force, stamped, skipped) stamp(TEMPLATES / "prompt_engineering", root / "adws" / "adw_data" / "prompt_engineering", args.force, stamped, skipped) stamp(TEMPLATES / "harness_engineering", root / "adws" / "adw_data" / "harness_engineering", args.force, stamped, skipped) stamp(TEMPLATES / "sssf.config.yaml", root / "adws" / "adw_sssf_config" / "sssf.config.yaml", args.force, stamped, skipped) stamp(TEMPLATES / "env.sample", root / ".env.sample", args.force, stamped, skipped) # The recipes are part of the operating experience, and several cookbooks # plus the run banner tell you to use them, so a stamped repo has to have # them. Skipped like any other file if the repo already has a justfile. stamp_justfile(root, args.force, stamped, skipped) ensure_gitignore(root, stamped) config_path = root / "adws" / "adw_sssf_config" / "sssf.config.yaml" if config_path.exists(): set_harness_in_config(config_path, harness) print(f" defaults.coding_agent = {harness} in {config_path}") fix_models_in_config(config_path, harness) register_repo(root) print(f"sssf installed into {root}") print(f" stamped: {len(stamped)} file(s)") for s in stamped: print(f" + {s}") if skipped: print(f" skipped (already exist, use --force to overwrite): {len(skipped)}") print("\nnext steps:") print(" 1. just validate # roster check: names, prompts, models all resolve") print(" 2. cp .env.sample .env # if no .env yet; else append the keys you need") print(" 3. just demo # two cheap read-only runs, end to end") print(" 4. just sessions # what just happened") print(" 5. just obs # the trace UI, needs bun") print("\n no just? the raw form of step 3 is:") print(" uv run adws/adw_prompt.py \"say hello\" --agent scout") return 0 if __name__ == "__main__": sys.exit(main())