A Migration Must Know Which Symlinks It Owns

The agentic-coding-toolkit changelog describes a migration from command wrappers to skills. One release removed stale toolkit-owned links while preserving user-managed entries. That sentence is short. The filesystem decision behind it is not.
An installer may already have created aliases in several host directories. Some can be symlinks from an old name. Users can replace others. Some can point nowhere after the new package arrives.
“Delete the old links” is suddenly not cleanup. It is a mutation across files that may no longer belong to the installer.
The safe direction is narrower: a migration may remove an artifact only when it can establish that the installer created and still owns that artifact.
A path is not proof of ownership
Installers often know expected destinations:
host-a/commands/tool
host-b/commands/tool
host-c/skills/tool
Finding something at an expected path does not make it disposable.
A user may have replaced a generated link with a regular file. Another tool may have claimed the name. A previous version may have linked it to a different target. An interrupted migration may have left a broken symlink.
Each state deserves a different response:
- expected symlink to the installer-managed target: eligible for cleanup;
- broken symlink whose recorded target matches the old managed artifact: eligible only under an explicit migration rule;
- symlink to an unknown target: preserve and report;
- regular file or directory: preserve and report;
- absent path: nothing to do.
The conservative cases may leave clutter. That is preferable to deleting a user’s replacement because its filename resembles installer history.
Inspect the link itself, not only its destination
Filesystem helpers can make broken links awkward. A call that follows a link may report that the path does not exist, even though the link entry is exactly what migration code needs to inspect.
The migration must reason about the directory entry and its recorded target separately. In pseudocode:
const entry = lstatIfPresent(path);
if (entry === null) return;
if (!entry.isSymbolicLink()) {
preserve(path);
return;
}
const target = readlink(path);
if (isManagedOldTarget(target)) {
remove(path);
return;
}
preserve(path);
This is not a request to copy those exact calls into every runtime. It is the decision boundary that matters: identify artifact type, inspect ownership evidence, then mutate.
Do not use “the destination resolves” as the only condition. The migration exists precisely because old destinations may have moved.
Compatibility aliases need an expiry story
Keeping every old command name forever avoids immediate breakage and creates permanent ambiguity.
Once skills become the canonical execution unit, aliases should have a declared role: compatibility entry points that forward to one implementation. They should not carry separate instructions, installation behavior, or release logic.
A migration plan should answer:
- Which unit is canonical now?
- Which old names remain as wrappers?
- Who created each wrapper?
- What evidence permits its removal?
- What happens to a user-owned collision?
- When does compatibility end?
Without those answers, the package slowly accumulates doors that open into different rooms. (Eventually somebody labels the hallway “legacy” and hopes for the best.)
Make the second run boring
Migration code should also be idempotent.
After one successful run, a second run should find the canonical installation present, old installer-owned links absent, and user-owned paths untouched. It should not recreate an alias it just removed or fail because cleanup already happened.
Test the states as a matrix: expected link, wrong link, broken managed link, regular file, directory, and missing path. Run the migration twice against each fixture.
The first run proves transformation. The second proves that transformation has a stable destination.
Cleanup is part of the product contract
Developers rightly focus on the new unit being installed. Users often meet the migration through what was left behind: duplicate menu entries, broken aliases, or a customized file that disappeared.
That makes cleanup observable product behavior, not an implementation footnote.
Before deleting an old alias, require evidence stronger than its name. If evidence is weak, preserve the artifact and print a specific manual action. A small leftover is visible and reversible. An overconfident deletion is neither.
The next time an installer migration says “remove legacy links,” replace that sentence with a list of artifact states and ownership checks. If the list feels annoyingly detailed, good. The filesystem was already that detailed; the original sentence was merely hiding it.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.