I stopped trusting my own eyes the moment I put six near-identical directory names into a single ls listing. Exactly one of those six rows was really claude-helper. The other five looked the same width, roughly the same length, and carried completely different bytes.
What sent me down this path was Claude Code v2.1.247, released on August 26. Buried in the security section of the Claude Code changelog is a line saying that marketplace names containing control or invisible characters are now rejected. It reads like a small housekeeping item. But when I asked myself how I would verify the state of my own machine, I had no answer. So I sat down and worked it out.
As an indie developer I install plugins and skills alone, use them alone, and review them alone. The fewer colleagues there are to catch something, the more it pays to own one check that does not depend on eyesight.
Six names that a listing could not tell apart
I built six deliberately confusing names. All of them are mine, created for this test — none were collected from the wild.
| Variant | Character mixed in | Characters | UTF-8 bytes |
|---|---|---|---|
| Plain name | none | 13 | 13 |
| Zero-width space | U+200B | 13 | 15 |
| Zero-width joiner | U+200D | 14 | 16 |
| Right-to-left override | U+202E / U+202C | 18 | 22 |
| Cyrillic a | U+0430 | 13 | 14 |
| Soft hyphen | U+00AD | 13 | 14 |
The row worth pausing on is the zero-width space. It has exactly the same character count as the plain name — thirteen. Counting characters buys you nothing. The only difference is the byte count: 13 against 15.
Here is what ls produced. Row order varies by environment, but this is the entire signal available to your eyes:
claude-helper
claude-hel[U+200D]per
claude-[U+202E]resu-daer[U+202C]
claude[U+00AD]helper
claude[U+200B]helper
clаude-helper
The bracketed labels are markers I added for this article. Your terminal prints nothing at all in those positions, so on a real screen you get six rows stacked in nearly identical shapes. That I had to annotate them just to make them visible in a blog post is, I think, a fair summary of the whole problem.
Recovering the original names by staring at that output is not realistic. I could not do it. Pipe the same listing through cat -v, though, and the picture changes completely.
$ ls -A d | cat -v
claude-helper
claude-helM-bM-^@M-^Mper
claude-M-bM-^@M-.resu-daerM-bM-^@M-,
claudeM-BM--helper
claudeM-bM-^@M-^Khelper
clM-PM-0ude-helperM-bM-^@M-^K is how cat -v spells the UTF-8 encoding of U+200B (E2 80 8B). Every non-printing byte gets rewritten into that notation, so differences your eyes cannot resolve show up inline. It is the first command I reach for now when a name looks suspicious.
What v2.1.247 closed, and what stays your job
Reading the changelog entry carefully, three separate things changed:
- The marketplace rejects names that contain control or invisible characters.
- Marketplace-supplied text in
/pluginandclaude pluginoutput is escaped before it is printed. - In rendered Markdown, hyperlink targets that point at a network or automounter path, contain a control character, or lead with an invisible character are now shown as plain text instead of a live link.
Both the name itself and the path by which a name reaches your screen were tightened. That is a sensible pair of fixes.
It is, however, a defense at the point where Claude Code ingests something. It does not inspect directory names already sitting on your disk, configuration files you wrote by hand, or the contents of a repository you cloned from GitHub last month. Those were the ones I actually wanted to know about.
Two check functions to run before you install
What I ended up with is two short shell functions. They live in my shell startup file, and I run them right after installing anything new.
# Detect names containing control or invisible characters
scan_names() {
local dir="${1:?usage: scan_names <dir>}"
local names hits
names=$(cd "$dir" && ls -A) || return 2
hits=$(printf '%s\n' "$names" \
| grep -nP '[\x00-\x1F\x{00AD}\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{206F}\x{FEFF}]')
if [ -n "$hits" ]; then
printf '%s\n' "$hits" | cat -v
echo "FAIL: $(printf '%s\n' "$hits" | wc -l) name(s) contain control or invisible characters"
return 1
fi
echo "OK: no control or invisible characters found"
}The character class covers C0 control characters (U+0000–U+001F), the soft hyphen (U+00AD), zero-width and directional formatting characters (U+200B–U+200F and U+202A–U+202E), general punctuation format characters (U+2060–U+206F), and the byte order mark (U+FEFF). The \x{...} syntax belongs to PCRE, so your grep has to be built with --perl-regexp support. The stock macOS grep is not, so either install GNU grep with brew install grep and call ggrep, or use a short Python equivalent instead.
Run against the six test names:
$ scan_names d
2:claude-helM-bM-^@M-^Mper
3:claude-M-bM-^@M-.resu-daerM-bM-^@M-,
4:claudeM-BM--helper
5:claudeM-bM-^@M-^Khelper
FAIL: 4 name(s) contain control or invisible characters
$ echo $?
1Four of six. Letting the plain name through is correct behavior. The problem is the fifth one that slipped past.
The Cyrillic a walks straight through
The one that escaped is clаude-helper. Its third character is not the Latin a (U+0061) but the Cyrillic а (U+0430). That is not invisible and not a control character — it is an ordinary printable letter in its own right, so no pattern built around invisibility will ever match it.
$ python3 -c "
a='claude-helper'; b='clаude-helper'
print('strings equal:', a == b)
import unicodedata
print('equal after NFKC:', unicodedata.normalize('NFKC',a) == unicodedata.normalize('NFKC',b))
"
strings equal: False
equal after NFKC: FalseUnicode normalization does not collapse them either. NFKC folds compatibility characters together, and Cyrillic and Latin letters are deliberately distinct scripts, so this is exactly what the standard promises. Chasing every confusable pair properly means pulling in Unicode's confusables table, which is more machinery than a personal pre-install check deserves.
So I moved the line earlier instead. My assumption is that a plugin or skill name should be printable ASCII and nothing else, and anything outside that gets reported.
# Detect names containing anything outside printable ASCII (catches homoglyphs too)
scan_ascii_only() {
local dir="${1:?usage: scan_ascii_only <dir>}"
local hits
hits=$(cd "$dir" && ls -A | LC_ALL=C grep -n '[^ -~]')
if [ -n "$hits" ]; then
printf '%s\n' "$hits" | cat -v
echo "FAIL: $(printf '%s\n' "$hits" | wc -l) name(s) contain non-ASCII characters"
return 1
fi
echo "OK: everything is printable ASCII"
}[^ -~] means anything outside the range from space (0x20) to tilde (0x7E). LC_ALL=C keeps the character class from being reinterpreted under a different locale.
$ scan_ascii_only d
2:claude-helM-bM-^@M-^Mper
3:claude-M-bM-^@M-.resu-daerM-bM-^@M-,
4:claudeM-BM--helper
5:claudeM-bM-^@M-^Khelper
6:clM-PM-0ude-helper
FAIL: 5 name(s) contain non-ASCII charactersFive out of five. If you keep directories named in your own language you will see false positives, but scoped to a plugin and skill directory, I find the blunter rule far more usable. A single line with a handful of eyeballed exceptions is a check I will still be running next year. A clever one is not.
Running it on my own 906 entries turned up nothing
Writing the check is meaningless without pointing it at my own machine, so I walked my plugin and skill directories: 906 entries in total counting both files and directories, with the longest name at 43 characters.
Zero names with control or invisible characters. Zero names with any non-ASCII character at all.
Nothing came back. As blog material that is an anticlimax, and I would rather say so plainly. I keep the check anyway, because "verified as zero" and "never looked" are not the same state. The first one turns any future addition into a visible difference. The second one never tells you anything.
One more thing worth passing on: my first version used find with -printf, which is a GNU findutils extension and simply does not exist on macOS. That is why the functions above are built on ls -A. Shipping code that happens to run on the machine you wrote it on is the easiest trap in this whole category.
What to try next
Point scan_ascii_only at your plugin and skill directory once. If it comes back clean, write that down somewhere and move on — that is the whole deliverable. If something does turn up, read the cat -v line and decide whether that character got there on purpose. Either way you have closed a gap that visual inspection was never going to close.
When running it by hand starts to feel tedious, the next step is putting it in a hook. Which of the eight hook types fits which kind of check — and the specific traps I walked into with each — is covered in Claude Code Hooks: A Complete Field Guide to All 8 Hook Types and How to Pick the Right One.