This file provides guidance to coding agents (including Claude Code, via a CLAUDE.md that imports
it) when working with code in this repository.
omodel is a Textual TUI that sets models in oh-my-openagent.jsonc (OMO's per-agent / per-category
config). Core flow: what omo suggests + what you already have → pick one → save a clean config.
It bundles a snapshot of omo's model requirements and reads live availability from the opencode CLI;
neither an omo checkout nor a network call is needed at runtime.
# Dev install (gets pytest + ruff)
pip install -e ".[dev]" # or: uv pip install -e .
# Lint (no ruff config → defaults; CI runs exactly this)
ruff check src/ tests/
# Tests
pytest tests/ -v --tb=short # full suite
pytest tests/ -x -q # fast, stop on first failure
pytest tests/test_resolve.py -v # one file
pytest tests/test_catalog_parse.py::TestVerboseParsing -v # one class
pytest tests/test_detect_family.py::TestBundledSuggestionsLoad::test_15_families -v # one test
# Run the app / CLI (also `python -m omodel ...`)
omodel # launch TUI
omodel --check # CI-safe dry-run resolve (exit 0; degrades w/o opencode)
omodel --print # resolved models, no UI
omodel --config /tmp/x.jsonc # ALWAYS use a temp path when testing saves
# Refresh opencode availability: force `opencode models --refresh` + rebuild ~/.cache/omodel
omodel --refresh-models # in-TUI equivalent: the `r` key (off-thread)
# Regenerate bundled suggestion data (needs bun + an omo checkout; non-fatal if absent)
OMO_SRC=~/source/oh-my-openagent omodel --refresh-omoopencode CLI output is cached for 24h under ~/.cache/omodel/ (cache.py) so warm launches/detail
are instant; --refresh-models / r bust it. Tests isolate the cache via tests/conftest.py
($OMODEL_CACHE_DIR → tmp) and must stub subprocess.run (each opencode call is ~3s / ~320 MB).
tests/verification.md maps the 8 DESIGN.md verification checks to concrete commands — use it as the
pre-release gate (it covers the live opencode and PyInstaller-binary checks that CI can't run).
A four-stage pipeline; app.py is the integration point that consumes all of it.
opencode models (live) ─► cache.py (24h) ─► catalog.py ─┐
├─► resolve.py ──► candidate-row dicts ──► app.py (TUI)
data/omo-suggestions.json ──────────────► suggestions.py ─┘ │
(bundled omo snapshot) ▼
config_io.py (save)
catalog.py— "what you have." Parsesopencode modelsintoavailable={provider:[ids]}+connected=[providers](first-seen order, never a set).detail()parses--verboseJSON blocks for the detail pane (display only). Degradation is load-bearing:opencodemissing → empty + banner; exit≠0 or zero lines parsed →CatalogUnavailable→ banner + retry.load()/detail()read throughcache.pyand all opencode calls carry atimeout=;refresh()forcesopencode models --refreshand rebuilds the cache (therkey /--refresh-models).cache.py— on-disk cache (24h TTL) of the two opencode subprocess outputs under~/.cache/omodel/(flat:models.json,verbose-<provider>.json). opencode calls are ~3s / ~320 MB, so the detail fetch runs in anapp.pyworker (never the UI thread) and is capped to one at a time (a spawned process can't be killed — stacking them OOM'd a machine). Those workers run on daemon threads (_to_thread_daemonin app.py, notasyncio.to_thread) soqnever blocks on an in-flight call;ris single-flight. Best-effort: corrupt/expired → miss; write errors swallowed.suggestions.py— "what omo suggests." Loads the bundled JSON;detect_family()is a faithful port of omo'sdetectHeuristicModelFamily(ordered, pattern-before-includes, first match wins — order matters for parity).FAMILY_VENDORis a hardcoded 15-family→vendor map (NOT from omo) used for gateway classification.resolve.py— the core logic.candidates(target)is the heart: a single filtered pass over omo'sfallbackChainkeeping only models you can run — exact match, else newest same-line substitute of the same family (glm-5→glm-5.1), else hidden. No connected-model dump; the list is chain-only plus a+ add model…row. Each resolved model is expanded to one row per serving provider, dedicated-first (_ordered_providers): a provider is a gateway if it serves ≥2 vendors (vendors_served), and a single-vendor dedicated provider sorts before a gateway — sogpt-5.5shows asopenai/gpt-5.5thenopencode/gpt-5.5and you pick either. Data-driven — no hardcoded provider list. (resolve_prefix()keeps the single dedicated-first pick for the add-model modal's bare-id auto-prefix.)config_io.py— edit-in-place save + backups. The write is text-preserving (render): only the top-levelagents/categoriesvalue spans are rewritten clean (json.dumps, no comments — dropping omo's commented palette inside them); everything else — other keys, formatting, and any comments / commented-out config outside those two — is kept byte-for-byte (a small JSONC-aware span scanner locates the two spans; non-omo / hand-broken files fall back to a full clean rewrite).serialize()is the canonical clean form (dirtiness_is_dirty+ the from-scratch/fallback writer), never required to equal the on-disk bytes. Each save snapshots the prior file verbatim to<config_dir>/.backup/<ts>.jsonc; the very first save pinsoriginal.jsonc(never pruned, never counts toward the 20-snapshot cap).app.py— Textual two-pane App. Stable widget IDs (#targets,#candidates,#detail,#providers) and option IDs (agent:<name>[.ultrawork|.compaction],cat:<name>,cand:<i>,cand:add) are a contract that pilot tests depend on — see the module docstring; don't rename.cli.py— argparse dispatch. Imports are deliberately lazy so--version/--check/--refresh-omo/--refresh-modelsnever import Textual. Two refresh flags, one per data source:--refresh-omo(bundled omo suggestions, viarefresh.py) and--refresh-models(opencode availability, viacatalog.refresh()).refresh.py+tools/snapshot_omo.ts— maintainer-time regeneration of the bundled data. The extractor runs under bun (node can't resolve omo's extensionless.tsimports).
resolve.candidates() yields these and app.py renders them — the one shape both sides agree on. Its
fields (source/model/provider/variant/entry/substitute_for/warn) are frozen in
CONTRACTS.md; the value written to config is f"{provider}/{model}" + variant. Read CONTRACTS.md
before changing any public signature or shared shape.
- DESIGN.md is the design-of-record (the spec), CONTRACTS.md pins the frozen shapes + module
signatures, GLOSSARY.md disambiguates the vocabulary. Update DESIGN.md in the same commit as
the code it describes; add/fix a line in GLOSSARY.md when you coin or rename a term. Read DESIGN.md
- CONTRACTS.md before non-trivial changes; skim GLOSSARY.md when a term is ambiguous.
- Python floor is 3.9 (CI matrix 3.9–3.13). Every module starts with
from __future__ import annotations. No runtime PEP-604 unions (isinstance(x, A | B)) or PEP-585 generics — annotations-as-strings makedict | Nonein signatures fine, but runtime use is not. - Real-config safety (hard rule): never read-then-write the live
~/.config/opencode/oh-my-openagent.jsoncin tests or examples. Pass an explicit temppath/--configeverywhere. Tests monkeypatchsubprocess.run; no test calls realopencode. - Real-cache safety (hard rule): never let tests touch the real
~/.cache/omodel/. The autousetests/conftest.pyfixture redirects$OMODEL_CACHE_DIRto a per-test tmp dir, andtest_app_pilot.pystubssubprocess.runso the TUI never spawns real opencode (~320 MB/call — un-stubbed it OOM'd a box). - The model pickers (add-model +
v) read variants from cachedopencode --verbose, viaCatalog.variants_for(provider, model)— opencode's per-(provider, model)variantskeys are the source of truth (decision #14). It prefers the first non-empty set across the picked provider then others (dedicated providers report{}; the gateway has the real set), and offers nothing when empty everywhere or uncached — no heuristic fallback (kimi/glm-5 → no variant step).--verbose.familyis still never read (family stays heuristic), and the bundled family registry still backsdetect_family/substitution and resolve's omo-suggestion⚠warn (which warn-but-allow, never block). - GPT-only agents: Hephaestus mirrors omo's
no-hephaestus-non-gpthook via_GPT_ONLY_AGENTS/_is_gpt_modelinapp.py— a hardcoded agent key, not a data field.
src/omodel/data/omo-suggestions.jsonis generated (do not hand-edit); regenerate via--refresh-omo, which CI also runs weekly (refresh-suggestions.yml) to open a PR on change. It is derived from omo (Sustainable Use License) — keepNOTICEattribution intact when redistributing.- Distribution is GitHub-only, no PyPI:
release.ymlbuilds PyInstaller one-file binaries onv*tags (linux-x64, darwin-arm64, darwin-x64);install.shis the curl|sh installer. Non-Python payload (data/,tools/) ships because it lives under the package tree and is read viaimportlib.resources— do not add a hatch force-include for it (duplicates the path and fails the wheel build).