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
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ TypeScript, installed as executables on `PATH`.
| [`tcfeed`](#tcfeed) | Find repositories worth scanning, scan them, print a shortlist |
| [`domainjson`](#domainjson) | whois-style, JSON-first name lookup |
| [`domainfree`](#domainfree) | Which of these domains you can actually register |
| [`free-names`](#free-names) | Name ideas nobody has registered yet, in one command |
| [`blog-post`](#blog-post) | Publish to a plain-HTML blog without breaking the feed |
| [`ask-web`](#ask-web) | Answer a question from the live web, with its sources |
| [`tts`](#tts) | Read text aloud and keep the audio |
Expand Down Expand Up @@ -113,7 +114,7 @@ Check what landed, and wire up the pit aliases:
cli-tools list # * runs from here, ! is shadowed by another copy
cli-tools companions # the two from npm, and whether they are on PATH
cli-tools companions --install # install the missing ones (--force updates all)
cli-tools aliases --install # /aff /blog /free /merge /prs /speak /web /whois
cli-tools aliases --install # /aff /blog /free /merge /names /prs /speak /web /whois
cli-tools config # API keys: what is set, and where it came from
cli-tools update # git pull, reinstall, relink, update companions
cli-tools autoupdate --install # …or have a timer do that daily
Expand Down Expand Up @@ -196,8 +197,8 @@ ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on

## API keys

Four commands here call a paid API: `generate-names` (OpenAI or Anthropic),
`ask-web` (Perplexity) and `tts` (ElevenLabs). Store the keys once, and nothing
Five commands here call a paid API: `generate-names` and `free-names` (OpenAI
or Anthropic), `ask-web` (Perplexity) and `tts` (ElevenLabs). Store the keys once, and nothing
has to carry them in an environment again:

```sh
Expand Down Expand Up @@ -245,8 +246,8 @@ carries the same masked previews, not the values.

| Key | Variable | Used by |
| --- | --- | --- |
| `openai` | `OPENAI_API_KEY` | `generate-names` |
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` |
| `openai` | `OPENAI_API_KEY` | `generate-names`, `free-names` |
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names`, `free-names` |
| `perplexity` | `PERPLEXITY_API_KEY` | `ask-web` |
| `elevenlabs` | `ELEVENLABS_API_KEY` | `tts` |
| `porkbun` | `PORKBUN_API_KEY` | `porkbun` |
Expand Down Expand Up @@ -403,6 +404,34 @@ and are overridable with `--model`.

Names go to stdout and the summary to stderr, so the output pipes cleanly.

### `free-names`

`generate-names "..." | domainfree` as one command: describe the thing, get back
only the names nobody has registered.

```sh
free-names "a desktop app that syncs over rsync, --partial and --archive"
free-names "an open directory of independent blogs" -n 200 --words 1
free-names "a registry that checks Lean proofs" --tld dev --all
```

It exists because a pipe cannot be aliased. A moshcode pit alias appends what
you typed to the end of its expansion, so `/names "a desktop app"` against
`generate-names -n 100 | domainfree` would put the description *after*
`domainfree`. The only workarounds are a shell function stored in a config file,
which is the thing this repository exists to avoid — so the composition became a
command, and `/names` stays a thin alias pointing at it.

The default count is **100**, not `generate-names`' 1000: every candidate here
costs a registry lookup rather than a line of output, and a thousand RDAP
lookups against rate-limited servers turns a ten-second command into a
multi-minute one. Both halves behave exactly as they do separately — one small
API call for vocabulary, availability read from RDAP — and an indeterminate
lookup is never reported as available.

It takes the flags of both, with `--timeout` kept for the registry (matching
`domainfree`) and `--api-timeout` for the model.

### `domainfree`

Bulk domain availability, straight from the registry. Prints only the names you
Expand Down Expand Up @@ -1111,6 +1140,7 @@ without writing anything.
| `/blog` | `blog-post` |
| `/free` | `domainfree` |
| `/merge` | `gh-prs-merge --apply` |
| `/names` | `free-names` |
| `/prs` | `gh-prs` |
| `/speak` | `tts` |
| `/web` | `ask-web` |
Expand All @@ -1123,6 +1153,14 @@ shadow those programs *from inside the pit only*, which is about the most
confusing failure available. `/tts` would be worse — it would shadow our own
command. Hence `/web`, `/speak` and `/aff`.

`/names` is the one alias that exists because a pipe cannot be aliased at all.
An alias appends what you typed to the end of its expansion, so binding it to
`generate-names -n 100 | domainfree` would put your description after
`domainfree`. That is what [`free-names`](#free-names) is for: the composition
became a command, so the alias could stay thin. It is `/names` and not
`/free-names` because no alias may share a name with a command — a shell
function beats `PATH`, and the two would drift apart.

To manage them by hand:

```
Expand Down
161 changes: 161 additions & 0 deletions bin/free-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env -S npx --yes tsx
/**
* free-names — describe the thing, get names nobody has registered.
*
* `generate-names "..." | domainfree` in one command. The pipe is the right
* shape in a shell and the wrong one in a pit alias, which appends what you
* typed to the end of its expansion — so the description would land after the
* pipe instead of in front of the generator. A command on PATH takes it in the
* middle without a shell function in a config file.
*/

import { UsageError, integer, parseArgs } from '../src/args.ts';
import { resolveCredentials } from '../src/credentials.ts';
import { DEFAULT_JOBS, DEFAULT_TIMEOUT_MS } from '../src/domain-free.ts';
import { DEFAULT_COUNT, freeNames } from '../src/free-names.ts';
import {
DEFAULT_MODELS,
DEFAULT_TLD,
anthropicCaller,
openaiCaller,
resolveProvider,
} from '../src/generate-names.ts';
import { isMain } from '../src/is-main.ts';

const USAGE = `Usage:
free-names "<what the product does>"
free-names "a desktop app for fast rsync transfers" -n 200 --words 1

Generates candidate names from one small API call, then asks the registry which
of them can actually be registered. Equivalent to:

generate-names "..." | domainfree

Only available names go to stdout, so the output pipes and counts cleanly. The
summary goes to stderr.

Options:
-n, --count N candidates to generate and check (default: ${DEFAULT_COUNT})
--tld TLD extension to append (default: ${DEFAULT_TLD})
--words N 1 or 2 English words per name (default: 2)
--provider P openai | anthropic (default: whichever key is set)
--model M override the model (default: ${DEFAULT_MODELS.openai} / ${DEFAULT_MODELS.anthropic})
--seed N shuffle seed; the same seed reproduces the same candidates
-j, --jobs N parallel registry lookups (default: ${DEFAULT_JOBS})
-t, --timeout MS per-lookup timeout (default: ${DEFAULT_TIMEOUT_MS})
--api-timeout MS model timeout (default: 60000)
-a, --all print every candidate as "STATUS domain", not just the free
-q, --quiet suppress the summary
-h, --help show this help

The default count is lower than \`generate-names\` alone, because every
candidate here costs a registry lookup rather than a line of output.

Availability is read from RDAP, never inferred from DNS: a parked domain
resolves but is taken, and a registered domain with no nameservers returns
NXDOMAIN exactly like a free one. Exit status is 2 when any lookup stayed
indeterminate — an unknown is never reported as available.

Needs an OpenAI or Anthropic key:

cli-tools config set openai # prompts, nothing echoed or logged
`;

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

if (flags.has('-h') || flags.has('--help') || positional.length === 0) {
const asked = flags.has('-h') || flags.has('--help');
process[asked ? 'stdout' : 'stderr'].write(USAGE);
process.exit(asked ? 0 : 1);
}

const description = positional.join(' ').trim();
if (description.length < 8) {
throw new UsageError('describe the product in a sentence, not a word');
}

const count = integer(values, values.has('-n') ? '-n' : '--count', DEFAULT_COUNT, {
min: 1,
// Lower than generate-names' own ceiling on purpose: past this, the
// registry lookups are the whole cost and a person should be piping
// `generate-names` into `domainfree` themselves, with a file in between.
max: 5_000,
});
const seed = integer(values, '--seed', 1, { min: 0, max: 2 ** 31 });
const words = integer(values, '--words', 2, { min: 1, max: 2 }) as 1 | 2;
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 apiTimeout = integer(values, '--api-timeout', 60_000, { min: 1000, max: 600_000 });

const tld = (values.get('--tld') ?? DEFAULT_TLD).replace(/^\./, '');
if (!/^[a-z]{2,}$/i.test(tld)) throw new UsageError(`--tld must be letters, got "${tld}"`);

const credentials = resolveCredentials(process.env);
let provider;
try {
provider = resolveProvider(credentials, values.get('--provider'));
} catch (error) {
throw new UsageError(error instanceof Error ? error.message : String(error));
}
const model = values.get('--model') ?? DEFAULT_MODELS[provider];
const apiKey = credentials[provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']!;
const call =
provider === 'openai'
? openaiCaller(apiKey, model, apiTimeout)
: anthropicCaller(apiKey, model, apiTimeout);

const report = await freeNames(description, call, {
count, tld, words, seed, jobs, timeout,
});

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

if (!(flags.has('-q') || flags.has('--quiet'))) {
const parts = [
`${report.candidates.length} generated · ${provider}/${model}`,
`${report.checked} checked`,
`${report.available.length} available`,
`${report.taken} taken`,
];
if (report.unknown > 0) parts.push(`${report.unknown} unknown`);
process.stderr.write(`${parts.join(' · ')}\n`);
}

process.exit(report.unknown > 0 ? 2 : 0);
} catch (error) {
if (error instanceof UsageError) {
process.stderr.write(`free-names: ${error.message}\n`);
process.exit(1);
}
process.stderr.write(`free-names: ${error instanceof Error ? error.message : error}\n`);
process.exit(2);
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.14.0",
"version": "0.13.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
105 changes: 105 additions & 0 deletions src/free-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* free-names — the two halves of naming something, joined.
*
* `generate-names | domainfree` has been the documented pairing since both
* existed, and it is the right shape for a shell. It is the wrong shape for a
* pit alias: an alias appends what you typed to the end of its expansion, so
* the description would land after the pipe rather than where the generator
* wants it. Every workaround for that is a shell function stored in a config
* file, which is exactly the thing this repository exists to avoid — a command
* on PATH works from every caller without anything having been sourced.
*
* So the composition lives here instead, and the alias stays thin.
*
* Nothing about the two halves changes: names are still expanded locally from
* one small API call, and availability is still read from RDAP rather than
* inferred from DNS. This only removes the pipe.
*/

import { checkMany, summarize, type Result } from './domain-free.ts';
import { generateNames, type Caller } from './generate-names.ts';

/**
* A hundred, where `generate-names` alone defaults to a thousand.
*
* The generator prints; this one asks a registry about every line it printed.
* A thousand candidates is one cheap API call and then a thousand RDAP lookups
* against servers that rate-limit, which turns a ten-second command into a
* multi-minute one. A hundred is what fits in the time somebody will actually
* sit and wait, and `-n` is there for when it is not.
*/
export const DEFAULT_COUNT = 100;

export interface FreeNamesOptions {
count?: number;
tld?: string;
words?: 1 | 2;
seed?: number;
jobs?: number;
timeout?: number;
}

export interface FreeNamesDeps {
generate?: typeof generateNames;
check?: typeof checkMany;
}

export interface FreeNamesReport {
/** Every candidate the generator produced, in the order it produced them. */
candidates: string[];
/** The ones a registry says nobody holds, sorted. */
available: string[];
/** The raw per-name results, for a caller that wants to explain itself. */
results: Result[];
checked: number;
taken: number;
/**
* Lookups that neither confirmed nor denied. Kept separate from `taken` on
* purpose: an unknown must never be reported as available, because the cost
* of that mistake is somebody trying to buy a name that is not for sale.
*/
unknown: number;
}

/**
* Generate, then check, and hand back both halves of the answer.
*
* `generate` and `check` are injectable so the composition can be tested
* without an API key or a network — the two halves have their own tests for
* what they each do, and what is worth testing here is only how they join.
*/
export async function freeNames(
description: string,
call: Caller,
options: FreeNamesOptions = {},
deps: FreeNamesDeps = {},
): Promise<FreeNamesReport> {
const { generate = generateNames, check = checkMany } = deps;
const { count = DEFAULT_COUNT, tld, words, seed, jobs, timeout } = options;

const candidates = await generate(description, call, { count, tld, seed, words });

// An empty generation is not an error and must not become one: the model
// answered, the vocabulary was simply too thin to expand. Checking nothing
// against a registry would be a pointless round trip.
if (candidates.length === 0) {
return { candidates: [], available: [], results: [], checked: 0, taken: 0, unknown: 0 };
}

const results = await check(candidates, { jobs, timeout });
const { taken, unknown } = summarize(results);

return {
candidates,
// Filtered from the results rather than counted separately, so the list
// and the summary can never disagree about what "available" meant.
available: results
.filter((r) => r.status === 'available')
.map((r) => r.domain)
.sort((a, b) => a.localeCompare(b)),
results,
checked: results.length,
taken,
unknown,
};
}
6 changes: 6 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const SUMMARIES: Record<string, string> = {
domainfree: 'Which of these domains can you actually register',
domainjson: 'whois-style, JSON-first name lookup',
favicon: 'Every icon a site links, rendered from one SVG',
'free-names': 'Name ideas nobody has registered yet, in one command',
'generate-names': 'Turn a sentence about a product into a thousand candidate names',
genrewatch: 'What is coming out, and whether it exists at all',
img: 'Resize, convert and inspect images, with sharp or ImageMagick',
Expand Down Expand Up @@ -177,6 +178,11 @@ export const PIT_ALIASES: Record<string, string> = {
blog: 'blog-post',
free: 'domainfree',
merge: 'gh-prs-merge --apply',
// `names` rather than `free-names`, because an alias may not share a name
// with a command — and this is the one alias that exists because a pipe
// cannot be aliased at all: `/names "..."` would append the description
// after `| domainfree`, so `free-names` had to become a command first.
names: 'free-names',
prs: 'gh-prs',
speak: 'tts',
web: 'ask-web',
Expand Down
Loading
Loading