@plosson/agentio 0.5.10 → 0.5.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,11 +7,94 @@ import { setProfile, getProfile } from '../config/config-manager';
7
7
  import { createProfileCommands } from '../utils/profile-commands';
8
8
  import { performOAuthFlow } from '../auth/oauth';
9
9
  import { GmailClient } from '../services/gmail/client';
10
- import { printMessageList, printMessage, printSendResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
10
+ import { printMessageList, printMessage, printSendResult, printDraftResult, printArchived, printMarked, printAttachmentList, printAttachmentDownloaded, raw } from '../utils/output';
11
11
  import { CliError, handleError } from '../utils/errors';
12
12
  import { readStdin } from '../utils/stdin';
13
13
  import { enforceWriteAccess } from '../utils/read-only';
14
- import type { GmailAttachment } from '../types/gmail';
14
+ import type { GmailAttachment, GmailSendOptions } from '../types/gmail';
15
+
16
+ function addComposeOptions(cmd: Command): Command {
17
+ return cmd
18
+ .option('--profile <name>', 'Profile name (optional if only one profile exists)')
19
+ .option('--to <email>', 'Recipient (repeatable, required unless --reply-to)', (val: string, acc: string[]) => [...acc, val], [])
20
+ .option('--cc <email>', 'CC recipient (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
21
+ .option('--bcc <email>', 'BCC recipient (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
22
+ .option('--subject <subject>', 'Email subject (required unless --reply-to)')
23
+ .option('--body <body>', 'Email body (or pipe via stdin)')
24
+ .option('--html', 'Treat body as HTML')
25
+ .option('--reply-to <thread-id>', 'Thread ID to reply to (derives to/subject from thread)')
26
+ .option('--attachment <path>', 'File to attach (repeatable)', (val: string, acc: string[]) => [...acc, val], [])
27
+ .option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val: string, acc: string[]) => [...acc, val], []);
28
+ }
29
+
30
+ async function parseBody(body: string | undefined): Promise<string> {
31
+ if (!body) {
32
+ body = await readStdin() ?? undefined;
33
+ }
34
+ if (!body) {
35
+ throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
36
+ }
37
+ return body;
38
+ }
39
+
40
+ function parseAttachments(paths: string[]): GmailAttachment[] {
41
+ return paths.map((path: string) => ({
42
+ path,
43
+ filename: basename(path),
44
+ }));
45
+ }
46
+
47
+ function parseInlineAttachments(specs: string[]): GmailAttachment[] {
48
+ return specs.map((spec: string) => {
49
+ const colonIndex = spec.indexOf(':');
50
+ if (colonIndex === -1) {
51
+ throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
52
+ }
53
+ const contentId = spec.substring(0, colonIndex);
54
+ const path = spec.substring(colonIndex + 1);
55
+ return {
56
+ path,
57
+ filename: basename(path),
58
+ contentId,
59
+ };
60
+ });
61
+ }
62
+
63
+ async function parseSendOptions(options: Record<string, unknown>): Promise<GmailSendOptions> {
64
+ const replyTo = options.replyTo as string | undefined;
65
+ const to = options.to as string[];
66
+ const subject = options.subject as string | undefined;
67
+
68
+ if (!replyTo) {
69
+ if (!to.length) {
70
+ throw new CliError('INVALID_PARAMS', '--to is required (unless using --reply-to)');
71
+ }
72
+ if (!subject) {
73
+ throw new CliError('INVALID_PARAMS', '--subject is required (unless using --reply-to)');
74
+ }
75
+ }
76
+
77
+ const body = await parseBody(options.body as string | undefined);
78
+
79
+ const regularAttachments = parseAttachments(options.attachment as string[]);
80
+ const inlineAttachments = parseInlineAttachments(options.inline as string[]);
81
+
82
+ const attachments: GmailAttachment[] | undefined =
83
+ regularAttachments.length || inlineAttachments.length
84
+ ? [...regularAttachments, ...inlineAttachments]
85
+ : undefined;
86
+
87
+ return {
88
+ to,
89
+ cc: (options.cc as string[]).length ? options.cc as string[] : undefined,
90
+ bcc: (options.bcc as string[]).length ? options.bcc as string[] : undefined,
91
+ subject: subject || '',
92
+ body,
93
+ isHtml: options.html as boolean | undefined,
94
+ attachments,
95
+ replyTo,
96
+ };
97
+ }
15
98
 
16
99
  function escapeHtml(text: string): string {
17
100
  return text
@@ -176,102 +259,27 @@ Query Syntax Examples:
176
259
  }
177
260
  });
