From b01f1f6c978ddafd572839339437828c87d4782b Mon Sep 17 00:00:00 2001 From: Davide Barletta Date: Wed, 8 Jul 2026 20:26:03 +0200 Subject: [PATCH 1/2] feat: update Bob integration to skills-based layout for Bob 2.0 Bob 2.0 replaces the command-based workflow (.bob/commands/*.md) with a skills-based layout (.bob/skills/speckit-/SKILL.md), matching the pattern used by Claude Code, Codex, and other skills-first agents. - Switch BobIntegration from MarkdownIntegration to SkillsIntegration - Update folder/dir from .bob/commands to .bob/skills - Change extension from .md to /SKILL.md (skills layout) - Add --skills option (default: True) consistent with Codex pattern - Update tests to inherit from SkillsIntegrationTests (28 tests pass) - Bump catalog entry to version 2.0.0 with updated description Assisted-by: IBM Bob (model: claude-sonnet-4-5, autonomous) --- integrations/catalog.json | 4 +-- src/specify_cli/integrations/bob/__init__.py | 31 ++++++++++++++++---- tests/integrations/test_integration_bob.py | 8 ++--- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/integrations/catalog.json b/integrations/catalog.json index 601ae0ad92..221ba15d9d 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -177,8 +177,8 @@ "bob": { "id": "bob", "name": "IBM Bob", - "version": "1.0.0", - "description": "IBM Bob IDE integration", + "version": "2.0.0", + "description": "IBM Bob 2.0 IDE integration", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", "tags": ["ide", "ibm"] diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index b953151bd2..d4c75ef425 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -1,20 +1,39 @@ -"""IBM Bob integration.""" +"""IBM Bob integration. -from ..base import MarkdownIntegration +Bob 2.0 uses the ``.bob/skills/speckit-/SKILL.md`` layout. +The legacy ``.bob/commands/`` layout (Bob 1.x) is no longer supported. +""" +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class BobIntegration(SkillsIntegration): + """Integration for IBM Bob IDE.""" -class BobIntegration(MarkdownIntegration): key = "bob" config = { "name": "IBM Bob", "folder": ".bob/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".bob/commands", + "dir": ".bob/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Bob 2.0)", + ), + ] diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index 8e0e72f0bd..a5cf8f70d4 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -1,10 +1,10 @@ """Tests for BobIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +from .test_integration_base_skills import SkillsIntegrationTests -class TestBobIntegration(MarkdownIntegrationTests): +class TestBobIntegration(SkillsIntegrationTests): KEY = "bob" FOLDER = ".bob/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".bob/commands" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".bob/skills" From dea23f06be96d5edacab65f4bc7490cb445ad3e3 Mon Sep 17 00:00:00 2001 From: Davide Barletta Date: Mon, 13 Jul 2026 16:59:59 +0200 Subject: [PATCH 2/2] PR comments fix: keep old Bob 1 commands till next release --- src/specify_cli/integrations/bob/__init__.py | 129 ++++++++- tests/integrations/test_integration_bob.py | 273 ++++++++++++++++++- 2 files changed, 389 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index d4c75ef425..c7afe926e6 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -1,16 +1,43 @@ """IBM Bob integration. -Bob 2.0 uses the ``.bob/skills/speckit-/SKILL.md`` layout. -The legacy ``.bob/commands/`` layout (Bob 1.x) is no longer supported. +Bob 2.0 supports the ``.bob/skills/speckit-/SKILL.md`` layout. +The legacy ``.bob/commands/*.md`` layout (Bob 1.x) remains the default +for this release and will be deprecated in a future Spec Kit release. + +Deprecation cycle: + This release: Markdown layout is default; skills layout is opt-in via + ``--integration-options "--skills"``. + Next cycle: Skills layout becomes default; markdown remains opt-in. + Cycle after: Markdown layout removed. """ from __future__ import annotations -from ..base import IntegrationOption, SkillsIntegration +import warnings +from pathlib import Path +from typing import Any + +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +def _warn_legacy_markdown_default() -> None: + """Warn that Bob's default markdown scaffold is being phased out.""" + warnings.warn( + "Bob legacy markdown mode (.bob/commands/) is deprecated and will stop " + "being the default in a future Spec Kit release; pass " + '--integration-options "--skills" to opt in to Bob skills mode now.', + UserWarning, + stacklevel=3, + ) -class BobIntegration(SkillsIntegration): - """Integration for IBM Bob IDE.""" +class _BobSkillsHelper(SkillsIntegration): + """Internal helper used when Bob is scaffolded in skills mode. + + Not registered in the integration registry — only used as a delegate + by ``BobIntegration`` when ``--skills`` is passed. + """ key = "bob" config = { @@ -27,13 +54,101 @@ class BobIntegration(SkillsIntegration): "extension": "/SKILL.md", } + +class BobIntegration(IntegrationBase): + """Integration for IBM Bob IDE. + + Default mode: installs ``.bob/commands/speckit..md`` files + (Bob 1.x markdown layout — legacy, will be deprecated). + + Skills mode (``--skills``): installs + ``.bob/skills/speckit-/SKILL.md`` files (Bob 2.0 layout). + """ + + key = "bob" + config = { + "name": "IBM Bob", + "folder": ".bob/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".bob/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + + # Mutable flag set by setup() — indicates the active scaffolding mode. + _skills_mode: bool = False + + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return ``"-"`` when skills mode is requested, ``"."`` otherwise.""" + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator + @classmethod def options(cls) -> list[IntegrationOption]: return [ IntegrationOption( "--skills", is_flag=True, - default=True, - help="Install as agent skills (default for Bob 2.0)", + default=False, + help=( + "Scaffold commands as agent skills " + "(.bob/skills/speckit-/SKILL.md) instead of " + "the legacy .bob/commands/*.md layout" + ), ), ] + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Bob commands. + + When ``parsed_options["skills"]`` is truthy, delegates to skills + scaffolding (``.bob/skills/speckit-/SKILL.md``). + Otherwise uses the default ``.bob/commands/speckit..md`` layout + and emits a deprecation warning. + """ + parsed_options = parsed_options or {} + self._skills_mode = bool(parsed_options.get("skills")) + if self._skills_mode: + return self._setup_skills(project_root, manifest, parsed_options, **opts) + if "skills" not in parsed_options: + _warn_legacy_markdown_default() + return self._setup_default(project_root, manifest, parsed_options, **opts) + + def _setup_default( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Default mode: ``.bob/commands/speckit..md`` layout.""" + from ..base import MarkdownIntegration + + return MarkdownIntegration.setup(self, project_root, manifest, parsed_options, **opts) + + def _setup_skills( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Skills mode: delegate to ``_BobSkillsHelper``.""" + helper = _BobSkillsHelper() + return SkillsIntegration.setup(helper, project_root, manifest, parsed_options, **opts) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index a5cf8f70d4..fc1a91c5eb 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -1,10 +1,271 @@ """Tests for BobIntegration.""" -from .test_integration_base_skills import SkillsIntegrationTests +import os +import warnings +import pytest -class TestBobIntegration(SkillsIntegrationTests): - KEY = "bob" - FOLDER = ".bob/" - COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".bob/skills" +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import IntegrationBase, SkillsIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestBobIntegrationRegistration: + def test_registered(self): + assert "bob" in INTEGRATION_REGISTRY + assert get_integration("bob") is not None + + def test_is_integration_base(self): + assert isinstance(get_integration("bob"), IntegrationBase) + + def test_not_skills_integration_directly(self): + """BobIntegration is not itself a SkillsIntegration — it's dual-mode.""" + from specify_cli.integrations.bob import BobIntegration + assert not isinstance(BobIntegration(), SkillsIntegration) + + def test_key_and_config(self): + bob = get_integration("bob") + assert bob.key == "bob" + assert bob.config["folder"] == ".bob/" + # Default mode is commands (markdown) + assert bob.config["commands_subdir"] == "commands" + assert bob.registrar_config["dir"] == ".bob/commands" + assert bob.registrar_config["extension"] == ".md" + + +class TestBobOptionsFlag: + def test_options_include_skills_flag(self): + bob = get_integration("bob") + opts = bob.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 1 + opt = skills_opts[0] + assert opt.is_flag is True + # Skills must be OPT-IN (default=False) — not the default + assert opt.default is False + + +class TestBobDefaultMarkdownMode: + """Default mode: .bob/commands/speckit..md layout.""" + + def test_setup_creates_markdown_files(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + assert len(created) > 0 + for f in created: + assert f.exists() + assert f.suffix == ".md" + assert f.name.startswith("speckit.") + assert f.parent == tmp_path / ".bob" / "commands" + + def test_setup_warns_legacy_markdown_is_deprecated(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + + with pytest.warns(UserWarning, match="Bob legacy markdown mode"): + bob.setup(tmp_path, m) + + def test_setup_explicit_skills_false_no_warn(self, tmp_path): + """Explicitly passing skills=False is a conscious choice — no warning. + + The warning fires only when ``skills`` is absent from parsed_options + (i.e. the user gave no opinion), matching the Copilot pattern. + """ + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bob.setup(tmp_path, m, parsed_options={"skills": False}) + + assert not any( + "Bob legacy markdown mode" in str(item.message) + for item in caught + ) + + def test_setup_no_skills_dirs_created(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + bob.setup(tmp_path, m) + skills_dir = tmp_path / ".bob" / "skills" + assert not skills_dir.exists() + + def test_templates_are_processed(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + bob.setup(tmp_path, m) + commands_dir = tmp_path / ".bob" / "commands" + for md_file in commands_dir.glob("speckit.*.md"): + content = md_file.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{md_file.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{md_file.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{md_file.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{md_file.name} has unprocessed __SPECKIT_COMMAND_*__" + + def test_all_files_tracked_in_manifest(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = bob.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + +class TestBobSkillsMode: + """Skills mode: .bob/skills/speckit-/SKILL.md layout.""" + + def test_setup_skills_creates_skill_files(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m, parsed_options={"skills": True}) + assert len(created) > 0 + for f in created: + assert f.exists() + assert f.name == "SKILL.md" + assert f.parent.name.startswith("speckit-") + + def test_setup_skills_does_not_warn_about_legacy(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bob.setup(tmp_path, m, parsed_options={"skills": True}) + + assert not any( + "Bob legacy markdown mode" in str(item.message) + for item in caught + ) + + def test_setup_skills_creates_correct_directory(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + bob.setup(tmp_path, m, parsed_options={"skills": True}) + skills_dir = tmp_path / ".bob" / "skills" + assert skills_dir.is_dir() + + def test_setup_skills_no_commands_dir(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + bob.setup(tmp_path, m, parsed_options={"skills": True}) + commands_dir = tmp_path / ".bob" / "commands" + assert not commands_dir.exists() + + def test_setup_skills_has_expected_commands(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m, parsed_options={"skills": True}) + + expected_commands = { + "analyze", "clarify", "constitution", "converge", "implement", + "plan", "checklist", "specify", "tasks", "taskstoissues", + } + actual_commands = {f.parent.name.removeprefix("speckit-") for f in created} + assert actual_commands == expected_commands + + def test_setup_skills_all_files_tracked(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m, parsed_options={"skills": True}) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_skills_uninstall_roundtrip(self, tmp_path): + from specify_cli.integrations.bob import BobIntegration + bob = BobIntegration() + m = IntegrationManifest("bob", tmp_path) + created = bob.install(tmp_path, m, parsed_options={"skills": True}) + assert len(created) > 0 + m.save() + removed, skipped = bob.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + +class TestBobInitFlowDefault: + """CLI init integration tests for default (markdown) mode.""" + + def test_init_default_creates_commands(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "test-proj" + result = CliRunner().invoke(app, [ + "init", str(target), "--integration", "bob", + "--ignore-agent-tools", "--script", "sh", + ]) + assert result.exit_code == 0, f"init --integration bob failed: {result.output}" + assert (target / ".bob" / "commands" / "speckit.plan.md").exists() + assert not (target / ".bob" / "skills").exists() + + def test_init_default_complete_file_inventory_sh(self, tmp_path): + """Default init creates .bob/commands/*.md files.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-sh-bob" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "bob", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + + commands = [ + "analyze", "clarify", "constitution", "converge", "implement", + "plan", "checklist", "specify", "tasks", "taskstoissues", + ] + for cmd in commands: + assert (project / ".bob" / "commands" / f"speckit.{cmd}.md").exists(), ( + f"Missing .bob/commands/speckit.{cmd}.md" + ) + + +class TestBobInitFlowSkills: + """CLI init integration tests for skills mode.""" + + def test_init_skills_creates_skill_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "test-proj" + result = CliRunner().invoke(app, [ + "init", str(target), "--integration", "bob", + "--integration-options", "--skills", + "--ignore-agent-tools", "--script", "sh", + ]) + assert result.exit_code == 0, f"init --integration bob --skills failed: {result.output}" + assert (target / ".bob" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert not (target / ".bob" / "commands").exists()