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
69 changes: 57 additions & 12 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3813,7 +3813,15 @@ def _retire_legacy_flat_extension_commands(
agent_name: str,
command_names: List[str],
) -> List[Path]:
"""Remove old flat commands whose replacement skills were written."""
"""Remove old flat commands whose replacements were written.

Qoder's ``.qoder/commands`` files became skills, and Kiro CLI's dotted
``.kiro/prompts/speckit.<cmd>.md`` files became hyphenated prompts in
the same directory (#4797). This runs on every registration pass, so
a command whose old file outlived an upgrade (the integration was
inactive, or the extension disabled) is cleaned up when it is next
registered (#2948).
"""
from ..agents import CommandRegistrar
from ..integrations import get_integration

Expand All @@ -3832,7 +3840,7 @@ def _retire_legacy_flat_extension_commands(

registrar = CommandRegistrar(self.project_root)
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
if not agent_config or agent_config.get("extension") != "/SKILL.md":
if not agent_config:
return []

def safe_project_dir(relative: str) -> Optional[Path]:
Expand All @@ -3851,8 +3859,8 @@ def safe_project_dir(relative: str) -> Optional[Path]:
return current

legacy_root = safe_project_dir(legacy_dir)
skills_root = safe_project_dir(str(agent_config.get("dir", "")))
if legacy_root is None or skills_root is None or not legacy_root.is_dir():
output_root = safe_project_dir(str(agent_config.get("dir", "")))
if legacy_root is None or output_root is None or not legacy_root.is_dir():
return []

removed: List[Path] = []
Expand All @@ -3864,14 +3872,18 @@ def safe_project_dir(relative: str) -> Optional[Path]:
):
continue

skill_name = registrar._compute_output_name(
output_name = registrar._compute_output_name(
agent_name, command_name, agent_config
)
replacement = skills_root / skill_name / "SKILL.md"
replacement = output_root / f"{output_name}{agent_config['extension']}"
if replacement.is_symlink() or not replacement.is_file():
continue

legacy_file = legacy_root / f"{command_name}{legacy_extension}"
# Kiro's old and new prompts share a directory: a name without
# dots (e.g. an alias ``speckit-git-c``) is its own replacement.
if registrar._same_lexical_path(legacy_file, replacement):
continue
if legacy_file.is_symlink() or legacy_file.is_file():
legacy_file.unlink()
removed.append(legacy_file)
Expand Down Expand Up @@ -3968,13 +3980,46 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
if not isinstance(registered_commands, dict):
registered_commands = {}
new_registered = copy.deepcopy(registered_commands)
if registered:
new_registered[agent_name] = registered
# register_commands skips a missing source and returns
# only the names it wrote; it does not raise. Replacing
# the agent's list with that return value (or dropping
# the entry when nothing was written) untracks a prompt
# that is still on disk, and extension removal then
# cannot delete it. Keep a previously registered name
# when this pass did not write it and the manifest
# still declares it. A name that was never registered
# is not added. A name the manifest no longer declares
# is not kept: removal deletes the formatted path, and
# another extension may now own that file. Retirement
# below still runs only for names written this pass,
# and only once that pass's replacement file exists
# (#4797, #2948).
declared_names: Set[str] = set()
for command in manifest.commands:
if not isinstance(command, dict):
continue
primary_name = command.get("name")
if isinstance(primary_name, str):
declared_names.add(primary_name)
aliases = command.get("aliases") or []
if isinstance(aliases, list):
declared_names.update(
alias for alias in aliases
if isinstance(alias, str)
)
previous = self._valid_name_list(
new_registered.get(agent_name)
)
kept = [
name for name in previous
if name in registered or name in declared_names
]
merged = kept + [
name for name in registered if name not in kept
]
if merged:
new_registered[agent_name] = merged
else:
# Registration returned empty list (e.g., corrupted
# manifest pointing at missing command files). Clear
# stale entry so later cleanup doesn't try to remove
# files that were never written.
new_registered.pop(agent_name, None)
if new_registered != registered_commands:
updates["registered_commands"] = new_registered
Expand Down
33 changes: 33 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,39 @@ 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 refuse the rename while presets have commands registered
for the agent, 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.
"""
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
6 changes: 5 additions & 1 deletion src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ class IntegrationBase(ABC):
"""

legacy_flat_command_dir: str | None = None
"""Previous flat command directory retired after skill replacements exist."""
"""Previous flat command directory retired after replacements exist.
Its files are named ``<command name><legacy_flat_command_extension>``. It
may be the current command directory when only the file names changed.
"""

legacy_flat_command_extension: str | None = None
"""File extension used by commands in ``legacy_flat_command_dir``."""
Expand Down
48 changes: 48 additions & 0 deletions src/specify_cli/integrations/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
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
Expand Down Expand Up @@ -188,6 +190,52 @@ 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.
if _command_file_names_changed(
integration, old_manifest.files, _planned_command_files(integration)
):
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
Loading