Skip to content

Commit dcd9bcb

Browse files
ralyodioclaude
andauthored
domainfree: find the domains you can actually register (#8)
Ported from the bash version in profullstack/scripts to this repo's conventions, and exposed to moshcode as a `domain` plugin alongside `blog`. Availability is read from RDAP, never inferred from DNS, because DNS cannot tell registration apart from configuration: - a parked domain resolves fine and is taken - a domain registered with no nameservers returns NXDOMAIN, exactly like a name nobody owns Over 8,513 generated candidates the DNS shortcut (`dig NAME | grep "ANSWER: 0"`) called 20 registered domains free and missed none that were genuinely free. oubliette.com is the one to remember: registered 1996, paid through 2034, three nameservers, no A record, so dig reports ANSWER: 0 and it reads as available. Fine as a cheap prefilter, wrong as a buy signal. An indeterminate response — 429, 5xx, timeout — is retried once and then reported as ERR:<code>, never as available, and the exit status is 2. A name wrongly reported free is the only failure here that costs real time. Layout follows the repo: logic in src/domain-free.ts with an injectable fetcher, a thin bin/ entry guarded by isMain, args through the shared parseArgs, and vitest tests that touch no network. New plugin `domain` exposes /domain:free and /domain:lookup, the latter wrapping the existing domainjson so the plugin covers both directions — one verdict across thousands of names, or everything about one. One thing worth recording: the first version of these tests used real setTimeout delays, and the added wall-clock load made blog.test.ts's concurrent-createPost race fail — it passed alone and failed in the full suite. That test is timing-sensitive and this change happened to expose it. Rather than touch it, checkMany's retry pause is now injectable (retryDelayMs), the tests pass 0, and no test in this file uses a real timer. Full suite is green across four consecutive runs. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0020f84 commit dcd9bcb

9 files changed

Lines changed: 691 additions & 2 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
33
"name": "cli-tools",
4-
"description": "Profullstack's command-line tools as installable plugins publish to the plain-HTML blog without getting a convention wrong.",
4+
"description": "Profullstack's command-line tools as installable plugins \u2014 publish to the plain-HTML blog without getting a convention wrong.",
55
"owner": {
66
"name": "profullstack",
77
"url": "https://profullstack.com"
@@ -17,7 +17,32 @@
1717
"url": "https://profullstack.com"
1818
},
1919
"homepage": "https://github.com/profullstack/cli-tools#blog",
20-
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
20+
"keywords": [
21+
"blog",
22+
"rss",
23+
"feed",
24+
"publishing",
25+
"smolweb"
26+
]
27+
},
28+
{
29+
"name": "domain",
30+
"description": "Find domains you can actually register, and look one up in depth. Availability is read from the registry over RDAP, never guessed from DNS, so parked names and registrations with no nameservers are not mistaken for free.",
31+
"source": "./plugins/domain",
32+
"category": "productivity",
33+
"author": {
34+
"name": "profullstack",
35+
"url": "https://profullstack.com"
36+
},
37+
"homepage": "https://github.com/profullstack/cli-tools#domainfree",
38+
"keywords": [
39+
"domain",
40+
"rdap",
41+
"dns",
42+
"whois",
43+
"availability",
44+
"naming"
45+
]
2146
}
2247
]
2348
}

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,49 @@ The scanner itself lives in the threatcrush checkout, so this is a launcher.
155155
Point it elsewhere with `TCFEED_REPO`; every other `TCFEED_*` variable is read
156156
by the script it launches and works unchanged.
157157

