Imported from zhoufanscut/oModel (
AGENTS.md). Install upstream withnpx skills add zhoufanscut/oModel. Copyright stays with the author.
AGENTS.md
This file provides guidance to coding agents (including Claude Code, via a CLAUDE.md that imports
it) when working on this repository's source.
Just want to USE
omodelto set a model? That is a different document: runomodel agent-guide(or readsrc/omodel/data/agent-usage.md) for the verbs, the JSON shapes, and the exit codes. Short version: never hand-edit omo's config — runomodel candidates <target> --json, thenomodel set <target> <value>.
What this is
omodel is a Textual TUI that sets models in omo's config (OMO's per-agent / per-category config):
~/.omo/omo.jsonc on omo 4.19.3+, where the assignments live under "[opencode]", falling back to
the pre-4.19.3 ~/.config/opencode/oh-my-openagent.jsonc. See DESIGN §Config scope.
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.
Commands
# Dev install (gets pytest + ruff)
pip install -e ".[dev]" # or: uv pip install -e .
# Lint (ruff defaults MINUS the documented ignores in pyproject; 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_11_agents -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
omodel --update --json # newer omodel? (the only network call; --json never installs)
# Agent surface (JSON + exit codes; see `omodel agent-guide` for the full contract)
omodel targets --json
omodel candidates agent:sisyphus --json
omodel set agent:sisyphus opencode/claude-opus-4-8 --variant max --dry-run --json
omodel apply --json < assignments.json # batch, ONE save (backup ring holds 20)
omodel preset use cheap
# 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-omo
opencode 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 DESIGN.md's 9 verification checks (plus Check 10, the agent surface) to
concrete commands — use it as the
pre-release gate (it covers the live opencode and PyInstaller-binary checks that CI can't run).
Architecture
A four-stage pipeline; app.py is the integration point that consumes all of it.
opencode models (live) ─► cache.py (24h) ─► catalog.py ─┐ ┌─► app.py (TUI)
├─► resolve.py ─► session.py ┤
data/omo-suggestions.json ──────────────► suggestions.py ─┘ candidate-row (headless)└─► cli.py (agent JSON)
(bundled omo snapshot) dicts │
▼
config_io.py + presets.py (save, together)
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 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).presets.py— named presets (unlimited, dense list seeded with onedefault), and they ARE the working state (decision #17): a leaf likehistory.py(pure data + file IO, no omodel imports) over<config_dir>/.omodel-presets.json, so presets follow the active config. Exactly one preset is active; the config on disk always equals it — that invariant drives the rest: edits flow into the active preset (app.py's_projected_store),enterswitches (banking your edits into the one you leave),rrenames,xrefuses on the active one, and onlyswrites — both files, together, so quitting discards both in lockstep. First launch with no presets seeds one from your config, in memory. The list is dense, so a delete renumbers every later preset —app.pyremaps the undo history's stored indices in the same breath (History.map_aux_key, always PER-ENTRY: a blanket stamp erases older switches and the delete sentinels); that is the sharp edge. Reads are best-effort (missing/corrupt → empty store,activenormalized);write()raises so the app can notify.session.py— the headless core (decision #18), and the reason the CLI can do anything the TUI can. Holds cfg + catalog + suggestions + resolver + the presets store, and owns every cfg mutation (set_model/clear/switch_preset) plus the both-files save.app.pyandcli.pyare both thin over it, so the rules (provider prefixing, thenone-variant drop, the GPT-only lock, config-equals-active-preset) can't fork between the two surfaces.Session.build()is the shared production wiring. Never import textual or app here —cli.py's lazy imports depend on it. The guards moved here fromapp.py(GPT_ONLY_AGENTS,ULTRAWORK_AGENTS,is_gpt_model,subkinds_for,is_no_variant,read_map,coerce_dict).app.pyre-imports only the four it calls directly, under their old private names (SUBKINDS,is_gpt_model,is_no_variant,subkinds_for), and reaches the rest through the module (session_mod.gpt_only/read_map/target_label) — the two frozensets are never imported at all, which is the point: they exist in exactly one place and can't fork.app.py— Textual two-pane App. Wraps aSessionand keeps only what needs a UI (the undoHistory, the per-target row cache,_custom_rows, rendering);cfg/_store/_saved_text/_saved_store_fpare properties onto the session. Stable widget IDs (#targets,#presets,#candidates,#detail,#providers) and option IDs (agent:<name>[.ultrawork|.compaction],cat:<name>,cand:<i>,cand:add,preset:<i>,preset:new) are a contract that pilot tests depend on — see the module docstring; don't rename.cli.py— argparse dispatch, two audiences. The flat flags (--print/--check/--restore/--refresh-*/--version) are the human surface and are unchanged. The subcommands are the agent surface:agent-guide,targets,show,candidates,check,set,clear,apply,preset— all with--jsonand meaningful exit codes (0 ok / 1 omodel failed / 2 usage / 3 refused by a guard; an agent branches on 1-vs-3). Imports are deliberately lazy so--version/--check/the JSON verbs never import Textual.--configis on both the main parser and a sharedparents=parser withdefault=SUPPRESS, soomodel --config X showandomodel show --config Xboth work (without SUPPRESS the subparser silently clobbers the main parser's value). Two refresh flags, one per data source:--refresh-omo(bundled omo suggestions, viarefresh.py) and--refresh-models(opencode availability, viacatalog.refresh()).omodel check(exit 3 on a problem) is deliberately distinct from--check(always exit 0, CI-safe) — don't merge them.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).update.py—omodel --update: the only runtime network access in the codebase, and only when that verb runs (there is deliberately no launch-time version check — "no network call needed at runtime" is a property, not an oversight). Stdlib only. It self-updates just the PyInstaller binary, by downloading the release tarball, verifying its published sha256, and running the new binary's--versionbeforeos.replace-ing it over the old one — every failure path leaves the installed binary byte-for-byte untouched. pipx/uv/pip/source installs get a tag-pinned command and exit 3. It confirms before swapping (--yesskips;--jsonand a missing TTY decline by construction, which is why there is no separate--update-check), and every reason an update is impossible is raised bypreflightbefore that prompt._openis the single network seam — https-only, and its reads catchhttp.client.HTTPExceptionas well asOSError(IncompleteReadis not anOSError, and it escaped as a traceback with empty--jsonstdout); tests monkeypatch_open, or_openerone level lower to exercise_openitself, so no test ever reaches api.github.com. It is a flat flag, not a subcommand, and is not inagent-usage.mdon purpose: an agent replacing the binary it is running is not a model change.
The integration seam: the candidate-row dict
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.
Conventions specific to this repo
-
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.
-
CHANGELOG: check
[Unreleased]before every push. This is the upkeep rule that actually gets skipped — twice now the tag has shipped and user-visible fixes have sat onmainwith an empty[Unreleased], to be reconstructed fromgit loglater under time pressure. Nothing enforces it; CI doesn't check it and the release workflow publishes an empty body, so the file is the only record. Before you push, run:git log --oneline "$(git describe --tags --abbrev=0)"..HEAD # what's unreleased sed -n '/^## \[Unreleased\]/,/^## \[/p' CHANGELOG.md # what's written downEvery commit that changes what a user sees or what an agent gets back needs a line — behaviour, a flag, JSON output, an exit code, bundled-data ids. Refactors, tests, lint and CI don't (0.3.0's
session.pyextraction earned one line only because it's the reason the CLI exists). Write it in the same commit as the change, not at tag time: you will not remember why it mattered. Prefer describing the symptom the user hit over the mechanism you changed. Keep the tone plain — see the existing entries.## [Unreleased]stays as a heading even when empty; never fold a released section into it (a bad edit did exactly that, hiding a whole shipped release). -
Python floor is 3.11 (CI matrix 3.11–3.14;
requires-pythondrives ruff's target, so theUPrules follow it). Every module starts withfrom __future__ import annotations. The floor was 3.9 until 0.7.0 and its workarounds are gone (theSIM117ignore, the pilot-test event-loop shims) — don't add new version shims; raise the floor instead. -
Real-config safety (hard rule): never read-then-write the live
~/.omo/omo.jsonc(or the legacy~/.config/opencode/oh-my-openagent.jsonc) in tests or examples. Pass an explicit temppath/--configeverywhere. Tests monkeypatchsubprocess.run; no test calls realopencode. The default path now carries side effects a temp one does not — the first run there adopts a stranded presets store and deletes the original — sotests/conftest.pyredirects$HOME/$USERPROFILEas well as$XDG_CONFIG_HOME. -
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). -
Never render data as a plain
str(hard rule,app.py): Textual parses content markup in any plain string it renders, so a[in a model id / provider / variant / target name / preset name /str(exc)is a tag, and an unmatched close raisesMarkupErrorinside the render pass — uncatchable, app dies. Data-carryingStatic/Labeltakemarkup=False;Optionprompts go through_lit(); only#detailrenders markup, so what it splices in goes through_esc();OModelApp.notifydefaultsmarkup=False. See DESIGN §Textual contract. -
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 viaGPT_ONLY_AGENTS/is_gpt_modelinsession.py(notapp.py— they moved there so the CLI enforces the same lock) — a hardcoded agent key, not a data field. Same forULTRAWORK_AGENTS.
Bundled data & packaging
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.- Three files agree on the release asset names and must be changed together:
release.yml(what gets built + uploaded),install.sh(first install) andupdate.py(platform_asset, every install after that).omodel-<os>-<arch>.tar.gz+.sha256is a contract, not a detail — dropping the checksum asset silently downgrades both the installer and the updater to unverified, and renaming the tarball breaks--updatefor everyone already installed. - Distribution is GitHub-only, no PyPI:
release.ymlbuilds PyInstaller one-file binaries onv*tags (linux-x64 + darwin-arm64 only — Intel-macdarwin-x64was dropped in 0.2.0; those runners are being retired, and Intel macs install via pipx);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).
