Why I Switched from Bun to Deno for Claude Code Skills
Last week I wrote about using npx bun to write Claude Code skills with third-party dependencies. The approach worked for simple cases—self-contained scripts with auto-installing packages, no build step. But after using it in real environments, I discovered a significant problem.
Bun's auto-install only works when no node_modules directory exists in the working directory or any parent directory. When node_modules is present anywhere up the tree, Bun switches to standard Node.js module resolution. Version specifiers in imports—the core feature that made the approach useful—throw VersionSpecifierNotAllowedHere errors:
$ cd ~/my-project # has node_modules/$ cat skill.ts#!/usr/bin/env -S npx -y bunimport chalk from "chalk@^5.0.0"console.log(chalk.green("Hello"))$ ./skill.tserror: VersionSpecifierNotAllowedHereimport chalk from "chalk@^5.0.0"^
This breaks in practical scenarios. Run a skill from within a project directory? Broken. Work in a monorepo where some ancestor has node_modules? Broken. Your home directory happens to have an old node_modules from a forgotten experiment? Broken.
For portable Claude Code skills that might run from anywhere, this is a footgun. The script works when you test it in ~/.claude/skills/, then fails mysteriously when Claude invokes it from a different directory. The error message obscures the problem—diagnosing it requires understanding Bun's internal resolution logic.
Credit for the solution goes to J Edward Wynia, who pointed me toward Deno in response to that article. I forget why I skipped Deno initially—probably because Bun's syntax looked cleaner—but the suggestion was right.
Why Deno Solves This
Deno's npm: specifier works regardless of whether node_modules exists. Dependencies always go to Deno's global cache at ~/.cache/deno. Local node_modules directories don't affect resolution. Consistent behavior everywhere.
The same npx distribution trick works. Just like npx -y bun, you can use npx -y deno to run Deno without installing it globally. Any environment with npm can execute Deno scripts.
One caveat: if Deno is already installed on your system, npx -y deno still downloads a separate copy to npm's cache (~40MB, comparable to Bun's ~100MB first-download cost). For systems with Deno pre-installed, use deno run directly. The npx approach targets portability—scripts that work on any machine with npm, regardless of what's pre-installed.
The Deno Approach
Here's what a Deno-based skill looks like:
#!/usr/bin/env -S npx -y deno run --allow-read --allow-writeimport { parse } from "npm:csv-parse@^5.0/sync"import chalk from "npm:chalk@^5.0.0"import { z } from "npm:zod@^3.23"const inputPath = Deno.args[0]const content = await Deno.readTextFile(inputPath)const rows = parse(content, { columns: true })console.log(chalk.green(`Parsed ${rows.length} rows`))
The npm: prefix is more verbose than Bun's bare imports, but it clarifies package origins. TypeScript works natively. Version pinning lives in the import path, same as with Bun. No deno.json or import map required—dependencies resolve directly from the specifiers.
Deno requires permission flags—--allow-read, --allow-write, --allow-net, etc. More verbose than Bun, but you declare exactly what the script does. For skills running through Claude Code, explicit permissions document what the script can access. For trusted environments, --allow-all (or -A) skips the ceremony.
Trade-offs
| Aspect | Bun | Deno |
|---|---|---|
| Import syntax | import x from "[email protected]" | import x from "npm:[email protected]" |
| node_modules safe | No | Yes |
| Raw performance | ~20-30% faster | Slightly slower |
| TypeScript | Native | Native |
| Permissions model | Permissive by default | Explicit flags required |
Bun is faster. Startup time, runtime performance, HTTP serving—Bun consistently beats Deno in benchmarks. If you're building a production API or a performance-critical CLI tool, that matters.
For Claude Code skills, it doesn't.
Why Performance Doesn't Matter Here
The agent's thinking time dwarfs script execution time. Claude takes two to five seconds to decide what to do next. A skill that runs in 50 milliseconds versus 80 milliseconds is effectively the same—both are instant compared to the agent's decision loop.
Reliability matters more. A skill that works from any directory is more valuable than a skill that's 30% faster but breaks in monorepos.
Practical Example for Skills
The structure follows the same pattern from the original article—a SKILL.md pointing to executable scripts. The only changes are the shebang and Deno-specific APIs:
#!/usr/bin/env -S npx -y deno run --allow-read --allow-writeimport { parse } from "npm:csv-parse@^5.0/sync"import * as XLSX from "npm:xlsx@^0.20"const inputPath = Deno.args[0]const content = await Deno.readTextFile(inputPath)const rows = parse(content, { columns: true })console.log(JSON.stringify(rows, null, 2))
Claude runs the skill, the script accesses npm packages, and everything works regardless of directory.
Conclusion
The npm: prefix is more verbose. Permission flags add ceremony. Bun's import syntax is cleaner and faster. But Deno's reliability across different directory structures makes it the better choice for Claude Code skills.
You don't have to debug why a skill works in one directory and fails in another. You don't have to document "this only works outside of projects with node_modules." The script just works.
If Bun adds a flag to force auto-install regardless of node_modules presence, I'd reconsider. Until then, Deno's consistency wins.
References
- Writing Powerful Claude Code Skills with npx bun — The original exploration of this approach
- Deno — A modern runtime for JavaScript and TypeScript
- Deno npm compatibility — How the
npm:specifier works - Bun Auto-Install Documentation — Understanding when auto-install activates
- Claude Code Skills Documentation