BACK

Writing Powerful Claude Code Skills with npx bun

9 min read

Update (January 15, 2026): After more testing, I've found Bun's node_modules detection can break auto-install in unexpected situations. I now recommend Deno for this use case. The approach below still works for standalone scripts, but see Why I Switched from Bun to Deno for Claude Code Skills for the full breakdown.

Claude Code skills let you extend Anthropic's agentic coding CLI with custom instructions and executable scripts. But what if your script needs third-party npm packages like lodash, zod, or csv-parse? Without a build step or node_modules folder, imports fail. This guide shows how to use npx bun to write self-contained TypeScript skills that auto-install dependencies at runtime—no package.json required.

The problem: Claude Code skills have no build step. You cannot just import lodash from 'lodash' and expect it to work. The script runs in a fresh environment with no node_modules folder.

What I Wanted: uv run for JavaScript

Python solves this elegantly with uv. You declare dependencies inline using PEP 723 metadata:

#!/usr/bin/env -S uv run
# /// script
# dependencies = ["requests", "rich"]
# requires-python = ">=3.10"
# ///
import requests
from rich import print
print(requests.get("https://example.com"))

Run it with uv run script.py or just ./script.py. Dependencies install automatically into an isolated environment. No requirements.txt, no virtual environment management, no build step. The script is self-contained.

Python was my first professional programming language, and I still admire how its ecosystem has evolved. But my team at Buffer works in TypeScript. I needed something that would be easy for my teammates to pick up—familiar npm packages, familiar syntax—but as flexible and powerful as uv run.

The Constraint

A typical Claude Code skill looks like this:

my-skill/
├── SKILL.md
└── scripts/
└── process.js

Claude reads SKILL.md for instructions and executes the scripts when relevant. But if process.js imports any npm package, it fails. No package.json, no node_modules, no dependencies.

The obvious solutions—committing node_modules or running npm install at runtime—are ugly. The first bloats your skill folder. The second adds latency every time.

I explored several approaches.

Approach 1: Pre-bundling with esbuild

Bundle your script into a single file with all dependencies included:

esbuild script.ts --bundle --platform=node --outfile=script.mjs

The output is self-contained. No runtime dependencies. Ship it with your skill and Claude runs it directly.

This works, but requires a build step. You must rebuild after every change. Debugging bundled code is harder. It's the opposite of what I wanted.

Approach 2: Dynamic imports with esm.sh

esm.sh serves npm packages as ES modules over HTTPS:

import _ from "https://esm.sh/[email protected]";
import { z } from "https://esm.sh/[email protected]";

No installation needed. The runtime fetches the module on first use. Version pinning lives in the URL.

The problem: Node.js doesn't natively support HTTPS imports without experimental flags or custom loaders. Some packages don't work well as pure ESM. Network latency on every cold start adds up.

Approach 3: Google zx with --install

zx is Google's tool for writing shell scripts in JavaScript. It wraps child_process and adds conveniences like the $ template literal for running commands.

The --install flag auto-installs missing dependencies:

#!/usr/bin/env zx
import _ from "lodash"; // @^4.17
import { parse } from "yaml"; // @^2.0
await $`echo "Dependencies auto-installed"`;

Run it with npx zx --install script.mjs. On first run, zx detects the imports, installs the packages, and caches them.

This gets closer to what I wanted. But version pinning through comments feels hacky. And there's no native TypeScript support—you'd need tsx or similar.

Approach 4: Bun

Bun takes a different approach. Auto-install is built into the runtime. Write normal imports and Bun handles the rest:

#!/usr/bin/env bun
import _ from "lodash";
import { z } from "zod@^3.20";
import chalk from "chalk@^5.0.0";
console.log(chalk.green("Dependencies just work"));

Version pinning happens directly in the import path—cleaner than zx's comment syntax. TypeScript runs natively. Startup is fast.

The catch: Bun might not be installed in every environment. Claude Code environments have Node.js and npm, but not necessarily Bun.

The Discovery: npx bun

Then I realized: I don't need Bun installed globally. I just need npm.

npx -y bun script.ts

The -y flag skips the confirmation prompt, which matters for non-interactive execution. This works because bun is published as an npm package. When you run npx bun, npm downloads the Bun binary and executes your script. You get Bun's auto-install, TypeScript support, and speed—all through the npm/Node.js toolchain that's already everywhere.

I tested this in a fresh environment:

import chalk from "chalk@^5.0.0";
import { z } from "[email protected]";
import _ from "lodash@^4.17.0";
console.log(chalk.green("✓ chalk loaded"));
const schema = z.object({ name: z.string() });
console.log(chalk.blue(`✓ zod loaded - validation works`));
const grouped = _.groupBy(["one", "two", "three"], "length");
console.log(chalk.yellow(`✓ lodash loaded`));