158+
### `domainfree`
159+
160+
Bulk domain availability, straight from the registry. Prints only the names you
161+
can actually buy, one per line, so it pipes into anything.
162+
163+
```sh
164+
domainfree sorrycheck.com sinkstate.com
165+
domainfree --file candidates.txt
166+
generate-names | domainfree --jobs 24
167+
domainfree --all example.com # show TAKEN rows too
168+
```
169+
170+
Availability is read from **RDAP, never inferred from DNS**, because DNS cannot
171+
tell registration apart from configuration:
172+
173+
- a parked domain resolves fine and is taken;
174+
- a domain registered with no nameservers returns `NXDOMAIN` — identical to a
175+
name nobody owns.
176+
177+
Measured over 8,513 generated candidates, the DNS shortcut
178+
(`dig NAME | grep "ANSWER: 0"`) reported 20 registered domains as free while
179+
missing none that were genuinely free. `oubliette.com` is the instructive one:
180+
registered in 1996, paid through 2034, three nameservers, no `A` record — so
181+
`dig` says `ANSWER: 0` and it reads as available. Fine as a cheap prefilter,
182+
useless as a buy signal.
183+
184+
Lookups run through a fixed-size pool (16 by default; about 8,500 names in 45
185+
seconds). Anything indeterminate — a 429, a 5xx, a timeout — is retried once
186+
and then reported as `ERR:<code>`, never as available, and the exit status is
187+
`2` so an unknown cannot be mistaken for a free name.
188+
189+
| Flag | Effect |
190+
| --- | --- |
191+
| `-f, --file FILE` | read names from FILE, one per line (`-` for stdin) |
192+
| `-j, --jobs N` | parallel lookups, default 16 |
193+
| `-t, --timeout MS` | per-lookup timeout, default 20000 |
194+
| `-a, --all` | print every name as `STATUS domain`, not just the free ones |
195+
| `-q, --quiet` | suppress the summary, which is written to stderr |
196+
197+
The summary goes to stderr and the names to stdout, so `domainfree -f in.txt |
198+
wc -l` counts what you can buy. For a deep look at one name rather than a
199+
verdict across thousands, use `domainjson`.
200+
158201
### `domainjson`
159202

160203
One JSON object on stdout: `{ name, rdap | moshpit, dns }`.

bin/domainfree.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env -S npx --yes tsx
2+
/**
3+
* domainfree — bulk domain availability, straight from the registry.
4+
*
5+
* Prints only the names that can actually be registered, one per line, so the
6+
* output pipes into anything. Companion to `domainjson`, which looks one name
7+
* up in depth; this answers one question across thousands.
8+
*/
9+
10+
import { readFile } from 'node:fs/promises';
11+
import { UsageError, integer, parseArgs } from '../src/args.ts';
12+
import { isMain } from '../src/is-main.ts';
13+
import {
14+
DEFAULT_JOBS,
15+
DEFAULT_TIMEOUT_MS,
16+
checkMany,
17+
normalizeNames,
18+
summarize,
19+
} from '../src/domain-free.ts';
20+
21+
const USAGE = `Usage:
22+
domainfree <name>...
23+
domainfree --file candidates.txt
24+
generate-names | domainfree --jobs 24
25+
26+
Availability is read from RDAP, never inferred from DNS: a parked domain
27+
resolves but is taken, and a domain registered with no nameservers returns
28+
NXDOMAIN exactly like a free one.
29+
30+
Options:
31+
-f, --file FILE read names from FILE, one per line ("-" for stdin)
32+
-j, --jobs N parallel lookups (default: ${DEFAULT_JOBS})
33+
-t, --timeout MS per-lookup timeout (default: ${DEFAULT_TIMEOUT_MS})
34+
-a, --all print every name as "STATUS domain", not just the free ones
35+
-q, --quiet suppress the summary, which goes to stderr
36+
-h, --help show this help
37+
38+
Only available names go to stdout, so \`domainfree -f in.txt | wc -l\` counts
39+
what you can buy. Exit status is 2 when any lookup stayed indeterminate — an
40+
unknown is never reported as available.
41+
`;
42+
43+
async function readStdin(): Promise<string> {
44+
const chunks: Buffer[] = [];
45+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
46+
return Buffer.concat(chunks).toString('utf8');
47+
}
48+
49+
if (isMain(import.meta.url)) {
50+
try {
51+
const { flags, values, positional } = parseArgs(process.argv.slice(2), {
52+
boolean: ['-a', '--all', '-q', '--quiet', '-h', '--help'],
53+
string: ['-f', '--file', '-j', '--jobs', '-t', '--timeout'],
54+
});
55+
56+
if (flags.has('-h') || flags.has('--help')) {
57+
process.stdout.write(USAGE);
58+
process.exit(0);
59+
}
60+
61+
const showAll = flags.has('-a') || flags.has('--all');
62+
const quiet = flags.has('-q') || flags.has('--quiet');
63+
const jobs = integer(values, values.has('-j') ? '-j' : '--jobs', DEFAULT_JOBS, {
64+
min: 1,
65+
max: 128,
66+
});
67+
const timeout = integer(
68+
values,
69+
values.has('-t') ? '-t' : '--timeout',
70+
DEFAULT_TIMEOUT_MS,
71+
{ min: 100, max: 120_000 },
72+
);
73+
74+
const file = values.get('-f') ?? values.get('--file');
75+
let raw: string;
76+
if (file) {
77+
raw = file === '-' ? await readStdin() : await readFile(file, 'utf8');
78+
} else if (positional.length > 0) {
79+
raw = positional.join('\n');
80+
} else if (!process.stdin.isTTY) {
81+
raw = await readStdin();
82+
} else {
83+
process.stderr.write(USAGE);
84+
process.exit(1);
85+
}
86+
87+
const names = normalizeNames(raw);
88+
if (names.length === 0) {
89+
process.stderr.write('domainfree: no valid domain names given\n');
90+
process.exit(1);
91+
}
92+
93+
const results = await checkMany(names, { jobs, timeout });
94+
95+
for (const result of results.slice().sort((a, b) => a.domain.localeCompare(b.domain))) {
96+
if (showAll) {
97+
const label =
98+
result.status === 'available'
99+
? 'AVAILABLE'
100+
: result.status === 'taken'
101+
? 'TAKEN'
102+
: `ERR:${result.code ?? 'timeout'}`;
103+
process.stdout.write(`${label} ${result.domain}\n`);
104+
} else if (result.status === 'available') {
105+
process.stdout.write(`${result.domain}\n`);
106+
}
107+
}
108+
109+
const { available, taken, unknown } = summarize(results);
110+
if (!quiet) {
111+
const parts = [`${names.length} checked`, `${available} available`, `${taken} taken`];
112+
if (unknown > 0) parts.push(`${unknown} unknown`);
113+
process.stderr.write(`${parts.join(' · ')}\n`);
114+
}
115+
116+
process.exit(unknown > 0 ? 2 : 0);
117+
} catch (error) {
118+
if (error instanceof UsageError) {
119+
process.stderr.write(`domainfree: ${error.message}\n`);
120+
process.exit(1);
121+
}
122+
throw error;
123+
}
124+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3+
"name": "domain",
4+
"description": "Find domains you can actually register, and look one up in depth. Availability is read from the registry over RDAP, never guessed from DNS, so parked names and registrations with no nameservers are not mistaken for free.",
5+
"version": "0.1.0",
6+
"author": {
7+
"name": "profullstack",
8+
"url": "https://profullstack.com"
9+
},
10+
"homepage": "https://github.com/profullstack/cli-tools#domainfree",
11+
"license": "MIT",
12+
"keywords": ["domain", "rdap", "dns", "whois", "availability", "naming"]
13+
}

