Skip to main content

Command Palette

Search for a command to run...

Introducing the `with-watch` CLI tool

Published
10 min read

I spend a lot of time in the shell, and I keep running into the same small frustration.

Inside our company repo, I kept seeing the same pattern play out in different ways. In some places, we had custom scripts just to get watch-based recompilation or repeat a file operation. In other places, we deliberately gave up on small bits of automation, like automatic cp, because building a dedicated solution for them felt like overengineering. The result was inconsistent workflows around a problem that should have felt simple.

I wanted the convenience of "watch mode," but I did not want to stop using the commands I already reach for every day. I did not want to wrap everything in a bespoke script. I did not want to move simple workflows into a task runner just to get automatic reruns. I wanted to keep using grep, cp, sed, ls, and cargo test, and I wanted them to run again when their inputs changed.

That is why I built with-watch.

with-watch is a Rust CLI that reruns a delegated command when its inferred or explicit filesystem inputs change. The idea is simple: keep your existing command line, add a small wrapper, and get repeatable reruns without changing how you work. More than anything, I wanted the API to feel user-friendly: no new workflow to memorize, no new config format to invent, and as little change to the original command as possible.

The approach I cared about most was this: in many cases, the user has already expressed the watch requirement in the command they typed. The job is not to ask for a second description of intent. The job is to read the intent that is already there and turn it into a watch plan.

Why I built it

Most watch tools are great when your workflow already fits their model. But a lot of day-to-day shell work is more direct than that.

What pushed me over the edge was seeing how uneven this became in a real codebase. For watch-based recompilation, some parts of our repo had accumulated custom scripts. Elsewhere, we skipped automating a simple copy step because we did not want to build yet another layer around it. Both choices were understandable on their own, but together they pointed to the same missing tool.

Before with-watch, I even used Air for jobs like protobuf recompilation. It worked well, but in a larger project it also meant carrying more project-level configuration than I wanted for something as small as rerunning protoc. That experience made the gap even clearer to me: I did not just want live reload for one category of workflow. I wanted a small, user-friendly, input-driven interface that I could apply to the commands I was already running.

Sometimes I just want to:

  • re-run grep hello input.txt whenever the file changes

  • re-run cp src.txt dest.txt when the source changes

  • re-run a small shell pipeline while I am iterating

  • re-run cargo test for a specific crate when files under src/ change

I wanted a tool that respected those workflows instead of asking me to adopt a new one. I wanted something light enough that I would actually use it for the boring cases where writing a custom watcher felt silly, but structured enough that I did not have to keep rebuilding the same tiny automation in different corners of the repo. I wanted a user-friendly API: something you could understand in seconds, not something that required learning a new DSL, config model, or command vocabulary before it became useful.

I also wanted it to be conservative. If a tool can safely infer what to watch, great. If it cannot, it should not guess. It should fail clearly and give me a way to be explicit. That is the design principle I kept coming back to while building with-watch: convenience when the intent is obvious, and an escape hatch when it is not.

What with-watch is

At a high level, with-watch sits in front of a command and watches the files or directories that matter for that command.

That sounds ordinary, but the important part is where the watch plan comes from. I wanted the command itself to be the starting point. If someone types cp src.txt dest.txt, the input already tells us something meaningful about what should be watched and what should be treated as output. If someone types grep hello input.txt, the input already tells us where the read dependency is. The watch requirement is often already embedded in the command; with-watch is built around extracting that instead of demanding a separate layer of configuration.

It supports three command modes:

with-watch [--no-hash] <utility> [args...]
with-watch [--no-hash] --shell '<expr>'
with-watch exec [--no-hash] --input <glob>... -- <command> [args...]

The first mode is the one I wanted most: wrap a familiar utility and let with-watch infer the inputs.

The second mode is for simple shell command lines on Unix-like platforms that need operators like &&, ||, or |.

The third mode is the explicit escape hatch. If inference would be ambiguous, or if the delegated command does not have stable filesystem inputs on its own, exec --input lets me say exactly what should trigger a rerun.

How it works in practice

Here are a few real examples that match the current CLI and docs:

with-watch grep hello input.txt
with-watch cp src.txt dest.txt
with-watch ls -l
with-watch --shell 'grep hello src.txt | wc -l'
with-watch sed -i.bak -e 's/old/new/' config.txt
with-watch exec --input 'src/**/*.rs' -- cargo test -p with-watch

That mix is what makes the tool feel useful to me.

