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
21 changes: 21 additions & 0 deletions design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,27 @@ partial and shared files are preserved by manifest-only uninstall.
`teardown()` preserves modified tracked files by default; `--force` can remove
them. Keep agent-specific settings and events consistent with that lifecycle.

Event cleanup releases only the departing integration's dispatcher claim.
It deletes `.specify/events.py` only when that integration's current or prior
manifest records a produced copy and no other installed integration references
it. Unowned, partial, or merely recovered files are preserved, even with `--force`;
modified managed dispatchers are preserved with a warning unless forced.
An exact match with the current generated dispatcher (LF or CRLF) also permits
last-consumer cleanup when another integration regenerated the file and left
this integration's recorded hash stale. Matching generated content alone never
establishes ownership.
If the prior ownership manifest or non-forced dispatcher content cannot be
read, cleanup warns and preserves the dispatcher without blocking cleanup of
other integration-owned files.
Empty-event upgrades consult the prior manifest so the last managed dispatcher
is still cleaned up. Event helpers accept keyword-only `force=False`.
Teardown forwards `force` to `remove_events()` overrides that accept it
explicitly or via `**kwargs`; legacy `(project_root, manifest)` overrides
remain supported without that keyword. Signature compatibility is checked
before invocation, so errors raised inside an override are not retried.
Custom `setup()` implementations forwarding event emission should pass through
`opts["force"]` when present.