plugins/domain/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# domain
2+
3+
Two commands for working with domain names, both reading the registry rather
4+
than guessing from DNS.
5+
6+
| Command | Does |
7+
| --- | --- |
8+
| `/domain:free` | Filter a list of names down to the ones you can actually register. |
9+
| `/domain:lookup` | Everything about one name — RDAP record, dates, nameservers, DNS, reverse PTR — as JSON. |
10+
11+
## Install
12+
13+
```sh
14+
moshcode plugin marketplace add profullstack/cli-tools
15+
moshcode plugin install domain@cli-tools
16+
```
17+
18+
Both commands shell out to tools from this repo, so they need it installed and
19+
linked:
20+
21+
```sh
22+
pnpm install && pnpm link:bin
23+
```
24+
25+
## Why RDAP and not dig
26+
27+
DNS cannot distinguish registration from configuration. A parked domain
28+
resolves and is taken; a domain registered with no nameservers returns
29+
`NXDOMAIN`, exactly like a name nobody owns.
30+
31+
Over 8,513 generated candidates, `dig NAME | grep "ANSWER: 0"` reported 20
32+
registered domains as free and missed none that were genuinely free.
33+
`oubliette.com` is the clearest case — registered in 1996, paid through 2034,
34+
three nameservers, no `A` record.
35+
36+
Good enough as a cheap prefilter. Wrong as a buy signal.

