@fragno-dev/cli 0.1.14 → 0.1.16

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.
@@ -1,50 +1,444 @@
1
1
  import { define } from "gunshi";
2
- import { getSubjects, getSubject } from "@fragno-dev/corpus";
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
+ }
3
303
 
4
304
  /**
5
- * Print a subject with its examples
305
+ * Find and print code blocks by ID
6
306
  */
7
- function printSubject(subject: ReturnType<typeof getSubject>[number]): void {
8
- console.log(`\n${"=".repeat(60)}`);
9
- console.log(`${subject.title}`);
10
- console.log(`${"=".repeat(60)}\n`);
11
-
12
- if (subject.description) {
13
- console.log(subject.description);
14
- console.log();
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;
15
323
  }
16
324
 
17
- // Print imports block if present
18
- if (subject.imports) {
19
- console.log("### Imports\n");
20
- console.log("```typescript");
21
- console.log(subject.imports);
22
- console.log("```\n");
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
+ }
23
408
  }
24
409
 
25
- // Print init block if present
26
- if (subject.init) {
27
- console.log("### Initialization\n");
28
- console.log("```typescript");
29
- console.log(subject.init);
30
- console.log("```\n");
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);
31
418
  }
32
419
 
33
- // Print examples
34
- for (let i = 0; i < subject.examples.length; i++) {
35
- const example = subject.examples[i];
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
+ }
36
427
 
37
- console.log(`### Example ${i + 1}\n`);
38
- console.log("```typescript");
39
- console.log(example.code);
40
- console.log("```");
428
+ // Build markdown for this match
429
+ let matchMarkdown = `# ${match.subjectTitle}\n\n`;
430
+ matchMarkdown += `## ${match.section}\n\n`;
41
431
 
42
- if (example.explanation) {
43
- console.log();
44
- console.log(example.explanation);
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`);
45
435
  }
46
436
 
47
- console.log();
437
+ matchMarkdown += `\`\`\`typescript\n${match.code}\n\`\`\`\n`;
438
+
439
+ // Render the markdown
440
+ const rendered = await marked.parse(matchMarkdown);
441
+ console.log(rendered);
48
442
  }
49
443
  }
50
444
 
@@ -52,29 +446,109 @@ function printSubject(subject: ReturnType<typeof getSubject>[number]): void {
52
446
  * Print information about the corpus command
53
447
  */
54
448
  function printCorpusHelp(): void {
55
- console.log("Fragno Corpus - Code examples and documentation");
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...]");
56
452
  console.log("");
57
- console.log("Usage: fragno-cli corpus [topic...]");
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");
58
459
  console.log("");
59
460
  console.log("Examples:");
60
461
  console.log(" fragno-cli corpus # List all available topics");
61
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");
62
468
  console.log(" fragno-cli corpus database-adapters kysely-adapter");
63
469
  console.log(" # Show multiple topics");
64
470
  console.log("");
65
471
  console.log("Available topics:");
66
472
 
67
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
+
68
483
  for (const subject of subjects) {
69
- console.log(` ${subject.id.padEnd(30)} ${subject.title}`);
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
+ }
70
508
  }
71
509
  }
72
510
 
73
511
  export const corpusCommand = define({
74
512
  name: "corpus",
75
513
  description: "View code examples and documentation for Fragno",
76
- run: (ctx) => {
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) => {
77
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
+ }
78
552
 
79
553
  // No topics provided - show help
80
554
  if (topics.length === 0) {
@@ -82,17 +556,22 @@ export const corpusCommand = define({
82
556
  return;
83
557
  }
84
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
+
85
565
  // Load and display requested topics
86
566
  try {
87
567
  const subjects = getSubject(...topics);
88
568
 
89
- for (const subject of subjects) {
90
- printSubject(subject);
91
- }
92
-
93
- console.log(`${"=".repeat(60)}`);
94
- console.log(`Displayed ${subjects.length} topic(s)`);
95
- console.log(`${"=".repeat(60)}\n`);
569
+ await printSubjects(subjects, {
570
+ showLineNumbers,
571
+ startLine,
572
+ endLine,
573
+ headingsOnly,
574
+ });
96
575
  } catch (error) {
97
576
  console.error("Error loading topics:", error instanceof Error ? error.message : error);
98
577
  console.log("\nRun 'fragno-cli corpus' to see available topics.");