@namingsignal/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/core/batch.js +142 -0
- package/dist/core/brief-constraints.js +72 -0
- package/dist/core/brief.js +402 -0
- package/dist/core/candidate-sort.js +47 -0
- package/dist/core/candidates.js +210 -0
- package/dist/core/domain.js +607 -0
- package/dist/core/editorial-review.js +27 -0
- package/dist/core/export.js +235 -0
- package/dist/core/import-safety.js +298 -0
- package/dist/core/index.js +31 -0
- package/dist/core/namesake.js +53 -0
- package/dist/core/namespaces.js +295 -0
- package/dist/core/normalize.js +75 -0
- package/dist/core/scoring.js +409 -0
- package/dist/core/types.js +3 -0
- package/dist/core/url-input.js +28 -0
- package/dist/core/usage.js +271 -0
- package/dist/index.js +642 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const promises_1 = require("node:fs/promises");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const index_1 = require("./core/index");
|
|
7
|
+
const VERSION = "0.2.0";
|
|
8
|
+
const DEFAULT_API_URL = "https://namingsignal.com";
|
|
9
|
+
// Flags that never consume a following positional argument (they may still
|
|
10
|
+
// take an explicit literal "true"/"false" or an inline --flag=value form).
|
|
11
|
+
const BOOLEAN_FLAGS = new Set(["namespaces", "json", "dry-run", "help", "version"]);
|
|
12
|
+
const MAX_FILE_BYTES = 100_000;
|
|
13
|
+
const MAX_TOTAL_BYTES = 250_000;
|
|
14
|
+
const MAX_CONTEXT_FILES = 10;
|
|
15
|
+
const ALLOWED_FILES = [
|
|
16
|
+
/^readme(?:\.[a-z0-9]+)?$/i,
|
|
17
|
+
/^claude\.md$/i,
|
|
18
|
+
/^agents\.md$/i,
|
|
19
|
+
/^goal\.md$/i,
|
|
20
|
+
/^package\.json$/i,
|
|
21
|
+
/^pyproject\.toml$/i,
|
|
22
|
+
/^cargo\.toml$/i,
|
|
23
|
+
/^go\.mod$/i,
|
|
24
|
+
/^app\.json$/i,
|
|
25
|
+
/^manifest\.json$/i,
|
|
26
|
+
];
|
|
27
|
+
const ALLOWED_DOC_EXTENSIONS = new Set([".md", ".txt"]);
|
|
28
|
+
const DENIED_SEGMENTS = new Set([
|
|
29
|
+
".git",
|
|
30
|
+
".env",
|
|
31
|
+
".next",
|
|
32
|
+
".output",
|
|
33
|
+
".turbo",
|
|
34
|
+
".wrangler",
|
|
35
|
+
"build",
|
|
36
|
+
"coverage",
|
|
37
|
+
"dist",
|
|
38
|
+
"node_modules",
|
|
39
|
+
"target",
|
|
40
|
+
"vendor",
|
|
41
|
+
]);
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const positional = [];
|
|
44
|
+
const flags = {};
|
|
45
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
46
|
+
const token = argv[index];
|
|
47
|
+
if (!token.startsWith("--")) {
|
|
48
|
+
positional.push(token);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const [rawKey, inline] = token.slice(2).split("=", 2);
|
|
52
|
+
if (inline !== undefined) {
|
|
53
|
+
flags[rawKey] = inline;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const next = argv[index + 1];
|
|
57
|
+
if (BOOLEAN_FLAGS.has(rawKey)) {
|
|
58
|
+
// Boolean flags never swallow a positional: `check alpha --namespaces beta`
|
|
59
|
+
// must treat beta as a second name, not as the flag's value.
|
|
60
|
+
if (next === "true" || next === "false") {
|
|
61
|
+
flags[rawKey] = next;
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
flags[rawKey] = true;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (next && !next.startsWith("--")) {
|
|
70
|
+
flags[rawKey] = next;
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
flags[rawKey] = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { positional, flags };
|
|
78
|
+
}
|
|
79
|
+
function flagString(flags, key, fallback = "") {
|
|
80
|
+
const value = flags[key];
|
|
81
|
+
return typeof value === "string" ? value : fallback;
|
|
82
|
+
}
|
|
83
|
+
function flagBoolean(flags, key) {
|
|
84
|
+
return flags[key] === true || flags[key] === "true";
|
|
85
|
+
}
|
|
86
|
+
function withExitCode(error, exitCode) {
|
|
87
|
+
error.exitCode = exitCode;
|
|
88
|
+
return error;
|
|
89
|
+
}
|
|
90
|
+
function usageError(message) {
|
|
91
|
+
return withExitCode(new Error(message), 2);
|
|
92
|
+
}
|
|
93
|
+
function exitCodeForStatus(status) {
|
|
94
|
+
if (status === 401 || status === 403)
|
|
95
|
+
return 3;
|
|
96
|
+
if (status === 402 || status === 429)
|
|
97
|
+
return 4;
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
function printHelp() {
|
|
101
|
+
process.stdout.write(`NamingSignal CLI ${VERSION}
|
|
102
|
+
|
|
103
|
+
Evidence-backed product naming for developer agents.
|
|
104
|
+
|
|
105
|
+
USAGE
|
|
106
|
+
namingsignal <command> [arguments] [options]
|
|
107
|
+
|
|
108
|
+
COMMANDS
|
|
109
|
+
check <name...> Score names and run live domain checks
|
|
110
|
+
brief <path|file> Compile a privacy-bounded project brief locally
|
|
111
|
+
generate --brief <file> Generate deterministic candidate veins from a brief
|
|
112
|
+
research <name...> Ask a running NamingSignal API for finalist research
|
|
113
|
+
compare <name...> Produce a portable decision record
|
|
114
|
+
health Inspect a running API's provider readiness
|
|
115
|
+
|
|
116
|
+
CHECK OPTIONS
|
|
117
|
+
--tlds com,io,dev Domain extensions (default: com,io,dev,app)
|
|
118
|
+
--namespaces Also check npm, PyPI, GitHub, and Apple
|
|
119
|
+
--category developer-tool Context for transparent scoring
|
|
120
|
+
--json Machine-readable JSON (recommended for agents)
|
|
121
|
+
|
|
122
|
+
BRIEF OPTIONS
|
|
123
|
+
--out naming-brief.json Write the brief instead of stdout
|
|
124
|
+
--include docs/overview.md Explicitly add one safe text file
|
|
125
|
+
--dry-run List selected/excluded files without compiling
|
|
126
|
+
|
|
127
|
+
API OPTIONS
|
|
128
|
+
--api <url> Base URL (default: ${DEFAULT_API_URL};
|
|
129
|
+
env NAMING_SIGNAL_API_URL overrides, for
|
|
130
|
+
example http://localhost:3000 for local dev)
|
|
131
|
+
NAMING_SIGNAL_API_KEY User key for hosted API calls (never commit it)
|
|
132
|
+
--format json|md Export format
|
|
133
|
+
|
|
134
|
+
COMPARE OPTIONS
|
|
135
|
+
--brief naming-brief.json Use the approved strategic brief when scoring
|
|
136
|
+
--evidence check-output.json Reuse prior check, research, or run evidence
|
|
137
|
+
|
|
138
|
+
EXIT CODES
|
|
139
|
+
0 success 1 unexpected or network error 2 usage error
|
|
140
|
+
3 auth error (401/403) 4 quota or rate limit (402/429)
|
|
141
|
+
|
|
142
|
+
AGENT GUIDES
|
|
143
|
+
https://namingsignal.com/agent-guide.txt Hosted workflow guide
|
|
144
|
+
https://namingsignal.com/openapi.json REST discovery contract
|
|
145
|
+
https://github.com/moekoelueker/namingsignal/blob/main/docs/agent-workflow.md
|
|
146
|
+
Complete AI workflow and evidence rules
|
|
147
|
+
|
|
148
|
+
PRIVACY
|
|
149
|
+
Repository inspection is local and allowlisted. It excludes source code,
|
|
150
|
+
dependencies, .env files, keys, credentials, lockfiles, and generated output.
|
|
151
|
+
Use --dry-run to see the exact selected files before producing a brief.
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
function isDeniedPath(path) {
|
|
155
|
+
return path.split(/[\\/]/).some((segment) => DENIED_SEGMENTS.has(segment) || segment.startsWith(".env"));
|
|
156
|
+
}
|
|
157
|
+
function isAllowedFile(path, explicit = false) {
|
|
158
|
+
const name = (0, node_path_1.basename)(path);
|
|
159
|
+
if (isDeniedPath(path))
|
|
160
|
+
return false;
|
|
161
|
+
if (ALLOWED_FILES.some((pattern) => pattern.test(name)))
|
|
162
|
+
return true;
|
|
163
|
+
return explicit && ALLOWED_DOC_EXTENSIONS.has((0, node_path_1.extname)(name).toLowerCase());
|
|
164
|
+
}
|
|
165
|
+
function documentPriority(path) {
|
|
166
|
+
const normalized = path.replaceAll("\\", "/").toLowerCase();
|
|
167
|
+
const name = (0, node_path_1.basename)(normalized);
|
|
168
|
+
if (name === "agents.md")
|
|
169
|
+
return 0;
|
|
170
|
+
if (name === "claude.md")
|
|
171
|
+
return 1;
|
|
172
|
+
if (/^goal(?:\d+)?\.md$/.test(name))
|
|
173
|
+
return 2;
|
|
174
|
+
if (/product[-_ ]strategy|positioning|product[-_ ]brief/.test(normalized))
|
|
175
|
+
return 3;
|
|
176
|
+
if (/^readme(?:\.|$)/.test(name))
|
|
177
|
+
return 4;
|
|
178
|
+
if (/\b(?:spec|current-focus|overview)\.md$/.test(normalized))
|
|
179
|
+
return 5;
|
|
180
|
+
if (/competitive|app-store|naming/.test(normalized))
|
|
181
|
+
return 7;
|
|
182
|
+
if (normalized.includes("/docs/"))
|
|
183
|
+
return 8;
|
|
184
|
+
return 6;
|
|
185
|
+
}
|
|
186
|
+
async function declaredContextFiles(root, governingFiles) {
|
|
187
|
+
const declared = [];
|
|
188
|
+
for (const governing of governingFiles) {
|
|
189
|
+
const text = await (0, promises_1.readFile)(governing, "utf8");
|
|
190
|
+
const precedence = text.match(/##\s+Documents and precedence\b([\s\S]*?)(?=\n##\s|$)/i)?.[0] ?? "";
|
|
191
|
+
const preamble = text.slice(0, Math.max(0, text.search(/##\s+Documents and precedence\b/i)) || Math.min(text.length, 2_000));
|
|
192
|
+
const governingContext = `${precedence}\n${preamble}`;
|
|
193
|
+
const references = [
|
|
194
|
+
...governingContext.matchAll(/`([^`\n]+\.(?:md|txt))`/gi),
|
|
195
|
+
...governingContext.matchAll(/\*\*([^*\n]+\.(?:md|txt))\*\*/gi),
|
|
196
|
+
...governingContext.matchAll(/\b((?:docs\/)?[A-Za-z0-9][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_.-]+)*\.(?:md|txt))\b/gi),
|
|
197
|
+
].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
|
|
198
|
+
for (const match of references) {
|
|
199
|
+
const reference = match[1]?.trim().replace(/^\.\//, "");
|
|
200
|
+
if (!reference || reference.includes(".."))
|
|
201
|
+
continue;
|
|
202
|
+
const path = (0, node_path_1.resolve)(root, reference);
|
|
203
|
+
if (!path.startsWith(`${(0, node_path_1.resolve)(root)}/`) || !isAllowedFile(path, true))
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
if ((await (0, promises_1.stat)(path)).isFile() && !declared.includes(path))
|
|
207
|
+
declared.push(path);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// A stale declaration is shown by the absence of a selected file; it
|
|
211
|
+
// must never make the scanner leave the repository boundary.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return declared;
|
|
216
|
+
}
|
|
217
|
+
async function discoverContextFiles(root, explicit) {
|
|
218
|
+
let selected = [];
|
|
219
|
+
const excluded = [];
|
|
220
|
+
const rootStat = await (0, promises_1.stat)(root);
|
|
221
|
+
if (rootStat.isFile()) {
|
|
222
|
+
if (!isAllowedFile(root, true))
|
|
223
|
+
throw usageError(`Unsupported input file: ${root}`);
|
|
224
|
+
return { selected: [root], excluded };
|
|
225
|
+
}
|
|
226
|
+
const candidates = [];
|
|
227
|
+
const governing = [];
|
|
228
|
+
const top = (await (0, promises_1.readdir)(root, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
229
|
+
for (const entry of top) {
|
|
230
|
+
const path = (0, node_path_1.join)(root, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
if (DENIED_SEGMENTS.has(entry.name) || entry.name.startsWith(".")) {
|
|
233
|
+
excluded.push({ path, reason: "excluded directory" });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (entry.name.toLowerCase() === "docs") {
|
|
237
|
+
const docs = (await (0, promises_1.readdir)(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
238
|
+
for (const doc of docs) {
|
|
239
|
+
const docPath = (0, node_path_1.join)(path, doc.name);
|
|
240
|
+
if (doc.isFile() && isAllowedFile(docPath, true))
|
|
241
|
+
candidates.push(docPath);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (isAllowedFile(path)) {
|
|
247
|
+
candidates.push(path);
|
|
248
|
+
if (/^(?:agents|claude)\.md$/i.test(entry.name))
|
|
249
|
+
governing.push(path);
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
excluded.push({ path, reason: "not in context allowlist" });
|
|
253
|
+
}
|
|
254
|
+
const declared = await declaredContextFiles(root, governing);
|
|
255
|
+
const declaredOrder = new Map(declared.map((path, index) => [path, index]));
|
|
256
|
+
selected = [...new Set([...governing, ...declared, ...candidates])]
|
|
257
|
+
.sort((left, right) => {
|
|
258
|
+
const leftDeclared = declaredOrder.get(left);
|
|
259
|
+
const rightDeclared = declaredOrder.get(right);
|
|
260
|
+
if (leftDeclared !== undefined || rightDeclared !== undefined) {
|
|
261
|
+
if (leftDeclared === undefined)
|
|
262
|
+
return 1;
|
|
263
|
+
if (rightDeclared === undefined)
|
|
264
|
+
return -1;
|
|
265
|
+
return leftDeclared - rightDeclared;
|
|
266
|
+
}
|
|
267
|
+
return documentPriority(left) - documentPriority(right) || left.localeCompare(right);
|
|
268
|
+
});
|
|
269
|
+
if (explicit) {
|
|
270
|
+
const explicitPath = (0, node_path_1.resolve)(root, explicit);
|
|
271
|
+
const rootPrefix = `${(0, node_path_1.resolve)(root)}/`;
|
|
272
|
+
if (!explicitPath.startsWith(rootPrefix))
|
|
273
|
+
throw usageError("--include must stay inside the project root");
|
|
274
|
+
if (!isAllowedFile(explicitPath, true))
|
|
275
|
+
throw usageError("--include accepts only safe Markdown or text files");
|
|
276
|
+
if (!selected.includes(explicitPath))
|
|
277
|
+
selected.unshift(explicitPath);
|
|
278
|
+
}
|
|
279
|
+
if (selected.length > MAX_CONTEXT_FILES) {
|
|
280
|
+
for (const path of selected.slice(MAX_CONTEXT_FILES)) {
|
|
281
|
+
excluded.push({ path, reason: `lower-priority context file; pass --include to select it explicitly` });
|
|
282
|
+
}
|
|
283
|
+
selected = selected.slice(0, MAX_CONTEXT_FILES);
|
|
284
|
+
}
|
|
285
|
+
return { selected, excluded };
|
|
286
|
+
}
|
|
287
|
+
async function readBoundedContext(paths) {
|
|
288
|
+
let totalBytes = 0;
|
|
289
|
+
const used = [];
|
|
290
|
+
const excluded = [];
|
|
291
|
+
const chunks = [];
|
|
292
|
+
const documents = [];
|
|
293
|
+
const seenContent = new Set();
|
|
294
|
+
for (const path of paths) {
|
|
295
|
+
const metadata = await (0, promises_1.stat)(path);
|
|
296
|
+
if (metadata.size > MAX_FILE_BYTES) {
|
|
297
|
+
excluded.push({ path, reason: `over ${MAX_FILE_BYTES.toLocaleString()} byte per-file cap` });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (totalBytes + metadata.size > MAX_TOTAL_BYTES) {
|
|
301
|
+
excluded.push({ path, reason: `over ${MAX_TOTAL_BYTES.toLocaleString()} byte total cap` });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const text = await (0, promises_1.readFile)(path, "utf8");
|
|
305
|
+
const validation = (0, index_1.validateImportText)(text, { maxBytes: MAX_FILE_BYTES, source: path });
|
|
306
|
+
if (!validation.ok) {
|
|
307
|
+
excluded.push({ path, reason: validation.errors.join("; ") });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const safeText = validation.sanitizedText ?? text;
|
|
311
|
+
const comparison = safeText.replace(/\s+/g, " ").trim();
|
|
312
|
+
if (comparison && seenContent.has(comparison)) {
|
|
313
|
+
excluded.push({ path, reason: "duplicate of a higher-priority selected context file" });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (comparison)
|
|
317
|
+
seenContent.add(comparison);
|
|
318
|
+
totalBytes += Buffer.byteLength(safeText);
|
|
319
|
+
used.push({ path, bytes: Buffer.byteLength(safeText) });
|
|
320
|
+
documents.push({ path, text: safeText });
|
|
321
|
+
// Keep provenance in `used`; synthetic source markers must not become part
|
|
322
|
+
// of the semantic brief and outrank terse project descriptions.
|
|
323
|
+
chunks.push(safeText);
|
|
324
|
+
}
|
|
325
|
+
return { text: chunks.join("\n"), documents, used, excluded, totalBytes };
|
|
326
|
+
}
|
|
327
|
+
function briefSourcePriority(path) {
|
|
328
|
+
const normalized = path.replaceAll("\\", "/").toLowerCase();
|
|
329
|
+
const name = (0, node_path_1.basename)(normalized);
|
|
330
|
+
if (/product[-_ ]strategy|positioning|product[-_ ]brief/.test(normalized))
|
|
331
|
+
return 0;
|
|
332
|
+
if (/^goal(?:\d+)?\.md$/.test(name))
|
|
333
|
+
return 1;
|
|
334
|
+
if (/^readme(?:\.|$)/.test(name))
|
|
335
|
+
return 2;
|
|
336
|
+
if (/\b(?:spec|overview)\.md$/.test(normalized))
|
|
337
|
+
return 3;
|
|
338
|
+
if (name === "agents.md" || name === "claude.md")
|
|
339
|
+
return 5;
|
|
340
|
+
return 4;
|
|
341
|
+
}
|
|
342
|
+
function compileRepositoryBrief(root, documents) {
|
|
343
|
+
const compiled = documents
|
|
344
|
+
.map((document) => ({
|
|
345
|
+
path: document.path,
|
|
346
|
+
priority: briefSourcePriority(document.path),
|
|
347
|
+
brief: (0, index_1.compileBrief)({
|
|
348
|
+
text: document.text,
|
|
349
|
+
source: `local:${(0, node_path_1.relative)(root, document.path) || (0, node_path_1.basename)(document.path)}`,
|
|
350
|
+
}),
|
|
351
|
+
}))
|
|
352
|
+
.sort((left, right) => left.priority - right.priority || left.path.localeCompare(right.path));
|
|
353
|
+
const primary = compiled[0]?.brief;
|
|
354
|
+
if (!primary)
|
|
355
|
+
throw new Error("No safe project context files were found.");
|
|
356
|
+
const scalar = (key) => compiled.map((item) => item.brief[key]).find((value) => value.trim()) ?? "";
|
|
357
|
+
const array = (key) => compiled
|
|
358
|
+
// Governance files often contain implementation do-not lists (for
|
|
359
|
+
// example, "avoid Flutter") that are not naming constraints.
|
|
360
|
+
.filter((item) => key !== "avoid" || !/^(?:agents|claude)\.md$/i.test((0, node_path_1.basename)(item.path)))
|
|
361
|
+
.map((item) => item.brief[key])
|
|
362
|
+
.find((value) => value.length) ?? [];
|
|
363
|
+
const evidence = compiled.flatMap((item) => item.brief.evidence).slice(0, 8);
|
|
364
|
+
return (0, index_1.reconcileBriefUncertainties)({
|
|
365
|
+
product: scalar("product"),
|
|
366
|
+
primaryUser: scalar("primaryUser"),
|
|
367
|
+
pain: scalar("pain"),
|
|
368
|
+
outcome: scalar("outcome"),
|
|
369
|
+
mechanism: scalar("mechanism"),
|
|
370
|
+
platform: array("platform"),
|
|
371
|
+
acquisitionChannels: array("acquisitionChannels"),
|
|
372
|
+
brandTone: array("brandTone"),
|
|
373
|
+
futureExpansion: scalar("futureExpansion"),
|
|
374
|
+
domainRequirements: array("domainRequirements"),
|
|
375
|
+
avoid: array("avoid"),
|
|
376
|
+
uncertainties: primary.uncertainties,
|
|
377
|
+
evidence,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async function commandBrief(args, flags) {
|
|
381
|
+
const input = (0, node_path_1.resolve)(args[0] || ".");
|
|
382
|
+
const discovered = await discoverContextFiles(input, flagString(flags, "include") || undefined);
|
|
383
|
+
const root = (await (0, promises_1.stat)(input)).isDirectory() ? input : (0, node_path_1.resolve)(input, "..");
|
|
384
|
+
const bounded = await readBoundedContext(discovered.selected);
|
|
385
|
+
const preview = {
|
|
386
|
+
root,
|
|
387
|
+
selected: bounded.used.map((file) => ({ path: (0, node_path_1.relative)(root, file.path) || (0, node_path_1.basename)(file.path), bytes: file.bytes })),
|
|
388
|
+
excluded: [...discovered.excluded, ...bounded.excluded].map((file) => ({ ...file, path: (0, node_path_1.relative)(root, file.path) || (0, node_path_1.basename)(file.path) })),
|
|
389
|
+
totalBytes: bounded.totalBytes,
|
|
390
|
+
transmitted: [],
|
|
391
|
+
};
|
|
392
|
+
if (flagBoolean(flags, "dry-run")) {
|
|
393
|
+
process.stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!bounded.text.trim())
|
|
397
|
+
throw new Error("No safe project context files were found. Pass a README or use --include with a text file.");
|
|
398
|
+
const brief = compileRepositoryBrief(root, bounded.documents);
|
|
399
|
+
const output = JSON.stringify({ brief, preview }, null, 2);
|
|
400
|
+
const out = flagString(flags, "out");
|
|
401
|
+
if (out) {
|
|
402
|
+
await (0, promises_1.writeFile)((0, node_path_1.resolve)(out), `${output}\n`, "utf8");
|
|
403
|
+
process.stderr.write(`Wrote ${(0, node_path_1.resolve)(out)}\n`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
process.stdout.write(`${output}\n`);
|
|
407
|
+
}
|
|
408
|
+
async function commandCheck(names, flags) {
|
|
409
|
+
if (!names.length)
|
|
410
|
+
throw usageError("Provide at least one name.");
|
|
411
|
+
if (names.length > 100)
|
|
412
|
+
throw usageError("A single check accepts at most 100 names.");
|
|
413
|
+
const tlds = [...new Set(flagString(flags, "tlds", "com,io,dev,app")
|
|
414
|
+
.split(",")
|
|
415
|
+
.map((item) => item.replace(/^\./, "").trim().toLowerCase())
|
|
416
|
+
.filter(Boolean))];
|
|
417
|
+
if (!tlds.length || tlds.length > 5 || tlds.some((tld) => !/^[a-z0-9-]{2,24}$/.test(tld))) {
|
|
418
|
+
throw usageError("Use one to five valid TLDs, for example --tlds com,app,dev.");
|
|
419
|
+
}
|
|
420
|
+
if (names.length * tlds.length > 100) {
|
|
421
|
+
throw usageError("A single check accepts at most 100 name × TLD combinations.");
|
|
422
|
+
}
|
|
423
|
+
if (flagBoolean(flags, "namespaces") && names.length > 20) {
|
|
424
|
+
throw usageError("Namespace checks accept at most 20 names per command.");
|
|
425
|
+
}
|
|
426
|
+
const category = flagString(flags, "category", "software");
|
|
427
|
+
const started = Date.now();
|
|
428
|
+
const domains = await (0, index_1.checkDomains)(names.flatMap((name) => {
|
|
429
|
+
const label = name.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
430
|
+
if (!label)
|
|
431
|
+
throw usageError(`Name ${JSON.stringify(name)} has no valid domain-label characters.`);
|
|
432
|
+
return tlds.map((tld) => `${label}.${tld}`);
|
|
433
|
+
}));
|
|
434
|
+
const byDomain = new Map(domains.map((domain) => [domain.domain, domain]));
|
|
435
|
+
const results = [];
|
|
436
|
+
for (const name of names) {
|
|
437
|
+
const label = name.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
438
|
+
const nameDomains = tlds.map((tld) => byDomain.get(`${label}.${tld}`)).filter((domain) => Boolean(domain));
|
|
439
|
+
const namespaces = flagBoolean(flags, "namespaces") ? await (0, index_1.checkNamespaces)(name) : [];
|
|
440
|
+
const score = (0, index_1.scoreName)({ name, category, domains: nameDomains, namespaces }, { category });
|
|
441
|
+
results.push({ name, score, domains: nameDomains, namespaces });
|
|
442
|
+
}
|
|
443
|
+
const record = { checkedAt: new Date().toISOString(), latencyMs: Date.now() - started, results };
|
|
444
|
+
if (flagBoolean(flags, "json") || !process.stdout.isTTY) {
|
|
445
|
+
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
for (const result of results) {
|
|
449
|
+
process.stdout.write(`\n${result.name} — ${Math.round(result.score.overall)}/100\n`);
|
|
450
|
+
for (const domain of result.domains)
|
|
451
|
+
process.stdout.write(` ${domain.domain.padEnd(30)} ${domain.status}\n`);
|
|
452
|
+
for (const namespace of result.namespaces)
|
|
453
|
+
process.stdout.write(` ${namespace.namespace.padEnd(30)} ${namespace.status}\n`);
|
|
454
|
+
}
|
|
455
|
+
process.stdout.write(`\nChecked in ${record.latencyMs}ms. RDAP absence is not a purchase guarantee.\n`);
|
|
456
|
+
}
|
|
457
|
+
async function readBrief(path) {
|
|
458
|
+
const raw = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.resolve)(path), "utf8"));
|
|
459
|
+
return raw.brief ?? raw;
|
|
460
|
+
}
|
|
461
|
+
function isRecord(value) {
|
|
462
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
463
|
+
}
|
|
464
|
+
async function readEvidenceArtifact(path) {
|
|
465
|
+
const raw = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.resolve)(path), "utf8"));
|
|
466
|
+
const queue = [{ value: raw, depth: 0 }];
|
|
467
|
+
const rows = new Map();
|
|
468
|
+
let brief;
|
|
469
|
+
let visited = 0;
|
|
470
|
+
while (queue.length && visited < 5_000) {
|
|
471
|
+
const current = queue.shift();
|
|
472
|
+
visited += 1;
|
|
473
|
+
if (current.depth > 6)
|
|
474
|
+
continue;
|
|
475
|
+
if (typeof current.value === "string" && current.value.trim().startsWith("{")) {
|
|
476
|
+
try {
|
|
477
|
+
queue.push({ value: JSON.parse(current.value), depth: current.depth + 1 });
|
|
478
|
+
}
|
|
479
|
+
catch { /* Non-JSON evidence text. */ }
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (Array.isArray(current.value)) {
|
|
483
|
+
for (const item of current.value)
|
|
484
|
+
queue.push({ value: item, depth: current.depth + 1 });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (!isRecord(current.value))
|
|
488
|
+
continue;
|
|
489
|
+
if (!brief && isRecord(current.value.brief) && typeof current.value.brief.product === "string") {
|
|
490
|
+
brief = current.value.brief;
|
|
491
|
+
}
|
|
492
|
+
if (typeof current.value.name === "string" && (Array.isArray(current.value.domains) || Array.isArray(current.value.namespaces) || isRecord(current.value.score))) {
|
|
493
|
+
const key = current.value.name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
494
|
+
if (key)
|
|
495
|
+
rows.set(key, current.value);
|
|
496
|
+
}
|
|
497
|
+
for (const [key, value] of Object.entries(current.value)) {
|
|
498
|
+
if (["results", "candidates", "ledger", "finalists", "shortlist", "result", "research", "structuredContent", "content"].includes(key)) {
|
|
499
|
+
queue.push({ value, depth: current.depth + 1 });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return { rows: [...rows.values()], brief };
|
|
504
|
+
}
|
|
505
|
+
async function commandGenerate(flags) {
|
|
506
|
+
const path = flagString(flags, "brief");
|
|
507
|
+
if (!path)
|
|
508
|
+
throw usageError("Use --brief <naming-brief.json>.");
|
|
509
|
+
const brief = await readBrief(path);
|
|
510
|
+
const requested = Number(flagString(flags, "count", "24"));
|
|
511
|
+
if (!Number.isInteger(requested) || requested < 4 || requested > 120) {
|
|
512
|
+
throw usageError("--count must be an integer from 4 to 120.");
|
|
513
|
+
}
|
|
514
|
+
const count = requested;
|
|
515
|
+
const candidates = (0, index_1.generateFallbackCandidates)(brief, { count });
|
|
516
|
+
process.stdout.write(`${JSON.stringify({ provider: "deterministic-fallback", candidates }, null, 2)}\n`);
|
|
517
|
+
}
|
|
518
|
+
async function apiRequest(path, init, flags = {}) {
|
|
519
|
+
const base = flagString(flags, "api", process.env.NAMING_SIGNAL_API_URL?.trim() || process.env.NAME_LEDGER_API_URL?.trim() || DEFAULT_API_URL).replace(/\/$/, "");
|
|
520
|
+
const apiKey = process.env.NAMING_SIGNAL_API_KEY?.trim();
|
|
521
|
+
const headers = new Headers(init?.headers);
|
|
522
|
+
if (apiKey)
|
|
523
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
524
|
+
let response;
|
|
525
|
+
try {
|
|
526
|
+
response = await fetch(`${base}${path}`, { ...init, headers });
|
|
527
|
+
}
|
|
528
|
+
catch (cause) {
|
|
529
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
530
|
+
const keyHint = apiKey
|
|
531
|
+
? ""
|
|
532
|
+
: " NAMING_SIGNAL_API_KEY is not set; hosted calls require a key created at https://namingsignal.com/developers.";
|
|
533
|
+
throw withExitCode(new Error(`Could not reach ${base}${path} (${detail}). Check NAMING_SIGNAL_API_URL or --api if you meant a different server (for local development set NAMING_SIGNAL_API_URL=http://localhost:3000).${keyHint}`), 1);
|
|
534
|
+
}
|
|
535
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
536
|
+
if (!contentType.includes("application/json")) {
|
|
537
|
+
throw withExitCode(new Error(`${path} returned HTTP ${response.status} ${response.statusText} from ${base} with a non-JSON response (${contentType || "no content type"}).`), exitCodeForStatus(response.status));
|
|
538
|
+
}
|
|
539
|
+
const body = (await response.json());
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
const hint = response.status === 401
|
|
542
|
+
? " Set NAMING_SIGNAL_API_KEY to a key created at /developers."
|
|
543
|
+
: "";
|
|
544
|
+
throw withExitCode(new Error(`${body.error || `${response.status} ${response.statusText}`}${hint}`), exitCodeForStatus(response.status));
|
|
545
|
+
}
|
|
546
|
+
return body;
|
|
547
|
+
}
|
|
548
|
+
async function commandResearch(names, flags) {
|
|
549
|
+
if (!names.length || names.length > 3)
|
|
550
|
+
throw usageError("Research accepts one to three finalists.");
|
|
551
|
+
const briefPath = flagString(flags, "brief");
|
|
552
|
+
const brief = briefPath ? await readBrief(briefPath) : undefined;
|
|
553
|
+
const body = await apiRequest("/api/v1/research", {
|
|
554
|
+
method: "POST",
|
|
555
|
+
headers: { "Content-Type": "application/json", "Idempotency-Key": `cli_${Date.now()}` },
|
|
556
|
+
body: JSON.stringify({ names, brief }),
|
|
557
|
+
}, flags);
|
|
558
|
+
process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
|
|
559
|
+
}
|
|
560
|
+
async function commandCompare(names, flags) {
|
|
561
|
+
if (names.length < 2)
|
|
562
|
+
throw usageError("Compare at least two names.");
|
|
563
|
+
const briefPath = flagString(flags, "brief");
|
|
564
|
+
const evidencePath = flagString(flags, "evidence");
|
|
565
|
+
const artifact = evidencePath ? await readEvidenceArtifact(evidencePath) : { rows: [], brief: undefined };
|
|
566
|
+
const brief = briefPath
|
|
567
|
+
? await readBrief(briefPath)
|
|
568
|
+
: artifact.brief ?? (0, index_1.compileBrief)(`Compare these product names: ${names.join(", ")}`);
|
|
569
|
+
const evidenceByName = new Map(artifact.rows.map((row) => [row.name.toLowerCase().replace(/[^a-z0-9]/g, ""), row]));
|
|
570
|
+
const finalists = names.map((name) => {
|
|
571
|
+
const prior = evidenceByName.get(name.toLowerCase().replace(/[^a-z0-9]/g, ""));
|
|
572
|
+
const domains = prior?.domains ?? [];
|
|
573
|
+
const namespaces = prior?.namespaces ?? [];
|
|
574
|
+
const riskSignals = prior?.riskSignals;
|
|
575
|
+
return {
|
|
576
|
+
...(prior ?? {}),
|
|
577
|
+
name,
|
|
578
|
+
domains,
|
|
579
|
+
namespaces,
|
|
580
|
+
score: (0, index_1.scoreName)({ name, domains, namespaces }, { brief, domainResults: domains, namespaceResults: namespaces, riskSignals }),
|
|
581
|
+
evidence: prior?.evidence ?? [],
|
|
582
|
+
};
|
|
583
|
+
});
|
|
584
|
+
const missingEvidence = finalists.filter((item) => !item.domains?.length && !item.namespaces?.length).map((item) => item.name);
|
|
585
|
+
const record = {
|
|
586
|
+
title: "NamingSignal CLI decision record",
|
|
587
|
+
brief,
|
|
588
|
+
finalists,
|
|
589
|
+
rejected: [],
|
|
590
|
+
notes: evidencePath
|
|
591
|
+
? missingEvidence.length
|
|
592
|
+
? [`The evidence artifact contained no domain or namespace rows for: ${missingEvidence.join(", ")}.`]
|
|
593
|
+
: [`Prior evidence loaded from ${(0, node_path_1.resolve)(evidencePath)}.`]
|
|
594
|
+
: ["No prior evidence artifact was supplied; domain and namespace posture is explicitly unverified."],
|
|
595
|
+
exportedAt: new Date().toISOString(),
|
|
596
|
+
disclaimer: "Preliminary naming research. Not legal clearance or legal advice.",
|
|
597
|
+
};
|
|
598
|
+
const format = flagString(flags, "format", "json");
|
|
599
|
+
process.stdout.write(format === "md" || format === "markdown" ? (0, index_1.exportDecisionMarkdown)(record) : (0, index_1.exportDecisionJson)(record));
|
|
600
|
+
process.stdout.write("\n");
|
|
601
|
+
}
|
|
602
|
+
async function main() {
|
|
603
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
604
|
+
const [command, ...args] = positional;
|
|
605
|
+
if (!command || command === "help" || flagBoolean(flags, "help")) {
|
|
606
|
+
printHelp();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (command === "version" || flagBoolean(flags, "version")) {
|
|
610
|
+
process.stdout.write(`${VERSION}\n`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
switch (command) {
|
|
614
|
+
case "brief":
|
|
615
|
+
await commandBrief(args, flags);
|
|
616
|
+
break;
|
|
617
|
+
case "check":
|
|
618
|
+
await commandCheck(args, flags);
|
|
619
|
+
break;
|
|
620
|
+
case "generate":
|
|
621
|
+
await commandGenerate(flags);
|
|
622
|
+
break;
|
|
623
|
+
case "research":
|
|
624
|
+
await commandResearch(args.flatMap((item) => item.split(",")).filter(Boolean), flags);
|
|
625
|
+
break;
|
|
626
|
+
case "compare":
|
|
627
|
+
await commandCompare(args.flatMap((item) => item.split(",")).filter(Boolean), flags);
|
|
628
|
+
break;
|
|
629
|
+
case "health": {
|
|
630
|
+
const body = await apiRequest("/api/v1/health", undefined, flags);
|
|
631
|
+
process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
default: throw usageError(`Unknown command: ${command}. Run \"namingsignal help\".`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
main().catch((error) => {
|
|
638
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
639
|
+
process.stderr.write(`NamingSignal: ${message}\n`);
|
|
640
|
+
const exitCode = error instanceof Error ? error.exitCode : undefined;
|
|
641
|
+
process.exitCode = typeof exitCode === "number" ? exitCode : 1;
|
|
642
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@namingsignal/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "NamingSignal CLI: keyless local domain checks and privacy-bounded naming briefs, plus hosted finalist research through namingsignal.com.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://namingsignal.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/moekoelueker/namingsignal.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/moekoelueker/namingsignal/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"naming",
|
|
15
|
+
"domain",
|
|
16
|
+
"rdap",
|
|
17
|
+
"domain-checker",
|
|
18
|
+
"cli",
|
|
19
|
+
"brand",
|
|
20
|
+
"agent"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"namingsignal": "dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"prepack": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.19.19",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|