From d7f4fc1498a0359e5f44d3663fe82ae1be5cec89 Mon Sep 17 00:00:00 2001 From: temp-droid <82510451+temp-droid@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:13:58 +0200 Subject: [PATCH 1/2] fix(integrations): dispatch bob commands via `bob run` `BobIntegration` never overrode `build_exec_args()`, so it inherited the `IntegrationBase` no-op returning `None`. Callers read `None` as "this CLI is unavailable", so every workflow command/prompt step targeting Bob reported `CLI not found or not installed` even with `bob` on PATH. `build_command_invocation()` was inherited too, rendering `/speckit.specify` where skills-mode projects install `.bob/skills/speckit-specify/`. --- src/specify_cli/integrations/bob/__init__.py | 42 ++++++++++++++++ tests/integrations/test_integration_bob.py | 51 ++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 0d1f26dc29..4dcb3f8125 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -254,6 +254,48 @@ 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 = "") -> str: + """Render ``/speckit-`` for skills mode, ``/speckit.`` legacy. + + ``IntegrationBase`` hardcodes ``.``; Bob's skills live at + ``.bob/skills/speckit-/SKILL.md``, so the base rendering names a + command that does not exist in a skills-mode project. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + sep = self.effective_invoke_separator() + invocation = f"/speckit{sep}{stem}" + return f"{invocation} {args}" if args else invocation + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> 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. + """ + 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. diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index 52a25ae2c6..a2cd12ea6c 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -925,3 +925,54 @@ 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``.""" + + 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-/``, 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" From 1e40e7ea514980b0663ee52125ac63b0c6240111 Mon Sep 17 00:00:00 2001 From: temp-droid <82510451+temp-droid@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:18:48 +0200 Subject: [PATCH 2/2] fix(integrations): resolve the command layout from the project being dispatched into `build_command_invocation()` called `effective_invoke_separator()` with no arguments, so `is_skills_mode()` never reached its disk-detection branch (bob/__init__.py:223-228) and always returned the fresh-project default. `dispatch_command()` does receive the workflow project root, but the shared two-argument `build_command_invocation(command_name, args)` contract -- which eight integrations implement -- had nowhere to put it. Two consequences, measured against a real `.bob/commands/speckit.specify.md` project: legacy project, speckit.specify -> /speckit-specify (want /speckit.specify) skills project, speckit.git.commit -> /speckit-git.commit (want /speckit-git-commit) (a) Every existing Bob 1.x install is dispatched a skills-mode invocation naming a command it does not have, so the run fails exactly as it did before dispatch was implemented. (b) Only the `speckit.` prefix was converted, leaving inner dots. The installed skill directory is `speckit-git-commit` -- `SkillsIntegration` derives it with `stem.replace(".", "-")` (base.py:1759) and renders the invocation the same way (base.py:1649) -- so a dotted extension command names a skill that does not exist. The two layouts differ in both the separator and the dot handling: skills flatten every dot, legacy commands keep them. `_build_dispatch_prompt()` carries the project root from `dispatch_command()` into invocation building. The base implementation is the call it replaces, so the other integrations are unaffected; Bob overrides it to resolve the layout from disk, falling back to the working directory, which is where `dispatch_command` runs `bob` when no root is given. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TqDT4qTj3sFBeg9tMRiXqZ --- src/specify_cli/integrations/base.py | 33 ++++- src/specify_cli/integrations/bob/__init__.py | 52 +++++++- tests/integrations/test_integration_bob.py | 128 +++++++++++++++++++ 3 files changed, 205 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e58d231d36..f6235bdc0c 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -336,6 +336,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, @@ -349,11 +372,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. @@ -365,7 +390,7 @@ def dispatch_command( """ import subprocess - 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( diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 4dcb3f8125..447736e6d0 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -254,20 +254,64 @@ 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 = "") -> str: + def build_command_invocation( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + ) -> str: """Render ``/speckit-`` for skills mode, ``/speckit.`` legacy. ``IntegrationBase`` hardcodes ``.``; Bob's skills live at ``.bob/skills/speckit-/SKILL.md``, so the base rendering names a - command that does not exist in a skills-mode project. + 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."):] - sep = self.effective_invoke_separator() - invocation = f"/speckit{sep}{stem}" + 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.`` + 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 ``, 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, diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index a2cd12ea6c..84a4637672 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -930,6 +930,18 @@ def test_post_process_matches_skills_helper(self): 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. @@ -976,3 +988,119 @@ def test_command_invocation_uses_hyphen_in_skills_mode(self): 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..md``. + + The Bob 1.x invocation is ``/speckit.`` 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 ``, 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"