mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
70e1571d89
commit
2ed96372ad
8 changed files with 475 additions and 9 deletions
|
|
@ -13267,6 +13267,43 @@ Examples:
|
||||||
help="Skip confirmation prompt when using --restore",
|
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(
|
skills_repair_official = skills_subparsers.add_parser(
|
||||||
"repair-official",
|
"repair-official",
|
||||||
help="Backfill or restore official optional skills from repo source",
|
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")
|
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,
|
def do_repair_official(name: str, restore: bool = False,
|
||||||
console: Optional[Console] = None,
|
console: Optional[Console] = None,
|
||||||
skip_confirm: bool = False,
|
skip_confirm: bool = False,
|
||||||
|
|
@ -1446,6 +1547,11 @@ def skills_command(args) -> None:
|
||||||
elif action == "reset":
|
elif action == "reset":
|
||||||
do_reset(args.name, restore=getattr(args, "restore", False),
|
do_reset(args.name, restore=getattr(args, "restore", False),
|
||||||
skip_confirm=getattr(args, "yes", 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":
|
elif action == "repair-official":
|
||||||
do_repair_official(args.name, restore=getattr(args, "restore", False),
|
do_repair_official(args.name, restore=getattr(args, "restore", False),
|
||||||
skip_confirm=getattr(args, "yes", False))
|
skip_confirm=getattr(args, "yes", False))
|
||||||
|
|
@ -1471,7 +1577,7 @@ def skills_command(args) -> None:
|
||||||
return
|
return
|
||||||
do_tap(tap_action, repo=repo)
|
do_tap(tap_action, repo=repo)
|
||||||
else:
|
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")
|
_console.print("Run 'hermes skills <command> --help' for details.\n")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ DETECTED_BROWSER_EXECUTABLE=""
|
||||||
USE_VENV=true
|
USE_VENV=true
|
||||||
RUN_SETUP=true
|
RUN_SETUP=true
|
||||||
SKIP_BROWSER=false
|
SKIP_BROWSER=false
|
||||||
|
NO_SKILLS=false
|
||||||
BRANCH="main"
|
BRANCH="main"
|
||||||
INSTALL_COMMIT=""
|
INSTALL_COMMIT=""
|
||||||
ENSURE_DEPS=""
|
ENSURE_DEPS=""
|
||||||
|
|
@ -104,6 +105,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
SKIP_BROWSER=true
|
SKIP_BROWSER=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--no-skills)
|
||||||
|
NO_SKILLS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--branch|-Branch)
|
--branch|-Branch)
|
||||||
BRANCH="$2"
|
BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|
@ -158,6 +163,9 @@ while [[ $# -gt 0 ]]; do
|
||||||
echo " --no-venv Don't create virtual environment"
|
echo " --no-venv Don't create virtual environment"
|
||||||
echo " --skip-setup Skip interactive setup wizard"
|
echo " --skip-setup Skip interactive setup wizard"
|
||||||
echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)"
|
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 " --branch NAME Git branch to install (default: main)"
|
||||||
echo " --commit SHA Pin checkout to a specific commit after clone/update"
|
echo " --commit SHA Pin checkout to a specific commit after clone/update"
|
||||||
echo " --manifest Print desktop bootstrap stage manifest as JSON"
|
echo " --manifest Print desktop bootstrap stage manifest as JSON"
|
||||||
|
|
@ -1767,14 +1775,26 @@ SOUL_EOF
|
||||||
log_success "Configuration directory ready: ~/.hermes/"
|
log_success "Configuration directory ready: ~/.hermes/"
|
||||||
|
|
||||||
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
|
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
|
||||||
log_info "Syncing bundled skills to ~/.hermes/skills/ ..."
|
if [ "$NO_SKILLS" = true ]; then
|
||||||
if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then
|
# Blank-slate install: write the opt-out marker and skip seeding.
|
||||||
log_success "Skills synced to ~/.hermes/skills/"
|
# 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
|
else
|
||||||
# Fallback: simple directory copy if Python sync fails
|
log_info "Syncing bundled skills to ~/.hermes/skills/ ..."
|
||||||
if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then
|
if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then
|
||||||
cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true
|
log_success "Skills synced to ~/.hermes/skills/"
|
||||||
log_success "Skills copied 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
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -947,3 +947,123 @@ class TestResetBundledSkill:
|
||||||
assert "google-workspace" in manifest_after
|
assert "google-workspace" in manifest_after
|
||||||
# User copy is still on disk (we changed nothing).
|
# User copy is still on disk (we changed nothing).
|
||||||
assert (dest / "SKILL.md").exists()
|
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"
|
SKILLS_DIR = HERMES_HOME / "skills"
|
||||||
MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
|
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:
|
def _get_bundled_dir() -> Path:
|
||||||
"""Locate the bundled skills/ directory.
|
"""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),
|
dict with keys: copied (list), updated (list), skipped (int),
|
||||||
user_modified (list), cleaned (list), total_bundled (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()
|
bundled_dir = _get_bundled_dir()
|
||||||
if not bundled_dir.exists():
|
if not bundled_dir.exists():
|
||||||
return {
|
return {
|
||||||
|
|
@ -727,6 +750,131 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
|
||||||
return {"ok": True, "action": action, "message": message, "synced": synced}
|
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__":
|
if __name__ == "__main__":
|
||||||
print("Syncing bundled skills into ~/.hermes/skills/ ...")
|
print("Syncing bundled skills into ~/.hermes/skills/ ...")
|
||||||
result = sync_skills(quiet=False)
|
result = sync_skills(quiet=False)
|
||||||
|
|
|
||||||
|
|
@ -982,6 +982,8 @@ Subcommands:
|
||||||
| `audit` | Re-scan installed hub skills. |
|
| `audit` | Re-scan installed hub skills. |
|
||||||
| `uninstall` | Remove a hub-installed skill. |
|
| `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. |
|
| `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. |
|
| `publish` | Publish a skill to a registry. |
|
||||||
| `snapshot` | Export/import skill configurations. |
|
| `snapshot` | Export/import skill configurations. |
|
||||||
| `tap` | Manage custom skill sources. |
|
| `tap` | Manage custom skill sources. |
|
||||||
|
|
@ -1005,6 +1007,9 @@ hermes skills update
|
||||||
hermes skills config
|
hermes skills config
|
||||||
hermes skills reset google-workspace
|
hermes skills reset google-workspace
|
||||||
hermes skills reset google-workspace --restore --yes
|
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:
|
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`. |
|
| `--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. |
|
| `--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`. |
|
| `--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`.
|
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)
|
- [Bundled Skills Catalog](/reference/skills-catalog)
|
||||||
- [Official Optional Skills Catalog](/reference/optional-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
|
## Using Skills
|
||||||
|
|
||||||
Every installed skill is automatically available as a slash command:
|
Every installed skill is automatically available as a slash command:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue