diff --git a/integrations/catalog.json b/integrations/catalog.json index 601ae0ad92..013d1be8b7 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-23T00:00:00Z", + "updated_at": "2026-07-15T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -177,11 +177,11 @@ "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 skills-based integration", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", - "tags": ["ide", "ibm"] + "tags": ["ide", "ibm", "skills"] }, "trae": { "id": "trae", diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index dd815b8c5d..3ec7f96054 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -498,9 +498,13 @@ def init( } from ..integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist) or getattr( - resolved_integration, "_skills_mode", False - ): + _legacy_commands = bool( + (integration_parsed_options or {}).get("legacy_commands") + ) + if ( + isinstance(resolved_integration, _SkillsPersist) + or getattr(resolved_integration, "_skills_mode", False) + ) and not _legacy_commands: init_opts["ai_skills"] = True save_init_options(project_path, init_opts) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index d1bf051f77..b18a793d9d 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -263,14 +263,13 @@ def _update_init_options_for_integration( load_init_options, save_init_options, ) - from .base import SkillsIntegration opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key opts["speckit_version"] = _get_speckit_version() if script_type: opts["script"] = script_type - if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): + if getattr(integration, "_skills_mode", False): opts["ai_skills"] = True else: opts.pop("ai_skills", None) diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index b953151bd2..a61c6c9782 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -1,9 +1,44 @@ -"""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/*.md`` layout (Bob 1.x) is available as an +opt-in for projects that have not yet migrated, via +``--integration-options "--legacy-commands"``. +Deprecation cycle: + This release: Skills layout is the default; legacy ``.bob/commands/`` + is opt-in via ``--legacy-commands``. + Next cycle: ``--legacy-commands`` flag removed. +""" + +from __future__ import annotations + +import warnings +from pathlib import Path +from typing import Any + +from ..base import IntegrationBase, IntegrationOption, MarkdownIntegration, SkillsIntegration +from ..manifest import IntegrationManifest + + +def _warn_legacy_commands_deprecated() -> None: + """Warn that Bob's legacy markdown layout is being phased out.""" + warnings.warn( + "Bob legacy commands mode (.bob/commands/) is deprecated and will be " + "removed in a future Spec Kit release. Omit --legacy-commands to use " + "the default skills layout (.bob/skills/).", + UserWarning, + stacklevel=3, + ) + + +class _BobMarkdownHelper(MarkdownIntegration): + """Internal helper used when Bob is scaffolded in legacy commands mode. + + Not registered in the integration registry — only used as a delegate + by ``BobIntegration`` when ``--legacy-commands`` is passed. + """ -class BobIntegration(MarkdownIntegration): key = "bob" config = { "name": "IBM Bob", @@ -18,3 +53,122 @@ class BobIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } + + +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`` for skills-mode setup. + """ + + key = "bob" + config = { + "name": "IBM Bob", + "folder": ".bob/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".bob/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + + +class BobIntegration(IntegrationBase): + """Integration for IBM Bob IDE. + + Default mode: installs ``.bob/skills/speckit-/SKILL.md`` files + (Bob 2.0 skills layout). + + Legacy mode (``--legacy-commands``): installs + ``.bob/commands/speckit..md`` files (Bob 1.x layout — deprecated). + + Extends ``IntegrationBase`` directly so that ``isinstance(integration, + SkillsIntegration)`` is ``False`` and consumers such as + ``_update_init_options_for_integration`` and the ``specify init`` + next-steps builder derive the effective mode from ``_skills_mode`` + rather than the class hierarchy. ``invoke_separator = "-"`` is set + explicitly to match the skills-default behaviour expected by + ``CommandRegistrar.AGENT_CONFIGS``. + """ + + key = "bob" + invoke_separator = "-" + + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return the invocation separator for the selected Bob layout.""" + if parsed_options and parsed_options.get("legacy_commands"): + return "." + return "-" + config = { + "name": "IBM Bob", + "folder": ".bob/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".bob/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + + # Set by setup() to reflect the active mode; read by _helpers.py and + # init.py via getattr(integration, "_skills_mode", False). + _skills_mode: bool = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--legacy-commands", + is_flag=True, + default=False, + help=( + "Scaffold commands as legacy .bob/commands/*.md files " + "(Bob 1.x layout, deprecated) instead of the default " + "skills layout" + ), + ), + ] + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Bob commands. + + Default: skills layout (``.bob/skills/speckit-/SKILL.md``). + When ``parsed_options["legacy_commands"]`` is truthy, falls back to + the deprecated ``.bob/commands/speckit..md`` layout. + """ + parsed_options = parsed_options or {} + if parsed_options.get("legacy_commands"): + self._skills_mode = False + _warn_legacy_commands_deprecated() + return self._setup_legacy(project_root, manifest, parsed_options, **opts) + self._skills_mode = True + return SkillsIntegration.setup( + _BobSkillsHelper(), project_root, manifest, parsed_options, **opts + ) + + def _setup_legacy( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Legacy mode: ``.bob/commands/speckit..md`` layout.""" + helper = _BobMarkdownHelper() + return MarkdownIntegration.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 8e0e72f0bd..eb52317f23 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -1,10 +1,297 @@ """Tests for BobIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import os +import warnings +import pytest +import yaml -class TestBobIntegration(MarkdownIntegrationTests): - KEY = "bob" - FOLDER = ".bob/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".bob/commands" +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import 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_not_skills_integration(self): + """BobIntegration extends IntegrationBase directly — not SkillsIntegration. + + It must NOT be an instance of SkillsIntegration so that consumers + such as _update_init_options_for_integration and the init next-steps + builder derive the effective mode from _skills_mode rather than the + class hierarchy. invoke_separator='-' is set explicitly on the class. + """ + from specify_cli.integrations.base import IntegrationBase + bob = get_integration("bob") + assert isinstance(bob, IntegrationBase) + assert not isinstance(bob, SkillsIntegration) + assert bob.invoke_separator == "-" + + def test_key_and_config(self): + bob = get_integration("bob") + assert bob.key == "bob" + assert bob.config["folder"] == ".bob/" + assert bob.config["commands_subdir"] == "skills" + assert bob.registrar_config["dir"] == ".bob/skills" + assert bob.registrar_config["extension"] == "/SKILL.md" + + def test_invoke_separator_is_hyphen(self): + """Class-level invoke_separator must be '-' so CommandRegistrar.AGENT_CONFIGS + generates correct /speckit- refs without calling effective_invoke_separator.""" + bob = get_integration("bob") + assert bob.invoke_separator == "-" + + +class TestBobOptionsFlag: + def test_options_include_legacy_commands_flag(self): + bob = get_integration("bob") + opts = bob.options() + legacy_opts = [o for o in opts if o.name == "--legacy-commands"] + assert len(legacy_opts) == 1 + opt = legacy_opts[0] + assert opt.is_flag is True + # Legacy must be OPT-IN (default=False) — skills are the default + assert opt.default is False + + def test_no_skills_flag(self): + """The old --skills flag must be gone; it has been replaced by the default.""" + bob = get_integration("bob") + opts = bob.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0 + + +class TestBobDefaultSkillsMode: + """Default mode: .bob/skills/speckit-/SKILL.md layout.""" + + def test_setup_creates_skill_files(self, tmp_path): + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + 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_writes_to_correct_directory(self, tmp_path): + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + bob.setup(tmp_path, m) + skills_dir = tmp_path / ".bob" / "skills" + assert skills_dir.is_dir() + + def test_setup_does_not_warn(self, tmp_path): + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bob.setup(tmp_path, m) + assert not any( + "legacy" in str(item.message).lower() for item in caught + ) + + def test_setup_no_commands_dir(self, tmp_path): + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + bob.setup(tmp_path, m) + assert not (tmp_path / ".bob" / "commands").exists() + + def test_skill_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + + 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_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + for f in created: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm + assert "description" in fm + assert "compatibility" in fm + assert "metadata" in fm + assert fm["metadata"]["author"] == "github-spec-kit" + + def test_templates_are_processed(self, tmp_path): + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + for f in created: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" + + def test_command_refs_use_hyphen_separator(self, tmp_path): + """Default skills layout must use /speckit-, not /speckit..""" + bob = get_integration("bob") + m = IntegrationManifest("bob", tmp_path) + created = bob.setup(tmp_path, m) + for f in created: + content = f.read_text(encoding="utf-8") + assert "/speckit." not in content, ( + f"{f.name} contains dot-notation /speckit. reference; " + "skills must use /speckit-" + ) + + def test_all_files_tracked_in_manifest(self, tmp_path): + bob = get_integration("bob") + 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): + bob = get_integration("bob") + 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 TestBobLegacyCommandsMode: + """Legacy opt-in mode: .bob/commands/speckit..md layout.""" + + def test_setup_legacy_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, parsed_options={"legacy_commands": True}) + 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_legacy_warns_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 commands mode"): + bob.setup(tmp_path, m, parsed_options={"legacy_commands": True}) + + def test_setup_legacy_no_skills_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={"legacy_commands": True}) + assert not (tmp_path / ".bob" / "skills").exists() + + def test_setup_legacy_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, parsed_options={"legacy_commands": True}) + 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 + assert "__AGENT__" not in content + assert "{ARGS}" not in content + assert "__SPECKIT_COMMAND_" not in content + + def test_setup_legacy_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={"legacy_commands": 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_setup_legacy_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={"legacy_commands": 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 creates skills by default.""" + + def test_init_default_creates_skills(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" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert not (target / ".bob" / "commands").exists() + + def test_init_default_complete_file_inventory_sh(self, tmp_path): + 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" / "skills" / f"speckit-{cmd}" / "SKILL.md").exists(), ( + f"Missing .bob/skills/speckit-{cmd}/SKILL.md" + ) + + +class TestBobInitFlowLegacy: + """CLI init with --legacy-commands produces .bob/commands/*.md.""" + + def test_init_legacy_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", + "--integration-options", "--legacy-commands", + "--ignore-agent-tools", "--script", "sh", + ]) + assert result.exit_code == 0, f"init --integration bob --legacy-commands failed: {result.output}" + assert (target / ".bob" / "commands" / "speckit.plan.md").exists() + assert not (target / ".bob" / "skills").exists()