feat(profiles): --no-skills flag for empty profile creation (#20986)

Adds `hermes profile create <name> --no-skills` to create a profile with
zero bundled skills. Writes a `.no-bundled-skills` marker file in the
profile root so `hermes update`'s all-profile skill sync loop also skips
the profile — without the marker, every update would re-seed skills and
the user would have to delete them again.

Use case (from @hiut1u): orchestrator profiles and narrow-task profiles
don't need 100+ bundled skills polluting their system prompt.

- create_profile() gains a `no_skills` param, mutually exclusive with
  `--clone` / `--clone-all` (cloning explicitly copies skills).
- seed_profile_skills() no-ops on opted-out profiles and returns
  `{skipped_opt_out: True}` so callers can report cleanly.
- Web API (POST /api/profiles) accepts `no_skills: bool`.
- Delete `.no-bundled-skills` to opt back in — next `hermes update`
  re-seeds normally.

6 new tests in TestNoSkillsOptOut cover marker write, mutual exclusion
with clone, seed_profile_skills opt-out, fresh profile unaffected, and
delete-marker-re-enables-seeding.
This commit is contained in:
Teknium 2026-05-07 04:34:38 -07:00 committed by GitHub
parent 49c3c2e0d3
commit 51f9953e69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 188 additions and 4 deletions

View file

@ -7331,7 +7331,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
for p in all_profiles:
try:
r = seed_profile_skills(p.path, quiet=True)
if r:
if r and r.get("skipped_opt_out"):
status = "opted out (--no-skills)"
elif r:
copied = len(r.get("copied", []))
updated = len(r.get("updated", []))
modified = len(r.get("user_modified", []))
@ -8124,6 +8126,7 @@ def cmd_profile(args):
clone = getattr(args, "clone", False)
clone_all = getattr(args, "clone_all", False)
no_alias = getattr(args, "no_alias", False)
no_skills = getattr(args, "no_skills", False)
try:
clone_from = getattr(args, "clone_from", None)
@ -8134,6 +8137,7 @@ def cmd_profile(args):
clone_all=clone_all,
clone_config=clone,
no_alias=no_alias,
no_skills=no_skills,
)
print(f"\nProfile '{name}' created at {profile_dir}")
@ -8158,10 +8162,17 @@ def cmd_profile(args):
except Exception:
pass # Honcho plugin not installed or not configured
# Seed bundled skills (skip if --clone-all already copied them)
# Seed bundled skills (skip if --clone-all already copied them, or
# if --no-skills was passed — in which case seed_profile_skills()
# honors the marker file and returns skipped_opt_out=True).
if not clone_all:
result = seed_profile_skills(profile_dir)
if result:
if result and result.get("skipped_opt_out"):
print(
"No bundled skills seeded (--no-skills). "
"Delete .no-bundled-skills in the profile to opt back in."
)
elif result:
copied = len(result.get("copied", []))
print(f"{copied} bundled skills synced.")
else:
@ -10523,6 +10534,11 @@ Examples:
profile_create.add_argument(
"--no-alias", action="store_true", help="Skip wrapper script creation"
)
profile_create.add_argument(
"--no-skills",
action="store_true",
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
)
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
profile_delete.add_argument("profile_name", help="Profile to delete")

View file

