
Running a Claude Code skill from another repo — a symlink, cd -P, and every path absolute
Making a Claude Code skill reachable from any repository: a symlink for discovery, additionalDirectories for access, one cd -P at entry, and no relative paths.
On this page
Introduction
I keep a trilingual blog whose article pipeline is a Claude Code skill living in the blog’s own repository. The problem is that almost every article is about work that happened somewhere else: an incident in a different codebase, the three things tried before the fix, who decided what. That material only exists in the session that lived through it, and a week later it comes back thinner.
So the writing wants to start in the other repository, while the pipeline stays in this one. My workaround was to paste the skill’s path into that session, which produced a draft that looked finished and had silently skipped every check the pipeline runs.
The fix turned out to be four things, and none of them was the one I went looking for: a symlink for discovery, permissions.additionalDirectories for access, a single cd -P at entry, and no relative file paths anywhere. Getting there took four releases in one day, because three separate assumptions about how paths resolve were wrong, and each one was only visible from the far side.
This article walks through the working setup first, then each assumption and what disproved it.
The setup that works
Two symlinks and one settings key, once:
mkdir -p ~/.claude/skillsln -s /path/to/blog-repo/.claude/skills/blog ~/.claude/skills/blogln -s /path/to/blog-repo/.claude/skills/svg ~/.claude/skills/svg{ "permissions": { "additionalDirectories": ["/path/to/blog-repo"] }}Then /blog new <topic> works from any repository, with no per-session command.
The two halves do different jobs and neither substitutes for the other. The symlink makes the skill discoverable: the documentation states that a skill-name entry in the personal, project, or enterprise location may be a symlink, and Claude Code reads SKILL.md from the target. The settings key grants file access to the repository, and makes the directory permitted, which matters for a reason covered below.
A symlink is not a copy, which is the point. It resolves to the repository’s own file, so it cannot drift. That matters because a personal skill overrides a project skill of the same name. A copied one would shadow the real pipeline inside the blog repository too, going stale invisibly; a link shadows it with itself.
The second symlink is easy to forget. The blog skill delegates diagram work to a sibling svg skill through the Skill tool, and a skill that was never discovered cannot be invoked.
What using it actually looks like
The invocation is the boring part, which is the goal. In a session in some unrelated repository, mid-way through the work the article is about:
/blog new astro service worker cache evictionThe skill loads, its entry step prints the blog repository’s path, and the interview starts. Nothing about the calling repository comes up, and no path is typed by hand.
What is worth being precise about is where things end up, because that is the part with a wrong answer available. The article folder, the .mdx, the figure components and the images are all written into the blog repository. The far repository is untouched. The session that holds the material stays where it is, and only the file crosses.
Then, back in the blog repository:
/blog publish astro-service-worker-cache-evictionPublishing is the one step that stays home, and not for tidiness. It starts a dev server to measure figure labels, runs a production build, and greps the build output. The repository’s own CLAUDE.md carries the rules governing all three, and CLAUDE.md does not load from a directory reached by symlink or by --add-dir. A far session would run those steps with the guardrails missing.
Every subcommand starts with one cd
--add-dir and a symlink both make the skill reachable. Neither changes the working directory, which stays in the far repository. So pnpm check, node scripts/prose-check.ts and every other command in the skill would run against the wrong project.
The Bash tool keeps a single shell whose working directory persists between calls, so one cd at the start of each subcommand is enough for all thirty-five command blocks in the skill:
cd -P ${CLAUDE_SKILL_DIR}/../../.. && pwdThe pwd is not decoration. It makes “I am in the wrong repository” visible instead of silent, and a far repository that happens to define its own check script will otherwise pass a gate against the wrong project.
Persistence has one condition worth knowing, because it decides whether this design works at all. Measured across three targets:
cd target |
Permitted | Next call’s pwd |
|---|---|---|
| A directory inside the project root | yes | held |
/tmp, an additionalDirectories entry |
yes | held |
| An unlisted repository | no | Shell cwd was reset to … |
The working directory survives only while the directory is permitted. That is why additionalDirectories is not optional. Without it the cd is quietly undone after the command, and the next check runs in the far repository.
Assumption 1: that a compound command can be pre-approved
The first version pre-approved the entry step with a rule in the skill’s allowed-tools:
Bash(cd ${CLAUDE_SKILL_DIR}/../../.. && pwd)That rule can never match. The permissions documentation is explicit: the recognised separators are &&, ||, ;, |, |&, & and newlines, and a rule must match each subcommand independently. A rule whose own text contains && matches neither half of the command it was written for.
It granted nothing, silently, and the step ran prompt-free anyway because cd into a permitted directory and pwd are both built-in read-only commands. The rule was removed rather than split, because a prompt at that step is a signal worth keeping.
What that signal means took a second correction to get right. The first version of this said a prompt there meant the target was not permitted. It can also mean the Bash tool is not granted in that context — which is exactly what happened to the sibling svg skill, given a mandatory cd-based step while its allowed-tools line carried no Bash entry at all. Two causes, two different fixes, and naming only one sends a reader to the wrong file.
The same rule demolished a second claim
The same splitting rule demolished a second claim. I had rejected an earlier design that prefixed every command with cd … &&, and the agent justified that reversal by arguing the prefixes would break the existing bare Bash(pnpm check) rules. They would not have. Subcommand matching means cd X && pnpm check matches a pnpm check rule perfectly well.
My reason for rejecting the prefixes was that they were churn for a rare case, and that reason stood. The one written down underneath it did not.
Assumption 2: that /add-dir is available
/add-dir <path> does everything the symlink and the settings key do together, in one command. It was the entire documented entry point for the first release.
It is CLI-only. It does not exist in the VS Code or JetBrains extensions, which is where I actually write. The feature shipped with a front door that could not be opened from the room it was built for.
Nothing in the documentation says so. The permissions page describes “the --add-dir flag or the /add-dir command” with no carve-out, and the only extension-specific difference recorded anywhere is for /bug. Issue #36123 requested it for the IDE extensions and was closed NOT_PLANNED by a staleness bot after inactivity, rather than by a decision. It sits inside a wider known gap: the extension shows an incomplete slash command list compared to the CLI.
Why the obvious workaround does not help
Running claude --add-dir <repo> in the integrated terminal works perfectly, with a real flag and real substitution. But it starts a new session, and the material worth writing about lives in the current session’s transcript. That transcript is the whole reason the drafting happens elsewhere.
/add-dir stays documented as the second route, because where it does exist it is better: it loads the added repository’s entire .claude/skills/ directory, sibling skills included, and permits the repository by the same act. It needs neither symlink nor settings key.
Assumption 3: that a symlink is transparent
This is the one that cost two releases, and it is the part worth taking away.
A symlinked directory is transparent going down and opaque going up. Reads into it resolve to the real file. Anything using .. to climb out of it does not, because paths are normalised lexically: .. collapses against the textual path and never sees the target.
.. climbs the link’s own parents instead, landing in the home directory.Measured, with the skill symlinked at ~/.claude/skills/blog:
| Path | Result |
|---|---|
<link>/voice.md |
resolves to the real file |
cd <link>/../../.. |
wrong, lands in ~ |
cd -P <link>/../../.. |
correct, and identical with no symlink |
Read <link>/../../../docs/glossary.md |
File does not exist |
Read <link>/../svg/web-figure.md |
File does not exist, even for a sibling |
Two consequences follow.
cd needs -P. A shell keeps a logical working directory, so a plain cd walks ../../.. up the symlink’s path. -P resolves the link physically first and then applies .. from the real location. It gives the same answer when there is no symlink, so it costs nothing to make unconditional.
The file tools have no -P, and no amount of .. will reach the repository. ${CLAUDE_SKILL_DIR} expands to the symlink’s path, so it cannot be used to climb out either. Anything outside the skill directory has to be built from the root that the entry cd printed. That is why the step reports its pwd, and why the skill refers to that value by name everywhere afterwards.
The assumption inside the assumption
Having written all that down, the skill still claimed that relative paths to its own files worked, and labelled the claim “measured, not assumed”.
It had not been measured. What had been tested was ~/.claude/skills/blog/voice.md, the absolute form. What the skill actually wrote was [voice.md](voice.md), the relative form. Two different claims, one word apart, and only the wrong one carried the word “measured”.
A diagnostic run from an unrelated repository returned eleven passes and three failures, and the three were the pipeline reading its own writing guidelines:
File does not exist. Note: your current working directory is/Users/…/oharu-tech-blog.The file tools do not resolve a relative path against the skill’s directory. Not even for a file sitting beside the one being read.
The fix was mechanical: sixty-one markdown links across ten files were rewritten to carry their directory, so the rendered dispatch table reads [new.md](/Users/…/.claude/skills/blog/new.md) and passing it through verbatim is correct.
A stated rule would probably have worked, since the far session applied a comparable rule correctly on every check that used it. The links were rewritten anyway, on one asymmetry. A placeholder looks unresolved, and a reader who ignores it gets an obviously broken path; [voice.md](voice.md) looks like a link that already works. It reads as correct, renders as correct, and fails only at the moment of use. That is how it survived two code reviews and a release.
Testing a flow that only breaks somewhere else
This is the part I would reuse on anything else with a far and a near side, and it took three rounds of asking the wrong way to arrive at.
The problem is structural. The bug only exists in the other repository, the fix only exists here, and the two are different sessions that cannot see each other. Asking “does it work over there?” comes back as prose, which is the least useful shape available: it mixes what was observed with what was inferred, and it omits whatever the other session did not think to mention.
What works is to send one prompt containing a numbered battery of checks and a fixed output schema, and ask for the table back. Roughly:
Run a diagnostic. This is a TEST: change nothing, write nothing.
T1 Report the base directory the skill loaded from.T2 Report the exact path the entry step printed.T3 In a FRESH shell call, run pwd. Report it verbatim.T4 Read voice.md via the link as the skill writes it. First line?…T14 NEGATIVE CONTROL. This is EXPECTED TO FAIL. Try to read exactly: ~/.claude/skills/blog/../../../docs/glossary.md If it SUCCEEDS, say so loudly.
OUTPUT — reply with only this:| ID | PASS/FAIL | Evidence (verbatim, one line) |
ANY PATH THAT FAILED TO RESOLVEANYTHING YOU COULD NOT DOThe battery-and-schema shape was not the first attempt. The first round asked the far session to run two subcommands and describe what happened, which produced exactly the readable prose that hides things. I asked for it to be restructured into one prompt with a fixed output format, and that is the version worth keeping.
What makes the battery work
Four things, each learned by leaving it out first.
- Demand verbatim evidence, not verdicts. A column reading
PASSis a claim; a column containing# Voiceis a fact. The evidence column is what separates a real pass from a plausible one when the person reading the table was not there. - Include a negative control. A check that is expected to fail is the one that proves the harness is honest. If everything passes, including the thing that should not, the run is measuring nothing.
- Ask what it could not do. This produced the single most useful line of the exercise, and it was unprompted: the far session reported that its negative control’s error was byte-identical to three real failures, so that run could not distinguish two competing explanations. Without it, the result would have been counted as evidence the test had not actually earned.
- Say “do not fix anything.” A session that quietly retries with a corrected path reports success and destroys the evidence. Silent recovery is exactly how the original wrong claim survived.
The loop is then: run the battery over there, paste the table back here, fix here, re-run. The far session never edits anything, which keeps one repository as the only place changes happen.
It is worth knowing what this cannot do. A prompt written against the old behaviour goes stale the moment the behaviour changes: after the links were made absolute, three checks still asked for “the relative link from the skill”, which no longer existed, so they failed while testing nothing. The far session caught that too, and tested both readings rather than picking one.
What actually caught these
Worth stating plainly, because it decided how the remaining work was verified.
Two code reviews and a release passed over the relative-path claim. A fourteen-check diagnostic run from another repository found it on the first attempt. Reviews are good at contradictions between two pieces of text; they cannot catch a sentence that is internally coherent and false about the world.
The other two were caught the same way, by something outside the loop. I found /add-dir missing by trying to type it. The compound-rule error surfaced only when a verification agent read the permissions documentation rather than the code.
The limit of that diagnostic
The negative control returned an error byte-identical to the three real failures, so that run alone could not distinguish “escapes the directory and collapses” from “relative never anchors at all”. The lexical-collapse model survives on separate evidence, gathered with absolute paths, which are independent of the working directory. A test that cannot tell two explanations apart has not chosen between them, whatever its verdict column says.
One question is deliberately still open. What a relative path is resolved against, the session’s directory or the shell’s, was never tested with a path that could distinguish them, and the skill says so instead of picking. Nothing depends on the answer, because absolute paths are correct under either. Settling it by argument is exactly how the previous three went wrong.
Summary
To make a Claude Code skill runnable from any repository:
- Symlink the skill directory into
~/.claude/skills/, and every sibling skill it invokes. A link cannot go stale; a copy shadows the original and does. - Add the repository to
permissions.additionalDirectories. It grants file access, and it makes the directory permitted. Without that, the working directory silently stops persisting between calls. - Start each subcommand with
cd -P ${CLAUDE_SKILL_DIR}/../../.. && pwd, and report the path. Onecdcovers every shell command in the skill, and-Pis what makes it correct through a symlink. - Use no relative file paths at all, not even to a file in the same directory. Write links with their directory substituted in, and build everything outside the skill from the path the entry step printed.
/add-dirreplaces the first two steps in one command, and does not exist in the IDE extensions. Use it where you have it.- Test it with a numbered battery and a fixed output schema, run from a real far session, demanding verbatim evidence, one negative control, and an explicit “what could you not do”.
The recurring failure was not any single wrong path. It was writing down a mechanism after testing something adjacent to it: the absolute path instead of the relative one, the near session instead of the far one. Each time, the correction came from running the thing somewhere else, not from reading it again.
References
- Extend Claude with skills, including the note that a skill directory entry may be a symlink
- Configure permissions, covering additional directories and the recognised command separators
- Issue #36123: Support /add-dir in IDE extensions (VS Code / JetBrains), closed NOT_PLANNED
- Issue #8590: VS Code extension has an incomplete slash command list compared to the CLI
- Issue #14836: /skills does not find skills in symlinked directories, still open

