diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 065ba24..31f39a5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -17,7 +17,13 @@ "url": "https://profullstack.com" }, "homepage": "https://github.com/profullstack/cli-tools#install", - "keywords": ["cli", "install", "path", "aliases", "moshcode"] + "keywords": [ + "cli", + "install", + "path", + "aliases", + "moshcode" + ] }, { "name": "blog", @@ -39,7 +45,7 @@ }, { "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.", + "description": "Generate candidate product names from a sentence, find the ones 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": { @@ -53,7 +59,8 @@ "dns", "whois", "availability", - "naming" + "naming", + "llm" ] } ] diff --git a/README.md b/README.md index 51ef52d..a7ec3e5 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,39 @@ 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. +### `generate-names` + +Turn a sentence describing a product into a long list of candidate names, ready +to pipe into `domainfree`. + +```sh +generate-names "a registry that checks whether Lean proofs actually compile" +generate-names "a tool that finds dead states in agent graphs" -n 1000 --tld dev +generate-names "an open directory of independent blogs" | domainfree +``` + +**It asks the model for vocabulary, not for a thousand names.** One cheap call +returns ~40 head words and ~40 modifiers; the cross product is expanded locally +and shuffled. Asking a model for 1,000 names directly repeats itself within a +few hundred, drifts off-brief, and costs far more — and the call count here is +the same whether you ask for 10 names or 10,000. + +Needs `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. Whichever is set is used; +OpenAI wins if both are. Defaults are the cheap tier on each side +(`gpt-4.1-mini` / `claude-haiku-4-5`) and are overridable with `--model`. + +| Flag | Effect | +| --- | --- | +| `-n, --count N` | how many names to print, default 1000 | +| `--tld TLD` | extension to append, default `com` | +| `--words N` | 1 or 2 English words per name, default 2 | +| `--provider P` | `openai` or `anthropic`, default whichever key is set | +| `--model M` | override the model | +| `--seed N` | shuffle seed; the same seed reproduces the same list | +| `--timeout MS` | API timeout, default 60000 | + +Names go to stdout and the summary to stderr, so the output pipes cleanly. + ### `domainfree` Bulk domain availability, straight from the registry. Prints only the names you @@ -192,7 +225,8 @@ 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 +generate-names "a registry that checks Lean proofs" | domainfree --jobs 24 +printf '%s\n' sorry{check,lint,scan}.com | domainfree domainfree --all example.com # show TAKEN rows too ``` diff --git a/bin/domainfree.ts b/bin/domainfree.ts index 8bd1c03..723c518 100755 --- a/bin/domainfree.ts +++ b/bin/domainfree.ts @@ -21,7 +21,8 @@ import { const USAGE = `Usage: domainfree ... domainfree --file candidates.txt - generate-names | domainfree --jobs 24 + generate-names "a registry that checks Lean proofs" | domainfree + printf '%s\n' sorry{check,lint,scan}.com | domainfree Availability is read from RDAP, never inferred from DNS: a parked domain resolves but is taken, and a domain registered with no nameservers returns diff --git a/bin/generate-names.ts b/bin/generate-names.ts new file mode 100755 index 0000000..df6a199 --- /dev/null +++ b/bin/generate-names.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * generate-names — turn a sentence into a long list of candidate names. + * + * Pipes straight into `domainfree`, which is the point: + * + * generate-names "a registry that checks Lean proofs" | domainfree + */ + +import { UsageError, integer, parseArgs } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { + DEFAULT_COUNT, + DEFAULT_MODELS, + DEFAULT_TLD, + anthropicCaller, + generateNames, + openaiCaller, + resolveProvider, +} from '../src/generate-names.ts'; + +const USAGE = `Usage: + generate-names "" + generate-names "a registry that checks Lean proofs" | domainfree + +Asks a cheap model for naming vocabulary, then expands it locally into +candidates. One small API call regardless of --count: asking a model for a +thousand names directly repeats itself and costs far more. + +Options: + -n, --count N how many names to print (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 list + --timeout MS API timeout (default: 60000) + -h, --help show this help + +Needs OPENAI_API_KEY or ANTHROPIC_API_KEY. Names go to stdout and nothing +else does, so the output pipes cleanly. +`; + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['-h', '--help'], + string: [ + '-n', '--count', '--tld', '--words', '--provider', '--model', '--seed', '--timeout', + ], + }); + + if (flags.has('-h') || flags.has('--help') || positional.length === 0) { + process.stdout.write(USAGE); + process.exit(positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0); + } + + 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, + max: 20_000, + }); + const seed = integer(values, '--seed', 1, { min: 0, max: 2 ** 31 }); + const timeout = integer(values, '--timeout', 60_000, { min: 1000, max: 600_000 }); + const wordCount = integer(values, '--words', 2, { min: 1, max: 2 }) as 1 | 2; + const tld = (values.get('--tld') ?? DEFAULT_TLD).replace(/^\./, ''); + if (!/^[a-z]{2,}$/i.test(tld)) throw new UsageError(`--tld must be letters, got "${tld}"`); + + let provider; + try { + provider = resolveProvider(process.env, values.get('--provider')); + } catch (error) { + // A bad --provider is a typo and a missing key is a setup problem; both + // are the caller's to fix, so report them like any other usage error. + throw new UsageError(error instanceof Error ? error.message : String(error)); + } + const model = values.get('--model') ?? DEFAULT_MODELS[provider]; + const apiKey = process.env[provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY']!; + const call = + provider === 'openai' + ? openaiCaller(apiKey, model, timeout) + : anthropicCaller(apiKey, model, timeout); + + const names = await generateNames(description, call, { + count, + tld, + seed, + words: wordCount, + }); + + for (const name of names) process.stdout.write(`${name}\n`); + process.stderr.write(`${names.length} names · ${provider}/${model}\n`); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`generate-names: ${error.message}\n`); + process.exit(1); + } + process.stderr.write(`generate-names: ${error instanceof Error ? error.message : error}\n`); + process.exit(2); + } +} diff --git a/plugins/domain/.claude-plugin/plugin.json b/plugins/domain/.claude-plugin/plugin.json index a725898..828b0b0 100644 --- a/plugins/domain/.claude-plugin/plugin.json +++ b/plugins/domain/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$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.", + "description": "Generate candidate product names from a sentence, find the ones 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", @@ -9,5 +9,13 @@ }, "homepage": "https://github.com/profullstack/cli-tools#domainfree", "license": "MIT", - "keywords": ["domain", "rdap", "dns", "whois", "availability", "naming"] + "keywords": [ + "domain", + "rdap", + "dns", + "whois", + "availability", + "naming", + "llm" + ] } diff --git a/plugins/domain/README.md b/plugins/domain/README.md index 40a29b4..abe0938 100644 --- a/plugins/domain/README.md +++ b/plugins/domain/README.md @@ -5,6 +5,7 @@ than guessing from DNS. | Command | Does | | --- | --- | +| `/domain:names` | Turn a sentence about your product into a thousand candidates. | | `/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. | diff --git a/plugins/domain/commands/names.md b/plugins/domain/commands/names.md new file mode 100644 index 0000000..f18c646 --- /dev/null +++ b/plugins/domain/commands/names.md @@ -0,0 +1,53 @@ +--- +description: Turn a sentence about your product into a thousand candidate names. +allowed-tools: Bash(generate-names:*), Bash(domainfree:*), Write +--- + +## Task + +Generate candidate names from a description, then keep only the ones that can +actually be registered. + +```bash +generate-names "a registry that checks whether Lean proofs actually compile" | domainfree +``` + +Save the survivors when there are many: + +```bash +generate-names "a tool that finds dead states in agent graphs" -n 1000 \ + | domainfree > free.txt +``` + +## How it works, and why it matters + +The model is asked for **vocabulary**, not for a thousand names: roughly 40 +head words and 40 modifiers, expanded locally into the cross product and +shuffled. That is one cheap API call whether you want 10 names or 10,000. + +Asking a model for a thousand names directly is the obvious approach and the +wrong one — it repeats itself within a few hundred, drifts off the brief, and +costs far more for a worse list. + +## Choosing well from the output + +The generator is deliberately high-volume and low-precision; `domainfree` is +the filter, and you are the judge. Expect most combinations to be noise and a +handful to be good. Worth weighing: + +- Does the name say what the thing does, or only gesture at it? +- Would the intended audience recognise the vocabulary? Insider terms are an + asset with practitioners and a wall with everyone else. +- Read it aloud. If it needs spelling out, it will need spelling out forever. + +## Options worth knowing + +- `-n 1000` — how many to print. Default is already 1000. +- `--tld dev` — any extension, not just `.com`. +- `--words 1` — single-word names instead of two-word compounds. +- `--seed 42` — the same seed reproduces the same list from the same vocabulary. +- `--provider anthropic` — force a provider; by default it uses whichever of + `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` is set. + +Availability is checked by `/domain:free`, which reads the registry over RDAP +rather than guessing from DNS. Re-check immediately before buying. diff --git a/src/generate-names.ts b/src/generate-names.ts new file mode 100644 index 0000000..961af6e --- /dev/null +++ b/src/generate-names.ts @@ -0,0 +1,232 @@ +/** + * Turn a sentence describing a product into a long list of candidate names. + * + * The model is asked for *vocabulary*, not for a thousand names. Asking any + * model to emit 1,000 names directly goes repetitive within a few hundred, + * costs far more, and drifts off-brief; asking for ~40 head words and ~40 + * modifiers and expanding the cross product in code is one cheap call, has no + * duplicates by construction, and stays on theme. + * + * Output is bare names, one per line, so it pipes into `domainfree`. + */ + +export const DEFAULT_COUNT = 1000; +export const DEFAULT_TLD = 'com'; + +/** Cheap-tier default per provider. Overridable with --model. */ +export const DEFAULT_MODELS = { + openai: 'gpt-4.1-mini', + anthropic: 'claude-haiku-4-5', +} as const; + +export type Provider = keyof typeof DEFAULT_MODELS; + +export interface Vocabulary { + /** Nouns naming the thing itself: check, registry, proof, graph… */ + heads: string[]; + /** Words that pair in front of or behind a head: no, zero, lint, scan… */ + modifiers: string[]; + /** A handful the model liked enough to write out whole. */ + exemplars: string[]; +} + +/** + * Pick a provider from the environment. Explicit choice wins; otherwise + * whichever key is actually present, preferring OpenAI when both are. + */ +export function resolveProvider( + env: Record, + requested?: string, +): Provider { + if (requested) { + if (requested !== 'openai' && requested !== 'anthropic') { + throw new Error(`unknown provider: ${requested} (expected openai or anthropic)`); + } + const key = requested === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'; + if (!env[key]) throw new Error(`${requested} requested but ${key} is not set`); + return requested; + } + if (env['OPENAI_API_KEY']) return 'openai'; + if (env['ANTHROPIC_API_KEY']) return 'anthropic'; + throw new Error('set OPENAI_API_KEY or ANTHROPIC_API_KEY (or pass --provider)'); +} + +export function buildPrompt(description: string, words: 1 | 2): string { + const shape = + words === 2 + ? 'Two short English words joined without a space, like "sorrycheck" or "graphtrap".' + : 'One short English word, or a tight blend of two, like "proofdex".'; + + return `You are naming a software product. Here is what it does: + +${description} + +Return JSON only, no prose, matching exactly: + +{"heads": [...], "modifiers": [...], "exemplars": [...]} + +- "heads": 40 short, concrete English nouns naming the thing or what it acts on. +- "modifiers": 40 short English words that read naturally next to a head — verbs, + qualities, negations, or actions. No articles, no prepositions. +- "exemplars": 10 complete names you would actually pick. + +Rules: +- ${shape} +- Real English words only. No invented words, no misspellings, no numbers, no hyphens. +- All lowercase, 2-9 letters each. +- Prefer words a practitioner in this field would recognise over generic startup vocabulary. +- Avoid: hub, lab, ify, ly, sync, flow, stack, cloud, ai.`; +} + +/** Both providers speak plain HTTP; this repo has no runtime dependencies. */ +export type Caller = (body: string) => Promise; + +export function openaiCaller(apiKey: string, model: string, timeoutMs: number): Caller { + return async (prompt) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + signal: controller.signal, + headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: prompt }], + response_format: { type: 'json_object' }, + }), + }); + if (!response.ok) throw new Error(`openai ${response.status}: ${await response.text()}`); + const data = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + return data.choices?.[0]?.message?.content ?? ''; + } finally { + clearTimeout(timer); + } + }; +} + +export function anthropicCaller(apiKey: string, model: string, timeoutMs: number): Caller { + return async (prompt) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + signal: controller.signal, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model, + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!response.ok) throw new Error(`anthropic ${response.status}: ${await response.text()}`); + const data = (await response.json()) as { content?: { type: string; text?: string }[] }; + return data.content?.find((b) => b.type === 'text')?.text ?? ''; + } finally { + clearTimeout(timer); + } + }; +} + +// 2 letters minimum: "no", "up", "on" are among the most useful +// modifiers in this space (nosorry, noexit) and a 3-letter floor loses them. +const WORD = /^[a-z]{2,9}$/; + +/** Models wrap JSON in prose or fences often enough to be worth handling. */ +export function parseVocabulary(raw: string): Vocabulary { + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/); + const text = (fenced ? fenced[1]! : raw).trim(); + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start === -1 || end === -1) throw new Error('model returned no JSON object'); + + const parsed = JSON.parse(text.slice(start, end + 1)) as Partial; + const clean = (list: unknown): string[] => + Array.isArray(list) + ? [...new Set(list.map((w) => String(w).toLowerCase().trim()).filter((w) => WORD.test(w)))] + : []; + + const vocab = { + heads: clean(parsed.heads), + modifiers: clean(parsed.modifiers), + exemplars: clean(parsed.exemplars), + }; + if (vocab.heads.length === 0) throw new Error('model returned no usable head words'); + return vocab; +} + +/** Deterministic PRNG so a given seed reproduces a given list. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export interface ExpandOptions { + count?: number; + tld?: string; + seed?: number; + maxLength?: number; +} + +/** + * Expand vocabulary into candidate domains. Exemplars lead, then the shuffled + * cross product in both orders — shuffled so a truncated list is still varied + * rather than every name starting with the same word. + */ +export function expand(vocab: Vocabulary, options: ExpandOptions = {}): string[] { + const { count = DEFAULT_COUNT, tld = DEFAULT_TLD, seed = 1, maxLength = 14 } = options; + const suffix = `.${tld.replace(/^\./, '').toLowerCase()}`; + + const seen = new Set(); + const out: string[] = []; + const push = (base: string): void => { + if (base.length > maxLength || seen.has(base)) return; + seen.add(base); + out.push(base + suffix); + }; + + for (const name of vocab.exemplars) push(name); + + const pairs: string[] = []; + for (const head of vocab.heads) { + for (const modifier of vocab.modifiers) { + if (head === modifier) continue; + pairs.push(modifier + head); + pairs.push(head + modifier); + } + } + + const random = mulberry32(seed); + for (let i = pairs.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [pairs[i], pairs[j]] = [pairs[j]!, pairs[i]!]; + } + + for (const pair of pairs) { + if (out.length >= count) break; + push(pair); + } + + return out.slice(0, count); +} + +export async function generateNames( + description: string, + call: Caller, + options: ExpandOptions & { words?: 1 | 2 } = {}, +): Promise { + const raw = await call(buildPrompt(description, options.words ?? 2)); + return expand(parseVocabulary(raw), options); +} diff --git a/src/registry.ts b/src/registry.ts index 412d8bf..0d3ed6a 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -31,6 +31,7 @@ const SUMMARIES: Record = { 'cli-tools': 'This dispatcher: list, update and wire up the others', domainfree: 'Which of these domains can you actually register', domainjson: 'whois-style, JSON-first name lookup', + 'generate-names': 'Turn a sentence about a product into a thousand candidate names', 'gh-prs': 'Every open PR across the owners you name', 'gh-prs-fix-all': 'Repair the open scan PRs that are broken because of us', 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', diff --git a/test/generate-names.test.ts b/test/generate-names.test.ts new file mode 100644 index 0000000..02ce740 --- /dev/null +++ b/test/generate-names.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + type Vocabulary, + buildPrompt, + expand, + generateNames, + parseVocabulary, + resolveProvider, +} from '../src/generate-names.ts'; + +describe('resolveProvider', () => { + it('prefers OpenAI when both keys are set', () => { + expect(resolveProvider({ OPENAI_API_KEY: 'x', ANTHROPIC_API_KEY: 'y' })).toBe('openai'); + }); + + it('falls back to whichever key exists', () => { + expect(resolveProvider({ ANTHROPIC_API_KEY: 'y' })).toBe('anthropic'); + expect(resolveProvider({ OPENAI_API_KEY: 'x' })).toBe('openai'); + }); + + it('honours an explicit choice', () => { + expect(resolveProvider({ OPENAI_API_KEY: 'x', ANTHROPIC_API_KEY: 'y' }, 'anthropic')).toBe( + 'anthropic', + ); + }); + + it('refuses a provider whose key is missing, rather than silently switching', () => { + expect(() => resolveProvider({ OPENAI_API_KEY: 'x' }, 'anthropic')).toThrow( + /ANTHROPIC_API_KEY is not set/, + ); + }); + + it('rejects an unknown provider and says what is valid', () => { + expect(() => resolveProvider({ OPENAI_API_KEY: 'x' }, 'gemini')).toThrow(/expected openai or anthropic/); + }); + + it('explains what to set when there is no key at all', () => { + expect(() => resolveProvider({})).toThrow(/OPENAI_API_KEY or ANTHROPIC_API_KEY/); + }); +}); + +describe('buildPrompt', () => { + it('carries the description and asks for JSON only', () => { + const prompt = buildPrompt('a tool that finds dead states in agent graphs', 2); + expect(prompt).toContain('dead states in agent graphs'); + expect(prompt).toContain('Return JSON only'); + expect(prompt).toContain('Two short English words'); + }); + + it('switches shape for one-word names', () => { + expect(buildPrompt('x'.repeat(20), 1)).toContain('One short English word'); + }); +}); + +describe('parseVocabulary', () => { + const good = '{"heads":["check","proof"],"modifiers":["no","zero"],"exemplars":["nocheck"]}'; + + it('parses a bare object', () => { + expect(parseVocabulary(good).heads).toEqual(['check', 'proof']); + }); + + it('parses through a code fence, which models add unprompted', () => { + expect(parseVocabulary('```json\n' + good + '\n```').modifiers).toEqual(['no', 'zero']); + }); + + it('parses through surrounding prose', () => { + expect(parseVocabulary(`Sure! Here you go:\n${good}\nHope that helps.`).heads).toHaveLength(2); + }); + + it('drops words that are not usable as name parts', () => { + const messy = JSON.stringify({ + heads: ['check', 'a', 'waytoolongawordhere', 'CHECK', 'we-b', 'n0de', ''], + modifiers: ['zero'], + exemplars: [], + }); + // "CHECK" lowercases onto "check" and dedupes; the rest fail length or charset. + expect(parseVocabulary(messy).heads).toEqual(['check']); + }); + + it('throws when there is no JSON at all', () => { + expect(() => parseVocabulary('I cannot help with that.')).toThrow(/no JSON object/); + }); + + it('throws when nothing usable survives, rather than returning an empty list', () => { + expect(() => parseVocabulary('{"heads":["a"],"modifiers":[],"exemplars":[]}')).toThrow( + /no usable head words/, + ); + }); +}); + +const vocab: Vocabulary = { + heads: ['check', 'proof', 'graph', 'state'], + modifiers: ['no', 'zero', 'lint', 'scan'], + exemplars: ['sorrycheck', 'sinkstate'], +}; + +describe('expand', () => { + it('leads with the exemplars', () => { + const names = expand(vocab, { count: 5 }); + expect(names.slice(0, 2)).toEqual(['sorrycheck.com', 'sinkstate.com']); + }); + + it('appends the requested tld, with or without a leading dot', () => { + expect(expand(vocab, { count: 1, tld: 'dev' })[0]).toBe('sorrycheck.dev'); + expect(expand(vocab, { count: 1, tld: '.io' })[0]).toBe('sorrycheck.io'); + }); + + it('never repeats a name', () => { + const names = expand(vocab, { count: 500 }); + expect(new Set(names).size).toBe(names.length); + }); + + it('honours count exactly when supply allows', () => { + expect(expand(vocab, { count: 12 })).toHaveLength(12); + }); + + it('returns what it can when the vocabulary cannot fill the count', () => { + // 4x4 in both orders, minus collisions, plus 2 exemplars — far short of 1000. + const names = expand(vocab, { count: 1000 }); + expect(names.length).toBeGreaterThan(10); + expect(names.length).toBeLessThan(1000); + }); + + it('is deterministic for a seed, and different across seeds', () => { + expect(expand(vocab, { count: 20, seed: 7 })).toEqual(expand(vocab, { count: 20, seed: 7 })); + expect(expand(vocab, { count: 20, seed: 7 })).not.toEqual( + expand(vocab, { count: 20, seed: 8 }), + ); + }); + + it('drops names longer than the cap', () => { + const long: Vocabulary = { heads: ['characters'], modifiers: ['exceedingly'], exemplars: [] }; + expect(expand(long, { count: 10, maxLength: 14 })).toEqual([]); + }); + + it('scales to 1000 from a realistic 40x40 vocabulary', () => { + const many: Vocabulary = { + heads: Array.from({ length: 40 }, (_, i) => `head${i}`.padEnd(6, 'x').slice(0, 6)), + modifiers: Array.from({ length: 40 }, (_, i) => `mod${i}`.padEnd(5, 'y').slice(0, 5)), + exemplars: [], + }; + expect(expand(many, { count: 1000 })).toHaveLength(1000); + }); +}); + +describe('generateNames', () => { + it('sends one prompt and expands the reply — one call regardless of count', async () => { + let calls = 0; + const call = async (prompt: string) => { + calls += 1; + expect(prompt).toContain('a registry for proofs'); + return JSON.stringify(vocab); + }; + const names = await generateNames('a registry for proofs', call, { count: 10 }); + expect(calls).toBe(1); + expect(names).toHaveLength(10); + expect(names.every((n) => n.endsWith('.com'))).toBe(true); + }); +});