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: "-f",
125 Name: "--feeds-file FILE",
126 Description: "Path to file containing RSS feed URLs (one per line)",
127 Required: true,
128 },
129 {
130 Short: "-p",
131 Name: "--prompt PROMPT",
132 Description: "User prompt for summarisation/analysis",
133 Required: true,
134 },
135 },
136 },
137 {
138 Title: "Feed Aggregation",
139 Options: []HelpOption{
140 {
141 Short: "-n",
142 Name: "--max-articles-per-feed N",
143 Description: "Maximum articles to fetch from each feed",
144 Default: "10",
145 },
146 {
147 Short: "-d",
148 Name: "--max-days-old N",
149 Description: "Only include articles from the last N days (0 for all)",
150 Default: "7",
151 },
152 {
153 Name: "--max-total-articles N",
154 Description: "Maximum total articles to process across all feeds",
155 Default: "20",
156 },
157 },
158 },
159 {
160 Title: "Content Filtering",
161 Options: []HelpOption{
162 {
163 Short: "-i",
164 Name: "--include-keywords LIST",
165 Description: "Comma-separated keywords to include (case-insensitive)",
166 },
167 {
168 Short: "-e",
169 Name: "--exclude-keywords LIST",
170 Description: "Comma-separated keywords to exclude (case-insensitive)",
171 },
172 },
173 },
174 {
175 Title: "LLM API Options",
176 Options: []HelpOption{
177 {
178 Name: "--api-key KEY",
179 Description: "OpenAI-compatible API key (default: read from $LLM_AGGREGATOR_API_KEY)",
180 },
181 {
182 Name: "--base-url URL",
183 Description: "API base URL",
184 Default: "https://api.deepseek.com",
185 },
186 {
187 Short: "-m",
188 Name: "--model MODEL",
189 Description: "LLM model to use",
190 Default: "deepseek-chat",
191 },
192 {
193 Name: "--max-tokens N",
194 Description: "Maximum tokens in LLM response",
195 Default: "4000",
196 },
197 {
198 Name: "--temperature VALUE",
199 Description: "Sampling temperature (0.0 to 1.0)",
200 Default: "0.7",
201 },
202 {
203 Name: "--system-prompt TEXT",
204 Description: "Custom system prompt for LLM",
205 },
206 },
207 },
208 {
209 Title: "Output Options",
210 Options: []HelpOption{
211 {
212 Short: "-o",
213 Name: "--output FORMAT",
214 Description: "Output format",
215 Type: "text|markdown|json",
216 Default: "text",
217 },
218 {
219 Name: "--output-file FILE",
220 Description: "Write output to FILE instead of stdout",
221 },
222 {
223 Name: "--include-articles",
224 Description: "Include original articles in JSON output",
225 },
226 {
227 Short: "-P",
228 Name: "--plain",
229 Description: "Output only the raw LLM response without any formatting or metadata",
230 },
231 },
232 },
233 {
234 Title: "Interface Options",
235 Options: []HelpOption{
236 {
237 Short: "-t",
238 Name: "--tui",
239 Description: "Enable TUI interface with progress bar, live counters, and elapsed time",
240 },
241 {
242 Short: "-D",
243 Name: "--dry-run",
244 Description: "Validate config, show article statistics, and exit without making LLM API calls",
245 },
246 {
247 Short: "-v",
248 Name: "--verbose",
249 Description: "Enable verbose logging",
250 },
251 },
252 },
253 {
254 Title: "General Options",
255 Options: []HelpOption{
256 {
257 Name: "--version",
258 Description: "Show version information and exit",
259 },
260 {
261 Short: "-h",
262 Name: "--help",
263 Description: "Show this help message and exit",
264 },
265 },
266 },
267 }
268
269 flagWidth := maxFlagWidth(sections)
270
271 for _, section := range sections {
272 b.WriteString(formatSection(section, flagWidth, 0))
273 }
274
275 b.WriteString("\n")
276 b.WriteString(headerStyle.Render("Examples:"))
277 b.WriteString("\n\n")
278
279 examples := []struct {
280 cmd string
281 desc string
282 }{
283 {"llm_aggregator -f feeds.txt -p \"Summarise news\"", "Basic usage with short flags"},
284 {"llm_aggregator -f feeds.txt -p \"Linux news\" -i linux,opensource -d 3", "Filter by keywords and age"},
285 {"llm_aggregator -f feeds.txt -p \"Summarise tech news\" -t", "With TUI progress bar"},
286 {"llm_aggregator -f feeds.txt -p \"Summarise news\" -D", "Dry run (no LLM API calls)"},
287 {"llm_aggregator -f feeds.txt -p \"Analyse AI\" -o json --output-file output.json", "JSON output to file"},
288 {"llm_aggregator -f feeds.txt -p \"Summarise news\" -P", "Plain output (no metadata)"},
289 }
290
291 for _, ex := range examples {
292 if shouldStyle() {
293 fmt.Fprintf(&b, " %s\n %s\n\n", flagStyle.Render(ex.cmd), ex.desc)
294 } else {
295 fmt.Fprintf(&b, " %s\n %s\n\n", ex.cmd, ex.desc)
296 }
297 }
298
299 return b.String()
300}
301
302func WriteHelp(args *Args, w *os.File) {
303 output := BuildStyledHelp(args)
304 w.WriteString(output)
305}