For straightforward commands, I can stay in passthrough mode and let with-watch do the boring part. For a quick one-liner pipeline, I can use --shell. For something like cargo test, where I want to define the watch set myself, I can use exec --input and keep the original command untouched.

That last detail matters to me: with-watch reruns the delegated command exactly as provided. It does not inject changed file paths into argv. It does not rewrite the command line behind the scenes. It just decides when to run the command again.

The design choices I cared about most

1. Treat the user's command as the primary source of truth

This is the core idea behind the tool.

I did not want watch behavior to start from a separate config file, a second DSL, or an extra description of what the user meant. In many cases, the requirement is already present in the input. The command, its operands, and even parts of shell syntax already contain the information needed to build a useful watch plan.

That is why passthrough mode is the default shape of the tool. It is also why exec --input is an escape hatch rather than the main interface. I wanted explicit inputs to be available when needed, but I wanted the primary experience to begin with the command the user was already going to run.

2. Run once immediately

After input inference, watcher setup, and baseline capture succeed, with-watch performs one initial run right away.

That sounds small, but it makes the tool feel natural. I do not want to start a watch command and then wait for the first file change just to confirm that everything is wired correctly. I want the command to run once immediately, then keep running when inputs change later.

3. Prefer real content changes by default

By default, with-watch compares content hashes when it decides whether to rerun.

This is not just a theoretical preference for me. It comes from real workflow friction. In practice, I kept running into cases where a rerun happened because something in the pipeline wrote to the filesystem again, even though the actual content had not meaningfully changed. When that happens, I usually do not want the rest of the pipeline to keep flowing. I want it to stop as early as possible, or at least in the middle, instead of paying for more downstream work that does not represent a real change.

I recently made a similar optimization in my day job: if the content-based result had not changed, the step would skip the filesystem write entirely. That experience reinforced the same instinct behind with-watch: treat real content changes as the thing that matters most, and avoid turning incidental rewrites into more work.

If I want metadata-only comparison instead, I can opt into it with:

with-watch --no-hash grep hello input.txt

That keeps the default behavior more intentional while still leaving room for the lighter-weight mode when it is the right tradeoff.

4. Fail safely when inference is not trustworthy

One of the easiest ways to make a watch tool feel magical is also one of the easiest ways to make it feel wrong: infer too much.

I did not want with-watch to pretend it knows what to watch when it really does not. If it cannot infer safe filesystem inputs from the delegated command, it fails with guidance instead of guessing. The answer in those cases is to use exec --input and make the watch set explicit.

That tradeoff is deliberate. I would rather make the explicit path easy than make the implicit path unpredictable.

5. Do not loop forever on self-mutating commands

Commands like this are common:

with-watch sed -i.bak -e 's/old/new/' config.txt

This kind of command mutates a watched input. A naive watcher can easily end up retriggering itself forever.

with-watch avoids that by refreshing its baseline after self-mutating commands run, so the command does not keep firing just because of its own write. If a real external change happens later, it can rerun again.

6. Keep shell support useful, but intentionally narrow

--shell is there for command-line expressions on Unix-like platforms, not full shell scripting. Today it is meant for cases that need &&, ||, or |, plus ordinary redirects handled within the documented boundaries.

That was another intentional choice. I wanted the feature to be helpful without quietly expanding into a much larger, fuzzier surface area.

Installing with-watch

Right now, the documented install paths are:

cargo install with-watch
brew install delinoio/tap/with-watch

If you are already comfortable in Rust or Homebrew-based workflows, getting started is very lightweight.

Why I think this shape matters

I did not build with-watch to replace every task runner or watcher. I built it for a narrower but very common feeling: "I already have the command I want. I just want it to run again when the right files change."

That is why the tool is centered on delegated commands instead of a new config format.

That is why the API tries to stay close to the command you were already going to run.

That is why I keep coming back to the same idea: the user's input often already contains the watch requirement.

That is why safe inference matters.

That is why exec --input exists.

That is why the delegated command stays unchanged, with only the minimum extra syntax needed when you want to be explicit.

And that is why the tool tries to help without pretending it knows more than it does.

Try it

If that workflow sounds familiar, give with-watch a try.

Start with something small like with-watch grep hello input.txt, then move on to a shell expression or an explicit cargo test loop with exec --input. And if you want to see the full recognized command inventory and the exact current CLI shape, check the project documentation or run:

with-watch --help

That help output is the best place to see what the tool currently recognizes and where it expects you to be explicit.