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:
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -84,7 +84,7 @@ Creates a new profile.
|
||||
| `--clone-from <profile>` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. |
|
||||
| `--no-alias` | Skip wrapper script creation. |
|
||||
| `--description "<text>"` | 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_dir>/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`.
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user