@fragno-dev/cli 0.1.13 → 0.1.15

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.
@@ -0,0 +1,581 @@
1
+ import { define } from "gunshi";
2
+ import {
3
+ getSubjects,
4
+ getSubject,
5
+ getAllSubjects,
6
+ getSubjectParent,
7
+ getSubjectChildren,
8
+ } from "@fragno-dev/corpus";
9
+ import type { Subject, Example } from "@fragno-dev/corpus";
10
+ import { marked } from "marked";
11
+ // @ts-expect-error - marked-terminal types are outdated for v7
12
+ import { markedTerminal } from "marked-terminal";
13
+ import { stripVTControlCharacters } from "node:util";
14
+
15
+ // Always configure marked to use terminal renderer
16
+ marked.use(markedTerminal());
17
+
18
+ interface PrintOptions {
19
+ showLineNumbers: boolean;
20
+ startLine?: number;
21
+ endLine?: number;
22
+ headingsOnly: boolean;
23
+ }
24
+
25
+ /**
26
+ * Build markdown content for multiple subjects
27
+ */
28
+ function buildSubjectsMarkdown(subjects: Subject[]): string {
29
+ let fullMarkdown = "";
30
+
31
+ for (const subject of subjects) {
32
+ fullMarkdown += `# ${subject.title}\n\n`;
33
+
34
+ if (subject.description) {
35
+ fullMarkdown += `${subject.description}\n\n`;
36
+ }
37
+
38
+ // Add imports block if present
39
+ if (subject.imports) {
40
+ fullMarkdown += `### Imports\n\n\`\`\`typescript\n${subject.imports}\n\`\`\`\n\n`;
41
+ }
42
+
43
+ // Add prelude blocks if present
44
+ if (subject.prelude.length > 0) {
45
+ fullMarkdown += `### Prelude\n\n`;
46
+ for (const block of subject.prelude) {
47
+ // Don't include the directive in the displayed code fence
48
+ fullMarkdown += `\`\`\`typescript\n${block.code}\n\`\`\`\n\n`;
49
+ }
50
+ }
51
+
52
+ // Add all sections
53
+ for (const section of subject.sections) {
54
+ fullMarkdown += `## ${section.heading}\n\n${section.content}\n\n`;
55
+ }
56
+ }
57
+
58
+ return fullMarkdown;
59
+ }
60
+
61
+ /**
62
+ * Add line numbers to content
63
+ */
64
+ function addLineNumbers(content: string, startFrom: number = 1): string {
65
+ const lines = content.split("\n");
66
+ const maxDigits = String(startFrom + lines.length - 1).length;
67
+
68
+ return lines
69
+ .map((line, index) => {
70
+ const lineNum = startFrom + index;
71
+ const paddedNum = String(lineNum).padStart(maxDigits, " ");
72
+ return `${paddedNum}│ ${line}`;
73
+ })
74
+ .join("\n");
75
+ }
76
+
77
+ /**
78
+ * Filter content by line range
79
+ */
80
+ function filterByLineRange(content: string, startLine: number, endLine: number): string {
81
+ const lines = content.split("\n");
82
+ // Convert to 0-based index
83
+ const start = Math.max(0, startLine - 1);
84
+ const end = Math.min(lines.length, endLine);
85
+ return lines.slice(start, end).join("\n");
86
+ }
87
+
88
+ /**
89
+ * Extract headings and code block information with line numbers
90
+ */
91
+ function extractHeadingsAndBlocks(subjects: Subject[]): string {
92
+ let output = "";
93
+ let currentLine = 1;
94
+ let lastOutputLine = 0;
95
+
96
+ // Helper to add a gap indicator if we skipped lines
97
+ const addGapIfNeeded = () => {
98
+ if (lastOutputLine > 0 && currentLine > lastOutputLine + 1) {
99
+ output += ` │\n`;
100
+ }
101
+ };
102
+
103
+ // Add instruction header
104
+ output += "Use --start N --end N flags to show specific line ranges\n\n";
105
+
106
+ for (const subject of subjects) {
107
+ // Title
108
+ addGapIfNeeded();
109
+ output += `${currentLine.toString().padStart(4, " ")}│ # ${subject.title}\n`;
110
+ lastOutputLine = currentLine;
111
+ currentLine += 1;
112
+
113
+ // Empty line after title - SHOW IT
114
+ output += `${currentLine.toString().padStart(4, " ")}│\n`;
115
+ lastOutputLine = currentLine;
116
+ currentLine += 1;
117
+
118
+ // Description - show full text
119
+ if (subject.description) {
120
+ const descLines = subject.description.split("\n");
121
+ for (const line of descLines) {
122
+ output += `${currentLine.toString().padStart(4, " ")}│ ${line}\n`;
123
+ lastOutputLine = currentLine;
124
+ currentLine += 1;
125
+ }
126
+ // Empty line after description - SHOW IT
127
+ output += `${currentLine.toString().padStart(4, " ")}│\n`;
128
+ lastOutputLine = currentLine;
129
+ currentLine += 1;
130
+ }
131
+
132
+ // Imports block - show full code
133
+ if (subject.imports) {
134
+ addGapIfNeeded();
135
+ output += `${currentLine.toString().padStart(4, " ")}│ ### Imports\n`;
136
+ lastOutputLine = currentLine;
137
+ currentLine += 1;
138
+ // Empty line after heading - SHOW IT
139
+ output += `${currentLine.toString().padStart(4, " ")}│\n`;
140
+ lastOutputLine = currentLine;
141
+ currentLine += 1;
142
+ output += `${currentLine.toString().padStart(4, " ")}│ \`\`\`typescript\n`;
143
+ lastOutputLine = currentLine;
144
+ currentLine += 1;
145
+ const importLines = subject.imports.split("\n");
146
+ for (const line of importLines) {
147
+ output += `${currentLine.toString().padStart(4, " ")}│ ${line}\n`;
148
+ lastOutputLine = currentLine;
149
+ currentLine += 1;
150
+ }
151
+ output += `${currentLine.toString().padStart(4, " ")}│ \`\`\`\n`;
152
+ lastOutputLine = currentLine;
153
+ currentLine += 1;
154
+ // Empty line after code block - SHOW IT
155
+ output += `${currentLine.toString().padStart(4, " ")}│\n`;
156
+ lastOutputLine = currentLine;
157
+ currentLine += 1;
158
+ }
159
+
160
+ // Prelude blocks - show as list
161
+ if (subject.prelude.length > 0) {
162
+ addGapIfNeeded();
163
+ output += `${currentLine.toString().padStart(4, " ")}│ ### Prelude\n`;
164
+ lastOutputLine = currentLine;
165
+ currentLine += 1;
166
+ // Empty line after heading
167
+ output += `${currentLine.toString().padStart(4, " ")}│\n`;
168
+ lastOutputLine = currentLine;
169
+ currentLine += 1;
170
+
171
+ for (const block of subject.prelude) {
172
+ const id = block.id || "(no-id)";
173
+ const blockStartLine = currentLine + 1; // +1 for opening ```
174
+ const codeLines = block.code.split("\n").length;
175
+ const blockEndLine = currentLine + 1 + codeLines; // opening ``` + code lines
176
+ output += `${currentLine.toString().padStart(4, " ")}│ - id: \`${id}\`, L${blockStartLine}-${blockEndLine}\n`;
177
+ lastOutputLine = currentLine;
178
+ currentLine += codeLines + 3; // opening ```, code, closing ```, blank line
179
+ }
180
+ // Update lastOutputLine to current position to avoid gap indicator
181
+ lastOutputLine = currentLine - 1;
182
+ }
183
+
184
+ // Sections - show headings and any example IDs that belong to them
185
+ const sectionToExamples = new Map<string, Example[]>();
186
+
187
+ // Group examples by their rough section (based on heading appearance in explanations)
188
+ for (const example of subject.examples) {
189
+ // Try to match the example to a section based on context
190
+ // For now, we'll list all example IDs under the sections where they appear
191
+ for (const section of subject.sections) {
192
+ // Check if the section contains references to this example
193
+ if (
194
+ section.content.includes(example.code.substring(0, Math.min(50, example.code.length)))
195
+ ) {
196
+ if (!sectionToExamples.has(section.heading)) {
197
+ sectionToExamples.set(section.heading, []);
198
+ }
199
+ sectionToExamples.get(section.heading)!.push(example);
200
+ break;
201
+ }
202
+ }
203
+ }
204
+
205
+ for (const section of subject.sections) {
206
+ addGapIfNeeded();
207
+ output += `${currentLine.toString().padStart(4, " ")}│ ## ${section.heading}\n`;
208
+ lastOutputLine = currentLine;
209
+ currentLine += 1;
210
+
211
+ // Show code block IDs as a list if any examples match this section
212
+ const examples = sectionToExamples.get(section.heading) || [];
213
+ if (examples.length > 0) {
214
+ // We need to parse the section content to find where each example appears
215
+ const sectionStartLine = currentLine;
216
+ const lines = section.content.split("\n");
217
+
218
+ for (const example of examples) {
219
+ const id = example.id || "(no-id)";
220
+ // Find the code block in section content
221
+ let blockStartLine = sectionStartLine;
222
+ let blockEndLine = sectionStartLine;
223
+ let inCodeBlock = false;
224
+ let foundBlock = false;
225
+
226
+ for (let i = 0; i < lines.length; i++) {
227
+ const line = lines[i];
228
+ if (line.trim().startsWith("```") && !inCodeBlock) {
229
+ // Check if next lines match the example
230
+ const codeStart = i + 1;
231
+ let matches = true;
232
+ const exampleLines = example.code.split("\n");
233
+ for (let j = 0; j < Math.min(3, exampleLines.length); j++) {
234
+ if (lines[codeStart + j]?.trim() !== exampleLines[j]?.trim()) {
235
+ matches = false;
236
+ break;
237
+ }
238
+ }
239
+ if (matches) {
240
+ blockStartLine = sectionStartLine + i + 1; // +1 to skip opening ```
241
+ blockEndLine = sectionStartLine + i + exampleLines.length;
242
+ foundBlock = true;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+
248
+ if (foundBlock) {
249
+ output += `${currentLine.toString().padStart(4, " ")}│ - id: \`${id}\`, L${blockStartLine}-${blockEndLine}\n`;
250
+ } else {
251
+ output += `${currentLine.toString().padStart(4, " ")}│ - id: \`${id}\`\n`;
252
+ }
253
+ lastOutputLine = currentLine;
254
+ }
255
+ }
256
+
257
+ // Count lines
258
+ const sectionLines = section.content.split("\n");
259
+ for (const _line of sectionLines) {
260
+ currentLine += 1;
261
+ }
262
+ currentLine += 1; // blank line after section
263
+ // Update lastOutputLine to current position to avoid gap indicator
264
+ lastOutputLine = currentLine - 1;
265
+ }
266
+ }
267
+
268
+ return output;
269
+ }
270
+
271
+ /**
272
+ * Print subjects with the given options
273
+ */
274
+ async function printSubjects(subjects: Subject[], options: PrintOptions): Promise<void> {
275
+ if (options.headingsOnly) {
276
+ // Show only headings and code block IDs
277
+ const headingsOutput = extractHeadingsAndBlocks(subjects);
278
+ console.log(headingsOutput);
279
+ return;
280
+ }
281
+
282
+ // Build the full markdown content
283
+ const markdown = buildSubjectsMarkdown(subjects);
284
+
285
+ // Render markdown to terminal for nice formatting
286
+ let output = await marked.parse(markdown);
287
+
288
+ // Apply line range filter if specified (after rendering)
289
+ const startLine = options.startLine ?? 1;
290
+ if (options.startLine !== undefined || options.endLine !== undefined) {
291
+ const end = options.endLine ?? output.split("\n").length;
292
+ output = filterByLineRange(output, startLine, end);
293
+ }
294
+
295
+ // Add line numbers after rendering (if requested)
296
+ // Line numbers correspond to the rendered output that agents interact with
297
+ if (options.showLineNumbers) {
298
+ output = addLineNumbers(output, startLine);
299
+ }
300
+
301
+ console.log(output);
302
+ }
303
+
304
+ /**
305
+ * Find and print code blocks by ID
306
+ */
307
+ async function printCodeBlockById(
308
+ id: string,
309
+ topics: string[],
310
+ showLineNumbers: boolean,
311
+ ): Promise<void> {
312
+ // If topics are specified, search only those; otherwise search all subjects
313
+ const subjects = topics.length > 0 ? getSubject(...topics) : getAllSubjects();
314
+
315
+ interface CodeBlockMatch {
316
+ subjectId: string;
317
+ subjectTitle: string;
318
+ section: string;
319
+ code: string;
320
+ type: "prelude" | "example";
321
+ startLine?: number;
322
+ endLine?: number;
323
+ }
324
+
325
+ const matches: CodeBlockMatch[] = [];
326
+
327
+ for (const subject of subjects) {
328
+ // Build the rendered markdown to get correct line numbers (matching --start/--end behavior)
329
+ const fullMarkdown = buildSubjectsMarkdown([subject]);
330
+ const renderedOutput = await marked.parse(fullMarkdown);
331
+ const renderedLines = renderedOutput.split("\n");
332
+
333
+ // Search in prelude blocks
334
+ for (const block of subject.prelude) {
335
+ if (block.id === id) {
336
+ // Find line numbers in the rendered output
337
+ let startLine: number | undefined;
338
+ let endLine: number | undefined;
339
+
340
+ // Search for the prelude code in the rendered output
341
+ const codeLines = block.code.split("\n");
342
+ const firstCodeLine = codeLines[0].trim();
343
+
344
+ for (let i = 0; i < renderedLines.length; i++) {
345
+ // Strip ANSI codes before comparing
346
+ if (stripVTControlCharacters(renderedLines[i]).trim() === firstCodeLine) {
347
+ // Found the start of the code
348
+ startLine = i + 1; // 1-based line numbers
349
+ endLine = i + codeLines.length;
350
+ break;
351
+ }
352
+ }
353
+
354
+ matches.push({
355
+ subjectId: subject.id,
356
+ subjectTitle: subject.title,
357
+ section: "Prelude",
358
+ code: block.code,
359
+ type: "prelude",
360
+ startLine,
361
+ endLine,
362
+ });
363
+ }
364
+ }
365
+
366
+ // Search in examples
367
+ for (const example of subject.examples) {
368
+ if (example.id === id) {
369
+ // Try to find which section this example belongs to
370
+ let sectionName = "Unknown Section";
371
+ let startLine: number | undefined;
372
+ let endLine: number | undefined;
373
+
374
+ for (const section of subject.sections) {
375
+ if (
376
+ section.content.includes(example.code.substring(0, Math.min(50, example.code.length)))
377
+ ) {
378
+ sectionName = section.heading;
379
+
380
+ // Find line numbers in the rendered output
381
+ const codeLines = example.code.split("\n");
382
+ const firstCodeLine = codeLines[0].trim();
383
+
384
+ for (let i = 0; i < renderedLines.length; i++) {
385
+ // Strip ANSI codes before comparing
386
+ if (stripVTControlCharacters(renderedLines[i]).trim() === firstCodeLine) {
387
+ // Found the start of the code
388
+ startLine = i + 1; // 1-based line numbers
389
+ endLine = i + codeLines.length;
390
+ break;
391
+ }
392
+ }
393
+ break;
394
+ }
395
+ }
396
+
397
+ matches.push({
398
+ subjectId: subject.id,
399
+ subjectTitle: subject.title,
400
+ section: sectionName,
401
+ code: example.code,
402
+ type: "example",
403
+ startLine,
404
+ endLine,
405
+ });
406
+ }
407
+ }
408
+ }
409
+
410
+ if (matches.length === 0) {
411
+ console.error(`Error: No code block found with id "${id}"`);
412
+ if (topics.length > 0) {
413
+ console.error(`Searched in topics: ${topics.join(", ")}`);
414
+ } else {
415
+ console.error("Searched in all available topics");
416
+ }
417
+ process.exit(1);
418
+ }
419
+
420
+ // Build markdown output
421
+ for (let i = 0; i < matches.length; i++) {
422
+ const match = matches[i];
423
+
424
+ if (matches.length > 1 && i > 0) {
425
+ console.log("\n---\n");
426
+ }
427
+
428
+ // Build markdown for this match
429
+ let matchMarkdown = `# ${match.subjectTitle}\n\n`;
430
+ matchMarkdown += `## ${match.section}\n\n`;
431
+
432
+ // Add line number info if available and requested (as plain text, not in markdown)
433
+ if (showLineNumbers && match.startLine && match.endLine) {
434
+ console.log(`Lines ${match.startLine}-${match.endLine} (use with --start/--end)\n`);
435
+ }
436
+
437
+ matchMarkdown += `\`\`\`typescript\n${match.code}\n\`\`\`\n`;
438
+
439
+ // Render the markdown
440
+ const rendered = await marked.parse(matchMarkdown);
441
+ console.log(rendered);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Print information about the corpus command
447
+ */
448
+ function printCorpusHelp(): void {
449
+ console.log("Fragno Corpus - Code examples and documentation (similar to LLMs.txt");
450
+ console.log("");
451
+ console.log("Usage: fragno-cli corpus [options] [topic...]");
452
+ console.log("");
453
+ console.log("Options:");
454
+ console.log(" -n, --no-line-numbers Hide line numbers (shown by default)");
455
+ console.log(" -s, --start N Starting line number to display from");
456
+ console.log(" -e, --end N Ending line number to display to");
457
+ console.log(" --headings Show only headings and code block IDs");
458
+ console.log(" --id <id> Retrieve a specific code block by ID");
459
+ console.log("");
460
+ console.log("Examples:");
461
+ console.log(" fragno-cli corpus # List all available topics");
462
+ console.log(" fragno-cli corpus defining-routes # Show route definition examples");
463
+ console.log(" fragno-cli corpus --headings database-querying");
464
+ console.log(" # Show structure overview");
465
+ console.log(" fragno-cli corpus --start 10 --end 50 database-querying");
466
+ console.log(" # Show specific lines");
467
+ console.log(" fragno-cli corpus --id create-user # Get code block by ID");
468
+ console.log(" fragno-cli corpus database-adapters kysely-adapter");
469
+ console.log(" # Show multiple topics");
470
+ console.log("");
471
+ console.log("Available topics:");
472
+
473
+ const subjects = getSubjects();
474
+
475
+ // Group subjects by their tree structure
476
+ const rootSubjects: Array<{
477
+ id: string;
478
+ title: string;
479
+ children: Array<{ id: string; title: string }>;
480
+ }> = [];
481
+ const subjectMap = new Map(subjects.map((s) => [s.id, s]));
482
+
483
+ for (const subject of subjects) {
484
+ const parent = getSubjectParent(subject.id);
485
+ if (!parent) {
486
+ // This is a root subject
487
+ const children = getSubjectChildren(subject.id);
488
+ rootSubjects.push({
489
+ id: subject.id,
490
+ title: subject.title,
491
+ children: children.map((childId) => ({
492
+ id: childId,
493
+ title: subjectMap.get(childId)?.title || childId,
494
+ })),
495
+ });
496
+ }
497
+ }
498
+
499
+ // Display in tree format
500
+ for (const root of rootSubjects) {
501
+ console.log(` ${root.id.padEnd(30)} ${root.title}`);
502
+ for (let i = 0; i < root.children.length; i++) {
503
+ const child = root.children[i];
504
+ const isLast = i === root.children.length - 1;
505
+ const connector = isLast ? "└─" : "├─";
506
+ console.log(` ${connector} ${child.id.padEnd(26)} ${child.title}`);
507
+ }
508
+ }
509
+ }
510
+
511
+ export const corpusCommand = define({
512
+ name: "corpus",
513
+ description: "View code examples and documentation for Fragno",
514
+ args: {
515
+ "no-line-numbers": {
516
+ type: "boolean",
517
+ short: "n",
518
+ description: "Hide line numbers (line numbers are shown by default)",
519
+ },
520
+ start: {
521
+ type: "number",
522
+ short: "s",
523
+ description: "Starting line number (1-based) to display from",
524
+ },
525
+ end: {
526
+ type: "number",
527
+ short: "e",
528
+ description: "Ending line number (1-based) to display to",
529
+ },
530
+ headings: {
531
+ type: "boolean",
532
+ description: "Show only section headings and code block IDs with line numbers",
533
+ },
534
+ id: {
535
+ type: "string",
536
+ description: "Retrieve a specific code block by ID",
537
+ },
538
+ },
539
+ run: async (ctx) => {
540
+ const topics = ctx.positionals;
541
+ const showLineNumbers = !(ctx.values["no-line-numbers"] ?? false);
542
+ const startLine = ctx.values.start;
543
+ const endLine = ctx.values.end;
544
+ const headingsOnly = ctx.values.headings ?? false;
545
+ const codeBlockId = ctx.values.id;
546
+
547
+ // Handle --id flag
548
+ if (codeBlockId) {
549
+ await printCodeBlockById(codeBlockId, topics, showLineNumbers);
550
+ return;
551
+ }
552
+
553
+ // No topics provided - show help
554
+ if (topics.length === 0) {
555
+ printCorpusHelp();
556
+ return;
557
+ }
558
+
559
+ // Validate line range
560
+ if (startLine !== undefined && endLine !== undefined && startLine > endLine) {
561
+ console.error("Error: --start must be less than or equal to --end");
562
+ process.exit(1);
563
+ }
564
+
565
+ // Load and display requested topics
566
+ try {
567
+ const subjects = getSubject(...topics);
568
+
569
+ await printSubjects(subjects, {
570
+ showLineNumbers,
571
+ startLine,
572
+ endLine,
573
+ headingsOnly,
574
+ });
575
+ } catch (error) {
576
+ console.error("Error loading topics:", error instanceof Error ? error.message : error);
577
+ console.log("\nRun 'fragno-cli corpus' to see available topics.");
578
+ process.exit(1);
579
+ }
580
+ },
581
+ });
@@ -0,0 +1,105 @@
1
+ import { define } from "gunshi";
2
+ import {
3
+ mergeResultsByUrl,
4
+ formatAsMarkdown,
5
+ formatAsJson,
6
+ } from "../utils/format-search-results.js";
7
+
8
+ interface SearchResult {
9
+ id: string;
10
+ type: "page" | "heading" | "text";
11
+ content: string;
12
+ breadcrumbs?: string[];
13
+ contentWithHighlights?: Array<{
14
+ type: string;
15
+ content: string;
16
+ styles?: { highlight?: boolean };
17
+ }>;
18
+ url: string;
19
+ }
20
+
21
+ export const searchCommand = define({
22
+ name: "search",
23
+ description: "Search the Fragno documentation",
24
+ args: {
25
+ limit: {
26
+ type: "number",
27
+ description: "Maximum number of results to show",
28
+ default: 10,
29
+ },
30
+ json: {
31
+ type: "boolean",
32
+ description: "Output results in JSON format",
33
+ default: false,
34
+ },
35
+ markdown: {
36
+ type: "boolean",
37
+ description: "Output results in Markdown format (default)",
38
+ default: true,
39
+ },
40
+ "base-url": {
41
+ type: "string",
42
+ description: "Base URL for the documentation site",
43
+ default: "fragno.dev",
44
+ },
45
+ },
46
+ run: async (ctx) => {
47
+ const query = ctx.positionals.join(" ");
48
+
49
+ if (!query || query.trim().length === 0) {
50
+ throw new Error("Please provide a search query");
51
+ }
52
+
53
+ // Determine output mode
54
+ const jsonMode = ctx.values.json as boolean;
55
+ const baseUrl = ctx.values["base-url"] as string;
56
+
57
+ if (!jsonMode) {
58
+ console.log(`Searching for: "${query}"\n`);
59
+ }
60
+
61
+ try {
62
+ // Make request to the docs search API
63
+ const encodedQuery = encodeURIComponent(query);
64
+ const response = await fetch(`https://${baseUrl}/api/search?query=${encodedQuery}`);
65
+
66
+ if (!response.ok) {
67
+ throw new Error(`API request failed with status ${response.status}`);
68
+ }
69
+
70
+ const results = (await response.json()) as SearchResult[];
71
+
72
+ // Apply limit
73
+ const limit = ctx.values.limit as number;
74
+ const limitedResults = results.slice(0, limit);
75
+
76
+ if (limitedResults.length === 0) {
77
+ if (jsonMode) {
78
+ console.log("[]");
79
+ } else {
80
+ console.log("No results found.");
81
+ }
82
+ return;
83
+ }
84
+
85
+ // Merge results by URL
86
+ const mergedResults = mergeResultsByUrl(limitedResults, baseUrl);
87
+
88
+ // Output based on mode
89
+ if (jsonMode) {
90
+ console.log(formatAsJson(mergedResults));
91
+ } else {
92
+ // Markdown mode (default)
93
+ console.log(
94
+ `Found ${results.length} result${results.length === 1 ? "" : "s"}${results.length > limit ? ` (showing ${limit})` : ""}\n`,
95
+ );
96
+ console.log(formatAsMarkdown(mergedResults));
97
+ }
98
+ } catch (error) {
99
+ if (error instanceof Error) {
100
+ throw new Error(`Search failed: ${error.message}`);
101
+ }
102
+ throw new Error("Search failed: An unknown error occurred");
103
+ }
104
+ },
105
+ });