all repos — llm_aggregator @ master

A CLI tool to aggregate RSS feeds and summarise them with LLMs

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}