Output:

✓ chalk loaded
✓ zod loaded - validation works
✓ lodash loaded

No package.json. No node_modules. No build step. The first run installs dependencies to Bun's global cache. Subsequent runs are instant.

This is the JavaScript equivalent of uv run. Same developer experience, same self-contained scripts, familiar npm ecosystem.

Making Scripts Directly Executable

It gets better. Just like Python's #!/usr/bin/env -S uv run, you can use a shebang to make scripts directly executable:

#!/usr/bin/env -S npx -y bun
import chalk from "chalk@^5.0.0";
console.log(chalk.green("Hello!"));

The -S flag tells env to split the string into separate arguments. Make the script executable and run it directly:

chmod +x script.ts
./script.ts

Now you have self-contained TypeScript scripts—no explicit invocation needed.

Using This in Claude Code Skills

Structure your skill like this:

my-skill/
├── SKILL.md
└── scripts/
└── process.ts

In SKILL.md:

---
name: data-processor
description: Process and transform data files using advanced libraries
allowed-tools: [Bash, Read, Write]
---
# Data Processor
Run the processing script:
```bash
./scripts/process.ts <input-file>
```

In scripts/process.ts:

import { parse } from "csv-parse/sync@^5.0";
import * as XLSX from "xlsx@^0.20";
const [, , inputPath] = Bun.argv;
const file = Bun.file(inputPath);
const content = await file.text();
const rows = parse(content, { columns: true });
console.log(JSON.stringify(rows, null, 2));

Claude runs the skill, the script executes with full access to npm packages, and you never touch a package.json.

Comparison

ApproachBuild StepTypeScriptVersion PinningFirst-run Speed
esbuild bundleYesVia buildIn sourceFast
esm.shNoNoIn URLNetwork-bound
npx zx --installNoVia tsxCommentsModerate
npx -y bunNoNativeIn import pathFast after cache

Caveats

This approach isn't perfect. A few things to consider:

Auto-install requires no node_modules directory. Bun's auto-install feature only works when no node_modules directory is found in the working directory or any parent directory.1 When a node_modules folder exists—common in monorepos or existing projects—Bun switches to regular Node.js module resolution instead of its auto-install algorithm. Even the --install=force flag doesn't fully solve this: version specifiers in imports (like import { z } from "[email protected]") will throw a VersionSpecifierNotAllowedHere error when node_modules is present. This means the approach works best for standalone scripts outside of existing projects. For Claude Code skills stored in ~/.claude/skills/, this typically isn't an issue. But if you're writing scripts inside a project directory with node_modules, you'll need to either use a traditional package.json or move the script outside the project tree.

Bun is not fully Node.js compatible. Most npm packages work fine, but some Node.js APIs behave differently or aren't implemented yet. If your script depends on edge-case Node.js behavior—certain fs operations, specific child_process options, native addons—you might hit unexpected issues. Check Bun's Node.js compatibility documentation before committing to this approach.

First-run latency still exists. The first execution downloads Bun via npx (~100MB depending on architecture) and installs dependencies. On a slow connection or in a cold-start environment, this adds noticeable time. Subsequent runs are fast, but that initial hit matters if your skill runs in ephemeral environments that don't preserve Bun's cache.

Version pinning in imports is non-standard. The import x from "pkg@^1.0" syntax is Bun-specific. Your IDE won't understand it for autocompletion or type checking. For quick scripts, you can add // @ts-ignore above the problematic imports. For more serious development, maintain a package.json with proper versions and only use the inline syntax in the deployed skill.

When to use zx instead. If you need guaranteed Node.js compatibility—because you're using a package that relies on Node-specific internals, or your team has strict runtime requirements—zx with --install is the safer choice. It runs on Node.js directly, so compatibility is never a question. The trade-off is no native TypeScript and the comment-based version pinning.

For most skills that use common packages like lodash, zod, or csv-parse, Bun works fine. But know the escape hatch exists.

Conclusion

npx -y bun combines the best properties: no build step, native TypeScript, clean version pinning, and availability anywhere npm runs. For Claude Code skills that need third-party packages, it's the simplest path to powerful scripts—as long as you stay within Bun's compatibility boundaries.

If you've used Python's uv and wished JavaScript had something similar, this is it. Same philosophy, same workflow, familiar tools. And when you hit Bun's edges, zx is there as a fallback.

References

Footnotes

  1. Bun Auto-Install Documentation — "If no node_modules directory is found in the working directory or higher, Bun will abandon Node.js-style module resolution in favor of the Bun module resolution algorithm."