npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@simpill/acp-llm-cli

v0.4.0

Published

Modular, extensible layer to run ACP-compatible LLM CLIs (Claude, Codex, Gemini, Cursor)

Readme

@simpill/acp-llm-cli

CI npm node license

One typed TypeScript client for the coding-agent CLIs — Claude, Codex, Gemini, and Cursor — behind a single interface.

Why

Every coding-agent vendor ships a CLI, and every CLI wants to be driven differently. Some speak the Agent Client Protocol over stdio; Cursor streams NDJSON and exits per prompt. Their flags disagree, their streaming formats disagree, and each one fails in its own way when the subprocess dies mid-turn.

Talking to them directly means writing that plumbing once per vendor, then maintaining it. This package writes it once:

  • One port interface. connect, initialize, prompt, disconnect behave the same for every provider. Differences that cannot be hidden are advertised through capabilities rather than discovered at runtime.
  • Validated config. Every provider config is a Zod schema, so a bad command or flag fails at the boundary with a useful message instead of a confusing subprocess error.
  • Subprocess lifecycle that holds up. Restart with real exponential backoff, force-kill that cannot leak onto a restarted child, and streams that deliver their tail instead of dropping it at close.
  • Optional streaming and session persistence, in OpenAI-compatible chunks or raw ACP updates.

Requirements

  • Node.js >= 22
  • At least one provider CLI installed — see Providers

Install

npm install @simpill/acp-llm-cli

zod and eventemitter3 are peer dependencies. zod 3 and 4 are both supported, and CI verifies each.

Quick start

import {
  ANTHROPIC_MODEL_IDS,
  getDefaultProviderClientFactory,
  Provider,
} from "@simpill/acp-llm-cli";

const factory = getDefaultProviderClientFactory();

const client = factory.getClient(Provider.CLAUDE, {
  command: "claude-agent-acp",
  args: [],
  // Any string. The enum is a convenience for autocomplete, not a constraint. For ACP
  // providers this labels OpenAI-style stream envelopes rather than selecting
  // the agent's model — for that use port.setSessionConfigOption(...) or pass
  // the flag your CLI expects in `args`.
  model: ANTHROPIC_MODEL_IDS.CLAUDE_SONNET_4_6,
});

await client.port.connect();
await client.port.initialize();

const { sessionId } = await client.port.newSession({
  cwd: process.cwd(),
  mcpServers: [],
});
const result = await client.port.prompt({
  sessionId,
  prompt: [{ type: "text", text: "What does this repo do?" }],
});

console.log(result.stopReason);
await client.port.disconnect();

Swap Provider.CLAUDE for Provider.CODEX, Provider.GEMINI, or Provider.CURSOR and nothing else changes. factory.listProviders() returns all of them.

Runnable versions live in examples/minimal-claude.ts, cursor-print.ts, stream-prompt.ts. Run npm run build first.

Providers

| Provider | Provider value | Default command | Transport | |---|---|---|---| | Claude | Provider.CLAUDE | claude-agent-acp | ACP over stdio | | Codex | Provider.CODEX | codex-acp | ACP over stdio | | Gemini | Provider.GEMINI | gemini --experimental-acp | ACP over stdio | | Cursor | Provider.CURSOR | cursor-agent | NDJSON, process per prompt |

Defaults live in DEFAULT_COMMANDS and can be overridden per provider through config or environment. The Claude and Codex bins match the wrappers ACPX prefers, so the two can front the same installation.

Cursor is the outlier: it spawns a process per prompt, so it supports neither streaming nor lifecycle. Its capabilities report streamPrompt, restart, openClose, and sessionPersistence as false — check them rather than assuming. Runtime options that have no meaning for it (sessionPersistence, workspace, resumeOnRestart, restartOptions, envelopeMode, modelId, clientCapabilities, permissionHandler, toolHost) are ignored, and the adapter logs a warning naming them — capabilities covers only the first four of those concerns.

Configuration

Config resolves in one direction, last wins:

DEFAULT_COMMANDS  →  environment (ENV_KEY)  →  config you pass

The result is validated against the provider's Zod schema before a process is spawned.

That arrow is a simplification worth unpacking, because the env layer is narrower than it looks. command is required by the config schema, so a config you pass always supplies it — which means ACP_LLM_CLI_*_COMMAND only takes effect when you call resolveBaseConfig yourself (exported from @simpill/acp-llm-cli/runtime). ACP_LLM_CLI_*_ARGS applies whenever args is empty. Provider-specific fields you pass — model, trust, mode — survive resolution untouched and reach the provider.

Copy .env.sample to .env. Every key this package reads is declared in ENV_KEY (src/domain/env.keys.ts):

| Variable | Purpose | |---|---| | ACP_LLM_CLI_DEBUG | Truthy enables debug logging | | ACP_LLM_CLI_CLAUDE_COMMAND | Override the Claude binary — same pattern for GEMINI, CODEX, CURSOR | | ACP_LLM_CLI_CLAUDE_ARGS | Override the Claude default args | | ACP_LLM_CLI_LIVE | Set to 1 to run tests that spawn real CLIs |

The provider CLIs read their own credentials — ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY or GOOGLE_API_KEY, CURSOR_API_KEY. This package never reads or forwards them.

Streaming

Claude, Codex, and Gemini stream. Feature-detect, then iterate. Narrow each envelope with the exported isOpenAIEnvelope / isNativeEnvelope guards -- StreamEnvelope is a union, so object is not present on every member and reading it directly does not type-check:

import { isOpenAIEnvelope } from "@simpill/acp-llm-cli";

if (port.capabilities?.streamPrompt && port.streamPrompt) {
  for await (const envelope of port.streamPrompt(
    { sessionId, prompt: [{ type: "text", text: "Hello" }] },
    { envelopeMode: "openai" }
  )) {
    if (isOpenAIEnvelope(envelope)) {
      process.stdout.write(envelope.choices?.[0]?.delta?.content ?? "");
    }
  }
}

envelopeMode is openai for OpenAI-compatible chunks, native for raw ACP session updates, or both.

The final chunk carries a finish_reason translated from the agent's stop reason — length for a truncated turn, content_filter for a refusal, stop otherwise. Import OPENAI_FINISH_REASON to switch on it; a truncated turn is not a clean completion.

Lifecycle and session persistence

Ports wrapped by the shared ACP runtime expose restart(), open(), and close(). restart() retries on an awaited, capped exponential backoff.

To survive a restart with the conversation intact, pass a persistence store:

import {
  createMemorySessionPersistence,
  getDefaultFactory,
  PROVIDER_IDS,
} from "@simpill/acp-llm-cli";

const port = getDefaultFactory().createRuntime(
  PROVIDER_IDS.CLAUDE_CLI_ID,
  { command: "claude-agent-acp", args: [] },
  {
    sessionPersistence: createMemorySessionPersistence(),
    workspace: "/path/to/project",
  }
);

if (port.capabilities?.restart) {
  await port.restart?.(); // reloads and resumes the persisted session
}

createMemorySessionPersistence() is process-local. For anything durable, implement ISessionPersistence over a file or database.

Building CLI arguments

Every adapter carries a cliSpec that turns typed options into argv, so callers never hand-write flags:

import { getAdapter, getDefaultRegistry, PROVIDER_IDS } from "@simpill/acp-llm-cli";

const adapter = getAdapter(getDefaultRegistry(), PROVIDER_IDS.CLAUDE_CLI_ID);
const argv = adapter?.cliSpec?.buildArgs({
  command: "claude-agent-acp",
  args: [],
  model: "claude-sonnet-4-20250514",
  outputFormat: "stream-json",
  print: true,
});
// ["--model", "claude-sonnet-4-20250514", "--output-format", "stream-json", "--print"]

The generic options — model, outputFormat, inputFormat, stream, trust, sandbox, workspace, resume, sessionId, verbose, debug, print — are the shared vocabulary for flag maps. Each provider maps the subset its binary actually supports, and an option the provider does not map is skipped rather than emitted: trust reaches only Cursor, sessionId only Claude, and stream/debug are not currently mapped by any bundled provider, so they affect argv only for a custom flag map you supply. buildGenericArgs is exported for custom builders.

cliSpec.getHelp() shells out to the CLI's --help and returns stdout, which is useful for discovery and for checking that an installed CLI supports what you are about to send:

const helpText = await adapter?.cliSpec?.getHelp({
  command: "claude-agent-acp",
  args: adapter.cliSpec.defaultArgs,
  cwd: process.cwd(),
});

Model IDs

Model IDs are exported as const objects — ANTHROPIC_MODEL_IDS, OPENAI_MODEL_IDS, GEMINI_MODEL_IDS, XAI_MODEL_IDS — so editors autocomplete them and typos fail to compile. Provider config schemas accept any string for model, so a model released this morning is never blocked by this package's release cadence. The exported *ModelIdSchema are strict opt-in validators - parse with one explicitly if you want a vendor catalogue enforced.

Refresh them from live provider catalogues:

npm run update-models                              # OpenRouter's public endpoint, no key needed
ACP_LLM_CLI_MODELS_DRY_RUN=1 npm run update-models # preview the diff

Setting ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or XAI_API_KEY makes the script use that provider's own API instead.

Package entry points

| Import | Contains | |---|---| | @simpill/acp-llm-cli | Product API — factories, Provider, model IDs, config schemas | | @simpill/acp-llm-cli/runtime | Extension API — ports, decorators, connections, session persistence |

Reach for /runtime when you are building a custom port or adapter rather than consuming one. docs/api.md lists the curated exports.

Extending

Adding a provider

Create src/providers/<name>/ with four files:

| File | Responsibility | |---|---| | schema.ts | Zod config schema, extending baseCliConfigSchema and genericLlmCliOptionsSchema.partial() | | constants.ts | Env keys, *_CLI_ARG flag names, and *_GENERIC_FLAG_MAP. *_CLI_ARG is the codomain of ProviderFlagMap, so it must hold flags only — put bare subcommands in a *_CLI_SUBCOMMAND const and flag operands in their own, or a generic option can be mapped onto a positional argument | | cli.definition.ts | ICliSpecdefaultArgs, genericFlagMap, knownFlags, buildArgs, getHelp | | adapter.ts | Ties them together and exposes createRuntime(config) |

Then add the default command to src/domain/default.commands.ts, the env keys to src/domain/env.keys.ts, and register the adapter in src/bootstrap.ts. If the CLI speaks ACP over stdio, createAcpCliHarnessRuntime(config) is the whole runtime. If it does not, src/providers/cursor/ is the reference for a fully custom port.

Architecture notes

IProviderFactory.createRuntime validates config against the provider schema and throws with messages from VALIDATION_ERROR; no raw error strings live in business logic. Pass collectMetrics to expose invocations, lastError, and lastInvocationMs through factory.getMetrics(id). Logging goes through createLogger(), with debug output gated on ACP_LLM_CLI_DEBUG.

This package builds on the @simpill utilities for env reads (env.utils), structured logging (logger.utils), and async helpers (async.utils). errors.utils, patterns.utils, and protocols.utils are declared directly so packed installs resolve their transitive requirements.

Contributing

CONTRIBUTING.md covers branching and PR expectations, and CODE_OF_CONDUCT.md applies to every interaction here. npm run verify runs lint, typecheck, build, and tests — the same gate CI applies.

Report security issues privately per SECURITY.md, never as a public issue.

License

ISC