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
41 changes: 41 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,47 @@ extension files intact. The core `speckit.taskstoissues` command remains
available; installing the GitHub extension adds the namespaced replacement
without deprecating or removing the core command.

### Oh My Pi runtime options

The options above apply at install time. Oh My Pi (`omp`) additionally accepts
per-step runtime configuration on workflow command steps (see
[Per-Step Integration Configuration](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. 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

## Scaffold a New Integration

```bash
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,11 @@ or paused command is resumed, the complete dispatch configuration is re-resolved
from the current inputs, so values supplied with `workflow resume --input` take
effect consistently. A resume without updated inputs reproduces the same values.

Oh My Pi (`omp`) also accepts per-step configuration: `integration_args` are
passed through as raw CLI flags, and `profile`, `thinking`, and `tools` are
accepted as named integration options. See
[Oh My Pi runtime options](integrations.md#oh-my-pi-runtime-options).

## Expressions

Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:
Expand Down
60 changes: 60 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,60 @@ 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():
Comment thread
kevinnguyenhoang91 marked this conversation as 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 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}."
)
128 changes: 128 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,129 @@ 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,
)

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,
)
Loading