Skip to content

Adopt is non-atomic and confusing when a harness skill folder is a symlink to an external store #48

Description

@ZeshiWu

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:

  1. 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."

  2. 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

  1. Install a Skill via an installer that symlinks ~/.claude/skills/<name> to an external dir (e.g. ~/.agents/skills/<name>).
  2. In skill-manager, open Skills → Needs review and click Adopt on that skill.
  3. The request returns 409 with symlink exists but points to ....
  4. 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.pyadopt_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions