feat(skills): blank-slate skills — install --no-skills + opt-out/opt-in (#36228)

* feat(install): --no-skills flag for blank-slate default profile

Add an install-time --no-skills flag so the default ~/.hermes profile can
be created with zero bundled skills, matching what
`hermes profile create --no-skills` already does for named profiles.

The flag writes $HERMES_HOME/.no-bundled-skills and skips the install-time
seed. sync_skills() now honors that marker with an early return
(skipped_opt_out=True), so neither the installer, a later `hermes update`,
nor a direct sync re-injects bundled skills into a profile that opted out.

Previously the marker was only checked by seed_profile_skills() (named
profiles); the default profile had no opt-out and `hermes update` would
re-seed it every time.

Tests: TestNoBundledSkillsOptOut covers marker-present (no-op) and
marker-absent (normal seed) paths.

* feat(skills): hermes skills opt-out / opt-in for existing profiles

Adds an interactive counterpart to the install-time --no-skills flag so
an already-installed profile (default or named) can toggle the
.no-bundled-skills marker without reinstalling.

- `hermes skills opt-out` writes the marker (stop future seeding). Safe
  by default: nothing on disk is touched.
- `hermes skills opt-out --remove` ALSO deletes already-present bundled
  skills, but ONLY ones that are manifest-tracked AND byte-identical to
  their origin hash. User-edited bundled skills, hub-installed skills, and
  hand-written skills are never removed. Previews + confirms before
  deleting (--yes to skip).
- `hermes skills opt-in [--sync]` removes the marker and optionally
  re-seeds immediately.

Core logic lives in tools/skills_sync.py (set_bundled_skills_opt_out,
is_bundled_skills_opt_out, remove_pristine_bundled_skills) reusing the
existing manifest origin-hash machinery for the safety check.

Tests: TestOptOutToggleAndRemove covers marker toggle idempotency and
proves user-modified + non-bundled skills survive --remove.

* docs: blank-slate skills — install --no-skills + opt-out/opt-in

- features/skills.md: new 'Starting with a blank slate' section covering
  the install flag, profile-create flag, and runtime opt-out/opt-in, with
  a safe-by-default note.
- reference/cli-commands.md: document the new skills opt-out / opt-in
  subcommands + examples.
- reference/profile-commands.md: fix the marker filename (was .no-skills,
  actually .no-bundled-skills) and cross-link the runtime commands.

Validated with a full docusaurus build (exit 0); the three edited pages
compile clean with no new warnings.
This commit is contained in:
Teknium
2026-06-01 02:57:57 -07:00
committed by GitHub
parent 70e1571d89
commit 2ed96372ad
8 changed files with 475 additions and 9 deletions

View File

@ -40,6 +40,15 @@ HERMES_HOME = get_hermes_home()
SKILLS_DIR = HERMES_HOME / "skills"
MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
# Marker file written by `hermes profile create --no-skills` (named profiles)
# and by the installer's `--no-skills` flag (the default ~/.hermes profile).
# When present in HERMES_HOME, sync_skills() is a no-op so neither the
# installer, `hermes update`, nor a direct sync re-injects bundled skills.
# Delete the file to opt back in. Mirrors
# hermes_cli.profiles.NO_BUNDLED_SKILLS_MARKER (kept as a literal here to
# avoid importing the CLI layer into this low-level sync module).
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
def _get_bundled_dir() -> Path:
"""Locate the bundled skills/ directory.
@ -450,6 +459,20 @@ def sync_skills(quiet: bool = False) -> dict:
dict with keys: copied (list), updated (list), skipped (int),
user_modified (list), cleaned (list), total_bundled (int)
"""
# Opt-out: a profile (named or the default ~/.hermes) that wrote the
# .no-bundled-skills marker gets zero bundled-skill seeding. Returning the
# empty-result shape with skipped_opt_out lets callers report "opted out"
# instead of "synced 0 / failed". This is the default-profile counterpart
# to seed_profile_skills()'s marker check for named profiles.
if (HERMES_HOME / NO_BUNDLED_SKILLS_MARKER).exists():
if not quiet:
print(" (skipped — profile opted out of bundled skills via .no-bundled-skills)")
return {
"copied": [], "updated": [], "skipped": 0,
"user_modified": [], "cleaned": [], "total_bundled": 0,
"optional_provenance_backfilled": [], "skipped_opt_out": True,
}
bundled_dir = _get_bundled_dir()
if not bundled_dir.exists():
return {
@ -727,6 +750,131 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
return {"ok": True, "action": action, "message": message, "synced": synced}
def set_bundled_skills_opt_out(enabled: bool) -> dict:
"""Toggle the .no-bundled-skills opt-out marker for the active profile.
When ``enabled`` is True, writes HERMES_HOME/.no-bundled-skills so the
installer, ``hermes update``, and any direct sync stop seeding bundled
skills. When False, removes the marker so seeding resumes on the next
sync. This is the on-disk-state half of ``hermes skills opt-out`` /
``opt-in``; removal of already-present skills is a separate, explicit
step (see ``remove_pristine_bundled_skills``).
Returns:
dict with keys: ok (bool), changed (bool), marker (str path),
message (str).
"""
marker = HERMES_HOME / NO_BUNDLED_SKILLS_MARKER
existed = marker.exists()
try:
if enabled:
HERMES_HOME.mkdir(parents=True, exist_ok=True)
marker.write_text(
"This profile opted out of bundled-skill seeding "
"(`hermes skills opt-out`).\n"
"Delete this file to re-enable sync on the next `hermes update`.\n",
encoding="utf-8",
)
changed = not existed
message = (
"Opted out of bundled skills. Future install / update / sync "
"runs will not seed bundled skills into this profile."
if changed
else "Already opted out — marker was already present."
)
else:
if existed:
marker.unlink()
changed = existed
message = (
"Opted back in. The next `hermes update` (or `hermes skills "
"opt-in --sync`) will re-seed bundled skills."
if changed
else "Not opted out — no marker to remove."
)
except OSError as e:
return {
"ok": False, "changed": False, "marker": str(marker),
"message": f"Could not update opt-out marker at {marker}: {e}",
}
return {"ok": True, "changed": changed, "marker": str(marker), "message": message}
def is_bundled_skills_opt_out() -> bool:
"""Return True if the active profile carries the opt-out marker."""
return (HERMES_HOME / NO_BUNDLED_SKILLS_MARKER).exists()
def remove_pristine_bundled_skills(dry_run: bool = False) -> dict:
"""Delete bundled skills that are present, manifest-tracked, AND unmodified.
Safety is the whole point of this function. A skill on disk is removed
ONLY when all of these hold:
- it is recorded in the sync manifest (so it is genuinely a bundled
skill, not a hub-installed or hand-written one), AND
- it still exists in the bundled source (so we can hash-compare), AND
- its on-disk copy is byte-identical to the manifest origin hash
(so the user has not edited it).
Anything user-modified, hub-installed, or locally authored is left
untouched and reported under ``skipped``. The manifest entry for each
removed skill is dropped so a later opt-in re-seed treats it as new.
Args:
dry_run: When True, compute what would be removed without deleting.
Returns:
dict with keys: ok (bool), removed (list[str]),
skipped (list[dict]) where each dict is
{name, reason}, dry_run (bool), message (str).
"""
manifest = _read_manifest()
bundled_dir = _get_bundled_dir()
bundled_by_name = dict(_discover_bundled_skills(bundled_dir))
removed: List[str] = []
skipped: List[dict] = []
for name, origin_hash in sorted(manifest.items()):
src = bundled_by_name.get(name)
if src is None:
# Tracked but no longer bundled upstream — leave it; not ours to judge.
skipped.append({"name": name, "reason": "no bundled source (removed upstream)"})
continue
dest = _compute_relative_dest(src, bundled_dir)
if not dest.exists():
# Already gone from disk; just forget the stale manifest entry.
if not dry_run and name in manifest:
del manifest[name]
continue
on_disk = _dir_hash(dest)
if on_disk != origin_hash:
skipped.append({"name": name, "reason": "user-modified (kept)"})
continue
# Pristine bundled copy — safe to remove.
if dry_run:
removed.append(name)
continue
try:
_rmtree_writable(dest)
except (OSError, IOError) as e:
skipped.append({"name": name, "reason": f"delete failed: {e}"})
continue
if name in manifest:
del manifest[name]
removed.append(name)
if not dry_run and removed:
_write_manifest(manifest)
verb = "Would remove" if dry_run else "Removed"
message = f"{verb} {len(removed)} pristine bundled skill(s); kept {len(skipped)}."
return {
"ok": True, "removed": removed, "skipped": skipped,
"dry_run": dry_run, "message": message,
}
if __name__ == "__main__":
print("Syncing bundled skills into ~/.hermes/skills/ ...")
result = sync_skills(quiet=False)