Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
af9205f
fix: register extensions for the active integration only
marcelsafin Jul 10, 2026
e9e9072
fix: address review feedback on active-only extension registration
marcelsafin Jul 10, 2026
2486c08
fix: address second round of review feedback (priority order, fail-cl…
marcelsafin Jul 10, 2026
12a3d67
fix: address third round of review feedback (multi-integration semant…
marcelsafin Jul 10, 2026
db78903
fix: address fourth round of review feedback (skill registration prov…
marcelsafin Jul 10, 2026
e5d5553
fix: address fifth round of review feedback (symlink presence, rescaf…
marcelsafin Jul 10, 2026
fc7b2e2
fix: guard skill subdirectories and active-agent scoping in preset re…
marcelsafin Jul 10, 2026
5b0e559
fix: persist command registration before fallible skills phase on res…
marcelsafin Jul 10, 2026
b9d9053
fix: unregister stale opposite-mode preset artifact on same-agent ski…
marcelsafin Jul 11, 2026
3a1e749
fix: migrate legacy flat-list registered_skills on rescaffold even wh…
marcelsafin Jul 11, 2026
1bdb5b3
fix: reconcile before fallible skills phase, infer legacy skill prove…
marcelsafin Jul 11, 2026
ee03a50
fix: broaden legacy skill provenance inference to command-backed agents
marcelsafin Jul 11, 2026
37cceb7
fix: preserve extension skill tracking for mirrors in other agent dirs
marcelsafin Jul 11, 2026
28561e6
fix: reject symlinked skills-directory escape in extension skill scans
marcelsafin Jul 11, 2026
1d8f9e3
Fix unscoped extension-skill removal and legacy preset provenance on …
marcelsafin Jul 11, 2026
0ab9a5f
Keep unregister_agent_artifacts scoped to its agent when directory is…
marcelsafin Jul 11, 2026
31c9b97
Preserve global skill tracking across agents in unregister_agent_arti…
marcelsafin Jul 11, 2026
ab6c28c
Reconcile every historical agent on preset removal; validate child sk…
marcelsafin Jul 11, 2026
f19225a
Persist historical reconciliation ownership; defer destructive toggle…
marcelsafin Jul 11, 2026
d0d152e
Verify replacement actually landed before retiring stale toggle artif…
marcelsafin Jul 11, 2026
664dfbe
Retire alias command groups on toggle; scope preset cleanup to switch…
marcelsafin Jul 11, 2026
b64937a
fix: track reconciled extension artifacts
marcelsafin Jul 11, 2026
170ca14
Fix native skill preset reconciliation
marcelsafin Jul 11, 2026
bee8c77
Fix shared native skill cleanup
marcelsafin Jul 11, 2026
b500653
Fix partial preset rescaffold tracking
marcelsafin Jul 11, 2026
1c99743
Fix preset agent skill lifecycle
marcelsafin Jul 11, 2026
bd31afb
Clarify preset removal reconciliation
marcelsafin Jul 11, 2026
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
12 changes: 11 additions & 1 deletion docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Installs the specified integration into the current project. If another integrat

Installing an additional integration does not change the default integration. Use `specify integration use <key>` to change the default.

Installed extensions and presets are not registered for a non-default integration at install time — they follow the currently active (default) integration only. `specify integration use <key>` (or `switch <key>`) is what rescaffolds them for the newly active integration.

> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.

**Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install <key>` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`.
Expand Down Expand Up @@ -121,7 +123,7 @@ specify integration switch <key>
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |

If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`. Like `use`, `switch` rescaffolds installed extensions and presets for the target integration once it becomes the default.

## Use an Installed Integration

Expand All @@ -135,6 +137,8 @@ specify integration use <key>

Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used.

`use` is also the activation point for installed extensions and presets: it re-registers every enabled extension's and preset's command overrides (and skills, for skills-mode agents) for the newly active integration, so artifacts installed while a different integration was active are rescaffolded here rather than at install time.

## Upgrade an Integration

```bash
Expand All @@ -149,6 +153,8 @@ specify integration upgrade [<key>]

Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.

Enabled extensions and presets are re-registered only when upgrading the currently active (default) integration; upgrading a non-default integration does not touch its extension or preset artifacts — `use`/`switch` that integration afterward to rescaffold them.

## Report Integration Status

```bash
Expand Down Expand Up @@ -288,3 +294,7 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins
### When should I use `upgrade` vs `switch`?

Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`.

### Do extensions and presets I install apply to every installed integration?

No. Extensions (`specify extension add`) and presets (`specify preset add`) register their command overrides for the currently active (default) integration only, even if other integrations are installed. A non-default integration does not receive those artifacts until it becomes the default: `specify integration use <key>` (or `switch <key>`) rescaffolds every enabled extension and preset for the newly active integration. `specify integration upgrade` follows the same rule — it only re-registers extensions and presets when upgrading the active integration.
2 changes: 1 addition & 1 deletion docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ catalogs:

Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.

Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default — `specify integration use <key>` (or `switch <key>`) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command.

By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.

Expand Down
49 changes: 48 additions & 1 deletion src/specify_cli/_init_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from typing import Any, Union


INIT_OPTIONS_FILE = ".specify/init-options.json"


class _MissingInitOptionsFile:
"""Sentinel: init-options.json does not exist at all (legacy layout)."""

def __repr__(self) -> str: # pragma: no cover - debug aid only
return "MISSING_INIT_OPTIONS_FILE"


MISSING_INIT_OPTIONS_FILE = _MissingInitOptionsFile()


def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``."""
dest = project_path / INIT_OPTIONS_FILE
Expand All @@ -34,3 +44,40 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
"""Return True only when init options explicitly enable AI skills."""
return isinstance(opts, Mapping) and opts.get("ai_skills") is True


def resolve_active_agent_for_registration(
project_path: Path,
) -> Union[str, None, _MissingInitOptionsFile]:
"""Resolve the active integration key for active-only registration (#2948).

``load_init_options`` collapses "no file", "unreadable/malformed file",
and "valid file with no recorded active agent" into the same ``{}``
result, which previously made corrupted-but-present init-options behave
like a legacy pre-init-options project and fall back to registering
every detected agent. This helper distinguishes those cases explicitly:

- Returns :data:`MISSING_INIT_OPTIONS_FILE` when init-options.json does
not exist at all (pre-init-options layout or direct library use).
Callers should fall back to detection-based registration for all
agents, matching the original pre-#2948 behavior for such projects.
- Returns ``None`` when init-options.json exists but could not provide a
valid non-empty string active agent (malformed/unreadable JSON,
non-object payload, or a non-string/empty ``ai`` value). Callers must
fail closed (register nothing) rather than treat this like "no file"
or pass a non-string key into agent-config lookups.
- Returns the active agent key (a non-empty string) otherwise.
"""
path = project_path / INIT_OPTIONS_FILE
# A dangling symlink's target doesn't exist, so Path.exists() (which
# follows symlinks) returns False even though the path itself is
# present as a broken/corrupted entry. Treat any symlink as "present"
# so a dangling one fails closed via the invalid-file branch below
# instead of being mistaken for "no file at all" (legacy fallback).
if not path.is_symlink() and not path.exists():
return MISSING_INIT_OPTIONS_FILE

active_agent = load_init_options(project_path).get("ai")
if isinstance(active_agent, str) and active_agent:
return active_agent
return None
25 changes: 24 additions & 1 deletion src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterable, List, Optional

import yaml

Expand Down Expand Up @@ -932,6 +932,7 @@ def register_commands_for_all_agents(
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
only_agent: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.

Expand All @@ -949,6 +950,8 @@ def register_commands_for_all_agents(
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
only_agent: If set, restrict registration to this single agent
while keeping all detection and recovery safeguards (#2948).

Returns:
Dictionary mapping agent names to list of registered commands
Expand All @@ -972,6 +975,8 @@ def register_commands_for_all_agents(
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if only_agent is not None and agent_name != only_agent:
continue
active_skills_output = (
agent_name == active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
Expand Down Expand Up @@ -1077,6 +1082,8 @@ def register_commands_for_non_skill_agents(
context_note: Optional[str] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
only_agent: Optional[str] = None,
extra_agents: Optional[Iterable[str]] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.

Expand All @@ -1093,13 +1100,29 @@ def register_commands_for_non_skill_agents(
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
only_agent: If set, restrict registration to this single agent
(#2948). An agent name that matches no configured agent
(e.g. an empty string) yields no registrations at all.
extra_agents: Additional agent names to register for besides
``only_agent``. Used by post-removal reconciliation to also
restore surviving content into historical agent directories
a just-removed preset actually wrote to, not only the
currently active agent (#2948). Ignored when ``only_agent``
is ``None`` (already unrestricted).

Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
extra_agents_set = frozenset(extra_agents) if extra_agents else frozenset()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if (
only_agent is not None
and agent_name != only_agent
and agent_name not in extra_agents_set
):
continue
if agent_config.get("extension") == "/SKILL.md":
continue
detect_dir_str = agent_config.get("detect_dir")
Expand Down
Loading