178
261
 
179
- gmail
180
- .command('send')
181
- .description('Send an email')
182
- .option('--profile <name>', 'Profile name (optional if only one profile exists)')
183
- .requiredOption('--to <email>', 'Recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
184
- .option('--cc <email>', 'CC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
185
- .option('--bcc <email>', 'BCC recipient (repeatable)', (val, acc: string[]) => [...acc, val], [])
186
- .requiredOption('--subject <subject>', 'Email subject')
187
- .option('--body <body>', 'Email body (or pipe via stdin)')
188
- .option('--html', 'Treat body as HTML')
189
- .option('--attachment <path>', 'File to attach (repeatable)', (val, acc: string[]) => [...acc, val], [])
190
- .option('--inline <cid:path>', 'Inline image (repeatable, format: contentId:filepath). Supports PNG, JPG, GIF only (not SVG)', (val, acc: string[]) => [...acc, val], [])
262
+ addComposeOptions(gmail.command('send').description('Send an email'))
191
263
  .action(async (options) => {
192
264
  try {
193
- let body = options.body;
194
-
195
- // Check for stdin if no body provided
196
- if (!body) {
197
- body = await readStdin();
198
- }
199
-
200
- if (!body) {
201
- throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
202
- }
203
-
204
- // Process regular attachments
205
- const regularAttachments: GmailAttachment[] = options.attachment.map((path: string) => ({
206
- path,
207
- filename: basename(path),
208
- }));
209
-
210
- // Process inline images (format: contentId:filepath)
211
- const inlineAttachments: GmailAttachment[] = options.inline.map((spec: string) => {
212
- const colonIndex = spec.indexOf(':');
213
- if (colonIndex === -1) {
214
- throw new CliError('INVALID_PARAMS', `Invalid inline format: ${spec}`, 'Use format: contentId:filepath (e.g., logo:./logo.png)');
215
- }
216
- const contentId = spec.substring(0, colonIndex);
217
- const path = spec.substring(colonIndex + 1);
218
- return {
219
- path,
220
- filename: basename(path),
221
- contentId,
222
- };
223
- });
224
-
225
- // Combine attachments
226
- const attachments: GmailAttachment[] | undefined =
227
- regularAttachments.length || inlineAttachments.length
228
- ? [...regularAttachments, ...inlineAttachments]
229
- : undefined;
230
-
265
+ const sendOptions = await parseSendOptions(options);
231
266
  const { client, profile } = await getGmailClient(options.profile);
232
267
  await enforceWriteAccess('gmail', profile, 'send email');
233
- const result = await client.send({
234
- to: options.to,
235
- cc: options.cc.length ? options.cc : undefined,
236
- bcc: options.bcc.length ? options.bcc : undefined,
237
- subject: options.subject,
238
- body,
239
- isHtml: options.html,
240
- attachments,
241
- });
268
+ const result = await client.send(sendOptions);
242
269
  printSendResult(result);
243
270
  } catch (error) {
244
271
  handleError(error);
245
272
  }
246
273
  });
247
274
 
248
- gmail
249
- .command('reply')
250
- .description('Reply to a thread')
251
- .option('--profile <name>', 'Profile name (optional if only one profile exists)')
252
- .requiredOption('--thread-id <id>', 'Thread ID')
253
- .option('--body <body>', 'Reply body (or pipe via stdin)')
254
- .option('--html', 'Treat body as HTML')
275
+ addComposeOptions(gmail.command('draft').description('Create an email draft'))
255
276
  .action(async (options) => {
256
277
  try {
257
- let body = options.body;
258
-
259
- if (!body) {
260
- body = await readStdin();
261
- }
262
-
263
- if (!body) {
264
- throw new CliError('INVALID_PARAMS', 'Body is required. Use --body or pipe via stdin.');
265
- }
266
-
278
+ const sendOptions = await parseSendOptions(options);
267
279
  const { client, profile } = await getGmailClient(options.profile);
268
- await enforceWriteAccess('gmail', profile, 'reply to email');
269
- const result = await client.reply({
270
- threadId: options.threadId,
271
- body,
272
- isHtml: options.html,
273
- });
274
- printSendResult(result);
280
+ await enforceWriteAccess('gmail', profile, 'create draft');
281
+ const result = await client.draft(sendOptions);
282
+ printDraftResult(result);
275
283
  } catch (error) {
276
284
  handleError(error);
277
285
  }
@@ -1,7 +1,7 @@
1
1
  import { gmail, type gmail_v1 } from '@googleapis/gmail';
2
2
  import type { OAuth2Client } from 'google-auth-library';
3
3
  import { basename } from 'path';
4
- import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailReplyOptions, GmailAttachment, GmailAttachmentInfo } from '../../types/gmail';
4
+ import type { GmailMessage, GmailListOptions, GmailSendOptions, GmailAttachment, GmailAttachmentInfo } from '../../types/gmail';
5
5
  import type { ServiceClient, ValidationResult } from '../../types/service';
6
6
  import { CliError } from '../../utils/errors';
7
7
 
@@ -265,14 +265,57 @@ export class GmailClient implements ServiceClient {
265
265
  return this.list({ query, limit });
266
266
  }
267
267
 
268
- async send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
269
- const { to, cc, bcc, subject, body, isHtml, attachments } = options;
268
+ private async resolveReplyContext(threadId: string): Promise<{
269
+ to: string[];
270
+ subject: string;
271
+ extraHeaders: string[];
272
+ }> {
273
+ const thread = await this.gmail.users.threads.get({
274
+ userId: 'me',
275
+ id: threadId,
276
+ });
277
+
278
+ const messages = thread.data.messages || [];
279
+ if (messages.length === 0) {
280
+ throw new CliError('NOT_FOUND', `Thread not found: ${threadId}`);
281
+ }
282
+
283
+ const lastMessage = messages[messages.length - 1];
284
+ const headers = this.parseHeaders(lastMessage.payload?.headers);
285
+
286
+ const to = [headers['reply-to'] || headers['from'] || ''];
287
+ const subject = headers['subject']?.startsWith('Re:')
288
+ ? headers['subject']
289
+ : `Re: ${headers['subject'] || '(no subject)'}`;
290
+ const messageId = headers['message-id'] || '';
291
+
292
+ const extraHeaders: string[] = [];
293
+ if (messageId) {
294
+ extraHeaders.push(`In-Reply-To: ${messageId}`);
295
+ extraHeaders.push(`References: ${messageId}`);
296
+ }
297
+
298
+ return { to, subject, extraHeaders };
299
+ }
300
+
301
+ private async buildEncodedMessage(options: GmailSendOptions): Promise<string> {
302
+ const { cc, bcc, body, isHtml, attachments, replyTo } = options;
270
303
  const userEmail = await this.getUserEmail();
271
304
 
305
+ let to = options.to;
306
+ let subject = options.subject;
307
+ let extraHeaders: string[] | undefined;
308
+
309
+ if (replyTo) {
310
+ const replyContext = await this.resolveReplyContext(replyTo);
311
+ if (!to.length) to = replyContext.to;
312
+ if (!subject) subject = replyContext.subject;
313
+ extraHeaders = replyContext.extraHeaders;
314
+ }
315
+
272
316
  let rawMessage: string;
273
317
 
274
318
  if (attachments && attachments.length > 0) {
275
- // Build multipart MIME message with attachments
276
319
  rawMessage = await this.buildMultipartMessage({
277
320
  from: userEmail,
278
321
  to,
@@ -282,27 +325,35 @@ export class GmailClient implements ServiceClient {
282
325
  body,
283
326
  isHtml,
284
327
  attachments,
328
+ extraHeaders,
285
329
  });
286
330
  } else {
287
- // Simple message without attachments
288
331
  rawMessage = [
289
332
  `From: ${userEmail}`,
290
333
  `To: ${to.join(', ')}`,
291
334
  cc?.length ? `Cc: ${cc.join(', ')}` : null,
292
335
  bcc?.length ? `Bcc: ${bcc.join(', ')}` : null,
293
336
  `Subject: ${subject}`,
337
+ ...(extraHeaders || []),
294
338
  `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
295
339
  '',
296
340
  body,
297
341
  ].filter((line): line is string => line !== null).join('\r\n');
298
342
  }
299
343
 
300
- const encodedMessage = Buffer.from(rawMessage).toString('base64url');
344
+ return Buffer.from(rawMessage).toString('base64url');
345
+ }
346
+
347
+ async send(options: GmailSendOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
348
+ const encodedMessage = await this.buildEncodedMessage(options);
301
349
 
302
350
  try {
303
351
  const response = await this.gmail.users.messages.send({
304
352
  userId: 'me',
305
- requestBody: { raw: encodedMessage },
353
+ requestBody: {
354
+ raw: encodedMessage,
355
+ ...(options.replyTo ? { threadId: options.replyTo } : {}),
356
+ },
306
357
  });
307
358
 
308
359
  return {
@@ -316,6 +367,30 @@ export class GmailClient implements ServiceClient {
316
367
  }
317
368
  }
318
369
 
370
+ async draft(options: GmailSendOptions): Promise<{ id: string; messageId: string }> {
371
+ const encodedMessage = await this.buildEncodedMessage(options);
372
+
373
+ try {
374
+ const response = await this.gmail.users.drafts.create({
375
+ userId: 'me',
376
+ requestBody: {
377
+ message: {
378
+ raw: encodedMessage,
379
+ ...(options.replyTo ? { threadId: options.replyTo } : {}),
380
+ },
381
+ },
382
+ });
383
+
384
+ return {
385
+ id: response.data.id!,
386
+ messageId: response.data.message?.id || '',
387
+ };
388
+ } catch (error) {
389
+ const message = error instanceof Error ? error.message : String(error);
390
+ throw new CliError('API_ERROR', `Failed to create draft: ${message}`);
391
+ }
392
+ }
393
+
319
394
  private async buildMultipartMessage(options: {
320
395
  from: string;
321
396
  to: string[];
@@ -325,8 +400,9 @@ export class GmailClient implements ServiceClient {
325
400
  body: string;
326
401
  isHtml?: boolean;
327
402
  attachments: GmailAttachment[];
403
+ extraHeaders?: string[];
328
404
  }): Promise<string> {
329
- const { from, to, cc, bcc, subject, body, isHtml, attachments } = options;
405
+ const { from, to, cc, bcc, subject, body, isHtml, attachments, extraHeaders } = options;
330
406
 
331
407
  // Separate inline and regular attachments
332
408
  const inlineAttachments = attachments.filter(a => a.contentId);
@@ -347,6 +423,11 @@ export class GmailClient implements ServiceClient {
347
423
  if (cc?.length) lines.push(`Cc: ${cc.join(', ')}`);
348
424
  if (bcc?.length) lines.push(`Bcc: ${bcc.join(', ')}`);
349
425
  lines.push(`Subject: ${subject}`);
426
+ if (extraHeaders) {
427
+ for (const header of extraHeaders) {
428
+ lines.push(header);
429
+ }
430
+ }
350
431
  lines.push('MIME-Version: 1.0');
351
432
 
352
433
  if (hasRegular && hasInline) {
@@ -464,64 +545,6 @@ export class GmailClient implements ServiceClient {
464
545
  }
465
546
  }
466
547
 
467
- async reply(options: GmailReplyOptions): Promise<{ id: string; threadId: string; labelIds: string[] }> {
468
- const { threadId, body, isHtml } = options;
469
-
470
- // Get the thread to find the last message
471
- try {
472
- const thread = await this.gmail.users.threads.get({
473
- userId: 'me',
474
- id: threadId,
475
- });
476
-
477
- const messages = thread.data.messages || [];
478
- if (messages.length === 0) {
479
- throw new CliError('NOT_FOUND', `Thread not found: ${threadId}`);
480
- }
481
-
482
- const lastMessage = messages[messages.length - 1];
483
- const headers = this.parseHeaders(lastMessage.payload?.headers);
484
-
485
- const userEmail = await this.getUserEmail();
486
- const replyTo = headers['reply-to'] || headers['from'] || '';
487
- const subject = headers['subject']?.startsWith('Re:')
488
- ? headers['subject']
489
- : `Re: ${headers['subject'] || '(no subject)'}`;
490
- const messageId = headers['message-id'] || '';
491
-
492
- const rawHeaders = [
493
- `From: ${userEmail}`,
494
- `To: ${replyTo}`,
495
- `Subject: ${subject}`,
496
- messageId ? `In-Reply-To: ${messageId}` : '',
497
- messageId ? `References: ${messageId}` : '',
498
- `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8`,
499
- '',
500
- body,
501
- ].filter(Boolean).join('\r\n');
502
-
503
- const encodedMessage = Buffer.from(rawHeaders).toString('base64url');
504
-
505
- const response = await this.gmail.users.messages.send({
506
- userId: 'me',
507
- requestBody: {
508
- raw: encodedMessage,
509
- threadId,
510
- },
511
- });
512
-
513
- return {
514
- id: response.data.id!,
515
- threadId: response.data.threadId!,
516
- labelIds: response.data.labelIds || ['SENT'],
517
- };
518
- } catch (error) {
519
- if (error instanceof CliError) throw error;
520
- const message = error instanceof Error ? error.message : String(error);
521
- throw new CliError('API_ERROR', `Failed to send reply: ${message}`);
522
- }
523
- }
524
-
525
548
  async archive(messageId: string): Promise<void> {
526
549
  try {
527
550
  await this.gmail.users.messages.modify({
@@ -40,10 +40,5 @@ export interface GmailSendOptions {
40
40
  body: string;
41
41
  isHtml?: boolean;
42
42
  attachments?: GmailAttachment[];
43
- }
44
-
45
- export interface GmailReplyOptions {
46
- threadId: string;
47
- body: string;
48
- isHtml?: boolean;
43
+ replyTo?: string; // Thread ID to reply to
49
44
  }
@@ -86,6 +86,13 @@ export function printSendResult(result: { id: string; threadId: string }): void
86
86
  console.log(`Thread: ${result.threadId}`);
87
87
  }
88
88
 
89
+ // Format draft creation result
90
+ export function printDraftResult(result: { id: string; messageId: string }): void {
91
+ console.log('Draft created');
92
+ console.log(`Draft ID: ${result.id}`);
93
+ console.log(`Message ID: ${result.messageId}`);
94
+ }
95
+
89
96
  // Format archive confirmation
90
97
  export function printArchived(messageId: string): void {
91
98
  console.log(`Archived: ${messageId}`);