Skip to content

Why ActiveCLI

Wrapping a CLI is easy until you ship it. Then you find that the executable is not on the PATH your process inherited, that Windows is four different environments, that killing the child leaves its children running, and that a hard kill of your own process leaves the CLI alive and billing.

Those problems are the same for every CLI, and they are most of what this library is.

Problem What it looks like in the wild
GUI processes inherit a minimal PATH A CLI the user installed with brew or nvm is invisible; spawn fails with ENOENT
where claude names the wrong file npm ships an extension-less wrapper beside claude.cmd; cmd.exe runs the latter
WSL UNC paths cannot be a cwd \\wsl.localhost\... fails from Windows, and does not exist inside the distro
shell: true on win32 splits on spaces A launcher under C:\Program Files\ tears in half; arguments become injectable
cmd.exe expands %VAR% inside quotes An argument is silently rewritten before the CLI sees it
cmd.exe ends the command at & JSON config arrives truncated; CI caught this on the first run
A capable terminal decorates output with ANSI escapes \x1b[1 q lands on the same stream as the protocol and the JSON line fails to parse
Killing the child is not killing the tree Subagent shells and background tasks keep running
git-bash workers detach from the tree taskkill /T cannot see them; they survive as orphans
A hard SIGKILL runs none of your cleanup The CLI outlives the host, invisible to any later one

One defect surfaced on the first Windows CI run that a macOS machine could not have revealed.

An argument passing through cmd.exe containing a&b|c<d>e was truncated at a, and cmd emitted 'b' is not recognized as an internal or external command. The received wisdom — that Node’s CommandLineToArgvW quoting renders & literal — is wrong, and the original comment in the code said so too.

The JSON that mcp add-json passes is full of exactly these characters. A Windows user’s MCP configuration was about to be silently truncated on the way to disk.

The remedy is to caret-escape & | < > and nothing else. A first attempt that also escaped parentheses broke ordinary arguments such as write("hi") — a caret is only meaningful in an unquoted context, and parentheses are only special inside block syntax.

Wrapping for a person and wrapping for a program are different jobs

Section titled “Wrapping for a person and wrapping for a program are different jobs”

Most work in this space wraps a CLI and hands the result to a person — a terminal UI, a desktop application, an editor panel. That is a different job from wrapping a CLI and handing the result to a program.

A person supplies what the wrapper does not. They notice that the CLI never started and install it. They see a prompt and answer it. They spot a stray process in Activity Monitor and kill it. A library consumer has none of that: whatever the wrapper fails to handle becomes an unhandled promise rejection, a hung await, or a process quietly billing in the background.

So the surface that matters here is not the one a person looks at. It is the one a program can rely on — a spawn that either yields a working session or throws something you can act on, an event stream with a stable shape, and a teardown that holds even when the host is killed.

Why an abstract adapter, rather than one wrapper per CLI

Section titled “Why an abstract adapter, rather than one wrapper per CLI”

The obvious approach is to write a small wrapper per CLI and let each expose whatever its CLI happens to take. It works for the first one, and it is exactly what stops working at the second.

The reason is that the CLIs disagree on the words themselves. One identifies a conversation with a session id; another with a thread id. One takes a model name; another takes a model name plus an endpoint and a service tier. Left alone, each wrapper’s send grows the parameters its own CLI wanted, and the caller ends up needing to know which CLI it is addressing in order to build a payload.

At that point the CLIs are not integrated — they are merely stored under one roof.

An abstract adapter is the alternative: Command speaks in intent — a message, a model, a permission mode — and translating that intent into one CLI’s flags is the adapter’s problem, not the caller’s. The vocabulary belongs to the library, so a caller names a provider in a word and stops there.

Zed’s Agent Client Protocol has been built into JetBrains IDEs since 2025-12 and supports 25+ agents. ActiveCLI does not compete with it, for two reasons.

  • ACP presumes a human sits at an editor and approves. That premise does not hold for a program driving a CLI unattended.
  • ACP does not standardise the process-execution problem — locating the executable, Windows quoting, WSL paths. Its adopters carry those problems unchanged: Zed #56176 reports ACP servers failing to launch under WSL, the Kiro documentation offers “hard-code an absolute path” as the official remedy, and JetBrains AI Assistant does not support WSL at all.

ACP is therefore a candidate adapter, not a rival — one more CLI-shaped thing to drive through the same contract.