refactor: share category detection and prune vendored SKILL.md hits

/simplify-code findings on the salvage stack:
- extract _category_skill_dirs() as the single category detector; the
  install guard and hermes_cli._existing_categories() now share it
  (third copy of the heuristic eliminated)
- filter rglob hits through is_excluded_skill_path so vendored /
  support-dir SKILL.md files (node_modules, references/pkg) no longer
  misclassify a plain directory as a category and block install
- fix inaccurate WHY comment (lock-file check only guards hub-installed
  skills, not hand-authored dirs), drop underscore prefixes on locals,
  fold the file-collision guard under the single exists() check
This commit is contained in:
kshitijk4poor
2026-08-02 13:18:32 +05:30
committed by kshitij
parent 881ac52423
commit 0f2da9687f
2 changed files with 60 additions and 52 deletions
+9 -21
View File
@@ -23,7 +23,6 @@ from rich.table import Table
# Lazy imports to avoid circular dependencies and slow startup.
# tools.skills_hub and tools.skills_guard are imported inside functions.
from hermes_constants import display_hermes_home
from agent.skill_utils import is_excluded_skill_path
_console = Console()
@@ -184,29 +183,18 @@ def _existing_categories() -> List[str]:
Used to suggest reusable categories when interactively installing from a
URL. Hidden dirs (``.hub``, ``.trash``) are skipped.
"""
from tools.skills_hub import SKILLS_DIR
out: List[str] = []
from tools.skills_hub import SKILLS_DIR, _category_skill_dirs
try:
for entry in SKILLS_DIR.iterdir():
if not entry.is_dir() or entry.name.startswith("."):
continue
# Only count as a category if it contains skills, not if it IS a skill.
# Heuristic: if ``<entry>/SKILL.md`` exists, it's a skill at the
# top level (no category); otherwise treat as a category bucket.
if (entry / "SKILL.md").exists():
continue
# Has at least one nested SKILL.md (excluding dependency/cache dirs)?
try:
if any(
not is_excluded_skill_path(p)
for p in entry.rglob("SKILL.md")
):
out.append(entry.name)
except OSError:
continue
# _category_skill_dirs returns children containing any active
# SKILL.md — including top-level skills themselves. Only children
# WITHOUT their own SKILL.md are category buckets.
return sorted(
name
for name in set(_category_skill_dirs(SKILLS_DIR))
if not (SKILLS_DIR / name / "SKILL.md").exists()
)
except (FileNotFoundError, OSError):
return []
return sorted(set(out))
def _prompt_for_skill_name(c: Console, url: str, default: str = "") -> Optional[str]:
+51 -31
View File
@@ -3680,6 +3680,32 @@ def quarantine_bundle(bundle: SkillBundle) -> Path:
return dest
def _category_skill_dirs(directory: Path) -> List[str]:
"""Names of direct children of *directory* that contain skills.
A child counts when it is a non-hidden directory holding at least one
active ``SKILL.md`` anywhere below it (recursive, so nested category
layouts like ``mlops/training/<skill>`` are detected). Vendored,
cache, and progressive-disclosure support paths are pruned via
:func:`is_excluded_skill_path` so a lone ``node_modules`` or
``references/pkg/SKILL.md`` hit does not misclassify the directory as
a category. Shared by the install-time category guard here and
``hermes_cli.skills_hub._existing_categories``.
"""
skill_dirs: List[str] = []
for entry in directory.iterdir():
if not entry.is_dir() or entry.name.startswith("."):
continue
for skill_md in entry.rglob("SKILL.md"):
if is_excluded_skill_path(
skill_md.relative_to(directory), root=directory
):
continue
skill_dirs.append(entry.name)
break
return skill_dirs
def install_from_quarantine(
quarantine_path: Path,
skill_name: str,
@@ -3711,49 +3737,43 @@ def install_from_quarantine(
# skill-plus-category directory; a later update or uninstall of the outer
# skill would then rmtree the inner one — the sibling case of the
# category-bucket wipe reported in issue #75983.
_skills_root = _skills_dir().resolve()
_ancestor = install_dir.parent
while _ancestor != _skills_root and _ancestor.is_relative_to(_skills_root):
if (_ancestor / "SKILL.md").is_file():
skills_root = _skills_dir().resolve()
ancestor = install_dir.parent
while ancestor != skills_root and ancestor.is_relative_to(skills_root):
if (ancestor / "SKILL.md").is_file():
raise ValueError(
f"Refusing to install into '{_ancestor.name}': it is an "
f"Refusing to install into '{ancestor.name}': it is an "
f"existing skill directory, not a category. Choose a "
f"different category."
)
_ancestor = _ancestor.parent
if install_dir.exists() and not install_dir.is_dir():
# A stray regular file at the install path. rmtree() on a file raises
# NotADirectoryError (an uncaught traceback at the CLI); refuse with
# the same actionable ValueError contract the guards below use.
raise ValueError(
f"Refusing to install: '{install_dir.name}' already exists and "
f"is not a directory. Remove it or choose a different skill name."
)
ancestor = ancestor.parent
if install_dir.exists():
if not install_dir.is_dir():
# A stray regular file at the install path. rmtree() on a file
# raises NotADirectoryError (an uncaught traceback at the CLI);
# refuse with the same actionable ValueError contract instead.
raise ValueError(
f"Refusing to install: '{install_dir.name}' already exists "
f"and is not a directory. Remove it or choose a different "
f"skill name."
)
# Guard against silent data loss when the install target collides with
# an existing category bucket (a directory that holds other skills).
# This was reported as GitHub issue #75983: installing a skill with
# --name matching an existing category directory caused rmtree to wipe
# all sibling skills. An existing skill installation (a directory that
# directly contains SKILL.md) is safe to overwrite — that path is
# already guarded by the lock-file check in do_install(). But a
# directory that contains *other* skill directories is a category bucket
# and must NOT be silently deleted.
if install_dir.is_dir() and not (install_dir / "SKILL.md").exists():
_skill_dirs_in = [
entry.name
for entry in install_dir.iterdir()
if entry.is_dir()
and not entry.name.startswith(".")
and any(entry.rglob("SKILL.md"))
]
if _skill_dirs_in:
# all sibling skills. A directory that directly contains SKILL.md is
# an existing skill installation and stays overwritable (hub-installed
# skills are additionally guarded by the lock-file check in
# do_install()). But a directory that contains *other* skill
# directories is a category bucket and must NOT be silently deleted.
if not (install_dir / "SKILL.md").exists():
skill_dirs_in = _category_skill_dirs(install_dir)
if skill_dirs_in:
raise ValueError(
f"Refusing to overwrite category directory '{install_dir}' "
f"which contains {len(_skill_dirs_in)} skill(s): "
f"{', '.join(sorted(_skill_dirs_in))}. "
f"which contains {len(skill_dirs_in)} skill(s): "
f"{', '.join(sorted(skill_dirs_in))}. "
f"Use a different --name or install into a subcategory."
)
shutil.rmtree(install_dir)