@plosson/agentio 0.1.5 → 0.1.8

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
@@ -137,6 +137,24 @@ agentio gmail profile add --profile personal
137
137
  agentio gmail list --profile work
138
138
  ```
139
139
 
140
+ ## Claude Code Integration
141
+
142
+ agentio provides a plugin for [Claude Code](https://claude.com/claude-code) with skills for Gmail, Telegram, and Google Chat operations.
143
+
144
+ ### Add the Marketplace
145
+
146
+ ```bash
147
+ /plugin marketplace add plosson/agentio
148
+ ```
149
+
150
+ ### Install the Plugin
151
+
152
+ ```bash
153
+ /plugin install agentio@agentio
154
+ ```
155
+
156
+ Once installed, Claude Code can use the agentio CLI skills to help you manage emails, send Telegram messages, and more.
157
+
140
158
  ## Design
141
159
 
142
160
  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.5",
3
+ "version": "0.1.8",
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,249 @@
1
+ import { createServer, type Server } from 'http';
2
+ import { URL } from 'url';
3
+ import { JIRA_OAUTH_CONFIG } from '../config/credentials';
4
+
5
+ const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
6
+ const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
7
+ const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources';
8
+
9
+ const JIRA_SCOPES = [
10
+ 'read:jira-work', // Read projects, issues
11
+ 'write:jira-work', // Add comments, change status
12
+ 'offline_access', // Get refresh tokens
13
+ ];
14
+
15
+ const PORT_RANGE_START = 3000;
16
+ const PORT_RANGE_END = 3010;
17
+
18
+ export interface JiraOAuthResult {
19
+ accessToken: string;
20
+ refreshToken: string;
21
+ expiryDate: number;
22
+ cloudId: string;
23
+ siteUrl: string;
24
+ }
25
+
26
+ export interface AtlassianSite {
27
+ id: string;
28
+ url: string;
29
+ name: string;
30
+ scopes: string[];
31
+ avatarUrl?: string;
32
+ }
33
+
34
+ async function findAvailablePort(): Promise<number> {
35
+ for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
36
+ try {
37
+ await new Promise<void>((resolve, reject) => {
38
+ const server = createServer();
39
+ server.listen(port, () => {
40
+ server.close(() => resolve());
41
+ });
42
+ server.on('error', reject);
43
+ });
44
+ return port;
45
+ } catch {
46
+ continue;
47
+ }
48
+ }
49
+ throw new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
50
+ }
51
+
52
+ async function exchangeCodeForTokens(
53
+ code: string,
54
+ clientId: string,
55
+ clientSecret: string,
56
+ redirectUri: string
57
+ ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
58
+ const response = await fetch(ATLASSIAN_TOKEN_URL, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ body: JSON.stringify({
64
+ grant_type: 'authorization_code',
65
+ client_id: clientId,
66
+ client_secret: clientSecret,
67
+ code,
68
+ redirect_uri: redirectUri,
69
+ }),
70
+ });
71
+
72
+ if (!response.ok) {
73
+ const error = await response.text();
74
+ throw new Error(`Failed to exchange code for tokens: ${error}`);
75
+ }
76
+
77
+ const data = await response.json();
78
+ return {
79
+ accessToken: data.access_token,
80
+ refreshToken: data.refresh_token,
81
+ expiresIn: data.expires_in,
82
+ };
83
+ }
84
+
85
+ async function getAccessibleResources(accessToken: string): Promise<AtlassianSite[]> {
86
+ const response = await fetch(ATLASSIAN_RESOURCES_URL, {
87
+ headers: {
88
+ Authorization: `Bearer ${accessToken}`,
89
+ Accept: 'application/json',
90
+ },
91
+ });
92
+
93
+ if (!response.ok) {
94
+ const error = await response.text();
95
+ throw new Error(`Failed to get accessible resources: ${error}`);
96
+ }
97
+
98
+ return response.json();
99
+ }
100
+
101
+ export async function refreshJiraToken(
102
+ refreshToken: string
103
+ ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
104
+ const response = await fetch(ATLASSIAN_TOKEN_URL, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ body: JSON.stringify({
110
+ grant_type: 'refresh_token',
111
+ client_id: JIRA_OAUTH_CONFIG.clientId,
112
+ client_secret: JIRA_OAUTH_CONFIG.clientSecret,
113
+ refresh_token: refreshToken,
114
+ }),
115
+ });
116
+
117
+ if (!response.ok) {
118
+ const error = await response.text();
119
+ throw new Error(`Failed to refresh token: ${error}`);
120
+ }
121
+
122
+ const data = await response.json();
123
+ return {
124
+ accessToken: data.access_token,
125
+ refreshToken: data.refresh_token || refreshToken,
126
+ expiresIn: data.expires_in,
127
+ };
128
+ }
129
+
130
+ export async function performJiraOAuthFlow(
131
+ selectSite?: (sites: AtlassianSite[]) => Promise<AtlassianSite>
132
+ ): Promise<JiraOAuthResult> {
133
+ const port = await findAvailablePort();
134
+ const redirectUri = `http://localhost:${port}/callback`;
135
+
136
+ const state = Math.random().toString(36).substring(2);
137
+ const authUrl = new URL(ATLASSIAN_AUTH_URL);
138
+ authUrl.searchParams.set('audience', 'api.atlassian.com');
139
+ authUrl.searchParams.set('client_id', JIRA_OAUTH_CONFIG.clientId);
140
+ authUrl.searchParams.set('scope', JIRA_SCOPES.join(' '));
141
+ authUrl.searchParams.set('redirect_uri', redirectUri);
142
+ authUrl.searchParams.set('state', state);
143
+ authUrl.searchParams.set('response_type', 'code');
144
+ authUrl.searchParams.set('prompt', 'consent');
145
+
146
+ return new Promise((resolve, reject) => {
147
+ let server: Server;
148
+
149
+ const timeout = setTimeout(() => {
150
+ server?.close();
151
+ reject(new Error('OAuth flow timed out after 5 minutes'));
152
+ }, 5 * 60 * 1000);
153
+
154
+ server = createServer(async (req, res) => {
155
+ const url = new URL(req.url || '', `http://localhost:${port}`);
156
+
157
+ if (url.pathname !== '/callback') {
158
+ res.writeHead(404);
159
+ res.end('Not found');
160
+ return;
161
+ }
162
+
163
+ const code = url.searchParams.get('code');
164
+ const error = url.searchParams.get('error');
165
+ const returnedState = url.searchParams.get('state');
166
+
167
+ if (error) {
168
+ res.writeHead(200, { 'Content-Type': 'text/html' });
169
+ res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
170
+ clearTimeout(timeout);
171
+ server.close();
172
+ reject(new Error(`OAuth error: ${error}`));
173
+ return;
174
+ }
175
+
176
+ if (returnedState !== state) {
177
+ res.writeHead(400, { 'Content-Type': 'text/html' });
178
+ res.end('<html><body><h1>State Mismatch</h1><p>You can close this window.</p></body></html>');
179
+ clearTimeout(timeout);
180
+ server.close();
181
+ reject(new Error('OAuth state mismatch - possible CSRF attack'));
182
+ return;
183
+ }
184
+
185
+ if (!code) {
186
+ res.writeHead(400, { 'Content-Type': 'text/html' });
187
+ res.end('<html><body><h1>Missing Authorization Code</h1><p>You can close this window.</p></body></html>');
188
+ clearTimeout(timeout);
189
+ server.close();
190
+ reject(new Error('Missing authorization code in OAuth callback'));
191
+ return;
192
+ }
193
+
194
+ try {
195
+ res.writeHead(200, { 'Content-Type': 'text/html' });
196
+ res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
197
+
198
+ clearTimeout(timeout);
199
+ server.close();
200
+
201
+ // Exchange code for tokens
202
+ const tokens = await exchangeCodeForTokens(code, JIRA_OAUTH_CONFIG.clientId, JIRA_OAUTH_CONFIG.clientSecret, redirectUri);
203
+
204
+ // Get accessible resources to find cloud ID
205
+ const sites = await getAccessibleResources(tokens.accessToken);
206
+
207
+ if (sites.length === 0) {
208
+ throw new Error('No accessible Jira sites found. Make sure your app has the correct permissions.');
209
+ }
210
+
211
+ // Let user select site if multiple, otherwise use the first one
212
+ let selectedSite: AtlassianSite;
213
+ if (sites.length === 1) {
214
+ selectedSite = sites[0];
215
+ } else if (selectSite) {
216
+ selectedSite = await selectSite(sites);
217
+ } else {
218
+ selectedSite = sites[0];
219
+ }
220
+
221
+ resolve({
222
+ accessToken: tokens.accessToken,
223
+ refreshToken: tokens.refreshToken,
224
+ expiryDate: Date.now() + tokens.expiresIn * 1000,
225
+ cloudId: selectedSite.id,
226
+ siteUrl: selectedSite.url,
227
+ });
228
+ } catch (err) {
229
+ reject(err);
230
+ }
231
+ });
232
+
233
+ server.listen(port, () => {
234
+ console.error(`\nOpening browser for Atlassian authorization...`);
235
+ console.error(`If browser doesn't open, visit:\n${authUrl.toString()}\n`);
236
+
237
+ // Open browser
238
+ const open = process.platform === 'darwin' ? 'open' :
239
+ process.platform === 'win32' ? 'start' : 'xdg-open';
240
+ Bun.spawn([open, authUrl.toString()], { stdout: 'ignore', stderr: 'ignore' });
241
+ });
242
+
243
+ server.on('error', (err) => {
244
+ clearTimeout(timeout);
245
+ server?.close();
246
+ reject(err);
247
+ });
248
+ });
249
+ }
@@ -0,0 +1,319 @@
1
+ import { Command } from 'commander';
2
+ import { createInterface } from 'readline';
3
+ import { setCredentials, removeCredentials, getCredentials, setCredentials as updateCredentials } from '../auth/token-store';
4
+ import { setProfile, removeProfile, listProfiles, getProfile } from '../config/config-manager';
5
+ import { performJiraOAuthFlow, refreshJiraToken, type AtlassianSite } from '../auth/jira-oauth';
6
+ import { JiraClient } from '../services/jira/client';
7
+ import { CliError, handleError } from '../utils/errors';
8
+ import { readStdin } from '../utils/stdin';
9
+ import {
10
+ printJiraProjectList,
11
+ printJiraIssueList,
12
+ printJiraIssue,
13
+ printJiraTransitions,
14
+ printJiraCommentResult,
15
+ printJiraTransitionResult,
16
+ } from '../utils/output';
17
+ import type { JiraCredentials } from '../types/jira';
18
+
19
+ function prompt(question: string): Promise<string> {
20
+ const rl = createInterface({
21
+ input: process.stdin,
22
+ output: process.stderr,
23
+ });
24
+
25
+ return new Promise((resolve) => {
26
+ rl.question(question, (answer) => {
27
+ rl.close();
28
+ resolve(answer.trim());
29
+ });
30
+ });
31
+ }
32
+
33
+ async function ensureValidToken(credentials: JiraCredentials, profile: string): Promise<JiraCredentials> {
34
+ // Check if token is expired or about to expire (within 5 minutes)
35
+ const bufferTime = 5 * 60 * 1000;
36
+ if (credentials.expiryDate && Date.now() + bufferTime >= credentials.expiryDate) {
37
+ console.error('Access token expired, refreshing...');
38
+
39
+ try {
40
+ const refreshed = await refreshJiraToken(credentials.refreshToken);
41
+
42
+ const newCredentials: JiraCredentials = {
43
+ ...credentials,
44
+ accessToken: refreshed.accessToken,
45
+ refreshToken: refreshed.refreshToken,
46
+ expiryDate: Date.now() + refreshed.expiresIn * 1000,
47
+ };
48
+
49
+ await updateCredentials('jira', profile, newCredentials);
50
+ return newCredentials;
51
+ } catch (error) {
52
+ throw new CliError(
53
+ 'AUTH_FAILED',
54
+ 'Failed to refresh access token. Please re-authenticate.',
55
+ `Run: agentio jira profile add --profile ${profile}`
56
+ );
57
+ }
58
+ }
59
+
60
+ return credentials;
61
+ }
62
+
63
+ async function getJiraClient(profileName?: string): Promise<{ client: JiraClient; profile: string }> {
64
+ const profile = await getProfile('jira', profileName);
65
+
66
+ if (!profile) {
67
+ throw new CliError(
68
+ 'PROFILE_NOT_FOUND',
69
+ profileName
70
+ ? `Profile "${profileName}" not found for jira`
71
+ : 'No default profile configured for jira',
72
+ 'Run: agentio jira profile add'
73
+ );
74
+ }
75
+
76
+ let credentials = await getCredentials<JiraCredentials>('jira', profile);
77
+
78
+ if (!credentials) {
79
+ throw new CliError(
80
+ 'AUTH_FAILED',
81
+ `No credentials found for jira profile "${profile}"`,
82
+ `Run: agentio jira profile add --profile ${profile}`
83
+ );
84
+ }
85
+
86
+ // Ensure token is valid
87
+ credentials = await ensureValidToken(credentials, profile);
88
+
89
+ return {
90
+ client: new JiraClient(credentials),
91
+ profile,
92
+ };
93
+ }
94
+
95
+ export function registerJiraCommands(program: Command): void {
96
+ const jira = program
97
+ .command('jira')
98
+ .description('JIRA operations');
99
+
100
+ // List projects
101
+ jira
102
+ .command('projects')
103
+ .description('List JIRA projects')
104
+ .option('--profile <name>', 'Profile name')
105
+ .option('--limit <number>', 'Maximum number of projects', '50')
106
+ .action(async (options) => {
107
+ try {
108
+ const { client } = await getJiraClient(options.profile);
109
+ const projects = await client.listProjects({
110
+ maxResults: parseInt(options.limit, 10),
111
+ });
112
+ printJiraProjectList(projects);
113
+ } catch (error) {
114
+ handleError(error);
115
+ }
116
+ });
117
+
118
+ // Search issues
119
+ jira
120
+ .command('search')
121
+ .description('Search JIRA issues')
122
+ .option('--profile <name>', 'Profile name')
123
+ .option('--jql <query>', 'JQL query')
124
+ .option('--project <key>', 'Project key')
125
+ .option('--status <status>', 'Issue status')
126
+ .option('--assignee <name>', 'Assignee name')
127
+ .option('--limit <number>', 'Maximum number of issues', '50')
128
+ .action(async (options) => {
129
+ try {
130
+ const { client } = await getJiraClient(options.profile);
131
+ const issues = await client.searchIssues({
132
+ jql: options.jql,
133
+ project: options.project,
134
+ status: options.status,
135
+ assignee: options.assignee,
136
+ maxResults: parseInt(options.limit, 10),
137
+ });
138
+ printJiraIssueList(issues);
139
+ } catch (error) {
140
+ handleError(error);
141
+ }
142
+ });
143
+
144
+ // Get issue details
145
+ jira
146
+ .command('get')
147
+ .description('Get JIRA issue details')
148
+ .argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
149
+ .option('--profile <name>', 'Profile name')
150
+ .action(async (issueKey: string, options) => {
151
+ try {
152
+ const { client } = await getJiraClient(options.profile);
153
+ const issue = await client.getIssue(issueKey);
154
+ printJiraIssue(issue);
155
+ } catch (error) {
156
+ handleError(error);
157
+ }
158
+ });
159
+
160
+ // Add comment
161
+ jira
162
+ .command('comment')
163
+ .description('Add a comment to an issue')
164
+ .argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
165
+ .argument('[body]', 'Comment body (or pipe via stdin)')
166
+ .option('--profile <name>', 'Profile name')
167
+ .action(async (issueKey: string, body: string | undefined, options) => {
168
+ try {
169
+ let text = body;
170
+
171
+ if (!text) {
172
+ text = await readStdin() || undefined;
173
+ }
174
+
175
+ if (!text) {
176
+ throw new CliError('INVALID_PARAMS', 'Comment body is required. Provide as argument or pipe via stdin.');
177
+ }
178
+
179
+ const { client } = await getJiraClient(options.profile);
180
+ const result = await client.addComment(issueKey, text);
181
+ printJiraCommentResult(result);
182
+ } catch (error) {
183
+ handleError(error);
184
+ }
185
+ });
186
+
187
+ // List transitions
188
+ jira
189
+ .command('transitions')
190
+ .description('List available transitions for an issue')
191
+ .argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
192
+ .option('--profile <name>', 'Profile name')
193
+ .action(async (issueKey: string, options) => {
194
+ try {
195
+ const { client } = await getJiraClient(options.profile);
196
+ const transitions = await client.getTransitions(issueKey);
197
+ printJiraTransitions(issueKey, transitions);
198
+ } catch (error) {
199
+ handleError(error);
200
+ }
201
+ });
202
+
203
+ // Transition issue (change status)
204
+ jira
205
+ .command('transition')
206
+ .description('Transition an issue to a new status')
207
+ .argument('<issue-key>', 'Issue key (e.g., PROJ-123)')
208
+ .argument('<transition-id>', 'Transition ID (use "transitions" command to see available)')
209
+ .option('--profile <name>', 'Profile name')
210
+ .action(async (issueKey: string, transitionId: string, options) => {
211
+ try {
212
+ const { client } = await getJiraClient(options.profile);
213
+ const result = await client.transitionIssue(issueKey, transitionId);
214
+ printJiraTransitionResult(result);
215
+ } catch (error) {
216
+ handleError(error);
217
+ }
218
+ });
219
+
220
+ // Profile management
221
+ const profile = jira
222
+ .command('profile')
223
+ .description('Manage JIRA profiles');
224
+
225
+ profile
226
+ .command('add')
227
+ .description('Add a new JIRA profile with OAuth authentication')
228
+ .option('--profile <name>', 'Profile name', 'default')
229
+ .action(async (options) => {
230
+ try {
231
+ const profileName = options.profile;
232
+
233
+ console.error('\nšŸ”§ JIRA OAuth Setup\n');
234
+
235
+ // Site selection callback
236
+ const selectSite = async (sites: AtlassianSite[]): Promise<AtlassianSite> => {
237
+ console.error('\nMultiple JIRA sites found:\n');
238
+ sites.forEach((site, index) => {
239
+ console.error(` [${index + 1}] ${site.name} (${site.url})`);
240
+ });
241
+ console.error('');
242
+
243
+ const choice = await prompt(`? Select a site (1-${sites.length}): `);
244
+ const index = parseInt(choice, 10) - 1;
245
+
246
+ if (isNaN(index) || index < 0 || index >= sites.length) {
247
+ throw new CliError('INVALID_PARAMS', 'Invalid selection');
248
+ }
249
+
250
+ return sites[index];
251
+ };
252
+
253
+ const result = await performJiraOAuthFlow(selectSite);
254
+
255
+ console.error(`\nāœ“ Authorized for site: ${result.siteUrl}\n`);
256
+
257
+ // Save credentials
258
+ const credentials: JiraCredentials = {
259
+ accessToken: result.accessToken,
260
+ refreshToken: result.refreshToken,
261
+ expiryDate: result.expiryDate,
262
+ cloudId: result.cloudId,
263
+ siteUrl: result.siteUrl,
264
+ };
265
+
266
+ await setProfile('jira', profileName);
267
+ await setCredentials('jira', profileName, credentials);
268
+
269
+ console.log(`\nāœ… Profile "${profileName}" configured!`);
270
+ console.log(` Test with: agentio jira projects --profile ${profileName}`);
271
+ } catch (error) {
272
+ handleError(error);
273
+ }
274
+ });
275
+
276
+ profile
277
+ .command('list')
278
+ .description('List JIRA profiles')
279
+ .action(async () => {
280
+ try {
281
+ const result = await listProfiles('jira');
282
+ const { profiles, default: defaultProfile } = result[0];
283
+
284
+ if (profiles.length === 0) {
285
+ console.log('No profiles configured');
286
+ } else {
287
+ for (const name of profiles) {
288
+ const marker = name === defaultProfile ? ' (default)' : '';
289
+ const credentials = await getCredentials<JiraCredentials>('jira', name);
290
+ const siteInfo = credentials?.siteUrl ? ` - ${credentials.siteUrl}` : '';
291
+ console.log(`${name}${marker}${siteInfo}`);
292
+ }
293
+ }
294
+ } catch (error) {
295
+ handleError(error);
296
+ }
297
+ });
298
+
299
+ profile
300
+ .command('remove')
301
+ .description('Remove a JIRA profile')
302
+ .requiredOption('--profile <name>', 'Profile name')
303
+ .action(async (options) => {
304
+ try {
305
+ const profileName = options.profile;
306
+
307
+ const removed = await removeProfile('jira', profileName);
308
+ await removeCredentials('jira', profileName);
309
+
310
+ if (removed) {
311
+ console.error(`Removed profile "${profileName}"`);
312
+ } else {
313
+ console.error(`Profile "${profileName}" not found`);
314
+ }
315
+ } catch (error) {
316
+ handleError(error);
317
+ }
318
+ });
319
+ }