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