The integration does **not** own agent context files such as `AGENTS.md` or
`CLAUDE.md`. The opt-in `extensions/agent-context/` owns their defaults,
configuration, and managed sections; do not add `context_file` fields or
Expand Down
94 changes: 60 additions & 34 deletions src/specify_cli/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -1356,6 +1357,8 @@ def install_integration_events(
project_root: Path,
manifest: IntegrationManifest,
events: ResolvedEvents,
*,
force: bool = False,
) -> list[Path]:
"""Generate dispatcher, merge native config, return created files.

Expand Down Expand Up @@ -1388,7 +1391,7 @@ def install_integration_events(
# new manifest no longer claims it and stale cleanup excludes it).
if not filtered:
_remove_native_event_hooks(integration, project_root, manifest)
_cleanup_shared_dispatcher(integration, project_root, manifest)
_cleanup_shared_dispatcher(integration, project_root, manifest, force=force)
return []

created: list[Path] = []
Expand Down Expand Up @@ -1717,51 +1720,74 @@ def _other_event_integrations_reference_dispatcher(


def _cleanup_shared_dispatcher(
integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest,
*,
force: bool = False,
) -> None:
"""Drop this integration's manifest claim on the shared dispatcher and
delete the file only when no other installed event-capable integration
still references it (#10, S3).

The manifest.remove() runs in both branches (S1): if we retain the file
but leave it tracked, the subsequent manifest.uninstall() in teardown()
sees the matching hash and deletes the file another integration still
depends on. Used by full teardown and by the empty-resolved-map install
path so an ``--events false`` upgrade of the last event integration
doesn't orphan ``.specify/events.py`` permanently (S3).
"""
"""Release this integration's claim; delete only its last managed copy."""
from ..integrations.manifest import IntegrationManifest

dispatcher_rel = EVENTS_DISPATCHER_REL
# Drop this integration's manifest claim if present. The remove() is
# conditional (S1): an upgrade passes a *fresh* manifest that may never
# have claimed the dispatcher, so the key may be absent — that's a no-op.
owner = manifest
# Upgrades emit into a fresh manifest before replacing the installed one.
# Only this integration's prior manifest can supply its missing claim.
if dispatcher_rel not in owner.files:
try:
owner = IntegrationManifest.load(integration.key, project_root)
except FileNotFoundError:
owner = manifest
except (OSError, ValueError) as exc:
logger.warning(
"Could not read prior dispatcher manifest for '%s'; preserving %s: %s",
integration.key, project_root / dispatcher_rel, exc,
)
return
expected_hash = owner.files.get(dispatcher_rel)
recovered = owner.is_recovered(dispatcher_rel)
partial = owner.ownership_modes.get(dispatcher_rel) == "partial"

# Release even retained claims so manifest-only teardown cannot delete a
# user-owned copy or a dispatcher still used by another integration.
if dispatcher_rel in manifest.files:
manifest.remove(dispatcher_rel)
# S2: run the no-other-references deletion independently of whether the
# new manifest currently contains the key. An ``integration upgrade
# --events false`` passes a fresh manifest that never recorded the
# dispatcher, so gating the deletion on its presence orphans the file the
# old on-disk manifest owned — and stale cleanup excludes it (C3). If no
# other installed event-capable integration references the dispatcher,
# delete it; otherwise leave it for them.
if not _other_event_integrations_reference_dispatcher(project_root, integration.key):
dispatcher_path = project_root / dispatcher_rel
if dispatcher_path.exists():
_ensure_safe_destination(dispatcher_path)
_unlink_file(dispatcher_path, missing_ok=True)
if not expected_hash or recovered or partial:
return
if _other_event_integrations_reference_dispatcher(project_root, integration.key):
return
dispatcher_path = project_root / dispatcher_rel
if dispatcher_path.exists():
_ensure_safe_destination(dispatcher_path)
if not force:
try:
content = dispatcher_path.read_bytes()
except OSError as exc:
logger.warning("Preserving unreadable dispatcher %s: %s", dispatcher_path, exc)
return
# Another integration may have regenerated the shared file since
# this manifest recorded it. Accept only exact generated LF/CRLF.
matches = hashlib.sha256(content).hexdigest() == expected_hash or content in (
_EVENTS_DISPATCHER_TEMPLATE.encode("utf-8"),
_EVENTS_DISPATCHER_TEMPLATE.replace("\n", "\r\n").encode("utf-8"),
)
if not matches:
logger.warning("Preserving modified dispatcher: %s", dispatcher_path)
return
_unlink_file(dispatcher_path, missing_ok=True)


def remove_integration_events(
integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest,
*,
force: bool = False,
) -> None:
"""Remove Specify-authored event entries from native config.

The shared ``.specify/events.py`` dispatcher is deleted only when no other
installed event-capable integration still references it (#10); otherwise
it is left in place so multi-install setups don't lose the dispatcher
mid-stream.
The shared dispatcher is deleted only if this integration owns it and no
other installed integration references it. Modified copies require force;
unowned or recovered copies are always retained.
"""
_remove_native_event_hooks(integration, project_root, manifest)
_cleanup_shared_dispatcher(integration, project_root, manifest)
_cleanup_shared_dispatcher(integration, project_root, manifest, force=force)

# Clean up opencode TS plugin (owned solely by the opencode integration).
if integration.key == "opencode":
Expand Down
30 changes: 23 additions & 7 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,15 @@ def teardown(

Returns ``(removed, skipped)`` file lists.
"""
self.remove_events(project_root, manifest)
import inspect

remove_events = self.remove_events
try:
inspect.signature(remove_events).bind(project_root, manifest, force=force)
except TypeError:
remove_events(project_root, manifest)
else:
remove_events(project_root, manifest, force=force)
return manifest.uninstall(project_root, force=force)

def emit_events(
Expand All @@ -1071,15 +1079,19 @@ def emit_events(
**opts: Any,
) -> list[Path]:
"""Emit native event configuration for this integration."""
return install_integration_events(self, project_root, manifest, events or {})
return install_integration_events(
self, project_root, manifest, events or {}, force=opts.get("force", False)
)

def remove_events(
self,
project_root: Path,
manifest: IntegrationManifest,
*,
force: bool = False,
) -> None:
"""Remove Specify-authored event entries from native config."""
remove_integration_events(self, project_root, manifest)
remove_integration_events(self, project_root, manifest, force=force)

def supports_events(self) -> bool:
"""Return True if this integration supports agent-native events."""
Expand Down Expand Up @@ -1209,7 +1221,8 @@ def setup(

# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options,
force=opts.get("force", False),
)
created.extend(event_files)

Expand Down Expand Up @@ -1426,7 +1439,8 @@ def setup(

# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options,
force=opts.get("force", False),
)
created.extend(event_files)

Expand Down Expand Up @@ -1668,7 +1682,8 @@ def setup(

# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options,
force=opts.get("force", False),
)
created.extend(event_files)

Expand Down Expand Up @@ -1954,7 +1969,8 @@ def setup(

# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options,
force=opts.get("force", False),
)
created.extend(event_files)

Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ def integration_upgrade(
script_type=selected_script,
raw_options=raw_options,
events=events_map,
force=force,
)
settings = _with_integration_setting(
current,
Expand Down
3 changes: 2 additions & 1 deletion src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,8 @@ def setup(

# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options,
force=opts.get("force", False),
)
created.extend(event_files)
return created
Expand Down
60 changes: 60 additions & 0 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,66 @@ def test_uninstall_delegates_to_teardown(self, tmp_path):
assert removed == []
assert skipped == []

@pytest.mark.parametrize("force", [False, True])
def test_teardown_supports_legacy_remove_events(self, tmp_path, force):
calls = []

class LegacyIntegration(StubIntegration):
def remove_events(self, project_root, manifest):
calls.append((project_root, manifest))

integration = LegacyIntegration()
manifest = IntegrationManifest(integration.key, tmp_path)
generated = manifest.record_file(".stub/commands/speckit.plan.md", "# Plan\n")
manifest.save()

removed, skipped = integration.teardown(tmp_path, manifest, force=force)

assert calls == [(tmp_path, manifest)]
assert removed == [generated]
assert skipped == []
assert not generated.exists()
assert not manifest.manifest_path.exists()

@pytest.mark.parametrize("force", [False, True])
@pytest.mark.parametrize("style", ["keyword", "kwargs"])
def test_teardown_forwards_supported_force(self, tmp_path, force, style):
calls = []

class KeywordIntegration(StubIntegration):
def remove_events(self, project_root, manifest, *, force=False):
calls.append(force)

class KwargsIntegration(StubIntegration):
def remove_events(self, project_root, manifest, **kwargs):
calls.append(kwargs["force"])

integration = KeywordIntegration() if style == "keyword" else KwargsIntegration()
manifest = IntegrationManifest(integration.key, tmp_path)

integration.teardown(tmp_path, manifest, force=force)

assert calls == [force]

@pytest.mark.parametrize("force", [False, True])
def test_teardown_does_not_retry_remove_events_type_error(self, tmp_path, force):
calls = []

class BrokenIntegration(StubIntegration):
def remove_events(self, project_root, manifest, *, force=False):
calls.append(force)
raise TypeError("adapter cleanup failed")

integration = BrokenIntegration()
manifest = IntegrationManifest(integration.key, tmp_path)
generated = manifest.record_file(".stub/commands/speckit.plan.md", "# Plan\n")

with pytest.raises(TypeError, match="adapter cleanup failed"):
integration.teardown(tmp_path, manifest, force=force)

assert calls == [force]
assert generated.exists()


class TestMarkdownIntegration:
def test_is_subclass_of_base(self):
Expand Down
Loading
Loading