# ncheap
A command-line tool for the Namecheap registrar API, built for terminal use
and AI-agent operability: structured `--json` output, meaningful exit codes,
and non-interactive operation by default.
**Status: early development (0.x).** Full read-only surface plus gated
mutating commands (DNS, privacy, register/renew) are implemented.
## Install
```
cargo install ncheap # from crates.io
cargo build --release # or from source: target/release/ncheap
```
Fleets should pin a version rather than float on `latest`: each GitHub
release ships per-target tarballs with sha256 checksums, a version-pinned
installer (`https://github.com/jkindrix/ncheap/releases/download/vX.Y.Z/ncheap-installer.sh`),
and build-provenance attestations (verifiable with
`gh attestation verify`). The `releases/latest` installer URL floats and
is not recommended for automation.
## Configuration
Credentials live in `~/.config/ncheap/config.toml` on Linux, or
`~/Library/Application Support/ncheap/config.toml` on macOS (must be
`chmod 600`; ncheap refuses group/other-readable config files):
```toml
default_profile = "production"
[profile.production]
api_user = "your-namecheap-username"
api_key = "your-api-key"
client_ip = "203.0.113.10" # your whitelisted outbound IPv4
[profile.sandbox]
api_user = "your-sandbox-username"
api_key = "your-sandbox-api-key"
client_ip = "203.0.113.10"
sandbox = true
```
`username` defaults to `api_user`. Environment variables override the config
file: `NCHEAP_API_USER`, `NCHEAP_API_KEY`, `NCHEAP_USERNAME`,
`NCHEAP_CLIENT_IP`, `NCHEAP_SANDBOX`, `NCHEAP_PROFILE`. Pure-env operation
(no config file) is supported.
Namecheap's API requires the calling IP to be whitelisted (IPv4 only) under
Profile → Tools → API Access in the Namecheap dashboard. API access has
eligibility requirements (at the time of writing: ≥20 domains, or ≥$50
balance, or ≥$50 spent in the last 2 years). The sandbox is a separate
account with separate data; its pricing and behavior are not guaranteed to
match production.
## Usage
```
ncheap audit # every safety check, one report
ncheap domains list # all domains, auto-paginated
ncheap domains check example.com ... # availability (up to 50 per call)
ncheap domains info example.com # registration, privacy, DNS details
ncheap domains lock example.com # registrar (transfer) lock status
ncheap domains lock example.com --lock # mutating; also --unlock
ncheap domains contacts example.com # contacts; PII redacted unless --full
ncheap dns get example.com # nameserver mode + host records
ncheap dns add example.com --type A --name www --address 192.0.2.1 # mutating
ncheap dns remove example.com --type A --name www # mutating
ncheap dns set example.com ns1.host ns2.host # mutating; see safety model
ncheap privacy list # domain privacy subscriptions
ncheap privacy enable example.com --forward-to you@example.org # mutating
ncheap privacy disable example.com # mutating
ncheap account balances # amounts redacted unless --full
ncheap domains register new.com --max-price 15 --contacts-from owned.com
ncheap domains renew owned.com --max-price 20 # both mutating, price-guarded
ncheap account pricing --action REGISTER --product com # cached 24h
ncheap raw domains.getTldList # direct API call, raw XML out
ncheap raw domains.getInfo --param DomainName=example.com
```
`raw` only calls methods on a read-only allowlist (the wrapped Phase 1
methods plus `domains.getTldList`); mutating methods are refused, and
authentication parameters cannot be supplied via `--param`.
Any command takes `--json` for the machine-readable envelope. Domains for
`dns` commands may be IDN (normalized to punycode) and are split SLD/TLD via
the Public Suffix List, so `example.co.uk` works; subdomains are rejected
with a suggestion rather than silently trimmed.
List commands auto-paginate: accounts with more than 20 domains are fetched
completely, not truncated at the API's default page size.
### JSON envelope
Every command with `--json` emits one envelope on stdout:
```json
{
"ok": true,
"schema": 3,
"command": "domains.list",
"data": [ ... ],
"error": null,
"meta": { "profile": "production", "sandbox": false, "api_calls": 1, "version": "0.3.0" }
}
```
`schema` identifies the envelope revision and `meta.version` the producing
binary. `command` is the dotted command name (`domains.list`,
`domains.check`, `domains.lock`, `domains.info`, `domains.contacts`,
`domains.register`, `domains.renew`, `dns.get`, `dns.set`, `privacy.list`,
`privacy.enable`, `privacy.disable`, `account.balances`, `account.pricing`,
`raw`) — or the sentinel `cli` when argument parsing itself failed. All dates in envelope data are ISO-8601 (`YYYY-MM-DD`) — the API's
native `MM/DD/YYYY` strings sort wrong lexically; `raw` output remains a
verbatim passthrough. The `registry_hold` field (formerly `is_locked`)
reports the API's `IsLocked` — a registry/dispute hold, **not** the
registrar transfer lock, which `domains lock` reports. (Upstream docs do
not define this distinction; the interpretation is from observed live
divergence between `getList.IsLocked` and `getRegistrarLock` on accounts
whose domains are transfer-locked yet report `IsLocked=false`.) On failure `ok` is `false` and `error` carries `kind`
(`usage|config|transport|api|parse|rate_limit`), `code` (Namecheap error
number, if any), and `message`; `meta` is populated whenever a profile had
resolved before the failure, so failures are attributable to a
profile/sandbox, and is `null` only for pre-configuration errors.
### Exit codes
| 0 | Success (per-item results such as an unavailable domain are data, not errors) |
| 1 | Namecheap API returned an error response, or the response did not parse (`error.kind` distinguishes `api` from `parse`) |
| 2 | Usage error (bad arguments) |
| 3 | Configuration / credential / policy error |
| 4 | Transport / network error |
| 5 | Rate-limited. Namecheap documents no rate-limit response; ncheap maps three observed/reported shapes best-effort: HTTP 429 (after one backoff retry), in-band error 500000 (third-party reports), and HTTP 405 with an HTML body (the shape actually captured live, sandbox 2026-06-07) |
#### Envelope compatibility
The envelope's top-level keys (`ok`/`schema`/`command`/`data`/`error`/`meta`),
the `error.kind` values, and the exit-code meanings are stable; `schema`
increments whenever any of them change. New fields
or new `kind` values may be added in minor versions (additive); removing or
renaming any of them is a breaking change and bumps the major version.
Per-command `data` shapes follow the same rule. Note: if stdout closes
mid-write (e.g. piping to `head`), ncheap exits 0 like standard tools —
consumers should treat truncated JSON as incomplete output, not as a
command result. The success-path end-to-end test runs against debug builds
(release builds can only reach the two Namecheap hosts, by design).
## Releasing
Releases are automated by [dist](https://opensource.axo.dev/cargo-dist/):
bump the version in `Cargo.toml`, update `CHANGELOG.md`, run
`cargo update -p psl` (the embedded Public Suffix List snapshot is frozen
into each binary at build time), commit, then tag `vX.Y.Z` and push the
tag. CI builds the binaries, checksums, and installer. After tagging, run
`cargo install --path . --locked` so the PATH binary tracks the release.
## Safety model
**Blast radius, stated plainly:** the Namecheap API key is account-wide —
the API offers no read-only or per-domain sub-keys. Every gate ncheap
enforces (read-only allowlist, production-mutation gate, price guards,
`--yes`) is client-side: they reduce the probability of an *accident* by a
well-behaved caller, they do not constrain a compromised or maliciously
instructed agent holding an armed profile. Treat any host running ncheap
with `allow_production_mutations = true` as holding full registrar
authority over the account. Namecheap's Universal ToS also reserves
discretionary suspension for high-volume or abusive automated use —
sustained agentic operation is at the account owner's risk.
- The API key is never written to logs, error messages, or request traces.
Requests are sent as POST with a form body, so the key never appears in a
URL; the HTTP agent is HTTPS-only and follows no redirects. Note that a
key supplied via `NCHEAP_API_KEY` is visible in `/proc/<pid>/environ` to
same-user processes and may land in shell history; the 0600 config file
is the preferred channel on shared or backed-up machines.
- DNS record edits (`dns add`/`dns remove`) ride on setHosts, which is a
**full-zone replace with no upstream undo or compare-and-swap**: ncheap
fetches the zone, modifies it, and rewrites it whole, preserving the
domain's `EmailType` (mail routing) and journaling the complete
pre-image. Concurrent edits to one zone are last-writer-wins — do not
run parallel editors against the same domain. Removals that would
empty the zone are refused.
- Every mutation is journaled to an append-only, 0600 JSONL file
(`~/.local/state/ncheap/mutations.jsonl`): an fsync'd intent record
before the request, an outcome record after, and pre-images (previous
nameservers / lock state) where the API offers no undo. If the intent
cannot be recorded, the mutation is refused.
- An interrupted mutation (killed process, network drop after send) has an
unknown outcome — the charge or change may have committed server-side.
Never blind-retry an interrupted `register`/`renew`/`dns set`; consult
the mutation journal and reconcile via `domains list`/`domains info`/
`account balances` first.
- Purchasing commands (`domains register`, `domains renew`) additionally
require `--max-price` and refuse pre-flight if the **live** listed price
exceeds it — the pricing cache is never consulted for purchase decisions.
Registration contacts are copied from an owned domain (`--contacts-from`);
ncheap stores no contact data. Premium domains are refused. The actual
charge can exceed the listed price slightly (ICANN fees); both figures
are reported, and `charged_exceeded_max_price` is set when the charge
came in above the cap. Early Access Phase (EAP) domains are refused like
premium ones. A rolling-24h budget (`max_daily_spend` in the profile,
config-file-only like the gate) bounds cumulative purchases via a local
0600 ledger; **production purchases are refused entirely until a cap is
set**, so arming the mutation gate never exposes unlimited spend.
Sandbox is unlimited when uncapped. The cap check holds a file lock
across check-and-reserve, so concurrent purchases on one machine cannot
both pass. A purchase that fails after reservation still consumes its
budget for 24h (fails in the safe direction). The journal and spend
ledger are append-only and never pruned; at sustained agentic volume,
rotate them externally.
- Mutating commands (`dns set`, `privacy enable/disable`, `domains
register/renew`) are enforced at the client layer,
not per-command: they are refused against production unless the profile
sets `allow_production_mutations = true` **in the config file** (the
environment deliberately cannot arm this), they require `--yes`
non-interactively (or an interactive confirmation), and they never
auto-retry — an ambiguous failure after a mutation surfaces instead of
double-submitting. Sandbox profiles may always mutate.
- Client-side throttling spaces requests ~3s apart **within one invocation**,
with backoff on HTTP 429/5xx. Namecheap's FAQ documents 50/min (plus
700/hour and 8000/day) key-wide; older third-party reports say 20/min;
ncheap spaces for the conservative figure. Concurrent ncheap processes on
one machine coordinate through a lock file in the state directory, so
parallel invocations are serialized to the same spacing (fail-open: if
the state directory is unavailable, spacing falls back to per-process).
Processes on different machines sharing one key still do not coordinate.
## License
Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.