Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "cli-tools",
"description": "Profullstack's command-line tools as installable plugins publish to the plain-HTML blog without getting a convention wrong.",
"description": "Profullstack's command-line tools as installable plugins \u2014 publish to the plain-HTML blog without getting a convention wrong.",
"owner": {
"name": "profullstack",
"url": "https://profullstack.com"
Expand All @@ -17,7 +17,32 @@
"url": "https://profullstack.com"
},
"homepage": "https://github.com/profullstack/cli-tools#blog",
"keywords": ["blog", "rss", "feed", "publishing", "smolweb"]
"keywords": [
"blog",
"rss",
"feed",
"publishing",
"smolweb"
]
},
{
"name": "domain",
"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.",
"source": "./plugins/domain",
"category": "productivity",
"author": {
"name": "profullstack",
"url": "https://profullstack.com"
},
"homepage": "https://github.com/profullstack/cli-tools#domainfree",
"keywords": [
"domain",
"rdap",
"dns",
"whois",
"availability",
"naming"
]
}
]
}
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,49 @@ The scanner itself lives in the threatcrush checkout, so this is a launcher.
Point it elsewhere with `TCFEED_REPO`; every other `TCFEED_*` variable is read
by the script it launches and works unchanged.

### `domainfree`

Bulk domain availability, straight from the registry. Prints only the names you
can actually buy, one per line, so it pipes into anything.

```sh
domainfree sorrycheck.com sinkstate.com
domainfree --file candidates.txt
generate-names | domainfree --jobs 24
domainfree --all example.com # show TAKEN rows too
```

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` — identical to a
name nobody owns.

Measured over 8,513 generated candidates, the DNS shortcut
(`dig NAME | grep "ANSWER: 0"`) reported 20 registered domains as free while
missing none that were genuinely free. `oubliette.com` is the instructive one:
registered in 1996, paid through 2034, three nameservers, no `A` record — so
`dig` says `ANSWER: 0` and it reads as available. Fine as a cheap prefilter,
useless as a buy signal.

Lookups run through a fixed-size pool (16 by default; about 8,500 names in 45
seconds). Anything indeterminate — a 429, a 5xx, a timeout — is retried once
and then reported as `ERR:<code>`, never as available, and the exit status is
`2` so an unknown cannot be mistaken for a free name.

| Flag | Effect |
| --- | --- |
| `-f, --file FILE` | read names from FILE, one per line (`-` for stdin) |
| `-j, --jobs N` | parallel lookups, default 16 |
| `-t, --timeout MS` | per-lookup timeout, default 20000 |
| `-a, --all` | print every name as `STATUS domain`, not just the free ones |
| `-q, --quiet` | suppress the summary, which is written to stderr |

The summary goes to stderr and the names to stdout, so `domainfree -f in.txt |
wc -l` counts what you can buy. For a deep look at one name rather than a
verdict across thousands, use `domainjson`.

### `domainjson`

One JSON object on stdout: `{ name, rdap | moshpit, dns }`.
Expand Down
124 changes: 124 additions & 0 deletions bin/domainfree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env -S npx --yes tsx
/**
* domainfree — bulk domain availability, straight from the registry.
*
* Prints only the names that can actually be registered, one per line, so the
* output pipes into anything. Companion to `domainjson`, which looks one name
* up in depth; this answers one question across thousands.
*/

import { readFile } from 'node:fs/promises';
import { UsageError, integer, parseArgs } from '../src/args.ts';
import { isMain } from '../src/is-main.ts';
import {
DEFAULT_JOBS,
DEFAULT_TIMEOUT_MS,
checkMany,
normalizeNames,
summarize,
} from '../src/domain-free.ts';

const USAGE = `Usage:
domainfree <name>...
domainfree --file candidates.txt
generate-names | domainfree --jobs 24

Availability is read from RDAP, never inferred from DNS: a parked domain
resolves but is taken, and a domain registered with no nameservers returns
NXDOMAIN exactly like a free one.

Options:
-f, --file FILE read names from FILE, one per line ("-" for stdin)
-j, --jobs N parallel lookups (default: ${DEFAULT_JOBS})
-t, --timeout MS per-lookup timeout (default: ${DEFAULT_TIMEOUT_MS})
-a, --all print every name as "STATUS domain", not just the free ones
-q, --quiet suppress the summary, which goes to stderr
-h, --help show this help

