Skip to content

Commit d2321b2

Browse files
mnriemCopilot
andcommitted
fix(bob): reject layout migration when preset overrides are installed (review #3415)
A command↔skills layout change during `integration upgrade` cannot reconcile preset artifacts: presets track their command/skill files in per-preset `registered_commands`/`registered_skills` metadata, and there is no agent-scoped preset re-registration anywhere in the CLI. Migrating would delete a preset's old-layout files without recreating them in the new layout and leave the preset registry claiming artifacts that no longer exist. Detect the intended layout via `is_skills_mode` (so a plain same-layout upgrade is unaffected) and, when it flips while preset overrides are installed for the agent, reject the upgrade *before any mutation* with an actionable error pointing at the remove → upgrade → reinstall workaround. Extension artifacts are still reconciled for the safe (no-preset) case. Adds a regression test and documents the migration caveat in the Bob integration reference entry. 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 779eb89 commit d2321b2

3 files changed

Lines changed: 135 additions & 6 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` | 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. |
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. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
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/integrations/_migrate_commands.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,42 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
5353
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
5454

5555

56+
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
57+
"""Return IDs of installed presets with artifacts registered for *agent_key*.
58+
59+
Presets register command overrides for every detected agent and mirror
60+
skills for the active skills agent, tracking the result in each preset's
61+
``registered_commands`` / ``registered_skills`` metadata. There is no
62+
agent-scoped preset re-registration mechanism, so a command↔skills *layout
63+
change* cannot reconcile those artifacts (see ``integration_upgrade``).
64+
Callers use this to detect the unsafe case and reject the migration rather
65+
than silently orphaning preset files / leaving stale registry entries.
66+
67+
Best-effort: any error resolving preset state yields an empty list so a
68+
broken/absent preset registry never blocks an otherwise-valid upgrade.
69+
"""
70+
try:
71+
from ..presets import PresetManager
72+
73+
presets = PresetManager(project_root).registry.list()
74+
except Exception:
75+
return []
76+
77+
affected: list[str] = []
78+
for preset_id, meta in presets.items():
79+
if not isinstance(meta, dict):
80+
continue
81+
registered_commands = meta.get("registered_commands", {})
82+
has_commands = (
83+
isinstance(registered_commands, dict)
84+
and bool(registered_commands.get(agent_key))
85+
)
86+
has_skills = bool(meta.get("registered_skills"))
87+
if has_commands or has_skills:
88+
affected.append(preset_id)
89+
return affected
90+
91+
5692
@integration_app.command("switch")
5793
def integration_switch(
5894
target: str = typer.Argument(help="Integration key to switch to"),
@@ -412,6 +448,42 @@ def integration_upgrade(
412448
integration, current, key, integration_options
413449
)
414450

451+
# Guard: reject a command↔skills layout change while preset overrides are
452+
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
453+
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
454+
# Extension artifacts are reconciled after the flip (see below), but preset
455+
# artifacts cannot be: there is no agent-scoped preset re-registration
456+
# anywhere in the CLI, so migrating would delete a preset's old-layout
457+
# files without recreating them in the new layout and leave the preset
458+
# registry claiming artifacts that no longer exist. Detect the intended
459+
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
460+
# plain same-layout upgrade is unaffected) and bail out *before* any
461+
# mutation with an actionable error so the project is never left in a
462+
# half-migrated, inconsistent state.
463+
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
464+
parsed_options, project_root
465+
):
466+
affected_presets = _installed_presets_affecting_agent(project_root, key)
467+
if affected_presets:
468+
preset_list = ", ".join(sorted(affected_presets))
469+
console.print(
470+
f"[red]Error:[/red] Cannot change '{key}' command layout while "
471+
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
472+
)
473+
console.print(
474+
"Preset artifacts cannot yet be reconciled across a command↔skills "
475+
"layout change, so the migration would orphan their files and leave "
476+
"the preset registry inconsistent."
477+
)
478+
console.print(
479+
"Remove the preset(s), run the upgrade, then reinstall them:\n"
480+
f" [cyan]specify preset remove <id>[/cyan]\n"
481+
f" [cyan]specify integration upgrade {key} "
482+
f"--integration-options \"...\"[/cyan]\n"
483+
f" [cyan]specify preset add <id>[/cyan]"
484+
)
485+
raise typer.Exit(1)
486+
415487
# Ensure shared infrastructure is up to date; --force overwrites existing files.
416488
infra_integration = integration
417489
infra_key = key
@@ -544,11 +616,12 @@ def integration_upgrade(
544616
# layout change. There is no agent-scoped preset re-registration mechanism
545617
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
546618
# presets for any agent (presets are only (un)registered at preset
547-
# install/remove time). Reconciling them here would require a new
548-
# cross-cutting PresetManager subsystem affecting every dual-layout agent,
549-
# which is out of scope for this Bob migration. A project that changes Bob's
550-
# layout while a preset override is installed should re-run
551-
# ``preset remove``/``preset install`` to refresh those artifacts.
619+
# install/remove time). Rather than silently orphan them, the guard near
620+
# the top of this function rejects a layout-changing upgrade while preset
621+
# overrides are installed, so control only reaches here (with a changed
622+
# layout) when no preset artifacts are at stake. Full preset reconciliation
623+
# would require a new cross-cutting PresetManager subsystem affecting every
624+
# dual-layout agent, which is out of scope for this Bob migration.
552625
if _manifest_tracks_skill_layout(old_manifest) != _manifest_tracks_skill_layout(
553626
new_manifest
554627
):

tests/integrations/test_integration_subcommand.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2606,6 +2606,62 @@ def _git_registry():
26062606
cmds_agents, skill_names = _git_registry()
26072607
assert "bob" in cmds_agents and not skill_names
26082608

2609+
def test_upgrade_bob_layout_change_rejected_with_presets_installed(self, tmp_path):
2610+
"""Regression (review #3415, 4726193915).
2611+
2612+
A command↔skills layout change cannot reconcile preset artifacts (no
2613+
agent-scoped preset re-registration exists). Rather than silently
2614+
orphaning preset files / leaving the registry inconsistent, a
2615+
layout-changing ``upgrade`` must reject the migration with an
2616+
actionable error *before any mutation* when preset overrides are
2617+
installed for the agent. A same-layout upgrade must still succeed.
2618+
"""
2619+
project = _init_project(
2620+
tmp_path, "bob", integration_options="--legacy-commands"
2621+
)
2622+
commands = project / ".bob" / "commands"
2623+
skills = project / ".bob" / "skills"
2624+
assert sorted(commands.glob("speckit.*.md"))
2625+
2626+
# Simulate an installed preset that registered command overrides for bob.
2627+
presets_dir = project / ".specify" / "presets"
2628+
presets_dir.mkdir(parents=True, exist_ok=True)
2629+
(presets_dir / ".registry").write_text(
2630+
json.dumps({
2631+
"presets": {
2632+
"my-preset": {
2633+
"version": "1.0.0",
2634+
"enabled": True,
2635+
"registered_commands": {"bob": ["speckit.plan"]},
2636+
"registered_skills": [],
2637+
}
2638+
}
2639+
}),
2640+
encoding="utf-8",
2641+
)
2642+
2643+
# Layout-changing upgrade is rejected, and nothing is mutated.
2644+
result = _run_in_project(project, [
2645+
"integration", "upgrade", "bob",
2646+
"--integration-options", "--skills",
2647+
"--script", "sh", "--force",
2648+
])
2649+
assert result.exit_code != 0, "layout change with presets must be rejected"
2650+
assert "preset" in result.output.lower()
2651+
assert "my-preset" in result.output
2652+
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
2653+
assert sorted(commands.glob("speckit.*.md")), (
2654+
"legacy command files must be left untouched on rejection"
2655+
)
2656+
2657+
# A same-layout upgrade (no flag) must still succeed with presets present.
2658+
result = _run_in_project(project, [
2659+
"integration", "upgrade", "bob", "--script", "sh", "--force",
2660+
])
2661+
assert result.exit_code == 0, (
2662+
f"same-layout upgrade must not be blocked by presets: {result.output}"
2663+
)
2664+
26092665
def test_upgrade_preserves_existing_vscode_settings(self, tmp_path):
26102666
"""Regression: copilot upgrade must not stale-delete .vscode/settings.json.
26112667

0 commit comments

Comments
 (0)