all repos — llm_aggregator @ a0e7d1a442a251ae94645cdddccf30ded92e97a1

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 maxFlagWidth(sections []HelpSection) int {
 57	maxWidth := 0
 58	for _, section := range sections {
 59		for _, opt := range section.Options {
 60			flagStr := renderFlag(opt.Short, opt.Name)
 61			width := lipgloss.Width(flagStr)
 62			maxWidth = max(maxWidth, width)
 63		}
 64	}
 65	return maxWidth
 66}
 67
 68func formatSection(section HelpSection, flagWidth int, _ int) string {
 69	var b strings.Builder
 70
 71	b.WriteString("\n")
 72	b.WriteString(headerStyle.Render(section.Title))
 73	b.WriteString("\n")
 74
 75	for _, opt := range section.Options {
 76		flagStr := renderFlag(opt.Short, opt.Name)
 77		padding := max(flagWidth-lipgloss.Width(flagStr), 0)
 78
 79		if shouldStyle() {
 80			b.WriteString(flagStyle.Render(flagStr))
 81		} else {
 82			b.WriteString(flagStr)
 83		}
 84		b.WriteString(strings.Repeat(" ", padding+2))
 85
 86		value := getOptionValue(opt)
 87		if value != "" {
 88			if shouldStyle() {
 89				b.WriteString(flagStyle.Render(value))
 90			} else {
 91				b.WriteString(value)
 92			}
 93			b.WriteString("  ")
 94		}
 95
 96		if opt.Required {
 97			b.WriteString("[required] ")
 98		}
 99
100		b.WriteString(opt.Description)
101		b.WriteString("\n")
102	}
103
104	return b.String()
105}
106
107func BuildStyledHelp(args *Args) string {
108	var b strings.Builder
109
110	b.WriteString(headerStyle.Render("llm_aggregator"))
111	b.WriteString("\n\n")
112
113	b.WriteString(args.Description())
114	b.WriteString("\n\n")
115
116	b.WriteString(headerStyle.Render("FLAGS"))
117	b.WriteString("\n")
118
119	sections := []HelpSection{
120		{
121			Title: "Required Arguments",
122			Options: []HelpOption{
123				{
124					Short:       "-p",
125					Name:        "--prompt PROMPT",
126					Description: "User prompt for summarisation/analysis",
127					Required:    true,
128				},
129			},
130		},
131		{
132			Title: "Feed Input",
133			Options: []HelpOption{
134				{
135					Short:       "-f",
136					Name:        "--feeds-file FILE",
137					Description: "Path to file containing RSS feed URLs (one per line)",
138				},
139				{
140					Name:        "--stdin",
141					Description: "Read a single RSS/Atom feed from stdin (can be combined with --feeds-file)",
142				},
143			},
144		},
145		{
146			Title: "Feed Aggregation",
147			Options: []HelpOption{
148				{
149					Short:       "-n",
150					Name:        "--max-articles-per-feed N",
151					Description: "Maximum articles to fetch from each feed",
152					Default:     "10",
153				},
154				{
155					Short:       "-d",
156					Name:        "--max-days-old N",
157					Description: "Only include articles from the last N days (0 for all)",
158					Default:     "7",
159				},
160				{
161					Name:        "--max-total-articles N",
162					Description: "Maximum total articles to process across all feeds",
163					Default:     "20",
164				},
165			},
166		},
167		{
168			Title: "Content Filtering",
169			Options: []HelpOption{
170				{
171					Short:       "-i",
172					Name:        "--include-keywords LIST",
173					Description: "Comma-separated keywords to include (case-insensitive)",
174				},
175				{
176					Short:       "-e",
177					Name:        "--exclude-keywords LIST",
178					Description: "Comma-separated keywords to exclude (case-insensitive)",
179				},
180			},
181		},
182		{
183			Title: "LLM API Options",
184			Options: []HelpOption{
185				{
186					Name:        "--api-key KEY",
187					Description: "OpenAI-compatible API key (default: read from $LLM_AGGREGATOR_API_KEY)",
188				},
189				{
190					Name:        "--base-url URL",
191					Description: "API base URL",
192					Default:     "https://api.deepseek.com",
193				},
194				{
195					Short:       "-m",
196					Name:        "--model MODEL",
197					Description: "LLM model to use",
198					Default:     "deepseek-chat",
199				},
200				{
201					Name:        "--max-tokens N",
202					Description: "Maximum tokens in LLM response",
203					Default:     "4000",
204				},
205				{
206					Name:        "--temperature VALUE",
207					Description: "Sampling temperature (0.0 to 1.0)",
208					Default:     "0.7",
209				},
210				{
211					Name:        "--system-prompt TEXT",
212					Description: "Custom system prompt for LLM",
213				},
214			},
215		},
216		{
217			Title: "Output Options",
218			Options: []HelpOption{
219				{
220					Short:       "-o",
221					Name:        "--output FORMAT",
222					Description: "Output format",
223					Type:        "text|markdown|json",
224					Default:     "text",
225				},
226				{
227					Name:        "--output-file FILE",
228					Description: "Write output to FILE instead of stdout",
229				},
230				{
231					Name:        "--include-articles",
232					Description: "Include original articles in JSON output",
233				},
234				{
235					Short:       "-P",
236					Name:        "--plain",
237					Description: "Output only the raw LLM response without any formatting or metadata",
238				},
239			},
240		},
241		{
242			Title: "Interface Options",
243			Options: []HelpOption{
244				{
245					Short:       "-t",
246					Name:        "--tui",
247					Description: "Enable TUI interface with progress bar, live counters, and elapsed time",
248				},
249				{
250					Short:       "-D",
251					Name:        "--dry-run",
252					Description: "Validate config, show article statistics, and exit without making LLM API calls",
253				},
254				{
255					Short:       "-v",
256					Name:        "--verbose",
257					Description: "Enable verbose logging",
258				},
259			},
260		},
261		{
262			Title: "General Options",
263			Options: []HelpOption{
264				{
265					Name:        "--version",
266					Description: "Show version information and exit",
267				},
268				{
269					Short:       "-h",
270					Name:        "--help",
271					Description: "Show this help message and exit",
272				},
273			},
274		},
275	}
276
277	flagWidth := maxFlagWidth(sections)
278
279	for _, section := range sections {
280		b.WriteString(formatSection(section, flagWidth, 0))
281	}
282
283	b.WriteString("\n")
284	b.WriteString(headerStyle.Render("Examples:"))
285	b.WriteString("\n\n")
286
287	examples := []struct {
288		cmd  string
289		desc string
290	}{
291		{"llm_aggregator -f feeds.txt -p \"Summarise news\"", "Basic usage with feeds file"},
292		{"curl -s https://example.com/feed.xml | llm_aggregator --stdin -p \"Summarise\"", "RSS from stdin"},
293		{"llm_aggregator -f feeds.txt --stdin -p \"Summarise all\"", "Combine feeds file with stdin feed"},
294		{"llm_aggregator -f feeds.txt -p \"Linux news\" -i linux,opensource -d 3", "Filter by keywords and age"},
295		{"llm_aggregator -f feeds.txt -p \"Summarise tech news\" -t", "With TUI progress bar"},
296		{"llm_aggregator -f feeds.txt -p \"Summarise news\" -D", "Dry run (no LLM API calls)"},
297		{"llm_aggregator -f feeds.txt -p \"Analyse AI\" -o json --output-file output.json", "JSON output to file"},
298		{"llm_aggregator -f feeds.txt -p \"Summarise news\" -P", "Plain output (no metadata)"},
299	}
300
301	for _, ex := range examples {
302		if shouldStyle() {
303			fmt.Fprintf(&b, "  %s\n    %s\n\n", flagStyle.Render(ex.cmd), ex.desc)
304		} else {
305			fmt.Fprintf(&b, "  %s\n    %s\n\n", ex.cmd, ex.desc)
306		}
307	}
308
309	return b.String()
310}
311
312func WriteHelp(args *Args, w *os.File) {
313	output := BuildStyledHelp(args)
314	w.WriteString(output)
315}