← Back to list

Building CLI tool (v1 of zap-search)

I just wanted one thing: I should be able to search files and folders fast from terminal, hit enter, and either open the file or jump into…

Sujal Rana · 2026-04-11 20:48 · 3 claps · 4.6 min read
#typescript #software-development #zsh #bash #javascript
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Building CLI tool (v1 of zap-search)

I just wanted one thing: I should be able to search files and folders fast from terminal, hit enter, and either open the file or jump into the folder. That’s it.

Nothing too fancy. No overengineering. Just a small tool that feels instant and useful in daily work.

[embed]GitHub - Sran012/zap: Search for files, folders and command history. github.com

npm i zap-search

But while building v1, I ran into a bunch of issues that looked small from outside and turned out to be surprisingly deep once I got into them. Especially three things:

— search was slower than I expected — folder selection was “working” but not actually changing my shell directory the way I expected.

This post is basically the story of that. Not as some polished “here is the architecture” thing, but more like what actually happened, what confused me, what broke, and how I finally understood it.

The idea behind zap

The basic idea was simple:

  • search files and folders from the current working directory — search zsh history too — if I select a file, open it — if I select a folder, move into it

The main use case was pretty personal. When I’m inside a repo, I don’t always remember the exact path. Sometimes I only remember a few letters like pac and I just want package.json or maybe packages/. I didn’t want to manually cd around or keep typing long paths.

So I built ***zap*** around fuzzy matching.

The first version worked… but not really

Initially, I got the basic search running. It scanned files, gave me some fuzzy matches, and showed a picker. On surface level, nice. Felt like progress.

But after using it a bit, I got irritated very fast.

Search felt slow

The repo I was testing on was not even huge, but still the tool felt heavier than it should. For something that is supposed to feel instant, even a little delay feels bad.

When you’re building a search tool, speed is not a “nice to have”. Speed is the product.

The search slowdown: understanding readdirSync and statSync

This was one of those moments where the real bottleneck became clear only after slowing down and reading the code properly.

My traversal logic was basically doing this:

  • readdirSync(dir) to list everything in a directory — for every item returned, call statSync(fullPath) — check whether it is a file or a folder — if it is a folder, recurse into it — keep building the full list

At first that sounds normal. But the problem is in the number of filesystem calls.

What readdirSync does

readdirSync(path) reads a folder and gives you the names inside it.

Example:

  • src — package.json — README.md

It does not tell you whether each item is a file or a directory.

What statSync does

statSync(path) asks the OS for metadata about one path.

That tells you:

  • is it a file — is it a directory — size — timestamps — other metadata

So the old flow was:

  • list names with readdirSync — then for every single name, do another filesystem call with statSync

That means a lot of extra work, especially as the repo grows.

And because both were sync calls, Node was waiting on every one of them in sequence.

That was the first clear speed problem.

The better approach was:

fs.readdirSync(dir, { withFileTypes: true })

That gives Dirent objects instead of plain string names.

Now each entry already knows things like:

  • item.isDirectory() — item.isFile()

So I no longer needed statSync for every entry just to know what it is.

That was a very nice improvement because it reduced unnecessary filesystem calls without changing the search behavior itself.

This mattered a lot to me because I didn’t want some fancy redesign for v1. I wanted a clean, practical improvement.

The most interesting bug: “changing directory ? ”

This was probably the most educational part of the whole build.

I ran:

node apps/cli/dist/index.js pac

Then I selected a folder and got:

CD:/home/sujal/zap/packages

As i was appending cd for changing directory. At first glance this felt broken. Like, why is it printing instead of actually moving?

But once I understood what was happening, it made total sense.

The root issue

A child process cannot change the parent shell’s current directory.

This is the key thing.

When you run a Node CLI from terminal, that Node process is a child of your shell. Even if that child process calls logic that says “go to this folder”, it cannot make your existing shell session move there.

That is just how process boundaries work.

So if I run the CLI directly with node …, it can only:

— print the path — write it somewhere — communicate it back somehow

But it cannot itself change my current shell session.

That is why it printed CD:/….

The actual solution: a zsh wrapper plus a temp file

To make cd work, I needed the shell itself to do the cd.

So the design became:

  1. zsh wrapper creates a temp file
  2. wrapper runs the real zap command and passes the temp file path via env var
  3. CLI writes the selected directory into that temp file
  4. wrapper reads the file after the CLI exits
  5. wrapper does cd “$target”

That looked roughly like this in zsh:

zap() {
    local cd_file
    local exit_code
    local target

    cd_file=$(mktemp)
    ZAP_CD_FILE="$cd_file" command zap "$@"
    exit_code=$?

    if [[ $exit_code -eq 0 && -s "$cd_file" ]]; then
      target=$(<"$cd_file")
      if [[ -d "$target" ]]; then
        cd "$target"
      fi
    fi

    rm -f "$cd_file"
    return $exit_code
  }

And in the CLI, if a directory is selected:

  • if ZAP_CD_FILE exists, write the chosen path there — otherwise print CD:…

This was a very satisfying fix because it felt simple after understanding it, but before understanding it, it felt almost magical.

Small detail, but critical.

One thing v1 taught me: a lot of bugs are really boundary problems

This was maybe the biggest learning from the whole process.

Most of the frustrating issues were not some super complex algorithm problem. They were boundary problems:

— filesystem boundary — child process vs parent shell boundary

And once I identified the exact boundary, the bug stopped feeling random.

For example:

— slow search was a filesystem-call boundary problem — missing cd was a shell-process boundary problem

Final thought

I started building **zap** thinking I was just making a small search utility.

But v1 ended up teaching me way more than I expected:

— how Node filesystem calls affect UX — why a terminal tool can feel bad even when it is “working” — why shells need wrappers for things like cd

This is exactly why I like building small tools.

A small tool looks simple, but it forces you to understand the system properly. And once you understand the system properly, the tool becomes much better.

This is basically my zap v1 story.


메타데이터
post_id
fab427e640fa
slug
building-cli-tool-v1-of-zap-search-fab427e640fa
url
https://medium.com/@sujal-rana/building-cli-tool-v1-of-zap-search-fab427e640fa
canonical_url
https://medium.com/@sujal-rana/building-cli-tool-v1-of-zap-search-fab427e640fa
author_url
https://medium.com/@sujal-rana
status
ok
fetched_at
2026-06-14 11:28:49