all repos — llm_aggregator @ 26eb79c063daa25ab326497558d2b3bf17a675bd

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/cli/help.go (view raw)

  1package cli
  2
  3import (
  4	"fmt"
  5	"os"
  6	"strings"
  7
  8	"github.com/charmbracelet/lipgloss"
  9	"llm_aggregator/internal/style"
 10)
 11
 12type HelpOption struct {
 13	Short       string
 14	Name        string
 15	Description string
 16	Default     string
 17	Type        string
 18	Required    bool
 19}
 20
 21type HelpSection struct {
 22	Title   string
 23	Options []HelpOption
 24}
 25
 26var (
 27	headerStyle = lipgloss.NewStyle().Bold(true)
 28	flagStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color(style.Cyan))
 29)
 30
 31func shouldStyle() bool {
 32	return !style.NoColor()
 33}
 34
 35func renderFlag(short, long string) string {
 36	if short != "" {
 37		return fmt.Sprintf("%s, %s", short, long)
 38	}
 39	return fmt.Sprintf("%s", long)
 40}
 41
 42func getOptionValue(opt HelpOption) string {
 43	if opt.Type != "" {
 44		val := opt.Type
 45		if opt.Default != "" {
 46			val = fmt.Sprintf("%s (default: %s)", opt.Type, opt.Default)
 47		}
 48		return val
 49	}
 50	if opt.Default != "" {
 51		return opt.Default
 52	}
 53	return ""
 54}
 55
 56func buildOptionLine(opt HelpOption, flagWidth int) string {
 57	flagStr := renderFlag(opt.Short, opt.Name)
 58	padding := max(flagWidth-lipgloss.Width(flagStr), 0)
 59
 60	var line strings.Builder
 61
 62	if shouldStyle() {
 63		line.WriteString(flagStyle.Render(flagStr))
 64		line.WriteString(strings.Repeat(" ", padding+2))
 65	} else {
 66		line.WriteString(flagStr)
 67		line.WriteString(strings.Repeat(" ", padding+2))
 68	}
 69
 70	value := getOptionValue(opt)
 71	if value != "" {
 72		if shouldStyle() {
 73			line.WriteString(flagStyle.Render(value))
 74			line.WriteString("  ")
 75		} else {
 76			line.WriteString(value)
 77			line.WriteString("  ")
 78		}
 79	}
 80
 81	if opt.Required {
 82		if shouldStyle() {
 83			line.WriteString("[required] ")
 84		} else {
 85			line.WriteString("[required] ")
 86		}
 87	}
 88
 89	line.WriteString(opt.Description)
 90
 91	return line.String()
 92}
 93
 94func maxFlagWidth(sections []HelpSection) int {
 95	maxWidth := 0
 96	for _, section := range sections {
 97		for _, opt := range section.Options {
 98			flagStr := renderFlag(opt.Short, opt.Name)
 99			width := lipgloss.Width(flagStr)
100			maxWidth = max(maxWidth, width)
101		}
102	}
103	return maxWidth
104}
105
106func formatSection(section HelpSection, flagWidth int, _ int) string {
107	var b strings.Builder
108
109	b.WriteString("\n")
110	b.WriteString(headerStyle.Render(section.Title))
111	b.WriteString("\n")
112
113	for _, opt := range section.Options {
114		flagStr := renderFlag(opt.Short, opt.Name)
115		padding := max(flagWidth-lipgloss.Width(flagStr), 0)
116
117		if shouldStyle() {
118			b.WriteString(flagStyle.Render(flagStr))
119		} else {
120			b.WriteString(flagStr)
121		}
122		b.WriteString(strings.Repeat(" ", padding+2))
123
124		value := getOptionValue(opt)
125		if value != "" {
126			if shouldStyle() {
127				b.WriteString(flagStyle.Render(value))
128			} else {
129				b.WriteString(value)
130			}
131			b.WriteString("  ")
132		}
133
134		if opt.Required {
135			b.WriteString("[required] ")
136		}
137
138		b.WriteString(opt.Description)
139		b.WriteString("\n")
140	}
141
142	return b.String()
143}
144
145var descriptionStyle = lipgloss.NewStyle()
146
147func getTerminalWidth() int {
148	if termWidth := os.Getenv("COLUMNS"); termWidth != "" {
149		var width int
150		if _, err := fmt.Sscanf(termWidth, "%d", &width); err == nil && width > 0 {
151			return width
152		}
153	}
154	return 80
155}
156
157func BuildStyledHelp(args *Args) string {
158	var b strings.Builder
159
160	b.WriteString(headerStyle.Render("llm_aggregator"))
161	b.WriteString("\n\n")
162
163	b.WriteString(args.Description())
164	b.WriteString("\n\n")
165
166	b.WriteString(headerStyle.Render("FLAGS"))
167	b.WriteString("\n")
168
169	sections := []HelpSection{
170		{
171			Title: "Required Arguments",
172			Options: []HelpOption{
173				{
174					Short:       "-f",
175					Name:        "--feeds-file FILE",
176					Description: "Path to file containing RSS feed URLs (one per line)",
177					Required:    true,
178				},
179				{
180					Short:       "-p",
181					Name:        "--prompt PROMPT",
182					Description: "User prompt for summarisation/analysis",
183					Required:    true,
184				},
185			},
186		},
187		{
188			Title: "Feed Aggregation",
189			Options: []HelpOption{
190				{
191					Short:       "-n",
192					Name:        "--max-articles-per-feed N",
193					Description: "Maximum articles to fetch from each feed",
194					Default:     "10",
195				},
196				{
197					Short:       "-d",
198					Name:        "--max-days-old N",
199					Description: "Only include articles from the last N days (0 for all)",
200					Default:     "7",
201				},
202				{
203					Name:        "--max-total-articles N",
204					Description: "Maximum total articles to process across all feeds",
205					Default:     "20",
206				},
207			},
208		},
209		{
210			Title: "Content Filtering",
211			Options: []HelpOption{
212				{
213					Short:       "-i",
214					Name:        "--include-keywords LIST",
215					Description: "Comma-separated keywords to include (case-insensitive)",
216				},
217				{
218					Short:       "-e",
219					Name:        "--exclude-keywords LIST",
220					Description: "Comma-separated keywords to exclude (case-insensitive)",
221				},
222			},
223		},
224		{
225			Title: "LLM API Options",
226			Options: []HelpOption{
227				{
228					Name:        "--api-key KEY",
229					Description: "OpenAI-compatible API key (default: read from $LLM_AGGREGATOR_API_KEY)",
230				},
231				{
232					Name:        "--base-url URL",
233					Description: "API base URL",
234					Default:     "https://api.deepseek.com",
235				},
236				{
237					Short:       "-m",
238					Name:        "--model MODEL",
239					Description: "LLM model to use",
240					Default:     "deepseek-chat",
241				},
242				{
243					Name:        "--max-tokens N",
244					Description: "Maximum tokens in LLM response",
245					Default:     "4000",
246				},
247				{
248					Name:        "--temperature VALUE",
249					Description: "Sampling temperature (0.0 to 1.0)",
250					Default:     "0.7",
251				},
252				{
253					Name:        "--system-prompt TEXT",
254					Description: "Custom system prompt for LLM",
255				},
256			},
257		},
258		{
259			Title: "Output Options",
260			Options: []HelpOption{
261				{
262					Short:       "-o",
263					Name:        "--output FORMAT",
264					Description: "Output format",
265					Type:        "text|markdown|json",
266					Default:     "text",
267				},
268				{
269					Name:        "--output-file FILE",
270					Description: "Write output to FILE instead of stdout",
271				},
272				{
273					Name:        "--include-articles",
274					Description: "Include original articles in JSON output",
275				},
276			},
277		},
278		{
279			Title: "Interface Options",
280			Options: []HelpOption{
281				{
282					Short:       "-t",
283					Name:        "--tui",
284					Description: "Enable TUI interface with progress bar, live counters, and elapsed time",
285				},
286				{
287					Short:       "-D",
288					Name:        "--dry-run",
289					Description: "Validate config, show article statistics, and exit without making LLM API calls",
290				},
291				{
292					Short:       "-v",
293					Name:        "--verbose",
294					Description: "Enable verbose logging",
295				},
296			},
297		},
298		{
299			Title: "General Options",
300			Options: []HelpOption{
301				{
302					Name:        "--version",
303					Description: "Show version information and exit",
304				},
305				{
306					Short:       "-h",
307					Name:        "--help",
308					Description: "Show this help message and exit",
309				},
310			},
311		},
312	}
313
314	flagWidth := maxFlagWidth(sections)
315
316	for _, section := range sections {
317		b.WriteString(formatSection(section, flagWidth, 0))
318	}
319
320	b.WriteString("\n")
321	b.WriteString(headerStyle.Render("Examples:"))
322	b.WriteString("\n\n")
323
324	examples := []struct {
325		cmd  string
326		desc string
327	}{
328		{"llm_aggregator -f feeds.txt -p \"Summarise news\"", "Basic usage with short flags"},
329		{"llm_aggregator -f feeds.txt -p \"Linux news\" -i linux,opensource -d 3", "Filter by keywords and age"},
330		{"llm_aggregator -f feeds.txt -p \"Summarise tech news\" -t", "With TUI progress bar"},
331		{"llm_aggregator -f feeds.txt -p \"Summarise news\" -D", "Dry run (no LLM API calls)"},
332		{"llm_aggregator -f feeds.txt -p \"Analyse AI\" -o json --output-file output.json", "JSON output to file"},
333	}
334
335	for _, ex := range examples {
336		if shouldStyle() {
337			fmt.Fprintf(&b, "  %s\n    %s\n\n", flagStyle.Render(ex.cmd), ex.desc)
338		} else {
339			fmt.Fprintf(&b, "  %s\n    %s\n\n", ex.cmd, ex.desc)
340		}
341	}
342
343	return b.String()
344}
345
346func WriteHelp(args *Args, w *os.File) {
347	output := BuildStyledHelp(args)
348	w.WriteString(output)
349}