fix: close adapter metadata and rollback gaps · github/spec-kit@a72c6bc · GitHub
Skip to content

Commit a72c6bc

Browse files
mnriemCopilot
andcommitted
fix: close adapter metadata and rollback gaps
Keep workflow inspection metadata-only, advertise install-enabled catalog adapters, and journal host settings, events, legacy migration, rendering caches, and participating built-in home outputs without changing uninstall ownership. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 8678402 commit a72c6bc

19 files changed

Lines changed: 424 additions & 66 deletions

File tree

‎design/integration.md‎

Lines changed: 7 additions & 0 deletions

‎docs/reference/integrations.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ and an archive `download_url`; an optional explicit `id` must match the map key;
162162
an archive `sha256` digest is recommended. Downloads support ZIP, tar.gz, and tgz,
163163
HTTPS or loopback HTTP, and the existing authenticated GitHub asset flow.
164164
See the [catalog schema](../../integrations/README.md#catalog-schema).
165+
Search advertises `specify integration install <id>` for install-enabled sources;
166+
discovery-only results do not advertise installation.
165167

166168
A package contains root `integration.yml` and `__init__.py`, not a copied
167169
inventory of Spec Kit's commands. The host renders shared templates through the
@@ -172,6 +174,9 @@ load the trusted package without consulting the catalog. Missing, modified, or
172174
incompatible code is an error, not a silent fallback. Upgrade installs the
173175
catalog's current adapter version; uninstall removes its persisted code while
174176
preserving modified generated files by default.
177+
Metadata-only `workflow status` and `workflow info` remain available without
178+
loading adapter code, including when an installed adapter is damaged. Workflow
179+
execution and resume still report adapter-loading failures explicitly.
175180

176181
Execution consent is stored in `~/.specify/integration-trust.json`, bound to
177182
the canonical project root, integration ID, and complete verified package

‎src/specify_cli/agents.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,8 +1056,10 @@ def _write_registered_output(
10561056
CommandRegistrar._ensure_inside(cache_file, cache_root)
10571057

10581058
try:
1059+
before_file_change(cache_file)
10591060
cache_file.parent.mkdir(parents=True, exist_ok=True)
10601061
cache_file.write_text(content, encoding="utf-8")
1062+
after_file_change(cache_file)
10611063
if dest_file.exists() or dest_file.is_symlink():
10621064
dest_file.unlink()
10631065
target = os.path.relpath(cache_file, dest_file.parent)

‎src/specify_cli/events/__init__.py‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import yaml
3030
import typer
3131

32+
from ..integrations._file_changes import unlink as _unlink_file, write_text as _write_text
33+
3234
if TYPE_CHECKING:
3335
from ..integrations.base import IntegrationBase
3436
from ..integrations.manifest import IntegrationManifest
@@ -1394,7 +1396,7 @@ def install_integration_events(
13941396
dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME
13951397
_ensure_safe_destination(dispatcher_path)
13961398
dispatcher_dir.mkdir(parents=True, exist_ok=True)
1397-
dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8")
1399+
_write_text(dispatcher_path, _EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8")
13981400
dispatcher_path.chmod(0o755)
13991401
manifest.record_file(
14001402
str(dispatcher_path.relative_to(project_root)),
@@ -1416,7 +1418,8 @@ def install_integration_events(
14161418
plugin_path = project_root / plugin_rel
14171419
_ensure_safe_destination(plugin_path)
14181420
plugin_path.parent.mkdir(parents=True, exist_ok=True)
1419-
plugin_path.write_text(
1421+
_write_text(
1422+
plugin_path,
14201423
_build_opencode_plugin(filtered, canonical_to_native),
14211424
encoding="utf-8",
14221425
)
@@ -1747,7 +1750,7 @@ def _cleanup_shared_dispatcher(
17471750
dispatcher_path = project_root / dispatcher_rel
17481751
if dispatcher_path.exists():
17491752
_ensure_safe_destination(dispatcher_path)
1750-
dispatcher_path.unlink(missing_ok=True)
1753+
_unlink_file(dispatcher_path, missing_ok=True)
17511754

17521755

17531756
def remove_integration_events(
@@ -1770,7 +1773,7 @@ def remove_integration_events(
17701773
plugin_path = project_root / plugin_rel
17711774
if plugin_path.exists():
17721775
_ensure_safe_destination(plugin_path)
1773-
plugin_path.unlink(missing_ok=True)
1776+
_unlink_file(plugin_path, missing_ok=True)
17741777
manifest.remove(plugin_rel)
17751778

17761779

@@ -2156,7 +2159,7 @@ def _remove_opencode_entries(config_path: Path) -> bool:
21562159
else:
21572160
existing.pop("plugin", None)
21582161
if not existing:
2159-
config_path.unlink(missing_ok=True)
2162+
_unlink_file(config_path, missing_ok=True)
21602163
return True
21612164
_safe_write_json(config_path, existing)
21622165
return False
@@ -2195,7 +2198,7 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
21952198
if not fragment and stripped == existing:
21962199
return False
21972200
dst.parent.mkdir(parents=True, exist_ok=True)
2198-
dst.write_text(stripped.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
2201+
_write_text(dst, stripped.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
21992202
return True
22002203

22012204

@@ -2231,7 +2234,7 @@ def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool:
22312234
flags=re.DOTALL,
22322235
)
22332236
dst.parent.mkdir(parents=True, exist_ok=True)
2234-
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
2237+
_write_text(dst, existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
22352238
return True
22362239

22372240

@@ -2280,9 +2283,9 @@ def _remove_toml_entries(dst: Path) -> bool:
22802283
if line.strip() and not line.strip().startswith("#")
22812284
)
22822285
if not stripped:
2283-
dst.unlink(missing_ok=True)
2286+
_unlink_file(dst, missing_ok=True)
22842287
return True
2285-
dst.write_text(cleaned, encoding="utf-8")
2288+
_write_text(dst, cleaned, encoding="utf-8")
22862289
return False
22872290

22882291

@@ -2317,9 +2320,9 @@ def _remove_vibe_toml_entries(dst: Path) -> bool:
23172320
if line.strip() and not line.strip().startswith("#")
23182321
)
23192322
if not stripped:
2320-
dst.unlink(missing_ok=True)
2323+
_unlink_file(dst, missing_ok=True)
23212324
return True
2322-
dst.write_text(cleaned, encoding="utf-8")
2325+
_write_text(dst, cleaned, encoding="utf-8")
23232326
return False
23242327

23252328

@@ -2388,7 +2391,7 @@ def _remove_copilot_entries(dst: Path) -> bool:
23882391
# ``version`` key would remain — no user content to preserve.
23892392
user_keys = {k for k in existing if k != "version"}
23902393
if not user_keys:
2391-
dst.unlink(missing_ok=True)
2394+
_unlink_file(dst, missing_ok=True)
23922395
return True
23932396
_safe_write_json(dst, existing)
23942397
return False
@@ -2485,7 +2488,7 @@ def _merge_json_root(dst: Path, new_hooks: dict) -> bool:
24852488
else:
24862489
existing = {}
24872490
if not existing:
2488-
dst.unlink(missing_ok=True)
2491+
_unlink_file(dst, missing_ok=True)
24892492
return True
24902493
_safe_write_json(dst, existing)
24912494
return True
@@ -2511,7 +2514,7 @@ def _remove_json_root_entries(dst: Path) -> bool:
25112514
if kept_entries:
25122515
cleaned[event] = kept_entries
25132516
if not cleaned:
2514-
dst.unlink(missing_ok=True)
2517+
_unlink_file(dst, missing_ok=True)
25152518
return True
25162519
_safe_write_json(dst, cleaned)
25172520
return False
@@ -2576,7 +2579,8 @@ def _safe_write_json(dst: Path, data: dict) -> None:
25762579
_ensure_safe_destination(dst)
25772580
dst.parent.mkdir(parents=True, exist_ok=True)
25782581
# A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape.
2579-
dst.write_text(
2582+
_write_text(
2583+
dst,
25802584
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
25812585
encoding="utf-8",
25822586
errors="backslashreplace",
@@ -2642,7 +2646,7 @@ def _remove_json_entries(dst: Path) -> bool:
26422646
# doesn't leave a generated stub behind.
26432647
user_keys = {k for k in existing if k != "version"}
26442648
if not user_keys:
2645-
dst.unlink(missing_ok=True)
2649+
_unlink_file(dst, missing_ok=True)
26462650
return True
26472651
_safe_write_json(dst, existing)
26482652
return False

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,6 +1612,7 @@ def _register_extension_skills(
16121612
from ..agents import CommandRegistrar
16131613
from ..integrations import get_integration
16141614
from ..integrations.base import IntegrationBase
1615+
from ..integrations._file_changes import changing_file, unlink, write_text
16151616

16161617
written: List[str] = []
16171618
opts = load_init_options(self.project_root)
@@ -1769,19 +1770,20 @@ def _replacement(match: re.Match[str]) -> str:
17691770
if use_dev_symlink:
17701771
try:
17711772
cache_file.parent.mkdir(parents=True, exist_ok=True)
1772-
cache_file.write_text(skill_content, encoding="utf-8")
1773+
write_text(cache_file, skill_content, encoding="utf-8")
17731774
if skill_file.exists() or skill_file.is_symlink():
1774-
skill_file.unlink()
1775+
unlink(skill_file)
17751776
target = os.path.relpath(cache_file, skill_file.parent)
1776-
os.symlink(target, skill_file)
1777+
with changing_file(skill_file):
1778+
os.symlink(target, skill_file)
17771779
except (OSError, ValueError):
17781780
if skill_file.is_symlink():
1779-
skill_file.unlink()
1780-
skill_file.write_text(skill_content, encoding="utf-8")
1781+
unlink(skill_file)
1782+
write_text(skill_file, skill_content, encoding="utf-8")
17811783
else:
17821784
if skill_file.is_symlink():
1783-
skill_file.unlink()
1784-
skill_file.write_text(skill_content, encoding="utf-8")
1785+
unlink(skill_file)
1786+
write_text(skill_file, skill_content, encoding="utf-8")
17851787
written.append(skill_name)
17861788

17871789
return written
@@ -2055,6 +2057,7 @@ def _restore_generic_refresh_artifacts(
20552057
_ensure_safe_shared_directory,
20562058
_validate_safe_shared_directory,
20572059
)
2060+
from ..integrations._file_changes import changing_file, unlink, write_bytes
20582061

20592062
root = self.project_root.resolve()
20602063
source = (self.extensions_dir / extension_id).resolve()
@@ -2067,15 +2070,15 @@ def _restore_generic_refresh_artifacts(
20672070
raise ValueError("unexpected symlink at output path")
20682071
if os.readlink(path) == link:
20692072
continue
2070-
path.unlink()
2073+
unlink(path)
20712074
elif path.exists() and not path.is_file():
20722075
raise ValueError("output path is no longer a file")
20732076
elif content is not None and link is None and path.is_file():
20742077
if path.read_bytes() == content:
20752078
continue
20762079
if content is None:
20772080
if path.is_file():
2078-
path.unlink()
2081+
unlink(path)
20792082
if not parent_existed and path.parent.is_dir():
20802083
try:
20812084
path.parent.rmdir()
@@ -2085,10 +2088,11 @@ def _restore_generic_refresh_artifacts(
20852088
_ensure_safe_shared_directory(root, path.parent)
20862089
if link is not None:
20872090
if path.is_file():
2088-
path.unlink()
2089-
path.symlink_to(link)
2091+
unlink(path)
2092+
with changing_file(path):
2093+
path.symlink_to(link)
20902094
else:
2091-
path.write_bytes(content)
2095+
write_bytes(path, content)
20922096
except (OSError, ValueError) as exc:
20932097
errors.append(f"{path}: {exc}")
20942098
if errors:
@@ -2156,6 +2160,7 @@ def _remove_generic_artifact_paths(
21562160
) -> None:
21572161
"""Clean recorded generic paths even after the configured directory moves."""
21582162
from ..shared_infra import _validate_safe_shared_directory
2163+
from ..integrations._file_changes import unlink, write_bytes
21592164

21602165
manifest = self.get_extension(extension_id)
21612166
registered = metadata.get("registered_commands", {})
@@ -2205,10 +2210,10 @@ def _remove_generic_artifact_paths(
22052210
content = path.read_bytes()
22062211
if hashlib.sha256(content).hexdigest() != expected:
22072212
if path.is_symlink():
2208-
path.unlink()
2209-
path.write_bytes(content)
2213+
unlink(path)
2214+
write_bytes(path, content)
22102215
continue
2211-
path.unlink()
2216+
unlink(path)
22122217
if skill_output:
22132218
try:
22142219
path.parent.rmdir()
@@ -2243,6 +2248,8 @@ def _unregister_extension_skills(
22432248
every configured agent's skills directory is scanned
22442249
instead of resolving just the currently active one.
22452250
"""
2251+
from ..integrations._file_changes import changing_file, unlink
2252+
22462253
generic_roots = {
22472254
Path(path).parent.parent
22482255
for path in (generic_hashes or {})
@@ -2269,13 +2276,14 @@ def _unregister_extension_skills(
22692276
skill_file.read_bytes()
22702277
).hexdigest():
22712278
continue
2272-
skill_file.unlink()
2279+
unlink(skill_file)
22732280
try:
22742281
skill_subdir.rmdir()
22752282
except OSError:
22762283
pass
22772284
continue
2278-
shutil.rmtree(skill_subdir)
2285+
with changing_file(skill_subdir, removal=True):
2286+
shutil.rmtree(skill_subdir)
22792287

22802288
def _extension_owned_skill_names(
22812289
self, skill_names: List[str], extension_id: str

‎src/specify_cli/integrations/_file_changes.py‎

Lines changed: 24 additions & 0 deletions

0 commit comments

Comments
 (0)