@plosson/agentio 0.1.27 → 0.1.29

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.
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { setCredentials, removeCredentials, getCredentials } from '../auth/token-store';
3
- import { setProfile, removeProfile, listProfiles, getProfile } from '../config/config-manager';
2
+ import { setCredentials, getCredentials } from '../auth/token-store';
3
+ import { setProfile, getProfile } from '../config/config-manager';
4
+ import { createProfileCommands } from '../utils/profile-commands';
4
5
  import { TelegramClient } from '../services/telegram/client';
5
6
  import { CliError, handleError } from '../utils/errors';
6
7
  import { readStdin, prompt, resolveProfileName } from '../utils/stdin';
@@ -82,9 +83,11 @@ export function registerTelegramCommands(program: Command): void {
82
83
  });
83
84
 
84
85
  // Profile management
85
- const profile = telegram
86
- .command('profile')
87
- .description('Manage Telegram profiles');
86
+ const profile = createProfileCommands<TelegramCredentials>(telegram, {
87
+ service: 'telegram',
88
+ displayName: 'Telegram',
89
+ getExtraInfo: (credentials) => credentials?.channel_name ? ` - ${credentials.channel_name}` : '',
90
+ });
88
91
 
89
92
  profile
90
93
  .command('add')
@@ -94,16 +97,16 @@ export function registerTelegramCommands(program: Command): void {
94
97
  try {
95
98
  const profileName = await resolveProfileName('telegram', options.profile);
96
99
 
97
- console.error('\nšŸ“± Telegram Bot Setup\n');
100
+ console.error('\nTelegram Bot Setup\n');
98
101
 
99
102
  // Step 1: Create bot
100
103
  console.error('Step 1: Create your bot');
101
104
  console.error(' Open Telegram and message @BotFather');
102
- console.error(' → https://t.me/BotFather\n');
105
+ console.error(' -> https://t.me/BotFather\n');
103
106
  console.error(' Send these commands:');
104
107
  console.error(' /newbot');
105
- console.error(' → Enter a display name (e.g., "My Announcements Bot")');
106
- console.error(' → Enter a username ending in "bot" (e.g., "my_announce_bot")\n');
108
+ console.error(' -> Enter a display name (e.g., "My Announcements Bot")');
109
+ console.error(' -> Enter a username ending in "bot" (e.g., "my_announce_bot")\n');
107
110
  console.error(' BotFather will give you a token like:');
108
111
  console.error(' 123456789:ABCdefGHIjklMNOpqrsTUVwxyz\n');
109
112
 
@@ -125,17 +128,17 @@ export function registerTelegramCommands(program: Command): void {
125
128
  throw error;
126
129
  }
127
130
 
128
- console.error(`\nāœ“ Bot verified: @${botInfo.username}\n`);
131
+ console.error(`\nBot verified: @${botInfo.username}\n`);
129
132
 
130
133
  // Step 2: Add bot to channel
131
134
  console.error('Step 2: Add bot to your channel');
132
135
  console.error(' 1. Open your Telegram channel');
133
- console.error(' 2. Go to Channel Settings → Administrators');
136
+ console.error(' 2. Go to Channel Settings -> Administrators');
134
137
  console.error(` 3. Add @${botInfo.username} as admin with "Post Messages" permission\n`);
135
138
 
136
139
  console.error(' How to find your channel ID:');
137
- console.error(' • Public channel: Use @username (e.g., @mychannel)');
138
- console.error(' • Private channel: Forward any message from the channel to @userinfobot');
140
+ console.error(' - Public channel: Use @username (e.g., @mychannel)');
141
+ console.error(' - Private channel: Forward any message from the channel to @userinfobot');
139
142
  console.error(' The bot will reply with the channel ID (starts with -100)\n');
140
143
 
141
144
  const channelId = await prompt('? Enter channel ID: ');
@@ -162,8 +165,8 @@ export function registerTelegramCommands(program: Command): void {
162
165
  }
163
166
 
164
167
  const channelName = chatInfo.title || chatInfo.username || channelId;
165
- console.error(`\nāœ“ Channel verified: ${channelName}`);
166
- console.error('āœ“ Bot can post to this channel\n');
168
+ console.error(`\nChannel verified: ${channelName}`);
169
+ console.error('Bot can post to this channel\n');
167
170
 
168
171
  // Step 3: Optional customization tips
169
172
  console.error('Step 3: Customize your bot (optional)');
@@ -182,54 +185,10 @@ export function registerTelegramCommands(program: Command): void {
182
185
  await setProfile('telegram', profileName);
183
186
  await setCredentials('telegram', profileName, credentials);
184
187
 
185
- console.log(`\nāœ… Profile "${profileName}" configured!`);
188
+ console.log(`\nProfile "${profileName}" configured!`);
186
189
  console.log(` Test with: agentio telegram send --profile ${profileName} "Hello world"`);
187
190
  } catch (error) {
188
191
  handleError(error);
189
192
  }
190
193
  });
