Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion docs/integrations/omp.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,46 @@ Invoke them in the agent's chat using dotted notation, for example
Workflow steps use `omp --print <prompt>`; `--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
66 changes: 66 additions & 0 deletions src/specify_cli/integrations/omp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ())
Comment thread
kevinnguyenhoang91 marked this conversation as resolved.
Resolved
for option, value in (integration_options or {}).items():
Comment thread
kevinnguyenhoang91 marked this conversation as resolved.
Resolved
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}."
)
138 changes: 138 additions & 0 deletions tests/integrations/test_integration_omp.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for OmpIntegration."""

import pytest

from specify_cli.integrations import get_integration

from .test_integration_base_markdown import MarkdownIntegrationTests
Expand Down Expand Up @@ -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,
)