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
33 changes: 29 additions & 4 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,29 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
invocation = f"{invocation} {args}"
return invocation

def _build_dispatch_prompt(
self,
command_name: str,
args: str,
project_root: Path | None,
) -> str:
"""Return the dispatch prompt, given the target *project_root*.

Seam for integrations whose invocation depends on the project's
on-disk layout. ``build_command_invocation()`` is a two-argument
contract implemented by every integration, so widening it to carry a
*project_root* would change a broad public surface for the sake of
the one caller that needs it. Dispatch is that caller: it alone
knows which project the command is being run against, so dual-mode
integrations (e.g. Bob) resolve the layout here instead.

The default ignores *project_root* and preserves the previous
behaviour exactly.

See issue #4491.
"""
return self.build_command_invocation(command_name, args)

def dispatch_command(
self,
command_name: str,
Expand All @@ -384,11 +407,13 @@ def dispatch_command(
"""Dispatch a Spec Kit command through this integration's CLI.

By default this builds a slash-command invocation with
``build_command_invocation()`` and passes that prompt to
``_build_dispatch_prompt()`` -- which defers to
``build_command_invocation()`` unless the integration needs the
*project_root* to decide -- and passes that prompt to
``build_exec_args()`` to construct the CLI command line.
Integrations with custom dispatch behavior can override
``build_command_invocation()``, ``build_exec_args()``, or
``dispatch_command()`` directly.
``build_command_invocation()``, ``_build_dispatch_prompt()``,
``build_exec_args()``, or ``dispatch_command()`` directly.

When *stream* is ``True`` (the default), stdout and stderr are
piped directly to the terminal so the user sees live output.
Expand All @@ -401,7 +426,7 @@ def dispatch_command(
import subprocess

self.validate_runtime_config(integration_args, integration_options)
prompt = self.build_command_invocation(command_name, args)
prompt = self._build_dispatch_prompt(command_name, args, project_root)
# When streaming to the terminal, request text output so the
# user sees readable output instead of raw JSONL events.
exec_args = self.build_exec_args(
Expand Down
90 changes: 90 additions & 0 deletions src/specify_cli/integrations/bob/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from __future__ import annotations

import warnings
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -254,6 +255,95 @@ def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""
return "-" if skills_enabled else "."

def build_command_invocation(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
) -> str:
"""Render ``/speckit-<cmd>`` for skills mode, ``/speckit.<cmd>`` legacy.

``IntegrationBase`` hardcodes ``.``; Bob's skills live at
``.bob/skills/speckit-<cmd>/SKILL.md``, so the base rendering names a
command that does not exist in a skills-mode project. The two layouts
differ in the *separator* and in how a dotted extension command is
spelled: skills flatten every dot (``speckit.git.commit`` ->
``/speckit-git-commit``, matching the installed
``.bob/skills/speckit-git-commit/`` directory), while legacy commands
keep them (``/speckit.git.commit``).

*project_root* is keyword-only so this stays a superset of the base
signature that every other integration implements. It is resolved
through :meth:`is_skills_mode`, so a ``None`` root falls back to the
same skills default that :meth:`effective_invoke_separator` and
``is_skills_mode`` rule 4 already apply -- the class answers
"unknown project" consistently, whichever hook is asked.
"""
if not self.is_skills_mode(None, project_root):
return super().build_command_invocation(command_name, args)

stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
return f"{invocation} {args}" if args else invocation

def _build_dispatch_prompt(
self,
command_name: str,
args: str,
project_root: Path | None,
) -> str:
"""Resolve the layout from *project_root* at dispatch time.

Dispatch is the one caller that knows the target project, so it is
where a legacy install can be detected and given ``/speckit.<cmd>``
instead of the skills default.

A ``None`` root resolves to the working directory rather than being
passed through: ``dispatch_command`` runs ``bob`` with
``cwd = project_root or <the current directory>``, so when no root is
given the cwd *is* the project being dispatched into. Detecting its
layout beats falling back to the layout-unknown default, which would
send a legacy install the skills spelling.
"""
root = project_root if project_root is not None else Path.cwd()
return self.build_command_invocation(
command_name, args, project_root=root
)

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
"""Non-interactive dispatch through ``bob run``.

The prompt is **positional and last** -- Bob Shell 2.x has no ``-p`` on
``run``. ``--trust`` is per-invocation (``run`` never persists it) and
``--accept-license`` is required non-interactively.

*model* is ignored: ``run`` exposes no model flag; model choice comes
from ``session.model`` in Bob's settings.
"""
self.validate_runtime_config(integration_args, integration_options)
args = [
self._resolve_executable(),
"run",
"--trust",
"--accept-license",
"-f",
"json" if output_json else "pretty",
]
self._apply_extra_args_env_var(args)
args.append(prompt)
return args

def post_process_skill_content(self, content: str) -> str:
"""Bob skills are intent-activated; no slash-command note is injected.

Expand Down
179 changes: 179 additions & 0 deletions tests/integrations/test_integration_bob.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,3 +925,182 @@ def test_post_process_matches_skills_helper(self):
bob.post_process_skill_content(sample)
== _BobSkillsHelper().post_process_skill_content(sample)
)


class TestBobCliDispatch:
"""Headless dispatch through ``bob run``."""

@staticmethod
def _skills_project(tmp_path):
(tmp_path / ".bob" / "skills" / "speckit-specify").mkdir(parents=True)
return tmp_path

@staticmethod
def _legacy_project(tmp_path):
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.specify.md").write_text("x", encoding="utf-8")
return tmp_path

def test_requires_cli_is_false_for_ide_first_flow(self):
"""``requires_cli`` must stay False so the IDE-only flow keeps working.

``specify init --integration bob`` (without ``--ignore-agent-tools``)
treats ``requires_cli=True`` as a hard precheck and fails when the
``bob`` CLI isn't on PATH -- even though the Bob IDE / skills flow can
run without it. Workflow dispatch support is signalled by overriding
``build_exec_args()`` instead, mirroring ``CursorAgentIntegration``.
"""
bob = get_integration("bob")
assert bob.config.get("requires_cli") is False

def test_build_exec_args_default_is_bob_run_with_json(self):
"""Default argv is ``bob run`` with the headless flags, ``-f json``,
then the prompt: ``run`` takes the prompt positionally, not via ``-p``.
"""
bob = get_integration("bob")
assert bob.build_exec_args("/speckit-specify some-feature") == [
"bob", "run", "--trust", "--accept-license", "-f", "json",
"/speckit-specify some-feature",
]

def test_build_exec_args_text_output_uses_pretty(self):
bob = get_integration("bob")
assert bob.build_exec_args("/speckit-plan", output_json=False) == [
"bob", "run", "--trust", "--accept-license", "-f", "pretty",
"/speckit-plan",
]

def test_build_exec_args_ignores_model(self):
"""Bob exposes no model flag on ``run``, so *model* is a no-op."""
bob = get_integration("bob")
assert bob.build_exec_args("/speckit-plan", model="some-model") == \
bob.build_exec_args("/speckit-plan")

def test_command_invocation_uses_hyphen_in_skills_mode(self):
"""Skills-mode projects install ``.bob/skills/speckit-<cmd>/``, so the
invocation must use the same separator.
"""
bob = get_integration("bob")
assert bob.build_command_invocation("speckit.specify") == "/speckit-specify"
assert bob.build_command_invocation("speckit.plan", "arg") == "/speckit-plan arg"

def test_command_invocation_accepts_bare_stem(self):
bob = get_integration("bob")
assert bob.build_command_invocation("specify") == "/speckit-specify"

def test_command_invocation_flattens_dots_in_skills_mode(self, tmp_path):
"""Extension commands install as ``.bob/skills/speckit-git-commit/``.

``SkillsIntegration`` derives the skill directory with
``stem.replace(".", "-")``, so the invocation must flatten every dot,
not just the ``speckit.`` prefix.
"""
bob = get_integration("bob")
root = self._skills_project(tmp_path)
assert (
bob.build_command_invocation("speckit.git.commit", project_root=root)
== "/speckit-git-commit"
)
assert (
bob.build_command_invocation("git.commit", project_root=root)
== "/speckit-git-commit"
)
# Three segments: distinguishes "flatten every dot" from "flatten the
# first one". A two-segment stem cannot tell those apart.
assert (
bob.build_command_invocation("speckit.a.b.c", project_root=root)
== "/speckit-a-b-c"
)

def test_command_invocation_legacy_project_keeps_dots(self, tmp_path):
"""A legacy project installs ``.bob/commands/speckit.<cmd>.md``.

The Bob 1.x invocation is ``/speckit.<cmd>`` with dots preserved --
the skills flattening must not leak into this layout.
"""
bob = get_integration("bob")
root = self._legacy_project(tmp_path)
assert (
bob.build_command_invocation("speckit.specify", project_root=root)
== "/speckit.specify"
)
assert (
bob.build_command_invocation("speckit.git.commit", project_root=root)
== "/speckit.git.commit"
)
assert (
bob.build_command_invocation("speckit.specify", "arg", project_root=root)
== "/speckit.specify arg"
)

def test_command_invocation_without_project_root_uses_skills_default(self):
"""No *project_root* -> Bob's documented skills default.

``is_skills_mode`` rule 4 makes a project with no detectable layout
skills-mode, and ``effective_invoke_separator()`` answers ``-`` in
that state, so the invocation must agree rather than falling back to
the Bob 1.x spelling. No production caller reaches this method
without a root today; the assertion locks the documented default so a
future caller cannot silently inherit the wrong layout.
"""
bob = get_integration("bob")
assert bob.build_command_invocation("speckit.specify") == "/speckit-specify"
assert (
bob.build_command_invocation("speckit.git.commit")
== "/speckit-git-commit"
)

def test_dispatch_without_project_root_uses_cwd_layout(self, tmp_path, monkeypatch):
"""No explicit root -> resolve the layout from the cwd.

``dispatch_command`` runs ``bob`` with ``cwd = project_root or <the
current directory>``, so with no root the cwd is the project being
dispatched into. A *legacy* cwd is the discriminating case: skills is
the layout-unknown default, so only a legacy project proves the cwd
was inspected at all.
"""
import subprocess

captured = {}

def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
return subprocess.CompletedProcess(cmd, 0, "", "")

monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.chdir(self._legacy_project(tmp_path))

get_integration("bob").dispatch_command("speckit.git.commit")
assert captured["cmd"][-1] == "/speckit.git.commit"

def test_dispatch_resolves_layout_from_project_root(self, tmp_path, monkeypatch):
"""Dispatch knows the project, so it must render for that layout.

This is the seam that carries *project_root* from ``dispatch_command``
into invocation building; without it a legacy install is dispatched
with the skills spelling and the command is never found.
"""
import subprocess

captured = {}

def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
return subprocess.CompletedProcess(cmd, 0, "", "")

monkeypatch.setattr(subprocess, "run", fake_run)
bob = get_integration("bob")

bob.dispatch_command(
"speckit.specify",
args="my feature",
project_root=self._legacy_project(tmp_path),
)
assert captured["cmd"][-1] == "/speckit.specify my feature"

skills_root = tmp_path / "skills-proj"
bob.dispatch_command(
"speckit.git.commit", project_root=self._skills_project(skills_root)
)
assert captured["cmd"][-1] == "/speckit-git-commit"