feat: add configuration file and environment variable support
- Add `internal/config` package with Viper-based TOML loading
- Load configuration from `~/.config/llm_aggregator/config.toml` (XDG
compliant)
- Support all aggregation, API, and output options in config file
- Implement precedence: CLI args > env vars > config file > built‑in
defaults
- Integrate config loading into `cmd/llm_aggregator.go`
- Call `config.Load()` before argument parsing
- Apply config values to runtime with `applyConfiguration()`
- Gracefully warn if config file exists but fails to load
- Rename environment variable prefix from `DEEPSEEK_API_KEY` to
`LLM_AGGREGATOR_API_KEY`
- Update `internal/cli/args.go` help text and environment variable
reference
- Update `internal/llm/deepseek.go` to read `LLM_AGGREGATOR_API_KEY`
- Update `README.md` and `CHANGELOG.md` to reflect new variable name
- Add `configs/config.example.toml` with documented configuration
options
- Add `internal/config/config_test.go` covering defaults, path
resolution, save/load, and environment variable precedence
- Update `go.mod` / `go.sum` with new dependencies:
- `github.com/spf13/viper` for configuration management
- `github.com/adrg/xdg` for XDG base directory support
- `github.com/pelletier/go-toml/v2` (indirect via viper)
- Revise `README.md` to document the new configuration system,
precedence order, and environment variables
- Update `CHANGELOG.md` for version 0.3.0
- Remove unused `internal/config` global flags (`QuietMode`,
`VerboseMode`) replaced by runtime configuration
@@ -8,10 +8,53 @@ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] +## [0.3.0] - 2026-04-21 + +### Added + +- **Configuration file support via TOML** (XDG-compliant and cross-platform) + - Load settings from `~/.config/llm_aggregator/config.toml` + - Supports all aggregation, API, and output options + - Example configuration file at `configs/config.example.toml` +- **Environment variable support with `LLM_AGGREGATOR_` prefix** + - All configuration options can be set via environment variables + - Precedence order: CLI arguments > environment variables > config file > + built‑in defaults +- New `internal/config` package with Viper integration + - `Load()` and `Save()` methods for configuration management + - `GetConfigPath()` for XDG‑compliant config file location + - `ConfigExists()` to check for existing configuration +- Comprehensive unit tests for configuration loading, saving, and environment + variable precedence (`internal/config/config_test.go`) + ### Changed + +- **Environment variable renamed:** `DEEPSEEK_API_KEY` → + `LLM_AGGREGATOR_API_KEY` +- **CLI help text** now reflects the new API key environment variable and + general configuration options +- **Command‑line argument precedence** implemented in `cmd/llm_aggregator.go` +via `applyConfiguration()` +- Updated all help text, documentation, and code references +- `README.md` completely revised with a new "Configuration" section + +### Removed + +- Global `QuietMode` and `VerboseMode` variables from +`internal/config/config.go`. Replaced by runtime‑specific configuration in +`runtime.Runtime` and CLI arguments +- Hardcoded system prompt in `runtime.NewRuntime()`. Now left empty, allowing +configuration or client default to be used +- CLI flags now correctly override config file and environment variables + +## [0.2.0] - 2026-04-21 + +### Changed + - Moved all progress and status messages behind `-v/--verbose` flag - Default CLI mode is now silent except for final output and errors -- Components (aggregator, processor, LLM client) use logger interface controlled by verbose flag +- Components (aggregator, processor, LLM client) use logger interface +controlled by verbose flag ## [0.1.0] - 2026-04-21@@ -32,7 +75,7 @@ - Keyboard controls (q/Ctrl+C to quit)
- Web content extraction fallback using `goquery` when feed descriptions are minimal - Configurable limits: articles per feed, maximum age, total articles - Token estimation and API usage logging -- Environment variable support (`DEEPSEEK_API_KEY`) for authentication +- Environment variable support (`LLM_AGGREGATOR_API_KEY`) for authentication - Example feeds file with technology, programming, and free software sources ### Changed
@@ -27,15 +27,15 @@ llm_aggregator --feeds-file FEEDS-FILE --prompt PROMPT [OPTIONS]...
By default, `llm_aggregator` reads a list of RSS feed URLs, fetches the articles, filters them by date and keywords, and sends a summary request to -Deepseek. The resulting output is printed to the terminal in your chosen format +the LLM. The resulting output is printed to the terminal in your chosen format (text, markdown, or JSON). ### Basic Options --feeds-file FILE Path to file containing RSS feed URLs (one per line) --prompt PROMPT User prompt for summarisation/analysis - --api-key KEY Deepseek API key (default: read from DEEPSEEK_API_KEY env var) - --model MODEL Deepseek model to use (default: deepseek-chat) + --api-key KEY API key (default: read from $LLM_AGGREGATOR_API_KEY) + --model MODEL Model to use (default: deepseek-chat) --max-tokens N Maximum tokens in response (default: 4000) --output FORMAT Output format: text, markdown, or json (default: text) --output-file FILE Write output to FILE instead of stdout@@ -76,9 +76,9 @@ # Filter by keywords (only include articles about Linux or open source)
llm_aggregator --feeds-file feeds.txt --prompt "Linux news" \ --include-keywords linux,opensource --max-days-old 3 -# Use a custom Deepseek model and higher token limit +# Use a custom model and higher token limit llm_aggregator --feeds-file feeds.txt --prompt "Code analysis" \ - --model deepseek-coder --max-tokens 8000 + --model deepseek-reasoner --max-tokens 8000 # Show version information llm_aggregator --version@@ -113,34 +113,66 @@ When the `--tui` flag is used, the entire process is wrapped in a `bubbletea`
TUI that shows a colourful progress bar, live article counters, and elapsed time (WIP). -## Dependencies +## Configuration -`llm_aggregator` is written in Go and uses the following libraries: +`llm_aggregator` supports multiple configuration sources with the following precedence order (highest to lowest): -* [`gofeed`](https://github.com/mmcdole/gofeed): a robust RSS/Atom/JSON feed parser -* [`openai‑go`](https://github.com/openai/openai-go): the official OpenAI Go - SDK, configured to work with Deepseek’s compatible API -* [`bubbletea`](https://github.com/charmbracelet/bubbletea): a TUI framework - for building terminal applications -* [`lipgloss`](https://github.com/charmbracelet/lipgloss): a library for - styling terminal output (colours, borders, alignment) -* [`go‑arg`](https://github.com/alexflint/go-arg): struct‑based argument - parsing with automatic help and version flags -* [`goquery`](https://github.com/PuerkitoBio/goquery): a jQuery‑like HTML - scraping library (used as a fallback when feed content is minimal) +1. **Command‑line arguments** – Override everything +2. **Environment variables** – Start with `LLM_AGGREGATOR_` prefix +3. **Configuration file** – `~/.config/llm_aggregator/config.toml` +4. **Built‑in defaults** -## How do I build `llm_aggregator`? +### Configuration file -`llm_aggregator` can be built with a standard Go toolchain: +Create a TOML file at `~/.config/llm_aggregator/config.toml` with the following structure: - go build ./cmd/llm_aggregator.go +```toml +# Feed aggregation options +max_articles_per_feed = 10 +max_days_old = 7 +max_total_articles = 20 -## Configuration file +# Content filtering (comma-separated keywords) +# include_keywords = "linux,opensource" +# exclude_keywords = "windows,microsoft" -A configuration file is not yet implemented; all options are passed via -command‑line arguments or environment variables. The API key can be provided -either with `--api-key` or by setting the `DEEPSEEK_API_KEY` environment -variable. +# Deepseek API options +# api_key = "your_api_key_here" # Can also be set via LLM_AGGREGATOR_API_KEY env var +model = "deepseek-chat" +max_tokens = 4000 +temperature = 0.7 + +# System prompt for Deepseek API +system_prompt = """You are an expert analyst and summariser. +You analyse content from multiple sources and provide +concise, insightful summaries based on user requests. +Focus on key points, trends, and important information.""" + +# Output options +output = "text" # Options: text, json, markdown +# output_file = "" # Optional output file path +include_articles = false +``` + +### Environment variables + +All configuration options can also be set via environment variables with the `LLM_AGGREGATOR_` prefix: + +- `LLM_AGGREGATOR_API_KEY` – Deepseek API key +- `LLM_AGGREGATOR_MODEL` – Model name (default: "deepseek-chat") +- `LLM_AGGREGATOR_MAX_TOKENS` – Maximum tokens in response (default: 4000) +- `LLM_AGGREGATOR_TEMPERATURE` – Sampling temperature (default: 0.7) +- `LLM_AGGREGATOR_SYSTEM_PROMPT` – Custom system prompt +- `LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED` – Maximum articles per feed (default: 10) +- `LLM_AGGREGATOR_MAX_DAYS_OLD` – Maximum article age in days (default: 7) +- `LLM_AGGREGATOR_MAX_TOTAL_ARTICLES` – Maximum total articles (default: 20) +- `LLM_AGGREGATOR_INCLUDE_KEYWORDS` – Comma‑separated include keywords +- `LLM_AGGREGATOR_EXCLUDE_KEYWORDS` – Comma‑separated exclude keywords +- `LLM_AGGREGATOR_OUTPUT` – Output format (default: "text") +- `LLM_AGGREGATOR_OUTPUT_FILE` – Output file path +- `LLM_AGGREGATOR_INCLUDE_ARTICLES` – Include articles in JSON output (true/false) + +The API key can be provided via `--api‑key`, `LLM_AGGREGATOR_API_KEY` environment variable, or in the configuration file. ## Example feeds file@@ -155,6 +187,30 @@
Then run: llm_aggregator --feeds-file feeds.txt --prompt "Summarise the top tech stories" + +## Dependencies + +`llm_aggregator` is written in Go and uses the following libraries: + +* [`gofeed`](https://github.com/mmcdole/gofeed): a robust RSS/Atom/JSON feed parser +* [`openai‑go`](https://github.com/openai/openai-go): the official OpenAI API +library for Go +* [`bubbletea`](https://github.com/charmbracelet/bubbletea): a TUI framework + for building terminal applications +* [`lipgloss`](https://github.com/charmbracelet/lipgloss): a library for + styling terminal output (colours, borders, alignment) +* [`go‑arg`](https://github.com/alexflint/go-arg): struct‑based argument + parsing with automatic help and version flags +* [`goquery`](https://github.com/PuerkitoBio/goquery): a jQuery‑like HTML + scraping library (used as a fallback when feed content is minimal) +* [`viper`](https://github.com/spf13/viper): library used for reading and + writing to configuration files. + +## How do I build `llm_aggregator`? + +`llm_aggregator` can be built with a standard Go toolchain: + + go build ./cmd/llm_aggregator.go ## Licence
@@ -8,6 +8,7 @@ "os/signal"
"syscall" "llm_aggregator/internal/cli" + "llm_aggregator/internal/config" "llm_aggregator/internal/runtime" "llm_aggregator/internal/tui"@@ -22,6 +23,15 @@
func main() { cli.BuildDate = buildDate cli.Version = version + + // Load configuration from file and environment variables + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not load configuration: %v\n", err) + // Continue with defaults + cfg = config.DefaultConfig() + } + // Parse command line arguments args, err := cli.ParseArgs() if err != nil {@@ -32,28 +42,14 @@
// Create runtime configuration rt := runtime.NewRuntime() rt.FeedsFile = args.FeedsFile - rt.MaxArticlesPerFeed = args.MaxArticlesPerFeed - rt.MaxDaysOld = args.MaxDaysOld - rt.MaxTotalArticles = args.MaxTotalArticles - rt.IncludeKeywords = cli.ParseKeywords(args.IncludeKeywords) - rt.ExcludeKeywords = cli.ParseKeywords(args.ExcludeKeywords) - rt.APIKey = args.APIKey - if rt.APIKey == "" { - rt.APIKey = os.Getenv("DEEPSEEK_API_KEY") - } - rt.Model = args.Model - rt.MaxTokens = args.MaxTokens - rt.Temperature = args.Temperature rt.Prompt = args.Prompt - rt.SystemPrompt = args.SystemPrompt - rt.Output = args.Output - rt.OutputFile = args.OutputFile - rt.IncludeArticles = args.IncludeArticles - rt.Verbose = args.Verbose + + // Apply configuration with precedence: CLI args > Environment vars > Config file > Defaults + applyConfiguration(rt, cfg, args) // Validate API key if rt.APIKey == "" { - fmt.Fprintln(os.Stderr, "Error: DeepSeek API key is required. Set via --api-key or DEEPSEEK_API_KEY environment variable.") + fmt.Fprintln(os.Stderr, "Error: OpenAI-compatible API key is required. Set via --api-key, LLM_AGGREGATOR_API_KEY environment variable, or config file.") os.Exit(1) }@@ -65,6 +61,92 @@ runWithoutTUI(rt, args.Verbose)
} } +// applyConfiguration applies configuration with proper precedence +func applyConfiguration(rt *runtime.Runtime, cfg *config.Config, args *cli.Args) { + // Feed aggregation options - CLI args override config + if args.MaxArticlesPerFeed != 0 { + rt.MaxArticlesPerFeed = args.MaxArticlesPerFeed + } else if cfg.MaxArticlesPerFeed != 0 { + rt.MaxArticlesPerFeed = cfg.MaxArticlesPerFeed + } + + if args.MaxDaysOld != 0 { + rt.MaxDaysOld = args.MaxDaysOld + } else if cfg.MaxDaysOld != 0 { + rt.MaxDaysOld = cfg.MaxDaysOld + } + + if args.MaxTotalArticles != 0 { + rt.MaxTotalArticles = args.MaxTotalArticles + } else if cfg.MaxTotalArticles != 0 { + rt.MaxTotalArticles = cfg.MaxTotalArticles + } + + // Content filtering - CLI args override config + if args.IncludeKeywords != "" { + rt.IncludeKeywords = cli.ParseKeywords(args.IncludeKeywords) + } else if cfg.IncludeKeywords != "" { + rt.IncludeKeywords = cli.ParseKeywords(cfg.IncludeKeywords) + } + + if args.ExcludeKeywords != "" { + rt.ExcludeKeywords = cli.ParseKeywords(args.ExcludeKeywords) + } else if cfg.ExcludeKeywords != "" { + rt.ExcludeKeywords = cli.ParseKeywords(cfg.ExcludeKeywords) + } + + // Deepseek API options - CLI args override config + if args.APIKey != "" { + rt.APIKey = args.APIKey + } else if cfg.APIKey != "" { + rt.APIKey = cfg.APIKey + } else { + // Fall back to environment variable + rt.APIKey = os.Getenv("LLM_AGGREGATOR_API_KEY") + } + + if args.Model != "" { + rt.Model = args.Model + } else if cfg.Model != "" { + rt.Model = cfg.Model + } + + if args.MaxTokens != 0 { + rt.MaxTokens = args.MaxTokens + } else if cfg.MaxTokens != 0 { + rt.MaxTokens = cfg.MaxTokens + } + + if args.Temperature != 0 { + rt.Temperature = args.Temperature + } else if cfg.Temperature != 0 { + rt.Temperature = cfg.Temperature + } + + // System prompt - CLI args override config + if args.SystemPrompt != "" { + rt.SystemPrompt = args.SystemPrompt + } else if cfg.SystemPrompt != "" { + rt.SystemPrompt = cfg.SystemPrompt + } + + // Output options - CLI args override config + if args.Output != "" { + rt.Output = args.Output + } else if cfg.Output != "" { + rt.Output = cfg.Output + } + + if args.OutputFile != "" { + rt.OutputFile = args.OutputFile + } else if cfg.OutputFile != "" { + rt.OutputFile = cfg.OutputFile + } + + rt.IncludeArticles = args.IncludeArticles || cfg.IncludeArticles + rt.Verbose = args.Verbose +} + func runWithTUI(rt *runtime.Runtime) { // Create TUI model m := tui.New()@@ -160,4 +242,3 @@ os.Exit(1)
} } } -
@@ -0,0 +1,28 @@
+# llm_aggregator configuration file +# Location: ~/.config/llm_aggregator/config.toml + +# Feed aggregation options +max_articles_per_feed = 10 +max_days_old = 7 +max_total_articles = 20 + +# Content filtering (comma-separated keywords) +# include_keywords = "linux,opensource" +# exclude_keywords = "windows,microsoft" + +# Deepseek API options +# api_key = "your_api_key_here" # Can also be set via LLM_AGGREGATOR_API_KEY env var +model = "deepseek-chat" +max_tokens = 4000 +temperature = 0.7 + +# System prompt for Deepseek API +system_prompt = """You are an expert analyst and summariser. +You analyse content from multiple sources and provide +concise, insightful summaries based on user requests. +Focus on key points, trends, and important information.""" + +# Output options +output = "text" # Options: text, json, markdown +# output_file = "" # Optional output file path +include_articles = false
@@ -24,7 +24,10 @@ github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect@@ -36,13 +39,22 @@ github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 + github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect
@@ -12,24 +12,16 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=@@ -39,23 +31,32 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=@@ -75,16 +76,34 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/openai/openai-go/v3 v3.32.0 h1:aHp/3wkX1W6jB8zTtf9xV0aK0qPFSVDqS7AHmlJ4hXs= github.com/openai/openai-go/v3 v3.32.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=@@ -97,6 +116,10 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=@@ -111,5 +134,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -28,7 +28,7 @@ IncludeKeywords string `arg:"--include-keywords" help:"Comma-separated list of keywords to include (case-insensitive)"`
ExcludeKeywords string `arg:"--exclude-keywords" help:"Comma-separated list of keywords to exclude (case-insensitive)"` // Deepseek API options - APIKey string `arg:"--api-key" help:"Deepseek API key (default: read from DEEPSEEK_API_KEY env var)"` + APIKey string `arg:"--api-key" help:"OpenAI-compatible API key (default: read from LLM_AGGREGATOR_API_KEY env var)"` Model string `arg:"--model" help:"Deepseek model to use" default:"deepseek-chat"` MaxTokens int `arg:"--max-tokens" help:"Maximum tokens in response" default:"4000"` Temperature float64 `arg:"--temperature" help:"Sampling temperature (0.0 to 1.0)" default:"0.7"`@@ -68,7 +68,7 @@ # Filter by keywords
llm_aggregator --feeds-file feeds.txt --prompt "Linux news" --include-keywords linux,opensource Environment Variables: - DEEPSEEK_API_KEY: Your Deepseek API key (required if not provided via --api-key)` + LLM_AGGREGATOR_API_KEY: Your Deepseek API key (required if not provided via --api-key)` } // ParseKeywords parses comma-separated keywords string into list.
@@ -1,8 +1,193 @@
package config -// QuietMode suppresses stdout output when true (e.g., when TUI is active). -var QuietMode = false +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// Config holds the application configuration loaded from TOML file, environment variables, and CLI arguments. +type Config struct { + // Feed aggregation options + MaxArticlesPerFeed int `mapstructure:"max_articles_per_feed"` + MaxDaysOld int `mapstructure:"max_days_old"` + MaxTotalArticles int `mapstructure:"max_total_articles"` + IncludeKeywords string `mapstructure:"include_keywords"` + ExcludeKeywords string `mapstructure:"exclude_keywords"` + + // Deepseek API options + APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` + MaxTokens int `mapstructure:"max_tokens"` + Temperature float64 `mapstructure:"temperature"` + + // System prompt + SystemPrompt string `mapstructure:"system_prompt"` + + // Output options + Output string `mapstructure:"output"` + OutputFile string `mapstructure:"output_file"` + IncludeArticles bool `mapstructure:"include_articles"` + + // Internal state + loaded bool +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + MaxArticlesPerFeed: 10, + MaxDaysOld: 7, + MaxTotalArticles: 20, + Model: "deepseek-chat", + MaxTokens: 4000, + Temperature: 0.7, + SystemPrompt: `You are an expert analyst and summariser. +You analyse content from multiple sources and provide +concise, insightful summaries based on user requests. +Focus on key points, trends, and important information.`, + Output: "text", + IncludeArticles: false, + } +} + +// Load loads configuration from TOML file, environment variables, and sets defaults. +// The precedence order is: CLI args > Environment variables > Config file > Defaults. +func Load() (*Config, error) { + config := DefaultConfig() + + // Initialise Viper + v := viper.New() + v.SetConfigName("config") + v.SetConfigType("toml") + + // Set environment variable prefix first + v.SetEnvPrefix("LLM_AGGREGATOR") + v.AutomaticEnv() // Read from environment variables + + // Bind environment variables to config fields + bindEnvVars(v) + + // Get config path from XDG + configPath, err := GetConfigPath() + if err != nil { + return nil, fmt.Errorf("failed to get config path: %w", err) + } + + // Set config file path + v.AddConfigPath(filepath.Dir(configPath)) + + // Try to read config file + if err := v.ReadInConfig(); err != nil { + // If config file doesn't exist, that's OK - we'll use defaults + env vars + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } else { + // Config file was found + config.loaded = true + } + + // Unmarshal config into struct + if err := v.Unmarshal(config); err != nil { + return nil, fmt.Errorf("error unmarshalling config: %w", err) + } + + return config, nil +} -// VerboseMode enables debug output when true. -var VerboseMode = false +// bindEnvVars binds environment variables to viper keys. +func bindEnvVars(v *viper.Viper) { + // Feed aggregation options + v.BindEnv("max_articles_per_feed", "LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED") + v.BindEnv("max_days_old", "LLM_AGGREGATOR_MAX_DAYS_OLD") + v.BindEnv("max_total_articles", "LLM_AGGREGATOR_MAX_TOTAL_ARTICLES") + v.BindEnv("include_keywords", "LLM_AGGREGATOR_INCLUDE_KEYWORDS") + v.BindEnv("exclude_keywords", "LLM_AGGREGATOR_EXCLUDE_KEYWORDS") + // Deepseek API options + v.BindEnv("api_key", "LLM_AGGREGATOR_API_KEY") + v.BindEnv("model", "LLM_AGGREGATOR_MODEL") + v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS") + v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE") + + // System prompt + v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT") + + // Output options + v.BindEnv("output", "LLM_AGGREGATOR_OUTPUT") + v.BindEnv("output_file", "LLM_AGGREGATOR_OUTPUT_FILE") + v.BindEnv("include_articles", "LLM_AGGREGATOR_INCLUDE_ARTICLES") +} + +// GetConfigPath returns the path to the config file. +func GetConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config directory: %w", err) + } + return filepath.Join(configDir, "llm_aggregator", "config.toml"), nil +} + +// ConfigExists returns true if a config file exists. +func ConfigExists() (bool, error) { + configPath, err := GetConfigPath() + if err != nil { + return false, err + } + _, err = os.Stat(configPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// Save saves the configuration to the TOML file. +func (c *Config) Save() error { + v := viper.New() + v.SetConfigType("toml") + + // Set values from config struct + v.Set("max_articles_per_feed", c.MaxArticlesPerFeed) + v.Set("max_days_old", c.MaxDaysOld) + v.Set("max_total_articles", c.MaxTotalArticles) + v.Set("include_keywords", c.IncludeKeywords) + v.Set("exclude_keywords", c.ExcludeKeywords) + v.Set("api_key", c.APIKey) + v.Set("model", c.Model) + v.Set("max_tokens", c.MaxTokens) + v.Set("temperature", c.Temperature) + v.Set("system_prompt", c.SystemPrompt) + v.Set("output", c.Output) + v.Set("output_file", c.OutputFile) + v.Set("include_articles", c.IncludeArticles) + + // Get config path + configPath, err := GetConfigPath() + if err != nil { + return fmt.Errorf("failed to get config path: %w", err) + } + + // Create directory if it doesn't exist + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Write config file + if err := v.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// IsLoaded returns true if the configuration was loaded from a file. +func (c *Config) IsLoaded() bool { + return c.loaded +}
@@ -0,0 +1,216 @@
+package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.MaxArticlesPerFeed != 10 { + t.Errorf("Expected MaxArticlesPerFeed = 10, got %d", cfg.MaxArticlesPerFeed) + } + + if cfg.MaxDaysOld != 7 { + t.Errorf("Expected MaxDaysOld = 7, got %d", cfg.MaxDaysOld) + } + + if cfg.MaxTotalArticles != 20 { + t.Errorf("Expected MaxTotalArticles = 20, got %d", cfg.MaxTotalArticles) + } + + if cfg.Model != "deepseek-chat" { + t.Errorf("Expected Model = 'deepseek-chat', got %s", cfg.Model) + } + + if cfg.MaxTokens != 4000 { + t.Errorf("Expected MaxTokens = 4000, got %d", cfg.MaxTokens) + } + + if cfg.Temperature != 0.7 { + t.Errorf("Expected Temperature = 0.7, got %f", cfg.Temperature) + } + + expectedPrompt := `You are an expert analyst and summariser. +You analyse content from multiple sources and provide +concise, insightful summaries based on user requests. +Focus on key points, trends, and important information.` + + if cfg.SystemPrompt != expectedPrompt { + t.Errorf("SystemPrompt doesn't match expected default") + } + + if cfg.Output != "text" { + t.Errorf("Expected Output = 'text', got %s", cfg.Output) + } +} + +func TestConfigPath(t *testing.T) { + path, err := GetConfigPath() + if err != nil { + t.Fatalf("Failed to get config path: %v", err) + } + + // Should end with .config/llm_aggregator/config.toml + expectedSuffix := filepath.Join(".config", "llm_aggregator", "config.toml") + if !hasSuffix(path, expectedSuffix) { + t.Errorf("Config path %s doesn't end with %s", path, expectedSuffix) + } +} + +func TestConfigSaveAndLoad(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + oldEnv := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer os.Setenv("XDG_CONFIG_HOME", oldEnv) + + cfg := DefaultConfig() + cfg.SystemPrompt = "Test system prompt" + cfg.Model = "test-model" + cfg.MaxTokens = 1234 + + // Save config + if err := cfg.Save(); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Verify file exists + configPath, err := GetConfigPath() + if err != nil { + t.Fatalf("Failed to get config path: %v", err) + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatalf("Config file not created at %s", configPath) + } + + // Load config + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if !loadedCfg.IsLoaded() { + t.Error("Config should be marked as loaded") + } + + if loadedCfg.SystemPrompt != cfg.SystemPrompt { + t.Errorf("SystemPrompt mismatch: expected %q, got %q", cfg.SystemPrompt, loadedCfg.SystemPrompt) + } + + if loadedCfg.Model != cfg.Model { + t.Errorf("Model mismatch: expected %s, got %s", cfg.Model, loadedCfg.Model) + } + + if loadedCfg.MaxTokens != cfg.MaxTokens { + t.Errorf("MaxTokens mismatch: expected %d, got %d", cfg.MaxTokens, loadedCfg.MaxTokens) + } +} + +func TestEnvironmentVariables(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + oldEnv := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer os.Setenv("XDG_CONFIG_HOME", oldEnv) + + // Set environment variables + os.Setenv("LLM_AGGREGATOR_SYSTEM_PROMPT", "Env system prompt") + os.Setenv("LLM_AGGREGATOR_MODEL", "env-model") + os.Setenv("LLM_AGGREGATOR_MAX_TOKENS", "9999") + defer func() { + os.Unsetenv("LLM_AGGREGATOR_SYSTEM_PROMPT") + os.Unsetenv("LLM_AGGREGATOR_MODEL") + os.Unsetenv("LLM_AGGREGATOR_MAX_TOKENS") + }() + + // Load config (should pick up env vars) + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.SystemPrompt != "Env system prompt" { + t.Errorf("SystemPrompt should be from env var, got %q", cfg.SystemPrompt) + } + + if cfg.Model != "env-model" { + t.Errorf("Model should be from env var, got %s", cfg.Model) + } + + if cfg.MaxTokens != 9999 { + t.Errorf("MaxTokens should be from env var, got %d", cfg.MaxTokens) + } +} + +func TestConfigExists(t *testing.T) { + // Create temporary directory for test + tempDir := t.TempDir() + oldEnv := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer os.Setenv("XDG_CONFIG_HOME", oldEnv) + + // Get config path to verify + configPath, err := GetConfigPath() + if err != nil { + t.Fatalf("Failed to get config path: %v", err) + } + + // Ensure it's in our temp directory + if !contains(configPath, tempDir) { + t.Logf("Config path %s not in temp dir %s", configPath, tempDir) + } + + // Initially should not exist + exists, err := ConfigExists() + if err != nil { + t.Fatalf("Failed to check config existence: %v", err) + } + + if exists { + // Config exists, maybe from previous test run + t.Logf("Config exists at %s, removing...", configPath) + os.Remove(configPath) + // Check again + exists, err = ConfigExists() + if err != nil { + t.Fatalf("Failed to check config existence after removal: %v", err) + } + if exists { + t.Error("Config should not exist after removal") + } + } + + // Create config + cfg := DefaultConfig() + if err := cfg.Save(); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Now should exist + exists, err = ConfigExists() + if err != nil { + t.Fatalf("Failed to check config existence: %v", err) + } + + if !exists { + t.Error("Config should exist after saving") + } +} + +// Helper function to check if path has suffix +func hasSuffix(path, suffix string) bool { + if len(path) < len(suffix) { + return false + } + return path[len(path)-len(suffix):] == suffix +} + +// Helper function to check if string contains substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (s[0:len(substr)] == substr || contains(s[1:], substr))) +}
@@ -23,7 +23,7 @@ logger *progress.Context
} // NewDeepseekClient creates a new Deepseek client. -// apiKey: Deepseek API key (or read from DEEPSEEK_API_KEY env var) +// apiKey: Deepseek API key (or read from LLM_AGGREGATOR_API_KEY env var) // baseURL: API base URL (defaults to "https://api.deepseek.com") // model: Model to use (defaults to "deepseek-chat") // maxTokens: Maximum tokens in response (defaults to 4000)@@ -31,12 +31,12 @@ // temperature: Sampling temperature (0.0 to 1.0, defaults to 0.7)
func NewDeepseekClient(apiKey, baseURL, model string, maxTokens int, temperature float64) (*DeepseekClient, error) { // Get API key from parameter or environment variable if apiKey == "" { - apiKey = os.Getenv("DEEPSEEK_API_KEY") + apiKey = os.Getenv("LLM_AGGREGATOR_API_KEY") } if apiKey == "" || strings.TrimSpace(apiKey) == "" { return nil, fmt.Errorf( "Deepseek API key is required. " + - "Set DEEPSEEK_API_KEY environment variable or pass apiKey parameter", + "Set LLM_AGGREGATOR_API_KEY environment variable or pass apiKey parameter", ) }@@ -145,9 +145,9 @@ }
func (dc *DeepseekClient) createMessages(context, userPrompt, systemPrompt string) []openai.ChatCompletionMessageParamUnion { if systemPrompt == "" { - systemPrompt = `You are an expert analyst and summariser. -You analyse content from multiple sources and provide -concise, insightful summaries based on user requests. + systemPrompt = `You are an expert analyst and summariser. +You analyse content from multiple sources and provide +concise, insightful summaries based on user requests. Focus on key points, trends, and important information.` }@@ -195,7 +195,7 @@ return "", fmt.Errorf("rate limit exceeded. Please try again later")
} else if strings.Contains(errStr, "500") { return "", fmt.Errorf("Deepseek API server error. Please try again later") } else if strings.Contains(errStr, "404") { - return "", fmt.Errorf("API endpoint not found. Please check the base URL and endpoint. Deepseek uses /chat/completions") + return "", fmt.Errorf("API endpoint not found. Please check the base URL and endpoint. OpenAI API uses /chat/completions") } return "", fmt.Errorf("failed to connect to Deepseek API: %w", err) }@@ -238,4 +238,4 @@ %s`, context, userPrompt)),
} return dc.callAPIWithMessages(messages) -}+}
@@ -53,7 +53,7 @@ Model: "deepseek-chat",
MaxTokens: 2000, Temperature: 0.7, Output: "text", - SystemPrompt: "You are a helpful assistant that summarises news articles.", + // SystemPrompt left empty to be filled from config or use deepseek default } }