crawldna 0.1.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/bin/cli.mjs ADDED
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
4
+ // crawldna CLI — a thin face over the core (§7). All logic lives in src/index.mjs.
5
+
6
+ import { parseArgs } from 'node:util';
7
+ import { readFile, stat } from 'node:fs/promises';
8
+ import process from 'node:process';
9
+ import { crawlDocs, resumeCrawl, DEFAULT_OPTIONS } from '../src/index.mjs';
10
+ import { listRuns, deleteRun, deleteAllRuns, cacheRoot } from '../src/lib/runs.mjs';
11
+
12
+ const C = {
13
+ reset: '\x1b[0m',
14
+ dim: '\x1b[2m',
15
+ bold: '\x1b[1m',
16
+ red: '\x1b[31m',
17
+ green: '\x1b[32m',
18
+ yellow: '\x1b[33m',
19
+ blue: '\x1b[34m',
20
+ cyan: '\x1b[36m',
21
+ };
22
+ const useColor = process.stdout.isTTY;
23
+ const c = (color, s) => (useColor ? color + s + C.reset : s);
24
+
25
+ const HELP = `${C.bold}crawldna${C.reset} — general, task-driven web crawler → clean Markdown
26
+
27
+ Usage:
28
+ crawldna <url> [--task "..."] crawl one site
29
+ crawldna crawl <url> [options] crawl one or more sites
30
+ crawldna resume <runId> [options] complete an interrupted run (crash/stop)
31
+ crawldna reshape <runId> --ask "..." reshape a saved extraction (Phase 2)
32
+ crawldna serve [--port 4000] start the optional Web UI (source repo only)
33
+ crawldna runs [list|rm <id…>|clear|path] manage cached runs
34
+ crawldna --help · --version
35
+
36
+ The crawler is CLI- and library-first; the Web UI is an optional frontend that
37
+ ships only with the source repository (not the npm package), so it never weighs
38
+ down a \`crawldna\` dependency. \`serve\` explains how to get it if it isn't present.
39
+
40
+ Two phases. The CRAWL extracts what your task asks for, VERBATIM — one faithful
41
+ .md per link (+ manifest.json). Every run is saved automatically to the runs cache
42
+ (${cacheRoot()}), and every kept page is journaled to disk AS IT IS CAPTURED — a
43
+ crash or Ctrl-C never loses extracted content: \`crawldna resume <runId>\` completes
44
+ the run from where it stopped (flags override the saved options; an API key is
45
+ never stored, so pass --api-key or set the env var again when resuming).
46
+ RESHAPE is separate and optional: turn that extraction into tables, splits or
47
+ filtered subsets with \`crawldna reshape <runId> --ask "…"\` (or Reshape in the Web
48
+ UI). It works over the saved files, as many times as you like — crawl once,
49
+ reshape many times.
50
+
51
+ Options:
52
+ --task <text> extraction task (repeatable, pairs with --url; speaks to
53
+ the AI, so it is refused with --no-ai)
54
+ default: "${DEFAULT_OPTIONS.task}"
55
+ --url <url> a target URL (repeatable; pair with --task for per-link tasks)
56
+ --targets <file.json> JSON file: a targets array ([{ "url", "task" }, ...])
57
+ --model <name> model id for the engine (REQUIRED unless --no-ai — e.g.
58
+ qwen3-coder:30b for Ollama, or gpt-4o-mini for an API)
59
+ --provider <name> ollama (default) | openai (any OpenAI-compatible API)
60
+ --no-ai crawl without any model: the reveal engine still runs
61
+ (heuristic-triaged clicks), but pages are kept whole and
62
+ EVERY in-scope link is followed. Zero tokens, no model
63
+ needed. The task speaks only to the AI, so here it has NO
64
+ role: --task (and --min-relevance, which reads it) is
65
+ refused, output files are named from the site. Big sites
66
+ can take longer — contain the crawl with
67
+ --include/--exclude or --max-pages
68
+ (incompatible with --mode targeted, which IS the AI)
69
+ --mode <m> what to extract — an explicit switch, not guessed from
70
+ the task text (default: ${DEFAULT_OPTIONS.mode}):
71
+ complete everything reachable: llms-full.txt/sitemap
72
+ shortcuts tried first, pages kept WHOLE, no
73
+ AI link-gate/scoping calls (cheapest; works
74
+ with --no-ai too)
75
+ targeted only what the task asks: AI link gate +
76
+ section scoping, in any language (needs AI)
77
+ auto legacy: the task wording decides — never the
78
+ default; only for old saved runs/scripts
79
+ --base-url <url> API base URL for --provider openai
80
+ (e.g. https://api.openai.com/v1, https://openrouter.ai/api/v1)
81
+ --api-key <key> API key for --provider openai
82
+ (or set CRAWLDNA_API_KEY / OPENAI_API_KEY in the environment)
83
+ --browser <mode> never | auto | always (default: ${DEFAULT_OPTIONS.browser})
84
+ --concurrency <n> parallel page fetches (default: ${DEFAULT_OPTIONS.concurrency})
85
+ --max-pages <n> safety cap, 0 = unlimited (default: ${DEFAULT_OPTIONS.maxPages})
86
+ --max-actions <n> per-page engine action cap (default: ${DEFAULT_OPTIONS.maxActions})
87
+ --include <regex> only crawl URLs matching
88
+ --exclude <regex> skip URLs matching
89
+ --delay <ms> politeness (opt-in): minimum gap between requests to the
90
+ SAME host (default: ${DEFAULT_OPTIONS.delay} = off)
91
+ --respect-robots politeness (opt-in): read robots.txt — disallowed URLs are
92
+ SKIPPED with a warning (never silently); Crawl-delay is
93
+ honoured (the larger of it and --delay wins)
94
+ --min-relevance <0-1> focus on task: skip links below this task-relevance
95
+ (default: ${DEFAULT_OPTIONS.minRelevance} = off; reads the task → refused with --no-ai)
96
+ --max-routes <n> cap the JS-mined route candidates sent to the AI link gate,
97
+ top-ranked by task relevance (default: ${DEFAULT_OPTIONS.maxRoutes}; 0 = unlimited;
98
+ only cuts when the task discriminates — DOM links are never capped)
99
+ --embed-model <name> OPTIONAL embedding model (e.g. nomic-embed-text on Ollama,
100
+ text-embedding-3-small on an API; same provider as --model).
101
+ Makes task→link relevance SEMANTIC — multilingual, synonym-
102
+ aware — for frontier ordering, --max-routes and
103
+ --min-relevance, and for reshape's context retrieval.
104
+ Orders only, never drops by itself; unset = lexical scoring.
105
+ Ignored with --no-ai (zero model calls of any kind)
106
+ --ollama-host <url> Ollama server URL (default: http://127.0.0.1:11434)
107
+ --cache-dir <dir> override the runs-cache location
108
+ --per-document also emit one identifiable .md per page + index.md + a JSONL
109
+ (for programmatic use); the consolidated .md is still written
110
+ --mirror-hamming <n> collapse mirror/variant re-servings of a kept page — same
111
+ locale-stripped path (mirror host, ?ui-state variant, locale
112
+ twin) AND content SimHash within <n> (default: ${DEFAULT_OPTIONS.mirrorHamming}; 0 = off)
113
+ --near-dup-hamming <n> collapse near-duplicate pages ACROSS different paths within
114
+ this SimHash Hamming distance (default: ${DEFAULT_OPTIONS.nearDupHamming} = off).
115
+ Opt-in: content similarity alone can drop a real page
116
+ --incremental re-crawl only what changed: reuse pages whose sitemap
117
+ <lastmod> is unchanged since the last --incremental run of the
118
+ same target (skips render+reveal). Conservative — any doubt
119
+ re-crawls, so a change is never skipped. Implies saving; the
120
+ first run establishes the baseline
121
+ --port <n> port for \`serve\` (default: 4000)
122
+
123
+ Reshape (Phase 2 — over a saved run):
124
+ --ask <text> the reshape request, e.g. "make a table of the prices"
125
+ --scan <id> which link of the run to reshape (default: the only/first)
126
+ --no-verify skip the fidelity check (default: every produced file's values
127
+ — numbers, URLs, code, quoted strings — are verified against
128
+ the crawled sources; unverifiable ones are flagged in the file)
129
+
130
+ Examples:
131
+ crawldna https://docusaurus.io/docs --task "Extract all documentation"
132
+ crawldna https://pizzeria.example/menu --task "Extract the full menu"
133
+ crawldna https://site.dev --no-ai --max-pages 50 # classic crawl + reveal, zero tokens
134
+ crawldna https://docs.dev --mode complete --model qwen3-coder:30b # whole site, pages whole,
135
+ # zero link-gate/scope calls
136
+ crawldna https://hotel.example --mode targeted --task "room prices" --model qwen3-coder:30b
137
+ crawldna --url https://a.dev --task "Get pricing" --url https://b.dev --task "Get API docs"
138
+ OPENAI_API_KEY=sk-… crawldna https://docs.dev --provider openai \\
139
+ --base-url https://api.openai.com/v1 --model gpt-4o-mini
140
+ crawldna reshape 20260615-084021-3f9c1a --ask "make a table of the prices"
141
+ crawldna runs # list cached runs
142
+ crawldna runs rm 20260615-084021-3f9c1a
143
+ crawldna serve --port 4000
144
+ `;
145
+
146
+ const OPTION_CONFIG = {
147
+ task: { type: 'string', multiple: true },
148
+ url: { type: 'string', multiple: true },
149
+ targets: { type: 'string' },
150
+ model: { type: 'string' },
151
+ provider: { type: 'string' },
152
+ 'no-ai': { type: 'boolean' },
153
+ mode: { type: 'string' },
154
+ 'base-url': { type: 'string' },
155
+ 'api-key': { type: 'string' },
156
+ browser: { type: 'string' },
157
+ concurrency: { type: 'string' },
158
+ 'max-pages': { type: 'string' },
159
+ 'max-actions': { type: 'string' },
160
+ include: { type: 'string' },
161
+ exclude: { type: 'string' },
162
+ delay: { type: 'string' },
163
+ 'respect-robots': { type: 'boolean' },
164
+ 'min-relevance': { type: 'string' },
165
+ 'max-routes': { type: 'string' },
166
+ 'embed-model': { type: 'string' },
167
+ 'ollama-host': { type: 'string' },
168
+ 'cache-dir': { type: 'string' },
169
+ 'per-document': { type: 'boolean' },
170
+ 'near-dup-hamming': { type: 'string' },
171
+ 'mirror-hamming': { type: 'string' },
172
+ incremental: { type: 'boolean' },
173
+ port: { type: 'string' },
174
+ ask: { type: 'string' },
175
+ scan: { type: 'string' },
176
+ 'no-verify': { type: 'boolean' },
177
+ help: { type: 'boolean', short: 'h' },
178
+ version: { type: 'boolean', short: 'v' },
179
+ };
180
+
181
+ async function buildTargets(values, positionals) {
182
+ // 1) explicit JSON file
183
+ if (values.targets) {
184
+ const raw = await readFile(values.targets, 'utf8');
185
+ const parsed = JSON.parse(raw.replace(/^/, '')); // tolerate a UTF-8 BOM
186
+ return Array.isArray(parsed) ? parsed : [parsed];
187
+ }
188
+
189
+ const tasks = values.task || [];
190
+
191
+ // 2) repeated --url (optionally paired with --task)
192
+ if (values.url && values.url.length) {
193
+ return values.url.map((url, i) => {
194
+ const task = tasks.length === 1 ? tasks[0] : tasks[i];
195
+ return task ? { url, task } : { url };
196
+ });
197
+ }
198
+
199
+ // 3) positional URLs (after an optional `crawl` subcommand)
200
+ const urls = positionals.filter((p) => p !== 'crawl');
201
+ const shared = tasks[0];
202
+ return urls.map((url) => (shared ? { url, task: shared } : { url }));
203
+ }
204
+
205
+ function optionsFromFlags(values) {
206
+ // The CLI is an app, not a library call: it always persists the run to the cache
207
+ // (rooted at the current working directory) so `crawldna runs` and `crawldna reshape`
208
+ // can find it afterwards. Library callers of crawlDocs save only on opt-in.
209
+ const o = { save: true };
210
+ if (values.model) o.model = values.model;
211
+ if (values.provider) o.provider = values.provider;
212
+ if (values['no-ai']) o.noAi = true;
213
+ if (values.mode) o.mode = values.mode;
214
+ if (values['base-url']) o.baseUrl = values['base-url'];
215
+ if (values['api-key']) o.apiKey = values['api-key'];
216
+ if (values.browser) o.browser = values.browser;
217
+ if (values.concurrency) o.concurrency = Number(values.concurrency);
218
+ if (values['max-pages'] != null) o.maxPages = Number(values['max-pages']);
219
+ if (values['max-actions'] != null) o.maxActions = Number(values['max-actions']);
220
+ if (values.include) o.include = values.include;
221
+ if (values.exclude) o.exclude = values.exclude;
222
+ if (values.delay != null) o.delay = Number(values.delay);
223
+ if (values['respect-robots']) o.respectRobots = true;
224
+ if (values['min-relevance'] != null) o.minRelevance = Number(values['min-relevance']);
225
+ if (values['max-routes'] != null) o.maxRoutes = Number(values['max-routes']);
226
+ if (values['embed-model']) o.embedModel = values['embed-model'];
227
+ if (values['ollama-host']) o.ollamaHost = values['ollama-host'];
228
+ if (values['cache-dir']) o.cacheDir = values['cache-dir'];
229
+ if (values['per-document']) o.perDocument = true;
230
+ if (values['near-dup-hamming'] != null) o.nearDupHamming = Number(values['near-dup-hamming']);
231
+ if (values['mirror-hamming'] != null) o.mirrorHamming = Number(values['mirror-hamming']);
232
+ if (values.incremental) o.incremental = true;
233
+ if (values.task && values.task.length === 1) o.task = values.task[0];
234
+ return o;
235
+ }
236
+
237
+ // One in-place progress line on a TTY (thousands of pages must not mean thousands
238
+ // of printed lines); a throttled plain line when piped to a file/CI log.
239
+ let progressOpen = false;
240
+ function endProgressLine() {
241
+ if (progressOpen) {
242
+ process.stdout.write('\n');
243
+ progressOpen = false;
244
+ }
245
+ }
246
+
247
+ function renderEvent(ev) {
248
+ if (ev.type !== 'progress') endProgressLine();
249
+ switch (ev.type) {
250
+ case 'site':
251
+ process.stdout.write(`\n${c(C.bold, '▶ site')} ${ev.url} ${c(C.dim, '— ' + ev.task)}\n`);
252
+ break;
253
+ case 'strategy':
254
+ process.stdout.write(
255
+ ` ${c(C.cyan, 'strategy')} ${ev.strategy}${ev.framework ? c(C.dim, ' [' + ev.framework + ']') : ''}\n`,
256
+ );
257
+ break;
258
+ case 'discover':
259
+ process.stdout.write(` ${c(C.cyan, 'discover')} ${ev.count} page(s)\n`);
260
+ break;
261
+ case 'action':
262
+ process.stdout.write(` ${c(C.blue, ev.action)} ${c(C.dim, ev.detail || '')}\n`);
263
+ break;
264
+ case 'extracted':
265
+ process.stdout.write(
266
+ ` ${c(C.green, '✓')} ${ev.title || '(untitled)'} ${c(C.dim, `(${ev.bytes}b) ${ev.url}`)}\n`,
267
+ );
268
+ break;
269
+ case 'resume':
270
+ process.stdout.write(
271
+ ` ${c(C.cyan, 'resume')} ${c(C.dim, `${ev.restored} page(s) restored from the journal — not re-crawled`)}\n`,
272
+ );
273
+ break;
274
+ case 'saved': {
275
+ const nFiles = (ev.scans || []).reduce((n, s) => n + (s.files || []).length, 0);
276
+ process.stdout.write(
277
+ ` ${c(C.cyan, 'saved')} ${c(C.dim, `run ${ev.runId} · ${(ev.scans || []).length} link(s) · ${nFiles} file(s)`)}\n`,
278
+ );
279
+ break;
280
+ }
281
+ case 'progress':
282
+ if (!ev.total) break;
283
+ if (useColor) {
284
+ process.stdout.write(`\r ${c(C.dim, `progress ${ev.done}/${ev.total}`)}\x1b[K`);
285
+ progressOpen = true;
286
+ } else if (ev.done % 25 === 0 || ev.done === ev.total) {
287
+ process.stdout.write(` progress ${ev.done}/${ev.total}\n`);
288
+ }
289
+ break;
290
+ case 'dedup':
291
+ process.stdout.write(
292
+ ` ${c(C.dim, `≡ dup[${ev.kind}] ${ev.url}${ev.of ? ' ≈ ' + ev.of : ''}`)}\n`,
293
+ );
294
+ break;
295
+ case 'warn':
296
+ process.stdout.write(` ${c(C.yellow, '⚠ warn')} ${ev.reason ? '[' + ev.reason + '] ' : ''}${ev.message}\n`);
297
+ break;
298
+ case 'error':
299
+ process.stdout.write(` ${c(C.red, '✗ error')} ${ev.message}\n`);
300
+ break;
301
+ case 'done':
302
+ break;
303
+ default:
304
+ break;
305
+ }
306
+ }
307
+
308
+ // Drive a live run (fresh or resumed): render its events, handle Ctrl-C
309
+ // gracefully, print the final summary. Shared by `crawl` and `resume`.
310
+ async function driveRun(run) {
311
+ const onSigint = () => {
312
+ process.stdout.write(c(C.yellow, '\nStopping (graceful)… the run stays resumable: crawldna resume <runId>\n'));
313
+ run.stop();
314
+ };
315
+ process.on('SIGINT', onSigint);
316
+
317
+ for await (const ev of run) renderEvent(ev);
318
+ endProgressLine();
319
+
320
+ const result = await run.result;
321
+ process.off('SIGINT', onSigint);
322
+
323
+ const sc = result.stats.strategyCounts;
324
+ const parts = Object.entries(sc)
325
+ .filter(([, n]) => n > 0)
326
+ .map(([k, n]) => `${k}=${n}`)
327
+ .join(' ');
328
+ process.stdout.write(
329
+ `\n${c(C.bold, 'Summary')} ${result.stats.pages} page(s) in ${result.stats.durationMs}ms\n`,
330
+ );
331
+ if (parts) process.stdout.write(` ${c(C.dim, parts)}\n`);
332
+ const dd = result.stats.deduped || {};
333
+ const ddTotal = (dd.exact || 0) + (dd.mirror || 0) + (dd.near || 0);
334
+ if (ddTotal) {
335
+ process.stdout.write(
336
+ ` ${c(C.dim, `${ddTotal} duplicate(s) skipped (exact=${dd.exact || 0} mirror=${dd.mirror || 0} near=${dd.near || 0})`)}\n`,
337
+ );
338
+ }
339
+ if (result.warnings.length) {
340
+ process.stdout.write(` ${c(C.yellow, result.warnings.length + ' warning(s)')}\n`);
341
+ }
342
+ if (result.run) {
343
+ process.stdout.write(` ${c(C.dim, 'saved to ' + result.run.dir)}\n`);
344
+ for (const s of result.run.scans || []) {
345
+ const names = (s.files || []).map((f) => f.filename).join(', ');
346
+ process.stdout.write(
347
+ ` ${c(C.cyan, s.title || s.url)} ${c(
348
+ C.dim,
349
+ `${s.pages} page(s) → ${(s.files || []).length} file(s)${names ? ': ' + names : ''}`,
350
+ )}\n`,
351
+ );
352
+ }
353
+ }
354
+ }
355
+
356
+ async function runCrawl(values, positionals) {
357
+ const targets = await buildTargets(values, positionals);
358
+ if (!targets.length) {
359
+ process.stderr.write(c(C.red, 'No targets given.\n\n'));
360
+ process.stdout.write(HELP);
361
+ process.exitCode = 1;
362
+ return;
363
+ }
364
+
365
+ // crawlDocs rejects contract violations synchronously (unknown --mode,
366
+ // --mode targeted + --no-ai): show the reason, not a stack trace.
367
+ let run;
368
+ try {
369
+ run = crawlDocs(targets, optionsFromFlags(values));
370
+ } catch (err) {
371
+ process.stderr.write(c(C.red, 'crawl failed: ' + (err && err.message ? err.message : err) + '\n'));
372
+ process.exitCode = 1;
373
+ return;
374
+ }
375
+ await driveRun(run);
376
+ }
377
+
378
+ // Complete an interrupted run (#13): restore its journaled pages, re-seed the
379
+ // frontier and crawl only what is missing — into the SAME run folder.
380
+ async function resumeCommand(args, values) {
381
+ const runId = args[0];
382
+ if (!runId) {
383
+ process.stderr.write(c(C.red, 'Usage: crawldna resume <runId> [options]\n'));
384
+ process.exitCode = 1;
385
+ return;
386
+ }
387
+ let run;
388
+ try {
389
+ run = await resumeCrawl(runId, optionsFromFlags(values));
390
+ } catch (err) {
391
+ process.stderr.write(c(C.red, 'resume failed: ' + (err && err.message ? err.message : err) + '\n'));
392
+ process.exitCode = 1;
393
+ return;
394
+ }
395
+ await driveRun(run);
396
+ }
397
+
398
+ // Phase 2 — reshape a saved extraction into new files, on demand.
399
+ async function reshapeCommand(args, values) {
400
+ const runId = args[0];
401
+ const message = values.ask || (values.task && values.task[0]);
402
+ if (!runId || !message) {
403
+ process.stderr.write(c(C.red, 'Usage: crawldna reshape <runId> --ask "<request>" [--scan <id>]\n'));
404
+ process.exitCode = 1;
405
+ return;
406
+ }
407
+ const { reshape } = await import('../src/reshape.mjs');
408
+ try {
409
+ const out = await reshape({
410
+ runId,
411
+ scanId: values.scan || '',
412
+ message,
413
+ model: values.model || DEFAULT_OPTIONS.model,
414
+ provider: values.provider,
415
+ host: values['ollama-host'],
416
+ baseUrl: values['base-url'],
417
+ apiKey: values['api-key'],
418
+ embedModel: values['embed-model'],
419
+ cacheDir: values['cache-dir'],
420
+ verify: !values['no-verify'],
421
+ });
422
+ if (out.reply) process.stdout.write('\n' + out.reply + '\n');
423
+ if (out.files.length) {
424
+ process.stdout.write(`\n${c(C.bold, 'Files')} ${c(C.dim, '(saved under the run’s chat/ folder)')}\n`);
425
+ for (const f of out.files) {
426
+ process.stdout.write(` ${c(C.green, '✓')} ${f.filename} ${c(C.dim, `(${f.bytes}b)`)}\n`);
427
+ const fid = f.fidelity;
428
+ if (fid && fid.unverified && fid.unverified.length) {
429
+ process.stdout.write(
430
+ ` ${c(C.yellow, `⚠ ${fid.unverified.length}/${fid.checked} value(s) not found in the crawled sources`)} ` +
431
+ c(C.dim, '— possibly invented; see the warning inside the file\n'),
432
+ );
433
+ }
434
+ }
435
+ } else {
436
+ process.stdout.write(c(C.dim, '\n(no files produced — the model answered without emitting any)\n'));
437
+ }
438
+ if (out.truncated) {
439
+ process.stdout.write(
440
+ c(
441
+ C.yellow,
442
+ out.contextMode === 'retrieval'
443
+ ? '\n⚠ the sources exceed the model budget — only the sections relevant to your request were sent\n'
444
+ : '\n⚠ only the first part of a large extraction was used (nothing in the request narrows it down)\n',
445
+ ),
446
+ );
447
+ }
448
+ } catch (err) {
449
+ process.stderr.write(c(C.red, 'reshape failed: ' + (err && err.message ? err.message : err) + '\n'));
450
+ process.exitCode = 1;
451
+ }
452
+ }
453
+
454
+ function fmtDate(iso) {
455
+ try {
456
+ return new Date(iso).toLocaleString();
457
+ } catch {
458
+ return iso || '';
459
+ }
460
+ }
461
+
462
+ async function runsCommand(args, values) {
463
+ const opts = values['cache-dir'] ? { cacheDir: values['cache-dir'] } : {};
464
+ const sub = args[0] || 'list';
465
+
466
+ if (sub === 'path') {
467
+ process.stdout.write(cacheRoot(opts) + '\n');
468
+ return;
469
+ }
470
+
471
+ if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
472
+ const ids = args.slice(1);
473
+ if (!ids.length) {
474
+ process.stderr.write(c(C.red, 'Usage: crawldna runs rm <id> [<id> …]\n'));
475
+ process.exitCode = 1;
476
+ return;
477
+ }
478
+ for (const id of ids) {
479
+ try {
480
+ await deleteRun(id, opts);
481
+ process.stdout.write(`${c(C.green, '✓')} deleted ${id}\n`);
482
+ } catch (err) {
483
+ process.stderr.write(c(C.red, `✗ ${id}: ${err && err.message}\n`));
484
+ }
485
+ }
486
+ return;
487
+ }
488
+
489
+ if (sub === 'clear' || sub === 'prune') {
490
+ const n = await deleteAllRuns(opts);
491
+ process.stdout.write(`${c(C.green, '✓')} deleted ${n} run(s)\n`);
492
+ return;
493
+ }
494
+
495
+ // default: list
496
+ const runs = await listRuns(opts);
497
+ if (!runs.length) {
498
+ process.stdout.write(c(C.dim, `No cached runs in ${cacheRoot(opts)}\n`));
499
+ return;
500
+ }
501
+ process.stdout.write(`${c(C.bold, 'Cached runs')} ${c(C.dim, '(' + cacheRoot(opts) + ')')}\n\n`);
502
+ for (const r of runs) {
503
+ const scans = r.scans || [];
504
+ const nFiles = scans.reduce((n, s) => n + (s.files || []).length, 0);
505
+ // 'running' = crashed mid-crawl (or still crawling elsewhere); 'stopped' =
506
+ // voluntary Stop. Both keep their journal and can be completed with resume.
507
+ const status =
508
+ r.status === 'running'
509
+ ? ' ' + c(C.yellow, `⏸ interrupted — resume: crawldna resume ${r.id}`)
510
+ : r.status === 'stopped'
511
+ ? ' ' + c(C.yellow, `⏸ stopped — resume: crawldna resume ${r.id}`)
512
+ : '';
513
+ process.stdout.write(` ${c(C.cyan, r.id)} ${c(C.dim, fmtDate(r.createdAt))}${status}\n`);
514
+ process.stdout.write(` ${r.pages} page(s) · ${scans.length} link(s) → ${nFiles} file(s)\n`);
515
+ for (const s of scans) {
516
+ const files = (s.files || []).map((f) => f.filename).join(', ');
517
+ process.stdout.write(
518
+ ` ${c(C.dim, '• ' + (s.url || s.title || s.scanId))}` +
519
+ `${s.task ? c(C.dim, ' — ' + s.task) : ''}${files ? c(C.dim, ' [' + files + ']') : ''}\n`,
520
+ );
521
+ }
522
+ }
523
+ process.stdout.write(`\n${c(C.dim, 'Delete: crawldna runs rm <id> · clear all: crawldna runs clear')}\n`);
524
+ }
525
+
526
+ async function main() {
527
+ let parsed;
528
+ try {
529
+ parsed = parseArgs({
530
+ args: process.argv.slice(2),
531
+ options: OPTION_CONFIG,
532
+ allowPositionals: true,
533
+ });
534
+ } catch (err) {
535
+ process.stderr.write(c(C.red, 'Argument error: ' + err.message + '\n\n'));
536
+ process.stdout.write(HELP);
537
+ process.exitCode = 1;
538
+ return;
539
+ }
540
+
541
+ const { values, positionals } = parsed;
542
+
543
+ if (values.help || (positionals[0] === 'help')) {
544
+ process.stdout.write(HELP);
545
+ return;
546
+ }
547
+
548
+ if (values.version || positionals[0] === 'version') {
549
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
550
+ process.stdout.write(`crawldna ${pkg.version}\n`);
551
+ return;
552
+ }
553
+
554
+ if (positionals[0] === 'serve') {
555
+ // The Web UI is OPTIONAL and ships only with the repository, not the npm
556
+ // package (it would be dead weight for library/CLI users). Detect whether the
557
+ // UI is present and, if not, explain how to get it instead of crashing — the
558
+ // crawler itself works fully without it.
559
+ const uiEntry = new URL('../ui/server.mjs', import.meta.url);
560
+ let hasUI = true;
561
+ try {
562
+ await stat(uiEntry);
563
+ } catch {
564
+ hasUI = false;
565
+ }
566
+ if (!hasUI) {
567
+ process.stderr.write(
568
+ c(C.yellow, '\nThe Web UI is optional and not bundled with the npm package') +
569
+ ' (to keep it lightweight).\n\n' +
570
+ 'The crawler works fully without it — use the CLI or the library API:\n' +
571
+ c(C.cyan, ' crawldna <url> --task "…" --model qwen3-coder:30b\n') +
572
+ c(C.cyan, " import { crawlDocs } from 'crawldna'\n") +
573
+ '\nTo use the Web UI, run it from the source repository:\n' +
574
+ c(C.cyan, ' git clone https://github.com/BogdanVasaiu/crawlDNA && cd crawlDNA\n') +
575
+ c(C.cyan, ' npm install && npm run serve\n'),
576
+ );
577
+ process.exitCode = 1;
578
+ return;
579
+ }
580
+ const { startServer } = await import('../ui/server.mjs');
581
+ const port = Number(values.port) || 4000;
582
+ await startServer({ port });
583
+ return;
584
+ }
585
+
586
+ if (positionals[0] === 'runs') {
587
+ await runsCommand(positionals.slice(1), values);
588
+ return;
589
+ }
590
+
591
+ if (positionals[0] === 'reshape') {
592
+ await reshapeCommand(positionals.slice(1), values);
593
+ return;
594
+ }
595
+
596
+ if (positionals[0] === 'resume') {
597
+ await resumeCommand(positionals.slice(1), values);
598
+ return;
599
+ }
600
+
601
+ await runCrawl(values, positionals);
602
+ }
603
+
604
+ main().catch((err) => {
605
+ process.stderr.write('Fatal: ' + (err && err.stack ? err.stack : err) + '\n');
606
+ process.exitCode = 1;
607
+ });