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.
Bun is not only for servers. If you only use it for web apps, you miss half the point.
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.
The cow is silly on purpose. Boring scripts are harder to remember.
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. If you know npm install cowsay, this is the same idea with Bun managing the install and lockfile.
Run:
$ bun add cowsay
You should see something like:
installed cowsay@1.6.0
1 package installed
One command changed three parts of the project.
$ 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 now have a small command-line tool that does real work:
- reads a filename from
Bun.argv - reads the file with
Bun.file - counts words in a pure
countWordsfunction - handles missing input and missing files cleanly
- uses an npm package installed with
bun add
The important part is not the cow. The important part is the loop: install a package, import it, use it, and commit the lockfile so the project can be rebuilt later.
In the next chapter, we will pull countWords into its own file and test it with Bun's test runner.