The Claude starburst logo in terracotta and the Claude wordmark in black on a white card

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:

Terminal window
mkdir -p ~/.claude/skills
ln -s /path/to/blog-repo/.claude/skills/blog ~/.claude/skills/blog
ln -s /path/to/blog-repo/.claude/skills/svg ~/.claude/skills/svg
~/.claude/settings.json
{
"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 eviction

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

The article file crosses to the blog repo; the transcript stays putTwo repositories side by side. On the left, the repository the work happened in, holding the session transcript, where the new and figures subcommands run. An arrow crosses to the right carrying the article file. On the right, the blog repository, holding the content and the build, where publish runs. A note states that the transcript itself never moves.Draft where the work happened, publish where the site is builtThe repo you were working inHolds the session transcript/blog new/blog figuresThe blog repoHolds the content and the build/blog publishthe article fileThe transcript never moves. That is why the drafting comes to it.
Only the article file crosses. The transcript the article is made of never moves, which is why the drafting goes to it rather than the material being handed over.

Then, back in the blog repository:

/blog publish astro-service-worker-cache-eviction

Publishing 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:

Terminal window
cd -P ${CLAUDE_SKILL_DIR}/../../.. && pwd

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

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.

Reads into a symlinked directory resolve; .. out of one does notTwo rows. In the first, a path descending into the symlinked skill directory reaches the real file inside the repository, marked as resolving. In the second, the same directory followed by three levels of dot-dot reaches a path under the home directory instead of the repository, marked as not found.A symlink is transparent going down and opaque going upPath as writtenWhat it actually reaches~/.claude/skills/blog/voice.md<repo>/.claude/skills/blog/voice.mdresolvesReads into the linked directory reach the repository.~/.claude/skills/blog/../../../docs/glossary.md~/docs/glossary.mdnot found.. climbs the link’s own parents, so no depth reaches the repository.
Reads into the linked directory reach the repository. Every .. 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.

Each wrong assumption was disproved from outside the workThree rows pairing an assumption with what disproved it. That /add-dir works in every client was disproved by typing it in the VS Code extension. That a permission rule may contain a double ampersand was disproved by reading the permissions documentation. That a relative link resolves against the skill directory was disproved by a diagnostic run from an unrelated repository. None was found by re-reading the work itself.Three assumptions, and what disproved eachAssumedDisproved by1/add-dir is the entry pointfor every clientTyping it in theVS Code extension2An allowed-tools rule cancontain &&Reading thepermissions documentation3A relative link resolvesagainst the skill directoryA diagnostic run froman unrelated repositoryNot one was caught by re-reading the work.
Each release fixed the previous one’s assumption. What disproved each came from outside the work every time: the editor, the documentation, a run in another repository.

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 RESOLVE
ANYTHING YOU COULD NOT DO

The 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 PASS is a claim; a column containing # Voice is 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. One cd covers every shell command in the skill, and -P is 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-dir replaces 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

Share this article