diff --git a/docs/README.skills.md b/docs/README.skills.md
index d7e9a764b..9c448fc5c 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -245,6 +245,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [mvvm-toolkit](../skills/mvvm-toolkit/SKILL.md)
`gh skills install github/awesome-copilot mvvm-toolkit` | CommunityToolkit.Mvvm (the MVVM Toolkit) core: source generators ([ObservableProperty], [RelayCommand], [NotifyPropertyChangedFor], [NotifyCanExecuteChangedFor], [NotifyDataErrorInfo]), base classes (ObservableObject / ObservableValidator / ObservableRecipient), commands (RelayCommand / AsyncRelayCommand), and validation. Companion skills: mvvm-toolkit-messenger for pub/sub, mvvm-toolkit-di for Microsoft.Extensions.DependencyInjection wiring. Works across WPF, WinUI 3, MAUI, Uno, and Avalonia. | `references/end-to-end-walkthrough.md`
`references/relaycommand-cookbook.md`
`references/source-generators.md`
`references/troubleshooting.md`
`references/validation.md` |
| [mvvm-toolkit-di](../skills/mvvm-toolkit-di/SKILL.md)
`gh skills install github/awesome-copilot mvvm-toolkit-di` | Wire CommunityToolkit.Mvvm ViewModels into Microsoft.Extensions.DependencyInjection. Covers the .NET Generic Host composition root, constructor injection, service lifetimes (Singleton / Transient / Scoped), IMessenger registration, resolving ViewModels in Views, keyed services, testing seams, and the legacy Ioc.Default escape hatch. Use across WPF, WinUI 3, .NET MAUI, Uno, and Avalonia. | `references/dependency-injection.md` |
| [mvvm-toolkit-messenger](../skills/mvvm-toolkit-messenger/SKILL.md)
`gh skills install github/awesome-copilot mvvm-toolkit-messenger` | CommunityToolkit.Mvvm Messenger pub/sub for decoupled communication between ViewModels (or any objects). Covers WeakReferenceMessenger vs StrongReferenceMessenger, IRecipient, RequestMessage / AsyncRequestMessage / CollectionRequestMessage, ValueChangedMessage, channels (tokens), and the ObservableRecipient activation lifecycle. Use across WPF, WinUI 3, .NET MAUI, Uno, and Avalonia. | `references/messenger-patterns.md` |
+| [namecheap](../skills/namecheap/SKILL.md)
`gh skills install github/awesome-copilot namecheap` | Manage DNS records for domains registered with Namecheap via their API. List domains, view/add/update/remove DNS host entries (A, AAAA, CNAME, MX, TXT, etc.), and guide users through API setup including public IP detection and credential configuration. Use when the user mentions Namecheap, DNS records, domain management, or wants to add/change/remove A records, CNAME records, MX records, or TXT records for their domains. | `namecheap.py`
`references/namecheap-api.md` |
| [nano-banana-pro-openrouter](../skills/nano-banana-pro-openrouter/SKILL.md)
`gh skills install github/awesome-copilot nano-banana-pro-openrouter` | Generate or edit images via OpenRouter with the Gemini 3 Pro Image model. Use for prompt-only image generation, image edits, and multi-image compositing; supports 1K/2K/4K output. | `assets/SYSTEM_TEMPLATE`
`scripts/generate_image.py` |
| [napkin](../skills/napkin/SKILL.md)
`gh skills install github/awesome-copilot napkin` | Visual whiteboard collaboration for Copilot CLI. Creates an interactive whiteboard that opens in your browser — draw, sketch, add sticky notes, then share everything back with Copilot. Copilot sees your drawings and text, and responds with analysis, suggestions, and ideas. | `assets/napkin.html`
`assets/step1-activate.svg`
`assets/step2-whiteboard.svg`
`assets/step3-draw.svg`
`assets/step4-share.svg`
`assets/step5-response.svg` |
| [next-intl-add-language](../skills/next-intl-add-language/SKILL.md)
`gh skills install github/awesome-copilot next-intl-add-language` | Add new language to a Next.js + next-intl application | None |
diff --git a/skills/namecheap/SKILL.md b/skills/namecheap/SKILL.md
new file mode 100644
index 000000000..1fae5cea6
--- /dev/null
+++ b/skills/namecheap/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: namecheap
+description: 'Manage DNS records for domains registered with Namecheap via their API. List domains, view/add/update/remove DNS host entries (A, AAAA, CNAME, MX, TXT, etc.), and guide users through API setup including public IP detection and credential configuration. Use when the user mentions Namecheap, DNS records, domain management, or wants to add/change/remove A records, CNAME records, MX records, or TXT records for their domains.'
+---
+
+# Namecheap DNS Management
+
+**UTILITY SKILL** — manages DNS records via the Namecheap API.
+USE FOR: "add DNS record", "update A record", "manage Namecheap domains", "set CNAME", "add MX record", "add TXT record", "list my domains", "show DNS records", "namecheap setup", "configure namecheap API", "what is my public IP"
+DO NOT USE FOR: domain registration/purchase, SSL certificate management, hosting configuration, non-Namecheap DNS providers
+
+## Workflow
+
+### First-time Setup
+
+Before executing any API commands, verify credentials are configured:
+
+1. **Check for existing config** — look for `~/.namecheap-api`
+2. If not configured, guide the user through setup:
+ a. **Show public IP** — run `python3 namecheap.py public-ip` to display the user's public IP
+ b. **Instruct IP whitelisting** — tell the user to go to https://ap.www.namecheap.com/settings/tools/apiaccess/, enable API (select ON), and whitelist the displayed IP
+ c. **Have the user run setup themselves** — ask the user to run `python3 namecheap.py setup` directly **in their own terminal**. The script prompts for the username and reads the API key with a hidden prompt (`getpass`), writes `~/.namecheap-api` with `chmod 600`, and validates the connection. **Never ask the user to paste their API key into the chat, and never log, echo, or display the API key value.** If you cannot run an interactive terminal for the user, instruct them to run `setup` themselves, or to export `NAMECHEAP_API_USER` and `NAMECHEAP_API_KEY` as environment variables in their own shell — rather than collecting the secret via `ask_user`.
+ d. **Confirm** — once the user reports setup succeeded, proceed with DNS operations.
+
+### DNS Operations
+
+Use the `namecheap.py` script (bundled in this skill's directory) for all API interactions. It requires only Python 3 (standard library only — no `pip install` needed) and works the same on macOS, Linux, and Windows:
+
+```bash
+# Show public IP (for setup)
+python3 namecheap.py public-ip
+
+# Run setup flow
+python3 namecheap.py setup
+
+# List domains
+python3 namecheap.py domains.getList
+
+# Get nameservers for a domain (shows if using Namecheap DNS or custom)
+python3 namecheap.py domains.dns.getList --domain example.com
+
+# Get DNS records for a domain
+python3 namecheap.py domains.dns.getHosts --domain example.com
+
+# Add a single record (preserves existing records)
+python3 namecheap.py dns.addHost --domain example.com --type A --name www --address 1.2.3.4 --ttl 1800
+
+# Remove a single record
+python3 namecheap.py dns.removeHost --domain example.com --type A --name www --address 1.2.3.4
+
+# Replace all records from a JSON file
+python3 namecheap.py domains.dns.setHosts --domain example.com --hosts records.json
+
+# Switch to Namecheap default DNS
+python3 namecheap.py domains.dns.setDefault --domain example.com
+
+# Switch to custom nameservers
+python3 namecheap.py domains.dns.setCustom --domain example.com --nameservers ns1.cloudflare.com,ns2.cloudflare.com
+
+# Get email forwarding rules
+python3 namecheap.py domains.dns.getEmailForwarding --domain example.com
+
+# Set email forwarding (single rule)
+python3 namecheap.py domains.dns.setEmailForwarding --domain example.com --mailbox info --forward-to user@gmail.com
+
+# Set email forwarding (from JSON file)
+python3 namecheap.py domains.dns.setEmailForwarding --domain example.com --forwards forwards.json
+
+# Create a child nameserver (glue record)
+python3 namecheap.py domains.ns.create --domain example.com --nameserver ns1.example.com --ip 1.2.3.4
+
+# Delete a child nameserver
+python3 namecheap.py domains.ns.delete --domain example.com --nameserver ns1.example.com
+
+# Get nameserver info
+python3 namecheap.py domains.ns.getInfo --domain example.com --nameserver ns1.example.com
+
+# Update nameserver IP
+python3 namecheap.py domains.ns.update --domain example.com --nameserver ns1.example.com --old-ip 1.2.3.4 --ip 5.6.7.8
+```
+
+### JSON file formats
+
+`domains.dns.setHosts --hosts records.json` expects an array of objects with Namecheap API field names:
+
+```json
+[
+ { "HostName": "@", "RecordType": "A", "Address": "1.2.3.4", "TTL": 1800 },
+ { "HostName": "www", "RecordType": "CNAME", "Address": "@", "TTL": 1800 },
+ { "HostName": "@", "RecordType": "MX", "Address": "mail.example.com.", "TTL": 1800, "MXPref": 10 }
+]
+```
+
+`domains.dns.setEmailForwarding --forwards forwards.json` expects an array of mailbox rules:
+
+```json
+[
+ { "MailBox": "info", "ForwardTo": "team@example.net" },
+ { "MailBox": "sales", "ForwardTo": "owner@example.net" }
+]
+```
+
+## Behavior
+
+- **Always check credentials first.** Before any API operation, verify `~/.namecheap-api` exists and is readable. If not, run the setup flow.
+- **Show current records before modifying.** Before adding or removing records, always fetch and display the current DNS records so the user can confirm the change.
+- **Use `ask_user` to confirm destructive changes.** Before removing records or replacing all records with `setHosts`, confirm with the user.
+- **The Namecheap `setHosts` API replaces ALL records.** Never call `domains.dns.setHosts` directly unless you have fetched all existing records first. Use `dns.addHost` and `dns.removeHost` for safe single-record operations — they handle the fetch-modify-write cycle internally.
+- **Explain TTL in human terms.** When the user asks about TTL, explain that 1800 = 30 minutes, 3600 = 1 hour, etc.
+- **Handle multi-part TLDs.** Domains like `example.co.uk` have SLD=example and TLD=co.uk. The script recognizes a built-in list of common second-level suffixes (e.g. `co.uk`, `com.au`, `co.jp`, `com.br`). This list is best-effort and not a full public-suffix database — if a domain with an unlisted multi-part suffix returns a `2019166` ("Domain not found") error, the SLD/TLD split was likely wrong. In that case, confirm the registered domain with the user and report the limitation.
+
+## Credential Storage
+
+Credentials are stored in `~/.namecheap-api`:
+
+```bash
+NAMECHEAP_API_USER="username"
+NAMECHEAP_API_KEY="api-key-here"
+```
+
+This file must have `600` permissions (owner read/write only). Alternatively, the script reads credentials from the `NAMECHEAP_API_USER` and `NAMECHEAP_API_KEY` environment variables, which take precedence over the file when both are set.
+
+## Supported Record Types
+
+A, AAAA, CNAME, MX, MXE, TXT, URL, URL301, FRAME
+
+## References
+
+See `references/namecheap-api.md` for full API documentation including request/response formats.
diff --git a/skills/namecheap/namecheap.py b/skills/namecheap/namecheap.py
new file mode 100644
index 000000000..bb7d1dffd
--- /dev/null
+++ b/skills/namecheap/namecheap.py
@@ -0,0 +1,697 @@
+#!/usr/bin/env python3
+"""Namecheap API CLI wrapper for DNS management.
+
+Uses only the Python standard library (no third-party dependencies). Credentials
+are read from ``~/.namecheap-api`` (or env vars) and are never passed on the
+command line, so they cannot leak via ``ps``/shell history.
+"""
+
+import argparse
+import getpass
+import json
+import os
+import re
+import stat
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+import xml.etree.ElementTree as ET
+
+API_URL = "https://api.namecheap.com/xml.response"
+CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".namecheap-api")
+
+# Known multi-part (second-level) public suffixes. Best-effort list, not a full
+# public-suffix database. For unlisted suffixes the domain is split on the last
+# dot, which is correct for single-label TLDs (.com, .io, .dev, ...).
+MULTI_PART_SUFFIXES = {
+ "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "ltd.uk", "plc.uk",
+ "com.au", "net.au", "org.au", "id.au",
+ "co.nz", "net.nz", "org.nz",
+ "co.za", "org.za",
+ "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
+ "co.kr", "or.kr", "ne.kr",
+ "com.br", "net.br", "org.br",
+ "com.cn", "net.cn", "org.cn",
+ "co.in", "net.in", "org.in",
+ "com.mx", "org.mx",
+ "com.sg", "edu.sg",
+ "com.tr",
+ "co.il", "org.il",
+}
+
+# Cached public IP for the lifetime of the process.
+_public_ip = None
+
+
+# --- Output helpers -------------------------------------------------------
+
+_USE_COLOR = sys.stdout.isatty()
+
+
+def _c(code, text):
+ return f"\033[{code}m{text}\033[0m" if _USE_COLOR else text
+
+
+def err(msg):
+ print(_c("0;31", "Error:") + " " + msg, file=sys.stderr)
+
+
+def success(msg):
+ print(_c("0;32", "\u2713") + " " + msg)
+
+
+def info(msg):
+ print(_c("0;36", "\u2139") + " " + msg)
+
+
+def warn(msg):
+ print(_c("1;33", "\u26a0") + " " + msg)
+
+
+class NamecheapError(Exception):
+ """Raised when the API returns a Status="ERROR" response."""
+
+
+# --- Configuration --------------------------------------------------------
+
+def load_config():
+ """Return (api_user, api_key), preferring env vars then the config file."""
+ api_user = os.environ.get("NAMECHEAP_API_USER")
+ api_key = os.environ.get("NAMECHEAP_API_KEY")
+ if api_user and api_key:
+ return api_user, api_key
+
+ if os.path.isfile(CONFIG_FILE):
+ with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
+ content = fh.read()
+ # File uses shell-style KEY="value" lines for backward compatibility.
+ pattern = re.compile(r'^\s*([A-Z_]+)\s*=\s*"?([^"\n]*)"?\s*$', re.MULTILINE)
+ values = {m.group(1): m.group(2) for m in pattern.finditer(content)}
+ api_user = api_user or values.get("NAMECHEAP_API_USER")
+ api_key = api_key or values.get("NAMECHEAP_API_KEY")
+
+ return api_user, api_key
+
+
+def check_credentials():
+ api_user, api_key = load_config()
+ if not api_user or not api_key:
+ err("Namecheap API credentials not configured.")
+ print()
+ print("Run 'python3 namecheap.py setup' to configure your credentials.")
+ print()
+ print("You need:")
+ print(" 1. Your Namecheap username")
+ print(" 2. An API key from: https://ap.www.namecheap.com/settings/tools/apiaccess/")
+ print(" 3. Your public IP whitelisted in the API settings")
+ sys.exit(1)
+ return api_user, api_key
+
+
+def save_config(api_user, api_key):
+ with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
+ fh.write(f'NAMECHEAP_API_USER="{api_user}"\n')
+ fh.write(f'NAMECHEAP_API_KEY="{api_key}"\n')
+ os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600
+
+
+# --- Networking -----------------------------------------------------------
+
+def _http_get(url, timeout=15):
+ req = urllib.request.Request(url, headers={"User-Agent": "namecheap-skill/1.0"})
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https only)
+ return resp.read().decode("utf-8", errors="replace")
+
+
+def get_public_ip():
+ """Resolve the public IP once and cache it for subsequent calls."""
+ global _public_ip
+ if _public_ip:
+ return _public_ip
+ for url in ("https://api.ipify.org", "https://ifconfig.me"):
+ try:
+ ip = _http_get(url, timeout=10).strip()
+ if ip:
+ _public_ip = ip
+ return ip
+ except Exception:
+ continue
+ _public_ip = "unknown"
+ return _public_ip
+
+
+def _strip_namespaces(root):
+ for elem in root.iter():
+ if isinstance(elem.tag, str) and "}" in elem.tag:
+ elem.tag = elem.tag.split("}", 1)[1]
+ return root
+
+
+def _check_error(root):
+ if (root.attrib.get("Status") or "").upper() == "ERROR":
+ messages = []
+ for e in root.iter("Err"):
+ code = e.attrib.get("Number") or e.attrib.get("Code") or ""
+ text = (e.text or "").strip()
+ messages.append(f"[{code}] {text}" if code else text)
+ raise NamecheapError("; ".join(m for m in messages if m) or "Unknown API error")
+
+
+def api_request(command, params=None):
+ """Issue a Namecheap API GET request and return the parsed (ns-stripped) root.
+
+ The API key is encoded into the request URL inside this process; it is never
+ passed as a command-line argument, so it cannot leak via ``ps`` or shell
+ history. Values are URL-encoded by ``urllib``.
+ """
+ api_user, api_key = check_credentials()
+ query = {
+ "ApiUser": api_user,
+ "ApiKey": api_key,
+ "UserName": api_user,
+ "Command": f"namecheap.{command}",
+ "ClientIp": get_public_ip(),
+ }
+ for key, value in (params or {}).items():
+ if value is not None and value != "":
+ query[key] = value
+
+ url = f"{API_URL}?{urllib.parse.urlencode(query)}"
+ body = _http_get(url, timeout=30)
+ root = _strip_namespaces(ET.fromstring(body))
+ _check_error(root)
+ return root
+
+
+def _attr(root, tag, name, default=""):
+ for elem in root.iter(tag):
+ return elem.attrib.get(name, default)
+ return default
+
+
+# --- Domain parsing -------------------------------------------------------
+
+def parse_domain(domain):
+ """Split a registered domain into (SLD, TLD), handling multi-part TLDs."""
+ domain = domain.strip().rstrip(".").lower()
+ labels = domain.split(".")
+ if len(labels) >= 3 and ".".join(labels[-2:]) in MULTI_PART_SUFFIXES:
+ tld = ".".join(labels[-2:])
+ sld = ".".join(labels[:-2])
+ elif len(labels) >= 2:
+ tld = labels[-1]
+ sld = ".".join(labels[:-1])
+ else:
+ tld = ""
+ sld = domain
+ return sld, tld
+
+
+# --- Commands -------------------------------------------------------------
+
+def cmd_public_ip(_args):
+ print(get_public_ip())
+
+
+def cmd_setup(_args):
+ print("=== Namecheap API Setup ===\n")
+ public_ip = get_public_ip()
+ info("Your public IP address is: " + _c("0;36", public_ip))
+ print()
+ print("Make sure this IP is whitelisted at:")
+ print(" https://ap.www.namecheap.com/settings/tools/apiaccess/")
+ print()
+
+ existing_user, existing_key = load_config()
+ if existing_user and existing_key:
+ info(f"Existing configuration found for user: {existing_user}")
+ print("\nTesting API connection...")
+ try:
+ api_request("domains.getList", {"PageSize": "1"})
+ success("API connection successful!")
+ except Exception as exc: # noqa: BLE001
+ err(f"API connection failed: {exc}")
+ print()
+ answer = input("Update stored credentials? [y/N]: ").strip().lower()
+ if answer not in ("y", "yes"):
+ info("Keeping existing credentials.")
+ return
+ print()
+
+ print("Enter your Namecheap credentials:\n")
+ api_user = input(" API Username: ").strip()
+ api_key = getpass.getpass(" API Key (hidden): ").strip()
+ print()
+
+ if not api_user or not api_key:
+ err("Both username and API key are required.")
+ sys.exit(1)
+
+ save_config(api_user, api_key)
+ success(f"Credentials saved to {CONFIG_FILE}")
+ print("\nTesting API connection...")
+ try:
+ # Use the just-entered credentials directly for the validation call.
+ os.environ["NAMECHEAP_API_USER"] = api_user
+ os.environ["NAMECHEAP_API_KEY"] = api_key
+ api_request("domains.getList", {"PageSize": "1"})
+ success("API connection successful!")
+ except Exception as exc: # noqa: BLE001
+ warn("API connection failed. Please verify:")
+ print(" 1. API access is enabled (ON) at the Namecheap settings page")
+ print(f" 2. IP address {public_ip} is whitelisted")
+ print(" 3. Your API key is correct")
+ print(f" (details: {exc})")
+
+
+def cmd_domains_list(args):
+ params = {"ListType": args.type, "Page": str(args.page), "PageSize": str(args.page_size)}
+ if args.search:
+ params["SearchTerm"] = args.search
+ info("Fetching domain list...")
+ root = api_request("domains.getList", params)
+
+ print()
+ print(f"{'DOMAIN':<30} {'EXPIRES':<12} {'LOCKED':<12} {'AUTO-RENEW':<10}")
+ print(f"{'------':<30} {'-------':<12} {'------':<12} {'----------':<10}")
+ for d in root.iter("Domain"):
+ print("{:<30} {:<12} {:<12} {:<10}".format(
+ d.attrib.get("Name", ""),
+ d.attrib.get("Expires", ""),
+ d.attrib.get("IsLocked", ""),
+ d.attrib.get("AutoRenew", ""),
+ ))
+ print()
+
+
+def _print_hosts(root):
+ print()
+ print(f"{'HOST':<20} {'TYPE':<8} {'ADDRESS':<40} {'TTL':<8} {'MXPREF':<6}")
+ print(f"{'----':<20} {'----':<8} {'-------':<40} {'---':<8} {'------':<6}")
+ for h in root.iter("host"):
+ print("{:<20} {:<8} {:<40} {:<8} {:<6}".format(
+ h.attrib.get("Name", ""),
+ h.attrib.get("Type", ""),
+ h.attrib.get("Address", ""),
+ h.attrib.get("TTL", "1800"),
+ h.attrib.get("MXPref", "-"),
+ ))
+ print()
+
+
+def cmd_dns_get_hosts(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Fetching DNS records for {args.domain} (SLD={sld}, TLD={tld})...")
+ root = api_request("domains.dns.getHosts", {"SLD": sld, "TLD": tld})
+ _print_hosts(root)
+
+
+def _existing_hosts(root):
+ """Return existing host records as a list of dicts."""
+ records = []
+ for h in root.iter("host"):
+ records.append({
+ "name": h.attrib.get("Name", ""),
+ "type": h.attrib.get("Type", ""),
+ "address": h.attrib.get("Address", ""),
+ "ttl": h.attrib.get("TTL", "1800"),
+ "mxpref": h.attrib.get("MXPref", ""),
+ })
+ return records
+
+
+def _hosts_to_params(sld, tld, records):
+ params = {"SLD": sld, "TLD": tld}
+ i = 1
+ for r in records:
+ if not (r["name"] and r["type"] and r["address"]):
+ continue
+ params[f"HostName{i}"] = r["name"]
+ params[f"RecordType{i}"] = r["type"]
+ params[f"Address{i}"] = r["address"]
+ params[f"TTL{i}"] = r.get("ttl") or "1800"
+ mxpref = r.get("mxpref")
+ if mxpref not in (None, ""):
+ # MX priority 0 is valid, so always send MXPref for MX records;
+ # for other record types only forward a non-zero value.
+ if r["type"].upper() == "MX" or mxpref != "0":
+ params[f"MXPref{i}"] = mxpref
+ i += 1
+ return params, i - 1
+
+
+def cmd_dns_set_hosts(args):
+ if not os.path.isfile(args.hosts):
+ err(f"Hosts file not found: {args.hosts}")
+ sys.exit(1)
+ with open(args.hosts, "r", encoding="utf-8") as fh:
+ raw = json.load(fh)
+
+ records = []
+ for r in raw:
+ records.append({
+ "name": r.get("HostName", ""),
+ "type": r.get("RecordType", ""),
+ "address": r.get("Address", ""),
+ "ttl": str(r.get("TTL", "") or ""),
+ "mxpref": str(r.get("MXPref", "") or ""),
+ })
+
+ sld, tld = parse_domain(args.domain)
+ params, count = _hosts_to_params(sld, tld, records)
+ if count == 0:
+ err(f"No valid host records found in {args.hosts}")
+ sys.exit(1)
+
+ info(f"Setting {count} DNS records for {args.domain}...")
+ root = api_request("domains.dns.setHosts", params)
+ if _attr(root, "DomainDNSSetHostsResult", "IsSuccess").lower() == "true":
+ success(f"DNS records updated successfully for {args.domain}!")
+ else:
+ err("Failed to update DNS records.")
+ sys.exit(1)
+
+
+def cmd_dns_add_host(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Fetching existing DNS records for {args.domain}...")
+ root = api_request("domains.dns.getHosts", {"SLD": sld, "TLD": tld})
+ records = _existing_hosts(root)
+ records.append({
+ "name": args.name,
+ "type": args.type.upper(),
+ "address": args.address,
+ "ttl": args.ttl,
+ "mxpref": args.mxpref or "",
+ })
+ params, _ = _hosts_to_params(sld, tld, records)
+
+ info(f"Adding {args.type.upper()} record: {args.name} -> {args.address}")
+ result = api_request("domains.dns.setHosts", params)
+ if _attr(result, "DomainDNSSetHostsResult", "IsSuccess").lower() == "true":
+ success(f"DNS record added: {args.name} {args.type} {args.address}")
+ else:
+ err("Failed to add DNS record.")
+ sys.exit(1)
+
+
+def cmd_dns_remove_host(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Fetching existing DNS records for {args.domain}...")
+ root = api_request("domains.dns.getHosts", {"SLD": sld, "TLD": tld})
+
+ kept = []
+ removed = False
+ for r in _existing_hosts(root):
+ if (not removed and r["name"] == args.name
+ and r["type"].upper() == args.type.upper()
+ and (not args.address or r["address"] == args.address)):
+ removed = True
+ info(f"Removing record: {r['name']} {r['type']} {r['address']}")
+ continue
+ kept.append(r)
+
+ if not removed:
+ err("No matching record found to remove.")
+ sys.exit(1)
+
+ params, count = _hosts_to_params(sld, tld, kept)
+ if count == 0:
+ err("Cannot remove the last DNS record. Namecheap requires at least one record.")
+ sys.exit(1)
+
+ info(f"Updating DNS records for {args.domain}...")
+ result = api_request("domains.dns.setHosts", params)
+ if _attr(result, "DomainDNSSetHostsResult", "IsSuccess").lower() == "true":
+ success("DNS record removed successfully!")
+ else:
+ err("Failed to remove DNS record.")
+ sys.exit(1)
+
+
+def cmd_dns_get_list(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Fetching nameservers for {args.domain}...")
+ root = api_request("domains.dns.getList", {"SLD": sld, "TLD": tld})
+
+ using = _attr(root, "DomainDNSGetListResult", "IsUsingOurDNS", "unknown")
+ print()
+ info(f"Using Namecheap DNS: {using}")
+ print("\nNameservers:")
+ for ns in root.iter("Nameserver"):
+ if ns.text:
+ print(f" - {ns.text.strip()}")
+ print()
+
+
+def cmd_dns_set_default(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Setting {args.domain} to use Namecheap default DNS...")
+ root = api_request("domains.dns.setDefault", {"SLD": sld, "TLD": tld})
+ if _attr(root, "DomainDNSSetDefaultResult", "Updated").lower() == "true":
+ success(f"Domain {args.domain} now uses Namecheap default DNS!")
+ else:
+ err("Failed to set default DNS.")
+ sys.exit(1)
+
+
+def cmd_dns_set_custom(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Setting {args.domain} to use custom nameservers: {args.nameservers}")
+ root = api_request(
+ "domains.dns.setCustom",
+ {"SLD": sld, "TLD": tld, "Nameservers": args.nameservers},
+ )
+ if _attr(root, "DomainDNSSetCustomResult", "Updated").lower() == "true":
+ success(f"Domain {args.domain} now uses custom nameservers!")
+ else:
+ err("Failed to set custom nameservers.")
+ sys.exit(1)
+
+
+def cmd_dns_get_email_forwarding(args):
+ info(f"Fetching email forwarding for {args.domain}...")
+ root = api_request("domains.dns.getEmailForwarding", {"DomainName": args.domain})
+
+ print()
+ print(f"{'MAILBOX':<20} {'FORWARDS TO':<40}")
+ print(f"{'-------':<20} {'-----------':<40}")
+ for fwd in root.iter("Forward"):
+ mailbox = (fwd.attrib.get("mailbox") or fwd.attrib.get("MailBox")
+ or fwd.attrib.get("Mailbox") or "")
+ forward_to = (fwd.attrib.get("ForwardTo") or fwd.attrib.get("forwardto")
+ or (fwd.text or "").strip())
+ print(f"{mailbox + '@' + args.domain:<20} {forward_to:<40}")
+ print()
+
+
+def cmd_dns_set_email_forwarding(args):
+ params = {"DomainName": args.domain}
+
+ if args.mailbox and args.forward_to:
+ params["MailBox1"] = args.mailbox
+ params["ForwardTo1"] = args.forward_to
+ elif args.forwards:
+ if not os.path.isfile(args.forwards):
+ err(f"Forwards file not found: {args.forwards}")
+ sys.exit(1)
+ with open(args.forwards, "r", encoding="utf-8") as fh:
+ rules = json.load(fh)
+ i = 1
+ for rule in rules:
+ mailbox = rule.get("MailBox") or rule.get("mailbox") or ""
+ forward_to = rule.get("ForwardTo") or rule.get("forwardto") or ""
+ if mailbox and forward_to:
+ params[f"MailBox{i}"] = mailbox
+ params[f"ForwardTo{i}"] = forward_to
+ i += 1
+ else:
+ err("Provide either --mailbox/--forward-to or --forwards ")
+ sys.exit(1)
+
+ info(f"Setting email forwarding for {args.domain}...")
+ root = api_request("domains.dns.setEmailForwarding", params)
+ if _attr(root, "DomainDNSSetEmailForwardingResult", "IsSuccess").lower() == "true":
+ success(f"Email forwarding updated for {args.domain}!")
+ else:
+ err("Failed to set email forwarding.")
+ sys.exit(1)
+
+
+def cmd_ns_create(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Creating nameserver {args.nameserver} -> {args.ip}...")
+ root = api_request(
+ "domains.ns.create",
+ {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver, "IP": args.ip},
+ )
+ if _attr(root, "DomainNSCreateResult", "IsSuccess").lower() == "true":
+ success(f"Nameserver {args.nameserver} created!")
+ else:
+ err("Failed to create nameserver.")
+ sys.exit(1)
+
+
+def cmd_ns_delete(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Deleting nameserver {args.nameserver}...")
+ root = api_request(
+ "domains.ns.delete",
+ {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver},
+ )
+ if _attr(root, "DomainNSDeleteResult", "IsSuccess").lower() == "true":
+ success(f"Nameserver {args.nameserver} deleted!")
+ else:
+ err("Failed to delete nameserver.")
+ sys.exit(1)
+
+
+def cmd_ns_get_info(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Fetching info for nameserver {args.nameserver}...")
+ root = api_request(
+ "domains.ns.getInfo",
+ {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver},
+ )
+ ns_ip = _attr(root, "DomainNSInfoResult", "IP", "unknown")
+ print()
+ print(f"Nameserver: {args.nameserver}")
+ print(f"IP Address: {ns_ip}")
+ statuses = [s.text.strip() for s in root.iter("Status") if s.text and s.text.strip()]
+ if statuses:
+ print(f"Status: {', '.join(statuses)}")
+ print()
+
+
+def cmd_ns_update(args):
+ sld, tld = parse_domain(args.domain)
+ info(f"Updating nameserver {args.nameserver}: {args.old_ip} -> {args.ip}...")
+ root = api_request(
+ "domains.ns.update",
+ {"SLD": sld, "TLD": tld, "Nameserver": args.nameserver,
+ "OldIP": args.old_ip, "IP": args.ip},
+ )
+ if _attr(root, "DomainNSUpdateResult", "IsSuccess").lower() == "true":
+ success(f"Nameserver {args.nameserver} updated to {args.ip}!")
+ else:
+ err("Failed to update nameserver.")
+ sys.exit(1)
+
+
+# --- Argument parsing -----------------------------------------------------
+
+def build_parser():
+ parser = argparse.ArgumentParser(
+ prog="namecheap.py",
+ description="Namecheap DNS Management CLI",
+ )
+ sub = parser.add_subparsers(dest="command", metavar="")
+
+ sub.add_parser("setup", help="Configure API credentials and test connection").set_defaults(func=cmd_setup)
+ sub.add_parser("public-ip", help="Show your public IP address").set_defaults(func=cmd_public_ip)
+
+ p = sub.add_parser("domains.getList", help="List your Namecheap domains")
+ p.add_argument("--type", default="ALL")
+ p.add_argument("--search", default="")
+ p.add_argument("--page", type=int, default=1)
+ p.add_argument("--page-size", type=int, default=20)
+ p.set_defaults(func=cmd_domains_list)
+
+ p = sub.add_parser("domains.dns.getList", help="Get nameservers for a domain")
+ p.add_argument("--domain", required=True)
+ p.set_defaults(func=cmd_dns_get_list)
+
+ p = sub.add_parser("domains.dns.getHosts", help="Get DNS records for a domain")
+ p.add_argument("--domain", required=True)
+ p.set_defaults(func=cmd_dns_get_hosts)
+
+ p = sub.add_parser("domains.dns.setHosts", help="Set all DNS records (from JSON file)")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--hosts", required=True)
+ p.set_defaults(func=cmd_dns_set_hosts)
+
+ p = sub.add_parser("domains.dns.setDefault", help="Use Namecheap default DNS")
+ p.add_argument("--domain", required=True)
+ p.set_defaults(func=cmd_dns_set_default)
+
+ p = sub.add_parser("domains.dns.setCustom", help="Use custom nameservers")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--nameservers", required=True)
+ p.set_defaults(func=cmd_dns_set_custom)
+
+ p = sub.add_parser("domains.dns.getEmailForwarding", help="Get email forwarding rules")
+ p.add_argument("--domain", required=True)
+ p.set_defaults(func=cmd_dns_get_email_forwarding)
+
+ p = sub.add_parser("domains.dns.setEmailForwarding", help="Set email forwarding rules")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--mailbox", default="")
+ p.add_argument("--forward-to", default="")
+ p.add_argument("--forwards", default="")
+ p.set_defaults(func=cmd_dns_set_email_forwarding)
+
+ p = sub.add_parser("domains.ns.create", help="Create a child nameserver (glue record)")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--nameserver", required=True)
+ p.add_argument("--ip", required=True)
+ p.set_defaults(func=cmd_ns_create)
+
+ p = sub.add_parser("domains.ns.delete", help="Delete a child nameserver")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--nameserver", required=True)
+ p.set_defaults(func=cmd_ns_delete)
+
+ p = sub.add_parser("domains.ns.getInfo", help="Get nameserver info")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--nameserver", required=True)
+ p.set_defaults(func=cmd_ns_get_info)
+
+ p = sub.add_parser("domains.ns.update", help="Update nameserver IP")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--nameserver", required=True)
+ p.add_argument("--old-ip", required=True)
+ p.add_argument("--ip", required=True)
+ p.set_defaults(func=cmd_ns_update)
+
+ p = sub.add_parser("dns.addHost", help="Add a single DNS record (preserves existing)")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--type", required=True)
+ p.add_argument("--name", required=True)
+ p.add_argument("--address", required=True)
+ p.add_argument("--ttl", default="1800")
+ p.add_argument("--mxpref", default="")
+ p.set_defaults(func=cmd_dns_add_host)
+
+ p = sub.add_parser("dns.removeHost", help="Remove a single DNS record")
+ p.add_argument("--domain", required=True)
+ p.add_argument("--type", required=True)
+ p.add_argument("--name", required=True)
+ p.add_argument("--address", default="")
+ p.set_defaults(func=cmd_dns_remove_host)
+
+ return parser
+
+
+def main(argv=None):
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if not getattr(args, "command", None):
+ parser.print_help()
+ return 1
+ try:
+ args.func(args)
+ except NamecheapError as exc:
+ err(f"API returned error: {exc}")
+ return 1
+ except urllib.error.URLError as exc:
+ err(f"Network error: {exc}")
+ return 1
+ except (OSError, ET.ParseError, json.JSONDecodeError) as exc:
+ err(str(exc))
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/namecheap/references/namecheap-api.md b/skills/namecheap/references/namecheap-api.md
new file mode 100644
index 000000000..312121f06
--- /dev/null
+++ b/skills/namecheap/references/namecheap-api.md
@@ -0,0 +1,392 @@
+# Namecheap API Reference
+
+## Base URL
+
+```
+https://api.namecheap.com/xml.response
+```
+
+## Authentication
+
+All requests require these common parameters:
+
+| Parameter | Description |
+|-----------|-------------|
+| `ApiUser` | Namecheap username |
+| `ApiKey` | API key from https://ap.www.namecheap.com/settings/tools/apiaccess/ |
+| `UserName` | Same as ApiUser |
+| `ClientIp` | The whitelisted public IP address of the client |
+| `Command` | The API command prefixed with `namecheap.` |
+
+## Setup Requirements
+
+1. Log in to Namecheap
+2. Go to https://ap.www.namecheap.com/settings/tools/apiaccess/
+3. Enable API Access (toggle to ON)
+4. Add the client's public IP address to the whitelist
+5. Copy the generated API key
+
+## Commands
+
+---
+
+### namecheap.domains.getList
+
+Lists all domains in the account.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `ListType` | No | `ALL` (default), `EXPIRING`, or `EXPIRED` |
+| `SearchTerm` | No | Keyword to filter domains |
+| `Page` | No | Page number (default: 1) |
+| `PageSize` | No | Results per page, 10-100 (default: 20) |
+| `SortBy` | No | `NAME`, `NAME_DESC`, `EXPIREDATE`, `EXPIREDATE_DESC`, `CREATEDATE`, `CREATEDATE_DESC` |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+ 5120
+
+
+```
+
+---
+
+### namecheap.domains.dns.getList
+
+Gets the list of DNS servers associated with a domain (shows whether it uses Namecheap DNS or custom nameservers).
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain (e.g., `example` for `example.com`) |
+| `TLD` | Yes | Top-level domain (e.g., `com` for `example.com`) |
+
+**Response XML:**
+
+```xml
+
+
+
+ dns1.registrar-servers.com
+ dns2.registrar-servers.com
+
+
+
+```
+
+---
+
+### namecheap.domains.dns.getHosts
+
+Gets DNS host records for a domain.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain (e.g., `example` for `example.com`) |
+| `TLD` | Yes | Top-level domain (e.g., `com` for `example.com`) |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.dns.setHosts
+
+Sets (replaces) all DNS host records for a domain.
+
+**IMPORTANT:** This command replaces ALL existing records. Always fetch existing records first.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+| `HostNameN` | Yes | Host name for record N (e.g., `@`, `www`, `mail`) |
+| `RecordTypeN` | Yes | Record type for record N (A, AAAA, CNAME, MX, TXT, etc.) |
+| `AddressN` | Yes | Value for record N (IP address or target hostname) |
+| `MXPrefN` | No | MX priority for record N (required for MX records) |
+| `TTLN` | No | TTL in seconds for record N (default: 1800) |
+
+Records are numbered starting from 1: `HostName1`, `RecordType1`, `Address1`, `HostName2`, `RecordType2`, `Address2`, etc.
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.dns.setDefault
+
+Sets a domain to use Namecheap's default DNS servers.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.dns.setCustom
+
+Sets a domain to use custom nameservers (e.g., Cloudflare, Route53).
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+| `Nameservers` | Yes | Comma-separated list of nameservers (max 12, no spaces) |
+
+**Example:** `Nameservers=ns1.cloudflare.com,ns2.cloudflare.com`
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.dns.getEmailForwarding
+
+Gets email forwarding settings for a domain.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `DomainName` | Yes | Full domain name (e.g., `example.com`) |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.dns.setEmailForwarding
+
+Sets email forwarding for a domain. Replaces all existing forwarding rules.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `DomainName` | Yes | Full domain name (e.g., `example.com`) |
+| `MailBoxN` | Yes | Mailbox name for rule N (e.g., `info`, `support`) |
+| `ForwardToN` | Yes | Destination email for rule N |
+
+Rules are numbered starting from 1: `MailBox1`, `ForwardTo1`, `MailBox2`, `ForwardTo2`, etc.
+Omitting all MailBox/ForwardTo parameters deletes all forwarding rules.
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.ns.create
+
+Creates a child nameserver (glue record) for a domain.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+| `Nameserver` | Yes | Nameserver hostname to create (e.g., `ns1.example.com`) |
+| `IP` | Yes | IP address for the nameserver |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.ns.delete
+
+Deletes a child nameserver.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+| `Nameserver` | Yes | Nameserver hostname to delete |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+---
+
+### namecheap.domains.ns.getInfo
+
+Gets information about a child nameserver.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+| `Nameserver` | Yes | Nameserver hostname to query |
+
+**Response XML:**
+
+```xml
+
+
+
+
+ OK
+
+
+
+
+```
+
+---
+
+### namecheap.domains.ns.update
+
+Updates the IP address of a child nameserver.
+
+**Additional Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `SLD` | Yes | Second-level domain |
+| `TLD` | Yes | Top-level domain |
+| `Nameserver` | Yes | Nameserver hostname to update |
+| `OldIP` | Yes | Current IP address of the nameserver |
+| `IP` | Yes | New IP address for the nameserver |
+
+**Response XML:**
+
+```xml
+
+
+
+
+
+```
+
+## Error Responses
+
+```xml
+
+
+ Domain not found
+
+
+```
+
+Common error codes:
+- `1011102` — Invalid API key
+- `1011148` — IP not whitelisted
+- `2019166` — Domain not found
+- `2016166` — Domain not using Namecheap DNS
+
+## Record Types
+
+| Type | Description | Address Format |
+|------|-------------|---------------|
+| `A` | IPv4 address | `1.2.3.4` |
+| `AAAA` | IPv6 address | `2001:db8::1` |
+| `CNAME` | Canonical name | `target.example.com.` |
+| `MX` | Mail exchange | `mail.example.com.` (requires MXPref) |
+| `MXE` | MX equivalent (IP) | `1.2.3.4` |
+| `TXT` | Text record | Any text value |
+| `URL` | URL redirect (unmasked) | `http://example.com` |
+| `URL301` | Permanent redirect | `http://example.com` |
+| `FRAME` | URL redirect (masked) | `http://example.com` |
+
+## TTL Values
+
+| Seconds | Human Readable |
+|---------|---------------|
+| 60 | 1 minute |
+| 300 | 5 minutes |
+| 1800 | 30 minutes (default) |
+| 3600 | 1 hour |
+| 14400 | 4 hours |
+| 43200 | 12 hours |
+| 86400 | 1 day |