@plosson/agentio 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "commander": "^14.0.2",
50
- "googleapis": "^169.0.0"
50
+ "googleapis": "^169.0.0",
51
+ "rss-parser": "^3.13.0"
51
52
  }
52
53
  }
@@ -0,0 +1,68 @@
1
+ import { Command } from 'commander';
2
+ import { RssClient } from '../services/rss/client';
3
+ import { handleError } from '../utils/errors';
4
+ import {
5
+ printRssArticleList,
6
+ printRssArticle,
7
+ printRssFeedInfo,
8
+ } from '../utils/output';
9
+
10
+ export function registerRssCommands(program: Command): void {
11
+ const rss = program
12
+ .command('rss')
13
+ .description('RSS feed operations');
14
+
15
+ // Get articles from a feed
16
+ rss
17
+ .command('articles')
18
+ .description('List articles from a blog')
19
+ .argument('<url>', 'Blog URL (feed will be auto-discovered)')
20
+ .option('--limit <n>', 'Number of articles', '20')
21
+ .option('--since <date>', 'Only articles after this date (YYYY-MM-DD)')
22
+ .action(async (url, options) => {
23
+ try {
24
+ const client = new RssClient();
25
+ const listOptions = {
26
+ limit: parseInt(options.limit, 10),
27
+ since: options.since ? new Date(options.since) : undefined,
28
+ };
29
+
30
+ const info = await client.getInfo(url);
31
+ const articles = await client.list(url, listOptions);
32
+ printRssArticleList(articles, info.title);
33
+ } catch (error) {
34
+ handleError(error);
35
+ }
36
+ });
37
+
38
+ // Get a specific article
39
+ rss
40
+ .command('get')
41
+ .description('Get a specific article')
42
+ .argument('<url>', 'Blog URL (feed will be auto-discovered)')
43
+ .argument('<article-id>', 'Article ID or URL')
44
+ .action(async (url, articleId) => {
45
+ try {
46
+ const client = new RssClient();
47
+ const article = await client.get(url, articleId);
48
+ printRssArticle(article);
49
+ } catch (error) {
50
+ handleError(error);
51
+ }
52
+ });
53
+
54
+ // Get feed info
55
+ rss
56
+ .command('info')
57
+ .description('Get feed information')
58
+ .argument('<url>', 'Blog URL (feed will be auto-discovered)')
59
+ .action(async (url) => {
60
+ try {
61
+ const client = new RssClient();
62
+ const info = await client.getInfo(url);
63
+ printRssFeedInfo(info);
64
+ } catch (error) {
65
+ handleError(error);
66
+ }
67
+ });
68
+ }
@@ -115,7 +115,7 @@ export async function listProfiles(service?: ServiceName): Promise<{
115
115
  default?: string;
116
116
  }[]> {
117
117
  const config = await loadConfig();
118
- const services: ServiceName[] = service ? [service] : ['gmail', 'gchat', 'jira', 'telegram'];
118
+ const services: ServiceName[] = service ? [service] : ['gmail', 'gchat', 'jira', 'slack', 'telegram'];
119
119
 
120
120
  return services.map((svc) => ({
121
121
  service: svc,
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { registerTelegramCommands } from './commands/telegram';
5
5
  import { registerGChatCommands } from './commands/gchat';
6
6
  import { registerJiraCommands } from './commands/jira';
7
7
  import { registerSlackCommands } from './commands/slack';
8
+ import { registerRssCommands } from './commands/rss';
8
9
  import { registerUpdateCommand } from './commands/update';
9
10
  import { registerConfigCommands } from './commands/config';
10
11
  import { registerClaudeCommands } from './commands/claude';
@@ -31,6 +32,7 @@ registerTelegramCommands(program);
31
32
  registerGChatCommands(program);
32
33
  registerJiraCommands(program);
33
34
  registerSlackCommands(program);
35
+ registerRssCommands(program);
34
36
  registerUpdateCommand(program);
35
37
  registerConfigCommands(program);
36
38
  registerClaudeCommands(program);
@@ -0,0 +1,230 @@
1
+ import Parser from 'rss-parser';
2
+ import type { RssFeed, RssArticle, RssListOptions } from '../../types/rss';
3
+ import { CliError } from '../../utils/errors';
4
+
5
+ type CustomItem = {
6
+ contentEncoded?: string;
7
+ dcCreator?: string;
8
+ };
9
+
10
+ type CustomFeed = {
11
+ language?: string;
12
+ lastBuildDate?: string;
13
+ };
14
+
15
+ // Common feed paths to try if discovery fails
16
+ const COMMON_FEED_PATHS = [
17
+ '/feed',
18
+ '/feed.xml',
19
+ '/rss',
20
+ '/rss.xml',
21
+ '/atom.xml',
22
+ '/index.xml',
23
+ '/feed/atom',
24
+ '/feed/rss',
25
+ ];
26
+
27
+ export class RssClient {
28
+ private parser: Parser<CustomFeed, CustomItem>;
29
+ private feedUrl: string | null = null;
30
+
31
+ constructor() {
32
+ this.parser = new Parser({
33
+ customFields: {
34
+ item: [['content:encoded', 'contentEncoded'], ['dc:creator', 'dcCreator']],
35
+ },
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Discover RSS feed URL from a blog URL
41
+ */
42
+ async discoverFeed(url: string): Promise<string> {
43
+ // Normalize URL
44
+ let baseUrl = url.trim();
45
+ if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
46
+ baseUrl = 'https://' + baseUrl;
47
+ }
48
+ baseUrl = baseUrl.replace(/\/$/, '');
49
+
50
+ // First, try to parse the URL directly as a feed
51
+ try {
52
+ await this.parser.parseURL(baseUrl);
53
+ this.feedUrl = baseUrl;
54
+ return baseUrl;
55
+ } catch {
56
+ // Not a feed, continue with discovery
57
+ }
58
+
59
+ // Fetch the page and look for feed links in HTML
60
+ try {
61
+ const response = await fetch(baseUrl, {
62
+ headers: { 'User-Agent': 'agentio-rss/1.0' },
63
+ });
64
+
65
+ if (response.ok) {
66
+ const html = await response.text();
67
+ const feedUrl = this.extractFeedUrl(html, baseUrl);
68
+ if (feedUrl) {
69
+ // Verify it's a valid feed
70
+ try {
71
+ await this.parser.parseURL(feedUrl);
72
+ this.feedUrl = feedUrl;
73
+ return feedUrl;
74
+ } catch {
75
+ // Invalid feed, continue trying
76
+ }
77
+ }
78
+ }
79
+ } catch {
80
+ // Failed to fetch page, continue with common paths
81
+ }
82
+
83
+ // Try common feed paths
84
+ for (const path of COMMON_FEED_PATHS) {
85
+ const feedUrl = baseUrl + path;
86
+ try {
87
+ await this.parser.parseURL(feedUrl);
88
+ this.feedUrl = feedUrl;
89
+ return feedUrl;
90
+ } catch {
91
+ // Try next path
92
+ }
93
+ }
94
+
95
+ throw new CliError(
96
+ 'NOT_FOUND',
97
+ `Could not find RSS feed for: ${url}`,
98
+ 'Try providing the direct feed URL instead'
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Extract feed URL from HTML link tags
104
+ */
105
+ private extractFeedUrl(html: string, baseUrl: string): string | null {
106
+ // Match <link> tags with rel="alternate" and type containing rss or atom
107
+ const linkRegex = /<link[^>]*rel=["']alternate["'][^>]*>/gi;
108
+ const matches = html.match(linkRegex) || [];
109
+
110
+ for (const link of matches) {
111
+ // Check if it's an RSS or Atom feed
112
+ if (link.includes('application/rss+xml') ||
113
+ link.includes('application/atom+xml') ||
114
+ link.includes('application/feed+json')) {
115
+ // Extract href
116
+ const hrefMatch = link.match(/href=["']([^"']+)["']/i);
117
+ if (hrefMatch) {
118
+ let href = hrefMatch[1];
119
+ // Handle relative URLs
120
+ if (href.startsWith('/')) {
121
+ const urlObj = new URL(baseUrl);
122
+ href = urlObj.origin + href;
123
+ } else if (!href.startsWith('http')) {
124
+ href = baseUrl + '/' + href;
125
+ }
126
+ return href;
127
+ }
128
+ }
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Fetch and parse the RSS feed
136
+ */
137
+ async getFeed(url?: string): Promise<RssFeed> {
138
+ const feedUrl = url || this.feedUrl;
139
+ if (!feedUrl) {
140
+ throw new CliError('INVALID_PARAMS', 'No feed URL provided', 'Call discoverFeed() first or provide a URL');
141
+ }
142
+
143
+ try {
144
+ const feed = await this.parser.parseURL(feedUrl);
145
+ return this.parseFeed(feed);
146
+ } catch (error) {
147
+ if (error instanceof CliError) throw error;
148
+ if (error instanceof Error) {
149
+ if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
150
+ throw new CliError('NETWORK_ERROR', `Cannot reach feed: ${feedUrl}`, 'Check the URL and your internet connection');
151
+ }
152
+ if (error.message.includes('Non-whitespace before first tag') || error.message.includes('Invalid XML')) {
153
+ throw new CliError('INVALID_PARAMS', 'Invalid RSS feed format', 'The URL may not be an RSS feed');
154
+ }
155
+ }
156
+ throw new CliError('API_ERROR', `Failed to parse feed: ${error}`, 'Verify the feed URL is valid');
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Get articles from the feed
162
+ */
163
+ async list(url: string, options?: RssListOptions): Promise<RssArticle[]> {
164
+ const feedUrl = await this.discoverFeed(url);
165
+ const feed = await this.getFeed(feedUrl);
166
+ let articles = feed.items;
167
+
168
+ // Filter by date if specified
169
+ if (options?.since) {
170
+ articles = articles.filter(article => {
171
+ if (!article.pubDate) return true;
172
+ return new Date(article.pubDate) >= options.since!;
173
+ });
174
+ }
175
+
176
+ // Apply limit
177
+ const limit = options?.limit ?? 20;
178
+ return articles.slice(0, limit);
179
+ }
180
+
181
+ /**
182
+ * Get a specific article by ID
183
+ */
184
+ async get(url: string, articleId: string): Promise<RssArticle> {
185
+ const feedUrl = await this.discoverFeed(url);
186
+ const feed = await this.getFeed(feedUrl);
187
+ const article = feed.items.find(item => item.id === articleId || item.link === articleId);
188
+
189
+ if (!article) {
190
+ throw new CliError('NOT_FOUND', `Article not found: ${articleId}`, 'Use "agentio rss articles" to list available articles');
191
+ }
192
+
193
+ return article;
194
+ }
195
+
196
+ /**
197
+ * Get feed info
198
+ */
199
+ async getInfo(url: string): Promise<RssFeed & { feedUrl: string }> {
200
+ const feedUrl = await this.discoverFeed(url);
201
+ const feed = await this.getFeed(feedUrl);
202
+ return { ...feed, feedUrl };
203
+ }
204
+
205
+ private parseFeed(raw: Parser.Output<CustomFeed & CustomItem>): RssFeed {
206
+ // Cast to access optional feed properties not in base type
207
+ const feedData = raw as Parser.Output<CustomFeed & CustomItem> & CustomFeed;
208
+ return {
209
+ title: raw.title || 'Untitled Feed',
210
+ description: raw.description,
211
+ link: raw.link,
212
+ language: feedData.language,
213
+ lastBuildDate: feedData.lastBuildDate,
214
+ items: (raw.items || []).map(item => this.parseArticle(item)),
215
+ };
216
+ }
217
+
218
+ private parseArticle(raw: Parser.Item & CustomItem): RssArticle {
219
+ return {
220
+ id: raw.guid || raw.link || raw.title || '',
221
+ title: raw.title || 'Untitled',
222
+ link: raw.link,
223
+ description: raw.contentSnippet || raw.content,
224
+ content: raw.contentEncoded || raw.content,
225
+ author: raw.creator || raw.dcCreator,
226
+ pubDate: raw.pubDate || raw.isoDate,
227
+ categories: raw.categories,
228
+ };
229
+ }
230
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Parsed feed metadata
3
+ */
4
+ export interface RssFeed {
5
+ title: string;
6
+ description?: string;
7
+ link?: string;
8
+ language?: string;
9
+ lastBuildDate?: string;
10
+ items: RssArticle[];
11
+ }
12
+
13
+ /**
14
+ * Individual article/item from feed
15
+ */
16
+ export interface RssArticle {
17
+ id: string;
18
+ title: string;
19
+ link?: string;
20
+ description?: string;
21
+ content?: string;
22
+ author?: string;
23
+ pubDate?: string;
24
+ categories?: string[];
25
+ }
26
+
27
+ /**
28
+ * Options for listing articles
29
+ */
30
+ export interface RssListOptions {
31
+ limit?: number;
32
+ since?: Date;
33
+ }
@@ -2,6 +2,7 @@ import type { GmailMessage, GmailAttachmentInfo } from '../types/gmail';
2
2
  import type { GChatMessage } from '../types/gchat';
3
3
  import type { JiraProject, JiraIssue, JiraTransition, JiraCommentResult, JiraTransitionResult } from '../types/jira';
4
4
  import type { SlackSendResult } from '../types/slack';
5
+ import type { RssFeed, RssArticle } from '../types/rss';
5
6
 
6
7
  function formatBytes(bytes: number): string {
7
8
  if (bytes === 0) return '0 B';
@@ -230,3 +231,50 @@ export function printSlackSendResult(result: SlackSendResult): void {
230
231
  console.log('Type: Block Kit payload');
231
232
  }
232
233
  }
234
+
235
+ // RSS specific formatters
236
+ export function printRssArticleList(articles: RssArticle[], feedName: string): void {
237
+ if (articles.length === 0) {
238
+ console.log('No articles found');
239
+ return;
240
+ }
241
+
242
+ console.log(`Articles from ${feedName} (${articles.length})\n`);
243
+
244
+ for (let i = 0; i < articles.length; i++) {
245
+ const article = articles[i];
246
+ console.log(`[${i + 1}] ${article.title}`);
247
+ if (article.author) console.log(` Author: ${article.author}`);
248
+ if (article.pubDate) console.log(` Date: ${article.pubDate}`);
249
+ if (article.link) console.log(` Link: ${article.link}`);
250
+ if (article.description) {
251
+ const snippet = article.description.length > 150
252
+ ? article.description.substring(0, 150) + '...'
253
+ : article.description;
254
+ console.log(` > ${snippet}`);
255
+ }
256
+ console.log('');
257
+ }
258
+ }
259
+
260
+ export function printRssArticle(article: RssArticle): void {
261
+ console.log(`Title: ${article.title}`);
262
+ if (article.author) console.log(`Author: ${article.author}`);
263
+ if (article.pubDate) console.log(`Date: ${article.pubDate}`);
264
+ if (article.link) console.log(`Link: ${article.link}`);
265
+ if (article.categories && article.categories.length > 0) {
266
+ console.log(`Categories: ${article.categories.join(', ')}`);
267
+ }
268
+ console.log('---');
269
+ console.log(article.content || article.description || 'No content available');
270
+ }
271
+
272
+ export function printRssFeedInfo(feed: RssFeed & { feedUrl: string }): void {
273
+ console.log(`Title: ${feed.title}`);
274
+ console.log(`Feed URL: ${feed.feedUrl}`);
275
+ if (feed.description) console.log(`Description: ${feed.description}`);
276
+ if (feed.link) console.log(`Site: ${feed.link}`);
277
+ if (feed.language) console.log(`Language: ${feed.language}`);
278
+ if (feed.lastBuildDate) console.log(`Last Updated: ${feed.lastBuildDate}`);
279
+ console.log(`Articles: ${feed.items.length}`);
280
+ }