internal/cli/args.go (view raw)
1package cli
2
3import (
4 "fmt"
5 "os"
6 "strings"
7
8 "github.com/alexflint/go-arg"
9)
10
11var (
12 Version string
13 BuildDate string
14)
15
16// Args represents command-line arguments.
17type Args struct {
18 FeedsFile string `arg:"-f,--feeds-file,required" help:"Path to file containing RSS feed URLs (one per line)"`
19 Prompt string `arg:"-p,--prompt,required" help:"User prompt for summarisation/analysis"`
20
21 // Feed aggregation options
22 MaxArticlesPerFeed *int `arg:"-n,--max-articles-per-feed" help:"Maximum articles to fetch from each feed"`
23 MaxDaysOld *int `arg:"-d,--max-days-old" help:"Only include articles from the last N days"`
24 MaxTotalArticles *int `arg:"--max-total-articles" help:"Maximum total articles to process"`
25
26 // Content filtering
27 IncludeKeywords string `arg:"-i,--include-keywords" help:"Comma-separated list of keywords to include (case-insensitive)"`
28 ExcludeKeywords string `arg:"-e,--exclude-keywords" help:"Comma-separated list of keywords to exclude (case-insensitive)"`
29
30 // LLM API options
31 APIKey *string `arg:"--api-key" help:"OpenAI-compatible API key (default: read from LLM_AGGREGATOR_API_KEY env var)"`
32 BaseURL *string `arg:"--base-url" help:"API base URL"`
33 Model *string `arg:"-m,--model" help:"LLM model to use"`
34 MaxTokens *int `arg:"--max-tokens" help:"Maximum tokens in response"`
35 Temperature *float64 `arg:"--temperature" help:"Sampling temperature (0.0 to 1.0)"`
36
37 // Output options
38 Output string `arg:"-o,--output" help:"Output format" choice:"text,json,markdown"`
39 OutputFile string `arg:"--output-file" help:"Write output to file (default: stdout)"`
40 IncludeArticles bool `arg:"--include-articles" help:"Include original articles in JSON output"`
41 Plain bool `arg:"-P,--plain" help:"Output only the raw LLM response without any formatting or metadata"`
42
43 // System options
44 SystemPrompt string `arg:"--system-prompt" help:"Custom system prompt for LLM"`
45 TUI bool `arg:"-t,--tui" help:"Enable TUI interface with progress bar"`
46 Verbose bool `arg:"-v,--verbose" help:"Show verbose output"`
47 ShowVersion bool `arg:"--version" help:"Show version"`
48 DryRun bool `arg:"-D,--dry-run" help:"Validate config, show article statistics, and exit without making LLM API calls"`
49}
50
51// Version returns the version string.
52func (Args) Version() string {
53 return fmt.Sprintf("llm_aggregator v%s (built %s)", Version, BuildDate)
54}
55
56// Description returns the program description.
57func (Args) Description() string {
58 return "LLM Aggregator - Aggregate RSS feeds and summarise with LLM API"
59}
60
61// ParseKeywords parses comma-separated keywords string into list.
62func ParseKeywords(keywordString string) []string {
63 if keywordString == "" {
64 return nil
65 }
66 keywords := strings.Split(keywordString, ",")
67 result := make([]string, 0, len(keywords))
68 for _, kw := range keywords {
69 if trimmed := strings.TrimSpace(kw); trimmed != "" {
70 result = append(result, trimmed)
71 }
72 }
73 return result
74}
75
76// ParseArgs parses command line arguments.
77func ParseArgs() (*Args, error) {
78 var args Args
79 parser, err := arg.NewParser(arg.Config{
80 Program: "llm_aggregator",
81 }, &args)
82 if err != nil {
83 return nil, err
84 }
85
86 // Handle help and version flags before checking required fields
87 if len(os.Args) > 1 {
88 if os.Args[1] == "-h" || os.Args[1] == "--help" {
89 WriteHelp(&args, os.Stdout)
90 os.Exit(0)
91 }
92 if os.Args[1] == "--version" {
93 fmt.Printf("llm_aggregator v%s (built %s)", Version, BuildDate)
94 os.Exit(0)
95 }
96 }
97
98 err = parser.Parse(os.Args[1:])
99 if err != nil {
100 return nil, err
101 }
102
103 return &args, nil
104}
105
106// ToViperMap converts Args to a map for binding to Viper.
107// Only non-nil values (explicitly provided CLI flags) are included.
108//
109// NOTE: FeedsFile and Prompt are strings (not pointers), so they're ALWAYS
110// included if non-empty. This differs from optional fields which use pointer
111// types to detect explicit provision vs. default zero values. See isZero() in
112// config package.
113func (a *Args) ToViperMap() map[string]any {
114 m := map[string]any{}
115 if a.FeedsFile != "" {
116 m["feeds_file"] = a.FeedsFile
117 }
118 if a.MaxArticlesPerFeed != nil {
119 m["max_articles_per_feed"] = *a.MaxArticlesPerFeed
120 }
121 if a.MaxDaysOld != nil {
122 m["max_days_old"] = *a.MaxDaysOld
123 }
124 if a.MaxTotalArticles != nil {
125 m["max_total_articles"] = *a.MaxTotalArticles
126 }
127 if a.IncludeKeywords != "" {
128 m["include_keywords"] = a.IncludeKeywords
129 }
130 if a.ExcludeKeywords != "" {
131 m["exclude_keywords"] = a.ExcludeKeywords
132 }
133 if a.APIKey != nil {
134 m["api_key"] = *a.APIKey
135 }
136 if a.BaseURL != nil {
137 m["base_url"] = *a.BaseURL
138 }
139 if a.Model != nil {
140 m["model"] = *a.Model
141 }
142 if a.MaxTokens != nil {
143 m["max_tokens"] = *a.MaxTokens
144 }
145 if a.Temperature != nil {
146 m["temperature"] = *a.Temperature
147 }
148 if a.Prompt != "" {
149 m["prompt"] = a.Prompt
150 }
151 if a.SystemPrompt != "" {
152 m["system_prompt"] = a.SystemPrompt
153 }
154 if a.Output != "" {
155 m["output"] = a.Output
156 }
157 if a.OutputFile != "" {
158 m["output_file"] = a.OutputFile
159 }
160 if a.IncludeArticles {
161 m["include_articles"] = a.IncludeArticles
162 }
163 if a.Plain {
164 m["plain"] = a.Plain
165 }
166 return m
167}