From b897bb4b17083f12962f1f3fc8edf7fb224c858a Mon Sep 17 00:00:00 2001 From: "Tobias J. Endres" Date: Sun, 23 Aug 2026 17:10:35 +0200 Subject: [PATCH] feat(omos): interactive init and setup commands - omos init [--fill]: create user mapping file, interactively resolve PLACEHOLDER classes by picking from models discovered in opencode.json(c) - omos setup: guided flow - show profile summary, create/fill missing mappings with explicit user choice, then render - README quickstart updated to the two-command path --- omos/README.md | 37 ++++++++-- omos/omos.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 7 deletions(-) diff --git a/omos/README.md b/omos/README.md index 86e946a..7c5ff90 100644 --- a/omos/README.md +++ b/omos/README.md @@ -19,6 +19,22 @@ team-profile.yaml + model-mapping.yaml → omos render → .opencode/ ## Schnellstart +```bash +# 1. Modell-Mapping anlegen – interaktiv, mit Auswahl aus deinen opencode-Modellen +python3 omos.py init --fill + +# 2. Geführt: Team-Profil lesen, Mapping prüfen/auffüllen, rendern +cd +python3 /pfad/zu/omos.py setup --package /pfad/zum/package + +# 3. Aktivieren +opencode # Projekt-Config wird geladen +/preset acme-job-applications +# Reload OpenCode → Team aktiv (by Design: kein Hot-Swap) +``` + +Alternativ manuell: + ```bash # 1. Modell-Mapping einmalig anlegen mkdir -p ~/.config/apm-team @@ -31,11 +47,6 @@ python3 omos.py check --package examples/package # 3. Rendern cd python3 /pfad/zu/omos.py render --package /pfad/zum/package - -# 4. Aktivieren -opencode # Projekt-Config wird geladen -/preset acme-job-applications -# Reload OpenCode → Team aktiv (by Design: kein Hot-Swap) ``` ## Die drei Dateien @@ -51,10 +62,22 @@ Dazu eine Provenance-Datei `.opencode/oh-my-opencode-slim.omos-provenance.json`, ## Kommandoreferenz ```bash -python3 omos.py render --package DIR [--mapping FILE] [--output FILE] [--dry-run] -python3 omos.py check --package DIR [--mapping FILE] +python3 omos.py init [--mapping FILE] [--fill] # Mapping anlegen/füllen +python3 omos.py setup --package DIR [--mapping F] ... # geführter Ablauf +python3 omos.py render --package DIR [--mapping F] [--output F] [--dry-run] +python3 omos.py check --package DIR [--mapping F] # Dry-Run-Validierung ``` +### `init` – Mapping anlegen + +Erstellt `~/.config/apm-team/model-mapping.yaml` (falls nicht vorhanden). Mit `--fill` werden alle `PLACEHOLDER`-Klassen interaktiv abgefragt: `omos` listet alle Modelle aus deiner `opencode.json(c)` auf, du wählst per Nummer oder gibst eine Modell-ID manuell ein. + +### `setup` – Geführter Ablauf + +Der Ein-Kommando-Weg: zeigt das Team-Profil verständlich an, legt fehlendes Mapping an, fragt fehlende Modellklassen interaktiv ab (gleiche Auswahl wie `init --fill`), rendert und gibt die Aktivierungsschritte aus. Entspricht dem Resolve–Validate–Render-Ablauf. + +### `render` / `check` + | Option | Default | Bedeutung | |---|---|---| | `--package` | `.` | APM-Package-Verzeichnis mit `team-profile.yaml` | diff --git a/omos/omos.py b/omos/omos.py index 7d2b865..880cd90 100644 --- a/omos/omos.py +++ b/omos/omos.py @@ -256,6 +256,46 @@ def configured_mcps() -> set[str]: return set() +def available_models() -> list[str]: + """Listet alle provider/model-Kombinationen aus der opencode.json(c).""" + candidates = [ + Path.home() / ".config" / "opencode" / "opencode.jsonc", + Path.home() / ".config" / "opencode" / "opencode.json", + ] + models: list[str] = [] + for path in candidates: + if not path.exists(): + continue + try: + data = json.loads(strip_jsonc(path.read_text(encoding="utf-8"))) + except (json.JSONDecodeError, OSError): + continue + for provider, pconf in (data.get("provider") or {}).items(): + for model in (pconf.get("models") or {}): + models.append(f"{provider}/{model}") + break + return sorted(models) + + +INIT_TEMPLATE = """\ +# Lokale Mapping-Tabelle des NUTZERS. +# Ordnet abstrakte Modellklassen konkrete Modell-IDs zu. +# Formate: +# klasse: provider/modell +# klasse: +# model: provider/modell +# variant: thinking # optional +# temperature: 0.7 # optional + +model_classes: + strong-generalist: PLACEHOLDER + fast-research: PLACEHOLDER + strong-writing: PLACEHOLDER + high-reasoning: PLACEHOLDER + cheap-reliable: PLACEHOLDER +""" + + def check_mcps(preset: dict, warnings: list[str]) -> None: available = configured_mcps() if not available: @@ -372,6 +412,115 @@ def _report(warnings: list[str], dry_run: bool = False) -> None: print("\nKeine Warnungen.") +# --------------------------------------------------------------------------- +# Interaktive Assistenten + + +def _choose_model(model_class: str, models: list[str]) -> str: + """Interaktive Auswahl eines Modells für eine Modellklasse.""" + print(f"\nFür '{model_class}' ist kein Modell zugeordnet.") + if models: + print("Verfügbare Modelle (aus opencode.json(c)):") + for i, model in enumerate(models, 1): + print(f" {i}. {model}") + print(" 0. Modell-ID manuell eingeben") + while True: + choice = input("Auswahl: ").strip() + if choice == "0": + return input("Modell-ID (provider/model): ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(models): + return models[int(choice) - 1] + print("Ungültige Auswahl.") + else: + print("Keine Modelle in opencode.json(c) gefunden.") + return input("Modell-ID (provider/model): ").strip() + + +def cmd_init(args: argparse.Namespace) -> int: + mapping_path = Path(args.mapping) if args.mapping else DEFAULT_MAPPING_PATH + if mapping_path.exists(): + print(f"Mapping existiert bereits: {mapping_path}") + else: + mapping_path.parent.mkdir(parents=True, exist_ok=True) + mapping_path.write_text(INIT_TEMPLATE, encoding="utf-8") + print(f"Angelegt: {mapping_path}") + + if args.fill: + missing = [k for k, v in load_mapping(mapping_path).items() + if v == "PLACEHOLDER"] + models = available_models() + if missing and not models: + raise OmosError( + "Keine Modelle in ~/.config/opencode/opencode.json(c) gefunden. " + "Trage die Modell-IDs manuell ein oder richte opencode ein." + ) + data = load_yaml(mapping_path) + for model_class in missing: + data["model_classes"][model_class] = _choose_model(model_class, models) + mapping_path.write_text( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + remaining = sum(1 for v in data["model_classes"].values() + if v == "PLACEHOLDER") + if remaining: + print(f"\n{remaining} Eintrag/Einträge bleiben PLACEHOLDER – " + "bitte manuell ergänzen.") + + print("\nNächster Schritt:") + print(f" omos.py check --package --mapping {mapping_path}") + return 0 + + +def cmd_setup(args: argparse.Namespace) -> int: + """Geführter Ablauf: Profil lesen → Mapping auffüllen → rendern.""" + package_dir = Path(args.package).resolve() + profile, profile_path = load_team_profile(package_dir, args.team_file) + mapping_path = Path(args.mapping) if args.mapping else DEFAULT_MAPPING_PATH + + print(f"Team-Profil: {profile['id']}") + if profile.get("description"): + print(f" {str(profile['description']).strip()}") + print("Rollen:") + for role in profile["roles"]: + mcps = (role.get("capabilities", {}) or {}).get("mcps", []) + skills = (role.get("capabilities", {}) or {}).get("skills", []) + print(f" - {role['id']} → {role.get('model_class')} " + f"(MCPs: {', '.join(mcps) or 'keine'}; " + f"Skills: {', '.join(skills) or 'keine'})") + + if not mapping_path.exists(): + print(f"\nMapping fehlt – lege an: {mapping_path}") + args_init = argparse.Namespace(mapping=str(mapping_path), fill=True) + cmd_init(args_init) + + classes = load_mapping(mapping_path) + needed = sorted({r.get("model_class") for r in profile["roles"] + if r.get("model_class")} - set(classes)) + models = available_models() + changed = False + for model_class in needed: + chosen = _choose_model(model_class, models) + classes[model_class] = {"model": chosen, "# selected_by": "user"} + changed = True + if changed: + mapping_path.write_text( + yaml.safe_dump(classes, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + print(f"\nMapping aktualisiert: {mapping_path}") + + print("\nRendern …") + args_render = argparse.Namespace( + package=str(package_dir), + team_file=args.team_file, + mapping=str(mapping_path), + output=args.output, + dry_run=args.dry_run, + ) + return cmd_render(args_render) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="omos", @@ -415,6 +564,37 @@ def main(argv: list[str] | None = None) -> int: common(p_check) p_check.set_defaults(func=cmd_check) + p_init = sub.add_parser( + "init", + help="Modell-Mapping-Datei anlegen und optional Platzhalter füllen", + ) + p_init.add_argument( + "--mapping", + help=f"Zielpfad (Default: {DEFAULT_MAPPING_PATH})", + ) + p_init.add_argument( + "--fill", + action="store_true", + help="Fehlende Modellklassen interaktiv aus opencode.json(c)-Modellen wählen", + ) + p_init.set_defaults(func=cmd_init) + + p_setup = sub.add_parser( + "setup", + help="Geführt: Profil lesen, Mapping auffüllen, rendern", + ) + common(p_setup) + p_setup.add_argument( + "--output", + help=f"Zieldatei (Default: {DEFAULT_OUTPUT} relativ zum Package)", + ) + p_setup.add_argument( + "--dry-run", + action="store_true", + help="Nur anzeigen, nichts schreiben", + ) + p_setup.set_defaults(func=cmd_setup) + args = parser.parse_args(argv) try: return args.func(args)