Skip to content

Commit 1f7d912

Browse files
author
Markus
committed
fix(integrations): make build_exec_args declarative (#2416)
Centralize non-interactive CLI dispatch argument construction on IntegrationBase and let integrations declare prompt, model, and JSON variations with exec_* attributes.\n\nThis fixes the inherited dispatch defaults called out in #2416 and keeps special cases like Codex, Devin, and Goose explicit without per-integration boilerplate.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent da1bf02 commit 1f7d912

5 files changed

Lines changed: 71 additions & 83 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ class IntegrationBase(ABC):
8787
invoke_separator: str = "."
8888
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
8989

90+
# -- Declarative batch-mode attributes --------------------------------
91+
92+
exec_mode: str = "flag"
93+
"""How the CLI accepts a prompt: ``"flag"`` (``-p "prompt"``),
94+
``"subcommand"`` (``<subcmd> "prompt"``), or ``"none"`` (no CLI dispatch)."""
95+
96+
exec_prompt_flag: str = "-p"
97+
"""Flag used to pass the prompt when ``exec_mode == "flag"``."""
98+
99+
exec_subcommand: str = ""
100+
"""Subcommand inserted before the prompt when ``exec_mode == "subcommand"``."""
101+
102+
exec_model_flag: str = "--model"
103+
"""Flag for model selection (e.g. ``"--model"``, ``"-m"``).
104+
Set to ``""`` to omit model passing entirely."""
105+
106+
exec_json_args: tuple[str, ...] = ("--output-format", "json")
107+
"""Arguments appended when JSON output is requested.
108+
Set to ``()`` if the CLI has no structured-output flag."""
109+
90110
# -- Markers for managed context section ------------------------------
91111

92112
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
@@ -124,9 +144,31 @@ def build_exec_args(
124144
non-interactively using this integration's CLI tool, or ``None``
125145
if the integration does not support CLI dispatch.
126146
127-
Subclasses for CLI-based integrations should override this.
147+
The default implementation uses the declarative ``exec_*`` class
148+
attributes. Integrations with complex dispatch logic (e.g.
149+
dynamic flags) can still override this method directly.
128150
"""
129-
return None
151+
if not self.config or not self.config.get("requires_cli"):
152+
return None
153+
if self.exec_mode == "none":
154+
return None
155+
156+
args = [self.key]
157+
158+
if self.exec_mode == "subcommand" and self.exec_subcommand:
159+
args.append(self.exec_subcommand)
160+
161+
if self.exec_mode == "flag":
162+
args.extend([self.exec_prompt_flag, prompt])
163+
elif self.exec_mode == "subcommand":
164+
args.append(prompt)
165+
166+
if model and self.exec_model_flag:
167+
args.extend([self.exec_model_flag, model])
168+
if output_json and self.exec_json_args:
169+
args.extend(self.exec_json_args)
170+
171+
return args
130172

131173
def build_command_invocation(self, command_name: str, args: str = "") -> str:
132174
"""Build the native slash-command invocation for a Spec Kit command.
@@ -830,22 +872,6 @@ class MarkdownIntegration(IntegrationBase):
830872
managed context section into the agent context file.
831873
"""
832874

833-
def build_exec_args(
834-
self,
835-
prompt: str,
836-
*,
837-
model: str | None = None,
838-
output_json: bool = True,
839-
) -> list[str] | None:
840-
if not self.config or not self.config.get("requires_cli"):
841-
return None
842-
args = [self.key, "-p", prompt]
843-
if model:
844-
args.extend(["--model", model])
845-
if output_json:
846-
args.extend(["--output-format", "json"])
847-
return args
848-
849875
def setup(
850876
self,
851877
project_root: Path,
@@ -917,21 +943,7 @@ class TomlIntegration(IntegrationBase):
917943
TOML format (``description`` key + ``prompt`` multiline string).
918944
"""
919945

920-
def build_exec_args(
921-
self,
922-
prompt: str,
923-
*,
924-
model: str | None = None,
925-
output_json: bool = True,
926-
) -> list[str] | None:
927-
if not self.config or not self.config.get("requires_cli"):
928-
return None
929-
args = [self.key, "-p", prompt]
930-
if model:
931-
args.extend(["-m", model])
932-
if output_json:
933-
args.extend(["--output-format", "json"])
934-
return args
946+
exec_model_flag = "-m"
935947

936948
def command_filename(self, template_name: str) -> str:
937949
"""TOML commands use ``.toml`` extension."""
@@ -1315,22 +1327,6 @@ class SkillsIntegration(IntegrationBase):
13151327

13161328
invoke_separator = "-"
13171329

1318-
def build_exec_args(
1319-
self,
1320-
prompt: str,
1321-
*,
1322-
model: str | None = None,
1323-
output_json: bool = True,
1324-
) -> list[str] | None:
1325-
if not self.config or not self.config.get("requires_cli"):
1326-
return None
1327-
args = [self.key, "-p", prompt]
1328-
if model:
1329-
args.extend(["--model", model])
1330-
if output_json:
1331-
args.extend(["--output-format", "json"])
1332-
return args
1333-
13341330
def skills_dest(self, project_root: Path) -> Path:
13351331
"""Return the absolute path to the skills output directory.
13361332

src/specify_cli/integrations/codex/__init__.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,9 @@ class CodexIntegration(SkillsIntegration):
2828
}
2929
context_file = "AGENTS.md"
3030

31-
def build_exec_args(
32-
self,
33-
prompt: str,
34-
*,
35-
model: str | None = None,
36-
output_json: bool = True,
37-
) -> list[str] | None:
38-
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
39-
args: list[str] = ["codex", "exec", prompt]
40-
if model:
41-
args.extend(["--model", model])
42-
if output_json:
43-
args.append("--json")
44-
return args
31+
exec_mode = "subcommand"
32+
exec_subcommand = "exec"
33+
exec_json_args = ("--json",)
4534

4635
@classmethod
4736
def options(cls) -> list[IntegrationOption]:

src/specify_cli/integrations/devin/__init__.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,8 @@ class DevinIntegration(SkillsIntegration):
3232
}
3333
context_file = "AGENTS.md"
3434

35-
def build_exec_args(
36-
self,
37-
prompt: str,
38-
*,
39-
model: str | None = None,
40-
output_json: bool = True,
41-
) -> list[str] | None:
42-
"""Build non-interactive CLI args for Devin for Terminal.
43-
44-
Devin supports ``devin -p <prompt>`` for single-turn execution
45-
and ``--model`` for model selection, but its CLI has no flag
46-
for structured JSON output. When ``output_json`` is requested,
47-
Devin is still dispatched normally and returns plain-text
48-
stdout instead of structured JSON. ``requires_cli=True`` is
49-
kept on the integration for tool detection.
50-
"""
51-
args = [self.key, "-p", prompt]
52-
if model:
53-
args.extend(["--model", model])
54-
return args
35+
# Devin has no structured JSON output flag.
36+
exec_json_args = ()
5537

5638
@classmethod
5739
def options(cls) -> list[IntegrationOption]:

src/specify_cli/integrations/goose/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ class GooseIntegration(YamlIntegration):
1919
"extension": ".yaml",
2020
}
2121
context_file = "AGENTS.md"
22+
23+
# Goose CLI dispatch is not supported (recipe-based workflow).
24+
exec_mode = "none"

tests/test_workflows.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,24 @@ def test_no_json_omits_flag(self):
427427
args = impl.build_exec_args("do stuff", output_json=False)
428428
assert "--output-format" not in args
429429

430+
def test_devin_no_json_args(self):
431+
from specify_cli.integrations.devin import DevinIntegration
432+
impl = DevinIntegration()
433+
args = impl.build_exec_args("do stuff", model="gpt-4o", output_json=True)
434+
assert args == ["devin", "-p", "do stuff", "--model", "gpt-4o"]
435+
436+
def test_goose_returns_none(self):
437+
from specify_cli.integrations.goose import GooseIntegration
438+
impl = GooseIntegration()
439+
assert impl.build_exec_args("do stuff") is None
440+
441+
def test_amp_inherits_defaults(self):
442+
from specify_cli.integrations.amp import AmpIntegration
443+
impl = AmpIntegration()
444+
args = impl.build_exec_args("do stuff", model="fast")
445+
assert args == ["amp", "-p", "do stuff", "--model", "fast",
446+
"--output-format", "json"]
447+
430448

431449
# ===== Step Type Tests =====
432450

0 commit comments

Comments
 (0)