Skip to content
Merged
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
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"folder": ".kilo/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilocode/workflows",
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
Expand Down Expand Up @@ -201,8 +202,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
specify init my-project --integration <key>

# Verify files were created in the commands directory configured by
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
ls -R my-project/.kilocode/workflows/
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
ls -R my-project/.kilo/commands/

# Uninstall cleanly
cd my-project && specify integration uninstall <key>
Expand Down
6 changes: 4 additions & 2 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [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. |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
| [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 |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
Expand Down Expand Up @@ -269,12 +269,14 @@ The currently declared multi-install safe integrations are:
| `codebuddy` | `.codebuddy/commands` |
| `codex` | `.agents/skills` |
| `cursor-agent` | `.cursor/skills` |
| `droid` | `.factory/skills` |
| `firebender` | `.firebender/commands` |
| `gemini` | `.gemini/commands` |
| `grok` | `.grok/skills` |
| `junie` | `.junie/commands` |
| `kilocode` | `.kilocode/workflows` |
| `kilocode` | `.kilo/commands` |
| `kiro-cli` | `.kiro/prompts` |
| `lingma` | `.lingma/skills` |
| `omp` | `.omp/commands` |
| `pi` | `.pi/prompts` |
| `qodercli` | `.qoder/commands` |
Expand Down
22 changes: 9 additions & 13 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,13 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
**Example for Kilo Code:**

```bash
# Navigate to the agent's commands folder
cd .kilocode/workflows/

# List files and identify duplicates
ls -la
# List current and legacy Kilo command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/

# Delete old versions (example filenames - yours may differ)
rm speckit.specify-old.md
rm speckit.plan-v1.md
rm .kilocode/workflows/speckit.specify-old.md
rm .kilocode/workflows/speckit.plan-v1.md
```

Restart your IDE to refresh the command list.
Expand Down Expand Up @@ -248,14 +246,12 @@ specify extension update
This happens with IDE-based agents (Kilo Code, Cline, etc.).

```bash
# Find the agent folder (example: .kilocode/workflows/)
cd .kilocode/workflows/

# List all files
ls -la
# For Kilo Code, inspect both current and legacy command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/

# Delete old command files
rm speckit.old-command-name.md
rm .kilocode/workflows/speckit.old-command-name.md

# Restart your IDE
```
Expand Down
21 changes: 19 additions & 2 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2179,14 +2179,28 @@ def _valid_name_list(value: Any) -> List[str]:
return []
return [item for item in value if isinstance(item, str)]

def unregister_agent_artifacts(self, agent_name: str) -> None:
def unregister_agent_artifacts(
self,
agent_name: str,
*,
enabled_only: bool = False,
commands_only: bool = False,
) -> None:
"""Remove extension files registered for a specific agent.

Extension command files are tracked per agent in ``registered_commands``.
Extension skills are scoped to the provided *agent_name*; they are removed
from that agent's skills directory (resolved via its integration config)
and the registry field is cleared.

Set ``enabled_only=True`` when a caller is about to re-register enabled
extensions and must preserve disabled extensions' existing artifacts and
registry entries.

Set ``commands_only=True`` for command-directory reconciliation where
skill artifacts are outside the target agent's lifecycle and must not
be touched.

Skips cleanup when *agent_name* is not a supported agent to avoid
losing registry entries while leaving orphaned files on disk.
"""
Expand All @@ -2205,6 +2219,9 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)

for ext_id, metadata in self.registry.list().items():
if enabled_only and not metadata.get("enabled", True):
continue

updates: Dict[str, Any] = {}

registered_commands = metadata.get("registered_commands", {})
Expand All @@ -2227,7 +2244,7 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
registered_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
if registered_skills:
if registered_skills and not commands_only:
# Only pass the resolved skills_dir when it actually exists.
# Otherwise let _unregister_extension_skills fall back to
# scanning all known agent skills directories, which is useful
Expand Down
20 changes: 20 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,26 @@ def _unregister_extensions_for_agent(
)


def _unregister_enabled_extension_commands_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of enabled extension command artifacts for ``agent_key``."""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.unregister_agent_artifacts(
key,
enabled_only=True,
commands_only=True,
),
phase="clean up enabled extension command artifacts for",
continuing=continuing,
)


# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
Expand Down
161 changes: 155 additions & 6 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_enabled_extension_commands_for_agent,
_unregister_extensions_for_agent,
_update_init_options_for_integration,
_write_integration_json,
Expand All @@ -54,6 +55,66 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)


def _manifest_path_under(rel_path: str, root: str) -> bool:
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
normalized_root = PurePath(root).as_posix().strip("/")
normalized_rel = PurePath(rel_path).as_posix().strip("/")
if not normalized_root:
return False
return normalized_rel == normalized_root or normalized_rel.startswith(
f"{normalized_root}/"
)


def _legacy_command_root_changed(
integration,
project_root: Path,
old_manifest,
new_manifest,
) -> bool:
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False

canonical_dir = project_root / canonical
legacy_dir = project_root / legacy
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
return False

old_had_legacy = any(
_manifest_path_under(rel, legacy) for rel in old_manifest.files
)
new_has_canonical = any(
_manifest_path_under(rel, canonical) for rel in new_manifest.files
)
return old_had_legacy and new_has_canonical


def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
"""Return True when the old manifest tracks command files under legacy_dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)


