b
LearnBun.org
Chapter 03 · Your First Real Bun Script
Chapter 3

Your First Real Bun Script

In Chapter 2 you built a server. In this chapter we step away from the browser and build a command-line tool — a small script you run in the terminal that does real work on your computer. By the end you will have a wordcount script that reads a file, counts its words, and prints the result. With a cow.

Along the way you will install your first package with bun add. That is the second of Bun's four pillars: the package manager.

What We're Building

Here is the finished tool in action:

$ bun run count sample.txt
 _____________
< 42 words >
 -------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

The script takes a filename, reads the file, counts the words, and prints the count inside a cow's speech bubble. Silly? A little. But it exercises everything a real script needs to do: read arguments, read a file, run logic on the contents, and use a package from the npm registry.

Set Up the Project

Open your terminal and navigate to the learn-bun folder from Chapter 1:

$ cd learn-bun

Create the project:

$ bun init wordcount

Choose Blank and press Enter. Then move into the new folder:

$ cd wordcount

Same ritual as Chapter 2. You will repeat it at the start of every project.

Read Command-Line Arguments

A command-line tool needs to know what you typed after its name. When you run bun run index.ts README.md, the word README.md is an argument we need to read.

Bun gives you those arguments in Bun.argv.

Replace the contents of index.ts with:

// index.ts
const filename = Bun.argv[2];
console.log(`You asked for: ${filename}`);

Run it, passing the README.md that bun init created for you:

$ bun run index.ts README.md

You should see:

You asked for: README.md

Bun.argv is an array. The first two entries are always Bun itself and the path to your script. Your arguments start at index 2. We grab Bun.argv[2] because that is the first thing the user typed.

Try it without an argument:

$ bun run index.ts

You will see:

You asked for: undefined

We will handle that case shortly. First let's read a real file.

Read a File with Bun.file

Create a sample file to count:

$ echo "The quick brown fox jumps over the lazy dog." > sample.txt

Now update index.ts to read it:

// index.ts
const filename = Bun.argv[2];
const file = Bun.file(filename);
const contents = await file.text();
console.log(contents);

Run it:

$ bun run index.ts sample.txt

You should see:

The quick brown fox jumps over the lazy dog.

Bun.file() is Bun's built-in file API. You give it a path and it returns a file reference. Calling .text() reads the file as a string. No fs module, no package to install.

The await keyword is there because reading a file takes time. Bun returns a Promise, and await waits for it to finish before moving on. You will see await constantly in Bun code.

Count the Words

Now we count. Add a countWords function above the existing code:

// index.ts
function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

const filename = Bun.argv[2];
const file = Bun.file(filename);
const contents = await file.text();
const count = countWords(contents);
console.log(`${count} words`);

Run it:

$ bun run index.ts sample.txt

You should see:

9 words

Here is what countWords does. trim() removes whitespace from the start and end. split(/\s+/) breaks the string into pieces wherever there is one or more whitespace characters — spaces, tabs, newlines. filter() drops any empty pieces. length gives us the count.

countWords is a pure function. You give it a string, it returns a number. It does not read files, print anything, or touch the outside world. That makes it easy to reason about — and easy to test, which we will do in the next chapter.

Handle a Missing File

Try running the script with a file that does not exist:

$ bun run index.ts nope.txt

You will see an ugly error with a stack trace. That is not a good experience for someone using your tool.

Let's check that the file exists before reading it:

// index.ts
function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

const filename = Bun.argv[2];

if (!filename) {
  console.error("Usage: bun run index.ts <filename>");
  process.exit(1);
}

const file = Bun.file(filename);

if (!(await file.exists())) {
  console.error(`File not found: ${filename}`);
  process.exit(1);
}

const contents = await file.text();
const count = countWords(contents);
console.log(`${count} words`);

Run it again with a missing file:

$ bun run index.ts nope.txt

You should see:

File not found: nope.txt

And running with no argument at all:

$ bun run index.ts
Usage: bun run index.ts <filename>

Two small additions, one big quality jump. We use console.error instead of console.log to write to standard error, which is the conventional channel for error messages. process.exit(1) ends the script with a non-zero exit code, which tells the shell that something went wrong.

Install Your First Package with bun add

Now for the cow.

