diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index f3b74b0c05..b419e4ad1e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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"`` (`` "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 = "" @@ -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) + + 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. @@ -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, @@ -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.""" @@ -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. diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index b3b509b654..76b7a8cede 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -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]: diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index f5656e4aef..d985a25670 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -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 `` 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]: diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 0fc4d9d57a..ed4ccfe4b9 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -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" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 4c042fc7d5..39964fc12a 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -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 =====