@ -71,6 +71,22 @@ _CLONE_ALL_STRIP = [
"processes.json",
]
# Marker file written by `hermes profile create --no-skills`. When present in
# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes
# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding
# for that profile. The user can still install skills manually via
# `hermes skills install` or drop SKILL.md files into the profile's skills/.
# Delete the marker file to opt back in.
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
def has_bundled_skills_opt_out(profile_dir: Path) -> bool:
"""Return True if the profile opted out of bundled-skill seeding."""
try:
return (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists()
except OSError:
return False
def _clone_all_copytree_ignore(source_dir: Path):
"""Ignore ``profiles/`` at the root of *source_dir* only.
@ -427,6 +443,7 @@ def create_profile(
clone_all: bool = False,
clone_config: bool = False,
no_alias: bool = False,
no_skills: bool = False,
) -> Path:
"""Create a new profile directory.
@ -444,12 +461,22 @@ def create_profile(
skills, and selected profile identity files from the source profile.
no_alias:
If True, skip wrapper script creation.
no_skills:
If True, create an empty profile with no bundled skills, and write
a marker file so ``hermes update`` skips re-seeding this profile's
skills. Mutually exclusive with ``clone_config``/``clone_all`` (those
explicitly copy skills from the source).
Returns
-------
Path
The newly created profile directory.
"""
if no_skills and (clone_config or clone_all):
raise ValueError(
"--no-skills is mutually exclusive with --clone / --clone-all "
"(cloning explicitly copies skills from the source profile)."
)
canon = normalize_profile_name(name)
validate_profile_name(canon)
@ -527,6 +554,19 @@ def create_profile(
except Exception:
pass # best-effort — don't fail profile creation over this
# Write the opt-out marker so seed_profile_skills() and `hermes update`'s
# all-profile sync loop both skip this profile for bundled-skill seeding.
if no_skills:
try:
(profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text(
"This profile opted out of bundled-skill seeding "
"(`hermes profile create --no-skills`).\n"
"Delete this file to re-enable sync on the next `hermes update`.\n",
encoding="utf-8",
)
except OSError:
pass # best-effort — the feature still works via the empty skills/ dir
return profile_dir
@ -535,7 +575,19 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
Uses subprocess because sync_skills() caches HERMES_HOME at module level.
Returns the sync result dict, or None on failure.
Profiles that opted out of bundled skills (via ``hermes profile create
--no-skills`` which writes ``.no-bundled-skills`` to the profile root)
are skipped and get an empty-result dict so callers can report
"opted out" instead of "failed".
"""
if has_bundled_skills_opt_out(profile_dir):
return {
"copied": [],
"updated": [],
"user_modified": [],
"skipped_opt_out": True,
}
project_root = Path(__file__).parent.parent.resolve()
try:
result = subprocess.run(

View file

@ -2366,6 +2366,7 @@ async def delete_cron_job(job_id: str):
class ProfileCreate(BaseModel):
name: str
clone_from_default: bool = False
no_skills: bool = False
class ProfileRename(BaseModel):
@ -2471,11 +2472,13 @@ async def create_profile_endpoint(body: ProfileCreate):
name=body.name,
clone_from="default" if body.clone_from_default else None,
clone_config=body.clone_from_default,
no_skills=body.no_skills,
)
# Match the CLI's profile-create flow: fresh named profiles get the
# bundled skills installed. When cloning from default, create_profile()
# has already copied the source profile's skills, including any
# user-installed skills.
# user-installed skills. When no_skills=True, create_profile() wrote
# the opt-out marker and seed_profile_skills() will no-op.
if not body.clone_from_default:
profiles_mod.seed_profile_skills(path, quiet=True)

View file

@ -33,6 +33,9 @@ from hermes_cli.profiles import (
generate_zsh_completion,
_get_profiles_root,
_get_default_hermes_home,
seed_profile_skills,
has_bundled_skills_opt_out,
NO_BUNDLED_SKILLS_MARKER,
)
@ -243,6 +246,116 @@ class TestCreateProfile:
assert (profile_dir / "SOUL.md").exists()
# ===================================================================
# TestNoSkillsOptOut
# ===================================================================
class TestNoSkillsOptOut:
"""Tests for `hermes profile create --no-skills` and the opt-out marker."""
def test_no_skills_writes_marker_and_skips_seeding(self, profile_env):
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
# Marker file is present
marker = profile_dir / NO_BUNDLED_SKILLS_MARKER
assert marker.is_file(), "expected .no-bundled-skills marker in profile root"
assert "--no-skills" in marker.read_text()
# has_bundled_skills_opt_out() agrees
assert has_bundled_skills_opt_out(profile_dir) is True
# skills/ dir exists (profile bootstrapping still creates the dir) but
# contains nothing yet because create_profile itself doesn't seed.
assert (profile_dir / "skills").is_dir()
assert list((profile_dir / "skills").iterdir()) == []
def test_no_skills_conflicts_with_clone(self, profile_env):
with pytest.raises(ValueError, match="mutually exclusive"):
create_profile(
"orchestrator",
no_alias=True,
no_skills=True,
clone_config=True,
)
def test_no_skills_conflicts_with_clone_all(self, profile_env):
with pytest.raises(ValueError, match="mutually exclusive"):
create_profile(
"orchestrator",
no_alias=True,
no_skills=True,
clone_all=True,
)
def test_seed_profile_skills_respects_marker(self, profile_env):
"""seed_profile_skills() must no-op on opted-out profiles even when
called directly (e.g. by `hermes update`'s all-profile sync loop)."""
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
# Call seed_profile_skills() directly — it should NOT invoke subprocess,
# NOT modify the skills/ dir, and return a dict with skipped_opt_out=True.
result = seed_profile_skills(profile_dir, quiet=True)
assert result is not None
assert result.get("skipped_opt_out") is True
assert result.get("copied") == []
# skills/ stays empty — no subprocess ran
assert list((profile_dir / "skills").iterdir()) == []
def test_default_profile_gets_skills_seeded(self, profile_env, monkeypatch):
"""Sanity: without --no-skills, seed_profile_skills() runs the real
subprocess path. Mock the subprocess so the test is hermetic, and
just confirm the marker is NOT checked in the non-opt-out case."""
import subprocess as _sp
profile_dir = create_profile("coder", no_alias=True)
# No marker — not opted out
assert not (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists()
assert has_bundled_skills_opt_out(profile_dir) is False
# Mock subprocess.run to avoid actually running skill sync in tests
calls = []
def fake_run(*args, **kwargs):
calls.append(args)
return _sp.CompletedProcess(
args=args, returncode=0, stdout='{"copied": ["x"]}', stderr=""
)
monkeypatch.setattr("subprocess.run", fake_run)
result = seed_profile_skills(profile_dir, quiet=True)
# Subprocess was invoked (the opt-out branch did NOT short-circuit)
assert len(calls) == 1
assert result == {"copied": ["x"]}
def test_delete_marker_re_enables_seeding(self, profile_env, monkeypatch):
"""Deleting .no-bundled-skills opts the profile back in."""
import subprocess as _sp
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
assert has_bundled_skills_opt_out(profile_dir) is True
# First call: opted out, returns skipped dict without touching subprocess
called = []
monkeypatch.setattr(
"subprocess.run",
lambda *a, **kw: (called.append(a), _sp.CompletedProcess(
args=a, returncode=0, stdout='{"copied": []}', stderr=""
))[1],
)
r1 = seed_profile_skills(profile_dir, quiet=True)
assert r1.get("skipped_opt_out") is True
assert called == []
# Delete marker → next call runs the real path
(profile_dir / NO_BUNDLED_SKILLS_MARKER).unlink()
assert has_bundled_skills_opt_out(profile_dir) is False
r2 = seed_profile_skills(profile_dir, quiet=True)
assert r2 == {"copied": []}
assert len(called) == 1
# ===================================================================
# TestDeleteProfile
# ===================================================================