Cowsay is a tiny package that prints text inside an ASCII speech bubble. We could write our own, but this is a chance to install our first package.

Run:

$ bun add cowsay

You should see something like:

installed cowsay@1.6.0

1 package installed

That single command did three things. Look at your project folder:

$ ls

You should see a few new things alongside the original files:

bun.lock        node_modules/   package.json    sample.txt
index.ts        ...

bun.lock is the lockfile. It records the exact version of cowsay (and everything cowsay depends on) that got installed. Commit it to Git so anyone else who clones your project gets the same versions.

node_modules/ is where the package code actually lives. You will never edit anything in here. It is rebuilt from the lockfile whenever you run bun install.

Open package.json and you will see a new section:

{
  ...
  "dependencies": {
    "cowsay": "^1.6.0"
  }
}

That is how Bun remembers which packages your project uses. The ^1.6.0 means "1.6.0 or any compatible newer version."

A quick note on bun add variants:

  • bun add cowsay — adds it as a regular dependency (your code uses it at runtime)
  • bun add -d typescript — adds it as a dev dependency (only needed during development)
  • bun add -g some-tool — installs it globally on your machine

We want cowsay at runtime, so the plain bun add cowsay is right.

Use the Package

Now import cowsay and use it. Update index.ts:

// index.ts
import { say } from "cowsay";

function countWords(text: string): number {
  return text
    .trim()
    .split(/\s+/)
    .filter((word) => word.length > 0).length;
}

const filename = Bun.argv[2];

if (!filename) {
  console.error("Usage: bun run index.ts <filename>");
  process.exit(1);
}

const file = Bun.file(filename);

if (!(await file.exists())) {
  console.error(`File not found: ${filename}`);
  process.exit(1);
}

const contents = await file.text();
const count = countWords(contents);
console.log(say({ text: `${count} words` }));

Run it:

$ bun run index.ts sample.txt

You should see:

 __________
< 9 words >
 ----------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

The cow speaks. We imported the say function from cowsay, passed it our word count, and printed the result.

This is the whole loop. You wanted a feature. You installed a package. You imported what you needed. You used it. That is what bun add is for.

Add a Script to package.json

Just like Chapter 2, let's add a script so we don't have to type bun run index.ts every time. Open package.json and add a scripts section:

{
  "name": "wordcount",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "count": "bun run index.ts"
  },
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  },
  "dependencies": {
    "cowsay": "^1.6.0"
  }
}

Now you can run:

$ bun run count sample.txt

Anything you type after the script name gets passed through to the underlying command. So bun run count sample.txt becomes bun run index.ts sample.txt.

Run a Package Without Installing It with bunx

Sometimes you want to run a package once without making it part of your project. Maybe you want to scaffold a new app with a generator, or run a one-off formatter.

That's what bunx is for. Try it:

$ bunx cowsay "Hello from bunx"

You should see a cow saying hello — even though if cowsay weren't already installed in this project, bunx would download it temporarily, run it, and forget about it.

The rule of thumb:

  • If your code imports a package, use bun add. The package becomes part of your project.
  • If you just want to run a package's command-line tool, use bunx. Nothing gets added to your project.

Our wordcount script imports cowsay, so bun add is correct here. But you will reach for bunx constantly for one-off tools.

Save Your Work with Git

Time to commit. Initialize the repository:

$ git init

Bun's generated .gitignore already excludes node_modules/. Add the rest:

$ git add .
$ git commit -m "Word count CLI tool with cowsay"

Both bun.lock and package.json are committed — that is how anyone else who clones your project ends up with the exact same packages installed.

Conclusion

You built a real command-line tool. Along the way you used Bun.argv to read arguments, Bun.file to read a file, and process.exit to handle errors cleanly. You wrote a pure function, countWords, that we will test in the next chapter. And you installed your first package with bun add, watched the lockfile and node_modules appear, imported what you needed, and used it.

This chapter introduced the second of Bun's four pillars: the package manager. bun add installs packages from the npm registry, fast, with a lockfile that keeps your dependencies consistent across machines. The same registry, the same import syntax — but a different, faster tool managing it.

In the next chapter, we will write tests for countWords and meet the third pillar: the test runner.

Notify me

Get notified when Part II ships.

One email per release. No spam, no upsells. Just the next set of chapters when they're ready.