From 2ed96372ade3e2f6797b68fb88bf0a53f52f2ee8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:57:57 -0700 Subject: [PATCH] =?UTF-8?q?feat(skills):=20blank-slate=20skills=20?= =?UTF-8?q?=E2=80=94=20install=20--no-skills=20+=20opt-out/opt-in=20(#3622?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- hermes_cli/main.py | 37 ++++++ hermes_cli/skills_hub.py | 108 ++++++++++++++- scripts/install.sh | 34 ++++- tests/tools/test_skills_sync.py | 120 +++++++++++++++++ tools/skills_sync.py | 148 +++++++++++++++++++++ website/docs/reference/cli-commands.md | 5 + website/docs/reference/profile-commands.md | 2 +- website/docs/user-guide/features/skills.md | 30 +++++ 8 files changed, 475 insertions(+), 9 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fa4bbb9e4..13d827752 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13267,6 +13267,43 @@ Examples: help="Skip confirmation prompt when using --restore", ) + skills_opt_out = skills_subparsers.add_parser( + "opt-out", + help="Stop bundled skills from being seeded into this profile", + description=( + "Write the .no-bundled-skills marker so the installer, " + "`hermes update`, and any direct sync stop seeding bundled skills " + "into the active profile. By default nothing already on disk is " + "touched. Pass --remove to ALSO delete bundled skills that are " + "unmodified (user-edited and hub/local skills are never removed)." + ), + ) + skills_opt_out.add_argument( + "--remove", + action="store_true", + help="Also delete already-present unmodified bundled skills", + ) + skills_opt_out.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip confirmation prompt when using --remove", + ) + + skills_opt_in = skills_subparsers.add_parser( + "opt-in", + help="Re-enable bundled-skill seeding (undo opt-out)", + description=( + "Remove the .no-bundled-skills marker so bundled skills are seeded " + "again on the next `hermes update`. Pass --sync to re-seed now." + ), + ) + skills_opt_in.add_argument( + "--sync", + action="store_true", + help="Re-seed bundled skills immediately instead of waiting for update", + ) + skills_repair_official = skills_subparsers.add_parser( "repair-official", help="Backfill or restore official optional skills from repo source", diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 4fe2a4dc7..afc41cca4 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -1072,6 +1072,107 @@ def do_reset(name: str, restore: bool = False, c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n") +def do_opt_out(remove: bool = False, + console: Optional[Console] = None, + skip_confirm: bool = False, + invalidate_cache: bool = True) -> None: + """Opt the active profile out of bundled-skill seeding. + + Always writes the .no-bundled-skills marker (stop future seeding). With + ``remove``, also deletes already-present bundled skills that are pristine + (manifest-tracked AND unmodified); user-edited and non-bundled skills are + never touched. + """ + from tools.skills_sync import ( + set_bundled_skills_opt_out, + remove_pristine_bundled_skills, + ) + + c = console or _console + + # Write the marker first (the always-safe part). + res = set_bundled_skills_opt_out(True) + if not res["ok"]: + c.print(f"[bold red]Error:[/] {res['message']}\n") + return + c.print(f"[bold green]{res['message']}[/]") + c.print(f"[dim]Marker: {res['marker']}[/]") + + if not remove: + c.print("[dim]Existing skills on disk were left in place. " + "Re-run with --remove to also delete unmodified bundled skills.[/]\n") + return + + # Destructive step: preview, confirm, then delete. + preview = remove_pristine_bundled_skills(dry_run=True) + candidates = preview["removed"] + kept = preview["skipped"] + if not candidates: + c.print("[dim]No pristine bundled skills to remove " + "(nothing tracked, or all are user-modified/local).[/]\n") + return + + c.print(f"\n[bold]Will remove {len(candidates)} unmodified bundled skill(s):[/]") + c.print(f"[dim]{', '.join(candidates)}[/]") + if kept: + c.print(f"[dim]Keeping {len(kept)} (user-modified or non-bundled).[/]") + + if not skip_confirm: + c.print("[dim]This deletes the on-disk copies. User-edited and " + "hub/local skills are NOT touched.[/]") + try: + answer = input("Confirm [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + if answer not in {"y", "yes"}: + c.print("[dim]Marker kept; no skills deleted.[/]\n") + return + + result = remove_pristine_bundled_skills(dry_run=False) + c.print(f"[bold green]{result['message']}[/]") + if result["removed"]: + c.print(f"[dim]Removed: {', '.join(result['removed'])}[/]") + c.print() + + if invalidate_cache: + try: + from agent.prompt_builder import clear_skills_system_prompt_cache + clear_skills_system_prompt_cache(clear_snapshot=True) + except Exception: + pass + + +def do_opt_in(sync: bool = False, + console: Optional[Console] = None, + invalidate_cache: bool = True) -> None: + """Remove the opt-out marker so bundled-skill seeding resumes. + + With ``sync``, immediately re-seed bundled skills instead of waiting for + the next ``hermes update``. + """ + from tools.skills_sync import set_bundled_skills_opt_out, sync_skills + + c = console or _console + + res = set_bundled_skills_opt_out(False) + if not res["ok"]: + c.print(f"[bold red]Error:[/] {res['message']}\n") + return + c.print(f"[bold green]{res['message']}[/]") + + if sync: + synced = sync_skills(quiet=True) + copied = len(synced.get("copied", [])) + c.print(f"[dim]Re-seeded {copied} bundled skill(s).[/]") + if invalidate_cache: + try: + from agent.prompt_builder import clear_skills_system_prompt_cache + clear_skills_system_prompt_cache(clear_snapshot=True) + except Exception: + pass + c.print() + + def do_repair_official(name: str, restore: bool = False, console: Optional[Console] = None, skip_confirm: bool = False, @@ -1446,6 +1547,11 @@ def skills_command(args) -> None: elif action == "reset": do_reset(args.name, restore=getattr(args, "restore", False), skip_confirm=getattr(args, "yes", False)) + elif action == "opt-out": + do_opt_out(remove=getattr(args, "remove", False), + skip_confirm=getattr(args, "yes", False)) + elif action == "opt-in": + do_opt_in(sync=getattr(args, "sync", False)) elif action == "repair-official": do_repair_official(args.name, restore=getattr(args, "restore", False), skip_confirm=getattr(args, "yes", False)) @@ -1471,7 +1577,7 @@ def skills_command(args) -> None: return do_tap(tap_action, repo=repo) else: - _console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|publish|snapshot|tap]\n") + _console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n") _console.print("Run 'hermes skills --help' for details.\n") diff --git a/scripts/install.sh b/scripts/install.sh index 706ca36d3..24321c9b8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -70,6 +70,7 @@ DETECTED_BROWSER_EXECUTABLE="" USE_VENV=true RUN_SETUP=true SKIP_BROWSER=false +NO_SKILLS=false BRANCH="main" INSTALL_COMMIT="" ENSURE_DEPS="" @@ -104,6 +105,10 @@ while [[ $# -gt 0 ]]; do SKIP_BROWSER=true shift ;; + --no-skills) + NO_SKILLS=true + shift + ;; --branch|-Branch) BRANCH="$2" shift 2 @@ -158,6 +163,9 @@ while [[ $# -gt 0 ]]; do echo " --no-venv Don't create virtual environment" echo " --skip-setup Skip interactive setup wizard" echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)" + echo " --no-skills Start with a blank slate — seed no bundled skills, and" + echo " write \$HERMES_HOME/.no-bundled-skills so future" + echo " 'hermes update' runs never inject bundled skills either" echo " --branch NAME Git branch to install (default: main)" echo " --commit SHA Pin checkout to a specific commit after clone/update" echo " --manifest Print desktop bootstrap stage manifest as JSON" @@ -1767,14 +1775,26 @@ SOUL_EOF log_success "Configuration directory ready: ~/.hermes/" # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill) - log_info "Syncing bundled skills to ~/.hermes/skills/ ..." - if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then - log_success "Skills synced to ~/.hermes/skills/" + if [ "$NO_SKILLS" = true ]; then + # Blank-slate install: write the opt-out marker and skip seeding. + # skills_sync.py and `hermes update` both honor this marker, so the + # default profile stays empty across future updates too. + printf '%s\n' \ + "This profile opted out of bundled-skill seeding (installed with --no-skills)." \ + "Delete this file to re-enable sync on the next 'hermes update'." \ + > "$HERMES_HOME/.no-bundled-skills" 2>/dev/null || true + log_info "Skipping bundled skills (--no-skills). Wrote $HERMES_HOME/.no-bundled-skills" + log_info " Future 'hermes update' runs will not inject bundled skills. Delete the marker to opt back in." else - # Fallback: simple directory copy if Python sync fails - if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then - cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true - log_success "Skills copied to ~/.hermes/skills/" + log_info "Syncing bundled skills to ~/.hermes/skills/ ..." + if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then + log_success "Skills synced to ~/.hermes/skills/" + else + # Fallback: simple directory copy if Python sync fails + if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then + cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true + log_success "Skills copied to ~/.hermes/skills/" + fi fi fi } diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py index c39e2fcd3..6711b3bb2 100644 --- a/tests/tools/test_skills_sync.py +++ b/tests/tools/test_skills_sync.py @@ -947,3 +947,123 @@ class TestResetBundledSkill: assert "google-workspace" in manifest_after # User copy is still on disk (we changed nothing). assert (dest / "SKILL.md").exists() + + +class TestNoBundledSkillsOptOut: + """The .no-bundled-skills marker makes sync_skills() a no-op. + + This is what `hermes profile create --no-skills` (named profiles) and the + installer's `--no-skills` flag (default ~/.hermes) rely on so bundled + skills are never seeded at install time NOR re-injected by `hermes update`. + """ + + def _setup_bundled(self, tmp_path): + bundled = tmp_path / "bundled" + skill = bundled / "category" / "new-skill" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: new-skill\n---\nbody\n") + return bundled + + def test_marker_skips_sync(self, tmp_path): + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + hermes_home = tmp_path / "home" + hermes_home.mkdir() + (hermes_home / ".no-bundled-skills").write_text("opted out\n") + + with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \ + patch("tools.skills_sync.SKILLS_DIR", skills_dir), \ + patch("tools.skills_sync.MANIFEST_FILE", manifest_file), \ + patch("tools.skills_sync.HERMES_HOME", hermes_home): + result = sync_skills(quiet=True) + + # Opt-out signalled, nothing copied, nothing written to disk. + assert result["skipped_opt_out"] is True + assert result["copied"] == [] + assert result["total_bundled"] == 0 + assert not (skills_dir / "category" / "new-skill" / "SKILL.md").exists() + + def test_no_marker_seeds_normally(self, tmp_path): + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + hermes_home = tmp_path / "home" + hermes_home.mkdir() + # No marker written. + + with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \ + patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"), \ + patch("tools.skills_sync.SKILLS_DIR", skills_dir), \ + patch("tools.skills_sync.MANIFEST_FILE", manifest_file), \ + patch("tools.skills_sync.HERMES_HOME", hermes_home): + result = sync_skills(quiet=True) + + assert result.get("skipped_opt_out") is not True + assert "new-skill" in result["copied"] + assert (skills_dir / "category" / "new-skill" / "SKILL.md").exists() + + +class TestOptOutToggleAndRemove: + """`hermes skills opt-out/opt-in` core: marker toggle + safe removal.""" + + def _setup_bundled(self, tmp_path): + bundled = tmp_path / "bundled" + for n in ("alpha", "beta"): + d = bundled / n + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"---\nname: {n}\n---\nbody {n}\n") + return bundled + + def test_marker_toggle(self, tmp_path): + from tools.skills_sync import ( + set_bundled_skills_opt_out, is_bundled_skills_opt_out, + ) + home = tmp_path / "home" + home.mkdir() + with patch("tools.skills_sync.HERMES_HOME", home): + assert is_bundled_skills_opt_out() is False + r = set_bundled_skills_opt_out(True) + assert r["ok"] and r["changed"] + assert is_bundled_skills_opt_out() is True + # idempotent + r2 = set_bundled_skills_opt_out(True) + assert r2["ok"] and r2["changed"] is False + # opt back in + r3 = set_bundled_skills_opt_out(False) + assert r3["ok"] and r3["changed"] + assert is_bundled_skills_opt_out() is False + + def test_remove_keeps_user_modified(self, tmp_path): + from tools.skills_sync import ( + sync_skills, remove_pristine_bundled_skills, + ) + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + home = tmp_path / "home" + home.mkdir() + with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \ + patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"), \ + patch("tools.skills_sync.SKILLS_DIR", skills_dir), \ + patch("tools.skills_sync.MANIFEST_FILE", manifest_file), \ + patch("tools.skills_sync.HERMES_HOME", home): + sync_skills(quiet=True) + # User edits 'beta' + (skills_dir / "beta" / "SKILL.md").write_text("---\nname: beta\n---\nEDITED\n") + # A hand-written, non-bundled skill must also survive. + (skills_dir / "mine").mkdir() + (skills_dir / "mine" / "SKILL.md").write_text("---\nname: mine\n---\nlocal\n") + + preview = remove_pristine_bundled_skills(dry_run=True) + assert "alpha" in preview["removed"] + assert "beta" not in preview["removed"] + + result = remove_pristine_bundled_skills(dry_run=False) + assert "alpha" in result["removed"] + assert not (skills_dir / "alpha").exists() + # user-modified bundled skill kept + assert (skills_dir / "beta" / "SKILL.md").exists() + assert "EDITED" in (skills_dir / "beta" / "SKILL.md").read_text() + # non-bundled local skill never considered + assert (skills_dir / "mine" / "SKILL.md").exists() diff --git a/tools/skills_sync.py b/tools/skills_sync.py index a7fc1e4a5..99f79fc6a 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -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) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b8b41a621..439e64a42 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -982,6 +982,8 @@ Subcommands: | `audit` | Re-scan installed hub skills. | | `uninstall` | Remove a hub-installed skill. | | `reset` | Un-stick a bundled skill flagged as `user_modified` by clearing its manifest entry. With `--restore`, also replaces the user copy with the bundled version. | +| `opt-out` | Stop bundled skills from being seeded into the active profile. Writes a `.no-bundled-skills` marker so the installer, `hermes update`, and any sync skip bundled-skill seeding. Safe by default — nothing on disk is touched. With `--remove`, also deletes already-present bundled skills that are **unmodified** (user-edited, hub-installed, and hand-written skills are never removed; previews and confirms first, `--yes` to skip). | +| `opt-in` | Undo `opt-out` by removing the `.no-bundled-skills` marker so bundled skills are seeded again on the next `hermes update`. With `--sync`, re-seed immediately. | | `publish` | Publish a skill to a registry. | | `snapshot` | Export/import skill configurations. | | `tap` | Manage custom skill sources. | @@ -1005,6 +1007,9 @@ hermes skills update hermes skills config hermes skills reset google-workspace hermes skills reset google-workspace --restore --yes +hermes skills opt-out # stop future bundled-skill seeding (nothing deleted) +hermes skills opt-out --remove --yes # also delete UNMODIFIED bundled skills +hermes skills opt-in --sync # undo: remove marker and re-seed now ``` Notes: diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 87bbd16de..c02da55cf 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -84,7 +84,7 @@ Creates a new profile. | `--clone-from ` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. | | `--no-alias` | Skip wrapper script creation. | | `--description ""` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `/profile.yaml`. | -| `--no-skills` | Create an **empty** profile with zero bundled skills enabled. Writes a `.no-skills` marker into the profile so future `hermes update` runs won't re-seed the bundled set, and refuses to combine with `--clone` / `--clone-all` (which would copy skills in anyway). Useful for narrow orchestrator profiles or sandbox profiles that should not inherit the full skill catalog. | +| `--no-skills` | Create an **empty** profile with zero bundled skills enabled. Writes a `.no-bundled-skills` marker into the profile so future `hermes update` runs won't re-seed the bundled set, and refuses to combine with `--clone` / `--clone-all` (which would copy skills in anyway). Useful for narrow orchestrator profiles or sandbox profiles that should not inherit the full skill catalog. To toggle this on an already-created profile (including the default `~/.hermes`), use `hermes skills opt-out` / `hermes skills opt-in`. | Creating a profile does **not** make that profile directory the default project/workspace directory for terminal commands. If you want a profile to start in a specific project, set `terminal.cwd` in that profile's `config.yaml`. diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 25376afcf..8a5209c69 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -17,6 +17,36 @@ See also: - [Bundled Skills Catalog](/reference/skills-catalog) - [Official Optional Skills Catalog](/reference/optional-skills-catalog) +## Starting with a blank slate + +By default every profile is seeded with the bundled skill catalog, and each `hermes update` adds any newly bundled skills. If you want a profile with **no bundled skills** — and that stays empty across updates — you have two paths: + +**At install time** (applies to the default `~/.hermes` profile): + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --no-skills +``` + +**At profile-create time** (named profiles): + +```bash +hermes profile create research --no-skills +``` + +**On an already-installed profile** (default or named), toggle it at runtime: + +```bash +hermes skills opt-out # stop future seeding — nothing on disk is touched +hermes skills opt-out --remove # also delete UNMODIFIED bundled skills (confirms first) +hermes skills opt-in --sync # undo: remove the marker and re-seed now +``` + +All three paths write a `.no-bundled-skills` marker into the profile directory. While the marker is present, the installer, `hermes update`, and any skill sync all skip bundled-skill seeding for that profile. Delete the marker (or run `hermes skills opt-in`) to re-enable. + +:::note Safe by default +`hermes skills opt-out` only stops *future* seeding — it never deletes anything already on disk. The optional `--remove` flag deletes bundled skills **only** when they are unmodified (byte-identical to the version Hermes installed). Skills you have edited, skills installed from the hub, and skills you wrote yourself are always kept. +::: + ## Using Skills Every installed skill is automatically available as a slash command: