Skip to content
Open
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: 3 additions & 8 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,12 @@ run_command "echo \"$KIRO_INSTALLER_SHA256 $KIRO_INSTALLER_PATH\" | sha256sum -

run_command "bash \"$KIRO_INSTALLER_PATH\""

kiro_binary=""
if command -v kiro-cli >/dev/null 2>&1; then
kiro_binary="kiro-cli"
elif command -v kiro >/dev/null 2>&1; then
kiro_binary="kiro"
else
echo -e "\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' or 'kiro' in PATH.\033[0m" >&2
if ! command -v kiro-cli >/dev/null 2>&1; then
echo -e "\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' in PATH.\033[0m" >&2
exit 1
fi

run_command "$kiro_binary --help > /dev/null"
run_command "kiro-cli --help > /dev/null"
echo "✅ Done"

echo -e "\n🤖 Installing Kimi Code CLI..."
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [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` |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Installs prompts as `.kiro/prompts/speckit-<command>.md` and invokes them as `/speckit-<command>`, because Kiro CLI does not run slash commands whose names contain dots. Headless workflow dispatch runs `kiro-cli chat --no-interactive --trust-all-tools`, which auto-approves every tool call: without it, Kiro denies all file writes in non-interactive mode and still exits 0. Spec Kit looks for the `kiro-cli` executable only, because a bare `kiro` launches Kiro IDE by default. 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 |
| [MiniMax Code](https://github.com/MiniMax-AI/minimax-code) | `mcode` | Skills-based integration; installs skills into `.minimax/skills` and invokes them as `/speckit-<command>`. Headless workflow dispatch uses `--permission full` so tool actions are not blocked by headless permission gating; set `SPECKIT_INTEGRATION_MCODE_EXTRA_ARGS="--permission smart"` to use MiniMax Code's stricter policy. |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
Expand Down
6 changes: 1 addition & 5 deletions src/specify_cli/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,7 @@ def check_tool(tool: str, tracker=None) -> bool:
return True

# Per-integration executable resolution.
if tool == "kiro-cli":
# Kiro currently supports both executable names. Prefer kiro-cli and
# accept kiro as a compatibility fallback.
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
elif tool == "rovodev":
if tool == "rovodev":
found = shutil.which("acli") is not None
elif tool == "docker-agent":
found = docker_agent_command() is not None
Expand Down
34 changes: 34 additions & 0 deletions src/specify_cli/integrations/_command_upgrade_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ def _legacy_command_root_changed(
return old_had_legacy and new_has_canonical


def _planned_command_files(integration) -> set[str]:
"""Return the manifest keys ``setup()`` will write for core command templates."""
commands_dir = (integration.registrar_config or {}).get("dir")
if not isinstance(commands_dir, str) or not commands_dir.strip():
return set()
return {
(PurePath(commands_dir) / integration.command_filename(template.stem)).as_posix()
for template in integration.list_command_templates()
}


def _command_file_names_changed(integration, old_files, new_files) -> bool:
"""Return True when core command files are renamed inside the command dir.

Kiro CLI moved from ``speckit.<cmd>.md`` to ``speckit-<cmd>.md`` in the
same ``.kiro/prompts`` directory (#4797). *old_files* and *new_files* are
manifest keys; ``upgrade`` passes ``_planned_command_files()`` as the new
ones so it can decide before changing files. Only a removed file that
matches an added one up to ``.``/``-`` separators counts, so a release
that just adds and drops commands is not a rename. Extension and preset
commands are tracked outside the manifest, so re-registering them alone
would leave their old names beside the new ones.
"""
commands_dir = (integration.registrar_config or {}).get("dir")
if not isinstance(commands_dir, str) or not commands_dir.strip():
return False
old = {rel for rel in old_files if _manifest_path_under(rel, commands_dir)}
new = {rel for rel in new_files if _manifest_path_under(rel, commands_dir)}
# Compare whole paths: skill layouts name every file SKILL.md.
removed = {rel.replace(".", "-") for rel in old - new}
added = {rel.replace(".", "-") for rel in new - old}
return bool(removed & added)


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 {}
Expand Down
63 changes: 63 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,69 @@ def _unregister_enabled_extension_commands_for_agent(
)


def _retire_renamed_command_files(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Remove enabled extension command files left under their old names.

``upgrade`` calls this after re-registration when the core command files
were renamed in place, as Kiro CLI's ``speckit.<cmd>.md`` prompts became
``speckit-<cmd>.md`` (#4797). Extension commands are tracked in the
extension registry, not the integration manifest, so stale cleanup leaves
their old files. An old file is removed only when the file under the new
name exists, so a command whose re-registration failed keeps its old file
and its registry entry (replacement before retirement, as in
``ExtensionManager._retire_legacy_flat_extension_commands``). Preset
overrides can share a path with another layer, so ``upgrade`` refuses the
rename while presets have commands registered for the agent.

Best-effort: never aborts the surrounding integration operation.
"""

def retire(ext_mgr: Any, key: str) -> None:
from ..agents import CommandRegistrar

registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(key)
if not agent_config:
return
commands_dir = registrar._resolve_agent_dir(key, agent_config, project_root)
suffix = agent_config["extension"]
for metadata in ext_mgr.registry.list().values():
if not isinstance(metadata, dict) or not metadata.get("enabled", True):
continue
registered = metadata.get("registered_commands")
names = registered.get(key) if isinstance(registered, dict) else None
if not isinstance(names, list):
continue
for name in names:
if not isinstance(name, str) or not registrar._is_safe_command_name(name):
continue
new_name = registrar._compute_output_name(key, name, agent_config)
old_file = commands_dir / f"{name}{suffix}"
try:
registrar._ensure_inside(old_file, commands_dir)
except ValueError:
continue
if (
new_name != name
and old_file.is_file()
and (commands_dir / f"{new_name}{suffix}").is_file()
):
old_file.unlink()

_best_effort_extension_op(
project_root,
agent_key,
retire,
phase="clean up renamed extension command files for",
continuing=continuing,
)


# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
Expand Down
60 changes: 59 additions & 1 deletion src/specify_cli/integrations/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@
from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys
from ._command_upgrade_layout import (
_PresetRegistryUnreadableError,
_command_file_names_changed,
_installed_command_presets_affecting_agent,
_installed_presets_affecting_agent,
_legacy_command_root_changed,
_legacy_command_root_upgrade_pending,
_manifest_tracks_skill_layout,
_planned_command_files,
)
from ._commands import integration_app
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _retire_renamed_command_files, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json


@integration_app.command("upgrade")
Expand Down Expand Up @@ -188,6 +190,53 @@ def integration_upgrade(
)
raise typer.Exit(1)

# Reject in-place command file renames (Kiro CLI's speckit.<cmd>.md ->
# speckit-<cmd>.md, #4797) while preset command artifacts are tracked for
# the integration. A preset override shares its path with the core or
# extension command it overrides, and its rescaffold is best-effort: if
# the preset can't be re-registered, stale cleanup or the other layer's
# new file would replace the override. Refuse before any mutation, as for
# the layout changes above.
command_file_names_changed = _command_file_names_changed(
integration, old_manifest.files, _planned_command_files(integration)
)
if command_file_names_changed:
try:
affected_presets = _installed_command_presets_affecting_agent(
project_root,
key,
)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot rename '{key}' command files: 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 file rename 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 rename '{key}' command files while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset command artifacts cannot yet be reconciled across a "
"command file rename, 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}[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)

# Ensure shared infrastructure is up to date; --force overwrites existing files.
infra_integration = integration
infra_key = key
Expand Down Expand Up @@ -343,6 +392,15 @@ def integration_upgrade(
key,
continuing="The integration was upgraded, but installed presets may need re-registration.",
)
if command_file_names_changed:
_retire_renamed_command_files(
project_root,
key,
continuing=(
"The integration was upgraded, but extension command files "
"under the old names may need manual cleanup."
),
)
_resync_manifest_after_registration(
new_manifest,
key,
Expand Down
Loading