From 456cd58caf188d24dc00c1b6aea1bbb5947cec3d Mon Sep 17 00:00:00 2001 From: Ernest Oporto Date: Thu, 5 Mar 2026 15:35:08 -0500 Subject: [PATCH 1/3] feat: add auto-quit flag to fetch command - Add --auto-quit flag to automatically exit after fetch completes - Update fetchui.Model to accept and handle autoQuit parameter - When flag is set, TUI quits immediately after FinalSummaryMsg instead of waiting for user input - Useful for scripting and CI/CD pipelines --- cmd/fetch.go | 8 +++++--- internal/tui/fetchui/model.go | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/fetch.go b/cmd/fetch.go index 39c18c9..6ad2ef2 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -37,6 +37,7 @@ var fetchCmd = &cobra.Command{ plain, _ := cmd.Flags().GetBool("plain") workers, _ := cmd.Flags().GetInt("workers") limit, _ := cmd.Flags().GetInt("limit") + autoQuit, _ := cmd.Flags().GetBool("auto-quit") cfg, err := loadCfg(configPath) if err != nil { @@ -79,14 +80,14 @@ var fetchCmd = &cobra.Command{ opts := fetcher.FetchOptions{Limit: limit} if !plain && tui.ShouldUseTUI() { - return runInteractiveFetch(ctx, cfg, queries, aiProcessor, workers, opts) + return runInteractiveFetch(ctx, cfg, queries, aiProcessor, workers, opts, autoQuit) } return runPlainFetch(ctx, cmd, cfg, queries, aiProcessor, opts) }, } -func runInteractiveFetch(ctx context.Context, cfg *config.Config, queries *database.Queries, aiProcessor processor.AIProcessor, workers int, opts fetcher.FetchOptions) error { +func runInteractiveFetch(ctx context.Context, cfg *config.Config, queries *database.Queries, aiProcessor processor.AIProcessor, workers int, opts fetcher.FetchOptions, autoQuit bool) error { if workers <= 0 { workers = runtime.NumCPU() } @@ -96,7 +97,7 @@ func runInteractiveFetch(ctx context.Context, cfg *config.Config, queries *datab sourceNames[i] = source.Name } - model := fetchui.New(sourceNames) + model := fetchui.New(sourceNames, autoQuit) model.SetWorkerCount(workers) program := tea.NewProgram(model, tea.WithAltScreen()) @@ -217,5 +218,6 @@ func init() { fetchCmd.Flags().Bool("plain", false, "Use plain text output instead of interactive TUI") fetchCmd.Flags().IntP("workers", "w", 0, "Number of worker goroutines (0 = auto-detect based on CPU cores)") fetchCmd.Flags().IntP("limit", "n", 5, "Maximum number of articles to fetch per source (0 = unlimited)") + fetchCmd.Flags().Bool("auto-quit", false, "Automatically quit after fetch completes instead of waiting for user input") rootCmd.AddCommand(fetchCmd) } diff --git a/internal/tui/fetchui/model.go b/internal/tui/fetchui/model.go index 42c2133..4f46974 100644 --- a/internal/tui/fetchui/model.go +++ b/internal/tui/fetchui/model.go @@ -37,9 +37,10 @@ type Model struct { width int height int workerCount int + autoQuit bool } -func New(sourceNames []string) Model { +func New(sourceNames []string, autoQuit bool) Model { s := spinner.New() s.Spinner = spinner.Dot s.Style = tui.SpinnerStyle @@ -60,6 +61,7 @@ func New(sourceNames []string) Model { spinner: s, workerCount: runtime.NumCPU(), totalSources: len(sourceNames), + autoQuit: autoQuit, } } @@ -163,6 +165,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.successCount = msg.SuccessCount m.errorCount = msg.ErrorCount m.errors = msg.Errors + if m.autoQuit { + return m, tea.Quit + } } return m, tea.Batch(cmds...) From 427ebe83c7be6d6f7c79acb7cf06a70ecfd95d44 Mon Sep 17 00:00:00 2001 From: Ernest Oporto Date: Fri, 13 Mar 2026 23:26:45 -0400 Subject: [PATCH 2/3] feat: add configurable User-Agent for article fetching - Add UserAgent field to Config with USER_AGENT env var support - Add SetUserAgent() to article/fetch.go for runtime configuration - Apply user agent from config in read command - Add commented user_agent example to configs/config.yaml Co-Authored-By: Claude Sonnet 4.6 --- cmd/read.go | 5 +++++ configs/config.yaml | 3 +++ internal/article/fetch.go | 8 +++++++- internal/config/config.go | 9 +++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cmd/read.go b/cmd/read.go index 7b79a4c..330811e 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -8,6 +8,7 @@ import ( "time" "github.com/robertguss/rss-agent-cli/internal/article" + "github.com/robertguss/rss-agent-cli/internal/config" "github.com/robertguss/rss-agent-cli/internal/state" "github.com/spf13/cobra" ) @@ -40,6 +41,10 @@ func runRead(cmd *cobra.Command, args []string) error { noStyle, _ := cmd.Flags().GetBool("no-style") noCache, _ := cmd.Flags().GetBool("no-cache") + if cfg, err := config.Load(); err == nil { + article.SetUserAgent(cfg.UserAgent) + } + vs, err := state.Load() if err != nil { return fmt.Errorf("failed to load view state: %w", err) diff --git a/configs/config.yaml b/configs/config.yaml index 61d2829..366c048 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,5 +1,8 @@ dsn: "./ai-news.db" +# User-Agent header sent when fetching articles via Jina Reader. +# user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15" + ai: # Available models: gemini-1.5-flash, gemini-1.5-pro, gemini-2.5-flash, gemini-2.5-pro, etc. # For full list, see: https://ai.google.dev/gemini-api/docs/models/gemini diff --git a/internal/article/fetch.go b/internal/article/fetch.go index 24cbb20..5fe9a39 100644 --- a/internal/article/fetch.go +++ b/internal/article/fetch.go @@ -10,6 +10,7 @@ import ( ) var jinaEndpoint = "https://r.jina.ai/%s" +var userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15" var httpClient = &http.Client{ Timeout: 30 * time.Second, } @@ -20,6 +21,11 @@ func SetJinaEndpointForTesting(endpoint string) string { return old } +// SetUserAgent sets the User-Agent header used when fetching articles. +func SetUserAgent(ua string) { + userAgent = ua +} + func FetchArticle(ctx context.Context, articleURL string, noCache bool) (string, error) { jinaURL := fmt.Sprintf(jinaEndpoint, url.QueryEscape(articleURL)) if noCache { @@ -31,7 +37,7 @@ func FetchArticle(ctx context.Context, articleURL string, noCache bool) (string, return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "rss-agent-cli/1.0") + req.Header.Set("User-Agent", userAgent) resp, err := httpClient.Do(req) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index d7eadb5..4e0ebf1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type Config struct { BackoffMaxMs int `mapstructure:"backoff_max_ms"` DBBusyRetries int `mapstructure:"db_busy_retries"` LogFile string `mapstructure:"log_file"` + UserAgent string `mapstructure:"user_agent"` } // Load loads the application configuration from the default config.yaml file. @@ -146,6 +147,14 @@ func setDefaults(cfg *Config) { cfg.AI.GeminiModel = "gemini-1.5-flash" } } + + if cfg.UserAgent == "" { + if ua := os.Getenv("USER_AGENT"); ua != "" { + cfg.UserAgent = ua + } else { + cfg.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15" + } + } } func (c *Config) RetryConfig() retry.Config { From d854f4d691fe81a2979e165cd1c66334690664a9 Mon Sep 17 00:00:00 2001 From: Ernest Oporto Date: Tue, 31 Mar 2026 08:05:20 -0400 Subject: [PATCH 3/3] feat: add config command for managing RSS sources Adds `config` subcommand with list, add, delete, and edit operations for managing RSS feed sources in config.yaml without manual editing. Co-Authored-By: Claude Sonnet 4.6 --- cmd/config.go | 345 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 cmd/config.go diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..9ec31d9 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,345 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// configFile is the resolved path to the config file being edited. +var configFile string + +// configCmd is the parent command for config editing operations. +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage the RSS agent configuration file", +} + +// addSourceCmd adds a new RSS source to the config file. +var addSourceCmd = &cobra.Command{ + Use: "add", + Short: "Add a new RSS source", + RunE: runAddSource, +} + +// deleteSourceCmd removes an RSS source from the config file by name. +var deleteSourceCmd = &cobra.Command{ + Use: "delete", + Short: "Delete an RSS source by name", + RunE: runDeleteSource, +} + +// editSourceCmd edits an existing RSS source in the config file. +var editSourceCmd = &cobra.Command{ + Use: "edit", + Short: "Edit an existing RSS source by name", + RunE: runEditSource, +} + +// resolveConfigPath returns the config path from the flag or the default locations. +func resolveConfigPath() (string, error) { + if configFile != "" { + return configFile, nil + } + for _, p := range []string{"./config.yaml", "./configs/config.yaml"} { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", errors.New("no config file found; use --config to specify one") +} + +// loadRawYAML reads and parses the YAML file as a generic node tree so we can +// write it back without losing comments or field ordering. +func loadRawYAML(path string) (*yaml.Node, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return &doc, nil +} + +// saveRawYAML marshals the node tree back to the file. +func saveRawYAML(path string, doc *yaml.Node) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("open config for writing: %w", err) + } + defer f.Close() + + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + return fmt.Errorf("write config: %w", err) + } + return enc.Close() +} + +// sourcesNode finds the sequence node for the "sources" key inside the root +// mapping node. Returns nil if not found. +func sourcesNode(root *yaml.Node) *yaml.Node { + if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { + return nil + } + mapping := root.Content[0] + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == "sources" { + return mapping.Content[i+1] + } + } + return nil +} + +// getScalarField returns the value of a named field from a YAML mapping node. +func getScalarField(node *yaml.Node, key string) string { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1].Value + } + } + return "" +} + +// setScalarField sets the value of a named field in a YAML mapping node, +// adding the key-value pair if it does not already exist. +func setScalarField(node *yaml.Node, key, value string) { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + node.Content[i+1].Value = value + return + } + } + // Key not found – append it. + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valNode := &yaml.Node{Kind: yaml.ScalarNode, Value: value} + node.Content = append(node.Content, keyNode, valNode) +} + +// newSourceNode builds a YAML mapping node for a source entry. +func newSourceNode(name, url, srcType string, priority int) *yaml.Node { + makeScalar := func(v string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Value: v} + } + return &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + makeScalar("name"), makeScalar(name), + makeScalar("url"), makeScalar(url), + makeScalar("type"), makeScalar(srcType), + makeScalar("priority"), makeScalar(strconv.Itoa(priority)), + }, + } +} + +// listSourcesCmd lists all configured RSS sources. +var listSourcesCmd = &cobra.Command{ + Use: "list", + Short: "List all configured RSS sources", + RunE: runListSources, +} + +// runListSources implements the "config list" command. +func runListSources(cmd *cobra.Command, _ []string) error { + path, err := resolveConfigPath() + if err != nil { + return err + } + + doc, err := loadRawYAML(path) + if err != nil { + return err + } + + seq := sourcesNode(doc) + if seq == nil { + return errors.New("config file has no 'sources' key") + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tURL\tTYPE\tPRIORITY") + fmt.Fprintln(w, "----\t---\t----\t--------") + for _, child := range seq.Content { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + getScalarField(child, "name"), + getScalarField(child, "url"), + getScalarField(child, "type"), + getScalarField(child, "priority"), + ) + } + return w.Flush() +} + +// runAddSource implements the "config add" command. +func runAddSource(cmd *cobra.Command, _ []string) error { + name, _ := cmd.Flags().GetString("name") + url, _ := cmd.Flags().GetString("url") + srcType, _ := cmd.Flags().GetString("type") + priority, _ := cmd.Flags().GetInt("priority") + + if name == "" || url == "" || srcType == "" { + return errors.New("--name, --url, and --type are required") + } + + path, err := resolveConfigPath() + if err != nil { + return err + } + + doc, err := loadRawYAML(path) + if err != nil { + return err + } + + seq := sourcesNode(doc) + if seq == nil { + return errors.New("config file has no 'sources' key") + } + + // Check for duplicate name. + for _, child := range seq.Content { + if getScalarField(child, "name") == name { + return fmt.Errorf("source %q already exists", name) + } + } + + seq.Content = append(seq.Content, newSourceNode(name, url, srcType, priority)) + + if err := saveRawYAML(path, doc); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Added source %q to %s\n", name, path) + return nil +} + +// runDeleteSource implements the "config delete" command. +func runDeleteSource(cmd *cobra.Command, _ []string) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + return errors.New("--name is required") + } + + path, err := resolveConfigPath() + if err != nil { + return err + } + + doc, err := loadRawYAML(path) + if err != nil { + return err + } + + seq := sourcesNode(doc) + if seq == nil { + return errors.New("config file has no 'sources' key") + } + + original := len(seq.Content) + filtered := seq.Content[:0] + for _, child := range seq.Content { + if getScalarField(child, "name") != name { + filtered = append(filtered, child) + } + } + if len(filtered) == original { + return fmt.Errorf("source %q not found", name) + } + seq.Content = filtered + + if err := saveRawYAML(path, doc); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Deleted source %q from %s\n", name, path) + return nil +} + +// runEditSource implements the "config edit" command. +func runEditSource(cmd *cobra.Command, _ []string) error { + name, _ := cmd.Flags().GetString("name") + if name == "" { + return errors.New("--name is required") + } + + newName, _ := cmd.Flags().GetString("new-name") + newURL, _ := cmd.Flags().GetString("url") + newType, _ := cmd.Flags().GetString("type") + newPriority, _ := cmd.Flags().GetInt("priority") + prioritySet := cmd.Flags().Changed("priority") + + path, err := resolveConfigPath() + if err != nil { + return err + } + + doc, err := loadRawYAML(path) + if err != nil { + return err + } + + seq := sourcesNode(doc) + if seq == nil { + return errors.New("config file has no 'sources' key") + } + + found := false + for _, child := range seq.Content { + if getScalarField(child, "name") != name { + continue + } + found = true + if newName != "" { + setScalarField(child, "name", newName) + } + if newURL != "" { + setScalarField(child, "url", newURL) + } + if newType != "" { + setScalarField(child, "type", newType) + } + if prioritySet { + setScalarField(child, "priority", strconv.Itoa(newPriority)) + } + break + } + if !found { + return fmt.Errorf("source %q not found", name) + } + + if err := saveRawYAML(path, doc); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Updated source %q in %s\n", name, path) + return nil +} + +func init() { + // Global config path flag on the parent command. + configCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Path to config file (default: ./config.yaml or ./configs/config.yaml)") + + // add flags + addSourceCmd.Flags().String("name", "", "Source name (required)") + addSourceCmd.Flags().String("url", "", "RSS feed URL (required)") + addSourceCmd.Flags().String("type", "rss", "Feed type (required, e.g. rss)") + addSourceCmd.Flags().Int("priority", 1, "Priority (1 = highest)") + + // delete flags + deleteSourceCmd.Flags().String("name", "", "Name of source to delete (required)") + + // edit flags + editSourceCmd.Flags().String("name", "", "Name of source to edit (required)") + editSourceCmd.Flags().String("new-name", "", "New name for the source") + editSourceCmd.Flags().String("url", "", "New RSS feed URL") + editSourceCmd.Flags().String("type", "", "New feed type") + editSourceCmd.Flags().Int("priority", 0, "New priority") + + configCmd.AddCommand(listSourcesCmd, addSourceCmd, deleteSourceCmd, editSourceCmd) + rootCmd.AddCommand(configCmd) +}