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
94 changes: 45 additions & 49 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ class IntegrationBase(ABC):
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""

# -- Declarative batch-mode attributes --------------------------------

exec_mode: str = "flag"
"""How the CLI accepts a prompt: ``"flag"`` (``-p "prompt"``),
``"subcommand"`` (``<subcmd> "prompt"``), or ``"none"`` (no CLI dispatch)."""

exec_prompt_flag: str = "-p"
"""Flag used to pass the prompt when ``exec_mode == "flag"``."""

exec_subcommand: str = ""
"""Subcommand inserted before the prompt when ``exec_mode == "subcommand"``."""

exec_model_flag: str = "--model"
"""Flag for model selection (e.g. ``"--model"``, ``"-m"``).
Set to ``""`` to omit model passing entirely."""

exec_json_args: tuple[str, ...] = ("--output-format", "json")
"""Arguments appended when JSON output is requested.
Set to ``()`` if the CLI has no structured-output flag."""

# -- Markers for managed context section ------------------------------

CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
Expand Down Expand Up @@ -124,9 +144,31 @@ def build_exec_args(
non-interactively using this integration's CLI tool, or ``None``
if the integration does not support CLI dispatch.

Subclasses for CLI-based integrations should override this.
The default implementation uses the declarative ``exec_*`` class
attributes. Integrations with complex dispatch logic (e.g.
dynamic flags) can still override this method directly.
"""
return None
if not self.config or not self.config.get("requires_cli"):
return None
if self.exec_mode == "none":
return None

args = [self.key]

if self.exec_mode == "subcommand" and self.exec_subcommand:
args.append(self.exec_subcommand)

if self.exec_mode == "flag":
args.extend([self.exec_prompt_flag, prompt])
elif self.exec_mode == "subcommand":
args.append(prompt)

Comment on lines +151 to +165
if model and self.exec_model_flag:
args.extend([self.exec_model_flag, model])
if output_json and self.exec_json_args:
args.extend(self.exec_json_args)

return args

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

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args

def setup(
self,
project_root: Path,
Expand Down Expand Up @@ -917,21 +943,7 @@ class TomlIntegration(IntegrationBase):
TOML format (``description`` key + ``prompt`` multiline string).
"""

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["-m", model])
if output_json:
args.extend(["--output-format", "json"])
return args
exec_model_flag = "-m"

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

invoke_separator = "-"

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args

def skills_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the skills output directory.

Expand Down
17 changes: 3 additions & 14 deletions src/specify_cli/integrations/codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,9 @@ class CodexIntegration(SkillsIntegration):
}
context_file = "AGENTS.md"

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
args: list[str] = ["codex", "exec", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.append("--json")
return args
exec_mode = "subcommand"
exec_subcommand = "exec"
exec_json_args = ("--json",)

@classmethod
def options(cls) -> list[IntegrationOption]:
Expand Down
22 changes: 2 additions & 20 deletions src/specify_cli/integrations/devin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,8 @@ class DevinIntegration(SkillsIntegration):
}
context_file = "AGENTS.md"

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build non-interactive CLI args for Devin for Terminal.

Devin supports ``devin -p <prompt>`` for single-turn execution
and ``--model`` for model selection, but its CLI has no flag
for structured JSON output. When ``output_json`` is requested,
Devin is still dispatched normally and returns plain-text
stdout instead of structured JSON. ``requires_cli=True`` is
kept on the integration for tool detection.
"""
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
return args
# Devin has no structured JSON output flag.
exec_json_args = ()

@classmethod
def options(cls) -> list[IntegrationOption]:
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/integrations/goose/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ class GooseIntegration(YamlIntegration):
"extension": ".yaml",
}
context_file = "AGENTS.md"

# Goose CLI dispatch is not supported (recipe-based workflow).
exec_mode = "none"
18 changes: 18 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,24 @@ def test_no_json_omits_flag(self):
args = impl.build_exec_args("do stuff", output_json=False)
assert "--output-format" not in args

def test_devin_no_json_args(self):
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args = impl.build_exec_args("do stuff", model="gpt-4o", output_json=True)
assert args == ["devin", "-p", "do stuff", "--model", "gpt-4o"]

def test_goose_returns_none(self):
from specify_cli.integrations.goose import GooseIntegration
impl = GooseIntegration()
assert impl.build_exec_args("do stuff") is None

def test_amp_inherits_defaults(self):
from specify_cli.integrations.amp import AmpIntegration
impl = AmpIntegration()
args = impl.build_exec_args("do stuff", model="fast")
assert args == ["amp", "-p", "do stuff", "--model", "fast",
"--output-format", "json"]


# ===== Step Type Tests =====

Expand Down
Loading