diff --git a/README.md b/README.md index 27eea96..c94a859 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Go CLI tool to track blog articles, detect new posts, and manage read/unread s - **Blog Filtering** - View articles from specific blogs - **Duplicate Prevention** - Never tracks the same article twice - **Colored CLI Output** - User-friendly terminal interface +- **JSON Output** - Machine-readable output for automation and agent integrations ## Installation @@ -30,6 +31,24 @@ Pre-built binaries for Linux, macOS, and Windows are available on the [GitHub Re ## Usage +### Output Format + +Human-readable text output is the default. Use `--format json` for machine-readable output in scripts or agent integrations: + +```bash +blogwatcher-cli add "xkcd" https://xkcd.com --feed-url https://xkcd.com/atom.xml --format json +blogwatcher-cli blogs --format json +blogwatcher-cli scan --format json +blogwatcher-cli articles --format json +blogwatcher-cli read 42 --format json +blogwatcher-cli unread 42 --format json +blogwatcher-cli read-all --yes --format json +blogwatcher-cli remove "xkcd" --yes --format json +blogwatcher-cli import subscriptions.opml --format json +``` + +You can also set `BLOGWATCHER_FORMAT=json`. Commands that normally prompt for confirmation (`remove`, `read-all`) require `--yes` in JSON mode. + ### Adding Blogs ```bash diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 916d0cd..1377b1f 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -2,6 +2,7 @@ package e2e import ( "context" + "encoding/json" "errors" "fmt" "net" @@ -407,6 +408,149 @@ func TestE2E(t *testing.T) { } } +func TestJSONOutput(t *testing.T) { + baseURL := startTestServer(t) + + for _, mode := range []string{"flags", "env"} { + t.Run(mode, func(t *testing.T) { + c := &cliOpts{ + mode: mode, + dbPath: filepath.Join(t.TempDir(), "test.db"), + } + + addOut := c.ok(t, []string{"add", "go-blog", baseURL + "/go/"}, map[string]string{ + "feed-url": baseURL + "/go/feed.atom", + "format": "json", + }) + var added struct { + OK bool `json:"ok"` + Blog struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"blog"` + } + require.NoError(t, json.Unmarshal([]byte(addOut), &added)) + assert.True(t, added.OK) + assert.Equal(t, "go-blog", added.Blog.Name) + + blogsOut := c.ok(t, []string{"blogs"}, map[string]string{"format": "json"}) + var blogs struct { + Blogs []struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + FeedURL string `json:"feed_url"` + } `json:"blogs"` + } + require.NoError(t, json.Unmarshal([]byte(blogsOut), &blogs)) + require.Len(t, blogs.Blogs, 1) + assert.Equal(t, "go-blog", blogs.Blogs[0].Name) + assert.Equal(t, baseURL+"/go/", blogs.Blogs[0].URL) + assert.Equal(t, baseURL+"/go/feed.atom", blogs.Blogs[0].FeedURL) + + scanOut := c.ok(t, []string{"scan"}, map[string]string{"format": "json"}) + var scan struct { + Scanned int `json:"scanned"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + TotalNewArticles int `json:"total_new_articles"` + Results []struct { + BlogName string `json:"blog_name"` + NewArticles int `json:"new_articles"` + TotalFound int `json:"total_found"` + Source string `json:"source"` + } `json:"results"` + } + require.NoError(t, json.Unmarshal([]byte(scanOut), &scan)) + assert.Equal(t, 1, scan.Scanned) + assert.Equal(t, 1, scan.Succeeded) + assert.Equal(t, 0, scan.Failed) + assert.Equal(t, 3, scan.TotalNewArticles) + require.Len(t, scan.Results, 1) + assert.Equal(t, "go-blog", scan.Results[0].BlogName) + assert.Equal(t, "rss", scan.Results[0].Source) + + articlesOut := c.ok(t, []string{"articles"}, map[string]string{"format": "json"}) + var articles struct { + Articles []struct { + ID int64 `json:"id"` + BlogID int64 `json:"blog_id"` + Blog string `json:"blog"` + Title string `json:"title"` + URL string `json:"url"` + IsRead bool `json:"is_read"` + Categories []string `json:"categories"` + } `json:"articles"` + } + require.NoError(t, json.Unmarshal([]byte(articlesOut), &articles)) + require.Len(t, articles.Articles, 3) + assert.Equal(t, "go-blog", articles.Articles[0].Blog) + assert.NotEmpty(t, articles.Articles[0].Title) + assert.NotEmpty(t, articles.Articles[0].URL) + articleID := fmt.Sprintf("%d", articles.Articles[0].ID) + + readOut := c.ok(t, []string{"read", articleID}, map[string]string{"format": "json"}) + var readResult struct { + OK bool `json:"ok"` + Action string `json:"action"` + ArticleID int64 `json:"article_id"` + Changed bool `json:"changed"` + Article struct { + IsRead bool `json:"is_read"` + } `json:"article"` + } + require.NoError(t, json.Unmarshal([]byte(readOut), &readResult)) + assert.True(t, readResult.OK) + assert.Equal(t, "read", readResult.Action) + assert.True(t, readResult.Changed) + assert.True(t, readResult.Article.IsRead) + + unreadOut := c.ok(t, []string{"unread", articleID}, map[string]string{"format": "json"}) + var unreadResult struct { + OK bool `json:"ok"` + Action string `json:"action"` + Changed bool `json:"changed"` + Article struct { + IsRead bool `json:"is_read"` + } `json:"article"` + } + require.NoError(t, json.Unmarshal([]byte(unreadOut), &unreadResult)) + assert.True(t, unreadResult.OK) + assert.Equal(t, "unread", unreadResult.Action) + assert.True(t, unreadResult.Changed) + assert.False(t, unreadResult.Article.IsRead) + + readAllOut := c.ok(t, []string{"read-all"}, map[string]string{"yes": "", "format": "json"}) + var readAll struct { + OK bool `json:"ok"` + Count int `json:"count"` + } + require.NoError(t, json.Unmarshal([]byte(readAllOut), &readAll)) + assert.True(t, readAll.OK) + assert.Greater(t, readAll.Count, 0) + + removeOut := c.ok(t, []string{"remove", "go-blog"}, map[string]string{"yes": "", "format": "json"}) + var removed struct { + OK bool `json:"ok"` + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal([]byte(removeOut), &removed)) + assert.True(t, removed.OK) + assert.Equal(t, "go-blog", removed.Name) + }) + } +} + +func TestInvalidOutputFormat(t *testing.T) { + c := &cliOpts{ + mode: "flags", + dbPath: filepath.Join(t.TempDir(), "test.db"), + } + _, stderr, code := c.run(t, []string{"blogs"}, map[string]string{"format": "xml"}) + assert.NotEqual(t, 0, code) + assert.Contains(t, stderr, `invalid output format "xml": expected text or json`) +} + func TestAddBlogInvalidURL(t *testing.T) { for _, mode := range []string{"flags", "env"} { t.Run(mode, func(t *testing.T) { @@ -465,6 +609,19 @@ func TestImportOPML(t *testing.T) { out := c.ok(t, []string{"import", opmlPath}, nil) checkOutput(t, "30_import_opml", out, baseURL) + jsonDB := filepath.Join(t.TempDir(), "json-test.db") + jsonCLI := &cliOpts{mode: mode, dbPath: jsonDB} + jsonOut := jsonCLI.ok(t, []string{"import", opmlPath}, map[string]string{"format": "json"}) + var imported struct { + OK bool `json:"ok"` + Added int `json:"added"` + Skipped int `json:"skipped"` + } + require.NoError(t, json.Unmarshal([]byte(jsonOut), &imported)) + assert.True(t, imported.OK) + assert.Equal(t, 2, imported.Added) + assert.Equal(t, 0, imported.Skipped) + // Verify blogs appear in list. out = c.ok(t, []string{"blogs"}, nil) checkOutput(t, "31_import_blogs_listed", out, baseURL) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 305e8e1..d03bbf4 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -53,11 +53,14 @@ func newAddCommand() *cobra.Command { name := args[0] url := args[1] return withDatabase(cmd, func(db *storage.Database) error { - _, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector")) + blog, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector")) if err != nil { printError(err) return markError(err) } + if isJSONOutput() { + return writeJSON(addBlogOutput{OK: true, Blog: newBlogOutput(blog)}) + } cprintf([]color.Attribute{color.FgGreen}, "Added blog '%s'\n", name) return nil }) @@ -76,6 +79,9 @@ func newRemoveCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { name := args[0] if !viper.GetBool("yes") { + if isJSONOutput() { + return fmt.Errorf("remove with --format json requires --yes") + } confirmed, err := confirm(fmt.Sprintf("Remove blog '%s' and all its articles?", name)) if err != nil { return err @@ -89,6 +95,9 @@ func newRemoveCommand() *cobra.Command { printError(err) return markError(err) } + if isJSONOutput() { + return writeJSON(removeBlogOutput{OK: true, Name: name}) + } cprintf([]color.Attribute{color.FgGreen}, "Removed blog '%s'\n", name) return nil }) @@ -108,6 +117,9 @@ func newBlogsCommand() *cobra.Command { if err != nil { return err } + if isJSONOutput() { + return writeJSON(newBlogsOutput(blogs)) + } if len(blogs) == 0 { fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.") return nil @@ -157,6 +169,9 @@ func newScanCommand() *cobra.Command { printError(err) return markError(err) } + if isJSONOutput() { + return writeJSON(newScanOutput([]scanner.ScanResult{*result})) + } if !silent { printScanResult(*result) } @@ -166,10 +181,13 @@ func newScanCommand() *cobra.Command { return err } if len(blogs) == 0 { + if isJSONOutput() { + return writeJSON(newScanOutput(nil)) + } fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.") return nil } - if !silent { + if !silent && !isJSONOutput() { cprintf([]color.Attribute{color.FgCyan}, "Scanning %d blog(s)...\n\n", len(blogs)) } results, err := sc.ScanAllBlogs(cmd.Context(), db, workers) @@ -179,7 +197,7 @@ func newScanCommand() *cobra.Command { totalNew := 0 failed := 0 for _, result := range results { - if !silent { + if !silent && !isJSONOutput() { printScanResult(result) } if result.Error != "" { @@ -188,6 +206,9 @@ func newScanCommand() *cobra.Command { totalNew += result.NewArticles } } + if isJSONOutput() { + return writeJSON(newScanOutput(results)) + } if !silent { fmt.Println() succeeded := len(results) - failed @@ -209,7 +230,7 @@ func newScanCommand() *cobra.Command { } } - if silent { + if silent && !isJSONOutput() { fmt.Println("scan done") } return nil @@ -240,6 +261,9 @@ func newArticlesCommand() *cobra.Command { printError(err) return markError(err) } + if isJSONOutput() { + return writeJSON(newArticlesOutput(articles, blogNames)) + } if len(articles) == 0 { if showAll { fmt.Println("No articles found.") @@ -286,6 +310,11 @@ func newReadCommand() *cobra.Command { printError(err) return markError(err) } + changed := !article.IsRead + if isJSONOutput() { + article.IsRead = true + return writeJSON(articleStatusOutput{OK: true, Action: "read", ArticleID: articleID, Changed: changed, Article: newArticleOutput(article, "")}) + } if article.IsRead { fmt.Printf("Article %d is already marked as read.\n", articleID) } else { @@ -312,11 +341,17 @@ func newReadAllCommand() *cobra.Command { return markError(err) } if len(articles) == 0 { + if isJSONOutput() { + return writeJSON(readAllOutput{OK: true, Blog: blogName, Count: 0, Articles: []articleOutput{}}) + } cprintln([]color.Attribute{color.FgGreen}, "No unread articles to mark as read.") return nil } if !viper.GetBool("yes") { + if isJSONOutput() { + return fmt.Errorf("read-all with --format json requires --yes") + } scope := "all blogs" if blogName != "" { scope = fmt.Sprintf("from '%s'", blogName) @@ -336,6 +371,15 @@ func newReadAllCommand() *cobra.Command { return markError(err) } + if isJSONOutput() { + out := readAllOutput{OK: true, Blog: blogName, Count: len(marked), Articles: make([]articleOutput, 0, len(marked))} + for _, article := range marked { + article.IsRead = true + out.Articles = append(out.Articles, newArticleOutput(article, "")) + } + return writeJSON(out) + } + cprintf([]color.Attribute{color.FgGreen}, "Marked %d article(s) as read\n", len(marked)) return nil }) @@ -363,6 +407,11 @@ func newUnreadCommand() *cobra.Command { printError(err) return markError(err) } + changed := article.IsRead + if isJSONOutput() { + article.IsRead = false + return writeJSON(articleStatusOutput{OK: true, Action: "unread", ArticleID: articleID, Changed: changed, Article: newArticleOutput(article, "")}) + } if !article.IsRead { fmt.Printf("Article %d is already marked as unread.\n", articleID) } else { @@ -396,6 +445,9 @@ func newImportCommand() *cobra.Command { printError(err) return markError(err) } + if isJSONOutput() { + return writeJSON(importOutput{OK: true, Added: added, Skipped: skipped}) + } cprintf([]color.Attribute{color.FgGreen}, "Imported %d blog(s), skipped %d duplicate(s)\n", added, skipped) return nil }) diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..89ddef8 --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,175 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/viper" + + "github.com/JulienTant/blogwatcher-cli/internal/model" + "github.com/JulienTant/blogwatcher-cli/internal/scanner" +) + +const ( + outputFormatText = "text" + outputFormatJSON = "json" +) + +type blogOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + FeedURL string `json:"feed_url,omitempty"` + ScrapeSelector string `json:"scrape_selector,omitempty"` + LastScanned *time.Time `json:"last_scanned,omitempty"` +} + +type blogsOutput struct { + Blogs []blogOutput `json:"blogs"` +} + +type articleOutput struct { + ID int64 `json:"id"` + BlogID int64 `json:"blog_id"` + Blog string `json:"blog"` + Title string `json:"title"` + URL string `json:"url"` + PublishedDate *time.Time `json:"published_date,omitempty"` + DiscoveredDate *time.Time `json:"discovered_date,omitempty"` + IsRead bool `json:"is_read"` + Categories []string `json:"categories,omitempty"` +} + +type articlesOutput struct { + Articles []articleOutput `json:"articles"` +} + +type scanResultOutput struct { + BlogName string `json:"blog_name"` + NewArticles int `json:"new_articles"` + TotalFound int `json:"total_found"` + Source string `json:"source"` + Error string `json:"error,omitempty"` +} + +type scanOutput struct { + Scanned int `json:"scanned"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + TotalNewArticles int `json:"total_new_articles"` + Results []scanResultOutput `json:"results"` +} + +type addBlogOutput struct { + OK bool `json:"ok"` + Blog blogOutput `json:"blog"` +} + +type removeBlogOutput struct { + OK bool `json:"ok"` + Name string `json:"name"` +} + +type articleStatusOutput struct { + OK bool `json:"ok"` + Action string `json:"action"` + ArticleID int64 `json:"article_id"` + Changed bool `json:"changed"` + Article articleOutput `json:"article"` +} + +type readAllOutput struct { + OK bool `json:"ok"` + Blog string `json:"blog,omitempty"` + Count int `json:"count"` + Articles []articleOutput `json:"articles"` +} + +type importOutput struct { + OK bool `json:"ok"` + Added int `json:"added"` + Skipped int `json:"skipped"` +} + +func isJSONOutput() bool { + return viper.GetString("format") == outputFormatJSON +} + +func validateOutputFormat(format string) error { + switch format { + case outputFormatText, outputFormatJSON: + return nil + default: + return fmt.Errorf("invalid output format %q: expected text or json", format) + } +} + +func writeJSON(value any) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func newBlogOutput(blog model.Blog) blogOutput { + return blogOutput{ + ID: blog.ID, + Name: blog.Name, + URL: blog.URL, + FeedURL: blog.FeedURL, + ScrapeSelector: blog.ScrapeSelector, + LastScanned: blog.LastScanned, + } +} + +func newBlogsOutput(blogs []model.Blog) blogsOutput { + out := blogsOutput{Blogs: make([]blogOutput, 0, len(blogs))} + for _, blog := range blogs { + out.Blogs = append(out.Blogs, newBlogOutput(blog)) + } + return out +} + +func newArticleOutput(article model.Article, blogName string) articleOutput { + return articleOutput{ + ID: article.ID, + BlogID: article.BlogID, + Blog: blogName, + Title: article.Title, + URL: article.URL, + PublishedDate: article.PublishedDate, + DiscoveredDate: article.DiscoveredDate, + IsRead: article.IsRead, + Categories: article.Categories, + } +} + +func newArticlesOutput(articles []model.Article, blogNames map[int64]string) articlesOutput { + out := articlesOutput{Articles: make([]articleOutput, 0, len(articles))} + for _, article := range articles { + out.Articles = append(out.Articles, newArticleOutput(article, blogNames[article.BlogID])) + } + return out +} + +func newScanOutput(results []scanner.ScanResult) scanOutput { + out := scanOutput{Results: make([]scanResultOutput, 0, len(results))} + for _, result := range results { + out.Scanned++ + if result.Error != "" { + out.Failed++ + } else { + out.Succeeded++ + out.TotalNewArticles += result.NewArticles + } + out.Results = append(out.Results, scanResultOutput{ + BlogName: result.BlogName, + NewArticles: result.NewArticles, + TotalFound: result.TotalFound, + Source: result.Source, + Error: result.Error, + }) + } + return out +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 383aeeb..3ac8306 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -25,6 +25,7 @@ func NewRootCommand() *cobra.Command { rootCmd.PersistentFlags().String("db", "", "Path to the SQLite database file (default: ~/.blogwatcher-cli/blogwatcher-cli.db)") rootCmd.PersistentFlags().Bool("unsafe-client", false, "Disable SSRF protection (allow requests to private/loopback IPs)") + rootCmd.PersistentFlags().String("format", outputFormatText, "Output format: text or json") rootCmd.AddCommand(newAddCommand()) rootCmd.AddCommand(newRemoveCommand()) @@ -42,7 +43,10 @@ func initConfig(cmd *cobra.Command) error { viper.SetEnvPrefix("BLOGWATCHER") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() - return viper.BindPFlags(cmd.Flags()) + if err := viper.BindPFlags(cmd.Flags()); err != nil { + return err + } + return validateOutputFormat(viper.GetString("format")) } func Execute() { diff --git a/skills/SKILL.md b/skills/SKILL.md index b864417..2b824f7 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -54,6 +54,23 @@ All flags can be set via environment variables with the `BLOGWATCHER_` prefix: - `BLOGWATCHER_CATEGORY` - Filter articles by category - `BLOGWATCHER_SINCE` - Filter articles published on or after `YYYY-MM-DD` - `BLOGWATCHER_BEFORE` - Filter articles published before `YYYY-MM-DD` +- `BLOGWATCHER_FORMAT` - Output format (`text` or `json`) + +## Agent automation + +When using this tool from an agent or script, prefer `--format json` for machine-readable output: + +- `blogwatcher-cli add "My Blog" https://example.com --format json` +- `blogwatcher-cli blogs --format json` +- `blogwatcher-cli scan --format json` +- `blogwatcher-cli articles --format json` +- `blogwatcher-cli read 1 --format json` +- `blogwatcher-cli unread 1 --format json` +- `blogwatcher-cli read-all --yes --format json` +- `blogwatcher-cli remove "My Blog" --yes --format json` +- `blogwatcher-cli import subscriptions.opml --format json` + +Commands that normally prompt for confirmation (`remove`, `read-all`) require `--yes` in JSON mode. Use text output only when the user explicitly wants terminal-style output. ## Example output