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 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: "--timeout N",
212 Description: "LLM request timeout in seconds (default: 300)",
213 Default: "300",
214 },
215 {
216 Name: "--system-prompt TEXT",
217 Description: "Custom system prompt for LLM",
218 },
219 },
220 },
221 {
222 Title: "Output Options",
223 Options: []HelpOption{
224 {
225 Short: "-o",
226 Name: "--output FORMAT",
227 Description: "Output format",
228 Type: "text|markdown|json",
229 Default: "text",
230 },
231 {
232 Name: "--output-file FILE",
233 Description: "Write output to FILE instead of stdout",
234 },
235 {
236 Name: "--include-articles",
237 Description: "Include original articles in JSON output",
238 },
239 {
240 Short: "-P",
241 Name: "--plain",
242 Description: "Output only the raw LLM response without any formatting or metadata",
243 },
244 },
245 },
246 {
247 Title: "Interface Options",
248 Options: []HelpOption{
249 {
250 Short: "-t",
251 Name: "--tui",
252 Description: "Enable TUI interface with progress bar, live counters, and elapsed time",
253 },
254 {
255 Short: "-D",
256 Name: "--dry-run",
257 Description: "Validate config, show article statistics, and exit without making LLM API calls",
258 },
259 {
260 Short: "-v",
261 Name: "--verbose",
262 Description: "Enable verbose logging",
263 },
264 },
265 },
266 {
267 Title: "General Options",
268 Options: []HelpOption{
269 {
270 Name: "--version",
271 Description: "Show version information and exit",
272 },
273 {
274 Short: "-h",
275 Name: "--help",
276 Description: "Show this help message and exit",
277 },
278 },
279 },
280 }
281
282 flagWidth := maxFlagWidth(sections)
283
284 for _, section := range sections {
285 b.WriteString(formatSection(section, flagWidth, 0))
286 }
287
288 b.WriteString("\n")
289 b.WriteString(headerStyle.Render("Examples:"))
290 b.WriteString("\n\n")
291
292 examples := []struct {
293 cmd string
294 desc string
295 }{
296 {"llm_aggregator -f feeds.txt -p \"Summarise news\"", "Basic usage with feeds file"},
297 {"curl -s https://example.com/feed.xml | llm_aggregator --stdin -p \"Summarise\"", "RSS from stdin"},
298 {"llm_aggregator -f feeds.txt --stdin -p \"Summarise all\"", "Combine feeds file with stdin feed"},
299 {"llm_aggregator -f feeds.txt -p \"Linux news\" -i linux,opensource -d 3", "Filter by keywords and age"},
300 {"llm_aggregator -f feeds.txt -p \"Summarise tech news\" -t", "With TUI progress bar"},
301 {"llm_aggregator -f feeds.txt -p \"Summarise news\" -D", "Dry run (no LLM API calls)"},
302 {"llm_aggregator -f feeds.txt -p \"Analyse AI\" -o json --output-file output.json", "JSON output to file"},
303 {"llm_aggregator -f feeds.txt -p \"Summarise news\" -P", "Plain output (no metadata)"},
304 }
305
306 for _, ex := range examples {
307 if shouldStyle() {
308 fmt.Fprintf(&b, " %s\n %s\n\n", flagStyle.Render(ex.cmd), ex.desc)
309 } else {
310 fmt.Fprintf(&b, " %s\n %s\n\n", ex.cmd, ex.desc)
311 }
312 }
313
314 return b.String()
315}
316
317func WriteHelp(args *Args, w *os.File) {
318 output := BuildStyledHelp(args)
319 w.WriteString(output) //nolint:errcheck
320}