Skip to content

Commit ba6655c

Browse files
mnriemCopilot
andcommitted
feat(bob): add --skills migration opt-in; fix separator + manifest loss (review #3415)
Address review #3415 (4724160183): - Comment 1: Add an explicit `--skills` opt-in to BobIntegration. It forces the skills layout over on-disk auto-detection, giving legacy Bob 1.x installs a supported migration path (`integration upgrade bob --integration-options="--skills"`). `--skills` and `--legacy-commands` are mutually exclusive (clean exit-1 error). - Comment 2: In CommandRegistrar.register_commands, derive the command-ref separator from the output layout (agent_config["extension"]) for the active agent too, not the persisted ai_skills flag. A command-layout file (.bob/commands/*.md, .github/agents/*.agent.md) always renders /speckit.*; only a /SKILL.md scaffold uses /speckit-*. Dual-layout agents (Bob, Copilot) write skills via their own setup()/skills path, so register_commands only ever emits their command-layout files. - Comment 3: Update docs/reference/integrations.md Bob entry to document the skills-based default (.bob/skills/), the deprecated --legacy-commands opt-out, and the --skills migration path. Also fix a latent manifest-loss bug surfaced by the migration path: the upgrade Phase 2 stale-file cleanup built a throwaway manifest sharing the integration key and called uninstall(), which always deleted {key}.manifest.json. Any layout-shrinking upgrade (e.g. legacy->skills) thus wiped the freshly-saved manifest, leaving the project untracked and un-upgradeable. uninstall() now takes remove_manifest (default True); the stale-cleanup pass passes False. Adds regression tests for the --skills opt-in, mutual exclusion, corrected active-agent separator, remove_manifest=False, and an end-to-end legacy->skills migration that verifies the manifest survives and the project remains upgradeable. Full suite: 4555 passed, 5 skipped. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf
1 parent 1597973 commit ba6655c

8 files changed

Lines changed: 266 additions & 59 deletions

File tree

docs/reference/integrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
2222
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
2323
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
2424
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
25-
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
25+
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. |
2626
| [Junie](https://junie.jetbrains.com/) | `junie` | |
2727
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
2828
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |

src/specify_cli/agents.py

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -637,46 +637,34 @@ def register_commands(
637637
is_cline_ext = agent_name == "cline" and source_id != "core"
638638
source_root = source_dir.resolve()
639639

640-
# Resolve the command-reference separator once for this agent/project.
641-
# Dual-layout agents (e.g. Bob) use different separators for their
642-
# skills vs command layouts, so we ask the integration to map the
643-
# project's persisted skills state to the correct separator rather than
644-
# relying on the single static AGENT_CONFIGS value.
640+
# Resolve the command-reference separator for the file THIS registrar
641+
# is about to write. The separator must match the *output layout* the
642+
# registrar produces for this agent — not the project's persisted
643+
# ``ai_skills`` flag, and not unrelated sibling directories on disk. A
644+
# skill scaffold ("/SKILL.md") uses the skills separator; any
645+
# command-layout output (".md", ".agent.md", ".toml", …) uses the
646+
# command separator.
647+
#
648+
# This holds for the *active* agent too. Dual-layout agents (Bob,
649+
# Copilot) write their skills via their own setup()/skills path, so
650+
# ``register_commands`` only ever emits their command-layout files.
651+
# Deriving the separator from ``ai_skills`` would render such a
652+
# ``.bob/commands/*.md`` (or ``.github/agents/*.agent.md``) file with
653+
# ``/speckit-*`` whenever that agent is active in skills mode — even
654+
# though a command-layout file must use ``/speckit.*``. Deriving it
655+
# from the agent's static output config avoids that mismatch and stays
656+
# correct when a stale ``.bob/skills`` directory coexists with
657+
# ``.bob/commands``.
645658
_sep = agent_config.get("invoke_separator", ".")
646659
try:
647660
from specify_cli.integrations import get_integration # noqa: PLC0415
648661

649662
_integ = get_integration(agent_name)
650663
if _integ is not None:
651-
_opts = load_init_options(project_root)
652-
# The persisted ``ai_skills`` flag describes only the active
653-
# integration (``opts["ai"]``). ``register_commands_for_all_agents``
654-
# calls this for every detected agent, so trusting that flag for a
655-
# different agent would, e.g., render a legacy ``.bob/commands``
656-
# project's refs as ``/speckit-*`` just because Copilot is active in
657-
# skills mode. Only consult the flag for the agent it describes;
658-
# otherwise resolve this agent's separator from its own project-aware
659-
# detection.
660-
if _opts.get("ai") == agent_name:
661-
_sep = _integ.invoke_separator_for_mode(
662-
is_ai_skills_enabled(_opts)
663-
)
664-
else:
665-
# Inactive agent: the reference separator must match the
666-
# layout THIS registrar writes into — determined by the
667-
# agent's static output config (its command dir + file
668-
# extension), not by unrelated sibling directories on disk.
669-
# A skill-scaffold output ("/SKILL.md") uses the skills
670-
# separator; a command-layout output uses the command
671-
# separator. This avoids mislabeling a Bob command-layout
672-
# write as skills just because an unrelated .bob/skills
673-
# directory happens to exist.
674-
registrar_writes_skills = (
675-
agent_config.get("extension") == "/SKILL.md"
676-
)
677-
_sep = _integ.invoke_separator_for_mode(
678-
registrar_writes_skills
679-
)
664+
registrar_writes_skills = (
665+
agent_config.get("extension") == "/SKILL.md"
666+
)
667+
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
680668
except Exception:
681669
pass
682670

src/specify_cli/integrations/_migrate_commands.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,15 @@ def integration_upgrade(
496496
if stale_keys:
497497
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
498498
stale_manifest._files = {k: old_files[k] for k in stale_keys}
499-
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
499+
# remove_manifest=False: this throwaway manifest shares ``key`` with the
500+
# real one just saved above (new_manifest.save()). Letting uninstall()
501+
# delete ``{key}.manifest.json`` would wipe the freshly-written manifest
502+
# whenever an upgrade shrinks the tracked file set (e.g. Bob migrating
503+
# from the legacy commands layout to skills), leaving the integration
504+
# untracked and un-upgradeable.
505+
stale_removed, _ = stale_manifest.uninstall(
506+
project_root, force=True, remove_manifest=False
507+
)
500508
if stale_removed:
501509
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
502510

src/specify_cli/integrations/bob/__init__.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from pathlib import Path
2424
from typing import Any
2525

26+
import typer
27+
2628
from ..base import (
2729
IntegrationBase,
2830
IntegrationOption,
@@ -32,6 +34,24 @@
3234
from ..manifest import IntegrationManifest
3335

3436

37+
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
38+
"""Reject ``--skills`` and ``--legacy-commands`` used together.
39+
40+
The two flags select opposite layouts, so combining them is ambiguous.
41+
Fail fast with the same clean exit-1 UX as other bad-option paths rather
42+
than silently letting one win.
43+
"""
44+
opts = parsed_options or {}
45+
if opts.get("skills") and opts.get("legacy_commands"):
46+
from ..._console import console
47+
48+
console.print(
49+
"[red]Error:[/red] --skills and --legacy-commands are mutually "
50+
"exclusive; pass only one."
51+
)
52+
raise typer.Exit(1)
53+
54+
3555
def _warn_legacy_commands_deprecated() -> None:
3656
warnings.warn(
3757
"Bob legacy commands mode (.bob/commands/) is deprecated and will be "
@@ -132,6 +152,17 @@ class BobIntegration(IntegrationBase):
132152
@classmethod
133153
def options(cls) -> list[IntegrationOption]:
134154
return [
155+
IntegrationOption(
156+
"--skills",
157+
is_flag=True,
158+
default=False,
159+
help=(
160+
"Force the default skills layout (.bob/skills/), overriding "
161+
"on-disk auto-detection. Use this to migrate a legacy "
162+
"commands install to skills, e.g. "
163+
"`integration upgrade bob --integration-options \"--skills\"`"
164+
),
165+
),
135166
IntegrationOption(
136167
"--legacy-commands",
137168
is_flag=True,
@@ -151,23 +182,39 @@ def is_skills_mode(
151182
) -> bool:
152183
"""Bob is skills-first; ``--legacy-commands`` opts out.
153184
154-
On ``use`` / ``switch`` / ``upgrade`` no ``setup()`` runs and
155-
*parsed_options* is typically empty (existing Bob 1.x installs never
156-
stored ``legacy_commands``). Defaulting to skills there would rewrite
185+
Precedence:
186+
187+
1. Explicit ``--skills`` wins — it *forces* skills mode regardless of
188+
what is already on disk. This is the supported migration / opt-in
189+
path: ``integration upgrade bob --integration-options "--skills"``
190+
converts a legacy commands install to the skills layout (setup()
191+
scaffolds ``.bob/skills`` and the upgrade's stale-file pass removes
192+
the old ``.bob/commands`` files).
193+
2. Explicit ``--legacy-commands`` opts out to the Bob 1.x layout.
194+
3. Otherwise, when a *project_root* is supplied, the layout is inferred
195+
from **managed Spec Kit artifacts** (see below).
196+
4. A fresh project (no managed artifacts, no flags) defaults to skills.
197+
198+
The disk-detection fallback exists because on ``use`` / ``switch`` /
199+
``upgrade`` (without ``--skills``) no ``setup()`` runs and
200+
*parsed_options* is typically empty — existing Bob 1.x installs never
201+
stored ``legacy_commands``. Defaulting to skills there would rewrite
157202
such a project's ``ai_skills`` flag to ``True`` even though it still
158-
only contains a command layout — silently switching its extension /
159-
command-reference handling to the skills layout.
160-
161-
So when a *project_root* is supplied, the Spec Kit layout is inferred
162-
from **managed Spec Kit artifacts**, not from the mere presence of a
163-
``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
164-
``.bob/skills/`` while their Spec Kit commands still live in
203+
only contains a command layout, silently switching its extension /
204+
command-reference handling. So the layout is inferred from managed
205+
Spec Kit artifacts, not the mere presence of a ``.bob/skills/``
206+
directory: a user may keep unrelated Bob 2 skills in ``.bob/skills/``
207+
while their Spec Kit commands still live in
165208
``.bob/commands/speckit.*.md``. We therefore treat the project as
166209
legacy (command) mode only when managed Spec Kit command files exist
167-
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. A fresh
168-
project (no managed artifacts yet) still defaults to skills.
210+
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. Passing
211+
``--skills`` overrides this so users are never trapped in legacy mode.
169212
"""
170-
if (parsed_options or {}).get("legacy_commands", False):
213+
opts = parsed_options or {}
214+
_validate_mode_options(opts)
215+
if opts.get("skills", False):
216+
return True
217+
if opts.get("legacy_commands", False):
171218
return False
172219
if project_root is not None:
173220
bob_dir = Path(project_root) / ".bob"

src/specify_cli/integrations/manifest.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,18 @@ def uninstall(
327327
project_root: Path | None = None,
328328
*,
329329
force: bool = False,
330+
remove_manifest: bool = True,
330331
) -> tuple[list[Path], list[Path]]:
331332
"""Remove tracked files whose hash still matches.
332333
333334
Parameters:
334-
project_root: Override for the project root.
335-
force: If ``True``, remove files even if modified.
335+
project_root: Override for the project root.
336+
force: If ``True``, remove files even if modified.
337+
remove_manifest: If ``True`` (default), also delete this
338+
integration's ``{key}.manifest.json``. Set ``False`` for
339+
*partial* cleanups (e.g. the upgrade stale-file pass, which
340+
builds a throwaway manifest over a subset of files) so the
341+
real, freshly-saved manifest for the same key is not destroyed.
336342
337343
Returns:
338344
``(removed, skipped)`` — absolute paths.
@@ -393,7 +399,7 @@ def uninstall(
393399

394400
# Remove the manifest file itself
395401
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
396-
if manifest.exists():
402+
if remove_manifest and manifest.exists():
397403
manifest.unlink()
398404
parent = manifest.parent
399405
while parent != root:

0 commit comments

Comments
 (0)