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); + }); +});