feat(skills): skills.shared shorthand for cross-profile sharing - #5535
feat(skills): skills.shared shorthand for cross-profile sharing#5535cyb0rgk1tty wants to merge 2 commits into
Conversation
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>
…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>
|
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 |
teknium1
left a comment
There was a problem hiding this comment.
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: commit8c8fc6c1established that behavior, its regression coverage is attests/tools/test_skill_manager_tool.py:760-864, and the current contract is documented atwebsite/docs/user-guide/features/skills.md:325-326. - Adding shared roots only through
get_all_skills_dirs()(PRagent/skill_utils.py:305-310) would not integrate them with current external-ownership checks. Main's curator guard usesis_external_skill_path()(tools/skill_manager_tool.py:337-349), which only checksget_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: |
There was a problem hiding this comment.
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.
| dirs: List[Path] = [get_hermes_home() / "skills"] | ||
| seen: Set[Path] = {local_skills} | ||
|
|
||
| for p in get_shared_skill_dirs(): |
There was a problem hiding this comment.
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.
| if not config_path.exists(): | ||
| return [] | ||
| try: | ||
| parsed = yaml_load(config_path.read_text(encoding="utf-8")) |
There was a problem hiding this comment.
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.
|
Thanks for this — the
|
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:
Profiles opt in by name in their
config.yaml:Internally,
skills.sharedresolves to subdirectories of~/.hermes/shared-skills/that are appended to the skill search path between the profile-localskills/dir and anyskills.external_dirsentries. The end result is identical to listing the individual skill directories underskills.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?..traversal, no leading dot) are harder to mis-configure than raw paths.What it is NOT
skills.sharedkey 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.external_dirs.skills.external_dirsis still the right tool for skills outside~/.hermes/, env-var-substituted paths, or whole directories of skills.~/.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
Changes Made
agent/skill_utils.pyDEFAULT_SHARED_SKILLS = ~/.hermes/shared-skills/.get_shared_skill_dirs(): readsskills.sharedfrom the active profile'sconfig.yaml, validates each entry (rejects path traversal, slashes, hidden directories), returns paths to existing subdirectories underDEFAULT_SHARED_SKILLS. Invalid names and missing directories are silently skipped so a typo or half-provisioned host doesn't break the agent.get_all_skills_dirs(): inserts shared skill dirs between the profile-localskills/dir andskills.external_dirsentries. Duplicates (same resolved path) are filtered, so listing a shared skill in bothskills.sharedandskills.external_dirsonly includes it once. Updated docstring documents the full precedence order.tests/agent/test_external_skills.py— 10 new testsTestGetSharedSkillDirs:test_no_shared_config_returns_empty— noskills.sharedkey → empty listtest_shared_skill_resolved_by_name— name is resolved to the right directorytest_missing_shared_skill_silently_skipped— typo in config doesn't break discoverytest_path_traversal_rejected—../../etc,foo/bar,.hiddenare droppedtest_string_value_converted_to_list—shared: name(single string) worksTestSharedInGetAllSkillsDirs:test_shared_appears_after_local_before_external— order is local → shared → externaltest_no_shared_config_backward_compat— pre-PR behavior preserved when key absenttest_shared_not_duplicated_when_also_in_external_dirs— dedup worksTestSelectiveSharingAcrossProfiles:test_profile_a_and_b_see_different_subsets— proves the per-profile, per-skill selectivityTestLocalShadowsShared:test_profile_local_skill_takes_precedence_over_shared_by_position— local skills come firstwebsite/docs/user-guide/features/skills.mdexternal_dirs, precedence rules, and a pointer to the read-only / copy-on-write semantics from the External Skill Directories section.How to Test
Checklist
Code
feat(skills): ...)tests/agent/test_external_skills.pysuite passesDocumentation & Housekeeping
website/docs/user-guide/features/skills.md)cli-config.yaml.example(the new key is per-profile, not global)