@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.
@@ -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
- 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}`);
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
- headers.push(`--${boundary}--`);
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
- return headers.join('\r\n');
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
 
@@ -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 {
@@ -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
+ }
@@ -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
+ }