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}