Only available names go to stdout, so \`domainfree -f in.txt | wc -l\` counts
what you can buy. Exit status is 2 when any lookup stayed indeterminate — an
unknown is never reported as available.
`;

async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf8');
}

if (isMain(import.meta.url)) {
try {
const { flags, values, positional } = parseArgs(process.argv.slice(2), {
boolean: ['-a', '--all', '-q', '--quiet', '-h', '--help'],
string: ['-f', '--file', '-j', '--jobs', '-t', '--timeout'],
});

if (flags.has('-h') || flags.has('--help')) {
process.stdout.write(USAGE);
process.exit(0);
}

const showAll = flags.has('-a') || flags.has('--all');
const quiet = flags.has('-q') || flags.has('--quiet');
const jobs = integer(values, values.has('-j') ? '-j' : '--jobs', DEFAULT_JOBS, {
min: 1,
max: 128,
});
const timeout = integer(
values,
values.has('-t') ? '-t' : '--timeout',
DEFAULT_TIMEOUT_MS,
{ min: 100, max: 120_000 },
);

const file = values.get('-f') ?? values.get('--file');
let raw: string;
if (file) {
raw = file === '-' ? await readStdin() : await readFile(file, 'utf8');
} else if (positional.length > 0) {
raw = positional.join('\n');
} else if (!process.stdin.isTTY) {
raw = await readStdin();
} else {
process.stderr.write(USAGE);
process.exit(1);
}

const names = normalizeNames(raw);
if (names.length === 0) {
process.stderr.write('domainfree: no valid domain names given\n');
process.exit(1);
}

const results = await checkMany(names, { jobs, timeout });

for (const result of results.slice().sort((a, b) => a.domain.localeCompare(b.domain))) {
if (showAll) {
const label =
result.status === 'available'
? 'AVAILABLE'
: result.status === 'taken'
? 'TAKEN'
: `ERR:${result.code ?? 'timeout'}`;
process.stdout.write(`${label} ${result.domain}\n`);
} else if (result.status === 'available') {
process.stdout.write(`${result.domain}\n`);
}
}

const { available, taken, unknown } = summarize(results);
if (!quiet) {
const parts = [`${names.length} checked`, `${available} available`, `${taken} taken`];
if (unknown > 0) parts.push(`${unknown} unknown`);
process.stderr.write(`${parts.join(' · ')}\n`);
}

process.exit(unknown > 0 ? 2 : 0);
} catch (error) {
if (error instanceof UsageError) {
process.stderr.write(`domainfree: ${error.message}\n`);
process.exit(1);
}
throw error;
}
}
13 changes: 13 additions & 0 deletions plugins/domain/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "domain",
"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.",
"version": "0.1.0",
"author": {
"name": "profullstack",
"url": "https://profullstack.com"
},
"homepage": "https://github.com/profullstack/cli-tools#domainfree",
"license": "MIT",
"keywords": ["domain", "rdap", "dns", "whois", "availability", "naming"]
}
36 changes: 36 additions & 0 deletions plugins/domain/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# domain

Two commands for working with domain names, both reading the registry rather
than guessing from DNS.

| Command | Does |
| --- | --- |
| `/domain:free` | Filter a list of names down to the ones you can actually register. |
| `/domain:lookup` | Everything about one name — RDAP record, dates, nameservers, DNS, reverse PTR — as JSON. |

## Install

```sh
moshcode plugin marketplace add profullstack/cli-tools
moshcode plugin install domain@cli-tools
```

Both commands shell out to tools from this repo, so they need it installed and
linked:

```sh
pnpm install && pnpm link:bin
```

## Why RDAP and not dig

DNS cannot distinguish registration from configuration. A parked domain
resolves and is taken; a domain registered with no nameservers returns
`NXDOMAIN`, exactly like a name nobody owns.

Over 8,513 generated candidates, `dig NAME | grep "ANSWER: 0"` reported 20
registered domains as free and missed none that were genuinely free.
`oubliette.com` is the clearest case — registered in 1996, paid through 2034,
three nameservers, no `A` record.

