diff --git a/docs/integrations/omp.md b/docs/integrations/omp.md index b32a3fc6f4..ee4eec2ef1 100644 --- a/docs/integrations/omp.md +++ b/docs/integrations/omp.md @@ -28,4 +28,46 @@ Invoke them in the agent's chat using dotted notation, for example Workflow steps use `omp --print `; `--print` enables one-shot mode and the prompt is positional. The adapter forwards a configured `--model` and uses `--mode json`, not `--output-format`, for structured output. -There are no integration-specific setup options. +There are no integration-specific setup options; see Runtime Options below. + +## Runtime Options + +Command steps can pass per-step runtime configuration to `omp --print` (see +[Per-Step Integration Configuration](../reference/workflows.md#per-step-integration-configuration)): + +| `integration_options` key | OMP flag | Value | +| ------------------------- | ------------ | ------------------------------------------------------------------------ | +| `profile` | `--profile` | Isolated OMP profile name | +| `thinking` | `--thinking` | One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `auto` | +| `tools` | `--tools` | Comma-separated list of tools to enable | + +```yaml +- id: plan-with-omp + type: command + command: speckit.plan + integration: omp + integration_args: + - "--no-session" + integration_options: + profile: work + thinking: high + tools: "read,bash" + model: "gpt-5" +``` + +Option values must be non-empty strings; unknown keys and unsupported +`thinking` values fail before OMP is launched. `integration_args` is passed +through as raw OMP CLI flags and each value must be a non-empty string. The +`--` option terminator is rejected because it would turn the generated options +and the prompt into positional arguments. Select the model with the step's +top-level `model` field: `integration_options.model` and a `--model` flag in +`integration_args` are both rejected. + +Arguments are passed to `omp --print` in this order, so later sources follow +earlier ones on the command line: + +1. `SPECKIT_INTEGRATION_OMP_EXTRA_ARGS` +2. `integration_args` +3. `integration_options` flags +4. `--model` from the step's `model` field +5. `--mode json`, then the prompt diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py index b2088593d2..1cfbc7fa22 100644 --- a/src/specify_cli/integrations/omp/__init__.py +++ b/src/specify_cli/integrations/omp/__init__.py @@ -26,6 +26,15 @@ class OmpIntegration(MarkdownIntegration): } multi_install_safe = True + _RUNTIME_OPTION_FLAGS = { + "profile": "--profile", + "thinking": "--thinking", + "tools": "--tools", + } + _THINKING_LEVELS = { + "off", "minimal", "low", "medium", "high", "xhigh", "max", "auto", + } + def build_exec_args( self, prompt: str, @@ -45,9 +54,66 @@ def build_exec_args( self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "--print"] self._apply_extra_args_env_var(args) + args.extend(integration_args or ()) + for option, value in (integration_options or {}).items(): + args.extend([self._RUNTIME_OPTION_FLAGS[option], value]) if model: args.extend(["--model", model]) if output_json: args.extend(["--mode", "json"]) args.append(prompt) return args + + def validate_runtime_config( + self, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, + ) -> None: + """Validate OMP per-step CLI flags and named options.""" + if not all( + isinstance(value, str) and value.strip() + for value in integration_args or () + ): + raise ValueError("OMP 'integration_args' values must be non-empty strings.") + if "--" in (integration_args or ()): + raise ValueError( + "OMP 'integration_args' must not contain the '--' option " + "terminator; it would turn generated options and the prompt " + "into positional arguments." + ) + if any( + value == "--model" or value.startswith("--model=") + for value in integration_args or () + ): + raise ValueError( + "OMP model selection must use the command-step 'model' field, " + "not a '--model' flag in 'integration_args'." + ) + + options = integration_options or {} + if not all(isinstance(name, str) for name in options): + raise ValueError("OMP 'integration_options' keys must be strings.") + if "model" in options: + raise ValueError( + "OMP model selection must use the command-step 'model' field, " + "not 'integration_options.model'." + ) + unknown = sorted(set(options) - self._RUNTIME_OPTION_FLAGS.keys()) + if unknown: + names = ", ".join(repr(name) for name in unknown) + allowed = ", ".join(sorted(self._RUNTIME_OPTION_FLAGS)) + raise ValueError( + f"OMP received unknown integration option(s): {names}. " + f"Supported options: {allowed}." + ) + for name, value in options.items(): + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"OMP integration option {name!r} must be a non-empty string." + ) + thinking = options.get("thinking") + if thinking is not None and thinking not in self._THINKING_LEVELS: + allowed = ", ".join(sorted(self._THINKING_LEVELS)) + raise ValueError( + f"OMP integration option 'thinking' must be one of: {allowed}." + ) diff --git a/tests/integrations/test_integration_omp.py b/tests/integrations/test_integration_omp.py index 07dd57336b..5e5ba2d218 100644 --- a/tests/integrations/test_integration_omp.py +++ b/tests/integrations/test_integration_omp.py @@ -1,5 +1,7 @@ """Tests for OmpIntegration.""" +import pytest + from specify_cli.integrations import get_integration from .test_integration_base_markdown import MarkdownIntegrationTests @@ -34,3 +36,139 @@ def test_build_exec_args_uses_omp_json_mode(self): "json", "/speckit.specify Build auth", ] + + def test_build_exec_args_passes_integration_args_and_options(self, monkeypatch): + monkeypatch.delenv("SPECKIT_INTEGRATION_OMP_EXECUTABLE", raising=False) + monkeypatch.delenv("SPECKIT_INTEGRATION_OMP_EXTRA_ARGS", raising=False) + + args = get_integration(self.KEY).build_exec_args( + "/speckit.plan", + model="gpt-5", + integration_args=["--no-session", "--verbose"], + integration_options={ + "profile": "work", + "thinking": "high", + "tools": "read,bash", + }, + ) + + assert args == [ + "omp", + "--print", + "--no-session", + "--verbose", + "--profile", + "work", + "--thinking", + "high", + "--tools", + "read,bash", + "--model", + "gpt-5", + "--mode", + "json", + "/speckit.plan", + ] + + def test_build_exec_args_places_integration_args_after_env_extra_args( + self, monkeypatch + ): + monkeypatch.delenv("SPECKIT_INTEGRATION_OMP_EXECUTABLE", raising=False) + monkeypatch.setenv("SPECKIT_INTEGRATION_OMP_EXTRA_ARGS", "--from-env") + + args = get_integration(self.KEY).build_exec_args( + "prompt", + output_json=False, + integration_args=["--per-step"], + integration_options={"thinking": "low"}, + ) + + assert args == [ + "omp", + "--print", + "--from-env", + "--per-step", + "--thinking", + "low", + "prompt", + ] + + @pytest.mark.parametrize( + "thinking", + ["off", "minimal", "low", "medium", "high", "xhigh", "max", "auto"], + ) + def test_validate_runtime_config_accepts_thinking_levels(self, thinking): + get_integration(self.KEY).validate_runtime_config( + None, {"thinking": thinking} + ) + + @pytest.mark.parametrize( + ("integration_args", "integration_options"), + [(None, None), ([], {}), (["--no-session"], {"profile": "work"})], + ) + def test_validate_runtime_config_accepts_valid_config( + self, integration_args, integration_options + ): + get_integration(self.KEY).validate_runtime_config( + integration_args, integration_options + ) + + @pytest.mark.parametrize("integration_args", [[""], [" "], [42], ["--ok", None]]) + def test_integration_args_reject_malformed_values(self, integration_args): + with pytest.raises(ValueError, match="non-empty strings"): + get_integration(self.KEY).build_exec_args( + "prompt", + integration_args=integration_args, + ) + + @pytest.mark.parametrize( + "integration_args", + [["--model", "gpt-5"], ["--model=gpt-5"], ["--no-session", "--model", "gpt-5"]], + ) + def test_integration_args_model_flag_is_rejected(self, integration_args): + with pytest.raises(ValueError, match="command-step 'model' field"): + get_integration(self.KEY).build_exec_args( + "prompt", + integration_args=integration_args, + ) + + @pytest.mark.parametrize( + "integration_args", [["--"], ["--no-session", "--"], ["--", "extra"]] + ) + def test_integration_args_option_terminator_is_rejected(self, integration_args): + with pytest.raises(ValueError, match="option terminator"): + get_integration(self.KEY).build_exec_args( + "prompt", + integration_args=integration_args, + ) + + def test_integration_args_allow_models_cycling_flag(self): + # `--models` only scopes Ctrl+P cycling; it does not select the model. + get_integration(self.KEY).validate_runtime_config( + ["--models", "claude-sonnet,gpt-5"], None + ) + + def test_integration_options_model_is_rejected(self): + with pytest.raises(ValueError, match="command-step 'model' field"): + get_integration(self.KEY).build_exec_args( + "prompt", + integration_options={"model": "gpt-5"}, + ) + + @pytest.mark.parametrize( + ("options", "message"), + [ + ({1: "value"}, "keys must be strings"), + ({"unknown": "value"}, r"unknown integration option\(s\): 'unknown'"), + ({"profile": ""}, "'profile' must be a non-empty string"), + ({"tools": " "}, "'tools' must be a non-empty string"), + ({"profile": 42}, "'profile' must be a non-empty string"), + ({"thinking": "extreme"}, "'thinking' must be one of"), + ], + ) + def test_integration_options_are_validated(self, options, message): + with pytest.raises(ValueError, match=message): + get_integration(self.KEY).build_exec_args( + "prompt", + integration_options=options, + )