Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1239cbe
fix(presets): recompose surviving skills when a lower preset layer is…
yxshee Oct 17, 2026
8c0328f
fix(presets): limit removal recomposition to owned skill names
yxshee Oct 19, 2026
57bdfc9
fix(presets): retire a composed skill whose only base was removed
yxshee Oct 19, 2026
c184e1a
fix(presets): guard, widen and journal stale composed-cache deletion
yxshee Oct 19, 2026
b9a2c35
fix(presets): harden no-base cleanup against unsafe keys and read fai…
yxshee Oct 19, 2026
b38ba53
fix(presets): reject junctions in cache cleanup, filter historical wr…
yxshee Oct 19, 2026
3738814
fix(presets): reconcile composed skills on every path, historical dir…
yxshee Oct 19, 2026
a9a2000
refactor(presets): rework removal reconciliation around registration …
yxshee Oct 20, 2026
cf44847
fix(presets): reconcile native-skill aliases of composed commands
yxshee Nov 2, 2026
960f902
fix(presets): keep alias reconciliation active-only and failure-safe
yxshee Nov 3, 2026
a0defa5
fix(presets): refresh survivor-owned skills from the owner's layer
yxshee Nov 3, 2026
d44bee0
fix(presets): keep a lower survivor's skill when its base is unreadable
yxshee Nov 4, 2026
5f24cd3
fix(presets): refresh shadowed survivors' aliases from the owner's layer
yxshee Nov 4, 2026
25979c7
fix(presets): validate the composed cache path before refreshing an a…
yxshee Nov 4, 2026
cd5296a
fix(presets): keep survivor aliases and retire baseless shadowed ones
yxshee Nov 5, 2026
50c62a0
fix(presets): replace alias output instead of writing it in place
yxshee Nov 5, 2026
f956449
fix(agents): keep output permissions when replacing a registered file
yxshee Nov 6, 2026
586db13
fix(presets): key alias ownership on its declarer; keep modes without…
yxshee Nov 7, 2026
53a373e
fix(agents): never expose replacement text through a wider temp file
yxshee Nov 8, 2026
a6a344f
fix(agents): close the temp descriptor when restoring its mode fails
yxshee Nov 8, 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
71 changes: 68 additions & 3 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import hashlib
import os
import re
import secrets
import stat
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
Expand Down Expand Up @@ -1016,6 +1018,69 @@ def register_commands(

return registered

@staticmethod
def _replace_file_text(dest: Path, content: str) -> None:
"""Write ``content`` to ``dest`` by replacing its directory entry.

An in-place write reaches every hard link of the file, so an alias
linked to a file outside the project would carry the new text there
too. Writing a sibling temp file and renaming it over ``dest`` only
changes what ``dest`` names.

The replacement keeps an existing file's permission bits, and the
temp file is created with (at most) those bits and set to them before
any content is written, so the new text is never readable more widely
than the destination. A new file is created with mode 0666 so the
kernel applies the process umask, the same as an in-place write; the
umask itself is never changed.
The temp name has a fixed short length, so any name valid for
``dest`` stays writable.
"""
try:
dest_stat = os.lstat(dest)
except FileNotFoundError:
dest_stat = None
keep_mode = (
stat.S_IMODE(dest_stat.st_mode)
if dest_stat is not None and stat.S_ISREG(dest_stat.st_mode)
else None
)
# O_BINARY (Windows) leaves newline translation to the text wrapper
# below, as ``tempfile.mkstemp`` and ``Path.write_text`` do.
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_BINARY", 0)
)
for _attempt in range(100):
temp_path = dest.parent / f".speckit-{secrets.token_hex(8)}.tmp"
try:
fd = os.open(
temp_path, flags, 0o666 if keep_mode is None else keep_mode
)
except FileExistsError:
continue
break
else:
raise FileExistsError(
f"Could not create a temporary file next to {dest}"
)
try:
# Wrap the descriptor first so a failing ``chmod`` cannot leak it:
# registration catches write errors and carries on.
with os.fdopen(fd, "w", encoding="utf-8") as fh:
if keep_mode is not None:
# The umask may have cleared some of the destination's
# bits; restore them before writing. This never widens
# the mode beyond the destination's own.
os.chmod(temp_path, keep_mode)
fh.write(content)
os.replace(temp_path, dest)
finally:
temp_path.unlink(missing_ok=True)

@staticmethod
def _write_registered_output(
dest_file: Path,
Expand All @@ -1038,7 +1103,7 @@ def _write_registered_output(
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
if dest_file.is_symlink():
dest_file.unlink()
dest_file.write_text(content, encoding="utf-8")
CommandRegistrar._replace_file_text(dest_file, content)
after_file_change(dest_file)
return

Expand All @@ -1050,7 +1115,7 @@ def _write_registered_output(
try:
before_file_change(cache_file)
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(content, encoding="utf-8")
CommandRegistrar._replace_file_text(cache_file, content)
after_file_change(cache_file)
if dest_file.exists() or dest_file.is_symlink():
dest_file.unlink()
Expand All @@ -1062,7 +1127,7 @@ def _write_registered_output(
# functional by falling back to a copy.
if dest_file.is_symlink():
dest_file.unlink()
dest_file.write_text(content, encoding="utf-8")
CommandRegistrar._replace_file_text(dest_file, content)
after_file_change(dest_file)

@staticmethod
Expand Down
4 changes: 4 additions & 0 deletions src/specify_cli/presets/_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,10 @@ def remove(self, pack_id: str) -> bool:
registered_commands = commands_to_unregister

# Unregister non-skill command files from AI agents.
if registered_commands:
registered_commands = self._drop_survivor_owned_aliases(
pack_id, registered_commands
)
if registered_commands:
self._unregister_commands(registered_commands)

Expand Down
Loading