Good enough as a cheap prefilter. Wrong as a buy signal.
68 changes: 68 additions & 0 deletions plugins/domain/commands/free.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
description: Check which domains are actually registerable — registry truth, not a DNS guess.
allowed-tools: Bash(domainfree:*), Read, Write
---

## Task

Find out which of a set of domain names can actually be registered.

```bash
domainfree sorrycheck.com sinkstate.com
domainfree --file candidates.txt
domainfree --all example.com # show TAKEN rows too
```

Only available names go to stdout, one per line, so the output pipes straight
into anything:

```bash
domainfree --file candidates.txt | head -20
domainfree --file candidates.txt | wc -l # how many you could buy
```

## Naming a new project

Generate candidates first, then filter. The generation is the creative part;
this command is only the filter, and it is fast enough to be used on thousands
of names at a time — roughly 8,500 in 45 seconds at the default concurrency.

```bash
printf '%s\n' proofcheck.com sorrycheck.com qedcheck.com axiomcheck.com \
| domainfree
```

## Why not just use dig

Because DNS cannot tell registration apart from configuration, and will hand
you names you cannot buy:

- A **parked** domain resolves fine and is taken.
- A domain registered with **no nameservers** returns `NXDOMAIN` — exactly what
a name nobody owns returns.

Measured over 8,513 generated candidates, `dig NAME | grep "ANSWER: 0"` called
**20 registered domains free** while missing none that were genuinely free.
`oubliette.com` is the one to remember: registered in 1996, paid through 2034,
three nameservers, no `A` record — so `dig` reports `ANSWER: 0` and it reads as
available.

So DNS is a fine cheap prefilter and a bad buy signal. `domainfree` reads RDAP,
which is the registry's own record.

## Reading the result

An answer is only ever `AVAILABLE`, `TAKEN`, or `ERR:<code>`. A rate limit, a
5xx or a timeout is retried once and then reported as `ERR` — **never** as
available, because a name reported free that is not is the one failure that
wastes real time. Exit status is `2` if anything stayed indeterminate, so this
is usable as a gate.

## Before buying

Availability is a moment in time. Re-check immediately before registering, and
remember that if the project is already public under that name, the name is
worth securing sooner rather than later.

For everything about one name — RDAP record, registration and expiry dates,
nameservers, DNS records, reverse PTR — use `/domain:lookup` instead.
51 changes: 51 additions & 0 deletions plugins/domain/commands/lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
description: Everything about one name — RDAP record, dates, nameservers, DNS, reverse PTR — as JSON.
allowed-tools: Bash(domainjson:*), Read
---

## Task

Look one name up in depth. Output is a single JSON object, so it pipes into
`jq` without reshaping.

```bash
domainjson example.com
domainjson --timeout 8000 test.hacker
domainjson -s https://rdap.nic.cz -t domain example.cz
```

```json
{ "name": "...", "rdap": { ... }, "dns": { "records": {}, "hosts": [], "reverse": [], "axfr": [] } }
```

## What you get

- **`rdap`** — the registry's own record: status flags, registration, expiry
and last-changed dates, nameservers. This is where "is it actually
registered?" is answered, and it is the reason a name with no DNS is still
clearly taken.
- **`dns`** — `A`, `AAAA`, `CNAME`, `MX`, `TXT` and `NS` queried one type at a
time (never `ANY`), plus reverse PTR for every resolved address and an AXFR
attempt against each nameserver. A refused transfer is reported, never fatal.

Names ending in a [Moshpit](https://pit.moshcode.sh) TLD skip RDAP and are
served from the registry API under a `moshpit` key instead.

## Useful reads

```bash
# When does it expire, and who runs its DNS?
domainjson example.com | jq '.rdap.events, .rdap.nameservers'

# Registered, but is anything actually served?
domainjson example.com | jq '{status: .rdap.status, hosts: .dns.hosts}'
```

## Notes

Errors are JSON too — a tool whose output gets parsed should not change shape
on failure. If every data source fails, the exit status is non-zero and the
object carries an `error` key.

To check many names for availability rather than inspect one, use
`/domain:free`.
Loading
Loading