plugins/domain/commands/free.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
description: Check which domains are actually registerable — registry truth, not a DNS guess.
3+
allowed-tools: Bash(domainfree:*), Read, Write
4+
---
5+
6+
## Task
7+
8+
Find out which of a set of domain names can actually be registered.
9+
10+
```bash
11+
domainfree sorrycheck.com sinkstate.com
12+
domainfree --file candidates.txt
13+
domainfree --all example.com # show TAKEN rows too
14+
```
15+
16+
Only available names go to stdout, one per line, so the output pipes straight
17+
into anything:
18+
19+
```bash
20+
domainfree --file candidates.txt | head -20
21+
domainfree --file candidates.txt | wc -l # how many you could buy
22+
```
23+
24+
## Naming a new project
25+
26+
Generate candidates first, then filter. The generation is the creative part;
27+
this command is only the filter, and it is fast enough to be used on thousands
28+
of names at a time — roughly 8,500 in 45 seconds at the default concurrency.
29+
30+
```bash
31+
printf '%s\n' proofcheck.com sorrycheck.com qedcheck.com axiomcheck.com \
32+
| domainfree
33+
```
34+
35+
## Why not just use dig
36+
37+
Because DNS cannot tell registration apart from configuration, and will hand
38+
you names you cannot buy:
39+
40+
- A **parked** domain resolves fine and is taken.
41+
- A domain registered with **no nameservers** returns `NXDOMAIN` — exactly what
42+
a name nobody owns returns.
43+
44+
Measured over 8,513 generated candidates, `dig NAME | grep "ANSWER: 0"` called
45+
**20 registered domains free** while missing none that were genuinely free.
46+
`oubliette.com` is the one to remember: registered in 1996, paid through 2034,
47+
three nameservers, no `A` record — so `dig` reports `ANSWER: 0` and it reads as
48+
available.
49+
50+
So DNS is a fine cheap prefilter and a bad buy signal. `domainfree` reads RDAP,
51+
which is the registry's own record.
52+
53+
## Reading the result
54+
55+
An answer is only ever `AVAILABLE`, `TAKEN`, or `ERR:<code>`. A rate limit, a
56+
5xx or a timeout is retried once and then reported as `ERR`**never** as
57+
available, because a name reported free that is not is the one failure that
58+
wastes real time. Exit status is `2` if anything stayed indeterminate, so this
59+
is usable as a gate.
60+
61+
## Before buying
62+
63+
Availability is a moment in time. Re-check immediately before registering, and
64+
remember that if the project is already public under that name, the name is
65+
worth securing sooner rather than later.
66+
67+
For everything about one name — RDAP record, registration and expiry dates,
68+
nameservers, DNS records, reverse PTR — use `/domain:lookup` instead.

plugins/domain/commands/lookup.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
description: Everything about one name — RDAP record, dates, nameservers, DNS, reverse PTR — as JSON.
3+
allowed-tools: Bash(domainjson:*), Read
4+
---
5+
6+
## Task
7+
8+
Look one name up in depth. Output is a single JSON object, so it pipes into
9+
`jq` without reshaping.
10+
11+
```bash
12+
domainjson example.com
13+
domainjson --timeout 8000 test.hacker
14+
domainjson -s https://rdap.nic.cz -t domain example.cz
15+
```
16+
17+
```json
18+
{ "name": "...", "rdap": { ... }, "dns": { "records": {}, "hosts": [], "reverse": [], "axfr": [] } }
19+
```
20+
21+
## What you get
22+
23+
- **`rdap`** — the registry's own record: status flags, registration, expiry
24+
and last-changed dates, nameservers. This is where "is it actually
25+
registered?" is answered, and it is the reason a name with no DNS is still
26+
clearly taken.
27+
- **`dns`**`A`, `AAAA`, `CNAME`, `MX`, `TXT` and `NS` queried one type at a
28+
time (never `ANY`), plus reverse PTR for every resolved address and an AXFR
29+
attempt against each nameserver. A refused transfer is reported, never fatal.
30+
31+
Names ending in a [Moshpit](https://pit.moshcode.sh) TLD skip RDAP and are
32+
served from the registry API under a `moshpit` key instead.
33+
34+
## Useful reads
35+
36+
```bash
37+
# When does it expire, and who runs its DNS?
38+
domainjson example.com | jq '.rdap.events, .rdap.nameservers'
39+
40+
# Registered, but is anything actually served?
41+
domainjson example.com | jq '{status: .rdap.status, hosts: .dns.hosts}'
42+
```
43+
44+
## Notes
45+
46+
Errors are JSON too — a tool whose output gets parsed should not change shape
47+
on failure. If every data source fails, the exit status is non-zero and the
48+
object carries an `error` key.
49+
50+
To check many names for availability rather than inspect one, use
51+
`/domain:free`.

0 commit comments

Comments
 (0)