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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user