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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 475 additions and 9 deletions

View file

@ -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",

View file

@ -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 <command> --help' for details.\n")