# 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 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 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

| Code | Meaning |
| --- | --- |
| 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.
- 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.
