From 901b141e7775ef66b8445f0ccda9d1a9e3453d9f Mon Sep 17 00:00:00 2001 From: Zhu Lin Date: Wed, 7 Oct 2026 20:39:45 +0800 Subject: [PATCH] Add oh-my-pi (omp) as a supported coding agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ug can configure, launch, and register MCP servers for oh-my-pi (`omp`, @oh-my-pi/pi-coding-agent), alongside the existing pi entry. - agents/omp.py writes a private ~/.ucode/omp-home/.omp/agent/models.yml with the three Databricks providers (anthropic-messages / openai-responses / google-generative-ai) and pins the default model via `modelRoles.default` in config.yml (models.yml root must stay providers-only per omp's schema). - Each provider's apiKey is the `!ug auth-token ...` command value, so omp mints a token on demand and re-runs the command when the gateway answers 401 — nothing that expires is written to models.yml and no refresh thread runs. - MCP servers register through omp's agent-dir mcp.json ({command, args} only, per omp's stdioServer schema), including the batched write_user_mcp_servers path that the workspace-managed MCP flow uses. - Registry, discovery, launch (`ug omp`), state, revert, and README coverage; pyyaml becomes a direct dependency for config_io's YAML readers/writers. --- README.md | 9 +- pyproject.toml | 1 + src/ucode/agents/__init__.py | 22 +- src/ucode/agents/omp.py | 333 ++++++++++++++ src/ucode/cli.py | 54 ++- src/ucode/config_io.py | 26 ++ src/ucode/databricks.py | 3 + src/ucode/launcher.py | 14 +- src/ucode/managed_setup.py | 3 + src/ucode/mcp.py | 37 +- src/ucode/state.py | 7 + tests/test_agent_omp.py | 818 +++++++++++++++++++++++++++++++++++ tests/test_agents_init.py | 58 ++- tests/test_cli.py | 15 +- tests/test_config_io.py | 50 +++ tests/test_databricks.py | 11 + tests/test_doctor.py | 6 +- tests/test_e2e.py | 2 +- tests/test_managed_setup.py | 9 +- tests/test_mcp.py | 48 +- tests/test_state.py | 10 +- uv.lock | 46 ++ 22 files changed, 1541 insertions(+), 41 deletions(-) create mode 100644 src/ucode/agents/omp.py create mode 100644 tests/test_agent_omp.py diff --git a/README.md b/README.md index b5293c035..f2b3a15cb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Unity Gateway (`ug`) Unity Gateway runs coding agents through Databricks AI Gateway. It configures -Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, and Pi, and can -register Databricks MCP servers for Cursor Agent. +Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, Pi, and Oh My +Pi, and can register Databricks MCP servers for Cursor Agent. The command is `ug`. Existing `ucode` commands remain supported, and the Python package is still named `ucode` for compatibility. @@ -52,6 +52,7 @@ ug gemini ug opencode ug copilot ug pi +ug omp ug cursor # Cursor Agent, MCP-only ``` @@ -111,7 +112,8 @@ ug configure --profile DEFAULT --agents claude,codex ``` Available coding agents are `codex`, `claude`, `gemini`, `opencode`, -`copilot`, and `pi`. `cursor` can be included in `--agents` for MCP-only setup; +`copilot`, `pi`, and `omp`. `cursor` can be included in `--agents` for MCP-only +setup; Cursor models still run through your Cursor account. `UG_WORKSPACE` can provide the default workspace. An explicit `--workspace` or @@ -392,6 +394,7 @@ preferences are ignored and their files are left untouched. See the bundled | OpenCode | `~/.ucode/opencode-xdg/opencode/opencode.json`, `~/.ucode/opencode-xdg/opencode/plugin/ucode-auth.js` | | GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/ucode-mcp-config.json` | | Pi | `~/.ucode/pi-home/.pi/agent/models.json`, `~/.ucode/pi-home/.pi/agent/settings.json` | +| Oh My Pi | `~/.ucode/omp-home/.omp/agent/models.yml`, `~/.ucode/omp-home/.omp/agent/config.yml`, `~/.ucode/omp-home/.omp/agent/mcp.json` | | Cursor Agent | `~/.cursor/mcp.json` | | Unity Gateway | `~/.ucode/managed-state.json`, `~/.ucode/managed-backups/` | diff --git a/pyproject.toml b/pyproject.toml index dfcf91d19..1f1108d7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ # request, via the SDK's stdio server + streamable-HTTP client. Works against # both mcp 1.x (httpx) and mcp 2.x (httpx2) — see mcp_proxy for the shared path. "mcp>=1.28.0", + "pyyaml>=6.0.0", "questionary>=2.0.0", "tomlkit>=0.13.0", "typer>=0.26.0", diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index b148b496a..64c647035 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -43,7 +43,7 @@ status_subprocess_stdout, ) -from . import claude, codex, copilot, gemini, kilo, opencode, pi +from . import claude, codex, copilot, gemini, kilo, omp, opencode, pi from .args import LaunchOptions as LaunchOptions from .args import explicit_model_arg_value as explicit_model_arg_value @@ -51,6 +51,7 @@ "codex": codex, "claude": claude, "gemini": gemini, + "omp": omp, "opencode": opencode, "kilo": kilo, "copilot": copilot, @@ -75,6 +76,8 @@ "kilocode": "kilo", "copilot": "copilot", "pi": "pi", + "omp": "omp", + "oh-my-pi": "omp", } DEFAULT_TOOL = "codex" @@ -88,7 +91,7 @@ # required-upgrade confirmation; declining it would only fail the launch. AUTO_UPGRADE_AGENT_ENV = "UG_AUTO_UPGRADE_AGENT" -# ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported. +# ucode tool -> `databricks aitools` agent id. gemini/pi/omp aren't supported. AITOOLS_AGENT_TOKENS = { "claude": "claude-code", "codex": "codex", @@ -102,7 +105,7 @@ def install_databricks_ai_tools_for_agents( ) -> None: """Install Databricks AI Tools for supported agents. - Gemini and Pi have no ``aitools`` support and are dropped. + Gemini, Pi, and Oh My Pi have no ``aitools`` support and are dropped. This runs only during ``ug configure``. ``force_refresh`` reads the managed config fresh; a caller that already refreshed this launch (the main configure path) leaves it False so the gate @@ -124,7 +127,7 @@ def normalize_tool(tool: str) -> str: normalized = TOOL_ALIASES.get(tool.strip().lower()) if not normalized: raise RuntimeError( - f"Unsupported tool '{tool}'. Use one of: codex, claude, gemini, opencode, kilo, copilot, pi." + f"Unsupported tool '{tool}'. Use one of: codex, claude, gemini, opencode, kilo, copilot, pi, omp." ) return normalized @@ -462,9 +465,11 @@ def configure_tool( result = pi.write_tool_config(state, model) elif tool == "kilo": result = kilo.write_tool_config(state, model) + elif tool == "omp": + result = omp.write_tool_config(state, model) else: result = opencode.write_tool_config(state, model) - # gemini/opencode/copilot/pi return (state, token); codex/claude return state + # gemini/opencode/copilot/pi/omp return (state, token); codex/claude return state if isinstance(result, tuple): return result[0] return result @@ -517,6 +522,12 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool: or bool(state.get("codex_models")) or bool(state.get("gemini_models")) ) + if tool == "omp": + return ( + bool(state.get("claude_models")) + or bool(state.get("codex_models")) + or bool(state.get("gemini_models")) + ) return False @@ -528,6 +539,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool: "gemini": ("gemini",), "copilot": ("claude", "codex"), "pi": ("claude", "codex", "gemini"), + "omp": ("claude", "codex", "gemini"), } diff --git a/src/ucode/agents/omp.py b/src/ucode/agents/omp.py new file mode 100644 index 000000000..1878b970a --- /dev/null +++ b/src/ucode/agents/omp.py @@ -0,0 +1,333 @@ +"""Oh-my-pi (omp) coding agent: writes a ucode-private models.yml with Databricks-backed providers. + +omp (https://omp.sh) is a multi-provider coding agent compatible with Pi's +provider model but configured in YAML. We register three providers in its +`models.yml`, each speaking the API dialect best suited to that family's +gateway path: + +- `databricks-claude` (api: anthropic-messages) → /ai-gateway/anthropic +- `databricks-openai` (api: openai-responses) → /ai-gateway/codex/v1 +- `databricks-gemini` (api: google-generative-ai) → /ai-gateway/gemini/v1beta + +Per-provider `compat` flags work around fields the gateway translators reject: + +- claude: `supportsEagerToolInputStreaming: false` — the Anthropic translator + rejects `tools[].eager_input_streaming` on the streaming + tools path that + omp uses for every request. With this flag omp omits the per-tool field and + sends the legacy `anthropic-beta: fine-grained-tool-streaming-...` header + instead, which the gateway accepts. + +The default model is pinned via `modelRoles.default` in omp's `config.yml` +(the role value omp's startup resolution consults before falling back to the +first available model), never in `models.yml` — whose root carries only +`providers` (unknown root keys fail omp's schema validation). + +OSS / Databricks-foundation models (Llama, Qwen, etc.) are not exposed via +omp today — they live behind /ai-gateway/mlflow/v1 with per-model +`max_tokens` caps that omp has no global way to honor without per-model +config we don't currently maintain. + +Each provider's `apiKey` is an omp `!command` config value rather than a baked +bearer, so omp mints one on demand via `ug auth-token` and re-runs the command +when the gateway answers 401 (the auth-retry path invalidates command-backed +keys); nothing that expires is written to `models.yml`. omp reads no token from +the environment, so `launch` only redirects its agent dir. +""" + +from __future__ import annotations + +import os +import shlex +from pathlib import Path + +import yaml + +from ucode.config_io import ( + APP_DIR, + ToolSpec, + apply_json_mcp_diff, + backup_existing_file, + deep_merge_dict, + write_yaml_file, +) +from ucode.databricks import ( + build_auth_token_argv, + build_pi_base_urls, + get_databricks_token, +) +from ucode.launcher import exec_or_spawn +from ucode.state import mark_tool_managed, save_state +from ucode.telemetry import agent_version, ug_version +from ucode.ui import print_warning + +from .args import LaunchOptions + +OMP_UCODE_HOME = APP_DIR / "omp-home" +OMP_AGENT_DIR = OMP_UCODE_HOME / ".omp" / "agent" +OMP_MODELS_PATH = OMP_AGENT_DIR / "models.yml" +OMP_CONFIG_PATH = OMP_AGENT_DIR / "config.yml" +OMP_MCP_PATH = OMP_AGENT_DIR / "mcp.json" +OMP_MODELS_BACKUP_PATH = APP_DIR / "omp-models.backup.yml" +OMP_CONFIG_BACKUP_PATH = APP_DIR / "omp-config.backup.yml" +OMP_MCP_BACKUP_PATH = APP_DIR / "omp-mcp.backup.json" + + +def _read_yaml_for_merge(path: Path) -> dict | None: + """Read an existing YAML mapping; return None when it must be preserved untouched.""" + try: + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError): + return None + if data is None: + return {} + return data if isinstance(data, dict) else None + + +SPEC: ToolSpec = { + "binary": "omp", + "package": "@oh-my-pi/pi-coding-agent", + "display": "Oh My Pi", + "config_path": OMP_MODELS_PATH, + "backup_path": OMP_MODELS_BACKUP_PATH, +} + +PROVIDER_NAMES = ( + "databricks-claude", + "databricks-openai", + "databricks-gemini", +) + +# Old provider names earlier ucode versions wrote; cleaned up on each write so +# users don't end up with stale entries pointing at routes that 400. +LEGACY_PROVIDER_NAMES = ("databricks-anthropic", "databricks-codex", "databricks-oss") + + +def _resolve_model_selector( + model: str, + claude_models: dict[str, str], + codex_models: list[str], + gemini_models: list[str], +) -> str: + """Return an omp model selector in `/` form when possible.""" + for name in PROVIDER_NAMES: + if model.startswith(f"{name}/"): + return model + if model in claude_models.values(): + return f"databricks-claude/{model}" + if model in codex_models: + return f"databricks-openai/{model}" + if model in gemini_models: + return f"databricks-gemini/{model}" + return model + + +def render_overlay( + model: str, + api_key: str, + omp_base_urls: dict[str, str], + claude_models: dict[str, str], + codex_models: list[str], + gemini_models: list[str], +) -> tuple[dict, list[list[str]]]: + """Return (overlay, managed_key_paths) for omp's private agent config. + + ``api_key`` is a config value, not a literal bearer: it carries the + ``!ug auth-token ...`` command omp runs whenever it needs a token. + + The overlay carries only ``providers``: omp's ``models.yml`` schema + rejects unknown root keys, so the default-model selector is pinned in + ``config.yml`` by ``_write_default_model`` instead. + """ + providers: dict = {} + keys: list[list[str]] = [] + # omp expands header values that match an env var name. Our UA contains + # `/` and a space so it can never collide — safe to pass as a literal. + # One fresh dict per provider: sharing a single object would make YAML's + # dumper emit anchors and aliases into a file users read and edit. + ua = f"ucode/{ug_version()} omp/{agent_version('omp')}" + + claude_ids = sorted(set(claude_models.values())) + if claude_ids: + providers["databricks-claude"] = { + "baseUrl": omp_base_urls["claude"], + "api": "anthropic-messages", + "apiKey": api_key, + "authHeader": True, + # Gateway's Anthropic translator rejects per-tool + # `eager_input_streaming` on the streaming + tools path. omp sends + # the legacy beta header instead when this is false. + "compat": {"supportsEagerToolInputStreaming": False}, + "headers": {"User-Agent": ua}, + "models": [{"id": m} for m in claude_ids], + } + keys.append(["providers", "databricks-claude"]) + if codex_models: + providers["databricks-openai"] = { + "baseUrl": omp_base_urls["openai"], + "api": "openai-responses", + "apiKey": api_key, + "authHeader": True, + "headers": {"User-Agent": ua}, + "models": [{"id": m} for m in codex_models], + } + keys.append(["providers", "databricks-openai"]) + if gemini_models: + providers["databricks-gemini"] = { + "baseUrl": omp_base_urls["gemini"], + "api": "google-generative-ai", + "apiKey": api_key, + "authHeader": True, + "headers": {"User-Agent": ua}, + "models": [{"id": m} for m in gemini_models], + } + keys.append(["providers", "databricks-gemini"]) + overlay: dict = {} + if providers: + overlay["providers"] = providers + return overlay, keys + + +def build_omp_api_key(state: dict) -> str: + """Return the `!command` apiKey value omp resolves before a provider request. + + omp runs a leading-`!` config value through its embedded shell and uses its + stdout, and it re-runs the command after a 401 (the auth-retry path + invalidates command-backed keys), so the token is minted on demand and never + lands in the config. + + No `--force-refresh`: omp has no token cache of its own on this path, so + forcing a mint would round-trip to the workspace on every retry. Plain + `auth-token` serves the CLI's cached token until it nears expiry. + + Always POSIX-quoted: omp runs `!command` values through its embedded shell + on every OS (bash on Windows as well), which strips the backslashes from a + cmd.exe-style ``C:\\...\\ug.exe`` path.""" + argv = build_auth_token_argv( + state["workspace"], + state.get("profile"), + use_pat=bool(state.get("use_pat")), + ) + return "!" + shlex.join(argv) + + +def write_tool_config( + state: dict, + model: str, + token: str | None = None, +) -> tuple[dict, str]: + backup_existing_file(OMP_MODELS_PATH, OMP_MODELS_BACKUP_PATH) + if token is None: + token = get_databricks_token(state["workspace"], state.get("profile")) + omp_base_urls = state.get("base_urls", {}).get("omp") or build_pi_base_urls(state["workspace"]) + claude_models = state.get("claude_models") or {} + codex_models = state.get("codex_models") or [] + gemini_models = state.get("gemini_models") or [] + overlay, managed_keys = render_overlay( + model, + build_omp_api_key(state), + omp_base_urls, + claude_models, + codex_models, + gemini_models, + ) + existing = _read_yaml_for_merge(OMP_MODELS_PATH) + if existing is None: + print_warning(f"Could not read {OMP_MODELS_PATH}; leaving it unchanged.") + else: + providers = existing.get("providers") + if isinstance(providers, dict): + for stale in (*PROVIDER_NAMES, *LEGACY_PROVIDER_NAMES): + providers.pop(stale, None) + merged = deep_merge_dict(existing, overlay) + write_yaml_file(OMP_MODELS_PATH, merged) + _write_default_model(_resolve_model_selector(model, claude_models, codex_models, gemini_models)) + state = mark_tool_managed(state, "omp", managed_keys) + save_state(state) + return state, token + + +def _write_default_model(model_selector: str) -> None: + # Pin modelRoles.default in config.yml so omp starts on the Databricks + # model rather than falling through to the first available model (e.g. an + # env-key-backed provider) in its startup resolution order. + if "/" not in model_selector: + return + backup_existing_file(OMP_CONFIG_PATH, OMP_CONFIG_BACKUP_PATH) + existing = _read_yaml_for_merge(OMP_CONFIG_PATH) + if existing is None: + print_warning(f"Could not read {OMP_CONFIG_PATH}; leaving it unchanged.") + return + merged = deep_merge_dict(existing, {"modelRoles": {"default": model_selector}}) + write_yaml_file(OMP_CONFIG_PATH, merged) + + +def default_model(state: dict) -> str | None: + """Prefer Claude opus → sonnet → haiku; fall back to codex, gemini.""" + claude_models = state.get("claude_models") or {} + for family in ("opus", "sonnet", "haiku"): + if claude_models.get(family): + return claude_models[family] + codex_models = state.get("codex_models") or [] + if codex_models: + return codex_models[0] + gemini_models = state.get("gemini_models") or [] + return gemini_models[0] if gemini_models else next(iter(claude_models.values()), None) + + +def _configure_launch(state: dict) -> str: + model = default_model(state) + if not model: + raise RuntimeError("No Oh My Pi model is available on this workspace.") + # Writes models.yml/config.yml and mints one token, so broken auth fails here + # rather than inside the agent's first request. + _, token = write_tool_config(state, model) + return token + + +def build_runtime_env() -> dict[str, str]: + # omp reads no token from the environment (auth is the `!command` apiKey in + # models.yml); only redirect its agent dir into the ucode-private home. + env = os.environ.copy() + env["PI_CODING_AGENT_DIR"] = str(OMP_AGENT_DIR) + return env + + +def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None: + """Launch Oh My Pi; its `!command` apiKey re-mints the token on 401, so no refresher.""" + _configure_launch(state) + env = build_runtime_env() + + exec_or_spawn([SPEC["binary"], *tool_args], env=env) + + +def validate_cmd(binary: str) -> list[str]: + return [binary, "--print", "say hi in 5 words or less"] + + +def build_mcp_server_entry(argv: list[str]) -> dict: + # omp's stdioServer schema allows only command/args/env/cwd (stdio is the + # default transport when `command` is present without `url`), so the entry + # is exactly this — no `type`/`tools` keys like other clients use. + return { + "command": argv[0], + "args": list(argv[1:]), + } + + +def write_user_mcp_servers(add: dict[str, dict], remove: set[str]) -> set[str]: + """Apply ``add``/``remove`` to omp's agent-dir `mcpServers` in a single read-modify-write. Returns the names actually removed.""" + return apply_json_mcp_diff( + OMP_MCP_PATH, "mcpServers", add, remove, backup_path=OMP_MCP_BACKUP_PATH + ) + + +def write_mcp_server_config(name: str, argv: list[str]) -> bool: + # Returns whether an existing entry was replaced; `remove` is passed so the + # shared diff reports the prior presence in the same read-modify-write. + return name in write_user_mcp_servers({name: build_mcp_server_entry(argv)}, {name}) + + +def remove_mcp_server_config(name: str) -> bool: + return name in write_user_mcp_servers({}, {name}) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 4f81959a4..79bdafee2 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -44,6 +44,7 @@ ) from ucode.agents.args import has_explicit_model_arg from ucode.agents.codex import revert_legacy_shared_config +from ucode.agents.omp import OMP_CONFIG_BACKUP_PATH, OMP_CONFIG_PATH from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH from ucode.config_io import is_dry_run, restore_file, set_dry_run from ucode.constants import ( @@ -198,9 +199,9 @@ CustomOAuthConfig = custom_oauth.CustomOAuthConfig _DISCOVERY_CONSUMERS: dict[str, tuple[str, ...]] = { - "claude": ("claude", "opencode", "kilo", "copilot", "pi"), - "codex": ("codex", "copilot", "pi"), - "gemini": ("gemini", "opencode", "kilo", "pi"), + "claude": ("claude", "opencode", "kilo", "copilot", "pi", "omp"), + "codex": ("codex", "copilot", "pi", "omp"), + "gemini": ("gemini", "opencode", "kilo", "pi", "omp"), "oss": ("opencode", "kilo"), } @@ -638,13 +639,24 @@ def configure_shared_state( or "kilo" in tools or "copilot" in tools or "pi" in tools + or "omp" in tools ) want_gemini = ( - fetch_all or "gemini" in tools or "opencode" in tools or "kilo" in tools or "pi" in tools + fetch_all + or "gemini" in tools + or "opencode" in tools + or "kilo" in tools + or "pi" in tools + or "omp" in tools ) # Claude's web-search server also needs a Responses-capable model. want_codex = ( - fetch_all or "codex" in tools or "claude" in tools or "copilot" in tools or "pi" in tools + fetch_all + or "codex" in tools + or "claude" in tools + or "copilot" in tools + or "pi" in tools + or "omp" in tools ) # Codex smart routing can select OSS models such as GLM, so a Codex-only # configure must persist that discovered family too. @@ -1194,8 +1206,8 @@ def _status_models(tool: str, state: dict) -> list[str]: models = _model_values(state.get("copilot_models")) or ( _model_values(state.get("claude_models")) + _model_values(state.get("codex_models")) ) - elif tool == "pi": - models = _model_values(state.get("pi_models")) or ( + elif tool in ("pi", "omp"): + models = _model_values(state.get(f"{tool}_models")) or ( _model_values(state.get("claude_models")) + _model_values(state.get("codex_models")) + _model_values(state.get("gemini_models")) @@ -1211,7 +1223,11 @@ def _status_default_model(tool: str, state: dict, models: list[str]) -> str | No return explicit # Claude and Codex deliberately leave the starting model to the agent unless a managed # config pins one. The other clients write the first resolved model into their ug config. - return models[0] if models and tool in ("gemini", "opencode", "kilo", "copilot", "pi") else None + return ( + models[0] + if models and tool in ("gemini", "opencode", "kilo", "copilot", "pi", "omp") + else None + ) def _live_status_model_state(state: dict, tools: set[str]) -> tuple[dict, str]: @@ -1411,6 +1427,9 @@ def revert() -> int: pi_settings_restored = restore_file( PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, bool(managed_configs.get("pi")) ) + omp_config_restored = restore_file( + OMP_CONFIG_PATH, OMP_CONFIG_BACKUP_PATH, bool(managed_configs.get("omp")) + ) # Older Codex (< 0.134.0) had ucode edit the shared ~/.codex/config.toml in # place; restoring the per-profile file above does not undo that. legacy_codex_stripped = revert_legacy_shared_config() @@ -1426,6 +1445,7 @@ def revert() -> int: print_kv("Claude Desktop profile", desktop_result) print_kv("Codex OS-managed settings", codex_managed_result) print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") + print_kv("Oh My Pi config", "restored" if omp_config_restored else "unchanged") for client, spec in MCP_CLIENTS.items(): print_kv( f"{spec['display']} MCP config", @@ -1449,6 +1469,7 @@ def revert() -> int: "copilot", "cursor", "gemini", + "omp", "opencode", "kilo", "pi", @@ -3515,7 +3536,7 @@ def _launch_tool( f"{TOOL_SPECS[tool]['display']} may require one-time hook review. Open " "`/hooks` and trust the ug routing hooks if prompted." ) - if tool in ("gemini", "opencode", "kilo", "copilot", "pi"): + if tool in ("gemini", "opencode", "kilo", "copilot", "pi", "omp"): print_note( f"{TOOL_SPECS[tool]['display']} token refresh is managed automatically " f"every 30 minutes while the session is running." @@ -3996,6 +4017,19 @@ def pi_cmd( _launch_tool("pi", ctx, skip_preflight=skip_preflight) +@app.command( + "omp", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, + rich_help_panel="Launch", +) +def omp_cmd( + ctx: typer.Context, + skip_preflight: SkipPreflightOption = False, +) -> None: + """Launch Oh My Pi coding agent via Databricks.""" + _launch_tool("omp", ctx, skip_preflight=skip_preflight) + + @app.command( "cursor", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, @@ -4043,7 +4077,7 @@ def configure( str | None, typer.Option( "--agent", - help="Configure only the named agent (e.g. claude, codex, gemini, opencode, kilo, copilot, pi).", + help="Configure only the named agent (e.g. claude, codex, gemini, opencode, kilo, copilot, pi, omp).", ), ] = None, agents: Annotated[ diff --git a/src/ucode/config_io.py b/src/ucode/config_io.py index e431cf177..31a1f585c 100644 --- a/src/ucode/config_io.py +++ b/src/ucode/config_io.py @@ -10,6 +10,7 @@ import tomlkit import tomlkit.exceptions +import yaml from ucode.ui import console @@ -226,6 +227,31 @@ def write_toml_file(path: Path, doc: tomlkit.TOMLDocument) -> None: raise RuntimeError(f"Failed to write config file: {path}") from exc +def read_yaml_safe(path: Path) -> dict: + # See read_json_safe: keep `path.exists()` inside the try so a PermissionError on a locked + # parent directory is treated as an empty document rather than propagating. Any YAML error + # (or a non-mapping document, e.g. a list) reads as "nothing to merge into → {}". + try: + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError): + return {} + return data if isinstance(data, dict) else {} + + +def write_yaml_file(path: Path, payload: dict) -> None: + content = yaml.safe_dump(payload, sort_keys=False) + if _dry_run: + console.print(f"\n[bold]\\[dry run] {path}[/bold]\n{content}") + return + ensure_parent_dir(path) + try: + path.write_text(content, encoding="utf-8") + except OSError as exc: + raise RuntimeError(f"Failed to write config file: {path}") from exc + + def parse_dotenv(path: Path) -> dict[str, str]: """Parse a simple KEY=VALUE / KEY="VALUE" .env file, preserving insertion order. diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 6286a0276..e3cef37fd 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -3775,6 +3775,8 @@ def build_tool_base_url(tool: str, workspace: str) -> str: ) if tool == "pi": raise RuntimeError("Pi has multiple base URLs — use build_pi_base_urls() instead.") + if tool == "omp": + raise RuntimeError("Oh My Pi has multiple base URLs — use build_pi_base_urls() instead.") raise RuntimeError(f"Unsupported tool '{tool}'.") @@ -3844,5 +3846,6 @@ def build_shared_base_urls(workspace: str) -> dict[str, str | dict[str, str]]: "opencode": build_opencode_base_urls(workspace), "copilot": build_copilot_base_url(workspace), "pi": build_pi_base_urls(workspace), + "omp": build_pi_base_urls(workspace), } return urls diff --git a/src/ucode/launcher.py b/src/ucode/launcher.py index 382672a44..1abbe1b8d 100644 --- a/src/ucode/launcher.py +++ b/src/ucode/launcher.py @@ -5,11 +5,12 @@ import os import signal import sys +from collections.abc import Mapping from ucode.os_compatibility import subprocess_cross_os -def exec_or_spawn(argv: list[str]) -> None: +def exec_or_spawn(argv: list[str], *, env: Mapping[str, str] | None = None) -> None: """Hand the terminal to ``argv``, then exit with its status. On POSIX we ``os.execvp`` — the agent process *replaces* ucode, inheriting @@ -23,10 +24,17 @@ def exec_or_spawn(argv: list[str]) -> None: agents (gemini/opencode/copilot/pi) already use. """ if os.name != "nt": - os.execvp(argv[0], argv) + if env is None: + os.execvp(argv[0], argv) + else: + os.execvpe(argv[0], argv, env) return # unreachable on POSIX; keeps type-checkers happy - proc = subprocess_cross_os.popen(argv) + proc = ( + subprocess_cross_os.popen(argv, env=env) + if env is not None + else subprocess_cross_os.popen(argv) + ) try: returncode = proc.wait() except KeyboardInterrupt: diff --git a/src/ucode/managed_setup.py b/src/ucode/managed_setup.py index 8d130a057..391bf156b 100644 --- a/src/ucode/managed_setup.py +++ b/src/ucode/managed_setup.py @@ -59,6 +59,9 @@ "opencode": ("claude", "codex", "gemini", "oss"), "pi": ("claude", "codex", "gemini", "oss"), "copilot": ("claude", "codex", "gemini", "oss"), + # omp mirrors pi (the multi-provider harness). It has no managed-config variant: + # the server's CodingAgent proto carries no omp entry (see AGENT_ENUM_TO_TOOL). + "omp": ("claude", "codex", "gemini", "oss"), } diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 6d8155763..a6a347e4e 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -15,7 +15,7 @@ import questionary from rich.table import Table -from ucode.agents import claude, codex, copilot, cursor, gemini, kilo, opencode +from ucode.agents import claude, codex, copilot, cursor, gemini, kilo, omp, opencode from ucode.config_io import restore_file from ucode.constants import MCP_CLEANUP_SCOPES, MCP_USER_SCOPE from ucode.databricks import ( @@ -131,6 +131,12 @@ "display": "Cursor", "list_command": "cursor-agent mcp list", }, + "omp": { + # No list_command: omp publishes no `mcp list` subcommand, so the live + # status rows skip omp and fall back to configured-only output. + "binary": "omp", + "display": "Oh My Pi", + }, } SKILLS_MCP_KIND = "skills" SKILLS_MCP_SERVER_NAME = "databricks-skill-registry" @@ -411,6 +417,9 @@ def configure_client_mcp_server( if client == "cursor": removed = cursor.write_mcp_server_config(name, argv) return [MCP_USER_SCOPE] if removed else [] + if client == "omp": + removed = omp.write_mcp_server_config(name, argv) + return [MCP_USER_SCOPE] if removed else [] raise RuntimeError(f"Unsupported MCP client '{client}'.") @@ -431,6 +440,8 @@ def remove_client_mcp_server(client: str, name: str) -> list[str]: return [MCP_USER_SCOPE] if copilot.remove_mcp_server_config(name) else [] if client == "cursor": return [MCP_USER_SCOPE] if cursor.remove_mcp_server_config(name) else [] + if client == "omp": + return [MCP_USER_SCOPE] if omp.remove_mcp_server_config(name) else [] raise RuntimeError(f"Unsupported MCP client '{client}'.") @@ -461,6 +472,14 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: "copilot" in (server.get("clients") or []) for server in state.get("mcp_servers") or [] ), ) or results.get("copilot", False) + # Oh My Pi also keeps MCP servers in its own agent-dir mcp.json, restored the + # same way (the developer's own servers survive because the backup predates + # the first ucode write). + results["omp"] = restore_file( + omp.OMP_MCP_PATH, + omp.OMP_MCP_BACKUP_PATH, + any("omp" in (server.get("clients") or []) for server in state.get("mcp_servers") or []), + ) or results.get("omp", False) return results @@ -1378,6 +1397,7 @@ def _discover_selected_mcp_sources( "gemini": gemini, "copilot": copilot, "cursor": cursor, + "omp": omp, "opencode": opencode, "kilo": kilo, } @@ -1421,6 +1441,8 @@ def _managed_mcp_entry( return gemini.build_mcp_server_entry(argv) if client == "copilot": return copilot.build_mcp_server_entry(argv) + if client == "omp": + return omp.build_mcp_server_entry(argv) if client == "opencode": return opencode.build_mcp_server_entry(argv) if client == "kilo": @@ -1798,7 +1820,7 @@ def setup_mcp_clients( if not installed_clients: raise RuntimeError( "No supported MCP clients are installed. Install Claude, Codex, Gemini, OpenCode, " - "or GitHub Copilot CLI." + "GitHub Copilot CLI, or Oh My Pi." ) clients = configured_mcp_clients(state, installed_clients) if agents is not None: @@ -1812,7 +1834,7 @@ def setup_mcp_clients( if not clients: raise RuntimeError( "No configured MCP-capable coding agents are installed. Run `ug configure` " - "for Codex, Claude, Gemini, OpenCode, or GitHub Copilot CLI first." + "for Codex, Claude, Gemini, OpenCode, GitHub Copilot CLI, or Oh My Pi first." ) configured_tools = set(state.get("available_tools") or []) missing_clients = [ @@ -2420,8 +2442,13 @@ def _run_mcp_list(client: str) -> str | None: spec = MCP_CLIENTS.get(client) if not spec: return None - argv = str(spec["list_command"]).split() - # Gemini and Kilo read their config from a pinned dir, matching how ucode registers servers there. + list_command = spec.get("list_command") + if not list_command: + # Clients that keep MCP servers in a file ucode writes itself (omp) expose + # no listing subcommand; the caller shows configured servers only. + return None + argv = str(list_command).split() + # Gemini and Kilo read their config from pinned dirs, matching how ucode registers servers there. if client == "gemini": env = _gemini_cli_env() elif client == "kilo": diff --git a/src/ucode/state.py b/src/ucode/state.py index 925f62307..b0a71c815 100644 --- a/src/ucode/state.py +++ b/src/ucode/state.py @@ -199,6 +199,7 @@ def build_agent_state(state: dict) -> dict[str, dict]: ) codex_model = default_model_for_tool("codex", selection_state) pi_model = default_model_for_tool("pi", selection_state) + omp_model = default_model_for_tool("omp", selection_state) agents: dict[str, dict] = { "claude": { @@ -232,6 +233,12 @@ def build_agent_state(state: dict) -> dict[str, dict]: "auth_command": auth_command, "auth_refresh_interval_ms": AUTH_REFRESH_INTERVAL_MS, }, + "omp": { + "model": omp_model, + "base_urls": base_urls.get("omp") if isinstance(base_urls.get("omp"), dict) else {}, + "auth_command": auth_command, + "auth_refresh_interval_ms": AUTH_REFRESH_INTERVAL_MS, + }, } return { name: {key: value for key, value in config.items() if value is not None} diff --git a/tests/test_agent_omp.py b/tests/test_agent_omp.py new file mode 100644 index 000000000..85db0800f --- /dev/null +++ b/tests/test_agent_omp.py @@ -0,0 +1,818 @@ +"""Tests for agents/omp.py.""" + +from __future__ import annotations + +import re +from unittest.mock import patch + +import pytest +import yaml + +from ucode.agents import LaunchOptions, omp + +WS = "https://example.databricks.com" + + +def _base_urls() -> dict[str, str]: + # Native API per family — see agents/omp.py docstring for path conventions. + return { + "claude": f"{WS}/ai-gateway/anthropic", + "openai": f"{WS}/ai-gateway/codex/v1", + "gemini": f"{WS}/ai-gateway/gemini/v1beta", + } + + +def _empty() -> dict: + """No-models input bundle for render_overlay.""" + return { + "claude_models": {}, + "codex_models": [], + "gemini_models": [], + } + + +def _overlay(model: str, api_key: str = "tok", **kwargs): + """Wrapper to call render_overlay with sensible defaults so tests stay terse.""" + bundle = {**_empty(), **kwargs} + return omp.render_overlay( + model, + api_key, + _base_urls(), + bundle["claude_models"], + bundle["codex_models"], + bundle["gemini_models"], + ) + + +class TestOmpSpec: + def test_binary(self): + assert omp.SPEC["binary"] == "omp" + + def test_package(self): + assert omp.SPEC["package"] == "@oh-my-pi/pi-coding-agent" + + def test_display(self): + assert omp.SPEC["display"] == "Oh My Pi" + + def test_config_path_is_models_yml_under_omp_agent_dir(self): + assert omp.SPEC["config_path"].name == "models.yml" + assert omp.SPEC["config_path"].parent.name == "agent" + assert omp.OMP_UCODE_HOME in omp.SPEC["config_path"].parents + # Isolated from pi's home so the two agents never share config. + assert "omp-home" in omp.SPEC["config_path"].parts + assert "pi-home" not in omp.SPEC["config_path"].parts + + +class TestRenderOverlayProviders: + def test_no_providers_when_no_models(self): + overlay, _ = _overlay("foo") + assert "providers" not in overlay + + def test_claude_provider_uses_anthropic_messages(self): + overlay, _ = _overlay("claude-sonnet", claude_models={"sonnet": "claude-sonnet"}) + provider = overlay["providers"]["databricks-claude"] + assert provider["api"] == "anthropic-messages" + assert provider["baseUrl"] == f"{WS}/ai-gateway/anthropic" + + def test_openai_provider_uses_openai_responses(self): + overlay, _ = _overlay("gpt-5", codex_models=["gpt-5"]) + provider = overlay["providers"]["databricks-openai"] + assert provider["api"] == "openai-responses" + assert provider["baseUrl"] == f"{WS}/ai-gateway/codex/v1" + + def test_gemini_provider_uses_google_generative_ai(self): + overlay, _ = _overlay("gemini-2", gemini_models=["gemini-2"]) + provider = overlay["providers"]["databricks-gemini"] + assert provider["api"] == "google-generative-ai" + assert provider["baseUrl"] == f"{WS}/ai-gateway/gemini/v1beta" + + def test_all_three_providers_when_all_present(self): + overlay, _ = _overlay( + "claude-sonnet", + claude_models={"sonnet": "claude-sonnet"}, + codex_models=["gpt-5"], + gemini_models=["gemini-2"], + ) + assert set(overlay["providers"].keys()) == { + "databricks-claude", + "databricks-openai", + "databricks-gemini", + } + + def test_overlay_carries_no_model_key(self): + # omp's models.yml schema rejects unknown root keys; the default model + # is pinned in config.yml instead (see TestWriteDefaultModel). + overlay, _ = _overlay( + "claude-sonnet", + claude_models={"sonnet": "claude-sonnet"}, + ) + assert "model" not in overlay + + +class TestRenderOverlayUserAgent: + def test_user_agent_set_on_all_three_providers(self, monkeypatch): + monkeypatch.setattr(omp, "ug_version", lambda: "0.1.0") + monkeypatch.setattr(omp, "agent_version", lambda binary: "18.1.13") + overlay, _ = _overlay( + "claude-sonnet", + claude_models={"sonnet": "claude-sonnet"}, + codex_models=["gpt-5"], + gemini_models=["gemini-2"], + ) + expected = "ucode/0.1.0 omp/18.1.13" + for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + assert overlay["providers"][name]["headers"]["User-Agent"] == expected + + +class TestRenderOverlayCompatFlags: + def test_claude_disables_eager_tool_input_streaming(self): + # Gateway's Anthropic translator rejects per-tool + # `eager_input_streaming`; this flag makes omp send the legacy beta + # header instead. + overlay, _ = _overlay("claude-sonnet", claude_models={"sonnet": "claude-sonnet"}) + compat = overlay["providers"]["databricks-claude"]["compat"] + assert compat["supportsEagerToolInputStreaming"] is False + + def test_openai_and_gemini_have_no_compat_flags(self): + # Their gateway routes accept omp's request shape as-is. + overlay, _ = _overlay( + "gpt-5", + codex_models=["gpt-5"], + gemini_models=["gemini-2"], + ) + assert "compat" not in overlay["providers"]["databricks-openai"] + assert "compat" not in overlay["providers"]["databricks-gemini"] + + +class TestRenderOverlayAuthAndModels: + def test_api_key_is_the_auth_token_command(self): + # omp runs a leading-`!` value as a command and uses its stdout, so the + # token is minted on demand instead of being baked into models.yml. + overlay, _ = _overlay( + "claude-sonnet", + api_key="!ug auth-token --host https://example.databricks.com", + claude_models={"sonnet": "claude-sonnet"}, + ) + api_key = overlay["providers"]["databricks-claude"]["apiKey"] + assert api_key.startswith("!") + assert "auth-token" in api_key + assert "https://example.databricks.com" in api_key + + def test_auth_header_flag_set_on_all_providers(self): + overlay, _ = _overlay( + "claude-sonnet", + claude_models={"sonnet": "claude-sonnet"}, + codex_models=["gpt-5"], + gemini_models=["gemini-2"], + ) + for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + assert overlay["providers"][name]["authHeader"] is True + + def test_claude_models_listed(self): + claude_models = {"opus": "claude-opus", "sonnet": "claude-sonnet"} + overlay, _ = _overlay("claude-sonnet", claude_models=claude_models) + ids = {m["id"] for m in overlay["providers"]["databricks-claude"]["models"]} + assert ids == {"claude-opus", "claude-sonnet"} + + def test_openai_models_listed(self): + overlay, _ = _overlay("gpt-5", codex_models=["gpt-5", "gpt-5-mini"]) + ids = {m["id"] for m in overlay["providers"]["databricks-openai"]["models"]} + assert ids == {"gpt-5", "gpt-5-mini"} + + def test_gemini_models_listed(self): + overlay, _ = _overlay("gemini-2", gemini_models=["gemini-2", "gemini-2-pro"]) + ids = {m["id"] for m in overlay["providers"]["databricks-gemini"]["models"]} + assert ids == {"gemini-2", "gemini-2-pro"} + + +class TestRenderOverlayManagedKeys: + def test_managed_keys_exclude_model(self): + _, keys = _overlay("foo") + assert ["model"] not in keys + + def test_managed_keys_include_each_provider_present(self): + _, keys = _overlay( + "claude-sonnet", + claude_models={"sonnet": "claude-sonnet"}, + codex_models=["gpt-5"], + gemini_models=["gemini-2"], + ) + for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + assert ["providers", name] in keys + + +class TestResolveModelSelector: + def test_prefixes_claude_model(self): + selector = omp._resolve_model_selector("claude-sonnet", {"sonnet": "claude-sonnet"}, [], []) + assert selector == "databricks-claude/claude-sonnet" + + def test_prefixes_openai_model(self): + selector = omp._resolve_model_selector("gpt-5", {}, ["gpt-5"], []) + assert selector == "databricks-openai/gpt-5" + + def test_prefixes_gemini_model(self): + selector = omp._resolve_model_selector("gemini-2", {}, [], ["gemini-2"]) + assert selector == "databricks-gemini/gemini-2" + + def test_preserves_already_prefixed_model(self): + selector = omp._resolve_model_selector( + "databricks-claude/claude-sonnet", {"sonnet": "claude-sonnet"}, [], [] + ) + assert selector == "databricks-claude/claude-sonnet" + + def test_unknown_model_passes_through_unprefixed(self): + # Lets a user override to whatever omp accepts even if we + # didn't classify it. + assert omp._resolve_model_selector("custom/whatever", {}, [], []) == "custom/whatever" + + +class TestOmpDefaultModel: + def test_prefers_claude_opus(self): + state = {"claude_models": {"opus": "o4", "sonnet": "s4", "haiku": "h4"}} + assert omp.default_model(state) == "o4" + + def test_falls_back_to_sonnet(self): + state = {"claude_models": {"sonnet": "s4"}} + assert omp.default_model(state) == "s4" + + def test_falls_back_to_haiku(self): + state = {"claude_models": {"haiku": "h4"}} + assert omp.default_model(state) == "h4" + + def test_falls_back_to_codex(self): + state = {"claude_models": {}, "codex_models": ["gpt-5"]} + assert omp.default_model(state) == "gpt-5" + + def test_falls_back_to_gemini(self): + state = {"claude_models": {}, "codex_models": [], "gemini_models": ["gemini-2"]} + assert omp.default_model(state) == "gemini-2" + + def test_returns_none_when_empty(self): + assert omp.default_model({}) is None + assert ( + omp.default_model({"claude_models": {}, "codex_models": [], "gemini_models": []}) + is None + ) + + +class TestBuildRuntimeEnv: + def test_sets_private_agent_dir_without_replacing_home(self, monkeypatch): + monkeypatch.setenv("HOME", "/real-user-home") + + env = omp.build_runtime_env() + + assert env["PI_CODING_AGENT_DIR"] == str(omp.OMP_AGENT_DIR) + assert env["HOME"] == "/real-user-home" + + def test_sets_no_token_env(self, monkeypatch): + # omp never reads a token from the environment (no OAUTH_TOKEN-style + # hook anywhere in its packages); auth is the baked models.yml apiKey. + # ucode must not add one of its own — whatever the parent shell has + # is passed through untouched and ignored by omp. + monkeypatch.delenv("OAUTH_TOKEN", raising=False) + env = omp.build_runtime_env() + assert "OAUTH_TOKEN" not in env + + +class TestOmpValidateCmd: + def test_starts_with_binary(self): + cmd = omp.validate_cmd("omp") + assert cmd[0] == "omp" + + def test_uses_print_flag(self): + # `--print` puts omp in non-interactive mode; without it the TUI hangs on stdin. + cmd = omp.validate_cmd("omp") + assert "--print" in cmd + + def test_has_prompt(self): + cmd = omp.validate_cmd("omp") + assert len(cmd) > 2 + + +class TestBuildOmpApiKey: + def test_prefixes_command_marker(self): + state = {"workspace": WS} + api_key = omp.build_omp_api_key(state) + assert api_key.startswith("!") + assert "auth-token" in api_key + + def test_quotes_each_argv_token(self): + # omp runs `!command` values through its embedded shell (bash on every + # OS), so a workspace URL with shell metacharacters must arrive quoted. + state = {"workspace": "https://host/with space", "profile": "corp profile"} + api_key = omp.build_omp_api_key(state) + assert "'https://host/with space'" in api_key + assert "'corp profile'" in api_key + + def test_use_pat_is_honored(self): + assert "--use-pat" not in omp.build_omp_api_key({"workspace": WS}) + assert "--use-pat" in omp.build_omp_api_key({"workspace": WS, "use_pat": True}) + + +class TestWriteToolConfig: + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.omp as omp_mod + import ucode.config_io as config_io_mod + + monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) + models_file = tmp_path / "models.yml" + models_backup = tmp_path / "models.backup.yml" + config_file = tmp_path / "config.yml" + config_backup = tmp_path / "config.backup.yml" + mcp_file = tmp_path / "mcp.json" + mcp_backup = tmp_path / "mcp.backup.json" + monkeypatch.setattr(omp_mod, "OMP_MODELS_PATH", models_file) + monkeypatch.setattr(omp_mod, "OMP_MODELS_BACKUP_PATH", models_backup) + monkeypatch.setattr(omp_mod, "OMP_CONFIG_PATH", config_file) + monkeypatch.setattr(omp_mod, "OMP_CONFIG_BACKUP_PATH", config_backup) + monkeypatch.setattr(omp_mod, "OMP_MCP_PATH", mcp_file) + monkeypatch.setattr(omp_mod, "OMP_MCP_BACKUP_PATH", mcp_backup) + return omp_mod, models_file, config_file, config_backup + + def _state(self, **overrides) -> dict: + state = { + "workspace": WS, + "base_urls": {"pi": _base_urls()}, + "claude_models": {"sonnet": "claude-sonnet"}, + "codex_models": [], + "gemini_models": [], + "managed_configs": {}, + } + state.update(overrides) + return state + + def _read_yaml(self, path): + return yaml.safe_load(path.read_text(encoding="utf-8")) + + def test_stale_managed_providers_removed_before_merge(self, tmp_path, monkeypatch): + omp_mod, models_file, _, _ = self._setup(tmp_path, monkeypatch) + + stale = { + "providers": { + "databricks-claude": {"old": True}, + "databricks-openai": {"old": True}, + "databricks-gemini": {"old": True}, + "user-provider": {"keep": True}, + } + } + models_file.write_text(yaml.safe_dump(stale), encoding="utf-8") + + with ( + patch("ucode.agents.omp.get_databricks_token", return_value="tok"), + patch("ucode.agents.omp.save_state"), + ): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + written = self._read_yaml(models_file) + providers = written.get("providers", {}) + assert providers.get("databricks-claude") != {"old": True} + assert "old" not in providers.get("databricks-claude", {}) + assert providers.get("user-provider") == {"keep": True} + + def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch): + """Earlier ucode versions wrote `databricks-anthropic`, `databricks-codex`, + and `databricks-oss` providers. They must be stripped on the next write + so users don't end up with stale entries pointing at routes that 400.""" + omp_mod, models_file, _, _ = self._setup(tmp_path, monkeypatch) + + models_file.write_text( + yaml.safe_dump( + { + "providers": { + "databricks-anthropic": {"api": "anthropic-messages"}, + "databricks-codex": {"api": "openai-responses"}, + "databricks-oss": {"api": "openai-completions"}, + } + } + ), + encoding="utf-8", + ) + + with ( + patch("ucode.agents.omp.get_databricks_token", return_value="tok"), + patch("ucode.agents.omp.save_state"), + ): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + written_providers = self._read_yaml(models_file).get("providers", {}) + for legacy in ("databricks-anthropic", "databricks-codex", "databricks-oss"): + assert legacy not in written_providers + assert "databricks-claude" in written_providers + + def test_config_written_with_command_api_key_and_no_model_key(self, tmp_path, monkeypatch): + omp_mod, models_file, _, _ = self._setup(tmp_path, monkeypatch) + + with ( + patch("ucode.agents.omp.get_databricks_token", return_value="tok"), + patch("ucode.agents.omp.save_state"), + ): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="SECRET") + + written = self._read_yaml(models_file) + api_key = written["providers"]["databricks-claude"]["apiKey"] + assert api_key.startswith("!") + assert "auth-token" in api_key + # The literal token from the one-shot mint must never reach the file. + assert "SECRET" not in models_file.read_text(encoding="utf-8") + # omp's models.yml schema rejects unknown root keys. + assert "model" not in written + + def test_written_config_has_no_yaml_anchors(self, tmp_path, monkeypatch): + # Sharing one headers dict across providers would make the dumper emit + # `&id001`/`*id001` into a file users are meant to read and edit. + omp_mod, models_file, _, _ = self._setup(tmp_path, monkeypatch) + + with patch("ucode.agents.omp.save_state"): + omp_mod.write_tool_config( + self._state(codex_models=["gpt-5"], gemini_models=["gemini-2"]), + "claude-sonnet", + token="tok", + ) + + text = models_file.read_text(encoding="utf-8") + assert "&id" not in text + assert "\n" in text and not any(line.strip().startswith("*") for line in text.splitlines()) + written = self._read_yaml(models_file) + for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + assert written["providers"][name]["headers"]["User-Agent"].startswith("ucode/") + + def test_config_pins_default_role_in_config_yml(self, tmp_path, monkeypatch): + # Without this, omp's startup resolution falls through to the first + # available model when an unrelated env var makes a built-in provider + # look auth-configured. Pinning the default role keeps omp on ours. + omp_mod, _, config_file, _ = self._setup(tmp_path, monkeypatch) + + with ( + patch("ucode.agents.omp.get_databricks_token", return_value="tok"), + patch("ucode.agents.omp.save_state"), + ): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + config = self._read_yaml(config_file) + assert config["modelRoles"]["default"] == "databricks-claude/claude-sonnet" + + def test_pre_existing_config_is_backed_up_before_first_write(self, tmp_path, monkeypatch): + omp_mod, _, config_file, config_backup = self._setup(tmp_path, monkeypatch) + + original = "modelRoles:\n default: some-other-provider/model\ntheme: titanium\n" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(original, encoding="utf-8") + + with ( + patch("ucode.agents.omp.get_databricks_token", return_value="tok"), + patch("ucode.agents.omp.save_state"), + ): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + assert config_backup.read_text(encoding="utf-8") == original + # The on-disk config still gets the ucode pin applied via deep_merge. + merged = self._read_yaml(config_file) + assert merged["modelRoles"]["default"] == "databricks-claude/claude-sonnet" + assert merged["theme"] == "titanium" + + def test_unreadable_existing_config_is_preserved(self, tmp_path, monkeypatch): + omp_mod, _, config_file, _ = self._setup(tmp_path, monkeypatch) + original = "modelRoles: [\ntheme: titanium\n" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(original, encoding="utf-8") + + with ( + patch("ucode.agents.omp.get_databricks_token", return_value="tok"), + patch("ucode.agents.omp.save_state"), + ): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + assert config_file.read_text(encoding="utf-8") == original + + +class TestOmpMcpServerConfig: + def test_entry_is_exactly_command_and_args(self): + assert omp.build_mcp_server_entry(["ucode", "mcp-proxy", "--url", "https://x"]) == { + "command": "ucode", + "args": ["mcp-proxy", "--url", "https://x"], + } + + def test_ucode_server_names_match_omp_schema(self, tmp_path, monkeypatch): + # omp's mcp.json restricts names to ^[a-zA-Z0-9_.-]{1,100}$. + import ucode.agents.omp as omp_mod + + pattern = re.compile(r"^[a-zA-Z0-9_.-]{1,100}$") + mcp_file = tmp_path / "mcp.json" + monkeypatch.setattr(omp_mod, "OMP_MCP_PATH", mcp_file) + monkeypatch.setattr(omp_mod, "OMP_MCP_BACKUP_PATH", tmp_path / "mcp.backup.json") + + for name in ("databricks-sql", "databricks-skill-registry", "github-mcp"): + assert pattern.match(name), name + assert omp_mod.write_mcp_server_config(name, ["ucode", "mcp-proxy"]) is False + assert omp_mod.remove_mcp_server_config(name) is True + + def test_write_and_remove_round_trip(self, tmp_path, monkeypatch): + import json + + import ucode.agents.omp as omp_mod + + mcp_file = tmp_path / "mcp.json" + monkeypatch.setattr(omp_mod, "OMP_MCP_PATH", mcp_file) + monkeypatch.setattr(omp_mod, "OMP_MCP_BACKUP_PATH", tmp_path / "mcp.backup.json") + + assert omp_mod.write_mcp_server_config("github-mcp", ["ucode", "mcp-proxy"]) is False + written = json.loads(mcp_file.read_text(encoding="utf-8")) + assert written["mcpServers"]["github-mcp"] == { + "command": "ucode", + "args": ["mcp-proxy"], + } + assert omp_mod.write_mcp_server_config("github-mcp", ["ucode", "mcp-proxy"]) is True + assert omp_mod.remove_mcp_server_config("github-mcp") is True + assert omp_mod.remove_mcp_server_config("github-mcp") is False + + +class TestWriteToolConfigBaseUrls: + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.omp as omp_mod + import ucode.config_io as config_io_mod + + monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) + models_file = tmp_path / "models.yml" + monkeypatch.setattr(omp_mod, "OMP_MODELS_PATH", models_file) + monkeypatch.setattr(omp_mod, "OMP_MODELS_BACKUP_PATH", tmp_path / "m.bak") + monkeypatch.setattr(omp_mod, "OMP_CONFIG_PATH", tmp_path / "config.yml") + monkeypatch.setattr(omp_mod, "OMP_CONFIG_BACKUP_PATH", tmp_path / "c.bak") + return omp_mod, models_file + + def _state(self, **overrides) -> dict: + state = { + "workspace": WS, + "claude_models": {"sonnet": "claude-sonnet"}, + "codex_models": [], + "gemini_models": [], + "managed_configs": {}, + } + state.update(overrides) + return state + + def test_prefers_omp_base_urls_from_state(self, tmp_path, monkeypatch): + import yaml as yaml_lib + + omp_mod, models_file = self._setup(tmp_path, monkeypatch) + state_urls = { + "claude": "https://state.example/anthropic", + "openai": "https://state.example/codex", + "gemini": "https://state.example/gemini", + } + + def _fail(_workspace): + raise AssertionError("should use state base_urls, not rebuild them") + + monkeypatch.setattr(omp_mod, "build_pi_base_urls", _fail) + with patch("ucode.agents.omp.save_state"): + omp_mod.write_tool_config( + self._state(base_urls={"omp": state_urls}), "claude-sonnet", token="tok" + ) + + written = yaml_lib.safe_load(models_file.read_text(encoding="utf-8")) + assert written["providers"]["databricks-claude"]["baseUrl"] == state_urls["claude"] + + def test_falls_back_to_building_urls_without_state_key(self, tmp_path, monkeypatch): + import yaml as yaml_lib + + omp_mod, models_file = self._setup(tmp_path, monkeypatch) + monkeypatch.setattr(omp_mod, "build_pi_base_urls", lambda workspace: _base_urls()) + with patch("ucode.agents.omp.save_state"): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + written = yaml_lib.safe_load(models_file.read_text(encoding="utf-8")) + assert written["providers"]["databricks-claude"]["baseUrl"] == f"{WS}/ai-gateway/anthropic" + + +class TestWriteToolConfigEdgeCases: + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.omp as omp_mod + import ucode.config_io as config_io_mod + + monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) + models_file = tmp_path / "models.yml" + config_file = tmp_path / "config.yml" + monkeypatch.setattr(omp_mod, "OMP_MODELS_PATH", models_file) + monkeypatch.setattr(omp_mod, "OMP_MODELS_BACKUP_PATH", tmp_path / "m.bak") + monkeypatch.setattr(omp_mod, "OMP_CONFIG_PATH", config_file) + monkeypatch.setattr(omp_mod, "OMP_CONFIG_BACKUP_PATH", tmp_path / "c.bak") + return omp_mod, models_file, config_file + + def _state(self, **overrides) -> dict: + state = { + "workspace": WS, + "base_urls": {"omp": _base_urls()}, + "claude_models": {"sonnet": "claude-sonnet"}, + "codex_models": [], + "gemini_models": [], + "managed_configs": {}, + } + state.update(overrides) + return state + + def test_non_dict_providers_section_is_replaced(self, tmp_path, monkeypatch): + import yaml as yaml_lib + + omp_mod, models_file, _ = self._setup(tmp_path, monkeypatch) + models_file.write_text("providers:\n- not-a-mapping\n", encoding="utf-8") + + with patch("ucode.agents.omp.save_state"): + omp_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") + + written = yaml_lib.safe_load(models_file.read_text(encoding="utf-8")) + assert isinstance(written["providers"], dict) + assert "databricks-claude" in written["providers"] + + def test_passthrough_model_pins_verbatim_selector(self, tmp_path, monkeypatch): + import yaml as yaml_lib + + omp_mod, _, config_file = self._setup(tmp_path, monkeypatch) + + with patch("ucode.agents.omp.save_state"): + omp_mod.write_tool_config(self._state(), "custom/whatever", token="tok") + + # Unclassified selectors pass through like pi's `model` override and + # pin verbatim, so a user override still wins the startup default. + config = yaml_lib.safe_load(config_file.read_text(encoding="utf-8")) + assert config["modelRoles"]["default"] == "custom/whatever" + + def test_bare_model_name_writes_no_default_role(self, tmp_path, monkeypatch): + omp_mod, _, config_file = self._setup(tmp_path, monkeypatch) + + with patch("ucode.agents.omp.save_state"): + omp_mod.write_tool_config(self._state(), "somename", token="tok") + + assert not config_file.exists() + + def test_ignores_pi_managed_keys(self): + # omp has no managed-config support: pi's admin keys must not leak + # into omp's model choice even when both tools share a workspace. + state = { + "pi_default_model": "admin-chosen", + "pi_models": ["admin-allowlisted"], + "claude_models": {"sonnet": "discovered"}, + } + assert omp.default_model(state) == "discovered" + + +class TestLaunch: + def _state(self, **overrides) -> dict: + state = { + "workspace": WS, + "base_urls": {"omp": _base_urls()}, + "claude_models": {"sonnet": "claude-sonnet"}, + "codex_models": [], + "gemini_models": [], + "managed_configs": {}, + } + state.update(overrides) + return state + + def test_launch_raises_before_spawning_without_models(self, monkeypatch): + import ucode.agents.omp as omp_mod + + def _no_spawn(*args, **kwargs): + raise AssertionError("must not spawn a process without a model") + + monkeypatch.setattr(omp_mod, "exec_or_spawn", _no_spawn) + try: + omp_mod.launch({"workspace": WS}, [], options=LaunchOptions()) + except RuntimeError as exc: + assert "Oh My Pi" in str(exc) + else: # pragma: no cover - launch must raise + raise AssertionError("launch did not raise without models") + + def test_launch_spawns_with_private_agent_dir_and_no_refresher(self, tmp_path, monkeypatch): + import ucode.agents.omp as omp_mod + + monkeypatch.setattr(omp_mod, "OMP_MODELS_PATH", tmp_path / "models.yml") + monkeypatch.setattr(omp_mod, "OMP_MODELS_BACKUP_PATH", tmp_path / "models.backup.yml") + monkeypatch.setattr(omp_mod, "OMP_CONFIG_PATH", tmp_path / "config.yml") + monkeypatch.setattr(omp_mod, "OMP_CONFIG_BACKUP_PATH", tmp_path / "config.backup.yml") + monkeypatch.setattr(omp_mod, "save_state", lambda state: state) + monkeypatch.setattr( + omp_mod, "get_databricks_token", lambda workspace, profile: "MINTED-SECRET" + ) + + spawned: list[tuple[list[str], dict]] = [] + + def _exec_or_spawn(argv, *, env=None): + spawned.append((argv, env or {})) + + monkeypatch.setattr(omp_mod, "exec_or_spawn", _exec_or_spawn) + omp_mod.launch(self._state(), ["--print", "hi"], options=LaunchOptions()) + + argv, env = spawned[0] + assert argv == ["omp", "--print", "hi"] + assert env["PI_CODING_AGENT_DIR"] == str(omp_mod.OMP_AGENT_DIR) + # The token is minted per request by the `!command` apiKey, never exported. + assert all("MINTED-SECRET" not in value for value in env.values()) + # Launch writes the config exactly once (no refresh thread rewriting it). + assert (tmp_path / "models.yml").exists() + + +class TestOmpMcpEdgeCases: + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.omp as omp_mod + + mcp_file = tmp_path / "mcp.json" + monkeypatch.setattr(omp_mod, "OMP_MCP_PATH", mcp_file) + monkeypatch.setattr(omp_mod, "OMP_MCP_BACKUP_PATH", tmp_path / "mcp.backup.json") + return omp_mod, mcp_file + + def test_write_resets_non_dict_server_map(self, tmp_path, monkeypatch): + import json + + omp_mod, mcp_file = self._setup(tmp_path, monkeypatch) + mcp_file.write_text('{"mcpServers": ["not-a-mapping"]}', encoding="utf-8") + + assert omp_mod.write_mcp_server_config("github-mcp", ["ucode"]) is False + + written = json.loads(mcp_file.read_text(encoding="utf-8")) + assert written["mcpServers"] == {"github-mcp": {"command": "ucode", "args": []}} + + def test_remove_missing_file_returns_false(self, tmp_path, monkeypatch): + omp_mod, _ = self._setup(tmp_path, monkeypatch) + assert omp_mod.remove_mcp_server_config("github-mcp") is False + + def test_remove_non_dict_server_map_returns_false(self, tmp_path, monkeypatch): + omp_mod, mcp_file = self._setup(tmp_path, monkeypatch) + mcp_file.write_text('{"mcpServers": ["not-a-mapping"]}', encoding="utf-8") + assert omp_mod.remove_mcp_server_config("github-mcp") is False + + +class TestConfigureToolDispatch: + def test_omp_writes_its_own_config_not_the_opencode_fallback(self, monkeypatch): + import ucode.agents as agents_mod + + called: list[tuple[dict, str | None]] = [] + state = {"workspace": WS, "managed_configs": {}} + monkeypatch.setattr( + agents_mod.omp, + "write_tool_config", + lambda s, model, **kw: (called.append((s, model)), (s, "tok"))[1], + ) + monkeypatch.setattr( + agents_mod.opencode, + "write_tool_config", + lambda *a, **kw: pytest.fail("omp must not fall through to the OpenCode writer"), + ) + + result = agents_mod.configure_tool("omp", state, "claude-sonnet") + + assert result is state + assert called == [(state, "claude-sonnet")] + + +class TestWriteUserMcpServers: + """The batched writer `mcp.py` drives for the workspace-managed path.""" + + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.omp as omp_mod + + mcp_file = tmp_path / "mcp.json" + monkeypatch.setattr(omp_mod, "OMP_MCP_PATH", mcp_file) + monkeypatch.setattr(omp_mod, "OMP_MCP_BACKUP_PATH", tmp_path / "mcp.backup.json") + return omp_mod, mcp_file + + def test_adds_and_reports_only_removed_names(self, tmp_path, monkeypatch): + import json + + omp_mod, mcp_file = self._setup(tmp_path, monkeypatch) + mcp_file.write_text( + json.dumps({"mcpServers": {"keep-me": {"command": "x", "args": []}}}), encoding="utf-8" + ) + + removed = omp_mod.write_user_mcp_servers( + {"a": {"command": "ug", "args": ["mcp-proxy"]}}, + {"keep-me", "never-there"}, + ) + + assert removed == {"keep-me"} + written = json.loads(mcp_file.read_text(encoding="utf-8")) + assert written["mcpServers"] == {"a": {"command": "ug", "args": ["mcp-proxy"]}} + + def test_preserves_unrelated_keys_and_developer_entries(self, tmp_path, monkeypatch): + import json + + omp_mod, mcp_file = self._setup(tmp_path, monkeypatch) + mcp_file.write_text( + json.dumps( + { + "$schema": "https://omp.sh/schema/mcp.json", + "mcpServers": {"my-own": {"command": "x", "args": ["y"]}}, + } + ), + encoding="utf-8", + ) + + omp_mod.write_user_mcp_servers({"added": {"command": "ug", "args": []}}, set()) + + written = json.loads(mcp_file.read_text(encoding="utf-8")) + assert written["$schema"] == "https://omp.sh/schema/mcp.json" + assert written["mcpServers"]["my-own"] == {"command": "x", "args": ["y"]} + assert written["mcpServers"]["added"] == {"command": "ug", "args": []} + + def test_missing_file_yields_no_removals(self, tmp_path, monkeypatch): + omp_mod, _ = self._setup(tmp_path, monkeypatch) + assert omp_mod.write_user_mcp_servers({}, {"a", "b"}) == set() diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 47b4acacb..87a25105e 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -56,7 +56,16 @@ def test_has_explicit_model_arg_stops_at_harness_separator(self): class TestToolSpecs: def test_all_tools_present(self): - assert set(TOOL_SPECS) == {"codex", "claude", "gemini", "opencode", "kilo", "copilot", "pi"} + assert set(TOOL_SPECS) == { + "codex", + "claude", + "gemini", + "opencode", + "kilo", + "copilot", + "pi", + "omp", + } def test_each_spec_has_required_keys(self): required = {"binary", "package", "display", "config_path", "backup_path"} @@ -99,9 +108,9 @@ def _capture(self, monkeypatch, *, managed=None): def test_maps_supported_tools_and_drops_others(self, monkeypatch): captured = self._capture(monkeypatch) - # Gemini and Pi aren't supported by `databricks aitools`, so they drop. + # Gemini, Pi, and Oh My Pi aren't supported by `databricks aitools`, so they drop. install_databricks_ai_tools_for_agents( - ["claude", "codex", "gemini", "pi"], + ["claude", "codex", "gemini", "pi", "omp"], {"profile": "prof", "databricks_ai_tools_enabled": True}, ) assert captured == {"agents": ["claude-code", "codex"], "profile": "prof"} @@ -256,6 +265,8 @@ class TestNormalizeTool: ("opencode", "opencode"), ("copilot", "copilot"), ("pi", "pi"), + ("omp", "omp"), + ("oh-my-pi", "omp"), ("CODEX", "codex"), (" Claude ", "claude"), ], @@ -311,6 +322,18 @@ def test_pi_available_with_gemini(self): def test_pi_unavailable_when_no_models(self): assert check_gateway_endpoint({}, "pi") is False + def test_omp_available_with_claude(self): + assert check_gateway_endpoint({"claude_models": {"sonnet": "s4"}}, "omp") is True + + def test_omp_available_with_codex(self): + assert check_gateway_endpoint({"codex_models": ["m"]}, "omp") is True + + def test_omp_available_with_gemini(self): + assert check_gateway_endpoint({"gemini_models": ["gemini-2"]}, "omp") is True + + def test_omp_unavailable_when_no_models(self): + assert check_gateway_endpoint({}, "omp") is False + class TestDefaultModelForTool: def test_codex_returns_none_without_a_configured_model(self): @@ -365,6 +388,31 @@ def test_pi_falls_back_to_gemini(self): def test_pi_returns_none_when_no_models(self): assert default_model_for_tool("pi", {}) is None + def test_omp_prefers_claude_opus(self): + state = {"claude_models": {"opus": "o4", "sonnet": "s4"}, "codex_models": ["c"]} + assert default_model_for_tool("omp", state) == "o4" + + def test_omp_falls_back_to_codex(self): + state = {"claude_models": {}, "codex_models": ["c1"]} + assert default_model_for_tool("omp", state) == "c1" + + def test_omp_falls_back_to_gemini(self): + state = {"claude_models": {}, "codex_models": [], "gemini_models": ["gemini-2"]} + assert default_model_for_tool("omp", state) == "gemini-2" + + def test_omp_returns_none_when_no_models(self): + assert default_model_for_tool("omp", {}) is None + + def test_omp_ignores_pi_managed_keys(self): + # Oh My Pi has no managed-config variant, so pi's admin keys must not + # steer omp's model choice on a shared workspace. + state = { + "pi_default_model": "admin-chosen", + "pi_models": ["admin-allowlisted"], + "claude_models": {"sonnet": "discovered"}, + } + assert default_model_for_tool("omp", state) == "discovered" + class TestResolveLaunchModel: def test_codex_default_model_used_when_no_explicit(self): @@ -1076,7 +1124,7 @@ def test_ensure_tool_binary_available_raises_when_missing(self, monkeypatch): ensure_tool_binary_available("opencode") -@pytest.mark.parametrize("tool", ["claude", "opencode", "copilot", "pi"]) +@pytest.mark.parametrize("tool", ["claude", "opencode", "copilot", "pi", "omp"]) def test_fable_only_workspace_has_a_default(tool): state = { "claude_models": {"fable": "system.ai.claude-fable-5"}, @@ -1086,7 +1134,7 @@ def test_fable_only_workspace_has_a_default(tool): assert default_model_for_tool(tool, state) == "system.ai.claude-fable-5" -@pytest.mark.parametrize("tool", ["copilot", "pi"]) +@pytest.mark.parametrize("tool", ["copilot", "pi", "omp"]) def test_fable_does_not_displace_existing_default(tool): state = { "claude_models": {"fable": "system.ai.claude-fable-5"}, diff --git a/tests/test_cli.py b/tests/test_cli.py index 81acc425b..4e39e1390 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -135,7 +135,17 @@ def test_help_groups_commands_by_workflow(self): "Manage": output[panels["Manage"] : panels["Usage"]], "Usage": output[panels["Usage"] : global_options], } - for command in ("claude", "codex", "copilot", "cursor", "gemini", "opencode", "kilo", "pi"): + for command in ( + "claude", + "codex", + "copilot", + "cursor", + "gemini", + "omp", + "opencode", + "kilo", + "pi", + ): assert command in sections["Launch"] assert "configure" in sections["Setup"] for command in ("mcp", "skills"): @@ -3718,6 +3728,7 @@ def test_skip_preflight_bypasses_cli_version_check(self): ("opencode", "Launching OpenCode with Unity Gateway"), ("copilot", "Launching GitHub Copilot CLI with Unity Gateway"), ("pi", "Launching Pi with Unity Gateway"), + ("omp", "Launching Oh My Pi with Unity Gateway"), ], ) def test_launch_title(tool, expected): @@ -5742,7 +5753,7 @@ class TestSkipPreflightFlag: """`--skip-preflight` on a launch command threads through _launch_tool to configure_shared_state as skip_preflight.""" - LAUNCH_TOOLS = ["codex", "claude", "gemini", "opencode", "copilot", "pi"] + LAUNCH_TOOLS = ["codex", "claude", "gemini", "opencode", "copilot", "pi", "omp"] @staticmethod def _patches(cfg): diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 1cb325ee4..3e50a3de6 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -6,6 +6,7 @@ import pytest import tomlkit +import yaml import ucode.config_io as config_io from ucode.config_io import ( @@ -17,12 +18,14 @@ prune_key_paths, read_json_safe, read_toml_safe, + read_yaml_safe, restore_file, set_dry_run, write_dotenv, write_json_file, write_text_file, write_toml_file, + write_yaml_file, ) @@ -187,6 +190,26 @@ def test_write_toml_file_dry_run_no_write(self, tmp_path): write_toml_file(p, doc) assert not p.exists() + def test_write_yaml_file(self, tmp_path): + p = tmp_path / "out.yml" + write_yaml_file(p, {"providers": {"databricks-claude": {"apiKey": "!ug auth-token"}}}) + assert yaml.safe_load(p.read_text(encoding="utf-8")) == { + "providers": {"databricks-claude": {"apiKey": "!ug auth-token"}} + } + + def test_write_yaml_file_keeps_insertion_order(self, tmp_path): + # omp's models.yml is hand-read by users; a sorted dump would shuffle + # the provider blocks on every write. + p = tmp_path / "out.yml" + write_yaml_file(p, {"zebra": 1, "alpha": 2}) + assert p.read_text(encoding="utf-8").splitlines() == ["zebra: 1", "alpha: 2"] + + def test_write_yaml_file_dry_run_no_write(self, tmp_path): + set_dry_run(True) + p = tmp_path / "out.yml" + write_yaml_file(p, {"a": 1}) + assert not p.exists() + def test_write_dotenv(self, tmp_path): p = tmp_path / ".env" write_dotenv(p, {"KEY": "value", "OTHER": "123"}) @@ -255,6 +278,33 @@ def test_read_toml_safe_invalid(self, tmp_path): doc = read_toml_safe(p) assert dict(doc) == {} + def test_read_yaml_safe_missing_file(self, tmp_path): + assert read_yaml_safe(tmp_path / "missing.yml") == {} + + def test_read_yaml_safe_valid(self, tmp_path): + p = tmp_path / "models.yml" + p.write_text("providers:\n databricks-claude:\n api: anthropic-messages\n", "utf-8") + assert read_yaml_safe(p) == { + "providers": {"databricks-claude": {"api": "anthropic-messages"}} + } + + def test_read_yaml_safe_invalid(self, tmp_path): + p = tmp_path / "bad.yml" + p.write_text("key: [unclosed\n", encoding="utf-8") + assert read_yaml_safe(p) == {} + + def test_read_yaml_safe_non_mapping(self, tmp_path): + # A list document has no keys to merge into; treat it like an absent file + # rather than replacing the user's file wholesale. + p = tmp_path / "list.yml" + p.write_text("- a\n- b\n", encoding="utf-8") + assert read_yaml_safe(p) == {} + + def test_read_yaml_safe_non_utf8(self, tmp_path): + p = tmp_path / "binary.yml" + p.write_bytes(b"\xff\xfe\x00not utf-8") + assert read_yaml_safe(p) == {} + # --------------------------------------------------------------------------- # parse_dotenv diff --git a/tests/test_databricks.py b/tests/test_databricks.py index d2b569ea6..24762c880 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -283,6 +283,10 @@ def test_opencode_raises(self): with pytest.raises(RuntimeError, match="multiple base URLs"): build_tool_base_url("opencode", WS) + def test_omp_raises(self): + with pytest.raises(RuntimeError, match="multiple base URLs"): + build_tool_base_url("omp", WS) + def test_unsupported_tool_raises(self): with pytest.raises(RuntimeError, match="Unsupported"): build_tool_base_url("unknown", WS) @@ -311,6 +315,13 @@ def test_contains_all_tools(self): assert "claude" in urls assert "gemini" in urls assert "opencode" in urls + assert "copilot" in urls + assert "pi" in urls + assert "omp" in urls + + def test_omp_reuses_pi_gateway_paths(self): + urls = build_shared_base_urls(WS) + assert urls["omp"] == urls["pi"] def test_opencode_is_dict(self): urls = build_shared_base_urls(WS) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 45ea62c40..3f6c08527 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -110,7 +110,9 @@ def test_missing_binary_offers_install(self): assert checks[0].suggestion is not None def test_compatible_agents_are_ok_without_registry_checks(self): - state = {"available_tools": ["claude", "codex", "opencode", "gemini", "pi", "copilot"]} + state = { + "available_tools": ["claude", "codex", "opencode", "gemini", "pi", "copilot", "omp"] + } with ( patch.object(doctor_mod, "load_state", return_value=state), patch.object(doctor_mod, "tool_binary_installed", return_value=True), @@ -118,7 +120,7 @@ def test_compatible_agents_are_ok_without_registry_checks(self): patch("subprocess.run", side_effect=AssertionError("must not query npm")), ): checks = _check_agent_clis() - assert len(checks) == 6 + assert len(checks) == 7 assert all(check.status == "ok" and check.suggestion is None for check in checks) def test_only_below_minimum_agent_offers_upgrade(self): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index adc406947..7b214ce0f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -245,7 +245,7 @@ def test_claude_url_contains_workspace(self, e2e_workspace): def test_shared_base_urls_all_tools(self, e2e_workspace): urls = build_shared_base_urls(e2e_workspace) - for tool in ("codex", "claude", "gemini", "opencode", "copilot", "pi"): + for tool in ("codex", "claude", "gemini", "opencode", "copilot", "pi", "omp"): assert tool in urls diff --git a/tests/test_managed_setup.py b/tests/test_managed_setup.py index e06260915..632bb3d97 100644 --- a/tests/test_managed_setup.py +++ b/tests/test_managed_setup.py @@ -333,13 +333,18 @@ def test_codex_sees_gpt_and_oss(self): ] def test_multi_provider_agents_see_everything(self): - for tool in ("opencode", "pi", "copilot"): + for tool in ("opencode", "pi", "copilot", "omp"): options = model_options_for_agent(tool, STATE) assert "system.ai.claude-opus-4-8" in options, tool assert "system.ai.gpt-5-6" in options, tool assert "system.ai.gemini-3-flash" in options, tool assert "system.ai.kimi-k2-6" in options, tool + def test_omp_matches_pi_options(self): + # omp is the same multi-provider harness as pi; the setup picker + # excludes it, but any residual path must offer pi-identical options. + assert model_options_for_agent("omp", STATE) == model_options_for_agent("pi", STATE) + def test_empty_state_yields_no_options(self): assert model_options_for_agent("claude", {}) == [] @@ -375,7 +380,7 @@ def test_claude_does_not_support_openai(self): assert not supports_provider_service("claude", "openai") def test_other_agents_have_no_provider_support(self): - for tool in ("gemini", "opencode", "pi", "copilot"): + for tool in ("gemini", "opencode", "pi", "copilot", "omp"): assert not supports_provider_service(tool, "anthropic"), tool diff --git a/tests/test_mcp.py b/tests/test_mcp.py index f57147a3e..4ea08319d 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -246,6 +246,47 @@ def test_managed_entry_builds_via_kilo_module(self): ) == mcp.kilo.build_mcp_server_entry(argv) +class TestOmpMcpClient: + def test_omp_registered_without_list_command(self): + # omp exposes no `mcp list` subcommand; status output skips the + # list row when the key is absent. + assert "omp" in mcp.MCP_CLIENTS + assert mcp.MCP_CLIENTS["omp"]["binary"] == "omp" + assert "list_command" not in mcp.MCP_CLIENTS["omp"] + + def test_configure_dispatches_proxy_argv_to_omp_writer(self, monkeypatch): + calls: list[tuple[str, list[str]]] = [] + monkeypatch.setattr( + mcp.omp, + "write_mcp_server_config", + lambda name, argv: calls.append((name, argv)) or False, + ) + removed_scopes = mcp.configure_client_mcp_server("omp", "github", GH_URL, WS, "p") + + assert removed_scopes == [] + assert calls == [("github", _proxy_argv())] + + def test_configure_reports_user_scope_on_replace(self, monkeypatch): + monkeypatch.setattr(mcp.omp, "write_mcp_server_config", lambda name, argv: True) + assert mcp.configure_client_mcp_server("omp", "github", GH_URL, WS, "p") == [ + mcp.MCP_USER_SCOPE + ] + + def test_remove_dispatches_to_omp_writer(self, monkeypatch): + calls: list[str] = [] + monkeypatch.setattr( + mcp.omp, "remove_mcp_server_config", lambda name: calls.append(name) or True + ) + assert mcp.remove_client_mcp_server("omp", "github-mcp") == [mcp.MCP_USER_SCOPE] + assert calls == ["github-mcp"] + + def test_list_run_is_skipped_instead_of_raising(self): + # The status/probe paths call `_run_mcp_list` for every registered client; + # a client without a listing subcommand must report "no live status". + assert mcp._run_mcp_list("omp") is None + assert mcp.query_live_mcp_status("omp") == {} + + class TestConfigureClientMcpServer: def test_configures_copilot_with_proxy_argv(self, monkeypatch): calls: list[tuple[str, list[str]]] = [] @@ -3001,7 +3042,7 @@ def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkey "mcp_servers": [ { "name": "github-mcp", - "clients": ["claude", "codex", "gemini", "opencode", "copilot"], + "clients": ["claude", "codex", "gemini", "opencode", "copilot", "omp"], } ] } @@ -3013,9 +3054,11 @@ def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkey ("gemini", "github-mcp"), ("opencode", "github-mcp"), ("copilot", "github-mcp"), + ("omp", "github-mcp"), ] assert restored == [ - (mcp.copilot.COPILOT_MCP_CONFIG_PATH, mcp.copilot.COPILOT_MCP_BACKUP_PATH, True) + (mcp.copilot.COPILOT_MCP_CONFIG_PATH, mcp.copilot.COPILOT_MCP_BACKUP_PATH, True), + (mcp.omp.OMP_MCP_PATH, mcp.omp.OMP_MCP_BACKUP_PATH, True), ] assert result == { "claude": True, @@ -3023,6 +3066,7 @@ def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkey "gemini": True, "opencode": True, "copilot": True, + "omp": True, } def test_removes_skills_registry_across_its_clients(self, monkeypatch): diff --git a/tests/test_state.py b/tests/test_state.py index fcc6cbfa2..78b779599 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -47,6 +47,11 @@ "openai": f"{FAKE_WS}/ai-gateway/codex/v1", "gemini": f"{FAKE_WS}/ai-gateway/gemini/v1beta", }, + "omp": { + "claude": f"{FAKE_WS}/ai-gateway/anthropic", + "openai": f"{FAKE_WS}/ai-gateway/codex/v1", + "gemini": f"{FAKE_WS}/ai-gateway/gemini/v1beta", + }, } @@ -136,6 +141,7 @@ def test_persists_codex_launcher_default_in_agent_state(self): assert persisted["codex_models"][0] == "system.ai.gpt-5" assert "model" not in persisted["agents"]["codex"] assert persisted["agents"]["pi"]["model"] == "system.ai.gpt-5" + assert persisted["agents"]["omp"]["model"] == "system.ai.gpt-5" def test_save_respects_dry_run(self): import ucode.config_io as config_io_mod @@ -285,6 +291,8 @@ def test_populates_agent_state_when_workspace_present(self): assert codex_auth["args"][0] == "auth-token" assert result["agents"]["pi"]["model"] == "claude-opus" assert result["agents"]["pi"]["base_urls"] == FAKE_URLS["pi"] + assert result["agents"]["omp"]["model"] == "claude-opus" + assert result["agents"]["omp"]["base_urls"] == FAKE_URLS["omp"] def test_normalizes_managed_configs_dict_entry(self): state = {"managed_configs": {"claude": {"keys": [["env", "X"]]}}} @@ -319,7 +327,7 @@ def test_use_pat_state_builds_pat_auth_command(self): ) # --use-pat threads through to the `ucode auth-token --use-pat` helper, # which resolves the static PAT internally on every platform. - for agent in ("claude", "codex", "pi"): + for agent in ("claude", "codex", "pi", "omp"): assert "--use-pat" in result[agent]["auth_command"] assert "--profile DEFAULT" in result[agent]["auth_command"] assert result["codex"]["auth"]["timeout_ms"] == 5000 diff --git a/uv.lock b/uv.lock index de683fde2..e419c3667 100644 --- a/uv.lock +++ b/uv.lock @@ -841,6 +841,50 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1139,6 +1183,7 @@ dependencies = [ { name = "databricks-sdk" }, { name = "httpx" }, { name = "mcp" }, + { name = "pyyaml" }, { name = "questionary" }, { name = "tomlkit" }, { name = "typer" }, @@ -1160,6 +1205,7 @@ requires-dist = [ { name = "databricks-sdk", specifier = ">=0.135.0" }, { name = "httpx", specifier = ">=0.27.1" }, { name = "mcp", specifier = ">=1.28.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, { name = "questionary", specifier = ">=2.0.0" }, { name = "tomlkit", specifier = ">=0.13.0" }, { name = "typer", specifier = ">=0.26.0" },