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
This commit is contained in:
Tobias J. Endres 2026-08-23 17:10:35 +02:00
parent f035902f20
commit b897bb4b17
2 changed files with 210 additions and 7 deletions

View file

@ -19,6 +19,22 @@ team-profile.yaml + model-mapping.yaml → omos render → .opencode/
## Schnellstart ## 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 <projekt>
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 ```bash
# 1. Modell-Mapping einmalig anlegen # 1. Modell-Mapping einmalig anlegen
mkdir -p ~/.config/apm-team mkdir -p ~/.config/apm-team
@ -31,11 +47,6 @@ python3 omos.py check --package examples/package
# 3. Rendern # 3. Rendern
cd <projekt> cd <projekt>
python3 /pfad/zu/omos.py render --package /pfad/zum/package 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 ## Die drei Dateien
@ -51,10 +62,22 @@ Dazu eine Provenance-Datei `.opencode/oh-my-opencode-slim.omos-provenance.json`,
## Kommandoreferenz ## Kommandoreferenz
```bash ```bash
python3 omos.py render --package DIR [--mapping FILE] [--output FILE] [--dry-run] python3 omos.py init [--mapping FILE] [--fill] # Mapping anlegen/füllen
python3 omos.py check --package DIR [--mapping FILE] 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 ResolveValidateRender-Ablauf.
### `render` / `check`
| Option | Default | Bedeutung | | Option | Default | Bedeutung |
|---|---|---| |---|---|---|
| `--package` | `.` | APM-Package-Verzeichnis mit `team-profile.yaml` | | `--package` | `.` | APM-Package-Verzeichnis mit `team-profile.yaml` |

View file

@ -256,6 +256,46 @@ def configured_mcps() -> set[str]:
return set() 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: def check_mcps(preset: dict, warnings: list[str]) -> None:
available = configured_mcps() available = configured_mcps()
if not available: if not available:
@ -372,6 +412,115 @@ def _report(warnings: list[str], dry_run: bool = False) -> None:
print("\nKeine Warnungen.") 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 <package-dir> --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: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="omos", prog="omos",
@ -415,6 +564,37 @@ def main(argv: list[str] | None = None) -> int:
common(p_check) common(p_check)
p_check.set_defaults(func=cmd_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) args = parser.parse_args(argv)
try: try:
return args.func(args) return args.func(args)