Summary
Adopting a Skill (POST /api/skills/{ref}/manage) is not atomic and produces a confusing experience when the harness's skill folder is a symlink to a third‑party central store (a very common setup — see below). Two problems compound:
-
Non-atomic adopt leaves a phantom "Managed" skill. manage_skill → _manage_entry ingests the package into the shared store (writing shared/<pkg> + a manifest.json entry) before it tries to bind the harnesses. If a binding step then raises, the ingested package is never rolled back. The adopt returns an error, but on the next list refresh the skill now shows up as Managed / In use even though nothing was actually bound. From the user's side it looks like "I clicked Adopt, nothing happened, then a while later it silently showed up as In use."
-
Cryptic error on the failing binding. When the harness skill folder is a symlink that points somewhere other than skill-manager's store, adapter.adopt_local_copy raises symlink exists but points to X, not Y (HTTP 409). That message is unactionable for an end user.
Real-world trigger
External skill installers (the kind that write ~/.agents/.skill-lock.json) install Skills into a central ~/.agents/skills/<name> directory and symlink each harness into it, e.g. ~/.claude/skills/<name> -> ~/.agents/skills/<name>. This is the "install once, share across harnesses" pattern.
When skill-manager tries to adopt such a skill, it wants the harness folder to be a symlink into its own shared store, finds it pointing at ~/.agents instead, and fails — but only after it has already written into its store. Note this also interacts with the fact that skill-manager maps ~/.agents/skills to the Codex managed root, so these skills get mis-attributed to Codex even for users who only use Claude.
Repro
- Install a Skill via an installer that symlinks
~/.claude/skills/<name> to an external dir (e.g. ~/.agents/skills/<name>).
- In skill-manager, open Skills → Needs review and click Adopt on that skill.
- The request returns 409 with
symlink exists but points to ....
- Refresh the page → the skill now appears under In use (phantom Managed), and
shared/<name> + a manifest.json entry have been left behind.
Where
skill_manager/application/skills/mutations.py → _manage_entry (ingest happens before binding; no rollback on failure)
skill_manager/application/skills/adapters.py → adopt_local_copy (raw symlink exists but points to ... message)
Suggested fix
- Make adopt atomic: if any binding step raises, roll the freshly-ingested package back out of the shared store + manifest. In the realistic failure modes (uninstalled harness; folder is a symlink to an external store) the harness folders are untouched at raise time, so removing the ingest fully restores prior state.
- Replace the symlink error with an actionable message that names the harness, says the folder is managed outside skill-manager (e.g. by another installer), and tells the user to repoint/remove the link first.
I have both changes implemented locally with integration tests reproducing the phantom-managed case and asserting the rollback. Happy to open a PR if useful.
Notes for a Claude-only + existing-installer setup
For users who only run one harness and already use an external installer, skill-manager's adopt mostly duplicates what they have and conflicts with it. It might be worth detecting an externally-managed (symlinked) skill and surfacing a "managed by an external tool — not adoptable here" state instead of attempting adopt at all.
Summary
Adopting a Skill (
POST /api/skills/{ref}/manage) is not atomic and produces a confusing experience when the harness's skill folder is a symlink to a third‑party central store (a very common setup — see below). Two problems compound:Non-atomic adopt leaves a phantom "Managed" skill.
manage_skill→_manage_entryingests the package into the shared store (writingshared/<pkg>+ amanifest.jsonentry) before it tries to bind the harnesses. If a binding step then raises, the ingested package is never rolled back. The adopt returns an error, but on the next list refresh the skill now shows up as Managed / In use even though nothing was actually bound. From the user's side it looks like "I clicked Adopt, nothing happened, then a while later it silently showed up as In use."Cryptic error on the failing binding. When the harness skill folder is a symlink that points somewhere other than skill-manager's store,
adapter.adopt_local_copyraisessymlink exists but points to X, not Y(HTTP 409). That message is unactionable for an end user.Real-world trigger
External skill installers (the kind that write
~/.agents/.skill-lock.json) install Skills into a central~/.agents/skills/<name>directory and symlink each harness into it, e.g.~/.claude/skills/<name> -> ~/.agents/skills/<name>. This is the "install once, share across harnesses" pattern.When skill-manager tries to adopt such a skill, it wants the harness folder to be a symlink into its own shared store, finds it pointing at
~/.agentsinstead, and fails — but only after it has already written into its store. Note this also interacts with the fact that skill-manager maps~/.agents/skillsto the Codex managed root, so these skills get mis-attributed to Codex even for users who only use Claude.Repro
~/.claude/skills/<name>to an external dir (e.g.~/.agents/skills/<name>).symlink exists but points to ....shared/<name>+ amanifest.jsonentry have been left behind.Where
skill_manager/application/skills/mutations.py→_manage_entry(ingest happens before binding; no rollback on failure)skill_manager/application/skills/adapters.py→adopt_local_copy(rawsymlink exists but points to ...message)Suggested fix
I have both changes implemented locally with integration tests reproducing the phantom-managed case and asserting the rollback. Happy to open a PR if useful.
Notes for a Claude-only + existing-installer setup
For users who only run one harness and already use an external installer, skill-manager's adopt mostly duplicates what they have and conflicts with it. It might be worth detecting an externally-managed (symlinked) skill and surfacing a "managed by an external tool — not adoptable here" state instead of attempting adopt at all.