mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
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:
parent
49c3c2e0d3
commit
51f9953e69
4 changed files with 188 additions and 4 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue