Run local AI CLIs — Claude, Codex, Antigravity (agy) — and any third-party
OpenAI-compatible API as MCP tools, with background jobs. Self-maintained from
source, built on a registry architecture: adding an agent means adding one file.
Requires Node ^20.19.0 || >=22.12.0.
完整中文文件(環境變數、設定檔、每個工具的細節)見 README.zh-TW.md. The Chinese document is the exhaustive reference; this page covers the design and the parts you need to get running.
From npm — nothing to build:
claude mcp add ai-cli -s user -- npx -y @tkflyc/ai-cli-mcpFrom source — if you intend to change it, or want auto-update:
git clone https://github.com/moerasermax/tkflyc-ai-cli
cd tkflyc-ai-cli
npm install # triggers build via the prepare script and produces dist/
claude mcp add ai-cli -s user -- node "$PWD/dist/server.js"dist/ is not version-controlled, so the source install has to build before first
use. npm install runs it through the prepare script, so a separate
npm run build is normally unnecessary.
.git and package.json (see Auto-update below), so an npm/npx install never
updates itself — moving to a new version is something you do yourself. Pin the
version in the command (@tkflyc/ai-cli-mcp@<version>) if you need to know exactly
which one is running.
doctor reports whether this installation meets the source-update precondition
in update.supported.
Each supported CLI becomes a tool you can call from an MCP client. Jobs run in the
background: run returns a PID immediately, and list_processes, peek, wait and
get_result observe it while it works. Two surfaces expose this — the MCP server and
the ai-cli command line — and they differ in where job state lives.
src/
├─ agents/ one file per AI — this is the extension point
│ ├─ types.ts AgentDefinition, the core contract
│ ├─ registry.ts central registry
│ └─ claude.ts · codex.ts · antigravity.ts · direct-api.ts
├─ core/ the framework; untouched when adding an agent
│ ├─ command-builder.ts model routing and command assembly
│ ├─ process-service.ts in-memory job management (MCP)
│ ├─ file-process-service.ts file-backed job management (CLI)
│ ├─ pty-runner.ts ConPTY, for CLIs that need a real TTY
│ ├─ circuit-breaker.ts start-rate and duplicate-prompt breaker
│ ├─ updater.ts background check, subprocess apply, rollback
│ └─ user-config.ts · binary-resolver.ts · peek.ts · doctor.ts
├─ models/ model catalog, aliases, provenance and disk cache
├─ plugins/ quota lookup bridge
└─ app/ mcp.ts (MCP server) · cli.ts (command line)
The split is the point: agents/ is data about each CLI, core/ is the machinery.
A new backend never requires editing the machinery.
Job state lives in memory for the MCP server and in files for the CLI, so a CLI job
can be observed by a later command. Pipe-based CLI jobs run through a detached
wrapper and outlive the command that started them. The PTY path — used for CLIs that
only produce output on a real TTY — is not detached, and direct-api starts no
subprocess at all: on the CLI it runs in-process and blocks until the request
finishes, while the MCP server still returns a PID immediately.
- Copy
src/agents/codex.tstosrc/agents/<name>.tsand implementAgentDefinition:id,models,matchesModel,binary,reasoning,buildCommand,parseOutput.- Needs a real TTY → set
win32SpawnMode: 'pty'(seeantigravity.ts). - Is a real
.exerather than an npm shim → setwin32DirectExec: true.
- Needs a real TTY → set
- Import it in
src/agents/registry.tsand append it toAGENTS. Claude stays last — it is the fallback. - Add the new id to
AgentIdinsrc/agents/types.ts. If the agent needs a CLI binary, extend theCliPathsreturn incore/doctor.ts. npm run build.
A caller-side bug can turn into an infinite loop, and an infinite loop hammering a
provider can get an account flagged for abuse. Every subprocess start therefore goes
through src/core/circuit-breaker.ts first, which watches for two loop signatures:
- Rate — starts within the sliding window exceed
AI_CLI_BREAKER_MAX_STARTS. - Duplicate — the same agent with the same prompt exceeds
AI_CLI_BREAKER_DUP_LIMITwithin the window.
Either one opens the breaker for AI_CLI_BREAKER_COOLDOWN_SEC, during which starts
are refused with an explicit error, after which it recovers on its own. Normal usage
does not trip it. Verify with npm run build && node tests/verify-breaker.mjs.
A wait timeout means the observation window closed, not that the job failed — the
process keeps running. Earlier versions threw Timed out after N seconds, which MCP
wrapped as an InternalError, and calling models routinely read that as failure and
abandoned the PID.
Now wait returns the array of current results instead. Only items still running
at the timeout carry timedOut: true. An unknown PID is still an error;
completed, failed and lost carry neither liveness nor timedOut.
Running items from get_result, wait and list_processes all carry the same
liveness object:
| Field | Meaning |
|---|---|
alive |
Process is up. It does not promise the model is producing tokens. |
elapsedSec |
Seconds since start. |
sinceLastOutputSec |
Seconds since the last stdout/stderr chunk; null if there has never been output. |
stdoutBytes / stderrBytes |
Bytes received. PTY-merged output counts as stdout. |
lastEvent |
One-line summary of the last meaningful event, ≤120 chars. |
eventCount |
Complete decoded events. Blank lines, bad JSON and partial lines do not count. |
hint |
Plain-English advice: starting up, recently active, alive but silent, or waiting on end-of-run metadata. |
This matters because Codex and Claude emit nothing at all while reasoning. Without
liveness, silence is indistinguishable from death.
The file-backed path keeps a lost state: the PID is gone and no completion was
recorded, so the outcome is genuinely unknown — which is not the same as failed.
Precondition: this section applies only to a source clone. The updater requires
.git and package.json in the repo root, so an npm/npx install never updates
itself and doctor reports update.supported: false.
All three MCP entry points (dist/server.js, dist/bin/ai-cli-mcp.js, and
dist/bin/ai-cli.js mcp) serve normally after the transport connects, then check in the
background ~3 seconds later and apply updates in a subprocess; the new version takes
effect on the next start. Nothing blocks startup, and no running server or job is
killed.
AI_CLI_AUTO_UPDATE |
Behavior |
|---|---|
on (default) |
Check in the background, apply automatically |
check |
Report a new version, do not apply |
off |
Never touch the network; still reads existing state |
Also honored: AI_CLI_UPDATE_CHECK_INTERVAL_SEC (default 3600),
AI_CLI_UPDATE_BRANCH, and AI_CLI_STATE_DIR (default ~/.local/state/ai-cli).
master as a deployment: npm test must be green before you
push. This is a public repo, and anyone with write access can trigger that.
A source install that tracks master with auto-update on will try to fetch and apply
it on a later check. It does not always land: a dirty tree, a different tracked
branch (AI_CLI_UPDATE_BRANCH), a non-fast-forward or a held lock stops it before
the pull, and a failed install, build or doctor smoke test triggers a rollback
attempt — which can itself fail. So neither "it went everywhere" nor "it went
nowhere" is safe to assume.
Besides the three local CLIs there is a fourth path that starts no subprocess at all:
direct-api talks HTTP from inside the Node process, so any OpenAI-compatible
/chat/completions endpoint works — OpenRouter, DashScope, DeepSeek, NVIDIA's
hosted NIM catalog, a local Ollama, your company gateway. Adding a provider needs no
code change, only a config entry.
It is not a thin completion wrapper: it runs the full agent loop, so the model gets
read_file / write_file / bash and can actually change files and run commands.
~/.local/share/ai-cli/providers.json (override with AI_CLI_PROVIDERS_PATH):
{
"providers": {
"openrouter": { "api_key": "sk-or-v1-..." },
"local": { "base_url": "http://127.0.0.1:11434/v1", "api_key": "ollama" },
"nv": {
"base_url": "https://integrate.api.nvidia.com/v1",
"api_key": "nvapi-...",
"retry": { "max_retries": 3, "initial_delay_ms": 1000 },
"extra_body": { "max_tokens": 8192 },
"model_extra_body": {
"nvidia/nemotron-3.5-lightning-30b-a3b": { "reasoning_effort": "none" }
}
}
}
}Address a model as <provider>-<model>, where the prefix is the key above:
or-qwen/qwen3.7-plus, nv-openai/gpt-oss-20b, local-llama3.1. Everything after
the prefix is sent verbatim, so slashes in model names are fine. openrouter and
dashscope have built-in base URLs and short prefixes (or, ds); every other key
must supply its own base_url.
This file is fail-closed as a whole. One provider missing
base_urlorapi_keymakes the entire file throw, taking the working providers down with it. Back it up before editing.
The request body is model / messages / stream / stream_options / tools and
nothing else, so a hosted model's defaults apply untouched — and those defaults can be
expensive. nvidia/nemotron-3.5-lightning-30b-a3b spends 28.0s and 318 output tokens
per tool round on its default reasoning, and 6.1s / 84 tokens with
reasoning_effort: "none".
extra_body is the provider-wide default; model_extra_body overrides it field by
field, keyed by the model name as sent to the provider.
model, messages, stream, stream_options and tools are rejected at load
time, naming the provider and the offending key — not dropped silently. Overriding
stream would hand the SSE reader a single JSON blob; overriding tools would offer
the model tools executeTool cannot run.
Note the terminology clash: the run tool's reasoning_effort argument is a
CLI flag for claude/codex and still does not apply here. What extra_body sends is
the same-named API field, which is a different thing.
Free shared endpoints throttle and shed load. NVIDIA's own troubleshooting docs say the hosted Nemotron endpoints may return 429 or 503 under high demand, and advise a short wait plus lower concurrency; before this, any non-200 simply failed the job.
429 and 5xx are retried with exponential backoff and jitter. Every other 4xx is not —
a request the server rejected as malformed is still malformed the second time, and
retrying only burns quota. Defaults are 2 retries and a 1s initial delay;
max_retries: 0 turns it off. A Retry-After header wins over the computed backoff,
capped at 60s. A kill during backoff wakes immediately rather than sleeping it out.
Each retry emits a retry event on stdout — a silent retry makes "slow" and "stuck"
indistinguishable to a caller that only sees the tool result.
Retrying covers establishing the request. Once a 200 arrives and the stream starts, content has already reached the caller, so a mid-stream failure is not retried; replaying it would emit the same answer twice.
Measured on nvidia/nemotron-3-super-120b-a12b, 10 two-round tool loops per row:
| Request pacing | Retry | Success |
|---|---|---|
| back-to-back (~66 rpm) | off | 4/10 |
| 10 rpm | off | 8/10 |
| 15 rpm | off | 9/10 |
| 15 rpm | on | 10/10 |
Slowing down alone never reached 100%; retrying did, and at the faster rate. Even pacing trades every request's latency for a few requests' success, while backoff pays only when the server actually refuses.
Some models require the previous assistant turn to be replayed complete, including
its reasoning. Kimi-K3's model card: "clients must pass back the complete assistant
message, including reasoning_content and tool_calls". DeepSeek V4 goes further —
with tools in play, omitting it returns 400.
"nv": { "replay_reasoning": ["moonshotai/kimi-k3"] }true covers every model of that provider; an array names them individually.
Off by default, and deliberately not a built-in model list. reasoning_content
is not part of OpenAI's assistant message schema, and "OpenAI-compatible" does not
promise unknown fields are ignored — Azure AI Model Inference defaults extra-parameters
to error. A hardcoded allow-list would also go stale the way every other hardcoded
model list here has.
The replayed value is that turn's reasoning, not the run's accumulated text —
otherwise each round would re-append everything before it. It applies to every
assistant turn, not only the ones carrying tool calls: the final answer is written to
the session file and becomes a prior turn when you continue with the same
session_id.
Measured caveat: Kimi-K3 completed a three-round tool chain correctly without the replay, and the docs do not state what omitting it breaks. This follows the stated contract; it is not a fix for an observed failure.
models returns a directApiProviders block: each configured provider's usable
prefixes (including built-in shorthands), base URL, the models named in
model_extra_body, and a copy-pasteable example.
Without it a tool result cannot answer "what can this machine reach" — the static
direct-api list is four placeholder strings, and configured providers are local
state that lives in neither version control nor any static list.
The block never contains api_key, and never throws: an unreadable providers.json
comes back as a note, because "cannot read it" and "nothing configured" are
different answers.
- Sessions —
session_idpersists underworkFolder/.tmp/api_sessions. - Images — write
[image:C:/path/to.png]in the prompt (png/jpg/webp/gif). - Plain Q&A — prefix the prompt with
[no-tools]to drop the tool loop. - Per turn — at most 30 API calls and 30 tool-loop iterations.
- Keys — error text is redacted to
[redacted]before it is returned. - Model lists are dynamic; the framework keeps no allow-list.
Migrating from OpenCode: if
providers.jsonis absent but~/.local/share/opencode/auth.jsonexists, it is converted once, automatically.
# bash / git bash
claude mcp add ai-cli -s user -- node "$PWD/dist/server.js"# PowerShell
claude mcp add ai-cli -s user -- node "$PWD\dist\server.js"This project began as a clone of mkXultra/ai-cli-mcp
(MIT), which is itself derived from Peter Steinberger's claude-code-mcp (MIT).
Upstream is actively maintained — 2.23.0 shipped on 2026-09-06, and it has
roughly 680 weekly npm downloads. This is not a rescue of an abandoned project;
it is a fork that diverged on architecture. If you want the original, use
ai-cli-mcp. See NOTICE
for the full attribution and the retained MIT terms.
It has since been substantially rewritten. Measured against upstream v2.23.0, 299 of this tree's 1,980 substantive source lines (about 15%) are still identical — concentrated in the MCP tool surface (tool names, descriptions, schemas) and the CLI/MCP entry points. What is new here:
- Registry architecture. Upstream hard-coded five backends. Here,
src/agents/holds one file per CLI and is the only extension point;src/core/is the machinery and does not change when a backend is added. direct-api— bring any third-party OpenAI-compatible provider, rather than only the CLIs someone else compiled in.- Circuit breaker for start-rate and duplicate-prompt storms, so an orchestration loop cannot turn into anomalous traffic against a provider.
- ConPTY runner for CLIs that only produce output on a real TTY.
- Model catalog with provenance — every entry says where it came from and whether it is dispatchable — plus a dispatch guidance table.
- Background self-update with subprocess apply and rollback.
- Honest return values.
waitreturns liveness on timeout instead of throwing; job state distinguisheslostfromfailed;doctorreturnsnullfor checks it did not perform rather thanfalse. The caller is an AI and only sees the return value, so the return value has to say what is actually known.
The package is published as @tkflyc/ai-cli-mcp; the unscoped
ai-cli-mcp name on npm belongs to upstream.
Apache-2.0. See LICENSE.
This project is a derivative work of software originally released under the
MIT License. The original copyright notices and the retained MIT terms are in
NOTICE, which ships with every copy — it is in the repo and
listed in package.json's files, so it travels with the npm package too.