@plosson/agentio 0.1.26 → 0.1.27

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/README.md CHANGED
@@ -72,6 +72,7 @@ Download from [GitHub Releases](https://github.com/plosson/agentio/releases/late
72
72
  | Google Chat | Available | `send`, `list`, `get` |
73
73
  | Slack | Available | `send` |
74
74
  | JIRA | Available | `projects`, `search`, `get`, `comment`, `transitions`, `transition` |
75
+ | RSS | Available | `articles`, `get`, `info` |
75
76
  | Linear | Planned | - |
76
77
 
77
78
  ## Usage
@@ -177,6 +178,23 @@ agentio jira transitions PROJ-123
177
178
  agentio jira transition PROJ-123 <transition-id>
178
179
  ```
179
180
 
181
+ ### RSS
182
+
183
+ ```bash
184
+ # List articles from a blog (feed URL auto-discovered)
185
+ agentio rss articles https://simonwillison.net
186
+ agentio rss articles https://steipete.me --limit 5
187
+
188
+ # Filter by date
189
+ agentio rss articles https://blog.fsck.com --since 2025-01-01
190
+
191
+ # Get feed info (shows discovered feed URL)
192
+ agentio rss info https://kau.sh
193
+
194
+ # Get a specific article
195
+ agentio rss get https://simonwillison.net <article-url>
196
+ ```
197
+
180
198
  ## Multi-Profile Support
181
199
 
182
200
  Each service supports multiple named profiles:
@@ -192,54 +210,52 @@ agentio gmail list --profile work
192
210
 
193
211
  ## Claude Code Integration
194
212
 
195
- agentio provides plugins for [Claude Code](https://claude.ai/download) with skills for Gmail, Telegram, and Google Chat operations.
213
+ agentio provides plugins for [Claude Code](https://claude.ai/download) with skills for Gmail, Telegram, Google Chat, JIRA, and RSS operations.
196
214
 
197
- ### Install the Plugin
215
+ ### Install Marketplaces and Plugins
198
216
 
199
217
  ```bash
200
- # Install from GitHub
201
- agentio claude plugin install plosson/agentio
218
+ # Add a marketplace (auto-detected from URL)
219
+ agentio claude install https://github.com/plosson/agentio
202
220
 
203
- # Or install from a full GitHub URL
204
- agentio claude plugin install https://github.com/plosson/agentio
221
+ # Install a plugin (auto-detected from name@marketplace format)
222
+ agentio claude install agentio-gmail@agentio
205
223
 
206
- # Install to a specific directory
207
- agentio claude plugin install plosson/agentio -d ~/myproject
208
-
209
- # Install only specific components (skills, commands, hooks, agents)
210
- agentio claude plugin install plosson/agentio --skills
211
- agentio claude plugin install plosson/agentio --agents
212
-
213
- # Force reinstall if already exists
214
- agentio claude plugin install plosson/agentio -f
215
-
216
- # Show detailed installation logs
217
- agentio claude plugin install plosson/agentio --verbose
224
+ # Install all from agentio.json
225
+ agentio claude install
218
226
  ```
219
227
 
220
- Once installed, Claude Code can use the agentio CLI skills to help you manage emails, send Telegram messages, and more.
221
-
222
228
  ### Manage Plugins
223
229
 
224
230
  ```bash
225
- # List installed plugins
226
- agentio claude plugin list
231
+ # List marketplaces and plugins from agentio.json
232
+ agentio claude list
227
233
 
228
- # Remove a plugin
229
- agentio claude plugin remove agentio
234
+ # Update marketplaces
235
+ agentio claude update
236
+ agentio claude update https://github.com/plosson/agentio
237
+
238
+ # Remove a marketplace or plugin
239
+ agentio claude remove https://github.com/plosson/agentio
240
+ agentio claude remove agentio-gmail@agentio
230
241
  ```
231
242
 
232
- ### Install from agentio.json
243
+ ### agentio.json
233
244
 
234
- If your project has an `agentio.json` file listing plugins, you can install all of them at once:
245
+ Projects can define marketplaces and plugins in an `agentio.json` file:
235
246
 
236
- ```bash
237
- # Install all plugins from agentio.json in current directory
238
- agentio claude plugin install
247
+ ```json
248
+ {
249
+ "marketplaces": [
250
+ "https://github.com/plosson/agentio"
251
+ ],
252
+ "plugins": [
253
+ "agentio-gmail@agentio",
254
+ "agentio-rss@agentio"
255
+ ]
256
+ }
239
257
  ```
240
258
 
241
- Plugins are installed to `.claude/` in the target directory (skills, commands, hooks, and agents subdirectories).
242
-
243
259
  ## Design
244
260
 
245
261
  agentio is designed for LLM consumption:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,248 @@
1
+ import { Command } from 'commander';
2
+ import { setCredentials, removeCredentials, getCredentials } from '../auth/token-store';
3
+ import { setProfile, removeProfile, listProfiles, getProfile } from '../config/config-manager';
4
+ import { DiscourseClient } from '../services/discourse/client';
5
+ import { CliError, handleError } from '../utils/errors';
6
+ import { prompt, resolveProfileName } from '../utils/stdin';
7
+ import type { DiscourseCredentials } from '../types/discourse';
8
+ import {
9
+ printDiscourseTopicList,
10
+ printDiscourseTopic,
11
+ printDiscourseCategoryList,
12
+ } from '../utils/output';
13
+
14
+ async function getDiscourseClient(
15
+ profileName?: string
16
+ ): Promise<{ client: DiscourseClient; profile: string }> {
17
+ const profile = await getProfile('discourse', profileName);
18
+
19
+ if (!profile) {
20
+ throw new CliError(
21
+ 'PROFILE_NOT_FOUND',
22
+ profileName
23
+ ? `Profile "${profileName}" not found for discourse`
24
+ : 'No default profile configured for discourse',
25
+ 'Run: agentio discourse profile add'
26
+ );
27
+ }
28
+
29
+ const credentials = await getCredentials<DiscourseCredentials>('discourse', profile);
30
+
31
+ if (!credentials) {
32
+ throw new CliError(
33
+ 'AUTH_FAILED',
34
+ `No credentials found for discourse profile "${profile}"`,
35
+ `Run: agentio discourse profile add --profile ${profile}`
36
+ );
37
+ }
38
+
39
+ return {
40
+ client: new DiscourseClient(credentials),
41
+ profile,
42
+ };
43
+ }
44
+
45
+ export function registerDiscourseCommands(program: Command): void {
46
+ const discourse = program.command('discourse').description('Discourse forum operations');
47
+
48
+ // List topics
49
+ discourse
50
+ .command('list')
51
+ .description('List latest topics')
52
+ .option('--profile <name>', 'Profile name')
53
+ .option('--category <slug>', 'Filter by category slug or name')
54
+ .option('--page <number>', 'Page number (0-indexed)', '0')
55
+ .action(async (options) => {
56
+ try {
57
+ const { client } = await getDiscourseClient(options.profile);
58
+ const topics = await client.listTopics({
59
+ category: options.category,
60
+ page: parseInt(options.page, 10),
61
+ });
62
+ printDiscourseTopicList(topics);
63
+ } catch (error) {
64
+ handleError(error);
65
+ }
66
+ });
67
+
68
+ // Get topic detail
69
+ discourse
70
+ .command('get')
71
+ .description('Get a topic with its posts')
72
+ .argument('<topic-id>', 'Topic ID')
73
+ .option('--profile <name>', 'Profile name')
74
+ .action(async (topicId: string, options) => {
75
+ try {
76
+ const id = parseInt(topicId, 10);
77
+ if (isNaN(id)) {
78
+ throw new CliError('INVALID_PARAMS', 'Topic ID must be a number');
79
+ }
80
+
81
+ const { client } = await getDiscourseClient(options.profile);
82
+ const topic = await client.getTopic(id);
83
+ printDiscourseTopic(topic);
84
+ } catch (error) {
85
+ handleError(error);
86
+ }
87
+ });
88
+
89
+ // List categories
90
+ discourse
91
+ .command('categories')
92
+ .description('List all categories')
93
+ .option('--profile <name>', 'Profile name')
94
+ .action(async (options) => {
95
+ try {
96
+ const { client } = await getDiscourseClient(options.profile);
97
+ const categories = await client.getCategories();
98
+ printDiscourseCategoryList(categories);
99
+ } catch (error) {
100
+ handleError(error);
101
+ }
102
+ });
103
+
104
+ // Profile management
105
+ const profile = discourse.command('profile').description('Manage Discourse profiles');
106
+
107
+ profile
108
+ .command('add')
109
+ .description('Add a new Discourse profile')
110
+ .option('--profile <name>', 'Profile name', 'default')
111
+ .action(async (options) => {
112
+ try {
113
+ const profileName = await resolveProfileName('discourse', options.profile);
114
+
115
+ console.error('\n💬 Discourse Setup\n');
116
+
117
+ // Step 1: Get base URL
118
+ console.error('Step 1: Enter your Discourse forum URL');
119
+ console.error(' Example: https://meta.discourse.org or https://forum.example.com\n');
120
+
121
+ const baseUrl = await prompt('? Forum URL: ');
122
+
123
+ if (!baseUrl) {
124
+ throw new CliError('INVALID_PARAMS', 'Forum URL is required');
125
+ }
126
+
127
+ // Normalize URL
128
+ let normalizedUrl = baseUrl.trim();
129
+ if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
130
+ normalizedUrl = `https://${normalizedUrl}`;
131
+ }
132
+ normalizedUrl = normalizedUrl.replace(/\/$/, '');
133
+
134
+ // Step 2: Get API credentials
135
+ console.error('\nStep 2: Create an API key');
136
+ console.error(' 1. Go to your Discourse admin panel');
137
+ console.error(` ${normalizedUrl}/admin/api/keys`);
138
+ console.error(' 2. Click "New API Key"');
139
+ console.error(' 3. Description: "agentio CLI"');
140
+ console.error(' 4. User Level: Choose your user or "All Users" for admin access');
141
+ console.error(' 5. Scope: "Read" (or "Read, Write" if you plan to create topics later)');
142
+ console.error(' 6. Click "Save" and copy the API key\n');
143
+
144
+ const apiKey = await prompt('? API Key: ');
145
+
146
+ if (!apiKey) {
147
+ throw new CliError('INVALID_PARAMS', 'API key is required');
148
+ }
149
+
150
+ console.error('\nStep 3: Enter your Discourse username');
151
+ console.error(' This should match the user associated with the API key\n');
152
+
153
+ const username = await prompt('? Username: ');
154
+
155
+ if (!username) {
156
+ throw new CliError('INVALID_PARAMS', 'Username is required');
157
+ }
158
+
159
+ // Validate credentials
160
+ console.error('\nValidating credentials...');
161
+
162
+ const credentials: DiscourseCredentials = {
163
+ baseUrl: normalizedUrl,
164
+ apiKey: apiKey.trim(),
165
+ username: username.trim(),
166
+ };
167
+
168
+ const client = new DiscourseClient(credentials);
169
+ try {
170
+ await client.validateCredentials();
171
+ } catch (error) {
172
+ if (error instanceof CliError) {
173
+ if (error.code === 'AUTH_FAILED') {
174
+ throw new CliError(
175
+ 'AUTH_FAILED',
176
+ 'Invalid API key or username. Please check your credentials.',
177
+ 'Make sure the API key is active and the username matches'
178
+ );
179
+ }
180
+ if (error.code === 'NETWORK_ERROR') {
181
+ throw new CliError(
182
+ 'NETWORK_ERROR',
183
+ `Cannot connect to ${normalizedUrl}`,
184
+ 'Check the URL and your network connection'
185
+ );
186
+ }
187
+ }
188
+ throw error;
189
+ }
190
+
191
+ console.error(`\n✓ Connected to ${normalizedUrl}`);
192
+ console.error(`✓ Authenticated as ${username}\n`);
193
+
194
+ // Save credentials
195
+ await setProfile('discourse', profileName);
196
+ await setCredentials('discourse', profileName, credentials);
197
+
198
+ console.log(`\n✅ Profile "${profileName}" configured!`);
199
+ console.log(` Test with: agentio discourse list --profile ${profileName}`);
200
+ } catch (error) {
201
+ handleError(error);
202
+ }
203
+ });
204
+
205
+ profile
206
+ .command('list')
207
+ .description('List Discourse profiles')
208
+ .action(async () => {
209
+ try {
210
+ const result = await listProfiles('discourse');
211
+ const { profiles, default: defaultProfile } = result[0];
212
+
213
+ if (profiles.length === 0) {
214
+ console.log('No profiles configured');
215
+ } else {
216
+ for (const name of profiles) {
217
+ const marker = name === defaultProfile ? ' (default)' : '';
218
+ const credentials = await getCredentials<DiscourseCredentials>('discourse', name);
219
+ const urlInfo = credentials?.baseUrl ? ` - ${credentials.baseUrl}` : '';
220
+ console.log(`${name}${marker}${urlInfo}`);
221
+ }
222
+ }
223
+ } catch (error) {
224
+ handleError(error);
225
+ }
226
+ });
227
+
228
+ profile
229
+ .command('remove')
230
+ .description('Remove a Discourse profile')
231
+ .requiredOption('--profile <name>', 'Profile name')
232
+ .action(async (options) => {
233
+ try {
234
+ const profileName = options.profile;
235
+
236
+ const removed = await removeProfile('discourse', profileName);
237
+ await removeCredentials('discourse', profileName);
238
+
239
+ if (removed) {
240
+ console.error(`Removed profile "${profileName}"`);
241
+ } else {
242
+ console.error(`Profile "${profileName}" not found`);
243
+ }
244
+ } catch (error) {
245
+ handleError(error);
246
+ }
247
+ });
248
+ }
@@ -185,6 +185,7 @@ Query Syntax Examples:
185
185
  .option('--body <body>', 'Email body (or pipe via stdin)')
186
186
  .option('--html', 'Treat body as HTML')
187
187
  .option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
188
+ .option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val, acc: string[]) => [...acc, val], [])
188
189
  .action(async (options) => {
189
190
  try {
190
191
  let body = options.body;
@@ -198,13 +199,32 @@ Query Syntax Examples:
198
199
  throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
199
200
  }
200
201
 
201
- // Process attachments
202
- const attachments: GmailAttachment[] | undefined = options.attachment.length
203
- ? options.attachment.map((path: string) => ({
204
- path,
205
- filename: basename(path),
206
- }))
207
- : undefined;
202
+ // Process regular attachments
203
+ const regularAttachments: GmailAttachment[] = options.attachment.map((path: string) => ({
204
+ path,
205
+ filename: basename(path),
206
+ }));
207
+
208
+ // Process inline images (format: contentId:filepath)
209
+ const inlineAttachments: GmailAttachment[] = options.inline.map((spec: string) => {
210
+ const colonIndex = spec.indexOf(':');
211
+ if (colonIndex === -1) {
212
+ throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
213
+ }
214
+ const contentId = spec.substring(0, colonIndex);
215
+ const path = spec.substring(colonIndex + 1);
216
+ return {
217
+ path,
218
+ filename: basename(path),
219
+ contentId,
220
+ };
221
+ });
222
+
223
+ // Combine attachments
224
+ const attachments: GmailAttachment[] | undefined =
225
+ regularAttachments.length || inlineAttachments.length
226
+ ? [...regularAttachments, ...inlineAttachments]
227
+ : undefined;
208
228
 
209
229
  const { client } = await getGmailClient(options.profile);
210
230
  const result = await client.send({
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { registerGChatCommands } from './commands/gchat';
6
6
  import { registerJiraCommands } from './commands/jira';
7
7
  import { registerSlackCommands } from './commands/slack';
8
8
  import { registerRssCommands } from './commands/rss';
9
+ import { registerDiscourseCommands } from './commands/discourse';
9
10
  import { registerUpdateCommand } from './commands/update';
10
11
  import { registerConfigCommands } from './commands/config';
11
12
  import { registerClaudeCommands } from './commands/claude';
@@ -33,6 +34,7 @@ registerGChatCommands(program);
33
34
  registerJiraCommands(program);
34
35
  registerSlackCommands(program);
35
36
  registerRssCommands(program);
37
+ registerDiscourseCommands(program);
36
38
  registerUpdateCommand(program);
37
39
  registerConfigCommands(program);
38
40
  registerClaudeCommands(program);
@@ -0,0 +1,273 @@
1
+ import type {
2
+ DiscourseCredentials,
3
+ DiscourseCategory,
4
+ DiscourseTopic,
5
+ DiscourseTopicDetail,
6
+ DiscoursePost,
7
+ DiscourseListOptions,
8
+ } from '../../types/discourse';
9
+ import { CliError, type ErrorCode } from '../../utils/errors';
10
+
11
+ interface DiscourseApiResponse {
12
+ errors?: string[];
13
+ error_type?: string;
14
+ }
15
+
16
+ interface CategoryListResponse extends DiscourseApiResponse {
17
+ category_list: {
18
+ categories: RawCategory[];
19
+ };
20
+ }
21
+
22
+ interface RawCategory {
23
+ id: number;
24
+ name: string;
25
+ slug: string;
26
+ description: string;
27
+ topic_count: number;
28
+ post_count: number;
29
+ color: string;
30
+ parent_category_id?: number;
31
+ }
32
+
33
+ interface TopicListResponse extends DiscourseApiResponse {
34
+ topic_list: {
35
+ topics: RawTopic[];
36
+ };
37
+ }
38
+
39
+ interface RawTopic {
40
+ id: number;
41
+ title: string;
42
+ slug: string;
43
+ posts_count: number;
44
+ reply_count: number;
45
+ views: number;
46
+ like_count: number;
47
+ category_id: number;
48
+ created_at: string;
49
+ last_posted_at: string;
50
+ pinned: boolean;
51
+ closed: boolean;
52
+ archived: boolean;
53
+ posters?: Array<{ user_id: number; extras?: string }>;
54
+ }
55
+
56
+ interface TopicDetailResponse extends DiscourseApiResponse {
57
+ id: number;
58
+ title: string;
59
+ slug: string;
60
+ posts_count: number;
61
+ reply_count: number;
62
+ views: number;
63
+ like_count: number;
64
+ category_id: number;
65
+ created_at: string;
66
+ last_posted_at: string;
67
+ pinned: boolean;
68
+ closed: boolean;
69
+ archived: boolean;
70
+ post_stream: {
71
+ posts: RawPost[];
72
+ };
73
+ }
74
+
75
+ interface RawPost {
76
+ id: number;
77
+ username: string;
78
+ display_username?: string;
79
+ created_at: string;
80
+ updated_at: string;
81
+ post_number: number;
82
+ raw?: string;
83
+ cooked: string;
84
+ reply_count: number;
85
+ like_count: number;
86
+ }
87
+
88
+ export class DiscourseClient {
89
+ private baseUrl: string;
90
+ private apiKey: string;
91
+ private username: string;
92
+ private categoryCache: Map<number, string> = new Map();
93
+
94
+ constructor(credentials: DiscourseCredentials) {
95
+ this.baseUrl = credentials.baseUrl.replace(/\/$/, '');
96
+ this.apiKey = credentials.apiKey;
97
+ this.username = credentials.username;
98
+ }
99
+
100
+ private async request<T>(
101
+ method: string,
102
+ path: string,
103
+ body?: Record<string, unknown>
104
+ ): Promise<T> {
105
+ const url = `${this.baseUrl}${path}`;
106
+
107
+ try {
108
+ const response = await fetch(url, {
109
+ method,
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ 'Api-Key': this.apiKey,
113
+ 'Api-Username': this.username,
114
+ },
115
+ body: body ? JSON.stringify(body) : undefined,
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const errorCode = this.getErrorCode(response.status);
120
+ const text = await response.text();
121
+ let message = `Discourse API error: ${response.status}`;
122
+ try {
123
+ const data = JSON.parse(text) as DiscourseApiResponse;
124
+ if (data.errors) {
125
+ message = data.errors.join(', ');
126
+ }
127
+ } catch {
128
+ if (text) message = text;
129
+ }
130
+ throw new CliError(errorCode, message);
131
+ }
132
+
133
+ return (await response.json()) as T;
134
+ } catch (error) {
135
+ if (error instanceof CliError) throw error;
136
+
137
+ const message = error instanceof Error ? error.message : 'Unknown error';
138
+ throw new CliError('NETWORK_ERROR', `Failed to connect to Discourse: ${message}`);
139
+ }
140
+ }
141
+
142
+ private getErrorCode(status: number): ErrorCode {
143
+ if (status === 401 || status === 403) return 'AUTH_FAILED';
144
+ if (status === 404) return 'NOT_FOUND';
145
+ if (status === 429) return 'RATE_LIMITED';
146
+ return 'API_ERROR';
147
+ }
148
+
149
+ async getCategories(): Promise<DiscourseCategory[]> {
150
+ const data = await this.request<CategoryListResponse>('GET', '/categories.json');
151
+
152
+ const categories = data.category_list.categories.map((cat) => this.parseCategory(cat));
153
+
154
+ // Cache category names for topic listings
155
+ for (const cat of categories) {
156
+ this.categoryCache.set(cat.id, cat.name);
157
+ }
158
+
159
+ return categories;
160
+ }
161
+
162
+ private parseCategory(raw: RawCategory): DiscourseCategory {
163
+ return {
164
+ id: raw.id,
165
+ name: raw.name,
166
+ slug: raw.slug,
167
+ description: raw.description || '',
168
+ topicCount: raw.topic_count,
169
+ postCount: raw.post_count,
170
+ color: raw.color,
171
+ parentCategoryId: raw.parent_category_id,
172
+ };
173
+ }
174
+
175
+ async listTopics(options?: DiscourseListOptions): Promise<DiscourseTopic[]> {
176
+ let path: string;
177
+
178
+ if (options?.category) {
179
+ // Need to find category by slug
180
+ const categories = await this.getCategories();
181
+ const category = categories.find(
182
+ (c) => c.slug === options.category || c.name.toLowerCase() === options.category?.toLowerCase()
183
+ );
184
+ if (!category) {
185
+ throw new CliError('NOT_FOUND', `Category "${options.category}" not found`);
186
+ }
187
+ path = `/c/${category.slug}/${category.id}/l/latest.json`;
188
+ } else {
189
+ path = '/latest.json';
190
+ }
191
+
192
+ if (options?.page && options.page > 0) {
193
+ path += `?page=${options.page}`;
194
+ }
195
+
196
+ const data = await this.request<TopicListResponse>('GET', path);
197
+
198
+ // Ensure category cache is populated
199
+ if (this.categoryCache.size === 0) {
200
+ await this.getCategories();
201
+ }
202
+
203
+ return data.topic_list.topics.map((topic) => this.parseTopic(topic));
204
+ }
205
+
206
+ private parseTopic(raw: RawTopic): DiscourseTopic {
207
+ return {
208
+ id: raw.id,
209
+ title: raw.title,
210
+ slug: raw.slug,
211
+ postsCount: raw.posts_count,
212
+ replyCount: raw.reply_count,
213
+ views: raw.views,
214
+ likeCount: raw.like_count,
215
+ categoryId: raw.category_id,
216
+ categoryName: this.categoryCache.get(raw.category_id),
217
+ createdAt: raw.created_at,
218
+ lastPostedAt: raw.last_posted_at,
219
+ pinned: raw.pinned,
220
+ closed: raw.closed,
221
+ archived: raw.archived,
222
+ posters: raw.posters?.map((p) => ({ userId: p.user_id })),
223
+ };
224
+ }
225
+
226
+ async getTopic(topicId: number): Promise<DiscourseTopicDetail> {
227
+ const data = await this.request<TopicDetailResponse>('GET', `/t/${topicId}.json`);
228
+
229
+ // Ensure category cache is populated
230
+ if (this.categoryCache.size === 0) {
231
+ await this.getCategories();
232
+ }
233
+
234
+ return {
235
+ id: data.id,
236
+ title: data.title,
237
+ slug: data.slug,
238
+ postsCount: data.posts_count,
239
+ replyCount: data.reply_count,
240
+ views: data.views,
241
+ likeCount: data.like_count,
242
+ categoryId: data.category_id,
243
+ categoryName: this.categoryCache.get(data.category_id),
244
+ createdAt: data.created_at,
245
+ lastPostedAt: data.last_posted_at,
246
+ pinned: data.pinned,
247
+ closed: data.closed,
248
+ archived: data.archived,
249
+ posts: data.post_stream.posts.map((p) => this.parsePost(p)),
250
+ };
251
+ }
252
+
253
+ private parsePost(raw: RawPost): DiscoursePost {
254
+ return {
255
+ id: raw.id,
256
+ username: raw.username,
257
+ displayName: raw.display_username,
258
+ createdAt: raw.created_at,
259
+ updatedAt: raw.updated_at,
260
+ postNumber: raw.post_number,
261
+ raw: raw.raw,
262
+ cooked: raw.cooked,
263
+ replyCount: raw.reply_count,
264
+ likeCount: raw.like_count,
265
+ };
266
+ }
267
+
268
+ async validateCredentials(): Promise<{ username: string }> {
269
+ // Try to fetch categories as a validation check
270
+ await this.getCategories();
271
+ return { username: this.username };
272
+ }
273
+ }
@@ -308,54 +308,140 @@ export class GmailClient {
308
308
  attachments: GmailAttachment[];
309
309
  }): Promise<string> {
310
310
  const { from, to, cc, bcc, subject, body, isHtml, attachments } = options;
311
- const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
312
-
313
- const headers = [
314
- `From: ${from}`,
315
- `To: ${to.join(', ')}`,
316
- cc?.length ? `Cc: ${cc.join(', ')}` : null,
317
- bcc?.length ? `Bcc: ${bcc.join(', ')}` : null,
318
- `Subject: ${subject}`,
319
- 'MIME-Version: 1.0',
320
- `Content-Type: multipart/mixed; boundary="${boundary}"`,
321
- '',
322
- `--${boundary}`,
323
- `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
324
- '',
325
- body,
326
- ].filter((line): line is string => line !== null);
327
-
328
- // Add attachments
329
- for (const attachment of attachments) {
330
- try {
331
- const file = Bun.file(attachment.path);
332
- const exists = await file.exists();
333
- if (!exists) {
334
- throw new CliError('NOT_FOUND', `Attachment not found: ${attachment.path}`);
335
- }
336
311
 
337
- const content = await file.arrayBuffer();
338
- const base64Content = Buffer.from(content).toString('base64');
339
- const filename = attachment.filename || basename(attachment.path);
340
- const mimeType = attachment.mimeType || getMimeType(filename);
341
-
342
- headers.push(
343
- `--${boundary}`,
344
- `Content-Type: ${mimeType}; name="${filename}"`,
345
- 'Content-Transfer-Encoding: base64',
346
- `Content-Disposition: attachment; filename="${filename}"`,
347
- '',
348
- base64Content
349
- );
350
- } catch (error: any) {
351
- if (error instanceof CliError) throw error;
352
- throw new CliError('API_ERROR', `Failed to read attachment ${attachment.path}: ${error.message}`);
312
+ // Separate inline and regular attachments
313
+ const inlineAttachments = attachments.filter(a => a.contentId);
314
+ const regularAttachments = attachments.filter(a => !a.contentId);
315
+
316
+ const hasInline = inlineAttachments.length > 0;
317
+ const hasRegular = regularAttachments.length > 0;
318
+
319
+ // Generate boundaries
320
+ const mixedBoundary = `----=_Mixed_${Date.now()}_${Math.random().toString(36).substring(2)}`;
321
+ const relatedBoundary = `----=_Related_${Date.now()}_${Math.random().toString(36).substring(2)}`;
322
+
323
+ const lines: string[] = [];
324
+
325
+ // Email headers
326
+ lines.push(`From: ${from}`);
327
+ lines.push(`To: ${to.join(', ')}`);
328
+ if (cc?.length) lines.push(`Cc: ${cc.join(', ')}`);
329
+ if (bcc?.length) lines.push(`Bcc: ${bcc.join(', ')}`);
330
+ lines.push(`Subject: ${subject}`);
331
+ lines.push('MIME-Version: 1.0');
332
+
333
+ if (hasRegular && hasInline) {
334
+ // Both: multipart/mixed containing multipart/related + regular attachments
335
+ lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
336
+ lines.push('');
337
+ lines.push(`--${mixedBoundary}`);
338
+ lines.push(`Content-Type: multipart/related; boundary="${relatedBoundary}"`);
339
+ lines.push('');
340
+
341
+ // HTML body
342
+ lines.push(`--${relatedBoundary}`);
343
+ lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
344
+ lines.push('');
345
+ lines.push(body);
346
+
347
+ // Inline images
348
+ for (const attachment of inlineAttachments) {
349
+ const encoded = await this.encodeAttachment(attachment);
350
+ lines.push(`--${relatedBoundary}`);
351
+ lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
352
+ lines.push('Content-Transfer-Encoding: base64');
353
+ lines.push(`Content-ID: <${attachment.contentId}>`);
354
+ lines.push(`Content-Disposition: inline; filename="${encoded.filename}"`);
355
+ lines.push('');
356
+ lines.push(encoded.base64);
357
+ }
358
+ lines.push(`--${relatedBoundary}--`);
359
+
360
+ // Regular attachments
361
+ for (const attachment of regularAttachments) {
362
+ const encoded = await this.encodeAttachment(attachment);
363
+ lines.push(`--${mixedBoundary}`);
364
+ lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
365
+ lines.push('Content-Transfer-Encoding: base64');
366
+ lines.push(`Content-Disposition: attachment; filename="${encoded.filename}"`);
367
+ lines.push('');
368
+ lines.push(encoded.base64);
369
+ }
370
+ lines.push(`--${mixedBoundary}--`);
371
+
372
+ } else if (hasInline) {
373
+ // Only inline: multipart/related
374
+ lines.push(`Content-Type: multipart/related; boundary="${relatedBoundary}"`);
375
+ lines.push('');
376
+
377
+ // HTML body
378
+ lines.push(`--${relatedBoundary}`);
379
+ lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
380
+ lines.push('');
381
+ lines.push(body);
382
+
383
+ // Inline images
384
+ for (const attachment of inlineAttachments) {
385
+ const encoded = await this.encodeAttachment(attachment);
386
+ lines.push(`--${relatedBoundary}`);
387
+ lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
388
+ lines.push('Content-Transfer-Encoding: base64');
389
+ lines.push(`Content-ID: <${attachment.contentId}>`);
390
+ lines.push(`Content-Disposition: inline; filename="${encoded.filename}"`);
391
+ lines.push('');
392
+ lines.push(encoded.base64);
393
+ }
394
+ lines.push(`--${relatedBoundary}--`);
395
+
396
+ } else {
397
+ // Only regular: multipart/mixed (original behavior)
398
+ lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
399
+ lines.push('');
400
+ lines.push(`--${mixedBoundary}`);
401
+ lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
402
+ lines.push('');
403
+ lines.push(body);
404
+
405
+ for (const attachment of regularAttachments) {
406
+ const encoded = await this.encodeAttachment(attachment);
407
+ lines.push(`--${mixedBoundary}`);
408
+ lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
409
+ lines.push('Content-Transfer-Encoding: base64');
410
+ lines.push(`Content-Disposition: attachment; filename="${encoded.filename}"`);
411
+ lines.push('');
412
+ lines.push(encoded.base64);
353
413
  }
414
+ lines.push(`--${mixedBoundary}--`);
354
415
  }
355
416
 
356
- headers.push(`--${boundary}--`);
417
+ return lines.join('\r\n');
418
+ }
419
+
420
+ private async encodeAttachment(attachment: GmailAttachment): Promise<{
421
+ filename: string;
422
+ mimeType: string;
423
+ base64: string;
424
+ }> {
425
+ try {
426
+ const file = Bun.file(attachment.path);
427
+ const exists = await file.exists();
428
+ if (!exists) {
429
+ throw new CliError('NOT_FOUND', `Attachment not found: ${attachment.path}`);
430
+ }
357
431
 
358
- return headers.join('\r\n');
432
+ const content = await file.arrayBuffer();
433
+ const filename = attachment.filename || basename(attachment.path);
434
+ const mimeType = attachment.mimeType || getMimeType(filename);
435
+
436
+ return {
437
+ filename,
438
+ mimeType,
439
+ base64: Buffer.from(content).toString('base64'),
440
+ };
441
+ } catch (error: any) {
442
+ if (error instanceof CliError) throw error;
443
+ throw new CliError('API_ERROR', `Failed to read attachment ${attachment.path}: ${error.message}`);
444
+ }
359
445
  }
360
446
 
361
447
  async reply(options: GmailReplyOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
@@ -5,6 +5,7 @@ export interface Config {
5
5
  jira?: string[];
6
6
  slack?: string[];
7
7
  telegram?: string[];
8
+ discourse?: string[];
8
9
  };
9
10
  defaults: {
10
11
  gmail?: string;
@@ -12,7 +13,8 @@ export interface Config {
12
13
  jira?: string;
13
14
  slack?: string;
14
15
  telegram?: string;
16
+ discourse?: string;
15
17
  };
16
18
  }
17
19
 
18
- export type ServiceName = 'gmail' | 'gchat' | 'jira' | 'slack' | 'telegram';
20
+ export type ServiceName = 'gmail' | 'gchat' | 'jira' | 'slack' | 'telegram' | 'discourse';
@@ -0,0 +1,62 @@
1
+ export interface DiscourseCredentials {
2
+ baseUrl: string;
3
+ apiKey: string;
4
+ username: string;
5
+ }
6
+
7
+ export interface DiscourseCategory {
8
+ id: number;
9
+ name: string;
10
+ slug: string;
11
+ description: string;
12
+ topicCount: number;
13
+ postCount: number;
14
+ color: string;
15
+ parentCategoryId?: number;
16
+ }
17
+
18
+ export interface DiscoursePoster {
19
+ userId: number;
20
+ username?: string;
21
+ }
22
+
23
+ export interface DiscourseTopic {
24
+ id: number;
25
+ title: string;
26
+ slug: string;
27
+ postsCount: number;
28
+ replyCount: number;
29
+ views: number;
30
+ likeCount: number;
31
+ categoryId: number;
32
+ categoryName?: string;
33
+ createdAt: string;
34
+ lastPostedAt: string;
35
+ pinned: boolean;
36
+ closed: boolean;
37
+ archived: boolean;
38
+ posters?: DiscoursePoster[];
39
+ }
40
+
41
+ export interface DiscoursePost {
42
+ id: number;
43
+ username: string;
44
+ displayName?: string;
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ postNumber: number;
48
+ raw?: string;
49
+ cooked: string;
50
+ replyCount: number;
51
+ likeCount: number;
52
+ }
53
+
54
+ export interface DiscourseTopicDetail extends DiscourseTopic {
55
+ posts: DiscoursePost[];
56
+ }
57
+
58
+ export interface DiscourseListOptions {
59
+ category?: string;
60
+ page?: number;
61
+ order?: 'default' | 'created' | 'activity' | 'views' | 'posts' | 'likes';
62
+ }
@@ -22,6 +22,7 @@ export interface GmailAttachment {
22
22
  filename: string;
23
23
  path: string;
24
24
  mimeType?: string;
25
+ contentId?: string; // For inline images, referenced as cid:contentId in HTML
25
26
  }
26
27
 
27
28
  export interface GmailAttachmentInfo {
@@ -3,6 +3,7 @@ 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
5
  import type { RssFeed, RssArticle } from '../types/rss';
6
+ import type { DiscourseCategory, DiscourseTopic, DiscourseTopicDetail } from '../types/discourse';
6
7
 
7
8
  function formatBytes(bytes: number): string {
8
9
  if (bytes === 0) return '0 B';
@@ -278,3 +279,78 @@ export function printRssFeedInfo(feed: RssFeed & { feedUrl: string }): void {
278
279
  if (feed.lastBuildDate) console.log(`Last Updated: ${feed.lastBuildDate}`);
279
280
  console.log(`Articles: ${feed.items.length}`);
280
281
  }
282
+
283
+ // Discourse specific formatters
284
+ export function printDiscourseCategoryList(categories: DiscourseCategory[]): void {
285
+ if (categories.length === 0) {
286
+ console.log('No categories found');
287
+ return;
288
+ }
289
+
290
+ console.log(`Categories (${categories.length})\n`);
291
+
292
+ for (const cat of categories) {
293
+ const parentInfo = cat.parentCategoryId ? ' (subcategory)' : '';
294
+ console.log(`[${cat.id}] ${cat.name}${parentInfo}`);
295
+ console.log(` Slug: ${cat.slug}`);
296
+ console.log(` Topics: ${cat.topicCount} | Posts: ${cat.postCount}`);
297
+ if (cat.description) {
298
+ const desc = cat.description.replace(/<[^>]*>/g, '').substring(0, 100);
299
+ if (desc) console.log(` > ${desc}${cat.description.length > 100 ? '...' : ''}`);
300
+ }
301
+ console.log('');
302
+ }
303
+ }
304
+
305
+ export function printDiscourseTopicList(topics: DiscourseTopic[]): void {
306
+ if (topics.length === 0) {
307
+ console.log('No topics found');
308
+ return;
309
+ }
310
+
311
+ console.log(`Topics (${topics.length})\n`);
312
+
313
+ for (let i = 0; i < topics.length; i++) {
314
+ const topic = topics[i];
315
+ const flags: string[] = [];
316
+ if (topic.pinned) flags.push('pinned');
317
+ if (topic.closed) flags.push('closed');
318
+ if (topic.archived) flags.push('archived');
319
+ const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
320
+
321
+ console.log(`[${i + 1}] ${topic.id} | ${topic.title}${flagStr}`);
322
+ if (topic.categoryName) console.log(` Category: ${topic.categoryName}`);
323
+ console.log(` Posts: ${topic.postsCount} | Replies: ${topic.replyCount} | Views: ${topic.views} | Likes: ${topic.likeCount}`);
324
+ console.log(` Created: ${topic.createdAt}`);
325
+ if (topic.lastPostedAt !== topic.createdAt) {
326
+ console.log(` Last Post: ${topic.lastPostedAt}`);
327
+ }
328
+ console.log('');
329
+ }
330
+ }
331
+
332
+ export function printDiscourseTopic(topic: DiscourseTopicDetail): void {
333
+ const flags: string[] = [];
334
+ if (topic.pinned) flags.push('pinned');
335
+ if (topic.closed) flags.push('closed');
336
+ if (topic.archived) flags.push('archived');
337
+ const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
338
+
339
+ console.log(`ID: ${topic.id}`);
340
+ console.log(`Title: ${topic.title}${flagStr}`);
341
+ console.log(`Slug: ${topic.slug}`);
342
+ if (topic.categoryName) console.log(`Category: ${topic.categoryName}`);
343
+ console.log(`Posts: ${topic.postsCount} | Replies: ${topic.replyCount} | Views: ${topic.views} | Likes: ${topic.likeCount}`);
344
+ console.log(`Created: ${topic.createdAt}`);
345
+ console.log(`Last Post: ${topic.lastPostedAt}`);
346
+ console.log('---');
347
+
348
+ for (const post of topic.posts) {
349
+ const author = post.displayName || post.username;
350
+ console.log(`\n[Post #${post.postNumber}] by ${author} (${post.createdAt})`);
351
+ console.log(`Likes: ${post.likeCount} | Replies: ${post.replyCount}`);
352
+ // Strip HTML from cooked content for display
353
+ const content = post.cooked.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
354
+ console.log(content);
355
+ }
356
+ }