Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b01f1f6
feat: update Bob integration to skills-based layout for Bob 2.0
davidebibm Jul 8, 2026
e893245
Merge remote-tracking branch 'origin/main' into feat/update-bob-skill…
davidebibm Jul 13, 2026
dea23f0
PR comments fix: keep old Bob 1 commands till next release
davidebibm Jul 13, 2026
1c3d1cf
Copilot suggested change
davidebibm Jul 14, 2026
674a8db
feat(bob): address copilot comments, make skills layout default, demo…
davidebibm Jul 14, 2026
71ba3b9
fix(bob): honor legacy_commands in ai_skills persistence and add bob …
davidebibm Jul 14, 2026
d5e0e95
Copilot suggestion
davidebibm Jul 14, 2026
3679697
fix(bob): extend IntegrationBase directly to avoid false isinstance(S…
davidebibm Jul 14, 2026
64c0b9d
fix(lint): remove unused SkillsIntegration import from _helpers.py
davidebibm Jul 14, 2026
6ab8d61
Copilot suggested change
davidebibm Jul 14, 2026
7edf5b5
feat(bob): add bob skills integration with registrar-based mode detec…
davidebibm Jul 15, 2026
68bb876
address 3 comments from copilot
davidebibm Jul 15, 2026
f47ab61
feat(bob): update registrar config to use legacy commands layout
davidebibm Jul 15, 2026
d851289
fix lint
davidebibm Jul 15, 2026
73725c5
Suggested fix from Copilot
davidebibm Jul 16, 2026
230b287
Merge branch 'main' into feat/update-bob-skills-integration
davidebibm Jul 16, 2026
c6edc66
fix pr comment
davidebibm Jul 16, 2026
ef2cb53
fix pr comment
davidebibm Jul 16, 2026
cad76d3
fix pr comment
davidebibm Jul 16, 2026
5a8cb61
refactor(bob): resolve skills mode via base-class hooks + fix command…
mnriem Jul 16, 2026
04278b2
Merge remote-tracking branch 'origin/main' into mnriem-review-bob-ski…
mnriem Jul 16, 2026
d929bfb
fix(bob,copilot): address review — preserve legacy layout, dual-mode …
mnriem Jul 16, 2026
8a162b3
fix(bob): resolve command-ref separator with project-aware mode befor…
mnriem Jul 16, 2026
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
6 changes: 3 additions & 3 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
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 thread
davidebibm marked this conversation as resolved.
"copilot",
"cursor-agent",
Expand Down
25 changes: 22 additions & 3 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,23 @@ def register_commands(
is_cline_ext = agent_name == "cline" and source_id != "core"
source_root = source_dir.resolve()

# Resolve the command-reference separator once for this agent/project.
# Dual-layout agents (e.g. Bob) use different separators for their
# skills vs command layouts, so we ask the integration to map the
# project's persisted skills state to the correct separator rather than
# relying on the single static AGENT_CONFIGS value.
_sep = agent_config.get("invoke_separator", ".")
try:
from specify_cli.integrations import get_integration # noqa: PLC0415

_integ = get_integration(agent_name)
if _integ is not None:
_sep = _integ.invoke_separator_for_mode(
is_ai_skills_enabled(load_init_options(project_root))
)
Comment on lines +651 to +653

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mnriem is it valid?

except Exception:
pass

for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
Expand Down Expand Up @@ -709,13 +726,15 @@ def register_commands(
)

# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
# The separator is sourced from agent_config (populated by _build_agent_configs,
# which propagates each integration's invoke_separator class attribute).
# For dual-layout agents (e.g. Bob) the separator differs between the
# skills and command layouts, so a single static AGENT_CONFIGS value is
# insufficient. Resolve it from the integration using the project's
# persisted skills state; single-layout agents fall back to the static
# AGENT_CONFIGS value unchanged (invoke_separator_for_mode default).
# Deferred import of IntegrationBase avoids a circular import at module load
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415

_sep = agent_config.get("invoke_separator", ".")
body = IntegrationBase.resolve_command_refs(body, _sep)

output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
Expand Down
24 changes: 14 additions & 10 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ def init(
script_type=selected_script,
raw_options=integration_options,
parsed_options=integration_parsed_options or None,
project_root=project_path,
)
_write_integration_json(
project_path,
Expand All @@ -478,7 +479,7 @@ def init(
tracker=tracker,
force=force,
invoke_separator=resolved_integration.effective_invoke_separator(
integration_parsed_options
integration_parsed_options, project_root=project_path
),
)
tracker.complete(
Expand Down Expand Up @@ -532,10 +533,8 @@ def init(
"feature_numbering": "sequential",
"speckit_version": get_speckit_version(),
}
from ..integrations.base import SkillsIntegration as _SkillsPersist

if isinstance(resolved_integration, _SkillsPersist) or getattr(
resolved_integration, "_skills_mode", False
if resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
Expand Down Expand Up @@ -683,11 +682,9 @@ def init(
steps_lines.append("1. You're already in the project directory!")
step_num = 2

from ..integrations.base import SkillsIntegration as _SkillsInt

_is_skills_integration = isinstance(
resolved_integration, _SkillsInt
) or getattr(resolved_integration, "_skills_mode", False)
_is_skills_integration = resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
)

codex_skill_mode = selected_ai == "codex" and _is_skills_integration
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
Expand All @@ -703,6 +700,7 @@ def init(
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
native_skill_mode = (
codex_skill_mode
or zcode_skill_mode
Expand All @@ -715,6 +713,7 @@ def init(
or devin_skill_mode
or zed_skill_mode
or grok_skill_mode
or bob_skill_mode
)

if codex_skill_mode:
Expand Down Expand Up @@ -752,6 +751,11 @@ def init(
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
)
step_num += 1
if bob_skill_mode:
steps_lines.append(
f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]"
)
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"

from .._invocation_style import (
Expand Down
12 changes: 8 additions & 4 deletions src/specify_cli/integration_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def with_integration_setting(
script_type: str | None = None,
raw_options: str | None = None,
parsed_options: dict[str, Any] | None = None,
project_root: Any = None,
) -> dict[str, dict[str, Any]]:
"""Return integration settings with *key* updated."""
settings = integration_settings(state)
Expand All @@ -63,7 +64,9 @@ def with_integration_setting(
elif raw_options is not None:
current.pop("parsed_options", None)

current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
current["invoke_separator"] = integration.effective_invoke_separator(
parsed_options, project_root
)
settings[key] = current
return settings

Expand All @@ -73,10 +76,11 @@ def invoke_separator_for_integration(
state: dict[str, Any],
key: str,
parsed_options: dict[str, Any] | None = None,
project_root: Any = None,
) -> str:
"""Resolve the invocation separator for stored/default integration state."""
if parsed_options is not None:
return integration.effective_invoke_separator(parsed_options)
return integration.effective_invoke_separator(parsed_options, project_root)

setting = integration_setting(state, key)
stored_separator = setting.get("invoke_separator")
Expand All @@ -85,6 +89,6 @@ def invoke_separator_for_integration(

stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
return integration.effective_invoke_separator(stored_parsed)
return integration.effective_invoke_separator(stored_parsed, project_root)

return integration.effective_invoke_separator(None)
return integration.effective_invoke_separator(None, project_root)
23 changes: 10 additions & 13 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,24 +272,19 @@ 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
# Skills mode is either intrinsic (SkillsIntegration), set on the instance
# during setup() (_skills_mode), or requested via parsed options (e.g.
# Copilot's --skills, persisted as parsed_options["skills"]). The latter is
# the only signal available on the `use` path, where no setup() runs and a
# fresh integration instance has _skills_mode == False (issue #3550).
skills_mode = (
isinstance(integration, SkillsIntegration)
or getattr(integration, "_skills_mode", False)
or bool((parsed_options or {}).get("skills"))
)
if skills_mode:
# Whether skills mode is active is owned by each integration via the
# ``is_skills_mode`` hook (base default honors ``--skills``;
# SkillsIntegration returns True; skills-first integrations with a legacy
# opt-out such as Bob override it). This keeps shared code free of
# ``isinstance`` / ``_skills_mode`` probing. Passing parsed_options lets it
# work on the ``use``/``install`` path where no setup() runs (issue #3550).
if integration.is_skills_mode(parsed_options, project_root=project_root):
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
Expand Down Expand Up @@ -325,6 +320,7 @@ def _set_default_integration(
script_type=resolved_script,
raw_options=raw_options,
parsed_options=parsed_options,
project_root=project_root,
)

if refresh_templates:
Expand All @@ -333,7 +329,8 @@ def _set_default_integration(
project_root,
resolved_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
force=refresh_templates_force,
refresh_managed=True,
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ def integration_install(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
)
if os.name != "nt":
Expand Down Expand Up @@ -155,10 +156,16 @@ def integration_install(
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
project_root=project_root,
)
_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
17 changes: 13 additions & 4 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ def integration_switch(
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options
target_integration, current, target, parsed_options,
project_root=project_root,
),
refresh_hint=(
"To overwrite customizations, re-run with "
Expand Down Expand Up @@ -415,7 +416,8 @@ def integration_upgrade(
selected_script,
force=force,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
)
if os.name != "nt":
Expand All @@ -441,14 +443,16 @@ def integration_upgrade(
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
project_root=project_root,
)
if installed_key == key:
try:
_install_shared_infra(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
force=force,
refresh_managed=True,
Expand All @@ -463,7 +467,12 @@ 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
63 changes: 60 additions & 3 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,66 @@ def options(cls) -> list[IntegrationOption]:
return []

def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""Return the invoke separator for the given options.

Subclasses whose separator depends on runtime options (e.g.
Copilot in ``--skills`` mode) should override this method.
The default implementation ignores *parsed_options* and returns
the class-level ``invoke_separator``.
The default implementation ignores *parsed_options* and
*project_root* and returns the class-level ``invoke_separator``.
"""
return self.invoke_separator

def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Command-ref separator given the project's *resolved* skills state.

Registration paths (extension / preset command rendering) have no CLI
``parsed_options`` — only the persisted ``ai_skills`` flag — so they
resolve the command-reference separator through this hook rather than
the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which
cannot represent an agent whose separator differs between its skills
and command layouts.

The default is mode-independent and returns exactly what
``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the
``registrar_config`` override if present, else the class-level
``invoke_separator``), so single-layout agents are unaffected.
Dual-mode agents whose separator depends on the layout (e.g. Bob:
``-`` for skills, ``.`` for legacy commands) override this.
"""
cfg = self.registrar_config or {}
return cfg.get("invoke_separator", self.invoke_separator)

def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Return whether this integration scaffolds skills for these options.

This is the single, well-defined hook the shared init/install/upgrade
machinery consults to decide whether to persist ``ai_skills=True`` and
render skill invocations. It replaces ad-hoc ``isinstance`` /
``getattr(self, "_skills_mode", ...)`` probing so an integration's
internal representation never has to leak into shared dispatch code.

*project_root* is optional context for the ``use`` / ``switch`` /
``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be
empty: dual-mode integrations can consult the already-installed
on-disk layout to avoid silently migrating an existing project to a
different mode. The default ignores it.

The default (command-first integrations, e.g. Copilot's default
layout) is skills mode only when ``--skills`` was requested.
``SkillsIntegration`` overrides this to return ``True`` by default;
skills-first integrations that expose a legacy opt-out (e.g. Bob)
override it to honor their own flag.
"""
return bool((parsed_options or {}).get("skills"))

def build_exec_args(
self,
prompt: str,
Expand Down Expand Up @@ -1376,6 +1425,14 @@ class SkillsIntegration(IntegrationBase):

invoke_separator = "-"

def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Skills-native integrations scaffold skills unconditionally."""
return True

def build_exec_args(
self,
prompt: str,
Expand Down
Loading