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 },
228 {
229 Title: "Interface Options",
230 Options: []HelpOption{
231 {
232 Short: "-t",
233 Name: "--tui",
234 Description: "Enable TUI interface with progress bar, live counters, and elapsed time",
235 },
236 {
237 Short: "-D",
238 Name: "--dry-run",
239 Description: "Validate config, show article statistics, and exit without making LLM API calls",
240 },
241 {
242 Short: "-v",
243 Name: "--verbose",
244 Description: "Enable verbose logging",
245 },
246 },
247 },
248 {
249 Title: "General Options",
250 Options: []HelpOption{
251 {
252 Name: "--version",
253 Description: "Show version information and exit",
254 },
255 {
256 Short: "-h",
257 Name: "--help",
258 Description: "Show this help message and exit",
259 },
260 },
261 },
262 }
263
264 flagWidth := maxFlagWidth(sections)
265
266 for _, section := range sections {
267 b.WriteString(formatSection(section, flagWidth, 0))
268 }
269
270 b.WriteString("\n")
271 b.WriteString(headerStyle.Render("Examples:"))
272 b.WriteString("\n\n")
273
274 examples := []struct {
275 cmd string
276 desc string
277 }{
278 {"llm_aggregator -f feeds.txt -p \"Summarise news\"", "Basic usage with short flags"},
279 {"llm_aggregator -f feeds.txt -p \"Linux news\" -i linux,opensource -d 3", "Filter by keywords and age"},
280 {"llm_aggregator -f feeds.txt -p \"Summarise tech news\" -t", "With TUI progress bar"},
281 {"llm_aggregator -f feeds.txt -p \"Summarise news\" -D", "Dry run (no LLM API calls)"},
282 {"llm_aggregator -f feeds.txt -p \"Analyse AI\" -o json --output-file output.json", "JSON output to file"},
283 }
284
285 for _, ex := range examples {
286 if shouldStyle() {
287 fmt.Fprintf(&b, " %s\n %s\n\n", flagStyle.Render(ex.cmd), ex.desc)
288 } else {
289 fmt.Fprintf(&b, " %s\n %s\n\n", ex.cmd, ex.desc)
290 }
291 }
292
293 return b.String()
294}
295
296func WriteHelp(args *Args, w *os.File) {
297 output := BuildStyledHelp(args)
298 w.WriteString(output)
299}