From 218d4eb88479280f3e17ee22271fdac2203a0815 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 17:02:50 +0000 Subject: [PATCH] free-names: generating and checking as one command, because a pipe cannot be aliased MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate-names "..." | domainfree` has been the documented pairing since both existed, and it is the right shape in a shell. It is the wrong shape for a pit alias: an alias appends what you typed to the end of its expansion, so `/names "a desktop app"` against `generate-names -n 100 | domainfree` puts the description after `domainfree` rather than in front of the generator. The only workarounds store a shell function in a config file, which is the thing this repository exists to avoid — a command on PATH works from every caller with nothing sourced first. So the composition became a command and the alias stayed thin: `/names` → `free-names`. Not `/free-names`, because no alias may share a name with a command; a shell function beats PATH and the two would drift apart. Neither half changes. Vocabulary still comes from one small API call and is expanded locally, availability is still read from RDAP rather than inferred from DNS, and an indeterminate lookup is still never reported as available — the mistake in this command that costs somebody money. The default count is 100 where `generate-names` alone defaults to 1000. The generator prints a line per candidate; this one asks a rate-limited registry about every line it printed, so a thousand turns a ten-second command into a multi-minute one. `-n` is there for when a hundred is not enough. Composed in-process rather than by shelling out, so the join is testable without a key or a network: `generate` and `check` are injectable, and the tests cover only how the halves meet — every candidate is checked, the list of free names agrees with the counts beside it, an unknown never appears as available, and an empty generation does not make a pointless registry trip. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XGe9mWC6FvUjT6p1HCVjba --- README.md | 48 ++++++++++-- bin/free-names.ts | 161 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/free-names.ts | 105 ++++++++++++++++++++++++++ src/registry.ts | 6 ++ test/free-names.test.ts | 135 +++++++++++++++++++++++++++++++++ 6 files changed, 451 insertions(+), 6 deletions(-) create mode 100755 bin/free-names.ts create mode 100644 src/free-names.ts create mode 100644 test/free-names.test.ts diff --git a/README.md b/README.md index 84a4a10..686547d 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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 @@ -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` | @@ -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 @@ -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` | @@ -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: ``` diff --git a/bin/free-names.ts b/bin/free-names.ts new file mode 100755 index 0000000..53a0415 --- /dev/null +++ b/bin/free-names.ts @@ -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 "" + 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); + } +} diff --git a/package.json b/package.json index 37ca1ff..4a940e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/free-names.ts b/src/free-names.ts new file mode 100644 index 0000000..f50e174 --- /dev/null +++ b/src/free-names.ts @@ -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 { + 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, + }; +} diff --git a/src/registry.ts b/src/registry.ts index 3e2c1ff..c5273fa 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -36,6 +36,7 @@ const SUMMARIES: Record = { 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', @@ -177,6 +178,11 @@ export const PIT_ALIASES: Record = { 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', diff --git a/test/free-names.test.ts b/test/free-names.test.ts new file mode 100644 index 0000000..10410ea --- /dev/null +++ b/test/free-names.test.ts @@ -0,0 +1,135 @@ +/** + * free-names — generating and checking as one command. + * + * The two halves already have their own tests for what they each do, so what + * is worth testing here is only the join: that every candidate generated is a + * candidate checked, that the list of free names agrees with the counts beside + * it, and above all that a lookup which answered neither way is never reported + * as available. That last one is the whole point of the command — somebody + * acts on this output by trying to buy something. + */ + +import { describe, expect, it } from 'vitest'; +import type { Result } from '../src/domain-free.ts'; +import { DEFAULT_COUNT, freeNames } from '../src/free-names.ts'; + +const call = async () => '{"heads":["sync"],"modifiers":["swift"],"exemplars":["syncswift"]}'; + +/** A generator that returns exactly what you hand it. */ +const generating = (names: string[]) => async () => names; + +/** A checker that answers from a map, defaulting to taken. */ +const checking = (verdicts: Record) => async (names: readonly string[]) => + names.map((domain) => ({ + domain, + status: verdicts[domain] ?? ('taken' as const), + code: null, + })) as Result[]; + +describe('freeNames', () => { + it('checks every candidate the generator produced', async () => { + const seen: string[][] = []; + const report = await freeNames('a desktop app for fast transfers', call, {}, { + generate: generating(['one.com', 'two.com', 'three.com']), + check: async (names) => { + seen.push([...names]); + return checking({})(names); + }, + }); + + expect(seen).toEqual([['one.com', 'two.com', 'three.com']]); + expect(report.candidates).toEqual(['one.com', 'two.com', 'three.com']); + expect(report.checked).toBe(3); + }); + + it('returns only the names a registry says are free, sorted', async () => { + const report = await freeNames('a desktop app for fast transfers', call, {}, { + generate: generating(['zeta.com', 'alpha.com', 'beta.com']), + check: checking({ 'zeta.com': 'available', 'alpha.com': 'available' }), + }); + + expect(report.available).toEqual(['alpha.com', 'zeta.com']); + expect(report.taken).toBe(1); + }); + + it('never reports an indeterminate lookup as available', async () => { + // The failure that costs somebody real money: an unknown counted as free. + const report = await freeNames('a desktop app for fast transfers', call, {}, { + generate: generating(['maybe.com', 'sure.com']), + check: checking({ 'maybe.com': 'unknown', 'sure.com': 'available' }), + }); + + expect(report.available).toEqual(['sure.com']); + expect(report.unknown).toBe(1); + expect(report.available).not.toContain('maybe.com'); + }); + + it('keeps the list and the counts in agreement', async () => { + const report = await freeNames('a desktop app for fast transfers', call, {}, { + generate: generating(['a.com', 'b.com', 'c.com', 'd.com']), + check: checking({ 'a.com': 'available', 'b.com': 'available', 'c.com': 'unknown' }), + }); + + expect(report.available.length + report.taken + report.unknown).toBe(report.checked); + }); + + it('passes the generation options through', async () => { + let got: unknown; + await freeNames('a desktop app for fast transfers', call, { + count: 7, tld: 'dev', words: 1, seed: 42, + }, { + generate: async (_description, _call, options) => { + got = options; + return []; + }, + check: checking({}), + }); + + expect(got).toMatchObject({ count: 7, tld: 'dev', words: 1, seed: 42 }); + }); + + it('passes the lookup options through', async () => { + let got: unknown; + await freeNames('a desktop app for fast transfers', call, { jobs: 4, timeout: 1234 }, { + generate: generating(['one.com']), + check: async (_names, options) => { + got = options; + return []; + }, + }); + + expect(got).toMatchObject({ jobs: 4, timeout: 1234 }); + }); + + it('does not go to the registry when nothing was generated', async () => { + // A thin vocabulary is not an error, and checking nothing is a wasted trip. + let checked = false; + const report = await freeNames('a desktop app for fast transfers', call, {}, { + generate: generating([]), + check: async (names) => { + checked = true; + return checking({})(names); + }, + }); + + expect(checked).toBe(false); + expect(report.available).toEqual([]); + expect(report.checked).toBe(0); + }); + + it('defaults to fewer candidates than generate-names alone', async () => { + // Every candidate here costs a registry lookup, not a line of output. + expect(DEFAULT_COUNT).toBe(100); + + let got: { count?: number } | undefined; + await freeNames('a desktop app for fast transfers', call, {}, { + generate: async (_description, _call, options) => { + got = options; + return []; + }, + check: checking({}), + }); + + expect(got?.count).toBe(DEFAULT_COUNT); + }); +});