Skip to content
Back to skills

Run Shunt

ASecurity

Build, launch, and drive shunt — the Claude Code LLM gateway (a Rust/axum Anthropic-Messages proxy). Use to run, start, smoke-test, or curl-drive the gateway, exercise /v1/models discovery and /v1/messages proxying, or connect Claude Code to a local shunt instance.

  • 241 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 19, 2026
ai-agentspythonrustgoshellbashgitapi

Works with

  • claude code
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 23, 2026

npx -y skills add pleaseai/shunt --skill run-shunt --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Run Shunt?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Run Shunt
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/pleaseai-run-shunt/badge)](https://www.skillsdirectory.com/skills/pleaseai-run-shunt)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: run-shunt
description: Build, launch, and drive shunt — the Claude Code LLM gateway (a Rust/axum Anthropic-Messages proxy). Use to run, start, smoke-test, or curl-drive the gateway, exercise /v1/models discovery and /v1/messages proxying, or connect Claude Code to a local shunt instance.
---

# Run shunt

`shunt` is a Rust ([axum](https://github.com/tokio-rs/axum)) HTTP server — a Claude Code LLM gateway. It has **no GUI**: you drive it with `curl`. It listens on `127.0.0.1:3001` by default and serves an Anthropic-Messages surface (`GET /v1/models`, `POST /v1/messages`, `POST /v1/messages/count_tokens`, `HEAD /`). For each mapped `model` id it diverts inference to another provider (OpenAI / Codex / ChatGPT via the OpenAI Responses API); everything else passes through to Anthropic.

**Primary agent path:** run the committed driver [`.claude/skills/run-shunt/smoke.sh`](smoke.sh). It builds the binary, spins up a local mock upstream (so no real API key is needed), launches the gateway, and drives every route end to end with assertions. That is the way to confirm a change works.

All paths below are relative to the repo root (the `shunt/` directory).

## Prerequisites

Everything the driver needs is already standard on macOS/Linux dev boxes:

```bash
cargo --version     # Rust stable (built with 1.94); toolchain via rust-toolchain.toml if present
python3 --version   # smoke.sh uses http.server as a stand-in upstream
curl --version
jq --version        # smoke.sh asserts JSON responses with jq
lsof -v
```

On a bare Ubuntu container: `apt-get install -y curl jq lsof python3` and install Rust via `rustup` if `cargo` is missing.

## Build

```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
```

`BIN` resolves Cargo's actual executable path, including `CARGO_TARGET_DIR` and a configured target triple.

## Run (agent path) — the smoke driver

This is what you run to see shunt working. It is hermetic (no network, no credentials) and exits non-zero on the first failed assertion.

```bash
.claude/skills/run-shunt/smoke.sh
```

Expected tail:

```
  PASS shunt check -> config ok
  PASS HEAD / -> 200 (server live)
  PASS GET /v1/models returns configured model
  PASS POST /v1/messages proxied to upstream and returned its body
  PASS malformed request -> 400 invalid_request_error
  PASS GET /health reports ok
  PASS GET /protocol describes the anthropic-messages contract
  PASS GET /routes resolves the configured route
  PASS POST /v1/messages/count_tokens proxied to upstream on its own path
All smoke checks passed.
```

What it covers: every route the server registers unconditionally. Config validation (`shunt check`), liveness (`HEAD /`), health (`GET /health`), the gateway contract (`GET /protocol`), model discovery (`GET /v1/models`), the resolved route table (`GET /routes`), both proxy forward paths (`POST /v1/messages` and `POST /v1/messages/count_tokens`, routed to a local mock that stands in for `api.anthropic.com` and asserted by the path it forwarded to), and the routing error path (a body with no `model` field → `400 invalid_request_error`). Routes that only an optional config section registers (the Codex endpoint, the usage surfaces) are out of scope, since the smoke config does not enable them.

`SHUNT_PORT` and `MOCK_PORT` default to `31711` and `31712`. Set either to `0` to bind an ephemeral port instead, which is the conflict-free way to run the driver beside a live gateway.

The driver has its own regression, [`test_smoke.sh`](test_smoke.sh). Run it after editing `smoke.sh`. It proves the driver refuses a port outside `0..65535` by name; refuses a port another listener already holds, where the real process dies at bind and the driver says which one died; and refuses a shunt listening on a port it never requested, which is the case the pid-derived readiness check catches. It then runs the normal path. The stand-in listener answers every assertion the driver makes, so a driver that trusted `$SHUNT_PORT` over the process it started would go green against it and get caught.

### Drive it by hand

Write a config, launch, and curl it yourself:

```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
"$BIN" run --config ./shunt.toml    # or copy shunt.toml.example first
```

Then, against the running server:

```bash
curl -s "http://127.0.0.1:3001/v1/models?limit=1000" | jq .
# => {"data":[{"id":"claude-opus-via-codex","display_name":"Opus (via Codex)"}]}
```

Validate a config without starting the server:

```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
"$BIN" check --config ./shunt.toml   # prints "config ok" or a precise error
```

CLI shape: `shunt run|check [--config <path>]` (also `shunt --check`). Default config path is `./shunt.toml`; `SHUNT_`-prefixed env vars override (with `__` for nesting).

## Direct invocation (internal logic — most PRs touch this)

The interesting code is the **Anthropic Messages ⇄ OpenAI Responses translation** (`src/adapters/`, `src/model/`) and routing (`src/routing.rs`). These are covered by unit + integration tests — the fastest inner loop for a PR touching them:

```bash
cargo test --workspace                              # all tests
cargo test --test responses_translate               # the translation integration suite
```

Full pre-PR gate (matches CI):

```bash
cargo fmt --all --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --workspace
```

## Run (human path) — connect Claude Code to a local shunt

Point Claude Code at the running gateway. shunt does **not** validate the credential, but Claude Code still needs one set or it drops to its login wizard. Per the [gateway-connect docs](https://code.claude.com/docs/en/llm-gateway-connect):

```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_AUTH_TOKEN=local-dummy        # any string; shunt ignores it
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1   # opt-in: pull /v1/models into the picker
export ANTHROPIC_CUSTOM_MODEL_OPTION=gpt-5.2-codex    # add a non-claude id to /model (see Gotchas)
claude    # started from the same shell; /status shows the base URL
```

Verify the wiring without opening Claude Code (this is the docs' own check):

```bash
curl -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
  -d '{"model":"claude-opus-via-codex","max_tokens":1,"messages":[{"role":"user","content":"."}]}'
```

With a default (anthropic-routed) config this forwards to `api.anthropic.com` and needs a real Anthropic key in the header to get a `200`; a mapped model routed to `openai`/`codex` needs that provider's credential instead. For a credential-free run, use `smoke.sh` (mock upstream) rather than this path.

## Gotchas

- **No GUI, no `npm start`.** It's a Rust HTTP server. "Running it" means launch + `curl`. The driver is the smoke script.
- **Model discovery drops non-`claude`/`anthropic` ids.** Claude Code's `/v1/models` importer ignores any `id` not starting with `claude` or `anthropic`, so to route to e.g. `gpt-5.2-codex` you either alias it under a `claude…` discovery id **or** add it via `ANTHROPIC_CUSTOM_MODEL_OPTION` (the primary way). This is a Claude Code constraint, not a shunt bug.
- **shunt ignores the request credential.** `GET /v1/models` reads `authorization`/`x-api-key` but discards it (`src/discovery.rs`), and the proxy just forwards headers upstream. So any dummy token works for local driving — the real key only matters to the *upstream* provider.
- **`shunt check` is strict about provider shape.** `providers.openai.adapter` and `providers.codex.adapter` must be `"responses"`; `openai.auth` must be `api_key`, `codex.auth` must be `chatgpt_oauth` — otherwise `check` fails with a specific error (`src/config.rs`). Partial TOML is fine: figment merges your file over built-in defaults, so you only need to specify what differs.
- **A discovery `[[models]]` entry with no matching `[[routes]]`** logs a `WARN` at startup/`check` but is not fatal.
- **`GET /protocol` is the machine-readable gateway contract.** It is unauthenticated and reports shunt's package version, Anthropic-Messages format, supported endpoints, header handling, attribution behavior, and model-discovery constraints.
- **zsh quoting:** quote URLs containing `?` (globbing) and mind `noclobber` on `>` redirects when driving by hand in this repo's shell.

## Troubleshooting

- **`smoke.sh` reports `shunt exited during startup` or `mock upstream exited during startup`**: the dumped log says `Address already in use`, so a stale process holds that test port. Run `lsof -nP -iTCP:${MOCK_PORT:-31712} -sTCP:LISTEN` for the mock port or replace it with `${SHUNT_PORT:-31711}` for the gateway port. Stop the reported PID, then re-run. `SHUNT_PORT=0 MOCK_PORT=0` skips the conflict outright by binding ephemeral ports.
- **`502 Bad Gateway` / `api_error: error sending request for url (...)`** — shunt reached routing but the upstream `base_url` was unreachable (wrong host/port, or the mock/provider isn't up). This is the correct error mapping (`src/error.rs`), not a crash. Check the target `base_url`.
- **`400 invalid_request_error: request body must include a JSON model field`** — the request body isn't JSON with a `model` key. Routing happens before forwarding (`src/routing.rs`).
- **`cargo clippy` fails the build** — CI sets `RUSTFLAGS=-D warnings`; warnings are errors. Fix them before a PR (`cargo clippy --all-targets --all-features -- -D warnings`).

Files in this skill

  • SKILL.md7.7 KB
  • smoke.sh5.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…