class _PresetRegistryUnreadableError(Exception):
"""Raised when an existing preset registry cannot be read or parsed.

Expand All @@ -64,7 +125,12 @@ class _PresetRegistryUnreadableError(Exception):
"""


def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
def _installed_presets_affecting_agent(
project_root,
agent_key: str,
*,
include_skills: bool = True,
) -> list[str]:
"""Return IDs of installed presets with artifacts registered for *agent_key*.

Presets register command overrides for every detected agent and mirror
Expand Down Expand Up @@ -117,17 +183,30 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
f"preset '{preset_id}' registered_commands is malformed"
)
registered_skills = meta.get("registered_skills", [])
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
if include_skills:
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_commands = bool(registered_commands.get(agent_key))
has_skills = bool(registered_skills)
has_skills = include_skills and bool(registered_skills)
if has_commands or has_skills:
affected.append(preset_id)
return affected


def _installed_command_presets_affecting_agent(
project_root,
agent_key: str,
) -> list[str]:
"""Return installed presets with command artifacts registered for *agent_key*."""
return _installed_presets_affecting_agent(
project_root,
agent_key,
include_skills=False,
)


@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
Expand Down Expand Up @@ -487,6 +566,60 @@ def integration_upgrade(
integration, current, key, integration_options
)

legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
integration,
old_manifest,
)

# Guard: Kilo's legacy command root moves from .kilocode/workflows to
# .kilo/commands. Preset command artifacts are registered only during
# preset install/remove, with no agent-scoped re-registration hook to
# recreate them at the new command root while preserving priority and
# composition semantics. Refuse before setup writes .kilo/commands rather
# than leaving legacy preset files orphaned or registry-tracked overrides
# missing from the canonical directory.
if key == "kilocode" and legacy_command_root_upgrade_pending:
config = integration.registrar_config or {}
legacy = config.get("legacy_dir", "legacy command directory")
canonical = config.get("dir", "canonical command directory")
try:
affected_presets = _installed_command_presets_affecting_agent(
project_root,
key,
)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
"the preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A command directory migration cannot reconcile preset command "
"artifacts while the preset registry state is unknown. Fix or "
"restore [cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset command artifacts cannot yet be reconciled across this "
"command directory migration, so the upgrade is refused before "
"changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)

# Guard: reject a command↔skills layout change while preset overrides are
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
Expand Down Expand Up @@ -646,6 +779,22 @@ def integration_upgrade(
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")

legacy_command_root_changed = _legacy_command_root_changed(
integration,
project_root,
old_manifest,
new_manifest,
)
if legacy_command_root_changed:
_unregister_enabled_extension_commands_for_agent(
project_root,
key,
continuing=(
"The integration command directory changed, but legacy enabled "
"extension artifacts may need manual cleanup."
),
)

# Re-register enabled extensions for the upgraded agent so its extension
# commands are (re)created — including agents installed before this
# back-fill existed. Mirrors switch for command registration; see #2886.
Expand Down
7 changes: 4 additions & 3 deletions src/specify_cli/integrations/kilocode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"folder": ".kilo/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilocode/workflows",
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
Expand Down
Loading