fix(integrations): dispatch goose commands via goose run (#2416) (#3781)

* fix(integrations): dispatch goose commands via `goose run` (#2416)

`YamlIntegration` never overrode `build_exec_args()`, so `GooseIntegration`
inherited the `IntegrationBase` no-op that returns `None`. Callers read `None`
as "this CLI is unavailable", so every workflow command/prompt step targeting
Goose reported `CLI not found or not installed` even with `goose` on PATH.

Reproduced with the agent CLI present on PATH (shutil.which stubbed to a real
path, subprocess.run stubbed):

    amp       -> completed  argv=['amp', '-p', '/speckit.specify']
    opencode  -> completed  argv=['opencode', 'run', '--command', 'speckit.specify']
    goose     -> FAILED     "integration 'goose' CLI not found or not installed"

Implement `build_exec_args()` for Goose. Per the goose CLI docs there is no
`-p` flag; the non-interactive entry point is `goose run`, which takes
`-t/--text` for free-form text, `--recipe` for a stored recipe,
`--params KEY=VALUE` for recipe parameters, plus `--model` and
`--output-format`. Spec Kit installs its commands as Goose *recipes* under
`.goose/recipes/`, each declaring an optional `args` parameter (already
enforced by test_setup_declares_args_parameter_for_args_prompt), so a
`/speckit.<name> <rest>` invocation maps exactly onto
`--recipe <path> --params args=<rest>`. This mirrors `OpencodeIntegration`,
which maps the same leading slash-command onto opencode's `--command`.

The recipe path is derived from the same two sources `setup()` uses --
`config["folder"]` + `config["commands_subdir"]` and `command_filename()` --
so the dispatch target cannot drift from the installed file; a test asserts the
resolved `--recipe` path exists after `setup()`. Dotted extension commands
(`speckit.git.commit`) round-trip. Extra args are applied before the canonical
flags so Spec Kit's selection stays authoritative, matching opencode.

No behaviour change for other integrations, and `requires_cli` is untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(goose): only map the speckit. namespace onto --recipe

build_exec_args() treated every prompt starting with "/" as a Spec Kit
recipe. Because command_filename() unconditionally re-adds the "speckit."
prefix, a free-form slash prompt was silently promoted into a recipe run
against a file that was never installed:

  /help            -> --recipe .goose/recipes/speckit.help.yaml
  /plan the sprint -> --recipe .goose/recipes/speckit.plan.yaml
  /speckit.        -> --recipe .goose/recipes/speckit..yaml

PromptStep passes arbitrary prompt: strings to build_exec_args, and both
/help and /plan are Goose's own session commands, so this is reachable.
Unlike opencode's --command or hermes' -s, which hand a bare name to the
agent's own resolver, --recipe is a path Spec Kit synthesizes -- so only
the namespace it can actually spell may take that branch.

Gate the branch on "/speckit." and fall through to -t otherwise. A bare
"/speckit." leaves no stem and also falls through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(goose): stop asserting an argv that goose would reject

test_goose_extra_args_cannot_clobber_prompt_derived_recipe asserted that a
duplicated --recipe is merely reordered, on a "last value wins" premise.
That premise is wrong for goose: `goose run` is clap-derive based and
--recipe/--model/--output-format are single-value args without
args_override_self, so a duplicate makes goose exit with "cannot be used
multiple times" whichever side comes first. The test passed in pytest
while pinning a command line that cannot run.

Replace it with an ordering-parity test that asserts only what Spec Kit
actually controls: extra args precede the canonical flags (matching
opencode/codex/cursor-agent), and Spec Kit never emits a duplicate
single-value flag itself. Verified non-vacuous -- it fails if the
extra-args hook is moved after the canonical flags.

The ordering comment claimed precedence it cannot deliver; corrected to
state positional parity only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-08-13 15:02:48 -05:00
committed by GitHub
co-authored by Claude Opus 5
parent 2b36f0ce94
commit b485cd8c1f
3 changed files with 224 additions and 0 deletions
@@ -1,5 +1,7 @@
"""Goose integration — open source AI agent (Agentic AI Foundation)."""
from __future__ import annotations
from ..base import YamlIntegration
@@ -18,3 +20,84 @@ class GooseIntegration(YamlIntegration):
"args": "{{args}}",
"extension": ".yaml",
}
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``goose`` execution.
``YamlIntegration`` never overrode ``build_exec_args()``, so Goose
inherited the ``IntegrationBase`` no-op returning ``None``. Callers read
``None`` as "this CLI is unavailable", so a workflow command/prompt step
targeting Goose reported ``CLI not found or not installed`` even with
``goose`` on ``PATH`` (the Goose item in issue #2416).
``goose`` has no ``-p`` flag; its non-interactive entry point is
``goose run``, which takes ``-t/--text`` for free-form text,
``--recipe`` for a stored recipe, ``--params KEY=VALUE`` for recipe
parameters, plus ``--model`` and ``--output-format``.
Spec Kit installs its commands as Goose *recipes* under
``.goose/recipes/``, each declaring an optional ``args`` string
parameter, so a ``/speckit.<name> <rest>`` invocation maps onto
``--recipe <path> --params args=<rest>``. Only that namespace is
mapped: ``--recipe`` is a *path* Spec Kit synthesizes, unlike
opencode's ``--command`` or hermes' ``-s``, which hand a bare name to
the agent's own resolver. Any other prompt -- including Goose's own
session commands such as ``/help`` or ``/plan`` -- goes to ``-t``.
"""
args = [self._resolve_executable(), "run"]
# Extra args are applied first, matching the opencode / codex /
# cursor-agent ordering. Positional parity only, NOT precedence:
# ``goose run`` is clap-derive based, and --recipe / --model /
# --output-format are single-value args with no ``args_override_self``,
# so re-passing any of them through
# SPECKIT_INTEGRATION_GOOSE_EXTRA_ARGS makes goose exit with
# "cannot be used multiple times" whichever side comes first. The same
# is true of goose's boolean flags. Only its ``Vec``-typed args (which
# clap infers as ArgAction::Append) may legitimately repeat.
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
# Only the ``speckit.`` namespace maps to a recipe: this branch
# synthesizes a *path*, and ``command_filename()`` can only ever spell
# ``speckit.<name>.yaml``. ``PromptStep`` passes arbitrary ``prompt:``
# strings here, so other slash text -- including Goose's own session
# commands ``/help`` and ``/plan`` -- must reach ``-t`` unchanged.
if prompt.startswith("/speckit."):
command, _, remainder = prompt[1:].partition(" ")
# ``command_filename`` re-adds the ``speckit.`` prefix and the
# ``.yaml`` extension, so strip it here; a dotted extension command
# (``speckit.git.commit``) round-trips too. A bare ``/speckit.``
# leaves no stem and falls through to ``-t``.
stem = command[len("speckit."):]
if stem:
# Derive the recipe path from the same two sources ``setup()``
# uses -- ``config["folder"]`` + ``config["commands_subdir"]``
# (exactly what ``commands_dest()`` does) and
# ``command_filename()`` -- so the dispatch target cannot drift
# from the file that was actually installed.
folder = (self.config.get("folder") or "").strip("/")
subdir = (self.config.get("commands_subdir") or "").strip("/")
# Relative, forward-slash path: dispatch runs with
# ``cwd=project_root``, and goose accepts a POSIX separator on
# every platform (``commands_dest()`` yields backslashes on
# win32).
parts = [
p for p in (folder, subdir, self.command_filename(stem)) if p
]
args.extend(["--recipe", "/".join(parts)])
if remainder.strip():
args.extend(["--params", f"args={remainder}"])
return args
args.extend(["-t", prompt])
return args