omoctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
omoctl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
omoctl/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from omoctl.cli import main
2
+
3
+ main()
omoctl/cli.py ADDED
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from omoctl import __version__
7
+ from omoctl.config import load_config
8
+ from omoctl.models import enrich_cache_with_omo, load_model_cache
9
+ from omoctl.omo import fetch_omo_config
10
+ from omoctl.validate import print_validation_result, validate_config
11
+ from omoctl.output import (
12
+ BOLD,
13
+ DIM,
14
+ GREEN,
15
+ RESET,
16
+ die,
17
+ print_diff,
18
+ print_profile_list,
19
+ print_profile_status,
20
+ print_section,
21
+ )
22
+ from omoctl.paths import ACTIVE_CONFIG_PATH
23
+ from omoctl.patching import apply_patches_to_config
24
+ from omoctl.store import (
25
+ activate_profile,
26
+ cleanup_stale,
27
+ get_active_alias,
28
+ get_profile_config,
29
+ save_profile,
30
+ remove_profile,
31
+ )
32
+
33
+
34
+ def cmd_status(_args: argparse.Namespace) -> None:
35
+ config = load_config()
36
+ active_alias = get_active_alias()
37
+
38
+ profile = None
39
+ if config.active_profile:
40
+ profile = config.find_profile(config.active_profile)
41
+ if profile is None and active_alias:
42
+ profile = config.find_profile(active_alias)
43
+
44
+ if profile is None:
45
+ print(f"{DIM}No active profile.{RESET}")
46
+ print(f"{DIM}Run 'omoctl list' to see available profiles.{RESET}")
47
+ return
48
+
49
+ print_profile_status(profile.name, profile.alias, profile.providers)
50
+
51
+
52
+ def cmd_list(_args: argparse.Namespace) -> None:
53
+ config = load_config()
54
+ active_profile = config.get_active_profile()
55
+ active_alias = (
56
+ active_profile.alias if active_profile else get_active_alias()
57
+ )
58
+
59
+ profiles = [
60
+ (p.name, p.alias, p.providers) for p in config.profiles
61
+ ]
62
+
63
+ if not profiles:
64
+ print(f"{DIM}No profiles defined in config.{RESET}")
65
+ return
66
+
67
+ print_profile_list(profiles, active_alias)
68
+
69
+
70
+ def cmd_switch(args: argparse.Namespace) -> None:
71
+ config = load_config()
72
+ profile = config.find_profile(args.profile)
73
+
74
+ if profile is None:
75
+ die(f"Profile {args.profile!r} not found. Run 'omoctl list' to see available profiles.")
76
+
77
+ stored_config = get_profile_config(profile.alias)
78
+ if stored_config is None:
79
+ die(
80
+ f"Profile {profile.name!r} has no stored config.\n"
81
+ f" Run 'omoctl update {profile.alias}' first to fetch and build it."
82
+ )
83
+
84
+ activate_profile(profile.alias, profile.name, stored_config)
85
+ print(f"{GREEN}Switched to profile: {BOLD}{profile.name}{RESET}")
86
+
87
+
88
+ def cmd_update(args: argparse.Namespace) -> None:
89
+ config = load_config()
90
+
91
+ if args.profile:
92
+ profile = config.find_profile(args.profile)
93
+ if profile is None:
94
+ die(f"Profile {args.profile!r} not found.")
95
+ profiles = [profile]
96
+ else:
97
+ profiles = list(config.profiles)
98
+
99
+ cache = load_model_cache()
100
+
101
+ active_profile = config.get_active_profile()
102
+ active_alias = active_profile.alias if active_profile else get_active_alias()
103
+
104
+ for idx, profile in enumerate(profiles):
105
+ if idx:
106
+ print()
107
+ print_section(profile.name)
108
+ print()
109
+
110
+ omo_config = fetch_omo_config(tuple(profile.providers))
111
+ enriched_cache = enrich_cache_with_omo(cache, omo_config)
112
+
113
+ curr_config = get_profile_config(profile.alias)
114
+
115
+ patched_config, keys = apply_patches_to_config(
116
+ enriched_cache, profile, omo_config, config,
117
+ )
118
+
119
+ save_profile(profile.alias, profile.name, patched_config)
120
+
121
+ print_diff(curr_config, patched_config, keys)
122
+
123
+ to_activate = config.get_active_profile()
124
+ if not to_activate and active_alias:
125
+ to_activate = config.find_profile(active_alias)
126
+ if to_activate:
127
+ stored = get_profile_config(to_activate.alias)
128
+ if stored:
129
+ activate_profile(to_activate.alias, to_activate.name, stored)
130
+
131
+ valid_aliases = {p.alias for p in config.profiles}
132
+ cleanup_stale(valid_aliases)
133
+
134
+
135
+ def cmd_remove(args: argparse.Namespace) -> None:
136
+ config = load_config()
137
+ profile = config.find_profile(args.profile)
138
+
139
+ if profile is None:
140
+ die(f"Profile {args.profile!r} not found.")
141
+
142
+ remove_profile(profile.alias)
143
+ print(f"{GREEN}Removed profile: {BOLD}{profile.name}{RESET}")
144
+ print(f"{DIM}Note: the profile definition is still in config.yaml. Edit it to remove permanently.{RESET}")
145
+
146
+
147
+ def cmd_validate(_args: argparse.Namespace) -> None:
148
+ config = load_config()
149
+ cache = load_model_cache()
150
+
151
+ all_providers = set()
152
+ for profile in config.profiles:
153
+ all_providers.update(profile.providers)
154
+ omo_config = fetch_omo_config(tuple(sorted(all_providers)), quiet=True)
155
+ enriched_cache = enrich_cache_with_omo(cache, omo_config)
156
+
157
+ errors = validate_config(config, enriched_cache)
158
+ if not print_validation_result(errors):
159
+ sys.exit(1)
160
+
161
+
162
+ def cmd_show(args: argparse.Namespace) -> None:
163
+ if args.alias:
164
+ alias = get_active_alias()
165
+ if not alias:
166
+ die("No active profile. Run 'omoctl switch <profile>' first.")
167
+ print(alias)
168
+ return
169
+
170
+ if args.name:
171
+ alias = get_active_alias()
172
+ if not alias:
173
+ die("No active profile. Run 'omoctl switch <profile>' first.")
174
+ config = load_config()
175
+ profile = config.find_profile(alias)
176
+ if profile is None:
177
+ die(f"Active alias {alias!r} is not defined in config.yaml.")
178
+ print(profile.name)
179
+ return
180
+
181
+ if not ACTIVE_CONFIG_PATH.exists():
182
+ die(
183
+ f"No active config at {ACTIVE_CONFIG_PATH}.\n"
184
+ f" Run 'omoctl switch <profile>' or 'omoctl update' first."
185
+ )
186
+
187
+ if args.json:
188
+ print(ACTIVE_CONFIG_PATH.read_text())
189
+ return
190
+
191
+ alias = get_active_alias()
192
+ config = load_config()
193
+ profile = config.find_profile(alias) if alias else None
194
+
195
+ if profile:
196
+ print(
197
+ f"{BOLD}Profile:{RESET} "
198
+ f"{GREEN}{profile.name}{RESET} {DIM}({profile.alias}){RESET}"
199
+ )
200
+ elif alias:
201
+ print(f"{BOLD}Profile:{RESET} {DIM}{alias}{RESET}")
202
+ print()
203
+ print(ACTIVE_CONFIG_PATH.read_text())
204
+
205
+
206
+ def cmd_version(_args: argparse.Namespace) -> None:
207
+ print(f"omoctl {__version__}")
208
+
209
+
210
+ def main() -> None:
211
+ parser = argparse.ArgumentParser(
212
+ prog="omoctl",
213
+ description="Manage oh-my-openagent (OMO) profiles",
214
+ )
215
+ parser.add_argument(
216
+ "--version",
217
+ action="version",
218
+ version=f"omoctl {__version__}",
219
+ )
220
+ parser.set_defaults(func=cmd_status)
221
+
222
+ sub = parser.add_subparsers(dest="command")
223
+
224
+ list_p = sub.add_parser("list", help="List all profiles")
225
+ list_p.set_defaults(func=cmd_list)
226
+
227
+ switch_p = sub.add_parser("switch", help="Switch to a profile")
228
+ switch_p.add_argument("profile", help="Profile name or alias")
229
+ switch_p.set_defaults(func=cmd_switch)
230
+
231
+ update_p = sub.add_parser("update", help="Update profiles (fetch + patch + save)")
232
+ update_p.add_argument("profile", nargs="?", default=None, help="Profile name or alias (all if omitted)")
233
+ update_p.set_defaults(func=cmd_update)
234
+
235
+ remove_p = sub.add_parser("remove", help="Remove a stored profile")
236
+ remove_p.add_argument("profile", help="Profile name or alias")
237
+ remove_p.set_defaults(func=cmd_remove)
238
+
239
+ validate_p = sub.add_parser("validate", help="Validate config against available models/agents")
240
+ validate_p.set_defaults(func=cmd_validate)
241
+
242
+ show_p = sub.add_parser(
243
+ "show", help="Show the active profile (header + JSON by default)"
244
+ )
245
+ show_mode = show_p.add_mutually_exclusive_group()
246
+ show_mode.add_argument(
247
+ "-a", "--alias", action="store_true",
248
+ help="Print only the active profile alias",
249
+ )
250
+ show_mode.add_argument(
251
+ "-n", "--name", action="store_true",
252
+ help="Print only the active profile name",
253
+ )
254
+ show_mode.add_argument(
255
+ "-j", "--json", action="store_true",
256
+ help="Print only the raw JSON config (no header)",
257
+ )
258
+ show_p.set_defaults(func=cmd_show)
259
+
260
+ version_p = sub.add_parser("version", help="Print version")
261
+ version_p.set_defaults(func=cmd_version)
262
+
263
+ args = parser.parse_args()
264
+ try:
265
+ args.func(args)
266
+ except KeyboardInterrupt:
267
+ print("\nAborted.", file=sys.stderr)
268
+ sys.exit(130)
omoctl/config.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import re
5
+ import typing
6
+
7
+ import dacite
8
+ import yaml
9
+
10
+ from omoctl.output import die
11
+ from omoctl.paths import CONFIG_DIR, CONFIG_PATH, PROFILES_DIR
12
+ from omoctl.types import _UNSET
13
+
14
+
15
+ @dataclasses.dataclass
16
+ class PatchSource:
17
+ provider: str | None = None
18
+ model: typing.Any = None
19
+ agent: str | None = None
20
+ category: str | None = None
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class PatchTarget:
25
+ provider: str | None = None
26
+ model: typing.Any = None
27
+ variant: typing.Any = dataclasses.field(default=_UNSET)
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class Patch:
32
+ source: PatchSource = dataclasses.field(default_factory=PatchSource)
33
+ target: PatchTarget = dataclasses.field(default_factory=PatchTarget)
34
+
35
+
36
+ @dataclasses.dataclass
37
+ class Profile:
38
+ name: str = ""
39
+ providers: list[str] = dataclasses.field(default_factory=list)
40
+ patches: list[Patch] | None = None
41
+ overrides: dict | None = None
42
+
43
+ @property
44
+ def alias(self) -> str:
45
+ return re.sub(r"[^a-z0-9]+", "-", self.name.lower()).strip("-")[:50]
46
+
47
+
48
+ @dataclasses.dataclass
49
+ class Config:
50
+ active_profile: str | None = None
51
+ defaults: dict | None = None
52
+ patches: list[Patch] | None = None
53
+ profiles: list[Profile] = dataclasses.field(default_factory=list)
54
+
55
+ def find_profile(self, name_or_alias: str) -> Profile | None:
56
+ key = name_or_alias.lower()
57
+ for profile in self.profiles:
58
+ if profile.name.lower() == key or profile.alias == key:
59
+ return profile
60
+ return None
61
+
62
+ def get_active_profile(self) -> Profile | None:
63
+ if self.active_profile is None:
64
+ return None
65
+ return self.find_profile(self.active_profile)
66
+
67
+ def get_effective_patches(self, profile: Profile) -> list[Patch]:
68
+ result: list[Patch] = []
69
+ if profile.patches:
70
+ result.extend(profile.patches)
71
+ if self.patches:
72
+ result.extend(self.patches)
73
+ return result
74
+
75
+ def get_effective_overrides(self, profile: Profile) -> dict | None:
76
+ if not self.defaults and not profile.overrides:
77
+ return None
78
+ result = dict(self.defaults) if self.defaults else {}
79
+ if profile.overrides:
80
+ result = merge_dicts(result, profile.overrides)
81
+ return result or None
82
+
83
+
84
+ def merge_dicts(
85
+ base: dict[str, typing.Any], override: dict[str, typing.Any]
86
+ ) -> dict[str, typing.Any]:
87
+ result = base.copy()
88
+ for key, value in override.items():
89
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
90
+ result[key] = merge_dicts(result[key], value)
91
+ else:
92
+ result[key] = value
93
+ return result
94
+
95
+
96
+ _DACITE_CONFIG = dacite.Config(check_types=False, strict=True)
97
+
98
+ _DEFAULT_YAML = """\
99
+ # active_profile: my-profile
100
+
101
+ defaults:
102
+ disabled_hooks:
103
+ - context-window-monitor
104
+
105
+ # patches:
106
+ # - source: { provider: google }
107
+ # target: { provider: proxy }
108
+
109
+ profiles:
110
+ - name: Claude
111
+ providers: [claude]
112
+
113
+ - name: No Copilot
114
+ providers: [claude, gemini, openai]
115
+ """
116
+
117
+
118
+ def load_config() -> Config:
119
+ if not CONFIG_PATH.exists():
120
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
121
+ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
122
+ CONFIG_PATH.write_text(_DEFAULT_YAML)
123
+ die(
124
+ f"No config found. A default has been created at {CONFIG_PATH}\n"
125
+ f" Edit it to define your profiles and re-run."
126
+ )
127
+
128
+ try:
129
+ with CONFIG_PATH.open() as f:
130
+ data = yaml.safe_load(f)
131
+ except yaml.YAMLError as e:
132
+ die(f"Config at {CONFIG_PATH} has invalid YAML:\n {e}")
133
+
134
+ if not data or not data.get("profiles"):
135
+ die(f"No profiles defined in {CONFIG_PATH}. Add at least one profile.")
136
+
137
+ try:
138
+ config = dacite.from_dict(Config, data, config=_DACITE_CONFIG)
139
+ except dacite.DaciteError as e:
140
+ die(f"Config at {CONFIG_PATH} has invalid structure:\n {e}")
141
+
142
+ seen_aliases: dict[str, str] = {}
143
+ for profile in config.profiles:
144
+ if not profile.name:
145
+ die("Each profile must have a 'name' field.")
146
+ if not profile.providers:
147
+ die(f"Profile {profile.name!r} must have a 'providers' field.")
148
+ if profile.alias in seen_aliases:
149
+ die(
150
+ f"Profile {profile.name!r} produces alias {profile.alias!r} "
151
+ f"which collides with profile {seen_aliases[profile.alias]!r}.\n"
152
+ f" Rename one of them so they produce distinct aliases."
153
+ )
154
+ seen_aliases[profile.alias] = profile.name
155
+
156
+ return config
omoctl/models.py ADDED
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import itertools
5
+ import json
6
+ import re
7
+ import typing
8
+
9
+ from omoctl.output import die
10
+ from omoctl.paths import CACHED_MODELS_PATH, CUSTOM_MODELS_PATH
11
+ from omoctl.types import ModelFilter, ModelProps
12
+
13
+ DATED_MODEL_PATTERN: typing.Final[re.Pattern] = re.compile(
14
+ r".*("
15
+ r"\d{6,}"
16
+ r"|20\d{2}[-]\d{2}([-]\d{2})?"
17
+ r"|\d{2}[-]20\d{2}"
18
+ r"|\d{2}[-]\d{2}"
19
+ r")$"
20
+ )
21
+
22
+
23
+ @dataclasses.dataclass(frozen=True, slots=True)
24
+ class ModelCache:
25
+ provider_to_models: dict[str, tuple[str, ...]]
26
+ agent_names: tuple[str, ...] = ()
27
+ category_names: tuple[str, ...] = ()
28
+
29
+
30
+ def load_model_cache() -> ModelCache:
31
+ if not CACHED_MODELS_PATH.exists():
32
+ die(
33
+ f"Model cache not found at {CACHED_MODELS_PATH}.\n"
34
+ f" Run 'opencode' once to populate it."
35
+ )
36
+
37
+ try:
38
+ with CACHED_MODELS_PATH.open() as f:
39
+ data = json.load(f)
40
+ except json.JSONDecodeError as e:
41
+ die(f"Model cache at {CACHED_MODELS_PATH} is malformed:\n {e}")
42
+
43
+ custom_data: dict = {}
44
+ if CUSTOM_MODELS_PATH.exists():
45
+ try:
46
+ with CUSTOM_MODELS_PATH.open() as f:
47
+ custom_data = json.load(f).get("provider", {})
48
+ except json.JSONDecodeError as e:
49
+ die(f"Custom models at {CUSTOM_MODELS_PATH} is malformed:\n {e}")
50
+
51
+ providers = set(data.keys()) | set(custom_data.keys())
52
+
53
+ provider_to_models: dict[str, tuple[str, ...]] = {}
54
+ for provider in providers:
55
+ models: set[str] = set()
56
+ for source_data in (data, custom_data):
57
+ if provider in source_data and "models" in source_data[provider]:
58
+ provider_models = source_data[provider]["models"]
59
+ if isinstance(provider_models, dict):
60
+ models.update(provider_models.keys())
61
+
62
+ provider_to_models[provider] = tuple(
63
+ sorted(
64
+ models,
65
+ key=lambda model_id: (
66
+ bool(DATED_MODEL_PATTERN.match(model_id)),
67
+ not model_id.endswith("latest"),
68
+ len(model_id),
69
+ ),
70
+ )
71
+ )
72
+
73
+ return ModelCache(
74
+ provider_to_models=provider_to_models,
75
+ )
76
+
77
+
78
+ def enrich_cache_with_omo(cache: ModelCache, omo_config: dict) -> ModelCache:
79
+ return ModelCache(
80
+ provider_to_models=cache.provider_to_models,
81
+ agent_names=tuple(sorted(omo_config.get("agents", {}).keys())),
82
+ category_names=tuple(sorted(omo_config.get("categories", {}).keys())),
83
+ )
84
+
85
+
86
+ def _numbers_key(numbers: tuple[str, ...]) -> tuple[int, ...]:
87
+ return tuple(int(n) for n in numbers) if numbers else ()
88
+
89
+
90
+ def find_best_matching_model(
91
+ provider_to_models: dict[str, tuple[str, ...]],
92
+ original_provider: str | None,
93
+ original_model: str | None,
94
+ target_provider: str,
95
+ target_hint: ModelFilter | str | None,
96
+ ) -> str:
97
+ if target_provider not in provider_to_models:
98
+ die(f"Target provider {target_provider!r} not found in cached models.")
99
+
100
+ if isinstance(target_hint, str):
101
+ if target_hint in provider_to_models[target_provider]:
102
+ return f"{target_provider}/{target_hint}"
103
+ die(
104
+ f"Explicit model {target_hint!r} not found in provider {target_provider!r}."
105
+ )
106
+
107
+ original_props = (
108
+ ModelProps.from_model_id(original_model) if original_model else None
109
+ )
110
+
111
+ if target_hint is not None:
112
+ include_words = target_hint.words_include
113
+ exclude_words = target_hint.words_exclude
114
+ include_numbers = target_hint.numbers_include
115
+ exclude_numbers = target_hint.numbers_exclude
116
+ else:
117
+ include_words = ()
118
+ exclude_words = ()
119
+ include_numbers = ()
120
+ exclude_numbers = ()
121
+
122
+ if not target_hint and original_props:
123
+ include_words = original_props.words
124
+ include_numbers = original_props.numbers
125
+
126
+ version_specified = bool(include_numbers) or bool(
127
+ original_props and original_props.numbers
128
+ )
129
+
130
+ def _matches(props: ModelProps) -> bool:
131
+ return (
132
+ all(w in props.words for w in include_words)
133
+ and not any(w in props.words for w in exclude_words)
134
+ and all(n in props.numbers for n in include_numbers)
135
+ and not any(n in props.numbers for n in exclude_numbers)
136
+ )
137
+
138
+ candidates: list[tuple[str, ModelProps]] = []
139
+ for model_id in provider_to_models[target_provider]:
140
+ props = ModelProps.from_model_id(model_id)
141
+ if props is not None and _matches(props):
142
+ candidates.append((model_id, props))
143
+
144
+ if not candidates and version_specified:
145
+ for model_id in provider_to_models[target_provider]:
146
+ props = ModelProps.from_model_id(model_id)
147
+ if props is None:
148
+ continue
149
+ if all(w in props.words for w in include_words) and not any(
150
+ w in props.words for w in exclude_words
151
+ ):
152
+ candidates.append((model_id, props))
153
+
154
+ if not candidates:
155
+ die(
156
+ f"No matching model for {original_model!r} from "
157
+ f"{original_provider!r} in provider {target_provider!r}."
158
+ )
159
+
160
+ original_words = set(original_props.words) if original_props else set()
161
+ original_numbers = original_props.numbers if original_props else ()
162
+ target_version = _numbers_key(include_numbers or original_numbers)
163
+
164
+ def _sort_key(entry: tuple[str, ModelProps]) -> tuple:
165
+ model_id, props = entry
166
+ version = _numbers_key(props.numbers)
167
+ word_overlap = -len(original_words & set(props.words)) if original_words else 0
168
+
169
+ return (
170
+ (props.provider_prefix or "") != (original_provider or ""),
171
+ word_overlap,
172
+ bool(DATED_MODEL_PATTERN.match(model_id)),
173
+ "latest" in props.words,
174
+ tuple(
175
+ abs(a - b)
176
+ for a, b in itertools.zip_longest(version, target_version, fillvalue=0)
177
+ )
178
+ if version_specified
179
+ else tuple(-v for v in version),
180
+ len(model_id),
181
+ )
182
+
183
+ candidates.sort(key=_sort_key)
184
+ return f"{target_provider}/{candidates[0][0]}"