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}