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
8 changes: 4 additions & 4 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -177,11 +177,11 @@
"bob": {
"id": "bob",
"name": "IBM Bob",
"version": "1.0.0",
"description": "IBM Bob IDE integration",
"version": "2.0.0",
Comment thread
davidebibm marked this conversation as resolved.
"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",
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/_invocation_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
{
"agy",
"bob",
Comment thread
davidebibm marked this conversation as resolved.
"claude",
Comment on lines 18 to 22
"copilot",
"cursor-agent",
Expand Down
24 changes: 18 additions & 6 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,16 @@ 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")
)
_skills_mode_attr = getattr(resolved_integration, "_skills_mode", None)
_is_skills = (
isinstance(resolved_integration, _SkillsPersist)
or (callable(_skills_mode_attr) and _skills_mode_attr(integration_parsed_options))
or (not callable(_skills_mode_attr) and bool(_skills_mode_attr))
)
if _is_skills and not _legacy_commands:
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)

Expand Down Expand Up @@ -644,16 +651,20 @@ def init(

from ..integrations.base import SkillsIntegration as _SkillsInt

_is_skills_integration = isinstance(
resolved_integration, _SkillsInt
) or getattr(resolved_integration, "_skills_mode", False)
_skills_mode_attr = getattr(resolved_integration, "_skills_mode", None)
_is_skills_integration = (
isinstance(resolved_integration, _SkillsInt)
or (callable(_skills_mode_attr) and _skills_mode_attr(integration_parsed_options))
or (not callable(_skills_mode_attr) and bool(_skills_mode_attr))
)

codex_skill_mode = selected_ai == "codex" and _is_skills_integration
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
claude_skill_mode = selected_ai == "claude" and _is_skills_integration
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae"
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
cursor_agent_skill_mode = (
selected_ai == "cursor-agent" and _is_skills_integration
)
Expand All @@ -668,6 +679,7 @@ def init(
or kimi_skill_mode
or agy_skill_mode
or trae_skill_mode
or bob_skill_mode
or cursor_agent_skill_mode
or copilot_skill_mode
or devin_skill_mode
Expand Down
12 changes: 10 additions & 2 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def _update_init_options_for_integration(
project_root: Path,
integration: Any,
script_type: str | None = None,
parsed_options: dict[str, Any] | None = None,
) -> None:
"""Update init-options.json to reflect *integration* as the active one.

Expand All @@ -270,7 +271,12 @@ def _update_init_options_for_integration(
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
_skills_mode_attr = getattr(integration, "_skills_mode", None)
if callable(_skills_mode_attr):
is_skills = _skills_mode_attr(parsed_options)
else:
is_skills = bool(_skills_mode_attr) or isinstance(integration, SkillsIntegration)
if is_skills:
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
Comment on lines +274 to 282
Expand Down Expand Up @@ -326,7 +332,9 @@ def _set_default_integration(
) from exc

_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
_update_init_options_for_integration(
project_root, integration, script_type=resolved_script, parsed_options=parsed_options
)


def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
Expand Down
4 changes: 3 additions & 1 deletion src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ def integration_install(
)
_write_integration_json(project_root, new_default, new_installed, settings)
if new_default == integration.key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
_update_init_options_for_integration(
project_root, integration, script_type=selected_script, parsed_options=parsed_options
)
else:
_refresh_init_options_speckit_version(project_root)

Expand Down
4 changes: 3 additions & 1 deletion src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,9 @@ def integration_upgrade(
new_manifest.save()
_write_integration_json(project_root, installed_key, installed_keys, settings)
if installed_key == key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
_update_init_options_for_integration(
project_root, integration, script_type=selected_script, parsed_options=parsed_options
)
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
Expand Down
181 changes: 178 additions & 3 deletions src/specify_cli/integrations/bob/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
"""IBM Bob integration."""
"""IBM Bob integration.

from ..base import MarkdownIntegration
Bob 2.0 uses the ``.bob/skills/speckit-<name>/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",
Expand All @@ -18,3 +53,143 @@ 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",
Comment thread
davidebibm marked this conversation as resolved.
"extension": "/SKILL.md",
}

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


class BobIntegration(IntegrationBase):
"""Integration for IBM Bob IDE.

Default mode: installs ``.bob/skills/speckit-<name>/SKILL.md`` files
(Bob 2.0 skills layout).

Legacy mode (``--legacy-commands``): installs
``.bob/commands/speckit.<name>.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 so that ``effective_invoke_separator()`` returns ``"-"``
for skills-mode invocations.

``registrar_config`` intentionally mirrors the legacy commands layout
(``extension: ".md"``, ``dir: ".bob/commands"``) so that
``CommandRegistrar.AGENT_CONFIGS["bob"]`` follows the same pattern as
Copilot: extension/preset registration writes to ``.bob/commands/``
for legacy-mode projects, and is transparently skipped for skills-mode
projects (``skills_mode_active`` becomes ``True`` because
``ai_skills=True`` and ``extension != "/SKILL.md"``). The
``_BobSkillsHelper`` class owns the SKILL.md layout used exclusively
during ``setup()`` (``specify init`` / ``specify integration install``).
"""

key = "bob"
invoke_separator = "-"
Comment thread
davidebibm marked this conversation as resolved.

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": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".bob/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
# Legacy commands use dot-notation (/speckit.plan); the class-level
# invoke_separator="-" applies to skills-mode invocations only.
"invoke_separator": ".",
}

def _skills_mode(self, parsed_options: dict[str, Any] | None = None) -> bool:
"""True when the instance is configured in skills (default) mode.

Derived from *parsed_options* so that the value is correct both
during ``setup()`` and in fresh-process contexts where ``setup()``
has not been called (e.g. ``specify integration use bob``).
"""
if parsed_options is None:
parsed_options = {}
return not parsed_options.get("legacy_commands", False)
Comment thread
davidebibm marked this conversation as resolved.

@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--legacy-commands",
is_flag=True,
default=False,
Comment thread
davidebibm marked this conversation as resolved.
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-<name>/SKILL.md``).
When ``parsed_options["legacy_commands"]`` is truthy, falls back to
the deprecated ``.bob/commands/speckit.<name>.md`` layout.
"""
parsed_options = parsed_options or {}
if parsed_options.get("legacy_commands"):
_warn_legacy_commands_deprecated()
return self._setup_legacy(project_root, manifest, parsed_options, **opts)
return _BobSkillsHelper().setup(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.<name>.md`` layout."""
helper = _BobMarkdownHelper()
return MarkdownIntegration.setup(helper, project_root, manifest, parsed_options, **opts)
Loading