Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
eeaf85f
fix(bob): scope persisted ai_skills flag to active agent when resolvi…
mnriem Jul 17, 2026
21922f8
fix(bob): detect Spec Kit layout from managed artifacts, not any skil…
mnriem Jul 17, 2026
13db5f3
fix(bob): apply managed-artifact detection on upgrade + consistent sk…
mnriem Jul 17, 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"]
Comment on lines +180 to +184
},
"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
51 changes: 48 additions & 3 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,49 @@ 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:
_opts = load_init_options(project_root)
# The persisted ``ai_skills`` flag describes only the active
# integration (``opts["ai"]``). ``register_commands_for_all_agents``
# calls this for every detected agent, so trusting that flag for a
# different agent would, e.g., render a legacy ``.bob/commands``
# project's refs as ``/speckit-*`` just because Copilot is active in
# skills mode. Only consult the flag for the agent it describes;
# otherwise resolve this agent's separator from its own project-aware
# detection.
if _opts.get("ai") == agent_name:
_sep = _integ.invoke_separator_for_mode(
is_ai_skills_enabled(_opts)
)
Comment on lines +660 to +663
else:
# Inactive agent: the reference separator must match the
# layout THIS registrar writes into — determined by the
# agent's static output config (its command dir + file
# extension), not by unrelated sibling directories on disk.
# A skill-scaffold output ("/SKILL.md") uses the skills
# separator; a command-layout output uses the command
# separator. This avoids mislabeling a Bob command-layout
# write as skills just because an unrelated .bob/skills
# directory happens to exist.
registrar_writes_skills = (
agent_config.get("extension") == "/SKILL.md"
)
_sep = _integ.invoke_separator_for_mode(
registrar_writes_skills
)
except Exception:
pass

for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
Expand Down Expand Up @@ -709,13 +752,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
Loading