Skip to content

feat(skills): skills.shared shorthand for cross-profile sharing - #5535

Open
cyb0rgk1tty wants to merge 2 commits into
NousResearch:mainfrom
cyb0rgk1tty:feat/skills-shared-discovery
Open

feat(skills): skills.shared shorthand for cross-profile sharing#5535
cyb0rgk1tty wants to merge 2 commits into
NousResearch:mainfrom
cyb0rgk1tty:feat/skills-shared-discovery

Conversation

@cyb0rgk1tty

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a small, opt-in convention for sharing specific skills across some (but not all) profiles on a single host, without requiring users to repeat full directory paths in every profile's config.yaml.

The convention:

~/.hermes/shared-skills/
├── team-runbook/
│   └── SKILL.md
└── company-style-guide/
    └── SKILL.md

Profiles opt in by name in their config.yaml:

skills:
  shared:
    - team-runbook
    - company-style-guide

Internally, skills.shared resolves to subdirectories of ~/.hermes/shared-skills/ that are appended to the skill search path between the profile-local skills/ dir and any skills.external_dirs entries. The end result is identical to listing the individual skill directories under skills.external_dirs (which has always worked) — this just gives a shorthand and a canonical location.

Why a shorthand instead of asking users to use external_dirs?

  • Less typing. List a name once instead of a full path per profile.
  • Less error-prone. Bare names with strict validation (no slashes, no .. traversal, no leading dot) are harder to mis-configure than raw paths.
  • Discoverability. The docs can point at one canonical location.
  • Composes cleanly with copy-on-write for shared skills (see fix(skills): copy-on-write for agent edits of external skills #5407, the prerequisite PR) — the agent never mutates the shared source, only profile-local overrides.

What it is NOT

  • Not automatic. Behavior is fully opt-in: profiles with no skills.shared key see no shared skills. There is no implicit inclusion of ~/.hermes/shared-skills/ contents — sharing is always explicit, per-profile, per-skill. This preserves the existing profile-isolation guarantee.
  • Not a replacement for external_dirs. skills.external_dirs is still the right tool for skills outside ~/.hermes/, env-var-substituted paths, or whole directories of skills.
  • Not a copy. The skill lives in exactly one place on disk (~/.hermes/shared-skills/<name>/); profiles reference it. Edits in one profile don't affect another (copy-on-write handles this — see fix(skills): copy-on-write for agent edits of external skills #5407).

Related Issue / PR

Depends on #5407 (fix(skills): copy-on-write for agent edits of external skills). That PR ensures that when an agent edits a shared skill, the edit becomes a profile-local override instead of mutating the shared source. Without it, this PR would be a footgun.

This PR is mergeable on its own, but the safety story is only complete with both.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

agent/skill_utils.py

  • New constant DEFAULT_SHARED_SKILLS = ~/.hermes/shared-skills/.
  • New function get_shared_skill_dirs(): reads skills.shared from the active profile's config.yaml, validates each entry (rejects path traversal, slashes, hidden directories), returns paths to existing subdirectories under DEFAULT_SHARED_SKILLS. Invalid names and missing directories are silently skipped so a typo or half-provisioned host doesn't break the agent.
  • Updated get_all_skills_dirs(): inserts shared skill dirs between the profile-local skills/ dir and skills.external_dirs entries. Duplicates (same resolved path) are filtered, so listing a shared skill in both skills.shared and skills.external_dirs only includes it once. Updated docstring documents the full precedence order.

tests/agent/test_external_skills.py — 10 new tests

  • TestGetSharedSkillDirs:
    • test_no_shared_config_returns_empty — no skills.shared key → empty list
    • test_shared_skill_resolved_by_name — name is resolved to the right directory
    • test_missing_shared_skill_silently_skipped — typo in config doesn't break discovery
    • test_path_traversal_rejected../../etc, foo/bar, .hidden are dropped
    • test_string_value_converted_to_listshared: name (single string) works
  • TestSharedInGetAllSkillsDirs:
    • test_shared_appears_after_local_before_external — order is local → shared → external
    • test_no_shared_config_backward_compat — pre-PR behavior preserved when key absent
    • test_shared_not_duplicated_when_also_in_external_dirs — dedup works
  • TestSelectiveSharingAcrossProfiles:
    • test_profile_a_and_b_see_different_subsets — proves the per-profile, per-skill selectivity
  • TestLocalShadowsShared:
    • test_profile_local_skill_takes_precedence_over_shared_by_position — local skills come first

website/docs/user-guide/features/skills.md

  • New section "Sharing Specific Skills Across Profiles" documenting the convention, config syntax, relationship to external_dirs, precedence rules, and a pointer to the read-only / copy-on-write semantics from the External Skill Directories section.

How to Test

# 1. Run the new tests
pytest tests/agent/test_external_skills.py::TestGetSharedSkillDirs \
       tests/agent/test_external_skills.py::TestSharedInGetAllSkillsDirs \
       tests/agent/test_external_skills.py::TestSelectiveSharingAcrossProfiles \
       tests/agent/test_external_skills.py::TestLocalShadowsShared -v
# Expect: 10 passed

# Full external_skills suite (regression check)
pytest tests/agent/test_external_skills.py -q
# Expect: 21 passed

# 2. Manual end-to-end verification
mkdir -p ~/.hermes/shared-skills/team-runbook
cat > ~/.hermes/shared-skills/team-runbook/SKILL.md <<MD
---
name: team-runbook
description: Shared team operational runbook.
---

# Team Runbook

Shared step here.
MD

# Add to a profile's config:
#   skills:
#     shared:
#       - team-runbook
# Then start the agent and run: /skills_list
# Confirm "team-runbook" appears.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(skills): ...)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this feature
  • All new tests pass; full tests/agent/test_external_skills.py suite passes
  • I've added tests for my changes
  • I've tested on my platform: Ubuntu 24.04 / Python 3.12

Documentation & Housekeeping

  • I've updated relevant documentation (website/docs/user-guide/features/skills.md)
  • N/A — no changes to cli-config.yaml.example (the new key is per-profile, not global)
  • N/A — no architecture changes
  • N/A — Python-only change, no platform-specific code paths
  • N/A — no tool behavior changes (skill_manage tool already handles external/shared correctly via fix(skills): copy-on-write for agent edits of external skills #5407)

cyb0rgk1tty and others added 2 commits April 6, 2026 04:06
The docs at docs/user-guide/features/skills.md#external-skill-directories
state:

> External dirs are only scanned for skill discovery. When the agent
> creates or edits a skill, it always writes to ~/.hermes/skills/.

But the code didn't match. _edit_skill, _patch_skill, _write_file, and
_remove_file all use _find_skill to locate a skill (which walks both the
profile-local skills dir and every skills.external_dirs entry), then
write directly to the discovered path. So an agent calling skill_edit on
a skill that lives in an external/shared directory would silently mutate
the shared source file, affecting every profile that references it.

This is particularly dangerous once multiple profiles share a single
skill directory: a single edit in profile A leaks to profiles B, C, D…
with no indication.

This commit makes the mutation helpers copy-on-write for external
skills, matching the documented behavior:

* _is_external_skill() and _copy_on_write_to_local() helpers.
* _edit_skill / _patch_skill / _write_file / _remove_file now copy the
  skill into SKILLS_DIR before mutating, and surface a "note" field in
  the response telling the agent (and the user) that a profile-local
  override was created. The external source is untouched.
* _delete_skill refuses to delete external skills, pointing the agent
  at skills.disabled (to hide from the current profile) or direct
  filesystem removal (to unshare entirely). Deleting a local skill is
  unchanged.
* Validation / scan-block rollback paths clean up the CoW copy so no
  half-applied state lingers on the disk.

Tests: 12 new tests cover _is_external_skill, _copy_on_write_to_local,
and the copy-on-write paths for edit, patch, write_file, remove_file,
and delete. They set HERMES_HOME in addition to SKILLS_DIR so
_find_skill actually resolves the fake external dirs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a small, opt-in convention for sharing specific skills across some
(but not all) profiles on a single host, without requiring users to
repeat full directory paths in every profile's config.yaml.

The convention:

  ~/.hermes/shared-skills/
  ├── team-runbook/
  │   └── SKILL.md
  └── company-style-guide/
      └── SKILL.md

Profiles opt in by name in their config.yaml:

  skills:
    shared:
      - team-runbook
      - company-style-guide

Internally, skills.shared resolves to subdirectories of
~/.hermes/shared-skills/ that are appended to the skill search path
between the profile-local skills/ dir and any skills.external_dirs
entries. The end result is identical to listing the individual
skill directories under skills.external_dirs (which has always
worked) — this just gives a shorthand and a canonical location.

Why a shorthand instead of asking users to use external_dirs?

* Less typing: list a name once instead of a full path per profile.
* Less error-prone: bare names with strict validation (no slashes, no
  ".." traversal, no leading dot) are harder to mis-configure than
  raw paths.
* Discoverability: the docs can point at one canonical location.
* Composes cleanly with the new copy-on-write behavior for shared
  skills (introduced in the previous commit) — the agent never
  mutates the shared source, only profile-local overrides.

Behavior is fully opt-in: profiles with no skills.shared key see no
shared skills. There is NO automatic inclusion of ~/.hermes/shared-skills/
contents — sharing is always explicit, per-profile, per-skill. This
preserves the existing profile-isolation guarantee.

Changes:

* agent/skill_utils.py:
  - DEFAULT_SHARED_SKILLS = ~/.hermes/shared-skills/ constant.
  - get_shared_skill_dirs(): reads skills.shared from the active
    profile's config.yaml, validates each entry (rejects path
    traversal and hidden dirs), returns paths to existing
    subdirectories under DEFAULT_SHARED_SKILLS.
  - get_all_skills_dirs(): updated to insert shared skills between
    local and external_dirs in the search path. Duplicates (same
    resolved path) are filtered, so listing a shared skill in both
    skills.shared and skills.external_dirs only includes it once.

* tests/agent/test_external_skills.py: 10 new tests covering
  resolution, missing-skill skip, path-traversal rejection,
  string-to-list normalization, precedence ordering, dedup with
  external_dirs, selective sharing across two profiles, local
  shadowing of shared skills, and backward compatibility (no
  skills.shared key → unchanged behavior).

* website/docs/user-guide/features/skills.md: new "Sharing Specific
  Skills Across Profiles" section documenting the convention,
  config syntax, relationship to external_dirs, precedence rules,
  and the read-only / copy-on-write semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cyb0rgk1tty added a commit to cyb0rgk1tty/hermes-agent that referenced this pull request Apr 6, 2026
…e skills

Adds `hermes skills install --shared` to install a skill into
`~/.hermes/shared-skills/` instead of the active profile's local
`~/.hermes/skills/` directory.

When neither `--shared` nor `--local` is passed, the install command
shows an interactive scope prompt so the user can choose explicitly.
`--yes`/`-y` (non-interactive mode) silently defaults to local.

Depends on:
- fix(skills): copy-on-write for agent edits of external skills (PR NousResearch#5407)
- feat(skills): skills.shared shorthand for cross-profile sharing (PR NousResearch#5535)

Changes:
- tools/skills_hub.py: add DEFAULT_SHARED_SKILLS + SHARED_HUB_LOCKFILE
  constants; add target_root/lockfile keyword params to
  install_from_quarantine() and uninstall_skill()
- tools/skills_tool.py: include get_shared_skill_dirs() in all four skill
  discovery/search functions so shared-installed skills are visible to agents
- hermes_cli/skills_hub.py: _prompt_install_scope(), config helpers
  (_add/_remove_skill_to/from_profile_shared_config()), updated do_install()
  with shared= param, do_uninstall() auto-detects shared vs local lockfile,
  do_list() adds Scope column and --source shared filter
- hermes_cli/main.py: --shared / --local mutually-exclusive flags on
  `hermes skills install`; --source shared choice on `hermes skills list`
- tests: 18 new tests covering helpers, shared install flow, uninstall scope,
  and list scope column; existing mocks updated for HubLockFile path kwarg
- docs: install scope section in Skills Hub docs

Co-Authored-By: cyb0rgk1tty <cyb0rgk1tty@users.noreply.github.com>
@alt-glitch alt-glitch added type/feature New feature or request tool/skills Skills system (list, view, manage) area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have labels May 1, 2026
@WompaJango

Copy link
Copy Markdown
Contributor

Related design point now tracked in #19451. I like this PR as a selective shared-skills mechanism, but I think there is still a broader semantics issue: users naturally interpret "global skills" as automatically global across profiles. #19451 proposes making that a first-class layer with precedence profile-local -> global -> external_dirs, so the word "global" actually means global.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused selective-sharing proposal. skills.shared is not present on current main, so the shorthand itself is not superseded.

Problems

  • The bundled copy-on-write implementation changes all skills.external_dirs, not only the new shared convention. Main deliberately supports writable external skills being updated in place: commit 8c8fc6c1 established that behavior, its regression coverage is at tests/tools/test_skill_manager_tool.py:760-864, and the current contract is documented at website/docs/user-guide/features/skills.md:325-326.
  • Adding shared roots only through get_all_skills_dirs() (PR agent/skill_utils.py:305-310) would not integrate them with current external-ownership checks. Main's curator guard uses is_external_skill_path() (tools/skill_manager_tool.py:337-349), which only checks get_external_skills_dirs() (agent/skill_utils.py:579-596). Shared skills therefore need an explicit lifecycle/ownership treatment.

Suggested changes

  • Split the shorthand from the generic external-directory copy-on-write reversal, then align shared-root ownership with curator protections and add coverage.
  • Preserve the current mtime-keyed config-cache approach when implementing shared-name resolution.

Automated hermes-sweeper review.

return None


def _is_external_skill(skill_path: Path) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This classification applies copy-on-write to every skills.external_dirs skill, not just the proposed shared root. Current main intentionally supports in-place external mutation (commit 8c8fc6c1, with regression coverage in TestExternalSkillMutations); please separate the shared-layer policy from that established compatibility behavior.

Comment thread agent/skill_utils.py
dirs: List[Path] = [get_hermes_home() / "skills"]
seen: Set[Path] = {local_skills}

for p in get_shared_skill_dirs():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appending this root to discovery alone leaves it outside current external-ownership checks: is_external_skill_path() iterates only get_external_skills_dirs(), and curator guards depend on that helper. Register shared roots with the ownership/lifecycle path too, and add a curator/background-write regression test.

Comment thread agent/skill_utils.py
if not config_path.exists():
return []
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the existing mtime-keyed config cache when salvaging this. get_all_skills_dirs() is called repeatedly during skill discovery; reparsing config.yaml here on every call recreates the startup-cost issue current main avoids for external_dirs.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
@WompaJango

WompaJango commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Correction posted below — I originally wrote this comment against current main HEAD instead of the PR's base SHA (6f1cb46df, April 6). Several claims were wrong as a result. Please read the correction.


Thanks for this — the skills.shared shorthand itself is a clean, well-reasoned feature (name-based opt-in, traversal validation, dedup, backward compat). I went through the diff and want to share what I found.

⚠️ The PR needs a rebase — main has fundamentally changed since this base

This PR is built on 6f1cb46df (April 6). Since then, main has had major changes to the external-skills subsystem:

Against the PR's own base, the CoW changes are consistent — the base docs say read-only. But after rebasing to current main, there will be real conflicts:

Conflict 1: CoW vs. in-place editing

The CoW changes in tools/skill_manager_tool.py would conflict with main's TestExternalSkillMutations (added in 8c8fc6c1e), which explicitly assert in-place writes and assert not (local / "ext-skill").exists(). Those tests would fail with the CoW code.

Decision needed: Should CoW apply to shared skills only, or reverse main's in-place contract for all external skills? If the former, scope the CoW to get_shared_skill_dirs() roots only. If the latter, that's a separate breaking-change PR with its own docs and test updates.

Conflict 2: Shared skills outside ownership guard

After rebase, is_external_skill_path() (added in 96bc524a7) checks only get_external_skills_dirs(). Shared skill dirs added via get_shared_skill_dirs() would be invisible to the curator guard. The curator could mutate/delete shared skills — defeating the read-only guarantee.

Fix: Make is_external_skill_path() also check get_shared_skill_dirs().

Conflict 3: No config cache

After rebase, get_external_skills_dirs() has an mtime-keyed cache (4e6d05c6a), but get_shared_skill_dirs() would parse YAML on every call. Since get_all_skills_dirs() is called once per skill during discovery, this recreates the startup cost that the cache was added to fix.

Fix: Mirror the existing cache pattern in get_shared_skill_dirs().

Valid regardless of base: DEFAULT_SHARED_SKILLS at import time

DEFAULT_SHARED_SKILLS = Path.home() / ".hermes" / "shared-skills"

Path.home() is called once at module load. The tests work around this with importlib.reload(su) in every test case. Computing it lazily inside the function (or via get_hermes_home()) would eliminate the reload hacks.

Summary

The skills.shared shorthand is genuinely useful. The main work item is a rebase + resolving the three conflicts above. The CoW question (shared-only vs. universal) is the key design decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/profiles Multi-profile isolation, HERMES_HOME scoping P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/skills Skills system (list, view, manage) type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants