@plosson/agentio 0.1.26 → 0.1.28
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 +213 -166
- package/package.json +1 -1
- package/src/auth/jira-oauth.ts +5 -23
- package/src/commands/config.ts +33 -12
- package/src/commands/discourse.ts +209 -0
- package/src/commands/gchat.ts +8 -49
- package/src/commands/gmail.ts +37 -58
- package/src/commands/jira.ts +12 -53
- package/src/commands/slack.ts +8 -49
- package/src/commands/status.ts +164 -0
- package/src/commands/telegram.ts +19 -60
- package/src/config/config-manager.ts +17 -1
- package/src/config/credentials.ts +2 -2
- package/src/index.ts +4 -0
- package/src/services/discourse/client.ts +287 -0
- package/src/services/gchat/client.ts +23 -1
- package/src/services/gmail/client.ts +145 -44
- package/src/services/jira/client.ts +44 -1
- package/src/services/slack/client.ts +9 -1
- package/src/services/telegram/client.ts +14 -1
- package/src/types/config.ts +3 -1
- package/src/types/discourse.ts +62 -0
- package/src/types/gmail.ts +1 -0
- package/src/types/service.ts +21 -0
- package/src/utils/output.ts +76 -0
- package/src/utils/profile-commands.ts +90 -0
|
@@ -2,6 +2,7 @@ import { google, gmail_v1 } from 'googleapis';
|
|
|
2
2
|
import type { OAuth2Client } from 'google-auth-library';
|
|
3
3
|
import { basename } from 'path';
|
|
4
4
|
import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailReplyOptions, GmailAttachment, GmailAttachmentInfo } from '../../types/gmail';
|
|
5
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
5
6
|
import { CliError } from '../../utils/errors';
|
|
6
7
|
|
|
7
8
|
// Common MIME types by extension
|
|
@@ -38,7 +39,7 @@ function getMimeType(filename: string): string {
|
|
|
38
39
|
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export class GmailClient {
|
|
42
|
+
export class GmailClient implements ServiceClient {
|
|
42
43
|
private gmail: gmail_v1.Gmail;
|
|
43
44
|
private userEmail: string | null = null;
|
|
44
45
|
|
|
@@ -46,6 +47,20 @@ export class GmailClient {
|
|
|
46
47
|
this.gmail = google.gmail({ version: 'v1', auth });
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
async validate(): Promise<ValidationResult> {
|
|
51
|
+
try {
|
|
52
|
+
const email = await this.getUserEmail();
|
|
53
|
+
return { valid: true, info: email };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
56
|
+
// Check if it's a refresh failure
|
|
57
|
+
if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
|
|
58
|
+
return { valid: false, error: 'refresh token expired, re-authenticate' };
|
|
59
|
+
}
|
|
60
|
+
return { valid: false, error: message };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
private async getUserEmail(): Promise<string> {
|
|
50
65
|
if (this.userEmail) return this.userEmail;
|
|
51
66
|
|
|
@@ -308,54 +323,140 @@ export class GmailClient {
|
|
|
308
323
|
attachments: GmailAttachment[];
|
|
309
324
|
}): Promise<string> {
|
|
310
325
|
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
326
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
327
|
+
// Separate inline and regular attachments
|
|
328
|
+
const inlineAttachments = attachments.filter(a => a.contentId);
|
|
329
|
+
const regularAttachments = attachments.filter(a => !a.contentId);
|
|
330
|
+
|
|
331
|
+
const hasInline = inlineAttachments.length > 0;
|
|
332
|
+
const hasRegular = regularAttachments.length > 0;
|
|
333
|
+
|
|
334
|
+
// Generate boundaries
|
|
335
|
+
const mixedBoundary = `----=_Mixed_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
|
336
|
+
const relatedBoundary = `----=_Related_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
|
337
|
+
|
|
338
|
+
const lines: string[] = [];
|
|
339
|
+
|
|
340
|
+
// Email headers
|
|
341
|
+
lines.push(`From: ${from}`);
|
|
342
|
+
lines.push(`To: ${to.join(', ')}`);
|
|
343
|
+
if (cc?.length) lines.push(`Cc: ${cc.join(', ')}`);
|
|
344
|
+
if (bcc?.length) lines.push(`Bcc: ${bcc.join(', ')}`);
|
|
345
|
+
lines.push(`Subject: ${subject}`);
|
|
346
|
+
lines.push('MIME-Version: 1.0');
|
|
347
|
+
|
|
348
|
+
if (hasRegular && hasInline) {
|
|
349
|
+
// Both: multipart/mixed containing multipart/related + regular attachments
|
|
350
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
|
|
351
|
+
lines.push('');
|
|
352
|
+
lines.push(`--${mixedBoundary}`);
|
|
353
|
+
lines.push(`Content-Type: multipart/related; boundary="${relatedBoundary}"`);
|
|
354
|
+
lines.push('');
|
|
355
|
+
|
|
356
|
+
// HTML body
|
|
357
|
+
lines.push(`--${relatedBoundary}`);
|
|
358
|
+
lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
|
|
359
|
+
lines.push('');
|
|
360
|
+
lines.push(body);
|
|
361
|
+
|
|
362
|
+
// Inline images
|
|
363
|
+
for (const attachment of inlineAttachments) {
|
|
364
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
365
|
+
lines.push(`--${relatedBoundary}`);
|
|
366
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
367
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
368
|
+
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
369
|
+
lines.push(`Content-Disposition: inline; filename="${encoded.filename}"`);
|
|
370
|
+
lines.push('');
|
|
371
|
+
lines.push(encoded.base64);
|
|
372
|
+
}
|
|
373
|
+
lines.push(`--${relatedBoundary}--`);
|
|
374
|
+
|
|
375
|
+
// Regular attachments
|
|
376
|
+
for (const attachment of regularAttachments) {
|
|
377
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
378
|
+
lines.push(`--${mixedBoundary}`);
|
|
379
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
380
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
381
|
+
lines.push(`Content-Disposition: attachment; filename="${encoded.filename}"`);
|
|
382
|
+
lines.push('');
|
|
383
|
+
lines.push(encoded.base64);
|
|
384
|
+
}
|
|
385
|
+
lines.push(`--${mixedBoundary}--`);
|
|
386
|
+
|
|
387
|
+
} else if (hasInline) {
|
|
388
|
+
// Only inline: multipart/related
|
|
389
|
+
lines.push(`Content-Type: multipart/related; boundary="${relatedBoundary}"`);
|
|
390
|
+
lines.push('');
|
|
391
|
+
|
|
392
|
+
// HTML body
|
|
393
|
+
lines.push(`--${relatedBoundary}`);
|
|
394
|
+
lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
|
|
395
|
+
lines.push('');
|
|
396
|
+
lines.push(body);
|
|
397
|
+
|
|
398
|
+
// Inline images
|
|
399
|
+
for (const attachment of inlineAttachments) {
|
|
400
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
401
|
+
lines.push(`--${relatedBoundary}`);
|
|
402
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
403
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
404
|
+
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
405
|
+
lines.push(`Content-Disposition: inline; filename="${encoded.filename}"`);
|
|
406
|
+
lines.push('');
|
|
407
|
+
lines.push(encoded.base64);
|
|
408
|
+
}
|
|
409
|
+
lines.push(`--${relatedBoundary}--`);
|
|
410
|
+
|
|
411
|
+
} else {
|
|
412
|
+
// Only regular: multipart/mixed (original behavior)
|
|
413
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
|
|
414
|
+
lines.push('');
|
|
415
|
+
lines.push(`--${mixedBoundary}`);
|
|
416
|
+
lines.push(`Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`);
|
|
417
|
+
lines.push('');
|
|
418
|
+
lines.push(body);
|
|
419
|
+
|
|
420
|
+
for (const attachment of regularAttachments) {
|
|
421
|
+
const encoded = await this.encodeAttachment(attachment);
|
|
422
|
+
lines.push(`--${mixedBoundary}`);
|
|
423
|
+
lines.push(`Content-Type: ${encoded.mimeType}; name="${encoded.filename}"`);
|
|
424
|
+
lines.push('Content-Transfer-Encoding: base64');
|
|
425
|
+
lines.push(`Content-Disposition: attachment; filename="${encoded.filename}"`);
|
|
426
|
+
lines.push('');
|
|
427
|
+
lines.push(encoded.base64);
|
|
353
428
|
}
|
|
429
|
+
lines.push(`--${mixedBoundary}--`);
|
|
354
430
|
}
|
|
355
431
|
|
|
356
|
-
|
|
432
|
+
return lines.join('\r\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async encodeAttachment(attachment: GmailAttachment): Promise<{
|
|
436
|
+
filename: string;
|
|
437
|
+
mimeType: string;
|
|
438
|
+
base64: string;
|
|
439
|
+
}> {
|
|
440
|
+
try {
|
|
441
|
+
const file = Bun.file(attachment.path);
|
|
442
|
+
const exists = await file.exists();
|
|
443
|
+
if (!exists) {
|
|
444
|
+
throw new CliError('NOT_FOUND', `Attachment not found: ${attachment.path}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const content = await file.arrayBuffer();
|
|
448
|
+
const filename = attachment.filename || basename(attachment.path);
|
|
449
|
+
const mimeType = attachment.mimeType || getMimeType(filename);
|
|
357
450
|
|
|
358
|
-
|
|
451
|
+
return {
|
|
452
|
+
filename,
|
|
453
|
+
mimeType,
|
|
454
|
+
base64: Buffer.from(content).toString('base64'),
|
|
455
|
+
};
|
|
456
|
+
} catch (error: any) {
|
|
457
|
+
if (error instanceof CliError) throw error;
|
|
458
|
+
throw new CliError('API_ERROR', `Failed to read attachment ${attachment.path}: ${error.message}`);
|
|
459
|
+
}
|
|
359
460
|
}
|
|
360
461
|
|
|
361
462
|
async reply(options: GmailReplyOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError, type ErrorCode } from '../../utils/errors';
|
|
2
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
2
3
|
import type {
|
|
3
4
|
JiraCredentials,
|
|
4
5
|
JiraProject,
|
|
@@ -11,7 +12,7 @@ import type {
|
|
|
11
12
|
JiraTransitionResult,
|
|
12
13
|
} from '../../types/jira';
|
|
13
14
|
|
|
14
|
-
export class JiraClient {
|
|
15
|
+
export class JiraClient implements ServiceClient {
|
|
15
16
|
private credentials: JiraCredentials;
|
|
16
17
|
private baseUrl: string;
|
|
17
18
|
|
|
@@ -20,6 +21,48 @@ export class JiraClient {
|
|
|
20
21
|
this.baseUrl = `https://api.atlassian.com/ex/jira/${credentials.cloudId}/rest/api/3`;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
async validate(): Promise<ValidationResult> {
|
|
25
|
+
try {
|
|
26
|
+
// Try /myself endpoint first (requires read:me scope)
|
|
27
|
+
const url = `${this.baseUrl}/myself`;
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${this.credentials.accessToken}`,
|
|
31
|
+
Accept: 'application/json',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (response.ok) {
|
|
36
|
+
const user = await response.json() as { displayName?: string; emailAddress?: string };
|
|
37
|
+
const info = user.displayName || user.emailAddress || this.credentials.siteUrl;
|
|
38
|
+
return { valid: true, info };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Fall back to project search if /myself fails (older tokens without read:me scope)
|
|
42
|
+
const fallbackUrl = `${this.baseUrl}/project/search?maxResults=1`;
|
|
43
|
+
const fallbackResponse = await fetch(fallbackUrl, {
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${this.credentials.accessToken}`,
|
|
46
|
+
Accept: 'application/json',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (fallbackResponse.ok) {
|
|
51
|
+
return { valid: true, info: this.credentials.siteUrl };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
valid: false,
|
|
56
|
+
error: `API returned ${fallbackResponse.status}`,
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
private async request<T>(
|
|
24
67
|
method: string,
|
|
25
68
|
path: string,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../../utils/errors';
|
|
2
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
2
3
|
import type {
|
|
3
4
|
SlackCredentials,
|
|
4
5
|
SlackSendOptions,
|
|
@@ -6,13 +7,20 @@ import type {
|
|
|
6
7
|
SlackWebhookCredentials,
|
|
7
8
|
} from '../../types/slack';
|
|
8
9
|
|
|
9
|
-
export class SlackClient {
|
|
10
|
+
export class SlackClient implements ServiceClient {
|
|
10
11
|
private credentials: SlackCredentials;
|
|
11
12
|
|
|
12
13
|
constructor(credentials: SlackCredentials) {
|
|
13
14
|
this.credentials = credentials;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
async validate(): Promise<ValidationResult> {
|
|
18
|
+
// Webhooks cannot be validated without sending a message
|
|
19
|
+
const webhookCreds = this.credentials as SlackWebhookCredentials;
|
|
20
|
+
const info = webhookCreds.channelName ? `#${webhookCreds.channelName}` : 'webhook (not testable)';
|
|
21
|
+
return { valid: true, info };
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
async send(options: SlackSendOptions): Promise<SlackSendResult> {
|
|
17
25
|
if (this.credentials.type === 'webhook') {
|
|
18
26
|
return this.sendViaWebhook(options);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TelegramBotInfo, TelegramChat, TelegramMessage, TelegramSendOptions } from '../../types/telegram';
|
|
2
|
+
import type { ServiceClient, ValidationResult } from '../../types/service';
|
|
2
3
|
import { CliError } from '../../utils/errors';
|
|
3
4
|
|
|
4
5
|
const TELEGRAM_API_BASE = 'https://api.telegram.org/bot';
|
|
@@ -10,7 +11,7 @@ interface TelegramApiResponse<T> {
|
|
|
10
11
|
error_code?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export class TelegramClient {
|
|
14
|
+
export class TelegramClient implements ServiceClient {
|
|
14
15
|
private baseUrl: string;
|
|
15
16
|
|
|
16
17
|
constructor(
|
|
@@ -20,6 +21,18 @@ export class TelegramClient {
|
|
|
20
21
|
this.baseUrl = `${TELEGRAM_API_BASE}${botToken}`;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
async validate(): Promise<ValidationResult> {
|
|
25
|
+
try {
|
|
26
|
+
const botInfo = await this.getMe();
|
|
27
|
+
return { valid: true, info: `@${botInfo.username}` };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
valid: false,
|
|
31
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
private async request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
|
|
24
37
|
const url = `${this.baseUrl}/${method}`;
|
|
25
38
|
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/gmail.ts
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result of validating service credentials.
|
|
3
|
+
*/
|
|
4
|
+
export interface ValidationResult {
|
|
5
|
+
valid: boolean;
|
|
6
|
+
info?: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Common interface for all service clients.
|
|
12
|
+
* Implementations should handle token refresh internally if needed.
|
|
13
|
+
*/
|
|
14
|
+
export interface ServiceClient {
|
|
15
|
+
/**
|
|
16
|
+
* Validate credentials by making an API call.
|
|
17
|
+
* Should refresh tokens internally if needed before validating.
|
|
18
|
+
* @returns ValidationResult with status and optional info/error
|
|
19
|
+
*/
|
|
20
|
+
validate(): Promise<ValidationResult>;
|
|
21
|
+
}
|
package/src/utils/output.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { listProfiles, removeProfile, setDefault } from '../config/config-manager';
|
|
3
|
+
import { removeCredentials, getCredentials } from '../auth/token-store';
|
|
4
|
+
import { handleError, CliError } from './errors';
|
|
5
|
+
import type { ServiceName } from '../types/config';
|
|
6
|
+
|
|
7
|
+
export interface ProfileCommandsOptions<T> {
|
|
8
|
+
service: ServiceName;
|
|
9
|
+
displayName: string;
|
|
10
|
+
getExtraInfo?: (credentials: T | null) => string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createProfileCommands<T>(
|
|
14
|
+
parent: Command,
|
|
15
|
+
options: ProfileCommandsOptions<T>
|
|
16
|
+
): Command {
|
|
17
|
+
const { service, displayName, getExtraInfo } = options;
|
|
18
|
+
|
|
19
|
+
const profile = parent
|
|
20
|
+
.command('profile')
|
|
21
|
+
.description(`Manage ${displayName} profiles`);
|
|
22
|
+
|
|
23
|
+
profile
|
|
24
|
+
.command('list')
|
|
25
|
+
.description(`List ${displayName} profiles`)
|
|
26
|
+
.action(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const result = await listProfiles(service);
|
|
29
|
+
const { profiles, default: defaultProfile } = result[0];
|
|
30
|
+
|
|
31
|
+
if (profiles.length === 0) {
|
|
32
|
+
console.log('No profiles configured');
|
|
33
|
+
} else {
|
|
34
|
+
for (const name of profiles) {
|
|
35
|
+
const marker = name === defaultProfile ? ' (default)' : '';
|
|
36
|
+
const credentials = await getCredentials<T>(service, name);
|
|
37
|
+
const extraInfo = getExtraInfo ? getExtraInfo(credentials) : '';
|
|
38
|
+
console.log(`${name}${marker}${extraInfo}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
handleError(error);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
profile
|
|
47
|
+
.command('remove')
|
|
48
|
+
.description(`Remove a ${displayName} profile`)
|
|
49
|
+
.requiredOption('--profile <name>', 'Profile name')
|
|
50
|
+
.action(async (opts) => {
|
|
51
|
+
try {
|
|
52
|
+
const profileName = opts.profile;
|
|
53
|
+
|
|
54
|
+
const removed = await removeProfile(service, profileName);
|
|
55
|
+
await removeCredentials(service, profileName);
|
|
56
|
+
|
|
57
|
+
if (removed) {
|
|
58
|
+
console.log(`Removed profile "${profileName}"`);
|
|
59
|
+
} else {
|
|
60
|
+
console.error(`Profile "${profileName}" not found`);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
handleError(error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
profile
|
|
68
|
+
.command('default')
|
|
69
|
+
.description(`Set the default ${displayName} profile`)
|
|
70
|
+
.argument('<name>', 'Profile name to set as default')
|
|
71
|
+
.action(async (name) => {
|
|
72
|
+
try {
|
|
73
|
+
const success = await setDefault(service, name);
|
|
74
|
+
|
|
75
|
+
if (success) {
|
|
76
|
+
console.log(`Default profile set to "${name}"`);
|
|
77
|
+
} else {
|
|
78
|
+
throw new CliError(
|
|
79
|
+
'PROFILE_NOT_FOUND',
|
|
80
|
+
`Profile "${name}" not found`,
|
|
81
|
+
`Run: agentio ${service} profile list`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
handleError(error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return profile;
|
|
90
|
+
}
|