Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", []))
Expand Down Expand Up @@ -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)
Expand All @@ -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}")

Expand All @@ -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:
Expand Down Expand Up @@ -10523,6 +10534,11 @@ def cmd_acp(args):
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")
Expand Down
52 changes: 52 additions & 0 deletions hermes_cli/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@
"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()
Comment thread
ethernet8023 marked this conversation as resolved.
Dismissed
except OSError:
return False


def _clone_all_copytree_ignore(source_dir: Path):
"""Ignore ``profiles/`` at the root of *source_dir* only.
Expand Down Expand Up @@ -427,6 +443,7 @@
clone_all: bool = False,
clone_config: bool = False,
no_alias: bool = False,
no_skills: bool = False,
) -> Path:
"""Create a new profile directory.

Expand All @@ -444,12 +461,22 @@
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)

Expand Down Expand Up @@ -527,6 +554,19 @@
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(
Comment thread
ethernet8023 marked this conversation as resolved.
Dismissed
"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


Expand All @@ -535,7 +575,19 @@

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(
Expand Down
5 changes: 4 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
113 changes: 113 additions & 0 deletions tests/hermes_cli/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
generate_zsh_completion,
_get_profiles_root,
_get_default_hermes_home,
seed_profile_skills,
has_bundled_skills_opt_out,
NO_BUNDLED_SKILLS_MARKER,
)


Expand Down Expand Up @@ -243,6 +246,116 @@ def test_clone_config_missing_files_skipped(self, profile_env):
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
# ===================================================================
Expand Down
Loading