191
-
192
- profile
193
- .command('list')
194
- .description('List Telegram profiles')
195
- .action(async () => {
196
- try {
197
- const result = await listProfiles('telegram');
198
- const { profiles, default: defaultProfile } = result[0];
199
-
200
- if (profiles.length === 0) {
201
- console.log('No profiles configured');
202
- } else {
203
- for (const name of profiles) {
204
- const marker = name === defaultProfile ? ' (default)' : '';
205
- const credentials = await getCredentials<TelegramCredentials>('telegram', name);
206
- const channelInfo = credentials?.channel_name ? ` - ${credentials.channel_name}` : '';
207
- console.log(`${name}${marker}${channelInfo}`);
208
- }
209
- }
210
- } catch (error) {
211
- handleError(error);
212
- }
213
- });
214
-
215
- profile
216
- .command('remove')
217
- .description('Remove a Telegram profile')
218
- .requiredOption('--profile <name>', 'Profile name')
219
- .action(async (options) => {
220
- try {
221
- const profileName = options.profile;
222
-
223
- const removed = await removeProfile('telegram', profileName);
224
- await removeCredentials('telegram', profileName);
225
-
226
- if (removed) {
227
- console.error(`Removed profile "${profileName}"`);
228
- } else {
229
- console.error(`Profile "${profileName}" not found`);
230
- }
231
- } catch (error) {
232
- handleError(error);
233
- }
234
- });
235
194
  }
@@ -109,13 +109,29 @@ export async function removeProfile(
109
109
  return true;
110
110
  }
111
111
 
112
+ export async function setDefault(
113
+ service: ServiceName,
114
+ profileName: string
115
+ ): Promise<boolean> {
116
+ const config = await loadConfig();
117
+
118
+ const serviceProfiles = config.profiles[service] || [];
119
+ if (!serviceProfiles.includes(profileName)) {
120
+ return false;
121
+ }
122
+
123
+ config.defaults[service] = profileName;
124
+ await saveConfig(config);
125
+ return true;
126
+ }
127
+
112
128
  export async function listProfiles(service?: ServiceName): Promise<{
113
129
  service: ServiceName;
114
130
  profiles: string[];
115
131
  default?: string;
116
132
  }[]> {
117
133
  const config = await loadConfig();
118
- const services: ServiceName[] = service ? [service] : ['gmail', 'gchat', 'jira', 'slack', 'telegram'];
134
+ const services: ServiceName[] = service ? [service] : ['gmail', 'gchat', 'jira', 'slack', 'telegram', 'discourse'];
119
135
 
120
136
  return services.map((svc) => ({
121
137
  service: svc,
@@ -13,8 +13,8 @@ export const GOOGLE_OAUTH_CONFIG = {
13
13
  };
14
14
 
15
15
  // JIRA/Atlassian OAuth credentials
16
- const JIRA_CLIENT_ID = '7408S0MZKdYnsiz0KXlUT15Lb69k7y0e';
17
- const JIRA_CLIENT_SECRET_ENC = 'C0J5Cyvhfl_UHCvmY9BfvKa_gzqdaa5mb3FwzRmq-YApJdAoBAKmeCl3xmcYLRCFdMvQLWVmifTkryoB0bmhVWX6eRTzoj1q4iyyTxZzT0yff0pYvLguHzp9Y4U';
16
+ const JIRA_CLIENT_ID = 'cVyhx1kQLRUef6gr50M9cTDke7ZPL4CN';
17
+ const JIRA_CLIENT_SECRET_ENC = 'cFN1vM5KVVVCIkv9YlE5O0rerKJUkr-CszeusEVxofAH7W0evcCidzAB_OdTygfAcq2LjbN1IXK7ZiBBl3XrBsIO7RfxSGcEfHWpSbbHWxnKPP6H2iOoQZbOfns';
18
18
 
19
19
  export const JIRA_OAUTH_CONFIG = {
20
20
  clientId: JIRA_CLIENT_ID,
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { registerDiscourseCommands } from './commands/discourse';
10
10
  import { registerUpdateCommand } from './commands/update';
11
11
  import { registerConfigCommands } from './commands/config';
12
12
  import { registerClaudeCommands } from './commands/claude';
13
+ import { registerStatusCommand } from './commands/status';
13
14
 
14
15
  declare const BUILD_VERSION: string | undefined;
15
16
 
@@ -38,5 +39,6 @@ registerDiscourseCommands(program);
38
39
  registerUpdateCommand(program);
39
40
  registerConfigCommands(program);
40
41
  registerClaudeCommands(program);
42
+ registerStatusCommand(program);
41
43
 
42
44
  program.parse();
@@ -6,6 +6,7 @@ import type {
6
6
  DiscoursePost,
7
7
  DiscourseListOptions,
8
8
  } from '../../types/discourse';
9
+ import type { ServiceClient, ValidationResult } from '../../types/service';
9
10
  import { CliError, type ErrorCode } from '../../utils/errors';
10
11
 
11
12
  interface DiscourseApiResponse {
@@ -85,7 +86,7 @@ interface RawPost {
85
86
  like_count: number;
86
87
  }
87
88
 
88
- export class DiscourseClient {
89
+ export class DiscourseClient implements ServiceClient {
89
90
  private baseUrl: string;
90
91
  private apiKey: string;
91
92
  private username: string;
@@ -97,6 +98,18 @@ export class DiscourseClient {
97
98
  this.username = credentials.username;
98
99
  }
99
100
 
101
+ async validate(): Promise<ValidationResult> {
102
+ try {
103
+ await this.getCategories();
104
+ return { valid: true, info: this.baseUrl };
105
+ } catch (error) {
106
+ return {
107
+ valid: false,
108
+ error: error instanceof Error ? error.message : 'Unknown error',
109
+ };
110
+ }
111
+ }
112
+
100
113
  private async request<T>(
101
114
  method: string,
102
115
  path: string,
@@ -140,7 +153,8 @@ export class DiscourseClient {
140
153
  }
141
154
 
142
155
  private getErrorCode(status: number): ErrorCode {
143
- if (status === 401 || status === 403) return 'AUTH_FAILED';
156
+ if (status === 401) return 'AUTH_FAILED';
157
+ if (status === 403) return 'PERMISSION_DENIED';
144
158
  if (status === 404) return 'NOT_FOUND';
145
159
  if (status === 429) return 'RATE_LIMITED';
146
160
  return 'API_ERROR';
@@ -2,6 +2,7 @@ import { google } from 'googleapis';
2
2
  import type { chat_v1 } from 'googleapis';
3
3
  import { CliError } from '../../utils/errors';
4
4
  import type { ErrorCode } from '../../utils/errors';
5
+ import type { ServiceClient, ValidationResult } from '../../types/service';
5
6
  import { GOOGLE_OAUTH_CONFIG } from '../../config/credentials';
6
7
  import type {
7
8
  GChatCredentials,
@@ -14,13 +15,34 @@ import type {
14
15
  GChatMessage,
15
16
  } from '../../types/gchat';
16
17
 
17
- export class GChatClient {
18
+ export class GChatClient implements ServiceClient {
18
19
  private credentials: GChatCredentials;
19
20
 
20
21
  constructor(credentials: GChatCredentials) {
21
22
  this.credentials = credentials;
22
23
  }
23
24
 
25
+ async validate(): Promise<ValidationResult> {
26
+ if (this.credentials.type === 'webhook') {
27
+ // Cannot validate webhooks without sending a message
28
+ return { valid: true, info: 'webhook' };
29
+ }
30
+
31
+ try {
32
+ const oauthCreds = this.credentials as GChatOAuthCredentials;
33
+ const auth = this.createOAuthClient(oauthCreds);
34
+ const chat = google.chat({ version: 'v1', auth });
35
+ await chat.spaces.list({ pageSize: 1 });
36
+ return { valid: true, info: 'oauth' };
37
+ } catch (error) {
38
+ const message = error instanceof Error ? error.message : 'Unknown error';
39
+ if (message.includes('invalid_grant') || message.includes('Token has been expired or revoked')) {
40
+ return { valid: false, error: 'refresh token expired, re-authenticate' };
41
+ }
42
+ return { valid: false, error: message };
43
+ }
44
+ }
45
+
24
46
  async send(options: GChatSendOptions & { spaceId?: string }): Promise<GChatSendResult> {
25
47
  if (this.credentials.type === 'webhook') {
26
48
  return this.sendViaWebhook(options);
@@ -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
 
@@ -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';
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
 
@@ -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
+ }
@@ -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
+ }