Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ed167a7
fix: enforce bundle step version pins
marcelsafin Sep 8, 2026
0ab707c
chore: sync upstream before bundle consolidation
marcelsafin Sep 10, 2026
ebd3798
fix: scope custom steps to current project
marcelsafin Sep 8, 2026
eadef1d
fix: purge stale custom step bytecode
marcelsafin Sep 8, 2026
30f8115
fix: reject duplicate bundle components
marcelsafin Sep 8, 2026
1fe0d78
test: allow duplicate IDs across component kinds
marcelsafin Sep 8, 2026
e3048f4
fix: reject mismatched step catalog versions
marcelsafin Sep 8, 2026
0f78c80
fix: validate explicitly declared step versions
marcelsafin Sep 8, 2026
eaa1fce
fix: preserve exact fallback version comparison
marcelsafin Sep 8, 2026
6a254b6
fix: roll back bundle installs when record save fails
marcelsafin Sep 8, 2026
e0f957f
fix: close step version integrity gaps
marcelsafin Sep 10, 2026
42208be
fix: require usable downloaded step versions
marcelsafin Sep 10, 2026
6ec8b9c
fix: roll back failed bundle updates
marcelsafin Sep 10, 2026
130cb6e
fix: snapshot installed state for bundle rollback
marcelsafin Sep 10, 2026
65db940
fix: close bundle rollback and step integrity gaps
marcelsafin Sep 11, 2026
09d3fe9
Merge upstream main for bundle integrity review fixes
marcelsafin Sep 11, 2026
dbbc499
fix: isolate step registries and restore integration artifacts
marcelsafin Sep 11, 2026
93cd405
fix: invalidate external step caches and restore backup preimages
marcelsafin Sep 11, 2026
27ce84f
fix: keep snapshot cleanup outside transaction outcomes
marcelsafin Sep 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
8 changes: 7 additions & 1 deletion docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ specify bundle install <bundle_id | path>

Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack.

If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Installation is idempotent — components already present are skipped. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain.
If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Installation is idempotent — components already present are skipped. On failure, no provenance record is written, and completed new installations are removed on a best-effort basis. Incomplete rollback is reported explicitly; partial on-disk state may remain if a primitive fails or recovery itself fails.

## Update Bundles

Expand All @@ -59,6 +59,10 @@ specify bundle update [<bundle_id>]

Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.

Before refreshing or removing an owned component, the bundler snapshots its installed files and registry metadata. If a component operation or provenance write fails, it attempts to restore those local snapshots, including disabled state, user configuration, extension hook settings and pre-existing configuration backups, and generated command files and skill resources for current and previously active integrations, without downloading an older version. Previously absent outputs and configuration backups are also restored to absence. Custom steps are restored before dependent workflows. Recovery is best-effort and reports incomplete restoration; these temporary snapshots cover failures during the command, not process crashes or unrelated project files.

If temporary snapshot cleanup fails, a warning identifies the path for manual removal without changing the committed result or masking the original rollback error.

> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update` to re-apply every owned component at its pinned version.

## Remove a Bundle
Expand All @@ -69,6 +73,8 @@ specify bundle remove <bundle_id>

Uninstalls only the components this bundle contributed, leaving any component that another installed bundle still needs in place (no collateral removals).

If removal or the final provenance write fails, the bundler attempts to restore removed components from local snapshots and leaves the bundle record unchanged. An incomplete recovery is reported explicitly.

## List Installed Bundles

```bash
Expand Down
53 changes: 29 additions & 24 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1293,15 +1293,13 @@ def register_commands_for_non_skill_agents(
continue
return results

def unregister_commands(
def iter_command_artifacts(
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
) -> Iterable[tuple[Path, Path]]:
"""Yield command artifact paths and their output roots, including absent files.

When a ``legacy_dir`` is configured, files are removed from
*both* the canonical and the legacy directory so that orphaned
commands left behind after an ``integration upgrade`` are
cleaned up as well.
Canonical and existing legacy locations are both yielded so backup
and removal use the same path mapping and containment checks.

Args:
registered_commands: Dict mapping agent names to command name lists
Expand Down Expand Up @@ -1344,25 +1342,32 @@ def unregister_commands(
self._ensure_inside(cmd_file, target_dir)
except ValueError:
continue
if cmd_file.exists() or cmd_file.is_symlink():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
yield cmd_file, target_dir

if agent_name == "copilot":
prompt_file = (
project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
)
if prompt_file.exists():
prompt_file.unlink()
prompts_dir = project_root / ".github" / "prompts"
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
try:
self._ensure_inside(prompt_file, prompts_dir)
except ValueError:
continue
yield prompt_file, prompts_dir

def unregister_commands(
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:
"""Remove recorded command outputs from canonical and legacy locations."""
for cmd_file, target_dir in self.iter_command_artifacts(
registered_commands, project_root
):
if cmd_file.exists() or cmd_file.is_symlink():
cmd_file.unlink()
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass


# Populate AGENT_CONFIGS after class definition.
Expand Down
8 changes: 8 additions & 0 deletions src/specify_cli/bundler/models/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,17 @@ def structural_errors(self) -> list[str]:
"(lowercase letters, digits, '.', '_', '-'; no path separators)."
)

seen_components: set[tuple[str, str]] = set()
for ref in self.components:
if not ref.id:
errors.append(f"A {ref.kind[:-1]} entry is missing its 'id'.")
key = (ref.kind, ref.id)
if ref.id and key in seen_components:
errors.append(
f"Duplicate {ref.kind[:-1]} '{ref.id}' in "
f"'provides.{ref.kind}'."
)
seen_components.add(key)
if ref.kind != "steps" and not ref.version:
errors.append(
f"{ref.kind[:-1]} '{ref.id or '<unknown>'}' must be pinned to a 'version'."
Expand Down
40 changes: 40 additions & 0 deletions src/specify_cli/bundler/models/snapshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Ephemeral installed-component state, never serialized into bundle records."""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any

from .manifest import ComponentRef

logger = logging.getLogger(__name__)


@dataclass
class ArtifactSnapshot:
path: Path
backup: Path | None
trusted_root: Path


@dataclass
class ComponentSnapshot:
component: ComponentRef
metadata: dict[str, Any]
directory: Path | None = None
hooks: dict[str, list[tuple[int, dict[str, Any]]]] = field(default_factory=dict)
backup: TemporaryDirectory | None = field(default=None, repr=False)
artifacts: list[ArtifactSnapshot] = field(default_factory=list)
absent_artifact_parents: set[tuple[Path, Path]] = field(default_factory=set)

def close(self) -> None:
if self.backup is not None:
try:
self.backup.cleanup()
except OSError as exc:
logger.warning(
"Could not clean up rollback snapshot at %s; remove it manually: %s",
self.backup.name, exc,
)
11 changes: 11 additions & 0 deletions src/specify_cli/bundler/services/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from ..lib.yamlio import load_json, loads_json
from ..models.catalog import CatalogSource
from ..models.manifest import ComponentRef
from ..models.snapshot import ComponentSnapshot

COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
Expand Down Expand Up @@ -223,6 +224,16 @@ def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
manager = self._manager_for(component, project_root)
return manager.is_installed(component)

def snapshot(
self, project_root: Path, component: ComponentRef
) -> ComponentSnapshot | None:
manager = self._manager_for(component, project_root)
return manager.snapshot(component)

def restore(self, project_root: Path, snapshot: ComponentSnapshot) -> None:
manager = self._manager_for(snapshot.component, project_root)
manager.restore(snapshot)

def install(self, project_root: Path, component: ComponentRef) -> None:
manager = self._manager_for(component, project_root)
manager.install(component)
Expand Down
149 changes: 149 additions & 0 deletions src/specify_cli/bundler/services/artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Component-scoped preimages of manager-generated outputs and backups."""
from __future__ import annotations

import os
import shutil
from contextlib import ExitStack
from pathlib import Path
from typing import TYPE_CHECKING

from ..._init_options import MISSING_INIT_OPTIONS_FILE, resolve_active_agent_for_registration
from ...agents import CommandRegistrar
from ...shared_infra import _validate_safe_shared_directory
from .. import BundlerError
from ..models.snapshot import ArtifactSnapshot, ComponentSnapshot

if TYPE_CHECKING:
from ...extensions import ExtensionManager
from ...presets import PresetManager


def snapshot_generated_artifacts(
snapshot: ComponentSnapshot,
project_root: Path,
manager: PresetManager | ExtensionManager,
) -> ComponentSnapshot:
from ...presets import PresetManager

with ExitStack() as cleanup:
cleanup.callback(snapshot.close)
registrar = CommandRegistrar()
recorded = snapshot.metadata.get("registered_commands", {})
if not isinstance(recorded, dict) or any(
not isinstance(agent, str) or not isinstance(names, list)
or any(not isinstance(name, str) for name in names)
for agent, names in recorded.items()
):
raise BundlerError(f"Invalid command provenance for {snapshot.component.label()}.")
commands = {agent: list(names) for agent, names in recorded.items()}
resolved = resolve_active_agent_for_registration(project_root)
active = resolved if isinstance(resolved, str) else None
if resolved is MISSING_INIT_OPTIONS_FILE:
targets = [
agent for agent, config in registrar.AGENT_CONFIGS.items()
if (not config.get("detect_dir") or (project_root / config["detect_dir"]).is_dir())
and registrar._resolve_agent_dir(agent, config, project_root).is_dir()
]
else:
targets = [active] if active is not None and active in registrar.AGENT_CONFIGS else []
is_preset = isinstance(manager, PresetManager)
if isinstance(manager, PresetManager):
manifest = manager.get_pack(snapshot.component.id)
definitions = (
[entry for entry in manifest.templates if entry.get("type") == "command"]
if manifest is not None else []
)
else:
manifest = manager.get_extension(snapshot.component.id)
definitions = manifest.commands if manifest is not None else []
if manifest is None:
raise BundlerError(f"Missing installed manifest for {snapshot.component.label()}.")
provided_names = {
name for definition in definitions
for name in [definition["name"], *definition.get("aliases", [])]
}
for target in targets:
commands.setdefault(target, []).extend(sorted(provided_names))

paths = {
path.parent if path.name == "SKILL.md" else path
for path, _ in registrar.iter_command_artifacts(commands, project_root)
}
skills = snapshot.metadata.get("registered_skills", {} if is_preset else [])
if isinstance(manager, PresetManager):
if isinstance(skills, list):
skills = manager._infer_legacy_skill_provenance(
skills, snapshot.component.id, fallback_agent=active or ""
)
for agent, names in skills.items():
directory = manager._safe_skills_dir_for_agent(agent)
if directory is not None:
paths.update(
directory / name for name in names
if manager._is_safe_registry_skill_name(name)
)
active_skills = manager._resolve_agent_skills_dir(active) if active else None
else:
paths.add(manager.extensions_dir / ".backup" / snapshot.component.id)
paths.update(manager._find_extension_skill_dirs(
skills, snapshot.component.id, create_skills_dir=False
))
active_skills = manager._get_skills_dir(create=False)
if active_skills is not None:
paths.update(
active_skills / name
for command in provided_names
for name in PresetManager._skill_names_for_command(command)
)

if snapshot.directory is None:
raise BundlerError(f"Missing payload snapshot for {snapshot.component.label()}.")
artifact_backup = snapshot.directory.parent / ".artifacts"
artifact_backup.mkdir()
roots = (Path(os.path.abspath(project_root)), Path.home())
captured: list[Path] = []
for path in sorted({Path(os.path.abspath(p)) for p in paths}, key=lambda p: (len(p.parts), str(p))):
if any(path.is_relative_to(parent) for parent in captured):
continue
root = next((root for root in roots if path.is_relative_to(root)), None)
if root is None:
raise BundlerError(f"Artifact is outside the project and home roots: {path}")
_validate_safe_shared_directory(root, path.parent)
backup = None
if path.exists() or path.is_symlink():
backup = artifact_backup / str(len(captured))
if path.is_dir():
_validate_safe_shared_directory(root, path)
shutil.copytree(path, backup, symlinks=True)
else:
shutil.copy2(path, backup, follow_symlinks=False)
parent = path.parent
while parent != root and not parent.exists():
snapshot.absent_artifact_parents.add((root, parent))
parent = parent.parent
snapshot.artifacts.append(ArtifactSnapshot(path, backup, root))
captured.append(path)
cleanup.pop_all()
return snapshot


def restore_generated_artifacts(snapshot: ComponentSnapshot) -> None:
for artifact in snapshot.artifacts:
path = artifact.path
_validate_safe_shared_directory(artifact.trusted_root, path.parent)
if path.is_symlink() or path.is_file():
path.unlink()
elif path.exists():
shutil.rmtree(path)
if artifact.backup is not None:
path.parent.mkdir(parents=True, exist_ok=True)
if artifact.backup.is_dir() and not artifact.backup.is_symlink():
shutil.copytree(artifact.backup, path, symlinks=True)
else:
shutil.copy2(artifact.backup, path, follow_symlinks=False)
for root, parent in sorted(
snapshot.absent_artifact_parents, key=lambda entry: len(entry[1].parts), reverse=True
):
_validate_safe_shared_directory(root, parent)
if parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
Loading